mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 13:10:44 +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
|
|
@ -1,3 +1,4 @@
|
|||
import QRCode from 'qrcode';
|
||||
import {
|
||||
conversationKey,
|
||||
extractInboundMessage,
|
||||
|
|
@ -41,12 +42,28 @@ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无
|
|||
const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
|
||||
|
||||
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 WORKSPACE_LIST_COMMAND = /^\/workspacelist$/i;
|
||||
const NUMBER_REPLY = /^\d{1,2}$/;
|
||||
/** A displayed menu stays number-tappable for this long. */
|
||||
const MENU_TTL_MS = 10 * 60_000;
|
||||
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 = [
|
||||
'北汇星河 AIOS 已连接 DeepSeek Harness。',
|
||||
|
|
@ -64,6 +81,7 @@ const HELP_TEXT = [
|
|||
'/stop 停止当前任务',
|
||||
'/steer 补充指令 纠偏当前任务',
|
||||
'/status 检查连接状态',
|
||||
'/repair 修复卡片按钮回调',
|
||||
'/m(或 /menu) 打开交互卡片菜单',
|
||||
'/help 显示本帮助',
|
||||
].join('\n');
|
||||
|
|
@ -93,6 +111,87 @@ function senderOpenId(event) {
|
|||
?? 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) {
|
||||
return pending.needsPresentation !== true
|
||||
&& pending.questions[pending.index]
|
||||
|
|
@ -129,6 +228,14 @@ export class FeishuHarnessBridge {
|
|||
#replyTimeoutMs;
|
||||
#logger;
|
||||
#signal;
|
||||
#botId;
|
||||
#appId;
|
||||
#repair;
|
||||
#repairOwnerOpenIds;
|
||||
#repairAttempt = null;
|
||||
#repairMonitorVersion = 0;
|
||||
#repairPollIntervalMs;
|
||||
#repairLinkWaitMs;
|
||||
/** Number-tappable menus: conversation key → menu state. */
|
||||
#menus = new Map();
|
||||
/** Interactive-card message id → route context for button callbacks. */
|
||||
|
|
@ -141,6 +248,12 @@ export class FeishuHarnessBridge {
|
|||
state,
|
||||
status,
|
||||
allowedSenderOpenIds = new Set(),
|
||||
botId,
|
||||
appId,
|
||||
repair,
|
||||
repairOwnerOpenIds,
|
||||
repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
|
||||
repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
|
||||
replyTimeoutMs = 600_000,
|
||||
logger = console,
|
||||
signal,
|
||||
|
|
@ -148,12 +261,35 @@ export class FeishuHarnessBridge {
|
|||
if (!client || !harness || !state || !status) {
|
||||
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.#channel = channel;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#status = status;
|
||||
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.#logger = logger;
|
||||
this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
|
||||
|
|
@ -436,6 +572,10 @@ export class FeishuHarnessBridge {
|
|||
return;
|
||||
}
|
||||
|
||||
if (commandText !== null && REPAIR_COMMAND_PREFIX.test(commandText)) {
|
||||
await this.#handleRepairCommand(event, commandText);
|
||||
return;
|
||||
}
|
||||
if (commandText === '/help') {
|
||||
await this.#send(event.message.chat_id, HELP_TEXT);
|
||||
return;
|
||||
|
|
@ -467,7 +607,11 @@ export class FeishuHarnessBridge {
|
|||
if (NUMBER_REPLY.test(commandText)) {
|
||||
const menu = this.#takeMenu(key);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -508,6 +652,321 @@ export class FeishuHarnessBridge {
|
|||
|
||||
// ── 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
|
||||
* allowed sender: group members outside the allowlist must never drive
|
||||
|
|
@ -592,13 +1051,17 @@ export class FeishuHarnessBridge {
|
|||
return menu;
|
||||
}
|
||||
|
||||
async #handleMenuPick(menu, number, { chatId, key }) {
|
||||
async #handleMenuPick(menu, number, { chatId, key, event }) {
|
||||
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) {
|
||||
await this.#send(chatId, '菜单没有这个编号,回复 /m 重新打开。');
|
||||
return;
|
||||
}
|
||||
if (action === 'repair') {
|
||||
await this.#handleRepairCommand(event, '/repair');
|
||||
return;
|
||||
}
|
||||
await this.#handleCardAction(action, { chatId, key });
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,42 @@ export function menuCard() {
|
|||
button('3 · 新会话', 'new'),
|
||||
button('4 · 状态', 'status'),
|
||||
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 开启新会话',
|
||||
'4 · /status 连接状态',
|
||||
'5 · /help 本帮助',
|
||||
'6 · /repair 修复卡片按钮(请回复数字 6)',
|
||||
'',
|
||||
'直接发送文字/图片即继续当前会话。',
|
||||
'/session ID 或序号 绑定已有会话',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { FeishuHarnessBridge } from './bridge.mjs';
|
||||
import { cardActionProbeCard } from './feishu-cards.mjs';
|
||||
import { VerifiedFeishuChannel } from './feishu-channel.mjs';
|
||||
import {
|
||||
connectionTestTargetUnavailable,
|
||||
|
|
@ -6,6 +8,25 @@ import {
|
|||
} from '../shared/connection-test.mjs';
|
||||
|
||||
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) {
|
||||
if (!httpInstance || typeof httpInstance.request !== 'function') return undefined;
|
||||
|
|
@ -41,9 +62,12 @@ export function createBridgeStatus({ allowedSenderCount = 1 } = {}) {
|
|||
streamUpdates: 0,
|
||||
streamFallbacks: 0,
|
||||
streamErrors: 0,
|
||||
cardActionsReceived: 0,
|
||||
cardActionProbesVerified: 0,
|
||||
lastMessageAt: null,
|
||||
lastReplyAt: null,
|
||||
lastRejectedAt: null,
|
||||
lastCardActionAt: null,
|
||||
lastError: null,
|
||||
agentPreset: 'standard',
|
||||
authorizationMode: 'sender-open-id-allowlist',
|
||||
|
|
@ -59,6 +83,7 @@ export function createBridgeStatus({ allowedSenderCount = 1 } = {}) {
|
|||
*/
|
||||
export class FeishuRuntime {
|
||||
#lark;
|
||||
#botId;
|
||||
#appId;
|
||||
#appSecret;
|
||||
#domain;
|
||||
|
|
@ -69,15 +94,18 @@ export class FeishuRuntime {
|
|||
#connectTimeoutMs;
|
||||
#requestTimeoutMs;
|
||||
#logger;
|
||||
#repair;
|
||||
#client = null;
|
||||
#bridge = null;
|
||||
#wsClient = null;
|
||||
#starting = null;
|
||||
#abortController = null;
|
||||
#pendingCardActionProbes = new Map();
|
||||
#status;
|
||||
|
||||
constructor({
|
||||
lark,
|
||||
botId,
|
||||
appId,
|
||||
appSecret,
|
||||
domain = 'feishu',
|
||||
|
|
@ -85,6 +113,7 @@ export class FeishuRuntime {
|
|||
ownerOpenIds,
|
||||
harness,
|
||||
state,
|
||||
repair,
|
||||
replyTimeoutMs = 600000,
|
||||
connectTimeoutMs = 15000,
|
||||
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 (!harness) throw new Error('FeishuRuntime requires a Harness client');
|
||||
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) {
|
||||
throw new TypeError('FeishuRuntime requestTimeoutMs must be a positive number');
|
||||
}
|
||||
|
||||
this.#lark = lark;
|
||||
this.#botId = nonEmptyString(botId);
|
||||
this.#appId = appId;
|
||||
this.#appSecret = appSecret;
|
||||
this.#domain = domain;
|
||||
this.#ownerOpenIds = normalizedOwners;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#repair = repair ?? null;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#connectTimeoutMs = connectTimeoutMs;
|
||||
this.#requestTimeoutMs = requestTimeoutMs;
|
||||
|
|
@ -166,6 +200,10 @@ export class FeishuRuntime {
|
|||
state: this.#state,
|
||||
status: this.#status,
|
||||
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,
|
||||
signal,
|
||||
logger: this.#logger,
|
||||
|
|
@ -182,7 +220,9 @@ export class FeishuRuntime {
|
|||
// subscribes card.action.trigger; the number-reply fallback covers
|
||||
// apps that do not).
|
||||
'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 {};
|
||||
},
|
||||
});
|
||||
|
|
@ -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) {
|
||||
if (!this.#status.ready || !this.#client) {
|
||||
const error = new Error('飞书机器人尚未连接');
|
||||
|
|
@ -297,6 +478,16 @@ export class FeishuRuntime {
|
|||
const abortController = this.#abortController;
|
||||
this.#abortController = null;
|
||||
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;
|
||||
if (this.#wsClient) {
|
||||
this.#wsClient.close({ force: true });
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
||||
import { RegistrationManager } from './registration-manager.mjs';
|
||||
import {
|
||||
CALLBACK_REPAIR_OPERATION,
|
||||
CallbackRepairManager,
|
||||
} from './repair-manager.mjs';
|
||||
import { REQUIRED_TENANT_SCOPES } from './plugin-controller.mjs';
|
||||
|
||||
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 ALL_VISIBLE_SENDERS = '*';
|
||||
const DEFAULT_CALLBACK_PROBE_TIMEOUT_MS = 120_000;
|
||||
const MAX_CALLBACK_PROBE_TIMEOUT_MS = 600_000;
|
||||
|
||||
function idleConnection() {
|
||||
return {
|
||||
|
|
@ -60,6 +66,26 @@ function secretRefFor(botId) {
|
|||
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,
|
||||
* runtime and session store. Config commits are serialized, while unrelated
|
||||
|
|
@ -77,11 +103,13 @@ export class MultiBotDshFeishuController {
|
|||
#runtimes = new Map();
|
||||
#botErrors = new Map();
|
||||
#registrations = new Map();
|
||||
#activeRepairs = new Map();
|
||||
#botOwnership = new Map();
|
||||
#latestRegistrationId = null;
|
||||
#configTransition = Promise.resolve();
|
||||
#botTransitions = new Map();
|
||||
#revision = 1;
|
||||
#callbackProbeTimeoutMs;
|
||||
#closed = false;
|
||||
|
||||
constructor({
|
||||
|
|
@ -93,6 +121,7 @@ export class MultiBotDshFeishuController {
|
|||
deleteState = async () => {},
|
||||
createBotId = makeBotId,
|
||||
createRegistrationId = makeRegistrationId,
|
||||
callbackProbeTimeoutMs = DEFAULT_CALLBACK_PROBE_TIMEOUT_MS,
|
||||
}) {
|
||||
if (typeof registerApp !== 'function') throw new Error('registerApp 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 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.#verifyApp = verifyApp;
|
||||
this.#credentials = credentials;
|
||||
|
|
@ -110,6 +144,7 @@ export class MultiBotDshFeishuController {
|
|||
this.#deleteState = deleteState;
|
||||
this.#createBotId = createBotId;
|
||||
this.#createRegistrationId = createRegistrationId;
|
||||
this.#callbackProbeTimeoutMs = callbackProbeTimeoutMs;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
|
|
@ -194,6 +229,63 @@ export class MultiBotDshFeishuController {
|
|||
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) {
|
||||
return this.#registrations.has(attemptId);
|
||||
}
|
||||
|
|
@ -207,7 +299,14 @@ export class MultiBotDshFeishuController {
|
|||
async cancelRegistration(attemptId = this.#latestRegistrationId) {
|
||||
const record = this.#registrations.get(attemptId);
|
||||
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);
|
||||
}
|
||||
record.cancelled = true;
|
||||
|
|
@ -398,8 +497,15 @@ export class MultiBotDshFeishuController {
|
|||
async close() {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
const repairProcessing = [];
|
||||
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.manager.cancel();
|
||||
}
|
||||
|
|
@ -407,6 +513,15 @@ export class MultiBotDshFeishuController {
|
|||
await this.#configTransition;
|
||||
await Promise.allSettled([...this.#botTransitions.values()]);
|
||||
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 } = {}) {
|
||||
|
|
@ -462,9 +577,226 @@ export class MultiBotDshFeishuController {
|
|||
...snapshot,
|
||||
attempt: record.id,
|
||||
...(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) {
|
||||
if (record.cancelled) throw new Error('Registration was cancelled');
|
||||
const appId = result.client_id;
|
||||
|
|
@ -611,6 +943,7 @@ export class MultiBotDshFeishuController {
|
|||
botId: config.id,
|
||||
config,
|
||||
appSecret,
|
||||
repair: this.#runtimeRepairCapability(config.id),
|
||||
});
|
||||
this.#runtimes.set(config.id, runtime);
|
||||
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) {
|
||||
const runtime = this.#runtimes.get(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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue