mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 05:20:46 +08:00
- /m (or /menu) opens a card menu; /sessionlist and /workspacelist render cards with bind/switch buttons; number replies stay usable as a fallback when the app does not subscribe card.action.trigger. - card.action.trigger callbacks validate the operator's open_id against the allowed senders: group members outside the allowlist can never drive binding, workspace switches or other card actions. - Session-list pagination uses page numbers everywhere (buttons carry sessions:<page>), fixing the previous double page-size scaling that skipped pages past 20 sessions. - Bind/workspace failures map to safe user-facing messages instead of raw error details. - Apps registered after this change subscribe card.action.trigger during the scan flow.
707 lines
24 KiB
JavaScript
707 lines
24 KiB
JavaScript
import { randomUUID } from 'node:crypto';
|
||
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
||
import { RegistrationManager } from './registration-manager.mjs';
|
||
import { REQUIRED_TENANT_SCOPES } from './plugin-controller.mjs';
|
||
|
||
const ACTIVE_REGISTRATION_STATES = new Set([
|
||
'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched',
|
||
]);
|
||
const MUTABLE_REGISTRATION_STATES = new Set([...ACTIVE_REGISTRATION_STATES, 'saving']);
|
||
const ALL_VISIBLE_SENDERS = '*';
|
||
|
||
function idleConnection() {
|
||
return {
|
||
ready: false,
|
||
feishuLongConnectionState: 'idle',
|
||
harnessReachable: false,
|
||
};
|
||
}
|
||
|
||
function connectionStatus(runtime) {
|
||
return runtime ? runtime.status : idleConnection();
|
||
}
|
||
|
||
function isConnected(connection) {
|
||
return connection.ready === true
|
||
&& connection.feishuLongConnectionState === 'connected'
|
||
&& connection.harnessReachable === true;
|
||
}
|
||
|
||
function maskedAppId(appId) {
|
||
return appId.length > 12
|
||
? `${appId.slice(0, 8)}••••${appId.slice(-4)}`
|
||
: 'cli_••••';
|
||
}
|
||
|
||
function publicBot(config) {
|
||
return {
|
||
name: config.botName,
|
||
appIdMasked: maskedAppId(config.appId),
|
||
activated: config.activated,
|
||
domain: config.domain,
|
||
};
|
||
}
|
||
|
||
function botPhase({ connected, error, connection }) {
|
||
if (connected) return 'connected';
|
||
if (error || connection.feishuLongConnectionState === 'failed') return 'error';
|
||
return 'disconnected';
|
||
}
|
||
|
||
function makeBotId() {
|
||
return `bot_${randomUUID().replaceAll('-', '')}`;
|
||
}
|
||
|
||
function makeRegistrationId() {
|
||
return `reg_${randomUUID().replaceAll('-', '')}`;
|
||
}
|
||
|
||
function secretRefFor(botId) {
|
||
return `DSH_FEISHU_APP_SECRET_${botId.slice(4).toUpperCase()}`;
|
||
}
|
||
|
||
/**
|
||
* Multi-account Feishu orchestration. Each bot owns its credential reference,
|
||
* runtime and session store. Config commits are serialized, while unrelated
|
||
* runtime lifecycles may proceed independently.
|
||
*/
|
||
export class MultiBotDshFeishuController {
|
||
#registerApp;
|
||
#verifyApp;
|
||
#credentials;
|
||
#configStore;
|
||
#createRuntime;
|
||
#deleteState;
|
||
#createBotId;
|
||
#createRegistrationId;
|
||
#runtimes = new Map();
|
||
#botErrors = new Map();
|
||
#registrations = new Map();
|
||
#botOwnership = new Map();
|
||
#latestRegistrationId = null;
|
||
#configTransition = Promise.resolve();
|
||
#botTransitions = new Map();
|
||
#revision = 1;
|
||
#closed = false;
|
||
|
||
constructor({
|
||
registerApp,
|
||
verifyApp,
|
||
credentials,
|
||
configStore,
|
||
createRuntime,
|
||
deleteState = async () => {},
|
||
createBotId = makeBotId,
|
||
createRegistrationId = makeRegistrationId,
|
||
}) {
|
||
if (typeof registerApp !== 'function') throw new Error('registerApp is required');
|
||
if (typeof verifyApp !== 'function') throw new Error('verifyApp is required');
|
||
if (!credentials) throw new Error('credentials service is required');
|
||
if (!configStore || typeof configStore.list !== 'function') {
|
||
throw new Error('multi-bot config store is required');
|
||
}
|
||
if (typeof createRuntime !== 'function') throw new Error('createRuntime is required');
|
||
if (typeof deleteState !== 'function') throw new Error('deleteState must be a function');
|
||
this.#registerApp = registerApp;
|
||
this.#verifyApp = verifyApp;
|
||
this.#credentials = credentials;
|
||
this.#configStore = configStore;
|
||
this.#createRuntime = createRuntime;
|
||
this.#deleteState = deleteState;
|
||
this.#createBotId = createBotId;
|
||
this.#createRegistrationId = createRegistrationId;
|
||
}
|
||
|
||
async initialize() {
|
||
if (this.#closed) return this.status();
|
||
const bots = this.#configStore.list();
|
||
let attempted = false;
|
||
await Promise.allSettled(bots.map((config) => this.#withBotTransition(config.id, async () => {
|
||
const current = connectionStatus(this.#runtimes.get(config.id));
|
||
if (isConnected(current)
|
||
|| current.feishuLongConnectionState === 'connecting'
|
||
|| current.feishuLongConnectionState === 'reconnecting') {
|
||
return;
|
||
}
|
||
attempted = true;
|
||
if (config.deletionPending) {
|
||
this.#botErrors.set(config.id, {
|
||
code: 'deletion_pending',
|
||
message: '机器人正在等待完成本地删除,请重试移除。',
|
||
});
|
||
return;
|
||
}
|
||
let resolved;
|
||
try {
|
||
resolved = await this.#credentials.resolve(config.secretRef);
|
||
} catch {
|
||
this.#botErrors.set(config.id, {
|
||
code: 'missing_credentials',
|
||
message: '无法读取机器人凭据,请检查凭据存储。',
|
||
});
|
||
return;
|
||
}
|
||
if (!resolved?.value) {
|
||
this.#botErrors.set(config.id, {
|
||
code: 'missing_credentials',
|
||
message: '机器人凭据缺失,请删除后重新扫码接入。',
|
||
});
|
||
return;
|
||
}
|
||
try {
|
||
await this.#startRuntime(config, resolved.value);
|
||
this.#botErrors.delete(config.id);
|
||
} catch {
|
||
this.#botErrors.set(config.id, {
|
||
code: 'connection_failed',
|
||
message: '机器人暂时无法连接飞书,请重试。',
|
||
});
|
||
}
|
||
})));
|
||
if (attempted) this.#touch();
|
||
return this.status();
|
||
}
|
||
|
||
startRegistration() {
|
||
this.#assertOpen();
|
||
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, manager: null, botId: null, createdNew: false, cancelled: false };
|
||
record.manager = new RegistrationManager({
|
||
registerApp: this.#registerApp,
|
||
onCredentials: (result) => this.#serializeConfig(() => this.#acceptCredentials(record, result)),
|
||
});
|
||
this.#registrations.set(id, record);
|
||
this.#latestRegistrationId = id;
|
||
this.#trimRegistrations();
|
||
record.manager.start({
|
||
source: 'deepseek-harness',
|
||
createOnly: true,
|
||
appPreset: {
|
||
name: '{user} 的北汇星河 AI 助手',
|
||
desc: '连接飞书与 DeepSeek Harness,在聊天中使用企业 AI 助手。',
|
||
},
|
||
addons: {
|
||
preset: false,
|
||
scopes: { tenant: [...REQUIRED_TENANT_SCOPES] },
|
||
events: { items: { tenant: ['im.message.receive_v1', 'card.action.trigger'] } },
|
||
},
|
||
});
|
||
this.#touch();
|
||
return this.registrationStatus(id);
|
||
}
|
||
|
||
hasRegistration(attemptId) {
|
||
return this.#registrations.has(attemptId);
|
||
}
|
||
|
||
registrationStatus(attemptId) {
|
||
const record = this.#registrations.get(attemptId);
|
||
if (!record) return null;
|
||
return this.#status({ registration: record, selectedBotId: record.botId });
|
||
}
|
||
|
||
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)) {
|
||
return this.registrationStatus(attemptId);
|
||
}
|
||
record.cancelled = true;
|
||
record.manager.cancel();
|
||
await this.#serializeConfig(async () => {
|
||
if (record.createdNew && record.botId
|
||
&& this.#botOwnership.get(record.botId) === record.id
|
||
&& this.#configStore.getBot(record.botId)) {
|
||
await this.#withBotTransition(record.botId, () => this.#deleteBot(record.botId));
|
||
}
|
||
});
|
||
this.#touch();
|
||
return this.registrationStatus(attemptId) ?? this.status();
|
||
}
|
||
|
||
status(botId) {
|
||
return this.#status({
|
||
registration: this.#registrations.get(this.#latestRegistrationId) ?? null,
|
||
selectedBotId: botId,
|
||
});
|
||
}
|
||
|
||
async bindCredentials({ appId, appSecret, domain = 'feishu' } = {}) {
|
||
this.#assertOpen();
|
||
const normalizedAppId = typeof appId === 'string' ? appId.trim() : '';
|
||
const normalizedSecret = typeof appSecret === 'string' ? appSecret.trim() : '';
|
||
const normalizedDomain = domain === 'lark' ? 'lark' : 'feishu';
|
||
if (!normalizedAppId || !normalizedSecret) {
|
||
throw new TypeError('Feishu App ID and App Secret are required');
|
||
}
|
||
|
||
return this.#serializeConfig(async () => {
|
||
this.#assertOpen();
|
||
const bot = await this.#verifyApp({
|
||
appId: normalizedAppId,
|
||
appSecret: normalizedSecret,
|
||
domain: normalizedDomain,
|
||
});
|
||
this.#assertOpen();
|
||
const existing = this.#configStore.list().find(
|
||
(candidate) => candidate.appId === normalizedAppId,
|
||
);
|
||
const botId = existing?.id ?? this.#createBotId();
|
||
if (typeof botId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(botId)
|
||
|| (!existing && this.#configStore.getBot(botId))) {
|
||
throw new Error('Bot id generator returned an invalid or duplicate id');
|
||
}
|
||
const secretRef = existing?.secretRef ?? secretRefFor(botId);
|
||
const previousSecret = await this.#credentials.resolve(secretRef).catch(() => undefined);
|
||
const config = {
|
||
...existing,
|
||
id: botId,
|
||
appId: normalizedAppId,
|
||
secretRef,
|
||
ownerOpenIds: existing?.ownerOpenIds?.length
|
||
? existing.ownerOpenIds
|
||
: [ALL_VISIBLE_SENDERS],
|
||
domain: normalizedDomain,
|
||
botName: bot.name,
|
||
botOpenId: bot.openId,
|
||
activated: bot.activated,
|
||
deletionPending: false,
|
||
connectedAt: new Date().toISOString(),
|
||
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
||
};
|
||
|
||
await this.#credentials.set(secretRef, normalizedSecret);
|
||
let saved;
|
||
try {
|
||
saved = await this.#configStore.saveBot(config);
|
||
} catch (error) {
|
||
await this.#restoreCredential(secretRef, previousSecret);
|
||
throw error;
|
||
}
|
||
|
||
await this.#withBotTransition(botId, async () => {
|
||
try {
|
||
await this.#startRuntime(saved, normalizedSecret);
|
||
this.#botErrors.delete(botId);
|
||
} catch {
|
||
this.#botErrors.set(botId, {
|
||
code: 'connection_failed',
|
||
message: '机器人已经绑定,但长连接未就绪,请点击重试。',
|
||
});
|
||
}
|
||
});
|
||
this.#touch();
|
||
return this.status(botId);
|
||
});
|
||
}
|
||
|
||
async reconnectBot(botId) {
|
||
this.#assertOpen();
|
||
return this.#withBotTransition(botId, async () => {
|
||
const config = this.#requireBot(botId);
|
||
if (config.deletionPending) {
|
||
this.#botErrors.set(botId, {
|
||
code: 'deletion_pending',
|
||
message: '机器人正在等待完成本地删除,请重试移除。',
|
||
});
|
||
return this.status(botId);
|
||
}
|
||
if (isConnected(connectionStatus(this.#runtimes.get(botId)))) {
|
||
return this.status(botId);
|
||
}
|
||
let resolved;
|
||
try {
|
||
resolved = await this.#credentials.resolve(config.secretRef);
|
||
} catch {
|
||
resolved = null;
|
||
}
|
||
if (!resolved?.value) {
|
||
this.#botErrors.set(botId, {
|
||
code: 'missing_credentials',
|
||
message: '机器人凭据缺失,请删除后重新扫码接入。',
|
||
});
|
||
this.#touch();
|
||
return this.status(botId);
|
||
}
|
||
try {
|
||
await this.#startRuntime(config, resolved.value);
|
||
this.#botErrors.delete(botId);
|
||
} catch {
|
||
this.#botErrors.set(botId, {
|
||
code: 'connection_failed',
|
||
message: '机器人暂时无法连接飞书,请重试。',
|
||
});
|
||
}
|
||
this.#touch();
|
||
return this.status(botId);
|
||
});
|
||
}
|
||
|
||
async sendConnectionTest(botId) {
|
||
this.#assertOpen();
|
||
return this.#withBotTransition(botId, async () => {
|
||
const config = this.#requireBot(botId);
|
||
const runtime = this.#runtimes.get(botId);
|
||
if (!isConnected(connectionStatus(runtime))
|
||
|| typeof runtime.sendConnectionTest !== 'function') {
|
||
const error = new Error('飞书机器人尚未连接');
|
||
error.code = 'test-target-unavailable';
|
||
throw error;
|
||
}
|
||
return runtime.sendConnectionTest(
|
||
connectionTestMessage(
|
||
`${config.botName}(${maskedAppId(config.appId)})`,
|
||
'飞书机器人',
|
||
),
|
||
);
|
||
});
|
||
}
|
||
|
||
async disconnectBot(botId) {
|
||
this.#assertOpen();
|
||
// An operational pause only: credentials/config remain durable, so the
|
||
// bot reconnects on the next Host start unless it is explicitly deleted.
|
||
return this.#withBotTransition(botId, async () => {
|
||
this.#requireBot(botId);
|
||
await this.#stopRuntime(botId);
|
||
this.#botErrors.delete(botId);
|
||
this.#touch();
|
||
return this.status(botId);
|
||
});
|
||
}
|
||
|
||
async deleteBot(botId) {
|
||
this.#assertOpen();
|
||
return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
|
||
this.#requireBot(botId);
|
||
await this.#deleteBot(botId);
|
||
this.#touch();
|
||
return this.status();
|
||
}));
|
||
}
|
||
|
||
// Compatibility methods for the original one-bot browser contract.
|
||
async reconnect() {
|
||
const bot = this.#configStore.list()[0];
|
||
return bot ? this.reconnectBot(bot.id) : this.status();
|
||
}
|
||
|
||
async disconnect() {
|
||
const bot = this.#configStore.list()[0];
|
||
return bot ? this.deleteBot(bot.id) : this.status();
|
||
}
|
||
|
||
async close() {
|
||
if (this.#closed) return;
|
||
this.#closed = true;
|
||
for (const record of this.#registrations.values()) {
|
||
if (MUTABLE_REGISTRATION_STATES.has(record.manager.status().state)) {
|
||
record.cancelled = true;
|
||
record.manager.cancel();
|
||
}
|
||
}
|
||
await this.#configTransition;
|
||
await Promise.allSettled([...this.#botTransitions.values()]);
|
||
await Promise.allSettled([...this.#runtimes.keys()].map((id) => this.#stopRuntime(id)));
|
||
}
|
||
|
||
#status({ registration, selectedBotId } = {}) {
|
||
const bots = this.#configStore.list().map((config) => {
|
||
const connection = connectionStatus(this.#runtimes.get(config.id));
|
||
const connected = isConnected(connection);
|
||
const error = this.#botErrors.get(config.id) ?? null;
|
||
return {
|
||
botId: config.id,
|
||
phase: botPhase({ connected, error, connection }),
|
||
connected,
|
||
configured: true,
|
||
bot: publicBot(config),
|
||
connection,
|
||
error,
|
||
};
|
||
});
|
||
const registrationSnapshot = registration ? this.#registrationSnapshot(registration) : {
|
||
state: 'idle', attempt: 0, updatedAt: Date.now(),
|
||
};
|
||
const registering = ACTIVE_REGISTRATION_STATES.has(registrationSnapshot.state);
|
||
const connecting = registrationSnapshot.state === 'saving';
|
||
const registrationOwnsProjection = Boolean(registration) && (registering || connecting);
|
||
const selected = bots.find((bot) => bot.botId === selectedBotId)
|
||
?? (registrationOwnsProjection ? null : (bots[0] ?? null));
|
||
const aggregateConnected = bots.some((bot) => bot.connected);
|
||
let phase = selected?.phase ?? 'unconfigured';
|
||
if (registering) phase = 'registering';
|
||
else if (connecting) phase = 'connecting';
|
||
else if (registrationSnapshot.state === 'error' && !selected) phase = 'error';
|
||
return {
|
||
schemaVersion: 2,
|
||
revision: this.#revision,
|
||
phase,
|
||
connected: selected?.connected ?? false,
|
||
configured: bots.length > 0,
|
||
bot: selected?.bot ?? null,
|
||
connection: selected?.connection ?? idleConnection(),
|
||
error: selected?.error ?? registrationSnapshot.error ?? null,
|
||
registration: registrationSnapshot,
|
||
bots,
|
||
totals: {
|
||
configured: bots.length,
|
||
connected: bots.filter((bot) => bot.connected).length,
|
||
},
|
||
anyConnected: aggregateConnected,
|
||
};
|
||
}
|
||
|
||
#registrationSnapshot(record) {
|
||
const snapshot = record.manager.status();
|
||
return {
|
||
...snapshot,
|
||
attempt: record.id,
|
||
...(record.botId ? { botId: record.botId } : {}),
|
||
};
|
||
}
|
||
|
||
async #acceptCredentials(record, result) {
|
||
if (record.cancelled) throw new Error('Registration was cancelled');
|
||
const appId = result.client_id;
|
||
const appSecret = result.client_secret;
|
||
const ownerOpenId = result.user_info?.open_id;
|
||
const domain = result.user_info?.tenant_brand === 'lark' ? 'lark' : 'feishu';
|
||
if (!ownerOpenId) throw new Error('Feishu registration returned no owner open_id');
|
||
|
||
const bot = await this.#verifyApp({ appId, appSecret, domain });
|
||
if (record.cancelled) throw new Error('Registration was cancelled');
|
||
const existing = this.#configStore.list().find((candidate) => candidate.appId === appId);
|
||
const botId = existing?.id ?? this.#createBotId();
|
||
if (typeof botId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(botId)
|
||
|| (!existing && this.#configStore.getBot(botId))) {
|
||
throw new Error('Bot id generator returned an invalid or duplicate id');
|
||
}
|
||
const secretRef = existing?.secretRef ?? secretRefFor(botId);
|
||
const previousOwnership = this.#botOwnership.get(botId);
|
||
const previousSecret = await this.#credentials.resolve(secretRef).catch(() => undefined);
|
||
await this.#credentials.set(secretRef, appSecret);
|
||
let config;
|
||
try {
|
||
config = await this.#configStore.saveBot({
|
||
...existing,
|
||
id: botId,
|
||
appId,
|
||
secretRef,
|
||
ownerOpenIds: [...new Set([...(existing?.ownerOpenIds ?? []), ownerOpenId])],
|
||
domain,
|
||
botName: bot.name,
|
||
botOpenId: bot.openId,
|
||
activated: bot.activated,
|
||
deletionPending: false,
|
||
connectedAt: new Date().toISOString(),
|
||
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
||
});
|
||
record.botId = botId;
|
||
record.createdNew = !existing;
|
||
this.#botOwnership.set(botId, record.id);
|
||
} catch (error) {
|
||
try {
|
||
await this.#restoreCredential(secretRef, previousSecret);
|
||
} catch (restoreError) {
|
||
throw new Error('Unable to restore the Feishu credential after a config failure.', {
|
||
cause: restoreError,
|
||
});
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
if (record.cancelled) {
|
||
if (record.createdNew) {
|
||
await this.#withBotTransition(botId, () => this.#deleteBot(botId));
|
||
} else {
|
||
await this.#configStore.saveBot(existing);
|
||
await this.#restoreCredential(secretRef, previousSecret);
|
||
if (previousOwnership) this.#botOwnership.set(botId, previousOwnership);
|
||
else this.#botOwnership.delete(botId);
|
||
}
|
||
throw new Error('Registration was cancelled');
|
||
}
|
||
let cancellationRolledBack = false;
|
||
try {
|
||
await this.#withBotTransition(botId, () => this.#startRuntime(config, appSecret));
|
||
if (record.cancelled) {
|
||
if (record.createdNew) {
|
||
await this.#withBotTransition(botId, () => this.#deleteBot(botId));
|
||
} else {
|
||
await this.#configStore.saveBot(existing);
|
||
await this.#restoreCredential(secretRef, previousSecret);
|
||
if (previousOwnership) this.#botOwnership.set(botId, previousOwnership);
|
||
else this.#botOwnership.delete(botId);
|
||
if (previousSecret?.value && !existing.deletionPending) {
|
||
await this.#withBotTransition(botId, () => this.#startRuntime(existing, previousSecret.value));
|
||
} else {
|
||
await this.#withBotTransition(botId, () => this.#stopRuntime(botId));
|
||
}
|
||
}
|
||
cancellationRolledBack = true;
|
||
throw new Error('Registration was cancelled');
|
||
}
|
||
this.#botErrors.delete(botId);
|
||
this.#touch();
|
||
} catch (error) {
|
||
if (record.cancelled) {
|
||
if (!cancellationRolledBack && record.createdNew && this.#configStore.getBot(botId)) {
|
||
await this.#withBotTransition(botId, () => this.#deleteBot(botId));
|
||
} else if (!cancellationRolledBack && existing) {
|
||
await this.#configStore.saveBot(existing);
|
||
await this.#restoreCredential(secretRef, previousSecret);
|
||
if (previousOwnership) this.#botOwnership.set(botId, previousOwnership);
|
||
else this.#botOwnership.delete(botId);
|
||
if (!this.#closed && previousSecret?.value && !existing.deletionPending) {
|
||
await this.#withBotTransition(botId, () => this.#startRuntime(existing, previousSecret.value));
|
||
} else {
|
||
await this.#withBotTransition(botId, () => this.#stopRuntime(botId));
|
||
}
|
||
}
|
||
this.#touch();
|
||
throw error;
|
||
}
|
||
if (existing && previousSecret?.value) {
|
||
try {
|
||
await this.#configStore.saveBot(existing);
|
||
await this.#restoreCredential(secretRef, previousSecret);
|
||
if (previousOwnership) this.#botOwnership.set(botId, previousOwnership);
|
||
else this.#botOwnership.delete(botId);
|
||
if (!existing.deletionPending) {
|
||
await this.#withBotTransition(botId, () => this.#startRuntime(existing, previousSecret.value));
|
||
this.#botErrors.delete(botId);
|
||
} else {
|
||
await this.#withBotTransition(botId, () => this.#stopRuntime(botId));
|
||
this.#botErrors.set(botId, {
|
||
code: 'deletion_pending',
|
||
message: '机器人正在等待完成本地删除,请重试移除。',
|
||
});
|
||
}
|
||
this.#touch();
|
||
throw error;
|
||
} catch (restoreError) {
|
||
if (restoreError === error) throw error;
|
||
this.#botErrors.set(botId, {
|
||
code: 'connection_failed',
|
||
message: '机器人连接更新失败,且原连接无法恢复,请重试。',
|
||
});
|
||
this.#touch();
|
||
throw new Error('Unable to restore the previous Feishu bot connection.', {
|
||
cause: restoreError,
|
||
});
|
||
}
|
||
}
|
||
this.#botErrors.set(botId, {
|
||
code: 'connection_failed',
|
||
message: '机器人已经创建,但长连接未就绪,请点击重试。',
|
||
});
|
||
this.#touch();
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async #startRuntime(config, appSecret) {
|
||
await this.#stopRuntime(config.id);
|
||
const runtime = await this.#createRuntime({
|
||
botId: config.id,
|
||
config,
|
||
appSecret,
|
||
});
|
||
this.#runtimes.set(config.id, runtime);
|
||
try {
|
||
await runtime.start();
|
||
} catch (error) {
|
||
if (this.#runtimes.get(config.id) === runtime) this.#runtimes.delete(config.id);
|
||
await runtime.stop({ preserveError: true }).catch(() => undefined);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async #stopRuntime(botId) {
|
||
const runtime = this.#runtimes.get(botId);
|
||
this.#runtimes.delete(botId);
|
||
if (runtime) await runtime.stop();
|
||
}
|
||
|
||
async #deleteBot(botId) {
|
||
let config = this.#configStore.getBot(botId);
|
||
if (!config) return;
|
||
if (!config.deletionPending) {
|
||
config = await this.#configStore.saveBot({ ...config, deletionPending: true });
|
||
}
|
||
await this.#stopRuntime(botId);
|
||
try {
|
||
await this.#credentials.unset(config.secretRef);
|
||
} catch (error) {
|
||
this.#botErrors.set(botId, {
|
||
code: 'credential_removal_failed',
|
||
message: '无法删除机器人凭据,请稍后重试。',
|
||
});
|
||
throw new Error('Unable to remove the Feishu credential.', { cause: error });
|
||
}
|
||
try {
|
||
await this.#deleteState({ botId, config });
|
||
} catch (error) {
|
||
this.#botErrors.set(botId, {
|
||
code: 'state_cleanup_failed',
|
||
message: '无法删除机器人的本地会话数据,请稍后重试。',
|
||
});
|
||
throw new Error('Unable to remove the Feishu bot session state.', { cause: error });
|
||
}
|
||
await this.#configStore.removeBot(botId);
|
||
this.#botErrors.delete(botId);
|
||
this.#botOwnership.delete(botId);
|
||
}
|
||
|
||
async #restoreCredential(secretRef, previous) {
|
||
if (previous?.value) await this.#credentials.set(secretRef, previous.value);
|
||
else await this.#credentials.unset(secretRef);
|
||
}
|
||
|
||
#requireBot(botId) {
|
||
const config = this.#configStore.getBot(botId);
|
||
if (!config) throw new Error('Unknown Feishu bot');
|
||
return config;
|
||
}
|
||
|
||
#assertOpen() {
|
||
if (this.#closed) throw new Error('The Feishu controller is closed');
|
||
}
|
||
|
||
#serializeConfig(operation) {
|
||
const result = this.#configTransition.then(operation, operation);
|
||
this.#configTransition = result.then(() => undefined, () => undefined);
|
||
return result;
|
||
}
|
||
|
||
#withBotTransition(botId, operation) {
|
||
const previous = this.#botTransitions.get(botId) ?? Promise.resolve();
|
||
const result = previous.then(operation, operation);
|
||
const tail = result.then(() => undefined, () => undefined);
|
||
this.#botTransitions.set(botId, tail);
|
||
void tail.finally(() => {
|
||
if (this.#botTransitions.get(botId) === tail) this.#botTransitions.delete(botId);
|
||
});
|
||
return result;
|
||
}
|
||
|
||
#trimRegistrations() {
|
||
if (this.#registrations.size <= 32) return;
|
||
for (const [id, record] of this.#registrations) {
|
||
if (id === this.#latestRegistrationId) continue;
|
||
const state = record.manager.status().state;
|
||
if (!ACTIVE_REGISTRATION_STATES.has(state) && state !== 'saving') {
|
||
this.#registrations.delete(id);
|
||
}
|
||
if (this.#registrations.size <= 32) break;
|
||
}
|
||
}
|
||
|
||
#touch() {
|
||
this.#revision += 1;
|
||
}
|
||
}
|