@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.
- package/index.js +68 -0
- package/lib/controllers/moderation.js +80 -1
- package/lib/federation-setup.js +49 -29
- package/lib/inbox-handlers.js +1021 -0
- package/lib/inbox-listeners.js +147 -710
- package/lib/inbox-queue.js +99 -0
- package/lib/key-refresh.js +138 -0
- package/lib/redis-cache.js +16 -0
- package/lib/storage/server-blocks.js +121 -0
- package/package.json +1 -1
- package/views/activitypub-moderation.njk +77 -0
|
@@ -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
|
+
}
|