mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
217 lines
7.2 KiB
JavaScript
217 lines
7.2 KiB
JavaScript
import { randomUUID } from 'node:crypto';
|
|
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
import { dirname } from 'node:path';
|
|
|
|
const EMPTY_STATE = Object.freeze({
|
|
version: 1,
|
|
sessions: {},
|
|
seenMessageIds: [],
|
|
pendingSenders: {},
|
|
});
|
|
|
|
function nonEmptyString(value) {
|
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function displayName(value) {
|
|
return (nonEmptyString(value) ?? '钉钉用户').slice(0, 100);
|
|
}
|
|
|
|
function normalizePendingSender(value, fallbackRequestId) {
|
|
if (!value || typeof value !== 'object') return null;
|
|
const requestId = nonEmptyString(value.requestId) ?? nonEmptyString(fallbackRequestId);
|
|
const staffId = nonEmptyString(value.staffId);
|
|
const requestedAt = nonEmptyString(value.requestedAt) ?? nonEmptyString(value.lastSeenAt);
|
|
const lastSeenAt = nonEmptyString(value.lastSeenAt) ?? requestedAt;
|
|
if (!requestId || !staffId || !requestedAt || !lastSeenAt) return null;
|
|
return {
|
|
requestId,
|
|
staffId,
|
|
displayName: displayName(value.displayName ?? value.nick),
|
|
requestedAt,
|
|
lastSeenAt,
|
|
};
|
|
}
|
|
|
|
function normalizeState(value) {
|
|
if (!value || typeof value !== 'object') return structuredClone(EMPTY_STATE);
|
|
const sessions = {};
|
|
if (value.sessions && typeof value.sessions === 'object' && !Array.isArray(value.sessions)) {
|
|
for (const [key, sessionId] of Object.entries(value.sessions)) {
|
|
const normalizedKey = nonEmptyString(key);
|
|
const normalizedSession = nonEmptyString(sessionId);
|
|
if (normalizedKey && normalizedSession) sessions[normalizedKey] = normalizedSession;
|
|
}
|
|
}
|
|
|
|
const pendingSenders = {};
|
|
const entries = Array.isArray(value.pendingSenders)
|
|
? value.pendingSenders.map((entry) => [entry?.requestId, entry])
|
|
: Object.entries(value.pendingSenders && typeof value.pendingSenders === 'object'
|
|
? value.pendingSenders
|
|
: {});
|
|
for (const [key, candidate] of entries) {
|
|
const pending = normalizePendingSender(candidate, key);
|
|
if (!pending) continue;
|
|
const duplicate = Object.values(pendingSenders).find((entry) => entry.staffId === pending.staffId);
|
|
if (!duplicate || duplicate.lastSeenAt < pending.lastSeenAt) {
|
|
if (duplicate) delete pendingSenders[duplicate.requestId];
|
|
pendingSenders[pending.requestId] = pending;
|
|
}
|
|
}
|
|
|
|
return {
|
|
version: 1,
|
|
sessions,
|
|
seenMessageIds: Array.isArray(value.seenMessageIds)
|
|
? [...new Set(value.seenMessageIds.map(nonEmptyString).filter(Boolean))].slice(-1_000)
|
|
: [],
|
|
pendingSenders,
|
|
};
|
|
}
|
|
|
|
export class DingtalkStateStore {
|
|
#path;
|
|
#state = structuredClone(EMPTY_STATE);
|
|
#writeQueue = Promise.resolve();
|
|
#idFactory;
|
|
#now;
|
|
|
|
constructor(path, { idFactory = randomUUID, now = () => new Date().toISOString() } = {}) {
|
|
if (!nonEmptyString(path)) throw new TypeError('state path is required');
|
|
if (typeof idFactory !== 'function' || typeof now !== 'function') {
|
|
throw new TypeError('idFactory and now must be functions');
|
|
}
|
|
this.#path = path;
|
|
this.#idFactory = idFactory;
|
|
this.#now = now;
|
|
}
|
|
|
|
async load() {
|
|
try {
|
|
this.#state = normalizeState(JSON.parse(await readFile(this.#path, 'utf8')));
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') throw error;
|
|
this.#state = structuredClone(EMPTY_STATE);
|
|
await this.#persist();
|
|
}
|
|
return this;
|
|
}
|
|
|
|
sessionFor(key) {
|
|
return this.#state.sessions[key] ?? null;
|
|
}
|
|
|
|
async setSession(key, sessionId) {
|
|
const normalizedKey = nonEmptyString(key);
|
|
const normalizedSession = nonEmptyString(sessionId);
|
|
if (!normalizedKey || !normalizedSession) throw new TypeError('key and sessionId are required');
|
|
this.#state.sessions[normalizedKey] = normalizedSession;
|
|
await this.#persist();
|
|
}
|
|
|
|
async clearSession(key) {
|
|
const normalizedKey = nonEmptyString(key);
|
|
if (!normalizedKey || !(normalizedKey in this.#state.sessions)) return;
|
|
delete this.#state.sessions[normalizedKey];
|
|
await this.#persist();
|
|
}
|
|
|
|
async clearSessions() {
|
|
this.#state.sessions = {};
|
|
await this.#persist();
|
|
}
|
|
|
|
hasSeen(messageId) {
|
|
const id = nonEmptyString(messageId);
|
|
return Boolean(id && this.#state.seenMessageIds.includes(id));
|
|
}
|
|
|
|
async markSeen(messageId) {
|
|
const id = nonEmptyString(messageId);
|
|
if (!id) throw new TypeError('messageId is required');
|
|
if (this.hasSeen(id)) return;
|
|
this.#state.seenMessageIds.push(id);
|
|
if (this.#state.seenMessageIds.length > 1_000) {
|
|
this.#state.seenMessageIds.splice(0, this.#state.seenMessageIds.length - 1_000);
|
|
}
|
|
await this.#persist();
|
|
}
|
|
|
|
pendingSenders() {
|
|
return Object.values(this.#state.pendingSenders)
|
|
.sort((left, right) => left.requestedAt.localeCompare(right.requestedAt))
|
|
.map((entry) => structuredClone(entry));
|
|
}
|
|
|
|
pendingSender(requestId) {
|
|
const id = nonEmptyString(requestId);
|
|
const entry = id ? this.#state.pendingSenders[id] : null;
|
|
return entry ? structuredClone(entry) : null;
|
|
}
|
|
|
|
async recordPendingSender(staffIdOrEntry, name, seenAt) {
|
|
const input = staffIdOrEntry && typeof staffIdOrEntry === 'object'
|
|
? staffIdOrEntry
|
|
: { staffId: staffIdOrEntry, displayName: name, lastSeenAt: seenAt };
|
|
const staffId = nonEmptyString(input.staffId);
|
|
if (!staffId) throw new TypeError('staffId is required');
|
|
const timestamp = nonEmptyString(input.lastSeenAt) ?? nonEmptyString(input.requestedAt) ?? this.#now();
|
|
const existing = Object.values(this.#state.pendingSenders)
|
|
.find((entry) => entry.staffId === staffId);
|
|
const entry = {
|
|
requestId: existing?.requestId ?? `ding_sender_${this.#idFactory()}`,
|
|
staffId,
|
|
displayName: displayName(input.displayName ?? input.nick ?? name),
|
|
requestedAt: existing?.requestedAt ?? timestamp,
|
|
lastSeenAt: timestamp,
|
|
};
|
|
this.#state.pendingSenders[entry.requestId] = entry;
|
|
await this.#persist();
|
|
return structuredClone(entry);
|
|
}
|
|
|
|
async removePendingSender(requestId) {
|
|
const id = nonEmptyString(requestId);
|
|
if (!id || !this.#state.pendingSenders[id]) return false;
|
|
delete this.#state.pendingSenders[id];
|
|
await this.#persist();
|
|
return true;
|
|
}
|
|
|
|
async removePendingSenderByStaffId(staffId) {
|
|
const id = nonEmptyString(staffId);
|
|
const pending = id
|
|
? Object.values(this.#state.pendingSenders).find((entry) => entry.staffId === id)
|
|
: null;
|
|
return pending ? this.removePendingSender(pending.requestId) : false;
|
|
}
|
|
|
|
snapshot() {
|
|
return structuredClone(this.#state);
|
|
}
|
|
|
|
async remove() {
|
|
await this.#writeQueue;
|
|
try {
|
|
await unlink(this.#path);
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') throw error;
|
|
}
|
|
this.#state = structuredClone(EMPTY_STATE);
|
|
}
|
|
|
|
async #persist() {
|
|
const snapshot = `${JSON.stringify(this.#state, null, 2)}\n`;
|
|
const operation = this.#writeQueue.then(async () => {
|
|
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
|
const temporary = `${this.#path}.tmp`;
|
|
await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
|
|
await rename(temporary, this.#path);
|
|
});
|
|
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
|
await operation;
|
|
}
|
|
}
|
|
|
|
export const DingTalkStateStore = DingtalkStateStore;
|