mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
feat: Hollo-inspired federation patterns — outbox failure handling, reply chains, forwarding, visibility
- Add outbox permanent failure handling with smart cleanup: - 410 Gone: immediate full cleanup (follower + timeline + notifications) - 404: strike system (3 failures over 7+ days triggers cleanup) - Strike reset on inbound activity (proves actor is alive) - Add recursive reply chain fetching (depth 5) with isContext flag - Add reply forwarding to followers for public replies to our posts - Add write-time visibility classification (public/unlisted/private/direct) Confab-Link: http://localhost:8080/sessions/af5f8b45-6b8d-442d-8f25-78c326190709
This commit is contained in:
@@ -152,6 +152,73 @@ function isDirectMessage(object, ourActorUrl, followersUrl) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute post visibility from to/cc addressing fields.
|
||||
* Matches Hollo's write-time visibility classification.
|
||||
*
|
||||
* @param {object} object - Fedify object (Note, Article, etc.)
|
||||
* @returns {"public"|"unlisted"|"private"|"direct"}
|
||||
*/
|
||||
function computeVisibility(object) {
|
||||
const to = new Set((object.toIds || []).map((u) => u.href));
|
||||
const cc = new Set((object.ccIds || []).map((u) => u.href));
|
||||
|
||||
if (to.has(PUBLIC)) return "public";
|
||||
if (cc.has(PUBLIC)) return "unlisted";
|
||||
// Without knowing the remote actor's followers URL, we can't distinguish
|
||||
// "private" (followers-only) from "direct". Both are non-public.
|
||||
if (to.size > 0 || cc.size > 0) return "private";
|
||||
return "direct";
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively fetch and store ancestor posts for a reply chain.
|
||||
* Each ancestor is stored with isContext: true so it can be filtered
|
||||
* from the main timeline while being available for thread views.
|
||||
*
|
||||
* @param {object} object - Fedify object (Note, Article, etc.)
|
||||
* @param {object} collections - MongoDB collections
|
||||
* @param {object} authLoader - Authenticated document loader
|
||||
* @param {number} maxDepth - Maximum recursion depth
|
||||
*/
|
||||
async function fetchReplyChain(object, collections, authLoader, maxDepth) {
|
||||
if (maxDepth <= 0) return;
|
||||
const parentUrl = object.replyTargetId?.href;
|
||||
if (!parentUrl) return;
|
||||
|
||||
// Skip if we already have this post
|
||||
if (collections.ap_timeline) {
|
||||
const existing = await collections.ap_timeline.findOne({ uid: parentUrl });
|
||||
if (existing) return;
|
||||
}
|
||||
|
||||
// Fetch the parent post
|
||||
let parent;
|
||||
try {
|
||||
parent = await object.getReplyTarget({ documentLoader: authLoader });
|
||||
} catch {
|
||||
// Remote server unreachable — stop climbing
|
||||
return;
|
||||
}
|
||||
if (!parent || !parent.id) return;
|
||||
|
||||
// Store as context item
|
||||
try {
|
||||
const timelineItem = await extractObjectData(parent, {
|
||||
documentLoader: authLoader,
|
||||
});
|
||||
timelineItem.isContext = true;
|
||||
timelineItem.visibility = computeVisibility(parent);
|
||||
await addTimelineItem(collections, timelineItem);
|
||||
} catch {
|
||||
// Extraction failed — stop climbing
|
||||
return;
|
||||
}
|
||||
|
||||
// Recurse for the parent's parent
|
||||
await fetchReplyChain(parent, collections, authLoader, maxDepth - 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Individual handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -515,7 +582,7 @@ export async function handleAnnounce(item, collections, ctx, handle) {
|
||||
boostedAt: announce.published ? String(announce.published) : new Date().toISOString(),
|
||||
documentLoader: authLoader,
|
||||
});
|
||||
|
||||
timelineItem.visibility = computeVisibility(object);
|
||||
await addTimelineItem(collections, timelineItem);
|
||||
|
||||
// Fire-and-forget quote enrichment for boosted posts
|
||||
@@ -688,6 +755,18 @@ export async function handleCreate(item, collections, ctx, handle) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Recursive reply chain fetching ---
|
||||
// Fetch and store ancestor posts so conversation threads have context.
|
||||
// Each ancestor is stored with isContext: true to distinguish from organic timeline items.
|
||||
if (inReplyTo) {
|
||||
try {
|
||||
await fetchReplyChain(object, collections, authLoader, 5);
|
||||
} catch (error) {
|
||||
// Non-critical — incomplete context is acceptable
|
||||
console.warn("[inbox-handlers] Reply chain fetch failed:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for mentions of our actor
|
||||
if (object.tag) {
|
||||
const tags = Array.isArray(object.tag) ? object.tag : [object.tag];
|
||||
@@ -728,6 +807,7 @@ export async function handleCreate(item, collections, ctx, handle) {
|
||||
actorFallback: actorObj,
|
||||
documentLoader: authLoader,
|
||||
});
|
||||
timelineItem.visibility = computeVisibility(object);
|
||||
await addTimelineItem(collections, timelineItem);
|
||||
|
||||
// Fire-and-forget OG unfurling for notes and articles (not boosts)
|
||||
@@ -768,6 +848,7 @@ export async function handleCreate(item, collections, ctx, handle) {
|
||||
actorFallback: actorObj,
|
||||
documentLoader: authLoader,
|
||||
});
|
||||
timelineItem.visibility = computeVisibility(object);
|
||||
await addTimelineItem(collections, timelineItem);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user