mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 00:32:43 +08:00
Internalize all IM channel implementations
This commit is contained in:
parent
8996476693
commit
bd469f58f8
95 changed files with 25012 additions and 100 deletions
616
src/channels/feishu/multi-bot-controller.mjs
Normal file
616
src/channels/feishu/multi-bot-controller.mjs
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
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']);
|
||||
|
||||
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'] } },
|
||||
},
|
||||
});
|
||||
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 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 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue