@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 +6 -1
- package/lib/controllers/conversations.js +6 -0
- package/lib/notifications/activitypub.js +110 -0
- package/lib/polling/scheduler.js +97 -0
- package/locales/en.json +4 -1
- package/package.json +3 -2
- package/views/conversations.njk +37 -1
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
|
-
|
|
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
|
+
}
|
package/lib/polling/scheduler.js
CHANGED
|
@@ -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.
|
|
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": {
|
package/views/conversations.njk
CHANGED
|
@@ -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 %}
|