diff --git a/.changeset/add-zoom-adapter.md b/.changeset/add-zoom-adapter.md new file mode 100644 index 00000000..82869219 --- /dev/null +++ b/.changeset/add-zoom-adapter.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/zoom": minor +--- + +Add Zoom Team Chat adapter (`@chat-adapter/zoom`) with webhook verification (CRC challenge + HMAC-SHA256), S2S OAuth chatbot token caching, inbound event parsing for `bot_notification` and `team_chat.app_mention`, outbound message posting/editing/deletion, bidirectional Zoom markdown ↔ mdast format conversion, and integration test fixtures. diff --git a/.gitignore b/.gitignore index d4d4477d..ec35675b 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,7 @@ todo/ next-env.d.ts .source -packages/chat/docs \ No newline at end of file +packages/chat/docs + +# GSD planning (local-only, not committed) +.planning/ \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..03882698 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +# Route @zoom scope to public npm registry +# The global npmrc points to Zoom's internal Artifactory, which doesn't +# serve the @zoom/rivet tarballs in this environment. +@zoom:registry=https://registry.npmjs.org/ diff --git a/apps/docs/adapters.json b/apps/docs/adapters.json index f33364c8..992835f5 100644 --- a/apps/docs/adapters.json +++ b/apps/docs/adapters.json @@ -79,6 +79,16 @@ "beta": true, "readme": "https://github.com/vercel/chat/tree/main/packages/adapter-whatsapp" }, + { + "name": "Zoom", + "slug": "zoom", + "type": "platform", + "description": "Build bots for Zoom Team Chat with webhook verification, message threading, and bidirectional markdown formatting.", + "packageName": "@chat-adapter/zoom", + "icon": "zoom", + "beta": true, + "readme": "https://github.com/vercel/chat/tree/main/packages/adapter-zoom" + }, { "name": "Redis", "slug": "redis", diff --git a/packages/adapter-zoom/README.md b/packages/adapter-zoom/README.md new file mode 100644 index 00000000..b7415adf --- /dev/null +++ b/packages/adapter-zoom/README.md @@ -0,0 +1,197 @@ +# @chat-adapter/zoom + +[![npm version](https://img.shields.io/npm/v/@chat-adapter/zoom)](https://www.npmjs.com/package/@chat-adapter/zoom) +[![npm downloads](https://img.shields.io/npm/dm/@chat-adapter/zoom)](https://www.npmjs.com/package/@chat-adapter/zoom) + +Zoom Team Chat adapter for [Chat SDK](https://chat-sdk.dev) + +## Installation + +```bash +pnpm add @chat-adapter/zoom +``` + +## Usage + +```typescript +import { Chat } from "chat"; +import { createZoomAdapter } from "@chat-adapter/zoom"; + +const bot = new Chat({ + userName: "mybot", + adapters: { + zoom: createZoomAdapter(), + }, +}); + +bot.onNewMention(async (thread, message) => { + await thread.post("Hello from Zoom!"); +}); +``` + +When using `createZoomAdapter()` without arguments, credentials are auto-detected from environment variables. + +## Zoom Marketplace app setup + +### Prerequisites + +- Zoom account with developer access +- A deployed URL or ngrok tunnel for local testing (e.g. `https://your-domain.com`) + +### Steps + +1. **Create the app** + - Go to [marketplace.zoom.us](https://marketplace.zoom.us) → **Develop** → **Build App** + - Select **General App** + - In **Basic Information**, set management type to **Admin-managed** + +2. **Get core credentials** — from **Basic Information** → **App Credentials** + - Copy **Client ID** → `ZOOM_CLIENT_ID` + - Copy **Client Secret** → `ZOOM_CLIENT_SECRET` + - Copy **Account ID** → `ZOOM_ACCOUNT_ID` + +3. **Configure the bot endpoint and get Robot JID** — from **Surface** → **Team Chat Subscription** + - Enable **Team Chat Subscription** + - Set **Bot Endpoint URL** to `https://your-domain.com/api/webhooks/zoom` + - Copy the **Bot JID** that appears → `ZOOM_ROBOT_JID` + +4. **Get the webhook secret** — from **Features** → **Access** → **Token** + - Copy the **Secret Token** → `ZOOM_WEBHOOK_SECRET_TOKEN` + +5. **Add event subscriptions** — from **Features** → **Access** → **Event Subscription** + - Enable Event Subscription + - Set webhook URL to `https://your-domain.com/api/webhooks/zoom` + - Subscribe to: `bot_notification`, `team_chat.app_mention` + +6. **Add scopes** — from **Scopes** (see [Required scopes](#required-scopes) below) + +7. **Authorize the app** — from **Local Test** → **Add app now** + - Complete the OAuth flow to install the bot in your account + +### Environment variables checklist + +```bash +ZOOM_CLIENT_ID= # Basic Information → App Credentials +ZOOM_CLIENT_SECRET= # Basic Information → App Credentials +ZOOM_ACCOUNT_ID= # Basic Information → App Credentials +ZOOM_ROBOT_JID= # Surface → Team Chat Subscription → Bot JID +ZOOM_WEBHOOK_SECRET_TOKEN= # Features → Access → Token → Secret Token +``` + +### Local testing with ngrok + +```bash +ngrok http 3000 +# Use the https:// forwarding URL as your Bot Endpoint URL and event subscription URL +``` + +## Required scopes + +| Scope | Purpose | +|-------|---------| +| `imchat:bot` | Send bot messages to channels and DMs | +| `team_chat:read:app_mention:admin` | Receive app_mention events | +| `team_chat:write:message:admin` | Send, edit, and delete messages | + +## Webhook setup + +```typescript +// app/api/webhooks/zoom/route.ts +import { bot } from "@/lib/bot"; +import { after } from "next/server"; + +export async function POST(request: Request) { + return bot.adapters.zoom.handleWebhook(request, { waitUntil: after }); +} +``` + +## Environment variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `ZOOM_CLIENT_ID` | Yes | OAuth app Client ID | +| `ZOOM_CLIENT_SECRET` | Yes | OAuth app Client Secret | +| `ZOOM_ACCOUNT_ID` | Yes | Zoom account ID (account-level app) | +| `ZOOM_ROBOT_JID` | Yes | Bot's JID from Marketplace app settings | +| `ZOOM_WEBHOOK_SECRET_TOKEN` | Yes | Webhook Secret Token from Event Subscriptions | +| `ZOOM_BOT_USERNAME` | No | Bot username for self-message detection (defaults to `zoom-bot`) | + +## Configuration + +| Option | Required | Description | +|--------|----------|-------------| +| `clientId` | No* | OAuth app Client ID. Auto-detected from `ZOOM_CLIENT_ID` | +| `clientSecret` | No* | OAuth app Client Secret. Auto-detected from `ZOOM_CLIENT_SECRET` | +| `accountId` | No* | Zoom account ID. Auto-detected from `ZOOM_ACCOUNT_ID` | +| `robotJid` | No* | Bot's JID. Auto-detected from `ZOOM_ROBOT_JID` | +| `webhookSecretToken` | No* | Webhook secret token. Auto-detected from `ZOOM_WEBHOOK_SECRET_TOKEN` | +| `userName` | No | Bot username. Auto-detected from `ZOOM_BOT_USERNAME` (defaults to `zoom-bot`) | +| `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) | + +*Required at runtime — either via config or environment variable. + +## Features + +### Messaging + +| Feature | Supported | +|---------|-----------| +| Post message | Yes | +| Edit message | Yes | +| Delete message | Yes | +| Reply in thread | Yes | +| Streaming | Buffered (accumulates then sends) | + +### Conversations + +| Feature | Supported | +|---------|-----------| +| DMs | Yes (via bot_notification) | +| Channel slash commands | Yes (via bot_notification) | +| Thread subscription (subscribe flow) | No (Zoom platform limitation — see Known Limitations) | +| DM thread replies | No (Zoom platform limitation — see Known Limitations) | +| Reactions | No (not implemented in v1) | +| Typing indicator | No (Zoom has no typing indicator API) | + +## Thread ID format + +``` +zoom:{channelId}:{messageId} +``` + +Examples: +- Channel message: `zoom:abc123@conference.xmpp.zoom.us:msg-id-456` +- DM: `zoom:{userJid}:{event_ts}` (sender-based channel with event timestamp as message ID) + +## Known limitations + +**Thread subscription (subscribe flow):** The `thread.subscribe()` pattern does not work for Zoom. Each `bot_notification` arrives with a unique thread ID based on `event_ts` — Zoom provides no `reply_to` or `parent_message_id` field to link replies to the original message. This is a confirmed Zoom platform limitation (verified against `@zoom/rivet` SDK, which also has no thread reply handler). Subsequent replies in a thread cannot be detected as part of the same conversation. + +**DM thread replies (THRD-03):** Zoom does not fire `chat_message.replied` for 1:1 DM thread replies. The adapter cannot subscribe to or receive threaded replies in DMs. + +**Unicode HMAC verification bug (ZOOM-506645):** Zoom's servers may normalize Unicode characters (emoji, accented characters) differently before computing the HMAC. Payloads containing non-ASCII characters may fail signature verification. The adapter logs the raw body hex on verification failure to aid diagnosis. If this affects you, contact Zoom Support referencing ZOOM-506645. + +## Troubleshooting + +**Webhook signature verification fails** +- Ensure you're reading the raw request body before any JSON parsing +- Emoji or non-ASCII characters in payloads may fail HMAC verification due to a Zoom server-side Unicode normalization issue (ZOOM-506645) — check debug logs for raw body hex + +**Bot not receiving events** +- Confirm the Bot Endpoint URL in **Surface → Team Chat Subscription** matches your deployment URL +- Confirm the event subscription URL in **Features → Access → Event Subscription** matches too +- Verify the app is installed via **Local Test → Add app now** (OAuth flow must complete) + +**Bot appears in Zoom but doesn't respond** +- Check that `ZOOM_WEBHOOK_SECRET_TOKEN` matches the Secret Token in **Features → Access → Token** +- Ensure the app is marked as **Admin-managed** in Basic Information + +**DM thread replies not received** +- This is a confirmed Zoom platform limitation — `chat_message.replied` is not fired for 1:1 DM thread replies. See [Known limitations](#known-limitations). + +**`postMessage` returns 401** +- The `/v2/im/chat/messages` endpoint requires a `client_credentials` token. Ensure you're not using `account_credentials` grant type. + +## License + +MIT diff --git a/packages/adapter-zoom/package.json b/packages/adapter-zoom/package.json new file mode 100644 index 00000000..d0c4d703 --- /dev/null +++ b/packages/adapter-zoom/package.json @@ -0,0 +1,59 @@ +{ + "name": "@chat-adapter/zoom", + "version": "0.1.0", + "description": "Zoom Team Chat adapter for chat-sdk", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run --coverage", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@chat-adapter/shared": "workspace:*", + "chat": "workspace:*" + }, + "devDependencies": { + "@types/mdast": "^4.0.4", + "@types/node": "^25.3.2", + "@vitest/coverage-v8": "^4.0.18", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^4.0.18" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/chat.git", + "directory": "packages/adapter-zoom" + }, + "homepage": "https://github.com/vercel/chat#readme", + "bugs": { + "url": "https://github.com/vercel/chat/issues" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "chat", + "zoom", + "bot", + "adapter", + "messaging", + "team-chat" + ], + "license": "MIT" +} diff --git a/packages/adapter-zoom/src/index.test.ts b/packages/adapter-zoom/src/index.test.ts new file mode 100644 index 00000000..c5e1aaf6 --- /dev/null +++ b/packages/adapter-zoom/src/index.test.ts @@ -0,0 +1,730 @@ +import { createHmac } from "node:crypto"; +import { ValidationError } from "@chat-adapter/shared"; +import type { ChatInstance } from "chat"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createZoomAdapter } from "./index.js"; +import type { ZoomMessageWithReply } from "./types.js"; + +const TEST_SECRET = "test-webhook-secret"; +const TEST_CREDENTIALS = { + clientId: "test-client-id", + clientSecret: "test-client-secret", + robotJid: "test-robot-jid", + accountId: "test-account-id", + webhookSecretToken: TEST_SECRET, +}; + +function makeSignature(body: string, timestamp: string): string { + const message = `v0:${timestamp}:${body}`; + const hash = createHmac("sha256", TEST_SECRET).update(message).digest("hex"); + return `v0=${hash}`; +} + +function makeZoomRequest( + body: string, + overrides?: { + signature?: string; + timestamp?: string; + } +): Request { + const timestamp = + overrides?.timestamp ?? String(Math.floor(Date.now() / 1000)); + const signature = overrides?.signature ?? makeSignature(body, timestamp); + return new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-zm-signature": signature, + "x-zm-request-timestamp": timestamp, + }, + body, + }); +} + +describe("ZoomAdapter — Webhook Verification (WBHK-01, WBHK-02, WBHK-03)", () => { + it("WBHK-01: endpoint.url_validation returns { plainToken, encryptedToken } with HTTP 200", async () => { + const adapter = createZoomAdapter(TEST_CREDENTIALS); + const plainToken = "abc123"; + const body = JSON.stringify({ + event: "endpoint.url_validation", + payload: { plainToken }, + }); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + + const response = await adapter.handleWebhook(request); + + expect(response.status).toBe(200); + const json = await response.json(); + const expectedEncryptedToken = createHmac("sha256", TEST_SECRET) + .update(plainToken) + .digest("hex"); + expect(json).toEqual({ + plainToken, + encryptedToken: expectedEncryptedToken, + }); + }); + + it("WBHK-02: tampered x-zm-signature returns HTTP 401", async () => { + const adapter = createZoomAdapter(TEST_CREDENTIALS); + const body = JSON.stringify({ event: "bot_notification", payload: {} }); + const request = makeZoomRequest(body, { signature: "v0=deadbeef" }); + + const response = await adapter.handleWebhook(request); + + expect(response.status).toBe(401); + }); + + it("WBHK-02: missing x-zm-signature returns HTTP 401", async () => { + const adapter = createZoomAdapter(TEST_CREDENTIALS); + const body = JSON.stringify({ event: "bot_notification", payload: {} }); + const timestamp = String(Math.floor(Date.now() / 1000)); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-zm-request-timestamp": timestamp, + }, + body, + }); + + const response = await adapter.handleWebhook(request); + + expect(response.status).toBe(401); + }); + + it("WBHK-02: stale timestamp (>5 minutes) returns HTTP 401", async () => { + const adapter = createZoomAdapter(TEST_CREDENTIALS); + const body = JSON.stringify({ event: "bot_notification", payload: {} }); + const staleTimestamp = String(Math.floor(Date.now() / 1000) - 360); + const request = makeZoomRequest(body, { timestamp: staleTimestamp }); + + const response = await adapter.handleWebhook(request); + + expect(response.status).toBe(401); + }); + + it("WBHK-03: valid signature with correct raw body passes verification", async () => { + const adapter = createZoomAdapter(TEST_CREDENTIALS); + const body = JSON.stringify({ event: "bot_notification", payload: {} }); + const request = makeZoomRequest(body); + + const response = await adapter.handleWebhook(request); + + // Verification passed — status should NOT be 401. + // processEvent is a stub in Phase 1, so 200 ("ok") or any non-401 is acceptable. + expect(response.status).not.toBe(401); + }); +}); + +describe("ZoomAdapter — S2S OAuth Token (AUTH-01, AUTH-02, AUTH-04)", () => { + function mockTokenFetch(token = "access-token-1", expiresIn = 3600) { + return vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + access_token: token, + token_type: "bearer", + expires_in: expiresIn, + }), + }); + } + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("AUTH-01: getAccessToken calls https://zoom.us/oauth/token?grant_type=client_credentials", async () => { + const fetchMock = mockTokenFetch(); + vi.stubGlobal("fetch", fetchMock); + const adapter = createZoomAdapter(TEST_CREDENTIALS); + await adapter.getAccessToken(); + expect(fetchMock).toHaveBeenCalledWith( + "https://zoom.us/oauth/token?grant_type=client_credentials", + expect.objectContaining({ method: "POST" }) + ); + }); + + it("AUTH-02: token is reused within 1-hour TTL", async () => { + const fetchMock = mockTokenFetch(); + vi.stubGlobal("fetch", fetchMock); + const adapter = createZoomAdapter(TEST_CREDENTIALS); + await adapter.getAccessToken(); + await adapter.getAccessToken(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("AUTH-02: new token is fetched after TTL expires", async () => { + vi.useFakeTimers(); + const fetchMock = mockTokenFetch(); + vi.stubGlobal("fetch", fetchMock); + const adapter = createZoomAdapter(TEST_CREDENTIALS); + await adapter.getAccessToken(); + vi.advanceTimersByTime(3700 * 1000); // past 1-hour TTL + await adapter.getAccessToken(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("AUTH-04: token fetch uses grant_type=client_credentials (not account_credentials)", async () => { + const fetchMock = mockTokenFetch(); + vi.stubGlobal("fetch", fetchMock); + const adapter = createZoomAdapter(TEST_CREDENTIALS); + await adapter.getAccessToken(); + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain("grant_type=client_credentials"); + expect(url).not.toContain("account_credentials"); + }); +}); + +describe("ZoomAdapter — Factory Validation (AUTH-03)", () => { + const BASE = { + clientId: "id", + clientSecret: "secret", + robotJid: "jid", + accountId: "acct", + webhookSecretToken: "token", + }; + + it("throws ValidationError when clientId is missing", () => { + expect(() => createZoomAdapter({ ...BASE, clientId: undefined })).toThrow( + ValidationError + ); + }); + + it("throws ValidationError when clientSecret is missing", () => { + expect(() => + createZoomAdapter({ ...BASE, clientSecret: undefined }) + ).toThrow(ValidationError); + }); + + it("throws ValidationError when robotJid is missing", () => { + expect(() => createZoomAdapter({ ...BASE, robotJid: undefined })).toThrow( + ValidationError + ); + }); + + it("throws ValidationError when accountId is missing", () => { + expect(() => createZoomAdapter({ ...BASE, accountId: undefined })).toThrow( + ValidationError + ); + }); + + it("throws ValidationError when webhookSecretToken is missing", () => { + expect(() => + createZoomAdapter({ ...BASE, webhookSecretToken: undefined }) + ).toThrow(ValidationError); + }); +}); + +describe("ZoomAdapter — Thread ID (THRD-01)", () => { + const adapter = createZoomAdapter(TEST_CREDENTIALS); + + it("THRD-01: encodeThreadId produces zoom:{channelId}:{messageId}", () => { + expect( + adapter.encodeThreadId({ channelId: "chan123", messageId: "999" }) + ).toBe("zoom:chan123:999"); + expect( + adapter.encodeThreadId({ + channelId: "chan@conference.xmpp.zoom.us", + messageId: "abc-uuid", + }) + ).toBe("zoom:chan@conference.xmpp.zoom.us:abc-uuid"); + }); + + it("THRD-01: decodeThreadId round-trips without loss", () => { + expect(adapter.decodeThreadId("zoom:chan123:999")).toEqual({ + channelId: "chan123", + messageId: "999", + }); + expect( + adapter.decodeThreadId("zoom:chan@conference.xmpp.zoom.us:abc-uuid") + ).toEqual({ + channelId: "chan@conference.xmpp.zoom.us", + messageId: "abc-uuid", + }); + }); + + it("THRD-01: decodeThreadId throws ValidationError on wrong prefix", () => { + expect(() => adapter.decodeThreadId("slack:C123:ts")).toThrow( + ValidationError + ); + }); + + it("THRD-01: decodeThreadId returns channel-level ID when no messageId present", () => { + expect(adapter.decodeThreadId("zoom:only-one-part")).toEqual({ + channelId: "only-one-part", + messageId: "", + }); + }); + + it("THRD-01: decodeThreadId throws ValidationError on empty channel component", () => { + expect(() => adapter.decodeThreadId("zoom::msgid")).toThrow( + ValidationError + ); + }); +}); + +function makeMockChat(): ChatInstance { + return { + processMessage: vi.fn().mockResolvedValue(undefined), + } as unknown as ChatInstance; +} + +describe("ZoomAdapter — bot_notification (WBHK-04)", () => { + afterEach(() => vi.restoreAllMocks()); + + it("WBHK-04: channel message produces correct threadId, text, author", async () => { + const adapter = createZoomAdapter(TEST_CREDENTIALS); + const chat = makeMockChat(); + await adapter.initialize(chat); + + const eventTs = 1712600000000; + const toJid = "abc123@conference.xmpp.zoom.us"; + const body = JSON.stringify({ + event: "bot_notification", + event_ts: eventTs, + payload: { + accountId: "acct", + cmd: "hello world", + robotJid: "bot@xmpp.zoom.us", + timestamp: eventTs, + toJid, + userId: "user-id-1", + userJid: "user@xmpp.zoom.us", + userName: "Alice", + }, + }); + const request = makeZoomRequest(body); + await adapter.handleWebhook(request); + + expect(chat.processMessage).toHaveBeenCalledOnce(); + const [, threadId, message] = ( + chat.processMessage as ReturnType + ).mock.calls[0]; + expect(threadId).toBe(`zoom:${toJid}:${eventTs}`); + expect(message.text).toBe("hello world"); + expect(message.author.userId).toBe("user-id-1"); + expect(message.author.userName).toBe("Alice"); + }); + + it("WBHK-04: DM (toJid ends in @xmpp.zoom.us) uses userJid as channelId", async () => { + const adapter = createZoomAdapter(TEST_CREDENTIALS); + const chat = makeMockChat(); + await adapter.initialize(chat); + + const eventTs = 1712600001000; + const userJid = "user@xmpp.zoom.us"; + const body = JSON.stringify({ + event: "bot_notification", + event_ts: eventTs, + payload: { + accountId: "acct", + cmd: "dm message", + robotJid: "bot@xmpp.zoom.us", + timestamp: eventTs, + toJid: userJid, // user JID, not conference JID -> DM + userId: "user-id-2", + userJid, + userName: "Bob", + }, + }); + const request = makeZoomRequest(body); + await adapter.handleWebhook(request); + + expect(chat.processMessage).toHaveBeenCalledOnce(); + const [, threadId] = (chat.processMessage as ReturnType).mock + .calls[0]; + expect(threadId).toBe(`zoom:${userJid}:${eventTs}`); + }); +}); + +describe("ZoomAdapter — team_chat.app_mention (WBHK-05)", () => { + afterEach(() => vi.restoreAllMocks()); + + it("WBHK-05: produces correct threadId (channel_id:message_id), text, author", async () => { + const adapter = createZoomAdapter(TEST_CREDENTIALS); + const chat = makeMockChat(); + await adapter.initialize(chat); + + const messageId = "5DD614F4-DD19-ABCD-EF12-000000000001"; + const channelId = "channel-id-123"; + const body = JSON.stringify({ + event: "team_chat.app_mention", + event_ts: 1712600002000, + payload: { + account_id: "acct", + operator: "carol@example.com", + operator_id: "user-id-3", + operator_member_id: "member-id-3", + by_external_user: false, + object: { + message_id: messageId, + type: "to_channel", + channel_id: channelId, + channel_name: "general", + message: "@bot please help", + date_time: "2024-04-08T12:00:00Z", + timestamp: 1712577600000, + }, + }, + }); + const request = makeZoomRequest(body); + await adapter.handleWebhook(request); + + expect(chat.processMessage).toHaveBeenCalledOnce(); + const [, threadId, message] = ( + chat.processMessage as ReturnType + ).mock.calls[0]; + expect(threadId).toBe(`zoom:${channelId}:${messageId}`); + expect(message.text).toBe("@bot please help"); + expect(message.author.userId).toBe("user-id-3"); + }); +}); + +const POST_MESSAGE_ERROR_PATTERN = /postMessage.*403|403.*postMessage/; +const EDIT_MESSAGE_ERROR_PATTERN = /editMessage.*404|404.*editMessage/; +const DELETE_MESSAGE_ERROR_PATTERN = /deleteMessage.*403|403.*deleteMessage/; + +describe("ZoomAdapter — postMessage (MSG-01, MSG-02)", () => { + function mockFetch(status: number, body: unknown): ReturnType { + return vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }); + } + + afterEach(() => vi.restoreAllMocks()); + + it("MSG-01-a: postMessage to channel JID calls POST to chatbot IM API with Bearer token and to_jid", async () => { + const fetchMock = mockFetch(200, { message_id: "msg-uuid-123" }); + vi.stubGlobal("fetch", fetchMock); + const adapter = createZoomAdapter(TEST_CREDENTIALS); + vi.spyOn(adapter, "getAccessToken").mockResolvedValue("test-token"); + + const threadId = "zoom:chan@conference.xmpp.zoom.us:msg-001"; + await adapter.postMessage(threadId, "Hello channel"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.zoom.us/v2/im/chat/messages", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + }) + ); + const callBody = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body + ) as Record; + expect(callBody).toMatchObject({ + robot_jid: TEST_CREDENTIALS.robotJid, + to_jid: "chan@conference.xmpp.zoom.us", + account_id: TEST_CREDENTIALS.accountId, + }); + expect(callBody).toHaveProperty("content.body"); + }); + + it("MSG-01-b: postMessage to DM JID calls POST with correct body (no reply_main_message_id)", async () => { + const fetchMock = mockFetch(200, { message_id: "msg-uuid-123" }); + vi.stubGlobal("fetch", fetchMock); + const adapter = createZoomAdapter(TEST_CREDENTIALS); + vi.spyOn(adapter, "getAccessToken").mockResolvedValue("test-token"); + + const threadId = "zoom:user@xmpp.zoom.us:msg-002"; + await adapter.postMessage(threadId, "Hello DM"); + + const callBody = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body + ) as Record; + expect(callBody).toMatchObject({ + robot_jid: TEST_CREDENTIALS.robotJid, + to_jid: "user@xmpp.zoom.us", + account_id: TEST_CREDENTIALS.accountId, + }); + expect(callBody).not.toHaveProperty("reply_main_message_id"); + }); + + it("MSG-01-c: postMessage returns RawMessage with id, threadId, and raw", async () => { + const fetchMock = mockFetch(200, { message_id: "msg-uuid-123" }); + vi.stubGlobal("fetch", fetchMock); + const adapter = createZoomAdapter(TEST_CREDENTIALS); + vi.spyOn(adapter, "getAccessToken").mockResolvedValue("test-token"); + + const threadId = "zoom:chan@conference.xmpp.zoom.us:msg-003"; + const result = await adapter.postMessage(threadId, "Hello"); + + expect(result.id).toBe("msg-uuid-123"); + expect(result.threadId).toBe(threadId); + expect(result.raw).toEqual({ message_id: "msg-uuid-123" }); + }); + + it("MSG-02-a: postMessage with replyTo includes reply_main_message_id in body", async () => { + const fetchMock = mockFetch(200, { message_id: "msg-uuid-456" }); + vi.stubGlobal("fetch", fetchMock); + const adapter = createZoomAdapter(TEST_CREDENTIALS); + vi.spyOn(adapter, "getAccessToken").mockResolvedValue("test-token"); + + const threadId = "zoom:chan@conference.xmpp.zoom.us:msg-004"; + // Combine a valid postable string shape with metadata for replyTo + const replyMsg: ZoomMessageWithReply = { + raw: "Reply text", + metadata: { replyTo: "parent-id" }, + }; + await adapter.postMessage( + threadId, + replyMsg as unknown as import("./index.js").AdapterPostableMessage + ); + + const callBody = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body + ) as Record; + expect(callBody.reply_main_message_id).toBe("parent-id"); + }); + + it("MSG-02-b: postMessage with replyTo to a DM logs debug warning about THRD-03", async () => { + const fetchMock = mockFetch(200, { message_id: "msg-uuid-789" }); + vi.stubGlobal("fetch", fetchMock); + const mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), + }; + const adapter = createZoomAdapter({ + ...TEST_CREDENTIALS, + logger: mockLogger, + }); + vi.spyOn(adapter, "getAccessToken").mockResolvedValue("test-token"); + + const threadId = "zoom:user@xmpp.zoom.us:msg-005"; + // Combine a valid postable string shape with metadata for replyTo + const replyMsg: ZoomMessageWithReply = { + raw: "DM reply", + metadata: { replyTo: "parent-id" }, + }; + await adapter.postMessage( + threadId, + replyMsg as unknown as import("./index.js").AdapterPostableMessage + ); + + const debugCalls = mockLogger.debug.mock.calls as [string, ...unknown[]][]; + const hasThrd03Warning = debugCalls.some( + ([msg]) => typeof msg === "string" && msg.includes("THRD-03") + ); + expect(hasThrd03Warning).toBe(true); + }); + + it("zoomFetch-error: non-2xx response throws Error with operation name and status code", async () => { + const fetchMock = mockFetch(403, { error: "Forbidden" }); + vi.stubGlobal("fetch", fetchMock); + const adapter = createZoomAdapter(TEST_CREDENTIALS); + vi.spyOn(adapter, "getAccessToken").mockResolvedValue("test-token"); + + const threadId = "zoom:chan@conference.xmpp.zoom.us:msg-006"; + await expect(adapter.postMessage(threadId, "Hello")).rejects.toThrow( + POST_MESSAGE_ERROR_PATTERN + ); + }); +}); + +describe("ZoomAdapter — editMessage (MSG-03)", () => { + function mockFetch(status: number, body: unknown): ReturnType { + return vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }); + } + + afterEach(() => vi.restoreAllMocks()); + + it("MSG-03-a: editMessage calls PUT to chatbot IM API with Bearer token and content body", async () => { + const fetchMock = mockFetch(204, {}); + vi.stubGlobal("fetch", fetchMock); + const adapter = createZoomAdapter(TEST_CREDENTIALS); + vi.spyOn(adapter, "getAccessToken").mockResolvedValue("test-token"); + + await adapter.editMessage( + "zoom:chan@conference.xmpp.zoom.us:msg-to-edit", + "msg-to-edit", + "Updated text" as unknown as import("./index.js").AdapterPostableMessage + ); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.zoom.us/v2/im/chat/messages/msg-to-edit", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + }) + ); + const callBody = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body + ) as Record; + expect(callBody).toMatchObject({ + robot_jid: TEST_CREDENTIALS.robotJid, + account_id: TEST_CREDENTIALS.accountId, + }); + expect(callBody).toHaveProperty("content.body"); + }); + + it("MSG-03-b: editMessage returns RawMessage with id and threadId (204 No Content)", async () => { + const fetchMock = mockFetch(204, {}); + vi.stubGlobal("fetch", fetchMock); + const adapter = createZoomAdapter(TEST_CREDENTIALS); + vi.spyOn(adapter, "getAccessToken").mockResolvedValue("test-token"); + + const threadId = "zoom:chan@conference.xmpp.zoom.us:msg-to-edit"; + const result = await adapter.editMessage( + threadId, + "msg-to-edit", + "Updated text" as unknown as import("./index.js").AdapterPostableMessage + ); + + expect(result.id).toBe("msg-to-edit"); + expect(result.threadId).toBe(threadId); + expect(result.raw).toEqual({}); + }); + + it("MSG-03-error: editMessage throws Error with operation name and status on non-2xx response", async () => { + const fetchMock = mockFetch(404, { error: "Not Found" }); + vi.stubGlobal("fetch", fetchMock); + const adapter = createZoomAdapter(TEST_CREDENTIALS); + vi.spyOn(adapter, "getAccessToken").mockResolvedValue("test-token"); + + await expect( + adapter.editMessage( + "zoom:chan@conference.xmpp.zoom.us:msg-to-edit", + "msg-to-edit", + "Updated text" as unknown as import("./index.js").AdapterPostableMessage + ) + ).rejects.toThrow(EDIT_MESSAGE_ERROR_PATTERN); + }); +}); + +describe("ZoomAdapter — deleteMessage (MSG-04)", () => { + function mockFetch(status: number, body: unknown): ReturnType { + return vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }); + } + + afterEach(() => vi.restoreAllMocks()); + + it("MSG-04-a: deleteMessage calls DELETE to correct URL with Bearer token", async () => { + const fetchMock = mockFetch(204, {}); + vi.stubGlobal("fetch", fetchMock); + const adapter = createZoomAdapter(TEST_CREDENTIALS); + vi.spyOn(adapter, "getAccessToken").mockResolvedValue("test-token"); + + await adapter.deleteMessage( + "zoom:chan@conference.xmpp.zoom.us:msg-to-delete", + "msg-to-delete" + ); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("https://api.zoom.us/v2/im/chat/messages/msg-to-delete"), + expect.objectContaining({ + method: "DELETE", + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + }) + ); + }); + + it("MSG-04-b: deleteMessage returns void", async () => { + vi.stubGlobal("fetch", mockFetch(204, {})); + vi.spyOn( + createZoomAdapter(TEST_CREDENTIALS), + "getAccessToken" + ).mockResolvedValue("test-token"); + const adapter = createZoomAdapter(TEST_CREDENTIALS); + vi.spyOn(adapter, "getAccessToken").mockResolvedValue("test-token"); + const result = await adapter.deleteMessage( + "zoom:chan@conference.xmpp.zoom.us:msg-to-delete", + "msg-to-delete" + ); + expect(result).toBeUndefined(); + }); + + it("MSG-04-error: deleteMessage throws Error with operation name and status on non-2xx response", async () => { + const fetchMock = mockFetch(403, { error: "Forbidden" }); + vi.stubGlobal("fetch", fetchMock); + const adapter = createZoomAdapter(TEST_CREDENTIALS); + vi.spyOn(adapter, "getAccessToken").mockResolvedValue("test-token"); + + await expect( + adapter.deleteMessage( + "zoom:chan@conference.xmpp.zoom.us:msg-to-delete", + "msg-to-delete" + ) + ).rejects.toThrow(DELETE_MESSAGE_ERROR_PATTERN); + }); +}); + +describe("ZoomAdapter — Unhandled events and uninitialized adapter safety (THRD-02, THRD-03)", () => { + afterEach(() => vi.restoreAllMocks()); + + it("THRD-03: unknown event is logged at debug and does not throw", async () => { + const adapter = createZoomAdapter({ ...TEST_CREDENTIALS }); + const debugSpy = vi.spyOn( + ( + adapter as unknown as { + config: { logger: { debug: (msg: string, ctx: unknown) => void } }; + } + ).config.logger, + "debug" + ); + const chat = makeMockChat(); + await adapter.initialize(chat); + + const body = JSON.stringify({ + event: "team_chat.some_unknown_event", + event_ts: 1712600003000, + payload: {}, + }); + const request = makeZoomRequest(body); + const response = await adapter.handleWebhook(request); + + expect(response.status).toBe(200); + expect(debugSpy).toHaveBeenCalledWith("Unhandled Zoom event", { + event: "team_chat.some_unknown_event", + }); + expect(chat.processMessage).not.toHaveBeenCalled(); + }); + + it("THRD-02: uninitialized adapter safety — handleWebhook returns 200 without calling processMessage", async () => { + const adapter = createZoomAdapter(TEST_CREDENTIALS); + // Do NOT call initialize — chat is null + const body = JSON.stringify({ + event: "bot_notification", + event_ts: 1712600004000, + payload: { + accountId: "acct", + cmd: "hello", + robotJid: "bot@xmpp.zoom.us", + timestamp: 1712600004000, + toJid: "chan@conference.xmpp.zoom.us", + userId: "uid", + userJid: "u@xmpp.zoom.us", + userName: "Dave", + }, + }); + const request = makeZoomRequest(body); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); // no crash + }); +}); diff --git a/packages/adapter-zoom/src/index.ts b/packages/adapter-zoom/src/index.ts new file mode 100644 index 00000000..150ff5dd --- /dev/null +++ b/packages/adapter-zoom/src/index.ts @@ -0,0 +1,584 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { AuthenticationError, ValidationError } from "@chat-adapter/shared"; +import { + type Adapter, + type AdapterPostableMessage, + type ChatInstance, + ConsoleLogger, + type EmojiValue, + type FetchOptions, + type FetchResult, + type FormattedContent, + Message, + NotImplementedError, + type RawMessage, + type Root, + type ThreadInfo, + type WebhookOptions, +} from "chat"; +import { ZoomFormatConverter } from "./markdown.js"; +import type { + ZoomAdapterConfig, + ZoomAdapterInternalConfig, + ZoomAppMentionPayload, + ZoomBotNotificationPayload, + ZoomCrcPayload, + ZoomMessageWithReply, + ZoomThreadId, + ZoomWebhookPayload, +} from "./types.js"; + +export type { + ZoomAdapterConfig, + ZoomAppMentionPayload, + ZoomBotNotificationPayload, + ZoomCrcPayload, + ZoomMessageWithReply, + ZoomThreadId, + ZoomWebhookPayload, +} from "./types.js"; + +export class ZoomAdapter implements Adapter { + readonly name = "zoom"; + readonly lockScope = "thread" as const; + readonly userName: string; + + private readonly config: ZoomAdapterInternalConfig; + private readonly formatConverter = new ZoomFormatConverter(); + private cachedToken: { value: string; expiresAt: number } | null = null; + private chat: ChatInstance | null = null; + /** Maps threadId → userJid of the user who sent the triggering message. + * Populated during webhook handling so postMessage/editMessage/deleteMessage + * can include user_jid in Zoom API requests (required per Zoom chatbot API). */ + private readonly threadUserJid = new Map(); + + constructor(config: ZoomAdapterInternalConfig) { + this.config = config; + this.userName = config.userName; + } + + /** Fetches and caches a chatbot token via S2S OAuth client_credentials grant. + * Uses raw fetch — @zoom/rivet's ChatbotClient does not expose a public token-fetch API + * (its ClientCredentialsAuth is internal-only). @zoom/rivet is used in Phase 3 for + * message sending via endpoints.sendChatbotMessage(). + * Reuses the cached token within the 1-hour TTL (with 60-second early-expiry buffer). + * On failure, throws AuthenticationError — caller should let the SDK return 500. + */ + async getAccessToken(): Promise { + if (this.cachedToken && Date.now() < this.cachedToken.expiresAt - 60_000) { + return this.cachedToken.value; + } + + const credentials = Buffer.from( + `${this.config.clientId}:${this.config.clientSecret}` + ).toString("base64"); + + const response = await fetch( + "https://zoom.us/oauth/token?grant_type=client_credentials", + { + method: "POST", + headers: { + Authorization: `Basic ${credentials}`, + }, + } + ); + + if (!response.ok) { + throw new AuthenticationError( + "zoom", + `Token fetch failed with HTTP ${response.status}` + ); + } + + const data = (await response.json()) as { + access_token: string; + expires_in: number; + }; + + this.cachedToken = { + value: data.access_token, + expiresAt: Date.now() + data.expires_in * 1000, + }; + + return this.cachedToken.value; + } + + async handleWebhook( + request: Request, + options?: WebhookOptions + ): Promise { + // WBHK-03: Capture raw body FIRST — Web Request body can only be consumed once. + // The raw string is passed unchanged to HMAC verification. + const body = await request.text(); + + let parsed: ZoomWebhookPayload; + try { + parsed = JSON.parse(body) as ZoomWebhookPayload; + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + + // WBHK-01: Handle CRC URL validation challenge BEFORE signature check. + // CRC requests do NOT include x-zm-signature — checking signature first + // would return 401 and prevent Zoom Marketplace from validating the endpoint. + if (parsed.event === "endpoint.url_validation") { + const { plainToken } = (parsed as ZoomCrcPayload).payload; + const encryptedToken = createHmac( + "sha256", + this.config.webhookSecretToken + ) + .update(plainToken) + .digest("hex"); + return Response.json({ plainToken, encryptedToken }); + } + + // WBHK-02: Verify signature for all other events + if (!this.verifySignature(body, request)) { + return new Response("Invalid signature", { status: 401 }); + } + + // Process event asynchronously if waitUntil is available (edge runtime pattern) + const handlePromise = this.processEvent(parsed, options); + if (options?.waitUntil) { + options.waitUntil(handlePromise); + } else { + await handlePromise; + } + return new Response("ok", { status: 200 }); + } + + private verifySignature(body: string, request: Request): boolean { + const timestamp = request.headers.get("x-zm-request-timestamp"); + const signature = request.headers.get("x-zm-signature"); + + if (!(timestamp && signature)) { + return false; + } + + // Reject stale requests — fixed 5-minute window per Zoom spec + const fiveMinutesMs = 5 * 60 * 1000; + if (Date.now() - Number(timestamp) * 1000 > fiveMinutesMs) { + return false; + } + + const message = `v0:${timestamp}:${body}`; + const expected = + "v0=" + + createHmac("sha256", this.config.webhookSecretToken) + .update(message) + .digest("hex"); + + try { + return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); + } catch { + // Buffer length mismatch throws — treat as invalid signature. + // ZOOM-506645: Unicode normalization bug — emoji/non-ASCII payloads may fail + // HMAC verification due to normalization differences between Zoom signing and receipt. + // Log raw body hex for diagnosis without exposing full payload. + this.config.logger.debug( + "Signature comparison failed (possible ZOOM-506645 Unicode normalization issue)", + { bodyHex: Buffer.from(body).toString("hex").substring(0, 200) } + ); + return false; + } + } + + private async processEvent( + payload: ZoomWebhookPayload, + options?: WebhookOptions + ): Promise { + const chat = this.chat; + if (!chat) { + this.config.logger.warn( + "ZoomAdapter: chat not initialized, ignoring event" + ); + return; + } + if (payload.event === "bot_notification") { + await this.handleBotNotification( + payload as ZoomBotNotificationPayload, + chat, + options + ); + } else if (payload.event === "team_chat.app_mention") { + await this.handleAppMention( + payload as ZoomAppMentionPayload, + chat, + options + ); + } else { + this.config.logger.debug("Unhandled Zoom event", { + event: payload.event, + }); + } + } + + private async handleBotNotification( + payload: ZoomBotNotificationPayload, + chat: ChatInstance, + options?: WebhookOptions + ): Promise { + const { cmd, toJid, userId, userJid, userName } = payload.payload; + const eventTs = payload.event_ts; + + // DM detection: channel JIDs end in @conference.xmpp.zoom.us; user JIDs end in @xmpp.zoom.us + const isDM = !toJid.endsWith("@conference.xmpp.zoom.us"); + + // ZOOM PLATFORM LIMITATION: The chat_message.replied webhook event is NOT fired + // for 1:1 DM thread replies. Subscribing to a DM thread (THRD-02) will capture + // the initial message, but thread replies in DMs will not trigger any webhook. + // This is a confirmed Zoom platform limitation, not a configuration issue. + // See: https://devforum.zoom.us/t/clarification-on-zoom-chatbot-webhook-events-for-thread-replies-in-1-1-chats/134812 + const channelId = isDM ? userJid : toJid; + const threadId = this.encodeThreadId({ + channelId, + messageId: String(eventTs), + }); + + const text = cmd; + const formatted = this.formatConverter.toAst(text); + + const message = new Message({ + id: String(eventTs), + threadId, + text, + formatted, + // Zoom has no separate mention event — bot_notification fires for all + // bot interactions (DMs and slash commands). Mark as mention so + // onNewMention handlers fire consistently with other adapters. + isMention: true, + author: { + userId, + userName, + fullName: userName, + isBot: false, + isMe: false, + }, + metadata: { + dateSent: new Date(eventTs), + edited: false, + }, + attachments: [], + raw: payload, + }); + + // Store userJid so postMessage/editMessage/deleteMessage can include it + this.threadUserJid.set(threadId, userJid); + + await chat.processMessage(this, threadId, message, options); + } + + private async handleAppMention( + payload: ZoomAppMentionPayload, + chat: ChatInstance, + options?: WebhookOptions + ): Promise { + const { operator_id: operatorId, operator } = payload.payload; + const { + message_id: messageId, + channel_id: channelId, + message, + timestamp, + } = payload.payload.object; + + const threadId = this.encodeThreadId({ channelId, messageId }); + + const text = message; + const formatted = this.formatConverter.toAst(text); + + const msg = new Message({ + id: messageId, + threadId, + text, + formatted, + // team_chat.app_mention is an explicit @mention — always treat as mention + isMention: true, + author: { + userId: operatorId, + userName: operator, + fullName: operator, + isBot: false, + isMe: false, + }, + metadata: { + dateSent: new Date(timestamp), + edited: false, + }, + attachments: [], + raw: payload, + }); + + // Store operatorId as userJid for outgoing message context + this.threadUserJid.set(threadId, operatorId); + + await chat.processMessage(this, threadId, msg, options); + } + + async initialize(chat: ChatInstance): Promise { + this.chat = chat; + this.config.logger.debug("ZoomAdapter initialized"); + } + + private async zoomFetch( + url: string, + options: RequestInit, + operation: string + ): Promise { + const token = await this.getAccessToken(); + const response = await fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...options.headers, + }, + }); + if (!response.ok) { + const body = await response.text(); + this.config.logger.debug(`${operation} API error`, { + status: response.status, + body, + }); + throw new Error( + `ZoomAdapter: ${operation} failed with HTTP ${response.status}` + ); + } + return response; + } + + async postMessage( + threadId: string, + message: AdapterPostableMessage + ): Promise> { + const { channelId } = this.decodeThreadId(threadId); + const isDM = !channelId.endsWith("@conference.xmpp.zoom.us"); + // Render to markdown string first (handles all message variants), + // then re-parse to AST for styled content body conversion. + const markdown = this.formatConverter.renderPostable(message); + + // Zoom API rejects empty messages — skip silently + if (!markdown || markdown.trim() === "") { + return { id: String(Date.now()), threadId, raw: {} }; + } + + const ast = this.formatConverter.toAst(markdown); + const contentBody = this.formatConverter.toZoomContentBody(ast); + + // MSG-02: threading — add reply_main_message_id if present + // ZoomMessageWithReply is the Zoom-specific extension type for threaded replies + const zoomMsg = message as ZoomMessageWithReply; + const replyTo = zoomMsg.metadata?.replyTo; + if (replyTo && isDM) { + this.config.logger.debug( + "Posting threaded reply to DM thread — Zoom does not fire chat_message.replied webhook for 1:1 DM thread replies (THRD-03)" + ); + } + + const userJid = this.threadUserJid.get(threadId); + const body: Record = { + robot_jid: this.config.robotJid, + to_jid: channelId, + account_id: this.config.accountId, + ...(userJid ? { user_jid: userJid } : {}), + content: { + body: contentBody, + }, + ...(replyTo ? { reply_main_message_id: replyTo } : {}), + }; + + const response = await this.zoomFetch( + "https://api.zoom.us/v2/im/chat/messages", + { method: "POST", body: JSON.stringify(body) }, + "postMessage" + ); + const data = (await response.json()) as { + message_id?: string; + id?: string; + }; + return { + id: data.message_id ?? data.id ?? String(Date.now()), + threadId, + raw: data, + }; + } + + async editMessage( + threadId: string, + messageId: string, + message: AdapterPostableMessage + ): Promise> { + const markdown = this.formatConverter.renderPostable(message); + const ast = this.formatConverter.toAst(markdown); + const contentBody = this.formatConverter.toZoomContentBody(ast); + const editUserJid = this.threadUserJid.get(threadId); + await this.zoomFetch( + `https://api.zoom.us/v2/im/chat/messages/${messageId}`, + { + method: "PUT", + body: JSON.stringify({ + robot_jid: this.config.robotJid, + account_id: this.config.accountId, + ...(editUserJid ? { user_jid: editUserJid } : {}), + content: { body: contentBody }, + }), + }, + "editMessage" + ); + // Zoom returns 204 No Content on success — no body to parse + return { id: messageId, threadId, raw: {} }; + } + + async deleteMessage(threadId: string, messageId: string): Promise { + const deleteUserJid = this.threadUserJid.get(threadId); + const params = new URLSearchParams({ + robot_jid: this.config.robotJid, + account_id: this.config.accountId, + ...(deleteUserJid ? { user_jid: deleteUserJid } : {}), + }); + await this.zoomFetch( + `https://api.zoom.us/v2/im/chat/messages/${messageId}?${params.toString()}`, + { method: "DELETE" }, + "deleteMessage" + ); + } + + async fetchMessages( + _threadId: string, + _options?: FetchOptions + ): Promise> { + throw new NotImplementedError( + "ZoomAdapter: fetchMessages not yet implemented", + "fetchMessages" + ); + } + + async fetchThread(_threadId: string): Promise { + throw new NotImplementedError( + "ZoomAdapter: fetchThread not yet implemented", + "fetchThread" + ); + } + + async addReaction( + _threadId: string, + _messageId: string, + _emoji: EmojiValue | string + ): Promise { + throw new NotImplementedError( + "ZoomAdapter: addReaction not yet implemented", + "addReaction" + ); + } + + async removeReaction( + _threadId: string, + _messageId: string, + _emoji: EmojiValue | string + ): Promise { + throw new NotImplementedError( + "ZoomAdapter: removeReaction not yet implemented", + "removeReaction" + ); + } + + async startTyping(_threadId: string, _status?: string): Promise { + // Zoom Team Chat has no typing indicator API — silently no-op + } + + channelIdFromThreadId(threadId: string): string { + const parts = threadId.split(":"); + return `${parts[0]}:${parts[1]}`; + } + + isDM(threadId: string): boolean { + const { channelId } = this.decodeThreadId(threadId); + return !channelId.endsWith("@conference.xmpp.zoom.us"); + } + + encodeThreadId(platformData: ZoomThreadId): string { + return `zoom:${platformData.channelId}:${platformData.messageId}`; + } + + decodeThreadId(threadId: string): ZoomThreadId { + if (!threadId.startsWith("zoom:")) { + throw new ValidationError("zoom", `Invalid Zoom thread ID: ${threadId}`); + } + const withoutPrefix = threadId.slice(5); // remove "zoom:" + const colonIndex = withoutPrefix.indexOf(":"); + // Channel-level ID (no messageId) — used when posting a new message to a channel + if (colonIndex === -1) { + return { channelId: withoutPrefix, messageId: "" }; + } + const channelId = withoutPrefix.slice(0, colonIndex); + const messageId = withoutPrefix.slice(colonIndex + 1); + if (!channelId) { + throw new ValidationError( + "zoom", + `Invalid Zoom thread ID format (empty channelId): ${threadId}` + ); + } + return { channelId, messageId }; + } + + parseMessage(_raw: unknown): import("chat").Message { + throw new NotImplementedError( + "ZoomAdapter: parseMessage not yet implemented", + "parseMessage" + ); + } + + renderFormatted(content: FormattedContent): string { + return this.formatConverter.fromAst(content as Root); + } +} + +export function createZoomAdapter(config?: ZoomAdapterConfig): ZoomAdapter { + const logger = config?.logger ?? new ConsoleLogger("info").child("zoom"); + const clientId = config?.clientId ?? process.env.ZOOM_CLIENT_ID; + if (!clientId) { + throw new ValidationError( + "zoom", + "clientId is required. Set ZOOM_CLIENT_ID or provide it in config." + ); + } + const clientSecret = config?.clientSecret ?? process.env.ZOOM_CLIENT_SECRET; + if (!clientSecret) { + throw new ValidationError( + "zoom", + "clientSecret is required. Set ZOOM_CLIENT_SECRET or provide it in config." + ); + } + const robotJid = config?.robotJid ?? process.env.ZOOM_ROBOT_JID; + if (!robotJid) { + throw new ValidationError( + "zoom", + "robotJid is required. Set ZOOM_ROBOT_JID or provide it in config." + ); + } + const accountId = config?.accountId ?? process.env.ZOOM_ACCOUNT_ID; + if (!accountId) { + throw new ValidationError( + "zoom", + "accountId is required. Set ZOOM_ACCOUNT_ID or provide it in config." + ); + } + const webhookSecretToken = + config?.webhookSecretToken ?? process.env.ZOOM_WEBHOOK_SECRET_TOKEN; + if (!webhookSecretToken) { + throw new ValidationError( + "zoom", + "webhookSecretToken is required. Set ZOOM_WEBHOOK_SECRET_TOKEN or provide it in config." + ); + } + const userName = + config?.userName ?? process.env.ZOOM_BOT_USERNAME ?? "zoom-bot"; + return new ZoomAdapter({ + clientId, + clientSecret, + robotJid, + accountId, + webhookSecretToken, + userName, + logger, + }); +} diff --git a/packages/adapter-zoom/src/markdown.test.ts b/packages/adapter-zoom/src/markdown.test.ts new file mode 100644 index 00000000..df51c672 --- /dev/null +++ b/packages/adapter-zoom/src/markdown.test.ts @@ -0,0 +1,110 @@ +import { parseMarkdown } from "chat"; +import { describe, expect, it } from "vitest"; +import { ZoomFormatConverter } from "./markdown.js"; + +const converter = new ZoomFormatConverter(); + +describe("ZoomFormatConverter.toAst() — FMT-01", () => { + it("converts **bold** to strong node", () => { + const ast = converter.toAst("**bold**"); + const para = ast.children[0] as import("mdast").Paragraph; + expect(para.children[0].type).toBe("strong"); + }); + + it("converts _italic_ to emphasis node", () => { + const ast = converter.toAst("_italic_"); + const para = ast.children[0] as import("mdast").Paragraph; + expect(para.children[0].type).toBe("emphasis"); + }); + + it("converts `code` to inlineCode node", () => { + const ast = converter.toAst("`code`"); + const para = ast.children[0] as import("mdast").Paragraph; + expect(para.children[0].type).toBe("inlineCode"); + }); + + it("converts ~strikethrough~ (single tilde) to delete node", () => { + const ast = converter.toAst("~strikethrough~"); + const para = ast.children[0] as import("mdast").Paragraph; + expect(para.children[0].type).toBe("delete"); + }); + + it("converts __underline__ to custom underline node", () => { + const ast = converter.toAst("__underline__"); + const para = ast.children[0] as import("mdast").Paragraph; + expect(para.children[0].type).toBe("underline"); + }); + + it("converts # heading to heading depth-1 node", () => { + const ast = converter.toAst("# heading"); + expect(ast.children[0].type).toBe("heading"); + expect((ast.children[0] as import("mdast").Heading).depth).toBe(1); + }); + + it("converts * list item to list + listItem nodes", () => { + const ast = converter.toAst("* list item"); + expect(ast.children[0].type).toBe("list"); + }); +}); + +describe("ZoomFormatConverter.fromAst() — FMT-02", () => { + it("converts strong node to **bold**", () => { + const ast = parseMarkdown("**bold**"); + expect(converter.fromAst(ast)).toContain("**bold**"); + }); + + it("converts emphasis node to _italic_", () => { + const ast = parseMarkdown("_italic_"); + expect(converter.fromAst(ast)).toContain("_italic_"); + }); + + it("converts inlineCode node to `code`", () => { + const ast = parseMarkdown("`code`"); + expect(converter.fromAst(ast)).toContain("`code`"); + }); + + it("converts delete node to ~strikethrough~ (single tilde)", () => { + // Build AST with ~~strikethrough~~ (standard), expect Zoom output ~strikethrough~ + const ast = parseMarkdown("~~strikethrough~~"); + const output = converter.fromAst(ast); + expect(output).toContain("~strikethrough~"); + expect(output).not.toContain("~~strikethrough~~"); + }); + + it("converts custom underline node to __underline__", () => { + // Build AST with underline node (from toAst of __underline__) + const ast = converter.toAst("__underline__"); + expect(converter.fromAst(ast)).toContain("__underline__"); + }); + + it("converts heading node to # heading", () => { + const ast = parseMarkdown("# heading"); + expect(converter.fromAst(ast)).toContain("# heading"); + }); + + it("converts list+listItem nodes to * list item", () => { + const ast = parseMarkdown("* list item"); + expect(converter.fromAst(ast)).toContain("* list item"); + }); +}); + +describe("ZoomFormatConverter round-trips — FMT-03", () => { + it("__underline__ round-trips: toAst then fromAst produces __underline__", () => { + const ast = converter.toAst("__underline__"); + expect(converter.fromAst(ast)).toContain("__underline__"); + }); + + it("~strikethrough~ round-trips: toAst then fromAst produces ~strikethrough~", () => { + const ast = converter.toAst("~strikethrough~"); + const output = converter.fromAst(ast); + expect(output).toContain("~strikethrough~"); + expect(output).not.toContain("~~"); + }); + + it("combined formatting: __underline__ and ~strikethrough~ in same string", () => { + const ast = converter.toAst("__hello__ and ~world~"); + const output = converter.fromAst(ast); + expect(output).toContain("__hello__"); + expect(output).toContain("~world~"); + }); +}); diff --git a/packages/adapter-zoom/src/markdown.ts b/packages/adapter-zoom/src/markdown.ts new file mode 100644 index 00000000..cf252f82 --- /dev/null +++ b/packages/adapter-zoom/src/markdown.ts @@ -0,0 +1,209 @@ +/** + * Zoom-specific format conversion using AST-based parsing. + * + * Zoom Team Chat uses a markdown-like format with two differences from standard: + * - Underline: __text__ (double underscore, not standard markdown) + * - Strikethrough: ~text~ (single tilde, standard uses double ~~text~~) + * + * All other tokens (**bold**, _italic_, `code`, # heading, * list) are standard. + */ + +import type { AdapterPostableMessage } from "chat"; +import { + BaseFormatConverter, + type Content, + convertEmojiPlaceholders, + parseMarkdown, + type Root, + stringifyMarkdown, + walkAst, +} from "chat"; +import type { Heading, ListItem, PhrasingContent, Text } from "mdast"; +import type { UnderlineNode } from "./types.js"; + +export interface ZoomContentBodyItem { + is_markdown_support?: boolean; + style?: { bold?: boolean; italic?: boolean }; + text: string; + type: "message"; +} + +export class ZoomFormatConverter extends BaseFormatConverter { + override renderPostable(message: AdapterPostableMessage): string { + const text = super.renderPostable(message); + return convertEmojiPlaceholders(text, "whatsapp"); // Unicode emoji, same as WhatsApp + } + + /** + * Convert mdast to Zoom content body array with inline styles. + * Zoom's /v2/im/chat/messages API supports per-segment bold/italic via style object. + */ + toZoomContentBody(ast: Root): ZoomContentBodyItem[] { + const segments: ZoomContentBodyItem[] = []; + + const resolveText = (text: string): string => + convertEmojiPlaceholders(text, "whatsapp"); + + // Extract plain text from phrasing content nodes (strips bold/italic markers) + const extractText = (nodes: PhrasingContent[]): string => + nodes + .map((node) => { + if (node.type === "text") { + return resolveText((node as Text).value); + } + if (node.type === "strong" || node.type === "emphasis") { + return extractText(node.children as PhrasingContent[]); + } + if (node.type === "inlineCode") { + return `\`${node.value}\``; + } + if (node.type === "html") { + return resolveText((node as { type: "html"; value: string }).value); + } + return ""; + }) + .join(""); + + // Check if ALL phrasing nodes are bold (entire paragraph is bold) + const isAllBold = (nodes: PhrasingContent[]): boolean => + nodes.length > 0 && + nodes.every( + (n) => + n.type === "strong" || + (n.type === "text" && (n as Text).value.trim() === "") + ); + + for (const block of ast.children) { + if (block.type === "paragraph") { + const text = extractText(block.children).trim(); + if (!text) { + continue; + } + const style = isAllBold(block.children) ? { bold: true } : undefined; + segments.push({ type: "message", text, ...(style ? { style } : {}) }); + } else if (block.type === "heading") { + const headingNode = block as Heading; + const text = extractText( + headingNode.children as PhrasingContent[] + ).trim(); + if (text) { + segments.push({ type: "message", text, style: { bold: true } }); + } + } else if (block.type === "list") { + for (const item of block.children as ListItem[]) { + for (const child of item.children) { + if (child.type === "paragraph") { + const text = `• ${extractText(child.children).trim()}`; + segments.push({ type: "message", text }); + } + } + } + } else if (block.type === "code") { + segments.push({ + type: "message", + text: `\`\`\`\n${block.value}\n\`\`\``, + }); + } + } + + return segments.length > 0 ? segments : [{ type: "message", text: "" }]; + } + + /** + * Convert Zoom markdown to mdast. + * + * Zoom tokens vs standard markdown: + * - __underline__ — custom "underline" node (via link-sentinel approach) + * - ~strikethrough~ — "delete" node (single → double tilde before parse) + * - **bold**, _italic_, `code`, # heading, * list — standard (no conversion) + */ + toAst(markdown: string): Root { + const standardMarkdown = this.toStandardMarkdown(markdown); + const ast = parseMarkdown(standardMarkdown); + // Post-process: convert sentinel link nodes to UnderlineNode + return walkAst(ast, (node: Content) => { + if ( + node.type === "link" && + (node as Content & { url?: string }).url === "zoom-ul:" + ) { + const linkNode = node as Content & { + url: string; + children: PhrasingContent[]; + }; + return { + type: "underline", + children: linkNode.children, + } as unknown as Content; + } + return node; + }); + } + + /** + * Convert mdast to Zoom markdown. + * + * Handles: + * - Custom underline node → __text__ + * - delete node → ~strikethrough~ (post-process ~~...~~ → ~...~) + * - Headings, lists: use stringifyMarkdown with * bullets + */ + fromAst(ast: Root): string { + const transformed = walkAst( + structuredClone(ast) as Root, + (node: Content) => { + const nodeType = (node as unknown as { type: string }).type; + if (nodeType === "underline") { + const ul = node as unknown as UnderlineNode; + // Render children as plain text content, wrap in __...__ + // Use an html inline node so stringifyMarkdown passes through the raw + // __text__ token without escaping the underscores. + const childText = ul.children + .map((c) => + c.type === "text" + ? (c as { type: "text"; value: string }).value + : "" + ) + .join(""); + return { + type: "html", + value: `__${childText}__`, + } as Content; + } + return node; + } + ); + + const markdown = stringifyMarkdown(transformed as Root, { + emphasis: "_", + bullet: "*", // Zoom uses * for list bullets + }).trim(); + + return this.toZoomMarkdown(markdown); + } + + /** + * Pre-process Zoom markdown to standard markdown for parseMarkdown(). + * CRITICAL: Handle __underline__ BEFORE standard markdown processing. + * Double underscore is a superset of single underscore at regex level. + */ + private toStandardMarkdown(text: string): string { + // 1. Handle __underline__ first (double underscore before italic processing) + // Replace __text__ with a link sentinel that parseMarkdown will handle correctly. + // Using a link node [text](zoom-ul:) as the sentinel — recognized in AST post-processing. + let result = text.replace(/__([^\n_]+?)__/g, "[$1](zoom-ul:)"); + + // 2. Convert ~strikethrough~ to ~~strikethrough~~ (single → double tilde) + // Regex: single ~ not preceded or followed by ~, no newlines inside + result = result.replace(/(?; + }; diff --git a/packages/adapter-zoom/tsconfig.json b/packages/adapter-zoom/tsconfig.json new file mode 100644 index 00000000..8768f5bd --- /dev/null +++ b/packages/adapter-zoom/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "strictNullChecks": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/adapter-zoom/tsup.config.ts b/packages/adapter-zoom/tsup.config.ts new file mode 100644 index 00000000..fb359b66 --- /dev/null +++ b/packages/adapter-zoom/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: false, +}); diff --git a/packages/adapter-zoom/vitest.config.ts b/packages/adapter-zoom/vitest.config.ts new file mode 100644 index 00000000..5b01228b --- /dev/null +++ b/packages/adapter-zoom/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts"], + }, + }, +}); diff --git a/packages/integration-tests/fixtures/replay/zoom/zoom.json b/packages/integration-tests/fixtures/replay/zoom/zoom.json new file mode 100644 index 00000000..967e38a2 --- /dev/null +++ b/packages/integration-tests/fixtures/replay/zoom/zoom.json @@ -0,0 +1,38 @@ +{ + "botName": "Chat SDK ZoomBot", + "botRobotJid": "bot@xmpp.zoom.us", + "botNotification": { + "event": "bot_notification", + "event_ts": 1712600000000, + "payload": { + "accountId": "test-account-id", + "cmd": "hello world", + "robotJid": "bot@xmpp.zoom.us", + "timestamp": 1712600000000, + "toJid": "channel-id-123@conference.xmpp.zoom.us", + "userId": "U00FAKEUSER1", + "userJid": "user@xmpp.zoom.us", + "userName": "Alice" + } + }, + "appMention": { + "event": "team_chat.app_mention", + "event_ts": 1712600002000, + "payload": { + "account_id": "test-account-id", + "operator": "carol@example.com", + "operator_id": "user-id-3", + "operator_member_id": "member-id-3", + "by_external_user": false, + "object": { + "message_id": "5DD614F4-DD19-ABCD-EF12-000000000001", + "type": "to_channel", + "channel_id": "channel-id-123", + "channel_name": "general", + "message": "@bot please help", + "date_time": "2024-04-08T12:00:00Z", + "timestamp": 1712577600000 + } + } + } +} diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index cae2513c..2c05deb6 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -22,6 +22,7 @@ "@chat-adapter/teams": "workspace:*", "@chat-adapter/telegram": "workspace:*", "@chat-adapter/whatsapp": "workspace:*", + "@chat-adapter/zoom": "workspace:*", "chat": "workspace:*", "discord-api-types": "^0.37.119" } diff --git a/packages/integration-tests/src/documentation-test-utils.ts b/packages/integration-tests/src/documentation-test-utils.ts index 40409a72..be073243 100644 --- a/packages/integration-tests/src/documentation-test-utils.ts +++ b/packages/integration-tests/src/documentation-test-utils.ts @@ -19,6 +19,7 @@ export const VALID_PACKAGE_README_IMPORTS = [ "@chat-adapter/github", "@chat-adapter/linear", "@chat-adapter/whatsapp", + "@chat-adapter/zoom", "@chat-adapter/state-redis", "@chat-adapter/state-ioredis", "@chat-adapter/state-pg", diff --git a/packages/integration-tests/src/replay-zoom.test.ts b/packages/integration-tests/src/replay-zoom.test.ts new file mode 100644 index 00000000..61bede25 --- /dev/null +++ b/packages/integration-tests/src/replay-zoom.test.ts @@ -0,0 +1,253 @@ +/** + * Replay tests for Zoom webhook flows. + * + * These tests replay Zoom webhook payloads to verify bot_notification + * and team_chat.app_mention event handling flows. + */ + +import { createMemoryState } from "@chat-adapter/state-memory"; +import { createZoomAdapter, type ZoomAdapter } from "@chat-adapter/zoom"; +import { Chat, type Logger, Message, type Thread } from "chat"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fixtures from "../fixtures/replay/zoom/zoom.json"; +import { createWaitUntilTracker } from "./test-scenarios"; +import { + createZoomWebhookRequest, + setupZoomFetchMock, + ZOOM_CREDENTIALS, +} from "./zoom-utils"; + +const mockLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => mockLogger, +}; + +describe("Replay Tests - Zoom bot_notification", () => { + let adapter: ZoomAdapter; + let capturedThread: Thread | null; + let capturedMessage: Message | null; + let chat: Chat<{ zoom: ZoomAdapter }>; + let cleanupFetchMock: () => void; + + beforeEach(() => { + vi.clearAllMocks(); + cleanupFetchMock = setupZoomFetchMock(); + + adapter = createZoomAdapter({ + ...ZOOM_CREDENTIALS, + userName: fixtures.botName, + logger: mockLogger, + }); + + chat = new Chat({ + adapters: { zoom: adapter }, + logger: "error", + state: createMemoryState(), + userName: fixtures.botName, + }); + + capturedThread = null; + capturedMessage = null; + + // bot_notification sets isMention=true — use onNewMention to capture + chat.onNewMention(async (thread, message) => { + capturedThread = thread; + capturedMessage = message; + }); + }); + + afterEach(async () => { + await chat.shutdown(); + cleanupFetchMock(); + }); + + it("should parse a bot_notification webhook into a normalized Message", async () => { + const tracker = createWaitUntilTracker(); + await chat.webhooks.zoom( + createZoomWebhookRequest(fixtures.botNotification), + { waitUntil: tracker.waitUntil } + ); + await tracker.waitForAll(); + + expect(capturedMessage).not.toBeNull(); + expect(capturedMessage?.text).toBe("hello world"); + expect(capturedMessage?.author.userId).toBe("U00FAKEUSER1"); + expect(capturedMessage?.author.userName).toBe("Alice"); + }); + + it("should construct correct threadId for bot_notification", async () => { + const tracker = createWaitUntilTracker(); + await chat.webhooks.zoom( + createZoomWebhookRequest(fixtures.botNotification), + { waitUntil: tracker.waitUntil } + ); + await tracker.waitForAll(); + + expect(capturedThread).not.toBeNull(); + // threadId = zoom:{toJid}:{event_ts} + expect(capturedThread?.id).toBe( + "zoom:channel-id-123@conference.xmpp.zoom.us:1712600000000" + ); + expect(capturedThread?.adapter.name).toBe("zoom"); + }); +}); + +describe("Replay Tests - Zoom team_chat.app_mention", () => { + let adapter: ZoomAdapter; + let capturedThread: Thread | null; + let capturedMessage: Message | null; + let chat: Chat<{ zoom: ZoomAdapter }>; + let cleanupFetchMock: () => void; + + beforeEach(() => { + vi.clearAllMocks(); + cleanupFetchMock = setupZoomFetchMock(); + + adapter = createZoomAdapter({ + ...ZOOM_CREDENTIALS, + userName: fixtures.botName, + logger: mockLogger, + }); + + chat = new Chat({ + adapters: { zoom: adapter }, + logger: "error", + state: createMemoryState(), + userName: fixtures.botName, + }); + + capturedThread = null; + capturedMessage = null; + + // team_chat.app_mention sets isMention=true — use onNewMention to capture + chat.onNewMention(async (thread, message) => { + capturedThread = thread; + capturedMessage = message; + }); + }); + + afterEach(async () => { + await chat.shutdown(); + cleanupFetchMock(); + }); + + it("should parse a team_chat.app_mention webhook into a normalized Message", async () => { + const tracker = createWaitUntilTracker(); + await chat.webhooks.zoom(createZoomWebhookRequest(fixtures.appMention), { + waitUntil: tracker.waitUntil, + }); + await tracker.waitForAll(); + + expect(capturedMessage).not.toBeNull(); + expect(capturedMessage?.text).toBe("@bot please help"); + expect(capturedMessage?.author.userId).toBe("user-id-3"); + }); + + it("should construct correct threadId for team_chat.app_mention", async () => { + const tracker = createWaitUntilTracker(); + await chat.webhooks.zoom(createZoomWebhookRequest(fixtures.appMention), { + waitUntil: tracker.waitUntil, + }); + await tracker.waitForAll(); + + expect(capturedThread).not.toBeNull(); + // threadId = zoom:{channel_id}:{message_id} + expect(capturedThread?.id).toBe( + "zoom:channel-id-123:5DD614F4-DD19-ABCD-EF12-000000000001" + ); + expect(capturedThread?.adapter.name).toBe("zoom"); + }); +}); + +describe("Subscribe Flow - Zoom THRD-02", () => { + let adapter: ZoomAdapter; + let capturedSubscribedMessage: Message | null; + let chat: Chat<{ zoom: ZoomAdapter }>; + let cleanupFetchMock: () => void; + + beforeEach(() => { + vi.clearAllMocks(); + cleanupFetchMock = setupZoomFetchMock(); + + adapter = createZoomAdapter({ + ...ZOOM_CREDENTIALS, + userName: fixtures.botName, + logger: mockLogger, + }); + + chat = new Chat({ + adapters: { zoom: adapter }, + logger: "error", + state: createMemoryState(), + userName: fixtures.botName, + }); + + capturedSubscribedMessage = null; + + // bot_notification sets isMention=true — subscribe via onNewMention + chat.onNewMention(async (thread) => { + await thread.subscribe(); + }); + + chat.onSubscribedMessage(async (_thread, message) => { + capturedSubscribedMessage = message; + }); + }); + + afterEach(async () => { + await chat.shutdown(); + cleanupFetchMock(); + }); + + it("should fire onSubscribedMessage when replaying a subscribed thread", async () => { + // Send once — onNewMessage fires, subscribe() called, thread stored in state + const tracker1 = createWaitUntilTracker(); + await chat.webhooks.zoom( + createZoomWebhookRequest(fixtures.botNotification), + { waitUntil: tracker1.waitUntil } + ); + await tracker1.waitForAll(); + + // onSubscribedMessage should NOT have fired yet (first send subscribes, doesn't replay) + expect(capturedSubscribedMessage).toBeNull(); + + // The first webhook subscribed threadId = zoom:channel-id-123@conference.xmpp.zoom.us:1712600000000. + // To trigger onSubscribedMessage, we send a second message on that same thread ID + // with a distinct message ID (to bypass deduplication). + // We use handleIncomingMessage directly so we can supply the exact threadId + // and a fresh message ID — this is the standard approach when the adapter assigns + // per-message thread IDs (as Zoom does via event_ts). + const subscribedThreadId = + "zoom:channel-id-123@conference.xmpp.zoom.us:1712600000000"; + const followUpMessage = new Message({ + id: "follow-up-msg-1", + threadId: subscribedThreadId, + text: "hello world", + formatted: { type: "root", children: [] }, + raw: {}, + author: { + userId: "U00FAKEUSER1", + userName: "Alice", + fullName: "Alice", + isBot: false, + isMe: false, + }, + metadata: { + dateSent: new Date(fixtures.botNotification.event_ts), + edited: false, + }, + attachments: [], + }); + await chat.handleIncomingMessage( + adapter, + subscribedThreadId, + followUpMessage + ); + + expect(capturedSubscribedMessage).not.toBeNull(); + expect(capturedSubscribedMessage?.text).toBe("hello world"); + }); +}); diff --git a/packages/integration-tests/src/zoom-utils.ts b/packages/integration-tests/src/zoom-utils.ts new file mode 100644 index 00000000..3ce6fe7f --- /dev/null +++ b/packages/integration-tests/src/zoom-utils.ts @@ -0,0 +1,85 @@ +/** + * Zoom test utilities for replay/integration tests. + */ + +import { createHmac } from "node:crypto"; +import { vi } from "vitest"; + +const ZOOM_WEBHOOK_SECRET = "test-zoom-webhook-secret"; + +export const ZOOM_CREDENTIALS = { + clientId: "test-client-id", + clientSecret: "test-client-secret", + robotJid: "bot@xmpp.zoom.us", + accountId: "test-account-id", + webhookSecretToken: ZOOM_WEBHOOK_SECRET, +}; + +/** + * Creates a signed Zoom webhook Request. + * Zoom HMAC format: v0:{timestamp_seconds}:{body} + */ +export function createZoomWebhookRequest(payload: unknown): Request { + const body = JSON.stringify(payload); + const timestamp = Math.floor(Date.now() / 1000); + const message = `v0:${timestamp}:${body}`; + const hash = createHmac("sha256", ZOOM_WEBHOOK_SECRET) + .update(message) + .digest("hex"); + + return new Request("https://example.com/webhook/zoom", { + method: "POST", + headers: { + "content-type": "application/json", + "x-zm-signature": `v0=${hash}`, + "x-zm-request-timestamp": String(timestamp), + }, + body, + }); +} + +/** + * Stubs global fetch to intercept Zoom API calls. + * Returns a cleanup function that restores the original fetch. + */ +export function setupZoomFetchMock(): () => void { + const originalFetch = globalThis.fetch; + + globalThis.fetch = vi.fn( + async ( + input: RequestInfo | URL, + _init?: RequestInit + ): Promise => { + let url: string; + if (typeof input === "string") { + url = input; + } else if (input instanceof URL) { + url = input.toString(); + } else { + url = input.url; + } + + if (url.includes("zoom.us/oauth/token")) { + return { + ok: true, + json: async () => ({ access_token: "test-token", expires_in: 3600 }), + } as Response; + } + + if (url.includes("api.zoom.us/v2/chat")) { + return { + ok: true, + status: 200, + json: async () => ({ message_id: "msg-test-123" }), + text: async () => "", + } as Response; + } + + throw new Error(`Unexpected fetch: ${url}`); + } + ); + + return () => { + globalThis.fetch = originalFetch; + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52d206dc..fca51fd6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -482,6 +482,34 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/adapter-zoom: + dependencies: + '@chat-adapter/shared': + specifier: workspace:* + version: link:../adapter-shared + chat: + specifier: workspace:* + version: link:../chat + devDependencies: + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 + '@types/node': + specifier: ^25.3.2 + version: 25.3.2 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/chat: dependencies: '@workflow/serde': @@ -545,6 +573,9 @@ importers: '@chat-adapter/whatsapp': specifier: workspace:* version: link:../adapter-whatsapp + '@chat-adapter/zoom': + specifier: workspace:* + version: link:../adapter-zoom chat: specifier: workspace:* version: link:../chat diff --git a/skills/chat/SKILL.md b/skills/chat/SKILL.md index 349d9b9c..2d9bfe9c 100644 --- a/skills/chat/SKILL.md +++ b/skills/chat/SKILL.md @@ -2,20 +2,20 @@ name: chat-sdk description: > Build multi-platform chat bots with Chat SDK (`chat` npm package). Use when developers want to - (1) Build a Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, or WhatsApp bot, + (1) Build a Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, WhatsApp, or Zoom bot, (2) Use Chat SDK to handle mentions, direct messages, subscribed threads, reactions, slash commands, cards, modals, files, or AI streaming, (3) Set up webhook routes or multi-adapter bots, (4) Send rich cards or streamed AI responses to chat platforms, (5) Build or maintain a custom adapter or state adapter. Triggers on "chat sdk", "chat bot", "slack bot", "teams bot", "google chat bot", "discord bot", - "telegram bot", "whatsapp bot", "@chat-adapter", "@chat-adapter/state-", "custom adapter", + "telegram bot", "whatsapp bot", "zoom bot", "@chat-adapter", "@chat-adapter/state-", "custom adapter", "state adapter", "build adapter", and building bots that work across multiple chat platforms. --- # Chat SDK -Unified TypeScript SDK for building chat bots across Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp. Write bot logic once, deploy everywhere. +Unified TypeScript SDK for building chat bots across Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, WhatsApp, and Zoom. Write bot logic once, deploy everywhere. ## Start with published sources @@ -82,7 +82,7 @@ bot.onSubscribedMessage(async (thread, message) => { ## Core concepts - **Chat** — main entry point; coordinates adapters, routing, locks, and state -- **Adapters** — platform-specific integrations for Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp +- **Adapters** — platform-specific integrations for Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, WhatsApp, and Zoom - **State adapters** — persistence for subscriptions, locks, dedupe, and thread state - **Thread** — conversation context with `post()`, `stream()`, `subscribe()`, `setState()`, `startTyping()` - **Message** — normalized content with `text`, `formatted`, attachments, author info, and platform `raw` @@ -164,6 +164,7 @@ await thread.post( | Linear | `@chat-adapter/linear` | `createLinearAdapter` | | Telegram | `@chat-adapter/telegram` | `createTelegramAdapter` | | WhatsApp Business Cloud | `@chat-adapter/whatsapp` | `createWhatsAppAdapter` | +| Zoom | `@chat-adapter/zoom` | `createZoomAdapter` | ### Official state adapters