// Pure request builders and response judges. No I/O, no clock reads (callers pass `now`), so every behaviour here // is testable with literals. Two targets are supported and verified: ntfy, and a generic webhook (your own service, // or any other push service reached through the webhook's url/headers/body templates — see README "接入其他服务"). // // A "message" is { kind, title, text, priority?, tags?, vars? }. // An "endpoint" is { preset?, url, token?, method?, headers?, body? }. export const PRESETS = ['ntfy', 'webhook']; // ---------- small helpers ---------- /** Clip to at most `max` UTF-8 bytes, never splitting a character; appends "…" when it cut. */ export function clipBytes(s, max) { s = String(s ?? ''); if (Buffer.byteLength(s) <= max) return s; const budget = Math.max(0, max - 3); // "…" is 3 bytes let out = ''; let used = 0; for (const ch of s) { const n = Buffer.byteLength(ch); if (used + n > budget) break; out += ch; used += n; } return out + '…'; } /** Clip to at most `max` code points; appends "…" when it cut. */ export function clipChars(s, max) { s = String(s ?? ''); const a = Array.from(s); return a.length <= max ? s : a.slice(0, Math.max(0, max - 1)).join('') + '…'; } const dec = (s) => { try { return decodeURIComponent(s); } catch { return s; } }; const pathParts = (url) => { try { return new URL(url).pathname.split('/').filter(Boolean); } catch { return []; } }; const bodyText = (m) => m.text || m.title; // Body helpers return ONLY body + contentType. Headers are merged in one place (buildRequest), so a builder // that also sets auth/UA headers can never have them overwritten by a spread. const json = (o, contentType = 'application/json') => ({ contentType, body: JSON.stringify(o) }); /** HTTP header values must be Latin-1; anything else is sent as an RFC 2047 encoded-word. */ function headerValue(v) { const s = String(v).replace(/[\r\n\0]+/g, ' '); return /[^\x00-\xff]/.test(s) ? `=?UTF-8?B?${Buffer.from(s).toString('base64')}?=` : s; } // ---------- templating (generic webhook; also applied to header values) ---------- export function varsOf(m) { return { title: m.title, text: m.text ?? '', kind: m.kind, priority: m.priority ?? '', tags: m.tags ?? [], ...m.vars }; } const PLACEHOLDER = /\{\{\s*(\w+)\s*\}\}/g; const WHOLE = /^\{\{\s*(\w+)\s*\}\}$/; const asString = (v) => (Array.isArray(v) ? v.join(',') : v == null ? '' : String(v)); /** Replace {{name}}; unknown names are left as written so a typo is visible in the notification. */ export function sub(s, vars, enc = (x) => x) { return String(s).replace(PLACEHOLDER, (all, k) => (k in vars ? enc(asString(vars[k])) : all)); } /** Template every string leaf. A leaf that is exactly "{{name}}" keeps the variable's native type. */ function tpl(v, vars) { if (typeof v === 'string') { const w = WHOLE.exec(v); if (w && w[1] in vars && typeof vars[w[1]] !== 'string') return vars[w[1]]; return sub(v, vars); } if (Array.isArray(v)) return v.map((x) => tpl(x, vars)); if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, tpl(x, vars)])); return v; } // ---------- detection & validation ---------- export function detectPreset(url) { let h; try { h = new URL(url).hostname.toLowerCase(); } catch { return 'webhook'; } if (h === 'ntfy.sh') return 'ntfy'; // a self-hosted ntfy server needs preset=ntfy return 'webhook'; } /** Returns an error message, or null when the endpoint is usable. */ export function validateEndpoint(ep) { if (!ep || typeof ep !== 'object') return 'endpoint must be an object'; if (ep.preset !== undefined && !PRESETS.includes(ep.preset)) return `unknown preset (allowed: ${PRESETS.join(', ')})`; const preset = ep.preset || detectPreset(ep.url); if (!ep.url) return 'url is required'; let u; try { u = new URL(ep.url); } catch { return 'url is not a valid URL'; } if (u.protocol !== 'http:' && u.protocol !== 'https:') return 'url must be http(s)'; if (preset === 'ntfy' && !pathParts(ep.url).length) return 'ntfy needs a topic in the url path'; return null; } // ---------- request builders ---------- const BUILD = { // https://docs.ntfy.sh/publish/ — JSON publish to the server root keeps UTF-8 out of HTTP headers. ntfy(ep, m) { const u = new URL(ep.url); const parts = u.pathname.split('/').filter(Boolean); const topic = dec(parts.pop()); const o = { topic, title: m.title, message: clipBytes(bodyText(m), 4000) }; if (m.priority) o.priority = Math.min(5, Math.max(1, Math.round(m.priority))); if (m.tags?.length) o.tags = m.tags; return { url: `${u.origin}/${parts.length ? parts.join('/') + '/' : ''}`, headers: ep.token ? { Authorization: `Bearer ${ep.token}` } : {}, ...json(o), }; }, // Generic webhook: url/headers/body are templates. Body is a mapping (or a JSON string); string leaves are // substituted AFTER parsing, so quotes and newlines in a title can never break the JSON. webhook(ep, m, now, vars, user) { const method = String(ep.method || 'POST').toUpperCase(); const url = sub(ep.url, vars, encodeURIComponent); if (method === 'GET' || method === 'HEAD') return { method, url }; const ct = Object.entries(user).find(([k]) => k.toLowerCase() === 'content-type')?.[1] ?? 'application/json'; let b = ep.body; if (typeof b === 'string') { try { const p = JSON.parse(b); if (p && typeof p === 'object') b = p; } catch { /* not JSON: raw substitution below */ } } if (b === undefined) b = { title: m.title, text: m.text ?? '', kind: m.kind, priority: m.priority, tags: m.tags ?? [], ...m.vars }; else b = typeof b === 'string' ? sub(b, vars) : tpl(b, vars); if (typeof b === 'string') return { method, url, contentType: ct, body: b }; if (/x-www-form-urlencoded/i.test(ct)) { const flat = Object.fromEntries(Object.entries(b).map(([k, v]) => [k, typeof v === 'string' ? v : JSON.stringify(v)])); return { method, url, contentType: ct, body: new URLSearchParams(flat).toString() }; } return { method, url, contentType: ct, body: JSON.stringify(b) }; }, }; export function buildRequest(ep, m, now = Date.now()) { const preset = ep.preset || detectPreset(ep.url); const build = BUILD[preset]; if (!build) throw new Error(`unknown preset "${preset}"`); const vars = varsOf(m); const user = Object.fromEntries(Object.entries(ep.headers ?? {}).map(([k, v]) => [k, sub(v, vars)])); const r = build(ep, m, now, vars, user); // Single header merge, lowest to highest priority: content-type < builder (auth, UA) < user. // Case-insensitive; the spelling of the winner is kept. const merged = new Map(); const layers = [r.contentType && r.body !== undefined ? { 'Content-Type': r.contentType } : {}, r.headers, user]; for (const src of layers) { for (const [k, v] of Object.entries(src ?? {})) merged.set(k.toLowerCase(), [k, headerValue(v)]); } return { method: r.method ?? 'POST', url: r.url, headers: Object.fromEntries(merged.values()), body: r.body }; } // ---------- response judging ---------- const OK = Object.freeze({ ok: true }); const fail = (error, retryable = false) => ({ ok: false, retryable, error }); const parseJson = (t) => { try { const v = JSON.parse(t); return v && typeof v === 'object' ? v : null; } catch { return null; } }; // `red` redacts credentials from text BEFORE it is clipped for display (a clipped secret can no longer be matched) const snippet = (t, red, n = 120) => clipChars(red(String(t ?? '')).replace(/\s+/g, ' ').trim(), n); function retryAfterMs(headers, j) { const h = headers?.get?.('retry-after'); if (h != null && h !== '') { const n = Number(h); if (Number.isFinite(n)) return Math.max(0, Math.round(n * 1000)); const t = Date.parse(h); if (Number.isFinite(t)) return Math.max(0, t - Date.now()); } const b = j?.retry_after; // some webhooks put it in the JSON body return typeof b === 'number' && b >= 0 ? Math.round(b * 1000) : undefined; } function errorDetail(j, text, red) { if (j) { const s = ['errorDescription', 'description', 'error_description', 'msg', 'message', 'error'].map((k) => j[k]).find((x) => typeof x === 'string' && x); if (s) return snippet(s, red); } return snippet(text, red); } /** * Decide whether a response means "delivered". The verdict reads the RAW body; `redact` is applied only to the * diagnostic text, before it is clipped (a clipped secret can no longer be recognised). * @returns {{ok:boolean, retryable?:boolean, error?:string, retryAfterMs?:number}} */ export function judge(ep, status, text = '', headers, red = (t) => t) { const j = parseJson(text); const wait = retryAfterMs(headers, j); const withWait = (v) => (wait !== undefined && !v.ok ? { ...v, retryAfterMs: wait } : v); if (status >= 200 && status < 300) return OK; // ntfy and a webhook signal failure with the HTTP status const retryable = status === 408 || status === 425 || status === 429 || status >= 500; const redirect = status >= 300 && status < 400 ? ' (redirects are not followed — use the final https URL)' : ''; const detail = errorDetail(j, text, red); return withWait(fail(`HTTP ${status}${detail ? ': ' + detail : ''}${redirect}`, retryable)); }