Spaces:
Sleeping
Sleeping
| ; | |
| var __importDefault = (this && this.__importDefault) || function (mod) { | |
| return (mod && mod.__esModule) ? mod : { "default": mod }; | |
| }; | |
| Object.defineProperty(exports, "__esModule", { value: true }); | |
| exports.convKey = convKey; | |
| exports.initDmStore = initDmStore; | |
| exports.isDmReady = isDmReady; | |
| exports.sendMessage = sendMessage; | |
| exports.deleteMessage = deleteMessage; | |
| exports.getConversation = getConversation; | |
| exports.listConversations = listConversations; | |
| exports.countUnread = countUnread; | |
| const fs_1 = __importDefault(require("fs")); | |
| const path_1 = __importDefault(require("path")); | |
| const crypto_1 = __importDefault(require("crypto")); | |
| const secureStore_1 = require("./secureStore"); | |
| /** | |
| * Sharded encrypted DM store. | |
| * | |
| * Layout (local-db/): | |
| * dm.enc → shard 0 (conversations 0–N) | |
| * dm1.enc → shard 1 | |
| * dm2.enc → shard 2 … (infinite) | |
| * | |
| * Each shard holds up to DM_SHARD_SIZE conversation records. | |
| * Each conversation holds up to DM_MSG_LIMIT messages (ring-buffer — oldest | |
| * are evicted when the limit is reached, preventing unbounded growth). | |
| * | |
| * Conversation key is always the two usernames sorted and joined with ":", | |
| * e.g. "@alice:@bob" — canonical regardless of who sends first. | |
| */ | |
| const LOCAL_DIR = path_1.default.join(__dirname, "../../local-db"); | |
| const DM_SHARD_SIZE = 100; // max conversations per shard file | |
| const DM_MSG_LIMIT = 200; // max messages stored per conversation | |
| // ── in-memory cache ───────────────────────────────────────────────────────── | |
| const convCache = new Map(); | |
| let dmReady = false; | |
| let dmSecure = null; | |
| function ensureDir() { | |
| if (!fs_1.default.existsSync(LOCAL_DIR)) | |
| fs_1.default.mkdirSync(LOCAL_DIR, { recursive: true }); | |
| } | |
| function getSecure() { | |
| if (!dmSecure) { | |
| ensureDir(); | |
| dmSecure = new secureStore_1.SecureStore(LOCAL_DIR); | |
| } | |
| return dmSecure; | |
| } | |
| function shardPath(n) { | |
| return n === 0 | |
| ? path_1.default.join(LOCAL_DIR, "dm.enc") | |
| : path_1.default.join(LOCAL_DIR, `dm${n}.enc`); | |
| } | |
| function convKey(a, b) { | |
| return [a, b].sort().join(":"); | |
| } | |
| // ── persist ────────────────────────────────────────────────────────────────── | |
| function persistSync() { | |
| try { | |
| ensureDir(); | |
| const sec = getSecure(); | |
| const all = Array.from(convCache.entries()) | |
| .sort(([, a], [, b]) => b.updatedAt - a.updatedAt); // newest first | |
| const totalShards = Math.max(1, Math.ceil(all.length / DM_SHARD_SIZE)); | |
| for (let i = 0; i < totalShards; i++) { | |
| const chunk = all.slice(i * DM_SHARD_SIZE, (i + 1) * DM_SHARD_SIZE); | |
| const obj = {}; | |
| for (const [k, v] of chunk) | |
| obj[k] = v; | |
| const blob = sec.encrypt(JSON.stringify(obj)); | |
| fs_1.default.writeFileSync(shardPath(i), blob, { mode: 0o600 }); | |
| } | |
| // Remove stale shards if count shrank. | |
| for (let i = totalShards; i < 10_000; i++) { | |
| const stale = shardPath(i); | |
| if (fs_1.default.existsSync(stale)) | |
| fs_1.default.unlinkSync(stale); | |
| else | |
| break; | |
| } | |
| } | |
| catch (e) { | |
| console.error("[dm] persist failed:", e?.message); | |
| } | |
| } | |
| // ── load ───────────────────────────────────────────────────────────────────── | |
| function loadSync() { | |
| try { | |
| ensureDir(); | |
| const sec = getSecure(); | |
| convCache.clear(); | |
| for (let i = 0; i < 10_000; i++) { | |
| const f = shardPath(i); | |
| if (!fs_1.default.existsSync(f)) | |
| break; | |
| try { | |
| const blob = fs_1.default.readFileSync(f); | |
| const obj = JSON.parse(sec.decrypt(blob)); | |
| for (const [k, v] of Object.entries(obj)) { | |
| convCache.set(k, v); | |
| } | |
| } | |
| catch (e) { | |
| console.error(`[dm] shard ${i} load failed:`, e?.message); | |
| break; | |
| } | |
| } | |
| dmReady = true; | |
| const shardCount = Math.ceil(convCache.size / DM_SHARD_SIZE) || 1; | |
| console.log(`[dm] loaded ${convCache.size} conversations (${shardCount} shard(s))`); | |
| } | |
| catch (e) { | |
| console.error("[dm] load failed:", e?.message); | |
| dmReady = true; // still mark ready so the API doesn't block forever | |
| } | |
| } | |
| function initDmStore() { | |
| loadSync(); | |
| } | |
| function isDmReady() { return dmReady; } | |
| // ── public API ──────────────────────────────────────────────────────────────── | |
| /** Get or create a conversation between two users. */ | |
| function getOrCreate(a, b) { | |
| const key = convKey(a, b); | |
| if (!convCache.has(key)) { | |
| convCache.set(key, { | |
| key, | |
| participants: [a, b].sort(), | |
| messages: [], | |
| updatedAt: Date.now(), | |
| }); | |
| } | |
| return convCache.get(key); | |
| } | |
| /** Send a message from `from` to `to`. Returns the new message. */ | |
| function sendMessage(from, to, text) { | |
| const conv = getOrCreate(from, to); | |
| const msg = { | |
| id: crypto_1.default.randomUUID(), | |
| from, | |
| text: text.slice(0, 4000), // cap message length | |
| ts: Date.now(), | |
| }; | |
| conv.messages.push(msg); | |
| // Ring buffer — evict oldest messages beyond the limit. | |
| if (conv.messages.length > DM_MSG_LIMIT) { | |
| conv.messages.splice(0, conv.messages.length - DM_MSG_LIMIT); | |
| } | |
| conv.updatedAt = msg.ts; | |
| persistSync(); | |
| return msg; | |
| } | |
| /** Delete a message (soft-delete). Only the sender may delete. */ | |
| function deleteMessage(requester, partnerOrKey, msgId) { | |
| const key = partnerOrKey.includes(":") | |
| ? partnerOrKey | |
| : convKey(requester, partnerOrKey); | |
| const conv = convCache.get(key); | |
| if (!conv) | |
| return false; | |
| const msg = conv.messages.find(m => m.id === msgId); | |
| if (!msg || msg.from !== requester) | |
| return false; | |
| msg.deleted = true; | |
| msg.text = ""; | |
| conv.updatedAt = Date.now(); | |
| persistSync(); | |
| return true; | |
| } | |
| /** Return the conversation between two users (or null if none yet). */ | |
| function getConversation(a, b) { | |
| return convCache.get(convKey(a, b)) ?? null; | |
| } | |
| /** | |
| * Return all conversations where `username` is a participant, | |
| * sorted by most recent. Optionally limit the result count. | |
| */ | |
| function listConversations(username, limit = 100) { | |
| const results = []; | |
| for (const conv of convCache.values()) { | |
| if (conv.participants.includes(username)) | |
| results.push(conv); | |
| } | |
| return results | |
| .sort((a, b) => b.updatedAt - a.updatedAt) | |
| .slice(0, limit); | |
| } | |
| /** Count unread messages for a user (messages from others since `since` ms). */ | |
| function countUnread(username, since) { | |
| let count = 0; | |
| for (const conv of convCache.values()) { | |
| if (!conv.participants.includes(username)) | |
| continue; | |
| for (const m of conv.messages) { | |
| if (m.from !== username && m.ts > since && !m.deleted) | |
| count++; | |
| } | |
| } | |
| return count; | |
| } | |