mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-15 21:43:14 +08:00
fix(feishu): add one-click card callback repair
This commit is contained in:
parent
0e62c69382
commit
c1a3b0cf97
24 changed files with 3601 additions and 248 deletions
276
lib/client.js
276
lib/client.js
|
|
@ -587,6 +587,27 @@ var EN = Object.freeze({
|
||||||
"\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u65B0\u673A\u5668\u4EBA\u7684\u957F\u8FDE\u63A5\u5C31\u7EEA": "Keep this page open until the bot connection is ready",
|
"\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u65B0\u673A\u5668\u4EBA\u7684\u957F\u8FDE\u63A5\u5C31\u7EEA": "Keep this page open until the bot connection is ready",
|
||||||
"\u5728\u98DE\u4E66\u4E2D\u6253\u5F00": "Open in Feishu",
|
"\u5728\u98DE\u4E66\u4E2D\u6253\u5F00": "Open in Feishu",
|
||||||
"\u53D6\u6D88\u6DFB\u52A0": "Cancel",
|
"\u53D6\u6D88\u6DFB\u52A0": "Cancel",
|
||||||
|
"\u4F7F\u7528\u98DE\u4E66\u626B\u7801\u4FEE\u590D\u5361\u7247\u6309\u94AE": "Scan with Feishu to repair card buttons",
|
||||||
|
"\u626B\u7801\u4F1A\u66F4\u65B0\u73B0\u6709\u98DE\u4E66\u5E94\u7528\uFF0C\u53EA\u589E\u91CF\u8865\u5145\u5361\u7247\u6309\u94AE\u56DE\u8C03\uFF1B\u4E0D\u4F1A\u521B\u5EFA\u65B0\u5E94\u7528\u3002\u786E\u8BA4\u540E\u6B64\u673A\u5668\u4EBA\u4F1A\u77ED\u6682\u91CD\u8FDE\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u53D7\u5F71\u54CD\u3002": "Scanning updates the existing Feishu app with only the card-button callback. It does not create a new app. This bot reconnects briefly after confirmation; other bots are not affected.",
|
||||||
|
"\u6838\u5BF9\u73B0\u6709\u5E94\u7528\u540D\u79F0\uFF0C\u5E76\u786E\u8BA4\u53EA\u65B0\u589E\u5361\u7247\u56DE\u8C03": "Review the existing app name and confirm that only the card callback is added",
|
||||||
|
"\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u5361\u7247\u6309\u94AE\u4FEE\u590D\u5B8C\u6210": "Keep this page open until card-button repair finishes",
|
||||||
|
"\u53D6\u6D88\u4FEE\u590D": "Cancel repair",
|
||||||
|
"\u5DF2\u786E\u8BA4\uFF0C\u6B63\u5728\u5B8C\u6210\u5361\u7247\u6309\u94AE\u4FEE\u590D": "Confirmed. Finishing card-button repair",
|
||||||
|
"\u6B63\u5728\u51C6\u5907\u4FEE\u590D\u4E8C\u7EF4\u7801": "Preparing the repair QR code",
|
||||||
|
"\u914D\u7F6E\u5DF2\u63D0\u4EA4\uFF0C\u6B63\u5728\u9A8C\u8BC1\u5361\u7247\u6309\u94AE\u56DE\u8C03\u5E76\u91CD\u8FDE\u6B64\u673A\u5668\u4EBA\uFF1B\u6B64\u9636\u6BB5\u65E0\u6CD5\u53D6\u6D88\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u4F1A\u4E2D\u65AD\u3002": "The update was submitted. Verifying the card callback and reconnecting this bot. This stage cannot be cancelled; other bots will not be interrupted.",
|
||||||
|
"\u6B63\u5728\u4E3A\u73B0\u6709\u98DE\u4E66\u5E94\u7528\u7533\u8BF7\u4E00\u6B21\u6027\u66F4\u65B0\u4E8C\u7EF4\u7801\uFF0C\u8BF7\u7A0D\u5019\u3002": "Requesting a one-time update QR code for the existing Feishu app\u2026",
|
||||||
|
"\u5361\u7247\u6309\u94AE\u6CA1\u6709\u4FEE\u590D\u5B8C\u6210": "Card-button repair did not finish",
|
||||||
|
"\u4FEE\u590D\u5361\u7247\u6309\u94AE": "Repair card buttons",
|
||||||
|
"\u7B49\u5F85\u626B\u7801\u2026": "Waiting for scan\u2026",
|
||||||
|
"\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u4E86\u4E0D\u5339\u914D\u7684\u5361\u7247\u4FEE\u590D\u4E8C\u7EF4\u7801": "Feishu returned a repair QR code for a different bot",
|
||||||
|
"\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u7684\u4FEE\u590D\u4FE1\u606F\u7F3A\u5C11 botId": "Feishu repair status is missing the bot ID",
|
||||||
|
"\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u4E86\u4E0D\u5339\u914D\u7684\u6CE8\u518C\u8FDB\u5EA6": "Feishu returned registration progress for a different operation",
|
||||||
|
"\u6B64\u673A\u5668\u4EBA": "this bot",
|
||||||
|
'${botName ?? "\u673A\u5668\u4EBA"}\u7684\u4FEE\u590D\u4E8C\u7EF4\u7801\u5DF2\u751F\u6210\uFF0C\u8BF7\u4F7F\u7528\u98DE\u4E66\u626B\u7801\u3002': 'Repair QR code generated for ${botName ?? "bot"}. Scan it with Feishu.',
|
||||||
|
"${targetBot.bot.name}\u5DF2\u8FDE\u63A5\uFF0C\u53EF\u4EE5\u5728\u98DE\u4E66\u4E2D\u5F00\u59CB\u804A\u5929\u3002": "${targetBot.bot.name} is connected and ready to chat in Feishu.",
|
||||||
|
"\u5DF2\u53D6\u6D88\u5361\u7247\u6309\u94AE\u4FEE\u590D\u3002": "Card-button repair was cancelled.",
|
||||||
|
"\u5361\u7247\u6309\u94AE\u5DF2\u66F4\u65B0\uFF0C\u4F46\u6682\u65F6\u65E0\u6CD5\u786E\u8BA4\u673A\u5668\u4EBA\u8FDE\u63A5\u72B6\u6001": "The card callback was updated, but the bot connection could not be confirmed yet",
|
||||||
|
"\u98DE\u4E66\u5361\u7247\u6309\u94AE\u4FEE\u590D\u5931\u8D25": "Could not repair the Feishu card buttons",
|
||||||
"\u5DF2\u786E\u8BA4\uFF0C\u6B63\u5728\u8FDE\u63A5\u65B0\u673A\u5668\u4EBA": "Confirmed. Connecting the new bot",
|
"\u5DF2\u786E\u8BA4\uFF0C\u6B63\u5728\u8FDE\u63A5\u65B0\u673A\u5668\u4EBA": "Confirmed. Connecting the new bot",
|
||||||
"\u6B63\u5728\u5B89\u5168\u4FDD\u5B58\u51ED\u636E\u5E76\u68C0\u67E5\u65B0\u673A\u5668\u4EBA\u7684\u6D88\u606F\u901A\u9053\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u4F1A\u4E2D\u65AD\u3002": "Saving credentials and checking the new bot connection. Existing bots will not be interrupted.",
|
"\u6B63\u5728\u5B89\u5168\u4FDD\u5B58\u51ED\u636E\u5E76\u68C0\u67E5\u65B0\u673A\u5668\u4EBA\u7684\u6D88\u606F\u901A\u9053\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u4F1A\u4E2D\u65AD\u3002": "Saving credentials and checking the new bot connection. Existing bots will not be interrupted.",
|
||||||
"\u6B63\u5728\u5411\u98DE\u4E66\u7533\u8BF7\u4E00\u6B21\u6027\u6388\u6743\u4E8C\u7EF4\u7801\uFF0C\u8BF7\u7A0D\u5019\u3002": "Requesting a one-time authorization QR code from Feishu\u2026",
|
"\u6B63\u5728\u5411\u98DE\u4E66\u7533\u8BF7\u4E00\u6B21\u6027\u6388\u6743\u4E8C\u7EF4\u7801\uFF0C\u8BF7\u7A0D\u5019\u3002": "Requesting a one-time authorization QR code from Feishu\u2026",
|
||||||
|
|
@ -865,6 +886,16 @@ function translateDynamic(text5) {
|
||||||
if (match) return `Remove \u201C${match[1]}\u201D from DeepSeek Harness?`;
|
if (match) return `Remove \u201C${match[1]}\u201D from DeepSeek Harness?`;
|
||||||
match = /^从 DeepSeek Harness 移除(.+)$/.exec(text5);
|
match = /^从 DeepSeek Harness 移除(.+)$/.exec(text5);
|
||||||
if (match) return `Remove ${match[1]} from DeepSeek Harness`;
|
if (match) return `Remove ${match[1]} from DeepSeek Harness`;
|
||||||
|
match = /^用于修复(.+)卡片按钮的一次性授权二维码$/.exec(text5);
|
||||||
|
if (match) return `One-time QR code for repairing card buttons for ${match[1]}`;
|
||||||
|
match = /^正在修复「(.+)」$/.exec(text5);
|
||||||
|
if (match) return `Repairing \u201C${match[1]}\u201D`;
|
||||||
|
match = /^修复(.+)的卡片按钮$/.exec(text5);
|
||||||
|
if (match) return `Repair card buttons for ${match[1]}`;
|
||||||
|
match = /^(.+)的修复二维码已生成,请使用飞书扫码。$/.exec(text5);
|
||||||
|
if (match) return `Repair QR code generated for ${match[1]}. Scan it with Feishu.`;
|
||||||
|
match = /^(.+)的卡片按钮已修复。$/.exec(text5);
|
||||||
|
if (match) return `Card buttons repaired for ${match[1]}.`;
|
||||||
match = /^(检查连接|重试连接)(.+)$/.exec(text5);
|
match = /^(检查连接|重试连接)(.+)$/.exec(text5);
|
||||||
if (match) return `${localizeText(match[1])} ${match[2]}`;
|
if (match) return `${localizeText(match[1])} ${match[2]}`;
|
||||||
match = /^移除(.+)$/.exec(text5);
|
match = /^移除(.+)$/.exec(text5);
|
||||||
|
|
@ -3039,6 +3070,7 @@ var FEISHU_RPC_CHANNEL = "/feishu";
|
||||||
var FEISHU_ENDPOINTS = Object.freeze({
|
var FEISHU_ENDPOINTS = Object.freeze({
|
||||||
status: "connection.status",
|
status: "connection.status",
|
||||||
beginProvisioning: "provision.begin",
|
beginProvisioning: "provision.begin",
|
||||||
|
beginCallbackRepair: "bot.callback-repair.begin",
|
||||||
pollProvisioning: "provision.poll",
|
pollProvisioning: "provision.poll",
|
||||||
cancelProvisioning: "provision.cancel",
|
cancelProvisioning: "provision.cancel",
|
||||||
bindCredentials: "bot.bind-credentials",
|
bindCredentials: "bot.bind-credentials",
|
||||||
|
|
@ -3050,6 +3082,10 @@ var FEISHU_ENDPOINTS = Object.freeze({
|
||||||
testConnection: "connection.test",
|
testConnection: "connection.test",
|
||||||
disconnect: "connection.disconnect"
|
disconnect: "connection.disconnect"
|
||||||
});
|
});
|
||||||
|
var FEISHU_REGISTRATION_OPERATIONS = Object.freeze({
|
||||||
|
PROVISION: "provision",
|
||||||
|
CALLBACK_REPAIR: "callback_repair"
|
||||||
|
});
|
||||||
var CONNECTION_STATES = /* @__PURE__ */ new Set([
|
var CONNECTION_STATES = /* @__PURE__ */ new Set([
|
||||||
"disconnected",
|
"disconnected",
|
||||||
"offline",
|
"offline",
|
||||||
|
|
@ -3084,6 +3120,9 @@ function optionalTimestamp(value) {
|
||||||
function clamp2(value, min, max, fallback) {
|
function clamp2(value, min, max, fallback) {
|
||||||
return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
|
return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
|
||||||
}
|
}
|
||||||
|
function normalizeRegistrationOperation(value) {
|
||||||
|
return value === FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR ? FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR : FEISHU_REGISTRATION_OPERATIONS.PROVISION;
|
||||||
|
}
|
||||||
function unwrapRpcResult3(result) {
|
function unwrapRpcResult3(result) {
|
||||||
if (!isRecord3(result) || typeof result.ok !== "boolean") {
|
if (!isRecord3(result) || typeof result.ok !== "boolean") {
|
||||||
throw new Error("\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94");
|
throw new Error("\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94");
|
||||||
|
|
@ -3102,15 +3141,24 @@ function normalizeProvisioning2(value, now = Date.now()) {
|
||||||
const attemptId = optionalString2(source.attemptId) ?? optionalString2(source.provisioningId);
|
const attemptId = optionalString2(source.attemptId) ?? optionalString2(source.provisioningId);
|
||||||
const verificationUrl = optionalString2(source.verificationUrl);
|
const verificationUrl = optionalString2(source.verificationUrl);
|
||||||
const qrCodeDataUrl = optionalString2(source.qrCodeDataUrl);
|
const qrCodeDataUrl = optionalString2(source.qrCodeDataUrl);
|
||||||
if (!attemptId || !verificationUrl && !qrCodeDataUrl) {
|
const submitted = source.submitted === true;
|
||||||
|
if (!attemptId || !verificationUrl && !qrCodeDataUrl && !submitted) {
|
||||||
throw new Error("\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u7684\u4E8C\u7EF4\u7801\u4FE1\u606F\u4E0D\u5B8C\u6574");
|
throw new Error("\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u7684\u4E8C\u7EF4\u7801\u4FE1\u606F\u4E0D\u5B8C\u6574");
|
||||||
}
|
}
|
||||||
const explicitExpiry = optionalTimestamp(source.expiresAt);
|
const explicitExpiry = optionalTimestamp(source.expiresAt);
|
||||||
const expireIn = clamp2(source.expireIn, 1, 60 * 60, 5 * 60);
|
const expireIn = clamp2(source.expireIn, 1, 60 * 60, 5 * 60);
|
||||||
|
const operation = normalizeRegistrationOperation(source.operation);
|
||||||
|
const botId = optionalString2(source.botId);
|
||||||
|
if (operation === FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR && !botId) {
|
||||||
|
throw new Error("\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u7684\u4FEE\u590D\u4FE1\u606F\u7F3A\u5C11 botId");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
attemptId,
|
attemptId,
|
||||||
|
operation,
|
||||||
|
botId,
|
||||||
verificationUrl,
|
verificationUrl,
|
||||||
qrCodeDataUrl,
|
qrCodeDataUrl,
|
||||||
|
submitted,
|
||||||
expiresAt: explicitExpiry ?? now + expireIn * 1e3,
|
expiresAt: explicitExpiry ?? now + expireIn * 1e3,
|
||||||
pollIntervalMs: clamp2(source.pollIntervalMs, 800, 1e4, 1800)
|
pollIntervalMs: clamp2(source.pollIntervalMs, 800, 1e4, 1800)
|
||||||
};
|
};
|
||||||
|
|
@ -3228,6 +3276,7 @@ function normalizePollResult(value) {
|
||||||
if (!status) throw new Error("\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u4E86\u672A\u77E5\u7684\u521B\u5EFA\u72B6\u6001");
|
if (!status) throw new Error("\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u4E86\u672A\u77E5\u7684\u521B\u5EFA\u72B6\u6001");
|
||||||
const normalized = {
|
const normalized = {
|
||||||
status,
|
status,
|
||||||
|
operation: normalizeRegistrationOperation(value.operation),
|
||||||
botId: optionalString2(value.botId),
|
botId: optionalString2(value.botId),
|
||||||
message: optionalString2(value.error?.message) ?? optionalString2(value.message),
|
message: optionalString2(value.error?.message) ?? optionalString2(value.message),
|
||||||
connection: void 0,
|
connection: void 0,
|
||||||
|
|
@ -3746,6 +3795,8 @@ var CSS3 = String.raw`
|
||||||
.bxf-healthSummary[data-error="true"] { color: var(--bxf-error); }
|
.bxf-healthSummary[data-error="true"] { color: var(--bxf-error); }
|
||||||
.bxf-botActions { flex: none; flex-wrap: nowrap; gap: 8px; margin-top: 0; justify-content: flex-end; }
|
.bxf-botActions { flex: none; flex-wrap: nowrap; gap: 8px; margin-top: 0; justify-content: flex-end; }
|
||||||
.bxf-botActions .bxf-button { flex: none; white-space: nowrap; }
|
.bxf-botActions .bxf-button { flex: none; white-space: nowrap; }
|
||||||
|
.bxf-botActions .bxf-repairButton { color: var(--bxf-accent); border-color: color-mix(in srgb, var(--bxf-accent) 35%, var(--dsw-alias-border-l2, #dee0e3)); }
|
||||||
|
.bxf-botActions .bxf-repairButton:hover:not(:disabled) { background: color-mix(in srgb, var(--bxf-accent) 7%, transparent); }
|
||||||
|
|
||||||
.bxf-confirm {
|
.bxf-confirm {
|
||||||
border-top: 1px solid var(--dsw-alias-border-l2, #dee0e3);
|
border-top: 1px solid var(--dsw-alias-border-l2, #dee0e3);
|
||||||
|
|
@ -3837,6 +3888,10 @@ function installFeishuStyles() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// plugin-src/client/channels/feishu/index.js
|
// plugin-src/client/channels/feishu/index.js
|
||||||
|
var CALLBACK_REPAIR_OPERATION = FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR;
|
||||||
|
function isCallbackRepair(value) {
|
||||||
|
return value?.operation === CALLBACK_REPAIR_OPERATION;
|
||||||
|
}
|
||||||
function SvgIcon({ children, size = 18, className, viewBox = "0 0 24 24" }) {
|
function SvgIcon({ children, size = 18, className, viewBox = "0 0 24 24" }) {
|
||||||
return h2("svg", {
|
return h2("svg", {
|
||||||
width: size,
|
width: size,
|
||||||
|
|
@ -3996,7 +4051,12 @@ function safeVerificationHref(value) {
|
||||||
if (!value) return void 0;
|
if (!value) return void 0;
|
||||||
try {
|
try {
|
||||||
const url = new URL(value);
|
const url = new URL(value);
|
||||||
return url.protocol === "https:" ? url.toString() : void 0;
|
return url.protocol === "https:" && [
|
||||||
|
"accounts.feishu.cn",
|
||||||
|
"accounts.larksuite.com",
|
||||||
|
"open.feishu.cn",
|
||||||
|
"open.larksuite.com"
|
||||||
|
].includes(url.hostname) && !url.port && !url.username && !url.password ? url.toString() : void 0;
|
||||||
} catch {
|
} catch {
|
||||||
return void 0;
|
return void 0;
|
||||||
}
|
}
|
||||||
|
|
@ -4012,6 +4072,8 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
|
||||||
const remaining = Math.max(0, provision.expiresAt - now);
|
const remaining = Math.max(0, provision.expiresAt - now);
|
||||||
const expired = provision.expired === true || remaining === 0;
|
const expired = provision.expired === true || remaining === 0;
|
||||||
const progress = Math.min(1, remaining / Math.max(1, provision.durationMs ?? remaining));
|
const progress = Math.min(1, remaining / Math.max(1, provision.durationMs ?? remaining));
|
||||||
|
const repairing = isCallbackRepair(provision);
|
||||||
|
const botName = provision.botName ?? "\u6B64\u673A\u5668\u4EBA";
|
||||||
React10.useEffect(() => setImageFailed(false), [qrSource]);
|
React10.useEffect(() => setImageFailed(false), [qrSource]);
|
||||||
return h2(
|
return h2(
|
||||||
"div",
|
"div",
|
||||||
|
|
@ -4027,7 +4089,7 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
|
||||||
{ className: "bxf-qrFrame dim-qrFrame" },
|
{ className: "bxf-qrFrame dim-qrFrame" },
|
||||||
qrSource && !imageFailed ? h2("img", {
|
qrSource && !imageFailed ? h2("img", {
|
||||||
src: qrSource,
|
src: qrSource,
|
||||||
alt: "\u7528\u4E8E\u65B0\u589E DeepSeek Harness \u98DE\u4E66\u673A\u5668\u4EBA\u7684\u4E00\u6B21\u6027\u6388\u6743\u4E8C\u7EF4\u7801",
|
alt: repairing ? `\u7528\u4E8E\u4FEE\u590D${botName}\u5361\u7247\u6309\u94AE\u7684\u4E00\u6B21\u6027\u6388\u6743\u4E8C\u7EF4\u7801` : "\u7528\u4E8E\u65B0\u589E DeepSeek Harness \u98DE\u4E66\u673A\u5668\u4EBA\u7684\u4E00\u6B21\u6027\u6388\u6743\u4E8C\u7EF4\u7801",
|
||||||
onError: () => setImageFailed(true)
|
onError: () => setImageFailed(true)
|
||||||
}) : h2(
|
}) : h2(
|
||||||
"div",
|
"div",
|
||||||
|
|
@ -4066,16 +4128,16 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
|
||||||
"div",
|
"div",
|
||||||
{ className: "bxf-stateLabel dim-stateLabel" },
|
{ className: "bxf-stateLabel dim-stateLabel" },
|
||||||
h2("span", { className: "bxf-dot dim-stateDot", "data-tone": "warning" }),
|
h2("span", { className: "bxf-dot dim-stateDot", "data-tone": "warning" }),
|
||||||
h2("span", null, "\u6B63\u5728\u6DFB\u52A0\u65B0\u673A\u5668\u4EBA")
|
h2("span", null, repairing ? `\u6B63\u5728\u4FEE\u590D\u300C${botName}\u300D` : "\u6B63\u5728\u6DFB\u52A0\u65B0\u673A\u5668\u4EBA")
|
||||||
),
|
),
|
||||||
h2("h3", null, expired ? "\u5237\u65B0\u4E8C\u7EF4\u7801\u540E\u7EE7\u7EED" : "\u4F7F\u7528\u98DE\u4E66\u626B\u7801\u521B\u5EFA\u673A\u5668\u4EBA"),
|
h2("h3", null, expired ? "\u5237\u65B0\u4E8C\u7EF4\u7801\u540E\u7EE7\u7EED" : repairing ? "\u4F7F\u7528\u98DE\u4E66\u626B\u7801\u4FEE\u590D\u5361\u7247\u6309\u94AE" : "\u4F7F\u7528\u98DE\u4E66\u626B\u7801\u521B\u5EFA\u673A\u5668\u4EBA"),
|
||||||
h2("p", null, "\u626B\u7801\u53EA\u4F1A\u65B0\u589E\u4E00\u4E2A\u673A\u5668\u4EBA\uFF0C\u5DF2\u63A5\u5165\u7684\u673A\u5668\u4EBA\u4F1A\u7EE7\u7EED\u6B63\u5E38\u6536\u53D1\u6D88\u606F\u3002"),
|
h2("p", null, repairing ? "\u626B\u7801\u4F1A\u66F4\u65B0\u73B0\u6709\u98DE\u4E66\u5E94\u7528\uFF0C\u53EA\u589E\u91CF\u8865\u5145\u5361\u7247\u6309\u94AE\u56DE\u8C03\uFF1B\u4E0D\u4F1A\u521B\u5EFA\u65B0\u5E94\u7528\u3002\u786E\u8BA4\u540E\u6B64\u673A\u5668\u4EBA\u4F1A\u77ED\u6682\u91CD\u8FDE\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u53D7\u5F71\u54CD\u3002" : "\u626B\u7801\u53EA\u4F1A\u65B0\u589E\u4E00\u4E2A\u673A\u5668\u4EBA\uFF0C\u5DF2\u63A5\u5165\u7684\u673A\u5668\u4EBA\u4F1A\u7EE7\u7EED\u6B63\u5E38\u6536\u53D1\u6D88\u606F\u3002"),
|
||||||
h2(
|
h2(
|
||||||
"ol",
|
"ol",
|
||||||
{ className: "bxf-steps dim-steps" },
|
{ className: "bxf-steps dim-steps" },
|
||||||
h2("li", null, "\u6253\u5F00\u98DE\u4E66\u79FB\u52A8\u7AEF\uFF0C\u4F7F\u7528\u626B\u4E00\u626B\u8BFB\u53D6\u4E8C\u7EF4\u7801"),
|
h2("li", null, "\u6253\u5F00\u98DE\u4E66\u79FB\u52A8\u7AEF\uFF0C\u4F7F\u7528\u626B\u4E00\u626B\u8BFB\u53D6\u4E8C\u7EF4\u7801"),
|
||||||
h2("li", null, "\u6838\u5BF9\u5E94\u7528\u540D\u79F0\u4E0E\u6743\u9650\u8303\u56F4\uFF0C\u5E76\u786E\u8BA4\u521B\u5EFA"),
|
h2("li", null, repairing ? "\u6838\u5BF9\u73B0\u6709\u5E94\u7528\u540D\u79F0\uFF0C\u5E76\u786E\u8BA4\u53EA\u65B0\u589E\u5361\u7247\u56DE\u8C03" : "\u6838\u5BF9\u5E94\u7528\u540D\u79F0\u4E0E\u6743\u9650\u8303\u56F4\uFF0C\u5E76\u786E\u8BA4\u521B\u5EFA"),
|
||||||
h2("li", null, "\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u65B0\u673A\u5668\u4EBA\u7684\u957F\u8FDE\u63A5\u5C31\u7EEA")
|
h2("li", null, repairing ? "\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u5361\u7247\u6309\u94AE\u4FEE\u590D\u5B8C\u6210" : "\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u65B0\u673A\u5668\u4EBA\u7684\u957F\u8FDE\u63A5\u5C31\u7EEA")
|
||||||
),
|
),
|
||||||
h2(
|
h2(
|
||||||
"div",
|
"div",
|
||||||
|
|
@ -4092,14 +4154,15 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
|
||||||
rel: "noopener noreferrer"
|
rel: "noopener noreferrer"
|
||||||
}, h2("span", null, "\u5728\u98DE\u4E66\u4E2D\u6253\u5F00")) : null,
|
}, h2("span", null, "\u5728\u98DE\u4E66\u4E2D\u6253\u5F00")) : null,
|
||||||
!expired ? h2(Button5, { onClick: onRefresh, disabled: busy }, "\u6362\u4E00\u4E2A\u4E8C\u7EF4\u7801") : null,
|
!expired ? h2(Button5, { onClick: onRefresh, disabled: busy }, "\u6362\u4E00\u4E2A\u4E8C\u7EF4\u7801") : null,
|
||||||
h2(Button5, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u6DFB\u52A0")
|
h2(Button5, { onClick: onCancel, disabled: busy }, repairing ? "\u53D6\u6D88\u4FEE\u590D" : "\u53D6\u6D88\u6DFB\u52A0")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function ProvisionProgress({ phase, onCancel, busy }) {
|
function ProvisionProgress({ phase, provision, onCancel, busy }) {
|
||||||
const connecting = phase === "connecting";
|
const connecting = phase === "connecting";
|
||||||
|
const repairing = isCallbackRepair(provision);
|
||||||
return h2(
|
return h2(
|
||||||
"div",
|
"div",
|
||||||
{
|
{
|
||||||
|
|
@ -4107,16 +4170,17 @@ function ProvisionProgress({ phase, onCancel, busy }) {
|
||||||
"aria-busy": "true"
|
"aria-busy": "true"
|
||||||
},
|
},
|
||||||
h2("div", { className: "dim-spinner", "aria-hidden": "true" }),
|
h2("div", { className: "dim-spinner", "aria-hidden": "true" }),
|
||||||
h2("h3", null, connecting ? "\u5DF2\u786E\u8BA4\uFF0C\u6B63\u5728\u8FDE\u63A5\u65B0\u673A\u5668\u4EBA" : "\u6B63\u5728\u51C6\u5907\u6388\u6743\u4E8C\u7EF4\u7801"),
|
h2("h3", null, connecting ? repairing ? "\u5DF2\u786E\u8BA4\uFF0C\u6B63\u5728\u5B8C\u6210\u5361\u7247\u6309\u94AE\u4FEE\u590D" : "\u5DF2\u786E\u8BA4\uFF0C\u6B63\u5728\u8FDE\u63A5\u65B0\u673A\u5668\u4EBA" : repairing ? "\u6B63\u5728\u51C6\u5907\u4FEE\u590D\u4E8C\u7EF4\u7801" : "\u6B63\u5728\u51C6\u5907\u6388\u6743\u4E8C\u7EF4\u7801"),
|
||||||
h2("p", null, connecting ? "\u6B63\u5728\u5B89\u5168\u4FDD\u5B58\u51ED\u636E\u5E76\u68C0\u67E5\u65B0\u673A\u5668\u4EBA\u7684\u6D88\u606F\u901A\u9053\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u4F1A\u4E2D\u65AD\u3002" : "\u6B63\u5728\u5411\u98DE\u4E66\u7533\u8BF7\u4E00\u6B21\u6027\u6388\u6743\u4E8C\u7EF4\u7801\uFF0C\u8BF7\u7A0D\u5019\u3002"),
|
h2("p", null, connecting ? repairing ? "\u914D\u7F6E\u5DF2\u63D0\u4EA4\uFF0C\u6B63\u5728\u9A8C\u8BC1\u5361\u7247\u6309\u94AE\u56DE\u8C03\u5E76\u91CD\u8FDE\u6B64\u673A\u5668\u4EBA\uFF1B\u6B64\u9636\u6BB5\u65E0\u6CD5\u53D6\u6D88\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u4F1A\u4E2D\u65AD\u3002" : "\u6B63\u5728\u5B89\u5168\u4FDD\u5B58\u51ED\u636E\u5E76\u68C0\u67E5\u65B0\u673A\u5668\u4EBA\u7684\u6D88\u606F\u901A\u9053\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u4F1A\u4E2D\u65AD\u3002" : repairing ? "\u6B63\u5728\u4E3A\u73B0\u6709\u98DE\u4E66\u5E94\u7528\u7533\u8BF7\u4E00\u6B21\u6027\u66F4\u65B0\u4E8C\u7EF4\u7801\uFF0C\u8BF7\u7A0D\u5019\u3002" : "\u6B63\u5728\u5411\u98DE\u4E66\u7533\u8BF7\u4E00\u6B21\u6027\u6388\u6743\u4E8C\u7EF4\u7801\uFF0C\u8BF7\u7A0D\u5019\u3002"),
|
||||||
connecting ? h2(
|
connecting && onCancel ? h2(
|
||||||
"div",
|
"div",
|
||||||
{ className: "bxf-actions dim-viewActions", style: { justifyContent: "center" } },
|
{ className: "bxf-actions dim-viewActions", style: { justifyContent: "center" } },
|
||||||
h2(Button5, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u6DFB\u52A0")
|
h2(Button5, { onClick: onCancel, disabled: busy }, repairing ? "\u53D6\u6D88\u4FEE\u590D" : "\u53D6\u6D88\u6DFB\u52A0")
|
||||||
) : null
|
) : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function ProvisionError2({ error, onRetry, onCancel, busy }) {
|
function ProvisionError2({ error, provision, onRetry, onCancel, busy }) {
|
||||||
|
const repairing = isCallbackRepair(provision);
|
||||||
return h2(
|
return h2(
|
||||||
"div",
|
"div",
|
||||||
{ className: "bxf-card bxf-provisionCard dim-surfaceCard" },
|
{ className: "bxf-card bxf-provisionCard dim-surfaceCard" },
|
||||||
|
|
@ -4126,7 +4190,7 @@ function ProvisionError2({ error, onRetry, onCancel, busy }) {
|
||||||
h2(
|
h2(
|
||||||
"div",
|
"div",
|
||||||
null,
|
null,
|
||||||
h2("h3", null, "\u65B0\u673A\u5668\u4EBA\u6CA1\u6709\u6DFB\u52A0\u5B8C\u6210"),
|
h2("h3", null, repairing ? "\u5361\u7247\u6309\u94AE\u6CA1\u6709\u4FEE\u590D\u5B8C\u6210" : "\u65B0\u673A\u5668\u4EBA\u6CA1\u6709\u6DFB\u52A0\u5B8C\u6210"),
|
||||||
h2("p", null, error.message),
|
h2("p", null, error.message),
|
||||||
error.code ? h2("span", { className: "bxf-errorCode" }, error.code) : null,
|
error.code ? h2("span", { className: "bxf-errorCode" }, error.code) : null,
|
||||||
h2(
|
h2(
|
||||||
|
|
@ -4211,10 +4275,12 @@ function RemoveConfirmation2({ bot, busy, onConfirm, onCancel }) {
|
||||||
function BotCard({
|
function BotCard({
|
||||||
connection,
|
connection,
|
||||||
busy,
|
busy,
|
||||||
|
repairDisabled,
|
||||||
actionError,
|
actionError,
|
||||||
testNotice,
|
testNotice,
|
||||||
removing,
|
removing,
|
||||||
onReconnect,
|
onReconnect,
|
||||||
|
onRepairCallback,
|
||||||
onWorkspaceSave,
|
onWorkspaceSave,
|
||||||
onRequestRemove,
|
onRequestRemove,
|
||||||
onConfirmRemove,
|
onConfirmRemove,
|
||||||
|
|
@ -4307,6 +4373,13 @@ function BotCard({
|
||||||
"aria-busy": busy === "reconnect" ? "true" : void 0,
|
"aria-busy": busy === "reconnect" ? "true" : void 0,
|
||||||
"aria-label": `${connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5"}${bot.name}`
|
"aria-label": `${connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5"}${bot.name}`
|
||||||
}, busy === "reconnect" ? connected ? "\u68C0\u67E5\u4E2D\u2026" : "\u6B63\u5728\u8FDE\u63A5\u2026" : connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5"),
|
}, busy === "reconnect" ? connected ? "\u68C0\u67E5\u4E2D\u2026" : "\u6B63\u5728\u8FDE\u63A5\u2026" : connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5"),
|
||||||
|
h2(Button5, {
|
||||||
|
className: "bxf-repairButton dim-cardAction",
|
||||||
|
onClick: onRepairCallback,
|
||||||
|
disabled: Boolean(busy) || repairDisabled,
|
||||||
|
"aria-busy": busy === "callback-repair" ? "true" : void 0,
|
||||||
|
"aria-label": `\u4FEE\u590D${bot.name}\u7684\u5361\u7247\u6309\u94AE`
|
||||||
|
}, busy === "callback-repair" ? "\u7B49\u5F85\u626B\u7801\u2026" : "\u4FEE\u590D\u5361\u7247\u6309\u94AE"),
|
||||||
h2(Button5, {
|
h2(Button5, {
|
||||||
className: "dim-cardAction",
|
className: "dim-cardAction",
|
||||||
kind: "danger",
|
kind: "danger",
|
||||||
|
|
@ -4343,11 +4416,13 @@ function BotList(props) {
|
||||||
{ key: bot.botId },
|
{ key: bot.botId },
|
||||||
h2(BotCard, {
|
h2(BotCard, {
|
||||||
connection: bot,
|
connection: bot,
|
||||||
busy: props.busyByBot[bot.botId],
|
busy: props.busyByBot[bot.botId] ?? (isCallbackRepair(props.provisioning) && props.provisioning.botId === bot.botId ? "callback-repair" : void 0),
|
||||||
|
repairDisabled: Boolean(props.provisioning),
|
||||||
actionError: props.errorsByBot[bot.botId],
|
actionError: props.errorsByBot[bot.botId],
|
||||||
testNotice: props.testNoticesByBot[bot.botId],
|
testNotice: props.testNoticesByBot[bot.botId],
|
||||||
removing: props.removeTargetId === bot.botId,
|
removing: props.removeTargetId === bot.botId,
|
||||||
onReconnect: () => props.onReconnect(bot),
|
onReconnect: () => props.onReconnect(bot),
|
||||||
|
onRepairCallback: () => props.onRepairCallback(bot),
|
||||||
onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
|
onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
|
||||||
onRequestRemove: () => props.onRequestRemove(bot),
|
onRequestRemove: () => props.onRequestRemove(bot),
|
||||||
onConfirmRemove: () => props.onConfirmRemove(bot),
|
onConfirmRemove: () => props.onConfirmRemove(bot),
|
||||||
|
|
@ -4390,11 +4465,12 @@ function mergeFeishuSnapshotState(current, snapshot, { restoreProvisioning = fal
|
||||||
if (snapshot.revision > 0 && current.revision > snapshot.revision) return current;
|
if (snapshot.revision > 0 && current.revision > snapshot.revision) return current;
|
||||||
let provisioning = current.provisioning;
|
let provisioning = current.provisioning;
|
||||||
if (!provisioning && restoreProvisioning && snapshot.provisioning) {
|
if (!provisioning && restoreProvisioning && snapshot.provisioning) {
|
||||||
|
const submitted = snapshot.provisioning.submitted === true;
|
||||||
provisioning = {
|
provisioning = {
|
||||||
phase: snapshot.state === "connecting" ? "connecting" : "qr",
|
phase: submitted || snapshot.state === "connecting" ? "connecting" : "qr",
|
||||||
...snapshot.provisioning,
|
...snapshot.provisioning,
|
||||||
durationMs: Math.max(1, snapshot.provisioning.expiresAt - now),
|
durationMs: Math.max(1, snapshot.provisioning.expiresAt - now),
|
||||||
expired: snapshot.provisioning.expiresAt <= now
|
expired: !submitted && snapshot.provisioning.expiresAt <= now
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -4509,7 +4585,15 @@ function FeishuSettingsTab({ rpcCall }) {
|
||||||
node.focus({ preventScroll: true });
|
node.focus({ preventScroll: true });
|
||||||
setFocusBotId(null);
|
setFocusBotId(null);
|
||||||
}, [focusBotId, model.bots]);
|
}, [focusBotId, model.bots]);
|
||||||
const startProvisioning = React10.useCallback(async ({ replace = false } = {}) => {
|
const startProvisioning = React10.useCallback(async ({
|
||||||
|
replace = false,
|
||||||
|
operation = FEISHU_REGISTRATION_OPERATIONS.PROVISION,
|
||||||
|
bot
|
||||||
|
} = {}) => {
|
||||||
|
const repairing = operation === CALLBACK_REPAIR_OPERATION;
|
||||||
|
const botId = repairing ? bot?.botId ?? model.provisioning?.botId : void 0;
|
||||||
|
const botName = repairing ? bot?.bot?.name ?? model.provisioning?.botName : void 0;
|
||||||
|
if (repairing && !botId) return;
|
||||||
setCredentialOpen(false);
|
setCredentialOpen(false);
|
||||||
setCredentialError(null);
|
setCredentialError(null);
|
||||||
setProvisionBusy(true);
|
setProvisionBusy(true);
|
||||||
|
|
@ -4518,16 +4602,27 @@ function FeishuSettingsTab({ rpcCall }) {
|
||||||
setModel((current) => ({
|
setModel((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: current.phase === "loading" ? "ready" : current.phase,
|
phase: current.phase === "loading" ? "ready" : current.phase,
|
||||||
provisioning: { phase: "creating" }
|
provisioning: {
|
||||||
|
phase: "creating",
|
||||||
|
operation,
|
||||||
|
...botId ? { botId } : {},
|
||||||
|
...botName ? { botName } : {}
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
if (replace && previousAttemptId) {
|
if (replace && previousAttemptId) {
|
||||||
await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId: previousAttemptId });
|
try {
|
||||||
|
await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId: previousAttemptId });
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const provision2 = normalizeProvisioning2(await invoke(
|
const provision2 = normalizeProvisioning2(await invoke(
|
||||||
FEISHU_ENDPOINTS.beginProvisioning,
|
repairing ? FEISHU_ENDPOINTS.beginCallbackRepair : FEISHU_ENDPOINTS.beginProvisioning,
|
||||||
{ locale: "zh-CN" }
|
repairing ? { botId } : { locale: "zh-CN" }
|
||||||
));
|
));
|
||||||
|
if (repairing && (provision2.operation !== CALLBACK_REPAIR_OPERATION || provision2.botId !== botId)) {
|
||||||
|
throw new Error("\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u4E86\u4E0D\u5339\u914D\u7684\u5361\u7247\u4FEE\u590D\u4E8C\u7EF4\u7801");
|
||||||
|
}
|
||||||
const timestamp7 = Date.now();
|
const timestamp7 = Date.now();
|
||||||
setNow(timestamp7);
|
setNow(timestamp7);
|
||||||
setModel((current) => ({
|
setModel((current) => ({
|
||||||
|
|
@ -4535,20 +4630,34 @@ function FeishuSettingsTab({ rpcCall }) {
|
||||||
provisioning: {
|
provisioning: {
|
||||||
phase: "qr",
|
phase: "qr",
|
||||||
...provision2,
|
...provision2,
|
||||||
|
...botName ? { botName } : {},
|
||||||
durationMs: Math.max(1, provision2.expiresAt - timestamp7),
|
durationMs: Math.max(1, provision2.expiresAt - timestamp7),
|
||||||
expired: false
|
expired: false
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
announce("\u6388\u6743\u4E8C\u7EF4\u7801\u5DF2\u751F\u6210\uFF0C\u8BF7\u4F7F\u7528\u98DE\u4E66\u626B\u7801\u3002");
|
announce(repairing ? `${botName ?? "\u673A\u5668\u4EBA"}\u7684\u4FEE\u590D\u4E8C\u7EF4\u7801\u5DF2\u751F\u6210\uFF0C\u8BF7\u4F7F\u7528\u98DE\u4E66\u626B\u7801\u3002` : "\u6388\u6743\u4E8C\u7EF4\u7801\u5DF2\u751F\u6210\uFF0C\u8BF7\u4F7F\u7528\u98DE\u4E66\u626B\u7801\u3002");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setModel((current) => ({
|
setModel((current) => ({
|
||||||
...current,
|
...current,
|
||||||
provisioning: { phase: "error", error: presentError3(error) }
|
provisioning: {
|
||||||
|
phase: "error",
|
||||||
|
operation,
|
||||||
|
...botId ? { botId } : {},
|
||||||
|
...botName ? { botName } : {},
|
||||||
|
...replace && previousAttemptId ? { attemptId: previousAttemptId } : {},
|
||||||
|
error: presentError3(error)
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
} finally {
|
} finally {
|
||||||
setProvisionBusy(false);
|
setProvisionBusy(false);
|
||||||
}
|
}
|
||||||
}, [announce, invoke, model.provisioning?.attemptId]);
|
}, [
|
||||||
|
announce,
|
||||||
|
invoke,
|
||||||
|
model.provisioning?.attemptId,
|
||||||
|
model.provisioning?.botId,
|
||||||
|
model.provisioning?.botName
|
||||||
|
]);
|
||||||
const bindCredentials = React10.useCallback(async ({ identity, secret }) => {
|
const bindCredentials = React10.useCallback(async ({ identity, secret }) => {
|
||||||
const snapshotVersion = workspaceFence.beginMutation();
|
const snapshotVersion = workspaceFence.beginMutation();
|
||||||
setCredentialBusy(true);
|
setCredentialBusy(true);
|
||||||
|
|
@ -4572,23 +4681,64 @@ function FeishuSettingsTab({ rpcCall }) {
|
||||||
}
|
}
|
||||||
}, [announce, invoke, loadStatus, mergeSnapshot, workspaceFence]);
|
}, [announce, invoke, loadStatus, mergeSnapshot, workspaceFence]);
|
||||||
const cancelProvisioning = React10.useCallback(async () => {
|
const cancelProvisioning = React10.useCallback(async () => {
|
||||||
const attemptId = model.provisioning?.attemptId;
|
const activeProvision = model.provisioning;
|
||||||
|
const attemptId = activeProvision?.attemptId;
|
||||||
|
const repairing = isCallbackRepair(activeProvision);
|
||||||
|
const targetBot = repairing ? model.bots.find((bot) => bot.botId === activeProvision?.botId) : void 0;
|
||||||
setProvisionBusy(true);
|
setProvisionBusy(true);
|
||||||
try {
|
try {
|
||||||
if (attemptId) await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId });
|
const result = attemptId ? normalizePollResult(await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId })) : null;
|
||||||
|
if (repairing && result) {
|
||||||
|
if (result.operation !== CALLBACK_REPAIR_OPERATION || result.botId !== activeProvision.botId) {
|
||||||
|
throw new Error("\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u4E86\u4E0D\u5339\u914D\u7684\u6CE8\u518C\u8FDB\u5EA6");
|
||||||
|
}
|
||||||
|
if (result.status === "connecting") {
|
||||||
|
setModel((current) => current.provisioning?.attemptId === attemptId ? {
|
||||||
|
...current,
|
||||||
|
provisioning: {
|
||||||
|
...current.provisioning,
|
||||||
|
...result.provisioning ?? {},
|
||||||
|
phase: "connecting",
|
||||||
|
submitted: true,
|
||||||
|
expired: false
|
||||||
|
}
|
||||||
|
} : current);
|
||||||
|
announce("\u914D\u7F6E\u5DF2\u63D0\u4EA4\uFF0C\u6B63\u5728\u9A8C\u8BC1\u5361\u7247\u6309\u94AE\u56DE\u8C03\u5E76\u91CD\u8FDE\u6B64\u673A\u5668\u4EBA\uFF1B\u6B64\u9636\u6BB5\u65E0\u6CD5\u53D6\u6D88\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u4F1A\u4E2D\u65AD\u3002");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.status === "connected") {
|
||||||
|
const targetBotName = targetBot?.bot.name ?? activeProvision.botName ?? "\u673A\u5668\u4EBA";
|
||||||
|
setModel((current) => ({ ...current, provisioning: null }));
|
||||||
|
announce(`${targetBotName}\u7684\u5361\u7247\u6309\u94AE\u5DF2\u4FEE\u590D\u3002`);
|
||||||
|
if (activeProvision.botId) setFocusBotId(activeProvision.botId);
|
||||||
|
await loadStatus({ silent: true, restoreProvisioning: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
setModel((current) => ({ ...current, provisioning: null }));
|
setModel((current) => ({ ...current, provisioning: null }));
|
||||||
announce("\u5DF2\u53D6\u6D88\u6DFB\u52A0\u673A\u5668\u4EBA\u3002");
|
announce(repairing ? "\u5DF2\u53D6\u6D88\u5361\u7247\u6309\u94AE\u4FEE\u590D\u3002" : "\u5DF2\u53D6\u6D88\u6DFB\u52A0\u673A\u5668\u4EBA\u3002");
|
||||||
await loadStatus({ silent: true, restoreProvisioning: false });
|
await loadStatus({ silent: true, restoreProvisioning: false });
|
||||||
scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus");
|
scheduleAnimationFrame(() => {
|
||||||
|
if (repairing && activeProvision.botId) {
|
||||||
|
cardRefs.current.get(activeProvision.botId)?.focus();
|
||||||
|
} else {
|
||||||
|
addButtonRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, "focus");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setModel((current) => ({
|
setModel((current) => ({
|
||||||
...current,
|
...current,
|
||||||
provisioning: { phase: "error", attemptId, error: presentError3(error) }
|
provisioning: {
|
||||||
|
...activeProvision,
|
||||||
|
phase: "error",
|
||||||
|
attemptId,
|
||||||
|
error: presentError3(error)
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
} finally {
|
} finally {
|
||||||
setProvisionBusy(false);
|
setProvisionBusy(false);
|
||||||
}
|
}
|
||||||
}, [announce, invoke, loadStatus, model.provisioning?.attemptId, scheduleAnimationFrame]);
|
}, [announce, invoke, loadStatus, model.bots, model.provisioning, scheduleAnimationFrame]);
|
||||||
const countdownAttemptId = model.provisioning?.attemptId;
|
const countdownAttemptId = model.provisioning?.attemptId;
|
||||||
const countdownPhase = model.provisioning?.phase;
|
const countdownPhase = model.provisioning?.phase;
|
||||||
const countdownExpiresAt = model.provisioning?.expiresAt;
|
const countdownExpiresAt = model.provisioning?.expiresAt;
|
||||||
|
|
@ -4617,23 +4767,26 @@ function FeishuSettingsTab({ rpcCall }) {
|
||||||
{ attemptId: provision2.attemptId },
|
{ attemptId: provision2.attemptId },
|
||||||
controller.signal
|
controller.signal
|
||||||
));
|
));
|
||||||
|
if (result.operation !== provision2.operation || isCallbackRepair(provision2) && result.botId !== provision2.botId) {
|
||||||
|
throw new Error("\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u4E86\u4E0D\u5339\u914D\u7684\u6CE8\u518C\u8FDB\u5EA6");
|
||||||
|
}
|
||||||
if (result.status === "connected") {
|
if (result.status === "connected") {
|
||||||
const snapshot = await loadStatus({ signal: controller.signal, silent: true, restoreProvisioning: false });
|
const snapshot = await loadStatus({ signal: controller.signal, silent: true, restoreProvisioning: false });
|
||||||
const newBot = snapshot?.bots.find((bot) => bot.botId === result.botId);
|
const targetBot = snapshot?.bots.find((bot) => bot.botId === result.botId);
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
throw new Error("\u673A\u5668\u4EBA\u5DF2\u7ECF\u521B\u5EFA\uFF0C\u4F46\u6682\u65F6\u65E0\u6CD5\u786E\u8BA4\u8FDE\u63A5\u72B6\u6001");
|
throw new Error(isCallbackRepair(provision2) ? "\u5361\u7247\u6309\u94AE\u5DF2\u66F4\u65B0\uFF0C\u4F46\u6682\u65F6\u65E0\u6CD5\u786E\u8BA4\u673A\u5668\u4EBA\u8FDE\u63A5\u72B6\u6001" : "\u673A\u5668\u4EBA\u5DF2\u7ECF\u521B\u5EFA\uFF0C\u4F46\u6682\u65F6\u65E0\u6CD5\u786E\u8BA4\u8FDE\u63A5\u72B6\u6001");
|
||||||
}
|
}
|
||||||
if (!newBot?.connected) {
|
if (!targetBot?.connected) {
|
||||||
setModel((current) => current.provisioning?.attemptId === provision2.attemptId ? { ...current, provisioning: { ...current.provisioning, phase: "connecting" } } : current);
|
setModel((current) => current.provisioning?.attemptId === provision2.attemptId ? { ...current, provisioning: { ...current.provisioning, phase: "connecting" } } : current);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setModel((current) => ({ ...current, provisioning: null }));
|
setModel((current) => ({ ...current, provisioning: null }));
|
||||||
announce(newBot ? `${newBot.bot.name}\u5DF2\u8FDE\u63A5\uFF0C\u53EF\u4EE5\u5728\u98DE\u4E66\u4E2D\u5F00\u59CB\u804A\u5929\u3002` : "\u65B0\u98DE\u4E66\u673A\u5668\u4EBA\u5DF2\u8FDE\u63A5\uFF0C\u53EF\u4EE5\u5F00\u59CB\u804A\u5929\u3002");
|
announce(isCallbackRepair(provision2) ? `${targetBot.bot.name}\u7684\u5361\u7247\u6309\u94AE\u5DF2\u4FEE\u590D\u3002` : targetBot ? `${targetBot.bot.name}\u5DF2\u8FDE\u63A5\uFF0C\u53EF\u4EE5\u5728\u98DE\u4E66\u4E2D\u5F00\u59CB\u804A\u5929\u3002` : "\u65B0\u98DE\u4E66\u673A\u5668\u4EBA\u5DF2\u8FDE\u63A5\uFF0C\u53EF\u4EE5\u5F00\u59CB\u804A\u5929\u3002");
|
||||||
if (result.botId) setFocusBotId(result.botId);
|
if (result.botId) setFocusBotId(result.botId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.status === "failed") {
|
if (result.status === "failed") {
|
||||||
const error = new Error(result.message ?? "\u98DE\u4E66\u5E94\u7528\u521B\u5EFA\u5931\u8D25");
|
const error = new Error(result.message ?? (isCallbackRepair(provision2) ? "\u98DE\u4E66\u5361\u7247\u6309\u94AE\u4FEE\u590D\u5931\u8D25" : "\u98DE\u4E66\u5E94\u7528\u521B\u5EFA\u5931\u8D25"));
|
||||||
error.code = "FEISHU_PROVISION_FAILED";
|
error.code = "FEISHU_PROVISION_FAILED";
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -4658,6 +4811,7 @@ function FeishuSettingsTab({ rpcCall }) {
|
||||||
setModel((current) => current.provisioning?.attemptId === provision2.attemptId ? {
|
setModel((current) => current.provisioning?.attemptId === provision2.attemptId ? {
|
||||||
...current,
|
...current,
|
||||||
provisioning: {
|
provisioning: {
|
||||||
|
...current.provisioning,
|
||||||
phase: "error",
|
phase: "error",
|
||||||
attemptId: provision2.attemptId,
|
attemptId: provision2.attemptId,
|
||||||
error: presentError3(error)
|
error: presentError3(error)
|
||||||
|
|
@ -4686,6 +4840,20 @@ function FeishuSettingsTab({ rpcCall }) {
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
const repairCallback = React10.useCallback((connection) => {
|
||||||
|
if (model.provisioning) return;
|
||||||
|
setRemoveTargetId(null);
|
||||||
|
setBotError(connection.botId, null);
|
||||||
|
setTestNoticesByBot((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[connection.botId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
void startProvisioning({
|
||||||
|
operation: CALLBACK_REPAIR_OPERATION,
|
||||||
|
bot: connection
|
||||||
|
});
|
||||||
|
}, [model.provisioning, setBotError, startProvisioning]);
|
||||||
const reconnectOneBot = React10.useCallback(async (connection) => {
|
const reconnectOneBot = React10.useCallback(async (connection) => {
|
||||||
const { botId, bot } = connection;
|
const { botId, bot } = connection;
|
||||||
const snapshotVersion = workspaceFence.beginMutation();
|
const snapshotVersion = workspaceFence.beginMutation();
|
||||||
|
|
@ -4779,28 +4947,48 @@ function FeishuSettingsTab({ rpcCall }) {
|
||||||
}
|
}
|
||||||
}, [announce, invoke, loadStatus, mergeSnapshot, scheduleAnimationFrame, setBotBusy, setBotError, workspaceFence]);
|
}, [announce, invoke, loadStatus, mergeSnapshot, scheduleAnimationFrame, setBotBusy, setBotError, workspaceFence]);
|
||||||
const provision = model.provisioning;
|
const provision = model.provisioning;
|
||||||
|
const provisionBot = provision?.botId ? model.bots.find((bot) => bot.botId === provision.botId) ?? { botId: provision.botId, bot: { name: provision.botName ?? "\u6B64\u673A\u5668\u4EBA" } } : void 0;
|
||||||
|
const restartProvisioning = ({ replace = false } = {}) => startProvisioning({
|
||||||
|
replace,
|
||||||
|
operation: provision?.operation ?? FEISHU_REGISTRATION_OPERATIONS.PROVISION,
|
||||||
|
bot: provisionBot
|
||||||
|
});
|
||||||
let provisionContent = null;
|
let provisionContent = null;
|
||||||
if (provision?.phase === "creating") {
|
if (provision?.phase === "creating") {
|
||||||
provisionContent = h2(ProvisionProgress, { phase: "creating", busy: provisionBusy });
|
provisionContent = h2(ProvisionProgress, {
|
||||||
|
phase: "creating",
|
||||||
|
provision,
|
||||||
|
busy: provisionBusy
|
||||||
|
});
|
||||||
} else if (provision?.phase === "qr") {
|
} else if (provision?.phase === "qr") {
|
||||||
provisionContent = h2(QrPane, {
|
provisionContent = h2(QrPane, {
|
||||||
provision,
|
provision,
|
||||||
now,
|
now,
|
||||||
onRefresh: () => void startProvisioning({ replace: true }),
|
onRefresh: () => void restartProvisioning({ replace: true }),
|
||||||
onCancel: () => void cancelProvisioning(),
|
onCancel: () => void cancelProvisioning(),
|
||||||
busy: provisionBusy || model.phase !== "ready"
|
busy: provisionBusy || model.phase !== "ready"
|
||||||
});
|
});
|
||||||
} else if (provision?.phase === "connecting") {
|
} else if (provision?.phase === "connecting") {
|
||||||
provisionContent = h2(ProvisionProgress, {
|
provisionContent = h2(ProvisionProgress, {
|
||||||
phase: "connecting",
|
phase: "connecting",
|
||||||
onCancel: () => void cancelProvisioning(),
|
provision,
|
||||||
|
onCancel: isCallbackRepair(provision) ? void 0 : () => void cancelProvisioning(),
|
||||||
busy: provisionBusy
|
busy: provisionBusy
|
||||||
});
|
});
|
||||||
} else if (provision?.phase === "error") {
|
} else if (provision?.phase === "error") {
|
||||||
provisionContent = h2(ProvisionError2, {
|
provisionContent = h2(ProvisionError2, {
|
||||||
error: provision.error,
|
error: provision.error,
|
||||||
onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }),
|
provision,
|
||||||
onCancel: () => void cancelProvisioning(),
|
onRetry: () => void restartProvisioning({ replace: Boolean(provision.attemptId) }),
|
||||||
|
onCancel: () => {
|
||||||
|
const targetBotId = provision.botId;
|
||||||
|
setModel((current) => ({ ...current, provisioning: null }));
|
||||||
|
void loadStatus({ silent: true, restoreProvisioning: false });
|
||||||
|
scheduleAnimationFrame(() => {
|
||||||
|
if (targetBotId) cardRefs.current.get(targetBotId)?.focus();
|
||||||
|
else addButtonRef.current?.focus();
|
||||||
|
}, "focus");
|
||||||
|
},
|
||||||
busy: provisionBusy
|
busy: provisionBusy
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -4870,7 +5058,9 @@ function FeishuSettingsTab({ rpcCall }) {
|
||||||
errorsByBot,
|
errorsByBot,
|
||||||
testNoticesByBot,
|
testNoticesByBot,
|
||||||
removeTargetId,
|
removeTargetId,
|
||||||
|
provisioning: provision,
|
||||||
onReconnect: (bot) => void reconnectOneBot(bot),
|
onReconnect: (bot) => void reconnectOneBot(bot),
|
||||||
|
onRepairCallback: repairCallback,
|
||||||
onWorkspaceSave: saveWorkspace,
|
onWorkspaceSave: saveWorkspace,
|
||||||
onRequestRemove: requestRemove,
|
onRequestRemove: requestRemove,
|
||||||
onConfirmRemove: (bot) => void confirmRemove(bot),
|
onConfirmRemove: (bot) => void confirmRemove(bot),
|
||||||
|
|
|
||||||
268
lib/index.js
268
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -11,6 +11,7 @@ export const FEISHU_RPC_CHANNEL = "/feishu";
|
||||||
export const FEISHU_ENDPOINTS = Object.freeze({
|
export const FEISHU_ENDPOINTS = Object.freeze({
|
||||||
status: "connection.status",
|
status: "connection.status",
|
||||||
beginProvisioning: "provision.begin",
|
beginProvisioning: "provision.begin",
|
||||||
|
beginCallbackRepair: "bot.callback-repair.begin",
|
||||||
pollProvisioning: "provision.poll",
|
pollProvisioning: "provision.poll",
|
||||||
cancelProvisioning: "provision.cancel",
|
cancelProvisioning: "provision.cancel",
|
||||||
bindCredentials: "bot.bind-credentials",
|
bindCredentials: "bot.bind-credentials",
|
||||||
|
|
@ -23,6 +24,11 @@ export const FEISHU_ENDPOINTS = Object.freeze({
|
||||||
disconnect: "connection.disconnect",
|
disconnect: "connection.disconnect",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const FEISHU_REGISTRATION_OPERATIONS = Object.freeze({
|
||||||
|
PROVISION: "provision",
|
||||||
|
CALLBACK_REPAIR: "callback_repair",
|
||||||
|
});
|
||||||
|
|
||||||
const CONNECTION_STATES = new Set([
|
const CONNECTION_STATES = new Set([
|
||||||
"disconnected",
|
"disconnected",
|
||||||
"offline",
|
"offline",
|
||||||
|
|
@ -67,6 +73,12 @@ function clamp(value, min, max, fallback) {
|
||||||
: fallback;
|
: fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRegistrationOperation(value) {
|
||||||
|
return value === FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR
|
||||||
|
? FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR
|
||||||
|
: FEISHU_REGISTRATION_OPERATIONS.PROVISION;
|
||||||
|
}
|
||||||
|
|
||||||
export function unwrapRpcResult(result) {
|
export function unwrapRpcResult(result) {
|
||||||
if (!isRecord(result) || typeof result.ok !== "boolean") {
|
if (!isRecord(result) || typeof result.ok !== "boolean") {
|
||||||
throw new Error("飞书服务返回了无法识别的响应");
|
throw new Error("飞书服务返回了无法识别的响应");
|
||||||
|
|
@ -88,16 +100,25 @@ export function normalizeProvisioning(value, now = Date.now()) {
|
||||||
?? optionalString(source.provisioningId);
|
?? optionalString(source.provisioningId);
|
||||||
const verificationUrl = optionalString(source.verificationUrl);
|
const verificationUrl = optionalString(source.verificationUrl);
|
||||||
const qrCodeDataUrl = optionalString(source.qrCodeDataUrl);
|
const qrCodeDataUrl = optionalString(source.qrCodeDataUrl);
|
||||||
if (!attemptId || (!verificationUrl && !qrCodeDataUrl)) {
|
const submitted = source.submitted === true;
|
||||||
|
if (!attemptId || (!verificationUrl && !qrCodeDataUrl && !submitted)) {
|
||||||
throw new Error("飞书服务返回的二维码信息不完整");
|
throw new Error("飞书服务返回的二维码信息不完整");
|
||||||
}
|
}
|
||||||
|
|
||||||
const explicitExpiry = optionalTimestamp(source.expiresAt);
|
const explicitExpiry = optionalTimestamp(source.expiresAt);
|
||||||
const expireIn = clamp(source.expireIn, 1, 60 * 60, 5 * 60);
|
const expireIn = clamp(source.expireIn, 1, 60 * 60, 5 * 60);
|
||||||
|
const operation = normalizeRegistrationOperation(source.operation);
|
||||||
|
const botId = optionalString(source.botId);
|
||||||
|
if (operation === FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR && !botId) {
|
||||||
|
throw new Error("飞书服务返回的修复信息缺少 botId");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
attemptId,
|
attemptId,
|
||||||
|
operation,
|
||||||
|
botId,
|
||||||
verificationUrl,
|
verificationUrl,
|
||||||
qrCodeDataUrl,
|
qrCodeDataUrl,
|
||||||
|
submitted,
|
||||||
expiresAt: explicitExpiry ?? now + expireIn * 1000,
|
expiresAt: explicitExpiry ?? now + expireIn * 1000,
|
||||||
pollIntervalMs: clamp(source.pollIntervalMs, 800, 10_000, 1_800),
|
pollIntervalMs: clamp(source.pollIntervalMs, 800, 10_000, 1_800),
|
||||||
};
|
};
|
||||||
|
|
@ -296,6 +317,7 @@ export function normalizePollResult(value) {
|
||||||
|
|
||||||
const normalized = {
|
const normalized = {
|
||||||
status,
|
status,
|
||||||
|
operation: normalizeRegistrationOperation(value.operation),
|
||||||
botId: optionalString(value.botId),
|
botId: optionalString(value.botId),
|
||||||
message: optionalString(value.error?.message) ?? optionalString(value.message),
|
message: optionalString(value.error?.message) ?? optionalString(value.message),
|
||||||
connection: undefined,
|
connection: undefined,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from "../.
|
||||||
import { h } from "../../i18n.js";
|
import { h } from "../../i18n.js";
|
||||||
import {
|
import {
|
||||||
FEISHU_ENDPOINTS,
|
FEISHU_ENDPOINTS,
|
||||||
|
FEISHU_REGISTRATION_OPERATIONS,
|
||||||
FEISHU_RPC_CHANNEL,
|
FEISHU_RPC_CHANNEL,
|
||||||
formatRemaining,
|
formatRemaining,
|
||||||
normalizeBotsSnapshot,
|
normalizeBotsSnapshot,
|
||||||
|
|
@ -21,6 +22,12 @@ import { installFeishuStyles } from "./styles.js";
|
||||||
export const name = "feishu-settings";
|
export const name = "feishu-settings";
|
||||||
export const inject = ["slots", "connection"];
|
export const inject = ["slots", "connection"];
|
||||||
|
|
||||||
|
const CALLBACK_REPAIR_OPERATION = FEISHU_REGISTRATION_OPERATIONS.CALLBACK_REPAIR;
|
||||||
|
|
||||||
|
function isCallbackRepair(value) {
|
||||||
|
return value?.operation === CALLBACK_REPAIR_OPERATION;
|
||||||
|
}
|
||||||
|
|
||||||
function SvgIcon({ children, size = 18, className, viewBox = "0 0 24 24" }) {
|
function SvgIcon({ children, size = 18, className, viewBox = "0 0 24 24" }) {
|
||||||
return h("svg", {
|
return h("svg", {
|
||||||
width: size,
|
width: size,
|
||||||
|
|
@ -197,7 +204,18 @@ function safeVerificationHref(value) {
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
try {
|
try {
|
||||||
const url = new URL(value);
|
const url = new URL(value);
|
||||||
return url.protocol === "https:" ? url.toString() : undefined;
|
return url.protocol === "https:"
|
||||||
|
&& [
|
||||||
|
"accounts.feishu.cn",
|
||||||
|
"accounts.larksuite.com",
|
||||||
|
"open.feishu.cn",
|
||||||
|
"open.larksuite.com",
|
||||||
|
].includes(url.hostname)
|
||||||
|
&& !url.port
|
||||||
|
&& !url.username
|
||||||
|
&& !url.password
|
||||||
|
? url.toString()
|
||||||
|
: undefined;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -217,6 +235,8 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
|
||||||
const remaining = Math.max(0, provision.expiresAt - now);
|
const remaining = Math.max(0, provision.expiresAt - now);
|
||||||
const expired = provision.expired === true || remaining === 0;
|
const expired = provision.expired === true || remaining === 0;
|
||||||
const progress = Math.min(1, remaining / Math.max(1, provision.durationMs ?? remaining));
|
const progress = Math.min(1, remaining / Math.max(1, provision.durationMs ?? remaining));
|
||||||
|
const repairing = isCallbackRepair(provision);
|
||||||
|
const botName = provision.botName ?? "此机器人";
|
||||||
|
|
||||||
React.useEffect(() => setImageFailed(false), [qrSource]);
|
React.useEffect(() => setImageFailed(false), [qrSource]);
|
||||||
|
|
||||||
|
|
@ -225,9 +245,11 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
|
||||||
h("div", { className: "bxf-qrColumn dim-qrColumn" },
|
h("div", { className: "bxf-qrColumn dim-qrColumn" },
|
||||||
h("div", { className: "bxf-qrFrame dim-qrFrame" },
|
h("div", { className: "bxf-qrFrame dim-qrFrame" },
|
||||||
qrSource && !imageFailed
|
qrSource && !imageFailed
|
||||||
? h("img", {
|
? h("img", {
|
||||||
src: qrSource,
|
src: qrSource,
|
||||||
alt: "用于新增 DeepSeek Harness 飞书机器人的一次性授权二维码",
|
alt: repairing
|
||||||
|
? `用于修复${botName}卡片按钮的一次性授权二维码`
|
||||||
|
: "用于新增 DeepSeek Harness 飞书机器人的一次性授权二维码",
|
||||||
onError: () => setImageFailed(true),
|
onError: () => setImageFailed(true),
|
||||||
})
|
})
|
||||||
: h("div", { className: "bxf-qrFallback dim-qrFallback" },
|
: h("div", { className: "bxf-qrFallback dim-qrFallback" },
|
||||||
|
|
@ -251,13 +273,21 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
|
||||||
h("div", { className: "bxf-qrCopy dim-qrCopy" },
|
h("div", { className: "bxf-qrCopy dim-qrCopy" },
|
||||||
h("div", { className: "bxf-stateLabel dim-stateLabel" },
|
h("div", { className: "bxf-stateLabel dim-stateLabel" },
|
||||||
h("span", { className: "bxf-dot dim-stateDot", "data-tone": "warning" }),
|
h("span", { className: "bxf-dot dim-stateDot", "data-tone": "warning" }),
|
||||||
h("span", null, "正在添加新机器人")),
|
h("span", null, repairing ? `正在修复「${botName}」` : "正在添加新机器人")),
|
||||||
h("h3", null, expired ? "刷新二维码后继续" : "使用飞书扫码创建机器人"),
|
h("h3", null, expired
|
||||||
h("p", null, "扫码只会新增一个机器人,已接入的机器人会继续正常收发消息。"),
|
? "刷新二维码后继续"
|
||||||
|
: repairing ? "使用飞书扫码修复卡片按钮" : "使用飞书扫码创建机器人"),
|
||||||
|
h("p", null, repairing
|
||||||
|
? "扫码会更新现有飞书应用,只增量补充卡片按钮回调;不会创建新应用。确认后此机器人会短暂重连,其他机器人不受影响。"
|
||||||
|
: "扫码只会新增一个机器人,已接入的机器人会继续正常收发消息。"),
|
||||||
h("ol", { className: "bxf-steps dim-steps" },
|
h("ol", { className: "bxf-steps dim-steps" },
|
||||||
h("li", null, "打开飞书移动端,使用扫一扫读取二维码"),
|
h("li", null, "打开飞书移动端,使用扫一扫读取二维码"),
|
||||||
h("li", null, "核对应用名称与权限范围,并确认创建"),
|
h("li", null, repairing
|
||||||
h("li", null, "保持本页打开,等待新机器人的长连接就绪")),
|
? "核对现有应用名称,并确认只新增卡片回调"
|
||||||
|
: "核对应用名称与权限范围,并确认创建"),
|
||||||
|
h("li", null, repairing
|
||||||
|
? "保持本页打开,等待卡片按钮修复完成"
|
||||||
|
: "保持本页打开,等待新机器人的长连接就绪")),
|
||||||
h("div", { className: "bxf-actions dim-viewActions" },
|
h("div", { className: "bxf-actions dim-viewActions" },
|
||||||
expired
|
expired
|
||||||
? h(Button, {
|
? h(Button, {
|
||||||
|
|
@ -272,35 +302,43 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
|
||||||
!expired
|
!expired
|
||||||
? h(Button, { onClick: onRefresh, disabled: busy }, "换一个二维码")
|
? h(Button, { onClick: onRefresh, disabled: busy }, "换一个二维码")
|
||||||
: null,
|
: null,
|
||||||
h(Button, { onClick: onCancel, disabled: busy }, "取消添加")),
|
h(Button, { onClick: onCancel, disabled: busy }, repairing ? "取消修复" : "取消添加")),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProvisionProgress({ phase, onCancel, busy }) {
|
function ProvisionProgress({ phase, provision, onCancel, busy }) {
|
||||||
const connecting = phase === "connecting";
|
const connecting = phase === "connecting";
|
||||||
|
const repairing = isCallbackRepair(provision);
|
||||||
return h("div", {
|
return h("div", {
|
||||||
className: "bxf-card bxf-provisionCard dim-surfaceCard dim-loadingView",
|
className: "bxf-card bxf-provisionCard dim-surfaceCard dim-loadingView",
|
||||||
"aria-busy": "true",
|
"aria-busy": "true",
|
||||||
},
|
},
|
||||||
h("div", { className: "dim-spinner", "aria-hidden": "true" }),
|
h("div", { className: "dim-spinner", "aria-hidden": "true" }),
|
||||||
h("h3", null, connecting ? "已确认,正在连接新机器人" : "正在准备授权二维码"),
|
h("h3", null, connecting
|
||||||
|
? repairing ? "已确认,正在完成卡片按钮修复" : "已确认,正在连接新机器人"
|
||||||
|
: repairing ? "正在准备修复二维码" : "正在准备授权二维码"),
|
||||||
h("p", null, connecting
|
h("p", null, connecting
|
||||||
? "正在安全保存凭据并检查新机器人的消息通道,其他机器人不会中断。"
|
? repairing
|
||||||
: "正在向飞书申请一次性授权二维码,请稍候。"),
|
? "配置已提交,正在验证卡片按钮回调并重连此机器人;此阶段无法取消,其他机器人不会中断。"
|
||||||
connecting
|
: "正在安全保存凭据并检查新机器人的消息通道,其他机器人不会中断。"
|
||||||
|
: repairing
|
||||||
|
? "正在为现有飞书应用申请一次性更新二维码,请稍候。"
|
||||||
|
: "正在向飞书申请一次性授权二维码,请稍候。"),
|
||||||
|
connecting && onCancel
|
||||||
? h("div", { className: "bxf-actions dim-viewActions", style: { justifyContent: "center" } },
|
? h("div", { className: "bxf-actions dim-viewActions", style: { justifyContent: "center" } },
|
||||||
h(Button, { onClick: onCancel, disabled: busy }, "取消添加"))
|
h(Button, { onClick: onCancel, disabled: busy }, repairing ? "取消修复" : "取消添加"))
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProvisionError({ error, onRetry, onCancel, busy }) {
|
function ProvisionError({ error, provision, onRetry, onCancel, busy }) {
|
||||||
|
const repairing = isCallbackRepair(provision);
|
||||||
return h("div", { className: "bxf-card bxf-provisionCard dim-surfaceCard" },
|
return h("div", { className: "bxf-card bxf-provisionCard dim-surfaceCard" },
|
||||||
h("div", { className: "bxf-inlineError dim-inlineError", role: "alert" },
|
h("div", { className: "bxf-inlineError dim-inlineError", role: "alert" },
|
||||||
h("div", null,
|
h("div", null,
|
||||||
h("h3", null, "新机器人没有添加完成"),
|
h("h3", null, repairing ? "卡片按钮没有修复完成" : "新机器人没有添加完成"),
|
||||||
h("p", null, error.message),
|
h("p", null, error.message),
|
||||||
error.code ? h("span", { className: "bxf-errorCode" }, error.code) : null,
|
error.code ? h("span", { className: "bxf-errorCode" }, error.code) : null,
|
||||||
h("div", { className: "bxf-actions dim-viewActions" },
|
h("div", { className: "bxf-actions dim-viewActions" },
|
||||||
|
|
@ -373,10 +411,12 @@ function RemoveConfirmation({ bot, busy, onConfirm, onCancel }) {
|
||||||
export function BotCard({
|
export function BotCard({
|
||||||
connection,
|
connection,
|
||||||
busy,
|
busy,
|
||||||
|
repairDisabled,
|
||||||
actionError,
|
actionError,
|
||||||
testNotice,
|
testNotice,
|
||||||
removing,
|
removing,
|
||||||
onReconnect,
|
onReconnect,
|
||||||
|
onRepairCallback,
|
||||||
onWorkspaceSave,
|
onWorkspaceSave,
|
||||||
onRequestRemove,
|
onRequestRemove,
|
||||||
onConfirmRemove,
|
onConfirmRemove,
|
||||||
|
|
@ -441,6 +481,13 @@ export function BotCard({
|
||||||
disabled: Boolean(busy), "aria-busy": busy === "reconnect" ? "true" : undefined,
|
disabled: Boolean(busy), "aria-busy": busy === "reconnect" ? "true" : undefined,
|
||||||
"aria-label": `${connected ? "检查连接" : "重试连接"}${bot.name}`,
|
"aria-label": `${connected ? "检查连接" : "重试连接"}${bot.name}`,
|
||||||
}, busy === "reconnect" ? (connected ? "检查中…" : "正在连接…") : connected ? "检查连接" : "重试连接"),
|
}, busy === "reconnect" ? (connected ? "检查中…" : "正在连接…") : connected ? "检查连接" : "重试连接"),
|
||||||
|
h(Button, {
|
||||||
|
className: "bxf-repairButton dim-cardAction",
|
||||||
|
onClick: onRepairCallback,
|
||||||
|
disabled: Boolean(busy) || repairDisabled,
|
||||||
|
"aria-busy": busy === "callback-repair" ? "true" : undefined,
|
||||||
|
"aria-label": `修复${bot.name}的卡片按钮`,
|
||||||
|
}, busy === "callback-repair" ? "等待扫码…" : "修复卡片按钮"),
|
||||||
h(Button, {
|
h(Button, {
|
||||||
className: "dim-cardAction", kind: "danger", onClick: onRequestRemove,
|
className: "dim-cardAction", kind: "danger", onClick: onRequestRemove,
|
||||||
disabled: Boolean(busy), ref: removeButtonRef,
|
disabled: Boolean(busy), ref: removeButtonRef,
|
||||||
|
|
@ -467,11 +514,15 @@ function BotList(props) {
|
||||||
props.bots.map((bot) => h("li", { key: bot.botId },
|
props.bots.map((bot) => h("li", { key: bot.botId },
|
||||||
h(BotCard, {
|
h(BotCard, {
|
||||||
connection: bot,
|
connection: bot,
|
||||||
busy: props.busyByBot[bot.botId],
|
busy: props.busyByBot[bot.botId]
|
||||||
|
?? (isCallbackRepair(props.provisioning)
|
||||||
|
&& props.provisioning.botId === bot.botId ? "callback-repair" : undefined),
|
||||||
|
repairDisabled: Boolean(props.provisioning),
|
||||||
actionError: props.errorsByBot[bot.botId],
|
actionError: props.errorsByBot[bot.botId],
|
||||||
testNotice: props.testNoticesByBot[bot.botId],
|
testNotice: props.testNoticesByBot[bot.botId],
|
||||||
removing: props.removeTargetId === bot.botId,
|
removing: props.removeTargetId === bot.botId,
|
||||||
onReconnect: () => props.onReconnect(bot),
|
onReconnect: () => props.onReconnect(bot),
|
||||||
|
onRepairCallback: () => props.onRepairCallback(bot),
|
||||||
onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
|
onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
|
||||||
onRequestRemove: () => props.onRequestRemove(bot),
|
onRequestRemove: () => props.onRequestRemove(bot),
|
||||||
onConfirmRemove: () => props.onConfirmRemove(bot),
|
onConfirmRemove: () => props.onConfirmRemove(bot),
|
||||||
|
|
@ -507,11 +558,12 @@ export function mergeFeishuSnapshotState(
|
||||||
if (snapshot.revision > 0 && current.revision > snapshot.revision) return current;
|
if (snapshot.revision > 0 && current.revision > snapshot.revision) return current;
|
||||||
let provisioning = current.provisioning;
|
let provisioning = current.provisioning;
|
||||||
if (!provisioning && restoreProvisioning && snapshot.provisioning) {
|
if (!provisioning && restoreProvisioning && snapshot.provisioning) {
|
||||||
|
const submitted = snapshot.provisioning.submitted === true;
|
||||||
provisioning = {
|
provisioning = {
|
||||||
phase: snapshot.state === "connecting" ? "connecting" : "qr",
|
phase: submitted || snapshot.state === "connecting" ? "connecting" : "qr",
|
||||||
...snapshot.provisioning,
|
...snapshot.provisioning,
|
||||||
durationMs: Math.max(1, snapshot.provisioning.expiresAt - now),
|
durationMs: Math.max(1, snapshot.provisioning.expiresAt - now),
|
||||||
expired: snapshot.provisioning.expiresAt <= now,
|
expired: !submitted && snapshot.provisioning.expiresAt <= now,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -640,7 +692,15 @@ export function FeishuSettingsTab({ rpcCall }) {
|
||||||
setFocusBotId(null);
|
setFocusBotId(null);
|
||||||
}, [focusBotId, model.bots]);
|
}, [focusBotId, model.bots]);
|
||||||
|
|
||||||
const startProvisioning = React.useCallback(async ({ replace = false } = {}) => {
|
const startProvisioning = React.useCallback(async ({
|
||||||
|
replace = false,
|
||||||
|
operation = FEISHU_REGISTRATION_OPERATIONS.PROVISION,
|
||||||
|
bot,
|
||||||
|
} = {}) => {
|
||||||
|
const repairing = operation === CALLBACK_REPAIR_OPERATION;
|
||||||
|
const botId = repairing ? bot?.botId ?? model.provisioning?.botId : undefined;
|
||||||
|
const botName = repairing ? bot?.bot?.name ?? model.provisioning?.botName : undefined;
|
||||||
|
if (repairing && !botId) return;
|
||||||
setCredentialOpen(false);
|
setCredentialOpen(false);
|
||||||
setCredentialError(null);
|
setCredentialError(null);
|
||||||
setProvisionBusy(true);
|
setProvisionBusy(true);
|
||||||
|
|
@ -649,16 +709,33 @@ export function FeishuSettingsTab({ rpcCall }) {
|
||||||
setModel((current) => ({
|
setModel((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: current.phase === "loading" ? "ready" : current.phase,
|
phase: current.phase === "loading" ? "ready" : current.phase,
|
||||||
provisioning: { phase: "creating" },
|
provisioning: {
|
||||||
|
phase: "creating",
|
||||||
|
operation,
|
||||||
|
...(botId ? { botId } : {}),
|
||||||
|
...(botName ? { botName } : {}),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
if (replace && previousAttemptId) {
|
if (replace && previousAttemptId) {
|
||||||
await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId: previousAttemptId });
|
// A Host restart intentionally drops its in-memory registration map.
|
||||||
|
// Replacing a stale browser attempt must still be able to start a new
|
||||||
|
// authoritative attempt; both controller start paths already
|
||||||
|
// supersede/deduplicate a still-live registration safely.
|
||||||
|
try {
|
||||||
|
await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId: previousAttemptId });
|
||||||
|
} catch {
|
||||||
|
// Continue with begin. It is the source of truth for the new attempt.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const provision = normalizeProvisioning(await invoke(
|
const provision = normalizeProvisioning(await invoke(
|
||||||
FEISHU_ENDPOINTS.beginProvisioning,
|
repairing ? FEISHU_ENDPOINTS.beginCallbackRepair : FEISHU_ENDPOINTS.beginProvisioning,
|
||||||
{ locale: "zh-CN" },
|
repairing ? { botId } : { locale: "zh-CN" },
|
||||||
));
|
));
|
||||||
|
if (repairing
|
||||||
|
&& (provision.operation !== CALLBACK_REPAIR_OPERATION || provision.botId !== botId)) {
|
||||||
|
throw new Error("飞书服务返回了不匹配的卡片修复二维码");
|
||||||
|
}
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
setNow(timestamp);
|
setNow(timestamp);
|
||||||
setModel((current) => ({
|
setModel((current) => ({
|
||||||
|
|
@ -666,20 +743,36 @@ export function FeishuSettingsTab({ rpcCall }) {
|
||||||
provisioning: {
|
provisioning: {
|
||||||
phase: "qr",
|
phase: "qr",
|
||||||
...provision,
|
...provision,
|
||||||
|
...(botName ? { botName } : {}),
|
||||||
durationMs: Math.max(1, provision.expiresAt - timestamp),
|
durationMs: Math.max(1, provision.expiresAt - timestamp),
|
||||||
expired: false,
|
expired: false,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
announce("授权二维码已生成,请使用飞书扫码。");
|
announce(repairing
|
||||||
|
? `${botName ?? "机器人"}的修复二维码已生成,请使用飞书扫码。`
|
||||||
|
: "授权二维码已生成,请使用飞书扫码。");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setModel((current) => ({
|
setModel((current) => ({
|
||||||
...current,
|
...current,
|
||||||
provisioning: { phase: "error", error: presentError(error) },
|
provisioning: {
|
||||||
|
phase: "error",
|
||||||
|
operation,
|
||||||
|
...(botId ? { botId } : {}),
|
||||||
|
...(botName ? { botName } : {}),
|
||||||
|
...(replace && previousAttemptId ? { attemptId: previousAttemptId } : {}),
|
||||||
|
error: presentError(error),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
} finally {
|
} finally {
|
||||||
setProvisionBusy(false);
|
setProvisionBusy(false);
|
||||||
}
|
}
|
||||||
}, [announce, invoke, model.provisioning?.attemptId]);
|
}, [
|
||||||
|
announce,
|
||||||
|
invoke,
|
||||||
|
model.provisioning?.attemptId,
|
||||||
|
model.provisioning?.botId,
|
||||||
|
model.provisioning?.botName,
|
||||||
|
]);
|
||||||
|
|
||||||
const bindCredentials = React.useCallback(async ({ identity, secret }) => {
|
const bindCredentials = React.useCallback(async ({ identity, secret }) => {
|
||||||
const snapshotVersion = workspaceFence.beginMutation();
|
const snapshotVersion = workspaceFence.beginMutation();
|
||||||
|
|
@ -705,23 +798,71 @@ export function FeishuSettingsTab({ rpcCall }) {
|
||||||
}, [announce, invoke, loadStatus, mergeSnapshot, workspaceFence]);
|
}, [announce, invoke, loadStatus, mergeSnapshot, workspaceFence]);
|
||||||
|
|
||||||
const cancelProvisioning = React.useCallback(async () => {
|
const cancelProvisioning = React.useCallback(async () => {
|
||||||
const attemptId = model.provisioning?.attemptId;
|
const activeProvision = model.provisioning;
|
||||||
|
const attemptId = activeProvision?.attemptId;
|
||||||
|
const repairing = isCallbackRepair(activeProvision);
|
||||||
|
const targetBot = repairing
|
||||||
|
? model.bots.find((bot) => bot.botId === activeProvision?.botId)
|
||||||
|
: undefined;
|
||||||
setProvisionBusy(true);
|
setProvisionBusy(true);
|
||||||
try {
|
try {
|
||||||
if (attemptId) await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId });
|
const result = attemptId
|
||||||
|
? normalizePollResult(await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId }))
|
||||||
|
: null;
|
||||||
|
if (repairing && result) {
|
||||||
|
if (result.operation !== CALLBACK_REPAIR_OPERATION
|
||||||
|
|| result.botId !== activeProvision.botId) {
|
||||||
|
throw new Error("飞书服务返回了不匹配的注册进度");
|
||||||
|
}
|
||||||
|
if (result.status === "connecting") {
|
||||||
|
setModel((current) => current.provisioning?.attemptId === attemptId
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
provisioning: {
|
||||||
|
...current.provisioning,
|
||||||
|
...(result.provisioning ?? {}),
|
||||||
|
phase: "connecting",
|
||||||
|
submitted: true,
|
||||||
|
expired: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: current);
|
||||||
|
announce("配置已提交,正在验证卡片按钮回调并重连此机器人;此阶段无法取消,其他机器人不会中断。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.status === "connected") {
|
||||||
|
const targetBotName = targetBot?.bot.name ?? activeProvision.botName ?? "机器人";
|
||||||
|
setModel((current) => ({ ...current, provisioning: null }));
|
||||||
|
announce(`${targetBotName}的卡片按钮已修复。`);
|
||||||
|
if (activeProvision.botId) setFocusBotId(activeProvision.botId);
|
||||||
|
await loadStatus({ silent: true, restoreProvisioning: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
setModel((current) => ({ ...current, provisioning: null }));
|
setModel((current) => ({ ...current, provisioning: null }));
|
||||||
announce("已取消添加机器人。");
|
announce(repairing ? "已取消卡片按钮修复。" : "已取消添加机器人。");
|
||||||
await loadStatus({ silent: true, restoreProvisioning: false });
|
await loadStatus({ silent: true, restoreProvisioning: false });
|
||||||
scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus");
|
scheduleAnimationFrame(() => {
|
||||||
|
if (repairing && activeProvision.botId) {
|
||||||
|
cardRefs.current.get(activeProvision.botId)?.focus();
|
||||||
|
} else {
|
||||||
|
addButtonRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, "focus");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setModel((current) => ({
|
setModel((current) => ({
|
||||||
...current,
|
...current,
|
||||||
provisioning: { phase: "error", attemptId, error: presentError(error) },
|
provisioning: {
|
||||||
|
...activeProvision,
|
||||||
|
phase: "error",
|
||||||
|
attemptId,
|
||||||
|
error: presentError(error),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
} finally {
|
} finally {
|
||||||
setProvisionBusy(false);
|
setProvisionBusy(false);
|
||||||
}
|
}
|
||||||
}, [announce, invoke, loadStatus, model.provisioning?.attemptId, scheduleAnimationFrame]);
|
}, [announce, invoke, loadStatus, model.bots, model.provisioning, scheduleAnimationFrame]);
|
||||||
|
|
||||||
const countdownAttemptId = model.provisioning?.attemptId;
|
const countdownAttemptId = model.provisioning?.attemptId;
|
||||||
const countdownPhase = model.provisioning?.phase;
|
const countdownPhase = model.provisioning?.phase;
|
||||||
|
|
@ -758,27 +899,36 @@ export function FeishuSettingsTab({ rpcCall }) {
|
||||||
{ attemptId: provision.attemptId },
|
{ attemptId: provision.attemptId },
|
||||||
controller.signal,
|
controller.signal,
|
||||||
));
|
));
|
||||||
|
if (result.operation !== provision.operation
|
||||||
|
|| (isCallbackRepair(provision) && result.botId !== provision.botId)) {
|
||||||
|
throw new Error("飞书服务返回了不匹配的注册进度");
|
||||||
|
}
|
||||||
if (result.status === "connected") {
|
if (result.status === "connected") {
|
||||||
const snapshot = await loadStatus({ signal: controller.signal, silent: true, restoreProvisioning: false });
|
const snapshot = await loadStatus({ signal: controller.signal, silent: true, restoreProvisioning: false });
|
||||||
const newBot = snapshot?.bots.find((bot) => bot.botId === result.botId);
|
const targetBot = snapshot?.bots.find((bot) => bot.botId === result.botId);
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
throw new Error("机器人已经创建,但暂时无法确认连接状态");
|
throw new Error(isCallbackRepair(provision)
|
||||||
|
? "卡片按钮已更新,但暂时无法确认机器人连接状态"
|
||||||
|
: "机器人已经创建,但暂时无法确认连接状态");
|
||||||
}
|
}
|
||||||
if (!newBot?.connected) {
|
if (!targetBot?.connected) {
|
||||||
setModel((current) => current.provisioning?.attemptId === provision.attemptId
|
setModel((current) => current.provisioning?.attemptId === provision.attemptId
|
||||||
? { ...current, provisioning: { ...current.provisioning, phase: "connecting" } }
|
? { ...current, provisioning: { ...current.provisioning, phase: "connecting" } }
|
||||||
: current);
|
: current);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setModel((current) => ({ ...current, provisioning: null }));
|
setModel((current) => ({ ...current, provisioning: null }));
|
||||||
announce(newBot
|
announce(isCallbackRepair(provision)
|
||||||
? `${newBot.bot.name}已连接,可以在飞书中开始聊天。`
|
? `${targetBot.bot.name}的卡片按钮已修复。`
|
||||||
: "新飞书机器人已连接,可以开始聊天。");
|
: targetBot
|
||||||
|
? `${targetBot.bot.name}已连接,可以在飞书中开始聊天。`
|
||||||
|
: "新飞书机器人已连接,可以开始聊天。");
|
||||||
if (result.botId) setFocusBotId(result.botId);
|
if (result.botId) setFocusBotId(result.botId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.status === "failed") {
|
if (result.status === "failed") {
|
||||||
const error = new Error(result.message ?? "飞书应用创建失败");
|
const error = new Error(result.message
|
||||||
|
?? (isCallbackRepair(provision) ? "飞书卡片按钮修复失败" : "飞书应用创建失败"));
|
||||||
error.code = "FEISHU_PROVISION_FAILED";
|
error.code = "FEISHU_PROVISION_FAILED";
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -806,6 +956,7 @@ export function FeishuSettingsTab({ rpcCall }) {
|
||||||
? {
|
? {
|
||||||
...current,
|
...current,
|
||||||
provisioning: {
|
provisioning: {
|
||||||
|
...current.provisioning,
|
||||||
phase: "error",
|
phase: "error",
|
||||||
attemptId: provision.attemptId,
|
attemptId: provision.attemptId,
|
||||||
error: presentError(error),
|
error: presentError(error),
|
||||||
|
|
@ -838,6 +989,21 @@ export function FeishuSettingsTab({ rpcCall }) {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const repairCallback = React.useCallback((connection) => {
|
||||||
|
if (model.provisioning) return;
|
||||||
|
setRemoveTargetId(null);
|
||||||
|
setBotError(connection.botId, null);
|
||||||
|
setTestNoticesByBot((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[connection.botId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
void startProvisioning({
|
||||||
|
operation: CALLBACK_REPAIR_OPERATION,
|
||||||
|
bot: connection,
|
||||||
|
});
|
||||||
|
}, [model.provisioning, setBotError, startProvisioning]);
|
||||||
|
|
||||||
const reconnectOneBot = React.useCallback(async (connection) => {
|
const reconnectOneBot = React.useCallback(async (connection) => {
|
||||||
const { botId, bot } = connection;
|
const { botId, bot } = connection;
|
||||||
const snapshotVersion = workspaceFence.beginMutation();
|
const snapshotVersion = workspaceFence.beginMutation();
|
||||||
|
|
@ -938,27 +1104,48 @@ export function FeishuSettingsTab({ rpcCall }) {
|
||||||
}, [announce, invoke, loadStatus, mergeSnapshot, scheduleAnimationFrame, setBotBusy, setBotError, workspaceFence]);
|
}, [announce, invoke, loadStatus, mergeSnapshot, scheduleAnimationFrame, setBotBusy, setBotError, workspaceFence]);
|
||||||
|
|
||||||
const provision = model.provisioning;
|
const provision = model.provisioning;
|
||||||
|
const provisionBot = provision?.botId
|
||||||
|
? model.bots.find((bot) => bot.botId === provision.botId)
|
||||||
|
?? { botId: provision.botId, bot: { name: provision.botName ?? "此机器人" } }
|
||||||
|
: undefined;
|
||||||
|
const restartProvisioning = ({ replace = false } = {}) => startProvisioning({
|
||||||
|
replace,
|
||||||
|
operation: provision?.operation ?? FEISHU_REGISTRATION_OPERATIONS.PROVISION,
|
||||||
|
bot: provisionBot,
|
||||||
|
});
|
||||||
let provisionContent = null;
|
let provisionContent = null;
|
||||||
if (provision?.phase === "creating") {
|
if (provision?.phase === "creating") {
|
||||||
provisionContent = h(ProvisionProgress, { phase: "creating", busy: provisionBusy });
|
provisionContent = h(ProvisionProgress, {
|
||||||
|
phase: "creating", provision, busy: provisionBusy,
|
||||||
|
});
|
||||||
} else if (provision?.phase === "qr") {
|
} else if (provision?.phase === "qr") {
|
||||||
provisionContent = h(QrPane, {
|
provisionContent = h(QrPane, {
|
||||||
provision, now,
|
provision, now,
|
||||||
onRefresh: () => void startProvisioning({ replace: true }),
|
onRefresh: () => void restartProvisioning({ replace: true }),
|
||||||
onCancel: () => void cancelProvisioning(),
|
onCancel: () => void cancelProvisioning(),
|
||||||
busy: provisionBusy || model.phase !== "ready",
|
busy: provisionBusy || model.phase !== "ready",
|
||||||
});
|
});
|
||||||
} else if (provision?.phase === "connecting") {
|
} else if (provision?.phase === "connecting") {
|
||||||
provisionContent = h(ProvisionProgress, {
|
provisionContent = h(ProvisionProgress, {
|
||||||
phase: "connecting",
|
phase: "connecting",
|
||||||
onCancel: () => void cancelProvisioning(),
|
provision,
|
||||||
|
onCancel: isCallbackRepair(provision) ? undefined : () => void cancelProvisioning(),
|
||||||
busy: provisionBusy,
|
busy: provisionBusy,
|
||||||
});
|
});
|
||||||
} else if (provision?.phase === "error") {
|
} else if (provision?.phase === "error") {
|
||||||
provisionContent = h(ProvisionError, {
|
provisionContent = h(ProvisionError, {
|
||||||
error: provision.error,
|
error: provision.error,
|
||||||
onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }),
|
provision,
|
||||||
onCancel: () => void cancelProvisioning(),
|
onRetry: () => void restartProvisioning({ replace: Boolean(provision.attemptId) }),
|
||||||
|
onCancel: () => {
|
||||||
|
const targetBotId = provision.botId;
|
||||||
|
setModel((current) => ({ ...current, provisioning: null }));
|
||||||
|
void loadStatus({ silent: true, restoreProvisioning: false });
|
||||||
|
scheduleAnimationFrame(() => {
|
||||||
|
if (targetBotId) cardRefs.current.get(targetBotId)?.focus();
|
||||||
|
else addButtonRef.current?.focus();
|
||||||
|
}, "focus");
|
||||||
|
},
|
||||||
busy: provisionBusy,
|
busy: provisionBusy,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1026,7 +1213,9 @@ export function FeishuSettingsTab({ rpcCall }) {
|
||||||
errorsByBot,
|
errorsByBot,
|
||||||
testNoticesByBot,
|
testNoticesByBot,
|
||||||
removeTargetId,
|
removeTargetId,
|
||||||
|
provisioning: provision,
|
||||||
onReconnect: (bot) => void reconnectOneBot(bot),
|
onReconnect: (bot) => void reconnectOneBot(bot),
|
||||||
|
onRepairCallback: repairCallback,
|
||||||
onWorkspaceSave: saveWorkspace,
|
onWorkspaceSave: saveWorkspace,
|
||||||
onRequestRemove: requestRemove,
|
onRequestRemove: requestRemove,
|
||||||
onConfirmRemove: (bot) => void confirmRemove(bot),
|
onConfirmRemove: (bot) => void confirmRemove(bot),
|
||||||
|
|
|
||||||
|
|
@ -412,6 +412,8 @@ const CSS = String.raw`
|
||||||
.bxf-healthSummary[data-error="true"] { color: var(--bxf-error); }
|
.bxf-healthSummary[data-error="true"] { color: var(--bxf-error); }
|
||||||
.bxf-botActions { flex: none; flex-wrap: nowrap; gap: 8px; margin-top: 0; justify-content: flex-end; }
|
.bxf-botActions { flex: none; flex-wrap: nowrap; gap: 8px; margin-top: 0; justify-content: flex-end; }
|
||||||
.bxf-botActions .bxf-button { flex: none; white-space: nowrap; }
|
.bxf-botActions .bxf-button { flex: none; white-space: nowrap; }
|
||||||
|
.bxf-botActions .bxf-repairButton { color: var(--bxf-accent); border-color: color-mix(in srgb, var(--bxf-accent) 35%, var(--dsw-alias-border-l2, #dee0e3)); }
|
||||||
|
.bxf-botActions .bxf-repairButton:hover:not(:disabled) { background: color-mix(in srgb, var(--bxf-accent) 7%, transparent); }
|
||||||
|
|
||||||
.bxf-confirm {
|
.bxf-confirm {
|
||||||
border-top: 1px solid var(--dsw-alias-border-l2, #dee0e3);
|
border-top: 1px solid var(--dsw-alias-border-l2, #dee0e3);
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,27 @@ const EN = Object.freeze({
|
||||||
'保持本页打开,等待新机器人的长连接就绪': 'Keep this page open until the bot connection is ready',
|
'保持本页打开,等待新机器人的长连接就绪': 'Keep this page open until the bot connection is ready',
|
||||||
'在飞书中打开': 'Open in Feishu',
|
'在飞书中打开': 'Open in Feishu',
|
||||||
'取消添加': 'Cancel',
|
'取消添加': 'Cancel',
|
||||||
|
'使用飞书扫码修复卡片按钮': 'Scan with Feishu to repair card buttons',
|
||||||
|
'扫码会更新现有飞书应用,只增量补充卡片按钮回调;不会创建新应用。确认后此机器人会短暂重连,其他机器人不受影响。': 'Scanning updates the existing Feishu app with only the card-button callback. It does not create a new app. This bot reconnects briefly after confirmation; other bots are not affected.',
|
||||||
|
'核对现有应用名称,并确认只新增卡片回调': 'Review the existing app name and confirm that only the card callback is added',
|
||||||
|
'保持本页打开,等待卡片按钮修复完成': 'Keep this page open until card-button repair finishes',
|
||||||
|
'取消修复': 'Cancel repair',
|
||||||
|
'已确认,正在完成卡片按钮修复': 'Confirmed. Finishing card-button repair',
|
||||||
|
'正在准备修复二维码': 'Preparing the repair QR code',
|
||||||
|
'配置已提交,正在验证卡片按钮回调并重连此机器人;此阶段无法取消,其他机器人不会中断。': 'The update was submitted. Verifying the card callback and reconnecting this bot. This stage cannot be cancelled; other bots will not be interrupted.',
|
||||||
|
'正在为现有飞书应用申请一次性更新二维码,请稍候。': 'Requesting a one-time update QR code for the existing Feishu app…',
|
||||||
|
'卡片按钮没有修复完成': 'Card-button repair did not finish',
|
||||||
|
'修复卡片按钮': 'Repair card buttons',
|
||||||
|
'等待扫码…': 'Waiting for scan…',
|
||||||
|
'飞书服务返回了不匹配的卡片修复二维码': 'Feishu returned a repair QR code for a different bot',
|
||||||
|
'飞书服务返回的修复信息缺少 botId': 'Feishu repair status is missing the bot ID',
|
||||||
|
'飞书服务返回了不匹配的注册进度': 'Feishu returned registration progress for a different operation',
|
||||||
|
'此机器人': 'this bot',
|
||||||
|
'${botName ?? "机器人"}的修复二维码已生成,请使用飞书扫码。': 'Repair QR code generated for ${botName ?? "bot"}. Scan it with Feishu.',
|
||||||
|
'${targetBot.bot.name}已连接,可以在飞书中开始聊天。': '${targetBot.bot.name} is connected and ready to chat in Feishu.',
|
||||||
|
'已取消卡片按钮修复。': 'Card-button repair was cancelled.',
|
||||||
|
'卡片按钮已更新,但暂时无法确认机器人连接状态': 'The card callback was updated, but the bot connection could not be confirmed yet',
|
||||||
|
'飞书卡片按钮修复失败': 'Could not repair the Feishu card buttons',
|
||||||
'已确认,正在连接新机器人': 'Confirmed. Connecting the new bot',
|
'已确认,正在连接新机器人': 'Confirmed. Connecting the new bot',
|
||||||
'正在安全保存凭据并检查新机器人的消息通道,其他机器人不会中断。': 'Saving credentials and checking the new bot connection. Existing bots will not be interrupted.',
|
'正在安全保存凭据并检查新机器人的消息通道,其他机器人不会中断。': 'Saving credentials and checking the new bot connection. Existing bots will not be interrupted.',
|
||||||
'正在向飞书申请一次性授权二维码,请稍候。': 'Requesting a one-time authorization QR code from Feishu…',
|
'正在向飞书申请一次性授权二维码,请稍候。': 'Requesting a one-time authorization QR code from Feishu…',
|
||||||
|
|
@ -481,6 +502,16 @@ function translateDynamic(text) {
|
||||||
if (match) return `Remove “${match[1]}” from DeepSeek Harness?`;
|
if (match) return `Remove “${match[1]}” from DeepSeek Harness?`;
|
||||||
match = /^从 DeepSeek Harness 移除(.+)$/.exec(text);
|
match = /^从 DeepSeek Harness 移除(.+)$/.exec(text);
|
||||||
if (match) return `Remove ${match[1]} from DeepSeek Harness`;
|
if (match) return `Remove ${match[1]} from DeepSeek Harness`;
|
||||||
|
match = /^用于修复(.+)卡片按钮的一次性授权二维码$/.exec(text);
|
||||||
|
if (match) return `One-time QR code for repairing card buttons for ${match[1]}`;
|
||||||
|
match = /^正在修复「(.+)」$/.exec(text);
|
||||||
|
if (match) return `Repairing “${match[1]}”`;
|
||||||
|
match = /^修复(.+)的卡片按钮$/.exec(text);
|
||||||
|
if (match) return `Repair card buttons for ${match[1]}`;
|
||||||
|
match = /^(.+)的修复二维码已生成,请使用飞书扫码。$/.exec(text);
|
||||||
|
if (match) return `Repair QR code generated for ${match[1]}. Scan it with Feishu.`;
|
||||||
|
match = /^(.+)的卡片按钮已修复。$/.exec(text);
|
||||||
|
if (match) return `Card buttons repaired for ${match[1]}.`;
|
||||||
match = /^(检查连接|重试连接)(.+)$/.exec(text);
|
match = /^(检查连接|重试连接)(.+)$/.exec(text);
|
||||||
if (match) return `${localizeText(match[1])} ${match[2]}`;
|
if (match) return `${localizeText(match[1])} ${match[2]}`;
|
||||||
match = /^移除(.+)$/.exec(text);
|
match = /^移除(.+)$/.exec(text);
|
||||||
|
|
|
||||||
|
|
@ -130,13 +130,15 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
verifyApp,
|
verifyApp,
|
||||||
credentials: ctx.credentials,
|
credentials: ctx.credentials,
|
||||||
configStore: observedConfigStore,
|
configStore: observedConfigStore,
|
||||||
createRuntime: async ({ botId, config: botConfig, appSecret }) => {
|
createRuntime: async ({ botId, config: botConfig, appSecret, repair }) => {
|
||||||
const state = await stateFor(botConfig);
|
const state = await stateFor(botConfig);
|
||||||
const id = botId ?? botConfig.id ?? botConfig.appId;
|
const id = botId ?? botConfig.id ?? botConfig.appId;
|
||||||
await workspaces.ensure(id);
|
await workspaces.ensure(id);
|
||||||
const workspaceScope = createBotWorkspaceScope(harness, { botId: id, workspaces, state });
|
const workspaceScope = createBotWorkspaceScope(harness, { botId: id, workspaces, state });
|
||||||
return new Runtime({
|
return new Runtime({
|
||||||
lark,
|
lark,
|
||||||
|
botId: id,
|
||||||
|
repair,
|
||||||
appId: botConfig.appId,
|
appId: botConfig.appId,
|
||||||
appSecret,
|
appSecret,
|
||||||
domain: botConfig.domain,
|
domain: botConfig.domain,
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,16 @@ const REGISTRATION_STATES = new Set([
|
||||||
'idle', 'starting', 'qr_ready', 'polling', 'slow_down',
|
'idle', 'starting', 'qr_ready', 'polling', 'slow_down',
|
||||||
'domain_switched', 'saving', 'succeeded', 'expired', 'cancelled', 'error',
|
'domain_switched', 'saving', 'succeeded', 'expired', 'cancelled', 'error',
|
||||||
]);
|
]);
|
||||||
|
const REGISTRATION_OPERATIONS = new Set(['provision', 'callback_repair']);
|
||||||
|
const CALLBACK_REPAIR_OPERATION = 'callback_repair';
|
||||||
|
const OFFICIAL_REGISTRATION_HOSTS = new Set([
|
||||||
|
'accounts.feishu.cn',
|
||||||
|
'accounts.larksuite.com',
|
||||||
|
'open.feishu.cn',
|
||||||
|
'open.larksuite.com',
|
||||||
|
]);
|
||||||
const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
||||||
|
const SAFE_FEISHU_APP_ID = /^cli_[A-Za-z0-9_-]+$/;
|
||||||
|
|
||||||
const PUBLIC_ERROR_MESSAGES = Object.freeze({
|
const PUBLIC_ERROR_MESSAGES = Object.freeze({
|
||||||
abort: 'Registration was cancelled.',
|
abort: 'Registration was cancelled.',
|
||||||
|
|
@ -35,6 +44,20 @@ const PUBLIC_ERROR_MESSAGES = Object.freeze({
|
||||||
state_cleanup_failed: 'Unable to remove the bot session data. Please retry.',
|
state_cleanup_failed: 'Unable to remove the bot session data. Please retry.',
|
||||||
deletion_pending: 'Bot deletion is incomplete. Retry removal to finish cleanup.',
|
deletion_pending: 'Bot deletion is incomplete. Retry removal to finish cleanup.',
|
||||||
missing_credentials: 'The bot credentials are missing. Delete it and scan again.',
|
missing_credentials: 'The bot credentials are missing. Delete it and scan again.',
|
||||||
|
repair_app_mismatch: 'The authorized Feishu app does not match the selected bot.',
|
||||||
|
repair_owner_missing: 'Feishu did not return the authorizing account identity.',
|
||||||
|
repair_domain_mismatch: 'The authorized Feishu tenant does not match the selected bot.',
|
||||||
|
repair_owner_mismatch: 'The authorizing Feishu account is not an owner of the selected bot.',
|
||||||
|
repair_credentials_invalid: 'Feishu returned credentials that could not be verified for the selected bot.',
|
||||||
|
repair_bot_mismatch: 'The verified Feishu bot does not match the selected bot.',
|
||||||
|
repair_target_changed: 'The selected bot changed while repair was in progress. Start the repair again.',
|
||||||
|
credential_update_failed: 'Unable to store the repaired Feishu credentials.',
|
||||||
|
credential_state_unknown: 'The repaired Feishu credentials could not be confirmed after saving.',
|
||||||
|
repair_connection_failed: 'The callback update was accepted, but the selected bot could not reconnect.',
|
||||||
|
card_action_probe_unavailable: 'The selected bot is not connected, so its card button cannot be verified.',
|
||||||
|
card_action_probe_send_failed: 'The callback update was accepted, but the verification card could not be sent.',
|
||||||
|
card_action_probe_timeout: 'Feishu accepted the update, but the card button was not verified in time. Start the repair again and click the test button within two minutes.',
|
||||||
|
card_action_probe_failed: 'Feishu accepted the update, but the card button verification failed.',
|
||||||
});
|
});
|
||||||
|
|
||||||
const POLL_STATUS_BY_REGISTRATION = Object.freeze({
|
const POLL_STATUS_BY_REGISTRATION = Object.freeze({
|
||||||
|
|
@ -81,6 +104,9 @@ function publicRegistration(registration) {
|
||||||
? registration.attempt
|
? registration.attempt
|
||||||
: (finiteNumber(registration.attempt) ?? 0);
|
: (finiteNumber(registration.attempt) ?? 0);
|
||||||
const result = { state, attempt };
|
const result = { state, attempt };
|
||||||
|
result.operation = REGISTRATION_OPERATIONS.has(registration.operation)
|
||||||
|
? registration.operation
|
||||||
|
: 'provision';
|
||||||
const updatedAt = finiteNumber(registration.updatedAt);
|
const updatedAt = finiteNumber(registration.updatedAt);
|
||||||
const expiresAt = finiteNumber(registration.expiresAt);
|
const expiresAt = finiteNumber(registration.expiresAt);
|
||||||
const remainingSeconds = finiteNumber(registration.remainingSeconds);
|
const remainingSeconds = finiteNumber(registration.remainingSeconds);
|
||||||
|
|
@ -98,6 +124,40 @@ function publicRegistration(registration) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeRegistrationUrl(value, operation, expectedHost) {
|
||||||
|
if (typeof value !== 'string' || value.length === 0) return undefined;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.protocol !== 'https:'
|
||||||
|
|| !OFFICIAL_REGISTRATION_HOSTS.has(url.hostname)
|
||||||
|
|| url.port
|
||||||
|
|| url.username
|
||||||
|
|| url.password) return undefined;
|
||||||
|
if (operation === CALLBACK_REPAIR_OPERATION) {
|
||||||
|
const clientIds = url.searchParams.getAll('clientID');
|
||||||
|
const transportKinds = url.searchParams.getAll('tp');
|
||||||
|
const addons = url.searchParams.getAll('addons');
|
||||||
|
const hasPlaceholder = [...url.searchParams.values()].some((item) => (
|
||||||
|
item.includes('{{') || item.includes('}}')
|
||||||
|
));
|
||||||
|
if (clientIds.length !== 1
|
||||||
|
|| transportKinds.length !== 1
|
||||||
|
|| transportKinds[0] !== 'sdk'
|
||||||
|
|| !SAFE_FEISHU_APP_ID.test(clientIds[0] ?? '')
|
||||||
|
|| url.hostname !== expectedHost
|
||||||
|
|| url.searchParams.has('createOnly')
|
||||||
|
|| addons.length !== 1
|
||||||
|
|| !addons[0]?.trim()
|
||||||
|
|| hasPlaceholder) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function connectionFacts(connection) {
|
function connectionFacts(connection) {
|
||||||
const source = connection && typeof connection === 'object' ? connection : {};
|
const source = connection && typeof connection === 'object' ? connection : {};
|
||||||
const connected = source.connected === true
|
const connected = source.connected === true
|
||||||
|
|
@ -151,15 +211,35 @@ async function qrCodeDataUrl(verificationUrl) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publicProvisioning(registration, encodeQr) {
|
async function publicProvisioning(registration, encodeQr, expectedHost) {
|
||||||
if (!registration.qrCodeUrl) return undefined;
|
const verificationUrl = safeRegistrationUrl(
|
||||||
return {
|
registration.qrCodeUrl,
|
||||||
|
registration.operation,
|
||||||
|
expectedHost,
|
||||||
|
);
|
||||||
|
const projection = {
|
||||||
attemptId: String(registration.attempt),
|
attemptId: String(registration.attempt),
|
||||||
verificationUrl: registration.qrCodeUrl,
|
operation: registration.operation,
|
||||||
qrCodeDataUrl: await encodeQr(registration.qrCodeUrl),
|
...(registration.botId ? { botId: registration.botId } : {}),
|
||||||
expiresAt: registration.expiresAt ?? Date.now() + (5 * 60_000),
|
expiresAt: registration.expiresAt ?? Date.now() + (5 * 60_000),
|
||||||
pollIntervalMs: Math.max(800, Math.min(10_000, (registration.pollIntervalSeconds ?? 1.8) * 1000)),
|
pollIntervalMs: Math.max(800, Math.min(10_000, (registration.pollIntervalSeconds ?? 1.8) * 1000)),
|
||||||
};
|
};
|
||||||
|
if (verificationUrl) {
|
||||||
|
return {
|
||||||
|
...projection,
|
||||||
|
verificationUrl,
|
||||||
|
qrCodeDataUrl: await encodeQr(verificationUrl),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// RegistrationManager deliberately removes the device URL as soon as the
|
||||||
|
// remote update is committed. Keep only the opaque attempt identity so a
|
||||||
|
// browser reload can resume polling the non-cancellable verification phase.
|
||||||
|
if (registration.state === 'saving'
|
||||||
|
&& registration.operation === CALLBACK_REPAIR_OPERATION
|
||||||
|
&& registration.botId) {
|
||||||
|
return { ...projection, submitted: true };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function publicBotEntry(entry) {
|
function publicBotEntry(entry) {
|
||||||
|
|
@ -188,7 +268,19 @@ export async function toPublicFeishuStatus(status, { encodeQr = qrCodeDataUrl }
|
||||||
const registration = publicRegistration(source.registration);
|
const registration = publicRegistration(source.registration);
|
||||||
const facts = connectionFacts(source.connection);
|
const facts = connectionFacts(source.connection);
|
||||||
const connected = source.connected === true || facts.connected;
|
const connected = source.connected === true || facts.connected;
|
||||||
const provisioning = await publicProvisioning(registration, encodeQr);
|
const repairTarget = registration.operation === CALLBACK_REPAIR_OPERATION
|
||||||
|
&& registration.botId
|
||||||
|
&& Array.isArray(source.bots)
|
||||||
|
? source.bots.find((entry) => entry?.botId === registration.botId)
|
||||||
|
: undefined;
|
||||||
|
const expectedRegistrationHost = repairTarget?.bot?.domain === 'lark'
|
||||||
|
? 'open.larksuite.com'
|
||||||
|
: 'open.feishu.cn';
|
||||||
|
const provisioning = await publicProvisioning(
|
||||||
|
registration,
|
||||||
|
encodeQr,
|
||||||
|
expectedRegistrationHost,
|
||||||
|
);
|
||||||
const error = publicError(source.error) ?? registration.error ?? null;
|
const error = publicError(source.error) ?? registration.error ?? null;
|
||||||
const bots = Array.isArray(source.bots)
|
const bots = Array.isArray(source.bots)
|
||||||
? source.bots.map(publicBotEntry).filter(Boolean)
|
? source.bots.map(publicBotEntry).filter(Boolean)
|
||||||
|
|
@ -241,6 +333,11 @@ function validPayload(endpoint, payload) {
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (endpoint === FEISHU_ENDPOINTS.beginCallbackRepair) {
|
||||||
|
return hasOnlyKeys(payload, new Set(['botId'])) && safeOpaqueId(payload.botId)
|
||||||
|
? null
|
||||||
|
: 'Callback repair requires a single valid botId.';
|
||||||
|
}
|
||||||
if (endpoint === FEISHU_ENDPOINTS.bindCredentials) {
|
if (endpoint === FEISHU_ENDPOINTS.bindCredentials) {
|
||||||
return hasOnlyKeys(payload, new Set(['appId', 'appSecret']))
|
return hasOnlyKeys(payload, new Set(['appId', 'appSecret']))
|
||||||
&& validCredential(payload.appId, 256)
|
&& validCredential(payload.appId, 256)
|
||||||
|
|
@ -386,6 +483,20 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
|
||||||
value = (await toPublicFeishuStatus(ready, { encodeQr: cachedEncodeQr })).provisioning;
|
value = (await toPublicFeishuStatus(ready, { encodeQr: cachedEncodeQr })).provisioning;
|
||||||
if (!value) throw new Error('Provisioning did not produce a QR code.');
|
if (!value) throw new Error('Provisioning did not produce a QR code.');
|
||||||
attemptQr.set(attemptId, value.verificationUrl);
|
attemptQr.set(attemptId, value.verificationUrl);
|
||||||
|
} else if (endpoint === FEISHU_ENDPOINTS.beginCallbackRepair) {
|
||||||
|
if (typeof controller.startCallbackRepair !== 'function') {
|
||||||
|
throw new Error('Feishu callback repair is unavailable.');
|
||||||
|
}
|
||||||
|
const started = await controller.startCallbackRepair(payload.botId);
|
||||||
|
const attemptId = String(publicRegistration(started?.registration).attempt);
|
||||||
|
const ready = await waitForQr(controller, started, attemptId, signal);
|
||||||
|
value = (await toPublicFeishuStatus(ready, { encodeQr: cachedEncodeQr })).provisioning;
|
||||||
|
if (!value
|
||||||
|
|| value.operation !== CALLBACK_REPAIR_OPERATION
|
||||||
|
|| value.botId !== payload.botId) {
|
||||||
|
throw new Error('Callback repair did not produce a safe QR code.');
|
||||||
|
}
|
||||||
|
attemptQr.set(attemptId, value.verificationUrl);
|
||||||
} else if (endpoint === FEISHU_ENDPOINTS.pollProvisioning) {
|
} else if (endpoint === FEISHU_ENDPOINTS.pollProvisioning) {
|
||||||
const current = await statusForRegistration(controller, payload.attemptId);
|
const current = await statusForRegistration(controller, payload.attemptId);
|
||||||
if (!current || !sameAttempt(current, payload.attemptId)) {
|
if (!current || !sameAttempt(current, payload.attemptId)) {
|
||||||
|
|
@ -395,6 +506,7 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
|
||||||
const connection = await toPublicFeishuStatus(current, { encodeQr: cachedEncodeQr });
|
const connection = await toPublicFeishuStatus(current, { encodeQr: cachedEncodeQr });
|
||||||
value = {
|
value = {
|
||||||
status: pollStatus(current),
|
status: pollStatus(current),
|
||||||
|
operation: registration.operation,
|
||||||
...(registration.botId ? { botId: registration.botId } : {}),
|
...(registration.botId ? { botId: registration.botId } : {}),
|
||||||
...(connection.provisioning ? { provisioning: connection.provisioning } : {}),
|
...(connection.provisioning ? { provisioning: connection.provisioning } : {}),
|
||||||
...(registration.botId && connection.connected ? { connection } : {}),
|
...(registration.botId && connection.connected ? { connection } : {}),
|
||||||
|
|
@ -412,12 +524,34 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
|
||||||
}
|
}
|
||||||
const multi = typeof controller.registrationStatus === 'function';
|
const multi = typeof controller.registrationStatus === 'function';
|
||||||
const registration = publicRegistration(current.registration);
|
const registration = publicRegistration(current.registration);
|
||||||
if (!multi && registration.state === 'saving') await controller.disconnect();
|
let after;
|
||||||
else await controller.cancelRegistration(payload.attemptId);
|
if (!multi && registration.state === 'saving') {
|
||||||
const url = attemptQr.get(payload.attemptId);
|
after = await controller.disconnect();
|
||||||
if (url) qrCache.delete(url);
|
} else {
|
||||||
attemptQr.delete(payload.attemptId);
|
after = await controller.cancelRegistration(payload.attemptId);
|
||||||
value = { status: 'failed', message: 'Registration was cancelled.' };
|
}
|
||||||
|
const afterRegistration = publicRegistration(after?.registration);
|
||||||
|
if (registration.operation === CALLBACK_REPAIR_OPERATION
|
||||||
|
&& registration.state === 'saving'
|
||||||
|
&& ['saving', 'succeeded'].includes(afterRegistration.state)) {
|
||||||
|
value = {
|
||||||
|
status: pollStatus(after),
|
||||||
|
operation: afterRegistration.operation,
|
||||||
|
...(afterRegistration.botId ? { botId: afterRegistration.botId } : {}),
|
||||||
|
message: 'Callback repair was already submitted and is still being verified.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (value?.status !== 'connecting') {
|
||||||
|
const url = attemptQr.get(payload.attemptId);
|
||||||
|
if (url) qrCache.delete(url);
|
||||||
|
attemptQr.delete(payload.attemptId);
|
||||||
|
value ??= {
|
||||||
|
status: 'failed',
|
||||||
|
operation: registration.operation,
|
||||||
|
...(registration.botId ? { botId: registration.botId } : {}),
|
||||||
|
message: 'Registration was cancelled.',
|
||||||
|
};
|
||||||
|
}
|
||||||
} else if (endpoint === FEISHU_ENDPOINTS.bindCredentials) {
|
} else if (endpoint === FEISHU_ENDPOINTS.bindCredentials) {
|
||||||
if (typeof controller.bindCredentials !== 'function') {
|
if (typeof controller.bindCredentials !== 'function') {
|
||||||
throw new Error('Credential binding is unavailable');
|
throw new Error('Credential binding is unavailable');
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import QRCode from 'qrcode';
|
||||||
import {
|
import {
|
||||||
conversationKey,
|
conversationKey,
|
||||||
extractInboundMessage,
|
extractInboundMessage,
|
||||||
|
|
@ -41,12 +42,28 @@ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无
|
||||||
const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
|
const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
|
||||||
|
|
||||||
const MENU_COMMAND = /^\/m(?:enu)?$/i;
|
const MENU_COMMAND = /^\/m(?:enu)?$/i;
|
||||||
|
const REPAIR_COMMAND_PREFIX = /^\/repair(?:\s|$)/i;
|
||||||
|
const REPAIR_COMMAND = /^\/repair(?:\s+(qr|status|cancel|verify))?\s*$/i;
|
||||||
const SESSION_LIST_PREFIX = /^\/sessionlist(?:\s|$)/i;
|
const SESSION_LIST_PREFIX = /^\/sessionlist(?:\s|$)/i;
|
||||||
const WORKSPACE_LIST_COMMAND = /^\/workspacelist$/i;
|
const WORKSPACE_LIST_COMMAND = /^\/workspacelist$/i;
|
||||||
const NUMBER_REPLY = /^\d{1,2}$/;
|
const NUMBER_REPLY = /^\d{1,2}$/;
|
||||||
/** A displayed menu stays number-tappable for this long. */
|
/** A displayed menu stays number-tappable for this long. */
|
||||||
const MENU_TTL_MS = 10 * 60_000;
|
const MENU_TTL_MS = 10 * 60_000;
|
||||||
const MAX_TRACKED_MENUS = 50;
|
const MAX_TRACKED_MENUS = 50;
|
||||||
|
const REPAIR_LINK_WAIT_MS = 15_000;
|
||||||
|
const REPAIR_POLL_INTERVAL_MS = 1_000;
|
||||||
|
const REPAIR_ACTIVE_STATES = new Set([
|
||||||
|
'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched', 'saving',
|
||||||
|
]);
|
||||||
|
const REPAIR_TERMINAL_STATES = new Set([
|
||||||
|
'succeeded', 'expired', 'cancelled', 'error',
|
||||||
|
]);
|
||||||
|
const REPAIR_URL_HOSTS = new Set([
|
||||||
|
'accounts.feishu.cn',
|
||||||
|
'open.feishu.cn',
|
||||||
|
'accounts.larksuite.com',
|
||||||
|
'open.larksuite.com',
|
||||||
|
]);
|
||||||
|
|
||||||
const HELP_TEXT = [
|
const HELP_TEXT = [
|
||||||
'北汇星河 AIOS 已连接 DeepSeek Harness。',
|
'北汇星河 AIOS 已连接 DeepSeek Harness。',
|
||||||
|
|
@ -64,6 +81,7 @@ const HELP_TEXT = [
|
||||||
'/stop 停止当前任务',
|
'/stop 停止当前任务',
|
||||||
'/steer 补充指令 纠偏当前任务',
|
'/steer 补充指令 纠偏当前任务',
|
||||||
'/status 检查连接状态',
|
'/status 检查连接状态',
|
||||||
|
'/repair 修复卡片按钮回调',
|
||||||
'/m(或 /menu) 打开交互卡片菜单',
|
'/m(或 /menu) 打开交互卡片菜单',
|
||||||
'/help 显示本帮助',
|
'/help 显示本帮助',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
@ -93,6 +111,87 @@ function senderOpenId(event) {
|
||||||
?? nonEmptyString(event?.sender?.sender_id?.user_id);
|
?? nonEmptyString(event?.sender?.sender_id?.user_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function strictSenderOpenId(event) {
|
||||||
|
return nonEmptyString(event?.sender?.sender_id?.open_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortableDelay(milliseconds, signal) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(done, milliseconds);
|
||||||
|
timer.unref?.();
|
||||||
|
function done() {
|
||||||
|
signal?.removeEventListener('abort', aborted);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
function aborted() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
||||||
|
}
|
||||||
|
signal?.addEventListener('abort', aborted, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function repairSnapshot(value, { botId } = {}) {
|
||||||
|
const source = value && typeof value === 'object' ? value : {};
|
||||||
|
const registration = source.registration && typeof source.registration === 'object'
|
||||||
|
? source.registration
|
||||||
|
: source;
|
||||||
|
const operation = nonEmptyString(registration.operation) ?? nonEmptyString(source.operation);
|
||||||
|
if (operation && operation !== 'callback_repair') {
|
||||||
|
throw new Error('The active Feishu operation is not a callback repair');
|
||||||
|
}
|
||||||
|
const selectedBotId = nonEmptyString(registration.botId) ?? nonEmptyString(source.botId);
|
||||||
|
if (botId && selectedBotId && selectedBotId !== botId) {
|
||||||
|
throw new Error('The Feishu repair belongs to another bot');
|
||||||
|
}
|
||||||
|
const state = nonEmptyString(registration.state);
|
||||||
|
const attempt = registration.attemptId ?? registration.attempt;
|
||||||
|
const attemptId = typeof attempt === 'string' || Number.isFinite(attempt)
|
||||||
|
? String(attempt)
|
||||||
|
: null;
|
||||||
|
if (!state || !attemptId) throw new Error('Feishu returned an invalid repair status');
|
||||||
|
const verificationUrl = nonEmptyString(registration.verificationUrl)
|
||||||
|
?? nonEmptyString(registration.qrCodeUrl);
|
||||||
|
const expiresAt = Number(registration.expiresAt);
|
||||||
|
const remainingSeconds = Number(registration.remainingSeconds);
|
||||||
|
const pollIntervalMs = Number(registration.pollIntervalMs)
|
||||||
|
|| (Number(registration.pollIntervalSeconds) * 1000);
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
attemptId,
|
||||||
|
botId: selectedBotId,
|
||||||
|
verificationUrl,
|
||||||
|
expiresAt: Number.isFinite(expiresAt) ? expiresAt : null,
|
||||||
|
remainingSeconds: Number.isFinite(remainingSeconds) ? remainingSeconds : null,
|
||||||
|
pollIntervalMs: Number.isFinite(pollIntervalMs) && pollIntervalMs > 0
|
||||||
|
? pollIntervalMs
|
||||||
|
: null,
|
||||||
|
error: registration.error && typeof registration.error === 'object'
|
||||||
|
? { code: nonEmptyString(registration.error.code), message: nonEmptyString(registration.error.message) }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRepairUrl(rawUrl, expectedAppId) {
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
if (url.protocol !== 'https:' || !REPAIR_URL_HOSTS.has(url.hostname)) {
|
||||||
|
throw new Error('Feishu returned an untrusted repair URL');
|
||||||
|
}
|
||||||
|
if (url.searchParams.get('tp') !== 'sdk'
|
||||||
|
|| url.searchParams.get('clientID') !== expectedAppId
|
||||||
|
|| url.searchParams.has('createOnly')) {
|
||||||
|
throw new Error('Feishu returned an invalid existing-app repair URL');
|
||||||
|
}
|
||||||
|
if (url.toString().includes('{{client_id}}') || url.toString().includes('%7B%7Bclient_id%7D%7D')) {
|
||||||
|
throw new Error('Feishu returned an unresolved client id placeholder');
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
function canClaimInteractionReply(event, pending) {
|
function canClaimInteractionReply(event, pending) {
|
||||||
return pending.needsPresentation !== true
|
return pending.needsPresentation !== true
|
||||||
&& pending.questions[pending.index]
|
&& pending.questions[pending.index]
|
||||||
|
|
@ -129,6 +228,14 @@ export class FeishuHarnessBridge {
|
||||||
#replyTimeoutMs;
|
#replyTimeoutMs;
|
||||||
#logger;
|
#logger;
|
||||||
#signal;
|
#signal;
|
||||||
|
#botId;
|
||||||
|
#appId;
|
||||||
|
#repair;
|
||||||
|
#repairOwnerOpenIds;
|
||||||
|
#repairAttempt = null;
|
||||||
|
#repairMonitorVersion = 0;
|
||||||
|
#repairPollIntervalMs;
|
||||||
|
#repairLinkWaitMs;
|
||||||
/** Number-tappable menus: conversation key → menu state. */
|
/** Number-tappable menus: conversation key → menu state. */
|
||||||
#menus = new Map();
|
#menus = new Map();
|
||||||
/** Interactive-card message id → route context for button callbacks. */
|
/** Interactive-card message id → route context for button callbacks. */
|
||||||
|
|
@ -141,6 +248,12 @@ export class FeishuHarnessBridge {
|
||||||
state,
|
state,
|
||||||
status,
|
status,
|
||||||
allowedSenderOpenIds = new Set(),
|
allowedSenderOpenIds = new Set(),
|
||||||
|
botId,
|
||||||
|
appId,
|
||||||
|
repair,
|
||||||
|
repairOwnerOpenIds,
|
||||||
|
repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
|
||||||
|
repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
|
||||||
replyTimeoutMs = 600_000,
|
replyTimeoutMs = 600_000,
|
||||||
logger = console,
|
logger = console,
|
||||||
signal,
|
signal,
|
||||||
|
|
@ -148,12 +261,35 @@ export class FeishuHarnessBridge {
|
||||||
if (!client || !harness || !state || !status) {
|
if (!client || !harness || !state || !status) {
|
||||||
throw new TypeError('Feishu bridge dependencies are required');
|
throw new TypeError('Feishu bridge dependencies are required');
|
||||||
}
|
}
|
||||||
|
if (repair !== undefined && repair !== null) {
|
||||||
|
if (!repair || typeof repair.start !== 'function'
|
||||||
|
|| typeof repair.status !== 'function'
|
||||||
|
|| typeof repair.cancel !== 'function') {
|
||||||
|
throw new TypeError('Feishu repair capability requires start/status/cancel');
|
||||||
|
}
|
||||||
|
if (!nonEmptyString(botId) || !nonEmptyString(appId)) {
|
||||||
|
throw new TypeError('Feishu repair capability requires botId and appId');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(repairPollIntervalMs) || repairPollIntervalMs <= 0
|
||||||
|
|| !Number.isFinite(repairLinkWaitMs) || repairLinkWaitMs <= 0) {
|
||||||
|
throw new TypeError('Feishu repair timing values must be positive numbers');
|
||||||
|
}
|
||||||
this.#client = client;
|
this.#client = client;
|
||||||
this.#channel = channel;
|
this.#channel = channel;
|
||||||
this.#harness = harness;
|
this.#harness = harness;
|
||||||
this.#state = state;
|
this.#state = state;
|
||||||
this.#status = status;
|
this.#status = status;
|
||||||
this.#allowedSenderOpenIds = allowedSenderOpenIds;
|
this.#allowedSenderOpenIds = allowedSenderOpenIds;
|
||||||
|
this.#botId = nonEmptyString(botId);
|
||||||
|
this.#appId = nonEmptyString(appId);
|
||||||
|
this.#repair = repair ?? null;
|
||||||
|
const repairOwners = repairOwnerOpenIds ?? allowedSenderOpenIds;
|
||||||
|
this.#repairOwnerOpenIds = new Set(
|
||||||
|
[...(repairOwners ?? [])].filter((value) => typeof value === 'string' && value && value !== '*'),
|
||||||
|
);
|
||||||
|
this.#repairPollIntervalMs = repairPollIntervalMs;
|
||||||
|
this.#repairLinkWaitMs = repairLinkWaitMs;
|
||||||
this.#replyTimeoutMs = replyTimeoutMs;
|
this.#replyTimeoutMs = replyTimeoutMs;
|
||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
|
this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
|
||||||
|
|
@ -436,6 +572,10 @@ export class FeishuHarnessBridge {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (commandText !== null && REPAIR_COMMAND_PREFIX.test(commandText)) {
|
||||||
|
await this.#handleRepairCommand(event, commandText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (commandText === '/help') {
|
if (commandText === '/help') {
|
||||||
await this.#send(event.message.chat_id, HELP_TEXT);
|
await this.#send(event.message.chat_id, HELP_TEXT);
|
||||||
return;
|
return;
|
||||||
|
|
@ -467,7 +607,11 @@ export class FeishuHarnessBridge {
|
||||||
if (NUMBER_REPLY.test(commandText)) {
|
if (NUMBER_REPLY.test(commandText)) {
|
||||||
const menu = this.#takeMenu(key);
|
const menu = this.#takeMenu(key);
|
||||||
if (menu) {
|
if (menu) {
|
||||||
await this.#handleMenuPick(menu, Number(commandText), { chatId: event.message.chat_id, key });
|
await this.#handleMenuPick(menu, Number(commandText), {
|
||||||
|
chatId: event.message.chat_id,
|
||||||
|
key,
|
||||||
|
event,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -508,6 +652,321 @@ export class FeishuHarnessBridge {
|
||||||
|
|
||||||
// ── Interactive cards: menus and session/workspace lists ────────────────
|
// ── Interactive cards: menus and session/workspace lists ────────────────
|
||||||
|
|
||||||
|
// Existing-app callback repair. This path deliberately uses ordinary text
|
||||||
|
// and number replies because callback buttons are the capability being fixed.
|
||||||
|
async #handleRepairCommand(event, commandText) {
|
||||||
|
if (event?.message?.chat_type !== 'p2p') {
|
||||||
|
await this.#send(event.message.chat_id, '为避免授权链接暴露,请私聊机器人发送 /repair。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const actorOpenId = strictSenderOpenId(event);
|
||||||
|
if (!actorOpenId || !this.#repairOwnerOpenIds.has(actorOpenId)) {
|
||||||
|
await this.#send(
|
||||||
|
event.message.chat_id,
|
||||||
|
this.#repairOwnerOpenIds.size === 0
|
||||||
|
? '当前机器人没有可验证的接入者身份,不能从聊天发起修复;请先在插件页设置管理员。'
|
||||||
|
: '此操作只能由机器人接入者在私聊中发起,未进行任何修改。',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.#repair) {
|
||||||
|
await this.#send(event.message.chat_id, '当前 Host 版本暂不支持聊天内修复,请先更新插件。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = REPAIR_COMMAND.exec(commandText);
|
||||||
|
if (!parsed) {
|
||||||
|
await this.#send(event.message.chat_id, '用法:/repair、/repair qr、/repair status、/repair cancel 或 /repair verify');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const operation = parsed[1]?.toLowerCase() ?? 'start';
|
||||||
|
const chatId = event.message.chat_id;
|
||||||
|
if (operation === 'start') {
|
||||||
|
await this.#startRepair({ actorOpenId, chatId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attempt = this.#repairAttempt;
|
||||||
|
if (!attempt) {
|
||||||
|
await this.#send(
|
||||||
|
chatId,
|
||||||
|
'当前 Runtime 没有可恢复的修复任务记录(机器人可能刚完成密钥更新并重启)。本命令不会启动新的授权;请查看机器人发送的验证结果,确认上一次任务已结束后再发送 /repair。',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempt.actorOpenId !== actorOpenId) {
|
||||||
|
await this.#send(chatId, '另一位管理员正在修复该机器人,本次不会显示其授权信息。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (operation === 'cancel') {
|
||||||
|
let snapshot;
|
||||||
|
try {
|
||||||
|
const result = await this.#repair.cancel(this.#repairArgs(attempt));
|
||||||
|
snapshot = repairSnapshot(result, { botId: this.#botId });
|
||||||
|
attempt.snapshot = snapshot;
|
||||||
|
} catch {
|
||||||
|
await this.#send(chatId, '暂时无法取消修复任务,请稍后重试。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (snapshot.state === 'cancelled') {
|
||||||
|
attempt.stopped = true;
|
||||||
|
this.#repairMonitorVersion += 1;
|
||||||
|
}
|
||||||
|
await this.#send(chatId, this.#repairStatusText(snapshot));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot;
|
||||||
|
try {
|
||||||
|
snapshot = await this.#refreshRepairAttempt(attempt);
|
||||||
|
} catch {
|
||||||
|
await this.#send(chatId, '暂时无法查询修复状态,请稍后重试。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (operation === 'qr') {
|
||||||
|
if (!REPAIR_ACTIVE_STATES.has(snapshot.state) || !attempt.verificationUrl) {
|
||||||
|
await this.#send(chatId, this.#repairStatusText(snapshot, { verificationFocused: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.#sendRepairQr(chatId, attempt.verificationUrl, snapshot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.#send(chatId, this.#repairStatusText(snapshot, {
|
||||||
|
verificationFocused: operation === 'verify',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#repairArgs(attempt) {
|
||||||
|
return {
|
||||||
|
botId: this.#botId,
|
||||||
|
attemptId: attempt.attemptId,
|
||||||
|
actorOpenId: attempt.actorOpenId,
|
||||||
|
chatId: attempt.chatId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async #startRepair({ actorOpenId, chatId }) {
|
||||||
|
const previous = this.#repairAttempt;
|
||||||
|
if (previous && REPAIR_ACTIVE_STATES.has(previous.snapshot.state)) {
|
||||||
|
if (previous.actorOpenId !== actorOpenId) {
|
||||||
|
await this.#send(chatId, '另一位管理员正在修复该机器人,本次不会显示其授权信息。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const current = await this.#refreshRepairAttempt(previous);
|
||||||
|
if (REPAIR_ACTIVE_STATES.has(current.state) && previous.verificationUrl) {
|
||||||
|
await this.#sendRepairLink(chatId, previous.verificationUrl, current, { existing: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await this.#send(chatId, '暂时无法查询修复状态,请稍后重试。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot;
|
||||||
|
try {
|
||||||
|
snapshot = repairSnapshot(await this.#repair.start({
|
||||||
|
botId: this.#botId,
|
||||||
|
actorOpenId,
|
||||||
|
chatId,
|
||||||
|
}), { botId: this.#botId });
|
||||||
|
snapshot = await this.#waitForRepairLink(snapshot, { actorOpenId, chatId });
|
||||||
|
} catch {
|
||||||
|
await this.#send(chatId, '修复流程暂时失败,现有机器人连接不受影响;请稍后发送 /repair 重试。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const attempt = {
|
||||||
|
attemptId: snapshot.attemptId,
|
||||||
|
actorOpenId,
|
||||||
|
chatId,
|
||||||
|
snapshot,
|
||||||
|
verificationUrl: null,
|
||||||
|
stopped: false,
|
||||||
|
announcedSaving: false,
|
||||||
|
announcedTerminal: false,
|
||||||
|
};
|
||||||
|
this.#repairAttempt = attempt;
|
||||||
|
|
||||||
|
if (snapshot.verificationUrl) {
|
||||||
|
try {
|
||||||
|
attempt.verificationUrl = safeRepairUrl(snapshot.verificationUrl, this.#appId);
|
||||||
|
} catch {
|
||||||
|
attempt.stopped = true;
|
||||||
|
await this.#repair.cancel(this.#repairArgs(attempt)).catch(() => undefined);
|
||||||
|
await this.#send(chatId, '飞书返回了无法安全验证的授权链接,已中止本次修复。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (REPAIR_TERMINAL_STATES.has(snapshot.state)) {
|
||||||
|
attempt.announcedTerminal = true;
|
||||||
|
if (snapshot.state !== 'succeeded') {
|
||||||
|
await this.#send(chatId, this.#repairStatusText(snapshot));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!attempt.verificationUrl) {
|
||||||
|
attempt.stopped = true;
|
||||||
|
await this.#send(chatId, '飞书未返回授权链接,已中止本次修复。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.#sendRepairLink(chatId, attempt.verificationUrl, snapshot);
|
||||||
|
this.#monitorRepair(attempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #waitForRepairLink(initial, context) {
|
||||||
|
let current = initial;
|
||||||
|
const deadline = Date.now() + this.#repairLinkWaitMs;
|
||||||
|
while (!current.verificationUrl && REPAIR_ACTIVE_STATES.has(current.state)) {
|
||||||
|
if (Date.now() >= deadline) throw new Error('Feishu repair link timed out');
|
||||||
|
await abortableDelay(Math.min(100, this.#repairPollIntervalMs), this.#signal);
|
||||||
|
current = repairSnapshot(await this.#repair.status({
|
||||||
|
botId: this.#botId,
|
||||||
|
attemptId: current.attemptId,
|
||||||
|
actorOpenId: context.actorOpenId,
|
||||||
|
chatId: context.chatId,
|
||||||
|
}), { botId: this.#botId });
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #refreshRepairAttempt(attempt) {
|
||||||
|
const snapshot = repairSnapshot(
|
||||||
|
await this.#repair.status(this.#repairArgs(attempt)),
|
||||||
|
{ botId: this.#botId },
|
||||||
|
);
|
||||||
|
if (snapshot.attemptId !== attempt.attemptId) {
|
||||||
|
throw new Error('Feishu repair attempt changed unexpectedly');
|
||||||
|
}
|
||||||
|
attempt.snapshot = snapshot;
|
||||||
|
if (snapshot.verificationUrl) {
|
||||||
|
attempt.verificationUrl = safeRepairUrl(snapshot.verificationUrl, this.#appId);
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
#monitorRepair(attempt) {
|
||||||
|
const version = ++this.#repairMonitorVersion;
|
||||||
|
void (async () => {
|
||||||
|
while (!attempt.stopped && this.#repairAttempt === attempt
|
||||||
|
&& this.#repairMonitorVersion === version
|
||||||
|
&& !this.#signal?.aborted) {
|
||||||
|
const delayMs = Math.max(
|
||||||
|
250,
|
||||||
|
Math.min(10_000, attempt.snapshot.pollIntervalMs ?? this.#repairPollIntervalMs),
|
||||||
|
);
|
||||||
|
await abortableDelay(delayMs, this.#signal);
|
||||||
|
if (attempt.stopped || this.#repairAttempt !== attempt || this.#repairMonitorVersion !== version) return;
|
||||||
|
const snapshot = await this.#refreshRepairAttempt(attempt);
|
||||||
|
if (snapshot.state === 'saving' && !attempt.announcedSaving) {
|
||||||
|
attempt.announcedSaving = true;
|
||||||
|
await this.#send(
|
||||||
|
attempt.chatId,
|
||||||
|
'授权已确认,正在发送并等待测试按钮回调;收到真实回调后才会完成。',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (REPAIR_TERMINAL_STATES.has(snapshot.state)) {
|
||||||
|
attempt.stopped = true;
|
||||||
|
// Runtime sends the verified-success notice before resolving the
|
||||||
|
// controller probe. Avoid duplicating it here; failure terminals
|
||||||
|
// still need an explicit chat-side explanation.
|
||||||
|
if (snapshot.state !== 'succeeded' && !attempt.announcedTerminal) {
|
||||||
|
attempt.announcedTerminal = true;
|
||||||
|
await this.#send(attempt.chatId, this.#repairStatusText(snapshot));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})().catch(async () => {
|
||||||
|
if (this.#signal?.aborted || attempt.stopped || this.#repairAttempt !== attempt) return;
|
||||||
|
attempt.stopped = true;
|
||||||
|
this.#logger.warn?.('[dsh-feishu] callback repair status monitoring failed');
|
||||||
|
await this.#send(
|
||||||
|
attempt.chatId,
|
||||||
|
'修复状态查询中断,现有机器人连接不受影响;发送 /repair status 重试查询。',
|
||||||
|
).catch(() => undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async #sendRepairLink(chatId, url, snapshot, { existing = false } = {}) {
|
||||||
|
const remaining = snapshot.remainingSeconds
|
||||||
|
?? (snapshot.expiresAt ? Math.max(0, Math.ceil((snapshot.expiresAt - Date.now()) / 1000)) : null);
|
||||||
|
const expiry = remaining === null
|
||||||
|
? '链接为短期有效'
|
||||||
|
: `链接约 ${Math.max(1, Math.ceil(remaining / 60))} 分钟后过期`;
|
||||||
|
await this.#send(chatId, [
|
||||||
|
existing ? '已有一个修复任务在等待授权。' : '🔧 准备修复卡片按钮。',
|
||||||
|
'本次只会增量添加 card.action.trigger。请核对确认页只显示这一项;若出现其他权限或事件,请取消。',
|
||||||
|
'',
|
||||||
|
'当前设备直接打开:',
|
||||||
|
url,
|
||||||
|
'',
|
||||||
|
`若要用另一台设备扫码,发送 /repair qr。${expiry}。`,
|
||||||
|
].join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async #sendRepairQr(chatId, url, snapshot) {
|
||||||
|
try {
|
||||||
|
const image = await QRCode.toBuffer(url, {
|
||||||
|
errorCorrectionLevel: 'M', margin: 1, width: 480, type: 'png',
|
||||||
|
});
|
||||||
|
const uploaded = await this.#client.im.v1.image.create({
|
||||||
|
data: { image_type: 'message', image },
|
||||||
|
});
|
||||||
|
const imageKey = nonEmptyString(uploaded?.image_key) ?? nonEmptyString(uploaded?.data?.image_key);
|
||||||
|
if (!imageKey) throw new Error('Feishu QR upload returned no image key');
|
||||||
|
const remaining = snapshot.remainingSeconds
|
||||||
|
?? (snapshot.expiresAt ? Math.max(0, Math.ceil((snapshot.expiresAt - Date.now()) / 1000)) : null);
|
||||||
|
await this.#send(
|
||||||
|
chatId,
|
||||||
|
`请用另一台设备扫码完成授权${remaining === null ? '' : `(剩余约 ${Math.max(1, Math.ceil(remaining / 60))} 分钟)`}。`,
|
||||||
|
);
|
||||||
|
const response = await this.#client.im.v1.message.create({
|
||||||
|
params: { receive_id_type: 'chat_id' },
|
||||||
|
data: {
|
||||||
|
receive_id: chatId,
|
||||||
|
msg_type: 'image',
|
||||||
|
content: JSON.stringify({ image_key: imageKey }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response?.code && response.code !== 0) throw new Error('Feishu QR message send failed');
|
||||||
|
} catch {
|
||||||
|
await this.#send(chatId, `二维码暂时无法发送,请直接打开授权链接:\n${url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#repairStatusText(snapshot, { verificationFocused = false } = {}) {
|
||||||
|
if (snapshot.state === 'succeeded') {
|
||||||
|
return '✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。';
|
||||||
|
}
|
||||||
|
if (snapshot.state === 'expired' || snapshot.error?.code === 'expired_token') {
|
||||||
|
return '授权链接已过期;平台未返回成功结果,无法确认已修复。发送 /repair 生成新链接。';
|
||||||
|
}
|
||||||
|
if (snapshot.state === 'cancelled' || snapshot.error?.code === 'abort') {
|
||||||
|
return '已取消本次修复授权,未确认完成修复。';
|
||||||
|
}
|
||||||
|
if (snapshot.error?.code === 'access_denied') {
|
||||||
|
return '你已取消或拒绝授权,没有确认修复;发送 /repair 可重试。';
|
||||||
|
}
|
||||||
|
if (snapshot.error?.code === 'card_action_probe_timeout'
|
||||||
|
|| snapshot.error?.code === 'card-action-probe-timeout') {
|
||||||
|
return '授权已提交,但未收到测试按钮回调。可能尚未点击或配置仍在传播;稍后发送 /repair verify 查询,不要盲目重复授权。';
|
||||||
|
}
|
||||||
|
if (snapshot.state === 'error') {
|
||||||
|
return '修复流程暂时失败,现有机器人连接不受影响;发送 /repair 可重试。';
|
||||||
|
}
|
||||||
|
if (snapshot.state === 'saving') {
|
||||||
|
return '授权已确认,正在等待专用测试按钮的真实回调;回调到达前不会宣告成功。';
|
||||||
|
}
|
||||||
|
if (verificationFocused) {
|
||||||
|
return '授权尚未完成,暂时不能验证卡片按钮。请先打开授权链接并确认。';
|
||||||
|
}
|
||||||
|
const remaining = snapshot.remainingSeconds === null
|
||||||
|
? ''
|
||||||
|
: `,剩余约 ${Math.max(1, Math.ceil(snapshot.remainingSeconds / 60))} 分钟`;
|
||||||
|
return `修复任务正在等待授权${remaining}。发送 /repair qr 可获取二维码,/repair cancel 可取消。`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card button callback (card.action.trigger). The operator must be an
|
* Card button callback (card.action.trigger). The operator must be an
|
||||||
* allowed sender: group members outside the allowlist must never drive
|
* allowed sender: group members outside the allowlist must never drive
|
||||||
|
|
@ -592,13 +1051,17 @@ export class FeishuHarnessBridge {
|
||||||
return menu;
|
return menu;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #handleMenuPick(menu, number, { chatId, key }) {
|
async #handleMenuPick(menu, number, { chatId, key, event }) {
|
||||||
if (menu.kind === 'menu') {
|
if (menu.kind === 'menu') {
|
||||||
const action = ['sessions', 'workspaces', 'new', 'status', 'help'][number - 1];
|
const action = ['sessions', 'workspaces', 'new', 'status', 'help', 'repair'][number - 1];
|
||||||
if (!action) {
|
if (!action) {
|
||||||
await this.#send(chatId, '菜单没有这个编号,回复 /m 重新打开。');
|
await this.#send(chatId, '菜单没有这个编号,回复 /m 重新打开。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === 'repair') {
|
||||||
|
await this.#handleRepairCommand(event, '/repair');
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.#handleCardAction(action, { chatId, key });
|
await this.#handleCardAction(action, { chatId, key });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,42 @@ export function menuCard() {
|
||||||
button('3 · 新会话', 'new'),
|
button('3 · 新会话', 'new'),
|
||||||
button('4 · 状态', 'status'),
|
button('4 · 状态', 'status'),
|
||||||
button('5 · 帮助', 'help'),
|
button('5 · 帮助', 'help'),
|
||||||
|
// Repair must remain number-driven. Apps that need this command do not
|
||||||
|
// have card.action.trigger yet, so rendering it as a callback button would
|
||||||
|
// send the user straight back to Feishu's broken callback setup popup.
|
||||||
|
{ tag: 'div', text: markdown('**6 · 修复卡片按钮**(请直接回复数字 **6**)') },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-shot callback probe used only after an existing app was re-authorized. */
|
||||||
|
export function cardActionProbeCard(nonce) {
|
||||||
|
if (typeof nonce !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(nonce)) {
|
||||||
|
throw new TypeError('A safe card-action probe nonce is required');
|
||||||
|
}
|
||||||
|
return cardWith('🧪 验证卡片按钮', [
|
||||||
|
{
|
||||||
|
tag: 'div',
|
||||||
|
text: markdown('授权已提交。请点击下方按钮;机器人真实收到回调后才会判定修复成功。'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'column_set',
|
||||||
|
flex_mode: 'none',
|
||||||
|
columns: [{
|
||||||
|
tag: 'column',
|
||||||
|
width: 'weighted',
|
||||||
|
weight: 1,
|
||||||
|
elements: [{
|
||||||
|
tag: 'button',
|
||||||
|
text: plainText('完成验证'),
|
||||||
|
type: 'primary',
|
||||||
|
width: 'fill',
|
||||||
|
behaviors: [{
|
||||||
|
type: 'callback',
|
||||||
|
value: { action: 'repair_verify', nonce },
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,6 +145,7 @@ export function menuHelpText() {
|
||||||
'3 · /new 开启新会话',
|
'3 · /new 开启新会话',
|
||||||
'4 · /status 连接状态',
|
'4 · /status 连接状态',
|
||||||
'5 · /help 本帮助',
|
'5 · /help 本帮助',
|
||||||
|
'6 · /repair 修复卡片按钮(请回复数字 6)',
|
||||||
'',
|
'',
|
||||||
'直接发送文字/图片即继续当前会话。',
|
'直接发送文字/图片即继续当前会话。',
|
||||||
'/session ID 或序号 绑定已有会话',
|
'/session ID 或序号 绑定已有会话',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { FeishuHarnessBridge } from './bridge.mjs';
|
import { FeishuHarnessBridge } from './bridge.mjs';
|
||||||
|
import { cardActionProbeCard } from './feishu-cards.mjs';
|
||||||
import { VerifiedFeishuChannel } from './feishu-channel.mjs';
|
import { VerifiedFeishuChannel } from './feishu-channel.mjs';
|
||||||
import {
|
import {
|
||||||
connectionTestTargetUnavailable,
|
connectionTestTargetUnavailable,
|
||||||
|
|
@ -6,6 +8,25 @@ import {
|
||||||
} from '../shared/connection-test.mjs';
|
} from '../shared/connection-test.mjs';
|
||||||
|
|
||||||
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
||||||
|
const CALLBACK_PROBE_SUCCESS_NOTICE = '✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。';
|
||||||
|
const CALLBACK_PROBE_TIMEOUT_NOTICE = '⚠️ 修复验证超时:未收到测试卡按钮的 card.action.trigger,不能确认按钮已修复。请不要重复授权;先检查飞书开放平台的卡片回调配置,确认后再发送 /repair。';
|
||||||
|
const CALLBACK_PROBE_SEND_FAILURE_NOTICE = '⚠️ 修复验证失败:无法发送专用测试卡,不能确认 card.action.trigger 已恢复。请不要重复授权;先检查机器人消息权限和连接状态。';
|
||||||
|
const CALLBACK_PROBE_ABORT_NOTICE = '⚠️ 修复验证中断:Runtime 已停止,未完成 card.action.trigger 实测,不能确认修复成功。请不要重复授权;先等待机器人恢复连接。';
|
||||||
|
|
||||||
|
function nonEmptyString(value) {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function strictCardOperatorOpenId(event) {
|
||||||
|
return nonEmptyString(event?.operator?.open_id)
|
||||||
|
?? nonEmptyString(event?.operator?.operator_id?.open_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeError(code, message) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = code;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
function httpInstanceWithTimeout(httpInstance, timeoutMs) {
|
function httpInstanceWithTimeout(httpInstance, timeoutMs) {
|
||||||
if (!httpInstance || typeof httpInstance.request !== 'function') return undefined;
|
if (!httpInstance || typeof httpInstance.request !== 'function') return undefined;
|
||||||
|
|
@ -41,9 +62,12 @@ export function createBridgeStatus({ allowedSenderCount = 1 } = {}) {
|
||||||
streamUpdates: 0,
|
streamUpdates: 0,
|
||||||
streamFallbacks: 0,
|
streamFallbacks: 0,
|
||||||
streamErrors: 0,
|
streamErrors: 0,
|
||||||
|
cardActionsReceived: 0,
|
||||||
|
cardActionProbesVerified: 0,
|
||||||
lastMessageAt: null,
|
lastMessageAt: null,
|
||||||
lastReplyAt: null,
|
lastReplyAt: null,
|
||||||
lastRejectedAt: null,
|
lastRejectedAt: null,
|
||||||
|
lastCardActionAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
agentPreset: 'standard',
|
agentPreset: 'standard',
|
||||||
authorizationMode: 'sender-open-id-allowlist',
|
authorizationMode: 'sender-open-id-allowlist',
|
||||||
|
|
@ -59,6 +83,7 @@ export function createBridgeStatus({ allowedSenderCount = 1 } = {}) {
|
||||||
*/
|
*/
|
||||||
export class FeishuRuntime {
|
export class FeishuRuntime {
|
||||||
#lark;
|
#lark;
|
||||||
|
#botId;
|
||||||
#appId;
|
#appId;
|
||||||
#appSecret;
|
#appSecret;
|
||||||
#domain;
|
#domain;
|
||||||
|
|
@ -69,15 +94,18 @@ export class FeishuRuntime {
|
||||||
#connectTimeoutMs;
|
#connectTimeoutMs;
|
||||||
#requestTimeoutMs;
|
#requestTimeoutMs;
|
||||||
#logger;
|
#logger;
|
||||||
|
#repair;
|
||||||
#client = null;
|
#client = null;
|
||||||
#bridge = null;
|
#bridge = null;
|
||||||
#wsClient = null;
|
#wsClient = null;
|
||||||
#starting = null;
|
#starting = null;
|
||||||
#abortController = null;
|
#abortController = null;
|
||||||
|
#pendingCardActionProbes = new Map();
|
||||||
#status;
|
#status;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
lark,
|
lark,
|
||||||
|
botId,
|
||||||
appId,
|
appId,
|
||||||
appSecret,
|
appSecret,
|
||||||
domain = 'feishu',
|
domain = 'feishu',
|
||||||
|
|
@ -85,6 +113,7 @@ export class FeishuRuntime {
|
||||||
ownerOpenIds,
|
ownerOpenIds,
|
||||||
harness,
|
harness,
|
||||||
state,
|
state,
|
||||||
|
repair,
|
||||||
replyTimeoutMs = 600000,
|
replyTimeoutMs = 600000,
|
||||||
connectTimeoutMs = 15000,
|
connectTimeoutMs = 15000,
|
||||||
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
||||||
|
|
@ -97,17 +126,22 @@ export class FeishuRuntime {
|
||||||
if (normalizedOwners.length === 0) throw new Error('FeishuRuntime requires at least one owner open_id');
|
if (normalizedOwners.length === 0) throw new Error('FeishuRuntime requires at least one owner open_id');
|
||||||
if (!harness) throw new Error('FeishuRuntime requires a Harness client');
|
if (!harness) throw new Error('FeishuRuntime requires a Harness client');
|
||||||
if (!state) throw new Error('FeishuRuntime requires a state store');
|
if (!state) throw new Error('FeishuRuntime requires a state store');
|
||||||
|
if (repair !== undefined && repair !== null && !nonEmptyString(botId)) {
|
||||||
|
throw new TypeError('FeishuRuntime repair capability requires a botId');
|
||||||
|
}
|
||||||
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
|
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
|
||||||
throw new TypeError('FeishuRuntime requestTimeoutMs must be a positive number');
|
throw new TypeError('FeishuRuntime requestTimeoutMs must be a positive number');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#lark = lark;
|
this.#lark = lark;
|
||||||
|
this.#botId = nonEmptyString(botId);
|
||||||
this.#appId = appId;
|
this.#appId = appId;
|
||||||
this.#appSecret = appSecret;
|
this.#appSecret = appSecret;
|
||||||
this.#domain = domain;
|
this.#domain = domain;
|
||||||
this.#ownerOpenIds = normalizedOwners;
|
this.#ownerOpenIds = normalizedOwners;
|
||||||
this.#harness = harness;
|
this.#harness = harness;
|
||||||
this.#state = state;
|
this.#state = state;
|
||||||
|
this.#repair = repair ?? null;
|
||||||
this.#replyTimeoutMs = replyTimeoutMs;
|
this.#replyTimeoutMs = replyTimeoutMs;
|
||||||
this.#connectTimeoutMs = connectTimeoutMs;
|
this.#connectTimeoutMs = connectTimeoutMs;
|
||||||
this.#requestTimeoutMs = requestTimeoutMs;
|
this.#requestTimeoutMs = requestTimeoutMs;
|
||||||
|
|
@ -166,6 +200,10 @@ export class FeishuRuntime {
|
||||||
state: this.#state,
|
state: this.#state,
|
||||||
status: this.#status,
|
status: this.#status,
|
||||||
allowedSenderOpenIds: new Set(this.#ownerOpenIds),
|
allowedSenderOpenIds: new Set(this.#ownerOpenIds),
|
||||||
|
botId: this.#botId,
|
||||||
|
appId: this.#appId,
|
||||||
|
repair: this.#repair,
|
||||||
|
repairOwnerOpenIds: new Set(this.#ownerOpenIds.filter((value) => value !== '*')),
|
||||||
replyTimeoutMs: this.#replyTimeoutMs,
|
replyTimeoutMs: this.#replyTimeoutMs,
|
||||||
signal,
|
signal,
|
||||||
logger: this.#logger,
|
logger: this.#logger,
|
||||||
|
|
@ -182,7 +220,9 @@ export class FeishuRuntime {
|
||||||
// subscribes card.action.trigger; the number-reply fallback covers
|
// subscribes card.action.trigger; the number-reply fallback covers
|
||||||
// apps that do not).
|
// apps that do not).
|
||||||
'card.action.trigger': (event) => {
|
'card.action.trigger': (event) => {
|
||||||
this.#bridge.onCardAction(event);
|
this.#status.cardActionsReceived += 1;
|
||||||
|
this.#status.lastCardActionAt = new Date().toISOString();
|
||||||
|
if (!this.#consumeCardActionProbe(event)) this.#bridge.onCardAction(event);
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -251,6 +291,147 @@ export class FeishuRuntime {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a one-shot callback card and resolve only after Feishu delivers the
|
||||||
|
* exact message/nonce/operator tuple over card.action.trigger. The controller
|
||||||
|
* uses this as the final proof for both browser- and chat-initiated repairs.
|
||||||
|
*/
|
||||||
|
async beginCardActionProbe({ expectedOperatorOpenId, timeoutMs = 90_000 } = {}) {
|
||||||
|
if (!this.#status.ready || !this.#client) {
|
||||||
|
throw probeError('card_action_probe_unavailable', '飞书机器人尚未连接');
|
||||||
|
}
|
||||||
|
const operatorOpenId = nonEmptyString(expectedOperatorOpenId);
|
||||||
|
if (!operatorOpenId || operatorOpenId === '*') {
|
||||||
|
throw new TypeError('A precise Feishu operator open_id is required');
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 10 * 60_000) {
|
||||||
|
throw new TypeError('Card-action probe timeout must be between 1 and 600000ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonce = randomUUID().replaceAll('-', '');
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await this.#client.im.v1.message.create({
|
||||||
|
params: { receive_id_type: 'open_id' },
|
||||||
|
data: {
|
||||||
|
receive_id: operatorOpenId,
|
||||||
|
msg_type: 'interactive',
|
||||||
|
content: cardActionProbeCard(nonce),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
void this.#sendCardActionProbeNotice(
|
||||||
|
operatorOpenId,
|
||||||
|
CALLBACK_PROBE_SEND_FAILURE_NOTICE,
|
||||||
|
'failure',
|
||||||
|
);
|
||||||
|
throw probeError('card_action_probe_send_failed', '无法发送飞书卡片回调测试');
|
||||||
|
}
|
||||||
|
if (response?.code && response.code !== 0) {
|
||||||
|
void this.#sendCardActionProbeNotice(
|
||||||
|
operatorOpenId,
|
||||||
|
CALLBACK_PROBE_SEND_FAILURE_NOTICE,
|
||||||
|
'failure',
|
||||||
|
);
|
||||||
|
throw probeError('card_action_probe_send_failed', '无法发送飞书卡片回调测试');
|
||||||
|
}
|
||||||
|
const messageId = nonEmptyString(response?.data?.message_id)
|
||||||
|
?? nonEmptyString(response?.message_id);
|
||||||
|
if (!messageId) {
|
||||||
|
void this.#sendCardActionProbeNotice(
|
||||||
|
operatorOpenId,
|
||||||
|
CALLBACK_PROBE_SEND_FAILURE_NOTICE,
|
||||||
|
'failure',
|
||||||
|
);
|
||||||
|
throw probeError('card_action_probe_send_failed', '飞书未返回测试卡片的消息 ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
const current = this.#pendingCardActionProbes.get(messageId);
|
||||||
|
if (!current || current.nonce !== nonce) return;
|
||||||
|
this.#pendingCardActionProbes.delete(messageId);
|
||||||
|
void this.#sendCardActionProbeNotice(
|
||||||
|
operatorOpenId,
|
||||||
|
CALLBACK_PROBE_TIMEOUT_NOTICE,
|
||||||
|
'timeout',
|
||||||
|
);
|
||||||
|
reject(probeError(
|
||||||
|
'card_action_probe_timeout',
|
||||||
|
'在规定时间内未收到飞书卡片按钮回调',
|
||||||
|
));
|
||||||
|
}, timeoutMs);
|
||||||
|
timeout.unref?.();
|
||||||
|
this.#pendingCardActionProbes.set(messageId, {
|
||||||
|
messageId,
|
||||||
|
nonce,
|
||||||
|
expectedOperatorOpenId: operatorOpenId,
|
||||||
|
timeout,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#consumeCardActionProbe(event) {
|
||||||
|
const messageId = nonEmptyString(event?.context?.open_message_id);
|
||||||
|
if (!messageId) return false;
|
||||||
|
const probe = this.#pendingCardActionProbes.get(messageId);
|
||||||
|
if (!probe) return false;
|
||||||
|
const value = event?.action?.value;
|
||||||
|
const operatorOpenId = strictCardOperatorOpenId(event);
|
||||||
|
if (value?.action !== 'repair_verify'
|
||||||
|
|| value?.nonce !== probe.nonce
|
||||||
|
|| operatorOpenId !== probe.expectedOperatorOpenId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
clearTimeout(probe.timeout);
|
||||||
|
this.#pendingCardActionProbes.delete(messageId);
|
||||||
|
this.#status.cardActionProbesVerified += 1;
|
||||||
|
// Start the terminal notification before resolving the controller-facing
|
||||||
|
// probe. A repair may rotate the App Secret and immediately replace this
|
||||||
|
// runtime after resolution; initiating the send here keeps chat and web
|
||||||
|
// repair flows equally observable. Notification failure never invalidates
|
||||||
|
// the callback proof itself.
|
||||||
|
void this.#sendCardActionProbeNotice(
|
||||||
|
operatorOpenId,
|
||||||
|
CALLBACK_PROBE_SUCCESS_NOTICE,
|
||||||
|
'success',
|
||||||
|
).finally(() => {
|
||||||
|
probe.resolve({
|
||||||
|
verified: true,
|
||||||
|
messageId,
|
||||||
|
operatorOpenId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sendCardActionProbeNotice(operatorOpenId, text, outcome) {
|
||||||
|
const client = this.#client;
|
||||||
|
if (!client) {
|
||||||
|
this.#logger.warn?.(`[dsh-feishu] unable to send the callback repair ${outcome} notice`);
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
return Promise.resolve().then(async () => {
|
||||||
|
const response = await client.im.v1.message.create({
|
||||||
|
params: { receive_id_type: 'open_id' },
|
||||||
|
data: {
|
||||||
|
receive_id: operatorOpenId,
|
||||||
|
msg_type: 'text',
|
||||||
|
content: JSON.stringify({ text }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response?.code && response.code !== 0) {
|
||||||
|
throw new Error('Feishu callback repair notice failed');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).catch(() => {
|
||||||
|
this.#logger.warn?.(`[dsh-feishu] unable to send the callback repair ${outcome} notice`);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async sendConnectionTest(text) {
|
async sendConnectionTest(text) {
|
||||||
if (!this.#status.ready || !this.#client) {
|
if (!this.#status.ready || !this.#client) {
|
||||||
const error = new Error('飞书机器人尚未连接');
|
const error = new Error('飞书机器人尚未连接');
|
||||||
|
|
@ -297,6 +478,16 @@ export class FeishuRuntime {
|
||||||
const abortController = this.#abortController;
|
const abortController = this.#abortController;
|
||||||
this.#abortController = null;
|
this.#abortController = null;
|
||||||
abortController?.abort(new DOMException('Feishu runtime stopped', 'AbortError'));
|
abortController?.abort(new DOMException('Feishu runtime stopped', 'AbortError'));
|
||||||
|
for (const probe of this.#pendingCardActionProbes.values()) {
|
||||||
|
clearTimeout(probe.timeout);
|
||||||
|
void this.#sendCardActionProbeNotice(
|
||||||
|
probe.expectedOperatorOpenId,
|
||||||
|
CALLBACK_PROBE_ABORT_NOTICE,
|
||||||
|
'abort',
|
||||||
|
);
|
||||||
|
probe.reject(probeError('abort', '飞书运行时已停止'));
|
||||||
|
}
|
||||||
|
this.#pendingCardActionProbes.clear();
|
||||||
this.#status.ready = false;
|
this.#status.ready = false;
|
||||||
if (this.#wsClient) {
|
if (this.#wsClient) {
|
||||||
this.#wsClient.close({ force: true });
|
this.#wsClient.close({ force: true });
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
||||||
import { RegistrationManager } from './registration-manager.mjs';
|
import { RegistrationManager } from './registration-manager.mjs';
|
||||||
|
import {
|
||||||
|
CALLBACK_REPAIR_OPERATION,
|
||||||
|
CallbackRepairManager,
|
||||||
|
} from './repair-manager.mjs';
|
||||||
import { REQUIRED_TENANT_SCOPES } from './plugin-controller.mjs';
|
import { REQUIRED_TENANT_SCOPES } from './plugin-controller.mjs';
|
||||||
|
|
||||||
const ACTIVE_REGISTRATION_STATES = new Set([
|
const ACTIVE_REGISTRATION_STATES = new Set([
|
||||||
|
|
@ -8,6 +12,8 @@ const ACTIVE_REGISTRATION_STATES = new Set([
|
||||||
]);
|
]);
|
||||||
const MUTABLE_REGISTRATION_STATES = new Set([...ACTIVE_REGISTRATION_STATES, 'saving']);
|
const MUTABLE_REGISTRATION_STATES = new Set([...ACTIVE_REGISTRATION_STATES, 'saving']);
|
||||||
const ALL_VISIBLE_SENDERS = '*';
|
const ALL_VISIBLE_SENDERS = '*';
|
||||||
|
const DEFAULT_CALLBACK_PROBE_TIMEOUT_MS = 120_000;
|
||||||
|
const MAX_CALLBACK_PROBE_TIMEOUT_MS = 600_000;
|
||||||
|
|
||||||
function idleConnection() {
|
function idleConnection() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -60,6 +66,26 @@ function secretRefFor(botId) {
|
||||||
return `DSH_FEISHU_APP_SECRET_${botId.slice(4).toUpperCase()}`;
|
return `DSH_FEISHU_APP_SECRET_${botId.slice(4).toUpperCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function configuredBotFingerprint(config) {
|
||||||
|
return JSON.stringify({
|
||||||
|
id: config.id,
|
||||||
|
appId: config.appId,
|
||||||
|
secretRef: config.secretRef,
|
||||||
|
ownerOpenIds: config.ownerOpenIds,
|
||||||
|
domain: config.domain,
|
||||||
|
botName: config.botName,
|
||||||
|
botOpenId: config.botOpenId,
|
||||||
|
activated: config.activated,
|
||||||
|
deletionPending: config.deletionPending === true,
|
||||||
|
connectedAt: config.connectedAt ?? null,
|
||||||
|
createdAt: config.createdAt ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalNonEmptyString(value) {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multi-account Feishu orchestration. Each bot owns its credential reference,
|
* Multi-account Feishu orchestration. Each bot owns its credential reference,
|
||||||
* runtime and session store. Config commits are serialized, while unrelated
|
* runtime and session store. Config commits are serialized, while unrelated
|
||||||
|
|
@ -77,11 +103,13 @@ export class MultiBotDshFeishuController {
|
||||||
#runtimes = new Map();
|
#runtimes = new Map();
|
||||||
#botErrors = new Map();
|
#botErrors = new Map();
|
||||||
#registrations = new Map();
|
#registrations = new Map();
|
||||||
|
#activeRepairs = new Map();
|
||||||
#botOwnership = new Map();
|
#botOwnership = new Map();
|
||||||
#latestRegistrationId = null;
|
#latestRegistrationId = null;
|
||||||
#configTransition = Promise.resolve();
|
#configTransition = Promise.resolve();
|
||||||
#botTransitions = new Map();
|
#botTransitions = new Map();
|
||||||
#revision = 1;
|
#revision = 1;
|
||||||
|
#callbackProbeTimeoutMs;
|
||||||
#closed = false;
|
#closed = false;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
|
|
@ -93,6 +121,7 @@ export class MultiBotDshFeishuController {
|
||||||
deleteState = async () => {},
|
deleteState = async () => {},
|
||||||
createBotId = makeBotId,
|
createBotId = makeBotId,
|
||||||
createRegistrationId = makeRegistrationId,
|
createRegistrationId = makeRegistrationId,
|
||||||
|
callbackProbeTimeoutMs = DEFAULT_CALLBACK_PROBE_TIMEOUT_MS,
|
||||||
}) {
|
}) {
|
||||||
if (typeof registerApp !== 'function') throw new Error('registerApp is required');
|
if (typeof registerApp !== 'function') throw new Error('registerApp is required');
|
||||||
if (typeof verifyApp !== 'function') throw new Error('verifyApp is required');
|
if (typeof verifyApp !== 'function') throw new Error('verifyApp is required');
|
||||||
|
|
@ -102,6 +131,11 @@ export class MultiBotDshFeishuController {
|
||||||
}
|
}
|
||||||
if (typeof createRuntime !== 'function') throw new Error('createRuntime is required');
|
if (typeof createRuntime !== 'function') throw new Error('createRuntime is required');
|
||||||
if (typeof deleteState !== 'function') throw new Error('deleteState must be a function');
|
if (typeof deleteState !== 'function') throw new Error('deleteState must be a function');
|
||||||
|
if (!Number.isFinite(callbackProbeTimeoutMs)
|
||||||
|
|| callbackProbeTimeoutMs <= 0
|
||||||
|
|| callbackProbeTimeoutMs > MAX_CALLBACK_PROBE_TIMEOUT_MS) {
|
||||||
|
throw new TypeError('callbackProbeTimeoutMs must be between 1 and 600000ms');
|
||||||
|
}
|
||||||
this.#registerApp = registerApp;
|
this.#registerApp = registerApp;
|
||||||
this.#verifyApp = verifyApp;
|
this.#verifyApp = verifyApp;
|
||||||
this.#credentials = credentials;
|
this.#credentials = credentials;
|
||||||
|
|
@ -110,6 +144,7 @@ export class MultiBotDshFeishuController {
|
||||||
this.#deleteState = deleteState;
|
this.#deleteState = deleteState;
|
||||||
this.#createBotId = createBotId;
|
this.#createBotId = createBotId;
|
||||||
this.#createRegistrationId = createRegistrationId;
|
this.#createRegistrationId = createRegistrationId;
|
||||||
|
this.#callbackProbeTimeoutMs = callbackProbeTimeoutMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
|
|
@ -194,6 +229,63 @@ export class MultiBotDshFeishuController {
|
||||||
return this.registrationStatus(id);
|
return this.registrationStatus(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startCallbackRepair(botId, { actorOpenId, chatId } = {}) {
|
||||||
|
this.#assertOpen();
|
||||||
|
const target = this.#requireBot(botId);
|
||||||
|
if (target.deletionPending) throw new Error('Cannot repair a Feishu bot pending deletion');
|
||||||
|
|
||||||
|
const activeId = this.#activeRepairs.get(botId);
|
||||||
|
const active = activeId ? this.#registrations.get(activeId) : null;
|
||||||
|
if (active && MUTABLE_REGISTRATION_STATES.has(active.manager.status().state)) {
|
||||||
|
return this.registrationStatus(active.id);
|
||||||
|
}
|
||||||
|
this.#activeRepairs.delete(botId);
|
||||||
|
|
||||||
|
const id = this.#createRegistrationId();
|
||||||
|
if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(id) || this.#registrations.has(id)) {
|
||||||
|
throw new Error('Registration id generator returned an invalid or duplicate id');
|
||||||
|
}
|
||||||
|
const record = {
|
||||||
|
id,
|
||||||
|
operation: CALLBACK_REPAIR_OPERATION,
|
||||||
|
manager: null,
|
||||||
|
botId,
|
||||||
|
createdNew: false,
|
||||||
|
cancelled: false,
|
||||||
|
remoteCommitted: false,
|
||||||
|
processing: null,
|
||||||
|
publicError: null,
|
||||||
|
stage: 'authorizing',
|
||||||
|
target: structuredClone(target),
|
||||||
|
targetFingerprint: configuredBotFingerprint(target),
|
||||||
|
initiator: {
|
||||||
|
actorOpenId: optionalNonEmptyString(actorOpenId),
|
||||||
|
chatId: optionalNonEmptyString(chatId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
record.manager = new CallbackRepairManager({
|
||||||
|
registerApp: this.#registerApp,
|
||||||
|
appId: target.appId,
|
||||||
|
domain: target.domain,
|
||||||
|
onCredentials: (result) => {
|
||||||
|
record.remoteCommitted = true;
|
||||||
|
const processing = this.#acceptCallbackRepair(record, result);
|
||||||
|
const tracked = processing.finally(() => {
|
||||||
|
if (record.processing === tracked) record.processing = null;
|
||||||
|
});
|
||||||
|
record.processing = tracked;
|
||||||
|
return tracked;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#registrations.set(id, record);
|
||||||
|
this.#activeRepairs.set(botId, id);
|
||||||
|
this.#latestRegistrationId = id;
|
||||||
|
this.#trimRegistrations();
|
||||||
|
record.manager.start();
|
||||||
|
this.#touch();
|
||||||
|
return this.registrationStatus(id);
|
||||||
|
}
|
||||||
|
|
||||||
hasRegistration(attemptId) {
|
hasRegistration(attemptId) {
|
||||||
return this.#registrations.has(attemptId);
|
return this.#registrations.has(attemptId);
|
||||||
}
|
}
|
||||||
|
|
@ -207,7 +299,14 @@ export class MultiBotDshFeishuController {
|
||||||
async cancelRegistration(attemptId = this.#latestRegistrationId) {
|
async cancelRegistration(attemptId = this.#latestRegistrationId) {
|
||||||
const record = this.#registrations.get(attemptId);
|
const record = this.#registrations.get(attemptId);
|
||||||
if (!record) return this.status();
|
if (!record) return this.status();
|
||||||
if (!MUTABLE_REGISTRATION_STATES.has(record.manager.status().state)) {
|
const state = record.manager.status().state;
|
||||||
|
// Once registerApp has returned, the platform-side update has committed.
|
||||||
|
// A repair must finish converging the returned credential and callback
|
||||||
|
// probe; cancelling here cannot roll that remote mutation back.
|
||||||
|
if (record.operation === CALLBACK_REPAIR_OPERATION && state === 'saving') {
|
||||||
|
return this.registrationStatus(attemptId);
|
||||||
|
}
|
||||||
|
if (!MUTABLE_REGISTRATION_STATES.has(state)) {
|
||||||
return this.registrationStatus(attemptId);
|
return this.registrationStatus(attemptId);
|
||||||
}
|
}
|
||||||
record.cancelled = true;
|
record.cancelled = true;
|
||||||
|
|
@ -398,8 +497,15 @@ export class MultiBotDshFeishuController {
|
||||||
async close() {
|
async close() {
|
||||||
if (this.#closed) return;
|
if (this.#closed) return;
|
||||||
this.#closed = true;
|
this.#closed = true;
|
||||||
|
const repairProcessing = [];
|
||||||
for (const record of this.#registrations.values()) {
|
for (const record of this.#registrations.values()) {
|
||||||
if (MUTABLE_REGISTRATION_STATES.has(record.manager.status().state)) {
|
const state = record.manager.status().state;
|
||||||
|
if (record.operation === CALLBACK_REPAIR_OPERATION && state === 'saving') {
|
||||||
|
// Stop projecting an in-flight repair, but do not request its local
|
||||||
|
// credential rollback after the remote update has committed.
|
||||||
|
record.manager.cancel();
|
||||||
|
if (record.processing) repairProcessing.push(record.processing);
|
||||||
|
} else if (MUTABLE_REGISTRATION_STATES.has(state)) {
|
||||||
record.cancelled = true;
|
record.cancelled = true;
|
||||||
record.manager.cancel();
|
record.manager.cancel();
|
||||||
}
|
}
|
||||||
|
|
@ -407,6 +513,15 @@ export class MultiBotDshFeishuController {
|
||||||
await this.#configTransition;
|
await this.#configTransition;
|
||||||
await Promise.allSettled([...this.#botTransitions.values()]);
|
await Promise.allSettled([...this.#botTransitions.values()]);
|
||||||
await Promise.allSettled([...this.#runtimes.keys()].map((id) => this.#stopRuntime(id)));
|
await Promise.allSettled([...this.#runtimes.keys()].map((id) => this.#stopRuntime(id)));
|
||||||
|
await Promise.allSettled(repairProcessing);
|
||||||
|
// A committed repair can be between SDK completion and its serialized
|
||||||
|
// credential/runtime transition when close begins. Waiting for the repair
|
||||||
|
// can therefore create a replacement runtime after the first drain. Drain
|
||||||
|
// both transition queues again, then stop every runtime created by that
|
||||||
|
// late forward-convergence work.
|
||||||
|
await this.#configTransition;
|
||||||
|
await Promise.allSettled([...this.#botTransitions.values()]);
|
||||||
|
await Promise.allSettled([...this.#runtimes.keys()].map((id) => this.#stopRuntime(id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#status({ registration, selectedBotId } = {}) {
|
#status({ registration, selectedBotId } = {}) {
|
||||||
|
|
@ -462,9 +577,226 @@ export class MultiBotDshFeishuController {
|
||||||
...snapshot,
|
...snapshot,
|
||||||
attempt: record.id,
|
attempt: record.id,
|
||||||
...(record.botId ? { botId: record.botId } : {}),
|
...(record.botId ? { botId: record.botId } : {}),
|
||||||
|
...(record.operation ? { operation: record.operation } : {}),
|
||||||
|
...(record.stage ? { stage: record.stage } : {}),
|
||||||
|
...(snapshot.state === 'error' && record.publicError
|
||||||
|
? { error: { ...record.publicError } }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #acceptCallbackRepair(record, result) {
|
||||||
|
const appId = result.client_id;
|
||||||
|
const appSecret = result.client_secret;
|
||||||
|
const ownerOpenId = optionalNonEmptyString(result.user_info?.open_id);
|
||||||
|
const tenantBrand = result.user_info?.tenant_brand;
|
||||||
|
const target = record.target;
|
||||||
|
|
||||||
|
if (record.cancelled) {
|
||||||
|
throw this.#callbackRepairError(
|
||||||
|
record,
|
||||||
|
'abort',
|
||||||
|
'Callback repair was cancelled before local activation.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (appId !== target.appId) {
|
||||||
|
throw this.#callbackRepairError(
|
||||||
|
record,
|
||||||
|
'repair_app_mismatch',
|
||||||
|
'Feishu returned credentials for a different app.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!ownerOpenId) {
|
||||||
|
throw this.#callbackRepairError(
|
||||||
|
record,
|
||||||
|
'repair_owner_missing',
|
||||||
|
'Feishu returned no repair operator identity.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tenantBrand !== undefined && tenantBrand !== target.domain) {
|
||||||
|
throw this.#callbackRepairError(
|
||||||
|
record,
|
||||||
|
'repair_domain_mismatch',
|
||||||
|
'Feishu returned credentials for a different account domain.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (record.initiator.actorOpenId && record.initiator.actorOpenId !== ownerOpenId) {
|
||||||
|
throw this.#callbackRepairError(
|
||||||
|
record,
|
||||||
|
'repair_owner_mismatch',
|
||||||
|
'The Feishu repair was confirmed by a different operator.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!target.ownerOpenIds.includes(ALL_VISIBLE_SENDERS)
|
||||||
|
&& !target.ownerOpenIds.includes(ownerOpenId)) {
|
||||||
|
throw this.#callbackRepairError(
|
||||||
|
record,
|
||||||
|
'repair_owner_mismatch',
|
||||||
|
'The Feishu repair operator is not an owner of this configured bot.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
record.stage = 'verifying_identity';
|
||||||
|
let verified;
|
||||||
|
try {
|
||||||
|
verified = await this.#verifyApp({
|
||||||
|
appId,
|
||||||
|
appSecret,
|
||||||
|
domain: target.domain,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw this.#callbackRepairError(
|
||||||
|
record,
|
||||||
|
'repair_credentials_invalid',
|
||||||
|
'Feishu could not verify the repaired app credentials.',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (target.botOpenId && verified?.openId !== target.botOpenId) {
|
||||||
|
throw this.#callbackRepairError(
|
||||||
|
record,
|
||||||
|
'repair_bot_mismatch',
|
||||||
|
'The repaired Feishu app belongs to a different bot identity.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime = await this.#serializeConfig(() => this.#withBotTransition(
|
||||||
|
record.botId,
|
||||||
|
async () => {
|
||||||
|
const current = this.#configStore.getBot(record.botId);
|
||||||
|
if (!current
|
||||||
|
|| current.deletionPending
|
||||||
|
|| configuredBotFingerprint(current) !== record.targetFingerprint) {
|
||||||
|
throw this.#callbackRepairError(
|
||||||
|
record,
|
||||||
|
'repair_target_changed',
|
||||||
|
'The Feishu bot changed while its callback repair was in progress.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let previous;
|
||||||
|
try {
|
||||||
|
previous = await this.#credentials.resolve(current.secretRef);
|
||||||
|
} catch (error) {
|
||||||
|
throw this.#callbackRepairError(
|
||||||
|
record,
|
||||||
|
'credential_update_failed',
|
||||||
|
'Unable to read the current Feishu credential.',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const credentialChanged = previous?.value !== appSecret;
|
||||||
|
if (credentialChanged) {
|
||||||
|
record.stage = 'persisting_secret';
|
||||||
|
try {
|
||||||
|
await this.#credentials.set(current.secretRef, appSecret);
|
||||||
|
} catch (writeError) {
|
||||||
|
const observed = await this.#credentials.resolve(current.secretRef).catch(() => null);
|
||||||
|
if (observed?.value !== appSecret) {
|
||||||
|
throw this.#callbackRepairError(
|
||||||
|
record,
|
||||||
|
'credential_update_failed',
|
||||||
|
'Unable to store the repaired Feishu credential.',
|
||||||
|
writeError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const persisted = await this.#credentials.resolve(current.secretRef).catch(() => null);
|
||||||
|
if (persisted?.value !== appSecret) {
|
||||||
|
throw this.#callbackRepairError(
|
||||||
|
record,
|
||||||
|
'credential_state_unknown',
|
||||||
|
'The repaired Feishu credential could not be confirmed after writing.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentRuntime;
|
||||||
|
// Callback subscriptions are delivered over the long connection.
|
||||||
|
// Always replace it after the platform-side callback update commits,
|
||||||
|
// even when registerApp returns the same secret and the old socket
|
||||||
|
// still reports healthy, so the probe never runs on stale metadata.
|
||||||
|
record.stage = 'restarting';
|
||||||
|
try {
|
||||||
|
await this.#startRuntime(current, appSecret);
|
||||||
|
currentRuntime = this.#runtimes.get(record.botId);
|
||||||
|
} catch (error) {
|
||||||
|
// The returned credential was already verified and persisted. Do
|
||||||
|
// not restore a potentially revoked old secret; reconnectBot can
|
||||||
|
// safely retry this forward state later.
|
||||||
|
this.#botErrors.set(record.botId, {
|
||||||
|
code: 'connection_failed',
|
||||||
|
message: '机器人回调修复已保存,但长连接未就绪,请点击重试。',
|
||||||
|
});
|
||||||
|
this.#touch();
|
||||||
|
throw this.#callbackRepairError(
|
||||||
|
record,
|
||||||
|
'repair_connection_failed',
|
||||||
|
'The repaired Feishu runtime could not be started.',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!currentRuntime) {
|
||||||
|
throw this.#callbackRepairError(
|
||||||
|
record,
|
||||||
|
'repair_connection_failed',
|
||||||
|
'The repaired Feishu runtime is unavailable.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.#botErrors.delete(record.botId);
|
||||||
|
this.#touch();
|
||||||
|
return currentRuntime;
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
if (typeof runtime.beginCardActionProbe !== 'function') {
|
||||||
|
throw this.#callbackRepairError(
|
||||||
|
record,
|
||||||
|
'card_action_probe_unavailable',
|
||||||
|
'The Feishu runtime cannot verify card callbacks.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
record.stage = 'awaiting_callback';
|
||||||
|
try {
|
||||||
|
const proof = await runtime.beginCardActionProbe({
|
||||||
|
expectedOperatorOpenId: ownerOpenId,
|
||||||
|
timeoutMs: this.#callbackProbeTimeoutMs,
|
||||||
|
...(record.initiator.chatId ? { chatId: record.initiator.chatId } : {}),
|
||||||
|
});
|
||||||
|
if (proof?.verified !== true) {
|
||||||
|
const error = new Error('Feishu runtime returned no callback proof');
|
||||||
|
error.code = 'card_action_probe_failed';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const code = error?.code === 'card_action_probe_timeout'
|
||||||
|
? 'card_action_probe_timeout'
|
||||||
|
: error?.code === 'card_action_probe_unavailable'
|
||||||
|
? 'card_action_probe_unavailable'
|
||||||
|
: error?.code === 'card_action_probe_send_failed'
|
||||||
|
? 'card_action_probe_send_failed'
|
||||||
|
: 'card_action_probe_failed';
|
||||||
|
throw this.#callbackRepairError(
|
||||||
|
record,
|
||||||
|
code,
|
||||||
|
code === 'card_action_probe_timeout'
|
||||||
|
? 'Timed out waiting for the Feishu callback verification button.'
|
||||||
|
: 'The Feishu card callback probe failed.',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
record.stage = 'verified';
|
||||||
|
record.publicError = null;
|
||||||
|
this.#touch();
|
||||||
|
}
|
||||||
|
|
||||||
|
#callbackRepairError(record, code, message, cause) {
|
||||||
|
record.publicError = { code, message };
|
||||||
|
const error = new Error(message, cause ? { cause } : undefined);
|
||||||
|
error.code = code;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
async #acceptCredentials(record, result) {
|
async #acceptCredentials(record, result) {
|
||||||
if (record.cancelled) throw new Error('Registration was cancelled');
|
if (record.cancelled) throw new Error('Registration was cancelled');
|
||||||
const appId = result.client_id;
|
const appId = result.client_id;
|
||||||
|
|
@ -611,6 +943,7 @@ export class MultiBotDshFeishuController {
|
||||||
botId: config.id,
|
botId: config.id,
|
||||||
config,
|
config,
|
||||||
appSecret,
|
appSecret,
|
||||||
|
repair: this.#runtimeRepairCapability(config.id),
|
||||||
});
|
});
|
||||||
this.#runtimes.set(config.id, runtime);
|
this.#runtimes.set(config.id, runtime);
|
||||||
try {
|
try {
|
||||||
|
|
@ -622,6 +955,27 @@ export class MultiBotDshFeishuController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#runtimeRepairCapability(botId) {
|
||||||
|
const ownedAttempt = (attemptId) => {
|
||||||
|
const record = this.#registrations.get(attemptId);
|
||||||
|
return record?.operation === CALLBACK_REPAIR_OPERATION && record.botId === botId
|
||||||
|
? record
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
return Object.freeze({
|
||||||
|
start: ({ actorOpenId, chatId } = {}) => this.startCallbackRepair(botId, {
|
||||||
|
actorOpenId,
|
||||||
|
chatId,
|
||||||
|
}),
|
||||||
|
status: ({ attemptId } = {}) => ownedAttempt(attemptId)
|
||||||
|
? this.registrationStatus(attemptId)
|
||||||
|
: null,
|
||||||
|
cancel: async ({ attemptId } = {}) => ownedAttempt(attemptId)
|
||||||
|
? this.cancelRegistration(attemptId)
|
||||||
|
: this.status(botId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async #stopRuntime(botId) {
|
async #stopRuntime(botId) {
|
||||||
const runtime = this.#runtimes.get(botId);
|
const runtime = this.#runtimes.get(botId);
|
||||||
this.#runtimes.delete(botId);
|
this.#runtimes.delete(botId);
|
||||||
|
|
|
||||||
109
src/channels/feishu/repair-manager.mjs
Normal file
109
src/channels/feishu/repair-manager.mjs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { RegistrationManager } from './registration-manager.mjs';
|
||||||
|
|
||||||
|
export const CARD_ACTION_CALLBACK = 'card.action.trigger';
|
||||||
|
export const CALLBACK_REPAIR_OPERATION = 'callback_repair';
|
||||||
|
|
||||||
|
function accountsDomain(domain) {
|
||||||
|
return domain === 'lark' ? 'accounts.larksuite.com' : 'accounts.feishu.cn';
|
||||||
|
}
|
||||||
|
|
||||||
|
function launcherDomain(domain) {
|
||||||
|
return domain === 'lark' ? 'open.larksuite.com' : 'open.feishu.cn';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SDK owns the rest of the verification URL. The repair flow accepts only
|
||||||
|
* its target account host and singleton SDK/app/addon parameters, and it can
|
||||||
|
* never fall back to the create-only flow. This catches regressions such as a
|
||||||
|
* literal `{{client_id}}` before the broken URL reaches the browser.
|
||||||
|
*/
|
||||||
|
export function assertCallbackRepairUrl(value, expectedAppId, domain = 'feishu') {
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(value);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Feishu callback repair returned an invalid verification URL');
|
||||||
|
}
|
||||||
|
const supportedDomain = domain === 'feishu' || domain === 'lark';
|
||||||
|
const clientIds = url.searchParams.getAll('clientID');
|
||||||
|
const transportProviders = url.searchParams.getAll('tp');
|
||||||
|
const addons = url.searchParams.getAll('addons');
|
||||||
|
const hasPlaceholder = String(expectedAppId).includes('{{')
|
||||||
|
|| String(expectedAppId).includes('}}')
|
||||||
|
|| [...url.searchParams.values()].some((item) => (
|
||||||
|
item.includes('{{') || item.includes('}}')
|
||||||
|
));
|
||||||
|
if (!supportedDomain
|
||||||
|
|| url.protocol !== 'https:'
|
||||||
|
// registerApp begins on accounts.* but the SDK deliberately returns the
|
||||||
|
// user-facing /page/launcher URL on open.*.
|
||||||
|
|| url.hostname !== launcherDomain(domain)
|
||||||
|
|| url.port !== ''
|
||||||
|
|| url.username !== ''
|
||||||
|
|| url.password !== ''
|
||||||
|
|| transportProviders.length !== 1
|
||||||
|
|| transportProviders[0] !== 'sdk'
|
||||||
|
|| clientIds.length !== 1
|
||||||
|
|| clientIds[0] !== expectedAppId
|
||||||
|
|| url.searchParams.has('createOnly')
|
||||||
|
|| addons.length !== 1
|
||||||
|
|| !addons[0]?.trim()
|
||||||
|
|| hasPlaceholder) {
|
||||||
|
throw new Error('Feishu callback repair returned an unsafe verification URL');
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One targeted update attempt for an existing Feishu app. It intentionally
|
||||||
|
* shares RegistrationManager's polling/state implementation while fixing the
|
||||||
|
* update manifest in one place so callers cannot accidentally add scopes,
|
||||||
|
* events, presets, or createOnly.
|
||||||
|
*/
|
||||||
|
export class CallbackRepairManager {
|
||||||
|
#manager;
|
||||||
|
#appId;
|
||||||
|
#domain;
|
||||||
|
|
||||||
|
constructor({ registerApp, onCredentials, appId, domain = 'feishu' } = {}) {
|
||||||
|
if (typeof registerApp !== 'function') throw new TypeError('registerApp is required');
|
||||||
|
if (typeof onCredentials !== 'function') throw new TypeError('onCredentials is required');
|
||||||
|
if (typeof appId !== 'string' || !appId.trim()) throw new TypeError('appId is required');
|
||||||
|
if (domain !== 'feishu' && domain !== 'lark') throw new TypeError('domain is invalid');
|
||||||
|
|
||||||
|
this.#appId = appId.trim();
|
||||||
|
this.#domain = domain;
|
||||||
|
this.#manager = new RegistrationManager({
|
||||||
|
registerApp: (options) => registerApp({
|
||||||
|
...options,
|
||||||
|
onQRCodeReady: (info) => {
|
||||||
|
assertCallbackRepairUrl(info?.url, this.#appId, this.#domain);
|
||||||
|
options.onQRCodeReady(info);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
onCredentials,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
return this.#manager.start({
|
||||||
|
source: 'deepseek-harness-card-action-repair',
|
||||||
|
domain: accountsDomain(this.#domain),
|
||||||
|
appId: this.#appId,
|
||||||
|
addons: {
|
||||||
|
preset: false,
|
||||||
|
callbacks: { items: [CARD_ACTION_CALLBACK] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
return this.#manager.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
return this.#manager.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CallbackRepairManager;
|
||||||
|
|
@ -2129,3 +2129,278 @@ test('session pagination preserves an explicitly selected workspace', async () =
|
||||||
assert.equal(useActionsFromCard(cards(sent).at(-1).content)[0], 'selected-11');
|
assert.equal(useActionsFromCard(cards(sent).at(-1).content)[0], 'selected-11');
|
||||||
assert.match(JSON.stringify(cards(sent).at(-1).content), new RegExp(workspaceB.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
assert.match(JSON.stringify(cards(sent).at(-1).content), new RegExp(workspaceB.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const REPAIR_APP_ID = 'cli_repair_test';
|
||||||
|
const REPAIR_BOT_ID = 'bot_repair_test';
|
||||||
|
const REPAIR_URL = `https://open.feishu.cn/page/launcher?tp=sdk&clientID=${REPAIR_APP_ID}&addons=safe`;
|
||||||
|
|
||||||
|
function repairStatus(state = 'qr_ready', overrides = {}) {
|
||||||
|
return {
|
||||||
|
registration: {
|
||||||
|
operation: 'callback_repair',
|
||||||
|
state,
|
||||||
|
attempt: 'repair_attempt_1',
|
||||||
|
botId: REPAIR_BOT_ID,
|
||||||
|
qrCodeUrl: REPAIR_URL,
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
remainingSeconds: 60,
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function repairCapability({
|
||||||
|
startStatus = repairStatus(),
|
||||||
|
status = startStatus,
|
||||||
|
cancelStatus = repairStatus('cancelled', { qrCodeUrl: undefined }),
|
||||||
|
} = {}) {
|
||||||
|
const calls = { start: [], status: [], cancel: [] };
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
capability: {
|
||||||
|
async start(args) { calls.start.push(args); return startStatus; },
|
||||||
|
async status(args) {
|
||||||
|
calls.status.push(args);
|
||||||
|
return typeof status === 'function' ? status(calls.status.length) : status;
|
||||||
|
},
|
||||||
|
async cancel(args) {
|
||||||
|
calls.cancel.push(args);
|
||||||
|
return typeof cancelStatus === 'function'
|
||||||
|
? cancelStatus(calls.cancel.length)
|
||||||
|
: cancelStatus;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function repairBridge({
|
||||||
|
allowedSenderOpenIds = new Set(['ou_owner']),
|
||||||
|
repairOwnerOpenIds,
|
||||||
|
capability,
|
||||||
|
client,
|
||||||
|
sent = [],
|
||||||
|
} = {}) {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
let asks = 0;
|
||||||
|
const activeClient = client ?? cardClient(async (outgoing) => sent.push(outgoing));
|
||||||
|
return {
|
||||||
|
fixture,
|
||||||
|
sent,
|
||||||
|
get asks() { return asks; },
|
||||||
|
bridge: new FeishuHarnessBridge({
|
||||||
|
client: activeClient,
|
||||||
|
channel: {},
|
||||||
|
harness: {
|
||||||
|
ensureRunning: async () => true,
|
||||||
|
ask: async () => { asks += 1; return 'unexpected'; },
|
||||||
|
},
|
||||||
|
state: fixture.state,
|
||||||
|
status: bridgeStatus(),
|
||||||
|
allowedSenderOpenIds,
|
||||||
|
repairOwnerOpenIds,
|
||||||
|
botId: REPAIR_BOT_ID,
|
||||||
|
appId: REPAIR_APP_ID,
|
||||||
|
repair: capability,
|
||||||
|
repairPollIntervalMs: 5,
|
||||||
|
repairLinkWaitMs: 100,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('/repair sends a validated ordinary SDK link without prompting Harness', async () => {
|
||||||
|
const repair = repairCapability();
|
||||||
|
const fx = repairBridge({ capability: repair.capability });
|
||||||
|
|
||||||
|
await fx.bridge.accept(event('repair-start', '/repair', { senderOpenId: 'ou_owner' }));
|
||||||
|
await fx.bridge.waitForIdle();
|
||||||
|
|
||||||
|
assert.equal(repair.calls.start.length, 1);
|
||||||
|
assert.deepEqual(repair.calls.start[0], {
|
||||||
|
botId: REPAIR_BOT_ID,
|
||||||
|
actorOpenId: 'ou_owner',
|
||||||
|
chatId: 'oc_chat',
|
||||||
|
});
|
||||||
|
assert.equal(fx.asks, 0);
|
||||||
|
const message = JSON.parse(fx.sent.at(-1).content).text;
|
||||||
|
assert.match(message, /card\.action\.trigger/);
|
||||||
|
assert.match(message, new RegExp(REPAIR_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||||
|
assert.match(message, /\/repair qr/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/repair status after a runtime restart never starts a duplicate authorization', async () => {
|
||||||
|
const repair = repairCapability();
|
||||||
|
const fx = repairBridge({ capability: repair.capability });
|
||||||
|
|
||||||
|
await fx.bridge.accept(event('repair-restarted-status', '/repair status', {
|
||||||
|
senderOpenId: 'ou_owner',
|
||||||
|
}));
|
||||||
|
await fx.bridge.waitForIdle();
|
||||||
|
|
||||||
|
assert.equal(repair.calls.start.length, 0);
|
||||||
|
assert.equal(repair.calls.status.length, 0);
|
||||||
|
assert.equal(repair.calls.cancel.length, 0);
|
||||||
|
const message = JSON.parse(fx.sent.at(-1).content).text;
|
||||||
|
assert.match(message, /没有可恢复的修复任务记录/);
|
||||||
|
assert.match(message, /不会启动新的授权/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('menu repair entry is number-only and reply 6 starts the same repair flow', async () => {
|
||||||
|
const repair = repairCapability();
|
||||||
|
const fx = repairBridge({ capability: repair.capability });
|
||||||
|
|
||||||
|
await fx.bridge.accept(event('repair-menu-open', '/m', { senderOpenId: 'ou_owner' }));
|
||||||
|
await fx.bridge.waitForIdle();
|
||||||
|
const menu = cards(fx.sent)[0].content;
|
||||||
|
assert.match(JSON.stringify(menu), /6 · 修复卡片按钮/);
|
||||||
|
assert.equal(buttonsFromCard(menu).some((button) => callbackAction(button) === 'repair'), false);
|
||||||
|
|
||||||
|
await fx.bridge.accept(event('repair-menu-six', '6', { senderOpenId: 'ou_owner' }));
|
||||||
|
await fx.bridge.waitForIdle();
|
||||||
|
assert.equal(repair.calls.start.length, 1);
|
||||||
|
assert.equal(fx.asks, 0);
|
||||||
|
assert.match(JSON.parse(fx.sent.at(-1).content).text, /card\.action\.trigger/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chat repair requires a private chat and an exact owner; wildcard never authorizes it', async () => {
|
||||||
|
const wildcardRepair = repairCapability();
|
||||||
|
const wildcard = repairBridge({
|
||||||
|
allowedSenderOpenIds: new Set(['*']),
|
||||||
|
capability: wildcardRepair.capability,
|
||||||
|
});
|
||||||
|
await wildcard.bridge.accept(event('repair-wildcard', '/repair', { senderOpenId: 'ou_anyone' }));
|
||||||
|
await wildcard.bridge.waitForIdle();
|
||||||
|
assert.equal(wildcardRepair.calls.start.length, 0);
|
||||||
|
assert.match(JSON.parse(wildcard.sent.at(-1).content).text, /没有可验证的接入者身份/);
|
||||||
|
|
||||||
|
const mixedRepair = repairCapability();
|
||||||
|
const mixed = repairBridge({
|
||||||
|
allowedSenderOpenIds: new Set(['*', 'ou_owner']),
|
||||||
|
capability: mixedRepair.capability,
|
||||||
|
});
|
||||||
|
await mixed.bridge.accept(event('repair-mixed-intruder', '/repair', { senderOpenId: 'ou_other' }));
|
||||||
|
await mixed.bridge.waitForIdle();
|
||||||
|
assert.equal(mixedRepair.calls.start.length, 0);
|
||||||
|
assert.match(JSON.parse(mixed.sent.at(-1).content).text, /只能由机器人接入者/);
|
||||||
|
await mixed.bridge.accept(event('repair-mixed-owner', '/repair', { senderOpenId: 'ou_owner' }));
|
||||||
|
await mixed.bridge.waitForIdle();
|
||||||
|
assert.equal(mixedRepair.calls.start.length, 1);
|
||||||
|
|
||||||
|
const groupRepair = repairCapability();
|
||||||
|
const group = repairBridge({ capability: groupRepair.capability });
|
||||||
|
await group.bridge.accept(event('repair-group', '/repair', {
|
||||||
|
senderOpenId: 'ou_owner',
|
||||||
|
chat_type: 'group',
|
||||||
|
chat_id: 'oc_group',
|
||||||
|
}));
|
||||||
|
await group.bridge.waitForIdle();
|
||||||
|
assert.equal(groupRepair.calls.start.length, 0);
|
||||||
|
assert.match(JSON.parse(group.sent.at(-1).content).text, /请私聊机器人/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/repair qr, status, verify and cancel stay scoped to the initiating owner', async () => {
|
||||||
|
const sent = [];
|
||||||
|
let sequence = 0;
|
||||||
|
const client = {
|
||||||
|
im: { v1: {
|
||||||
|
image: { create: async ({ data }) => {
|
||||||
|
assert.equal(data.image_type, 'message');
|
||||||
|
assert.equal(Buffer.isBuffer(data.image), true);
|
||||||
|
return { image_key: 'img_repair_qr' };
|
||||||
|
} },
|
||||||
|
message: { create: async (request) => {
|
||||||
|
sent.push(request);
|
||||||
|
sequence += 1;
|
||||||
|
return { code: 0, data: { message_id: `om_repair_${sequence}` } };
|
||||||
|
} },
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
const repair = repairCapability();
|
||||||
|
const fx = repairBridge({ capability: repair.capability, client, sent });
|
||||||
|
|
||||||
|
await fx.bridge.accept(event('repair-commands-start', '/repair', { senderOpenId: 'ou_owner' }));
|
||||||
|
await fx.bridge.waitForIdle();
|
||||||
|
await fx.bridge.accept(event('repair-commands-qr', '/repair qr', { senderOpenId: 'ou_owner' }));
|
||||||
|
await fx.bridge.waitForIdle();
|
||||||
|
assert.equal(sent.some((request) => request.data.msg_type === 'image'
|
||||||
|
&& JSON.parse(request.data.content).image_key === 'img_repair_qr'), true);
|
||||||
|
|
||||||
|
await fx.bridge.accept(event('repair-commands-status', '/repair status', { senderOpenId: 'ou_owner' }));
|
||||||
|
await fx.bridge.accept(event('repair-commands-verify', '/repair verify', { senderOpenId: 'ou_owner' }));
|
||||||
|
await fx.bridge.waitForIdle();
|
||||||
|
const textMessages = sent
|
||||||
|
.filter((request) => request.data.msg_type === 'text')
|
||||||
|
.map((request) => JSON.parse(request.data.content).text);
|
||||||
|
assert.equal(textMessages.some((text) => text.includes('修复任务正在等待授权')), true);
|
||||||
|
assert.equal(textMessages.some((text) => text.includes('授权尚未完成')), true);
|
||||||
|
|
||||||
|
await fx.bridge.accept(event('repair-commands-cancel', '/repair cancel', { senderOpenId: 'ou_owner' }));
|
||||||
|
await fx.bridge.waitForIdle();
|
||||||
|
assert.equal(repair.calls.cancel.length, 1);
|
||||||
|
assert.equal(repair.calls.cancel[0].actorOpenId, 'ou_owner');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/repair cancel only reports cancellation when the controller confirms it', async () => {
|
||||||
|
for (const state of ['saving', 'succeeded']) {
|
||||||
|
const repair = repairCapability({
|
||||||
|
cancelStatus: repairStatus(state, { qrCodeUrl: undefined }),
|
||||||
|
status: repairStatus(state, { qrCodeUrl: undefined }),
|
||||||
|
});
|
||||||
|
const fx = repairBridge({ capability: repair.capability });
|
||||||
|
await fx.bridge.accept(event(`repair-cancel-${state}-start`, '/repair', {
|
||||||
|
senderOpenId: 'ou_owner',
|
||||||
|
}));
|
||||||
|
await fx.bridge.waitForIdle();
|
||||||
|
await fx.bridge.accept(event(`repair-cancel-${state}`, '/repair cancel', {
|
||||||
|
senderOpenId: 'ou_owner',
|
||||||
|
}));
|
||||||
|
await fx.bridge.waitForIdle();
|
||||||
|
|
||||||
|
const reply = JSON.parse(fx.sent.at(-1).content).text;
|
||||||
|
assert.doesNotMatch(reply, /已取消本次修复授权/);
|
||||||
|
assert.match(reply, state === 'saving' ? /正在等待专用测试按钮/ : /修复完成/);
|
||||||
|
await eventually(() => repair.calls.status.length > 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/repair rejects placeholder or mismatched launcher links and cancels the attempt', async () => {
|
||||||
|
for (const badUrl of [
|
||||||
|
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=%7B%7Bclient_id%7D%7D',
|
||||||
|
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_other_app',
|
||||||
|
`https://open.feishu.cn/page/launcher?tp=card&clientID=${REPAIR_APP_ID}`,
|
||||||
|
]) {
|
||||||
|
const repair = repairCapability({
|
||||||
|
startStatus: repairStatus('qr_ready', { qrCodeUrl: badUrl }),
|
||||||
|
});
|
||||||
|
const fx = repairBridge({ capability: repair.capability });
|
||||||
|
await fx.bridge.accept(event(`repair-bad-${repair.calls.start.length}-${badUrl.length}`, '/repair', {
|
||||||
|
senderOpenId: 'ou_owner',
|
||||||
|
}));
|
||||||
|
await fx.bridge.waitForIdle();
|
||||||
|
assert.equal(repair.calls.cancel.length, 1);
|
||||||
|
const text = JSON.parse(fx.sent.at(-1).content).text;
|
||||||
|
assert.match(text, /无法安全验证/);
|
||||||
|
assert.doesNotMatch(text, /\{\{client_id\}\}|cli_other_app/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repair monitor reports expiry without claiming that the callback was fixed', async () => {
|
||||||
|
const repair = repairCapability({
|
||||||
|
status: repairStatus('expired', {
|
||||||
|
qrCodeUrl: undefined,
|
||||||
|
remainingSeconds: 0,
|
||||||
|
error: { code: 'expired_token', message: 'safe' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const fx = repairBridge({ capability: repair.capability });
|
||||||
|
await fx.bridge.accept(event('repair-expiry', '/repair', { senderOpenId: 'ou_owner' }));
|
||||||
|
await fx.bridge.waitForIdle();
|
||||||
|
await eventually(() => fx.sent.some((outgoing) => (
|
||||||
|
outgoing.msgType === 'text'
|
||||||
|
&& JSON.parse(outgoing.content).text.includes('授权链接已过期')
|
||||||
|
)));
|
||||||
|
const terminal = fx.sent
|
||||||
|
.filter((outgoing) => outgoing.msgType === 'text')
|
||||||
|
.map((outgoing) => JSON.parse(outgoing.content).text)
|
||||||
|
.find((text) => text.includes('授权链接已过期'));
|
||||||
|
assert.doesNotMatch(terminal, /修复完成/);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from '../../../plugin-src/client/channels/feishu/api.js';
|
} from '../../../plugin-src/client/channels/feishu/api.js';
|
||||||
|
|
||||||
test('multi-bot endpoints are bot-scoped and keep legacy operations separate', () => {
|
test('multi-bot endpoints are bot-scoped and keep legacy operations separate', () => {
|
||||||
|
assert.equal(FEISHU_ENDPOINTS.beginCallbackRepair, 'bot.callback-repair.begin');
|
||||||
assert.equal(FEISHU_ENDPOINTS.reconnectBot, 'bot.reconnect');
|
assert.equal(FEISHU_ENDPOINTS.reconnectBot, 'bot.reconnect');
|
||||||
assert.equal(FEISHU_ENDPOINTS.disconnectBot, 'bot.disconnect');
|
assert.equal(FEISHU_ENDPOINTS.disconnectBot, 'bot.disconnect');
|
||||||
assert.equal(FEISHU_ENDPOINTS.deleteBot, 'bot.delete');
|
assert.equal(FEISHU_ENDPOINTS.deleteBot, 'bot.delete');
|
||||||
|
|
@ -76,6 +77,7 @@ test('provision polling preserves the newly connected botId', () => {
|
||||||
botId: 'bot-new',
|
botId: 'bot-new',
|
||||||
}), {
|
}), {
|
||||||
status: 'connected',
|
status: 'connected',
|
||||||
|
operation: 'provision',
|
||||||
botId: 'bot-new',
|
botId: 'bot-new',
|
||||||
message: undefined,
|
message: undefined,
|
||||||
connection: undefined,
|
connection: undefined,
|
||||||
|
|
@ -123,6 +125,47 @@ test('client accepts a Host-rendered QR code without exposing credentials', () =
|
||||||
assert.equal('clientSecret' in provisioning, false);
|
assert.equal('clientSecret' in provisioning, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('client preserves callback repair identity across QR and poll projections', () => {
|
||||||
|
const provisioning = normalizeProvisioning({
|
||||||
|
attemptId: 'reg_repair',
|
||||||
|
operation: 'callback_repair',
|
||||||
|
botId: 'bot_target',
|
||||||
|
verificationUrl: 'https://accounts.feishu.cn/device?tp=sdk&clientID=cli_target',
|
||||||
|
qrCodeDataUrl: 'data:image/png;base64,AAAA',
|
||||||
|
});
|
||||||
|
assert.equal(provisioning.operation, 'callback_repair');
|
||||||
|
assert.equal(provisioning.botId, 'bot_target');
|
||||||
|
|
||||||
|
const poll = normalizePollResult({
|
||||||
|
status: 'connecting',
|
||||||
|
operation: 'callback_repair',
|
||||||
|
botId: 'bot_target',
|
||||||
|
});
|
||||||
|
assert.equal(poll.operation, 'callback_repair');
|
||||||
|
assert.equal(poll.botId, 'bot_target');
|
||||||
|
assert.throws(() => normalizeProvisioning({
|
||||||
|
attemptId: 'reg_broken',
|
||||||
|
operation: 'callback_repair',
|
||||||
|
verificationUrl: 'https://accounts.feishu.cn/device',
|
||||||
|
}), /botId/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client restores a submitted callback repair without requiring an expired QR URL', () => {
|
||||||
|
const provisioning = normalizeProvisioning({
|
||||||
|
attemptId: 'reg_committed',
|
||||||
|
operation: 'callback_repair',
|
||||||
|
botId: 'bot_target',
|
||||||
|
submitted: true,
|
||||||
|
pollIntervalMs: 800,
|
||||||
|
});
|
||||||
|
assert.equal(provisioning.attemptId, 'reg_committed');
|
||||||
|
assert.equal(provisioning.operation, 'callback_repair');
|
||||||
|
assert.equal(provisioning.botId, 'bot_target');
|
||||||
|
assert.equal(provisioning.submitted, true);
|
||||||
|
assert.equal(provisioning.verificationUrl, undefined);
|
||||||
|
assert.equal(provisioning.qrCodeDataUrl, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
test('client unwraps RpcResult and redacts credential-shaped error text', () => {
|
test('client unwraps RpcResult and redacts credential-shaped error text', () => {
|
||||||
assert.deepEqual(unwrapRpcResult({ ok: true, value: { connected: true } }), {
|
assert.deepEqual(unwrapRpcResult({ ok: true, value: { connected: true } }), {
|
||||||
connected: true,
|
connected: true,
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,246 @@ test('Feishu connection check requests and displays test-message feedback', asyn
|
||||||
onCancelRemove() {},
|
onCancelRemove() {},
|
||||||
}));
|
}));
|
||||||
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
|
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
|
||||||
|
assert.match(markup, /修复卡片按钮/);
|
||||||
|
assert.match(markup, /aria-label="修复飞书测试机器人的卡片按钮"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Feishu callback repair keeps a Host-submitted attempt when a stale QR cancel races saving', async (t) => {
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
let nextTimer = 0;
|
||||||
|
const timeouts = new Map();
|
||||||
|
const frames = new Map();
|
||||||
|
globalThis.window = {
|
||||||
|
setInterval() { return ++nextTimer; },
|
||||||
|
clearInterval() {},
|
||||||
|
setTimeout(callback) {
|
||||||
|
const id = ++nextTimer;
|
||||||
|
timeouts.set(id, callback);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
clearTimeout(id) { timeouts.delete(id); },
|
||||||
|
requestAnimationFrame(callback) {
|
||||||
|
const id = ++nextTimer;
|
||||||
|
frames.set(id, callback);
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const pending = frames.get(id);
|
||||||
|
if (!pending) return;
|
||||||
|
frames.delete(id);
|
||||||
|
pending();
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
cancelAnimationFrame(id) { frames.delete(id); },
|
||||||
|
};
|
||||||
|
t.after(() => {
|
||||||
|
if (previousWindow === undefined) delete globalThis.window;
|
||||||
|
else globalThis.window = previousWindow;
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
schemaVersion: 2,
|
||||||
|
revision: 1,
|
||||||
|
state: 'connected',
|
||||||
|
bots: [{
|
||||||
|
botId: 'bot_target',
|
||||||
|
state: 'connected',
|
||||||
|
connected: true,
|
||||||
|
configured: true,
|
||||||
|
workspace: '/workspace/current',
|
||||||
|
bot: { name: '目标机器人', appIdMasked: 'cli_tar••••rget' },
|
||||||
|
health: { status: 'healthy', summary: '长连接运行正常' },
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const calls = [];
|
||||||
|
const rpcCall = async (endpoint, payload) => {
|
||||||
|
calls.push({ endpoint, payload });
|
||||||
|
if (endpoint === FEISHU_ENDPOINTS.status) return { ok: true, value: snapshot };
|
||||||
|
if (endpoint === FEISHU_ENDPOINTS.beginCallbackRepair) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
attemptId: 'reg_repair',
|
||||||
|
operation: 'callback_repair',
|
||||||
|
botId: 'bot_target',
|
||||||
|
verificationUrl: 'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_target',
|
||||||
|
qrCodeDataUrl: 'data:image/png;base64,AAAA',
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
pollIntervalMs: 800,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (endpoint === FEISHU_ENDPOINTS.pollProvisioning) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
status: 'connecting',
|
||||||
|
operation: 'callback_repair',
|
||||||
|
botId: 'bot_target',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (endpoint === FEISHU_ENDPOINTS.cancelProvisioning) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
status: 'connecting',
|
||||||
|
operation: 'callback_repair',
|
||||||
|
botId: 'bot_target',
|
||||||
|
message: 'Callback repair was already submitted and is still being verified.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
let renderer;
|
||||||
|
await act(async () => {
|
||||||
|
renderer = create(React.createElement(FeishuSettingsTab, { rpcCall }));
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
const card = renderer.root.findByProps({ 'data-bot-id': 'bot_target' });
|
||||||
|
await act(async () => {
|
||||||
|
card.findAllByType('button')
|
||||||
|
.find((button) => textOf(button) === '修复卡片按钮').props.onClick();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(calls.some(({ endpoint, payload }) => endpoint === FEISHU_ENDPOINTS.beginCallbackRepair
|
||||||
|
&& payload.botId === 'bot_target'));
|
||||||
|
const officialLink = renderer.root.findByType('a');
|
||||||
|
assert.equal(
|
||||||
|
officialLink.props.href,
|
||||||
|
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_target',
|
||||||
|
);
|
||||||
|
assert.match(textOf(renderer.toJSON()), /不会创建新应用/);
|
||||||
|
|
||||||
|
const staleCancel = renderer.root.findAllByType('button')
|
||||||
|
.find((button) => textOf(button) === '取消修复');
|
||||||
|
assert.ok(staleCancel);
|
||||||
|
await act(async () => {
|
||||||
|
staleCancel.props.onClick();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
assert.ok(calls.some(({ endpoint, payload }) => endpoint === FEISHU_ENDPOINTS.cancelProvisioning
|
||||||
|
&& payload.attemptId === 'reg_repair'));
|
||||||
|
assert.match(textOf(renderer.toJSON()), /此阶段无法取消/);
|
||||||
|
assert.equal(renderer.root.findAllByType('button').some(
|
||||||
|
(button) => textOf(button) === '取消修复',
|
||||||
|
), false);
|
||||||
|
assert.ok(timeouts.size > 0, 'submitted repair keeps polling after the refused cancel');
|
||||||
|
await act(async () => { renderer.unmount(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Feishu callback repair recovers when a Host restart forgets the browser attempt', async (t) => {
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
let nextTimer = 0;
|
||||||
|
const frames = new Map();
|
||||||
|
globalThis.window = {
|
||||||
|
setInterval() { return ++nextTimer; },
|
||||||
|
clearInterval() {},
|
||||||
|
setTimeout() { return ++nextTimer; },
|
||||||
|
clearTimeout() {},
|
||||||
|
requestAnimationFrame(callback) {
|
||||||
|
const id = ++nextTimer;
|
||||||
|
frames.set(id, callback);
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const pending = frames.get(id);
|
||||||
|
if (!pending) return;
|
||||||
|
frames.delete(id);
|
||||||
|
pending();
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
cancelAnimationFrame(id) { frames.delete(id); },
|
||||||
|
};
|
||||||
|
t.after(() => {
|
||||||
|
if (previousWindow === undefined) delete globalThis.window;
|
||||||
|
else globalThis.window = previousWindow;
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
schemaVersion: 2,
|
||||||
|
revision: 1,
|
||||||
|
state: 'connected',
|
||||||
|
bots: [{
|
||||||
|
botId: 'bot_target',
|
||||||
|
state: 'connected',
|
||||||
|
connected: true,
|
||||||
|
configured: true,
|
||||||
|
workspace: '/workspace/current',
|
||||||
|
bot: { name: '目标机器人', appIdMasked: 'cli_tar••••rget' },
|
||||||
|
health: { status: 'healthy', summary: '长连接运行正常' },
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let beginCount = 0;
|
||||||
|
const calls = [];
|
||||||
|
const rpcCall = async (endpoint, payload) => {
|
||||||
|
calls.push({ endpoint, payload });
|
||||||
|
if (endpoint === FEISHU_ENDPOINTS.status) return { ok: true, value: snapshot };
|
||||||
|
if (endpoint === FEISHU_ENDPOINTS.beginCallbackRepair) {
|
||||||
|
beginCount += 1;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
attemptId: `reg_repair_${beginCount}`,
|
||||||
|
operation: 'callback_repair',
|
||||||
|
botId: 'bot_target',
|
||||||
|
verificationUrl: `https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_target&attempt=${beginCount}`,
|
||||||
|
qrCodeDataUrl: 'data:image/png;base64,AAAA',
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
pollIntervalMs: 800,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (endpoint === FEISHU_ENDPOINTS.cancelProvisioning) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'bad-request',
|
||||||
|
message: 'The provisioning attempt is no longer active.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected endpoint: ${endpoint}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
let renderer;
|
||||||
|
await act(async () => {
|
||||||
|
renderer = create(React.createElement(FeishuSettingsTab, { rpcCall }));
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
const repairButton = () => renderer.root.findByProps({ 'data-bot-id': 'bot_target' })
|
||||||
|
.findAllByType('button')
|
||||||
|
.find((button) => textOf(button) === '修复卡片按钮');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
repairButton().props.onClick();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
renderer.root.findAllByType('button')
|
||||||
|
.find((button) => textOf(button) === '换一个二维码').props.onClick();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
assert.equal(beginCount, 2, 'a stale cancel cannot block the replacement begin');
|
||||||
|
assert.match(renderer.root.findByType('a').props.href, /attempt=2$/);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
renderer.root.findAllByType('button')
|
||||||
|
.find((button) => textOf(button) === '取消修复').props.onClick();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
assert.match(textOf(renderer.toJSON()), /The provisioning attempt is no longer active/);
|
||||||
|
await act(async () => {
|
||||||
|
renderer.root.find((node) => node.props.role === 'alert')
|
||||||
|
.findAllByType('button')
|
||||||
|
.find((button) => textOf(button) === '关闭').props.onClick();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
assert.equal(renderer.root.findAll((node) => node.props.role === 'alert').length, 0);
|
||||||
|
assert.equal(repairButton().props.disabled, false);
|
||||||
|
assert.ok(calls.some(({ endpoint }) => endpoint === FEISHU_ENDPOINTS.cancelProvisioning));
|
||||||
|
await act(async () => { renderer.unmount(); });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Feishu reconnect failures render fixed English-safe feedback', async (t) => {
|
test('Feishu reconnect failures render fixed English-safe feedback', async (t) => {
|
||||||
|
|
|
||||||
38
test/channels/feishu/feishu-cards.test.mjs
Normal file
38
test/channels/feishu/feishu-cards.test.mjs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
cardActionProbeCard,
|
||||||
|
menuCard,
|
||||||
|
} from '../../../src/channels/feishu/feishu-cards.mjs';
|
||||||
|
|
||||||
|
function buttons(value, result = []) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) buttons(item, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== 'object') return result;
|
||||||
|
if (value.tag === 'button') result.push(value);
|
||||||
|
for (const child of Object.values(value)) buttons(child, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('menu exposes repair as number-only text instead of a callback button', () => {
|
||||||
|
const card = JSON.parse(menuCard());
|
||||||
|
assert.match(JSON.stringify(card), /6 · 修复卡片按钮/);
|
||||||
|
const actions = buttons(card).flatMap((button) => (
|
||||||
|
button.behaviors?.map((behavior) => behavior?.value?.action) ?? []
|
||||||
|
));
|
||||||
|
assert.deepEqual(actions, ['sessions', 'workspaces', 'new', 'status', 'help']);
|
||||||
|
assert.equal(actions.includes('repair'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('card-action probe carries only its action and opaque nonce', () => {
|
||||||
|
const nonce = '0123456789abcdef0123456789abcdef';
|
||||||
|
const card = JSON.parse(cardActionProbeCard(nonce));
|
||||||
|
const probe = buttons(card)[0];
|
||||||
|
assert.deepEqual(probe.behaviors, [{
|
||||||
|
type: 'callback',
|
||||||
|
value: { action: 'repair_verify', nonce },
|
||||||
|
}]);
|
||||||
|
assert.throws(() => cardActionProbeCard('{{client_id}}'), /safe card-action probe nonce/);
|
||||||
|
});
|
||||||
|
|
@ -39,8 +39,9 @@ class FakeWSClient {
|
||||||
FakeWSClient.instances.push(this);
|
FakeWSClient.instances.push(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() {
|
async start({ eventDispatcher } = {}) {
|
||||||
this.state = 'connecting';
|
this.state = 'connecting';
|
||||||
|
this.dispatcher = eventDispatcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
becomeReady() {
|
becomeReady() {
|
||||||
|
|
@ -202,3 +203,154 @@ test('FeishuRuntime fails closed when Harness is unavailable', async () => {
|
||||||
assert.equal(runtime.status.feishuLongConnectionState, 'failed');
|
assert.equal(runtime.status.feishuLongConnectionState, 'failed');
|
||||||
assert.equal(runtime.status.lastError, 'Harness unavailable');
|
assert.equal(runtime.status.lastError, 'Harness unavailable');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function startRuntimeForProbe(options = {}) {
|
||||||
|
const runtime = new FeishuRuntime({
|
||||||
|
lark: fakeLark(),
|
||||||
|
botId: 'bot_probe',
|
||||||
|
appId: 'cli_probe',
|
||||||
|
appSecret: 'secret',
|
||||||
|
ownerOpenIds: ['ou_owner'],
|
||||||
|
harness: { async ensureRunning() {} },
|
||||||
|
state: { hasSeen: () => false },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
const starting = runtime.start();
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
FakeWSClient.instances[0].becomeReady();
|
||||||
|
await starting;
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeAction({ messageId = 'message-1', nonce, operatorOpenId = 'ou_owner' } = {}) {
|
||||||
|
return {
|
||||||
|
operator: { open_id: operatorOpenId },
|
||||||
|
action: { value: { action: 'repair_verify', nonce } },
|
||||||
|
context: { open_message_id: messageId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('FeishuRuntime resolves a card-action probe only for the exact message, nonce and operator', async () => {
|
||||||
|
const runtime = await startRuntimeForProbe();
|
||||||
|
let settled = false;
|
||||||
|
const probe = runtime.beginCardActionProbe({
|
||||||
|
expectedOperatorOpenId: 'ou_owner',
|
||||||
|
timeoutMs: 1_000,
|
||||||
|
}).then((value) => {
|
||||||
|
settled = true;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
const request = FakeClient.sent[0];
|
||||||
|
assert.deepEqual(request.params, { receive_id_type: 'open_id' });
|
||||||
|
assert.equal(request.data.receive_id, 'ou_owner');
|
||||||
|
assert.equal(request.data.msg_type, 'interactive');
|
||||||
|
const card = JSON.parse(request.data.content);
|
||||||
|
const behavior = card.body.elements[1].columns[0].elements[0].behaviors[0];
|
||||||
|
assert.equal(behavior.value.action, 'repair_verify');
|
||||||
|
const nonce = behavior.value.nonce;
|
||||||
|
assert.match(nonce, /^[A-Za-z0-9_-]{16,128}$/);
|
||||||
|
|
||||||
|
const dispatch = FakeWSClient.instances[0].dispatcher.handlers['card.action.trigger'];
|
||||||
|
dispatch(probeAction({ messageId: 'message-other', nonce }));
|
||||||
|
dispatch(probeAction({ nonce: `${nonce}x` }));
|
||||||
|
dispatch(probeAction({ nonce, operatorOpenId: 'ou_other' }));
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(settled, false);
|
||||||
|
|
||||||
|
dispatch(probeAction({ nonce }));
|
||||||
|
assert.deepEqual(await probe, {
|
||||||
|
verified: true,
|
||||||
|
messageId: 'message-1',
|
||||||
|
operatorOpenId: 'ou_owner',
|
||||||
|
});
|
||||||
|
assert.equal(runtime.status.cardActionsReceived, 4);
|
||||||
|
assert.equal(runtime.status.cardActionProbesVerified, 1);
|
||||||
|
assert.equal(FakeClient.sent.length, 2);
|
||||||
|
assert.deepEqual(FakeClient.sent[1], {
|
||||||
|
params: { receive_id_type: 'open_id' },
|
||||||
|
data: {
|
||||||
|
receive_id: 'ou_owner',
|
||||||
|
msg_type: 'text',
|
||||||
|
content: JSON.stringify({
|
||||||
|
text: '✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FeishuRuntime times out and aborts pending card-action probes with stable codes', async () => {
|
||||||
|
const runtime = await startRuntimeForProbe();
|
||||||
|
await assert.rejects(
|
||||||
|
runtime.beginCardActionProbe({ expectedOperatorOpenId: 'ou_owner', timeoutMs: 10 }),
|
||||||
|
(error) => error?.code === 'card_action_probe_timeout',
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.match(
|
||||||
|
JSON.parse(FakeClient.sent.at(-1).data.content).text,
|
||||||
|
/修复验证超时.*不能确认按钮已修复.*不要重复授权/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pending = runtime.beginCardActionProbe({
|
||||||
|
expectedOperatorOpenId: 'ou_owner',
|
||||||
|
timeoutMs: 1_000,
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
await runtime.stop();
|
||||||
|
await assert.rejects(pending, (error) => error?.code === 'abort');
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.match(
|
||||||
|
JSON.parse(FakeClient.sent.at(-1).data.content).text,
|
||||||
|
/修复验证中断.*不能确认修复成功.*不要重复授权/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FeishuRuntime reports probe-card send failure without masking its stable error', async () => {
|
||||||
|
const runtime = await startRuntimeForProbe();
|
||||||
|
const client = FakeClient.instances[0];
|
||||||
|
client.im.v1.message.create = async (payload) => {
|
||||||
|
FakeClient.sent.push(payload);
|
||||||
|
if (payload.data.msg_type === 'interactive') return { code: 230001 };
|
||||||
|
return { code: 0, data: { message_id: 'failure-notice' } };
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
runtime.beginCardActionProbe({ expectedOperatorOpenId: 'ou_owner', timeoutMs: 1_000 }),
|
||||||
|
(error) => error?.code === 'card_action_probe_send_failed',
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(FakeClient.sent.length, 2);
|
||||||
|
assert.match(
|
||||||
|
JSON.parse(FakeClient.sent[1].data.content).text,
|
||||||
|
/修复验证失败.*不能确认 card\.action\.trigger 已恢复.*不要重复授权/,
|
||||||
|
);
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FeishuRuntime rejects imprecise probe operators and probes before connection', async () => {
|
||||||
|
const runtime = new FeishuRuntime({
|
||||||
|
lark: fakeLark(),
|
||||||
|
botId: 'bot_probe',
|
||||||
|
appId: 'cli_probe',
|
||||||
|
appSecret: 'secret',
|
||||||
|
ownerOpenIds: ['*'],
|
||||||
|
harness: { async ensureRunning() {} },
|
||||||
|
state: { hasSeen: () => false },
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
runtime.beginCardActionProbe({ expectedOperatorOpenId: 'ou_owner' }),
|
||||||
|
(error) => error?.code === 'card_action_probe_unavailable',
|
||||||
|
);
|
||||||
|
|
||||||
|
const starting = runtime.start();
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
FakeWSClient.instances[0].becomeReady();
|
||||||
|
await starting;
|
||||||
|
await assert.rejects(
|
||||||
|
runtime.beginCardActionProbe({ expectedOperatorOpenId: '*' }),
|
||||||
|
/precise Feishu operator/,
|
||||||
|
);
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,9 @@ function fixture({
|
||||||
failResolveRefs = new Set(),
|
failResolveRefs = new Set(),
|
||||||
failUnsetRefs = new Set(),
|
failUnsetRefs = new Set(),
|
||||||
runtimeStart,
|
runtimeStart,
|
||||||
|
callbackProbe,
|
||||||
|
verifyApp,
|
||||||
|
credentialSet,
|
||||||
deleteState,
|
deleteState,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const configStore = new MemoryConfigStore(bots);
|
const configStore = new MemoryConfigStore(bots);
|
||||||
|
|
@ -70,17 +73,20 @@ function fixture({
|
||||||
registrationRuns.push({ options, resolve, reject });
|
registrationRuns.push({ options, resolve, reject });
|
||||||
return promise;
|
return promise;
|
||||||
},
|
},
|
||||||
verifyApp: async ({ appId }) => ({
|
verifyApp: verifyApp ?? (async ({ appId }) => ({
|
||||||
name: `已验证 ${appId}`,
|
name: `已验证 ${appId}`,
|
||||||
openId: `ou_bot_${appId}`,
|
openId: `ou_bot_${appId}`,
|
||||||
activated: 1,
|
activated: 1,
|
||||||
}),
|
})),
|
||||||
credentials: {
|
credentials: {
|
||||||
async resolve(ref) {
|
async resolve(ref) {
|
||||||
if (failResolveRefs.has(ref)) throw new Error('credential provider unavailable');
|
if (failResolveRefs.has(ref)) throw new Error('credential provider unavailable');
|
||||||
return values.has(ref) ? { value: values.get(ref), source: 'file' } : undefined;
|
return values.has(ref) ? { value: values.get(ref), source: 'file' } : undefined;
|
||||||
},
|
},
|
||||||
async set(ref, value) { values.set(ref, value); },
|
async set(ref, value) {
|
||||||
|
if (credentialSet) await credentialSet({ ref, value, values });
|
||||||
|
else values.set(ref, value);
|
||||||
|
},
|
||||||
async unset(ref) {
|
async unset(ref) {
|
||||||
unsetCalls.push(ref);
|
unsetCalls.push(ref);
|
||||||
if (failUnsetRefs.has(ref)) throw new Error('credential provider is read-only');
|
if (failUnsetRefs.has(ref)) throw new Error('credential provider is read-only');
|
||||||
|
|
@ -88,7 +94,7 @@ function fixture({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
configStore,
|
configStore,
|
||||||
createRuntime: async ({ botId, config, appSecret }) => {
|
createRuntime: async ({ botId, config, appSecret, repair }) => {
|
||||||
const status = {
|
const status = {
|
||||||
ready: false,
|
ready: false,
|
||||||
feishuLongConnectionState: 'idle',
|
feishuLongConnectionState: 'idle',
|
||||||
|
|
@ -101,6 +107,8 @@ function fixture({
|
||||||
starts: 0,
|
starts: 0,
|
||||||
stops: 0,
|
stops: 0,
|
||||||
sentTests: [],
|
sentTests: [],
|
||||||
|
probes: [],
|
||||||
|
repair,
|
||||||
get status() { return structuredClone(status); },
|
get status() { return structuredClone(status); },
|
||||||
async start() {
|
async start() {
|
||||||
runtime.starts += 1;
|
runtime.starts += 1;
|
||||||
|
|
@ -118,6 +126,11 @@ function fixture({
|
||||||
runtime.sentTests.push(text);
|
runtime.sentTests.push(text);
|
||||||
return { sent: true };
|
return { sent: true };
|
||||||
},
|
},
|
||||||
|
async beginCardActionProbe(options) {
|
||||||
|
runtime.probes.push(structuredClone(options));
|
||||||
|
if (callbackProbe) return callbackProbe({ botId, runtime, options });
|
||||||
|
return { verified: true };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const history = runtimes.get(botId) ?? [];
|
const history = runtimes.get(botId) ?? [];
|
||||||
history.push(runtime);
|
history.push(runtime);
|
||||||
|
|
@ -131,6 +144,7 @@ function fixture({
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
createRegistrationId: () => `reg_${++registrationSequence}`,
|
createRegistrationId: () => `reg_${++registrationSequence}`,
|
||||||
|
callbackProbeTimeoutMs: 50,
|
||||||
});
|
});
|
||||||
return { controller, configStore, values, unsetCalls, registrationRuns, runtimes };
|
return { controller, configStore, values, unsetCalls, registrationRuns, runtimes };
|
||||||
}
|
}
|
||||||
|
|
@ -148,6 +162,11 @@ async function completeScan(fx, result) {
|
||||||
return fx.controller.registrationStatus(attemptId);
|
return fx.controller.registrationStatus(attemptId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function callbackRepairQrUrl(appId, domain = 'feishu') {
|
||||||
|
const host = domain === 'lark' ? 'open.larksuite.com' : 'open.feishu.cn';
|
||||||
|
return `https://${host}/page/launcher?tp=sdk&clientID=${encodeURIComponent(appId)}&addons=encoded`;
|
||||||
|
}
|
||||||
|
|
||||||
test('QR registration separates events from card callbacks', async () => {
|
test('QR registration separates events from card callbacks', async () => {
|
||||||
const fx = fixture({ createBotIds: ['bot_callbacks'] });
|
const fx = fixture({ createBotIds: ['bot_callbacks'] });
|
||||||
const started = fx.controller.startRegistration();
|
const started = fx.controller.startRegistration();
|
||||||
|
|
@ -165,6 +184,420 @@ test('QR registration separates events from card callbacks', async () => {
|
||||||
await fx.controller.close();
|
await fx.controller.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('callback repair is deduplicated per bot, updates only its secret, and proves the callback', async () => {
|
||||||
|
const existing = bot('bot_existing', 'existing');
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [existing],
|
||||||
|
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||||
|
verifyApp: async () => ({
|
||||||
|
name: existing.botName,
|
||||||
|
openId: existing.botOpenId,
|
||||||
|
activated: existing.activated,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const oldRuntime = fx.runtimes.get(existing.id)[0];
|
||||||
|
|
||||||
|
const started = fx.controller.startCallbackRepair(existing.id, {
|
||||||
|
actorOpenId: existing.ownerOpenIds[0],
|
||||||
|
chatId: 'oc_repair_chat',
|
||||||
|
});
|
||||||
|
const duplicate = fx.controller.startCallbackRepair(existing.id, {
|
||||||
|
actorOpenId: existing.ownerOpenIds[0],
|
||||||
|
chatId: 'oc_repair_chat',
|
||||||
|
});
|
||||||
|
const attemptId = started.registration.attempt;
|
||||||
|
assert.equal(duplicate.registration.attempt, attemptId);
|
||||||
|
assert.equal(started.registration.operation, 'callback_repair');
|
||||||
|
assert.equal(started.registration.botId, existing.id);
|
||||||
|
|
||||||
|
await waitFor(() => fx.registrationRuns.length === 1);
|
||||||
|
const run = fx.registrationRuns.shift();
|
||||||
|
assert.equal(run.options.appId, existing.appId);
|
||||||
|
assert.equal(run.options.domain, 'accounts.feishu.cn');
|
||||||
|
assert.equal(Object.hasOwn(run.options, 'createOnly'), false);
|
||||||
|
assert.equal(Object.hasOwn(run.options, 'appPreset'), false);
|
||||||
|
assert.deepEqual(run.options.addons, {
|
||||||
|
preset: false,
|
||||||
|
callbacks: { items: ['card.action.trigger'] },
|
||||||
|
});
|
||||||
|
run.options.onQRCodeReady({
|
||||||
|
url: callbackRepairQrUrl(existing.appId),
|
||||||
|
expireIn: 60,
|
||||||
|
});
|
||||||
|
run.resolve({
|
||||||
|
client_id: existing.appId,
|
||||||
|
client_secret: 'rotated-secret',
|
||||||
|
user_info: { open_id: existing.ownerOpenIds[0], tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'succeeded');
|
||||||
|
const result = fx.controller.registrationStatus(attemptId);
|
||||||
|
const history = fx.runtimes.get(existing.id);
|
||||||
|
assert.equal(result.registration.operation, 'callback_repair');
|
||||||
|
assert.equal(result.registration.botId, existing.id);
|
||||||
|
assert.equal(result.registration.stage, 'verified');
|
||||||
|
assert.deepEqual(fx.configStore.list(), [existing]);
|
||||||
|
assert.equal(fx.values.get(existing.secretRef), 'rotated-secret');
|
||||||
|
assert.equal(history.length, 2);
|
||||||
|
assert.equal(oldRuntime.stops, 1);
|
||||||
|
assert.equal(history[1].appSecret, 'rotated-secret');
|
||||||
|
assert.deepEqual(history[1].probes, [{
|
||||||
|
expectedOperatorOpenId: existing.ownerOpenIds[0],
|
||||||
|
timeoutMs: 50,
|
||||||
|
chatId: 'oc_repair_chat',
|
||||||
|
}]);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
JSON.stringify(result),
|
||||||
|
/rotated-secret|stable-secret|ownerOpenIds|secretRef/,
|
||||||
|
);
|
||||||
|
await fx.controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callback repair with an unchanged secret still refreshes the target runtime before probing', async () => {
|
||||||
|
const existing = bot('bot_existing', 'existing');
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [existing],
|
||||||
|
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||||
|
verifyApp: async () => ({ openId: existing.botOpenId }),
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const oldRuntime = fx.runtimes.get(existing.id)[0];
|
||||||
|
const started = fx.controller.startCallbackRepair(existing.id);
|
||||||
|
const attemptId = started.registration.attempt;
|
||||||
|
await waitFor(() => fx.registrationRuns.length === 1);
|
||||||
|
const run = fx.registrationRuns.shift();
|
||||||
|
run.options.onQRCodeReady({ url: callbackRepairQrUrl(existing.appId), expireIn: 60 });
|
||||||
|
run.resolve({
|
||||||
|
client_id: existing.appId,
|
||||||
|
client_secret: 'stable-secret',
|
||||||
|
user_info: { open_id: existing.ownerOpenIds[0], tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'succeeded');
|
||||||
|
const history = fx.runtimes.get(existing.id);
|
||||||
|
assert.equal(history.length, 2);
|
||||||
|
assert.equal(oldRuntime.stops, 1);
|
||||||
|
assert.deepEqual(oldRuntime.probes, []);
|
||||||
|
assert.deepEqual(history[1].probes, [{
|
||||||
|
expectedOperatorOpenId: existing.ownerOpenIds[0],
|
||||||
|
timeoutMs: 50,
|
||||||
|
}]);
|
||||||
|
await fx.controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('close drains a repair runtime created after delayed credential verification', async () => {
|
||||||
|
const existing = bot('bot_existing', 'existing');
|
||||||
|
let releaseVerify;
|
||||||
|
const verifyGate = new Promise((resolve) => { releaseVerify = resolve; });
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [existing],
|
||||||
|
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||||
|
verifyApp: async () => {
|
||||||
|
await verifyGate;
|
||||||
|
return { openId: existing.botOpenId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const oldRuntime = fx.runtimes.get(existing.id)[0];
|
||||||
|
const started = fx.controller.startCallbackRepair(existing.id);
|
||||||
|
const attemptId = started.registration.attempt;
|
||||||
|
await waitFor(() => fx.registrationRuns.length === 1);
|
||||||
|
const run = fx.registrationRuns.shift();
|
||||||
|
run.options.onQRCodeReady({ url: callbackRepairQrUrl(existing.appId), expireIn: 60 });
|
||||||
|
run.resolve({
|
||||||
|
client_id: existing.appId,
|
||||||
|
client_secret: 'rotated-secret',
|
||||||
|
user_info: { open_id: existing.ownerOpenIds[0], tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'saving');
|
||||||
|
|
||||||
|
const closing = fx.controller.close();
|
||||||
|
await waitFor(() => oldRuntime.stops === 1);
|
||||||
|
releaseVerify();
|
||||||
|
await closing;
|
||||||
|
|
||||||
|
const history = fx.runtimes.get(existing.id);
|
||||||
|
assert.equal(history.length, 2);
|
||||||
|
assert.equal(history[1].starts, 1);
|
||||||
|
assert.equal(history[1].stops, 1);
|
||||||
|
assert.equal(fx.values.get(existing.secretRef), 'rotated-secret');
|
||||||
|
assert.equal(fx.controller.status().totals.connected, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('close waits for a delayed callback probe before its final runtime drain', async () => {
|
||||||
|
const existing = bot('bot_existing', 'existing');
|
||||||
|
let releaseProbe;
|
||||||
|
const probeGate = new Promise((resolve) => { releaseProbe = resolve; });
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [existing],
|
||||||
|
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||||
|
verifyApp: async () => ({ openId: existing.botOpenId }),
|
||||||
|
callbackProbe: async () => probeGate,
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const started = fx.controller.startCallbackRepair(existing.id);
|
||||||
|
const attemptId = started.registration.attempt;
|
||||||
|
await waitFor(() => fx.registrationRuns.length === 1);
|
||||||
|
const run = fx.registrationRuns.shift();
|
||||||
|
run.options.onQRCodeReady({ url: callbackRepairQrUrl(existing.appId), expireIn: 60 });
|
||||||
|
run.resolve({
|
||||||
|
client_id: existing.appId,
|
||||||
|
client_secret: 'rotated-secret',
|
||||||
|
user_info: { open_id: existing.ownerOpenIds[0], tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
await waitFor(() => fx.runtimes.get(existing.id)?.at(-1).probes.length === 1);
|
||||||
|
|
||||||
|
let closeFinished = false;
|
||||||
|
const closing = fx.controller.close().then(() => { closeFinished = true; });
|
||||||
|
await flush();
|
||||||
|
assert.equal(closeFinished, false);
|
||||||
|
releaseProbe({ verified: true });
|
||||||
|
await closing;
|
||||||
|
|
||||||
|
const history = fx.runtimes.get(existing.id);
|
||||||
|
assert.equal(history.length, 2);
|
||||||
|
assert.equal(history[1].stops, 1);
|
||||||
|
assert.equal(closeFinished, true);
|
||||||
|
assert.equal(fx.controller.status().totals.connected, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('web callback repair accepts wildcard visibility but probes the precise SDK operator', async () => {
|
||||||
|
const existing = bot('bot_existing', 'existing');
|
||||||
|
existing.ownerOpenIds = ['*'];
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [existing],
|
||||||
|
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||||
|
verifyApp: async () => ({ openId: existing.botOpenId }),
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const oldRuntime = fx.runtimes.get(existing.id)[0];
|
||||||
|
const started = fx.controller.startCallbackRepair(existing.id);
|
||||||
|
const attemptId = started.registration.attempt;
|
||||||
|
await waitFor(() => fx.registrationRuns.length === 1);
|
||||||
|
const run = fx.registrationRuns.shift();
|
||||||
|
run.options.onQRCodeReady({ url: callbackRepairQrUrl(existing.appId), expireIn: 60 });
|
||||||
|
run.resolve({
|
||||||
|
client_id: existing.appId,
|
||||||
|
client_secret: 'stable-secret',
|
||||||
|
user_info: { open_id: 'ou_sdk_operator', tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'succeeded');
|
||||||
|
assert.deepEqual(fx.configStore.list(), [existing]);
|
||||||
|
const history = fx.runtimes.get(existing.id);
|
||||||
|
assert.equal(history.length, 2);
|
||||||
|
assert.equal(oldRuntime.stops, 1);
|
||||||
|
assert.deepEqual(history[1].probes, [{
|
||||||
|
expectedOperatorOpenId: 'ou_sdk_operator',
|
||||||
|
timeoutMs: 50,
|
||||||
|
}]);
|
||||||
|
await fx.controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chat callback repair rejects SDK authorization by a different operator', async () => {
|
||||||
|
const existing = bot('bot_existing', 'existing');
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [existing],
|
||||||
|
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||||
|
verifyApp: async () => ({ openId: existing.botOpenId }),
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const runtime = fx.runtimes.get(existing.id)[0];
|
||||||
|
const started = fx.controller.startCallbackRepair(existing.id, {
|
||||||
|
actorOpenId: existing.ownerOpenIds[0],
|
||||||
|
chatId: 'oc_owner_chat',
|
||||||
|
});
|
||||||
|
const attemptId = started.registration.attempt;
|
||||||
|
await waitFor(() => fx.registrationRuns.length === 1);
|
||||||
|
const run = fx.registrationRuns.shift();
|
||||||
|
run.options.onQRCodeReady({ url: callbackRepairQrUrl(existing.appId), expireIn: 60 });
|
||||||
|
run.resolve({
|
||||||
|
client_id: existing.appId,
|
||||||
|
client_secret: 'rotated-secret',
|
||||||
|
user_info: { open_id: 'ou_different_operator', tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'error');
|
||||||
|
const result = fx.controller.registrationStatus(attemptId);
|
||||||
|
assert.equal(result.registration.error.code, 'repair_owner_mismatch');
|
||||||
|
assert.equal(fx.values.get(existing.secretRef), 'stable-secret');
|
||||||
|
assert.equal(fx.runtimes.get(existing.id).length, 1);
|
||||||
|
assert.equal(runtime.stops, 0);
|
||||||
|
assert.deepEqual(runtime.probes, []);
|
||||||
|
await fx.controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callback repair rejects an app mismatch without changing local bot state', async () => {
|
||||||
|
const existing = bot('bot_existing', 'existing');
|
||||||
|
let verifyCalls = 0;
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [existing],
|
||||||
|
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||||
|
verifyApp: async () => {
|
||||||
|
verifyCalls += 1;
|
||||||
|
return { openId: existing.botOpenId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const runtime = fx.runtimes.get(existing.id)[0];
|
||||||
|
const started = fx.controller.startCallbackRepair(existing.id);
|
||||||
|
const attemptId = started.registration.attempt;
|
||||||
|
await waitFor(() => fx.registrationRuns.length === 1);
|
||||||
|
const run = fx.registrationRuns.shift();
|
||||||
|
run.options.onQRCodeReady({ url: callbackRepairQrUrl(existing.appId), expireIn: 60 });
|
||||||
|
run.resolve({
|
||||||
|
client_id: 'cli_wrong_app',
|
||||||
|
client_secret: 'wrong-secret',
|
||||||
|
user_info: { open_id: existing.ownerOpenIds[0], tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'error');
|
||||||
|
const result = fx.controller.registrationStatus(attemptId);
|
||||||
|
assert.equal(result.registration.error.code, 'repair_app_mismatch');
|
||||||
|
assert.equal(verifyCalls, 0);
|
||||||
|
assert.deepEqual(fx.configStore.list(), [existing]);
|
||||||
|
assert.equal(fx.values.get(existing.secretRef), 'stable-secret');
|
||||||
|
assert.equal(fx.runtimes.get(existing.id).length, 1);
|
||||||
|
assert.equal(runtime.stops, 0);
|
||||||
|
assert.deepEqual(runtime.probes, []);
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /wrong-secret/);
|
||||||
|
await fx.controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callback probe timeout keeps the verified rotated secret and ready runtime', async () => {
|
||||||
|
const existing = bot('bot_existing', 'existing');
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [existing],
|
||||||
|
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||||
|
verifyApp: async () => ({ openId: existing.botOpenId }),
|
||||||
|
callbackProbe: async () => {
|
||||||
|
const error = new Error('probe timed out with sensitive diagnostics');
|
||||||
|
error.code = 'card_action_probe_timeout';
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const started = fx.controller.startCallbackRepair(existing.id);
|
||||||
|
const attemptId = started.registration.attempt;
|
||||||
|
await waitFor(() => fx.registrationRuns.length === 1);
|
||||||
|
const run = fx.registrationRuns.shift();
|
||||||
|
run.options.onQRCodeReady({ url: callbackRepairQrUrl(existing.appId), expireIn: 60 });
|
||||||
|
run.resolve({
|
||||||
|
client_id: existing.appId,
|
||||||
|
client_secret: 'rotated-secret',
|
||||||
|
user_info: { open_id: existing.ownerOpenIds[0], tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'error');
|
||||||
|
const result = fx.controller.registrationStatus(attemptId);
|
||||||
|
const history = fx.runtimes.get(existing.id);
|
||||||
|
assert.equal(result.registration.error.code, 'card_action_probe_timeout');
|
||||||
|
assert.equal(result.registration.stage, 'awaiting_callback');
|
||||||
|
assert.equal(fx.values.get(existing.secretRef), 'rotated-secret');
|
||||||
|
assert.equal(history.length, 2);
|
||||||
|
assert.equal(history.at(-1).appSecret, 'rotated-secret');
|
||||||
|
assert.equal(result.bots[0].connected, true);
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /sensitive diagnostics|rotated-secret/);
|
||||||
|
await fx.controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callback repair restart failure keeps the remotely committed secret for reconnect', async () => {
|
||||||
|
const existing = bot('bot_existing', 'existing');
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [existing],
|
||||||
|
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||||
|
verifyApp: async () => ({ openId: existing.botOpenId }),
|
||||||
|
runtimeStart: async ({ runtime }) => {
|
||||||
|
if (runtime.appSecret === 'rotated-secret') throw new Error('new secret handshake failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const started = fx.controller.startCallbackRepair(existing.id);
|
||||||
|
const attemptId = started.registration.attempt;
|
||||||
|
await waitFor(() => fx.registrationRuns.length === 1);
|
||||||
|
const run = fx.registrationRuns.shift();
|
||||||
|
run.options.onQRCodeReady({ url: callbackRepairQrUrl(existing.appId), expireIn: 60 });
|
||||||
|
run.resolve({
|
||||||
|
client_id: existing.appId,
|
||||||
|
client_secret: 'rotated-secret',
|
||||||
|
user_info: { open_id: existing.ownerOpenIds[0], tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'error');
|
||||||
|
const result = fx.controller.registrationStatus(attemptId);
|
||||||
|
assert.equal(result.registration.error.code, 'repair_connection_failed');
|
||||||
|
assert.deepEqual(fx.configStore.list(), [existing]);
|
||||||
|
assert.equal(fx.values.get(existing.secretRef), 'rotated-secret');
|
||||||
|
assert.equal(fx.runtimes.get(existing.id).length, 2);
|
||||||
|
assert.equal(fx.runtimes.get(existing.id).at(-1).appSecret, 'rotated-secret');
|
||||||
|
assert.equal(result.bots[0].connected, false);
|
||||||
|
assert.equal(result.bots[0].error.code, 'connection_failed');
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /new secret handshake failed|rotated-secret/);
|
||||||
|
await fx.controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callback repair leaves the existing runtime intact when the new secret cannot be stored', async () => {
|
||||||
|
const existing = bot('bot_existing', 'existing');
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [existing],
|
||||||
|
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||||
|
verifyApp: async () => ({ openId: existing.botOpenId }),
|
||||||
|
credentialSet: async () => { throw new Error('credential provider is read-only'); },
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const runtime = fx.runtimes.get(existing.id)[0];
|
||||||
|
const started = fx.controller.startCallbackRepair(existing.id);
|
||||||
|
const attemptId = started.registration.attempt;
|
||||||
|
await waitFor(() => fx.registrationRuns.length === 1);
|
||||||
|
const run = fx.registrationRuns.shift();
|
||||||
|
run.options.onQRCodeReady({ url: callbackRepairQrUrl(existing.appId), expireIn: 60 });
|
||||||
|
run.resolve({
|
||||||
|
client_id: existing.appId,
|
||||||
|
client_secret: 'rotated-secret',
|
||||||
|
user_info: { open_id: existing.ownerOpenIds[0], tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'error');
|
||||||
|
const result = fx.controller.registrationStatus(attemptId);
|
||||||
|
assert.equal(result.registration.error.code, 'credential_update_failed');
|
||||||
|
assert.equal(fx.values.get(existing.secretRef), 'stable-secret');
|
||||||
|
assert.equal(fx.runtimes.get(existing.id).length, 1);
|
||||||
|
assert.equal(runtime.stops, 0);
|
||||||
|
assert.equal(result.bots[0].connected, true);
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /credential provider is read-only|rotated-secret/);
|
||||||
|
await fx.controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime repair capability is bot-bound and can cancel a pre-commit attempt', async () => {
|
||||||
|
const alpha = bot('bot_alpha', 'alpha');
|
||||||
|
const beta = bot('bot_beta', 'beta');
|
||||||
|
const fx = fixture({
|
||||||
|
bots: [alpha, beta],
|
||||||
|
secrets: { [alpha.secretRef]: 'secret-a', [beta.secretRef]: 'secret-b' },
|
||||||
|
});
|
||||||
|
await fx.controller.initialize();
|
||||||
|
const alphaRepair = fx.runtimes.get(alpha.id)[0].repair;
|
||||||
|
const betaRepair = fx.runtimes.get(beta.id)[0].repair;
|
||||||
|
|
||||||
|
const started = alphaRepair.start({
|
||||||
|
actorOpenId: alpha.ownerOpenIds[0],
|
||||||
|
chatId: 'oc_alpha',
|
||||||
|
});
|
||||||
|
const attemptId = started.registration.attempt;
|
||||||
|
assert.equal(started.registration.botId, alpha.id);
|
||||||
|
assert.equal(alphaRepair.status({ attemptId }).registration.attempt, attemptId);
|
||||||
|
assert.equal(betaRepair.status({ attemptId }), null);
|
||||||
|
const cancelled = await alphaRepair.cancel({ attemptId });
|
||||||
|
assert.equal(cancelled.registration.state, 'cancelled');
|
||||||
|
assert.deepEqual(fx.configStore.list(), [alpha, beta]);
|
||||||
|
assert.equal(fx.values.get(alpha.secretRef), 'secret-a');
|
||||||
|
assert.equal(fx.values.get(beta.secretRef), 'secret-b');
|
||||||
|
assert.equal(fx.runtimes.get(alpha.id).length, 1);
|
||||||
|
assert.equal(fx.runtimes.get(beta.id).length, 1);
|
||||||
|
await fx.controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('manual Feishu credentials are verified, stored host-side, and use app visibility for access', async () => {
|
test('manual Feishu credentials are verified, stored host-side, and use app visibility for access', async () => {
|
||||||
const fx = fixture({ createBotIds: ['bot_manual'] });
|
const fx = fixture({ createBotIds: ['bot_manual'] });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,244 @@ test('RPC dispatch matches every endpoint in client/api.js', async () => {
|
||||||
assert.doesNotMatch(JSON.stringify(attemptedSecret), /must-not-cross-browser-boundary/);
|
assert.doesNotMatch(JSON.stringify(attemptedSecret), /must-not-cross-browser-boundary/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('callback repair begins for exactly one bot and returns only a safe official QR projection', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const repair = status({
|
||||||
|
schemaVersion: 2,
|
||||||
|
phase: 'registering',
|
||||||
|
configured: true,
|
||||||
|
registration: {
|
||||||
|
state: 'qr_ready',
|
||||||
|
attempt: 'reg_repair',
|
||||||
|
operation: 'callback_repair',
|
||||||
|
botId: 'bot_target',
|
||||||
|
qrCodeUrl: 'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_target&addons=encoded&user_code=opaque',
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
},
|
||||||
|
bots: [{
|
||||||
|
botId: 'bot_target',
|
||||||
|
connected: true,
|
||||||
|
configured: true,
|
||||||
|
bot: { name: '目标机器人', appIdMasked: 'cli_tar••••rget' },
|
||||||
|
connection: { ready: true, feishuLongConnectionState: 'connected', harnessReachable: true },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
const controller = {
|
||||||
|
status: async () => repair,
|
||||||
|
registrationStatus: async () => repair,
|
||||||
|
startRegistration: async () => status(),
|
||||||
|
startCallbackRepair: async (botId) => { calls.push(botId); return repair; },
|
||||||
|
cancelRegistration: async () => repair,
|
||||||
|
disconnect: async () => status(),
|
||||||
|
};
|
||||||
|
const fx = await rpcFixture(controller);
|
||||||
|
const result = await fx.registration.handler(
|
||||||
|
FEISHU_ENDPOINTS.beginCallbackRepair,
|
||||||
|
{ botId: 'bot_target' },
|
||||||
|
signal(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.deepEqual(calls, ['bot_target']);
|
||||||
|
assert.equal(result.value.operation, 'callback_repair');
|
||||||
|
assert.equal(result.value.botId, 'bot_target');
|
||||||
|
assert.equal(
|
||||||
|
result.value.verificationUrl,
|
||||||
|
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_target&addons=encoded&user_code=opaque',
|
||||||
|
);
|
||||||
|
assert.match(result.value.qrCodeDataUrl, /^data:image\/png;base64,/);
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /client_secret|appSecret/);
|
||||||
|
|
||||||
|
const restored = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
|
||||||
|
assert.equal(restored.value.provisioning.operation, 'callback_repair');
|
||||||
|
assert.equal(restored.value.provisioning.botId, 'bot_target');
|
||||||
|
|
||||||
|
for (const payload of [
|
||||||
|
{},
|
||||||
|
{ botId: '../target' },
|
||||||
|
{ botId: 'bot_target', appSecret: 'must-not-leak' },
|
||||||
|
]) {
|
||||||
|
const invalid = await fx.registration.handler(
|
||||||
|
FEISHU_ENDPOINTS.beginCallbackRepair,
|
||||||
|
payload,
|
||||||
|
signal(),
|
||||||
|
);
|
||||||
|
assert.equal(invalid.ok, false);
|
||||||
|
assert.equal(invalid.error.code, 'bad-request');
|
||||||
|
assert.doesNotMatch(JSON.stringify(invalid), /must-not-leak|\.\.\/target/);
|
||||||
|
}
|
||||||
|
await fx.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status preserves a submitted callback repair attempt after its QR URL is discarded', async () => {
|
||||||
|
const secret = 'must-never-cross-the-rpc-boundary';
|
||||||
|
const saving = status({
|
||||||
|
schemaVersion: 2,
|
||||||
|
phase: 'connecting',
|
||||||
|
configured: true,
|
||||||
|
registration: {
|
||||||
|
state: 'saving',
|
||||||
|
attempt: 'reg_committed',
|
||||||
|
operation: 'callback_repair',
|
||||||
|
botId: 'bot_target',
|
||||||
|
},
|
||||||
|
bots: [{
|
||||||
|
botId: 'bot_target',
|
||||||
|
configured: true,
|
||||||
|
bot: { name: '目标机器人', domain: 'feishu', appSecret: secret },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
const controller = {
|
||||||
|
status: async () => saving,
|
||||||
|
startRegistration: async () => status(),
|
||||||
|
cancelRegistration: async () => saving,
|
||||||
|
disconnect: async () => status(),
|
||||||
|
};
|
||||||
|
const fx = await rpcFixture(controller);
|
||||||
|
const restored = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
|
||||||
|
|
||||||
|
assert.equal(restored.ok, true);
|
||||||
|
assert.equal(restored.value.state, 'connecting');
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
attemptId: restored.value.provisioning.attemptId,
|
||||||
|
operation: restored.value.provisioning.operation,
|
||||||
|
botId: restored.value.provisioning.botId,
|
||||||
|
submitted: restored.value.provisioning.submitted,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attemptId: 'reg_committed',
|
||||||
|
operation: 'callback_repair',
|
||||||
|
botId: 'bot_target',
|
||||||
|
submitted: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(restored.value.provisioning.verificationUrl, undefined);
|
||||||
|
assert.equal(restored.value.provisioning.qrCodeDataUrl, undefined);
|
||||||
|
assert.doesNotMatch(JSON.stringify(restored), new RegExp(secret));
|
||||||
|
await fx.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callback repair failures cross RPC only as fixed safe public errors', async () => {
|
||||||
|
const expected = new Map([
|
||||||
|
['repair_app_mismatch', 'The authorized Feishu app does not match the selected bot.'],
|
||||||
|
['repair_domain_mismatch', 'The authorized Feishu tenant does not match the selected bot.'],
|
||||||
|
['repair_owner_mismatch', 'The authorizing Feishu account is not an owner of the selected bot.'],
|
||||||
|
['repair_target_changed', 'The selected bot changed while repair was in progress. Start the repair again.'],
|
||||||
|
['credential_update_failed', 'Unable to store the repaired Feishu credentials.'],
|
||||||
|
['repair_connection_failed', 'The callback update was accepted, but the selected bot could not reconnect.'],
|
||||||
|
['card_action_probe_send_failed', 'The callback update was accepted, but the verification card could not be sent.'],
|
||||||
|
['card_action_probe_unavailable', 'The selected bot is not connected, so its card button cannot be verified.'],
|
||||||
|
['card_action_probe_timeout', 'Feishu accepted the update, but the card button was not verified in time. Start the repair again and click the test button within two minutes.'],
|
||||||
|
]);
|
||||||
|
for (const [code, message] of expected) {
|
||||||
|
const failed = status({
|
||||||
|
phase: 'error',
|
||||||
|
registration: {
|
||||||
|
state: 'error',
|
||||||
|
attempt: 'reg_failed',
|
||||||
|
operation: 'callback_repair',
|
||||||
|
botId: 'bot_target',
|
||||||
|
error: { code, message: 'secret=must-not-cross' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const controller = {
|
||||||
|
status: async () => failed,
|
||||||
|
startRegistration: async () => status(),
|
||||||
|
cancelRegistration: async () => failed,
|
||||||
|
disconnect: async () => status(),
|
||||||
|
};
|
||||||
|
const fx = await rpcFixture(controller);
|
||||||
|
const result = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
|
||||||
|
assert.deepEqual(result.value.error, { code, message });
|
||||||
|
assert.doesNotMatch(JSON.stringify(result), /must-not-cross/);
|
||||||
|
await fx.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callback repair refuses placeholder, non-SDK, and non-official verification links', async () => {
|
||||||
|
for (const qrCodeUrl of [
|
||||||
|
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=%7B%7Bclient_id%7D%7D',
|
||||||
|
'https://open.feishu.cn/page/launcher?tp=card&clientID=cli_target',
|
||||||
|
'https://evil.example/device?tp=sdk&clientID=cli_target',
|
||||||
|
'http://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_target',
|
||||||
|
'https://accounts.feishu.cn/device?tp=sdk&clientID=cli_target',
|
||||||
|
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_target&addons=x&createOnly=true',
|
||||||
|
]) {
|
||||||
|
const repair = status({
|
||||||
|
phase: 'registering',
|
||||||
|
configured: true,
|
||||||
|
registration: {
|
||||||
|
state: 'qr_ready',
|
||||||
|
attempt: 'reg_unsafe',
|
||||||
|
operation: 'callback_repair',
|
||||||
|
botId: 'bot_target',
|
||||||
|
qrCodeUrl,
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const controller = {
|
||||||
|
status: async () => repair,
|
||||||
|
registrationStatus: async () => repair,
|
||||||
|
startRegistration: async () => status(),
|
||||||
|
startCallbackRepair: async () => repair,
|
||||||
|
cancelRegistration: async () => repair,
|
||||||
|
disconnect: async () => status(),
|
||||||
|
};
|
||||||
|
const fx = await rpcFixture(controller);
|
||||||
|
const result = await fx.registration.handler(
|
||||||
|
FEISHU_ENDPOINTS.beginCallbackRepair,
|
||||||
|
{ botId: 'bot_target' },
|
||||||
|
signal(),
|
||||||
|
);
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.error.code, 'internal');
|
||||||
|
assert.equal(JSON.stringify(result).includes(qrCodeUrl), false);
|
||||||
|
await fx.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callback repair cannot be cancelled after configuration enters saving', async () => {
|
||||||
|
let cancels = 0;
|
||||||
|
const saving = status({
|
||||||
|
phase: 'connecting',
|
||||||
|
configured: true,
|
||||||
|
registration: {
|
||||||
|
state: 'saving',
|
||||||
|
attempt: 'reg_committed',
|
||||||
|
operation: 'callback_repair',
|
||||||
|
botId: 'bot_target',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const controller = {
|
||||||
|
status: async () => saving,
|
||||||
|
registrationStatus: async () => saving,
|
||||||
|
startRegistration: async () => status(),
|
||||||
|
cancelRegistration: async () => { cancels += 1; return saving; },
|
||||||
|
disconnect: async () => status(),
|
||||||
|
};
|
||||||
|
const fx = await rpcFixture(controller);
|
||||||
|
const result = await fx.registration.handler(
|
||||||
|
FEISHU_ENDPOINTS.cancelProvisioning,
|
||||||
|
{ attemptId: 'reg_committed' },
|
||||||
|
signal(),
|
||||||
|
);
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.value.status, 'connecting');
|
||||||
|
assert.equal(result.value.operation, 'callback_repair');
|
||||||
|
assert.equal(result.value.botId, 'bot_target');
|
||||||
|
assert.equal(cancels, 1);
|
||||||
|
|
||||||
|
const polled = await fx.registration.handler(
|
||||||
|
FEISHU_ENDPOINTS.pollProvisioning,
|
||||||
|
{ attemptId: 'reg_committed' },
|
||||||
|
signal(),
|
||||||
|
);
|
||||||
|
assert.equal(polled.ok, true);
|
||||||
|
assert.equal(polled.value.status, 'connecting');
|
||||||
|
await fx.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test('connection.test does not restart an already healthy long connection', async () => {
|
test('connection.test does not restart an already healthy long connection', async () => {
|
||||||
let reconnects = 0;
|
let reconnects = 0;
|
||||||
const healthy = status({
|
const healthy = status({
|
||||||
|
|
@ -739,6 +977,7 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
||||||
});
|
});
|
||||||
assert.match(constructed.statePath, /integrations\/dsh-feishu\/state\.json$/);
|
assert.match(constructed.statePath, /integrations\/dsh-feishu\/state\.json$/);
|
||||||
assert.equal(constructed.runtime.appSecret, 'host-only');
|
assert.equal(constructed.runtime.appSecret, 'host-only');
|
||||||
|
const repair = { start() {}, status() {}, cancel() {} };
|
||||||
await constructed.controller.createRuntime({
|
await constructed.controller.createRuntime({
|
||||||
botId: 'bot_alpha',
|
botId: 'bot_alpha',
|
||||||
config: {
|
config: {
|
||||||
|
|
@ -749,8 +988,11 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
||||||
ownerOpenIds: ['ou_alpha'],
|
ownerOpenIds: ['ou_alpha'],
|
||||||
},
|
},
|
||||||
appSecret: 'alpha-secret',
|
appSecret: 'alpha-secret',
|
||||||
|
repair,
|
||||||
});
|
});
|
||||||
const alphaState = constructed.runtime.state;
|
const alphaState = constructed.runtime.state;
|
||||||
|
assert.equal(constructed.runtime.botId, 'bot_alpha');
|
||||||
|
assert.equal(constructed.runtime.repair, repair);
|
||||||
await constructed.controller.createRuntime({
|
await constructed.controller.createRuntime({
|
||||||
botId: 'bot_beta',
|
botId: 'bot_beta',
|
||||||
config: {
|
config: {
|
||||||
|
|
|
||||||
146
test/channels/feishu/repair-manager.test.mjs
Normal file
146
test/channels/feishu/repair-manager.test.mjs
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
CallbackRepairManager,
|
||||||
|
assertCallbackRepairUrl,
|
||||||
|
} from '../../../src/channels/feishu/repair-manager.mjs';
|
||||||
|
|
||||||
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
async function waitFor(predicate, timeoutMs = 1000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (!predicate()) {
|
||||||
|
if (Date.now() >= deadline) throw new Error('condition timed out');
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('CallbackRepairManager targets one real app with callbacks only', async () => {
|
||||||
|
let observed;
|
||||||
|
let resolveRegistration;
|
||||||
|
const accepted = [];
|
||||||
|
const manager = new CallbackRepairManager({
|
||||||
|
appId: 'cli_real_app',
|
||||||
|
domain: 'feishu',
|
||||||
|
registerApp(options) {
|
||||||
|
observed = options;
|
||||||
|
return new Promise((resolve) => { resolveRegistration = resolve; });
|
||||||
|
},
|
||||||
|
onCredentials: async (result) => { accepted.push(result); },
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.start();
|
||||||
|
await waitFor(() => observed !== undefined);
|
||||||
|
assert.equal(observed.appId, 'cli_real_app');
|
||||||
|
assert.equal(observed.domain, 'accounts.feishu.cn');
|
||||||
|
assert.equal(Object.hasOwn(observed, 'createOnly'), false);
|
||||||
|
assert.equal(Object.hasOwn(observed, 'appPreset'), false);
|
||||||
|
assert.deepEqual(observed.addons, {
|
||||||
|
preset: false,
|
||||||
|
callbacks: { items: ['card.action.trigger'] },
|
||||||
|
});
|
||||||
|
assert.equal(Object.hasOwn(observed.addons, 'scopes'), false);
|
||||||
|
assert.equal(Object.hasOwn(observed.addons, 'events'), false);
|
||||||
|
|
||||||
|
observed.onQRCodeReady({
|
||||||
|
url: 'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_real_app&addons=encoded',
|
||||||
|
expireIn: 60,
|
||||||
|
});
|
||||||
|
assert.equal(manager.status().state, 'qr_ready');
|
||||||
|
resolveRegistration({
|
||||||
|
client_id: 'cli_real_app',
|
||||||
|
client_secret: 'private-secret',
|
||||||
|
user_info: { open_id: 'ou_owner', tenant_brand: 'feishu' },
|
||||||
|
});
|
||||||
|
await waitFor(() => manager.status().state === 'succeeded');
|
||||||
|
assert.equal(accepted.length, 1);
|
||||||
|
assert.equal(accepted[0].client_id, 'cli_real_app');
|
||||||
|
assert.doesNotMatch(JSON.stringify(manager.status()), /private-secret/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CallbackRepairManager uses the Lark accounts domain', async () => {
|
||||||
|
let observed;
|
||||||
|
const manager = new CallbackRepairManager({
|
||||||
|
appId: 'cli_lark_app',
|
||||||
|
domain: 'lark',
|
||||||
|
registerApp(options) {
|
||||||
|
observed = options;
|
||||||
|
return new Promise(() => {});
|
||||||
|
},
|
||||||
|
onCredentials: async () => {},
|
||||||
|
});
|
||||||
|
manager.start();
|
||||||
|
await waitFor(() => observed !== undefined);
|
||||||
|
assert.equal(observed.domain, 'accounts.larksuite.com');
|
||||||
|
manager.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callback repair accepts only the exact SDK URL origin and singleton repair params', () => {
|
||||||
|
assert.equal(
|
||||||
|
assertCallbackRepairUrl(
|
||||||
|
'https://open.larksuite.com/page/launcher?tp=sdk&clientID=cli_real_app&addons=x',
|
||||||
|
'cli_real_app',
|
||||||
|
'lark',
|
||||||
|
),
|
||||||
|
'https://open.larksuite.com/page/launcher?tp=sdk&clientID=cli_real_app&addons=x',
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => assertCallbackRepairUrl(
|
||||||
|
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=%7B%7Bclient_id%7D%7D&addons=x',
|
||||||
|
'cli_real_app',
|
||||||
|
),
|
||||||
|
/unsafe verification URL/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => assertCallbackRepairUrl(
|
||||||
|
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_real_app&addons=x&createOnly=true',
|
||||||
|
'cli_real_app',
|
||||||
|
),
|
||||||
|
/unsafe verification URL/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => assertCallbackRepairUrl(
|
||||||
|
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_real_app',
|
||||||
|
'cli_real_app',
|
||||||
|
),
|
||||||
|
/unsafe verification URL/,
|
||||||
|
);
|
||||||
|
for (const unsafe of [
|
||||||
|
'http://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_real_app&addons=x',
|
||||||
|
'https://open.larksuite.com/page/launcher?tp=sdk&clientID=cli_real_app&addons=x',
|
||||||
|
'https://open.feishu.cn:4430/page/launcher?tp=sdk&clientID=cli_real_app&addons=x',
|
||||||
|
'https://user@open.feishu.cn/page/launcher?tp=sdk&clientID=cli_real_app&addons=x',
|
||||||
|
'https://open.feishu.cn/page/launcher?clientID=cli_real_app&addons=x',
|
||||||
|
'https://open.feishu.cn/page/launcher?tp=web&clientID=cli_real_app&addons=x',
|
||||||
|
'https://open.feishu.cn/page/launcher?tp=sdk&tp=sdk&clientID=cli_real_app&addons=x',
|
||||||
|
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_real_app&clientID=cli_real_app&addons=x',
|
||||||
|
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_real_app&addons=x&addons=y',
|
||||||
|
'https://open.feishu.cn/page/launcher?tp=sdk&clientID=cli_real_app&addons=%7B%7Baddons%7D%7D',
|
||||||
|
]) {
|
||||||
|
assert.throws(
|
||||||
|
() => assertCallbackRepairUrl(unsafe, 'cli_real_app'),
|
||||||
|
/unsafe verification URL/,
|
||||||
|
unsafe,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unsafe SDK URL becomes a safe terminal registration error', async () => {
|
||||||
|
const manager = new CallbackRepairManager({
|
||||||
|
appId: 'cli_real_app',
|
||||||
|
registerApp(options) {
|
||||||
|
options.onQRCodeReady({
|
||||||
|
url: 'https://open.feishu.cn/page/launcher?tp=sdk&clientID=%7B%7Bclient_id%7D%7D&addons=x',
|
||||||
|
expireIn: 60,
|
||||||
|
});
|
||||||
|
return new Promise(() => {});
|
||||||
|
},
|
||||||
|
onCredentials: async () => {},
|
||||||
|
});
|
||||||
|
manager.start();
|
||||||
|
await waitFor(() => manager.status().state === 'error');
|
||||||
|
assert.deepEqual(manager.status().error, {
|
||||||
|
code: 'registration_failed',
|
||||||
|
message: 'Unable to register the Feishu app.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -183,3 +183,34 @@ test('periodic snapshots cannot restore locally cancelled Weixin or Feishu provi
|
||||||
'fs_attempt_stale',
|
'fs_attempt_stale',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Feishu restores a submitted callback repair as non-cancellable connecting state', () => {
|
||||||
|
const current = {
|
||||||
|
phase: 'ready',
|
||||||
|
revision: 3,
|
||||||
|
bots: [],
|
||||||
|
totals: { configured: 1, connected: 1 },
|
||||||
|
provisioning: null,
|
||||||
|
pageError: null,
|
||||||
|
statusError: null,
|
||||||
|
};
|
||||||
|
const restored = mergeFeishuSnapshotState(current, {
|
||||||
|
revision: 4,
|
||||||
|
// The target runtime can remain connected while the callback proof is
|
||||||
|
// pending, so Host aggregate state alone cannot identify this phase.
|
||||||
|
state: 'connected',
|
||||||
|
bots: [],
|
||||||
|
totals: { configured: 1, connected: 1 },
|
||||||
|
provisioning: {
|
||||||
|
attemptId: 'reg_committed',
|
||||||
|
operation: 'callback_repair',
|
||||||
|
botId: 'bot_target',
|
||||||
|
submitted: true,
|
||||||
|
expiresAt: 1,
|
||||||
|
pollIntervalMs: 800,
|
||||||
|
},
|
||||||
|
}, { restoreProvisioning: true, now: 2_000 });
|
||||||
|
|
||||||
|
assert.equal(restored.provisioning.phase, 'connecting');
|
||||||
|
assert.equal(restored.provisioning.expired, false);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -232,7 +232,7 @@ test('Feishu bot cards place the application identifier under the bot name', asy
|
||||||
assert.match(markup, /<button[^>]*aria-label="检查连接今天是牢梁"[^>]*><span>检查连接<\/span><\/button>/);
|
assert.match(markup, /<button[^>]*aria-label="检查连接今天是牢梁"[^>]*><span>检查连接<\/span><\/button>/);
|
||||||
assert.match(markup, /class="bxf-connectedFooter dim-cardFooter"/);
|
assert.match(markup, /class="bxf-connectedFooter dim-cardFooter"/);
|
||||||
assert.doesNotMatch(markup, /dim-cardSummary|长连接运行正常/);
|
assert.doesNotMatch(markup, /dim-cardSummary|长连接运行正常/);
|
||||||
assert.equal((markup.match(/dim-cardAction(?: |")/g) ?? []).length, 2);
|
assert.equal((markup.match(/dim-cardAction(?: |")/g) ?? []).length, 3);
|
||||||
assert.doesNotMatch(markup, /连接状态:|bxf-divider/);
|
assert.doesNotMatch(markup, /连接状态:|bxf-divider/);
|
||||||
assert.doesNotMatch(markup, /custom-bot-avatar/);
|
assert.doesNotMatch(markup, /custom-bot-avatar/);
|
||||||
assert.equal((markup.match(/class="bxf-metric dim-botMetric"/g) ?? []).length, 2);
|
assert.equal((markup.match(/class="bxf-metric dim-botMetric"/g) ?? []).length, 2);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from '../plugin-src/client/channels/feishu/api.js';
|
} from '../plugin-src/client/channels/feishu/api.js';
|
||||||
|
|
||||||
test('multi-bot endpoints are bot-scoped and keep legacy operations separate', () => {
|
test('multi-bot endpoints are bot-scoped and keep legacy operations separate', () => {
|
||||||
|
assert.equal(FEISHU_ENDPOINTS.beginCallbackRepair, 'bot.callback-repair.begin');
|
||||||
assert.equal(FEISHU_ENDPOINTS.reconnectBot, 'bot.reconnect');
|
assert.equal(FEISHU_ENDPOINTS.reconnectBot, 'bot.reconnect');
|
||||||
assert.equal(FEISHU_ENDPOINTS.disconnectBot, 'bot.disconnect');
|
assert.equal(FEISHU_ENDPOINTS.disconnectBot, 'bot.disconnect');
|
||||||
assert.equal(FEISHU_ENDPOINTS.deleteBot, 'bot.delete');
|
assert.equal(FEISHU_ENDPOINTS.deleteBot, 'bot.delete');
|
||||||
|
|
@ -76,6 +77,7 @@ test('provision polling preserves the newly connected botId', () => {
|
||||||
botId: 'bot-new',
|
botId: 'bot-new',
|
||||||
}), {
|
}), {
|
||||||
status: 'connected',
|
status: 'connected',
|
||||||
|
operation: 'provision',
|
||||||
botId: 'bot-new',
|
botId: 'bot-new',
|
||||||
message: undefined,
|
message: undefined,
|
||||||
connection: undefined,
|
connection: undefined,
|
||||||
|
|
@ -123,6 +125,31 @@ test('client accepts a Host-rendered QR code without exposing credentials', () =
|
||||||
assert.equal('clientSecret' in provisioning, false);
|
assert.equal('clientSecret' in provisioning, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('client preserves callback repair identity across QR and poll projections', () => {
|
||||||
|
const provisioning = normalizeProvisioning({
|
||||||
|
attemptId: 'reg_repair',
|
||||||
|
operation: 'callback_repair',
|
||||||
|
botId: 'bot_target',
|
||||||
|
verificationUrl: 'https://accounts.feishu.cn/device?tp=sdk&clientID=cli_target',
|
||||||
|
qrCodeDataUrl: 'data:image/png;base64,AAAA',
|
||||||
|
});
|
||||||
|
assert.equal(provisioning.operation, 'callback_repair');
|
||||||
|
assert.equal(provisioning.botId, 'bot_target');
|
||||||
|
|
||||||
|
const poll = normalizePollResult({
|
||||||
|
status: 'connecting',
|
||||||
|
operation: 'callback_repair',
|
||||||
|
botId: 'bot_target',
|
||||||
|
});
|
||||||
|
assert.equal(poll.operation, 'callback_repair');
|
||||||
|
assert.equal(poll.botId, 'bot_target');
|
||||||
|
assert.throws(() => normalizeProvisioning({
|
||||||
|
attemptId: 'reg_broken',
|
||||||
|
operation: 'callback_repair',
|
||||||
|
verificationUrl: 'https://accounts.feishu.cn/device',
|
||||||
|
}), /botId/);
|
||||||
|
});
|
||||||
|
|
||||||
test('client unwraps RpcResult and redacts credential-shaped error text', () => {
|
test('client unwraps RpcResult and redacts credential-shaped error text', () => {
|
||||||
assert.deepEqual(unwrapRpcResult({ ok: true, value: { connected: true } }), {
|
assert.deepEqual(unwrapRpcResult({ ok: true, value: { connected: true } }), {
|
||||||
connected: true,
|
connected: true,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue