dsh-im-ops/src/channels/feishu/plugin-config-store.mjs

206 lines
7.4 KiB
JavaScript

import { createHash } from 'node:crypto';
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
export const LEGACY_FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
function cleanString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function safeId(value) {
const id = cleanString(value);
return id && /^[A-Za-z0-9_-]{1,128}$/.test(id) ? id : null;
}
function legacyBotId(appId) {
return `bot_${createHash('sha256').update(appId).digest('hex').slice(0, 24)}`;
}
function normalizeOwners(value) {
const candidates = Array.isArray(value.ownerOpenIds)
? value.ownerOpenIds
: [value.ownerOpenId];
return [...new Set(candidates.map(cleanString).filter(Boolean))];
}
function normalizeBot(value, { legacy = false } = {}) {
if (!value || typeof value !== 'object') return null;
const appId = cleanString(value.appId);
const ownerOpenIds = normalizeOwners(value);
if (!appId || ownerOpenIds.length === 0) return null;
const id = safeId(value.id) ?? (legacy ? legacyBotId(appId) : null);
const secretRef = cleanString(value.secretRef) ?? (legacy ? LEGACY_FEISHU_SECRET_REF : null);
if (!id || !secretRef) return null;
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(secretRef)) return null;
const domain = value.domain === 'lark' ? 'lark' : 'feishu';
return Object.freeze({
id,
appId,
secretRef,
ownerOpenIds: Object.freeze(ownerOpenIds),
domain,
botName: cleanString(value.botName),
botOpenId: cleanString(value.botOpenId),
activated: value.activated ?? null,
groupResponseMode: normalizeFeishuGroupResponseMode(value.groupResponseMode),
deletionPending: value.deletionPending === true,
connectedAt: cleanString(value.connectedAt),
createdAt: cleanString(value.createdAt) ?? cleanString(value.connectedAt),
});
}
function normalizeDocument(value) {
if (!value || typeof value !== 'object') return null;
if (value.version === 2 && Array.isArray(value.bots)) {
const bots = value.bots.map((bot) => normalizeBot(bot));
if (bots.some((bot) => bot === null)) {
throw new Error('dsh-feishu config contains an invalid bot entry');
}
const ids = new Set();
const refs = new Set();
const appIds = new Set();
for (const bot of bots) {
if (ids.has(bot.id) || refs.has(bot.secretRef) || appIds.has(bot.appId)) {
throw new Error('dsh-feishu config contains duplicate bot identities');
}
ids.add(bot.id);
refs.add(bot.secretRef);
appIds.add(bot.appId);
}
return { value: Object.freeze({ version: 2, bots: Object.freeze(bots) }), migrated: false };
}
// Version 1 was a single non-secret bot object. Preserve its existing
// credential reference so an environment-backed secret remains usable.
const legacyBot = normalizeBot(value, { legacy: true });
if (!legacyBot) return null;
return {
value: Object.freeze({ version: 2, bots: Object.freeze([legacyBot]) }),
migrated: true,
};
}
/** Stores only non-secret onboarding facts for all Feishu bots. */
export class PluginConfigStore {
#path;
#value = Object.freeze({ version: 2, bots: Object.freeze([]) });
#writeQueue = Promise.resolve();
constructor(path) {
this.#path = path;
}
async load() {
try {
const parsed = JSON.parse(await readFile(this.#path, 'utf8'));
const normalized = normalizeDocument(parsed);
if (!normalized) throw new Error('dsh-feishu config is incomplete or invalid');
this.#value = normalized.value;
if (normalized.migrated) await this.#writeDocument(this.#value);
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
this.#value = Object.freeze({ version: 2, bots: Object.freeze([]) });
}
return this;
}
/** Backward-compatible single-bot view used by the original controller. */
get() {
const bot = this.#value.bots[0];
if (!bot) return null;
const result = structuredClone(bot);
result.ownerOpenId = result.ownerOpenIds[0];
return result;
}
list() {
return structuredClone(this.#value.bots);
}
getBot(id) {
const bot = this.#value.bots.find((candidate) => candidate.id === id);
return bot ? structuredClone(bot) : null;
}
/** Backward-compatible save replaces the original single-bot view. */
async save(value) {
const fallback = { ...value };
if (!fallback.id) fallback.id = legacyBotId(cleanString(fallback.appId) ?? 'invalid');
if (!fallback.secretRef) fallback.secretRef = LEGACY_FEISHU_SECRET_REF;
const normalized = normalizeBot(fallback);
if (!normalized) throw new Error('Refusing to persist incomplete dsh-feishu configuration');
await this.#replaceBots([normalized]);
return this.get();
}
async saveBot(value) {
const normalized = normalizeBot(value);
if (!normalized) throw new Error('Refusing to persist incomplete dsh-feishu bot configuration');
return this.#mutate((bots) => {
const collision = bots.find((bot) => bot.secretRef === normalized.secretRef && bot.id !== normalized.id);
if (collision) throw new Error('Refusing to share a credential reference between Feishu bots');
const appCollision = bots.find((bot) => bot.appId === normalized.appId && bot.id !== normalized.id);
if (appCollision) throw new Error('Refusing to persist the same Feishu app twice');
const index = bots.findIndex((bot) => bot.id === normalized.id);
if (index === -1) bots.push(normalized);
else bots[index] = normalized;
return structuredClone(normalized);
});
}
async removeBot(id) {
if (!safeId(id)) throw new TypeError('Invalid Feishu bot id');
return this.#mutate((bots) => {
const index = bots.findIndex((bot) => bot.id === id);
if (index === -1) return null;
const [removed] = bots.splice(index, 1);
return structuredClone(removed);
});
}
async clear() {
const operation = this.#writeQueue.then(async () => {
try {
await unlink(this.#path);
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
this.#value = Object.freeze({ version: 2, bots: Object.freeze([]) });
});
this.#writeQueue = operation.then(() => undefined, () => undefined);
await operation;
}
async #replaceBots(bots) {
const document = Object.freeze({ version: 2, bots: Object.freeze([...bots]) });
const operation = this.#writeQueue.then(async () => {
await this.#writeDocument(document);
this.#value = document;
});
this.#writeQueue = operation.then(() => undefined, () => undefined);
await operation;
}
async #mutate(mutator) {
let result;
const operation = this.#writeQueue.then(async () => {
const bots = [...this.#value.bots];
result = mutator(bots);
const document = Object.freeze({ version: 2, bots: Object.freeze(bots) });
await this.#writeDocument(document);
this.#value = document;
});
this.#writeQueue = operation.then(() => undefined, () => undefined);
await operation;
return result;
}
async #writeDocument(document) {
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
const temporary = `${this.#path}.tmp`;
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
await rename(temporary, this.#path);
}
}