@rmdes/indiekit-endpoint-conversations 2.1.6 → 2.2.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/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # @rmdes/indiekit-endpoint-conversations
2
+
3
+ Conversation aggregation endpoint for [Indiekit](https://getindiekit.com/). Polls Mastodon, Bluesky, and ActivityPub notifications, stores interactions in MongoDB, and serves them as a JF2-compatible API — including threaded owner replies.
4
+
5
+ ## Features
6
+
7
+ - **Multi-platform polling** — Mastodon, Bluesky, and native ActivityPub (via Fedify)
8
+ - **JF2 API** — serves likes, reposts, and replies in webmention-compatible format
9
+ - **Owner reply threading** — enriches API responses with the site owner's replies from the `posts` collection, with threading metadata
10
+ - **Webmention ingestion** — accepts incoming webmentions from Bridgy or external services
11
+ - **Admin dashboard** — connection status, polling stats, platform health
12
+ - **Syndication URL matching** — resolves canonical post URLs from syndicated copies
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @rmdes/indiekit-endpoint-conversations
18
+ ```
19
+
20
+ ```javascript
21
+ // indiekit.config.js
22
+ import ConversationsEndpoint from "@rmdes/indiekit-endpoint-conversations";
23
+
24
+ export default {
25
+ plugins: [
26
+ new ConversationsEndpoint({
27
+ mountPath: "/conversations",
28
+ }),
29
+ ],
30
+ };
31
+ ```
32
+
33
+ ## Environment Variables
34
+
35
+ | Variable | Required | Description |
36
+ |----------|----------|-------------|
37
+ | `MASTODON_ACCESS_TOKEN` | For Mastodon | Mastodon API access token |
38
+ | `MASTODON_URL` or `MASTODON_INSTANCE` | For Mastodon | Mastodon instance URL |
39
+ | `BLUESKY_IDENTIFIER` or `BLUESKY_HANDLE` | For Bluesky | Bluesky account identifier |
40
+ | `BLUESKY_PASSWORD` | For Bluesky | Bluesky app password |
41
+ | `AUTHOR_NAME` | Optional | Owner display name (falls back to site hostname) |
42
+ | `AUTHOR_AVATAR` | Optional | Owner avatar URL |
43
+
44
+ ActivityPub polling is auto-detected when `@rmdes/indiekit-endpoint-activitypub` is installed.
45
+
46
+ ## API
47
+
48
+ ### GET /conversations/api/mentions
49
+
50
+ Returns interactions for a target URL in JF2 feed format. Compatible with the webmention.io API shape used by `@chrisburnell/eleventy-cache-webmentions`.
51
+
52
+ **Parameters:**
53
+
54
+ | Param | Type | Description |
55
+ |-------|------|-------------|
56
+ | `target` | string | Target URL to fetch interactions for |
57
+ | `wm-property` | string | Filter by type: `like-of`, `repost-of`, `in-reply-to` |
58
+ | `per-page` | number | Results per page (default: 50, max: 100) |
59
+ | `page` | number | Page number (default: 0) |
60
+
61
+ **Response:**
62
+
63
+ ```json
64
+ {
65
+ "type": "feed",
66
+ "name": "Conversations",
67
+ "children": [
68
+ {
69
+ "type": "entry",
70
+ "wm-id": "conv-mastodon:12345",
71
+ "wm-property": "in-reply-to",
72
+ "wm-target": "https://example.com/posts/hello",
73
+ "author": {
74
+ "type": "card",
75
+ "name": "Jane Doe",
76
+ "url": "https://mastodon.social/@jane",
77
+ "photo": "https://..."
78
+ },
79
+ "url": "https://mastodon.social/@jane/67890",
80
+ "published": "2026-03-11T16:19:52.652Z",
81
+ "platform": "mastodon",
82
+ "content": {
83
+ "html": "<p>Great post!</p>",
84
+ "text": "Great post!"
85
+ }
86
+ },
87
+ {
88
+ "type": "entry",
89
+ "wm-id": "owner-reply-abc123",
90
+ "wm-property": "in-reply-to",
91
+ "wm-target": "https://example.com/posts/hello",
92
+ "author": {
93
+ "type": "card",
94
+ "name": "Site Owner",
95
+ "url": "https://example.com",
96
+ "photo": "https://..."
97
+ },
98
+ "url": "https://example.com/replies/2026/03/11/65e12",
99
+ "published": "2026-03-11T17:00:00.000Z",
100
+ "content": {
101
+ "html": "<p>Thanks!</p>",
102
+ "text": "Thanks!"
103
+ },
104
+ "is_owner": true,
105
+ "parent_url": "https://mastodon.social/@jane/67890"
106
+ }
107
+ ]
108
+ }
109
+ ```
110
+
111
+ ### Owner Reply Enrichment
112
+
113
+ When the API returns replies (`wm-property: "in-reply-to"`), it checks the Indiekit `posts` collection for owner posts whose `properties.in-reply-to` matches any reply's `url`. Matching owner posts are appended to the response with two extra fields:
114
+
115
+ | Field | Type | Description |
116
+ |-------|------|-------------|
117
+ | `is_owner` | boolean | Always `true` for owner replies |
118
+ | `parent_url` | string | The URL of the interaction this reply responds to |
119
+
120
+ The frontend uses `parent_url` to thread the owner's reply under the correct parent interaction. See [`indiekit-eleventy-theme`](https://github.com/rmdes/indiekit-eleventy-theme) for the client-side threading implementation.
121
+
122
+ ### GET /conversations/api/status
123
+
124
+ Returns connection health and platform status.
125
+
126
+ ### POST /conversations/ingest
127
+
128
+ Accepts incoming webmentions. Body: `{ source, target }`.
129
+
130
+ ### POST /conversations/poll (authenticated)
131
+
132
+ Triggers an immediate poll of all configured platforms.
133
+
134
+ ## Architecture
135
+
136
+ ```
137
+ Mastodon API ──┐
138
+ Bluesky API ──┼──> Scheduler ──> conversation_items (MongoDB)
139
+ ActivityPub ──┘ │
140
+ v
141
+ GET /api/mentions ──> JF2 response
142
+ + owner reply enrichment
143
+ (from posts collection)
144
+ ```
145
+
146
+ ### Collections
147
+
148
+ | Collection | Purpose |
149
+ |------------|---------|
150
+ | `conversation_items` | Stored interactions (likes, reposts, replies) |
151
+ | `conversation_state` | Polling state (last poll timestamps, cursors) |
152
+
153
+ ### Dependencies
154
+
155
+ - **`@rmdes/indiekit-endpoint-activitypub`** — Optional. When installed, the scheduler also polls native ActivityPub interactions from the `ap_interactions` collection.
156
+ - **`indiekit-eleventy-theme`** — The theme's `webmentions.js` consumes the `/api/mentions` endpoint and threads owner replies using the `is_owner` and `parent_url` fields.
157
+ - **`@rmdes/indiekit-endpoint-comments`** — Handles native comment replies (not platform interactions). Owner replies to native comments go through the comments API, not this plugin.
158
+
159
+ ## License
160
+
161
+ MIT
package/index.js CHANGED
@@ -81,6 +81,7 @@ export default class ConversationsEndpoint {
81
81
  // Register MongoDB collections
82
82
  Indiekit.addCollection("conversation_items");
83
83
  Indiekit.addCollection("conversation_state");
84
+ Indiekit.addCollection("nodeinfo_cache");
84
85
 
85
86
  Indiekit.addEndpoint(this);
86
87
 
@@ -165,6 +165,58 @@ async function apiMentions(request, response) {
165
165
 
166
166
  const children = items.map(conversationItemToJf2);
167
167
 
168
+ // Enrich with owner replies from the posts collection
169
+ // Owner replies are Micropub posts with in-reply-to matching an interaction URL
170
+ const replyUrls = children
171
+ .filter((c) => c["wm-property"] === "in-reply-to")
172
+ .map((c) => c.url)
173
+ .filter(Boolean);
174
+
175
+ if (replyUrls.length > 0) {
176
+ const postsCollection = application.collections?.get("posts");
177
+ if (postsCollection) {
178
+ const siteUrl = application.publication?.me || application.url || "";
179
+ const ownerName =
180
+ process.env.AUTHOR_NAME ||
181
+ (siteUrl ? new URL(siteUrl).hostname : "Owner");
182
+
183
+ const ownerPosts = await postsCollection
184
+ .find({
185
+ "properties.in-reply-to": { $in: replyUrls },
186
+ })
187
+ .sort({ "properties.published": -1 })
188
+ .limit(50)
189
+ .toArray();
190
+
191
+ for (const post of ownerPosts) {
192
+ const inReplyTo = post.properties?.["in-reply-to"];
193
+ if (!inReplyTo || typeof inReplyTo !== "string") continue;
194
+
195
+ children.push({
196
+ type: "entry",
197
+ "wm-id": `owner-reply-${post._id}`,
198
+ "wm-property": "in-reply-to",
199
+ "wm-target": target || "",
200
+ "wm-received": post.properties?.published || "",
201
+ author: {
202
+ type: "card",
203
+ name: ownerName,
204
+ url: siteUrl,
205
+ photo: process.env.AUTHOR_AVATAR || "",
206
+ },
207
+ url: post.properties?.url || "",
208
+ published: post.properties?.published || "",
209
+ content: {
210
+ text: post.properties?.content?.text || "",
211
+ html: post.properties?.content?.html || "",
212
+ },
213
+ is_owner: true,
214
+ parent_url: inReplyTo,
215
+ });
216
+ }
217
+ }
218
+ }
219
+
168
220
  response.set("Cache-Control", "public, max-age=60");
169
221
  response.json({
170
222
  type: "feed",
@@ -0,0 +1,153 @@
1
+ /**
2
+ * NodeInfo-based server software resolver
3
+ * Fetches /.well-known/nodeinfo from a domain, follows the link,
4
+ * and returns the software name (e.g., "mastodon", "pleroma", "misskey").
5
+ * Results are cached in-memory and optionally persisted to MongoDB.
6
+ * @module nodeinfo/resolver
7
+ */
8
+
9
+ const NODEINFO_TIMEOUT_MS = 5000;
10
+ const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
11
+
12
+ // In-memory cache: domain -> { software, resolvedAt }
13
+ const memoryCache = new Map();
14
+
15
+ /**
16
+ * Resolve the server software for a given actor URL via NodeInfo.
17
+ * Returns a lowercase software name like "mastodon", "pleroma", "misskey",
18
+ * "gotosocial", "fedify", etc. Falls back to "activitypub" if NodeInfo
19
+ * is unavailable or unrecognizable.
20
+ *
21
+ * @param {string} actorUrl - The actor's URL (e.g., "https://mastodon.social/@user")
22
+ * @param {object} [collection] - Optional MongoDB collection for persistent cache
23
+ * @returns {Promise<string>} Lowercase software name or "activitypub"
24
+ */
25
+ export async function resolveServerSoftware(actorUrl, collection) {
26
+ const domain = extractDomain(actorUrl);
27
+ if (!domain) return "activitypub";
28
+
29
+ // Check in-memory cache first
30
+ const cached = memoryCache.get(domain);
31
+ if (cached && Date.now() - cached.resolvedAt < CACHE_TTL_MS) {
32
+ return cached.software;
33
+ }
34
+
35
+ // Check MongoDB cache
36
+ if (collection) {
37
+ try {
38
+ const doc = await collection.findOne({ _id: domain });
39
+ if (doc && Date.now() - new Date(doc.resolvedAt).getTime() < CACHE_TTL_MS) {
40
+ memoryCache.set(domain, {
41
+ software: doc.software,
42
+ resolvedAt: new Date(doc.resolvedAt).getTime(),
43
+ });
44
+ return doc.software;
45
+ }
46
+ } catch { /* proceed to live fetch */ }
47
+ }
48
+
49
+ // Live fetch via NodeInfo protocol
50
+ const software = await fetchNodeInfo(domain);
51
+
52
+ // Cache result (even "activitypub" fallback — avoids repeated failed lookups)
53
+ const entry = { software, resolvedAt: Date.now() };
54
+ memoryCache.set(domain, entry);
55
+
56
+ if (collection) {
57
+ try {
58
+ await collection.findOneAndUpdate(
59
+ { _id: domain },
60
+ { $set: { software, resolvedAt: new Date().toISOString() } },
61
+ { upsert: true },
62
+ );
63
+ } catch { /* non-critical */ }
64
+ }
65
+
66
+ return software;
67
+ }
68
+
69
+ /**
70
+ * Batch-resolve software for multiple actor URLs.
71
+ * Deduplicates by domain so each domain is only queried once.
72
+ *
73
+ * @param {string[]} actorUrls - Array of actor URLs
74
+ * @param {object} [collection] - Optional MongoDB collection for persistent cache
75
+ * @returns {Promise<Map<string, string>>} Map of domain -> software name
76
+ */
77
+ export async function batchResolve(actorUrls, collection) {
78
+ const domains = new Set();
79
+ for (const url of actorUrls) {
80
+ const domain = extractDomain(url);
81
+ if (domain) domains.add(domain);
82
+ }
83
+
84
+ const results = new Map();
85
+ for (const domain of domains) {
86
+ results.set(
87
+ domain,
88
+ await resolveServerSoftware(`https://${domain}/`, collection),
89
+ );
90
+ }
91
+ return results;
92
+ }
93
+
94
+ /**
95
+ * Extract domain from a URL
96
+ * @param {string} url
97
+ * @returns {string|null}
98
+ */
99
+ function extractDomain(url) {
100
+ try {
101
+ return new URL(url).hostname;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Fetch NodeInfo for a domain and return the software name
109
+ * @param {string} domain
110
+ * @returns {Promise<string>} Software name or "activitypub"
111
+ */
112
+ async function fetchNodeInfo(domain) {
113
+ try {
114
+ // Step 1: Fetch /.well-known/nodeinfo
115
+ const wellKnownUrl = `https://${domain}/.well-known/nodeinfo`;
116
+ const wellKnownResp = await fetch(wellKnownUrl, {
117
+ headers: { Accept: "application/json" },
118
+ signal: AbortSignal.timeout(NODEINFO_TIMEOUT_MS),
119
+ });
120
+
121
+ if (!wellKnownResp.ok) return "activitypub";
122
+
123
+ const wellKnown = await wellKnownResp.json();
124
+ const links = wellKnown.links;
125
+ if (!Array.isArray(links) || links.length === 0) return "activitypub";
126
+
127
+ // Prefer NodeInfo 2.x, fall back to any available link
128
+ const link =
129
+ links.find((l) => l.rel?.includes("nodeinfo/2.")) ||
130
+ links[0];
131
+
132
+ if (!link?.href) return "activitypub";
133
+
134
+ // Step 2: Fetch the actual NodeInfo document
135
+ const nodeInfoResp = await fetch(link.href, {
136
+ headers: { Accept: "application/json" },
137
+ signal: AbortSignal.timeout(NODEINFO_TIMEOUT_MS),
138
+ });
139
+
140
+ if (!nodeInfoResp.ok) return "activitypub";
141
+
142
+ const nodeInfo = await nodeInfoResp.json();
143
+ const softwareName = nodeInfo.software?.name;
144
+
145
+ if (typeof softwareName === "string" && softwareName.trim()) {
146
+ return softwareName.trim().toLowerCase();
147
+ }
148
+
149
+ return "activitypub";
150
+ } catch {
151
+ return "activitypub";
152
+ }
153
+ }
@@ -20,11 +20,12 @@ const typeMap = {
20
20
  * @param {object} options
21
21
  * @param {object} options.ap_activities - MongoDB collection
22
22
  * @param {object} options.ap_followers - MongoDB collection (for avatar lookup)
23
+ * @param {object} [options.nodeinfoCache] - MongoDB collection for NodeInfo cache
23
24
  * @param {string} [options.since] - ISO 8601 timestamp cursor (process activities after this)
24
25
  * @returns {Promise<{items: Array, cursor: string|null}>}
25
26
  */
26
27
  export async function fetchActivityPubInteractions(options) {
27
- const { ap_activities, ap_followers, since } = options;
28
+ const { ap_activities, ap_followers, nodeinfoCache, since } = options;
28
29
 
29
30
  const query = {
30
31
  direction: "inbound",
@@ -45,6 +46,11 @@ export async function fetchActivityPubInteractions(options) {
45
46
  return { items: [], cursor: null };
46
47
  }
47
48
 
49
+ // Resolve server software for all actor domains in this batch
50
+ const { batchResolve } = await import("../nodeinfo/resolver.js");
51
+ const actorUrls = activities.map((a) => a.actorUrl).filter(Boolean);
52
+ const domainSoftware = await batchResolve(actorUrls, nodeinfoCache);
53
+
48
54
  const items = [];
49
55
 
50
56
  for (const activity of activities) {
@@ -53,7 +59,12 @@ export async function fetchActivityPubInteractions(options) {
53
59
  const avatar =
54
60
  activity.actorAvatar ||
55
61
  (await lookupAvatar(ap_followers, activity.actorUrl));
56
- items.push(normalizeActivity(activity, avatar));
62
+
63
+ // Resolve platform from NodeInfo (e.g., "mastodon", "pleroma", "misskey")
64
+ const domain = extractDomain(activity.actorUrl);
65
+ const platform = domain ? (domainSoftware.get(domain) || "activitypub") : "activitypub";
66
+
67
+ items.push(normalizeActivity(activity, avatar, platform));
57
68
  }
58
69
 
59
70
  // Cursor is the receivedAt of the last activity processed
@@ -62,13 +73,27 @@ export async function fetchActivityPubInteractions(options) {
62
73
  return { items, cursor };
63
74
  }
64
75
 
76
+ /**
77
+ * Extract hostname from a URL
78
+ * @param {string} url
79
+ * @returns {string|null}
80
+ */
81
+ function extractDomain(url) {
82
+ try {
83
+ return new URL(url).hostname;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
65
89
  /**
66
90
  * Normalize an ap_activities document into conversations internal format
67
91
  * @param {object} activity - Document from ap_activities
68
92
  * @param {string} avatar - Avatar URL from ap_followers lookup
93
+ * @param {string} platform - Resolved server software (e.g., "mastodon", "pleroma")
69
94
  * @returns {object} Normalized interaction
70
95
  */
71
- function normalizeActivity(activity, avatar) {
96
+ function normalizeActivity(activity, avatar, platform) {
72
97
  const type = typeMap[activity.type] || "mention";
73
98
  const isReply = activity.type === "Reply";
74
99
 
@@ -81,7 +106,7 @@ function normalizeActivity(activity, avatar) {
81
106
  const url = isReply ? activity.objectUrl : activity.actorUrl;
82
107
 
83
108
  return {
84
- platform: "activitypub",
109
+ platform,
85
110
  platform_id: `activitypub:${activity.type}:${activity.actorUrl}:${activity.objectUrl}`,
86
111
  type,
87
112
  author: {
@@ -97,6 +97,9 @@ export async function runPollCycle(indiekit, options) {
97
97
 
98
98
  // Backfill missing avatars from ap_notifications (one-time sweep per cycle)
99
99
  await backfillMissingAvatars(indiekit, stateCollection);
100
+
101
+ // Backfill platform names for items stored as "activitypub" (one-time)
102
+ await backfillPlatformNames(indiekit, stateCollection);
100
103
  }
101
104
 
102
105
  /**
@@ -233,6 +236,72 @@ async function backfillMissingAvatars(indiekit, stateCollection) {
233
236
  }
234
237
  }
235
238
 
239
+ /**
240
+ * Backfill platform names for existing items stored with source "activitypub".
241
+ * Uses NodeInfo to resolve the actual server software (e.g., "mastodon", "pleroma").
242
+ * One-time operation — marks complete after first successful run.
243
+ */
244
+ async function backfillPlatformNames(indiekit, stateCollection) {
245
+ try {
246
+ const itemsCollection = indiekit.collections.get("conversation_items");
247
+ if (!itemsCollection) return;
248
+
249
+ // Check if backfill already completed
250
+ const state = await stateCollection.findOne({ _id: "poll_cursors" });
251
+ if (state?.platform_backfill_complete) return;
252
+
253
+ // Find unique author URLs for items with source "activitypub"
254
+ const actorUrls = await itemsCollection.distinct("author.url", {
255
+ source: "activitypub",
256
+ });
257
+
258
+ if (actorUrls.length === 0) {
259
+ await stateCollection.findOneAndUpdate(
260
+ { _id: "poll_cursors" },
261
+ { $set: { platform_backfill_complete: true } },
262
+ { upsert: true },
263
+ );
264
+ return;
265
+ }
266
+
267
+ const { batchResolve } = await import("../nodeinfo/resolver.js");
268
+ const nodeinfoCache = indiekit.collections?.get("nodeinfo_cache") || null;
269
+ const domainSoftware = await batchResolve(
270
+ actorUrls.filter(Boolean),
271
+ nodeinfoCache,
272
+ );
273
+
274
+ let updated = 0;
275
+
276
+ for (const [domain, software] of domainSoftware) {
277
+ if (software === "activitypub") continue; // No change needed
278
+
279
+ const result = await itemsCollection.updateMany(
280
+ {
281
+ source: "activitypub",
282
+ "author.url": { $regex: `^https?://${domain.replace(/\./g, "\\.")}/` },
283
+ },
284
+ { $set: { source: software } },
285
+ );
286
+ if (result.modifiedCount > 0) updated += result.modifiedCount;
287
+ }
288
+
289
+ if (updated > 0) {
290
+ console.info(
291
+ `[Conversations] Platform backfill: updated ${updated} items from "activitypub" to resolved software names`,
292
+ );
293
+ }
294
+
295
+ await stateCollection.findOneAndUpdate(
296
+ { _id: "poll_cursors" },
297
+ { $set: { platform_backfill_complete: true } },
298
+ { upsert: true },
299
+ );
300
+ } catch (error) {
301
+ console.warn("[Conversations] Platform backfill error:", error.message);
302
+ }
303
+ }
304
+
236
305
  /**
237
306
  * Poll Mastodon notifications and store matching interactions
238
307
  */
@@ -470,9 +539,13 @@ async function pollActivityPub(indiekit, stateCollection, state) {
470
539
  ""
471
540
  ).replace(/\/$/, "");
472
541
 
542
+ // NodeInfo cache collection for resolving server software per domain
543
+ const nodeinfoCache = indiekit.collections?.get("nodeinfo_cache") || null;
544
+
473
545
  const result = await fetchActivityPubInteractions({
474
546
  ap_activities,
475
547
  ap_followers,
548
+ nodeinfoCache,
476
549
  since: state.activitypub_last_received_at || null,
477
550
  });
478
551
 
@@ -491,7 +564,7 @@ async function pollActivityPub(indiekit, stateCollection, state) {
491
564
 
492
565
  await upsertConversationItem(indiekit, {
493
566
  canonical_url: interaction.canonical_url,
494
- source: "activitypub",
567
+ source: interaction.platform,
495
568
  type: interaction.type,
496
569
  author: interaction.author,
497
570
  content: interaction.content,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-conversations",
3
- "version": "2.1.6",
3
+ "version": "2.2.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",