clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

dependabot/github_actions/actions-9b61906d8b
bindws / src / notify.ts
notify.ts 7.6K
1// Relay-signed notifications. The relay has a keypair, so it can write its2// owner a NIP-17 private message: a kind 14 rumour, sealed and gift wrapped3// with the relay's key (NIP-59, NIP-44). The wrap is stored here, which is4// the owner's inbox on their own relay, and pushed to the relays in the5// owner's kind 10050 when this relay holds one. No email, no webhook.6import { now, tagValues, type Event } from "./event.ts";7import { dial } from "./pull.ts";8import type { Relay } from "./relay.ts";9import { viewRowsSince } from "./views.ts";10import type { FuelStatus } from "./fuel.ts";11import { KIND_DM } from "./kinds.ts";1213export type NotifyKind = "reports" | "fuel" | "jobs" | "succession" | "digest" | "test";14export interface NotifySettings {15 reports: boolean;16 fuel: boolean;17 jobs: boolean;18 succession: boolean; // the dead-man's switch warnings and the handover19 digest: boolean; // one message a week on how the relay is doing20}21export const DIGEST_DAYS = 7;2223const PUSH_RELAYS = 3;24const PUSH_TIMEOUT_MS = 4000;25// Below this balance, while past an allowance, fuel counts as low.26const LOW_SATS = 2000;2728// notifySettings validates a policy patch's notify object. Missing keys keep29// their current value.30export function notifySettings(raw: unknown, cur: NotifySettings): NotifySettings | null {31 if (!raw || typeof raw !== "object") return null;32 const r = raw as Record<string, unknown>;33 const pick = (k: keyof NotifySettings) => (typeof r[k] === "boolean" ? (r[k] as boolean) : cur[k]);34 return { reports: pick("reports"), fuel: pick("fuel"), jobs: pick("jobs"), succession: pick("succession"), digest: pick("digest") };35}3637// notify sends the owner (or `to`, a member the relay may address) a message38// of the given kind if that kind is switched on ("test" always sends).39// Returns whether a wrap was stored. A leased relay has no owner to write to.40export async function notify(relay: Relay, kind: NotifyKind, text: string, subject = "your relay", to = ""): Promise<boolean> {41 const p = relay.settings.policy;42 if (p.owner === "" || relay.settings.isLeased()) return false;43 if (kind !== "test" && !p.notify[kind]) return false;44 const recipient = to || p.owner;45 await relay.identity.ensure();46 const t = now();47 const wrap = relay.identity.wrap({ kind: KIND_DM, content: text, tags: [["p", recipient], ["subject", subject]], created_at: t }, recipient);48 const err = relay.store.save(wrap, t);49 if (err) return false;50 relay.broadcast(wrap);51 void pushToInbox(relay, wrap).catch(() => {});52 return true;53}5455// pushToInbox forwards the wrap to the owner's DM relays, best effort, a56// few seconds each, never to this relay itself.57async function pushToInbox(relay: Relay, wrap: Event): Promise<void> {58 const rows = relay.store.query({ kinds: [10050], authors: [relay.settings.policy.owner], tags: {}, limit: 1 }, { pubkeys: [], all: true }, 1, now()).rows;59 if (!rows[0]) return;60 const list = JSON.parse(rows[0]) as Event;61 const self = relay.slug + ".";62 const urls = tagValues(list, "relay")63 .map((u) => u.trim())64 .filter((u) => {65 try {66 return /^wss?:$/.test(new URL(u).protocol) && !new URL(u).hostname.startsWith(self);67 } catch {68 return false;69 }70 })71 .slice(0, PUSH_RELAYS);72 await Promise.all(urls.map((u) => pushOne(relay, u, wrap).catch(() => {})));73}7475function pushOne(relay: Relay, url: string, wrap: Event): Promise<void> {76 return new Promise((resolve) => {77 let ws: WebSocket | null = null;78 const timer = setTimeout(done, PUSH_TIMEOUT_MS);79 function done() {80 clearTimeout(timer);81 try {82 ws?.close(1000, "done");83 } catch {84 /* already closed */85 }86 resolve();87 }88 dial(relay, url)89 .then((sock) => {90 ws = sock;91 ws.addEventListener("message", (e) => {92 try {93 const m = JSON.parse(String(e.data)) as unknown[];94 if (m[0] === "OK" && m[1] === wrap.id) done();95 } catch {96 /* not for us */97 }98 });99 ws.addEventListener("close", done);100 ws.send(JSON.stringify(["EVENT", wrap]));101 })102 .catch(done);103 });104}105106// fuelLow says whether the owner should hear about fuel: out of it, or past107// an allowance with less than a small balance left.108export function fuelLow(f: FuelStatus): boolean {109 if (!f.enabled) return false;110 if (f.outOfFuel) return true;111 const over = f.eventBytes > f.freeEventBytes || f.mediaBytes > f.freeMediaBytes || f.activeMs > f.freeActiveMs || f.rowsWritten > f.freeRowsWritten;112 return over && f.balanceMsats < LOW_SATS * 1000;113}114115export function fuelText(relay: Relay, f: FuelStatus): string {116 const sats = Math.floor(f.balanceMsats / 1000);117 const state = f.outOfFuel ? "is out of fuel and read-only" : `is past its free allowance with ${sats} sats left`;118 return `Your relay ${relay.slug} ${state}. Zap it at https://${relay.slug}.${relay.domain}/ to keep it writing.`;119}120121// digestText is the week on the relay in a few lines, from what the store,122// the member list, the usage row and the job list already hold. When nothing123// changed it says so in one line.124export async function digestText(relay: Relay, since: number, t: number): Promise<string> {125 const sql = relay.sql;126 const one = (q: string, ...args: unknown[]) => sql.exec<{ n: number | null }>(q, ...args).one().n ?? 0;127 const self = relay.identity.pubkey;128 const joined = one(`SELECT count(*) AS n FROM members WHERE joined_at >= ? AND role <> 'owner'`, since);129 const left = self ? one(`SELECT count(*) AS n FROM events WHERE kind=8001 AND pubkey=? AND created_at >= ?`, self, since) : 0;130 const events = one(`SELECT count(*) AS n FROM events WHERE created_at >= ? AND pubkey <> ? AND kind <> 1059`, since, self);131 const files = one(`SELECT count(*) AS n FROM blobs WHERE uploaded >= ?`, since);132 const reports = one(`SELECT count(*) AS n FROM reports WHERE status='open'`);133 const hidden = one(`SELECT count(*) AS n FROM event_rules WHERE rule='hide'`);134 const jobs = (await relay.jobs()).filter((j) => j.last && j.last.finishedAt >= since);135 const failed = jobs.filter((j) => j.last && j.last.error).length;136 const f = relay.fuelStatus();137 const spent = Math.floor(f.chargedMsats / 1000);138 const balance = Math.floor(f.balanceMsats / 1000);139 const sc = await relay.succession.status();140 const viewRows = await viewRowsSince(relay, since);141 const lines: string[] = [];142 if (joined || left) lines.push(`People: ${joined} joined${left ? `, ${left} left` : ""}.`);143 if (events || files) lines.push(`Stored: ${events} events${files ? ` and ${files} files` : ""}.`);144 if (jobs.length) lines.push(`Jobs: ${jobs.length} ran${failed ? `, ${failed} failed` : ""}.`);145 if (reports || hidden) lines.push(`Moderation: ${reports} open reports${hidden ? `, ${hidden} events hidden` : ""}.`);146 if (f.enabled) {147 const over = f.eventBytes > f.freeEventBytes || f.mediaBytes > f.freeMediaBytes || f.activeMs > f.freeActiveMs || f.rowsWritten > f.freeRowsWritten;148 if (spent || over || f.outOfFuel) lines.push(`Fuel: ${spent} sats spent this month, ${balance} left${f.outOfFuel ? ", out of fuel" : over ? ", past the free allowance" : ""}.`);149 }150 if (sc.succession && sc.handoverAt) lines.push(`Handover to ${sc.succession.heir.slice(0, 8)} in ${Math.max(0, Math.ceil((sc.handoverAt - t) / 86400))} days unless you sign in.`);151 const days = Math.max(1, Math.round((t - since) / 86400));152 // The views' housekeeping rides along with a week that had news; a quiet week stays quiet.153 if (lines.length && viewRows) lines.push(`Views: ${viewRows} rows written.`);154 return lines.length ? [`The last ${days} days on ${relay.slug}:`, ...lines].join("\n") : `Nothing changed on ${relay.slug} in the last ${days} days.`;155}