feat: implement /api/v1/conversations endpoint for DM visibility
Deploy Indiekit Server / deploy (push) Successful in 1m22s

Replace the empty stub with a real Mastodon Conversations API implementation
so Mastodon clients (Mona, Phanpy) can see direct messages. Also backfills
ap_messages from ap_notifications on startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sven
2026-04-25 14:34:47 +02:00
parent 7d581cd909
commit 9fe04a64d9
+380
View File
@@ -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)`);