Ignore:
Timestamp:
06/24/2026 12:09:08 PM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
3a2c9d3
Parents:
47a0d29
Message:

feat(activitypub): reply back to the fediverse (Phase 4 outbound)

Site owner can reply to an inbound fediverse interaction from the post page. The
reply is sent as a signed Create(Note) with inReplyTo + @Mention to the remote
actor's inbox + our followers, stored in ap_outbox, shown in the thread, and
resolvable at /ap/notes/<id>.

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r47a0d29 r55bc7f9  
    191191export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
    192192
    193 // ── inbound interactions store (replies / likes / boosts), lazy stmts ──
    194 let _insI, _delLA, _delReply, _listI;
     193// ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
     194let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
    195195function iStmts() {
    196196  if (!_insI) {
     
    198198    _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
    199199    _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
    200     _listI = db.prepare('SELECT kind, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, created_at FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
    201   }
    202   return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI };
    203 }
     200    _listI = db.prepare('SELECT id, kind, object_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, created_at FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
     201    _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
     202    _insO = db.prepare('INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, created_at) VALUES (?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
     203    _listO = db.prepare('SELECT * FROM ap_outbox WHERE post_id = ? ORDER BY created_at ASC');
     204    _getO = db.prepare('SELECT * FROM ap_outbox WHERE id = ?');
     205  }
     206  return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI, getI: _getI, insO: _insO, listO: _listO, getO: _getO };
     207}
     208
     209export function getInteractionById(id) { return iStmts().getI.get(id); }
    204210
    205211const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
     
    226232}
    227233
    228 // Stored, view-ready summary of a post's inbound fediverse activity.
     234// Stored, view-ready summary of a post's inbound fediverse activity + our replies.
    229235export function getInteractions(postId) {
    230   const rows = iStmts().list.all(postId);
     236  const s = iStmts();
     237  const rows = s.list.all(postId);
     238  const outReplies = s.listO.all(postId).map((o) => ({
     239    id: o.id, content: o.content, in_reply_to: o.in_reply_to, to_handle: o.to_handle,
     240    created_at: o.created_at, mine: true,
     241  }));
    231242  return {
    232243    replies: rows.filter((r) => r.kind === 'reply'),
     244    outReplies,
    233245    likeCount: rows.filter((r) => r.kind === 'like').length,
    234246    announceCount: rows.filter((r) => r.kind === 'announce').length,
    235     total: rows.length,
     247    total: rows.length + outReplies.length,
    236248  };
    237249}
     
    399411}
    400412
     413// ── outbound replies (Klonkt → fediverse) ─────────────────────────
     414const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
     415const toISO = (v) => { if (!v) return new Date().toISOString(); const s = String(v); const d = new Date(/[TZ]/.test(s) ? s : s.replace(' ', 'T') + 'Z'); return isNaN(d) ? new Date().toISOString() : d.toISOString(); };
     416
     417// Build one of OUR outbound reply Notes from an ap_outbox row.
     418export function buildReplyNote(base, site, row) {
     419  const me = actorId(base, site.slug);
     420  return {
     421    id: noteId(base, row.id),
     422    type: 'Note',
     423    attributedTo: me,
     424    inReplyTo: row.in_reply_to || undefined,
     425    content: row.content,
     426    url: row.post_slug ? `${base}/${encodeURIComponent(row.post_slug)}` : undefined,
     427    published: toISO(row.created_at),
     428    to: row.to_actor ? [row.to_actor] : [PUBLIC],
     429    cc: [PUBLIC, `${me}/followers`],
     430    tag: row.to_actor ? [{ type: 'Mention', href: row.to_actor, name: row.to_handle }] : [],
     431  };
     432}
     433
     434// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
     435export function getOutboxNote(base, id) {
     436  const row = iStmts().getO.get(id);
     437  if (!row) return null;
     438  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
     439  if (!site) return null;
     440  return buildReplyNote(base, site, row);
     441}
     442
     443// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
     444// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
     445export async function deliverReply(site, { postId, postSlug, parent, text }) {
     446  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     447  if (!base || !site || !site.slug || !parent || !String(text || '').trim()) return null;
     448  const me = actorId(base, site.slug);
     449  const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
     450  const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
     451  const mention = parent.actor_uri
     452    ? `<a href="${escHtml(parent.actor_url || parent.actor_uri)}" class="u-url mention">${escHtml(handle)}</a> ` : '';
     453  const content = `<p>${mention}${body}</p>`;
     454  const id = crypto.randomUUID();
     455  iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content);
     456  const row = iStmts().getO.get(id);
     457  const note = buildReplyNote(base, site, row);
     458  const create = {
     459    '@context': 'https://www.w3.org/ns/activitystreams',
     460    id: note.id + '#create', type: 'Create', actor: me,
     461    published: note.published, to: note.to, cc: note.cc, object: note,
     462  };
     463  const keys = getOrCreateKeys(site.slug);
     464  const keyId = `${me}#main-key`;
     465  const inboxes = new Set();
     466  if (parent.actor_uri) {
     467    const a = await fetchActor(parent.actor_uri).catch(() => null);
     468    if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
     469  }
     470  for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
     471  let delivered = 0;
     472  for (const inbox of [...inboxes].filter(Boolean)) {
     473    try { const st = await deliver(inbox, create, keyId, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
     474  }
     475  console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
     476  return { id, content, delivered };
     477}
     478
    401479export default {
    402480  getOrCreateKeys, apWants, sendAP, actorId, noteId,
    403481  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers,
    404482  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete,
    405   getInteractions,
     483  getInteractions, getInteractionById, buildReplyNote, getOutboxNote, deliverReply,
    406484};
Note: See TracChangeset for help on using the changeset viewer.