mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 16:40:46 +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,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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue