feat: implement /api/v1/conversations endpoint for DM visibility
Deploy Indiekit Server / deploy (push) Successful in 1m22s
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:
@@ -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)`);
|
||||
Reference in New Issue
Block a user