New Fly app green-tea-runner-cron (shared-cpu-1x, 2GB, label cron, same config.yaml: 30s fetch timeout, 1h job cap) takes scheduled/ops jobs so deploys on green-tea-runner never wait behind them. og-badges now runs-on: cron. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVFi3wvLWKaEkpDw6eLnQC
111 lines
6.9 KiB
YAML
111 lines
6.9 KiB
YAML
name: og-badges
|
|
# NIP-58 OG badge for well-known.online, run every 15 minutes (and on demand).
|
|
# 1. Ensures the badge definition (kind 30009, d=og) by the well-known account (WK_NSEC) is on the relays.
|
|
# 2. Diffs og.well-known.online/.well-known/nostr.json against existing awards (kind 8, #a=30009:<wk>:og)
|
|
# on the relays and awards only keys that don't have it yet, so repeat runs are no-ops.
|
|
# 3. Our own keys (factory = SNAIL_KNOWN, well-known = WK_NSEC) accept their award via a kind-30008
|
|
# profile_badges list, so clients show it on those profiles.
|
|
on:
|
|
schedule:
|
|
- cron: '*/15 * * * *'
|
|
workflow_dispatch:
|
|
inputs:
|
|
republish_definition:
|
|
description: re-sign the definition even if one exists (true/false)
|
|
required: false
|
|
default: 'false'
|
|
jobs:
|
|
badges:
|
|
runs-on: cron
|
|
timeout-minutes: 8
|
|
steps:
|
|
- name: define, diff, award
|
|
env:
|
|
K_WK: ${{ secrets.WK_NSEC }}
|
|
K_FACTORY: ${{ secrets.SNAIL_KNOWN }}
|
|
REPUBLISH: ${{ inputs.republish_definition }}
|
|
RELAYS: wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
|
|
OG_DIR: https://og.well-known.online/.well-known/nostr.json
|
|
run: |
|
|
mkdir -p /tmp/ob && cd /tmp/ob
|
|
npm init -y >/dev/null && npm i --silent @noble/curves@1.9.7 >/dev/null
|
|
cat > b.mjs <<'EOF'
|
|
import { schnorr } from '@noble/curves/secp256k1';
|
|
import { createHash } from 'node:crypto';
|
|
const CH = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
|
const toHex = (s) => {
|
|
s = (s || '').trim().toLowerCase();
|
|
if (/^[0-9a-f]{64}$/.test(s)) return s;
|
|
const d = [...s.slice(s.lastIndexOf('1') + 1)].map((c) => CH.indexOf(c)).slice(0, -6);
|
|
let acc = 0, bits = 0; const out = [];
|
|
for (const v of d) { acc = (acc << 5) | v; bits += 5; while (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }
|
|
return Buffer.from(out).toString('hex');
|
|
};
|
|
const key = (raw) => { const sk = toHex(raw); return { sk, pk: Buffer.from(schnorr.getPublicKey(sk)).toString('hex') }; };
|
|
if (!process.env.K_WK) { console.error('WK_NSEC missing'); process.exit(1); }
|
|
const WK = key(process.env.K_WK);
|
|
const FACTORY = process.env.K_FACTORY ? key(process.env.K_FACTORY) : null;
|
|
const RELAYS = process.env.RELAYS.split(',').map((s) => s.trim()).filter(Boolean);
|
|
const A = `30009:${WK.pk}:og`;
|
|
const now = () => Math.floor(Date.now() / 1000);
|
|
const sign = (k, kind, tags, content = '') => {
|
|
const ev = { pubkey: k.pk, created_at: now(), kind, tags, content };
|
|
ev.id = createHash('sha256').update(JSON.stringify([0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content])).digest('hex');
|
|
ev.sig = Buffer.from(schnorr.sign(ev.id, k.sk)).toString('hex');
|
|
return ev;
|
|
};
|
|
const ws = (url, onopen, onmsg, ms) => new Promise((resolve) => {
|
|
let done = false, sock; const fin = (r) => { if (!done) { done = true; try { sock.close(); } catch {} resolve(r); } };
|
|
const t = setTimeout(() => fin('timeout'), ms);
|
|
try { sock = new WebSocket(url); } catch { clearTimeout(t); return fin('error'); }
|
|
sock.onopen = () => onopen(sock);
|
|
sock.onmessage = (m) => { let x; try { x = JSON.parse(m.data); } catch { return; } const r = onmsg(x); if (r !== undefined) { clearTimeout(t); fin(r); } };
|
|
sock.onerror = () => { clearTimeout(t); fin('error'); };
|
|
});
|
|
const query = async (filter) => {
|
|
const seen = new Map();
|
|
await Promise.all(RELAYS.map((url) => ws(url, (s) => s.send(JSON.stringify(['REQ', 'q', filter])),
|
|
(x) => { if (x[0] === 'EVENT' && x[1] === 'q') seen.set(x[2].id, x[2]); else if (x[0] === 'EOSE') return 'eose'; }, 8000)));
|
|
return [...seen.values()];
|
|
};
|
|
const publish = async (ev, label) => {
|
|
const res = await Promise.all(RELAYS.map(async (url) => [url, await ws(url, (s) => s.send(JSON.stringify(['EVENT', ev])),
|
|
(x) => (x[0] === 'OK' && x[1] === ev.id ? (x[2] ? 'ok' : 'rejected: ' + x[3]) : undefined), 8000)]));
|
|
const ok = res.filter(([, r]) => r === 'ok').length;
|
|
console.log(`${label}: kind ${ev.kind} id ${ev.id} ${ok}/${RELAYS.length}`, JSON.stringify(Object.fromEntries(res)));
|
|
if (!ok) process.exitCode = 1;
|
|
return ok;
|
|
};
|
|
// 1. definition
|
|
const defs = await query({ kinds: [30009], authors: [WK.pk], '#d': ['og'] });
|
|
if (!defs.length || process.env.REPUBLISH === 'true') {
|
|
const img = 'https://well-known.online/badges/og.png';
|
|
await publish(sign(WK, 30009, [['d', 'og'], ['name', 'OG'],
|
|
['description', 'One of the first names on well-known.online: an OG handle, with a free inbox at name@well-known.online.'],
|
|
['image', img, '512x512'], ['thumb', img, '512x512']]), 'definition');
|
|
} else console.log('definition: present', defs[0].id);
|
|
// 2. diff directory vs awards
|
|
const dir = await (await fetch(process.env.OG_DIR)).json();
|
|
const ogKeys = [...new Set(Object.values(dir.names || {}).map((k) => String(k).toLowerCase()).filter((k) => /^[0-9a-f]{64}$/.test(k)))];
|
|
const awards = await query({ kinds: [8], authors: [WK.pk], '#a': [A] });
|
|
const awarded = new Set(awards.flatMap((e) => e.tags.filter((t) => t[0] === 'p').map((t) => t[1])));
|
|
const missing = ogKeys.filter((k) => !awarded.has(k));
|
|
console.log(`og directory: ${ogKeys.length} keys; already awarded: ${awarded.size}; to award: ${missing.length}`);
|
|
for (let i = 0; i < missing.length; i += 50) {
|
|
await publish(sign(WK, 8, [['a', A], ...missing.slice(i, i + 50).map((p) => ['p', p, RELAYS[0]])]), 'award');
|
|
}
|
|
// 3. our own keys accept (kind 30008 profile_badges), once
|
|
const allAwards = missing.length ? await query({ kinds: [8], authors: [WK.pk], '#a': [A] }) : awards;
|
|
for (const [label, k] of [['factory', FACTORY], ['well-known', WK]]) {
|
|
if (!k || !ogKeys.includes(k.pk)) continue;
|
|
const award = allAwards.find((e) => e.tags.some((t) => t[0] === 'p' && t[1] === k.pk));
|
|
if (!award) continue;
|
|
const pb = await query({ kinds: [30008], authors: [k.pk], '#d': ['profile_badges'] });
|
|
const latest = pb.sort((a, b) => b.created_at - a.created_at)[0];
|
|
if (latest && latest.tags.some((t) => t[0] === 'a' && t[1] === A)) { console.log(`${label}: profile_badges already lists OG`); continue; }
|
|
const keep = latest ? latest.tags.filter((t) => t[0] !== 'd') : [];
|
|
await publish(sign(k, 30008, [['d', 'profile_badges'], ...keep, ['a', A], ['e', award.id, RELAYS[0]]]), `${label} accept`);
|
|
}
|
|
EOF
|
|
node b.mjs
|