@rmdes/indiekit-endpoint-conversations 2.3.1 → 2.4.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.
|
@@ -80,6 +80,19 @@ async function dashboard(request, response) {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// Get item counts by channel (ingestion path)
|
|
84
|
+
let channelCounts = {};
|
|
85
|
+
if (itemsCollection) {
|
|
86
|
+
const counts = await itemsCollection
|
|
87
|
+
.aggregate([
|
|
88
|
+
{ $group: { _id: "$channel", count: { $sum: 1 } } },
|
|
89
|
+
])
|
|
90
|
+
.toArray();
|
|
91
|
+
for (const c of counts) {
|
|
92
|
+
channelCounts[c._id] = c.count;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
83
96
|
response.render("conversations", {
|
|
84
97
|
title: response.__
|
|
85
98
|
? response.__("conversations.title")
|
|
@@ -90,6 +103,7 @@ async function dashboard(request, response) {
|
|
|
90
103
|
summaries,
|
|
91
104
|
recentItems,
|
|
92
105
|
platformCounts,
|
|
106
|
+
channelCounts,
|
|
93
107
|
typeCounts,
|
|
94
108
|
baseUrl: config.mountPath || "/conversations",
|
|
95
109
|
});
|
|
@@ -103,6 +117,7 @@ async function dashboard(request, response) {
|
|
|
103
117
|
summaries: [],
|
|
104
118
|
recentItems: [],
|
|
105
119
|
platformCounts: {},
|
|
120
|
+
channelCounts: {},
|
|
106
121
|
typeCounts: {},
|
|
107
122
|
});
|
|
108
123
|
}
|
|
@@ -399,6 +414,7 @@ async function ingest(request, response) {
|
|
|
399
414
|
const item = {
|
|
400
415
|
canonical_url: canonicalUrl,
|
|
401
416
|
source: classification.source,
|
|
417
|
+
channel: "webhook",
|
|
402
418
|
type: classification.type,
|
|
403
419
|
author: webmention.author || {
|
|
404
420
|
name: "Unknown",
|
|
@@ -33,10 +33,10 @@ export async function findCanonicalPost(application, syndicationUrl) {
|
|
|
33
33
|
export async function resolveCanonicalUrl(application, targetUrl, siteUrl) {
|
|
34
34
|
// If the target is already on our domain, it's likely canonical
|
|
35
35
|
if (targetUrl.startsWith(siteUrl)) {
|
|
36
|
-
return targetUrl;
|
|
36
|
+
return targetUrl.replace(/\/$/, "");
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
// Otherwise try to find via syndication reverse lookup
|
|
40
40
|
const canonical = await findCanonicalPost(application, targetUrl);
|
|
41
|
-
return canonical || targetUrl;
|
|
41
|
+
return (canonical || targetUrl).replace(/\/$/, "");
|
|
42
42
|
}
|
package/lib/polling/scheduler.js
CHANGED
|
@@ -95,11 +95,89 @@ export async function runPollCycle(indiekit, options) {
|
|
|
95
95
|
await pollActivityPub(indiekit, stateCollection, state);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// Normalize trailing slashes in canonical_url (one-time migration)
|
|
99
|
+
await normalizeCanonicalUrls(indiekit, stateCollection);
|
|
100
|
+
|
|
98
101
|
// Backfill missing avatars from ap_notifications (one-time sweep per cycle)
|
|
99
102
|
await backfillMissingAvatars(indiekit, stateCollection);
|
|
100
103
|
|
|
101
104
|
// Backfill platform names for items stored as "activitypub" (one-time)
|
|
102
105
|
await backfillPlatformNames(indiekit, stateCollection);
|
|
106
|
+
|
|
107
|
+
// Backfill channel field for items predating its introduction (one-time)
|
|
108
|
+
await backfillChannelField(indiekit, stateCollection);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One-time migration: normalize trailing slashes in canonical_url.
|
|
113
|
+
* Strips trailing slash from all canonical_url values, then removes
|
|
114
|
+
* duplicates that were created by the slash inconsistency.
|
|
115
|
+
*/
|
|
116
|
+
async function normalizeCanonicalUrls(indiekit, stateCollection) {
|
|
117
|
+
try {
|
|
118
|
+
const state = await stateCollection.findOne({ _id: "poll_cursors" });
|
|
119
|
+
if (state?.canonical_url_normalized) return;
|
|
120
|
+
|
|
121
|
+
const itemsCollection = indiekit.collections.get("conversation_items");
|
|
122
|
+
if (!itemsCollection) return;
|
|
123
|
+
|
|
124
|
+
// Find all items with trailing slash in canonical_url
|
|
125
|
+
const itemsWithSlash = await itemsCollection
|
|
126
|
+
.find({ canonical_url: /\/$/ })
|
|
127
|
+
.toArray();
|
|
128
|
+
|
|
129
|
+
if (itemsWithSlash.length === 0) {
|
|
130
|
+
await stateCollection.findOneAndUpdate(
|
|
131
|
+
{ _id: "poll_cursors" },
|
|
132
|
+
{ $set: { canonical_url_normalized: true } },
|
|
133
|
+
{ upsert: true },
|
|
134
|
+
);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let updated = 0;
|
|
139
|
+
let deduped = 0;
|
|
140
|
+
|
|
141
|
+
for (const item of itemsWithSlash) {
|
|
142
|
+
const normalized = item.canonical_url.replace(/\/$/, "");
|
|
143
|
+
|
|
144
|
+
// Check if a non-slash version already exists with same platform_id
|
|
145
|
+
const existing = await itemsCollection.findOne({
|
|
146
|
+
canonical_url: normalized,
|
|
147
|
+
platform_id: item.platform_id,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (existing) {
|
|
151
|
+
// Duplicate — remove the trailing-slash version
|
|
152
|
+
await itemsCollection.deleteOne({ _id: item._id });
|
|
153
|
+
deduped++;
|
|
154
|
+
} else {
|
|
155
|
+
// No duplicate — just normalize the URL
|
|
156
|
+
await itemsCollection.updateOne(
|
|
157
|
+
{ _id: item._id },
|
|
158
|
+
{ $set: { canonical_url: normalized } },
|
|
159
|
+
);
|
|
160
|
+
updated++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (updated > 0 || deduped > 0) {
|
|
165
|
+
console.info(
|
|
166
|
+
`[Conversations] URL normalization: updated ${updated}, removed ${deduped} duplicates from ${itemsWithSlash.length} items with trailing slashes`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await stateCollection.findOneAndUpdate(
|
|
171
|
+
{ _id: "poll_cursors" },
|
|
172
|
+
{ $set: { canonical_url_normalized: true } },
|
|
173
|
+
{ upsert: true },
|
|
174
|
+
);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.warn(
|
|
177
|
+
"[Conversations] URL normalization error:",
|
|
178
|
+
error.message,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
103
181
|
}
|
|
104
182
|
|
|
105
183
|
/**
|
|
@@ -302,6 +380,84 @@ async function backfillPlatformNames(indiekit, stateCollection) {
|
|
|
302
380
|
}
|
|
303
381
|
}
|
|
304
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Backfill the `channel` field for existing items that predate its introduction.
|
|
385
|
+
* Derives channel from platform_id prefix and bridgy_url presence.
|
|
386
|
+
* One-time operation — marks complete after first successful run.
|
|
387
|
+
*/
|
|
388
|
+
async function backfillChannelField(indiekit, stateCollection) {
|
|
389
|
+
try {
|
|
390
|
+
const itemsCollection = indiekit.collections.get("conversation_items");
|
|
391
|
+
if (!itemsCollection) return;
|
|
392
|
+
|
|
393
|
+
const state = await stateCollection.findOne({ _id: "poll_cursors" });
|
|
394
|
+
if (state?.channel_backfill_complete) return;
|
|
395
|
+
|
|
396
|
+
// Find all items missing the channel field
|
|
397
|
+
const itemsWithoutChannel = await itemsCollection
|
|
398
|
+
.find({ channel: { $exists: false } })
|
|
399
|
+
.project({ _id: 1, platform_id: 1, bridgy_url: 1 })
|
|
400
|
+
.toArray();
|
|
401
|
+
|
|
402
|
+
if (itemsWithoutChannel.length === 0) {
|
|
403
|
+
await stateCollection.findOneAndUpdate(
|
|
404
|
+
{ _id: "poll_cursors" },
|
|
405
|
+
{ $set: { channel_backfill_complete: true } },
|
|
406
|
+
{ upsert: true },
|
|
407
|
+
);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Group items by derived channel for batched updateMany
|
|
412
|
+
const groups = new Map();
|
|
413
|
+
|
|
414
|
+
for (const item of itemsWithoutChannel) {
|
|
415
|
+
const pid = item.platform_id || "";
|
|
416
|
+
const hasBridgy = !!item.bridgy_url;
|
|
417
|
+
let channel;
|
|
418
|
+
|
|
419
|
+
if (pid.startsWith("activitypub:")) {
|
|
420
|
+
channel = "activitypub_inbox";
|
|
421
|
+
} else if (hasBridgy) {
|
|
422
|
+
channel = "webhook";
|
|
423
|
+
} else if (pid.startsWith("mastodon:")) {
|
|
424
|
+
channel = "mastodon_api";
|
|
425
|
+
} else if (pid.startsWith("bluesky:")) {
|
|
426
|
+
channel = "bluesky_api";
|
|
427
|
+
} else {
|
|
428
|
+
channel = "webhook";
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (!groups.has(channel)) groups.set(channel, []);
|
|
432
|
+
groups.get(channel).push(item._id);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
let updated = 0;
|
|
436
|
+
|
|
437
|
+
for (const [channel, ids] of groups) {
|
|
438
|
+
const result = await itemsCollection.updateMany(
|
|
439
|
+
{ _id: { $in: ids } },
|
|
440
|
+
{ $set: { channel } },
|
|
441
|
+
);
|
|
442
|
+
updated += result.modifiedCount;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (updated > 0) {
|
|
446
|
+
console.info(
|
|
447
|
+
`[Conversations] Channel backfill: set channel on ${updated} items`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
await stateCollection.findOneAndUpdate(
|
|
452
|
+
{ _id: "poll_cursors" },
|
|
453
|
+
{ $set: { channel_backfill_complete: true } },
|
|
454
|
+
{ upsert: true },
|
|
455
|
+
);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
console.warn("[Conversations] Channel backfill error:", error.message);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
305
461
|
/**
|
|
306
462
|
* Poll Mastodon notifications and store matching interactions
|
|
307
463
|
*/
|
|
@@ -356,6 +512,7 @@ async function pollMastodon(indiekit, stateCollection, state, credentials) {
|
|
|
356
512
|
await upsertConversationItem(indiekit, {
|
|
357
513
|
canonical_url: canonicalUrl,
|
|
358
514
|
source: "mastodon",
|
|
515
|
+
channel: "mastodon_api",
|
|
359
516
|
type: notification.type,
|
|
360
517
|
author: notification.author,
|
|
361
518
|
content: notification.content,
|
|
@@ -452,6 +609,7 @@ async function pollBluesky(indiekit, stateCollection, state, credentials) {
|
|
|
452
609
|
await upsertConversationItem(indiekit, {
|
|
453
610
|
canonical_url: canonicalUrl,
|
|
454
611
|
source: "bluesky",
|
|
612
|
+
channel: "bluesky_api",
|
|
455
613
|
type: notification.type,
|
|
456
614
|
author: notification.author,
|
|
457
615
|
content: notification.content,
|
|
@@ -569,6 +727,7 @@ async function pollActivityPub(indiekit, stateCollection, state) {
|
|
|
569
727
|
await upsertConversationItem(indiekit, {
|
|
570
728
|
canonical_url: interaction.canonical_url,
|
|
571
729
|
source: interaction.platform,
|
|
730
|
+
channel: "activitypub_inbox",
|
|
572
731
|
type: interaction.type,
|
|
573
732
|
author: interaction.author,
|
|
574
733
|
content: interaction.content,
|
|
@@ -22,6 +22,11 @@ function getCollection(application) {
|
|
|
22
22
|
export async function upsertConversationItem(application, item) {
|
|
23
23
|
const collection = getCollection(application);
|
|
24
24
|
|
|
25
|
+
// Normalize canonical_url — strip trailing slash for consistent deduplication
|
|
26
|
+
if (item.canonical_url) {
|
|
27
|
+
item.canonical_url = item.canonical_url.replace(/\/$/, "");
|
|
28
|
+
}
|
|
29
|
+
|
|
25
30
|
const result = await collection.findOneAndUpdate(
|
|
26
31
|
{
|
|
27
32
|
canonical_url: item.canonical_url,
|
|
@@ -159,4 +164,9 @@ export async function createIndexes(application) {
|
|
|
159
164
|
{ name: "source_filter" },
|
|
160
165
|
);
|
|
161
166
|
|
|
167
|
+
await collection.createIndex(
|
|
168
|
+
{ channel: 1, received_at: -1 },
|
|
169
|
+
{ name: "channel_filter" },
|
|
170
|
+
);
|
|
171
|
+
|
|
162
172
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-conversations",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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",
|
package/views/conversations.njk
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
</p>
|
|
38
38
|
{% endif %}
|
|
39
39
|
<p style="font-size: 0.85em; margin: 0.25rem 0">
|
|
40
|
-
{{
|
|
40
|
+
{{ channelCounts.mastodon_api or 0 }} {{ __("conversations.dashboard.itemsCollected") }}
|
|
41
41
|
</p>
|
|
42
42
|
{% else %}
|
|
43
43
|
<p style="font-size: 0.85em; color: #6b7280; margin: 0.25rem 0">
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
</p>
|
|
72
72
|
{% endif %}
|
|
73
73
|
<p style="font-size: 0.85em; margin: 0.25rem 0">
|
|
74
|
-
{{
|
|
74
|
+
{{ channelCounts.bluesky_api or 0 }} {{ __("conversations.dashboard.itemsCollected") }}
|
|
75
75
|
</p>
|
|
76
76
|
{% else %}
|
|
77
77
|
<p style="font-size: 0.85em; color: #6b7280; margin: 0.25rem 0">
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
</p>
|
|
106
106
|
{% endif %}
|
|
107
107
|
<p style="font-size: 0.85em; margin: 0.25rem 0">
|
|
108
|
-
{{
|
|
108
|
+
{{ channelCounts.activitypub_inbox or 0 }} {{ __("conversations.dashboard.itemsCollected") }}
|
|
109
109
|
</p>
|
|
110
110
|
{% else %}
|
|
111
111
|
<p style="font-size: 0.85em; color: #6b7280; margin: 0.25rem 0">
|
|
@@ -130,7 +130,7 @@
|
|
|
130
130
|
POST {{ baseUrl }}/ingest
|
|
131
131
|
</code>
|
|
132
132
|
<p style="font-size: 0.85em; margin: 0.25rem 0">
|
|
133
|
-
{{
|
|
133
|
+
{{ channelCounts.webhook or 0 }} {{ __("conversations.dashboard.itemsReceived") }}
|
|
134
134
|
</p>
|
|
135
135
|
</div>
|
|
136
136
|
</div>
|