source: Klonkt/src/services/ap-polls.js@ 984e0c6

main
Last change on this file since 984e0c6 was 984e0c6, checked in by Robin <roboburr@…>, 2 weeks ago

Opsplitsing stap 8 (shaer-drc): de peilingen naar ap-polls.js

Beide kanten van een fediverse-poll verhuizen als twee blokken -- 192
regels, byte-voor-byte. Vreemde polls: parsePoll en de twee
stemhandelingen (voteOnPoll, voteOnRemotePoll, die tot nu toe midden in
de blokkeersectie lagen). Eigen polls: parseOwnPoll, de telling uit de
stembiljetten, de Question-vorm op een note, het innemen van een biljet
en de gebundelde Update(Question).

parsePoll, applyPollToNote en recordPollBallot komen terug de dienst in
voor de inbox, buildNote en de backfill, maar blijven naar buiten toe
prive zoals ze waren. Vier werktuigen gaan via wirePolls naar binnen:
deliverUpdate, rid, movedRefusal en actorUriOf.

Uitvoeroppervlak voor en na identiek gemeten (199 named exports, 180
sleutels op het default-object). Volle suite 1226 groen.
ActivityPubService staat nu op 5207 regels.

  • Property mode set to 100644
File size: 12.5 KB
Line 
1/**
2 * ap-polls.js — de peilingen (stap 8 van shaer-drc).
3 *
4 * Beide kanten van een fediverse-poll:
5 * - VREEMDE polls: parsePoll (het AS2 Question-formaat naar onze compacte
6 * vorm) en de twee stemhandelingen (voteOnPoll uit de tijdlijncache,
7 * voteOnRemotePoll op URL).
8 * - EIGEN polls: de definitie op posts.poll_json, de telling uit de
9 * stembiljetten (poll_votes), de Question-vorm op een note, het innemen
10 * van een biljet en de gebundelde Update(Question) naar de volgers.
11 *
12 * Vier werktuigen uit de dienstlaag komen via wirePolls binnen; de rest wijst
13 * omlaag (db, ap-core, ap-transport).
14 */
15import db from '../config/database.js';
16import { actorId, AP_CONTEXT } from './ap-core.js';
17import { fetchActor, getOrCreateKeys, deliverWithRetry } from './ap-transport.js';
18
19// De werktuigen uit de dienstlaag; ActivityPubService vult ze onderaan.
20let deliverUpdate, rid, movedRefusal, actorUriOf;
21export function wirePolls(deps) {
22 ({ deliverUpdate, rid, movedRefusal, actorUriOf } = deps);
23}
24
25// Parse a fediverse poll (an ActivityStreams `Question` — the Mastodon-standard poll form)
26// into our compact shape. `oneOf` = single choice, `anyOf` = multiple; each option is a Note
27// with a `name` and a `replies` collection whose `totalItems` is that option's vote count.
28export function parsePoll(o) {
29 if (!o || o.type !== 'Question') return null;
30 const raw = Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null);
31 if (!raw || !raw.length) return null;
32 const options = raw.slice(0, 12).map((opt) => ({
33 name: String((opt && opt.name) || '').slice(0, 300),
34 count: Math.max(0, Number(opt && opt.replies && opt.replies.totalItems) || 0),
35 })).filter((x) => x.name);
36 if (!options.length) return null;
37 const endTime = o.endTime || (typeof o.closed === 'string' ? o.closed : null);
38 const closed = !!o.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
39 return { multiple: Array.isArray(o.anyOf), options, endTime, closed, voters: Number(o.votersCount) || null, voted: null };
40}
41
42// ── Polls WE host (a local post with a poll) ──────────────────────
43// Parse the poll definition stored on our own post (posts.poll_json). Counts are
44// NOT stored here — they're derived from the poll_votes ballots so a re-render always
45// reflects the authoritative tally.
46export function parseOwnPoll(pollJson) {
47 if (!pollJson) return null;
48 let d; try { d = typeof pollJson === 'string' ? JSON.parse(pollJson) : pollJson; } catch { return null; }
49 if (!d || !Array.isArray(d.options)) return null;
50 const options = d.options.map((o) => ({ name: String((o && o.name != null ? o.name : o) || '').slice(0, 300) })).filter((o) => o.name);
51 if (options.length < 2) return null;
52 const endTime = d.endTime || null;
53 const closed = !!d.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
54 return { multiple: !!d.multiple, options, endTime, closed };
55}
56
57// Live tally of a hosted poll from its ballots: per-option counts + unique voters.
58export function pollTally(postId) {
59 const counts = {}; let voters = 0;
60 try {
61 for (const r of db.prepare('SELECT choice, COUNT(*) AS n FROM poll_votes WHERE post_id = ? GROUP BY choice').all(postId)) counts[r.choice] = r.n;
62 voters = db.prepare('SELECT COUNT(DISTINCT actor_uri) AS n FROM poll_votes WHERE post_id = ?').get(postId).n || 0;
63 } catch { /* table may not exist yet */ }
64 return { counts, voters };
65}
66
67// Render-ready view of a hosted poll (options with counts + percentages, totals, state).
68// Voting is fediverse-only, so this is display-only on the site.
69export function ownPollView(post) {
70 const poll = parseOwnPoll(post && post.poll_json);
71 if (!poll) return null;
72 const { counts, voters } = pollTally(post.id);
73 const total = Object.values(counts).reduce((a, b) => a + b, 0);
74 const denom = poll.multiple ? voters : total; // multiple-choice %: share of voters (can sum >100%)
75 const options = poll.options.map((o) => {
76 const count = counts[o.name] || 0;
77 return { name: o.name, count, pct: denom ? Math.round((count / denom) * 100) : 0 };
78 });
79 return { multiple: poll.multiple, options, total, voters, endTime: poll.endTime, closed: poll.closed };
80}
81
82// Attach the AS2 Question shape to a note built for a hosted poll. Mastodon renders a
83// status with either media OR a poll (never both), so a poll federates as content +
84// options with no media attachment. oneOf = single choice, anyOf = multiple.
85export function applyPollToNote(note, postId, poll) {
86 const { counts, voters } = pollTally(postId);
87 const opts = poll.options.map((o) => ({
88 type: 'Note',
89 name: o.name,
90 replies: { type: 'Collection', totalItems: counts[o.name] || 0 },
91 }));
92 note.type = 'Question';
93 note[poll.multiple ? 'anyOf' : 'oneOf'] = opts;
94 if (poll.endTime) note.endTime = new Date(poll.endTime).toISOString();
95 // Once closed, Mastodon expects a `closed` timestamp (the effective end).
96 if (poll.closed) note.closed = poll.endTime ? new Date(poll.endTime).toISOString() : new Date().toISOString();
97 note.votersCount = voters;
98 delete note.attachment; // media ATTACHMENTS + a poll are mutually exclusive on Mastodon
99 // Keep note.image: it's the cover, which Mastodon ignores on a Question anyway
100 // (same as on any Note) but Klonkt reads to show the cover in feeds/the Cirkel.
101 // Deleting it stripped the cover off every boosted poll.
102 return note;
103}
104
105// Record an inbound ballot on one of OUR polls. A vote arrives as a Create(Note) whose
106// `name` is the chosen option and `inReplyTo` is our poll note — the Mastodon-standard
107// vote form. Returns { handled } — handled=true means it was addressed to a poll (so the
108// caller must NOT also store it as a reply), false means "not a poll, fall through".
109export function recordPollBallot(postId, actorUri, rawChoice) {
110 const choice = String(rawChoice == null ? '' : rawChoice).slice(0, 300);
111 if (!choice) return { handled: false };
112 let post; try { post = db.prepare('SELECT poll_json FROM posts WHERE id = ?').get(postId); } catch { return { handled: false }; }
113 const poll = post && parseOwnPoll(post.poll_json);
114 if (!poll) return { handled: false }; // not a poll → let the reply logic handle it
115 if (poll.closed) return { handled: true }; // voting closed → drop
116 if (!poll.options.some((o) => o.name === choice)) return { handled: true }; // unknown option → drop
117 try {
118 // Single choice = one ballot per actor: ignore a later/different vote. Multiple choice
119 // allows one ballot per distinct option (the UNIQUE(post,actor,choice) dedupes repeats).
120 if (!poll.multiple && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? AND actor_uri = ? LIMIT 1').get(postId, actorUri)) return { handled: true };
121 db.prepare('INSERT OR IGNORE INTO poll_votes (post_id, actor_uri, choice) VALUES (?, ?, ?)').run(postId, actorUri, choice);
122 } catch { return { handled: true }; }
123 schedulePollUpdate(postId);
124 return { handled: true };
125}
126
127// Coalesce a burst of votes into ONE Update(Question) per poll: the first vote schedules a
128// refresh ~15s out; further votes in that window ride the same pending update (which carries
129// the accumulated tally). Non-follower voters re-fetch the Question (live tally) themselves.
130const _pollUpdTimers = new Map();
131function schedulePollUpdate(postId) {
132 if (_pollUpdTimers.has(postId)) return;
133 const t = setTimeout(() => { _pollUpdTimers.delete(postId); deliverPollUpdate(postId).catch(() => { /* best-effort */ }); }, 15000);
134 if (t.unref) t.unref();
135 _pollUpdTimers.set(postId, t);
136}
137
138// Push the fresh poll tally (or closed state) to followers as Update(Question).
139export async function deliverPollUpdate(postId) {
140 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
141 if (!base || !postId) return;
142 let post, site;
143 try {
144 post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
145 if (!post || !post.poll_json) return;
146 site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
147 } catch { return; }
148 if (site) await deliverUpdate(site, post);
149}
150
151// Vote on a remote fediverse poll (a cached Question). A ballot = a Create(Note) carrying only a
152// `name` (the chosen option) + inReplyTo the Question, addressed to the poll's author — the
153// Mastodon-standard vote. Records our choice locally + optimistically bumps the counts; the
154// author's Update(Question) refreshes the authoritative totals when it arrives.
155export async function voteOnPoll(site, questionId, choices) {
156 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
157 if (!base || !site || !site.slug || !questionId) return { error: 'config' };
158 let row; try { row = db.prepare('SELECT author_uri, poll_json FROM ap_timeline WHERE id = ? AND slug = ? LIMIT 1').get(questionId, site.slug); } catch { /* ignore */ }
159 if (!row || !row.poll_json) return { error: 'not_found' };
160 let poll; try { poll = JSON.parse(row.poll_json); } catch { return { error: 'not_found' }; }
161 if (poll.closed) return { error: 'closed' };
162 if (poll.voted) return { error: 'already' };
163 const valid = new Set(poll.options.map((o) => o.name));
164 const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
165 if (!picks.length) return { error: 'invalid' };
166 const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
167 const me = actorId(base, site.slug);
168 const keys = getOrCreateKeys(site.slug);
169 const authorUri = row.author_uri || null;
170 const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
171 const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
172 if (!inbox) return { error: 'unreachable' };
173 for (const name of chosen) {
174 const nid = `${me}/votes/${Date.now()}-${rid()}`;
175 const note = { id: nid, type: 'Note', attributedTo: me, to: authorUri ? [authorUri] : [], name, inReplyTo: questionId, published: new Date().toISOString() };
176 const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
177 deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
178 }
179 // Local optimistic update (authoritative counts arrive via the author's Update(Question)).
180 poll.voted = poll.multiple ? chosen : chosen[0];
181 for (const o of poll.options) if (chosen.includes(o.name)) o.count = (o.count || 0) + 1;
182 if (poll.voters != null) poll.voters += 1;
183 try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), questionId, site.slug); } catch { /* ignore */ }
184 return { ok: true };
185}
186
187// Vote on ANY fediverse poll by URL (the interact page) — no timeline cache needed. Fetches
188// the Question fresh, validates the choice(s), and casts the Mastodon-standard ballot (a
189// Create(Note) with `name` + inReplyTo) straight to the poll's author. Used for polls you find
190// by URL, not just ones from accounts you follow (which go through voteOnPoll via /news).
191export async function voteOnRemotePoll(site, questionUrl, choices) {
192 const _mv = movedRefusal(site, 'poll-vote'); if (_mv) return _mv;
193 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
194 if (!base || !site || !site.slug || !/^https?:\/\//i.test(String(questionUrl || ''))) return { error: 'config' };
195 const q = await fetchActor(questionUrl).catch(() => null); // AP GET (SSRF-guarded)
196 if (!q || q.type !== 'Question' || !q.id) return { error: 'not_found' };
197 const poll = parsePoll(q);
198 if (!poll) return { error: 'not_found' };
199 if (poll.closed) return { error: 'closed' };
200 const valid = new Set(poll.options.map((o) => o.name));
201 const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
202 if (!picks.length) return { error: 'invalid' };
203 const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
204 const authorUri = actorUriOf(q.attributedTo);
205 const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
206 const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
207 if (!inbox) return { error: 'unreachable' };
208 const me = actorId(base, site.slug);
209 const keys = getOrCreateKeys(site.slug);
210 for (const name of chosen) {
211 const nid = `${me}/votes/${Date.now()}-${rid()}`;
212 const note = { id: nid, type: 'Note', attributedTo: me, to: [authorUri], name, inReplyTo: q.id, published: new Date().toISOString() };
213 const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
214 deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
215 }
216 return { ok: true };
217}
Note: See TracBrowser for help on using the repository browser.