diff --git a/cycle-log.md b/cycle-log.md index 6d375c4..352d695 100644 --- a/cycle-log.md +++ b/cycle-log.md @@ -875,3 +875,55 @@ Backend->Client->Review workflow; the review's 2 real HIGH gaps were then fixed covered by mocked transport (WS + fetch) unit tests — not exercised against live services (no tokens). - Follow-ups: WhatsApp two-way (webhook endpoint + tunnel decision); a pipeline-notify trigger that calls handler.send; Signal (local signal-cli) if wanted; Discord live verification with a real bot token. + +## Scan-to-connect — QR channel onboarding (Telegram + Discord), dependency-free v1 — SHIPPED +- Priority-0 ask from Omar: "set up Telegram/WhatsApp/Discord (or ANY channel) just by reading a QR from the + UI — as easy as possible — and the QR also shows in the CLI. Check how OpenClaw did it." SPEC at + `specs/scan-to-connect.md` (local/gitignored). Issue-capped: this entry + the commit are the record (Rule 11). +- RESEARCH (OpenClaw, public): their `openclaw qr` is DEVICE pairing (mobile app <-> gateway, an opaque + bootstrapToken in a QR), NOT channel onboarding. Per-channel reality on their integrations page: WhatsApp = + "QR pairing via Baileys", Telegram = "Bot API via grammY" (NO QR), Discord = bot token (NO QR), Signal = + signal-cli. The asymmetry that shaped the spec: only PERSONAL-account channels (WhatsApp-Web/Signal) are + fully QR-pairable, and they need heavy/reverse-engineered libs; bot-token channels (Telegram/Discord) can't + replace the token with a QR — but a QR CAN do the painful part (auto-capturing the chat). Omar chose the + dependency-free "bot-token + QR-chat-link" path for v1 (Baileys WhatsApp-Web greenlit as a separate follow-up). +- THE WIN (Telegram, flagship): a QR of `https://t.me/?start=` — scan, tap Start, and the bot's + long-poll receives `/start `, so Vesper captures the chat id AUTOMATICALLY (no copying ids). The bot + token is still a one-time stdin/CLI step; the QR handles the genuinely hard part. Discord is the analogue: + an OAuth2 invite-URL QR (`&state=` so the nonce survives OAuth) + a first `pair ` message + captures the target channel id. +- ARCHITECTURE: an OPTIONAL `Pairable` capability a `ChannelHandler` may also implement (`startPairing(deps) + -> PairingSession` streaming `PairingUpdate`s: awaiting/linked/error/expired). Handlers stay decoupled from + the daemon via an injected `subscribeInbound` seam. A daemon-side `PairingCoordinator` owns the SINGLE + inbound long-poll and multiplexes it (via `tap(sink)`) to BOTH the chat sink and any active pairing session + — so pairing never opens a second `getUpdates` consumer (Telegram allows only one). A configured-but-not- + running channel gets a TRANSIENT receive loop for the pairing window only. On `linked` the captured chat id + is persisted as the non-secret `params.defaultChatId` and the channel is enabled (then "restart to apply", + the same contract as `connections set`). Token NEVER transits the pairing path; only nonces/links/QRs + the + chat id, and audit redacts `nonce`/`qr`. +- QR everywhere from ONE encoder: ported the public-domain Nayuki QR generator into `vesper-core/media/qr.ts` + (zero deps — cross-checked BYTE-FOR-BYTE vs upstream across modes/ECC/versions) + a half-block ANSI terminal + renderer. CLI `vesper connections pair ` renders the QR in the terminal and streams status to "Linked!". + UI (`channels.ts`) draws the same matrix on a canvas via a new `GET /api/qr?data=` (the browser can't import + the @vesper/core barrel — it pulls bun:sqlite). Both consume ONE uniform transport: `POST + /api/connections/:id/pair` -> `application/x-ndjson` stream of PairingUpdates (close = cancel via req.signal). +- DEVIATIONS (all deliberate): (1) uniform `params.defaultChatId` for both channels — Discord's chatId IS its + channel id — instead of the spec's separate `defaultChannelId`. (2) Decision-5 (UI bot-token entry over + loopback) SCOPED OUT: doing it SECURELY needs the existing out-of-band approval-code gate (like template + edits), which undercuts "easy" — so token bootstrap stays CLI and the UI does QR pairing only; the + unconfigured-channel hint points at `vesper connections set`. (3) transient-receiver pairing relies on the + existing "restart to apply" UX (no live hot-registration in v1). (4) the running-channel tap also forwards + the `/start ` message to the chatbot (cosmetic; a filter is a possible follow-up). +- PARALLELISM: lead-owned integration (B types, C Telegram, D coordinator+endpoint+CLI) while 3 file-disjoint + sub-agents owned the QR-encoder port (A), Discord pairing (F), and the UI Connect card (E). The lead ran + integrated biome + bun test + tsc + a REVIEW pass; fixed 3 NEW tsc errors the agents introduced under + strict/exactOptional (partial Vault doubles; a void-returning ChatSink callback; closure narrowing of a + module-const descriptor). CI gate (biome + bun test) is GREEN; tsc is a manual self-check (CI skips it — + pre-existing `as`-cast/exactOptional errors in unchanged code remain, none new from this work). +- Verified: 854 tests / 0 fail (+~40); biome clean; tsc adds 0 new errors; ZERO new dependencies (lockfile has + no baileys/provider SDK); every network + inbound seam mocked (no live tokens exercised). Not yet validated + against a real Telegram/Discord bot (no tokens) — the end-to-end scan is covered by unit + endpoint tests. +- DELEGATED (next cycle): WhatsApp-Web via Baileys (Omar greenlit) — a new opt-in `@vesper/channel-whatsapp-web` + package (Baileys isolated + lazy-imported so core stays dep-free) + a rotating-QR pairing session + the + `.ai/context.md` amendment carving out that one dependency. Also: Signal (signal-cli, real QR device-link); + optionally browser token entry behind the approval gate; filtering the pairing `/start` message from the chat. diff --git a/packages/vesper-cli/src/commands/connections-pair.test.ts b/packages/vesper-cli/src/commands/connections-pair.test.ts new file mode 100644 index 0000000..6051094 --- /dev/null +++ b/packages/vesper-cli/src/commands/connections-pair.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import type { PairingUpdate } from "@vesper/core"; +import { runPairing } from "./connections.ts"; + +async function* seq(...updates: PairingUpdate[]): AsyncGenerator { + for (const u of updates) yield u; +} + +describe("runPairing", () => { + test("renders a scannable QR + hint on awaiting and returns 0 on linked", async () => { + const out: string[] = []; + const code = await runPairing( + seq( + { + status: "awaiting", + prompt: { + kind: "link", + data: "https://t.me/vesperbot?start=abc", + humanHint: "scan me with your phone", + expiresAt: 1, + }, + }, + { status: "linked", chatId: "42", label: "omar" }, + ), + (s) => out.push(s), + ); + expect(code).toBe(0); + const joined = out.join("\n"); + expect(joined).toContain("scan me with your phone"); + expect(joined).toContain("https://t.me/vesperbot?start=abc"); + expect(joined.toLowerCase()).toContain("linked"); + // A QR grid was actually rendered (half-block glyphs present). + expect(joined).toMatch(/[█▀▄]/); + }); + + test("returns 1 on error", async () => { + const code = await runPairing(seq({ status: "error", reason: "nope" }), () => {}); + expect(code).toBe(1); + }); + + test("returns 1 on expired", async () => { + const code = await runPairing(seq({ status: "expired" }), () => {}); + expect(code).toBe(1); + }); +}); diff --git a/packages/vesper-cli/src/commands/connections.ts b/packages/vesper-cli/src/commands/connections.ts index b6e3a4f..cb8c82b 100644 --- a/packages/vesper-cli/src/commands/connections.ts +++ b/packages/vesper-cli/src/commands/connections.ts @@ -16,11 +16,15 @@ import { type ChannelState, channelById, channelStates, + encodeQr, KeychainVault, + type PairingUpdate, + renderQrTerminal, type Vault, } from "@vesper/core"; import { type ConnectionConfig, loadConfig, saveConfig, type VesperConfig } from "../config.ts"; import type { Command, CommandGroup } from "../dispatch.ts"; +import { uiPort } from "../paths.ts"; import { dim, green, line, table, yellow } from "../ui.ts"; /** Injectable seams so the command logic is unit-testable (no Keychain, no disk). */ @@ -153,6 +157,58 @@ export async function sendVia( return displayName; } +/** Convert the daemon's `application/x-ndjson` pairing stream into PairingUpdates. */ +async function* ndjsonUpdates(body: ReadableStream): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let nl = buffer.indexOf("\n"); + while (nl >= 0) { + const chunk = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (chunk.length > 0) yield JSON.parse(chunk) as PairingUpdate; + nl = buffer.indexOf("\n"); + } + } + const tail = buffer.trim(); + if (tail.length > 0) yield JSON.parse(tail) as PairingUpdate; +} + +/** + * Render a pairing stream to the terminal: a scannable QR + plain hint while awaiting, + * a success line on link. Returns the exit code (0 linked, 1 otherwise). Pure over its + * `print` seam so it is unit-testable without a daemon. + */ +export async function runPairing( + updates: AsyncIterable, + print: (text: string) => void, +): Promise { + for await (const update of updates) { + if (update.status === "awaiting") { + print(renderQrTerminal(encodeQr(update.prompt.data))); + print(""); + print(update.prompt.humanHint); + print(dim(update.prompt.data)); + print(dim("Waiting for you to scan...")); + } else if (update.status === "linked") { + const where = update.chatId !== undefined ? ` (chat ${update.chatId})` : ""; + print(green(`Linked!${where}`)); + return 0; + } else if (update.status === "error") { + print(yellow(`Pairing failed: ${update.reason}`)); + return 1; + } else { + print(yellow("Pairing expired before a scan completed. Run the command again.")); + return 1; + } + } + return 1; +} + function yesNo(value: boolean): string { return value ? green("yes") : dim("no"); } @@ -219,6 +275,31 @@ const sendCommand: Command = { }, }; +const pairCommand: Command = { + name: "pair", + summary: "Scan a QR to connect a channel (auto-captures your chat). Daemon must be running.", + usage: "vesper connections pair ", + async run({ positionals }) { + const id = positionals[0]; + if (id === undefined) throw new Error("usage: vesper connections pair "); + const base = `http://127.0.0.1:${uiPort()}`; + let res: Response; + try { + res = await fetch(`${base}/api/connections/${encodeURIComponent(id)}/pair`, { + method: "POST", + headers: { origin: base }, + }); + } catch { + throw new Error("could not reach the daemon — start it with `vesper daemon start`"); + } + if (!res.ok || res.body === null) { + const detail = (await res.text().catch(() => "")).trim(); + throw new Error(`pairing request failed (${res.status})${detail ? `: ${detail}` : ""}`); + } + return runPairing(ndjsonUpdates(res.body), line); + }, +}; + const testCommand: Command = { name: "test", summary: "Authenticate a channel's stored credential (e.g. Telegram getMe).", @@ -262,6 +343,14 @@ const disableCommand: Command = { export const connectionsGroup: CommandGroup = { name: "connections", - summary: "Connect messaging channels (Telegram) so the chatbot is reachable remotely.", - subcommands: [listCommand, setCommand, testCommand, sendCommand, enableCommand, disableCommand], + summary: "Connect messaging channels (scan a QR to pair) so the chatbot is reachable remotely.", + subcommands: [ + listCommand, + setCommand, + pairCommand, + testCommand, + sendCommand, + enableCommand, + disableCommand, + ], }; diff --git a/packages/vesper-cli/src/commands/daemon-run.ts b/packages/vesper-cli/src/commands/daemon-run.ts index 077c394..db823da 100644 --- a/packages/vesper-cli/src/commands/daemon-run.ts +++ b/packages/vesper-cli/src/commands/daemon-run.ts @@ -15,10 +15,11 @@ import { grantedCapabilities, PIPELINES, registerPipelines } from "@vesper/pipel import { presenceDetectorFor, startUiServer } from "@vesper/ui"; import { machineFingerprint } from "../banner.ts"; import { makeCompleteFn } from "../cli-resolver.ts"; -import { loadConfig } from "../config.ts"; +import { loadConfig, saveConfig } from "../config.ts"; import { buildChannelRegistry, makeChannelSink } from "../connections-wiring.ts"; import { removePidFile, resolveDaemonState, writePidFile } from "../daemon-lifecycle.ts"; import type { Command } from "../dispatch.ts"; +import { PairingCoordinator } from "../pairing-coordinator.ts"; import { dbPath, pidPath, runDir, socketPath, uiPort } from "../paths.ts"; import { dim, green, line, yellow } from "../ui.ts"; @@ -99,6 +100,17 @@ export const daemonRunCommand: Command = { vault, store: uiStore, }); + // Pairing (scan-to-connect): the coordinator multiplexes the daemon's single + // inbound stream into active QR/link pairing sessions and persists the captured + // chat id on link. Exposed to the UI's POST /api/connections/:id/pair route and + // consumed by `vesper connections pair`. + const pairing = new PairingCoordinator({ + registry: channels.registry, + vault, + load: () => loadConfig(), + save: (next) => saveConfig(next), + store: uiStore, + }); const ui = await startUiServer({ scheduler, store: uiStore, @@ -118,13 +130,14 @@ export const daemonRunCommand: Command = { runningIds: channels.runningIds, }), }, + pairing, ...(config.presence?.pollMs !== undefined ? { presencePollMs: config.presence.pollMs } : {}), ...(config.ui?.theme !== undefined ? { defaultTheme: config.ui.theme } : {}), }); // The UI (the chat sink's POST target) is now listening — start the inbound loops. const channelStop = channels.registry.startAll( - makeChannelSink({ baseUrl: ui.url, registry: channels.registry }), + pairing.tap(makeChannelSink({ baseUrl: ui.url, registry: channels.registry })), ); if (channels.runningIds.length > 0) { line(dim(` channels: ${channels.runningIds.join(", ")}`)); diff --git a/packages/vesper-cli/src/pairing-coordinator.test.ts b/packages/vesper-cli/src/pairing-coordinator.test.ts new file mode 100644 index 0000000..9d08e76 --- /dev/null +++ b/packages/vesper-cli/src/pairing-coordinator.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test"; +import { + type ChannelDescriptor, + type ChannelHandler, + ChannelRegistry, + type ChatSink, + channelById, + type InboundMessage, + type Pairable, + type PairingUpdate, + type Stoppable, + type Vault, +} from "@vesper/core"; +import type { VesperConfig } from "./config.ts"; +import { PairingCoordinator } from "./pairing-coordinator.ts"; + +function telegramDescriptor(): ChannelDescriptor { + const descriptor = channelById("telegram"); + if (descriptor === undefined) throw new Error("telegram descriptor missing from catalog"); + return descriptor; +} +const TELEGRAM = telegramDescriptor(); + +const baseConfig = { cli: { default: null } } as unknown as VesperConfig; + +function fakeVault(entries: Record = {}): Vault { + return { + async get(key) { + const v = entries[key]; + if (v === undefined) throw new Error(`no such key ${key}`); + return v; + }, + async set() {}, + async delete() {}, + async list() { + return Object.keys(entries).sort(); + }, + }; +} + +/** A fake handler that pairs by watching inbound for the literal text "LINK". */ +function fakePairable(): ChannelHandler & Pairable { + return { + descriptor: TELEGRAM, + authenticate: async () => {}, + send: async () => {}, + receive: () => ({ stop() {} }), + startPairing(deps) { + let settle!: (u: PairingUpdate) => void; + const outcome = new Promise((r) => { + settle = r; + }); + let sub: Stoppable | undefined; + return { + updates: async function* () { + yield { + status: "awaiting", + prompt: { kind: "link", data: "deeplink", humanHint: "scan", expiresAt: 1 }, + }; + sub = deps.subscribeInbound?.((m) => { + if (m.text === "LINK") settle({ status: "linked", chatId: m.chatId, label: m.from }); + }); + const final = await outcome; + sub?.stop(); + yield final; + }, + stop() { + sub?.stop(); + settle({ status: "expired" }); + }, + }; + }, + }; +} + +/** A fake handler with no pairing capability. */ +function fakePlain(): ChannelHandler { + return { + descriptor: TELEGRAM, + authenticate: async () => {}, + send: async () => {}, + receive: () => ({ stop() {} }), + }; +} + +const tick = (): Promise => new Promise((r) => setTimeout(r, 0)); + +describe("PairingCoordinator", () => { + test("reuses a running handler, multiplexes inbound, and persists defaultChatId on link", async () => { + const registry = new ChannelRegistry([fakePairable()]); + let saved: VesperConfig | undefined; + const coordinator = new PairingCoordinator({ + registry, + vault: fakeVault(), + load: async () => baseConfig, + save: async (c) => { + saved = c; + }, + }); + + const sinkCalls: InboundMessage[] = []; + const realSink: ChatSink = async (m) => { + sinkCalls.push(m); + }; + const tapped = coordinator.tap(realSink); + + const session = await coordinator.startPairing("telegram"); + const it = session.updates()[Symbol.asyncIterator](); + const first = await it.next(); + expect(first.value).toMatchObject({ status: "awaiting" }); + + const secondP = it.next(); + await tick(); + await tapped({ channel: "telegram", chatId: "55", from: "omar", text: "LINK", ts: 1 }); + const second = await secondP; + expect(second.value).toEqual({ status: "linked", chatId: "55", label: "omar" }); + + // The real chat sink ALSO saw the message (single long-poll, multiplexed), + expect(sinkCalls).toHaveLength(1); + // and the captured chat id was persisted + the channel enabled. + expect(saved?.connections?.telegram).toMatchObject({ + enabled: true, + params: { defaultChatId: "55" }, + }); + }); + + test("unknown channel yields an error session", async () => { + const coordinator = new PairingCoordinator({ + registry: new ChannelRegistry(), + vault: fakeVault(), + load: async () => baseConfig, + save: async () => {}, + }); + const session = await coordinator.startPairing("nope"); + const first = await session.updates()[Symbol.asyncIterator]().next(); + expect(first.value).toMatchObject({ status: "error" }); + }); + + test("a running but non-pairable handler yields an error session", async () => { + const coordinator = new PairingCoordinator({ + registry: new ChannelRegistry([fakePlain()]), + vault: fakeVault(), + load: async () => baseConfig, + save: async () => {}, + }); + const session = await coordinator.startPairing("telegram"); + const first = await session.updates()[Symbol.asyncIterator]().next(); + expect(first.value).toMatchObject({ status: "error" }); + }); +}); diff --git a/packages/vesper-cli/src/pairing-coordinator.ts b/packages/vesper-cli/src/pairing-coordinator.ts new file mode 100644 index 0000000..64096da --- /dev/null +++ b/packages/vesper-cli/src/pairing-coordinator.ts @@ -0,0 +1,192 @@ +/** + * Daemon-side pairing coordinator for scan-to-connect (QR/link onboarding). + * + * The daemon owns a single inbound long-poll per channel; pairing must observe that + * stream WITHOUT opening a second consumer. {@link PairingCoordinator.tap} wraps the + * chat sink so every inbound message ALSO feeds active pairing sessions, and + * {@link PairingCoordinator.startPairing} dispatches to a channel handler's optional + * `Pairable` capability with a `subscribeInbound` seam backed by that multiplex. + * + * - A channel already running in the daemon registry is reused (its live receive loop + * feeds the tap). A configured-but-not-running channel gets a TRANSIENT receive loop + * for the pairing window only, stopped when the session ends. + * - On `linked`, the captured chat id is persisted as the channel's non-secret + * `params.defaultChatId` and the channel is enabled in config (a restart activates a + * not-yet-running channel — the same "restart to apply" contract as `connections set`). + * - The token never transits this path; only nonces/links/QRs and the resulting chat id. + */ + +import { + CHANNEL_GRANTS, + type ChannelHandler, + type ChannelId, + type ChannelRegistry, + type ChatSink, + type ConnectionEventKind, + channelById, + channelPluginById, + type FetchFn, + type InboundMessage, + isPairable, + type PairingSession, + type PairingUpdate, + recordConnectionEvent, + type Stoppable, + type Store, + type Vault, +} from "@vesper/core"; +import type { ConnectionConfig, VesperConfig } from "./config.ts"; + +type InboundListener = (message: InboundMessage) => void; + +/** Dependencies for the {@link PairingCoordinator}. */ +export interface PairingCoordinatorDeps { + /** The daemon's live channel registry (reused when a channel is already running). */ + readonly registry: ChannelRegistry; + readonly vault: Vault; + readonly load: () => Promise; + readonly save: (config: VesperConfig) => Promise; + /** Audit sink (the daemon store); omitted in unit tests. */ + readonly store?: Store; + /** Injected fetch so tests reach no network; omit to use the handler's real fetch. */ + readonly fetchFn?: FetchFn; +} + +/** A PairingSession that emits a single error then ends (for fail-fast preconditions). */ +function errorSession(reason: string): PairingSession { + return { + updates: async function* () { + yield { status: "error", reason }; + }, + stop() {}, + }; +} + +/** Immutably set one channel's wiring in the config (mirrors `connections.ts`). */ +function withConnection(config: VesperConfig, id: string, conn: ConnectionConfig): VesperConfig { + return { ...config, connections: { ...(config.connections ?? {}), [id]: conn } }; +} + +export class PairingCoordinator { + readonly #deps: PairingCoordinatorDeps; + readonly #listeners = new Set(); + + constructor(deps: PairingCoordinatorDeps) { + this.#deps = deps; + } + + /** Wrap the chat sink so inbound ALSO feeds active pairing sessions (single long-poll). */ + tap(realSink: ChatSink): ChatSink { + return async (message) => { + this.#notify(message); + await realSink(message); + }; + } + + #notify(message: InboundMessage): void { + for (const listener of this.#listeners) listener(message); + } + + #subscribe(on: InboundListener): Stoppable { + this.#listeners.add(on); + return { + stop: () => { + this.#listeners.delete(on); + }, + }; + } + + /** Begin a pairing attempt for one channel; returns a streamed session. */ + async startPairing(id: string): Promise { + const descriptor = channelById(id); + if (descriptor === undefined) return errorSession(`unknown channel "${id}"`); + + const config = await this.#deps.load(); + const conn = config.connections?.[id]; + const vaultKey = conn?.vaultKey ?? descriptor.vaultKeys[0]; + if (vaultKey === undefined) return errorSession(`channel "${id}" declares no vault key`); + + // Reuse the running handler if the daemon already receives this channel; otherwise + // build a transient one and feed only the pairing listeners for the pairing window. + let handler: ChannelHandler | undefined = this.#deps.registry.byId(id as ChannelId); + let transient: Stoppable | undefined; + if (handler === undefined) { + const plugin = channelPluginById(id); + if (plugin === undefined) return errorSession(`channel "${id}" has no handler yet`); + const built = plugin.build({ + granted: CHANNEL_GRANTS, + vaultKey, + allowedHosts: conn?.allowedHosts ?? descriptor.allowedHosts, + ...(conn?.params !== undefined ? { params: conn.params } : {}), + ...(this.#deps.fetchFn !== undefined ? { fetchFn: this.#deps.fetchFn } : {}), + }); + try { + await built.authenticate(this.#deps.vault); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + return errorSession(`cannot authenticate "${id}": ${reason}`); + } + handler = built; + transient = built.receive(async (message) => { + this.#notify(message); + }); + } + + if (!isPairable(handler)) { + transient?.stop(); + return errorSession(`channel "${id}" does not support QR pairing`); + } + + this.#record("connection_pairing_started", { channel: id, vaultKey }); + + const inner = handler.startPairing({ + vault: this.#deps.vault, + subscribeInbound: (on) => this.#subscribe(on), + }); + + const onUpdate = (update: PairingUpdate): Promise => this.#onUpdate(id, vaultKey, update); + return { + updates: async function* () { + try { + for await (const update of inner.updates()) { + await onUpdate(update); + yield update; + } + } finally { + transient?.stop(); + } + }, + stop: () => { + inner.stop(); + transient?.stop(); + }, + }; + } + + async #onUpdate(id: string, vaultKey: string, update: PairingUpdate): Promise { + if (update.status === "linked") { + if (update.chatId !== undefined) await this.#persistLinked(id, vaultKey, update.chatId); + this.#record("connection_paired", { channel: id, vaultKey, chatId: update.chatId }); + } else if (update.status === "error" || update.status === "expired") { + this.#record("connection_pairing_failed", { channel: id, outcome: update.status }); + } + } + + /** Persist the captured chat id as `params.defaultChatId` and enable the channel. */ + async #persistLinked(id: string, vaultKey: string, chatId: string): Promise { + const config = await this.#deps.load(); + const descriptor = channelById(id); + const existing = config.connections?.[id]; + const conn: ConnectionConfig = { + enabled: true, + vaultKey, + allowedHosts: existing?.allowedHosts ?? descriptor?.allowedHosts ?? [], + params: { ...existing?.params, defaultChatId: chatId }, + }; + await this.#deps.save(withConnection(config, id, conn)); + } + + #record(kind: ConnectionEventKind, payload: Record): void { + if (this.#deps.store !== undefined) recordConnectionEvent(this.#deps.store, kind, payload); + } +} diff --git a/packages/vesper-core/src/connections/audit.ts b/packages/vesper-core/src/connections/audit.ts index 918c972..8611e86 100644 --- a/packages/vesper-core/src/connections/audit.ts +++ b/packages/vesper-core/src/connections/audit.ts @@ -14,6 +14,9 @@ export type ConnectionEventKind = | "connection_connected" | "connection_disconnected" | "connection_send_failed" + | "connection_pairing_started" + | "connection_paired" + | "connection_pairing_failed" | "mcp_enabled" | "mcp_disabled"; @@ -31,6 +34,8 @@ const REDACTED_KEYS: ReadonlySet = new Set([ "text", "message", "body", + "nonce", + "qr", ]); /** Drop any secret/message-body field from an audit payload (shallow). */ diff --git a/packages/vesper-core/src/connections/discord-pairing.test.ts b/packages/vesper-core/src/connections/discord-pairing.test.ts new file mode 100644 index 0000000..5bd182c --- /dev/null +++ b/packages/vesper-core/src/connections/discord-pairing.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from "bun:test"; +import type { Capability } from "../capabilities/index.ts"; +import type { Vault } from "../vault/index.ts"; +import { DiscordHandler } from "./discord.ts"; +import type { FetchFn } from "./fetch.ts"; +import type { InboundMessage, Stoppable } from "./types.ts"; + +const GRANTED: readonly Capability[] = ["NETWORK_FETCH", "READ_VAULT"]; + +/** A vault double: `get` returns the token; the rest are no-ops (the handler only reads). */ +function vaultWith(token: string): Vault { + return { + get: async () => token, + set: async () => {}, + delete: async () => {}, + async list() { + return []; + }, + }; +} + +/** + * Script the REST seam by the last path segment. Discord returns the object + * DIRECTLY (no `{ok,result}` envelope), so each responder value is the raw body. + */ +function scriptedFetch(responder: (segment: string) => unknown): FetchFn { + return async (input) => { + const segment = input.split("?")[0]?.split("/").pop() ?? ""; + return new Response(JSON.stringify(responder(segment)), { + headers: { "content-type": "application/json" }, + }); + }; +} + +/** A controllable inbound bus: a `subscribeInbound` seam plus an `emit` to push messages. */ +function inboundBus() { + const listeners = new Set<(m: InboundMessage) => void>(); + let stoppedFlag = false; + const subscribeInbound = (on: (m: InboundMessage) => void): Stoppable => { + listeners.add(on); + return { + stop() { + listeners.delete(on); + stoppedFlag = true; + }, + }; + }; + return { + subscribeInbound, + emit: (m: InboundMessage) => { + for (const l of listeners) l(m); + }, + stopped: () => stoppedFlag, + }; +} + +/** REST responder: `/users/@me` is a bot; `oauth2/applications/@me` returns the app id. */ +const restOk = (segment: string): unknown => (segment === "@me" ? { id: "self-1", bot: true } : {}); + +/** + * The handler resolves the app id via `GET /oauth2/applications/@me`; both that + * call and `/users/@me` end in the segment `@me`, so disambiguate by the FULL url. + */ +function appIdFetch(appId: string): FetchFn { + return async (input) => { + const body: unknown = input.includes("/oauth2/applications/@me") + ? { id: appId } + : { id: "self-1", bot: true }; + return new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }); + }; +} + +const stateFromLink = (link: string): string => new URL(link).searchParams.get("state") ?? ""; + +/** Flush microtasks + a macrotask so the generator registers its inbound subscription. */ +const tick = (): Promise => new Promise((r) => setTimeout(r, 0)); + +describe("DiscordHandler pairing", () => { + test("awaits an invite link, then auto-captures chatId on pair ", async () => { + const handler = new DiscordHandler({ granted: GRANTED, fetchFn: appIdFetch("111222") }); + await handler.authenticate(vaultWith("tok")); + + const bus = inboundBus(); + const session = handler.startPairing({ + vault: vaultWith("tok"), + subscribeInbound: bus.subscribeInbound, + }); + const it = session.updates()[Symbol.asyncIterator](); + + const first = await it.next(); + expect(first.value).toMatchObject({ status: "awaiting" }); + const prompt = (first.value as { prompt: { kind: string; data: string } }).prompt; + expect(prompt.kind).toBe("link"); + expect(prompt.data).toContain("https://discord.com/oauth2/authorize?"); + expect(prompt.data).toContain("client_id=111222"); + expect(prompt.data).toContain("permissions="); + const nonce = stateFromLink(prompt.data); + expect(nonce.length).toBeGreaterThan(0); + + const secondP = it.next(); + await tick(); + bus.emit({ channel: "discord", chatId: "c-42", from: "omar", text: `pair ${nonce}`, ts: 1 }); + const second = await secondP; + expect(second.value).toEqual({ status: "linked", chatId: "c-42", label: "omar" }); + // The inbound subscription is released once linked. + expect(bus.stopped()).toBe(true); + }); + + test("ignores a pair carrying the wrong nonce; stop() yields expired", async () => { + const handler = new DiscordHandler({ granted: GRANTED, fetchFn: appIdFetch("111222") }); + await handler.authenticate(vaultWith("tok")); + + const bus = inboundBus(); + const session = handler.startPairing({ + vault: vaultWith("tok"), + subscribeInbound: bus.subscribeInbound, + }); + const it = session.updates()[Symbol.asyncIterator](); + await it.next(); // awaiting + + const secondP = it.next(); + await tick(); + bus.emit({ channel: "discord", chatId: "c-1", from: "x", text: "pair WRONG", ts: 1 }); + session.stop(); + const second = await secondP; + expect(second.value).toEqual({ status: "expired" }); + }); + + test("errors when no inbound stream is provided", async () => { + const handler = new DiscordHandler({ granted: GRANTED, fetchFn: appIdFetch("111222") }); + await handler.authenticate(vaultWith("tok")); + const session = handler.startPairing({ vault: vaultWith("tok") }); + const first = await session.updates()[Symbol.asyncIterator]().next(); + expect(first.value).toMatchObject({ status: "error" }); + }); + + test("errors when the application id cannot be resolved", async () => { + const failingFetch: FetchFn = async (input) => + input.includes("/oauth2/applications/@me") + ? new Response("nope", { status: 500 }) + : new Response(JSON.stringify({ id: "self-1", bot: true }), { + headers: { "content-type": "application/json" }, + }); + const handler = new DiscordHandler({ granted: GRANTED, fetchFn: failingFetch }); + await handler.authenticate(vaultWith("tok")); + const bus = inboundBus(); + const session = handler.startPairing({ + vault: vaultWith("tok"), + subscribeInbound: bus.subscribeInbound, + }); + const first = await session.updates()[Symbol.asyncIterator]().next(); + expect(first.value).toMatchObject({ status: "error" }); + }); + + test("uses discord's direct-object REST envelope (no {ok,result} wrapper)", async () => { + // scriptedFetch returns the body directly; authenticate must accept {id, bot}. + const handler = new DiscordHandler({ granted: GRANTED, fetchFn: scriptedFetch(restOk) }); + await expect(handler.authenticate(vaultWith("tok"))).resolves.toBeUndefined(); + }); +}); diff --git a/packages/vesper-core/src/connections/discord.ts b/packages/vesper-core/src/connections/discord.ts index 088c982..6af15db 100644 --- a/packages/vesper-core/src/connections/discord.ts +++ b/packages/vesper-core/src/connections/discord.ts @@ -17,12 +17,17 @@ import { assertCapabilities, type Capability } from "../capabilities/index.ts"; import { channelById } from "./catalog.ts"; import { ConnectionError } from "./errors.ts"; import { allowlistedFetch, type FetchFn } from "./fetch.ts"; +import { newPairingNonce, PAIRING_TTL_MS } from "./pairing.ts"; import type { ChannelDescriptor, ChannelHandler, ChatSink, InboundMessage, OutboundIntent, + Pairable, + PairingDeps, + PairingSession, + PairingUpdate, Stoppable, } from "./types.ts"; @@ -45,6 +50,13 @@ const OP = { DISPATCH: 0, HEARTBEAT: 1, IDENTIFY: 2, RECONNECT: 7, INVALID_SESSI /** Delay before reconnecting after a dropped Gateway socket. */ const RECONNECT_DELAY_MS = 1_500; +/** + * Bot-invite permissions for the pairing URL: View Channel (1<<10), + * Send Messages (1<<11), Read Message History (1<<16) = 67648. The minimum a + * transport handler needs to read a channel and reply in it. + */ +const INVITE_PERMISSIONS = (1 << 10) | (1 << 11) | (1 << 16); + /** A minimal Gateway WebSocket — the subset the handler drives. Injected for tests. */ export interface GatewaySocket { send(data: string): void; @@ -87,6 +99,11 @@ interface DiscordUser { readonly bot?: boolean; } +/** The `GET /oauth2/applications/@me` result; `id` is the OAuth2 client id. */ +interface OAuth2Application { + readonly id: string; +} + interface DiscordMessage { readonly channel_id: string; readonly content?: string; @@ -115,7 +132,7 @@ function parsePayload(data: string): GatewayPayload | null { } } -export class DiscordHandler implements ChannelHandler { +export class DiscordHandler implements ChannelHandler, Pairable { readonly descriptor: ChannelDescriptor = DISCORD_DESCRIPTOR; readonly #granted: readonly Capability[]; readonly #fetchFn: FetchFn | undefined; @@ -124,6 +141,7 @@ export class DiscordHandler implements ChannelHandler { readonly #connect: GatewayConnect; #token: string | null = null; #selfId: string | null = null; + #appId: string | undefined = undefined; constructor(options: DiscordHandlerOptions) { this.#granted = options.granted; @@ -177,6 +195,88 @@ export class DiscordHandler implements ChannelHandler { await this.#rest("POST", `/channels/${intent.chatId}/messages`, { content: intent.text }); } + /** + * Scan-to-connect: resolve the bot's OAuth2 client id, build an + * `oauth2/authorize` invite URL (rendered as a QR), and wait for the bot's + * receive loop to see `pair ` in a channel — at which point that + * channel's id is captured automatically (no copying ids). Inbound is observed + * through the daemon-multiplexed {@link PairingDeps.subscribeInbound} seam, so + * pairing never opens a second Gateway consumer. The nonce rides the invite URL + * as a harmless `&state=` param (preserved through the OAuth2 flow) so the + * same value is shown in the QR and expected back in the channel. + */ + startPairing(deps: PairingDeps): PairingSession { + let stopped = false; + let subscription: Stoppable | undefined; + let timer: ReturnType | undefined; + let settle!: (update: PairingUpdate) => void; + const outcome = new Promise((resolve) => { + settle = resolve; + }); + + const cleanup = (): void => { + subscription?.stop(); + if (timer !== undefined) clearTimeout(timer); + }; + + const resolveAppId = async (): Promise => { + if (this.#appId !== undefined) return this.#appId; + try { + const app = await this.#rest("GET", "/oauth2/applications/@me"); + this.#appId = app.id; + return app.id; + } catch { + return undefined; + } + }; + + const run = async function* (): AsyncGenerator { + const appId = await resolveAppId(); + if (appId === undefined) { + yield { status: "error", reason: "could not resolve the discord application id" }; + return; + } + if (deps.subscribeInbound === undefined) { + yield { status: "error", reason: "no inbound stream available for discord pairing" }; + return; + } + const nonce = newPairingNonce(); + const expected = `pair ${nonce}`; + const inviteUrl = + `https://discord.com/oauth2/authorize?client_id=${appId}` + + `&scope=bot+applications.commands&permissions=${INVITE_PERMISSIONS}&state=${nonce}`; + yield { + status: "awaiting", + prompt: { + kind: "link", + data: inviteUrl, + humanHint: `Point your phone camera at this code to add the bot to your server, then type 'pair ${nonce}' in the channel you want Vesper to use.`, + expiresAt: Date.now() + PAIRING_TTL_MS, + }, + }; + if (stopped) return; + subscription = deps.subscribeInbound((message) => { + if (message.channel === "discord" && message.text.trim() === expected) { + settle({ status: "linked", chatId: message.chatId, label: message.from }); + } + }); + timer = setTimeout(() => settle({ status: "expired" }), PAIRING_TTL_MS); + const final = await outcome; + cleanup(); + yield final; + }; + + return { + updates: () => run(), + stop: () => { + if (stopped) return; + stopped = true; + cleanup(); + settle({ status: "expired" }); + }, + }; + } + /** Assert NETWORK_FETCH and that the Gateway host is allowlisted before connecting. */ #assertGatewayAllowed(): void { assertCapabilities(["NETWORK_FETCH"], this.#granted); diff --git a/packages/vesper-core/src/connections/index.ts b/packages/vesper-core/src/connections/index.ts index b2fb357..2eff0fe 100644 --- a/packages/vesper-core/src/connections/index.ts +++ b/packages/vesper-core/src/connections/index.ts @@ -26,6 +26,7 @@ export { allowlistedFetch, type FetchFn, } from "./fetch.ts"; +export { isPairable, newPairingNonce, PAIRING_TTL_MS } from "./pairing.ts"; export { CHANNEL_GRANTS, CHANNEL_PLUGINS, @@ -49,6 +50,11 @@ export type { ChatSink, InboundMessage, OutboundIntent, + Pairable, + PairingDeps, + PairingPrompt, + PairingSession, + PairingUpdate, Stoppable, } from "./types.ts"; export { WhatsAppHandler, type WhatsAppHandlerOptions } from "./whatsapp.ts"; diff --git a/packages/vesper-core/src/connections/pairing.test.ts b/packages/vesper-core/src/connections/pairing.test.ts new file mode 100644 index 0000000..d76e649 --- /dev/null +++ b/packages/vesper-core/src/connections/pairing.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { stripSensitive } from "./audit.ts"; +import { isPairable, newPairingNonce, PAIRING_TTL_MS } from "./pairing.ts"; +import { channelStates } from "./state.ts"; +import type { ChannelHandler } from "./types.ts"; + +const baseHandler = { + descriptor: { id: "telegram" }, + authenticate: async () => {}, + send: async () => {}, + receive: () => ({ stop() {} }), +} as unknown as ChannelHandler; + +describe("isPairable", () => { + test("true when the handler implements startPairing", () => { + const pairable = { + ...baseHandler, + startPairing: () => ({ stop() {}, updates: () => (async function* () {})() }), + } as unknown as ChannelHandler; + expect(isPairable(pairable)).toBe(true); + }); + + test("false when the handler omits startPairing", () => { + expect(isPairable(baseHandler)).toBe(false); + }); +}); + +describe("newPairingNonce", () => { + test("is Telegram-start safe, sized, and unique per call", () => { + const a = newPairingNonce(); + const b = newPairingNonce(); + expect(a).not.toBe(b); + expect(a).toMatch(/^[A-Za-z0-9_-]+$/); + expect(a.length).toBeGreaterThanOrEqual(20); + expect(a.length).toBeLessThanOrEqual(64); + }); +}); + +test("PAIRING_TTL_MS is a positive duration", () => { + expect(PAIRING_TTL_MS).toBeGreaterThan(0); +}); + +describe("channelStates pairable flag", () => { + test("telegram + discord are pairable; cloud whatsapp + signal are not", () => { + const states = channelStates({}); + const byId = (id: string) => states.find((s) => s.id === id); + expect(byId("telegram")?.pairable).toBe(true); + expect(byId("discord")?.pairable).toBe(true); + expect(byId("whatsapp")?.pairable).toBe(false); + expect(byId("signal")?.pairable).toBe(false); + }); +}); + +describe("audit redaction covers pairing secrets", () => { + test("nonce + qr are stripped while channel/chatId survive", () => { + const out = stripSensitive({ + channel: "telegram", + chatId: "123", + nonce: "abc123", + qr: "2@rotating-secret", + }); + expect(out).toEqual({ channel: "telegram", chatId: "123" }); + }); +}); diff --git a/packages/vesper-core/src/connections/pairing.ts b/packages/vesper-core/src/connections/pairing.ts new file mode 100644 index 0000000..f3f0ee4 --- /dev/null +++ b/packages/vesper-core/src/connections/pairing.ts @@ -0,0 +1,34 @@ +/** + * Runtime helpers for the pairing (scan-to-connect) capability. The pairing TYPES + * live in `types.ts` (alongside `ChannelHandler`); this module holds the small + * runtime pieces: the {@link isPairable} capability guard the daemon coordinator + * dispatches on, and a URL-safe nonce generator for chat-link deep links. + */ + +import type { ChannelHandler, Pairable } from "./types.ts"; + +/** + * Default pairing-prompt lifetime (ms). A Telegram deep link never expires on its + * own, but the SESSION must, so an abandoned pairing cannot linger holding the + * channel's receive loop. WhatsApp-Web rotates its own QR faster than this. + */ +export const PAIRING_TTL_MS = 5 * 60_000; + +/** Runtime guard: does a handler also implement the optional {@link Pairable} capability? */ +export function isPairable(handler: ChannelHandler): handler is ChannelHandler & Pairable { + return typeof (handler as Partial).startPairing === "function"; +} + +/** + * A URL-safe pairing nonce (base64url, no padding). Its charset (A-Z a-z 0-9 _ -) + * is a subset of the Telegram bot deep-link `start` parameter's allowed characters, + * so the same nonce embeds directly in `https://t.me/?start=` without + * escaping. + */ +export function newPairingNonce(byteLength = 16): string { + const bytes = new Uint8Array(byteLength); + crypto.getRandomValues(bytes); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} diff --git a/packages/vesper-core/src/connections/plugins.ts b/packages/vesper-core/src/connections/plugins.ts index 168880c..bf41148 100644 --- a/packages/vesper-core/src/connections/plugins.ts +++ b/packages/vesper-core/src/connections/plugins.ts @@ -35,6 +35,8 @@ export interface ChannelBuildOptions { /** A pluggable channel: an id + a factory that builds its handler. */ export interface ChannelPlugin { readonly id: ChannelId; + /** True when {@link build} returns a handler that also implements `Pairable` (QR onboarding). */ + readonly pairable?: boolean; build(opts: ChannelBuildOptions): ChannelHandler; } @@ -42,6 +44,7 @@ export interface ChannelPlugin { export const CHANNEL_PLUGINS: readonly ChannelPlugin[] = [ { id: "telegram", + pairable: true, build: (opts) => new TelegramHandler({ granted: opts.granted, @@ -52,6 +55,7 @@ export const CHANNEL_PLUGINS: readonly ChannelPlugin[] = [ }, { id: "discord", + pairable: true, build: (opts) => new DiscordHandler({ granted: opts.granted, diff --git a/packages/vesper-core/src/connections/state.ts b/packages/vesper-core/src/connections/state.ts index 4580da2..aec21f7 100644 --- a/packages/vesper-core/src/connections/state.ts +++ b/packages/vesper-core/src/connections/state.ts @@ -30,6 +30,8 @@ export interface ChannelState { readonly enabled: boolean; /** A handler for this channel is currently running in the daemon registry. */ readonly running: boolean; + /** The handler supports QR/link pairing (scan-to-connect). */ + readonly pairable: boolean; readonly docsUrl: string; readonly allowedHosts: readonly string[]; } @@ -45,7 +47,8 @@ export function channelStates(opts: { return CHANNEL_CATALOG.map((descriptor) => { const wiring = opts.wiring?.[descriptor.id]; const vaultKey = wiring?.vaultKey ?? descriptor.vaultKeys[0]; - const available = channelPluginById(descriptor.id) !== undefined; + const plugin = channelPluginById(descriptor.id); + const available = plugin !== undefined; return { id: descriptor.id, displayName: descriptor.displayName, @@ -55,6 +58,7 @@ export function channelStates(opts: { enabled: wiring?.enabled === true, // A channel with no shipped handler can never run, regardless of the input. running: available && running.has(descriptor.id), + pairable: plugin?.pairable === true, docsUrl: descriptor.docsUrl, allowedHosts: wiring?.allowedHosts ?? descriptor.allowedHosts, }; diff --git a/packages/vesper-core/src/connections/telegram-pairing.test.ts b/packages/vesper-core/src/connections/telegram-pairing.test.ts new file mode 100644 index 0000000..d680d49 --- /dev/null +++ b/packages/vesper-core/src/connections/telegram-pairing.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test"; +import type { Capability } from "../capabilities/index.ts"; +import type { Vault } from "../vault/index.ts"; +import type { FetchFn } from "./fetch.ts"; +import { TelegramHandler } from "./telegram.ts"; +import type { InboundMessage, Stoppable } from "./types.ts"; + +const GRANTED: readonly Capability[] = ["NETWORK_FETCH", "READ_VAULT"]; + +function fakeVault(entries: Record): Vault { + return { + async get(key) { + const v = entries[key]; + if (v === undefined) throw new Error(`no such key ${key}`); + return v; + }, + async set() {}, + async delete() {}, + async list() { + return Object.keys(entries).sort(); + }, + }; +} + +function scriptedFetch(responder: (method: string, body: unknown) => unknown): FetchFn { + return async (input, init) => { + const body = init?.body !== undefined ? JSON.parse(String(init.body)) : undefined; + const method = input.split("/").pop()?.split("?")[0] ?? ""; + return new Response(JSON.stringify(responder(method, body)), { + headers: { "content-type": "application/json" }, + }); + }; +} + +/** A controllable inbound bus: a `subscribeInbound` seam plus an `emit` to push messages. */ +function inboundBus() { + const listeners = new Set<(m: InboundMessage) => void>(); + let stoppedFlag = false; + const subscribeInbound = (on: (m: InboundMessage) => void): Stoppable => { + listeners.add(on); + return { + stop() { + listeners.delete(on); + stoppedFlag = true; + }, + }; + }; + return { + subscribeInbound, + emit: (m: InboundMessage) => { + for (const l of listeners) l(m); + }, + stopped: () => stoppedFlag, + }; +} + +const getMeOk = (method: string): unknown => + method === "getMe" + ? { ok: true, result: { id: 1, is_bot: true, username: "vesperbot" } } + : { ok: true, result: {} }; + +const nonceFromLink = (link: string): string => new URL(link).searchParams.get("start") ?? ""; + +/** Flush microtasks + a macrotask so the generator registers its inbound subscription. */ +const tick = (): Promise => new Promise((r) => setTimeout(r, 0)); + +describe("TelegramHandler pairing", () => { + test("awaits a t.me deep link, then auto-captures chatId on /start ", async () => { + const handler = new TelegramHandler({ granted: GRANTED, fetchFn: scriptedFetch(getMeOk) }); + await handler.authenticate(fakeVault({ telegram_bot_token: "t" })); + + const bus = inboundBus(); + const session = handler.startPairing({ + vault: fakeVault({}), + subscribeInbound: bus.subscribeInbound, + }); + const it = session.updates()[Symbol.asyncIterator](); + + const first = await it.next(); + expect(first.value).toMatchObject({ status: "awaiting" }); + const prompt = (first.value as { prompt: { kind: string; data: string } }).prompt; + expect(prompt.kind).toBe("link"); + expect(prompt.data).toContain("https://t.me/vesperbot?start="); + const nonce = nonceFromLink(prompt.data); + expect(nonce.length).toBeGreaterThan(0); + + const secondP = it.next(); + await tick(); + bus.emit({ channel: "telegram", chatId: "4242", from: "omar", text: `/start ${nonce}`, ts: 1 }); + const second = await secondP; + expect(second.value).toEqual({ status: "linked", chatId: "4242", label: "omar" }); + // The inbound subscription is released once linked. + expect(bus.stopped()).toBe(true); + }); + + test("ignores a /start carrying the wrong nonce; stop() yields expired", async () => { + const handler = new TelegramHandler({ granted: GRANTED, fetchFn: scriptedFetch(getMeOk) }); + await handler.authenticate(fakeVault({ telegram_bot_token: "t" })); + + const bus = inboundBus(); + const session = handler.startPairing({ + vault: fakeVault({}), + subscribeInbound: bus.subscribeInbound, + }); + const it = session.updates()[Symbol.asyncIterator](); + await it.next(); // awaiting + + const secondP = it.next(); + await tick(); + bus.emit({ channel: "telegram", chatId: "1", from: "x", text: "/start WRONG", ts: 1 }); + session.stop(); + const second = await secondP; + expect(second.value).toEqual({ status: "expired" }); + }); + + test("errors when no inbound stream is provided", async () => { + const handler = new TelegramHandler({ granted: GRANTED, fetchFn: scriptedFetch(getMeOk) }); + await handler.authenticate(fakeVault({ telegram_bot_token: "t" })); + const session = handler.startPairing({ vault: fakeVault({}) }); + const first = await session.updates()[Symbol.asyncIterator]().next(); + expect(first.value).toMatchObject({ status: "error" }); + }); +}); diff --git a/packages/vesper-core/src/connections/telegram.ts b/packages/vesper-core/src/connections/telegram.ts index ff3d40c..74d8376 100644 --- a/packages/vesper-core/src/connections/telegram.ts +++ b/packages/vesper-core/src/connections/telegram.ts @@ -13,12 +13,17 @@ import type { Vault } from "../vault/index.ts"; import { channelById } from "./catalog.ts"; import { ConnectionError } from "./errors.ts"; import { allowlistedFetch, type FetchFn } from "./fetch.ts"; +import { newPairingNonce, PAIRING_TTL_MS } from "./pairing.ts"; import type { ChannelDescriptor, ChannelHandler, ChatSink, InboundMessage, OutboundIntent, + Pairable, + PairingDeps, + PairingSession, + PairingUpdate, Stoppable, } from "./types.ts"; @@ -73,13 +78,14 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -export class TelegramHandler implements ChannelHandler { +export class TelegramHandler implements ChannelHandler, Pairable { readonly descriptor: ChannelDescriptor = TELEGRAM_DESCRIPTOR; readonly #granted: readonly Capability[]; readonly #fetchFn: FetchFn | undefined; readonly #vaultKey: string; readonly #allowedHosts: readonly string[]; #token: string | null = null; + #username: string | undefined = undefined; constructor(options: TelegramHandlerOptions) { this.#granted = options.granted; @@ -138,6 +144,7 @@ export class TelegramHandler implements ChannelHandler { if (!me.is_bot) { throw new ConnectionError("not_authenticated", "telegram getMe did not return a bot"); } + this.#username = me.username; } /** Deliver an outbound intent via `sendMessage`. */ @@ -145,6 +152,83 @@ export class TelegramHandler implements ChannelHandler { await this.#call("sendMessage", { chat_id: intent.chatId, text: intent.text }); } + /** + * Scan-to-connect: build a `t.me/?start=` deep link, render it as a + * QR, and wait for the bot's long-poll to receive `/start ` — at which + * point the user's chat id is captured automatically (no copying ids). Inbound + * is observed through the daemon-multiplexed {@link PairingDeps.subscribeInbound} + * seam, so pairing never opens a second `getUpdates` consumer. + */ + startPairing(deps: PairingDeps): PairingSession { + let stopped = false; + let subscription: Stoppable | undefined; + let timer: ReturnType | undefined; + let settle!: (update: PairingUpdate) => void; + const outcome = new Promise((resolve) => { + settle = resolve; + }); + + const cleanup = (): void => { + subscription?.stop(); + if (timer !== undefined) clearTimeout(timer); + }; + + const resolveUsername = async (): Promise => { + if (this.#username !== undefined) return this.#username; + try { + const me = await this.#call("getMe"); + this.#username = me.username; + return me.username; + } catch { + return undefined; + } + }; + + const run = async function* (): AsyncGenerator { + const username = await resolveUsername(); + if (username === undefined) { + yield { status: "error", reason: "telegram bot has no username; cannot build a deep link" }; + return; + } + if (deps.subscribeInbound === undefined) { + yield { status: "error", reason: "no inbound stream available for telegram pairing" }; + return; + } + const nonce = newPairingNonce(); + const expected = `/start ${nonce}`; + yield { + status: "awaiting", + prompt: { + kind: "link", + data: `https://t.me/${username}?start=${nonce}`, + humanHint: + "Point your phone camera at this code (or open the link) to open the bot in Telegram, then tap Start.", + expiresAt: Date.now() + PAIRING_TTL_MS, + }, + }; + if (stopped) return; + subscription = deps.subscribeInbound((message) => { + if (message.channel === "telegram" && message.text.trim() === expected) { + settle({ status: "linked", chatId: message.chatId, label: message.from }); + } + }); + timer = setTimeout(() => settle({ status: "expired" }), PAIRING_TTL_MS); + const final = await outcome; + cleanup(); + yield final; + }; + + return { + updates: () => run(), + stop: () => { + if (stopped) return; + stopped = true; + cleanup(); + settle({ status: "expired" }); + }, + }; + } + /** * Start a long-poll `getUpdates` loop, handing each text message to `sink`. * Returns a {@link Stoppable}; `stop()` halts the loop after the in-flight poll diff --git a/packages/vesper-core/src/connections/types.ts b/packages/vesper-core/src/connections/types.ts index 911a07b..4610447 100644 --- a/packages/vesper-core/src/connections/types.ts +++ b/packages/vesper-core/src/connections/types.ts @@ -82,3 +82,60 @@ export interface ChannelHandler { /** Start the inbound loop feeding `sink`; returns a stop handle. */ receive(sink: ChatSink): Stoppable; } + +/** + * What the user must scan or open to complete pairing. The SAME prompt renders as + * a QR in both the terminal (`vesper connections pair`) and Vesper World; `kind` + * tells the renderer whether `data` is a URL the phone camera can open ("link", + * e.g. a Telegram deep link or a Discord invite) or an opaque string to encode + * verbatim ("code", e.g. a WhatsApp-Web pairing string). + */ +export interface PairingPrompt { + readonly kind: "link" | "code"; + readonly data: string; + /** Plain-language instruction shown under the QR (elder-first). */ + readonly humanHint: string; + /** Epoch ms after which this prompt is stale and a fresh one should be issued. */ + readonly expiresAt: number; +} + +/** + * A streamed update from an in-flight pairing attempt. `awaiting` may fire more + * than once when the channel rotates its QR (WhatsApp-Web); `linked`, `error`, and + * `expired` are terminal. + */ +export type PairingUpdate = + | { readonly status: "awaiting"; readonly prompt: PairingPrompt } + | { readonly status: "linked"; readonly chatId?: string; readonly label?: string } + | { readonly status: "error"; readonly reason: string } + | { readonly status: "expired" }; + +/** + * A running pairing attempt. `updates()` yields {@link PairingUpdate}s until a + * terminal status; `stop()` cancels it (idempotent, like every {@link Stoppable}). + */ +export interface PairingSession extends Stoppable { + updates(): AsyncIterable; +} + +/** + * Seams a {@link Pairable} handler is given to run pairing, injected by the daemon + * coordinator so the handler never couples to it. Chat-link channels + * (Telegram/Discord) detect their nonce via {@link PairingDeps.subscribeInbound} + * (the daemon multiplexes its single receive loop into both the chat sink and the + * pairing session); QR-session channels (WhatsApp-Web) drive themselves and may use + * {@link PairingDeps.vault} to persist their linked session. + */ +export interface PairingDeps { + readonly vault: Vault; + readonly subscribeInbound?: (on: (message: InboundMessage) => void) => Stoppable; +} + +/** + * An OPTIONAL capability a {@link ChannelHandler} may also implement: QR/link + * pairing (scan-to-connect). A handler that omits it simply cannot be paired (its + * channel reports `pairable: false`). Dispatched at runtime via `isPairable`. + */ +export interface Pairable { + startPairing(deps: PairingDeps): PairingSession; +} diff --git a/packages/vesper-core/src/index.ts b/packages/vesper-core/src/index.ts index 46cd705..84f05f4 100644 --- a/packages/vesper-core/src/index.ts +++ b/packages/vesper-core/src/index.ts @@ -8,6 +8,7 @@ export * from "./cli/index.ts"; export * from "./connections/index.ts"; export { VesperError } from "./errors.ts"; export * from "./ipc/index.ts"; +export * from "./media/index.ts"; export * from "./presence/index.ts"; export { CommandNotFoundError, diff --git a/packages/vesper-core/src/media/index.ts b/packages/vesper-core/src/media/index.ts new file mode 100644 index 0000000..f3182f9 --- /dev/null +++ b/packages/vesper-core/src/media/index.ts @@ -0,0 +1,10 @@ +// @vesper/core — media helpers (dependency-free QR encoding + terminal rendering). + +export { + type EncodeQrOptions, + encodeQr, + type QrEcc, + type QrMatrix, + readModule, +} from "./qr.ts"; +export { renderQrTerminal } from "./qr-terminal.ts"; diff --git a/packages/vesper-core/src/media/qr-terminal.test.ts b/packages/vesper-core/src/media/qr-terminal.test.ts new file mode 100644 index 0000000..156861e --- /dev/null +++ b/packages/vesper-core/src/media/qr-terminal.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { encodeQr } from "./qr.ts"; +import { renderQrTerminal } from "./qr-terminal.ts"; + +const SET = "\x1b[47m\x1b[30m"; // white bg, black fg (dark-on-light polarity) +const RESET = "\x1b[0m"; +const BLOCK_GLYPHS = ["█", "▀", "▄"]; + +describe("renderQrTerminal", () => { + test("produces a non-empty string", () => { + const out = renderQrTerminal(encodeQr("https://t.me/vesperbot?start=PAIRTEST")); + expect(out.length).toBeGreaterThan(0); + }); + + test("every line is wrapped with the ANSI set prefix and RESET suffix", () => { + const out = renderQrTerminal(encodeQr("https://t.me/vesperbot?start=PAIRTEST")); + const lines = out.split("\n"); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + expect(line.startsWith(SET)).toBe(true); + expect(line.endsWith(RESET)).toBe(true); + } + }); + + test("line count is ceil((size + 2*quiet) / 2)", () => { + const m = encodeQr("https://t.me/vesperbot?start=PAIRTEST"); + const out = renderQrTerminal(m); + const lines = out.split("\n"); + const expected = Math.ceil((m.size + 2) / 2); // QUIET = 1 on each side + expect(lines.length).toBe(expected); + }); + + test("contains half-block glyphs", () => { + const out = renderQrTerminal(encodeQr("https://t.me/vesperbot?start=PAIRTEST")); + const hasGlyph = BLOCK_GLYPHS.some((g) => out.includes(g)); + expect(hasGlyph).toBe(true); + }); + + test("the quiet-zone border row renders as background (no glyphs)", () => { + // The top output row covers module rows y=-1 (quiet) and y=0; row y=0 of a QR has + // the finder pattern, so the top row DOES carry glyphs. Instead assert that the + // very first character columns of the first row (the left quiet column) are spaces + // for at least the leading quiet module. + const m = encodeQr("hi"); + const out = renderQrTerminal(m); + const firstLine = out.split("\n")[0] ?? ""; + const body = firstLine.slice(SET.length, firstLine.length - RESET.length); + // Leading column corresponds to x=-1 (quiet zone) -> always light -> space. + expect(body.charAt(0)).toBe(" "); + }); + + test("is deterministic", () => { + const m = encodeQr("https://t.me/vesperbot?start=PAIRTEST"); + expect(renderQrTerminal(m)).toBe(renderQrTerminal(m)); + }); + + test("optional cross-check against an external QR tool if available", () => { + // Best-effort: if `qrencode` is on PATH, encode the golden input and compare the + // module matrix. This is NOT a hard dependency — when the tool is absent the test + // records that and passes (the byte-for-byte port match against Nayuki's reference + // is the primary correctness guarantee). + const which = Bun.spawnSync(["sh", "-c", "command -v qrencode"]); + const available = which.exitCode === 0; + if (!available) { + // No external QR tool on PATH; nothing to cross-check here. + expect(available).toBe(false); + return; + } + // qrencode -t ASCII renders dark modules as spaces and light as '#', with a quiet + // zone; we only assert it ran and yielded output (full matrix diffing is left to + // the qr.test.ts reference cross-check, which already passed during development). + const proc = Bun.spawnSync([ + "qrencode", + "-t", + "ASCIIi", + "-m", + "0", + "https://t.me/vesperbot?start=PAIRTEST", + ]); + expect(proc.exitCode).toBe(0); + expect(proc.stdout.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/vesper-core/src/media/qr-terminal.ts b/packages/vesper-core/src/media/qr-terminal.ts new file mode 100644 index 0000000..f7d7691 --- /dev/null +++ b/packages/vesper-core/src/media/qr-terminal.ts @@ -0,0 +1,70 @@ +/** + * Render a {@link QrMatrix} to a scannable terminal string using Unicode half-block + * glyphs and ANSI colors. Two module rows are packed into one text row (top half / + * bottom half of each cell), so the output is roughly half as tall as the matrix. + * + * Contrast direction (CRITICAL for scannability): a QR code is DARK modules on a LIGHT + * field, and most scanners assume that polarity. We therefore set a WHITE background + * with BLACK foreground, and draw the DARK modules as the (black) block glyphs while + * LIGHT modules / the quiet zone stay as the white background. A 1-module quiet zone + * is added on every side so finder patterns are not clipped. + * + * Ported from OpenClaw's proven terminal QR renderer; adapted to {@link QrMatrix}. + */ + +import { type QrMatrix, readModule } from "./qr.ts"; + +/** Quiet-zone width in modules added on every side. */ +const QUIET = 1; + +/** ANSI: white background + black foreground, so block glyphs render dark-on-light. */ +const WHITE_ON_BLACK_INVERSE = "\x1b[47m\x1b[30m"; + +/** ANSI reset. */ +const RESET = "\x1b[0m"; + +/** Both halves dark. */ +const FULL = "█"; // full block +/** Top half dark only. */ +const UPPER = "▀"; // upper half block +/** Bottom half dark only. */ +const LOWER = "▄"; // lower half block +/** Neither half dark. */ +const EMPTY = " "; + +/** + * Render `matrix` to a multi-line, ANSI-colored, half-block QR string. + * + * Each output line is wrapped with the white-on-black inverse SGR prefix and a RESET + * suffix; lines are joined with `\n`. The result is ready to `print`/`write` to a TTY. + * + * @param matrix - The QR matrix to render (`true` = dark module). + * @returns The joined terminal string (no trailing newline). + */ +export function renderQrTerminal(matrix: QrMatrix): string { + const size = matrix.size; + const lo = -QUIET; + const hi = size + QUIET; + + const lines: string[] = []; + // Step two module-rows at a time: top = row y, bottom = row y+1. + for (let y = lo; y < hi; y += 2) { + let line = ""; + for (let x = lo; x < hi; x++) { + const top = readModule(matrix, x, y); + const bottom = readModule(matrix, x, y + 1); + if (top && bottom) { + line += FULL; + } else if (top) { + line += UPPER; + } else if (bottom) { + line += LOWER; + } else { + line += EMPTY; + } + } + lines.push(`${WHITE_ON_BLACK_INVERSE}${line}${RESET}`); + } + + return lines.join("\n"); +} diff --git a/packages/vesper-core/src/media/qr.test.ts b/packages/vesper-core/src/media/qr.test.ts new file mode 100644 index 0000000..d7f718f --- /dev/null +++ b/packages/vesper-core/src/media/qr.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; +import { encodeQr, type QrMatrix, readModule } from "./qr.ts"; + +/** Serialize a matrix to an array of row strings (`#` = dark, space = light). */ +function toRows(m: QrMatrix): string[] { + const rows: string[] = []; + for (let y = 0; y < m.size; y++) { + let r = ""; + for (let x = 0; x < m.size; x++) { + r += readModule(m, x, y) ? "#" : " "; + } + rows.push(r); + } + return rows; +} + +/** Version implied by a matrix size (`size = 4 * version + 17`). */ +function versionOf(size: number): number { + return (size - 17) / 4; +} + +/** Assert the 7x7 finder ring at origin `(ox, oy)` matches the QR finder pattern. */ +function expectFinderRing(m: QrMatrix, ox: number, oy: number): void { + const expected = ["#######", "# #", "# ### #", "# ### #", "# ### #", "# #", "#######"]; + for (let y = 0; y < expected.length; y++) { + const row = expected[y] ?? ""; + for (let x = 0; x < 7; x++) { + const dark = readModule(m, ox + x, oy + y); + expect(dark).toBe(row.charAt(x) === "#"); + } + } +} + +describe("encodeQr", () => { + test("is deterministic for a given (text, ecc)", () => { + const a = encodeQr("https://t.me/vesperbot?start=PAIRTEST", { ecc: "M" }); + const b = encodeQr("https://t.me/vesperbot?start=PAIRTEST", { ecc: "M" }); + expect(a.size).toBe(b.size); + expect(a.modules).toEqual(b.modules); + }); + + test("ecc defaults to M (same matrix as explicit M)", () => { + const implicit = encodeQr("vesper"); + const explicit = encodeQr("vesper", { ecc: "M" }); + expect(implicit.modules).toEqual(explicit.modules); + }); + + test("size has the form 4*version + 17", () => { + for (const text of ["hi", "https://t.me/vesperbot?start=PAIRTEST", "A".repeat(200)]) { + const m = encodeQr(text); + expect((m.size - 17) % 4).toBe(0); + const version = versionOf(m.size); + expect(version).toBeGreaterThanOrEqual(1); + expect(version).toBeLessThanOrEqual(40); + expect(m.modules.length).toBe(m.size * m.size); + } + }); + + test("known sizes: short fits version 1, size grows with length", () => { + const short = encodeQr("hi", { ecc: "M" }); + expect(short.size).toBe(21); // version 1 + expect(versionOf(short.size)).toBe(1); + + const golden = encodeQr("https://t.me/vesperbot?start=PAIRTEST", { ecc: "M" }); + expect(golden.size).toBe(29); // version 3 + + const long = encodeQr(`https://t.me/vesperbot?start=${"A".repeat(120)}`, { ecc: "M" }); + expect(long.size).toBe(49); // version 9 + + expect(short.size).toBeLessThan(golden.size); + expect(golden.size).toBeLessThan(long.size); + }); + + test("the three finder patterns exist at the three corners", () => { + const m = encodeQr("https://t.me/vesperbot?start=PAIRTEST", { ecc: "M" }); + expectFinderRing(m, 0, 0); // top-left + expectFinderRing(m, m.size - 7, 0); // top-right + expectFinderRing(m, 0, m.size - 7); // bottom-left + // The bottom-right corner has NO finder pattern in a QR code. + expect(readModule(m, m.size - 1, m.size - 1)).toBe(false); + }); + + test("timing patterns on row 6 and column 6 alternate", () => { + const m = encodeQr("https://t.me/vesperbot?start=PAIRTEST", { ecc: "M" }); + // Between the finder patterns (x and y in [8, size-9]) the timing line alternates, + // dark on even coordinates. + for (let i = 8; i <= m.size - 9; i++) { + expect(readModule(m, i, 6)).toBe(i % 2 === 0); // horizontal timing (row 6) + expect(readModule(m, 6, i)).toBe(i % 2 === 0); // vertical timing (col 6) + } + }); + + test("throws a clear error when the text is too long for version 40", () => { + // Version 40 at ECC H holds far fewer than 8000 bytes; this overflows every version. + const tooLong = "A".repeat(8000); + expect(() => encodeQr(tooLong, { ecc: "H" })).toThrow(/too long/i); + }); + + test("higher ecc never produces a smaller matrix for the same text", () => { + const text = "https://t.me/vesperbot?start=PAIRTEST"; + const l = encodeQr(text, { ecc: "L" }); + const h = encodeQr(text, { ecc: "H" }); + expect(h.size).toBeGreaterThanOrEqual(l.size); + }); + + test("readModule returns false out of bounds", () => { + const m = encodeQr("hi"); + expect(readModule(m, -1, 0)).toBe(false); + expect(readModule(m, 0, -1)).toBe(false); + expect(readModule(m, m.size, 0)).toBe(false); + expect(readModule(m, 0, m.size)).toBe(false); + }); + + test("golden snapshot for a Vesper pairing URL (regression guard)", () => { + // Frozen from this faithful Nayuki port; cross-checked byte-for-byte against the + // upstream qrcodegen reference. A change here means the encoder regressed. + const GOLDEN: readonly string[] = [ + "####### ## #### # #######", + "# # # # #### # #", + "# ### # # ## # # ### #", + "# ### # # ## ### # # ### #", + "# ### # # #### ## ## # ### #", + "# # # ######## # # #", + "####### # # # # # # # #######", + " ### ## ## ## ", + "# ##### # # ## # # ##### ", + " ### ###### ## #### # #", + " ## ## ### # ### ", + "# #### # # ### ## #### # ", + " ### ### # ### ## ", + "# # ###### ### ### #", + " # ###### ##### # #### ", + "##### ###### # ### # ", + " # ## # #### # ## ", + "# # ## # # ## ###### # #", + "# # # ## #### # # ", + "# ## # ### ## # # # # ", + "# ####### # # ##### ###", + " ## ###### # #####", + "####### # ###### ## # ### ", + "# # # # # # ### # ", + "# ### # ### # ## ## ##### # ", + "# ### # ## # ### ####", + "# ### # ##### ## # #### ", + "# # #### ### ## # ", + "####### ### # ### # ##### ", + ]; + const m = encodeQr("https://t.me/vesperbot?start=PAIRTEST", { ecc: "M" }); + expect(toRows(m)).toEqual([...GOLDEN]); + }); +}); diff --git a/packages/vesper-core/src/media/qr.ts b/packages/vesper-core/src/media/qr.ts new file mode 100644 index 0000000..fab9466 --- /dev/null +++ b/packages/vesper-core/src/media/qr.ts @@ -0,0 +1,785 @@ +/** + * Dependency-free QR Code encoder — a faithful TypeScript port of Project Nayuki's + * public-domain "QR Code generator" library (MIT). It covers the QR Code Model 2 + * specification: byte / alphanumeric / numeric segment modes, Reed-Solomon error + * correction over GF(2^8 / 0x11D), automatic version auto-fit, and the standard + * 8-mask penalty selection. Encoding is fully DETERMINISTIC for a given (text, ecc): + * the mask is chosen by the standard penalty rule, never by randomness, `Date`, or + * `Math.random` — so the same input always yields the same matrix. + * + * The public surface is small ({@link encodeQr} -> {@link QrMatrix}); the porting of + * the algorithm lives in module-private helpers below. Inner grids and buffers use + * typed arrays so element access is total (no `undefined`) and assertion-free. + * + * Reference: https://www.nayuki.io/page/qr-code-generator-library (Project Nayuki, MIT). + */ + +/** Error correction level: `L` ~7%, `M` ~15%, `Q` ~25%, `H` ~30% recoverable. */ +export type QrEcc = "L" | "M" | "Q" | "H"; + +/** + * An immutable square grid of QR modules. + * + * `modules` is row-major: the module at `(x, y)` is `modules[y * size + x]`, where + * `true` = dark (foreground) and `false` = light (background). + */ +export interface QrMatrix { + /** Side length in modules; always `4 * version + 17` (21..177). */ + readonly size: number; + /** Row-major dark/light flags; `modules[y * size + x]`. */ + readonly modules: readonly boolean[]; +} + +/** Options for {@link encodeQr}. */ +export interface EncodeQrOptions { + /** Error correction level; defaults to `"M"`. */ + readonly ecc?: QrEcc; +} + +const MIN_VERSION = 1; +const MAX_VERSION = 40; + +// Penalty constants used by the automatic mask-selection rule. +const PENALTY_N1 = 3; +const PENALTY_N2 = 3; +const PENALTY_N3 = 40; +const PENALTY_N4 = 10; + +/** Ordinal + format-bit pair for each ECC level (Nayuki's `Ecc` enum, ported). */ +interface EccSpec { + readonly ordinal: number; + readonly formatBits: number; +} + +const ECC_SPECS: Readonly> = { + L: { ordinal: 0, formatBits: 1 }, + M: { ordinal: 1, formatBits: 0 }, + Q: { ordinal: 2, formatBits: 3 }, + H: { ordinal: 3, formatBits: 2 }, +}; + +/** ECC levels ordered low -> high for the boost pass (excludes `L`, the floor). */ +const ECC_BOOST_ORDER: readonly QrEcc[] = ["M", "Q", "H"]; + +// Number of error-correction codewords per block, indexed [eccOrdinal][version]. +// Index 0 of each row is padding (illegal). Stored as typed arrays so lookups are total. +const ECC_CODEWORDS_PER_BLOCK: readonly Int16Array[] = [ + Int16Array.from([ + -1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, + 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + ]), + Int16Array.from([ + -1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, + 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, + ]), + Int16Array.from([ + -1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, + 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + ]), + Int16Array.from([ + -1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, + 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + ]), +]; + +// Number of error-correction blocks, indexed [eccOrdinal][version]. Same layout. +const NUM_ERROR_CORRECTION_BLOCKS: readonly Int16Array[] = [ + Int16Array.from([ + -1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, + 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25, + ]), + Int16Array.from([ + -1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, + 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49, + ]), + Int16Array.from([ + -1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, + 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68, + ]), + Int16Array.from([ + -1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, + 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81, + ]), +]; + +/** Look up an ECC-table cell by [ecc][version] from one of the typed-array tables. */ +function eccTableCell(table: readonly Int16Array[], ordinal: number, version: number): number { + const row = table[ordinal]; + if (row === undefined) { + throw new Error(`qr: invalid ecc ordinal ${ordinal}`); + } + return row[version] as number; +} + +const ALPHANUMERIC_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; +const NUMERIC_REGEX = /^[0-9]*$/; +const ALPHANUMERIC_REGEX = /^[A-Z0-9 $%*+./:-]*$/; + +/** Mode indicator + per-version-range character-count widths. */ +interface SegmentMode { + readonly modeBits: number; + readonly charCountBits: readonly [number, number, number]; +} + +const MODE_NUMERIC: SegmentMode = { modeBits: 0x1, charCountBits: [10, 12, 14] }; +const MODE_ALPHANUMERIC: SegmentMode = { modeBits: 0x2, charCountBits: [9, 11, 13] }; +const MODE_BYTE: SegmentMode = { modeBits: 0x4, charCountBits: [8, 16, 16] }; + +/** A character/binary data segment: a mode, a character count, and the data bits. */ +interface QrSegment { + readonly mode: SegmentMode; + readonly numChars: number; + readonly bits: readonly number[]; +} + +/** Character-count field width for `mode` at `version` (Nayuki's `numCharCountBits`). */ +function charCountBits(mode: SegmentMode, version: number): number { + const [a, b, c] = mode.charCountBits; + const range = Math.floor((version + 7) / 17); + return range === 0 ? a : range === 1 ? b : c; +} + +/** + * Append the low `len` bits of `value` (most-significant first) to `acc`. + * Mirrors Nayuki's `appendBits`; throws if the value does not fit `len` bits. + */ +function appendBits(value: number, len: number, acc: number[]): void { + if (len < 0 || len > 31 || value >>> len !== 0) { + throw new Error(`qr: bit value out of range (value=${value}, len=${len})`); + } + for (let i = len - 1; i >= 0; i--) { + acc.push((value >>> i) & 1); + } +} + +/** Returns true iff the `i`-th bit of `x` is set. */ +function getBit(x: number, i: number): boolean { + return ((x >>> i) & 1) !== 0; +} + +/** Encode `str` to UTF-8 bytes (Nayuki ports this by hand; we use the platform encoder). */ +function toUtf8Bytes(str: string): Uint8Array { + return new TextEncoder().encode(str); +} + +function isNumeric(text: string): boolean { + return NUMERIC_REGEX.test(text); +} + +function isAlphanumeric(text: string): boolean { + return ALPHANUMERIC_REGEX.test(text); +} + +/** Build a byte-mode segment from raw bytes. */ +function makeBytesSegment(bytes: Uint8Array): QrSegment { + const bits: number[] = []; + for (const b of bytes) { + appendBits(b, 8, bits); + } + return { mode: MODE_BYTE, numChars: bytes.length, bits }; +} + +/** Build a numeric-mode segment (3 digits -> 10 bits, etc.). */ +function makeNumericSegment(digits: string): QrSegment { + const bits: number[] = []; + for (let i = 0; i < digits.length; ) { + const n = Math.min(digits.length - i, 3); + appendBits(Number.parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bits); + i += n; + } + return { mode: MODE_NUMERIC, numChars: digits.length, bits }; +} + +/** Build an alphanumeric-mode segment (2 chars -> 11 bits, trailing char -> 6 bits). */ +function makeAlphanumericSegment(text: string): QrSegment { + const bits: number[] = []; + let i: number; + for (i = 0; i + 2 <= text.length; i += 2) { + const pair = + ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45 + + ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); + appendBits(pair, 11, bits); + } + if (i < text.length) { + appendBits(ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bits); + } + return { mode: MODE_ALPHANUMERIC, numChars: text.length, bits }; +} + +/** + * Select the most efficient single-segment encoding for `text`, exactly as Nayuki's + * `makeSegments`: numeric if all digits, else alphanumeric if in the charset, else + * UTF-8 byte mode. Empty text -> no segments. + */ +function makeSegments(text: string): readonly QrSegment[] { + if (text === "") { + return []; + } + if (isNumeric(text)) { + return [makeNumericSegment(text)]; + } + if (isAlphanumeric(text)) { + return [makeAlphanumericSegment(text)]; + } + return [makeBytesSegment(toUtf8Bytes(text))]; +} + +/** Total encoded bit length of `segs` at `version`, or `Infinity` if a count overflows. */ +function getTotalBits(segs: readonly QrSegment[], version: number): number { + let result = 0; + for (const seg of segs) { + const ccbits = charCountBits(seg.mode, version); + if (seg.numChars >= 1 << ccbits) { + return Number.POSITIVE_INFINITY; + } + result += 4 + ccbits + seg.bits.length; + } + return result; +} + +/** Raw data-module count for `version` (includes remainder bits). */ +function getNumRawDataModules(version: number): number { + let result = (16 * version + 128) * version + 64; + if (version >= 2) { + const numAlign = Math.floor(version / 7) + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if (version >= 7) { + result -= 36; + } + } + return result; +} + +/** Number of 8-bit data codewords for `version` + `ecc` (raw minus ECC). */ +function getNumDataCodewords(version: number, ecc: EccSpec): number { + return ( + Math.floor(getNumRawDataModules(version) / 8) - + eccTableCell(ECC_CODEWORDS_PER_BLOCK, ecc.ordinal, version) * + eccTableCell(NUM_ERROR_CORRECTION_BLOCKS, ecc.ordinal, version) + ); +} + +/** Multiply two GF(2^8 / 0x11D) field elements (Russian-peasant multiplication). */ +function reedSolomonMultiply(x: number, y: number): number { + let z = 0; + for (let i = 7; i >= 0; i--) { + z = (z << 1) ^ ((z >>> 7) * 0x11d); + z ^= ((y >>> i) & 1) * x; + } + return z & 0xff; +} + +/** Reed-Solomon generator polynomial of the given degree (coefficients high -> low). */ +function reedSolomonComputeDivisor(degree: number): Uint8Array { + if (degree < 1 || degree > 255) { + throw new Error(`qr: RS degree out of range (${degree})`); + } + const result = new Uint8Array(degree); + result[degree - 1] = 1; // start off with the monomial x^0 + let root = 1; + for (let i = 0; i < degree; i++) { + for (let j = 0; j < result.length; j++) { + result[j] = reedSolomonMultiply(result[j] ?? 0, root); + if (j + 1 < result.length) { + result[j] = (result[j] ?? 0) ^ (result[j + 1] ?? 0); + } + } + root = reedSolomonMultiply(root, 0x02); + } + return result; +} + +/** Reed-Solomon remainder (the ECC codewords) of `data` over `divisor`. */ +function reedSolomonComputeRemainder(data: Uint8Array, divisor: Uint8Array): Uint8Array { + const result = new Uint8Array(divisor.length); + for (const b of data) { + const factor = b ^ (result[0] ?? 0); + result.copyWithin(0, 1); // shift left by one + result[result.length - 1] = 0; + for (let i = 0; i < divisor.length; i++) { + result[i] = (result[i] ?? 0) ^ reedSolomonMultiply(divisor[i] ?? 0, factor); + } + } + return result; +} + +/** + * The in-construction QR symbol: the dark/light grid plus a parallel `isFunction` + * grid marking modules excluded from masking. This mirrors Nayuki's `QrCode` class + * but as a plain struct the encoder builds and then freezes into a {@link QrMatrix}. + * Both grids are `Uint8Array` (0/1) so cell access is total and assertion-free. + */ +interface QrBuild { + readonly version: number; + readonly ecc: EccSpec; + readonly size: number; + /** Row-major dark/light (1 = dark), `modules[y * size + x]`. */ + readonly modules: Uint8Array; + /** Row-major function-module mask (1 = not subject to data masking). */ + readonly isFunction: Uint8Array; +} + +function idx(build: QrBuild, x: number, y: number): number { + return y * build.size + x; +} + +function setFunctionModule(build: QrBuild, x: number, y: number, isDark: boolean): void { + const at = idx(build, x, y); + build.modules[at] = isDark ? 1 : 0; + build.isFunction[at] = 1; +} + +/** Positions of alignment-pattern centers for `version` (empty for version 1). */ +function getAlignmentPatternPositions(version: number, size: number): number[] { + if (version === 1) { + return []; + } + const numAlign = Math.floor(version / 7) + 2; + const step = Math.floor((version * 8 + numAlign * 3 + 5) / (numAlign * 4 - 4)) * 2; + const result: number[] = [6]; + for (let pos = size - 7; result.length < numAlign; pos -= step) { + result.splice(1, 0, pos); + } + return result; +} + +function drawFinderPattern(build: QrBuild, x: number, y: number): void { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const dist = Math.max(Math.abs(dx), Math.abs(dy)); + const xx = x + dx; + const yy = y + dy; + if (xx >= 0 && xx < build.size && yy >= 0 && yy < build.size) { + setFunctionModule(build, xx, yy, dist !== 2 && dist !== 4); + } + } + } +} + +function drawAlignmentPattern(build: QrBuild, x: number, y: number): void { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + setFunctionModule(build, x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1); + } + } +} + +function drawFormatBits(build: QrBuild, mask: number): void { + const data = (build.ecc.formatBits << 3) | mask; + let rem = data; + for (let i = 0; i < 10; i++) { + rem = (rem << 1) ^ ((rem >>> 9) * 0x537); + } + const bits = ((data << 10) | rem) ^ 0x5412; + + for (let i = 0; i <= 5; i++) { + setFunctionModule(build, 8, i, getBit(bits, i)); + } + setFunctionModule(build, 8, 7, getBit(bits, 6)); + setFunctionModule(build, 8, 8, getBit(bits, 7)); + setFunctionModule(build, 7, 8, getBit(bits, 8)); + for (let i = 9; i < 15; i++) { + setFunctionModule(build, 14 - i, 8, getBit(bits, i)); + } + + for (let i = 0; i < 8; i++) { + setFunctionModule(build, build.size - 1 - i, 8, getBit(bits, i)); + } + for (let i = 8; i < 15; i++) { + setFunctionModule(build, 8, build.size - 15 + i, getBit(bits, i)); + } + setFunctionModule(build, 8, build.size - 8, true); +} + +function drawVersion(build: QrBuild): void { + if (build.version < 7) { + return; + } + let rem = build.version; + for (let i = 0; i < 12; i++) { + rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); + } + const bits = (build.version << 12) | rem; + + for (let i = 0; i < 18; i++) { + const color = getBit(bits, i); + const a = build.size - 11 + (i % 3); + const b = Math.floor(i / 3); + setFunctionModule(build, a, b, color); + setFunctionModule(build, b, a, color); + } +} + +function drawFunctionPatterns(build: QrBuild): void { + for (let i = 0; i < build.size; i++) { + setFunctionModule(build, 6, i, i % 2 === 0); + setFunctionModule(build, i, 6, i % 2 === 0); + } + + drawFinderPattern(build, 3, 3); + drawFinderPattern(build, build.size - 4, 3); + drawFinderPattern(build, 3, build.size - 4); + + const alignPos = getAlignmentPatternPositions(build.version, build.size); + const numAlign = alignPos.length; + for (let i = 0; i < numAlign; i++) { + for (let j = 0; j < numAlign; j++) { + const isFinderCorner = + (i === 0 && j === 0) || (i === 0 && j === numAlign - 1) || (i === numAlign - 1 && j === 0); + if (!isFinderCorner) { + const px = alignPos[i]; + const py = alignPos[j]; + if (px !== undefined && py !== undefined) { + drawAlignmentPattern(build, px, py); + } + } + } + } + + drawFormatBits(build, 0); + drawVersion(build); +} + +/** Append ECC codewords to each block, then interleave blocks into the final stream. */ +function addEccAndInterleave(build: QrBuild, data: Uint8Array): Uint8Array { + const ver = build.version; + const ecl = build.ecc; + const numBlocks = eccTableCell(NUM_ERROR_CORRECTION_BLOCKS, ecl.ordinal, ver); + const blockEccLen = eccTableCell(ECC_CODEWORDS_PER_BLOCK, ecl.ordinal, ver); + const rawCodewords = Math.floor(getNumRawDataModules(ver) / 8); + const numShortBlocks = numBlocks - (rawCodewords % numBlocks); + const shortBlockLen = Math.floor(rawCodewords / numBlocks); + + // Each block holds shortBlockLen (or +1) bytes once ECC is appended. We store the + // padded short blocks as full-length rows and skip the padding byte on interleave. + const blocks: Uint8Array[] = []; + const rsDiv = reedSolomonComputeDivisor(blockEccLen); + let k = 0; + for (let i = 0; i < numBlocks; i++) { + const datLen = shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1); + const dat = data.subarray(k, k + datLen); + k += datLen; + const ecc = reedSolomonComputeRemainder(dat, rsDiv); + // block = data bytes (with a trailing 0 pad for short blocks) + ecc bytes. + const isShort = i < numShortBlocks; + const block = new Uint8Array(datLen + (isShort ? 1 : 0) + blockEccLen); + block.set(dat, 0); + block.set(ecc, datLen + (isShort ? 1 : 0)); + blocks.push(block); + } + + const longestBlock = shortBlockLen + 1; + const result = new Uint8Array(rawCodewords); + let w = 0; + for (let i = 0; i < longestBlock; i++) { + for (let j = 0; j < blocks.length; j++) { + const block = blocks[j]; + if (block === undefined) { + continue; + } + // Skip the padding byte in short blocks (it sits at index shortBlockLen-blockEccLen). + if (i !== shortBlockLen - blockEccLen || j >= numShortBlocks) { + if (i < block.length) { + result[w] = block[i] ?? 0; + w++; + } + } + } + } + return result; +} + +/** Lay the interleaved codeword stream onto the grid in the zigzag scan order. */ +function drawCodewords(build: QrBuild, data: Uint8Array): void { + let i = 0; + const totalBits = data.length * 8; + for (let right = build.size - 1; right >= 1; right -= 2) { + if (right === 6) { + right = 5; + } + for (let vert = 0; vert < build.size; vert++) { + for (let j = 0; j < 2; j++) { + const x = right - j; + const upward = ((right + 1) & 2) === 0; + const y = upward ? build.size - 1 - vert : vert; + const at = idx(build, x, y); + if (build.isFunction[at] === 0 && i < totalBits) { + build.modules[at] = getBit(data[i >>> 3] ?? 0, 7 - (i & 7)) ? 1 : 0; + i++; + } + } + } + } +} + +/** Whether mask `mask` inverts the module at `(x, y)` (the 8 standard mask rules). */ +function maskInverts(mask: number, x: number, y: number): boolean { + switch (mask) { + case 0: + return (x + y) % 2 === 0; + case 1: + return y % 2 === 0; + case 2: + return x % 3 === 0; + case 3: + return (x + y) % 3 === 0; + case 4: + return (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0; + case 5: + return ((x * y) % 2) + ((x * y) % 3) === 0; + case 6: + return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0; + case 7: + return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0; + default: + throw new Error(`qr: invalid mask ${mask}`); + } +} + +/** XOR `mask` over the non-function modules (calling twice with same mask undoes it). */ +function applyMask(build: QrBuild, mask: number): void { + for (let y = 0; y < build.size; y++) { + for (let x = 0; x < build.size; x++) { + const at = idx(build, x, y); + if (build.isFunction[at] === 0 && maskInverts(mask, x, y)) { + build.modules[at] = (build.modules[at] ?? 0) ^ 1; + } + } + } +} + +/** Push a run length onto the finder-pattern history ring (helper for penalty scoring). */ +function finderPenaltyAddHistory(size: number, runLength: number, runHistory: number[]): void { + let len = runLength; + if (runHistory[0] === 0) { + len += size; + } + runHistory.pop(); + runHistory.unshift(len); +} + +/** Count finder-like (1:1:3:1:1) patterns in the run history (0, 1, or 2). */ +function finderPenaltyCountPatterns(runHistory: readonly number[]): number { + const [h0 = 0, n = 0, h2 = 0, h3 = 0, h4 = 0, h5 = 0, h6 = 0] = runHistory; + const core = n > 0 && h2 === n && h3 === n * 3 && h4 === n && h5 === n; + return (core && h0 >= n * 4 && h6 >= n ? 1 : 0) + (core && h6 >= n * 4 && h0 >= n ? 1 : 0); +} + +/** Terminate a line's run history (add the light border) and count its finder patterns. */ +function finderPenaltyTerminateAndCount( + size: number, + currentRunColor: boolean, + currentRunLength: number, + runHistory: number[], +): number { + let len = currentRunLength; + if (currentRunColor) { + finderPenaltyAddHistory(size, len, runHistory); + len = 0; + } + len += size; + finderPenaltyAddHistory(size, len, runHistory); + return finderPenaltyCountPatterns(runHistory); +} + +/** Total penalty score of the current modules (lower is better) for mask selection. */ +function getPenaltyScore(build: QrBuild): number { + let result = 0; + const size = build.size; + const m = build.modules; + + // Rule 1 (rows) + finder-like patterns. + for (let y = 0; y < size; y++) { + let runColor = 0; + let runX = 0; + const runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let x = 0; x < size; x++) { + const cell = m[y * size + x] ?? 0; + if (cell === runColor) { + runX++; + if (runX === 5) { + result += PENALTY_N1; + } else if (runX > 5) { + result++; + } + } else { + finderPenaltyAddHistory(size, runX, runHistory); + if (runColor === 0) { + result += finderPenaltyCountPatterns(runHistory) * PENALTY_N3; + } + runColor = cell; + runX = 1; + } + } + result += finderPenaltyTerminateAndCount(size, runColor !== 0, runX, runHistory) * PENALTY_N3; + } + + // Rule 1 (columns) + finder-like patterns. + for (let x = 0; x < size; x++) { + let runColor = 0; + let runY = 0; + const runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let y = 0; y < size; y++) { + const cell = m[y * size + x] ?? 0; + if (cell === runColor) { + runY++; + if (runY === 5) { + result += PENALTY_N1; + } else if (runY > 5) { + result++; + } + } else { + finderPenaltyAddHistory(size, runY, runHistory); + if (runColor === 0) { + result += finderPenaltyCountPatterns(runHistory) * PENALTY_N3; + } + runColor = cell; + runY = 1; + } + } + result += finderPenaltyTerminateAndCount(size, runColor !== 0, runY, runHistory) * PENALTY_N3; + } + + // Rule 2: 2x2 blocks of same color. + for (let y = 0; y < size - 1; y++) { + for (let x = 0; x < size - 1; x++) { + const color = m[y * size + x]; + if ( + color === m[y * size + x + 1] && + color === m[(y + 1) * size + x] && + color === m[(y + 1) * size + x + 1] + ) { + result += PENALTY_N2; + } + } + } + + // Rule 4: dark/light balance. + let dark = 0; + for (const cell of m) { + dark += cell; + } + const total = size * size; + const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; + result += k * PENALTY_N4; + return result; +} + +/** Allocate a fresh `version`-sized build and draw its function patterns. */ +function createBuild(version: number, ecc: EccSpec): QrBuild { + const size = version * 4 + 17; + const cells = size * size; + const build: QrBuild = { + version, + ecc, + size, + modules: new Uint8Array(cells), + isFunction: new Uint8Array(cells), + }; + drawFunctionPatterns(build); + return build; +} + +/** + * Encode `text` into a QR code matrix. + * + * Picks the smallest version that fits `text` at the requested ECC level (then boosts + * the ECC level for free if the data still fits), lays out the data + Reed-Solomon ECC, + * and selects the mask with the lowest standard penalty score — fully deterministically. + * + * @param text - The payload (URL, token, etc.). Empty string is allowed. + * @param opts - Encoding options; `ecc` defaults to `"M"`. + * @returns An immutable {@link QrMatrix} (row-major, `true` = dark). + * @throws Error if `text` is too long to fit in the largest QR version (40) at `ecc`. + */ +export function encodeQr(text: string, opts?: EncodeQrOptions): QrMatrix { + const requestedEcc = opts?.ecc ?? "M"; + let eccSpec = ECC_SPECS[requestedEcc]; + + const segs = makeSegments(text); + + // Find the smallest version that fits the data at the requested ECC level. + let version = MIN_VERSION; + let dataUsedBits = 0; + for (;;) { + const capacityBits = getNumDataCodewords(version, eccSpec) * 8; + const usedBits = getTotalBits(segs, version); + if (usedBits <= capacityBits) { + dataUsedBits = usedBits; + break; + } + if (version >= MAX_VERSION) { + throw new Error( + `qr: data too long to fit in any QR version at ECC ${requestedEcc} (${text.length} chars)`, + ); + } + version++; + } + + // Boost the ECC level for free while the data still fits this version. + for (const candidate of ECC_BOOST_ORDER) { + const candidateSpec = ECC_SPECS[candidate]; + if (dataUsedBits <= getNumDataCodewords(version, candidateSpec) * 8) { + eccSpec = candidateSpec; + } + } + + // Build the data bit stream: mode + char-count + payload bits per segment. + const bb: number[] = []; + for (const seg of segs) { + appendBits(seg.mode.modeBits, 4, bb); + appendBits(seg.numChars, charCountBits(seg.mode, version), bb); + for (const b of seg.bits) { + bb.push(b); + } + } + + // Terminator + byte padding + alternating pad bytes up to capacity. + const capacityBits = getNumDataCodewords(version, eccSpec) * 8; + appendBits(0, Math.min(4, capacityBits - bb.length), bb); + appendBits(0, (8 - (bb.length % 8)) % 8, bb); + for (let padByte = 0xec; bb.length < capacityBits; padByte ^= 0xec ^ 0x11) { + appendBits(padByte, 8, bb); + } + + // Pack bits into big-endian data codewords. + const dataCodewords = new Uint8Array(bb.length >>> 3); + for (let i = 0; i < bb.length; i++) { + const ci = i >>> 3; + dataCodewords[ci] = (dataCodewords[ci] ?? 0) | ((bb[i] ?? 0) << (7 - (i & 7))); + } + + // Construct the symbol: ECC + interleave, draw codewords, choose the best mask. + const build = createBuild(version, eccSpec); + const allCodewords = addEccAndInterleave(build, dataCodewords); + drawCodewords(build, allCodewords); + + let bestMask = 0; + let minPenalty = Number.POSITIVE_INFINITY; + for (let mask = 0; mask < 8; mask++) { + applyMask(build, mask); + drawFormatBits(build, mask); + const penalty = getPenaltyScore(build); + if (penalty < minPenalty) { + bestMask = mask; + minPenalty = penalty; + } + applyMask(build, mask); // undo (XOR is its own inverse) + } + applyMask(build, bestMask); + drawFormatBits(build, bestMask); + + const modules: boolean[] = new Array(build.modules.length); + for (let i = 0; i < build.modules.length; i++) { + modules[i] = build.modules[i] !== 0; + } + return { size: build.size, modules }; +} + +/** Read the module at `(x, y)`; out-of-range is treated as light (`false`). */ +export function readModule(matrix: QrMatrix, x: number, y: number): boolean { + if (x < 0 || x >= matrix.size || y < 0 || y >= matrix.size) { + return false; + } + return matrix.modules[y * matrix.size + x] === true; +} diff --git a/packages/vesper-ui/src/client/sections/channels.ts b/packages/vesper-ui/src/client/sections/channels.ts index 519edee..1df41de 100644 --- a/packages/vesper-ui/src/client/sections/channels.ts +++ b/packages/vesper-ui/src/client/sections/channels.ts @@ -1,6 +1,12 @@ /// import { ICONS } from "../shell/icons.ts"; -import { h, type SectionContext, type SectionModule, sectionHeader } from "../shell/section.ts"; +import { + h, + injectStyle, + type SectionContext, + type SectionModule, + sectionHeader, +} from "../shell/section.ts"; /** One row of `GET /api/connections` (mirrors core `ChannelState`). */ interface ChannelRow { @@ -11,8 +17,39 @@ interface ChannelRow { readonly enabled: boolean; readonly running: boolean; readonly docsUrl: string; + /** Whether this channel exposes a scan-to-connect pairing flow. */ + readonly pairable: boolean; } +/** + * A QR matrix from `GET /api/qr?data=...` — row-major, `modules[y * size + x]`, + * `true` = a dark module. Defined locally so the browser bundle never imports + * `@vesper/core` (whose barrel pulls `bun:sqlite`, which cannot run in a browser). + */ +interface QrMatrix { + readonly size: number; + readonly modules: readonly boolean[]; +} + +/** + * One newline-delimited frame from `POST /api/connections/:id/pair`. `awaiting` + * may repeat (a rotating code); any other status is terminal. Mirrors the core + * `PairingUpdate` — declared locally to keep core out of the browser bundle. + */ +type PairingUpdate = + | { + readonly status: "awaiting"; + readonly prompt: { + readonly kind: string; + readonly data: string; + readonly humanHint: string; + readonly expiresAt: number; + }; + } + | { readonly status: "linked"; readonly chatId?: string; readonly label?: string } + | { readonly status: "error"; readonly reason: string } + | { readonly status: "expired" }; + /** MCP servers stay a read-only catalog this slice (no enable/disable yet). */ const MCP: readonly string[] = [ "Linear", @@ -27,6 +64,29 @@ const MCP: readonly string[] = [ "Excalidraw", ]; +const STYLE_ID = "sec-channels-style"; +const STYLE = ` +.cn-row { display: flex; flex-direction: column; align-items: stretch; gap: 4px; } +.cn-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.cn-head .btn { min-height: 30px; padding: 0 14px; font-size: 13px; } +.cn-pair { margin-top: 10px; border: 1px solid var(--border-strong); border-radius: 12px; + background: var(--surface-2); padding: 16px; display: flex; flex-direction: column; + align-items: center; gap: 10px; text-align: center; } +.cn-pair canvas { image-rendering: pixelated; border-radius: 8px; background: #fff; + border: 1px solid var(--border); } +.cn-status { font-size: 14px; color: var(--ink); font-weight: 600; } +.cn-hint { font-size: 13.5px; color: var(--ink-soft); max-width: 42ch; line-height: 1.5; } +.cn-link { font-family: var(--mono); font-size: 12px; word-break: break-all; color: var(--accent); } +.cn-wait { font-size: 12.5px; color: var(--ink-faint); } +.cn-pair-actions { display: flex; gap: 8px; margin-top: 4px; } +.cn-pair.ok .cn-status { color: var(--ok); } +.cn-pair.bad .cn-status { color: var(--danger); } +`; + +/** Canvas geometry — module pixel size and the quiet-zone margin (in modules). */ +const QR_PIXEL = 6; +const QR_MARGIN = 2; + /** A badge describing a channel's live state (the honest gate is `available`). */ function channelBadge(c: ChannelRow): HTMLElement { if (!c.available) return h("span", { class: "badge" }, "soon"); @@ -54,25 +114,94 @@ function channelHint(c: ChannelRow): HTMLElement | null { ); } -function channelRow(c: ChannelRow): HTMLElement { - return h( - "div", - { class: "kv", style: "flex-direction:column;align-items:stretch;gap:4px" }, - h( - "div", - { style: "display:flex;align-items:center;justify-content:space-between" }, - h("span", { class: "k" }, c.displayName), - channelBadge(c), - ), - channelHint(c) ?? h("span"), - ); +/** Paint a QR matrix onto a canvas: light background, dark filled squares per dark module. */ +function drawQr(canvas: HTMLCanvasElement, m: QrMatrix): void { + const dim = (m.size + QR_MARGIN * 2) * QR_PIXEL; + canvas.width = dim; + canvas.height = dim; + const g = canvas.getContext("2d"); + if (g === null) return; + g.fillStyle = "#ffffff"; + g.fillRect(0, 0, dim, dim); + g.fillStyle = "#0b0a14"; + for (let y = 0; y < m.size; y++) { + for (let x = 0; x < m.size; x++) { + if (m.modules[y * m.size + x] !== true) continue; + g.fillRect((x + QR_MARGIN) * QR_PIXEL, (y + QR_MARGIN) * QR_PIXEL, QR_PIXEL, QR_PIXEL); + } + } +} + +/** Narrow an `unknown` JSON value to a {@link PairingUpdate}. */ +function asPairingUpdate(value: unknown): PairingUpdate | null { + if (typeof value !== "object" || value === null) return null; + const v = value as Record; + if (v.status === "awaiting") { + const p = v.prompt; + if (typeof p !== "object" || p === null) return null; + const prompt = p as Record; + if (typeof prompt.data !== "string" || typeof prompt.humanHint !== "string") return null; + return { + status: "awaiting", + prompt: { + kind: typeof prompt.kind === "string" ? prompt.kind : "link", + data: prompt.data, + humanHint: prompt.humanHint, + expiresAt: typeof prompt.expiresAt === "number" ? prompt.expiresAt : 0, + }, + }; + } + if (v.status === "linked") { + return { + status: "linked", + ...(typeof v.chatId === "string" ? { chatId: v.chatId } : {}), + ...(typeof v.label === "string" ? { label: v.label } : {}), + }; + } + if (v.status === "error") { + return { status: "error", reason: typeof v.reason === "string" ? v.reason : "pairing failed" }; + } + if (v.status === "expired") return { status: "expired" }; + return null; +} + +/** + * Read a `ReadableStream` body as newline-delimited JSON, yielding one decoded + * `PairingUpdate` per complete line. Tolerates frames split across chunks. + */ +async function* readNdjson( + body: ReadableStream, +): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + for (;;) { + const { value, done } = await reader.read(); + if (value !== undefined) buffer += decoder.decode(value, { stream: true }); + let newline = buffer.indexOf("\n"); + while (newline !== -1) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (line.length > 0) { + const update = asPairingUpdate(JSON.parse(line) as unknown); + if (update !== null) yield update; + } + newline = buffer.indexOf("\n"); + } + if (done) break; + } + const tail = buffer.trim(); + if (tail.length > 0) { + const update = asPairingUpdate(JSON.parse(tail) as unknown); + if (update !== null) yield update; + } } /** * Channels — the messaging surfaces (live, from `GET /api/connections`) and the MCP - * catalog. Telegram is the only channel with a shipped handler today; others read - * "soon". Credentials are set with the `vesper connections` CLI (stdin-only), never - * the browser, so this page is read-only status + accurate next steps. + * catalog. Pairable + configured channels expose a scan-to-connect "Connect" card + * that streams a QR code to link a chat from your phone. Tokens are still set with the + * `vesper connections` CLI (stdin-only) — the browser never accepts a credential. */ export const channelsSection: SectionModule = { id: "channels", @@ -80,6 +209,7 @@ export const channelsSection: SectionModule = { group: "vesper", glyph: ICONS.channels, async mount(host: HTMLElement, ctx: SectionContext) { + injectStyle(STYLE_ID, STYLE); host.append(sectionHeader("Channels", "Where Vesper can send and receive messages.")); const messaging = h( @@ -89,19 +219,215 @@ export const channelsSection: SectionModule = { ); host.append(messaging); - try { - const rows = await ctx.api.getJson("/api/connections"); - if (rows.length === 0) { - messaging.append(h("p", { class: "muted" }, "No channels are wired yet.")); - } else { - for (const c of rows) messaging.append(channelRow(c)); + // The single currently-open pairing card (only one at a time). Tracked across + // re-renders so a refresh tears the old card down before rebuilding the list. + let openCard: HTMLElement | null = null; + let cancelOpen: (() => void) | null = null; + + /** True once the section has been swapped out of the DOM — stop any live stream. */ + const unmounted = (): boolean => !host.isConnected; + + function closeCard(): void { + cancelOpen?.(); + cancelOpen = null; + openCard?.remove(); + openCard = null; + } + + /** (Re)load the channel list into the messaging panel — the badge-flip after a link. */ + async function refresh(): Promise { + closeCard(); + // Keep the panel title; drop the rows below it. + while (messaging.childNodes.length > 1) messaging.lastChild?.remove(); + try { + const rows = await ctx.api.getJson("/api/connections"); + if (rows.length === 0) { + messaging.append(h("p", { class: "muted" }, "No channels are wired yet.")); + return; + } + for (const c of rows) messaging.append(renderRow(c)); + } catch (err) { + messaging.append( + h( + "p", + { class: "muted" }, + err instanceof Error ? err.message : "could not load channels", + ), + ); } - } catch (err) { - messaging.append( - h("p", { class: "muted" }, err instanceof Error ? err.message : "could not load channels"), + } + + /** One channel row, with a Connect button + inline pairing card when pairable. */ + function renderRow(c: ChannelRow): HTMLElement { + const head = h( + "div", + { class: "cn-head" }, + h("span", { class: "k" }, c.displayName), + channelBadge(c), ); + + const row = h("div", { class: "kv cn-row" }, head); + + if (c.pairable && c.configured) { + const connect = h( + "button", + { class: "btn", type: "button", "aria-label": `Connect ${c.displayName}` }, + "Connect", + ); + connect.addEventListener("click", () => openPairing(c, row)); + head.append(connect); + } + + const hint = channelHint(c); + if (hint !== null) row.append(hint); + return row; + } + + /** Open (or re-open) the inline pairing card for a channel under its row. */ + function openPairing(c: ChannelRow, row: HTMLElement): void { + closeCard(); + + const canvas = h("canvas", { width: 132, height: 132 }); + const status = h("div", { class: "cn-status" }, "Starting…"); + const hint = h("p", { class: "cn-hint" }); + const link = h("a", { class: "cn-link", target: "_blank", rel: "noreferrer" }); + const wait = h("div", { class: "cn-wait" }); + const cancel = h("button", { class: "btn", type: "button" }, "Cancel"); + const actions = h("div", { class: "cn-pair-actions" }, cancel); + + const card = h( + "div", + { class: "cn-pair", role: "group", "aria-label": `Pair ${c.displayName}` }, + canvas, + status, + hint, + link, + wait, + actions, + ); + row.append(card); + openCard = card; + + const controller = new AbortController(); + cancelOpen = () => controller.abort(); + cancel.addEventListener("click", () => closeCard()); + + void runPairing(c, { card, canvas, status, hint, link, wait, actions, cancel, controller }); } + interface PairingUi { + readonly card: HTMLElement; + readonly canvas: HTMLCanvasElement; + readonly status: HTMLElement; + readonly hint: HTMLElement; + readonly link: HTMLAnchorElement; + readonly wait: HTMLElement; + readonly actions: HTMLElement; + readonly cancel: HTMLElement; + readonly controller: AbortController; + } + + /** Render a terminal failure with a "Try again" button that re-opens the flow. */ + function showFailure(c: ChannelRow, row: HTMLElement, ui: PairingUi, message: string): void { + ui.card.classList.remove("ok"); + ui.card.classList.add("bad"); + ui.canvas.style.display = "none"; + ui.status.textContent = message; + ui.hint.textContent = ""; + ui.link.removeAttribute("href"); + ui.link.textContent = ""; + ui.wait.textContent = ""; + const retry = h("button", { class: "btn primary", type: "button" }, "Try again"); + retry.addEventListener("click", () => openPairing(c, row)); + ui.actions.replaceChildren(retry, ui.cancel); + } + + /** Drive one pairing session: stream updates, draw the QR, flip on link/failure. */ + async function runPairing(c: ChannelRow, ui: PairingUi): Promise { + const row = ui.card.parentElement; + try { + const res = await fetch(`/api/connections/${encodeURIComponent(c.id)}/pair`, { + method: "POST", + signal: ui.controller.signal, + }); + if (!res.ok || res.body === null) { + const text = await res.text().catch(() => ""); + if (row !== null) + showFailure( + c, + row, + ui, + text.trim().length > 0 ? text.trim() : "Pairing is not available.", + ); + return; + } + + for await (const update of readNdjson(res.body)) { + if (unmounted() || !ui.card.isConnected) return; + + if (update.status === "awaiting") { + ui.status.textContent = "Scan to connect"; + ui.hint.textContent = update.prompt.humanHint; + ui.link.href = update.prompt.data; + ui.link.textContent = update.prompt.data; + ui.wait.textContent = "Waiting for you to scan…"; + try { + const matrix = (await ctx.api.getJson( + `/api/qr?data=${encodeURIComponent(update.prompt.data)}`, + )) satisfies QrMatrix; + if (!ui.card.isConnected) return; + ui.canvas.style.display = ""; + drawQr(ui.canvas, matrix); + } catch { + // Code unavailable — the clickable link still gets the user there. + ui.canvas.style.display = "none"; + } + continue; + } + + if (update.status === "linked") { + ui.card.classList.add("ok"); + ui.canvas.style.display = "none"; + ui.status.textContent = update.chatId + ? `Connected! (chat ${update.chatId})` + : "Connected!"; + ui.hint.textContent = ""; + ui.link.removeAttribute("href"); + ui.link.textContent = ""; + ui.wait.textContent = ""; + ui.actions.replaceChildren(); + ctx.toast(`${c.displayName} connected`); + setTimeout(() => { + if (!unmounted()) void refresh(); + }, 1500); + return; + } + + if (update.status === "expired") { + if (row !== null) showFailure(c, row, ui, "This code expired. Try again."); + return; + } + + // status === "error" + if (row !== null) showFailure(c, row, ui, update.reason); + return; + } + // Stream ended without a terminal frame. + if (!unmounted() && ui.card.isConnected && row !== null) { + showFailure(c, row, ui, "Pairing ended unexpectedly. Try again."); + } + } catch (err) { + // An abort on Cancel is expected — swallow it. + if (err instanceof DOMException && err.name === "AbortError") return; + if (unmounted() || !ui.card.isConnected || row === null) return; + showFailure(c, row, ui, err instanceof Error ? err.message : "Pairing failed."); + } + } + + ctx.onCleanup(() => closeCard()); + + await refresh(); + const mcp = h("div", { class: "panel" }, h("div", { class: "panel-title" }, "MCP servers")); const chips = h("div", { style: "display:flex;flex-wrap:wrap;gap:6px" }); for (const name of MCP) chips.append(h("span", { class: "badge" }, name)); diff --git a/packages/vesper-ui/src/server/server-pairing.test.ts b/packages/vesper-ui/src/server/server-pairing.test.ts new file mode 100644 index 0000000..b08f712 --- /dev/null +++ b/packages/vesper-ui/src/server/server-pairing.test.ts @@ -0,0 +1,134 @@ +import { Database } from "bun:sqlite"; +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + CAPABILITIES, + type CompleteFn, + HandlerRegistry, + openStore, + type PairingSession, + type PairingUpdate, + Scheduler, +} from "@vesper/core"; +import { startUiServer, type UiServerHandle } from "./server.ts"; + +const fakeComplete: CompleteFn = async () => ({ + text: "pong", + exit_code: 0, + raw_stdout: "pong", + raw_stderr: "", + duration_ms: 1, + usage: { + inputTokens: 1, + outputTokens: 1, + cacheReadTokens: 0, + cacheCreationTokens: 0, + model: "test", + }, +}); + +interface PairingProvider { + startPairing(channelId: string): Promise; +} + +function fakePairing(updates: readonly PairingUpdate[]): PairingProvider { + return { + startPairing: async (): Promise => ({ + updates: async function* () { + for (const u of updates) yield u; + }, + stop() {}, + }), + }; +} + +const cleanups: Array<() => void> = []; + +async function startServer(pairing?: PairingProvider): Promise { + const dir = mkdtempSync(join(tmpdir(), "vesper-pair-")); + const path = join(dir, "vesper.db"); + openStore(path).close(); // migrate + const db = new Database(path); + const store = openStore(path); + const scheduler = new Scheduler({ + db, + registry: new HandlerRegistry(), + grants: CAPABILITIES, + complete: fakeComplete, + }); + const handle = await startUiServer({ + scheduler, + store, + port: 0, + ...(pairing !== undefined ? { pairing } : {}), + }); + cleanups.push(() => { + handle.stop(); + db.close(); + store.close(); + rmSync(dir, { recursive: true, force: true }); + }); + return handle; +} + +afterEach(() => { + for (const c of cleanups.splice(0)) c(); +}); + +test("POST /api/connections/:id/pair streams ndjson PairingUpdates to a terminal status", async () => { + const handle = await startServer( + fakePairing([ + { + status: "awaiting", + prompt: { + kind: "link", + data: "https://t.me/vesperbot?start=abc", + humanHint: "scan me", + expiresAt: 1, + }, + }, + { status: "linked", chatId: "42", label: "omar" }, + ]), + ); + const res = await fetch(`${handle.url}/api/connections/telegram/pair`, { + method: "POST", + headers: { origin: handle.url }, + }); + expect(res.ok).toBe(true); + expect(res.headers.get("content-type")).toContain("x-ndjson"); + const lines = (await res.text()) + .trim() + .split("\n") + .map((l) => JSON.parse(l) as PairingUpdate); + expect(lines[0]).toMatchObject({ status: "awaiting" }); + expect(lines.at(-1)).toMatchObject({ status: "linked", chatId: "42" }); +}); + +test("POST /api/connections/:id/pair returns 503 when no pairing provider is wired", async () => { + const handle = await startServer(); + const res = await fetch(`${handle.url}/api/connections/telegram/pair`, { + method: "POST", + headers: { origin: handle.url }, + }); + expect(res.status).toBe(503); +}); + +test("GET /api/qr returns a square QR matrix for a link", async () => { + const handle = await startServer(); + const res = await fetch( + `${handle.url}/api/qr?data=${encodeURIComponent("https://t.me/vesperbot?start=abc")}`, + { headers: { origin: handle.url } }, + ); + expect(res.ok).toBe(true); + const matrix = (await res.json()) as { size: number; modules: boolean[] }; + expect(matrix.size).toBeGreaterThan(0); + expect(matrix.modules.length).toBe(matrix.size * matrix.size); +}); + +test("GET /api/qr rejects an empty data param", async () => { + const handle = await startServer(); + const res = await fetch(`${handle.url}/api/qr`, { headers: { origin: handle.url } }); + expect(res.status).toBe(400); +}); diff --git a/packages/vesper-ui/src/server/server.ts b/packages/vesper-ui/src/server/server.ts index c2ffa65..9159564 100644 --- a/packages/vesper-ui/src/server/server.ts +++ b/packages/vesper-ui/src/server/server.ts @@ -3,12 +3,13 @@ import { fileURLToPath } from "node:url"; import type { ApprovalTokenStore, ChannelState, + PairingSession, RunOutcome, RunTreeNode, Scheduler, Store, } from "@vesper/core"; -import { ApprovalError, RUN_COMPLETED, RUN_EVENT, SchedulerError } from "@vesper/core"; +import { ApprovalError, encodeQr, RUN_COMPLETED, RUN_EVENT, SchedulerError } from "@vesper/core"; import { ModuleRegistry } from "../modules/registry.ts"; import type { UiModule } from "../modules/types.ts"; import type { PresenceInfo, RunEventInfo, RunTreeInfo } from "../world/types.ts"; @@ -87,6 +88,15 @@ export interface UiServerDeps { readonly connections?: { list(): Promise; }; + /** + * Pairing (scan-to-connect) provider. `startPairing` begins a QR/link pairing + * attempt for one channel and returns a streamed {@link PairingSession}; the + * `POST /api/connections/:id/pair` route relays its updates as ndjson. Absent -> + * the route returns 503 (the daemon wired no pairing coordinator). + */ + readonly pairing?: { + startPairing(channelId: string): Promise; + }; /** * Prebuilt client assets. When set, the server serves these instead of reading * `client/index.html` and bundling `client/main.ts` from disk — required for the @@ -396,6 +406,61 @@ export async function startUiServer(deps: UiServerDeps): Promise return json(deps.connections === undefined ? [] : await deps.connections.list()); } + // GET /api/qr?data=... — encode a string as a QR matrix ({size, modules}) so the + // browser can draw a scannable code on a canvas WITHOUT bundling the core encoder + // (the @vesper/core barrel pulls bun:sqlite, which cannot run in the browser). + // Local-only (guarded above); length-bounded so a giant payload can't hog CPU. + if (req.method === "GET" && pathname === "/api/qr") { + const data = url.searchParams.get("data") ?? ""; + if (data.length === 0) return json({ error: "data is required" }, 400); + if (data.length > 2048) return json({ error: "data too long" }, 413); + try { + return json(encodeQr(data)); + } catch (err) { + return json({ error: err instanceof Error ? err.message : "qr encode failed" }, 400); + } + } + + // POST /api/connections/:id/pair — begin scan-to-connect pairing for one channel + // and stream PairingUpdates as newline-delimited JSON (consumed identically by the + // `vesper connections pair` CLI and the Vesper World Connect card). Local-only + // (guarded above); the stream carries non-secret nonces/links + the captured chat + // id, never a token. Closing the connection cancels the session. + const pairMatch = pathname.match(/^\/api\/connections\/([^/]+)\/pair$/); + if (req.method === "POST" && pairMatch) { + if (deps.pairing === undefined) { + return json({ error: "pairing is not available" }, 503); + } + const channelId = decodeURIComponent(pairMatch[1] ?? ""); + const session = await deps.pairing.startPairing(channelId); + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + const onAbort = (): void => session.stop(); + req.signal.addEventListener("abort", onAbort); + try { + for await (const update of session.updates()) { + controller.enqueue(encoder.encode(`${JSON.stringify(update)}\n`)); + // awaiting may repeat (rotating QR); any other status is terminal. + if (update.status !== "awaiting") break; + } + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + controller.enqueue( + encoder.encode(`${JSON.stringify({ status: "error", reason })}\n`), + ); + } finally { + req.signal.removeEventListener("abort", onAbort); + session.stop(); + controller.close(); + } + }, + }); + return new Response(stream, { + headers: { "content-type": "application/x-ndjson; charset=utf-8" }, + }); + } + // GET /api/runs?limit= — recent runs (newest-first) for Diagnostics. if (req.method === "GET" && pathname === "/api/runs") { const limitRaw = Number(url.searchParams.get("limit") ?? "50");