fix(mastodon-api): DM with visibility=direct created public blog post
Intercept visibility="direct" in POST /api/v1/statuses before the Micropub pipeline. Resolve the @mention via WebFinger, build a Create/Note AP activity addressed only to the recipient (no public addressing), send via ctx.sendActivity(), and store in ap_notifications for the DM thread view. No blog post is created. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,9 @@
|
|||||||
* POST /api/v1/statuses/:id/bookmark — bookmark a post
|
* POST /api/v1/statuses/:id/bookmark — bookmark a post
|
||||||
* POST /api/v1/statuses/:id/unbookmark — remove bookmark
|
* POST /api/v1/statuses/:id/unbookmark — remove bookmark
|
||||||
*/
|
*/
|
||||||
|
import crypto from "node:crypto";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { Note, Create, Mention } from "@fedify/fedify/vocab";
|
||||||
import { ObjectId } from "mongodb";
|
import { ObjectId } from "mongodb";
|
||||||
import { serializeStatus } from "../entities/status.js";
|
import { serializeStatus } from "../entities/status.js";
|
||||||
import { decodeCursor } from "../helpers/pagination.js";
|
import { decodeCursor } from "../helpers/pagination.js";
|
||||||
@@ -22,6 +24,8 @@ import {
|
|||||||
bookmarkPost, unbookmarkPost,
|
bookmarkPost, unbookmarkPost,
|
||||||
} from "../helpers/interactions.js";
|
} from "../helpers/interactions.js";
|
||||||
import { addTimelineItem } from "../../storage/timeline.js";
|
import { addTimelineItem } from "../../storage/timeline.js";
|
||||||
|
import { lookupWithSecurity } from "../../lookup-helpers.js";
|
||||||
|
import { addNotification } from "../../storage/notifications.js";
|
||||||
|
|
||||||
const router = express.Router(); // eslint-disable-line new-cap
|
const router = express.Router(); // eslint-disable-line new-cap
|
||||||
|
|
||||||
@@ -187,7 +191,7 @@ router.post("/api/v1/statuses", async (req, res, next) => {
|
|||||||
jf2.sensitive = "true";
|
jf2.sensitive = "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visibility && visibility !== "public") {
|
if (visibility && visibility !== "public" && visibility !== "direct") {
|
||||||
jf2.visibility = visibility;
|
jf2.visibility = visibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +199,130 @@ router.post("/api/v1/statuses", async (req, res, next) => {
|
|||||||
jf2["mp-language"] = language;
|
jf2["mp-language"] = language;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Direct messages: bypass Micropub, send via native AP DM path ──────────
|
||||||
|
// Mastodon clients send visibility="direct" for DMs. These must NOT create
|
||||||
|
// a public blog post — instead send a Create/Note activity directly to the
|
||||||
|
// mentioned recipient, same as the web compose form does.
|
||||||
|
if (visibility === "direct") {
|
||||||
|
const federation = pluginOptions.federation;
|
||||||
|
const handle = pluginOptions.handle || "user";
|
||||||
|
const publicationUrl = pluginOptions.publicationUrl || baseUrl;
|
||||||
|
|
||||||
|
if (!federation) {
|
||||||
|
return res.status(503).json({ error: "Federation not available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract first @user@domain mention from status text
|
||||||
|
const mentionMatch = (statusText || "").match(/@([\w.-]+@[\w.-]+)/);
|
||||||
|
if (!mentionMatch) {
|
||||||
|
return res.status(422).json({ error: "Direct messages must mention a recipient (@user@domain)" });
|
||||||
|
}
|
||||||
|
const mentionHandle = mentionMatch[1];
|
||||||
|
|
||||||
|
const ctx = federation.createContext(new URL(publicationUrl), {
|
||||||
|
handle,
|
||||||
|
publicationUrl,
|
||||||
|
});
|
||||||
|
const actorUri = ctx.getActorUri(handle);
|
||||||
|
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
|
||||||
|
|
||||||
|
// Resolve @user@domain → actor URL via WebFinger
|
||||||
|
let recipientActorUrl;
|
||||||
|
try {
|
||||||
|
const webfingerUrl = `https://${mentionHandle.split("@")[1]}/.well-known/webfinger?resource=acct:${mentionHandle}`;
|
||||||
|
const wfRes = await fetch(webfingerUrl, { headers: { Accept: "application/jrd+json" } });
|
||||||
|
if (wfRes.ok) {
|
||||||
|
const wf = await wfRes.json();
|
||||||
|
recipientActorUrl = wf.links?.find((l) => l.rel === "self" && l.type?.includes("activity"))?.href;
|
||||||
|
}
|
||||||
|
} catch { /* fall through to lookup */ }
|
||||||
|
|
||||||
|
// Fallback: resolve via federation lookup
|
||||||
|
if (!recipientActorUrl) {
|
||||||
|
try {
|
||||||
|
const actor = await lookupWithSecurity(ctx, `acct:${mentionHandle}`, { documentLoader });
|
||||||
|
if (actor?.id) recipientActorUrl = actor.id.href;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recipientActorUrl) {
|
||||||
|
return res.status(422).json({ error: `Could not resolve recipient: @${mentionHandle}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuid = crypto.randomUUID();
|
||||||
|
const noteId = new URL(`${publicationUrl.replace(/\/$/, "")}/activitypub/notes/${uuid}`);
|
||||||
|
|
||||||
|
const note = new Note({
|
||||||
|
id: noteId,
|
||||||
|
attributedTo: actorUri,
|
||||||
|
to: new URL(recipientActorUrl),
|
||||||
|
content: (statusText || "").trim(),
|
||||||
|
...(inReplyTo ? { replyTarget: new URL(inReplyTo) } : {}),
|
||||||
|
tag: new Mention({ href: new URL(recipientActorUrl) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const create = new Create({
|
||||||
|
id: new URL(`${noteId.href}#create`),
|
||||||
|
actor: actorUri,
|
||||||
|
to: new URL(recipientActorUrl),
|
||||||
|
object: note,
|
||||||
|
});
|
||||||
|
|
||||||
|
let recipient;
|
||||||
|
try {
|
||||||
|
recipient = await lookupWithSecurity(ctx, new URL(recipientActorUrl), { documentLoader });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
if (!recipient) {
|
||||||
|
recipient = {
|
||||||
|
id: new URL(recipientActorUrl),
|
||||||
|
inboxId: new URL(`${recipientActorUrl}/inbox`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.sendActivity({ identifier: handle }, recipient, create, {
|
||||||
|
orderingKey: noteId.href,
|
||||||
|
});
|
||||||
|
console.info(`[Mastodon API] Sent DM to ${recipientActorUrl}`);
|
||||||
|
|
||||||
|
// Store in ap_notifications so it appears in the DM thread view
|
||||||
|
try {
|
||||||
|
const ap_notifications = collections.ap_notifications;
|
||||||
|
if (ap_notifications) {
|
||||||
|
const hostname = new URL(publicationUrl).hostname;
|
||||||
|
const profile = await collections.ap_profile.findOne({});
|
||||||
|
await addNotification({ ap_notifications }, {
|
||||||
|
uid: noteId.href,
|
||||||
|
url: noteId.href,
|
||||||
|
type: "mention",
|
||||||
|
isDirect: true,
|
||||||
|
direction: "outbound",
|
||||||
|
senderActorUrl: recipientActorUrl,
|
||||||
|
actorUrl: actorUri.href,
|
||||||
|
actorName: profile?.name || handle,
|
||||||
|
actorPhoto: profile?.icon || "",
|
||||||
|
actorHandle: `@${handle}@${hostname}`,
|
||||||
|
inReplyTo: inReplyTo || null,
|
||||||
|
content: { text: (statusText || "").trim(), html: (statusText || "").trim() },
|
||||||
|
published: new Date().toISOString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (storeError) {
|
||||||
|
console.warn("[Mastodon API] Failed to store outbound DM:", storeError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a minimal status object so the client doesn't error
|
||||||
|
return res.json({
|
||||||
|
id: noteId.href,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
content: (statusText || "").trim(),
|
||||||
|
visibility: "direct",
|
||||||
|
url: noteId.href,
|
||||||
|
account: { acct: handle },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// ── End DM path ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Syndicate to AP only — posts from Mastodon clients belong to the fediverse.
|
// Syndicate to AP only — posts from Mastodon clients belong to the fediverse.
|
||||||
// Never cross-post to Bluesky (conversations stay in their protocol).
|
// Never cross-post to Bluesky (conversations stay in their protocol).
|
||||||
// The publication URL is the AP syndicator's uid.
|
// The publication URL is the AP syndicator's uid.
|
||||||
|
|||||||
Reference in New Issue
Block a user