Files
zeekay 8ebddd8cce 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).
2026-07-03 19:15:50 -07:00

133 lines
4.7 KiB
TypeScript

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' });
});
});