mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
feat: deliver replies to original post author's inbox
Replies syndicated via ActivityPub were only sent to followers. Remote servers (e.g. Mastodon) never received the Create(Note) activity, so replies didn't appear under the original post. Changes: - Resolve the reply-to post author via ctx.lookupObject() + getAttributedTo() - Include the original author in CC addressing (ccs) on the Note - Add a Mention tag for the original author - Deliver the activity to the author's inbox via a second sendActivity() call - Log reply delivery with targetUrl for debugging Also includes: following list badge fix from refollow work, version bump to 1.0.20
This commit is contained in:
75
index.js
75
index.js
@@ -261,10 +261,50 @@ export default class ActivityPubEndpoint {
|
||||
|
||||
try {
|
||||
const actorUrl = self._getActorUrl();
|
||||
const handle = self.options.actor.handle;
|
||||
|
||||
const ctx = self._federation.createContext(
|
||||
new URL(self._publicationUrl),
|
||||
{},
|
||||
);
|
||||
|
||||
// For replies, resolve the original post author for proper
|
||||
// addressing (CC) and direct inbox delivery
|
||||
let replyToActor = null;
|
||||
if (properties["in-reply-to"]) {
|
||||
try {
|
||||
const remoteObject = await ctx.lookupObject(
|
||||
new URL(properties["in-reply-to"]),
|
||||
);
|
||||
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
|
||||
const author = await remoteObject.getAttributedTo();
|
||||
const authorActor = Array.isArray(author) ? author[0] : author;
|
||||
if (authorActor?.id) {
|
||||
replyToActor = {
|
||||
url: authorActor.id.href,
|
||||
handle: authorActor.preferredUsername || null,
|
||||
recipient: authorActor,
|
||||
};
|
||||
console.info(
|
||||
`[ActivityPub] Reply to ${properties["in-reply-to"]} — resolved author: ${replyToActor.url}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ActivityPub] Could not resolve reply-to author for ${properties["in-reply-to"]}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const activity = jf2ToAS2Activity(
|
||||
properties,
|
||||
actorUrl,
|
||||
self._publicationUrl,
|
||||
{
|
||||
replyToActorUrl: replyToActor?.url,
|
||||
replyToActorHandle: replyToActor?.handle,
|
||||
},
|
||||
);
|
||||
|
||||
if (!activity) {
|
||||
@@ -278,11 +318,6 @@ export default class ActivityPubEndpoint {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ctx = self._federation.createContext(
|
||||
new URL(self._publicationUrl),
|
||||
{},
|
||||
);
|
||||
|
||||
// Count followers for logging
|
||||
const followerCount =
|
||||
await self._collections.ap_followers.countDocuments();
|
||||
@@ -291,26 +326,50 @@ export default class ActivityPubEndpoint {
|
||||
`[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`,
|
||||
);
|
||||
|
||||
// Send to followers
|
||||
await ctx.sendActivity(
|
||||
{ identifier: self.options.actor.handle },
|
||||
{ identifier: handle },
|
||||
"followers",
|
||||
activity,
|
||||
);
|
||||
|
||||
// For replies, also deliver to the original post author's inbox
|
||||
// so their server can thread the reply under the original post
|
||||
if (replyToActor?.recipient) {
|
||||
try {
|
||||
await ctx.sendActivity(
|
||||
{ identifier: handle },
|
||||
replyToActor.recipient,
|
||||
activity,
|
||||
);
|
||||
console.info(
|
||||
`[ActivityPub] Reply delivered to author: ${replyToActor.url}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ActivityPub] Failed to deliver reply to ${replyToActor.url}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine activity type name
|
||||
const typeName =
|
||||
activity.constructor?.name || "Create";
|
||||
const replyNote = replyToActor
|
||||
? ` (reply to ${replyToActor.url})`
|
||||
: "";
|
||||
|
||||
await logActivity(self._collections.ap_activities, {
|
||||
direction: "outbound",
|
||||
type: typeName,
|
||||
actorUrl: self._publicationUrl,
|
||||
objectUrl: properties.url,
|
||||
summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers`,
|
||||
targetUrl: replyToActor?.url || undefined,
|
||||
summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}`,
|
||||
});
|
||||
|
||||
console.info(
|
||||
`[ActivityPub] Syndication queued: ${typeName} for ${properties.url}`,
|
||||
`[ActivityPub] Syndication queued: ${typeName} for ${properties.url}${replyNote}`,
|
||||
);
|
||||
|
||||
return properties.url || undefined;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Hashtag,
|
||||
Image,
|
||||
Like,
|
||||
Mention,
|
||||
Note,
|
||||
Video,
|
||||
} from "@fedify/fedify";
|
||||
@@ -126,9 +127,12 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
|
||||
* @param {object} properties - JF2 post properties
|
||||
* @param {string} actorUrl - Actor URL (e.g. "https://example.com/activitypub/users/rick")
|
||||
* @param {string} publicationUrl - Publication base URL with trailing slash
|
||||
* @param {object} [options] - Optional settings
|
||||
* @param {string} [options.replyToActorUrl] - Original post author's actor URL (for reply addressing)
|
||||
* @param {string} [options.replyToActorHandle] - Original post author's handle (for Mention tag)
|
||||
* @returns {import("@fedify/fedify").Activity | null}
|
||||
*/
|
||||
export function jf2ToAS2Activity(properties, actorUrl, publicationUrl) {
|
||||
export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options = {}) {
|
||||
const postType = properties["post-type"];
|
||||
const actorUri = new URL(actorUrl);
|
||||
|
||||
@@ -154,13 +158,25 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl) {
|
||||
const isArticle = postType === "article" && properties.name;
|
||||
const postUrl = resolvePostUrl(properties.url, publicationUrl);
|
||||
const followersUrl = `${actorUrl.replace(/\/$/, "")}/followers`;
|
||||
const { replyToActorUrl, replyToActorHandle } = options;
|
||||
|
||||
const noteOptions = {
|
||||
attributedTo: actorUri,
|
||||
to: new URL("https://www.w3.org/ns/activitystreams#Public"),
|
||||
cc: new URL(followersUrl),
|
||||
};
|
||||
|
||||
// Addressing: for replies, include original author in CC so their server
|
||||
// threads the reply and notifies them
|
||||
if (replyToActorUrl && properties["in-reply-to"]) {
|
||||
noteOptions.to = new URL("https://www.w3.org/ns/activitystreams#Public");
|
||||
noteOptions.ccs = [
|
||||
new URL(followersUrl),
|
||||
new URL(replyToActorUrl),
|
||||
];
|
||||
} else {
|
||||
noteOptions.to = new URL("https://www.w3.org/ns/activitystreams#Public");
|
||||
noteOptions.cc = new URL(followersUrl);
|
||||
}
|
||||
|
||||
if (postUrl) {
|
||||
noteOptions.id = new URL(postUrl);
|
||||
noteOptions.url = new URL(postUrl);
|
||||
@@ -208,8 +224,18 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl) {
|
||||
noteOptions.attachments = fedifyAttachments;
|
||||
}
|
||||
|
||||
// Hashtags
|
||||
// Tags: hashtags + Mention for reply addressing
|
||||
const fedifyTags = buildFedifyTags(properties, publicationUrl, postType);
|
||||
|
||||
if (replyToActorUrl) {
|
||||
fedifyTags.push(
|
||||
new Mention({
|
||||
href: new URL(replyToActorUrl),
|
||||
name: replyToActorHandle ? `@${replyToActorHandle}` : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (fedifyTags.length > 0) {
|
||||
noteOptions.tags = fedifyTags;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
||||
"version": "1.0.18",
|
||||
"version": "1.0.20",
|
||||
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
||||
"keywords": [
|
||||
"indiekit",
|
||||
|
||||
@@ -11,17 +11,21 @@
|
||||
|
||||
{% if following.length > 0 %}
|
||||
{% for account in following %}
|
||||
{% if account.source === "import" %}
|
||||
{% set sourceBadge = __("activitypub.sourceImport") %}
|
||||
{% elif account.source === "refollow:sent" %}
|
||||
{% set sourceBadge = __("activitypub.sourceRefollowPending") %}
|
||||
{% elif account.source === "refollow:failed" %}
|
||||
{% set sourceBadge = __("activitypub.sourceRefollowFailed") %}
|
||||
{% else %}
|
||||
{% set sourceBadge = __("activitypub.sourceFederation") %}
|
||||
{% endif %}
|
||||
{{ card({
|
||||
title: account.name or account.handle or account.actorUrl,
|
||||
url: account.actorUrl,
|
||||
description: { text: "@" + account.handle if account.handle },
|
||||
published: account.followedAt,
|
||||
badges: [{
|
||||
text: __("activitypub.sourceImport") if account.source === "import"
|
||||
else __("activitypub.sourceRefollowPending") if account.source === "refollow:sent"
|
||||
else __("activitypub.sourceRefollowFailed") if account.source === "refollow:failed"
|
||||
else __("activitypub.sourceFederation")
|
||||
}]
|
||||
badges: [{ text: sourceBadge }]
|
||||
}) }}
|
||||
{% endfor %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user