From ad0492ef64849bc991c9b4524f954c1ee8824070 Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:44:09 +0100 Subject: [PATCH] feat: dispatch native AP Like/Announce from fediverse identity on like/repost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add likePost() and boostPost() programmatic methods to the ActivityPub plugin, sending native Like and Announce activities as @svemagie@blog.giersig.eu - Wire up AP dispatch in submitCompose (microsub reader) after a successful Micropub post creation — fire-and-forget, non-blocking - Fix detectProtocol to recognise troet.cafe, hachyderm.io, infosec.exchange, chaos.social and other fediverse domains not in the original pattern list - Fix Mastodon syndication target auto-selection to match by service.name ("mastodon") and by configured instance hostname, not just uid string Co-Authored-By: Claude Sonnet 4.6 --- package.json | 4 +- ...ndpoint-activitypub-like-boost-methods.mjs | 226 ++++++++++++++++++ scripts/patch-microsub-reader-ap-dispatch.mjs | 175 ++++++++++++++ 3 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 scripts/patch-endpoint-activitypub-like-boost-methods.mjs create mode 100644 scripts/patch-microsub-reader-ap-dispatch.mjs diff --git a/package.json b/package.json index ea7eed4f..3a80cf50 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "postinstall": "xattr -w com.apple.fileprovider.ignore#P 1 node_modules 2>/dev/null || true && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-activitypub-docloader-loglevel.mjs && node scripts/patch-endpoint-activitypub-private-url-docloader.mjs && node scripts/patch-endpoint-activitypub-migrate-alias-clear.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-micropub-ai-block-resync.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-conversations-mastodon-disconnect.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs", - "serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/preflight-activitypub-rsa-key.mjs && node scripts/preflight-activitypub-profile-urls.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-activitypub-docloader-loglevel.mjs && node scripts/patch-endpoint-activitypub-private-url-docloader.mjs && node scripts/patch-endpoint-activitypub-migrate-alias-clear.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-micropub-ai-block-resync.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-conversations-mastodon-disconnect.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs", + "postinstall": "xattr -w com.apple.fileprovider.ignore#P 1 node_modules 2>/dev/null || true && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-activitypub-docloader-loglevel.mjs && node scripts/patch-endpoint-activitypub-private-url-docloader.mjs && node scripts/patch-endpoint-activitypub-migrate-alias-clear.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-micropub-ai-block-resync.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-conversations-mastodon-disconnect.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-endpoint-activitypub-like-boost-methods.mjs && node scripts/patch-microsub-reader-ap-dispatch.mjs", + "serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/preflight-activitypub-rsa-key.mjs && node scripts/preflight-activitypub-profile-urls.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-activitypub-docloader-loglevel.mjs && node scripts/patch-endpoint-activitypub-private-url-docloader.mjs && node scripts/patch-endpoint-activitypub-migrate-alias-clear.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-micropub-ai-block-resync.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-conversations-mastodon-disconnect.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-endpoint-activitypub-like-boost-methods.mjs && node scripts/patch-microsub-reader-ap-dispatch.mjs && node node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/scripts/patch-endpoint-activitypub-like-boost-methods.mjs b/scripts/patch-endpoint-activitypub-like-boost-methods.mjs new file mode 100644 index 00000000..eb22e050 --- /dev/null +++ b/scripts/patch-endpoint-activitypub-like-boost-methods.mjs @@ -0,0 +1,226 @@ +import { access, readFile, writeFile } from "node:fs/promises"; + +const patchSpecs = [ + { + name: "activitypub-resolveAuthor-import", + candidates: [ + "node_modules/@rmdes/indiekit-endpoint-activitypub/index.js", + "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/index.js", + ], + replacements: [ + { + oldSnippet: [ + "import { startBatchRefollow } from \"./lib/batch-refollow.js\";", + "import { logActivity } from \"./lib/activity-log.js\";", + "import { scheduleCleanup } from \"./lib/timeline-cleanup.js\";", + ].join("\n"), + newSnippet: [ + "import { startBatchRefollow } from \"./lib/batch-refollow.js\";", + "import { logActivity } from \"./lib/activity-log.js\";", + "import { resolveAuthor } from \"./lib/resolve-author.js\";", + "import { scheduleCleanup } from \"./lib/timeline-cleanup.js\";", + ].join("\n"), + }, + { + oldSnippet: [ + " await this._collections.ap_following.deleteOne({ actorUrl }).catch(() => {});", + " return { ok: false, error: error.message };", + " }", + " }", + "", + " /**", + " * Send an Update(Person) activity to all followers so remote servers", + ].join("\n"), + newSnippet: [ + " await this._collections.ap_following.deleteOne({ actorUrl }).catch(() => {});", + " return { ok: false, error: error.message };", + " }", + " }", + "", + " /**", + " * Send a native AP Like activity for a post URL (called programmatically,", + " * e.g. when a like is created via Micropub).", + " * @param {string} postUrl - URL of the post being liked", + " * @param {object} [collections] - MongoDB collections map (application.collections)", + " * @returns {Promise<{ok: boolean, error?: string}>}", + " */", + " async likePost(postUrl, collections) {", + " if (!this._federation) {", + " return { ok: false, error: \"Federation not initialized\" };", + " }", + "", + " try {", + " const { Like } = await import(\"@fedify/fedify/vocab\");", + " const handle = this.options.actor.handle;", + " const ctx = this._federation.createContext(", + " new URL(this._publicationUrl),", + " { handle, publicationUrl: this._publicationUrl },", + " );", + " const documentLoader = await ctx.getDocumentLoader({ identifier: handle });", + " const cols = collections || this._collections;", + "", + " const recipient = await resolveAuthor(postUrl, ctx, documentLoader, cols);", + " if (!recipient) {", + " return { ok: false, error: `Could not resolve post author for ${postUrl}` };", + " }", + "", + " const uuid = crypto.randomUUID();", + " const activityId = `${this._publicationUrl.replace(/\\/$/, \"\")}/activitypub/likes/${uuid}`;", + "", + " const like = new Like({", + " id: new URL(activityId),", + " actor: ctx.getActorUri(handle),", + " object: new URL(postUrl),", + " });", + "", + " await ctx.sendActivity({ identifier: handle }, recipient, like, {", + " orderingKey: postUrl,", + " });", + "", + " const interactions = cols?.get?.(\"ap_interactions\") || this._collections.ap_interactions;", + " if (interactions) {", + " await interactions.updateOne(", + " { objectUrl: postUrl, type: \"like\" },", + " { $set: { objectUrl: postUrl, type: \"like\", activityId, recipientUrl: recipient.id?.href || \"\", createdAt: new Date().toISOString() } },", + " { upsert: true },", + " );", + " }", + "", + " console.info(`[ActivityPub] Sent Like for ${postUrl}`);", + " return { ok: true };", + " } catch (error) {", + " console.error(`[ActivityPub] likePost failed for ${postUrl}:`, error.message);", + " return { ok: false, error: error.message };", + " }", + " }", + "", + " /**", + " * Send a native AP Announce (boost) activity for a post URL (called", + " * programmatically, e.g. when a repost is created via Micropub).", + " * @param {string} postUrl - URL of the post being boosted", + " * @param {object} [collections] - MongoDB collections map (application.collections)", + " * @returns {Promise<{ok: boolean, error?: string}>}", + " */", + " async boostPost(postUrl, collections) {", + " if (!this._federation) {", + " return { ok: false, error: \"Federation not initialized\" };", + " }", + "", + " try {", + " const { Announce } = await import(\"@fedify/fedify/vocab\");", + " const handle = this.options.actor.handle;", + " const ctx = this._federation.createContext(", + " new URL(this._publicationUrl),", + " { handle, publicationUrl: this._publicationUrl },", + " );", + " const documentLoader = await ctx.getDocumentLoader({ identifier: handle });", + " const cols = collections || this._collections;", + "", + " const uuid = crypto.randomUUID();", + " const activityId = `${this._publicationUrl.replace(/\\/$/, \"\")}/activitypub/boosts/${uuid}`;", + " const publicAddress = new URL(\"https://www.w3.org/ns/activitystreams#Public\");", + " const followersUri = ctx.getFollowersUri(handle);", + "", + " const announce = new Announce({", + " id: new URL(activityId),", + " actor: ctx.getActorUri(handle),", + " object: new URL(postUrl),", + " to: publicAddress,", + " cc: followersUri,", + " });", + "", + " // Broadcast to followers", + " await ctx.sendActivity({ identifier: handle }, \"followers\", announce, {", + " preferSharedInbox: true,", + " syncCollection: true,", + " orderingKey: postUrl,", + " });", + "", + " // Also deliver directly to original post author", + " const recipient = await resolveAuthor(postUrl, ctx, documentLoader, cols);", + " if (recipient) {", + " await ctx.sendActivity({ identifier: handle }, recipient, announce, {", + " orderingKey: postUrl,", + " }).catch((err) => {", + " console.warn(`[ActivityPub] Direct boost delivery to author failed: ${err.message}`);", + " });", + " }", + "", + " const interactions = cols?.get?.(\"ap_interactions\") || this._collections.ap_interactions;", + " if (interactions) {", + " await interactions.updateOne(", + " { objectUrl: postUrl, type: \"boost\" },", + " { $set: { objectUrl: postUrl, type: \"boost\", activityId, createdAt: new Date().toISOString() } },", + " { upsert: true },", + " );", + " }", + "", + " console.info(`[ActivityPub] Sent Announce (boost) for ${postUrl}`);", + " return { ok: true };", + " } catch (error) {", + " console.error(`[ActivityPub] boostPost failed for ${postUrl}:`, error.message);", + " return { ok: false, error: error.message };", + " }", + " }", + "", + " /**", + " * Send an Update(Person) activity to all followers so remote servers", + ].join("\n"), + }, + ], + }, +]; + +async function exists(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +let filesChecked = 0; +let filesPatched = 0; + +for (const patchSpec of patchSpecs) { + for (const filePath of patchSpec.candidates) { + if (!(await exists(filePath))) { + continue; + } + + filesChecked += 1; + + const source = await readFile(filePath, "utf8"); + let updated = source; + + for (const replacement of patchSpec.replacements) { + if (updated.includes(replacement.newSnippet)) { + continue; + } + + if (!updated.includes(replacement.oldSnippet)) { + continue; + } + + updated = updated.replace(replacement.oldSnippet, replacement.newSnippet); + } + + if (updated === source) { + continue; + } + + await writeFile(filePath, updated, "utf8"); + filesPatched += 1; + } +} + +if (filesChecked === 0) { + console.log("[postinstall] No activitypub like/boost patch targets found"); +} else if (filesPatched === 0) { + console.log("[postinstall] activitypub like/boost methods patch already applied"); +} else { + console.log( + `[postinstall] Patched activitypub likePost/boostPost methods in ${filesPatched}/${filesChecked} file(s)`, + ); +} diff --git a/scripts/patch-microsub-reader-ap-dispatch.mjs b/scripts/patch-microsub-reader-ap-dispatch.mjs new file mode 100644 index 00000000..f9e88588 --- /dev/null +++ b/scripts/patch-microsub-reader-ap-dispatch.mjs @@ -0,0 +1,175 @@ +import { access, readFile, writeFile } from "node:fs/promises"; + +const patchSpecs = [ + { + name: "microsub-reader-fediverse-detection-and-ap-dispatch", + candidates: [ + "node_modules/@rmdes/indiekit-endpoint-microsub/lib/controllers/reader.js", + "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-microsub/lib/controllers/reader.js", + ], + replacements: [ + { + // Extend detectProtocol to recognise more fediverse domains (e.g. troet.cafe) + oldSnippet: [ + "function detectProtocol(url) {", + " if (!url || typeof url !== \"string\") return \"web\";", + " const lower = url.toLowerCase();", + " if (lower.includes(\"bsky.app\") || lower.includes(\"bluesky\")) return \"atmosphere\";", + " if (lower.includes(\"mastodon.\") || lower.includes(\"mstdn.\") || lower.includes(\"fosstodon.\") ||", + " lower.includes(\"pleroma.\") || lower.includes(\"misskey.\") || lower.includes(\"pixelfed.\")) return \"fediverse\";", + " return \"web\";", + "}", + ].join("\n"), + newSnippet: [ + "function detectProtocol(url) {", + " if (!url || typeof url !== \"string\") return \"web\";", + " const lower = url.toLowerCase();", + " if (lower.includes(\"bsky.app\") || lower.includes(\"bluesky\")) return \"atmosphere\";", + " // Well-known fediverse software domain patterns", + " if (lower.includes(\"mastodon.\") || lower.includes(\"mstdn.\") || lower.includes(\"fosstodon.\") ||", + " lower.includes(\"troet.\") || lower.includes(\"social.\") || lower.includes(\"pleroma.\") ||", + " lower.includes(\"misskey.\") || lower.includes(\"pixelfed.\") || lower.includes(\"hachyderm.\") ||", + " lower.includes(\"infosec.exchange\") || lower.includes(\"chaos.social\")) return \"fediverse\";", + " return \"web\";", + "}", + ].join("\n"), + }, + { + // Replace naive Mastodon target matching with service-name-aware logic that also + // handles same-instance URLs (e.g. troet.cafe) not in the hardcoded pattern list. + oldSnippet: [ + " if (interactionUrl && syndicationTargets.length > 0) {", + " const protocol = detectProtocol(interactionUrl);", + " for (const target of syndicationTargets) {", + " const targetId = (target.uid || target.name || \"\").toLowerCase();", + " if (protocol === \"atmosphere\" && (targetId.includes(\"bluesky\") || targetId.includes(\"bsky\"))) {", + " target.checked = true;", + " } else if (protocol === \"fediverse\" && (targetId.includes(\"mastodon\") || targetId.includes(\"mstdn\"))) {", + " target.checked = true;", + " }", + " }", + " }", + ].join("\n"), + newSnippet: [ + " if (interactionUrl && syndicationTargets.length > 0) {", + " const protocol = detectProtocol(interactionUrl);", + "", + " // Build set of Mastodon instance hostnames from configured targets so we can", + " // match same-instance URLs (e.g. troet.cafe) even if not in the hardcoded list.", + " const mastodonHostnames = new Set();", + " for (const t of syndicationTargets) {", + " if (t.service?.name?.toLowerCase() === \"mastodon\" && t.service?.url) {", + " try { mastodonHostnames.add(new URL(t.service.url).hostname.toLowerCase()); } catch { /* ignore */ }", + " }", + " }", + " let interactionHostname = \"\";", + " try { interactionHostname = new URL(interactionUrl).hostname.toLowerCase(); } catch { /* ignore */ }", + "", + " for (const target of syndicationTargets) {", + " const targetId = (target.uid || target.name || \"\").toLowerCase();", + " // Identify a Mastodon target by service name (reliable) or legacy uid/name patterns", + " const isMastodonTarget =", + " target.service?.name?.toLowerCase() === \"mastodon\" ||", + " targetId.includes(\"mastodon\") ||", + " targetId.includes(\"mstdn\");", + "", + " if (protocol === \"atmosphere\" && (targetId.includes(\"bluesky\") || targetId.includes(\"bsky\"))) {", + " target.checked = true;", + " } else if (isMastodonTarget && (protocol === \"fediverse\" || mastodonHostnames.has(interactionHostname))) {", + " // Auto-check Mastodon when:", + " // - the URL is from a known fediverse instance (mastodon.social, fosstodon.org, …)", + " // - OR the URL is from the same instance as our Mastodon syndicator (e.g. troet.cafe)", + " target.checked = true;", + " }", + " }", + " }", + ].join("\n"), + }, + { + // After a successful Micropub post, dispatch native AP Like or Announce + // from the blog's own fediverse identity (@svemagie@blog.giersig.eu). + oldSnippet: [ + " // Redirect back to reader with success message", + " return response.redirect(`${request.baseUrl}/channels`);", + ].join("\n"), + newSnippet: [ + " // Dispatch native AP Like or Announce from the blog's own fediverse identity", + " const installedPlugins = request.app.locals.installedPlugins;", + " const apPlugin = installedPlugins", + " ? [...installedPlugins].find((p) => p.name === \"ActivityPub endpoint\")", + " : null;", + "", + " if (apPlugin) {", + " const { application } = request.app.locals;", + " if (likeOf) {", + " apPlugin.likePost(likeOf, application?.collections).then((result) => {", + " if (!result.ok) console.warn(`[Microsub] AP Like failed: ${result.error}`);", + " }).catch((err) => console.warn(`[Microsub] AP Like error: ${err.message}`));", + " } else if (repostOf) {", + " apPlugin.boostPost(repostOf, application?.collections).then((result) => {", + " if (!result.ok) console.warn(`[Microsub] AP Boost failed: ${result.error}`);", + " }).catch((err) => console.warn(`[Microsub] AP Boost error: ${err.message}`));", + " }", + " }", + "", + " // Redirect back to reader with success message", + " return response.redirect(`${request.baseUrl}/channels`);", + ].join("\n"), + }, + ], + }, +]; + +async function exists(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +let filesChecked = 0; +let filesPatched = 0; + +for (const patchSpec of patchSpecs) { + for (const filePath of patchSpec.candidates) { + if (!(await exists(filePath))) { + continue; + } + + filesChecked += 1; + + const source = await readFile(filePath, "utf8"); + let updated = source; + + for (const replacement of patchSpec.replacements) { + if (updated.includes(replacement.newSnippet)) { + continue; + } + + if (!updated.includes(replacement.oldSnippet)) { + continue; + } + + updated = updated.replace(replacement.oldSnippet, replacement.newSnippet); + } + + if (updated === source) { + continue; + } + + await writeFile(filePath, updated, "utf8"); + filesPatched += 1; + } +} + +if (filesChecked === 0) { + console.log("[postinstall] No microsub reader AP dispatch patch targets found"); +} else if (filesPatched === 0) { + console.log("[postinstall] microsub reader AP dispatch patch already applied"); +} else { + console.log( + `[postinstall] Patched microsub reader fediverse detection + AP dispatch in ${filesPatched}/${filesChecked} file(s)`, + ); +}