1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
|
// Moderation: the mute list (people, words, hashtags, threads) applies to
// the feed and to thread replies; a post reported by someone you follow is
// folded with a Show; Report… from the ⋯ menu publishes a kind 1984 and
// hides the post; repeated posts and hashtag-stuffed posts from strangers
// are hidden; Settings → Nostr lays the mute list out and edits it.
// No relay is written for real — the WebSocket shim swallows EVENT.
// Run: bun tools/moderation-test.js
import puppeteer from 'puppeteer-core';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
import { buildHtml } from '../build.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
const key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; };
const AUTHOR = key(), MUTED = key(), REPORTED = key(), OTHER = key(), SPAMMER = key(), TAGGER = key(), FLAGGED = key();
const now = Math.floor(Date.now() / 1000);
const post = (who, content, ago, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, who.sk);
const ROOT = post(AUTHOR, 'a post to open, about #bitcoin and other things', 300);
const THREADMUTED = post(AUTHOR, 'another post, in a thread I muted', 400);
const seeded = [
ROOT,
THREADMUTED,
post(MUTED, 'muted person post', 500),
post(AUTHOR, 'my friend loves lasagna apparently', 600),
post(AUTHOR, 'tagged post', 700, [['t', 'nsfw']]),
post(REPORTED, 'reported post here, flagged by my friend', 800),
post(FLAGGED, 'a fine post by someone one friend mis-flagged', 900),
];
const reply = (who, text, ago) => post(who, text, ago, [['e', ROOT.id, '', 'root'], ['p', AUTHOR.pk]]);
const REPLIES = [reply(OTHER, 'reply from ok person', 200), reply(MUTED, 'reply from muted person', 150)];
const LONG = 'buy the dip now, best exchange ever, sign up with my link and get free sats today!!!';
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 5299, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.evaluateOnNewDocument((fx) => {
window.__reqs = [];
window.__published = [];
const subs = [];
const Real = window.WebSocket;
const answer = (ws, id, evs) => setTimeout(() => {
for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) }));
ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', id]) }));
}, 0);
window.WebSocket = function (...a) {
const ws = new Real(...a);
const send = ws.send.bind(ws);
ws.send = (data) => {
try {
const m = JSON.parse(data);
if (m[0] === 'REQ' && m[2]) {
const f = m[2];
window.__reqs.push(f);
const kinds = f.kinds || [];
if (kinds.includes(10000) && window.__mutes && (f.authors || []).includes(window.__mutes.pubkey)) answer(ws, m[1], [window.__mutes]);
else if (kinds.includes(1984) && (f.authors || []).includes(fx.author)) answer(ws, m[1], fx.reports);
else if (kinds.includes(1) && f['#e'] && f['#e'].includes(fx.root)) answer(ws, m[1], fx.replies);
else if (kinds.includes(1)) { subs.push([ws, m[1], f]); answer(ws, m[1], []); return; } // no real posts: the feeds hold only what the test injects
}
if (m[0] === 'EVENT' && m[1]) {
window.__published.push(m[1]);
setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0);
return;
}
} catch {}
return send(data);
};
return ws;
};
window.WebSocket.prototype = Real.prototype;
Object.assign(window.WebSocket, Real);
window.__inject = (ev) => {
let n = 0;
for (const [ws, id, f] of subs) {
if (ws.readyState !== 1) continue;
if (f.authors && !f.authors.includes(ev.pubkey)) continue;
if (f['#t'] && !ev.tags.some((t) => t[0] === 't' && f['#t'].includes(t[1]))) continue;
ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) }));
n++;
}
return n;
};
}, { author: AUTHOR.pk, root: ROOT.id, replies: REPLIES, reports: [
finalizeEvent({ kind: 1984, created_at: now - 100, tags: [['e', seeded[5].id, 'spam'], ['p', REPORTED.pk]], content: '' }, AUTHOR.sk),
// one friend flagging a PERSON is not enough to fold everything they write
finalizeEvent({ kind: 1984, created_at: now - 90, tags: [['p', FLAGGED.pk, 'impersonation']], content: '' }, AUTHOR.sk),
] });
const errs = [];
page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
const text = () => page.evaluate(() => document.body.innerText);
const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
const waitFor = async (fn, ms = 10000) => { for (let i = 0; i < ms / 250; i++) { if (await page.evaluate(fn)) return true; await sleep(250); } return false; };
const has = async (x) => (await text()).includes(x);
const openFeed = async () => {
await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); });
await sleep(800);
await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => (n.querySelector('.chat-name') || {}).textContent === 'Feed'); if (e) e.click(); });
await sleep(1200);
};
try {
await page.setViewport({ width: 390, height: 844 });
await page.goto('http://localhost:5299/', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
await page.waitForSelector('.words .w .t');
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
await click('skip verification');
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
const ME_SK = privateKeyFromSeedWords(mn);
const ME = getPublicKey(ME_SK);
// the mute list as another client left it: a person, a word, a hashtag, a thread
const MUTES = finalizeEvent({ kind: 10000, created_at: now - 50, tags: [['p', MUTED.pk], ['word', 'Lasagna'], ['t', 'NSFW'], ['e', THREADMUTED.id]], content: '' }, ME_SK);
await page.evaluateOnNewDocument((m) => { window.__mutes = m; }, MUTES);
await page.evaluate(([k, pk, ns]) => {
localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
}, [base, AUTHOR.pk, seeded]);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await openFeed();
console.log('\n[the mute list, every kind of entry, on the feed]');
check('the relays are asked for my mute list', await waitFor(() => window.__reqs.some((f) => (f.kinds || []).includes(10000))));
check('a friend\'s post shows', await waitText('a post to open', 8000));
check('...a muted person\'s does not', await waitFor(() => !document.body.innerText.includes('muted person post'), 8000));
check('...nor one with a hidden word', !(await has('lasagna')));
check('...nor one on a muted hashtag', !(await has('tagged post')));
check('...nor one in a muted thread', !(await has('another post, in a thread')));
console.log('\n[a post my friend reported is folded]');
check('the relays are asked for my follows\' reports', await waitFor(() => window.__reqs.some((f) => (f.kinds || []).includes(1984)), 15000));
check('the flagged post is folded, naming who', await waitText('reported by', 12000) && !(await has('reported post here')), (await text()).match(/Reported by[^\n]*/)?.[0]);
await page.evaluate(() => { const b = [...document.querySelectorAll('.note-folded button')].find((x) => /show/i.test(x.textContent)); b.click(); }); await sleep(400);
check('Show unfolds it', await has('reported post here'));
check('a person flagged by only one friend is not folded', await has('a fine post by someone one friend mis-flagged') && !(await page.$('.note-folded')));
console.log('\n[thread replies go through the same door; Report… hides and publishes]');
await page.evaluate(() => { const r = [...document.querySelectorAll('[data-zap-post]')].find((n) => /a post to open/.test(n.textContent)); r.click(); }); await sleep(1500);
check('an ordinary reply shows in the thread', await waitText('reply from ok person', 8000));
check('...a muted person\'s reply does not', !(await has('reply from muted person')));
await page.evaluate(() => { const r = [...document.querySelectorAll('[data-zap-post]')].find((n) => /reply from ok person/.test(n.textContent)); [...r.querySelectorAll('button')].find((b) => b.getAttribute('aria-label') === 'More').click(); }); await sleep(300);
check('the ⋯ menu offers Report and Mute this thread', await has('Report…') && await has('Mute this thread'));
await page.evaluate(() => { window.__published = []; [...document.querySelectorAll('.confirm-pop button')].find((b) => /report/i.test(b.textContent)).click(); }); await sleep(300);
check('a reason sheet opens', await page.evaluate(() => !!document.querySelector('.report-pick')));
await page.evaluate(() => document.querySelector('.report-reason[data-reason="spam"]').click()); await sleep(1200);
const rep = (await page.evaluate(() => window.__published)).find((e) => e.kind === 1984);
check('a kind 1984 goes out: the reply, as spam, its author credited', !!rep && rep.pubkey === ME && rep.tags.some((x) => x[0] === 'e' && x[1] === REPLIES[0].id && x[2] === 'spam') && rep.tags.some((x) => x[0] === 'p' && x[1] === OTHER.pk), JSON.stringify(rep && rep.tags));
check('...and the reply is hidden for me', !(await has('reply from ok person')));
await page.evaluate(() => { const b = [...document.querySelectorAll('button')].filter((x) => /back/i.test(x.textContent)).pop(); b.click(); }); await sleep(800);
console.log('\n[strangers: repeated posts and hashtag stuffing]');
await page.evaluate(() => { const a = [...document.querySelectorAll('.notes-feed a')].find((x) => x.textContent === '#bitcoin'); a.click(); }); await sleep(1500);
check('a #bitcoin topic feed is open', await page.evaluate(() => [...document.querySelectorAll('.feed-chip.on')].some((c) => c.textContent.trim() === '#bitcoin')));
const dupes = [];
for (let i = 0; i < 6; i++) dupes.push(post(SPAMMER, LONG, 60 - i, [['t', 'bitcoin']]));
let n = 0;
for (const ev of dupes) { n += await page.evaluate((e) => window.__inject(e), ev); await sleep(150); }
const control = post(OTHER, 'a fine post from a stranger on #bitcoin', 10, [['t', 'bitcoin']]);
await page.evaluate((e) => window.__inject(e), control);
const stuffed = post(TAGGER, 'airdrop ' + Array.from({ length: 14 }, (_, i) => '#tag' + i).join(' '), 5, [['t', 'bitcoin'], ...Array.from({ length: 14 }, (_, i) => ['t', 'tag' + i])]);
await page.evaluate((e) => window.__inject(e), stuffed);
await sleep(3500);
const landed = n > 0 && await waitText('a fine post from a stranger', 6000);
if (!landed && process.env.DEBUG) console.log('DEBUG feed:', await page.evaluate(() => ({ rows: document.querySelectorAll('.notes-feed > .row').length, booting: !!document.querySelector('.notes-feed[data-booting]'), spacers: document.querySelectorAll('.feed-spacer').length, text: (document.querySelector('.chat-page') || document.body).innerText.slice(0, 400).replace(/\n+/g, ' | ') })));
check('a stranger\'s ordinary post lands', landed, n + ' sub(s)');
check('six copies of the same pitch do not: the spammer is hidden', !(await has('buy the dip now')));
check('...nor does a post stuffed with hashtags', !(await has('airdrop')));
console.log('\n[Settings → Nostr: the mute list, laid out and editable]');
await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings'); b.click(); }); await sleep(600);
await page.evaluate(() => { const b = [...document.querySelectorAll('.settings-tile')].find((e) => [...e.querySelectorAll('span')].some((x) => x.textContent.trim() === 'Nostr')); if (b) b.click(); }); await sleep(800);
check('the card lists the muted person and the hidden word and hashtag', await waitText('muted and hidden', 5000) && await page.evaluate(() => !!document.querySelector('.mod-person')) && await has('lasagna') && await has('#nsfw') && await has('1 muted thread'));
await page.click('.mod-word'); await page.type('.mod-word', 'pineapple');
await page.evaluate(() => { window.__published = []; });
await page.keyboard.press('Enter'); await sleep(1500);
const ml = (await page.evaluate(() => window.__published)).find((e) => e.kind === 10000);
check('adding a word publishes the list with it, keeping the rest', !!ml && ml.tags.some((x) => x[0] === 'word' && x[1] === 'pineapple') && ml.tags.some((x) => x[0] === 'p' && x[1] === MUTED.pk) && ml.tags.some((x) => x[0] === 't' && x[1] === 'NSFW'), JSON.stringify(ml && ml.tags));
check('...and it is on the card', await page.evaluate(() => [...document.querySelectorAll('.mod-chip')].some((c) => c.textContent.includes('pineapple'))));
await page.evaluate(() => { window.__published = []; document.querySelector('.mod-person button').click(); }); await sleep(1500);
const un = (await page.evaluate(() => window.__published)).find((e) => e.kind === 10000);
check('Unmute publishes the list without them', !!un && !un.tags.some((x) => x[0] === 'p' && x[1] === MUTED.pk) && un.tags.some((x) => x[0] === 'word' && x[1] === 'pineapple'), JSON.stringify(un && un.tags));
check('nobody muted now', await has('Nobody muted'));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ moderation: mutes everywhere, reports fold, spam hidden' : '\n❌ failed');
process.exit(ok ? 0 : 1);
|