/** * Patch: skip Micropub blog post creation for short standalone Mastodon posts. * * Very short status texts (≤ 280 chars, no media, no reply) are fediverse-only * interactions — they federate via AP but must NOT create a permanent blog entry. * Posts with in_reply_to, media, or text > 280 characters go through the full * Micropub pipeline as before. * * Short posts get a canonical URL at /activitypub/objects/note/{uuid} (same as * DMs), are stored in ap_timeline, and delivered to followers via Fedify's * ctx.sendActivity({ "followers" }) — the same mechanism the AP syndicator uses. */ import { access, readFile, writeFile } from "node:fs/promises"; const candidates = [ "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js", "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js", ]; const MARKER = "// [patch] short-post-bypass"; const OLD = ` // Resolve in_reply_to URL from status ID (cursor or ObjectId) let inReplyTo = null; if (inReplyToId) {`; const NEW = ` // Short standalone posts (≤ 280 chars, no media, no reply) skip Micropub // and go directly to AP — no blog entry is created. // [patch] short-post-bypass const _shortPostLimit = 280; const _isShortPost = !inReplyToId && (!mediaIds || mediaIds.length === 0) && visibility !== "direct" && (statusText || "").trim().length > 0 && (statusText || "").trim().length <= _shortPostLimit; if (_isShortPost) { const _spPluginOptions = req.app.locals.mastodonPluginOptions || {}; const _spPublicationUrl = (_spPluginOptions.publicationUrl || baseUrl).replace(/\\/+$/, ""); const _spFederation = _spPluginOptions.federation; const _spHandle = _spPluginOptions.handle || "user"; const _spHostname = (() => { try { return new URL(_spPublicationUrl).hostname; } catch { return ""; } })(); const _spProfile = await collections.ap_profile.findOne({}); const { randomUUID } = await import("node:crypto"); const _spUuid = randomUUID(); const _spNoteId = \`\${_spPublicationUrl}/activitypub/objects/note/\${_spUuid}\`; const _spNow = new Date().toISOString(); const _spText = (statusText || "").trim(); const _spHtml = _spText .replace(/&/g, "&") .replace(//g, ">") .replace(/(https?:\\/\\/[^\\s<>&"')\\]]+)/g, '$1') .replace(/\\n/g, "
"); const _spTlItem = await addTimelineItem(collections, { uid: _spNoteId, url: _spNoteId, type: "note", content: { text: _spText, html: \`

\${_spHtml}

\` }, author: { name: _spProfile?.name || _spHandle, url: _spProfile?.url || _spPublicationUrl, photo: _spProfile?.icon || "", handle: \`@\${_spHandle}@\${_spHostname}\`, emojis: [], bot: false, }, published: _spNow, createdAt: _spNow, inReplyTo: null, visibility: visibility || "public", sensitive: sensitive === true || sensitive === "true", category: [], counts: { likes: 0, boosts: 0, replies: 0 }, }); if (_spFederation) { try { const { Note, Create } = await import("@fedify/fedify/vocab"); const _spCtx = _spFederation.createContext( new URL(_spPublicationUrl + "/"), { handle: _spHandle, publicationUrl: _spPublicationUrl }, ); const _spActorUri = _spCtx.getActorUri(_spHandle); const _spNote = new Note({ id: new URL(_spNoteId), attributedTo: _spActorUri, content: \`

\${_spHtml}

\`, published: new Date(_spNow), }); const _spCreate = new Create({ id: new URL(\`\${_spNoteId}#create\`), actor: _spActorUri, object: _spNote, }); await _spCtx.sendActivity( { identifier: _spHandle }, "followers", _spCreate, { preferSharedInbox: true, syncCollection: true, orderingKey: _spNoteId }, ); console.info(\`[Mastodon API] Short post federated to followers (no blog entry): \${_spNoteId}\`); } catch (_spApErr) { console.warn(\`[Mastodon API] Short post AP federation error: \${_spApErr.message}\`); } } const _spStatus = serializeStatus(_spTlItem, { baseUrl, favouritedIds: new Set(), rebloggedIds: new Set(), bookmarkedIds: new Set(), pinnedIds: new Set(), }); if (idempotencyKey && collections.ap_idempotency) { const { createHash } = await import("node:crypto"); const _spKey = createHash("sha256").update(\`\${baseUrl}:\${idempotencyKey}\`).digest("hex"); await collections.ap_idempotency.insertOne({ key: _spKey, response: _spStatus, createdAt: new Date() }).catch(() => {}); } return res.json(_spStatus); } // Resolve in_reply_to URL from status ID (cursor or ObjectId) let inReplyTo = null; if (inReplyToId) {`; async function exists(p) { try { await access(p); return true; } catch { return false; } } let patched = false; for (const filePath of candidates) { if (!(await exists(filePath))) continue; const src = await readFile(filePath, "utf8"); if (src.includes(MARKER)) { console.log(`[postinstall] patch-ap-mastodon-short-post-bypass: already applied in ${filePath}`); patched = true; break; } if (!src.includes(OLD)) { console.log(`[postinstall] patch-ap-mastodon-short-post-bypass: target snippet not found in ${filePath}`); continue; } await writeFile(filePath, src.replace(OLD, NEW), "utf8"); console.log(`[postinstall] patch-ap-mastodon-short-post-bypass: applied to ${filePath}`); patched = true; break; } if (!patched) { console.log("[postinstall] patch-ap-mastodon-short-post-bypass: no target file found"); }