214 lines
7.6 KiB
TypeScript
214 lines
7.6 KiB
TypeScript
/**
|
|
* SkateSpots Community — always-on Cordn room bot.
|
|
*
|
|
* The room itself is founder-owned; the bot is a normal MLS member that joins
|
|
* once and then approves new join requests. It never creates a group (that
|
|
* would risk a split if its state were lost): if there is no local group it
|
|
* publishes a key package and asks to join, then waits for an admin's Welcome.
|
|
*
|
|
* Secrets: COMMUNITY_BOT_KEY (hex) comes from a 600 env file. The MLS snapshot
|
|
* on disk also contains group private material, so keep the state file 600 and
|
|
* back it up.
|
|
*
|
|
* Run locally:
|
|
* COMMUNITY_BOT_KEY=... COMMUNITY_GID=... npx tsx scripts/community-bot.ts
|
|
*
|
|
* Env:
|
|
* COMMUNITY_BOT_KEY 32-byte hex (required)
|
|
* COMMUNITY_GID e.g. 35006:<founder>:community (required)
|
|
* COMMUNITY_STATE snapshot path (default /var/lib/skatespots-community/state.json)
|
|
* COMMUNITY_COORDINATOR coordinator pubkey (default SkateSpots')
|
|
* COMMUNITY_RELAY coordinator relay (default wss://relay.skate-spots.org)
|
|
* COMMUNITY_POLL_MS poll interval (default 15000)
|
|
* COMMUNITY_AUTO_APPROVE=0 leave requests for admins instead of adding
|
|
* COMMUNITY_ONCE=1 one cycle then exit (for a dry run)
|
|
*/
|
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
import { dirname } from 'node:path';
|
|
import WebSocket from 'ws';
|
|
import { CordnSession, type CordnSnapshot, type CordnStore } from '../src/lib/cordn/native/session';
|
|
|
|
// Node 20 (the relay box) has no global WebSocket. The ContextVM transport
|
|
// looks for one at connect time, so install `ws` before opening a session.
|
|
(globalThis as unknown as { WebSocket?: unknown }).WebSocket = WebSocket;
|
|
|
|
const COORDINATOR_PUBKEY =
|
|
process.env.COMMUNITY_COORDINATOR ??
|
|
'02816a05b1d4e70a7df9c712ef6f7272774269d65bd8a2f262ce0330d0885693';
|
|
const RELAY = process.env.COMMUNITY_RELAY ?? 'wss://relay.skate-spots.org';
|
|
const GID = process.env.COMMUNITY_GID ?? '';
|
|
const BOT_KEY = (process.env.COMMUNITY_BOT_KEY ?? '').trim().toLowerCase();
|
|
const STATE_PATH =
|
|
process.env.COMMUNITY_STATE ?? '/var/lib/skatespots-community/state.json';
|
|
const POLL_MS = Number(process.env.COMMUNITY_POLL_MS ?? 15_000);
|
|
const AUTO_APPROVE = process.env.COMMUNITY_AUTO_APPROVE !== '0';
|
|
const CREATE = process.env.COMMUNITY_CREATE === '1';
|
|
const ONCE = process.env.COMMUNITY_ONCE === '1';
|
|
|
|
if (!/^[0-9a-f]{64}$/.test(BOT_KEY)) {
|
|
console.error('[community] COMMUNITY_BOT_KEY must be 64 lowercase hex chars');
|
|
process.exit(1);
|
|
}
|
|
if (!GID) {
|
|
console.error('[community] COMMUNITY_GID is required');
|
|
process.exit(1);
|
|
}
|
|
|
|
const started = Date.now();
|
|
const log = (...parts: unknown[]) => {
|
|
const uptime = Math.round((Date.now() - started) / 1000);
|
|
console.log(`[community +${uptime}s]`, ...parts);
|
|
};
|
|
const short = (pk: string) => `${pk.slice(0, 8)}…`;
|
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
/** JSON snapshot store. Atomic writes, 600, and no duplicated bot private key. */
|
|
class FileCordnStore implements CordnStore {
|
|
constructor(private readonly path: string) {}
|
|
|
|
async load(): Promise<CordnSnapshot | null> {
|
|
try {
|
|
return JSON.parse(await readFile(this.path, 'utf8')) as CordnSnapshot;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async save(snapshot: CordnSnapshot): Promise<void> {
|
|
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
// The bot key lives in the env file; don't duplicate it in the state.
|
|
const { privateKeyHex, ...safe } = snapshot;
|
|
void privateKeyHex;
|
|
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
await writeFile(tmp, JSON.stringify(safe), { mode: 0o600 });
|
|
await rename(tmp, this.path);
|
|
}
|
|
|
|
async clear(): Promise<void> {
|
|
await writeFile(this.path, 'null', { mode: 0o600 });
|
|
}
|
|
}
|
|
|
|
let session!: CordnSession;
|
|
|
|
const hasRoom = () => session.listGroups().some((group) => group.gid === GID);
|
|
|
|
async function joinRoom(): Promise<boolean> {
|
|
if (hasRoom()) return true;
|
|
|
|
// A Welcome already waiting? Accept it before asking again, so a restart
|
|
// between approval and pickup does not create a duplicate request.
|
|
const welcomes = await session.fetchWelcomes();
|
|
for (const welcome of welcomes) {
|
|
try {
|
|
const joined = await session.acceptWelcome(welcome, 'SkateSpots Community');
|
|
if (joined.gid === GID) {
|
|
log('joined the room as a member');
|
|
return true;
|
|
}
|
|
} catch {
|
|
// Welcome for a key package this bot no longer has — skip.
|
|
}
|
|
}
|
|
|
|
// Ask the founder/admin to add us. `requestJoin` publishes a key package if
|
|
// one is not already waiting.
|
|
await session.requestJoin(GID);
|
|
lastJoinRequestAt = Date.now();
|
|
log('join request sent; waiting for an admin to approve this bot');
|
|
return false;
|
|
}
|
|
|
|
const approved = new Set<string>();
|
|
const lastAttempt = new Map<string, number>();
|
|
let lastJoinRequestAt = 0;
|
|
let lastError = '';
|
|
|
|
async function cycle(): Promise<void> {
|
|
try {
|
|
if (!hasRoom()) {
|
|
if (CREATE) {
|
|
await session.createGroup({ groupId: GID, name: 'SkateSpots Community' });
|
|
log('created the room (bootstrap); set COMMUNITY_CREATE=0 and restart');
|
|
}
|
|
const joined = await joinRoom();
|
|
if (!joined) {
|
|
// Retry the request every 5 minutes; a fresh key package may be needed
|
|
// if the original one was consumed or expired.
|
|
if (Date.now() - lastJoinRequestAt > 5 * 60_000) {
|
|
await session.requestJoin(GID);
|
|
lastJoinRequestAt = Date.now();
|
|
log('join request refreshed');
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Keep the bot's MLS state current with commits from other members.
|
|
await session.fetch(GID);
|
|
|
|
if (!AUTO_APPROVE) return;
|
|
|
|
const requests = await session.fetchJoinRequests(GID);
|
|
for (const request of requests.slice(0, 10)) {
|
|
if (request.pk === session.pubkey) continue; // our own boot request
|
|
if (approved.has(request.pk)) continue;
|
|
const last = lastAttempt.get(request.pk) ?? 0;
|
|
if (Date.now() - last < 5 * 60_000) continue;
|
|
try {
|
|
await session.addMemberByIdentifier(GID, request.pk);
|
|
approved.add(request.pk);
|
|
log('approved member', short(request.pk));
|
|
await sleep(1500); // be gentle with the coordinator
|
|
} catch (error) {
|
|
lastAttempt.set(request.pk, Date.now());
|
|
log('could not approve', short(request.pk), error instanceof Error ? error.message : String(error));
|
|
await sleep(1500);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (message !== lastError) {
|
|
lastError = message;
|
|
log('cycle error', message);
|
|
}
|
|
}
|
|
}
|
|
|
|
let stopping = false;
|
|
async function stop(signal: string): Promise<void> {
|
|
if (stopping) return;
|
|
stopping = true;
|
|
log(`stopping (${signal})`);
|
|
try {
|
|
if (session) await session.disconnect();
|
|
} finally {
|
|
process.exit(0);
|
|
}
|
|
}
|
|
process.on('SIGINT', () => void stop('SIGINT'));
|
|
process.on('SIGTERM', () => void stop('SIGTERM'));
|
|
|
|
async function main(): Promise<void> {
|
|
session = await CordnSession.open({
|
|
coordinator: { serverPubkey: COORDINATOR_PUBKEY, relays: [RELAY] },
|
|
privateKeyHex: BOT_KEY,
|
|
store: new FileCordnStore(STATE_PATH),
|
|
});
|
|
log('bot pubkey', session.pubkey, 'room', GID);
|
|
|
|
do {
|
|
await cycle();
|
|
if (ONCE) break;
|
|
await sleep(POLL_MS);
|
|
} while (!stopping);
|
|
|
|
// `ONCE` is used for setup/dry runs and tests; close the ContextVM client so
|
|
// the process can exit instead of hanging on the WebSocket.
|
|
if (ONCE) await session.disconnect();
|
|
}
|
|
|
|
void main().catch((error) => {
|
|
console.error('[community] fatal', error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
});
|