@rmdes/indiekit-endpoint-conversations 2.1.1 → 2.1.3

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.
@@ -48,7 +48,11 @@ export async function fetchActivityPubInteractions(options) {
48
48
  const items = [];
49
49
 
50
50
  for (const activity of activities) {
51
- const avatar = await lookupAvatar(ap_followers, activity.actorUrl);
51
+ // Prefer avatar stored directly on the activity (added by inbox handler),
52
+ // fall back to ap_followers lookup for historical data without actorAvatar
53
+ const avatar =
54
+ activity.actorAvatar ||
55
+ (await lookupAvatar(ap_followers, activity.actorUrl));
52
56
  items.push(normalizeActivity(activity, avatar));
53
57
  }
54
58
 
@@ -94,6 +94,117 @@ export async function runPollCycle(indiekit, options) {
94
94
  }
95
95
  await pollActivityPub(indiekit, stateCollection, state);
96
96
  }
97
+
98
+ // Backfill missing avatars from ap_notifications (one-time sweep per cycle)
99
+ await backfillMissingAvatars(indiekit, stateCollection);
100
+ }
101
+
102
+ /**
103
+ * Backfill empty author.photo fields in conversation_items using ap_notifications.
104
+ * The AP endpoint stores actorPhoto in notifications for all interactions,
105
+ * so we can use those to enrich conversation_items that were stored before
106
+ * the actorAvatar field was added to ap_activities.
107
+ */
108
+ async function backfillMissingAvatars(indiekit, stateCollection) {
109
+ try {
110
+ const itemsCollection = indiekit.collections.get("conversation_items");
111
+ const notificationsCollection = indiekit.collections.get("ap_notifications");
112
+ const activitiesCollection = indiekit.collections.get("ap_activities");
113
+ const followersCollection = indiekit.collections.get("ap_followers");
114
+
115
+ if (!itemsCollection) return;
116
+
117
+ // Check if backfill already completed
118
+ const state = await stateCollection.findOne({ _id: "poll_cursors" });
119
+ if (state?.avatar_backfill_complete) return;
120
+
121
+ // Find conversation_items with empty author.photo
122
+ const itemsWithoutPhoto = await itemsCollection
123
+ .find({
124
+ $or: [
125
+ { "author.photo": "" },
126
+ { "author.photo": null },
127
+ { "author.photo": { $exists: false } },
128
+ ],
129
+ })
130
+ .limit(200)
131
+ .toArray();
132
+
133
+ if (itemsWithoutPhoto.length === 0) {
134
+ // Mark backfill as complete so we don't query every cycle
135
+ await stateCollection.findOneAndUpdate(
136
+ { _id: "poll_cursors" },
137
+ { $set: { avatar_backfill_complete: true } },
138
+ { upsert: true },
139
+ );
140
+ return;
141
+ }
142
+
143
+ let updated = 0;
144
+
145
+ for (const item of itemsWithoutPhoto) {
146
+ const actorUrl = item.author?.url;
147
+ if (!actorUrl) continue;
148
+
149
+ let photo = "";
150
+
151
+ // Strategy 1: Check ap_notifications (most reliable — has actorPhoto for all interaction types)
152
+ if (!photo && notificationsCollection) {
153
+ try {
154
+ const notification = await notificationsCollection.findOne({
155
+ actorUrl,
156
+ actorPhoto: { $ne: "" },
157
+ });
158
+ if (notification?.actorPhoto) photo = notification.actorPhoto;
159
+ } catch { /* ignore */ }
160
+ }
161
+
162
+ // Strategy 2: Check ap_activities for actorAvatar (new field from inbox handler fix)
163
+ if (!photo && activitiesCollection) {
164
+ try {
165
+ const activity = await activitiesCollection.findOne({
166
+ actorUrl,
167
+ actorAvatar: { $ne: "" },
168
+ });
169
+ if (activity?.actorAvatar) photo = activity.actorAvatar;
170
+ } catch { /* ignore */ }
171
+ }
172
+
173
+ // Strategy 3: Check ap_followers
174
+ if (!photo && followersCollection) {
175
+ try {
176
+ const follower = await followersCollection.findOne({ actorUrl });
177
+ if (follower?.avatar) photo = follower.avatar;
178
+ } catch { /* ignore */ }
179
+ }
180
+
181
+ if (photo) {
182
+ await itemsCollection.updateMany(
183
+ { "author.url": actorUrl, "author.photo": { $in: ["", null] } },
184
+ { $set: { "author.photo": photo } },
185
+ );
186
+ updated++;
187
+ }
188
+ }
189
+
190
+ if (updated > 0) {
191
+ console.info(
192
+ `[Conversations] Avatar backfill: updated ${updated} actors with photos`,
193
+ );
194
+ }
195
+
196
+ // If fewer than 200 items found, backfill is complete
197
+ if (itemsWithoutPhoto.length < 200) {
198
+ await stateCollection.findOneAndUpdate(
199
+ { _id: "poll_cursors" },
200
+ { $set: { avatar_backfill_complete: true } },
201
+ { upsert: true },
202
+ );
203
+ }
204
+ } catch (error) {
205
+ // Non-critical — log and continue
206
+ console.warn("[Conversations] Avatar backfill error:", error.message);
207
+ }
97
208
  }
