/** * Cliente mínimo de Noti para empresas. Un solo archivo, sin dependencias. * Node 18+ (usa `fetch`). Está escrito con sintaxis que Node 23.6+ (o 22.18+) * corre directo (`node noti.ts`); en un proyecto JavaScript o con otro Node, * pasalo por `tsx`/tu compilador, o pedile a tu agente que lo convierta a .js. * * https://noti.gualda.ai/empresas/integrar la guía (y el prompt para tu agente) * https://noti.gualda.ai/sdk/noti.ts este archivo * * El contrato de Noti (no es un detalle): * * - **Un 202 NO es «entregado».** `POST /send` contesta siempre `202 {"id"}`, le * llegue el aviso a mil personas o a ninguna. `send()` devuelve `accepted`, * que quiere decir «Noti lo aceptó», nada más. * - **No hay estado «conectado».** `vincularPersona()` da un pase de un solo * uso; Noti no dice si la persona lo abrió ni si después se dio de baja. * - **`send()` nunca rechaza la promesa**: avisar es accesorio, nunca tiene que * romper tu operación. Un fallo es `accepted: false` y una línea de log. * `vincularPersona()` sí lanza `NotiError`: ahí hay una persona esperando. * - **Reintentos seguros.** Sólo ante fallas de red y 5xx, con esperas cortas * (0,4 s, 0,8 s). Noti descarta un segundo aviso a la misma persona dentro de * los 10 segundos, así que un reintento tras un timeout no se duplica. * * Entorno (`Noti.fromEnv()`): NOTI_ENABLED=true, NOTI_URL, NOTI_TOKEN=ntf_… * Para tests: `new FakeNoti()` valida igual que Noti y guarda lo «enviado». */ export const TITLE_MAX = 100; export const BODY_MAX = 1000; export const CTA_LABEL_MAX = 40; export const CTA_URL_MAX = 2048; export const REF_MAX = 128; export const GROUP_MAX = 24; const CTA_SCHEMES = ["http://", "https://", "mailto:", "tel:", "sms:", "whatsapp:"]; export class NotiError extends Error {} /** Noti está apagado, sin token o no contestó. */ export class NotiUnavailable extends NotiError {} export interface Aviso { title: string; body: string; /** El id con el que conocés a UNA persona (la que vinculaste). */ ref?: string; /** El código de uno de tus QR. Con `ref` no, uno solo de los dos. */ group?: string; /** Exactamente uno. */ emoji?: string; ctaLabel?: string; ctaUrl?: string; /** Evita repetir el mismo aviso dentro de este proceso. La garantía real de * «un evento = un aviso» es un UNIQUE en TU base. */ key?: string; } export interface Pase { url: string; qrText: string; expiresAt: string; } /** `accepted` es «Noti lo aceptó», NO «le llegó». */ export interface Envio { accepted: boolean; id?: string; skipped?: boolean; } /** Sólo señales positivas (ver `aviso()` en la guía). */ export interface Visto { id: string; kind: "broadcast" | "group" | "ref" | "to"; [campo: string]: unknown; } export type Transport = ( method: string, url: string, headers: Record, body: string | undefined, timeoutMs: number, ) => Promise<{ status: number; text: string }>; /** El motivo por el que Noti rechazaría el aviso, o `null` si está bien. */ export function validate(m: Aviso): string | null { if (m.ref !== undefined && m.group !== undefined) return "usá uno solo de ref o group"; if (m.ref !== undefined && !(m.ref.length > 0 && m.ref.length <= REF_MAX)) return `ref vacío o de más de ${REF_MAX}`; if (m.group !== undefined && !(m.group.length > 0 && m.group.length <= GROUP_MAX)) return `group vacío o de más de ${GROUP_MAX}`; if (!m.title || m.title.length > TITLE_MAX) return `título vacío o de más de ${TITLE_MAX}`; if (!m.body || m.body.length > BODY_MAX) return `texto vacío o de más de ${BODY_MAX}`; if (m.emoji !== undefined && m.emoji === "") return "emoji vacío (poné exactamente uno o dejalo sin poner)"; if ((m.ctaLabel === undefined) !== (m.ctaUrl === undefined)) return "el botón lleva rótulo y link, los dos o ninguno"; if (m.ctaLabel !== undefined && m.ctaUrl !== undefined) { if (!(m.ctaLabel.length > 0 && m.ctaLabel.length <= CTA_LABEL_MAX)) return `rótulo del botón vacío o de más de ${CTA_LABEL_MAX}`; if (m.ctaUrl.length > CTA_URL_MAX || !CTA_SCHEMES.some((s) => m.ctaUrl!.toLowerCase().startsWith(s))) return "link del botón inválido (http, https, mailto, tel, sms o whatsapp)"; } return null; } const fetchTransport: Transport = async (method, url, headers, body, timeoutMs) => { const response = await fetch(url, { method, headers, body, signal: AbortSignal.timeout(timeoutMs) }); return { status: response.status, text: await response.text() }; }; export interface NotiOptions { baseUrl: string; token: string; timeoutMs?: number; retries?: number; backoffMs?: number; transport?: Transport; sleep?: (ms: number) => Promise; } export interface NotiLike { send(m: Aviso): Promise; vincularPersona(ref: string, group?: string): Promise; aviso(id: string): Promise; } export class Noti implements NotiLike { private base: string; private token: string; private timeoutMs: number; private retries: number; private backoffMs: number; private transport: Transport; private sleep: (ms: number) => Promise; private keys = new Set(); constructor(o: NotiOptions) { this.base = o.baseUrl.replace(/\/+$/, ""); this.token = o.token; this.timeoutMs = o.timeoutMs ?? 3000; this.retries = o.retries ?? 2; this.backoffMs = o.backoffMs ?? 400; this.transport = o.transport ?? fetchTransport; this.sleep = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))); } /** `NOTI_ENABLED` + `NOTI_URL` + `NOTI_TOKEN`. Apagado o sin token no * lanza: devuelve un `DisabledNoti` que no hace nada. */ static fromEnv(env: Record = process.env, extra: Partial = {}): NotiLike { if (!["1", "true", "yes", "si", "sí"].includes((env.NOTI_ENABLED ?? "").trim().toLowerCase())) return new DisabledNoti("Los avisos por Noti están apagados (NOTI_ENABLED)."); const token = (env.NOTI_TOKEN ?? "").trim(); if (!token) return new DisabledNoti("Falta NOTI_TOKEN."); return new Noti({ baseUrl: env.NOTI_URL ?? "https://noti.gualda.ai", token, ...extra }); } private async request(method: string, path: string, payload?: unknown): Promise<{ status: number; data: any }> { const headers: Record = { Authorization: `Bearer ${this.token}`, Accept: "application/json" }; let body: string | undefined; if (payload !== undefined) { headers["Content-Type"] = "application/json"; body = JSON.stringify(payload); } let ultimo = "no contestó"; for (let intento = 0; intento <= this.retries; intento++) { if (intento) await this.sleep(this.backoffMs * 2 ** (intento - 1)); try { const { status, text } = await this.transport(method, this.base + path, headers, body, this.timeoutMs); if (status >= 500) { ultimo = `HTTP ${status}`; continue; } let data: any = null; try { data = text ? JSON.parse(text) : null; } catch {} return { status, data }; } catch (error) { // De un error de red sólo importa el TIPO: el texto puede traer la URL. ultimo = (error as Error)?.name ?? "Error"; } } console.warn(`Noti no contestó (${ultimo})`); throw new NotiUnavailable("Noti no contestó. Probá de nuevo en un rato."); } /** Manda un aviso. **No rechaza nunca.** `accepted: true` es «Noti lo aceptó * (202)», NO que llegó. */ async send(m: Aviso): Promise { const problema = validate(m); if (problema !== null) { console.warn(`aviso a Noti descartado antes de enviar (${problema})`); return { accepted: false }; } if (m.key !== undefined) { if (this.keys.has(m.key)) return { accepted: false, skipped: true }; this.keys.add(m.key); } const payload: Record = { title: m.title, body: m.body }; if (m.ref !== undefined) payload.ref = m.ref; if (m.group !== undefined) payload.group = m.group; if (m.emoji !== undefined) payload.emoji = m.emoji; if (m.ctaLabel !== undefined && m.ctaUrl !== undefined) payload.cta = { label: m.ctaLabel, url: m.ctaUrl }; try { const { status, data } = await this.request("POST", "/send", payload); if (status !== 202) { console.warn(`Noti contestó ${status} al aviso`); if (m.key !== undefined) this.keys.delete(m.key); return { accepted: false }; } return { accepted: true, id: data?.id }; } catch { if (m.key !== undefined) this.keys.delete(m.key); return { accepted: false }; } } /** Pide el pase para que UNA persona vincule su Noti con tu empresa. Sirve * una vez y vence a los 7 días; si cambia de teléfono, pedí uno nuevo con el * mismo `ref`. `ref` es TU id estable (no un mail ni un teléfono, si podés * evitarlo), sacado de la sesión y no de lo que manda el navegador. */ async vincularPersona(ref: string, group?: string): Promise { if (!(ref.length > 0 && ref.length <= REF_MAX)) throw new NotiError(`ref vacío o de más de ${REF_MAX} caracteres`); const { status, data } = await this.request("POST", "/company/connect-links", group === undefined ? { ref } : { ref, group }); if (status === 401) throw new NotiUnavailable("Noti rechazó el token: avisale a quien administra la integración."); if (status === 404 && group !== undefined) throw new NotiError(`No existe un QR con el código "${group}" en esta empresa.`); if (status !== 201 || !data) throw new NotiError("Noti no pudo armar el enlace. Probá de nuevo en un rato."); if (!data.url || !data.qr_text || !data.expires_at) throw new NotiError("Noti contestó algo que no se entiende."); return { url: String(data.url), qrText: String(data.qr_text), expiresAt: String(data.expires_at) }; } /** Qué se sabe de un aviso que mandaste: sólo señales POSITIVAS. A una * persona (`ref`): `seen_on` (la fecha, sin hora) si lo abrió; `null` es «no * sabemos» (no lo vio, silenció, cortó o no se vinculó: Noti no distingue). * A muchos: `seen_count`/`click_count` sólo cuando son suficientes para no * delatar a nadie. `null` si no existe o Noti no contestó. */ async aviso(id: string): Promise { try { const { status, data } = await this.request("GET", `/company/avisos/${encodeURIComponent(id)}`); return status === 200 && data ? (data as Visto) : null; } catch { return null; } } } /** Apagado o sin token: no hace nada y no rompe nada. */ export class DisabledNoti implements NotiLike { reason: string; constructor(reason = "Los avisos por Noti están apagados.") { this.reason = reason; } async send(_m: Aviso): Promise { return { accepted: false }; } async vincularPersona(_ref: string, _group?: string): Promise { throw new NotiUnavailable(this.reason); } async aviso(_id: string): Promise { return null; } } /** Para dev y tests. Valida igual que Noti; `failWith` simula un Noti caído. */ export class FakeNoti implements NotiLike { sent: Aviso[] = []; links: string[] = []; failWith: string | null = null; private keys = new Set(); async send(m: Aviso): Promise { if (this.failWith !== null || validate(m) !== null) return { accepted: false }; if (m.key !== undefined) { if (this.keys.has(m.key)) return { accepted: false, skipped: true }; this.keys.add(m.key); } this.sent.push(m); return { accepted: true, id: `fake-${this.sent.length}` }; } async vincularPersona(ref: string, _group?: string): Promise { if (this.failWith !== null) throw new NotiUnavailable(this.failWith); if (!(ref.length > 0 && ref.length <= REF_MAX)) throw new NotiError(`ref vacío o de más de ${REF_MAX} caracteres`); this.links.push(ref); const token = `fake${this.links.length}`; return { url: `https://noti.test/c/${token}`, qrText: `noti://c/${token}`, expiresAt: "2099-01-01T00:00:00Z" }; } async aviso(_id: string): Promise { return null; } }