@rmdes/indiekit-endpoint-conversations 2.0.0 → 2.1.1

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,25 +106,29 @@ 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) {
110
- // Store detected platforms for dashboard status
111
- Indiekit.config.application.conversations = {
112
- ...this.options,
113
- mastodonEnabled: !!hasMastodon,
114
- blueskyEnabled: !!hasBluesky,
115
- };
116
-
117
- import("./lib/polling/scheduler.js")
118
- .then(({ startPolling }) => {
119
- startPolling(Indiekit, this.options);
120
- })
121
- .catch((error) => {
122
- console.error(
123
- "[Conversations] Polling scheduler failed to start:",
124
- error.message,
125
- );
126
- });
127
- }
109
+ // Store detected platforms for dashboard status
110
+ // Note: ActivityPub detection happens at poll time (not init time)
111
+ // because the AP endpoint may register its collections after this
112
+ // plugin. The scheduler updates activitypubEnabled dynamically.
113
+ Indiekit.config.application.conversations = {
114
+ ...this.options,
115
+ mastodonEnabled: !!hasMastodon,
116
+ blueskyEnabled: !!hasBluesky,
117
+ activitypubEnabled: false,
118
+ };
119
+
120
+ // Always start polling — the scheduler detects available sources
121
+ // at runtime (Mastodon/Bluesky from env vars, AP from collections)
122
+ import("./lib/polling/scheduler.js")
123
+ .then(({ startPolling }) => {
124
+ startPolling(Indiekit, this.options);
125
+ })
126
+ .catch((error) => {
127
+ console.error(
128
+ "[Conversations] Polling scheduler failed to start:",
129
+ error.message,
130
+ );
131
+ });
128
132
  }
129
133
  }
130
134
  }
@@ -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,22 @@ export async function runPollCycle(indiekit, options) {
78
78
  password: bskyPassword,
79
79
  });
80
80
  }
81
+
82
+ // Poll ActivityPub (auto-detect from local collections)
83
+ // Detection runs at poll time because the AP endpoint may init after
84
+ // the conversations plugin, so the collection isn't available at init.
85
+ const hasActivityPub = await detectActivityPubSource(indiekit);
86
+ if (hasActivityPub) {
87
+ // Update config flag so the dashboard shows AP as enabled.
88
+ // indiekit may be the Indiekit class or the application object
89
+ // depending on whether called from startPolling or triggerPoll.
90
+ const convConfig =
91
+ indiekit.config?.application?.conversations || indiekit.conversations;
92
+ if (convConfig && !convConfig.activitypubEnabled) {
93
+ convConfig.activitypubEnabled = true;
94
+ }
95
+ await pollActivityPub(indiekit, stateCollection, state);
96
+ }
81
97
  }
82
98
 
83
99
  /**
@@ -277,6 +293,97 @@ async function pollBluesky(indiekit, stateCollection, state, credentials) {
277
293
  }
278
294
  }
279
295
 
296
+ /**
297
+ * Detect whether the ActivityPub endpoint is installed and has interaction data
298
+ * @param {object} indiekit - Indiekit instance
299
+ * @returns {Promise<boolean>}
300
+ */
301
+ async function detectActivityPubSource(indiekit) {
302
+ try {
303
+ const ap_activities = indiekit.collections.get("ap_activities");
304
+ if (!ap_activities) return false;
305
+ // Collection exists — even if empty, polling is zero-cost (local DB query)
306
+ return true;
307
+ } catch {
308
+ return false;
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Poll ActivityPub interactions from local ap_activities collection
314
+ */
315
+ async function pollActivityPub(indiekit, stateCollection, state) {
316
+ try {
317
+ const { fetchActivityPubInteractions } = await import(
318
+ "../notifications/activitypub.js"
319
+ );
320
+
321
+ const ap_activities = indiekit.collections.get("ap_activities");
322
+ const ap_followers = indiekit.collections.get("ap_followers");
323
+
324
+ if (!ap_activities) return;
325
+
326
+ const result = await fetchActivityPubInteractions({
327
+ ap_activities,
328
+ ap_followers,
329
+ since: state.activitypub_last_received_at || null,
330
+ });
331
+
332
+ let stored = 0;
333
+
334
+ 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++;
348
+ }
349
+ }
350
+
351
+ // Update cursor and status
352
+ const updateFields = {
353
+ activitypub_last_poll: new Date().toISOString(),
354
+ activitypub_last_error: null,
355
+ };
356
+ if (result.cursor) {
357
+ updateFields.activitypub_last_received_at = result.cursor;
358
+ }
359
+
360
+ await stateCollection.findOneAndUpdate(
361
+ { _id: "poll_cursors" },
362
+ { $set: updateFields },
363
+ { upsert: true },
364
+ );
365
+
366
+ if (stored > 0) {
367
+ console.info(
368
+ `[Conversations] ActivityPub: stored ${stored}/${result.items.length} interactions`,
369
+ );
370
+ }
371
+ } catch (error) {
372
+ console.error("[Conversations] ActivityPub poll error:", error.message);
373
+
374
+ await stateCollection.findOneAndUpdate(
375
+ { _id: "poll_cursors" },
376
+ {
377
+ $set: {
378
+ activitypub_last_poll: new Date().toISOString(),
379
+ activitypub_last_error: error.message,
380
+ },
381
+ },
382
+ { upsert: true },
383
+ );
384
+ }
385
+ }
386
+
280
387
  /**
281
388
  * Resolve a Mastodon status ID to its URL
282
389
  * 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.1",
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 %}