diff --git a/scripts/patch-ap-conversations-endpoint.mjs b/scripts/patch-ap-conversations-endpoint.mjs new file mode 100644 index 00000000..0ffe9bbc --- /dev/null +++ b/scripts/patch-ap-conversations-endpoint.mjs @@ -0,0 +1,380 @@ +/** + * Patch: Implement /api/v1/conversations endpoint + backfill ap_messages. + * + * Problem: /api/v1/conversations was a stub returning []. DMs are stored in + * ap_notifications (type:"dm") but ap_messages was empty (code added after + * existing DMs were processed). + * + * Fix: + * A) Backfill ap_messages from ap_notifications type:"dm" (idempotent upsert) + * B) Replace the conversations stub with a real implementation that: + * - Aggregates ap_messages grouped by conversationId (actor URL) + * - Returns Mastodon Conversation entities + * - Supports pagination (max_id, since_id, limit) + * C) Add POST /api/v1/conversations/:id/read to mark conversations read + */ + +import { access, readFile, writeFile } from "node:fs/promises"; + +const AP_BASE = "@rmdes/indiekit-endpoint-activitypub"; +const AP_ROOTS = [ + `node_modules/${AP_BASE}`, + `node_modules/@indiekit/indiekit/node_modules/${AP_BASE}`, +]; + +function apPath(rel) { + return AP_ROOTS.map(r => `${r}/${rel}`); +} + +async function fileExists(p) { + try { await access(p); return true; } catch { return false; } +} + +const SCRIPT = "patch-ap-conversations-endpoint"; +const MARKER = "// [patch] ap-conversations-endpoint"; +let total = 0; + +// ── Part A: Backfill ap_messages from ap_notifications ────────────────────── + +async function backfillMessages() { + let MongoClient; + try { + ({ MongoClient } = await import("mongodb")); + } catch { + console.warn(`[postinstall] ${SCRIPT}: mongodb driver not available, skipping backfill`); + return; + } + + // Load .env for Mongo credentials + const dotenv = await import("dotenv"); + const fs = await import("node:fs"); + let envVars = {}; + try { + const envContent = fs.readFileSync(".env", "utf8"); + envVars = dotenv.parse(envContent); + } catch { + // .env not found — use process.env + } + const env = { ...process.env, ...envVars }; + + const mongoUsername = env.MONGO_USERNAME || env.MONGO_USER || ""; + const mongoPassword = env.MONGO_PASSWORD || ""; + const mongoHost = env.MONGO_HOST || "10.100.0.20"; + const mongoPort = env.MONGO_PORT || "27017"; + const mongoDatabase = env.MONGO_DATABASE || env.MONGO_DB || "indiekit"; + const mongoAuthSource = env.MONGO_AUTH_SOURCE || "admin"; + const mongoCredentials = mongoUsername && mongoPassword + ? `${encodeURIComponent(mongoUsername)}:${encodeURIComponent(mongoPassword)}@` + : ""; + const mongoQuery = mongoCredentials && mongoAuthSource + ? `?authSource=${encodeURIComponent(mongoAuthSource)}` + : ""; + const mongoUrl = env.MONGO_URL || `mongodb://${mongoCredentials}${mongoHost}:${mongoPort}/${mongoDatabase}${mongoQuery}`; + + let client; + try { + client = new MongoClient(mongoUrl); + await client.connect(); + const db = client.db(mongoDatabase); + const notifications = db.collection("ap_notifications"); + const messages = db.collection("ap_messages"); + + // Find all DM notifications + const dmNotifs = await notifications.find({ type: "dm" }).toArray(); + if (!dmNotifs.length) { + console.log(`[postinstall] ${SCRIPT}: no DM notifications to backfill`); + return; + } + + let backfilled = 0; + for (const notif of dmNotifs) { + // Build uid matching what addMessage uses + const uid = notif.uid?.startsWith("dm:") + ? notif.uid.replace(/^dm:/, "") + : notif.uid || `dm:${notif.actorUrl}:${Date.now()}`; + + const result = await messages.updateOne( + { uid }, + { + $setOnInsert: { + uid, + actorUrl: notif.actorUrl || "", + actorName: notif.actorName || "", + actorPhoto: notif.actorPhoto || "", + actorHandle: notif.actorHandle || "", + content: notif.content || { text: "", html: "" }, + inReplyTo: null, + conversationId: notif.actorUrl || "", + direction: "inbound", + published: notif.published || notif.createdAt || new Date().toISOString(), + createdAt: notif.createdAt || new Date().toISOString(), + read: notif.read || false, + }, + }, + { upsert: true }, + ); + if (result.upsertedCount > 0) backfilled++; + } + + console.log(`[postinstall] ${SCRIPT}: backfilled ${backfilled}/${dmNotifs.length} DM notifications into ap_messages`); + } catch (error) { + console.warn(`[postinstall] ${SCRIPT}: backfill failed: ${error.message}`); + } finally { + if (client) await client.close().catch(() => {}); + } +} + +// ── Part B: Replace conversations stub in stubs.js ────────────────────────── + +const STUBS_CANDIDATES = apPath("lib/mastodon/routes/stubs.js"); + +// The old stub — exact match +const CONVERSATIONS_STUB = `router.get("/api/v1/conversations", (req, res) => { + res.json([]); +});`; + +// The new implementation +const CONVERSATIONS_IMPL = `// ─── Conversations (Direct Messages) ──────────────────────────────────────── +// Real implementation replacing the empty stub. ${MARKER} +// Reads from ap_messages collection, groups by conversationId (actor URL). + +router.get("/api/v1/conversations", async (req, res, next) => { ${MARKER} + try { ${MARKER} + const collections = req.app.locals.mastodonCollections; ${MARKER} + const baseUrl = \`\${req.protocol}://\${req.get("host")}\`; ${MARKER} + const { serializeAccount } = await import("../entities/account.js"); ${MARKER} + const { remoteActorId } = await import("../helpers/id-mapping.js"); ${MARKER} + const { parseLimit } = await import("../helpers/pagination.js"); ${MARKER} + ${MARKER} + if (!collections?.ap_messages) { ${MARKER} + return res.json([]); ${MARKER} + } ${MARKER} + ${MARKER} + const limit = parseLimit(req.query.limit, 20); ${MARKER} + ${MARKER} + // Aggregate conversations: group by conversationId, get last message + unread count ${MARKER} + const pipeline = [ ${MARKER} + { $sort: { published: -1 } }, ${MARKER} + { ${MARKER} + $group: { ${MARKER} + _id: "$conversationId", ${MARKER} + lastMessageId: { $first: "$_id" }, ${MARKER} + lastUid: { $first: "$uid" }, ${MARKER} + lastContent: { $first: "$content" }, ${MARKER} + lastPublished: { $first: "$published" }, ${MARKER} + actorUrl: { $first: "$actorUrl" }, ${MARKER} + actorName: { $first: "$actorName" }, ${MARKER} + actorPhoto: { $first: "$actorPhoto" }, ${MARKER} + actorHandle: { $first: "$actorHandle" }, ${MARKER} + unreadCount: { ${MARKER} + $sum: { $cond: [{ $eq: ["$read", false] }, 1, 0] }, ${MARKER} + }, ${MARKER} + }, ${MARKER} + }, ${MARKER} + { $sort: { lastPublished: -1 } }, ${MARKER} + ]; ${MARKER} + ${MARKER} + // Apply cursor pagination on the aggregation result ${MARKER} + if (req.query.max_id) { ${MARKER} + pipeline.splice(0, 0, { ${MARKER} + $match: { _id: { $lt: req.query.max_id } }, ${MARKER} + }); ${MARKER} + } ${MARKER} + ${MARKER} + pipeline.push({ $limit: limit }); ${MARKER} + ${MARKER} + const conversations = await collections.ap_messages ${MARKER} + .aggregate(pipeline) ${MARKER} + .toArray(); ${MARKER} + ${MARKER} + const result = conversations.map((conv) => { ${MARKER} + const convId = remoteActorId(conv._id || conv.actorUrl); ${MARKER} + ${MARKER} + // Build a minimal Mastodon Status for last_status ${MARKER} + const lastStatus = { ${MARKER} + id: conv.lastMessageId.toString(), ${MARKER} + created_at: conv.lastPublished || new Date().toISOString(), ${MARKER} + in_reply_to_id: null, ${MARKER} + in_reply_to_account_id: null, ${MARKER} + sensitive: false, ${MARKER} + spoiler_text: "", ${MARKER} + visibility: "direct", ${MARKER} + language: null, ${MARKER} + uri: conv.lastUid || "", ${MARKER} + url: conv.lastUid || "", ${MARKER} + replies_count: 0, ${MARKER} + reblogs_count: 0, ${MARKER} + favourites_count: 0, ${MARKER} + edited_at: null, ${MARKER} + favourited: false, ${MARKER} + reblogged: false, ${MARKER} + muted: false, ${MARKER} + bookmarked: false, ${MARKER} + pinned: false, ${MARKER} + content: conv.lastContent?.html || conv.lastContent?.text || "", ${MARKER} + filtered: null, ${MARKER} + reblog: null, ${MARKER} + application: null, ${MARKER} + account: serializeAccount( ${MARKER} + { ${MARKER} + name: conv.actorName, ${MARKER} + url: conv.actorUrl, ${MARKER} + photo: conv.actorPhoto, ${MARKER} + handle: conv.actorHandle, ${MARKER} + }, ${MARKER} + { baseUrl }, ${MARKER} + ), ${MARKER} + media_attachments: [], ${MARKER} + mentions: [], ${MARKER} + tags: [], ${MARKER} + emojis: [], ${MARKER} + card: null, ${MARKER} + poll: null, ${MARKER} + }; ${MARKER} + ${MARKER} + return { ${MARKER} + id: convId, ${MARKER} + unread: conv.unreadCount > 0, ${MARKER} + last_status: lastStatus, ${MARKER} + accounts: [ ${MARKER} + serializeAccount( ${MARKER} + { ${MARKER} + name: conv.actorName, ${MARKER} + url: conv.actorUrl, ${MARKER} + photo: conv.actorPhoto, ${MARKER} + handle: conv.actorHandle, ${MARKER} + }, ${MARKER} + { baseUrl }, ${MARKER} + ), ${MARKER} + ], ${MARKER} + }; ${MARKER} + }); ${MARKER} + ${MARKER} + // Set Link header for pagination ${MARKER} + if (result.length === limit && conversations.length > 0) { ${MARKER} + const lastConv = conversations[conversations.length - 1]; ${MARKER} + const maxId = remoteActorId(lastConv._id || lastConv.actorUrl); ${MARKER} + res.set("Link", \`<\${baseUrl}/api/v1/conversations?max_id=\${maxId}>; rel="next"\`); ${MARKER} + } ${MARKER} + ${MARKER} + res.json(result); ${MARKER} + } catch (error) { ${MARKER} + next(error); ${MARKER} + } ${MARKER} +}); ${MARKER} + +// Mark conversation as read ${MARKER} +router.post("/api/v1/conversations/:id/read", async (req, res, next) => { ${MARKER} + try { ${MARKER} + const collections = req.app.locals.mastodonCollections; ${MARKER} + const baseUrl = \`\${req.protocol}://\${req.get("host")}\`; ${MARKER} + const { serializeAccount } = await import("../entities/account.js"); ${MARKER} + const { remoteActorId } = await import("../helpers/id-mapping.js"); ${MARKER} + ${MARKER} + if (!collections?.ap_messages) { ${MARKER} + return res.status(404).json({ error: "Not found" }); ${MARKER} + } ${MARKER} + ${MARKER} + // Find the conversation partner whose hashed actorUrl matches the :id ${MARKER} + const allPartners = await collections.ap_messages.aggregate([ ${MARKER} + { $group: { _id: "$conversationId" } }, ${MARKER} + ]).toArray(); ${MARKER} + ${MARKER} + const partner = allPartners.find( ${MARKER} + (p) => remoteActorId(p._id) === req.params.id ${MARKER} + ); ${MARKER} + ${MARKER} + if (!partner) { ${MARKER} + return res.status(404).json({ error: "Conversation not found" }); ${MARKER} + } ${MARKER} + ${MARKER} + // Mark all messages from this partner as read ${MARKER} + await collections.ap_messages.updateMany( ${MARKER} + { conversationId: partner._id, read: false }, ${MARKER} + { $set: { read: true } }, ${MARKER} + ); ${MARKER} + ${MARKER} + // Return the updated conversation ${MARKER} + const lastMsg = await collections.ap_messages ${MARKER} + .findOne({ conversationId: partner._id }, { sort: { published: -1 } }); ${MARKER} + ${MARKER} + if (!lastMsg) { ${MARKER} + return res.status(404).json({ error: "No messages" }); ${MARKER} + } ${MARKER} + ${MARKER} + const convId = remoteActorId(partner._id); ${MARKER} + const account = serializeAccount( ${MARKER} + { ${MARKER} + name: lastMsg.actorName, ${MARKER} + url: lastMsg.actorUrl, ${MARKER} + photo: lastMsg.actorPhoto, ${MARKER} + handle: lastMsg.actorHandle, ${MARKER} + }, ${MARKER} + { baseUrl }, ${MARKER} + ); ${MARKER} + ${MARKER} + res.json({ ${MARKER} + id: convId, ${MARKER} + unread: false, ${MARKER} + last_status: { ${MARKER} + id: lastMsg._id.toString(), ${MARKER} + created_at: lastMsg.published || new Date().toISOString(), ${MARKER} + in_reply_to_id: null, ${MARKER} + in_reply_to_account_id: null, ${MARKER} + sensitive: false, ${MARKER} + spoiler_text: "", ${MARKER} + visibility: "direct", ${MARKER} + language: null, ${MARKER} + uri: lastMsg.uid || "", ${MARKER} + url: lastMsg.uid || "", ${MARKER} + replies_count: 0, ${MARKER} + reblogs_count: 0, ${MARKER} + favourites_count: 0, ${MARKER} + edited_at: null, ${MARKER} + favourited: false, ${MARKER} + reblogged: false, ${MARKER} + muted: false, ${MARKER} + bookmarked: false, ${MARKER} + pinned: false, ${MARKER} + content: lastMsg.content?.html || lastMsg.content?.text || "", ${MARKER} + filtered: null, ${MARKER} + reblog: null, ${MARKER} + application: null, ${MARKER} + account, ${MARKER} + media_attachments: [], ${MARKER} + mentions: [], ${MARKER} + tags: [], ${MARKER} + emojis: [], ${MARKER} + card: null, ${MARKER} + poll: null, ${MARKER} + }, ${MARKER} + accounts: [account], ${MARKER} + }); ${MARKER} + } catch (error) { ${MARKER} + next(error); ${MARKER} + } ${MARKER} +}); ${MARKER}`; + +let stubsDone = false; +for (const f of STUBS_CANDIDATES) { + if (!(await fileExists(f))) continue; + const src = await readFile(f, "utf8"); + if (src.includes(MARKER)) { + console.log(`[postinstall] ${SCRIPT}: conversations endpoint already applied in ${f}`); + stubsDone = true; break; + } + if (!src.includes(CONVERSATIONS_STUB)) { + console.warn(`[postinstall] ${SCRIPT}: conversations stub not found in ${f}`); + continue; + } + const updated = src.replace(CONVERSATIONS_STUB, CONVERSATIONS_IMPL); + await writeFile(f, updated, "utf8"); + console.log(`[postinstall] ${SCRIPT}: applied conversations endpoint to ${f}`); + total++; stubsDone = true; break; +} +if (!stubsDone) console.log(`[postinstall] ${SCRIPT}: conversations stub — no target file found or no changes`); + +// ── Run backfill ──────────────────────────────────────────────────────────── +await backfillMessages(); + +console.log(`[postinstall] ${SCRIPT}: done (${total} patch(es) applied)`);