feat!: move msteams to plugin
This commit is contained in:
155
extensions/msteams/src/conversation-store-fs.ts
Normal file
155
extensions/msteams/src/conversation-store-fs.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user