@rmdes/indiekit-endpoint-activitypub 2.10.1 → 2.11.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
@@ -8,6 +8,7 @@ import {
8
8
  import {
9
9
  jf2ToActivityStreams,
10
10
  jf2ToAS2Activity,
11
+ parseMentions,
11
12
  } from "./lib/jf2-to-as2.js";
12
13
  import { dashboardController } from "./lib/controllers/dashboard.js";
13
14
  import {
@@ -467,6 +468,41 @@ export default class ActivityPubEndpoint {
467
468
  }
468
469
  }
469
470
 
471
+ // Resolve @user@domain mentions in content via WebFinger
472
+ const contentText = properties.content?.html || properties.content || "";
473
+ const mentionHandles = parseMentions(contentText);
474
+ const resolvedMentions = [];
475
+ const mentionRecipients = [];
476
+
477
+ for (const { handle } of mentionHandles) {
478
+ try {
479
+ const mentionedActor = await ctx.lookupObject(
480
+ new URL(`acct:${handle}`),
481
+ );
482
+ if (mentionedActor?.id) {
483
+ resolvedMentions.push({
484
+ handle,
485
+ actorUrl: mentionedActor.id.href,
486
+ profileUrl: mentionedActor.url?.href || null,
487
+ });
488
+ mentionRecipients.push({
489
+ handle,
490
+ actorUrl: mentionedActor.id.href,
491
+ actor: mentionedActor,
492
+ });
493
+ console.info(
494
+ `[ActivityPub] Resolved mention @${handle} → ${mentionedActor.id.href}`,
495
+ );
496
+ }
497
+ } catch (error) {
498
+ console.warn(
499
+ `[ActivityPub] Could not resolve mention @${handle}: ${error.message}`,
500
+ );
501
+ // Still add with no actorUrl so it gets a fallback link
502
+ resolvedMentions.push({ handle, actorUrl: null });
503
+ }
504
+ }
505
+
470
506
  const activity = jf2ToAS2Activity(
471
507
  properties,
472
508
  actorUrl,
@@ -475,6 +511,7 @@ export default class ActivityPubEndpoint {
475
511
  replyToActorUrl: replyToActor?.url,
476
512
  replyToActorHandle: replyToActor?.handle,
477
513
  visibility: self.options.defaultVisibility,
514
+ mentions: resolvedMentions,
478
515
  },
479
516
  );
480
517
 
@@ -529,12 +566,35 @@ export default class ActivityPubEndpoint {
529
566
  }
530
567
  }
531
568
 
569
+ // Deliver to mentioned actors' inboxes (skip reply-to author, already delivered above)
570
+ for (const { handle: mHandle, actorUrl: mUrl, actor: mActor } of mentionRecipients) {
571
+ if (replyToActor?.url === mUrl) continue;
572
+ try {
573
+ await ctx.sendActivity(
574
+ { identifier: handle },
575
+ mActor,
576
+ activity,
577
+ { orderingKey: properties.url },
578
+ );
579
+ console.info(
580
+ `[ActivityPub] Mention delivered to @${mHandle}: ${mUrl}`,
581
+ );
582
+ } catch (error) {
583
+ console.warn(
584
+ `[ActivityPub] Failed to deliver mention to @${mHandle}: ${error.message}`,
585
+ );
586
+ }
587
+ }
588
+
532
589
  // Determine activity type name
533
590
  const typeName =
534
591
  activity.constructor?.name || "Create";
535
592
  const replyNote = replyToActor
536
593
  ? ` (reply to ${replyToActor.url})`
537
594
  : "";
595
+ const mentionNote = mentionRecipients.length > 0
596
+ ? ` (mentions: ${mentionRecipients.map(m => `@${m.handle}`).join(", ")})`
597
+ : "";
538
598
 
539
599
  await logActivity(self._collections.ap_activities, {
540
600
  direction: "outbound",
@@ -542,7 +602,7 @@ export default class ActivityPubEndpoint {
542
602
  actorUrl: self._publicationUrl,
543
603
  objectUrl: properties.url,
544
604
  targetUrl: properties["in-reply-to"] || undefined,
545
- summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}`,
605
+ summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}${mentionNote}`,
546
606
  });
547
607
 
548
608
  console.info(
package/lib/jf2-to-as2.js CHANGED
@@ -36,6 +36,50 @@ function linkifyUrls(html) {
36
36
  );
37
37
  }
38
38
 
