mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
206 lines
7.4 KiB
JavaScript
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);
|
|
}
|
|
}
|