@rmdes/indiekit-endpoint-conversations 2.0.0 → 2.1.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 CHANGED
@@ -106,12 +106,17 @@ export default class ConversationsEndpoint {
106
106
  (process.env.BLUESKY_IDENTIFIER || process.env.BLUESKY_HANDLE) &&
107
107
  process.env.BLUESKY_PASSWORD;
108
108
 
109
- if (hasMastodon || hasBluesky) {
109
+ // ActivityPub: auto-detect from shared collection registry
110
+ // The AP endpoint registers ap_activities via Indiekit.addCollection()
111
+ const hasActivityPub = Indiekit.collections.has("ap_activities");
112
+
113
+ if (hasMastodon || hasBluesky || hasActivityPub) {
110
114
  // Store detected platforms for dashboard status
111
115
  Indiekit.config.application.conversations = {
112
116
  ...this.options,
113
117
  mastodonEnabled: !!hasMastodon,
114
118
  blueskyEnabled: !!hasBluesky,
119
+ activitypubEnabled: !!hasActivityPub,
115
120
  };
116
121
 
117
122
  import("./lib/polling/scheduler.js")
@@ -209,6 +209,12 @@ async function apiStatus(request, response) {
209
209
  lastError: pollState?.bluesky_last_error || null,
210
210
  lastPoll: pollState?.bluesky_last_poll || null,
211
211
  },
212
+ activitypub: {
213
+ enabled: !!config.activitypubEnabled,
214
+ lastCursor: pollState?.activitypub_last_received_at || null,
215
+ lastError: pollState?.activitypub_last_error || null,
216
+ lastPoll: pollState?.activitypub_last_poll || null,
217
+ },
212
218
  totalItems,
213
219
  });
214
220
  } catch (error) {
@@ -0,0 +1,110 @@
1
+ /**
2
+ * ActivityPub interaction fetcher
3
+ * Reads inbound interactions from the ap_activities collection
4
+ * (populated by the AP endpoint's inbox listeners) and normalizes
5
+ * them into the conversations plugin's internal format.
6
+ * @module notifications/activitypub
7
+ */
8
+
9
+ /**
10
+ * Map AP activity types to conversations interaction types
11
+ */
12
+ const typeMap = {
13
+ Like: "like",
14
+ Announce: "repost",
15
+ Reply: "reply",
16
+ };
17
+
18
+ /**
19
+ * Fetch ActivityPub interactions since the given cursor
20
+ * @param {object} options
21
+ * @param {object} options.ap_activities - MongoDB collection
22
+ * @param {object} options.ap_followers - MongoDB collection (for avatar lookup)
23
+ * @param {string} [options.since] - ISO 8601 timestamp cursor (process activities after this)
24
+ * @returns {Promise<{items: Array, cursor: string|null}>}
25
+ */
26
+ export async function fetchActivityPubInteractions(options) {
27
+ const { ap_activities, ap_followers, since } = options;
28
+
29
+ const query = {
30
+ direction: "inbound",
31
+ type: { $in: ["Like", "Announce", "Reply"] },
32
+ };
33
+
34
+ if (since) {
35
+ query.receivedAt = { $gt: since };
36
+ }
37
+
38
+ const activities = await ap_activities
39
+ .find(query)
40
+ .sort({ receivedAt: 1 })
41
+ .limit(200)
42
+ .toArray();
43
+
44
+ if (activities.length === 0) {
45
+ return { items: [], cursor: null };
46
+ }
47
+
48
+ const items = [];
49
+
50
+ for (const activity of activities) {
51
+ const avatar = await lookupAvatar(ap_followers, activity.actorUrl);
52
+ items.push(normalizeActivity(activity, avatar));
53
+ }
54
+
55
+ // Cursor is the receivedAt of the last activity processed
56
+ const cursor = activities[activities.length - 1].receivedAt;
57
+
58
+ return { items, cursor };
59
+ }
60
+
61
+ /**
62
+ * Normalize an ap_activities document into conversations internal format
63
+ * @param {object} activity - Document from ap_activities
64
+ * @param {string} avatar - Avatar URL from ap_followers lookup
65
+ * @returns {object} Normalized interaction
66
+ */
67
+ function normalizeActivity(activity, avatar) {
68
+ const type = typeMap[activity.type] || "mention";
69
+ const isReply = activity.type === "Reply";
70
+
71
+ // For replies: targetUrl is your post, objectUrl is the reply
72
+ // For likes/announces: objectUrl is your post, actorUrl is the source
73
+ const canonicalUrl = isReply
74
+ ? activity.targetUrl || activity.objectUrl
75
+ : activity.objectUrl;
76
+
77
+ const url = isReply ? activity.objectUrl : activity.actorUrl;
78
+
79
+ return {
80
+ platform: "activitypub",
81
+ platform_id: `activitypub:${activity.type}:${activity.actorUrl}:${activity.objectUrl}`,
82
+ type,
83
+ author: {
84
+ name: activity.actorName || activity.actorUrl,
85
+ url: activity.actorUrl,
86
+ photo: avatar,
87
+ },
88
+ content: activity.content || null,
89
+ url,
90
+ canonical_url: canonicalUrl,
91
+ created_at: activity.receivedAt,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Look up an actor's avatar from the ap_followers collection
97
+ * @param {object} ap_followers - MongoDB collection
98
+ * @param {string} actorUrl - The actor's URL
99
+ * @returns {Promise<string>} Avatar URL or empty string
100
+ */
101
+ async function lookupAvatar(ap_followers, actorUrl) {
102
+ if (!ap_followers || !actorUrl) return "";
103
+
104
+ try {
105
+ const follower = await ap_followers.findOne({ actorUrl });
106
+ return follower?.avatar || "";
107
+ } catch {
108
+ return "";
109
+ }
110
+ }
@@ -78,6 +78,12 @@ export async function runPollCycle(indiekit, options) {
78
78
  password: bskyPassword,
79
79
  });
80
80
  }
81
+
82
+ // Poll ActivityPub (auto-detect from local collections)
83
+ const hasActivityPub = await detectActivityPubSource(indiekit);
84
+ if (hasActivityPub) {
85
+ await pollActivityPub(indiekit, stateCollection, state);
86
+ }
81
87
  }
