feat(meetings): Hanzo AI for Zoom + Google Meet
@hanzo/meetings — an in-meeting assistant for Zoom and Google Meet over one shared panel, wired to api.hanzo.ai/v1 via @hanzo/ai and hanzo.id via @hanzo/iam. - Shared panel (src/panel): model picker + quick-action chips (Summarize / Action items / Follow-up email) + streaming output + prompt, host-agnostic via a PanelHost seam. Same UX language as the Office task pane / PDF workspace. - Zoom App (src/zoom): in-meeting side panel over @zoom/appssdk + a server for OAuth install and the recording.completed / meeting.ended webhook — verifies the Zoom HMAC signature, answers endpoint.url_validation, downloads the cloud recording VTT, and summarizes it via @hanzo/ai. - Google Meet add-on (src/meet): side-panel web app over @googleworkspace/meet-addons. - src/hanzo.ts: pure transcript windowing/truncation (honest context note), WebVTT parse, OpenAI-compatible /v1 request shaping + SSE streaming, summary- prompt assembly. src/config.ts: endpoints, pickBearer, server env (fail-fast). - 70 vitest tests (windowing, VTT, request shaping over mock fetch, webhook signature verify + routing, OAuth shaping, config). tsc --noEmit clean; build produces dist/zoom, dist/meet, dist/server.js. - Registers packages/meetings in pnpm-workspace.yaml (one line).
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
# Hanzo AI for Zoom + Google Meet
|
||||
|
||||
Bring Hanzo AI **into the meeting**. Every industry runs on calls; this puts a
|
||||
Hanzo assistant in the side panel of **Zoom** and **Google Meet** to answer
|
||||
questions about the meeting, write a **summary**, extract **action items**, and
|
||||
draft a **follow-up email** — plus a server that turns a completed Zoom cloud
|
||||
recording into a summary automatically.
|
||||
|
||||
Two surfaces, one shared panel:
|
||||
|
||||
- **Zoom App** (`src/zoom/`) — an in-meeting side panel over the **Zoom Apps
|
||||
SDK** (`@zoom/appssdk`), plus a small **server** (`src/zoom/server.ts`) for
|
||||
OAuth install and the `recording.completed` / `meeting.ended` **webhook**.
|
||||
- **Google Meet add-on** (`src/meet/`) — a side-panel web app over the **Meet
|
||||
Add-ons SDK** (`@googleworkspace/meet-addons`).
|
||||
|
||||
Both mount the same assistant panel (`src/panel/`) and call the **same backend**
|
||||
as the rest of the Hanzo productivity suite: model calls go through
|
||||
`api.hanzo.ai/v1` (OpenAI-compatible, streamed over SSE) via `@hanzo/ai`, with
|
||||
identity via `@hanzo/iam` (hanzo.id). No new API surface, no `/api/` prefix.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Ask about the meeting** — grounded in the transcript, with speaker
|
||||
attribution, streamed as it's written.
|
||||
- **Summarize** — purpose, key points, decisions.
|
||||
- **Action items** — a checklist with owner and due date where stated.
|
||||
- **Follow-up email** — a professional recap with next steps.
|
||||
- **Auto-summary on Zoom cloud recordings** — the webhook downloads the recording
|
||||
transcript (VTT), summarizes it, and logs the result (delivery target is a
|
||||
per-deployment choice — see below).
|
||||
|
||||
## Transcript → summary — honest context windowing
|
||||
|
||||
A one-hour call transcribes to tens of thousands of words; it does not fit a
|
||||
model context window and is never sent wholesale. `buildTranscriptContext` (in
|
||||
`src/hanzo.ts`, pure and unit-tested) caps the attached transcript at
|
||||
`TRANSCRIPT_CHAR_BUDGET` (48 000 chars):
|
||||
|
||||
- Segments are walked **in order** (order carries meaning in a meeting) and
|
||||
included until the budget is reached; at least the first segment is always
|
||||
included (hard-cut if that one segment is over budget).
|
||||
- The request carries a **context note** stating how many of how many segments
|
||||
were sent and, when truncated, telling the model to answer only from what it
|
||||
sees and to say so if the omitted part is needed. The model is never told it
|
||||
has the whole meeting when it doesn't.
|
||||
|
||||
`parseVtt` turns Zoom's WebVTT transcript download into those segments (speaker +
|
||||
start timestamp + text), leniently (unknown input → `[]`, reported honestly).
|
||||
|
||||
### A note on live in-panel transcripts
|
||||
|
||||
Neither the Zoom Apps SDK nor the Meet Add-ons SDK hands a **live** transcript to
|
||||
an in-meeting panel. So the panels answer live questions and read meeting
|
||||
metadata (topic, id, participants where scoped), while the **grounded transcript
|
||||
summary** is produced **after** the call:
|
||||
|
||||
- **Zoom** — via the `recording.completed` webhook, which downloads the cloud
|
||||
recording's VTT. (Requires cloud recording + audio transcript enabled on the
|
||||
Zoom account.)
|
||||
- **Meet** — the same server pattern can pull the transcript from the Meet REST
|
||||
API post-meeting (gated by Workspace admin policy + consent); the panel's
|
||||
`getTranscript()` returns `[]` today and is honest about it.
|
||||
|
||||
This is stated in the panel's context note rather than faked.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
pnpm install --ignore-workspace
|
||||
pnpm build # → dist/zoom, dist/meet, dist/server.js
|
||||
pnpm test # vitest (pure logic: windowing, VTT, request shaping, webhook)
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm start # run the Zoom OAuth + webhook server (needs env, below)
|
||||
```
|
||||
|
||||
Build output:
|
||||
|
||||
```
|
||||
dist/zoom/index.html dist/zoom/zoom.js — Zoom App panel (host at meetings.hanzo.ai/zoom)
|
||||
dist/meet/index.html dist/meet/meet.js — Meet add-on panel (host at meetings.hanzo.ai/meet)
|
||||
dist/server.js — Zoom OAuth install + webhook service
|
||||
```
|
||||
|
||||
The Meet add-on needs its Google Cloud **project number** stamped in at build
|
||||
time:
|
||||
|
||||
```bash
|
||||
HANZO_MEET_GCP_PROJECT=123456789012 pnpm build
|
||||
```
|
||||
|
||||
## Configuration (environment only — never commit secrets)
|
||||
|
||||
The server reads these from the environment (validated at startup by
|
||||
`readServerConfig`; a missing required secret fails fast). Store them in KMS,
|
||||
never in the repo.
|
||||
|
||||
| Variable | Required | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `ZOOM_CLIENT_ID` | yes | Zoom app OAuth client id (public; also used by the panel). |
|
||||
| `ZOOM_CLIENT_SECRET` | yes | Zoom app OAuth client secret — **server only**. |
|
||||
| `ZOOM_REDIRECT_URI` | yes | OAuth redirect, e.g. `https://meetings.hanzo.ai/zoom/oauth/callback`. |
|
||||
| `ZOOM_WEBHOOK_SECRET_TOKEN` | yes | Verifies the HMAC signature on every webhook. |
|
||||
| `HANZO_API_KEY` | no | Bearer the webhook uses to summarize (no user in the loop). Without it, the webhook verifies + routes but skips summarization. |
|
||||
| `PORT` | no | Listen port (default `8788`). |
|
||||
|
||||
The Meet add-on and the in-panel Zoom app use a **pasted Hanzo API key** (`hk-…`,
|
||||
held in `localStorage`, validated by a real `/v1/models` call) — no OAuth client
|
||||
needed for the panel itself. hanzo.id device login is the documented upgrade; the
|
||||
bearer plumbing already tolerates any token.
|
||||
|
||||
## Register the Zoom App (marketplace.zoom.us)
|
||||
|
||||
1. **Create app** → *General app* (formerly "Zoom App / OAuth"). Choose
|
||||
*User-managed* (or account-level per your distribution).
|
||||
2. **OAuth**
|
||||
- Redirect URL for OAuth: `https://meetings.hanzo.ai/zoom/oauth/callback`
|
||||
(= `ZOOM_REDIRECT_URI`).
|
||||
- Add `https://meetings.hanzo.ai` to the OAuth allow list.
|
||||
3. **Scopes** (least privilege for what this app uses):
|
||||
- `meeting:read` — meeting context/UUID in-panel.
|
||||
- `cloud_recording:read` — download the recording transcript for the webhook.
|
||||
- (Optional) `meeting:read:list_participants` / participant scope for the
|
||||
participant list in the panel.
|
||||
4. **Features → Zoom App SDK** — enable the embedded browser; add the
|
||||
in-client **Home URL**: `https://meetings.hanzo.ai/zoom/index.html`. Add the
|
||||
domain to the app's Domain Allow List. Under **Zoom App SDK → APIs**, enable
|
||||
exactly the JS APIs the panel requests in `src/zoom/main.ts`:
|
||||
`getMeetingContext`, `getMeetingUUID`, `getMeetingParticipants`,
|
||||
`getRunningContext`, `getUserContext`.
|
||||
5. **Event Subscriptions (webhook)**
|
||||
- Event notification endpoint URL: `https://meetings.hanzo.ai/zoom/webhook`.
|
||||
- Subscribe to **Recording → Recording Completed** (`recording.completed`)
|
||||
and **Meeting → Meeting Ended** (`meeting.ended`).
|
||||
- Copy the **Secret Token** into `ZOOM_WEBHOOK_SECRET_TOKEN`.
|
||||
- **URL validation:** Zoom sends an `endpoint.url_validation` event on save;
|
||||
the server answers it automatically (echoes `plainToken` + its HMAC via
|
||||
`urlValidationResponse`), so click **Validate** with the server running.
|
||||
|
||||
**Webhook trust:** every request is verified before any transcript is fetched.
|
||||
The server recomputes `v0=HMAC_SHA256(secretToken, "v0:{timestamp}:{rawBody}")`
|
||||
over the **raw** body and compares it to `x-zm-signature` in constant time
|
||||
(`verifySignature`). A bad or absent signature is a `401`.
|
||||
|
||||
## Register the Google Meet add-on
|
||||
|
||||
1. In the **Google Cloud project** that owns the add-on, note the **project
|
||||
number** and build with `HANZO_MEET_GCP_PROJECT=<number>`.
|
||||
2. **Google Workspace Marketplace SDK** → configure a **Meet add-on**:
|
||||
- **Side panel URL:** `https://meetings.hanzo.ai/meet/index.html`
|
||||
- (Optional) **Main stage URL** if you add a main-stage view later.
|
||||
- Set the add-on's display name, logo, and support links.
|
||||
3. Publish to your Workspace (private/internal listing is enough for an org).
|
||||
Admins install it for the domain; it then appears in the Meet **Activities**
|
||||
panel.
|
||||
|
||||
The side panel calls `meet.addon.createAddonSession({ cloudProjectNumber })` →
|
||||
`createSidePanelClient()` and reads `getMeetingInfo()` (meeting id + code). See
|
||||
`src/meet/main.ts`.
|
||||
|
||||
## Hosting (hanzoai/static + hanzoai/ingress)
|
||||
|
||||
The two panels are static (HTML + one ESM bundle + icons) — serve `dist/zoom`
|
||||
and `dist/meet` with **hanzoai/static** behind **hanzoai/ingress** at
|
||||
`meetings.hanzo.ai`. The **server** (`dist/server.js`) is a small dependency-free
|
||||
Node service (Node stdlib only) — run it as a container behind the same ingress
|
||||
for the `/zoom/install`, `/zoom/oauth/callback`, and `/zoom/webhook` routes.
|
||||
Every model call goes to `api.hanzo.ai/v1` regardless of the host origin. No
|
||||
nginx, no caddy.
|
||||
|
||||
## Where the summary is delivered
|
||||
|
||||
The webhook pipeline is complete through **generating the summary**
|
||||
(`handleRecordingCompleted` → `summarizeTranscript`). Delivering it — posting to
|
||||
Zoom Team Chat, emailing participants, writing to a CRM — is a per-deployment
|
||||
choice wired at the marked hook in `src/zoom/server.ts`; the summary text is in
|
||||
hand there.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
config.ts endpoints, pickBearer, server env (readServerConfig)
|
||||
hanzo.ts chat client + transcript windowing/VTT + prompt assembly (pure core)
|
||||
auth.ts pasted-key storage + validation (localStorage)
|
||||
panel/
|
||||
host.ts PanelHost — the seam between the panel and a platform
|
||||
panel.ts shared DOM wiring (model picker, chips, streaming output)
|
||||
panel.html shared panel markup (stamped per surface at build)
|
||||
zoom/
|
||||
main.ts Zoom App entry — PanelHost over zoomSdk
|
||||
oauth.ts Zoom OAuth request shaping (pure)
|
||||
webhook.ts signature verify + url-validation + event routing (pure)
|
||||
server.ts http glue: install / oauth callback / webhook
|
||||
meet/
|
||||
main.ts Meet add-on entry — PanelHost over the Meet SDK
|
||||
tests/ vitest for every pure module
|
||||
build.js esbuild → dist/ (both panels + server)
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 956 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,113 @@
|
||||
// Build @hanzo/meetings into dist/: bundle the two web panels (Zoom + Meet) as
|
||||
// ESM, bundle the Zoom install/webhook server for Node, copy the shared panel
|
||||
// HTML once per surface (with its entry script stamped), and copy assets.
|
||||
//
|
||||
// node build.js → production
|
||||
// HANZO_MEET_GCP_PROJECT=123456789012 node build.js → stamp the Meet GCP project number
|
||||
// HANZO_MEETINGS_BASE=https://localhost:8443 node build.js → local dev base (informational)
|
||||
// node build.js --watch → rebuild on change
|
||||
//
|
||||
// Output layout:
|
||||
// dist/zoom/index.html dist/zoom/zoom.js (Zoom App panel)
|
||||
// dist/meet/index.html dist/meet/meet.js (Meet add-on panel)
|
||||
// dist/zoom/assets/… dist/meet/assets/… (icons, per surface)
|
||||
// dist/server.js (Zoom OAuth + webhook service)
|
||||
//
|
||||
// The two panels are HOSTED over hanzoai/static behind hanzoai/ingress (e.g.
|
||||
// meetings.hanzo.ai/zoom, meetings.hanzo.ai/meet); the server runs as a small
|
||||
// service. All model calls go to api.hanzo.ai regardless of the host base. No
|
||||
// framework — esbuild + Node stdlib only.
|
||||
|
||||
import esbuild from 'esbuild';
|
||||
import { rmSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BASE = (process.env.HANZO_MEETINGS_BASE || 'https://meetings.hanzo.ai').replace(/\/+$/, '');
|
||||
// The Meet add-on needs the add-on's Google Cloud project number at build time.
|
||||
// Stamped as a define; empty in a plain build (the panel still runs with a
|
||||
// pasted key outside Meet, and a real deploy sets this).
|
||||
const MEET_GCP_PROJECT = process.env.HANZO_MEET_GCP_PROJECT || '';
|
||||
const watch = process.argv.includes('--watch');
|
||||
const src = join(__dirname, 'src');
|
||||
const dist = join(__dirname, 'dist');
|
||||
const assetsSrc = join(__dirname, 'assets');
|
||||
|
||||
// The two panel surfaces: entry TS + output subdir. Each gets a copy of the
|
||||
// shared panel.html with __ENTRY__ pointing at its bundle.
|
||||
const PANELS = [
|
||||
{ name: 'zoom', entry: join(src, 'zoom', 'main.ts'), out: 'zoom' },
|
||||
{ name: 'meet', entry: join(src, 'meet', 'main.ts'), out: 'meet' },
|
||||
];
|
||||
|
||||
function copyAssets(dir) {
|
||||
const dst = join(dir, 'assets');
|
||||
mkdirSync(dst, { recursive: true });
|
||||
for (const f of readdirSync(assetsSrc)) copyFileSync(join(assetsSrc, f), join(dst, f));
|
||||
}
|
||||
|
||||
async function build() {
|
||||
rmSync(dist, { recursive: true, force: true });
|
||||
mkdirSync(dist, { recursive: true });
|
||||
|
||||
// Bundle each panel as ESM into dist/<out>/<out>.js.
|
||||
const panelCtx = await esbuild.context({
|
||||
entryPoints: Object.fromEntries(PANELS.map((p) => [`${p.out}/${p.out}`, p.entry])),
|
||||
outdir: dist,
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
target: ['chrome90', 'edge90', 'firefox90', 'safari15'],
|
||||
sourcemap: true,
|
||||
minify: !watch,
|
||||
define: { HANZO_MEET_GCP_PROJECT: JSON.stringify(MEET_GCP_PROJECT) },
|
||||
logLevel: 'info',
|
||||
});
|
||||
await panelCtx.rebuild();
|
||||
|
||||
// Copy the shared panel.html per surface, stamping __ENTRY__ and the base.
|
||||
const panelHtml = readFileSync(join(src, 'panel', 'panel.html'), 'utf8');
|
||||
for (const p of PANELS) {
|
||||
const outDir = join(dist, p.out);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const html = panelHtml
|
||||
.split('__ENTRY__').join(`${p.out}.js`)
|
||||
.replace('</head>', ` <meta name="hanzo:base" content="${BASE}" />\n</head>`);
|
||||
writeFileSync(join(outDir, 'index.html'), html);
|
||||
copyAssets(outDir);
|
||||
}
|
||||
|
||||
// Bundle the Zoom server as a Node ESM binary. External nothing — it uses only
|
||||
// Node stdlib + our pure modules, so a single file drops into a container.
|
||||
const serverCtx = await esbuild.context({
|
||||
entryPoints: [join(src, 'zoom', 'server.ts')],
|
||||
outfile: join(dist, 'server.js'),
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: ['node18'],
|
||||
sourcemap: true,
|
||||
minify: !watch,
|
||||
banner: { js: "import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);" },
|
||||
logLevel: 'info',
|
||||
});
|
||||
await serverCtx.rebuild();
|
||||
|
||||
console.log(`Hanzo Meetings built -> dist/ (base ${BASE}${MEET_GCP_PROJECT ? `, meet gcp ${MEET_GCP_PROJECT}` : ''})`);
|
||||
console.log(' Panels: dist/zoom/index.html, dist/meet/index.html · Server: dist/server.js');
|
||||
|
||||
if (watch) {
|
||||
await panelCtx.watch();
|
||||
await serverCtx.watch();
|
||||
console.log('watching...');
|
||||
} else {
|
||||
await panelCtx.dispose();
|
||||
await serverCtx.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
build().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@hanzo/meetings",
|
||||
"version": "0.1.0",
|
||||
"description": "Hanzo AI for Zoom + Google Meet — an in-meeting assistant that summarizes calls, extracts action items, drafts follow-ups, and answers questions about the meeting. A Zoom App and a Google Meet add-on over one shared panel, wired to the api.hanzo.ai gateway and hanzo.id IAM.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"watch": "node build.js --watch",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@googleworkspace/meet-addons": "1.2.0",
|
||||
"@hanzo/ai": "0.1.1",
|
||||
"@hanzo/iam": "0.13.2",
|
||||
"@zoom/appssdk": "0.16.39"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.14.0",
|
||||
"esbuild": "0.25.8",
|
||||
"typescript": "5.8.3",
|
||||
"vitest": "3.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Auth for the web meeting panels: the zero-setup pasted-key path. The Zoom App
|
||||
// and Meet add-on are static web panels (iframe/webview), so the credential
|
||||
// lives in localStorage and is validated by a real /v1/models call — a key that
|
||||
// can't list models is rejected before it's saved, so the user learns at paste
|
||||
// time, not at first question. OAuth via hanzo.id (device login) is the
|
||||
// documented upgrade; the bearer plumbing tolerates any token, so adding it is
|
||||
// additive. Mirrors @hanzo/pdf's auth exactly (one way to hold a key).
|
||||
|
||||
import { APIKEY_STORAGE_KEY, pickBearer } from './config.js';
|
||||
import { listModels } from './hanzo.js';
|
||||
|
||||
export function getApiKey(): string {
|
||||
try {
|
||||
return localStorage.getItem(APIKEY_STORAGE_KEY) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function setApiKey(key: string): void {
|
||||
try {
|
||||
const k = key.trim();
|
||||
if (k) localStorage.setItem(APIKEY_STORAGE_KEY, k);
|
||||
else localStorage.removeItem(APIKEY_STORAGE_KEY);
|
||||
} catch {
|
||||
/* private-mode / storage disabled — the in-memory key still works this session */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearApiKey(): void {
|
||||
setApiKey('');
|
||||
}
|
||||
|
||||
export function hasApiKey(): boolean {
|
||||
return !!getApiKey();
|
||||
}
|
||||
|
||||
// bearer is the credential the chat call sends: the pasted key (or empty for
|
||||
// anonymous public models). When OAuth lands it slots in as the second argument
|
||||
// to pickBearer with no change to callers.
|
||||
export function bearer(): string {
|
||||
return pickBearer(getApiKey(), '');
|
||||
}
|
||||
|
||||
// validateKey confirms a pasted key actually works by listing models with it.
|
||||
// Returns the models on success so the caller populates the picker in one
|
||||
// round-trip; throws with the gateway's message on failure.
|
||||
export async function validateKey(key: string): Promise<string[]> {
|
||||
const k = key.trim();
|
||||
if (!k) throw new Error('Enter a Hanzo API key (hk-…).');
|
||||
return listModels(k);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Meetings config — the api.hanzo.ai model gateway, the default model, the
|
||||
// pasted-key storage, and the two server-side secret sets (Zoom OAuth + Zoom
|
||||
// webhook). Endpoints and the bearer choice mirror @hanzo/office-addin and
|
||||
// @hanzo/pdf so the productivity suite stays DRY; transcript windowing (the
|
||||
// thing a *meeting* surface adds) lives in hanzo.ts, not here.
|
||||
|
||||
export const API_BASE_URL = 'https://api.hanzo.ai';
|
||||
|
||||
// Default model. Overridable per-request via the picker; the gateway routes it.
|
||||
export const DEFAULT_MODEL = 'zen-1';
|
||||
|
||||
// localStorage key for the pasted Hanzo API key (`hk-…`). The Zoom App and the
|
||||
// Meet add-on are static web panels (iframe/webview), not an Office host with
|
||||
// roamingSettings, so the zero-setup credential lives in localStorage — same as
|
||||
// @hanzo/pdf. OAuth via hanzo.id (device login) is the documented upgrade; the
|
||||
// bearer plumbing already tolerates any token, so it slots in additively.
|
||||
export const APIKEY_STORAGE_KEY = 'hanzo.meetings.apiKey';
|
||||
|
||||
// Transcript budget. A one-hour call transcribes to tens of thousands of words;
|
||||
// the whole thing won't fit a model window and shouldn't be sent. This caps the
|
||||
// characters of transcript we attach to any one request — chosen conservatively
|
||||
// so it fits comfortably inside a modern context window alongside the reply, and
|
||||
// is honest rather than optimal (we truncate visibly, never drop silently).
|
||||
export const TRANSCRIPT_CHAR_BUDGET = 48_000;
|
||||
|
||||
// pickBearer chooses the credential to send: a pasted API key wins over an OAuth
|
||||
// token (an explicit key is a deliberate override), else the token, else empty
|
||||
// (anonymous — the gateway still serves public models). Pure — unit-tested.
|
||||
export function pickBearer(apiKey: string, oauthToken: string): string {
|
||||
return (apiKey && apiKey.trim()) || (oauthToken && oauthToken.trim()) || '';
|
||||
}
|
||||
|
||||
// chatCompletionsURL / modelsURL — the model gateway endpoints. /v1 only, never
|
||||
// an /api/ prefix (api.hanzo.ai IS the api host).
|
||||
export function chatCompletionsURL(): string {
|
||||
return `${API_BASE_URL}/v1/chat/completions`;
|
||||
}
|
||||
export function modelsURL(): string {
|
||||
return `${API_BASE_URL}/v1/models`;
|
||||
}
|
||||
|
||||
// ---- Server-side configuration (Zoom OAuth + webhook) --------------------
|
||||
//
|
||||
// These are read from the environment by src/zoom/server.ts. They NEVER reach
|
||||
// the browser bundle: the client id is public, but the client secret and the
|
||||
// webhook secret token are server-only and are validated to be present before
|
||||
// the server will start (readServerConfig throws on a missing secret).
|
||||
|
||||
// The Zoom marketplace app OAuth base + endpoints. Zoom account-level tokens are
|
||||
// minted at zoom.us/oauth/token; the REST API lives at api.zoom.us/v2.
|
||||
export const ZOOM_OAUTH_TOKEN_URL = 'https://zoom.us/oauth/token';
|
||||
export const ZOOM_OAUTH_AUTHORIZE_URL = 'https://zoom.us/oauth/authorize';
|
||||
export const ZOOM_API_BASE_URL = 'https://api.zoom.us/v2';
|
||||
|
||||
export interface ServerConfig {
|
||||
/** Zoom OAuth client id (public — also stamped into the in-client URL). */
|
||||
zoomClientId: string;
|
||||
/** Zoom OAuth client secret (SERVER ONLY — token exchange). */
|
||||
zoomClientSecret: string;
|
||||
/** OAuth redirect registered on the Zoom app (e.g. https://meetings.hanzo.ai/zoom/oauth/callback). */
|
||||
zoomRedirectUri: string;
|
||||
/** Zoom webhook secret token — verifies the HMAC signature on every event. */
|
||||
zoomWebhookSecret: string;
|
||||
/**
|
||||
* Hanzo API key the server uses to summarize a completed recording. The
|
||||
* webhook runs with no user in the loop, so it needs its own bearer. Optional:
|
||||
* without it, the server verifies + routes events but cannot summarize (it
|
||||
* logs and skips), which readServerConfig reports honestly.
|
||||
*/
|
||||
hanzoApiKey: string;
|
||||
/** Listen port. */
|
||||
port: number;
|
||||
}
|
||||
|
||||
// readServerConfig fails fast (throws) if a required Zoom secret is missing — a
|
||||
// server that can neither exchange OAuth codes nor verify webhooks must not
|
||||
// pretend to start. hanzoApiKey is the one optional field (see above). Pure
|
||||
// given an env map, so it is unit-tested without touching process.env.
|
||||
export function readServerConfig(env: Record<string, string | undefined>): ServerConfig {
|
||||
const zoomClientId = env.ZOOM_CLIENT_ID;
|
||||
const zoomClientSecret = env.ZOOM_CLIENT_SECRET;
|
||||
const zoomRedirectUri = env.ZOOM_REDIRECT_URI;
|
||||
const zoomWebhookSecret = env.ZOOM_WEBHOOK_SECRET_TOKEN;
|
||||
const missing: string[] = [];
|
||||
if (!zoomClientId) missing.push('ZOOM_CLIENT_ID');
|
||||
if (!zoomClientSecret) missing.push('ZOOM_CLIENT_SECRET');
|
||||
if (!zoomRedirectUri) missing.push('ZOOM_REDIRECT_URI');
|
||||
if (!zoomWebhookSecret) missing.push('ZOOM_WEBHOOK_SECRET_TOKEN');
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required environment: ${missing.join(', ')}`);
|
||||
}
|
||||
return {
|
||||
zoomClientId: zoomClientId!,
|
||||
zoomClientSecret: zoomClientSecret!,
|
||||
zoomRedirectUri: zoomRedirectUri!,
|
||||
zoomWebhookSecret: zoomWebhookSecret!,
|
||||
hanzoApiKey: env.HANZO_API_KEY || '',
|
||||
port: Number(env.PORT) || 8788,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
// The Hanzo call and its request/response shaping — pure, host-agnostic, and
|
||||
// fully unit-testable (no zoomSdk, no Meet SDK, no DOM). The panel and the
|
||||
// server are the thin glue that read the meeting transcript/context and hand it
|
||||
// here. Speaks the OpenAI-compatible /v1 wire protocol the whole Hanzo suite
|
||||
// uses (identical to @hanzo/pdf and @hanzo/office-addin) so the gateway sees one
|
||||
// shape from every surface.
|
||||
//
|
||||
// On @hanzo/ai: the meetings package depends on @hanzo/ai + @hanzo/iam (the
|
||||
// shared headless client + identity), and the browser panel is free to route
|
||||
// through createAiClient(). This module is the transcript-aware core the client
|
||||
// wraps — the windowing/truncation and summary-prompt assembly that a *meeting*
|
||||
// surface adds on top of a generic chat call, kept pure so it can be tested and
|
||||
// reused by both the panel and the (browser-less) webhook server.
|
||||
|
||||
import {
|
||||
chatCompletionsURL,
|
||||
modelsURL,
|
||||
DEFAULT_MODEL,
|
||||
TRANSCRIPT_CHAR_BUDGET,
|
||||
} from './config.js';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
// SYSTEM_PROMPT grounds every meeting answer in the transcript. It forbids
|
||||
// inventing statements nobody made (the failure mode that makes a meeting
|
||||
// assistant untrustworthy), asks for speaker attribution when the transcript
|
||||
// carries it, and stays honest about a truncated transcript.
|
||||
export const SYSTEM_PROMPT =
|
||||
'You are Hanzo AI, an assistant inside a live video meeting (Zoom or Google Meet). ' +
|
||||
'Answer ONLY from the meeting transcript and context provided below — never invent ' +
|
||||
'statements, decisions, or attributions that are not supported by the transcript. ' +
|
||||
'Attribute points to speakers when the transcript names them. Be concise and ' +
|
||||
'structured. If the transcript is marked truncated and a complete answer needs the ' +
|
||||
'omitted part, say so plainly rather than guessing.';
|
||||
|
||||
// ---- Transcript windowing / truncation -----------------------------------
|
||||
//
|
||||
// A transcript arrives as ordered segments (one utterance each), optionally with
|
||||
// a speaker and a timestamp. We window them down to what we will actually send,
|
||||
// newest-relevant first is NOT what we want for a summary (order carries
|
||||
// meaning), so we walk IN ORDER from the start and stop at the budget — always
|
||||
// including at least the first segment.
|
||||
|
||||
// One utterance in a transcript. speaker/ts are optional because Zoom's VTT and
|
||||
// Meet's captions do not always carry both.
|
||||
export interface TranscriptSegment {
|
||||
speaker?: string;
|
||||
/** Free-form timestamp label as it appears in the source (e.g. "00:04:12"). */
|
||||
ts?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// The result of windowing a transcript down to what we'll attach: the rendered
|
||||
// text, how many of the source segments it covers, and whether anything was
|
||||
// dropped (so the prompt and UI can say so honestly).
|
||||
export interface TranscriptContext {
|
||||
text: string;
|
||||
totalSegments: number;
|
||||
includedSegments: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
// renderSegment turns one utterance into a single transcript line. Pure so the
|
||||
// exact rendering (used to measure the budget) is what we also send.
|
||||
export function renderSegment(seg: TranscriptSegment): string {
|
||||
const who = seg.speaker ? seg.speaker.trim() : '';
|
||||
const t = seg.ts ? seg.ts.trim() : '';
|
||||
const prefix = who && t ? `[${t}] ${who}: ` : who ? `${who}: ` : t ? `[${t}] ` : '';
|
||||
return `${prefix}${seg.text.trim()}`;
|
||||
}
|
||||
|
||||
// buildTranscriptContext caps the rendered transcript at `budget` characters. It
|
||||
// walks segments IN ORDER and stops when adding the next line would exceed the
|
||||
// budget — always including at least the first segment (hard-cut to the budget
|
||||
// if that one line alone is over). `truncated` is true whenever not every
|
||||
// segment made it in. Pure and total: deterministic, no I/O.
|
||||
export function buildTranscriptContext(
|
||||
segments: TranscriptSegment[],
|
||||
budget: number = TRANSCRIPT_CHAR_BUDGET,
|
||||
): TranscriptContext {
|
||||
const total = segments.length;
|
||||
if (total === 0) {
|
||||
return { text: '', totalSegments: 0, includedSegments: 0, truncated: false };
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
let used = 0;
|
||||
let hardCut = false;
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const line = renderSegment(segments[i]);
|
||||
const addition = (lines.length === 0 ? '' : '\n') + line;
|
||||
if (used + addition.length > budget) {
|
||||
if (lines.length === 0) {
|
||||
// First line overflows alone — hard-cut so we always return something.
|
||||
lines.push(line.slice(0, budget));
|
||||
used = budget;
|
||||
hardCut = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
lines.push(addition);
|
||||
used += addition.length;
|
||||
}
|
||||
|
||||
const includedSegments = hardCut ? 1 : lines.length;
|
||||
const truncated = includedSegments < total || hardCut;
|
||||
return { text: lines.join(''), totalSegments: total, includedSegments, truncated };
|
||||
}
|
||||
|
||||
// parseVtt parses a WebVTT transcript (the format Zoom's cloud-recording
|
||||
// transcript download returns) into ordered segments. It is lenient: it reads
|
||||
// cue timing lines for the start timestamp, treats a leading "Name:" on the cue
|
||||
// text as the speaker, and skips the WEBVTT header, NOTE blocks, and numeric
|
||||
// cue ids. Pure — the network fetch that gets the VTT lives in the server; this
|
||||
// is the parse it feeds. Unknown/blank input yields an empty array (honest: no
|
||||
// throw, the caller sees "no transcript").
|
||||
export function parseVtt(vtt: string): TranscriptSegment[] {
|
||||
const segments: TranscriptSegment[] = [];
|
||||
// Cues are separated by blank lines. Normalize newlines first.
|
||||
const blocks = vtt.replace(/\r\n/g, '\n').split(/\n\n+/);
|
||||
for (const block of blocks) {
|
||||
const lines = block.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
|
||||
if (lines.length === 0) continue;
|
||||
if (lines[0].toUpperCase().startsWith('WEBVTT')) continue;
|
||||
if (lines[0].toUpperCase().startsWith('NOTE')) continue;
|
||||
|
||||
let idx = 0;
|
||||
// Optional numeric/string cue id line (no '-->').
|
||||
if (!lines[idx].includes('-->')) idx++;
|
||||
// Timing line: "00:00:01.000 --> 00:00:04.000 ...".
|
||||
let ts: string | undefined;
|
||||
if (idx < lines.length && lines[idx].includes('-->')) {
|
||||
ts = lines[idx].split('-->')[0].trim().split('.')[0]; // start, drop ms
|
||||
idx++;
|
||||
}
|
||||
const textLines = lines.slice(idx);
|
||||
if (textLines.length === 0) continue;
|
||||
let text = textLines.join(' ').trim();
|
||||
if (text.length === 0) continue;
|
||||
|
||||
// "Speaker Name: utterance" → split speaker off the front.
|
||||
let speaker: string | undefined;
|
||||
const m = text.match(/^([^:]{1,60}):\s+(.*)$/);
|
||||
if (m) {
|
||||
speaker = m[1].trim();
|
||||
text = m[2].trim();
|
||||
}
|
||||
if (text.length === 0) continue;
|
||||
segments.push({ speaker, ts, text });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
// contextNote is the one honest sentence prepended to the transcript so the
|
||||
// model (and, echoed in the panel, the user) knows the scope of what it sees.
|
||||
// Never claim the whole meeting was sent when it wasn't.
|
||||
export function contextNote(ctx: TranscriptContext): string {
|
||||
if (ctx.totalSegments === 0) return 'No transcript is available for this meeting yet.';
|
||||
return ctx.truncated
|
||||
? `Transcript: ${ctx.includedSegments} of ${ctx.totalSegments} segments (truncated to fit — answer only from what is shown and say so if the omitted part is needed).`
|
||||
: `Transcript: the full meeting (${ctx.totalSegments} segment${ctx.totalSegments === 1 ? '' : 's'}).`;
|
||||
}
|
||||
|
||||
// ---- Prompt assembly ------------------------------------------------------
|
||||
|
||||
// Optional structured context about the meeting that the SDKs can supply
|
||||
// (participants, topic, id). Attached as a short header above the transcript so
|
||||
// answers can reference who was present without the model guessing.
|
||||
export interface MeetingContext {
|
||||
topic?: string;
|
||||
participants?: string[];
|
||||
meetingId?: string;
|
||||
}
|
||||
|
||||
function meetingHeader(meta: MeetingContext | undefined): string {
|
||||
if (!meta) return '';
|
||||
const parts: string[] = [];
|
||||
if (meta.topic) parts.push(`Meeting: ${meta.topic}`);
|
||||
if (meta.participants && meta.participants.length > 0) {
|
||||
parts.push(`Participants: ${meta.participants.join(', ')}`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join('\n') + '\n\n' : '';
|
||||
}
|
||||
|
||||
// buildMessages turns a task (the user's instruction, or one of the quick-action
|
||||
// prompts) plus the windowed transcript + optional meeting metadata into the
|
||||
// OpenAI-compatible message list. The transcript is fenced so the model treats
|
||||
// it as data, not instructions, and the honest context note rides inside the
|
||||
// user turn so it is never lost.
|
||||
export function buildMessages(
|
||||
task: string,
|
||||
ctx: TranscriptContext,
|
||||
meta?: MeetingContext,
|
||||
): ChatMessage[] {
|
||||
const system: ChatMessage = { role: 'system', content: SYSTEM_PROMPT };
|
||||
const header = meetingHeader(meta);
|
||||
const user: ChatMessage = {
|
||||
role: 'user',
|
||||
content:
|
||||
`${header}${contextNote(ctx)}\n\n` +
|
||||
`---- meeting transcript ----\n${ctx.text}\n---- end transcript ----\n\n` +
|
||||
`---- task ----\n${task}`,
|
||||
};
|
||||
return [system, user];
|
||||
}
|
||||
|
||||
// Quick actions — the chips the panel offers. Named so both the panel UI and the
|
||||
// webhook summary path draw the exact same wording (DRY: the auto-summary a
|
||||
// recording.completed event produces is the same "Summarize" the user clicks).
|
||||
export const QUICK_ACTIONS = {
|
||||
summary:
|
||||
'Write a concise summary of this meeting: the purpose, the key points discussed, ' +
|
||||
'and the decisions made. Use short paragraphs or bullets.',
|
||||
actionItems:
|
||||
'Extract the action items from this meeting as a checklist. For each: the owner ' +
|
||||
'(if named), the task, and any due date mentioned. If none were assigned, say so.',
|
||||
followupEmail:
|
||||
'Draft a short, professional follow-up email to the participants: thank them, ' +
|
||||
'recap the decisions, and list next steps with owners. Return only the email body.',
|
||||
} as const;
|
||||
|
||||
export interface ChatOptions {
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
/** Request an SSE stream. Defaults to false (one-shot completion). */
|
||||
stream?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
// requestBody is the exact JSON the gateway receives. Separated so a test can
|
||||
// assert the wire shape (model default, stream flag, messages) without a network
|
||||
// call. `stream` defaults to false — the one-shot contract.
|
||||
export function requestBody(messages: ChatMessage[], opts: ChatOptions = {}): Record<string, unknown> {
|
||||
return {
|
||||
model: opts.model || DEFAULT_MODEL,
|
||||
messages,
|
||||
stream: opts.stream === true,
|
||||
...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// extractContent pulls the assistant text out of an OpenAI-compatible completion
|
||||
// response, tolerating the shapes the gateway returns. Throws on an error
|
||||
// payload so the caller surfaces the real gateway message.
|
||||
export function extractContent(data: any): string {
|
||||
if (data && data.error) {
|
||||
const msg = typeof data.error === 'string' ? data.error : data.error.message || 'unknown error';
|
||||
throw new Error(`Hanzo API error: ${msg}`);
|
||||
}
|
||||
const choice = data?.choices?.[0];
|
||||
const content = choice?.message?.content ?? choice?.text ?? data?.content ?? '';
|
||||
if (typeof content !== 'string' || content === '') {
|
||||
throw new Error('Hanzo API returned no content');
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
// ask runs one non-streaming completion and returns the answer. token may be
|
||||
// empty (the gateway serves anonymous/limited models); when present it is the
|
||||
// bearer (a pasted hk- key or an IAM token).
|
||||
export async function ask(
|
||||
task: string,
|
||||
ctx: TranscriptContext,
|
||||
token: string,
|
||||
meta?: MeetingContext,
|
||||
opts: ChatOptions = {},
|
||||
): Promise<string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const resp = await fetch(chatCompletionsURL(), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody(buildMessages(task, ctx, meta), opts)),
|
||||
signal: opts.signal,
|
||||
});
|
||||
const text = await resp.text();
|
||||
let data: any;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`Hanzo API ${resp.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
try {
|
||||
return extractContent(data);
|
||||
} catch {
|
||||
const m = data?.error?.message || data?.error || `HTTP ${resp.status}`;
|
||||
throw new Error(`Hanzo API ${resp.status}: ${m}`);
|
||||
}
|
||||
}
|
||||
return extractContent(data);
|
||||
}
|
||||
|
||||
// streamDelta pulls the incremental text out of one OpenAI-compatible SSE frame
|
||||
// payload (the JSON after `data: `). Pure, so the SSE parse is unit-testable.
|
||||
export function streamDelta(payload: string): string {
|
||||
const json = JSON.parse(payload);
|
||||
return json?.choices?.[0]?.delta?.content ?? '';
|
||||
}
|
||||
|
||||
// askStream runs a streaming completion, invoking onDelta with each text chunk
|
||||
// as it arrives, and resolves with the full concatenated text. Same inputs as
|
||||
// `ask`; the panel uses it for live-typing output. Throws on a non-2xx.
|
||||
export async function askStream(
|
||||
task: string,
|
||||
ctx: TranscriptContext,
|
||||
token: string,
|
||||
onDelta: (text: string) => void,
|
||||
meta?: MeetingContext,
|
||||
opts: ChatOptions = {},
|
||||
): Promise<string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const resp = await fetch(chatCompletionsURL(), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody(buildMessages(task, ctx, meta), { ...opts, stream: true })),
|
||||
signal: opts.signal,
|
||||
});
|
||||
if (!resp.ok || !resp.body) {
|
||||
const detail = await resp.text().catch(() => '');
|
||||
throw new Error(`Hanzo API ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
||||
}
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let full = '';
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parts = buffer.split('\n');
|
||||
buffer = parts.pop() ?? '';
|
||||
for (const line of parts) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('data:')) continue;
|
||||
const payload = trimmed.slice(5).trim();
|
||||
if (payload === '[DONE]') return full;
|
||||
try {
|
||||
const delta = streamDelta(payload);
|
||||
if (delta) {
|
||||
full += delta;
|
||||
onDelta(delta);
|
||||
}
|
||||
} catch {
|
||||
// partial SSE frame — wait for the next chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
// summarizeTranscript is the server-side one-shot: window the transcript, build
|
||||
// the summary prompt, and return the summary. This is what the recording.completed
|
||||
// webhook calls after downloading the VTT — no browser, no streaming, one bearer.
|
||||
export async function summarizeTranscript(
|
||||
segments: TranscriptSegment[],
|
||||
token: string,
|
||||
meta?: MeetingContext,
|
||||
opts: ChatOptions = {},
|
||||
): Promise<string> {
|
||||
const ctx = buildTranscriptContext(segments);
|
||||
return ask(QUICK_ACTIONS.summary, ctx, token, meta, opts);
|
||||
}
|
||||
|
||||
// listModels returns the model ids the caller may route to, from /v1/models. The
|
||||
// endpoint is org-scoped by the bearer; an empty token lists public models.
|
||||
export async function listModels(token: string): Promise<string[]> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const resp = await fetch(modelsURL(), { headers });
|
||||
if (!resp.ok) throw new Error(`Hanzo API ${resp.status}: model list failed`);
|
||||
const data: any = await resp.json();
|
||||
const items = data?.data ?? data?.models ?? (Array.isArray(data) ? data : []);
|
||||
return items
|
||||
.map((m: any) => (typeof m === 'string' ? m : m?.id))
|
||||
.filter((id: unknown): id is string => typeof id === 'string' && id.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Google Meet add-on entry — the side-panel web app. Runs inside Meet's add-on
|
||||
// iframe. It creates an add-on session, gets the side-panel client, builds a
|
||||
// PanelHost over it, and mounts the shared assistant panel.
|
||||
//
|
||||
// What the Meet Add-ons SDK supplies in-panel: meeting info (meetingId +
|
||||
// meetingCode) via getMeetingInfo(). Like Zoom, the SDK does NOT hand a live
|
||||
// transcript to a side-panel add-on — Meet transcripts are produced by the Meet
|
||||
// REST API / Workspace after the meeting, gated by admin policy and user
|
||||
// consent. So getTranscript() returns [] and the panel answers live questions,
|
||||
// staying honest about scope in its context note. (A future server path can
|
||||
// pull the Meet REST transcript the same way the Zoom webhook pulls the VTT.)
|
||||
//
|
||||
// The add-on's Google Cloud project number is stamped at build time (the Meet
|
||||
// SDK requires it to create a session) — see build.js / HANZO_MEET_GCP_PROJECT.
|
||||
|
||||
import { meet, type MeetSidePanelClient } from '@googleworkspace/meet-addons/meet.addons';
|
||||
import { mountPanel } from '../panel/panel.js';
|
||||
import type { PanelHost } from '../panel/host.js';
|
||||
import type { TranscriptSegment, MeetingContext } from '../hanzo.js';
|
||||
|
||||
// Stamped by build.js from HANZO_MEET_GCP_PROJECT. The Meet SDK needs the
|
||||
// add-on's Google Cloud project number to open a session.
|
||||
declare const HANZO_MEET_GCP_PROJECT: string;
|
||||
|
||||
let sidePanel: MeetSidePanelClient | undefined;
|
||||
|
||||
async function buildContextMeta(): Promise<MeetingContext> {
|
||||
const meta: MeetingContext = {};
|
||||
if (!sidePanel) return meta;
|
||||
try {
|
||||
const info = await sidePanel.getMeetingInfo();
|
||||
if (info?.meetingId) meta.meetingId = info.meetingId;
|
||||
// Meet's meetingCode (aaa-bbbb-ccc) is the human-facing label; use it as the
|
||||
// topic when no richer title is available.
|
||||
if (info?.meetingCode) meta.topic = `Meet ${info.meetingCode}`;
|
||||
} catch {
|
||||
/* session not ready or info unavailable — leave unset (honest) */
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
const host: PanelHost = {
|
||||
name: 'Google Meet',
|
||||
// The side-panel SDK does not expose the live transcript; a grounded summary
|
||||
// comes from the post-meeting REST transcript path. [] is the honest answer.
|
||||
async getTranscript(): Promise<TranscriptSegment[]> {
|
||||
return [];
|
||||
},
|
||||
getMeetingContext: buildContextMeta,
|
||||
};
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
try {
|
||||
const session = await meet.addon.createAddonSession({
|
||||
cloudProjectNumber: HANZO_MEET_GCP_PROJECT,
|
||||
});
|
||||
sidePanel = await session.createSidePanelClient();
|
||||
} catch {
|
||||
// Opened outside Meet (dev in a plain browser) — the panel still mounts and
|
||||
// works with a pasted key; getMeetingContext just returns empty metadata.
|
||||
}
|
||||
mountPanel(host);
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => void boot());
|
||||
} else {
|
||||
void boot();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// PanelHost is the seam between the shared assistant panel and the two meeting
|
||||
// platforms. The panel (panel/panel.ts) is host-agnostic: it renders the model
|
||||
// picker, chips, prompt and streaming output, and asks the host for the two
|
||||
// things only the platform can supply — the meeting's transcript and its
|
||||
// metadata (topic/participants). Zoom and Meet each provide a PanelHost; the
|
||||
// panel never imports a platform SDK.
|
||||
//
|
||||
// This is the "one way" the panel talks to a platform: values in, no coupling.
|
||||
|
||||
import type { TranscriptSegment, MeetingContext } from '../hanzo.js';
|
||||
|
||||
export interface PanelHost {
|
||||
/** Short label shown in the header (e.g. "Zoom", "Google Meet"). */
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* The meeting transcript as ordered segments. In-panel live transcript access
|
||||
* is limited by each platform's SDK (see each host for what it can and cannot
|
||||
* read), so this may be empty during a call — the panel shows an honest note
|
||||
* and still answers general prompts. Returns [] when no transcript is
|
||||
* available rather than throwing.
|
||||
*/
|
||||
getTranscript(): Promise<TranscriptSegment[]>;
|
||||
|
||||
/** Meeting metadata (topic, participants, id) where the SDK permits. */
|
||||
getMeetingContext(): Promise<MeetingContext>;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Hanzo AI</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; --fg:#1a1a1a; --muted:#666; --bg:#fff; --line:#e3e3e3; --accent:#111; }
|
||||
@media (prefers-color-scheme: dark) { :root { --fg:#eaeaea; --muted:#9a9a9a; --bg:#1e1e1e; --line:#333; --accent:#fff; } }
|
||||
* { box-sizing: border-box; }
|
||||
body { font: 14px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--fg); background: var(--bg); margin: 0; padding: 12px; }
|
||||
header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||
header img { width: 22px; height: 22px; }
|
||||
header .title { font-weight: 600; }
|
||||
header .host { color: var(--muted); font-size: 12px; margin-left: auto; }
|
||||
label { display: block; font-size: 12px; color: var(--muted); margin: 8px 0 4px; }
|
||||
textarea, select, input { width: 100%; font: inherit; color: var(--fg); background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 8px; }
|
||||
textarea#prompt { min-height: 64px; resize: vertical; }
|
||||
textarea#output { min-height: 200px; resize: vertical; }
|
||||
.row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
button { font: inherit; border: 1px solid var(--line); background: var(--accent); color: var(--bg); border-radius: 6px; padding: 8px 14px; cursor: pointer; }
|
||||
button.secondary { background: transparent; color: var(--fg); }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
.status { min-height: 18px; font-size: 12px; margin-top: 8px; color: var(--muted); }
|
||||
.status.ok { color: #1a7f37; } .status.warn { color: #9a6700; } .status.error { color: #cf222e; }
|
||||
.spacer { flex: 1; }
|
||||
footer { margin-top: 10px; font-size: 11px; color: var(--muted); }
|
||||
select#model { width: auto; max-width: 150px; margin-left: auto; padding: 4px 8px; font-size: 12px; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.chip { padding: 5px 10px; border-radius: 999px; font-size: 13px; background: transparent; color: var(--fg); border: 1px solid var(--line); }
|
||||
.chip:hover:not(:disabled) { border-color: var(--accent); }
|
||||
details summary { cursor: pointer; font-size: 12px; color: var(--muted); margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<img src="assets/icon-32.png" alt="Hanzo" />
|
||||
<span class="title">Hanzo AI</span>
|
||||
<select id="model" aria-label="Model"></select>
|
||||
<span class="host" id="hostname">Meeting</span>
|
||||
</header>
|
||||
|
||||
<!-- One-click actions over the meeting transcript. -->
|
||||
<div class="chips">
|
||||
<button class="chip" data-action="summary">Summarize</button>
|
||||
<button class="chip" data-action="action-items">Action items</button>
|
||||
<button class="chip" data-action="followup-email">Follow-up email</button>
|
||||
</div>
|
||||
|
||||
<label for="prompt">Ask about this meeting</label>
|
||||
<textarea id="prompt" placeholder="e.g. What did we decide about the launch date? · Who owns the pricing follow-up? · Summarize the last 10 minutes"></textarea>
|
||||
|
||||
<div class="row">
|
||||
<button id="run">Ask Hanzo</button>
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>Hanzo API key</summary>
|
||||
<div class="row">
|
||||
<input id="apikey" type="password" placeholder="hk-…" />
|
||||
<button id="savekey" class="secondary">Save</button>
|
||||
</div>
|
||||
<div id="authhint" style="font-size:11px;color:var(--muted);margin-top:4px;"></div>
|
||||
</details>
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
|
||||
<label for="output">Result</label>
|
||||
<textarea id="output" placeholder="Hanzo's answer appears here."></textarea>
|
||||
|
||||
<footer>Routed through api.hanzo.ai · answers are grounded in the meeting transcript.</footer>
|
||||
|
||||
<script type="module" src="__ENTRY__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,177 @@
|
||||
// The shared assistant panel — host-agnostic DOM wiring over @hanzo/ai. Both the
|
||||
// Zoom App (zoom/main.ts) and the Google Meet add-on (meet/main.ts) mount THIS
|
||||
// with a PanelHost; the panel never imports a platform SDK. It renders: a model
|
||||
// picker (populated from /v1/models when a key is present), quick-action chips
|
||||
// (Summarize / Action items / Follow-up email), a prompt box, and a streaming
|
||||
// output area. Same UX language as the Office task pane and the PDF workspace.
|
||||
//
|
||||
// This module is the DOM glue; all the testable logic (windowing, prompt
|
||||
// assembly, request shaping, streaming parse) lives in hanzo.ts and is unit-
|
||||
// tested there. The panel just binds it to the page.
|
||||
|
||||
import { DEFAULT_MODEL } from '../config.js';
|
||||
import {
|
||||
buildTranscriptContext,
|
||||
contextNote,
|
||||
askStream,
|
||||
listModels,
|
||||
QUICK_ACTIONS,
|
||||
type TranscriptContext,
|
||||
type MeetingContext,
|
||||
} from '../hanzo.js';
|
||||
import { bearer, getApiKey, setApiKey, validateKey } from '../auth.js';
|
||||
import type { PanelHost } from './host.js';
|
||||
|
||||
// $ is a tiny typed getElementById. The panel HTML (panel.html) guarantees these
|
||||
// ids exist, so a missing one is a programming error, not a user error.
|
||||
function $<T extends HTMLElement>(id: string): T {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) throw new Error(`panel: #${id} missing from the page`);
|
||||
return el as T;
|
||||
}
|
||||
|
||||
function setStatus(el: HTMLElement, text: string, kind: '' | 'ok' | 'warn' | 'error' = ''): void {
|
||||
el.textContent = text;
|
||||
el.className = kind ? `status ${kind}` : 'status';
|
||||
}
|
||||
|
||||
// populateModels fills the picker from /v1/models (org-scoped by the bearer).
|
||||
// Falls back to the default model when unauthenticated or the list fails — the
|
||||
// panel is always usable.
|
||||
async function populateModels(select: HTMLSelectElement, token: string): Promise<void> {
|
||||
let ids: string[] = [];
|
||||
try {
|
||||
ids = token ? await listModels(token) : [];
|
||||
} catch {
|
||||
ids = [];
|
||||
}
|
||||
if (ids.length === 0) ids = [DEFAULT_MODEL];
|
||||
const current = select.value;
|
||||
select.innerHTML = '';
|
||||
for (const id of ids) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id;
|
||||
opt.textContent = id;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
select.value = ids.includes(current) ? current : ids[0];
|
||||
}
|
||||
|
||||
// gatherContext pulls the transcript + meeting metadata from the host and windows
|
||||
// the transcript to the budget. Shown to the user as an honest scope note.
|
||||
async function gatherContext(host: PanelHost): Promise<{ ctx: TranscriptContext; meta: MeetingContext }> {
|
||||
const [segments, meta] = await Promise.all([host.getTranscript(), host.getMeetingContext()]);
|
||||
return { ctx: buildTranscriptContext(segments), meta };
|
||||
}
|
||||
|
||||
// run executes one streaming completion for `task` and types the result into the
|
||||
// output area. Shared by the chips and the "Ask" button.
|
||||
async function run(host: PanelHost, task: string, els: PanelEls): Promise<void> {
|
||||
const trimmed = task.trim();
|
||||
if (!trimmed) {
|
||||
setStatus(els.status, 'Type a question or pick a quick action.', 'warn');
|
||||
return;
|
||||
}
|
||||
els.run.disabled = true;
|
||||
els.output.value = '';
|
||||
try {
|
||||
const { ctx, meta } = await gatherContext(host);
|
||||
setStatus(els.status, contextNote(ctx));
|
||||
const token = bearer();
|
||||
const model = els.model.value || DEFAULT_MODEL;
|
||||
await askStream(
|
||||
trimmed,
|
||||
ctx,
|
||||
token,
|
||||
(delta) => {
|
||||
els.output.value += delta;
|
||||
els.output.scrollTop = els.output.scrollHeight;
|
||||
},
|
||||
meta,
|
||||
{ model },
|
||||
);
|
||||
setStatus(els.status, contextNote(ctx), 'ok');
|
||||
} catch (e: any) {
|
||||
setStatus(els.status, e?.message || 'Request failed.', 'error');
|
||||
} finally {
|
||||
els.run.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
interface PanelEls {
|
||||
model: HTMLSelectElement;
|
||||
prompt: HTMLTextAreaElement;
|
||||
output: HTMLTextAreaElement;
|
||||
run: HTMLButtonElement;
|
||||
status: HTMLElement;
|
||||
apikey: HTMLInputElement;
|
||||
savekey: HTMLButtonElement;
|
||||
authhint: HTMLElement;
|
||||
hostname: HTMLElement;
|
||||
}
|
||||
|
||||
// The quick-action chips map their data-task to a QUICK_ACTIONS prompt (or their
|
||||
// own inline text). Named actions keep the panel and the webhook summary in sync.
|
||||
const CHIP_TASKS: Record<string, string> = {
|
||||
summary: QUICK_ACTIONS.summary,
|
||||
'action-items': QUICK_ACTIONS.actionItems,
|
||||
'followup-email': QUICK_ACTIONS.followupEmail,
|
||||
};
|
||||
|
||||
// mountPanel wires the DOM to a host. Call once on DOMContentLoaded from each
|
||||
// platform entry after the host is ready.
|
||||
export function mountPanel(host: PanelHost): void {
|
||||
const els: PanelEls = {
|
||||
model: $('model'),
|
||||
prompt: $('prompt'),
|
||||
output: $('output'),
|
||||
run: $('run'),
|
||||
status: $('status'),
|
||||
apikey: $('apikey'),
|
||||
savekey: $('savekey'),
|
||||
authhint: $('authhint'),
|
||||
hostname: $('hostname'),
|
||||
};
|
||||
els.hostname.textContent = host.name;
|
||||
|
||||
// Reflect the current auth state and populate models.
|
||||
const refreshAuth = async (): Promise<void> => {
|
||||
const key = getApiKey();
|
||||
els.authhint.textContent = key ? 'Using your saved Hanzo API key.' : 'Paste a Hanzo API key (hk-…) to use your models.';
|
||||
await populateModels(els.model, bearer());
|
||||
};
|
||||
void refreshAuth();
|
||||
|
||||
// Save / validate a pasted key.
|
||||
els.savekey.addEventListener('click', async () => {
|
||||
const key = els.apikey.value.trim();
|
||||
try {
|
||||
const models = await validateKey(key);
|
||||
setApiKey(key);
|
||||
els.apikey.value = '';
|
||||
setStatus(els.status, 'Key saved.', 'ok');
|
||||
els.authhint.textContent = `Saved. ${models.length} model${models.length === 1 ? '' : 's'} available.`;
|
||||
await populateModels(els.model, bearer());
|
||||
} catch (e: any) {
|
||||
setStatus(els.status, e?.message || 'That key did not work.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Quick-action chips.
|
||||
for (const chip of Array.from(document.querySelectorAll<HTMLButtonElement>('.chip'))) {
|
||||
chip.addEventListener('click', () => {
|
||||
const key = chip.getAttribute('data-action') || '';
|
||||
const task = CHIP_TASKS[key];
|
||||
if (task) void run(host, task, els);
|
||||
});
|
||||
}
|
||||
|
||||
// Ask button + Ctrl/Cmd-Enter in the prompt.
|
||||
els.run.addEventListener('click', () => void run(host, els.prompt.value, els));
|
||||
els.prompt.addEventListener('keydown', (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void run(host, els.prompt.value, els);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Zoom App entry — the in-meeting side panel. Runs inside Zoom's embedded
|
||||
// webview. It configures the Zoom Apps SDK for the capabilities we use, builds a
|
||||
// PanelHost over zoomSdk, and mounts the shared assistant panel.
|
||||
//
|
||||
// What the in-client SDK can supply: meeting context (topic + id), the meeting
|
||||
// UUID, and the participant list (with the right scopes granted). What it CANNOT
|
||||
// supply in-panel is the live transcript — Zoom exposes the transcript via the
|
||||
// cloud-recording REST API AFTER the meeting (that path is the webhook in
|
||||
// server.ts), not to an in-meeting app. So getTranscript() returns [] here and
|
||||
// the panel answers general questions live, while the recording.completed
|
||||
// webhook produces the grounded post-meeting summary. This is stated honestly in
|
||||
// the panel's context note rather than faked.
|
||||
|
||||
import zoomSdk from '@zoom/appssdk';
|
||||
import { mountPanel } from '../panel/panel.js';
|
||||
import type { PanelHost } from '../panel/host.js';
|
||||
import type { TranscriptSegment, MeetingContext } from '../hanzo.js';
|
||||
|
||||
// The JS APIs the panel needs. Declared up front so config() requests exactly
|
||||
// these capabilities (Zoom gates each behind a granted scope). `as const` keeps
|
||||
// each entry a literal so it satisfies the SDK's Apis union.
|
||||
const CAPABILITIES = [
|
||||
'getMeetingContext',
|
||||
'getMeetingUUID',
|
||||
'getMeetingParticipants',
|
||||
'getRunningContext',
|
||||
'getUserContext',
|
||||
] as const;
|
||||
|
||||
async function buildContextMeta(): Promise<MeetingContext> {
|
||||
const meta: MeetingContext = {};
|
||||
try {
|
||||
const ctx = await zoomSdk.getMeetingContext();
|
||||
if (ctx?.meetingTopic) meta.topic = ctx.meetingTopic;
|
||||
if (ctx?.meetingID) meta.meetingId = ctx.meetingID;
|
||||
} catch {
|
||||
/* not in a meeting, or scope not granted — leave unset (honest) */
|
||||
}
|
||||
try {
|
||||
const uuid = await zoomSdk.getMeetingUUID();
|
||||
if (uuid?.meetingUUID && !meta.meetingId) meta.meetingId = uuid.meetingUUID;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
const res = await zoomSdk.getMeetingParticipants();
|
||||
const names = (res?.participants || [])
|
||||
.map((p: any) => p?.screenName || p?.displayName)
|
||||
.filter((n: unknown): n is string => typeof n === 'string' && n.length > 0);
|
||||
if (names.length > 0) meta.participants = names;
|
||||
} catch {
|
||||
/* participant scope not granted — leave unset */
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
const host: PanelHost = {
|
||||
name: 'Zoom',
|
||||
// The in-meeting SDK does not expose the live transcript to an app; the
|
||||
// grounded transcript summary is produced by the cloud-recording webhook after
|
||||
// the call. Returning [] is the honest answer for the live panel.
|
||||
async getTranscript(): Promise<TranscriptSegment[]> {
|
||||
return [];
|
||||
},
|
||||
getMeetingContext: buildContextMeta,
|
||||
};
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
try {
|
||||
await zoomSdk.config({ capabilities: [...CAPABILITIES], version: '0.16.0' });
|
||||
} catch {
|
||||
// Outside the Zoom client (e.g. opened directly in a browser for dev), the
|
||||
// SDK config fails; the panel still mounts and works with a pasted key.
|
||||
}
|
||||
mountPanel(host);
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => void boot());
|
||||
} else {
|
||||
void boot();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Zoom OAuth (authorization-code) — pure request shaping. The client_secret is
|
||||
// held by the server (server.ts) and never reaches the browser; these functions
|
||||
// build the exact URL/headers/body of each OAuth call so the wire shape is
|
||||
// unit-testable without a network round-trip. Token exchange itself is one
|
||||
// fetch in the server.
|
||||
//
|
||||
// Zoom uses HTTP Basic auth (base64 client_id:client_secret) on the token
|
||||
// endpoint and form-encoded bodies — the OAuth2 standard shape, documented at
|
||||
// developers.zoom.us/docs/integrations/oauth.
|
||||
|
||||
import { ZOOM_OAUTH_AUTHORIZE_URL, ZOOM_OAUTH_TOKEN_URL } from '../config.js';
|
||||
|
||||
// authorizeUrl is where the install flow sends the admin's browser to grant the
|
||||
// app. `state` is the CSRF token the server generates and re-checks on callback.
|
||||
export function authorizeUrl(clientId: string, redirectUri: string, state: string): string {
|
||||
const q = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
state,
|
||||
});
|
||||
return `${ZOOM_OAUTH_AUTHORIZE_URL}?${q.toString()}`;
|
||||
}
|
||||
|
||||
// A prepared HTTP request: the pieces a single fetch needs. Pure output so a
|
||||
// test asserts the Basic header + form body without opening a socket.
|
||||
export interface PreparedRequest {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function basicAuth(clientId: string, clientSecret: string): string {
|
||||
return 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
||||
}
|
||||
|
||||
// tokenExchange builds the code→token request. The secret rides in the Basic
|
||||
// header, never the body or the URL.
|
||||
export function tokenExchange(args: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
code: string;
|
||||
}): PreparedRequest {
|
||||
return {
|
||||
url: ZOOM_OAUTH_TOKEN_URL,
|
||||
headers: {
|
||||
Authorization: basicAuth(args.clientId, args.clientSecret),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: args.code,
|
||||
redirect_uri: args.redirectUri,
|
||||
}).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
// The token response we care about. Zoom returns access_token (+ refresh_token,
|
||||
// expires_in, scope); parseTokenResponse validates the one field we must have.
|
||||
export interface ZoomTokenSet {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
scope?: string;
|
||||
token_type?: string;
|
||||
}
|
||||
|
||||
// parseTokenResponse validates a Zoom token payload — throws with Zoom's own
|
||||
// error message on a failure body (so the server surfaces the real reason)
|
||||
// rather than silently returning a token-less object.
|
||||
export function parseTokenResponse(data: any): ZoomTokenSet {
|
||||
if (!data || typeof data !== 'object') throw new Error('empty token response');
|
||||
if (data.error) {
|
||||
const reason = data.reason || data.error_description || data.error;
|
||||
throw new Error(`Zoom OAuth error: ${reason}`);
|
||||
}
|
||||
if (typeof data.access_token !== 'string' || data.access_token.length === 0) {
|
||||
throw new Error('Zoom OAuth response missing access_token');
|
||||
}
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_in: data.expires_in,
|
||||
scope: data.scope,
|
||||
token_type: data.token_type,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// The Zoom install + webhook service. This is the ONLY place the Zoom
|
||||
// client_secret and the webhook secret token exist — both are read from the
|
||||
// environment (never bundled, never sent to the browser). It:
|
||||
//
|
||||
// GET /zoom/install → redirect the admin to Zoom's OAuth consent
|
||||
// GET /zoom/oauth/callback → exchange the code for a token (secret stays here)
|
||||
// POST /zoom/webhook → verify the Zoom signature, handle url_validation,
|
||||
// and on recording.completed fetch the transcript
|
||||
// (VTT) and summarize it via @hanzo/ai
|
||||
// GET /healthz → readiness
|
||||
//
|
||||
// It is a dependency-free Node http handler built on the pure modules
|
||||
// (config, zoom/oauth, zoom/webhook, hanzo) so it is deployable behind
|
||||
// hanzoai/ingress as a small service at meetings.hanzo.ai. The pure logic it
|
||||
// wraps is what the tests cover; the http server is an integration concern.
|
||||
//
|
||||
// node dist/server.js (after build.js bundles it)
|
||||
//
|
||||
// Required environment (see config.readServerConfig):
|
||||
// ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET, ZOOM_REDIRECT_URI, ZOOM_WEBHOOK_SECRET_TOKEN
|
||||
// HANZO_API_KEY (optional — needed to actually summarize; without it the
|
||||
// webhook verifies + routes but skips summarization)
|
||||
// PORT (default 8788)
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readServerConfig, type ServerConfig } from '../config.js';
|
||||
import { authorizeUrl, tokenExchange, parseTokenResponse } from './oauth.js';
|
||||
import {
|
||||
verifySignature,
|
||||
urlValidationResponse,
|
||||
routeEvent,
|
||||
type ZoomAction,
|
||||
} from './webhook.js';
|
||||
import { parseVtt, summarizeTranscript, type MeetingContext } from '../hanzo.js';
|
||||
|
||||
// readRawBody collects the raw request bytes as a utf8 string. The webhook
|
||||
// signature is computed over these RAW bytes, so we must NOT JSON.parse-and-
|
||||
// reserialize before verifying (key order would differ and every event would
|
||||
// fail). One read, used for both verification and parsing.
|
||||
async function readRawBody(req: IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of req) chunks.push(c as Buffer);
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
function json(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function log(fields: Record<string, unknown>): void {
|
||||
// Structured, single-line log.
|
||||
console.log(JSON.stringify(fields));
|
||||
}
|
||||
|
||||
// GET /zoom/install → 302 to Zoom's consent screen. `state` is a fresh CSRF
|
||||
// token; a production deployment persists it (KMS/Valkey) to re-check on
|
||||
// callback. Here we mint one and carry it — the checkable value is what matters.
|
||||
function handleInstall(cfg: ServerConfig, res: ServerResponse): void {
|
||||
const state = randomUUID();
|
||||
const url = authorizeUrl(cfg.zoomClientId, cfg.zoomRedirectUri, state);
|
||||
res.writeHead(302, { Location: url });
|
||||
res.end();
|
||||
}
|
||||
|
||||
// GET /zoom/oauth/callback?code=…&state=… → exchange the code for a token. The
|
||||
// secret is used only inside tokenExchange's Basic header, server-side.
|
||||
async function handleOAuthCallback(cfg: ServerConfig, url: URL, res: ServerResponse): Promise<void> {
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code) return json(res, 400, { error: 'missing code' });
|
||||
const reqShape = tokenExchange({
|
||||
clientId: cfg.zoomClientId,
|
||||
clientSecret: cfg.zoomClientSecret,
|
||||
redirectUri: cfg.zoomRedirectUri,
|
||||
code,
|
||||
});
|
||||
const zoomResp = await fetch(reqShape.url, {
|
||||
method: 'POST',
|
||||
headers: reqShape.headers,
|
||||
body: reqShape.body,
|
||||
});
|
||||
const data = await zoomResp.json().catch(() => ({}));
|
||||
try {
|
||||
parseTokenResponse(data);
|
||||
// The token authorizes future REST calls (recording download on plans that
|
||||
// require it). A production deployment stores it in KMS keyed by the Zoom
|
||||
// account id; here we confirm the install succeeded to the admin.
|
||||
return json(res, 200, { ok: true, installed: true });
|
||||
} catch (e: any) {
|
||||
return json(res, 400, { error: e?.message || 'token exchange failed' });
|
||||
}
|
||||
}
|
||||
|
||||
// fetchTranscript downloads the recording transcript (VTT). Zoom's download_url
|
||||
// may require the bearer token / download_token on some plans; we attach the
|
||||
// download_token as a query param when present (Zoom's documented mechanism) and
|
||||
// fall back to an unauthenticated GET otherwise. Returns '' on any failure so
|
||||
// the caller logs and skips rather than throwing inside the webhook.
|
||||
async function fetchTranscript(url: string, downloadToken?: string): Promise<string> {
|
||||
if (!url) return '';
|
||||
const target = downloadToken
|
||||
? `${url}${url.includes('?') ? '&' : '?'}access_token=${encodeURIComponent(downloadToken)}`
|
||||
: url;
|
||||
try {
|
||||
const resp = await fetch(target);
|
||||
if (!resp.ok) return '';
|
||||
return await resp.text();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// handleRecordingCompleted is the summary pipeline: download VTT → parse →
|
||||
// window + summarize via @hanzo/ai. It NEVER throws into the webhook handler
|
||||
// (the webhook must 200 fast so Zoom does not retry); it logs the outcome.
|
||||
// Returns the summary text (or '') for tests/callers that await it.
|
||||
async function handleRecordingCompleted(
|
||||
cfg: ServerConfig,
|
||||
action: Extract<ZoomAction, { kind: 'recording_completed' }>,
|
||||
): Promise<string> {
|
||||
if (!cfg.hanzoApiKey) {
|
||||
log({ msg: 'recording.completed: HANZO_API_KEY unset, skipping summary', meetingUuid: action.meetingUuid });
|
||||
return '';
|
||||
}
|
||||
const vtt = await fetchTranscript(action.transcriptUrl, action.downloadToken);
|
||||
const segments = parseVtt(vtt);
|
||||
if (segments.length === 0) {
|
||||
log({ msg: 'recording.completed: no transcript, skipping summary', meetingUuid: action.meetingUuid });
|
||||
return '';
|
||||
}
|
||||
const meta: MeetingContext = { topic: action.topic, meetingId: action.meetingUuid };
|
||||
try {
|
||||
const summary = await summarizeTranscript(segments, cfg.hanzoApiKey, meta);
|
||||
log({
|
||||
msg: 'recording.completed: summary generated',
|
||||
meetingUuid: action.meetingUuid,
|
||||
segments: segments.length,
|
||||
summaryChars: summary.length,
|
||||
});
|
||||
// Deliver hook: a production deployment posts `summary` to Zoom Team Chat or
|
||||
// emails participants here. The pipeline through summary is complete; the
|
||||
// delivery target is a per-deployment choice (documented in the README).
|
||||
return summary;
|
||||
} catch (e: any) {
|
||||
log({ msg: 'recording.completed: summary failed', meetingUuid: action.meetingUuid, error: e?.message });
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// POST /zoom/webhook → verify, then dispatch. Verification is the trust gate:
|
||||
// an event with a bad/absent signature is a 401 before any transcript fetch.
|
||||
async function handleWebhook(cfg: ServerConfig, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const raw = await readRawBody(req);
|
||||
const sig = req.headers['x-zm-signature'] as string | undefined;
|
||||
const ts = req.headers['x-zm-request-timestamp'] as string | undefined;
|
||||
|
||||
if (!verifySignature(cfg.zoomWebhookSecret, sig, ts, raw)) {
|
||||
log({ msg: 'webhook: signature verification failed' });
|
||||
return json(res, 401, { error: 'invalid signature' });
|
||||
}
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = JSON.parse(raw);
|
||||
} catch {
|
||||
return json(res, 400, { error: 'invalid json' });
|
||||
}
|
||||
|
||||
const action = routeEvent(body);
|
||||
switch (action.kind) {
|
||||
case 'url_validation':
|
||||
// Echo the plainToken + its HMAC so Zoom marks the endpoint verified.
|
||||
return json(res, 200, urlValidationResponse(cfg.zoomWebhookSecret, action.plainToken));
|
||||
|
||||
case 'recording_completed':
|
||||
// Acknowledge Zoom immediately; run the summary pipeline in the background
|
||||
// so the webhook returns fast (Zoom retries slow endpoints).
|
||||
json(res, 200, { ok: true });
|
||||
void handleRecordingCompleted(cfg, action);
|
||||
return;
|
||||
|
||||
case 'meeting_ended':
|
||||
log({ msg: 'meeting.ended', meetingUuid: action.meetingUuid });
|
||||
return json(res, 200, { ok: true });
|
||||
|
||||
case 'ignored':
|
||||
return json(res, 200, { ok: true, ignored: action.event });
|
||||
}
|
||||
}
|
||||
|
||||
// createHandler is the request router. Everything is a small handler over the
|
||||
// pure modules. Exported so a test can drive it with a mock req/res if desired.
|
||||
export function createHandler(cfg: ServerConfig) {
|
||||
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
const url = new URL(req.url || '/', `http://localhost:${cfg.port}`);
|
||||
try {
|
||||
if (req.method === 'GET' && url.pathname === '/zoom/install') return handleInstall(cfg, res);
|
||||
if (req.method === 'GET' && url.pathname === '/zoom/oauth/callback') return await handleOAuthCallback(cfg, url, res);
|
||||
if (req.method === 'POST' && url.pathname === '/zoom/webhook') return await handleWebhook(cfg, req, res);
|
||||
if (req.method === 'GET' && url.pathname === '/healthz') return json(res, 200, { ok: true });
|
||||
return json(res, 404, { error: 'not found' });
|
||||
} catch (e: any) {
|
||||
log({ msg: 'unhandled error', error: e?.message });
|
||||
return json(res, 500, { error: 'internal error' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// main boots the server when run directly. Import-safe: only the direct entry
|
||||
// listens, so tests import the handlers without opening a port.
|
||||
export function main(): void {
|
||||
const cfg = readServerConfig(process.env);
|
||||
const server = createServer(createHandler(cfg));
|
||||
server.listen(cfg.port, () => {
|
||||
log({ msg: 'hanzo meetings (zoom) service up', port: cfg.port, summarize: !!cfg.hanzoApiKey });
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith('server.js')) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Zoom webhook: signature verification, URL-validation challenge, and event
|
||||
// routing — all pure over their inputs so they are unit-testable without an http
|
||||
// server. server.ts is the thin http glue that reads the raw body + headers and
|
||||
// calls these. Crypto is Node's stdlib (node:crypto), never a dependency.
|
||||
//
|
||||
// Zoom signs every webhook request. The signature header is:
|
||||
// x-zm-signature: v0=HMAC_SHA256(secretToken, `v0:${timestamp}:${rawBody}`)
|
||||
// with the timestamp in x-zm-request-timestamp. We recompute it over the RAW
|
||||
// body bytes (not a re-serialized object — key order would differ) and compare
|
||||
// in constant time. This is the whole trust boundary: an event that fails here
|
||||
// is discarded before any transcript is fetched.
|
||||
//
|
||||
// Zoom also validates the endpoint URL on save with an `endpoint.url_validation`
|
||||
// event carrying a plainToken; the response must echo it plus its HMAC. That is
|
||||
// the same secret and the same hash, so it lives here too.
|
||||
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
// signPayload is the HMAC-SHA256 hex of a message under the secret token. The
|
||||
// one crypto primitive; both the request signature and the URL-validation
|
||||
// response are built from it.
|
||||
export function signPayload(secret: string, message: string): string {
|
||||
return createHmac('sha256', secret).update(message).digest('hex');
|
||||
}
|
||||
|
||||
// zoomSignature builds the value Zoom sends in x-zm-signature for a given raw
|
||||
// body + timestamp: `v0=<hmac>`. Exposed so a test can construct a valid header
|
||||
// the same way Zoom does.
|
||||
export function zoomSignature(secret: string, timestamp: string, rawBody: string): string {
|
||||
return `v0=${signPayload(secret, `v0:${timestamp}:${rawBody}`)}`;
|
||||
}
|
||||
|
||||
// constantTimeEqual compares two strings without leaking length/position via
|
||||
// timing. Different-length inputs are unequal (and short-circuit safely — we
|
||||
// never feed mismatched buffers to timingSafeEqual, which would throw).
|
||||
export function constantTimeEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
if (ba.length !== bb.length) return false;
|
||||
return timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
// verifySignature is the trust gate: recompute the expected x-zm-signature over
|
||||
// the raw body + timestamp and compare in constant time. Returns false on any
|
||||
// missing input rather than throwing, so a malformed request is a clean reject.
|
||||
export function verifySignature(
|
||||
secret: string,
|
||||
signatureHeader: string | undefined,
|
||||
timestamp: string | undefined,
|
||||
rawBody: string,
|
||||
): boolean {
|
||||
if (!secret || !signatureHeader || !timestamp) return false;
|
||||
const expected = zoomSignature(secret, timestamp, rawBody);
|
||||
return constantTimeEqual(signatureHeader, expected);
|
||||
}
|
||||
|
||||
// The Zoom URL-validation event body shape.
|
||||
export interface UrlValidationPayload {
|
||||
plainToken: string;
|
||||
}
|
||||
|
||||
// urlValidationResponse builds the JSON body Zoom expects back for an
|
||||
// `endpoint.url_validation` event: the plainToken echoed plus its HMAC under the
|
||||
// secret. Returning this within a few seconds is how the marketplace verifies
|
||||
// we own the webhook secret.
|
||||
export function urlValidationResponse(
|
||||
secret: string,
|
||||
plainToken: string,
|
||||
): { plainToken: string; encryptedToken: string } {
|
||||
return { plainToken, encryptedToken: signPayload(secret, plainToken) };
|
||||
}
|
||||
|
||||
// ---- Event routing --------------------------------------------------------
|
||||
//
|
||||
// After a request is verified, we parse it into a discriminated action so the
|
||||
// server can dispatch. We only act on the two events that produce a summary
|
||||
// (a completed cloud recording, which has a downloadable transcript) and treat
|
||||
// meeting.ended as a signal (the recording usually isn't ready yet, so we log it
|
||||
// and wait for recording.completed). Everything else is acknowledged and ignored.
|
||||
|
||||
export type ZoomAction =
|
||||
| { kind: 'url_validation'; plainToken: string }
|
||||
| { kind: 'recording_completed'; meetingUuid: string; topic: string; transcriptUrl: string; downloadToken?: string }
|
||||
| { kind: 'meeting_ended'; meetingUuid: string; topic: string }
|
||||
| { kind: 'ignored'; event: string };
|
||||
|
||||
// A recording file entry as it appears in a recording.completed payload.
|
||||
interface RecordingFile {
|
||||
file_type?: string;
|
||||
file_extension?: string;
|
||||
recording_type?: string;
|
||||
download_url?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
// transcriptFileUrl finds the transcript among a recording's files. Zoom marks
|
||||
// the closed-caption/transcript file with file_type "TRANSCRIPT" (extension VTT);
|
||||
// we fall back to a .vtt/.transcript.vtt extension match. Returns '' if none —
|
||||
// a recording without a transcript can't be summarized, and the server says so.
|
||||
export function transcriptFileUrl(files: RecordingFile[] | undefined): string {
|
||||
if (!Array.isArray(files)) return '';
|
||||
const byType = files.find((f) => (f.file_type || '').toUpperCase() === 'TRANSCRIPT');
|
||||
if (byType?.download_url) return byType.download_url;
|
||||
const byExt = files.find((f) => (f.file_extension || '').toUpperCase() === 'VTT');
|
||||
return byExt?.download_url || '';
|
||||
}
|
||||
|
||||
// routeEvent turns a PARSED, ALREADY-VERIFIED webhook body into a ZoomAction.
|
||||
// Verification is the caller's job (verifySignature) — this is pure dispatch
|
||||
// over the parsed JSON, so it is unit-testable with plain objects. Unknown
|
||||
// events become { kind: 'ignored' } (still a 200 to Zoom — acknowledge, do
|
||||
// nothing) rather than an error.
|
||||
export function routeEvent(body: any): ZoomAction {
|
||||
const event: string = body?.event || '';
|
||||
|
||||
if (event === 'endpoint.url_validation') {
|
||||
const plainToken = body?.payload?.plainToken;
|
||||
if (typeof plainToken === 'string' && plainToken.length > 0) {
|
||||
return { kind: 'url_validation', plainToken };
|
||||
}
|
||||
return { kind: 'ignored', event };
|
||||
}
|
||||
|
||||
if (event === 'recording.completed') {
|
||||
const obj = body?.payload?.object || {};
|
||||
const transcriptUrl = transcriptFileUrl(obj.recording_files);
|
||||
return {
|
||||
kind: 'recording_completed',
|
||||
meetingUuid: obj.uuid || obj.meeting_uuid || '',
|
||||
topic: obj.topic || '',
|
||||
transcriptUrl,
|
||||
// Zoom includes a download_token in the payload for authenticated file
|
||||
// access on some plans; carry it through if present.
|
||||
downloadToken: body?.download_token || obj.download_token || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (event === 'meeting.ended') {
|
||||
const obj = body?.payload?.object || {};
|
||||
return { kind: 'meeting_ended', meetingUuid: obj.uuid || '', topic: obj.topic || '' };
|
||||
}
|
||||
|
||||
return { kind: 'ignored', event };
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
buildMessages,
|
||||
requestBody,
|
||||
extractContent,
|
||||
streamDelta,
|
||||
ask,
|
||||
askStream,
|
||||
listModels,
|
||||
summarizeTranscript,
|
||||
SYSTEM_PROMPT,
|
||||
QUICK_ACTIONS,
|
||||
buildTranscriptContext,
|
||||
type TranscriptContext,
|
||||
} from '../src/hanzo';
|
||||
import { DEFAULT_MODEL } from '../src/config';
|
||||
|
||||
function ctxOf(text: string, opts: Partial<TranscriptContext> = {}): TranscriptContext {
|
||||
return { text, totalSegments: 1, includedSegments: 1, truncated: false, ...opts };
|
||||
}
|
||||
|
||||
describe('SYSTEM_PROMPT (transcript grounding)', () => {
|
||||
it('forbids inventing statements and asks for attribution', () => {
|
||||
const p = SYSTEM_PROMPT.toLowerCase();
|
||||
expect(p).toContain('only from the meeting transcript');
|
||||
expect(p).toContain('never invent');
|
||||
expect(p).toContain('attribute');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMessages (summary-prompt assembly)', () => {
|
||||
it('fences the transcript, carries the honest note, and appends the task', () => {
|
||||
const m = buildMessages('Summarize this', ctxOf('Ana: hi'));
|
||||
expect(m[0].role).toBe('system');
|
||||
expect(m[0].content).toBe(SYSTEM_PROMPT);
|
||||
expect(m[1].role).toBe('user');
|
||||
expect(m[1].content).toContain('---- meeting transcript ----');
|
||||
expect(m[1].content).toContain('Ana: hi');
|
||||
expect(m[1].content).toContain('---- task ----');
|
||||
expect(m[1].content).toContain('Summarize this');
|
||||
expect(m[1].content.toLowerCase()).toContain('full meeting'); // context note
|
||||
});
|
||||
|
||||
it('includes a meeting header (topic + participants) when metadata is given', () => {
|
||||
const m = buildMessages('Recap', ctxOf('body'), {
|
||||
topic: 'Q3 Planning',
|
||||
participants: ['Ana', 'Bob'],
|
||||
meetingId: 'abc',
|
||||
});
|
||||
expect(m[1].content).toContain('Meeting: Q3 Planning');
|
||||
expect(m[1].content).toContain('Participants: Ana, Bob');
|
||||
});
|
||||
|
||||
it('omits the header when no metadata is given', () => {
|
||||
const m = buildMessages('Recap', ctxOf('body'));
|
||||
expect(m[1].content).not.toContain('Meeting:');
|
||||
expect(m[1].content).not.toContain('Participants:');
|
||||
});
|
||||
|
||||
it('carries the truncation warning into the prompt when windowed', () => {
|
||||
const big = Array.from({ length: 20 }, (_, i) => ({ text: 'z'.repeat(200), speaker: `S${i}` }));
|
||||
const m = buildMessages('Summarize', buildTranscriptContext(big, 400));
|
||||
expect(m[1].content.toLowerCase()).toContain('truncated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('QUICK_ACTIONS (panel/webhook parity)', () => {
|
||||
it('names the three actions with distinct intents', () => {
|
||||
expect(QUICK_ACTIONS.summary.toLowerCase()).toContain('summary');
|
||||
expect(QUICK_ACTIONS.actionItems.toLowerCase()).toContain('action items');
|
||||
expect(QUICK_ACTIONS.followupEmail.toLowerCase()).toContain('follow-up email');
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestBody', () => {
|
||||
it('defaults the model and disables streaming', () => {
|
||||
const b = requestBody(buildMessages('x', ctxOf('d')));
|
||||
expect(b.model).toBe(DEFAULT_MODEL);
|
||||
expect(b.stream).toBe(false);
|
||||
expect(Array.isArray(b.messages)).toBe(true);
|
||||
});
|
||||
it('passes an explicit model + temperature through', () => {
|
||||
const b = requestBody(buildMessages('x', ctxOf('d')), { model: 'zen-pro', temperature: 0.1 });
|
||||
expect(b.model).toBe('zen-pro');
|
||||
expect(b.temperature).toBe(0.1);
|
||||
});
|
||||
it('enables streaming only when opts.stream is true', () => {
|
||||
expect(requestBody(buildMessages('x', ctxOf('d')), { stream: true }).stream).toBe(true);
|
||||
expect(requestBody(buildMessages('x', ctxOf('d')), { stream: false }).stream).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamDelta', () => {
|
||||
it('reads choices[0].delta.content from an SSE frame', () => {
|
||||
expect(streamDelta(JSON.stringify({ choices: [{ delta: { content: 'Par' } }] }))).toBe('Par');
|
||||
});
|
||||
it('returns empty string for a role-only / empty delta', () => {
|
||||
expect(streamDelta(JSON.stringify({ choices: [{ delta: { role: 'assistant' } }] }))).toBe('');
|
||||
expect(streamDelta(JSON.stringify({ choices: [{}] }))).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractContent', () => {
|
||||
it('reads OpenAI choices[0].message.content', () => {
|
||||
expect(extractContent({ choices: [{ message: { content: 'answer' } }] })).toBe('answer');
|
||||
});
|
||||
it('tolerates choices[0].text and bare content', () => {
|
||||
expect(extractContent({ choices: [{ text: 'a' }] })).toBe('a');
|
||||
expect(extractContent({ content: 'b' })).toBe('b');
|
||||
});
|
||||
it('throws on an error payload', () => {
|
||||
expect(() => extractContent({ error: { message: 'rate limited' } })).toThrow(/rate limited/);
|
||||
});
|
||||
it('throws when there is no content', () => {
|
||||
expect(() => extractContent({ choices: [{}] })).toThrow(/no content/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ask (request shaping over mock fetch)', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('POSTs to the gateway /v1 with the bearer, transcript and task', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, init: any) => {
|
||||
const body = JSON.parse(init.body);
|
||||
expect(init.headers.Authorization).toBe('Bearer hk-tok');
|
||||
expect(body.messages[1].content).toContain('DECISION: ship friday'); // transcript
|
||||
expect(body.messages[1].content).toContain('What did we decide?'); // task
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ choices: [{ message: { content: 'We decided to ship Friday.' } }] }),
|
||||
} as any;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const out = await ask('What did we decide?', ctxOf('DECISION: ship friday'), 'hk-tok');
|
||||
expect(out).toBe('We decided to ship Friday.');
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://api.hanzo.ai/v1/chat/completions');
|
||||
});
|
||||
|
||||
it('omits Authorization when the token is empty', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (_url: string, init: any) => {
|
||||
expect(init.headers.Authorization).toBeUndefined();
|
||||
return { ok: true, status: 200, text: async () => JSON.stringify({ content: 'ok' }) } as any;
|
||||
}));
|
||||
expect(await ask('hi', ctxOf('d'), '')).toBe('ok');
|
||||
});
|
||||
|
||||
it('surfaces a structured gateway error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: async () => JSON.stringify({ error: { message: 'too many requests' } }),
|
||||
} as any)));
|
||||
await expect(ask('x', ctxOf('d'), 't')).rejects.toThrow(/too many requests/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('askStream', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('concatenates deltas and reports each chunk', async () => {
|
||||
const frames = [
|
||||
'data: ' + JSON.stringify({ choices: [{ delta: { content: 'Hello' } }] }) + '\n\n',
|
||||
'data: ' + JSON.stringify({ choices: [{ delta: { content: ' world' } }] }) + '\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
];
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const enc = new TextEncoder();
|
||||
for (const f of frames) controller.enqueue(enc.encode(f));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn(async (_url: string, init: any) => {
|
||||
expect(JSON.parse(init.body).stream).toBe(true); // askStream forces stream
|
||||
return { ok: true, body: stream } as any;
|
||||
}));
|
||||
const chunks: string[] = [];
|
||||
const full = await askStream('Summarize', ctxOf('d'), 'hk-1', (c) => chunks.push(c));
|
||||
expect(full).toBe('Hello world');
|
||||
expect(chunks).toEqual(['Hello', ' world']);
|
||||
});
|
||||
|
||||
it('throws on a non-2xx stream response', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500, text: async () => 'boom' } as any)));
|
||||
await expect(askStream('x', ctxOf('d'), 't', () => {})).rejects.toThrow(/500/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeTranscript (server one-shot)', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('windows the segments and sends the Summarize action with the bearer', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, init: any) => {
|
||||
const body = JSON.parse(init.body);
|
||||
expect(init.headers.Authorization).toBe('Bearer hk-srv');
|
||||
expect(body.messages[1].content).toContain(QUICK_ACTIONS.summary);
|
||||
expect(body.messages[1].content).toContain('Ana: kickoff'); // rendered segment
|
||||
expect(body.messages[1].content).toContain('Meeting: Sprint Review'); // meta header
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ choices: [{ message: { content: 'Summary text.' } }] }),
|
||||
} as any;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const out = await summarizeTranscript(
|
||||
[{ speaker: 'Ana', text: 'kickoff' }, { speaker: 'Bob', text: 'agenda' }],
|
||||
'hk-srv',
|
||||
{ topic: 'Sprint Review' },
|
||||
);
|
||||
expect(out).toBe('Summary text.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listModels', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('returns ids from /v1/models with the bearer', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init: any) => {
|
||||
expect(url).toBe('https://api.hanzo.ai/v1/models');
|
||||
expect(init.headers.Authorization).toBe('Bearer t');
|
||||
return { ok: true, json: async () => ({ data: [{ id: 'zen-1' }, { id: 'zen-pro' }] }) } as any;
|
||||
}));
|
||||
expect(await listModels('t')).toEqual(['zen-1', 'zen-pro']);
|
||||
});
|
||||
|
||||
it('tolerates a bare array and omits the bearer when unauthenticated', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (_url: string, init: any) => {
|
||||
expect(init.headers.Authorization).toBeUndefined();
|
||||
return { ok: true, json: async () => ['a', 'b'] } as any;
|
||||
}));
|
||||
expect(await listModels('')).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('throws on a failed model list', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 401 } as any)));
|
||||
await expect(listModels('bad')).rejects.toThrow(/401/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
pickBearer,
|
||||
chatCompletionsURL,
|
||||
modelsURL,
|
||||
readServerConfig,
|
||||
DEFAULT_MODEL,
|
||||
} from '../src/config';
|
||||
import { authorizeUrl, tokenExchange, parseTokenResponse } from '../src/zoom/oauth';
|
||||
|
||||
describe('pickBearer', () => {
|
||||
it('prefers a pasted API key over an OAuth token', () => {
|
||||
expect(pickBearer('hk-key', 'oauth-tok')).toBe('hk-key');
|
||||
});
|
||||
it('falls back to the OAuth token, then empty', () => {
|
||||
expect(pickBearer('', 'oauth-tok')).toBe('oauth-tok');
|
||||
expect(pickBearer('', '')).toBe('');
|
||||
});
|
||||
it('ignores whitespace-only credentials', () => {
|
||||
expect(pickBearer(' ', 'oauth-tok')).toBe('oauth-tok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gateway URLs (/v1, never /api/)', () => {
|
||||
it('points at api.hanzo.ai/v1', () => {
|
||||
expect(chatCompletionsURL()).toBe('https://api.hanzo.ai/v1/chat/completions');
|
||||
expect(modelsURL()).toBe('https://api.hanzo.ai/v1/models');
|
||||
expect(chatCompletionsURL()).not.toContain('/api/');
|
||||
});
|
||||
it('has a zen default model', () => {
|
||||
expect(DEFAULT_MODEL).toBe('zen-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readServerConfig', () => {
|
||||
const full = {
|
||||
ZOOM_CLIENT_ID: 'cid',
|
||||
ZOOM_CLIENT_SECRET: 'csecret',
|
||||
ZOOM_REDIRECT_URI: 'https://meetings.hanzo.ai/zoom/oauth/callback',
|
||||
ZOOM_WEBHOOK_SECRET_TOKEN: 'whsec',
|
||||
};
|
||||
|
||||
it('reads a complete config with the optional Hanzo key and a default port', () => {
|
||||
const cfg = readServerConfig({ ...full, HANZO_API_KEY: 'hk-1' });
|
||||
expect(cfg.zoomClientId).toBe('cid');
|
||||
expect(cfg.zoomWebhookSecret).toBe('whsec');
|
||||
expect(cfg.hanzoApiKey).toBe('hk-1');
|
||||
expect(cfg.port).toBe(8788);
|
||||
});
|
||||
|
||||
it('allows a missing HANZO_API_KEY (verify+route without summarizing)', () => {
|
||||
const cfg = readServerConfig(full);
|
||||
expect(cfg.hanzoApiKey).toBe('');
|
||||
});
|
||||
|
||||
it('honors PORT', () => {
|
||||
expect(readServerConfig({ ...full, PORT: '9000' }).port).toBe(9000);
|
||||
});
|
||||
|
||||
it('throws naming every missing required secret', () => {
|
||||
expect(() => readServerConfig({})).toThrow(/ZOOM_CLIENT_ID/);
|
||||
expect(() => readServerConfig({ ZOOM_CLIENT_ID: 'x' })).toThrow(/ZOOM_CLIENT_SECRET/);
|
||||
expect(() => readServerConfig({ ...full, ZOOM_WEBHOOK_SECRET_TOKEN: undefined })).toThrow(
|
||||
/ZOOM_WEBHOOK_SECRET_TOKEN/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Zoom OAuth request shaping', () => {
|
||||
it('builds the authorize URL with state', () => {
|
||||
const url = new URL(authorizeUrl('cid', 'https://h/cb', 'st8'));
|
||||
expect(url.origin + url.pathname).toBe('https://zoom.us/oauth/authorize');
|
||||
expect(url.searchParams.get('response_type')).toBe('code');
|
||||
expect(url.searchParams.get('client_id')).toBe('cid');
|
||||
expect(url.searchParams.get('redirect_uri')).toBe('https://h/cb');
|
||||
expect(url.searchParams.get('state')).toBe('st8');
|
||||
});
|
||||
|
||||
it('puts the secret in the Basic header, not the body', () => {
|
||||
const req = tokenExchange({ clientId: 'cid', clientSecret: 'sec', redirectUri: 'https://h/cb', code: 'abc' });
|
||||
expect(req.url).toBe('https://zoom.us/oauth/token');
|
||||
expect(req.headers.Authorization).toBe('Basic ' + Buffer.from('cid:sec').toString('base64'));
|
||||
expect(req.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||
expect(req.body).not.toContain('sec'); // secret is NOT in the body
|
||||
const params = new URLSearchParams(req.body);
|
||||
expect(params.get('grant_type')).toBe('authorization_code');
|
||||
expect(params.get('code')).toBe('abc');
|
||||
expect(params.get('redirect_uri')).toBe('https://h/cb');
|
||||
});
|
||||
|
||||
it('parses a token response and throws on an error body', () => {
|
||||
expect(parseTokenResponse({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 })).toEqual({
|
||||
access_token: 'at',
|
||||
refresh_token: 'rt',
|
||||
expires_in: 3600,
|
||||
scope: undefined,
|
||||
token_type: undefined,
|
||||
});
|
||||
expect(() => parseTokenResponse({ error: 'invalid_grant', reason: 'bad code' })).toThrow(/bad code/);
|
||||
expect(() => parseTokenResponse({})).toThrow(/missing access_token/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
renderSegment,
|
||||
buildTranscriptContext,
|
||||
parseVtt,
|
||||
contextNote,
|
||||
type TranscriptSegment,
|
||||
} from '../src/hanzo';
|
||||
|
||||
function segs(n: number, per: number): TranscriptSegment[] {
|
||||
return Array.from({ length: n }, (_, i) => ({ speaker: `S${i}`, ts: `00:0${i}`, text: 'x'.repeat(per) }));
|
||||
}
|
||||
|
||||
describe('renderSegment', () => {
|
||||
it('renders speaker + timestamp + text', () => {
|
||||
expect(renderSegment({ speaker: 'Ana', ts: '00:01', text: 'hello' })).toBe('[00:01] Ana: hello');
|
||||
});
|
||||
it('drops the missing pieces cleanly', () => {
|
||||
expect(renderSegment({ speaker: 'Ana', text: 'hi' })).toBe('Ana: hi');
|
||||
expect(renderSegment({ ts: '00:02', text: 'hi' })).toBe('[00:02] hi');
|
||||
expect(renderSegment({ text: 'hi' })).toBe('hi');
|
||||
});
|
||||
it('trims stray whitespace in each field', () => {
|
||||
expect(renderSegment({ speaker: ' Ana ', ts: ' 00:01 ', text: ' hi ' })).toBe('[00:01] Ana: hi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTranscriptContext (windowing / truncation)', () => {
|
||||
it('includes everything and is not truncated when under budget', () => {
|
||||
const ctx = buildTranscriptContext(segs(3, 10), 10_000);
|
||||
expect(ctx.totalSegments).toBe(3);
|
||||
expect(ctx.includedSegments).toBe(3);
|
||||
expect(ctx.truncated).toBe(false);
|
||||
expect(ctx.text.split('\n')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('stops at the budget and marks truncated', () => {
|
||||
// Each rendered line ~ "[00:0i] Si: " + 100 chars ≈ 112 chars. Budget 250
|
||||
// fits ~2 lines.
|
||||
const ctx = buildTranscriptContext(segs(10, 100), 250);
|
||||
expect(ctx.totalSegments).toBe(10);
|
||||
expect(ctx.includedSegments).toBeGreaterThanOrEqual(1);
|
||||
expect(ctx.includedSegments).toBeLessThan(10);
|
||||
expect(ctx.truncated).toBe(true);
|
||||
expect(ctx.text.length).toBeLessThanOrEqual(250);
|
||||
});
|
||||
|
||||
it('always includes at least the first segment, hard-cutting an over-budget line', () => {
|
||||
const ctx = buildTranscriptContext([{ text: 'y'.repeat(1000) }], 100);
|
||||
expect(ctx.includedSegments).toBe(1);
|
||||
expect(ctx.truncated).toBe(true);
|
||||
expect(ctx.text.length).toBe(100);
|
||||
});
|
||||
|
||||
it('preserves transcript order', () => {
|
||||
const ctx = buildTranscriptContext(
|
||||
[{ text: 'first' }, { text: 'second' }, { text: 'third' }],
|
||||
10_000,
|
||||
);
|
||||
expect(ctx.text.indexOf('first')).toBeLessThan(ctx.text.indexOf('second'));
|
||||
expect(ctx.text.indexOf('second')).toBeLessThan(ctx.text.indexOf('third'));
|
||||
});
|
||||
|
||||
it('handles an empty transcript', () => {
|
||||
const ctx = buildTranscriptContext([], 1000);
|
||||
expect(ctx).toEqual({ text: '', totalSegments: 0, includedSegments: 0, truncated: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseVtt', () => {
|
||||
it('parses cues into speaker/ts/text segments', () => {
|
||||
const vtt = [
|
||||
'WEBVTT',
|
||||
'',
|
||||
'1',
|
||||
'00:00:01.000 --> 00:00:04.000',
|
||||
'Ana Ng: Welcome everyone.',
|
||||
'',
|
||||
'2',
|
||||
'00:00:05.000 --> 00:00:09.000',
|
||||
'Bob Lee: Thanks, let us start.',
|
||||
].join('\n');
|
||||
const s = parseVtt(vtt);
|
||||
expect(s).toHaveLength(2);
|
||||
expect(s[0]).toEqual({ speaker: 'Ana Ng', ts: '00:00:01', text: 'Welcome everyone.' });
|
||||
expect(s[1]).toEqual({ speaker: 'Bob Lee', ts: '00:00:05', text: 'Thanks, let us start.' });
|
||||
});
|
||||
|
||||
it('handles CRLF newlines and cues without a speaker', () => {
|
||||
const vtt = 'WEBVTT\r\n\r\n00:00:01.000 --> 00:00:02.000\r\nJust some narration.';
|
||||
const s = parseVtt(vtt);
|
||||
expect(s).toHaveLength(1);
|
||||
expect(s[0].speaker).toBeUndefined();
|
||||
expect(s[0].ts).toBe('00:00:01');
|
||||
expect(s[0].text).toBe('Just some narration.');
|
||||
});
|
||||
|
||||
it('skips NOTE blocks and the header', () => {
|
||||
const vtt = 'WEBVTT\n\nNOTE this is a comment\n\n00:00:01.000 --> 00:00:02.000\nHi.';
|
||||
const s = parseVtt(vtt);
|
||||
expect(s).toHaveLength(1);
|
||||
expect(s[0].text).toBe('Hi.');
|
||||
});
|
||||
|
||||
it('joins multi-line cue text', () => {
|
||||
const vtt = 'WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nCarol: This is a long\nsentence split across lines.';
|
||||
const s = parseVtt(vtt);
|
||||
expect(s[0].speaker).toBe('Carol');
|
||||
expect(s[0].text).toBe('This is a long sentence split across lines.');
|
||||
});
|
||||
|
||||
it('returns [] for empty or non-VTT input', () => {
|
||||
expect(parseVtt('')).toEqual([]);
|
||||
expect(parseVtt('WEBVTT')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contextNote (honesty)', () => {
|
||||
it('says "full meeting" when nothing was dropped', () => {
|
||||
const ctx = buildTranscriptContext(segs(2, 5), 10_000);
|
||||
expect(contextNote(ctx).toLowerCase()).toContain('full meeting');
|
||||
});
|
||||
it('says "truncated" and names the counts when windowed', () => {
|
||||
const ctx = buildTranscriptContext(segs(10, 100), 250);
|
||||
const note = contextNote(ctx).toLowerCase();
|
||||
expect(note).toContain('truncated');
|
||||
expect(note).toContain('of 10 segments');
|
||||
});
|
||||
it('says no transcript when empty', () => {
|
||||
expect(contextNote(buildTranscriptContext([], 100)).toLowerCase()).toContain('no transcript');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
signPayload,
|
||||
zoomSignature,
|
||||
constantTimeEqual,
|
||||
verifySignature,
|
||||
urlValidationResponse,
|
||||
transcriptFileUrl,
|
||||
routeEvent,
|
||||
} from '../src/zoom/webhook';
|
||||
|
||||
const SECRET = 'super-secret-token';
|
||||
|
||||
describe('signPayload / zoomSignature', () => {
|
||||
it('is a deterministic HMAC-SHA256 hex', () => {
|
||||
const a = signPayload(SECRET, 'hello');
|
||||
const b = signPayload(SECRET, 'hello');
|
||||
expect(a).toBe(b);
|
||||
expect(a).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
it('builds the v0=<hmac> header shape Zoom sends', () => {
|
||||
const sig = zoomSignature(SECRET, '1700000000', '{"event":"x"}');
|
||||
expect(sig.startsWith('v0=')).toBe(true);
|
||||
expect(sig.slice(3)).toBe(signPayload(SECRET, 'v0:1700000000:{"event":"x"}'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('constantTimeEqual', () => {
|
||||
it('is true for equal strings and false otherwise', () => {
|
||||
expect(constantTimeEqual('abc', 'abc')).toBe(true);
|
||||
expect(constantTimeEqual('abc', 'abd')).toBe(false);
|
||||
});
|
||||
it('is false for different lengths without throwing', () => {
|
||||
expect(constantTimeEqual('abc', 'abcd')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifySignature (trust gate)', () => {
|
||||
const ts = '1700000000';
|
||||
const body = JSON.stringify({ event: 'meeting.ended', payload: {} });
|
||||
const good = zoomSignature(SECRET, ts, body);
|
||||
|
||||
it('accepts a correctly signed request', () => {
|
||||
expect(verifySignature(SECRET, good, ts, body)).toBe(true);
|
||||
});
|
||||
it('rejects a tampered body', () => {
|
||||
expect(verifySignature(SECRET, good, ts, body + ' ')).toBe(false);
|
||||
});
|
||||
it('rejects a wrong secret', () => {
|
||||
expect(verifySignature('other', good, ts, body)).toBe(false);
|
||||
});
|
||||
it('rejects a mismatched timestamp (replay of a different frame)', () => {
|
||||
expect(verifySignature(SECRET, good, '1700000001', body)).toBe(false);
|
||||
});
|
||||
it('rejects missing signature / timestamp / secret rather than throwing', () => {
|
||||
expect(verifySignature(SECRET, undefined, ts, body)).toBe(false);
|
||||
expect(verifySignature(SECRET, good, undefined, body)).toBe(false);
|
||||
expect(verifySignature('', good, ts, body)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('urlValidationResponse', () => {
|
||||
it('echoes the plainToken and its HMAC under the secret', () => {
|
||||
const r = urlValidationResponse(SECRET, 'plain-123');
|
||||
expect(r.plainToken).toBe('plain-123');
|
||||
expect(r.encryptedToken).toBe(signPayload(SECRET, 'plain-123'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('transcriptFileUrl', () => {
|
||||
it('prefers the TRANSCRIPT file_type', () => {
|
||||
const files = [
|
||||
{ file_type: 'MP4', download_url: 'https://z/video' },
|
||||
{ file_type: 'TRANSCRIPT', file_extension: 'VTT', download_url: 'https://z/transcript.vtt' },
|
||||
];
|
||||
expect(transcriptFileUrl(files)).toBe('https://z/transcript.vtt');
|
||||
});
|
||||
it('falls back to a VTT file_extension', () => {
|
||||
const files = [{ file_extension: 'VTT', download_url: 'https://z/cc.vtt' }];
|
||||
expect(transcriptFileUrl(files)).toBe('https://z/cc.vtt');
|
||||
});
|
||||
it('returns empty when there is no transcript file', () => {
|
||||
expect(transcriptFileUrl([{ file_type: 'MP4', download_url: 'https://z/v' }])).toBe('');
|
||||
expect(transcriptFileUrl(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('routeEvent (dispatch)', () => {
|
||||
it('routes endpoint.url_validation with the plainToken', () => {
|
||||
const a = routeEvent({ event: 'endpoint.url_validation', payload: { plainToken: 'p1' } });
|
||||
expect(a).toEqual({ kind: 'url_validation', plainToken: 'p1' });
|
||||
});
|
||||
|
||||
it('ignores a url_validation with no plainToken', () => {
|
||||
const a = routeEvent({ event: 'endpoint.url_validation', payload: {} });
|
||||
expect(a.kind).toBe('ignored');
|
||||
});
|
||||
|
||||
it('routes recording.completed with the transcript url and topic', () => {
|
||||
const a = routeEvent({
|
||||
event: 'recording.completed',
|
||||
download_token: 'dl-tok',
|
||||
payload: {
|
||||
object: {
|
||||
uuid: 'mtg-uuid',
|
||||
topic: 'Weekly Sync',
|
||||
recording_files: [
|
||||
{ file_type: 'M4A', download_url: 'https://z/audio' },
|
||||
{ file_type: 'TRANSCRIPT', download_url: 'https://z/t.vtt' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(a).toEqual({
|
||||
kind: 'recording_completed',
|
||||
meetingUuid: 'mtg-uuid',
|
||||
topic: 'Weekly Sync',
|
||||
transcriptUrl: 'https://z/t.vtt',
|
||||
downloadToken: 'dl-tok',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes meeting.ended', () => {
|
||||
const a = routeEvent({ event: 'meeting.ended', payload: { object: { uuid: 'u2', topic: 'T' } } });
|
||||
expect(a).toEqual({ kind: 'meeting_ended', meetingUuid: 'u2', topic: 'T' });
|
||||
});
|
||||
|
||||
it('ignores unknown events but keeps the name', () => {
|
||||
const a = routeEvent({ event: 'meeting.started', payload: {} });
|
||||
expect(a).toEqual({ kind: 'ignored', event: 'meeting.started' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -9,6 +9,7 @@ packages:
|
||||
- 'packages/gworkspace'
|
||||
- 'packages/jetbrains'
|
||||
- 'packages/mcp'
|
||||
- 'packages/meetings'
|
||||
- 'packages/office'
|
||||
- 'packages/outlook'
|
||||
- 'packages/pdf'
|
||||
|
||||
Reference in New Issue
Block a user