diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js index 47a6789..0c4c264 100644 --- a/lib/mastodon/routes/statuses.js +++ b/lib/mastodon/routes/statuses.js @@ -12,7 +12,9 @@ * POST /api/v1/statuses/:id/bookmark — bookmark a post * POST /api/v1/statuses/:id/unbookmark — remove bookmark */ +import crypto from "node:crypto"; import express from "express"; +import { Note, Create, Mention } from "@fedify/fedify/vocab"; import { ObjectId } from "mongodb"; import { serializeStatus } from "../entities/status.js"; import { decodeCursor } from "../helpers/pagination.js"; @@ -22,6 +24,8 @@ import { bookmarkPost, unbookmarkPost, } from "../helpers/interactions.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 @@ -187,7 +191,7 @@ router.post("/api/v1/statuses", async (req, res, next) => { jf2.sensitive = "true"; } - if (visibility && visibility !== "public") { + if (visibility && visibility !== "public" && visibility !== "direct") { jf2.visibility = visibility; } @@ -195,6 +199,130 @@ router.post("/api/v1/statuses", async (req, res, next) => { 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. // Never cross-post to Bluesky (conversations stay in their protocol). // The publication URL is the AP syndicator's uid.