82
88
 
83
89
  /**
@@ -277,6 +283,97 @@ async function pollBluesky(indiekit, stateCollection, state, credentials) {
277
283
  }
278
284
  }
279
285
 
286
+ /**
287
+ * Detect whether the ActivityPub endpoint is installed and has interaction data
288
+ * @param {object} indiekit - Indiekit instance
289
+ * @returns {Promise<boolean>}
290
+ */
291
+ async function detectActivityPubSource(indiekit) {
292
+ try {
293
+ const ap_activities = indiekit.collections.get("ap_activities");
294
+ if (!ap_activities) return false;
295
+ // Collection exists — even if empty, polling is zero-cost (local DB query)
296
+ return true;
297
+ } catch {
298
+ return false;
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Poll ActivityPub interactions from local ap_activities collection
304
+ */
305
+ async function pollActivityPub(indiekit, stateCollection, state) {
306
+ try {
307
+ const { fetchActivityPubInteractions } = await import(
308
+ "../notifications/activitypub.js"
309
+ );
310
+
311
+ const ap_activities = indiekit.collections.get("ap_activities");
312
+ const ap_followers = indiekit.collections.get("ap_followers");
313
+
314
+ if (!ap_activities) return;
315
+
316
+ const result = await fetchActivityPubInteractions({
317
+ ap_activities,
318
+ ap_followers,
319
+ since: state.activitypub_last_received_at || null,
320
+ });
321
+
322
+ let stored = 0;
323
+
324
+ for (const interaction of result.items) {
325
+ if (interaction.canonical_url) {
326
+ await upsertConversationItem(indiekit, {
327
+ canonical_url: interaction.canonical_url,
328
+ source: "activitypub",
329
+ type: interaction.type,
330
+ author: interaction.author,
331
+ content: interaction.content,
332
+ url: interaction.url,
333
+ bridgy_url: null,
334
+ platform_id: interaction.platform_id,
335
+ created_at: interaction.created_at,
336
+ });
337
+ stored++;
338
+ }
339
+ }
340
+
341
+ // Update cursor and status
342
+ const updateFields = {
343
+ activitypub_last_poll: new Date().toISOString(),
344
+ activitypub_last_error: null,
345
+ };
346
+ if (result.cursor) {
347
+ updateFields.activitypub_last_received_at = result.cursor;
348
+ }
349
+
350
+ await stateCollection.findOneAndUpdate(
351
+ { _id: "poll_cursors" },
352
+ { $set: updateFields },
353
+ { upsert: true },
354
+ );
355
+
356
+ if (stored > 0) {
357
+ console.info(
358
+ `[Conversations] ActivityPub: stored ${stored}/${result.items.length} interactions`,
359
+ );
360
+ }
361
+ } catch (error) {
362
+ console.error("[Conversations] ActivityPub poll error:", error.message);
363
+
364
+ await stateCollection.findOneAndUpdate(
365
+ { _id: "poll_cursors" },
366
+ {
367
+ $set: {
368
+ activitypub_last_poll: new Date().toISOString(),
369
+ activitypub_last_error: error.message,
370
+ },
371
+ },
372
+ { upsert: true },
373
+ );
374
+ }
375
+ }
376
+
280
377
  /**
281
378
  * Resolve a Mastodon status ID to its URL
282
379
  * Used to find the parent status of a mention/reply
package/locales/en.json CHANGED
@@ -12,6 +12,8 @@
12
12
  "itemsReceived": "items received",
13
13
  "mastodonHint": "Set MASTODON_ACCESS_TOKEN and MASTODON_URL to enable",
14
14
  "blueskyHint": "Set BLUESKY_IDENTIFIER and BLUESKY_PASSWORD to enable",
15
+ "activitypubTitle": "ActivityPub",
16
+ "activitypubHint": "Auto-enabled when the ActivityPub endpoint receives interactions",
15
17
  "webhookTitle": "Webhook / Ingest",
16
18
  "webhookHint": "External services can POST webmentions to the ingest endpoint",
17
19
  "stats": "Statistics",
@@ -26,7 +28,8 @@
26
28
  "source": {
27
29
  "webmention": "Webmention",
28
30
  "mastodon": "Mastodon",
29
- "bluesky": "Bluesky"
31
+ "bluesky": "Bluesky",
32
+ "activitypub": "ActivityPub"
30
33
  }
31
34
  }
32
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-conversations",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
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",
@@ -10,7 +10,8 @@
10
10
  "webmention",
11
11
  "textcasting",
12
12
  "mastodon",
13
- "bluesky"
13
+ "bluesky",
14
+ "activitypub"
14
15
  ],
15
16
  "homepage": "https://github.com/rmdes/indiekit-endpoint-conversations",
16
17
  "author": {
@@ -80,6 +80,40 @@
80
80
  {% endif %}
81
81
  </div>
82
82
 
83
+ {# ActivityPub Card #}
84
+ <div style="border: 1px solid var(--color-border, #e5e7eb); border-radius: 8px; padding: 1rem">
85
+ <div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem">
86
+ <svg style="width:20px;height:20px" viewBox="0 0 24 24" fill="#f1007e" aria-label="ActivityPub">
87
+ <path d="M12 2L2 7v10l10 5 10-5V7L12 2zm0 2.18L19.35 8 12 11.82 4.65 8 12 4.18zM4 9.64l7 3.5V19.5l-7-3.5V9.64zm10 9.86v-6.36l7-3.5v6.36l-7 3.5z"/>
88
+ </svg>
89
+ <strong>{{ __("conversations.dashboard.activitypubTitle") }}</strong>
90
+ {% if config.activitypubEnabled %}
91
+ <span class="badge" style="background: #059669; color: white; font-size: 0.75em">{{ __("conversations.dashboard.connected") }}</span>
92
+ {% else %}
93
+ <span class="badge" style="background: #6b7280; color: white; font-size: 0.75em">{{ __("conversations.dashboard.disconnected") }}</span>
94
+ {% endif %}
95
+ </div>
96
+ {% if config.activitypubEnabled %}
97
+ {% if pollState.activitypub_last_poll %}
98
+ <p style="font-size: 0.85em; color: #6b7280; margin: 0.25rem 0">
99
+ {{ __("conversations.dashboard.lastPoll") }}: {{ pollState.activitypub_last_poll | date("PPp") }}
100
+ </p>
101
+ {% endif %}
102
+ {% if pollState.activitypub_last_error %}
103
+ <p style="font-size: 0.85em; color: #dc2626; margin: 0.25rem 0">
104
+ {{ __("conversations.dashboard.lastError") }}: {{ pollState.activitypub_last_error }}
105
+ </p>
106
+ {% endif %}
107
+ <p style="font-size: 0.85em; margin: 0.25rem 0">
108
+ {{ platformCounts.activitypub or 0 }} {{ __("conversations.dashboard.itemsCollected") }}
109
+ </p>
110
+ {% else %}
111
+ <p style="font-size: 0.85em; color: #6b7280; margin: 0.25rem 0">
112
+ {{ __("conversations.dashboard.activitypubHint") }}
113
+ </p>
114
+ {% endif %}
115
+ </div>
116
+
83
117
  {# Webhook/Ingest Card #}
84
118
  <div style="border: 1px solid var(--color-border, #e5e7eb); border-radius: 8px; padding: 1rem">
85
119
  <div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem">
@@ -129,7 +163,7 @@
129
163
  </div>
130
164
 
131
165
  {# Manual Poll Button #}
132
- {% if config.mastodonEnabled or config.blueskyEnabled %}
166
+ {% if config.mastodonEnabled or config.blueskyEnabled or config.activitypubEnabled %}
133
167
  <div style="margin-bottom: 2rem">
134
168
  <form method="post" action="{{ baseUrl }}/poll">
135
169
  <button type="submit" class="button">
@@ -150,6 +184,8 @@
150
184
  <svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 568 501" fill="#0085ff"><path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/></svg>
151
185
  {% elif item.source == "mastodon" %}
152
186
  <svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24" fill="#6364ff"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
187
+ {% elif item.source == "activitypub" %}
188
+ <svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24" fill="#f1007e"><path d="M12 2L2 7v10l10 5 10-5V7L12 2zm0 2.18L19.35 8 12 11.82 4.65 8 12 4.18zM4 9.64l7 3.5V19.5l-7-3.5V9.64zm10 9.86v-6.36l7-3.5v6.36l-7 3.5z"/></svg>
153
189
  {% else %}
154
190
  <svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="#888" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
155
191
  {% endif %}