feat: consolidated patch-ap-mastodon-statuses (threading, status-id, delete, reply-id, interactions)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sven
2026-04-12 13:51:24 +02:00
parent ea130f0c50
commit 55f48d210d
+264
View File
@@ -0,0 +1,264 @@
/**
* Consolidated patch for lib/mastodon/routes/statuses.js.
*
* Absorbs:
* - patch-ap-mastodon-reply-threading (eager ap_timeline insert on POST /statuses)
* - patch-ap-mastodon-status-id (return addTimelineItem _id as status ID)
* - patch-ap-mastodon-delete-fix (Change C only — broadcastDelete after timeline removal)
* - patch-ap-status-reply-id (Change B only — store inReplyToId in timeline insert)
* - patch-ap-interactions-context-state (load real interaction state for thread context)
*
* Note: patch-ap-mastodon-delete-fix Change A (index.js) is handled separately.
* patch-ap-status-reply-id Change A (status.js entity) is upstream-fixed — omitted.
*
* Patches are applied in order. Patches 2 and 4 depend on markers written by patch 1,
* so each patch in the PATCHES array is applied sequentially per file.
*/
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; }
}
async function applyPatch(filePath, marker, oldSnippet, newSnippet) {
if (!(await fileExists(filePath))) return "file_not_found";
const src = await readFile(filePath, "utf8");
if (src.includes(marker)) return "already_applied";
if (!src.includes(oldSnippet)) return "snippet_not_found";
await writeFile(filePath, src.replace(oldSnippet, newSnippet), "utf8");
return "applied";
}
/**
* Apply multiple replacements to a single file in one read/write cycle.
* Returns "already_applied" if marker is found, "applied" if changes were made,
* "snippet_not_found" if any required snippet was missing, "file_not_found" if absent.
*/
async function applyMultiPatch(filePath, marker, replacements) {
if (!(await fileExists(filePath))) return "file_not_found";
let src = await readFile(filePath, "utf8");
if (src.includes(marker)) return "already_applied";
let updated = src;
for (const { oldSnippet, newSnippet, label } of replacements) {
if (!updated.includes(oldSnippet)) {
console.warn(`[postinstall] patch-ap-mastodon-statuses: snippet "${label}" not found in ${filePath}`);
return "snippet_not_found";
}
updated = updated.replace(oldSnippet, newSnippet);
}
await writeFile(filePath, updated, "utf8");
return "applied";
}
const SCRIPT = "patch-ap-mastodon-statuses";
// ── Patch 1: ap-mastodon-reply-threading ────────────────────────────────────
const MARKER_THREADING = "// [patch] ap-mastodon-reply-threading";
const OLD_THREADING = ` // Return a minimal status to the Mastodon client.
// No timeline entry is created here — the post will appear in the timeline
// after the normal flow: Eleventy rebuild → syndication webhook → AP delivery.
const profile = await collections.ap_profile.findOne({});
const handle = pluginOptions.handle || "user";`;
const NEW_THREADING = ` // Return a minimal status to the Mastodon client. ${MARKER_THREADING}
// Eagerly insert own post into ap_timeline so the Mastodon client can resolve ${MARKER_THREADING}
// in_reply_to_id for this post immediately, without waiting for the build webhook. ${MARKER_THREADING}
// The AP syndicator will upsert the same uid later via $setOnInsert (no-op). ${MARKER_THREADING}
const profile = await collections.ap_profile.findOne({});
const handle = pluginOptions.handle || "user";
try { ${MARKER_THREADING}
const _ph = (() => { try { return new URL(publicationUrl).hostname; } catch { return ""; } })(); ${MARKER_THREADING}
await addTimelineItem(collections, { ${MARKER_THREADING}
uid: postUrl, ${MARKER_THREADING}
url: postUrl, ${MARKER_THREADING}
type: data.properties["post-type"] || "note", ${MARKER_THREADING}
content: { text: contentText, html: \`<p>\${contentHtml}</p>\` }, ${MARKER_THREADING}
author: { ${MARKER_THREADING}
name: profile?.name || handle, ${MARKER_THREADING}
url: profile?.url || publicationUrl, ${MARKER_THREADING}
photo: profile?.icon || "", ${MARKER_THREADING}
handle: \`@\${handle}@\${_ph}\`, ${MARKER_THREADING}
emojis: [], ${MARKER_THREADING}
bot: false, ${MARKER_THREADING}
}, ${MARKER_THREADING}
published: data.properties.published || new Date().toISOString(), ${MARKER_THREADING}
createdAt: new Date().toISOString(), ${MARKER_THREADING}
inReplyTo: inReplyTo || null, ${MARKER_THREADING}
visibility: jf2.visibility || "public", ${MARKER_THREADING}
sensitive: jf2.sensitive === "true", ${MARKER_THREADING}
category: [], ${MARKER_THREADING}
counts: { likes: 0, boosts: 0, replies: 0 }, ${MARKER_THREADING}
}); ${MARKER_THREADING}
} catch (tlErr) { ${MARKER_THREADING}
console.warn(\`[Mastodon API] Failed to pre-insert own post into timeline: \${tlErr.message}\`); ${MARKER_THREADING}
} ${MARKER_THREADING}`;
// ── Patch 2: ap-mastodon-status-id (3 replacements, depends on patch 1 markers) ─
const MARKER_STATUS_ID = "// [patch] ap-mastodon-status-id";
const STATUS_ID_REPLACEMENTS = [
{
label: "declare _tlItem before try",
oldSnippet: ` try { // [patch] ap-mastodon-reply-threading`,
newSnippet: ` let _tlItem = null; ${MARKER_STATUS_ID}
try { // [patch] ap-mastodon-reply-threading`,
},
{
label: "capture addTimelineItem return value",
oldSnippet: ` await addTimelineItem(collections, { // [patch] ap-mastodon-reply-threading`,
newSnippet: ` _tlItem = await addTimelineItem(collections, { // [patch] ap-mastodon-reply-threading ${MARKER_STATUS_ID}`,
},
{
label: "use _tlItem._id as status response ID",
oldSnippet: ` id: String(Date.now()),`,
newSnippet: ` id: _tlItem?._id?.toString() || String(Date.now()), ${MARKER_STATUS_ID}`,
},
];
// ── Patch 3: ap-mastodon-delete-fix Change C (broadcastDelete call) ──────────
const MARKER_DELETE_FIX = "// [patch] ap-mastodon-delete-fix";
const OLD_AFTER_DELETE = ` // Delete from timeline
await collections.ap_timeline.deleteOne({ _id: item._id });
// Clean up interactions`;
const NEW_AFTER_DELETE = ` // Delete from timeline
await collections.ap_timeline.deleteOne({ _id: item._id }); ${MARKER_DELETE_FIX}
// Broadcast AP Delete activity to followers ${MARKER_DELETE_FIX}
const _pluginOpts = req.app.locals.mastodonPluginOptions || {};
if (_pluginOpts.broadcastDelete && postUrl) {
_pluginOpts.broadcastDelete(postUrl).catch((err) =>
console.warn(\`[Mastodon API] broadcastDelete failed for \${postUrl}: \${err.message}\`),
);
}
// Clean up interactions`;
// ── Patch 4: ap-status-reply-id Change B (store inReplyToId in timeline insert) ─
const MARKER_REPLY_ID = "// [patch] ap-status-reply-id";
const OLD_REPLY_INSERT = ` inReplyTo: inReplyTo || null, // [patch] ap-mastodon-reply-threading`;
const NEW_REPLY_INSERT = ` inReplyTo: inReplyTo || null, // [patch] ap-mastodon-reply-threading
inReplyToId: inReplyToId || null, ${MARKER_REPLY_ID}`;
// ── Patch 5: ap-interactions-context-state ───────────────────────────────────
const MARKER_CTX_STATE = "// [patch] ap-interactions-context-state";
const OLD_CTX_STATE = ` // Serialize all items
const emptyInteractions = {
favouritedIds: new Set(),
rebloggedIds: new Set(),
bookmarkedIds: new Set(),
pinnedIds: new Set(),
};
const allItems = [...ancestors, ...descendants];
const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, allItems);
const serializeOpts = { baseUrl, ...emptyInteractions, replyIdMap, replyAccountIdMap };`;
const NEW_CTX_STATE = ` // Serialize all items
const allItems = [...ancestors, ...descendants];
const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, allItems);
// Load real interaction state for thread context ${MARKER_CTX_STATE}
const ctxFavouritedIds = new Set();
const ctxRebloggedIds = new Set();
const ctxBookmarkedIds = new Set();
if (allItems.length > 0 && collections.ap_interactions) {
const ctxUrlToUid = new Map();
for (const ci of allItems) {
if (ci.uid) { ctxUrlToUid.set(ci.uid, ci.uid); }
if (ci.url && ci.url !== ci.uid) { ctxUrlToUid.set(ci.url, ci.uid || ci.url); }
}
const ctxLookupUrls = [...ctxUrlToUid.keys()];
if (ctxLookupUrls.length > 0) {
const ctxInteractions = await collections.ap_interactions
.find({ objectUrl: { $in: ctxLookupUrls } })
.toArray();
for (const ci of ctxInteractions) {
const uid = ctxUrlToUid.get(ci.objectUrl) || ci.objectUrl;
if (ci.type === "like") ctxFavouritedIds.add(uid);
else if (ci.type === "boost") ctxRebloggedIds.add(uid);
else if (ci.type === "bookmark") ctxBookmarkedIds.add(uid);
}
}
}
const serializeOpts = { baseUrl, favouritedIds: ctxFavouritedIds, rebloggedIds: ctxRebloggedIds, bookmarkedIds: ctxBookmarkedIds, pinnedIds: new Set(), replyIdMap, replyAccountIdMap };`;
// ── Runner ───────────────────────────────────────────────────────────────────
const FILES = apPath("lib/mastodon/routes/statuses.js");
// Patches that use a single replacement (applyPatch)
const SINGLE_PATCHES = [
{ name: "reply-threading", marker: MARKER_THREADING, oldSnippet: OLD_THREADING, newSnippet: NEW_THREADING },
{ name: "delete-fix-C", marker: MARKER_DELETE_FIX, oldSnippet: OLD_AFTER_DELETE, newSnippet: NEW_AFTER_DELETE },
{ name: "status-reply-id-B", marker: MARKER_REPLY_ID, oldSnippet: OLD_REPLY_INSERT, newSnippet: NEW_REPLY_INSERT },
{ name: "interactions-context", marker: MARKER_CTX_STATE, oldSnippet: OLD_CTX_STATE, newSnippet: NEW_CTX_STATE },
];
// Patches that need multiple replacements in one pass (applyMultiPatch)
const MULTI_PATCHES = [
{ name: "status-id", marker: MARKER_STATUS_ID, replacements: STATUS_ID_REPLACEMENTS },
];
let total = 0;
// Apply single patches
for (const p of SINGLE_PATCHES) {
let done = false;
for (const f of FILES) {
const r = await applyPatch(f, p.marker, p.oldSnippet, p.newSnippet);
if (r === "applied") {
console.log(`[postinstall] ${SCRIPT}: applied ${p.name} to ${f}`);
total++; done = true; break;
} else if (r === "already_applied") {
console.log(`[postinstall] ${SCRIPT}: ${p.name} already applied in ${f}`);
done = true; break;
} else if (r === "snippet_not_found") {
console.warn(`[postinstall] ${SCRIPT}: ${p.name} — snippet not found in ${f}, skipping`);
}
}
if (!done) console.log(`[postinstall] ${SCRIPT}: ${p.name} — no target file found`);
}
// Apply multi-replacement patches
for (const p of MULTI_PATCHES) {
let done = false;
for (const f of FILES) {
const r = await applyMultiPatch(f, p.marker, p.replacements);
if (r === "applied") {
console.log(`[postinstall] ${SCRIPT}: applied ${p.name} to ${f}`);
total++; done = true; break;
} else if (r === "already_applied") {
console.log(`[postinstall] ${SCRIPT}: ${p.name} already applied in ${f}`);
done = true; break;
} else if (r === "snippet_not_found") {
// warning already printed inside applyMultiPatch
}
}
if (!done) console.log(`[postinstall] ${SCRIPT}: ${p.name} — no target file found`);
}
console.log(`[postinstall] ${SCRIPT}: done (${total} patch(es) applied)`);