Spaces:
Paused
Paused
| 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 }; | |
| } | |