39
+ /**
40
+ * Parse @user@domain mention patterns from text content.
41
+ * Returns array of { handle: "user@domain", username: "user", domain: "domain.tld" }.
42
+ */
43
+ export function parseMentions(text) {
44
+ if (!text) return [];
45
+ // Strip HTML tags for parsing
46
+ const plain = text.replace(/<[^>]*>/g, " ");
47
+ const mentionRegex = /(?<![\/\w])@([\w.-]+)@([\w.-]+\.\w{2,})/g;
48
+ const mentions = [];
49
+ const seen = new Set();
50
+ let match;
51
+ while ((match = mentionRegex.exec(plain)) !== null) {
52
+ const handle = `${match[1]}@${match[2]}`;
53
+ if (!seen.has(handle.toLowerCase())) {
54
+ seen.add(handle.toLowerCase());
55
+ mentions.push({ handle, username: match[1], domain: match[2] });
56
+ }
57
+ }
58
+ return mentions;
59
+ }
60
+
61
+ /**
62
+ * Replace @user@domain patterns in HTML with linked mentions.
63
+ * resolvedMentions: [{ handle, actorUrl, profileUrl? }]
64
+ * Uses profileUrl (human-readable) for href, falls back to Mastodon-style URL.
65
+ */
66
+ function linkifyMentions(html, resolvedMentions) {
67
+ if (!html || !resolvedMentions?.length) return html;
68
+ for (const { handle, profileUrl } of resolvedMentions) {
69
+ // Escape handle for regex (dots, hyphens)
70
+ const escaped = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
71
+ // Match @handle not already inside an HTML tag attribute or anchor text
72
+ const pattern = new RegExp(`(?<!["\\/\\w])@${escaped}(?![\\w])`, "gi");
73
+ const parts = handle.split("@");
74
+ const url = profileUrl || `https://${parts[1]}/@${parts[0]}`;
75
+ html = html.replace(
76
+ pattern,
77
+ `<a href="${url}" class="mention" rel="nofollow noopener" target="_blank">@${handle}</a>`,
78
+ );
79
+ }
80
+ return html;
81
+ }
82
+
39
83
  // ---------------------------------------------------------------------------
40
84
  // Plain JSON-LD (content negotiation on individual post URLs)
41
85
  // ---------------------------------------------------------------------------
@@ -137,10 +181,27 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl, optio
137
181
  }
138
182
 
139
183
  const tags = buildPlainTags(properties, publicationUrl, object.tag);
184
+
185
+ // Add Mention tags + cc addressing + content linkification for @mentions
186
+ const resolvedMentions = options.mentions || [];
187
+ for (const { handle, actorUrl: mentionUrl } of resolvedMentions) {
188
+ if (mentionUrl) {
189
+ tags.push({ type: "Mention", href: mentionUrl, name: `@${handle}` });
190
+ if (!object.cc.includes(mentionUrl)) {
191
+ object.cc.push(mentionUrl);
192
+ }
193
+ }
194
+ }
195
+
140
196
  if (tags.length > 0) {
141
197
  object.tag = tags;
142
198
  }
143
199
 
200
+ // Linkify @mentions in content (resolved get actor links, unresolved get profile links)
201
+ if (resolvedMentions.length > 0 && object.content) {
202
+ object.content = linkifyMentions(object.content, resolvedMentions);
203
+ }
204
+
144
205
  return {
145
206
  "@context": "https://www.w3.org/ns/activitystreams",
146
207
  type: "Create",
@@ -292,7 +353,7 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options =
292
353
  noteOptions.attachments = fedifyAttachments;
293
354
  }
294
355
 
295
- // Tags: hashtags + Mention for reply addressing
356
+ // Tags: hashtags + Mention for reply addressing + @mentions
296
357
  const fedifyTags = buildFedifyTags(properties, publicationUrl, postType);
297
358
 
298
359
  if (replyToActorUrl) {
@@ -304,10 +365,46 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options =
304
365
  );
305
366
  }
306
367
 
368
+ // Add Mention tags + cc addressing for resolved @mentions
369
+ const resolvedMentions = options.mentions || [];
370
+ const ccUrls = [];
371
+ for (const { handle, actorUrl: mentionUrl } of resolvedMentions) {
372
+ if (mentionUrl) {
373
+ // Skip if same as replyToActorUrl (already added above)
374
+ const alreadyTagged = replyToActorUrl && mentionUrl === replyToActorUrl;
375
+ if (!alreadyTagged) {
376
+ fedifyTags.push(
377
+ new Mention({
378
+ href: new URL(mentionUrl),
379
+ name: `@${handle}`,
380
+ }),
381
+ );
382
+ }
383
+ ccUrls.push(new URL(mentionUrl));
384
+ }
385
+ }
386
+
387
+ // Merge mention actors into cc/ccs
388
+ if (ccUrls.length > 0) {
389
+ if (noteOptions.ccs) {
390
+ noteOptions.ccs = [...noteOptions.ccs, ...ccUrls];
391
+ } else if (noteOptions.cc) {
392
+ noteOptions.ccs = [noteOptions.cc, ...ccUrls];
393
+ delete noteOptions.cc;
394
+ } else {
395
+ noteOptions.ccs = ccUrls;
396
+ }
397
+ }
398
+
307
399
  if (fedifyTags.length > 0) {
308
400
  noteOptions.tags = fedifyTags;
309
401
  }
310
402
 
403
+ // Linkify @mentions in content
404
+ if (resolvedMentions.length > 0 && noteOptions.content) {
405
+ noteOptions.content = linkifyMentions(noteOptions.content, resolvedMentions);
406
+ }
407
+
311
408
  const object = isArticle
312
409
  ? new Article(noteOptions)
313
410
  : new Note(noteOptions);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.10.1",
3
+ "version": "2.11.1",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",