98
209
 
99
210
  /**
@@ -323,6 +434,16 @@ async function pollActivityPub(indiekit, stateCollection, state) {
323
434
 
324
435
  if (!ap_activities) return;
325
436
 
437
+ // Resolve the site URL so we only store interactions about OUR content.
438
+ // indiekit may be the Indiekit class (from startPolling) or the
439
+ // application object (from triggerPoll via controller).
440
+ const siteUrl = (
441
+ indiekit.publication?.me ||
442
+ indiekit.url ||
443
+ process.env.PUBLICATION_URL ||
444
+ ""
445
+ ).replace(/\/$/, "");
446
+
326
447
  const result = await fetchActivityPubInteractions({
327
448
  ap_activities,
328
449
  ap_followers,
@@ -330,22 +451,30 @@ async function pollActivityPub(indiekit, stateCollection, state) {
330
451
  });
331
452
 
332
453
  let stored = 0;
454
+ let skipped = 0;
333
455
 
334
456
  for (const interaction of result.items) {
335
- if (interaction.canonical_url) {
336
- await upsertConversationItem(indiekit, {
337
- canonical_url: interaction.canonical_url,
338
- source: "activitypub",
339
- type: interaction.type,
340
- author: interaction.author,
341
- content: interaction.content,
342
- url: interaction.url,
343
- bridgy_url: null,
344
- platform_id: interaction.platform_id,
345
- created_at: interaction.created_at,
346
- });
347
- stored++;
457
+ if (!interaction.canonical_url) continue;
458
+
459
+ // Only store interactions targeting our own content — skip activities
460
+ // about posts on other domains (e.g. forwarded via shared inbox).
461
+ if (siteUrl && !interaction.canonical_url.startsWith(siteUrl)) {
462
+ skipped++;
463
+ continue;
348
464
  }
465
+
466
+ await upsertConversationItem(indiekit, {
467
+ canonical_url: interaction.canonical_url,
468
+ source: "activitypub",
469
+ type: interaction.type,
470
+ author: interaction.author,
471
+ content: interaction.content,
472
+ url: interaction.url,
473
+ bridgy_url: null,
474
+ platform_id: interaction.platform_id,
475
+ created_at: interaction.created_at,
476
+ });
477
+ stored++;
349
478
  }
350
479
 
351
480
  // Update cursor and status
@@ -363,9 +492,9 @@ async function pollActivityPub(indiekit, stateCollection, state) {
363
492
  { upsert: true },
364
493
  );
365
494
 
366
- if (stored > 0) {
495
+ if (stored > 0 || skipped > 0) {
367
496
  console.info(
368
- `[Conversations] ActivityPub: stored ${stored}/${result.items.length} interactions`,
497
+ `[Conversations] ActivityPub: stored ${stored}, skipped ${skipped} (not our content) of ${result.items.length} interactions`,
369
498
  );
370
499
  }
371
500
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-conversations",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "description": "Conversation aggregation endpoint for Indiekit. Backend enrichment service that polls Mastodon/Bluesky notifications and serves JF2-compatible data for the interactions page.",
5
5
  "keywords": [
6
6
  "indiekit",