156 lines
5.0 KiB
TypeScript
156 lines
5.0 KiB
TypeScript
import type {
|
|
MSTeamsConversationStore,
|
|
MSTeamsConversationStoreEntry,
|
|
StoredConversationReference,
|
|
} from "./conversation-store.js";
|
|
import { resolveMSTeamsStorePath } from "./storage.js";
|
|
import { readJsonFile, withFileLock, writeJsonFile } from "./store-fs.js";
|
|
|
|
type ConversationStoreData = {
|
|
version: 1;
|
|
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>;
|
|
};
|
|
|
|
const STORE_FILENAME = "msteams-conversations.json";
|
|
const MAX_CONVERSATIONS = 1000;
|
|
const CONVERSATION_TTL_MS = 365 * 24 * 60 * 60 * 1000;
|
|
|
|
function parseTimestamp(value: string | undefined): number | null {
|
|
if (!value) return null;
|
|
const parsed = Date.parse(value);
|
|
if (!Number.isFinite(parsed)) return null;
|
|
return parsed;
|
|
}
|
|
|
|
function pruneToLimit(
|
|
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>,
|
|
) {
|
|
const entries = Object.entries(conversations);
|
|
if (entries.length <= MAX_CONVERSATIONS) return conversations;
|
|
|
|
entries.sort((a, b) => {
|
|
const aTs = parseTimestamp(a[1].lastSeenAt) ?? 0;
|
|
const bTs = parseTimestamp(b[1].lastSeenAt) ?? 0;
|
|
return aTs - bTs;
|
|
});
|
|
|
|
const keep = entries.slice(entries.length - MAX_CONVERSATIONS);
|
|
return Object.fromEntries(keep);
|
|
}
|
|
|
|
function pruneExpired(
|
|
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>,
|
|
nowMs: number,
|
|
ttlMs: number,
|
|
) {
|
|
let removed = false;
|
|
const kept: typeof conversations = {};
|
|
for (const [conversationId, reference] of Object.entries(conversations)) {
|
|
const lastSeenAt = parseTimestamp(reference.lastSeenAt);
|
|
// Preserve legacy entries that have no lastSeenAt until they're seen again.
|
|
if (lastSeenAt != null && nowMs - lastSeenAt > ttlMs) {
|
|
removed = true;
|
|
continue;
|
|
}
|
|
kept[conversationId] = reference;
|
|
}
|
|
return { conversations: kept, removed };
|
|
}
|
|
|
|
function normalizeConversationId(raw: string): string {
|
|
return raw.split(";")[0] ?? raw;
|
|
}
|
|
|
|
export function createMSTeamsConversationStoreFs(params?: {
|
|
env?: NodeJS.ProcessEnv;
|
|
homedir?: () => string;
|
|
ttlMs?: number;
|
|
stateDir?: string;
|
|
storePath?: string;
|
|
}): MSTeamsConversationStore {
|
|
const ttlMs = params?.ttlMs ?? CONVERSATION_TTL_MS;
|
|
const filePath = resolveMSTeamsStorePath({
|
|
filename: STORE_FILENAME,
|
|
env: params?.env,
|
|
homedir: params?.homedir,
|
|
stateDir: params?.stateDir,
|
|
storePath: params?.storePath,
|
|
});
|
|
|
|
const empty: ConversationStoreData = { version: 1, conversations: {} };
|
|
|
|
const readStore = async (): Promise<ConversationStoreData> => {
|
|
const { value } = await readJsonFile<ConversationStoreData>(filePath, empty);
|
|
if (
|
|
value.version !== 1 ||
|
|
!value.conversations ||
|
|
typeof value.conversations !== "object" ||
|
|
Array.isArray(value.conversations)
|
|
) {
|
|
return empty;
|
|
}
|
|
const nowMs = Date.now();
|
|
const pruned = pruneExpired(value.conversations, nowMs, ttlMs).conversations;
|
|
return { version: 1, conversations: pruneToLimit(pruned) };
|
|
};
|
|
|
|
const list = async (): Promise<MSTeamsConversationStoreEntry[]> => {
|
|
const store = await readStore();
|
|
return Object.entries(store.conversations).map(([conversationId, reference]) => ({
|
|
conversationId,
|
|
reference,
|
|
}));
|
|
};
|
|
|
|
const get = async (conversationId: string): Promise<StoredConversationReference | null> => {
|
|
const store = await readStore();
|
|
return store.conversations[normalizeConversationId(conversationId)] ?? null;
|
|
};
|
|
|
|
const findByUserId = async (id: string): Promise<MSTeamsConversationStoreEntry | null> => {
|
|
const target = id.trim();
|
|
if (!target) return null;
|
|
for (const entry of await list()) {
|
|
const { conversationId, reference } = entry;
|
|
if (reference.user?.aadObjectId === target) {
|
|
return { conversationId, reference };
|
|
}
|
|
if (reference.user?.id === target) {
|
|
return { conversationId, reference };
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const upsert = async (
|
|
conversationId: string,
|
|
reference: StoredConversationReference,
|
|
): Promise<void> => {
|
|
const normalizedId = normalizeConversationId(conversationId);
|
|
await withFileLock(filePath, empty, async () => {
|
|
const store = await readStore();
|
|
store.conversations[normalizedId] = {
|
|
...reference,
|
|
lastSeenAt: new Date().toISOString(),
|
|
};
|
|
const nowMs = Date.now();
|
|
store.conversations = pruneExpired(store.conversations, nowMs, ttlMs).conversations;
|
|
store.conversations = pruneToLimit(store.conversations);
|
|
await writeJsonFile(filePath, store);
|
|
});
|
|
};
|
|
|
|
const remove = async (conversationId: string): Promise<boolean> => {
|
|
const normalizedId = normalizeConversationId(conversationId);
|
|
return await withFileLock(filePath, empty, async () => {
|
|
const store = await readStore();
|
|
if (!(normalizedId in store.conversations)) return false;
|
|
delete store.conversations[normalizedId];
|
|
await writeJsonFile(filePath, store);
|
|
return true;
|
|
});
|
|
};
|
|
|
|
return { upsert, get, list, remove, findByUserId };
|
|
}
|