Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions cycle-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<bot>?start=<nonce>` — scan, tap Start, and the bot's
long-poll receives `/start <nonce>`, 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=<nonce>` so the nonce survives OAuth) + a first `pair <nonce>` 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 <id>` 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 <nonce>` 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.
45 changes: 45 additions & 0 deletions packages/vesper-cli/src/commands/connections-pair.test.ts
Original file line number Diff line number Diff line change
@@ -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<PairingUpdate> {
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);
});
});
93 changes: 91 additions & 2 deletions packages/vesper-cli/src/commands/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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<Uint8Array>): AsyncGenerator<PairingUpdate> {
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<PairingUpdate>,
print: (text: string) => void,
): Promise<number> {
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");
}
Expand Down Expand Up @@ -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 <id>",
async run({ positionals }) {
const id = positionals[0];
if (id === undefined) throw new Error("usage: vesper connections pair <id>");
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).",
Expand Down Expand Up @@ -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,
],
};
17 changes: 15 additions & 2 deletions packages/vesper-cli/src/commands/daemon-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand All @@ -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(", ")}`));
Expand Down
Loading
Loading