@rmdes/indiekit-endpoint-conversations 2.2.0 → 2.3.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.
@@ -166,54 +166,99 @@ async function apiMentions(request, response) {
166
166
  const children = items.map(conversationItemToJf2);
167
167
 
168
168
  // Enrich with owner replies from the posts collection
169
- // Owner replies are Micropub posts with in-reply-to matching an interaction URL
169
+ // Owner replies are Micropub posts with in-reply-to matching an interaction URL.
170
+ // We collect reply URLs from conversations DB items, but also need to find
171
+ // owner replies to interactions that only exist in webmention.io (e.g., Bluesky
172
+ // replies via Bridgy). Strategy: query for reply URLs from conversations items,
173
+ // plus find owner posts replying to any URL that the frontend might display
174
+ // by checking the canonical post's syndication targets.
170
175
  const replyUrls = children
171
176
  .filter((c) => c["wm-property"] === "in-reply-to")
172
177
  .map((c) => c.url)
173
178
  .filter(Boolean);
174
179
 
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
- })
180
+ const postsCollection = application.collections?.get("posts");
181
+ if (postsCollection) {
182
+ const siteUrl = application.publication?.me || application.url || "";
183
+ const ownerName =
184
+ process.env.AUTHOR_NAME ||
185
+ (siteUrl ? new URL(siteUrl).hostname : "Owner");
186
+
187
+ // Find the canonical post to get its syndication URLs
188
+ // Interactions on syndicated copies (e.g., Bluesky replies to the bsky.app
189
+ // syndicated post) arrive via webmention.io but not conversations DB.
190
+ // Owner replies to those interactions have in-reply-to pointing to external
191
+ // URLs (bsky.app, mastodon, etc.) — we need to find them too.
192
+ let syndicationDomains = [];
193
+ if (target) {
194
+ const targetWithout = target.endsWith("/") ? target.slice(0, -1) : target;
195
+ const canonicalPost = await postsCollection.findOne({
196
+ $or: [
197
+ { "properties.url": target },
198
+ { "properties.url": targetWithout },
199
+ ],
200
+ });
201
+ if (canonicalPost?.properties?.syndication) {
202
+ const syns = Array.isArray(canonicalPost.properties.syndication)
203
+ ? canonicalPost.properties.syndication
204
+ : [canonicalPost.properties.syndication];
205
+ for (const syn of syns) {
206
+ try {
207
+ const domain = new URL(syn).hostname;
208
+ if (domain && !domain.includes(new URL(siteUrl).hostname)) {
209
+ syndicationDomains.push(domain);
210
+ }
211
+ } catch { /* skip invalid URLs */ }
212
+ }
213
+ }
214
+ }
215
+
216
+ // Build query: replies to known conversation URLs OR replies to URLs
217
+ // on syndication domains (for webmention.io items not in our DB)
218
+ const orClauses = [];
219
+ if (replyUrls.length > 0) {
220
+ orClauses.push({ "properties.in-reply-to": { $in: replyUrls } });
221
+ }
222
+ for (const domain of syndicationDomains) {
223
+ orClauses.push({
224
+ "properties.in-reply-to": { $regex: domain.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") },
225
+ });
226
+ }
227
+
228
+ let ownerPosts = [];
229
+ if (orClauses.length > 0) {
230
+ ownerPosts = await postsCollection
231
+ .find({ $or: orClauses })
187
232
  .sort({ "properties.published": -1 })
188
233
  .limit(50)
189
234
  .toArray();
235
+ }
190
236
 
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
- }
237
+ for (const post of ownerPosts) {
238
+ const inReplyTo = post.properties?.["in-reply-to"];
239
+ if (!inReplyTo || typeof inReplyTo !== "string") continue;
240
+
241
+ children.push({
242
+ type: "entry",
243
+ "wm-id": `owner-reply-${post._id}`,
244
+ "wm-property": "in-reply-to",
245
+ "wm-target": target || "",
246
+ "wm-received": post.properties?.published || "",
247
+ author: {
248
+ type: "card",
249
+ name: ownerName,
250
+ url: siteUrl,
251
+ photo: process.env.AUTHOR_AVATAR || "",
252
+ },
253
+ url: post.properties?.url || "",
254
+ published: post.properties?.published || "",
255
+ content: {
256
+ text: post.properties?.content?.text || "",
257
+ html: post.properties?.content?.html || "",
258
+ },
259
+ is_owner: true,
260
+ parent_url: inReplyTo,
261
+ });
217
262
  }
218
263
  }
219
264
 
@@ -27,9 +27,11 @@ export async function fetchBlueskyNotifications(options) {
27
27
  // Get or refresh session
28
28
  const session = await getSession(serviceUrl, identifier, password);
29
29
 
30
- // Fetch notifications
30
+ // Fetch the most recent notifications (no cursor)
31
+ // Bluesky's listNotifications returns newest first; the cursor pages backward
32
+ // into history. For polling, we always want the latest batch and rely on
33
+ // upsert deduplication (platform_id) to skip already-stored items.
31
34
  const params = new URLSearchParams({ limit: "50" });
32
- if (options.cursor) params.set("cursor", options.cursor);
33
35
 
34
36
  let notifResponse = await fetch(
35
37
  `${serviceUrl}/xrpc/app.bsky.notification.listNotifications?${params.toString()}`,
@@ -425,7 +425,6 @@ async function pollBluesky(indiekit, stateCollection, state, credentials) {
425
425
  const result = await fetchBlueskyNotifications({
426
426
  identifier: credentials.identifier,
427
427
  password: credentials.password,
428
- cursor: state.bluesky_cursor,
429
428
  });
430
429
 
431
430
  let stored = 0;
@@ -457,22 +456,19 @@ async function pollBluesky(indiekit, stateCollection, state, credentials) {
457
456
  }
458
457
  }
459
458
 
460
- // Update cursor and status
461
- const updateFields = {
462
- bluesky_last_poll: new Date().toISOString(),
463
- bluesky_last_error: null,
464
- };
465
- if (result.cursor) {
466
- updateFields.bluesky_cursor = result.cursor;
467
- }
468
-
459
+ // Update poll timestamp
469
460
  await stateCollection.findOneAndUpdate(
470
461
  { _id: "poll_cursors" },
471
- { $set: updateFields },
462
+ {
463
+ $set: {
464
+ bluesky_last_poll: new Date().toISOString(),
465
+ bluesky_last_error: null,
466
+ },
467
+ },
472
468
  { upsert: true },
473
469
  );
474
470
 
475
- if (stored > 0) {
471
+ if (stored > 0 || result.items.length > 0) {
476
472
  console.info(
477
473
  `[Conversations] Bluesky: stored ${stored}/${result.items.length} interactions`,
478
474
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-conversations",
3
- "version": "2.2.0",
3
+ "version": "2.3.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",