source: Klonkt/src/services/guardianship/delivery.js@ 189e335

main
Last change on this file since 189e335 was 189e335, checked in by Claude (agent) <aiclaude@…>, 5 weeks ago

Antwoorden vanuit een gesprek, rijk, met de zwaai als eigen knop

Berichten kon je gesprekken tonen maar er niet in antwoorden. Het invoerveld dat
er stond was msg-quickreply: platte tekst, 200 tekens, en het verstuurde geen
antwoord maar een zwaai (deliverDirectNote met wave:true, een FEP-633c-seintje).
Dat is iets anders dat toevallig op een reply leek.

Onder elke draad staat nu de gedeelde reply-editor, ingeklapt achter een
"Reageer" -- open onder elk gesprek maakt de lijst weer onleesbaar, precies wat
deze weergave moest oplossen. De zwaai blijft ernaast als eigen knop, want een
seintje en een antwoord horen niet op een hoop.

/messages/reply kiest het pad op wat de draad zelf meedraagt (replyTo):

  • draad aan een post -> deliverReply op het nieuwste ontvangen bericht erin, met een controle dat die parent ook echt bij die post hoort; anders kon een aangepast formulier een antwoord onder andermans draad hangen.
  • draad aan een persoon -> een direct bericht terug.

Voor dat tweede pad kon deliverDirectNote nog geen rijke inhoud: het escapete
platte tekst. Het accepteert nu html, door dezelfde sanitizer als deliverReply,
zodat een antwoord uit Berichten via één poort gaat. De mention-anker blijft een
eigen alinea vooraan, want de ontvanger moet genoemd staan ook als de inhoud met
een kop of lijst begint. Levert de sanitizer niets bruikbaars op, dan valt het
terug op de escaped tekst -- een leeggepoetste editor mag geen leeg bericht
versturen.

Meldingen in nl/en/de erbij, en getNotifications geeft nu het interactie-id en
de actor-uri mee, want zonder die twee weet een antwoord niet waar het heen moet.

Geverifieerd op dev: beide draden daar adresseren hun eigen tegenpartij, het
formulier draagt de juiste verborgen velden en de zwaai staat er los naast.
Suite 412/412.

Co-Authored-By: Claude Opus 5 <noreply@…>

  • Property mode set to 100644
