/** * blip v2: the plugin an app copies in to use Blip. One file, no dependencies (node:crypto and fetch); runs on your * server (Node 18+, Next.js route handlers and server actions, Express, Remix, SvelteKit, Bun, Deno with node:crypto). * Get the latest: curl -o src/lib/blip.ts https://sendablip.com/plugin/v1/blip.ts * Install guide: https://sendablip.com/plugin/v1/INSTALL.md API reference: https://sendablip.com/docs * * whoami is the key set up right, and what's left to do (GET /api/v1/me) (2.12) * testWebhook send a signed webhook.test to your app's webhook address now and see what it answered (2.12) * recorderUrl a link that opens Blip's recorder for your workspace, tagged with your ref ("ps:project:") * listVideos your workspace's Blips, all or just one ref's; getVideo one of them * createLink a personal link for one person (only its hash is kept: this is the one time you see it) * requests createRequest, listRequests, newRequestLink, revokeRequest: a link anyone records on (2.1) * tickets listTickets, getTicket, getTicketTeam, replyToTicket, addTicketNote, updateTicket, setTicketStatus: * your Support inbox by API (2.22; tickets:read, tickets:write, and tickets:reply to answer a visitor) * Replies by email (2.23): every ticket event carries reply_token; postTicketReply (no key) or * addVisitorReply (a key) brings a visitor's emailed reply onto the ticket. * listAssignments the Blips someone was asked to watch (2.1) * listCosts what the Blips cost, per ref (2.1, costs:read) * prepareClip, clipTarget for the renderer of Reply clips (2.2, clips:render) * watchUrl, embedUrl the share page and the player for a share code * Replies sendEvent, createInvite, cancelInvites, erasePerson, listInvites (Blip Reply, as in v1) * webhooks verify and read what Blip sends: video.ready, reply.invite_ready, reply.reminder, * reply.completed, reply.cancelled, reply.test, and the step events an app asks for: * reply.opened, reply.started, reply.expired, reply.tagged, reply.clip_published, reply.clip_taken_down, * and for an app that keeps Blip's alerts, CRM notes and clip renders on its side (2.11): video.alert, * video.gate_entry, video.link_milestone, clip.render_requested, clip.file_retired, and for an * app that files support tickets: ticket.created, ticket.transcript_ready, and (2.13) the rest of a * ticket's life, for an app or email tool that sends the visitor's emails itself: ticket.replied, * ticket.status_changed, ticket.solved_check. Blip sends a visitor no email: these events are how * your own tool does. (2.18) ticket.replied carries `video` when the team answered with a video: * its link, a picture of it and its length. (2.19) What your team does to a ticket, for an app * that keeps its own copy: ticket.assigned, ticket.note_added, ticket.tagged; and every ticket * event carries ticket.assignee and ticket.tags. * signSupportUser (2.24) sign who's signed in to your app, so the support button's tickets are verified: Blip checks * the signature, files the ticket under that name and email, and events say visitor.verified: true. * Needs the support button's identity secret (BLIP_SUPPORT_IDENTITY_SECRET), not your API key. * (2.25) listTickets and getTicket say the same: visitor.verified and visitor.external_id. * * Env: BLIP_API_KEY (blip_sk_..., from Blip, Settings, Developers; scopes videos:read, videos:write, replies:read, * replies:write as needed), BLIP_WEBHOOK_SECRET (bsk_..., the app's signing secret; only for webhooks), * BLIP_SUPPORT_IDENTITY_SECRET (bsi_..., a support button's; only for signSupportUser), and BLIP_URL * only to point somewhere other than https://sendablip.com. Server only: never import this into browser code. */ import { createHmac, timingSafeEqual } from "node:crypto"; export const BLIP_PLUGIN_VERSION = "2.27.0"; const TOLERANCE_SECONDS = 300; const SHARE_BASE = "https://sendablip.com"; // brand-files: origin (scripts/brand-files.mts writes it from src/lib/brand.ts) const NAME = "Blip"; // brand-files: name (scripts/brand-files.mts writes it from src/lib/brand.ts) export interface BlipConfig { url: string; apiKey: string; /** The app's signing secret, for webhooks. */ secret: string | null; } /** * The config from env, or null when the key is missing or BLIP_URL isn't https (the app then does nothing; ask * blipConfigProblem why). BLIP_URL is https://sendablip.com unless set. */ export function blipConfig(env: Record = process.env): BlipConfig | null { const url = (env.BLIP_URL?.trim() || SHARE_BASE).replace(/\/+$/, ""); const apiKey = env.BLIP_API_KEY?.trim(); if (!/^https:\/\/|^http:\/\/localhost/.test(url) || !apiKey || !apiKey.startsWith("blip_sk_")) return null; return { url, apiKey, secret: env.BLIP_WEBHOOK_SECRET?.trim() || null }; } /** Why blipConfig is null, in a sentence to log or show the developer; null when it's fine (2.12). */ export function blipConfigProblem(env: Record = process.env): string | null { const key = env.BLIP_API_KEY?.trim(); if (!key) return `BLIP_API_KEY isn't set. Make a key in ${NAME} under Settings, Developers and put it in your server's env (not a NEXT_PUBLIC_ one).`; if (!key.startsWith("blip_sk_")) return `BLIP_API_KEY should start with blip_sk_. Copy the whole key from ${NAME}, Settings, Developers.`; const url = env.BLIP_URL?.trim(); if (url && !/^https:\/\/|^http:\/\/localhost/.test(url)) return `BLIP_URL has to start with https://. Leave it out to use ${SHARE_BASE}.`; return null; } export type BlipCallResult = { ok: true; status: number; data: T } | { ok: false; status: number | null; error: string }; async function call(cfg: BlipConfig, method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", path: string, body?: unknown, timeoutMs = 10_000, headers: Record = {}): Promise> { try { const res = await fetch(`${cfg.url}/api/v1/${path}`, { method, headers: { authorization: `Bearer ${cfg.apiKey}`, "content-type": "application/json", "user-agent": `blip/${BLIP_PLUGIN_VERSION}`, ...headers }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs), }); const data = (await res.json().catch(() => ({}))) as T & { error?: string }; if (!res.ok) return { ok: false, status: res.status, error: data?.error ?? `HTTP ${res.status}` }; return { ok: true, status: res.status, data }; } catch (e) { return { ok: false, status: null, error: e instanceof Error ? e.message : "network error" }; } } const query = (q: Record) => { const p = new URLSearchParams(); for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null && v !== "") p.set(k, String(v)); const s = p.toString(); return s ? `?${s}` : ""; }; /** The config, or an error that says what's missing (2.12). For routes that can't do anything without Blip. */ export function requireBlip(env: Record = process.env): BlipConfig { const cfg = blipConfig(env); if (!cfg) throw new Error(`${NAME} isn't set up: ${blipConfigProblem(env) ?? "check BLIP_API_KEY."}`); return cfg; } // ---- Setup checks (2.12) ----------------------------------------------------------------------------------------------- export interface BlipWhoami { /** windows: may Blip open windows on your sites (off by default: it stays inside your pages). */ workspace: { id: string; name: string | null; windows: boolean }; key: { name: string; prefix: string; scopes: string[] }; /** sites: where the app's Record button opens Blip inside the page. */ app: { slug: string; name: string; active: boolean; webhook_url: string | null; events: string[]; sites: string[] } | null; /** ok false: the workspace's plan doesn't include the API, and problem says what to do. */ api: { ok: true } | { ok: false; problem: string }; /** snippet is the script tag to paste for that button. */ support_buttons: { name: string; active: boolean; files_with_this_app: boolean; sites: string[]; snippet: string }[]; plugin: { version: string; files: Record }; /** What still has to happen, in plain sentences. Empty when everything's set. */ next_steps: string[]; } /** Is this key set up right, and what's left to do. Any key, any scope. */ export const whoami = (cfg: BlipConfig) => call(cfg, "GET", "me"); /** Send {type: "webhook.test"} to the app's webhook address now, signed, and see what it answered. Needs an app key. */ export const testWebhook = (cfg: BlipConfig) => call<{ ok: boolean; status: number | null; answer: string; error: string | null; url: string; ms: number }>(cfg, "POST", "deliveries/test", {}, 15_000); // ---- Videos ----------------------------------------------------------------------------------------------------------- export interface BlipVideo { id: string; title: string; status: "uploading" | "processing" | "ready" | "failed"; duration_seconds: number; external_ref: string | null; created_at: string; owner: { name: string | null } | null; /** The name a guest gave, when it wasn't a member who recorded it (2.1). */ guest_name: string | null; source: string; views: number; /** Made through a request (2.1). */ request_id: string | null; /** What it is, set where it was made: a Blip, a support report, or a Blip Reply answer or clip. */ purpose?: "blip" | "support" | "reply" | "thread"; share: { code: string; enabled: boolean; url: string; embed_url: string; thumbnail_url: string }; /** With player: true, a signed player address for your own team (works with the share link off; expires). */ player_url?: string | null; } /** A link to Blip's recorder. Whoever opens it signs in to Blip and must be in your workspace. Lasts 2 hours unless asked. */ export const recorderUrl = (cfg: BlipConfig, o: { ref?: string; title?: string; expires_in?: number } = {}) => call<{ url: string; expires_at: string }>(cfg, "POST", "recorder-links", o); export const listVideos = (cfg: BlipConfig, q: { ref?: string; ref_prefix?: string; purpose?: "blip" | "support" | "reply" | "thread"; limit?: number; before?: string; player?: boolean } = {}) => call<{ videos: BlipVideo[]; next_before: string | null }>(cfg, "GET", `videos${query({ ...q, player: q.player ? 1 : undefined })}`); export const getVideo = (cfg: BlipConfig, id: string, o: { player?: boolean } = {}) => call<{ video: BlipVideo }>(cfg, "GET", `videos/${encodeURIComponent(id)}${o.player ? "?player=1" : ""}`); /** Rename a Blip, set or clear its ref, or turn its share link on or off (2.8). */ export const updateVideo = (cfg: BlipConfig, id: string, p: { title?: string; ref?: string | null; share_enabled?: boolean }) => call<{ video: BlipVideo }>(cfg, "PATCH", `videos/${encodeURIComponent(id)}`, p); /** Delete a Blip: its link stops and its files go (2.8). */ export const deleteVideo = (cfg: BlipConfig, id: string) => call<{ deleted: true }>(cfg, "DELETE", `videos/${encodeURIComponent(id)}`); export interface BlipTranscript { /** queued and running: coming. done: has words. no_speech, failed, none: won't. */ state: "queued" | "running" | "done" | "no_speech" | "failed" | "none"; language: string | null; text: string; segments: { start: number; end: number; text: string }[]; summary: string | null; chapters: { start: number; title: string }[]; action_items: { title: string; description: string; quote: string | null }[]; } /** A Blip's transcript, summary, chapters and action items (2.8). */ export const getTranscript = (cfg: BlipConfig, id: string) => call<{ transcript: BlipTranscript }>(cfg, "GET", `videos/${encodeURIComponent(id)}/transcript`); export interface BlipComment { id: string; body: string; at_seconds: number | null; created_at: string; /** guest: typed by a visitor, so not a confirmed name. */ author: { name: string | null; guest: boolean }; task: { label: string; url: string } | null; } /** A Blip's comments, oldest first (2.8). */ export const listComments = (cfg: BlipConfig, id: string) => call<{ comments: BlipComment[] }>(cfg, "GET", `videos/${encodeURIComponent(id)}/comments`); /** Views, viewers, minutes watched, finishes, button clicks and reactions. The owner's own watching doesn't count (2.8). */ export const getVideoStats = (cfg: BlipConfig, id: string) => call<{ stats: { views: number; viewers: number; minutes_watched: number; completed: number; button_clicks: number; reactions: Record } }>(cfg, "GET", `videos/${encodeURIComponent(id)}/stats`); export const createLink = (cfg: BlipConfig, l: { video_id: string; name: string; email?: string; ref?: string; expires_at?: string }) => call<{ id: string; url: string }>(cfg, "POST", "links", l); export interface BlipRequest { id: string; ref: string | null; label: string | null; recipient_name: string | null; note: string | null; due_at: string | null; expires_at: string; single_use: boolean; max_seconds: number; received_count: number; received_at: string | null; created_at: string; requested_by: { id: string; name: string | null } | null; state: "open" | "received" | "expired" | "revoked"; latest_video_id: string | null; } /** * A link anyone can record on, no account. `label` is what they see it's for. The url comes back this once. Made with an * app key, it opens inside your page from recorder.js (open: "page") on the app's sites; "window" only where your * workspace allows windows and the app lists no sites. Emailed or pasted, it's a page of its own either way. */ export const createRequest = ( cfg: BlipConfig, r: { ref: string; label?: string; note?: string; recipient_name?: string; due_at?: string | null; expires_in_days?: number; multi_use?: boolean; max_minutes?: number; requested_by?: string }, ) => call<{ id: string; url: string; open: "page" | "window" }>(cfg, "POST", "requests", r); export const listRequests = (cfg: BlipConfig, q: { ref?: string; ref_prefix?: string } = {}) => call<{ requests: BlipRequest[] }>(cfg, "GET", `requests${query(q)}`); /** A new link for a request; the old one stops. */ export const newRequestLink = (cfg: BlipConfig, id: string) => call<{ url: string; open: "page" | "window" }>(cfg, "POST", `requests/${encodeURIComponent(id)}/new-link`); export const revokeRequest = (cfg: BlipConfig, id: string) => call<{ ok: true }>(cfg, "POST", `requests/${encodeURIComponent(id)}/revoke`); // ---- Threads ----------------------------------------------------------------------------------------------------------- // A DM between a student and your team, kept in order: video, voice or text both ways, a coach or assistant (or the // AI) answering. People are yours, by your own ids. Needs an app key with threads:read and threads:write, and Threads // switched on for the app. And groups (2.21): one place where several students and your team talk together. export type BlipThreadRole = "student" | "coach" | "assistant"; export interface BlipThreadPerson { id: string; name: string; email: string | null; role: BlipThreadRole; active: boolean; } export interface BlipThread { id: string; /** "group" (2.21): several students and your team in one place. It has no student, is never waiting and is nobody's. */ kind: "dm" | "topic" | "group"; /** Whose it is. Null for a group. */ student: BlipThreadPerson | null; /** A topic's title, or a group's name. */ title: string | null; ref: string | null; /** A topic: the DM or group it was split out of, and the message it started from. */ parent_id: string | null; from_message_id: string | null; assigned_to: BlipThreadPerson | null; /** Since when the student has been waiting on your team, or null. */ waiting_since: string | null; answered_at: string | null; answered_by: BlipThreadPerson | null; messages: number; last_message_at: string | null; last_preview: string | null; created_at: string; /** * Where Blip's AI stands on the thread's latest question (2.20), or null: "answered", "unknown" (it said your * material doesn't have it), "draft" (one waits for your team: `getThreadDraft`), "nothing" (draft mode, and the * material had nothing), "no_material", "allowance" (this month's AI answers are used up) or "failed". */ ai: { state: "answered" | "unknown" | "draft" | "nothing" | "no_material" | "allowance" | "failed"; at: string | null } | null; /** When the student asked for a person instead of the AI (2.20); null once your team has written. */ person_asked_at: string | null; /** A group (2.21): how many students are in it (your team is in every group), and when it was archived. Null otherwise. */ member_count: number | null; archived_at: string | null; /** With `for` on `listThreads` or `getThread` (2.21): how many messages from others that person hasn't read, counted to 100. */ unread?: number; } /** A document Blip's AI answered from (2.20). */ export interface BlipThreadSource { id: string; title: string; kind: "text" | "video"; /** kind "video": the Blip whose transcript it is. */ video_id: string | null; } /** A voice note: sound only, kept as it was recorded. No video is made, and it doesn't use video minutes. */ export interface BlipThreadVoice { /** Its length. What the sender said until it has been heard, then what was measured. */ seconds: number; /** What was said, once `transcript_state` is "done". */ transcript: string | null; /** "pending" for a few seconds after it's sent; then "done", "none" (nobody spoke) or "failed". */ transcript_state: "pending" | "done" | "none" | "failed"; /** Where it plays from (an