@rmdes/indiekit-endpoint-activitypub 2.13.0 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1021 @@
1
+ /**
2
+ * Inbox handler functions for each ActivityPub activity type.
3
+ *
4
+ * These handlers are extracted from inbox-listeners.js so they can be
5
+ * invoked from a background queue processor. Each handler receives a
6
+ * queue item document instead of a live Fedify activity object.
7
+ *
8
+ * Design notes:
9
+ * - Follow handler: only logs activity. Follower storage, Accept/Reject
10
+ * response, pending follow storage, and notifications are all handled
11
+ * synchronously in the inbox listener before the item is enqueued.
12
+ * - Block handler: only logs activity. Follower removal is done
13
+ * synchronously in the inbox listener.
14
+ * - All other handlers: perform full processing.
15
+ */
16
+
17
+ import {
18
+ Accept,
19
+ Announce,
20
+ Article,
21
+ Block,
22
+ Create,
23
+ Delete,
24
+ Flag,
25
+ Follow,
26
+ Like,
27
+ Move,
28
+ Note,
29
+ Reject,
30
+ Undo,
31
+ Update,
32
+ } from "@fedify/fedify/vocab";
33
+
34
+ import { logActivity as logActivityShared } from "./activity-log.js";
35
+ import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline-store.js";
36
+ import { addTimelineItem, deleteTimelineItem, updateTimelineItem } from "./storage/timeline.js";
37
+ import { addNotification } from "./storage/notifications.js";
38
+ import { addMessage } from "./storage/messages.js";
39
+ import { fetchAndStorePreviews, fetchAndStoreQuote } from "./og-unfurl.js";
40
+ import { getFollowedTags } from "./storage/followed-tags.js";
41
+
42
+ /** @type {string} ActivityStreams Public Collection constant */
43
+ const PUBLIC = "https://www.w3.org/ns/activitystreams#Public";
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Router
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Route a queued inbox item to the appropriate handler.
51
+ *
52
+ * @param {object} item - Queue document
53
+ * @param {string} item.activityType - Activity type name (e.g. "Follow")
54
+ * @param {string} item.actorUrl - Actor URL
55
+ * @param {string} [item.objectUrl] - Object URL (if applicable)
56
+ * @param {object} item.rawJson - Raw JSON-LD activity payload
57
+ * @param {object} collections - MongoDB collections
58
+ * @param {import("@fedify/fedify").Context} ctx - Fedify context
59
+ * @param {string} handle - Local actor handle
60
+ */
61
+ export async function routeToHandler(item, collections, ctx, handle) {
62
+ const { activityType } = item;
63
+ switch (activityType) {
64
+ case "Follow":
65
+ return handleFollow(item, collections);
66
+ case "Undo":
67
+ return handleUndo(item, collections, ctx, handle);
68
+ case "Accept":
69
+ return handleAccept(item, collections, ctx, handle);
70
+ case "Reject":
71
+ return handleReject(item, collections, ctx, handle);
72
+ case "Like":
73
+ return handleLike(item, collections, ctx, handle);
74
+ case "Announce":
75
+ return handleAnnounce(item, collections, ctx, handle);
76
+ case "Create":
77
+ return handleCreate(item, collections, ctx, handle);
78
+ case "Delete":
79
+ return handleDelete(item, collections);
80
+ case "Move":
81
+ return handleMove(item, collections, ctx, handle);
82
+ case "Update":
83
+ return handleUpdate(item, collections, ctx, handle);
84
+ case "Block":
85
+ return handleBlock(item, collections);
86
+ case "Flag":
87
+ return handleFlag(item, collections, ctx, handle);
88
+ default:
89
+ console.warn(`[inbox-handlers] Unknown activity type: ${activityType}`);
90
+ }
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Helpers
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Get an authenticated DocumentLoader that signs outbound fetches with
99
+ * our actor's key.
100
+ *
101
+ * @param {import("@fedify/fedify").Context} ctx - Fedify context
102
+ * @param {string} handle - Actor handle
103
+ * @returns {Promise<import("@fedify/fedify").DocumentLoader>}
104
+ */
105
+ function getAuthLoader(ctx, handle) {
106
+ return ctx.getDocumentLoader({ identifier: handle });
107
+ }
108
+
109
+ /**
110
+ * Log an activity to the ap_activities collection.
111
+ *
112
+ * @param {object} collections - MongoDB collections
113
+ * @param {object} record - Activity record fields
114
+ */
115
+ async function logActivity(collections, record) {
116
+ await logActivityShared(collections.ap_activities, record, {});
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // isDirectMessage
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Determine if an object is a direct message (DM).
125
+ * A DM is addressed only to specific actors — no PUBLIC_COLLECTION,
126
+ * no followers collection, and includes our actor URL.
127
+ *
128
+ * Duplicated from inbox-listeners.js (not exported there).
129
+ *
130
+ * @param {object} object - Fedify object (Note, Article, etc.)
131
+ * @param {string} ourActorUrl - Our actor's URL
132
+ * @param {string} followersUrl - Our followers collection URL
133
+ * @returns {boolean}
134
+ */
135
+ function isDirectMessage(object, ourActorUrl, followersUrl) {
136
+ const allAddressed = [
137
+ ...object.toIds.map((u) => u.href),
138
+ ...object.ccIds.map((u) => u.href),
139
+ ...object.btoIds.map((u) => u.href),
140
+ ...object.bccIds.map((u) => u.href),
141
+ ];
142
+
143
+ // Must be addressed to us
144
+ if (!allAddressed.includes(ourActorUrl)) return false;
145
+
146
+ // Must NOT include public collection
147
+ if (allAddressed.some((u) => u === PUBLIC || u === "as:Public")) return false;
148
+
149
+ // Must NOT include our followers collection
150
+ if (followersUrl && allAddressed.includes(followersUrl)) return false;
151
+
152
+ return true;
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Individual handlers
157
+ // ---------------------------------------------------------------------------
158
+
159
+ /**
160
+ * Handle Follow activity.
161
+ *
162
+ * The synchronous inbox listener already handled:
163
+ * - follower storage (or pending follow storage)
164
+ * - Accept/Reject response
165
+ * - notification creation
166
+ *
167
+ * This async handler only logs the activity.
168
+ *
169
+ * @param {object} item - Queue document
170
+ * @param {object} collections - MongoDB collections
171
+ */
172
+ export async function handleFollow(item, collections) {
173
+ await logActivity(collections, {
174
+ direction: "inbound",
175
+ type: "Follow",
176
+ actorUrl: item.actorUrl,
177
+ summary: `${item.actorUrl} follow activity processed`,
178
+ });
179
+ }
180
+
181
+ /**
182
+ * Handle Undo activity.
183
+ *
184
+ * Undoes a Follow, Like, or Announce depending on the inner object type.
185
+ *
186
+ * @param {object} item - Queue document
187
+ * @param {object} collections - MongoDB collections
188
+ * @param {import("@fedify/fedify").Context} ctx - Fedify context
189
+ * @param {string} handle - Actor handle
190
+ */
191
+ export async function handleUndo(item, collections, ctx, handle) {
192
+ const authLoader = await getAuthLoader(ctx, handle);
193
+ const actorUrl = item.actorUrl;
194
+
195
+ let undo;
196
+ try {
197
+ undo = await Undo.fromJsonLd(item.rawJson, { documentLoader: authLoader });
198
+ } catch (error) {
199
+ console.warn("[inbox-handlers] Failed to reconstruct Undo from rawJson:", error.message);
200
+ return;
201
+ }
202
+
203
+ let inner;
204
+ try {
205
+ inner = await undo.getObject({ documentLoader: authLoader });
206
+ } catch {
207
+ // Inner activity not dereferenceable — can't determine what was undone
208
+ return;
209
+ }
210
+
211
+ if (inner instanceof Follow) {
212
+ await collections.ap_followers.deleteOne({ actorUrl });
213
+ await logActivity(collections, {
214
+ direction: "inbound",
215
+ type: "Undo(Follow)",
216
+ actorUrl,
217
+ summary: `${actorUrl} unfollowed you`,
218
+ });
219
+ } else if (inner instanceof Like) {
220
+ const objectId = inner.objectId?.href || "";
221
+ await collections.ap_activities.deleteOne({
222
+ type: "Like",
223
+ actorUrl,
224
+ objectUrl: objectId,
225
+ });
226
+ } else if (inner instanceof Announce) {
227
+ const objectId = inner.objectId?.href || "";
228
+ await collections.ap_activities.deleteOne({
229
+ type: "Announce",
230
+ actorUrl,
231
+ objectUrl: objectId,
232
+ });
233
+ } else {
234
+ const typeName = inner?.constructor?.name || "unknown";
235
+ await logActivity(collections, {
236
+ direction: "inbound",
237
+ type: `Undo(${typeName})`,
238
+ actorUrl,
239
+ summary: `${actorUrl} undid ${typeName}`,
240
+ });
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Handle Accept activity.
246
+ *
247
+ * Marks a pending follow in ap_following as accepted ("federation").
248
+ *
249
+ * @param {object} item - Queue document
250
+ * @param {object} collections - MongoDB collections
251
+ * @param {import("@fedify/fedify").Context} ctx - Fedify context
252
+ * @param {string} handle - Actor handle
253
+ */
254
+ export async function handleAccept(item, collections, ctx, handle) {
255
+ const authLoader = await getAuthLoader(ctx, handle);
256
+
257
+ let accept;
258
+ try {
259
+ accept = await Accept.fromJsonLd(item.rawJson, { documentLoader: authLoader });
260
+ } catch (error) {
261
+ console.warn("[inbox-handlers] Failed to reconstruct Accept from rawJson:", error.message);
262
+ return;
263
+ }
264
+
265
+ // We match against ap_following rather than inspecting the inner object
266
+ // because Fedify often resolves the Follow's target to a Person instead
267
+ // of the Follow itself. Any Accept from this actor confirms our pending follow.
268
+ const actorObj = await accept.getActor({ documentLoader: authLoader });
269
+ const actorUrl = actorObj?.id?.href || "";
270
+ if (!actorUrl) return;
271
+
272
+ const result = await collections.ap_following.findOneAndUpdate(
273
+ {
274
+ actorUrl,
275
+ source: { $in: ["refollow:sent", "reader", "microsub-reader"] },
276
+ },
277
+ {
278
+ $set: {
279
+ source: "federation",
280
+ acceptedAt: new Date().toISOString(),
281
+ },
282
+ $unset: {
283
+ refollowAttempts: "",
284
+ refollowLastAttempt: "",
285
+ refollowError: "",
286
+ },
287
+ },
288
+ { returnDocument: "after" },
289
+ );
290
+
291
+ if (result) {
292
+ const actorName = result.name || result.handle || actorUrl;
293
+ await logActivity(collections, {
294
+ direction: "inbound",
295
+ type: "Accept(Follow)",
296
+ actorUrl,
297
+ actorName,
298
+ summary: `${actorName} accepted our Follow`,
299
+ });
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Handle Reject activity.
305
+ *
306
+ * Marks a pending follow in ap_following as rejected.
307
+ *
308
+ * @param {object} item - Queue document
309
+ * @param {object} collections - MongoDB collections
310
+ * @param {import("@fedify/fedify").Context} ctx - Fedify context
311
+ * @param {string} handle - Actor handle
312
+ */
313
+ export async function handleReject(item, collections, ctx, handle) {
314
+ const authLoader = await getAuthLoader(ctx, handle);
315
+
316
+ let reject;
317
+ try {
318
+ reject = await Reject.fromJsonLd(item.rawJson, { documentLoader: authLoader });
319
+ } catch (error) {
320
+ console.warn("[inbox-handlers] Failed to reconstruct Reject from rawJson:", error.message);
321
+ return;
322
+ }
323
+
324
+ const actorObj = await reject.getActor({ documentLoader: authLoader });
325
+ const actorUrl = actorObj?.id?.href || "";
326
+ if (!actorUrl) return;
327
+
328
+ const result = await collections.ap_following.findOneAndUpdate(
329
+ {
330
+ actorUrl,
331
+ source: { $in: ["refollow:sent", "reader", "microsub-reader"] },
332
+ },
333
+ {
334
+ $set: {
335
+ source: "rejected",
336
+ rejectedAt: new Date().toISOString(),
337
+ },
338
+ },
339
+ { returnDocument: "after" },
340
+ );
341
+
342
+ if (result) {
343
+ const actorName = result.name || result.handle || actorUrl;
344
+ await logActivity(collections, {
345
+ direction: "inbound",
346
+ type: "Reject(Follow)",
347
+ actorUrl,
348
+ actorName,
349
+ summary: `${actorName} rejected our Follow`,
350
+ });
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Handle Like activity.
356
+ *
357
+ * Only logs likes of our own content and creates a notification.
358
+ *
359
+ * @param {object} item - Queue document
360
+ * @param {object} collections - MongoDB collections
361
+ * @param {import("@fedify/fedify").Context} ctx - Fedify context
362
+ * @param {string} handle - Actor handle
363
+ */
364
+ export async function handleLike(item, collections, ctx, handle) {
365
+ const authLoader = await getAuthLoader(ctx, handle);
366
+
367
+ let like;
368
+ try {
369
+ like = await Like.fromJsonLd(item.rawJson, { documentLoader: authLoader });
370
+ } catch (error) {
371
+ console.warn("[inbox-handlers] Failed to reconstruct Like from rawJson:", error.message);
372
+ return;
373
+ }
374
+
375
+ const objectId = like.objectId?.href || "";
376
+
377
+ // Only log likes of our own content
378
+ const pubUrl = collections._publicationUrl;
379
+ if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;
380
+
381
+ const actorUrl = like.actorId?.href || "";
382
+ let actorObj;
383
+ try {
384
+ actorObj = await like.getActor({ documentLoader: authLoader });
385
+ } catch {
386
+ actorObj = null;
387
+ }
388
+
389
+ const actorName =
390
+ actorObj?.name?.toString() ||
391
+ actorObj?.preferredUsername?.toString() ||
392
+ actorUrl;
393
+
394
+ // Extract actor info (including avatar) before logging so we can store it
395
+ const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
396
+
397
+ await logActivity(collections, {
398
+ direction: "inbound",
399
+ type: "Like",
400
+ actorUrl,
401
+ actorName,
402
+ actorAvatar: actorInfo.photo || "",
403
+ objectUrl: objectId,
404
+ summary: `${actorName} liked ${objectId}`,
405
+ });
406
+
407
+ // Store notification
408
+ await addNotification(collections, {
409
+ uid: like.id?.href || `like:${actorUrl}:${objectId}`,
410
+ type: "like",
411
+ actorUrl: actorInfo.url,
412
+ actorName: actorInfo.name,
413
+ actorPhoto: actorInfo.photo,
414
+ actorHandle: actorInfo.handle,
415
+ targetUrl: objectId,
416
+ targetName: "", // Could fetch post title, but not critical
417
+ published: like.published ? String(like.published) : new Date().toISOString(),
418
+ createdAt: new Date().toISOString(),
419
+ });
420
+ }
421
+
422
+ /**
423
+ * Handle Announce (boost) activity.
424
+ *
425
+ * PATH 1: If boost of OUR content → notification.
426
+ * PATH 2: If from followed account → store timeline item, quote enrichment.
427
+ *
428
+ * @param {object} item - Queue document
429
+ * @param {object} collections - MongoDB collections
430
+ * @param {import("@fedify/fedify").Context} ctx - Fedify context
431
+ * @param {string} handle - Actor handle
432
+ */
433
+ export async function handleAnnounce(item, collections, ctx, handle) {
434
+ const authLoader = await getAuthLoader(ctx, handle);
435
+
436
+ let announce;
437
+ try {
438
+ announce = await Announce.fromJsonLd(item.rawJson, { documentLoader: authLoader });
439
+ } catch (error) {
440
+ console.warn("[inbox-handlers] Failed to reconstruct Announce from rawJson:", error.message);
441
+ return;
442
+ }
443
+
444
+ const objectId = announce.objectId?.href || "";
445
+ if (!objectId) return;
446
+
447
+ const actorUrl = announce.actorId?.href || "";
448
+ const pubUrl = collections._publicationUrl;
449
+
450
+ // PATH 1: Boost of OUR content → Notification
451
+ if (pubUrl && objectId.startsWith(pubUrl)) {
452
+ let actorObj;
453
+ try {
454
+ actorObj = await announce.getActor({ documentLoader: authLoader });
455
+ } catch {
456
+ actorObj = null;
457
+ }
458
+
459
+ const actorName =
460
+ actorObj?.name?.toString() ||
461
+ actorObj?.preferredUsername?.toString() ||
462
+ actorUrl;
463
+
464
+ // Extract actor info (including avatar) before logging so we can store it
465
+ const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
466
+
467
+ // Log the boost activity
468
+ await logActivity(collections, {
469
+ direction: "inbound",
470
+ type: "Announce",
471
+ actorUrl,
472
+ actorName,
473
+ actorAvatar: actorInfo.photo || "",
474
+ objectUrl: objectId,
475
+ summary: `${actorName} boosted ${objectId}`,
476
+ });
477
+
478
+ // Create notification
479
+ await addNotification(collections, {
480
+ uid: announce.id?.href || `${actorUrl}#boost-${objectId}`,
481
+ type: "boost",
482
+ actorUrl: actorInfo.url,
483
+ actorName: actorInfo.name,
484
+ actorPhoto: actorInfo.photo,
485
+ actorHandle: actorInfo.handle,
486
+ targetUrl: objectId,
487
+ targetName: "", // Could fetch post title, but not critical
488
+ published: announce.published ? String(announce.published) : new Date().toISOString(),
489
+ createdAt: new Date().toISOString(),
490
+ });
491
+
492
+ // Don't return — fall through to check if actor is also followed
493
+ }
494
+
495
+ // PATH 2: Boost from someone we follow → Timeline (store original post)
496
+ const following = await collections.ap_following.findOne({ actorUrl });
497
+ if (following) {
498
+ try {
499
+ // Fetch the original object being boosted (authenticated for Secure Mode servers)
500
+ const object = await announce.getObject({ documentLoader: authLoader });
501
+ if (!object) return;
502
+
503
+ // Skip non-content objects (Lemmy/PieFed like/create activities
504
+ // that resolve to activity IDs instead of actual Note/Article posts)
505
+ const hasContent = object.content?.toString() || object.name?.toString();
506
+ if (!hasContent) return;
507
+
508
+ // Get booster actor info
509
+ const boosterActor = await announce.getActor({ documentLoader: authLoader });
510
+ const boosterInfo = await extractActorInfo(boosterActor, { documentLoader: authLoader });
511
+
512
+ // Extract and store with boost metadata
513
+ const timelineItem = await extractObjectData(object, {
514
+ boostedBy: boosterInfo,
515
+ boostedAt: announce.published ? String(announce.published) : new Date().toISOString(),
516
+ documentLoader: authLoader,
517
+ });
518
+
519
+ await addTimelineItem(collections, timelineItem);
520
+
521
+ // Fire-and-forget quote enrichment for boosted posts
522
+ if (timelineItem.quoteUrl) {
523
+ fetchAndStoreQuote(collections, timelineItem.uid, timelineItem.quoteUrl, ctx, authLoader)
524
+ .catch((error) => {
525
+ console.error(`[inbox-handlers] Quote fetch failed for ${timelineItem.uid}:`, error.message);
526
+ });
527
+ }
528
+ } catch (error) {
529
+ // Remote object unreachable (timeout, Authorized Fetch, deleted, etc.) — skip
530
+ const cause = error?.cause?.code || error?.message || "unknown";
531
+ console.warn(`[inbox-handlers] Skipped boost from ${actorUrl}: ${cause}`);
532
+ }
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Handle Create activity.
538
+ *
539
+ * Processes DMs, replies, mentions, and timeline storage.
540
+ *
541
+ * @param {object} item - Queue document
542
+ * @param {object} collections - MongoDB collections
543
+ * @param {import("@fedify/fedify").Context} ctx - Fedify context
544
+ * @param {string} handle - Actor handle
545
+ */
546
+ export async function handleCreate(item, collections, ctx, handle) {
547
+ const authLoader = await getAuthLoader(ctx, handle);
548
+
549
+ let create;
550
+ try {
551
+ create = await Create.fromJsonLd(item.rawJson, { documentLoader: authLoader });
552
+ } catch (error) {
553
+ console.warn("[inbox-handlers] Failed to reconstruct Create from rawJson:", error.message);
554
+ return;
555
+ }
556
+
557
+ let object;
558
+ try {
559
+ object = await create.getObject({ documentLoader: authLoader });
560
+ } catch {
561
+ // Remote object not dereferenceable (deleted, etc.)
562
+ return;
563
+ }
564
+ if (!object) return;
565
+
566
+ const actorUrl = create.actorId?.href || "";
567
+ let actorObj;
568
+ try {
569
+ actorObj = await create.getActor({ documentLoader: authLoader });
570
+ } catch {
571
+ // Actor not dereferenceable — use URL as fallback
572
+ actorObj = null;
573
+ }
574
+ const actorName =
575
+ actorObj?.name?.toString() ||
576
+ actorObj?.preferredUsername?.toString() ||
577
+ actorUrl;
578
+
579
+ // --- DM detection ---
580
+ // Check if this is a direct message before processing as reply/mention/timeline.
581
+ // DMs are handled separately and stored in ap_messages instead of ap_timeline.
582
+ const ourActorUrl = ctx.getActorUri(handle).href;
583
+ const followersUrl = ctx.getFollowersUri(handle)?.href || "";
584
+
585
+ if (isDirectMessage(object, ourActorUrl, followersUrl)) {
586
+ const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
587
+ const rawHtml = object.content?.toString() || "";
588
+ const contentHtml = sanitizeContent(rawHtml);
589
+ const contentText = rawHtml.replace(/<[^>]*>/g, "").substring(0, 500);
590
+ const published = object.published ? String(object.published) : new Date().toISOString();
591
+ const inReplyToDM = object.replyTargetId?.href || null;
592
+
593
+ // Store as message
594
+ await addMessage(collections, {
595
+ uid: object.id?.href || `dm:${actorUrl}:${Date.now()}`,
596
+ actorUrl: actorInfo.url,
597
+ actorName: actorInfo.name,
598
+ actorPhoto: actorInfo.photo,
599
+ actorHandle: actorInfo.handle,
600
+ content: {
601
+ text: contentText,
602
+ html: contentHtml,
603
+ },
604
+ inReplyTo: inReplyToDM,
605
+ conversationId: actorInfo.url,
606
+ direction: "inbound",
607
+ published,
608
+ createdAt: new Date().toISOString(),
609
+ });
610
+
611
+ // Also create a notification so DMs appear in the notification tab
612
+ await addNotification(collections, {
613
+ uid: `dm:${object.id?.href || `${actorUrl}:${Date.now()}`}`,
614
+ url: object.url?.href || object.id?.href || "",
615
+ type: "dm",
616
+ actorUrl: actorInfo.url,
617
+ actorName: actorInfo.name,
618
+ actorPhoto: actorInfo.photo,
619
+ actorHandle: actorInfo.handle,
620
+ content: {
621
+ text: contentText,
622
+ html: contentHtml,
623
+ },
624
+ published,
625
+ createdAt: new Date().toISOString(),
626
+ });
627
+
628
+ await logActivity(collections, {
629
+ direction: "inbound",
630
+ type: "DirectMessage",
631
+ actorUrl,
632
+ actorName,
633
+ actorAvatar: actorInfo.photo || "",
634
+ objectUrl: object.id?.href || "",
635
+ content: contentText.substring(0, 100),
636
+ summary: `${actorName} sent a direct message`,
637
+ });
638
+
639
+ return; // Don't process DMs as timeline/mention/reply
640
+ }
641
+
642
+ // Use replyTargetId (non-fetching) for the inReplyTo URL
643
+ const inReplyTo = object.replyTargetId?.href || null;
644
+
645
+ // Log replies to our posts (existing behavior for conversations)
646
+ const pubUrl = collections._publicationUrl;
647
+ if (inReplyTo) {
648
+ const content = object.content?.toString() || "";
649
+
650
+ // Extract actor info (including avatar) before logging so we can store it
651
+ const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
652
+
653
+ await logActivity(collections, {
654
+ direction: "inbound",
655
+ type: "Reply",
656
+ actorUrl,
657
+ actorName,
658
+ actorAvatar: actorInfo.photo || "",
659
+ objectUrl: object.id?.href || "",
660
+ targetUrl: inReplyTo,
661
+ content,
662
+ summary: `${actorName} replied to ${inReplyTo}`,
663
+ });
664
+
665
+ // Create notification if reply is to one of OUR posts
666
+ if (pubUrl && inReplyTo.startsWith(pubUrl)) {
667
+ const rawHtml = object.content?.toString() || "";
668
+ const contentHtml = sanitizeContent(rawHtml);
669
+ const contentText = rawHtml.replace(/<[^>]*>/g, "").substring(0, 200);
670
+
671
+ await addNotification(collections, {
672
+ uid: object.id?.href || `reply:${actorUrl}:${inReplyTo}`,
673
+ url: object.url?.href || object.id?.href || "",
674
+ type: "reply",
675
+ actorUrl: actorInfo.url,
676
+ actorName: actorInfo.name,
677
+ actorPhoto: actorInfo.photo,
678
+ actorHandle: actorInfo.handle,
679
+ targetUrl: inReplyTo,
680
+ targetName: "",
681
+ content: {
682
+ text: contentText,
683
+ html: contentHtml,
684
+ },
685
+ published: object.published ? String(object.published) : new Date().toISOString(),
686
+ createdAt: new Date().toISOString(),
687
+ });
688
+ }
689
+ }
690
+
691
+ // Check for mentions of our actor
692
+ if (object.tag) {
693
+ const tags = Array.isArray(object.tag) ? object.tag : [object.tag];
694
+
695
+ for (const tag of tags) {
696
+ if (tag.type === "Mention" && tag.href?.href === ourActorUrl) {
697
+ const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
698
+ const rawMentionHtml = object.content?.toString() || "";
699
+ const mentionHtml = sanitizeContent(rawMentionHtml);
700
+ const contentText = rawMentionHtml.replace(/<[^>]*>/g, "").substring(0, 200);
701
+
702
+ await addNotification(collections, {
703
+ uid: object.id?.href || `mention:${actorUrl}:${object.id?.href}`,
704
+ url: object.url?.href || object.id?.href || "",
705
+ type: "mention",
706
+ actorUrl: actorInfo.url,
707
+ actorName: actorInfo.name,
708
+ actorPhoto: actorInfo.photo,
709
+ actorHandle: actorInfo.handle,
710
+ content: {
711
+ text: contentText,
712
+ html: mentionHtml,
713
+ },
714
+ published: object.published ? String(object.published) : new Date().toISOString(),
715
+ createdAt: new Date().toISOString(),
716
+ });
717
+
718
+ break; // Only create one mention notification per post
719
+ }
720
+ }
721
+ }
722
+
723
+ // Store timeline items from accounts we follow (native storage)
724
+ const following = await collections.ap_following.findOne({ actorUrl });
725
+ if (following) {
726
+ try {
727
+ const timelineItem = await extractObjectData(object, {
728
+ actorFallback: actorObj,
729
+ documentLoader: authLoader,
730
+ });
731
+ await addTimelineItem(collections, timelineItem);
732
+
733
+ // Fire-and-forget OG unfurling for notes and articles (not boosts)
734
+ if (timelineItem.type === "note" || timelineItem.type === "article") {
735
+ fetchAndStorePreviews(collections, timelineItem.uid, timelineItem.content.html)
736
+ .catch((error) => {
737
+ console.error(`[inbox-handlers] OG unfurl failed for ${timelineItem.uid}:`, error);
738
+ });
739
+ }
740
+
741
+ // Fire-and-forget quote enrichment
742
+ if (timelineItem.quoteUrl) {
743
+ fetchAndStoreQuote(collections, timelineItem.uid, timelineItem.quoteUrl, ctx, authLoader)
744
+ .catch((error) => {
745
+ console.error(`[inbox-handlers] Quote fetch failed for ${timelineItem.uid}:`, error.message);
746
+ });
747
+ }
748
+ } catch (error) {
749
+ // Log extraction errors but don't fail the entire handler
750
+ console.error("[inbox-handlers] Failed to store timeline item:", error);
751
+ }
752
+ } else if (collections.ap_followed_tags) {
753
+ // Not a followed account — check if the post's hashtags match any followed tags
754
+ // so tagged posts from across the fediverse appear in the timeline
755
+ try {
756
+ const objectTags = Array.isArray(object.tag) ? object.tag : (object.tag ? [object.tag] : []);
757
+ const postHashtags = objectTags
758
+ .filter((t) => t.type === "Hashtag" && t.name)
759
+ .map((t) => t.name.toString().replace(/^#/, "").toLowerCase());
760
+
761
+ if (postHashtags.length > 0) {
762
+ const followedTags = await getFollowedTags(collections);
763
+ const followedSet = new Set(followedTags.map((t) => t.toLowerCase()));
764
+ const hasMatchingTag = postHashtags.some((tag) => followedSet.has(tag));
765
+
766
+ if (hasMatchingTag) {
767
+ const timelineItem = await extractObjectData(object, {
768
+ actorFallback: actorObj,
769
+ documentLoader: authLoader,
770
+ });
771
+ await addTimelineItem(collections, timelineItem);
772
+ }
773
+ }
774
+ } catch (error) {
775
+ // Non-critical — don't fail the handler
776
+ console.error("[inbox-handlers] Followed tag check failed:", error.message);
777
+ }
778
+ }
779
+ }
780
+
781
+ /**
782
+ * Handle Delete activity.
783
+ *
784
+ * Removes from ap_activities and timeline by object URL.
785
+ *
786
+ * @param {object} item - Queue document
787
+ * @param {object} collections - MongoDB collections
788
+ */
789
+ export async function handleDelete(item, collections) {
790
+ const objectId = item.objectUrl;
791
+ if (objectId) {
792
+ // Remove from activity log
793
+ await collections.ap_activities.deleteMany({ objectUrl: objectId });
794
+
795
+ // Remove from timeline
796
+ await deleteTimelineItem(collections, objectId);
797
+ }
798
+ }
799
+
800
+ /**
801
+ * Handle Move activity.
802
+ *
803
+ * Updates ap_followers to reflect the actor's new URL.
804
+ *
805
+ * @param {object} item - Queue document
806
+ * @param {object} collections - MongoDB collections
807
+ * @param {import("@fedify/fedify").Context} ctx - Fedify context
808
+ * @param {string} handle - Actor handle
809
+ */
810
+ export async function handleMove(item, collections, ctx, handle) {
811
+ const authLoader = await getAuthLoader(ctx, handle);
812
+
813
+ let move;
814
+ try {
815
+ move = await Move.fromJsonLd(item.rawJson, { documentLoader: authLoader });
816
+ } catch (error) {
817
+ console.warn("[inbox-handlers] Failed to reconstruct Move from rawJson:", error.message);
818
+ return;
819
+ }
820
+
821
+ const oldActorObj = await move.getActor({ documentLoader: authLoader });
822
+ const oldActorUrl = oldActorObj?.id?.href || "";
823
+ const target = await move.getTarget({ documentLoader: authLoader });
824
+ const newActorUrl = target?.id?.href || "";
825
+
826
+ if (oldActorUrl && newActorUrl) {
827
+ await collections.ap_followers.updateOne(
828
+ { actorUrl: oldActorUrl },
829
+ { $set: { actorUrl: newActorUrl, movedFrom: oldActorUrl } },
830
+ );
831
+ }
832
+
833
+ await logActivity(collections, {
834
+ direction: "inbound",
835
+ type: "Move",
836
+ actorUrl: oldActorUrl,
837
+ objectUrl: newActorUrl,
838
+ summary: `${oldActorUrl} moved to ${newActorUrl}`,
839
+ });
840
+ }
841
+
842
+ /**
843
+ * Handle Update activity.
844
+ *
845
+ * PATH 1: If Note/Article → update timeline item content.
846
+ * PATH 2: Otherwise → refresh stored follower data.
847
+ *
848
+ * @param {object} item - Queue document
849
+ * @param {object} collections - MongoDB collections
850
+ * @param {import("@fedify/fedify").Context} ctx - Fedify context
851
+ * @param {string} handle - Actor handle
852
+ */
853
+ export async function handleUpdate(item, collections, ctx, handle) {
854
+ const authLoader = await getAuthLoader(ctx, handle);
855
+
856
+ let update;
857
+ try {
858
+ update = await Update.fromJsonLd(item.rawJson, { documentLoader: authLoader });
859
+ } catch (error) {
860
+ console.warn("[inbox-handlers] Failed to reconstruct Update from rawJson:", error.message);
861
+ return;
862
+ }
863
+
864
+ // Try to get the object being updated
865
+ let object;
866
+ try {
867
+ object = await update.getObject({ documentLoader: authLoader });
868
+ } catch {
869
+ object = null;
870
+ }
871
+
872
+ // PATH 1: If object is a Note/Article → Update timeline item content
873
+ if (object && (object instanceof Note || object instanceof Article)) {
874
+ const objectUrl = object.id?.href || "";
875
+ if (objectUrl) {
876
+ try {
877
+ // Extract updated content
878
+ const contentHtml = object.content?.toString() || "";
879
+ const contentText = object.source?.content?.toString() || contentHtml.replace(/<[^>]*>/g, "");
880
+
881
+ const updates = {
882
+ content: {
883
+ text: contentText,
884
+ html: contentHtml,
885
+ },
886
+ name: object.name?.toString() || "",
887
+ summary: object.summary?.toString() || "",
888
+ sensitive: object.sensitive || false,
889
+ };
890
+
891
+ await updateTimelineItem(collections, objectUrl, updates);
892
+ } catch (error) {
893
+ console.error("[inbox-handlers] Failed to update timeline item:", error);
894
+ }
895
+ }
896
+ return;
897
+ }
898
+
899
+ // PATH 2: Otherwise, assume profile update — refresh stored follower data
900
+ const actorObj = await update.getActor({ documentLoader: authLoader });
901
+ const actorUrl = actorObj?.id?.href || "";
902
+ if (!actorUrl) return;
903
+
904
+ const existing = await collections.ap_followers.findOne({ actorUrl });
905
+ if (existing) {
906
+ await collections.ap_followers.updateOne(
907
+ { actorUrl },
908
+ {
909
+ $set: {
910
+ name:
911
+ actorObj.name?.toString() ||
912
+ actorObj.preferredUsername?.toString() ||
913
+ actorUrl,
914
+ handle: actorObj.preferredUsername?.toString() || "",
915
+ avatar: actorObj.icon
916
+ ? (await actorObj.icon)?.url?.href || ""
917
+ : "",
918
+ updatedAt: new Date().toISOString(),
919
+ },
920
+ },
921
+ );
922
+ }
923
+ }
924
+
925
+ /**
926
+ * Handle Block activity.
927
+ *
928
+ * The synchronous inbox listener already handled follower removal.
929
+ * This async handler only logs the activity.
930
+ *
931
+ * @param {object} item - Queue document
932
+ * @param {object} collections - MongoDB collections
933
+ */
934
+ export async function handleBlock(item, collections) {
935
+ await logActivity(collections, {
936
+ direction: "inbound",
937
+ type: "Block",
938
+ actorUrl: item.actorUrl,
939
+ summary: `${item.actorUrl} block activity processed`,
940
+ });
941
+ }
942
+
943
+ /**
944
+ * Handle Flag (report) activity.
945
+ *
946
+ * Stores the report in ap_reports, creates a notification, and logs the activity.
947
+ *
948
+ * @param {object} item - Queue document
949
+ * @param {object} collections - MongoDB collections
950
+ * @param {import("@fedify/fedify").Context} ctx - Fedify context
951
+ * @param {string} handle - Actor handle
952
+ */
953
+ export async function handleFlag(item, collections, ctx, handle) {
954
+ try {
955
+ const authLoader = await getAuthLoader(ctx, handle);
956
+
957
+ let flag;
958
+ try {
959
+ flag = await Flag.fromJsonLd(item.rawJson, { documentLoader: authLoader });
960
+ } catch (error) {
961
+ console.warn("[inbox-handlers] Failed to reconstruct Flag from rawJson:", error.message);
962
+ return;
963
+ }
964
+
965
+ const actorObj = await flag.getActor({ documentLoader: authLoader }).catch(() => null);
966
+
967
+ const reporterUrl = actorObj?.id?.href || flag.actorId?.href || "";
968
+ const reporterName = actorObj?.name?.toString() || actorObj?.preferredUsername?.toString() || reporterUrl;
969
+
970
+ // Extract reported objects — Flag can report actors or posts
971
+ const reportedIds = flag.objectIds?.map((u) => u.href) || [];
972
+ const reason = flag.content?.toString() || flag.summary?.toString() || "";
973
+
974
+ if (reportedIds.length === 0 && !reason) {
975
+ console.info("[inbox-handlers] Ignoring empty Flag from", reporterUrl);
976
+ return;
977
+ }
978
+
979
+ // Store report
980
+ if (collections.ap_reports) {
981
+ await collections.ap_reports.insertOne({
982
+ reporterUrl,
983
+ reporterName,
984
+ reportedUrls: reportedIds,
985
+ reason,
986
+ createdAt: new Date().toISOString(),
987
+ read: false,
988
+ });
989
+ }
990
+
991
+ // Create notification
992
+ if (collections.ap_notifications) {
993
+ await addNotification(collections, {
994
+ uid: `flag:${reporterUrl}:${Date.now()}`,
995
+ type: "report",
996
+ actorUrl: reporterUrl,
997
+ actorName: reporterName,
998
+ actorPhoto: actorObj?.iconUrl?.href || actorObj?.icon?.url?.href || "",
999
+ actorHandle: actorObj?.preferredUsername
1000
+ ? `@${actorObj.preferredUsername}@${new URL(reporterUrl).hostname}`
1001
+ : reporterUrl,
1002
+ objectUrl: reportedIds[0] || "",
1003
+ summary: reason ? reason.slice(0, 200) : "Report received",
1004
+ published: new Date().toISOString(),
1005
+ createdAt: new Date().toISOString(),
1006
+ });
1007
+ }
1008
+
1009
+ await logActivity(collections, {
1010
+ direction: "inbound",
1011
+ type: "Flag",
1012
+ actorUrl: reporterUrl,
1013
+ objectUrl: reportedIds[0] || "",
1014
+ summary: `Report from ${reporterName}: ${reason.slice(0, 100)}`,
1015
+ });
1016
+
1017
+ console.info(`[inbox-handlers] Flag received from ${reporterName} — ${reportedIds.length} objects reported`);
1018
+ } catch (error) {
1019
+ console.warn("[inbox-handlers] Flag handler error:", error.message);
1020
+ }
1021
+ }