File size: 8.3 KB
Line 
1/**
2 * Guardianship (FEP-633c) — the direct-note delivery leg.
3 *
4 * A direct note (private mention, shaer-tqc) is the ward's call-for-help
5 * carrier: addressed to specific actors only, no Public, no followers
6 * fan-out. Moved here from ActivityPubService (guardianship refactor);
7 * behavior is unchanged.
8 *
9 * This module has NO import back into ActivityPubService: the AP helpers it
10 * needs (actor fetch, key material, delivery, note building) are provided
11 * once via wireDelivery(deps) at ActivityPubService load time.
12 */
13import crypto from 'crypto';
14import db from '../../config/database.js';
15import { carriesGuardians } from './context.js';
16
17const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
18
19let deps = null;
20/** Called once by ActivityPubService with the shared AP helpers. */
21export function wireDelivery(d) { deps = d; }
22
23// Addressing → visibility. Arrays or bare strings; unknown shapes read as the
24// safest bucket they match.
25export function c2sVisibility(object) {
26 const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
27 const to = arr(object.to), cc = arr(object.cc);
28 const isPublic = (x) => x === PUBLIC || x === 'as:Public' || x === 'Public';
29 const isFollowers = (x) => /\/followers\/?$/.test(x);
30 if (to.some(isPublic)) return 'public';
31 if (cc.some(isPublic)) return 'quiet';
32 if (to.some(isFollowers) || cc.some(isFollowers)) return 'friends';
33 if (!to.length && !cc.length) return 'public'; // no addressing at all: legacy client, keep old behavior
34 return 'direct';
35}
36
37// A direct note: a NEW conversation (or a direct reply) addressed to specific
38// actors only. Stored in ap_outbox with visibility 'direct' + the recipient
39// list, delivered to exactly those inboxes: no followers fan-out, no Public,
40// so no boosts and no timelines. The same S2S leg a Mastodon DM takes, so a
41// guardian on any instance receives it as a private mention (the ward
42// call-for-help path).
43export async function deliverDirectNote(site, { recipients, text, html, language, inReplyTo, attachments, helpRequest, wave, awayUntil }) {
44 const { actorId, fetchActor, localActor, deliverTo, deriveHandle, escHtml, linkUrls, linkHashtags,
45 getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
46 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
47 const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8);
48 if (!base || !site || !site.slug || !list.length || !String(text || '').trim()) return null;
49 const me = actorId(base, site.slug);
50 // Resolve every recipient for a mention anchor + a delivery inbox.
51 const resolved = [];
52 const teapots = [];
53 for (const uri of list) {
54 // An actor we host is read from our own database, not fetched from our own
55 // hostname: that request has to leave the machine and come back, and when
56 // it does not, the recipient is silently dropped from the note. Everything
57 // that decides anything still runs below, for local and remote alike.
58 const a = (localActor && localActor(uri)) || await fetchActor(uri).catch(() => null);
59 if (!a || !(a.inbox || (a.endpoints && a.endpoints.sharedInbox))) continue;
60 // FEP-633c §4.1: an escalation addressed to a "guardian" that carries
61 // guardians of its own goes nowhere. There is no grand-guardian, so we
62 // MUST NOT recurse to that actor's guardians — and we fail SOFTLY: drop
63 // this one target and keep delivering to the rest, because a malformed
64 // guardian must never cost a child the guardians who are fine.
65 //
66 // Only for a call for help. An ordinary direct note is not an escalation,
67 // and a ward is perfectly entitled to message another ward.
68 if (helpRequest && carriesGuardians(a)) { teapots.push(uri); continue; }
69 resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, local: !!a.local, handle: deriveHandle(uri), url: a.url || uri });
70 }
71 if (teapots.length) console.warn('[AP] not a teapot: escalation dropped for malformed guardian(s)', teapots.join(', '));
72 if (!resolved.length) {
73 // Every guardian was malformed. §4 does not say what to do here because
74 // §4.1 assumes there are others to continue to — but a ward whose whole
75 // safety net is broken has just called for help into nothing, which is the
76 // one outcome this FEP exists to prevent. Say so loudly; the caller can
77 // tell "nobody was reachable" from "nobody was valid".
78 if (teapots.length) console.error('[AP] EVERY guardian of', site.slug, 'is malformed: the call for help reached no one');
79 return null;
80 }
81 const mention = resolved.map((r) => {
82 const disp = r.handle && r.handle[0] === '@' ? r.handle : '@' + (r.handle || '');
83 return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
84 }).join('');
85 // Rijk antwoord: `html` is de HTML uit de reply-editor, hier gesaneerd; `text`
86 // blijft de platte versie (het `source`-veld en de no-JS-fallback). Levert de
87 // sanitizer niets bruikbaars op, dan valt hij terug op de escaped tekst --
88 // een leeggepoetste editor mag geen leeg bericht versturen.
89 const richClean = html ? deps.sanitizeHtml(String(html)) : '';
90 const rich = richClean && deps.htmlToPlainText(richClean).trim() ? richClean : '';
91 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
92 // De mention-anker blijft een eigen alinea vooraan: de ontvanger moet in het
93 // bericht genoemd staan, ook als de rijke inhoud met een kop of lijst begint.
94 const content = rich
95 ? `<p>${mention}</p>${linkUrls(linkHashtags(base, rich))}`
96 : `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
97 const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
98 // Attachments: same rules as deliverReply (own /media/ uploads only,
99 // image/audio/video, max 4) — the help-buoy capture rides this.
100 const media = (Array.isArray(attachments) ? attachments : [])
101 .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
102 && /^(image|audio|video)\//.test(String(a.mediaType || '')))
103 .slice(0, 4)
104 .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
105 const id = crypto.randomUUID();
106 db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, wave, away_until, created_at)
107 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
108 .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0, wave ? 1 : 0, awayUntil || null);
109 const row = getOutboxRow(id);
110 const note = buildReplyNote(base, site, row);
111 const create = {
112 '@context': AP_CONTEXT,
113 id: note.id + '#create', type: 'Create', actor: me,
114 published: note.published, to: note.to, cc: note.cc, object: note,
115 };
116 const keys = getOrCreateKeys(site.slug);
117 const keyId = `${me}#main-key`;
118 let delivered = 0;
119 // A recipient on this machine takes the loopback (deliverToActor), which
120 // hands the Create to the same inbox handler an HTTP POST would reach: the
121 // note is stored, the mention is stored, and a shaer:away on it is applied,
122 // all by the code that does it for everyone else. A hairpin POST to our own
123 // hostname is not that code path, it is a second one that only appears to be.
124 for (const r of resolved.filter((x) => x.local)) {
125 const res = await deliverTo(site, r.uri, create).catch(() => null);
126 if (res && res.delivered) delivered++;
127 }
128 // Remote: one POST per inbox, so two guardians on the same server share it.
129 for (const inbox of [...new Set(resolved.filter((x) => !x.local).map((r) => r.inbox))]) {
130 let ok = false;
131 try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
132 if (ok) delivered++;
133 else enqueueDelivery(site.slug, inbox, create);
134 }
135 console.log('[AP] direct note', site.slug, '→', resolved.length, 'recipient(s), delivered', delivered);
136 return { id, content, delivered, teapots };
137}
138
139export default { wireDelivery, c2sVisibility, deliverDirectNote };
Note: See TracBrowser for help on using the repository browser.