Skip to content

feat: add @chat-adapter/zoom — Zoom Team Chat adapter#360

Open
shishirsharma wants to merge 28 commits intovercel:mainfrom
zoom:feat/zoom-adapter
Open

feat: add @chat-adapter/zoom — Zoom Team Chat adapter#360
shishirsharma wants to merge 28 commits intovercel:mainfrom
zoom:feat/zoom-adapter

Conversation

@shishirsharma
Copy link
Copy Markdown

Summary

  • Adds @chat-adapter/zoom as a first-party platform adapter alongside Slack, Teams, and Google Chat
  • Registers Zoom in apps/docs/adapters.json and skills/chat/SKILL.md
  • Includes changeset for minor release

What's included

Webhook verification

  • CRC URL validation challenge (endpoint.url_validation) with HMAC-SHA256 response
  • Signature verification via x-zm-signature header and raw body capture (timing-safe comparison)

Auth

  • S2S OAuth chatbot token fetched and cached in-memory with 1-hour TTL

Inbound

  • Parses bot_notification and team_chat.app_mention events into normalized SDK Message objects
  • Thread IDs follow zoom:{channelId}:{messageId} format

Outbound

  • thread.post(), thread.edit(), thread.delete(), and threaded replies via reply_to

Format conversion

  • ZoomFormatConverter: bidirectional Zoom markdown ↔ mdast (handles **bold**, _italic_, `code`, __underline__, ~strikethrough~, # heading, * list)
  • Non-standard __underline__ handled via link-sentinel pattern (no mdast underline node type)

Testing

  • Unit tests covering all 25 requirements
  • Integration test replay fixtures (bot_notification, team_chat.app_mention)
  • Subscribe-flow integration test (THRD-02)

Why

Zoom Team Chat has 8M+ business users. This completes the major enterprise messaging platforms alongside Slack, Teams, and Google Chat. Developers can build Zoom bots using the same Chat SDK API they already use for other platforms — no platform-specific code in the application layer.

I work at Zoom. This adapter is built and tested against real Zoom Marketplace app credentials and I'm happy to co-maintain it going forward.

Known limitations

  • DM thread reply subscriptions — Zoom platform does not fire chat_message.replied for 1:1 DMs; documented in README and code
  • Live credential tests — post/edit/delete against a real Zoom workspace require manual execution (Zoom Marketplace app + credentials)
  • ZOOM-506645 — emoji/non-ASCII HMAC normalization edge case; mitigated with hex debug logging on mismatch

Test plan

  • pnpm validate — all 17 tasks pass (build, typecheck, lint, test, docs build)
  • Unit tests: webhook verification, event parsing, message sending, format conversion
  • Integration replay tests: bot_notification and team_chat.app_mention fixtures
  • Subscribe flow integration test
  • pnpm knip — no unused exports or dependencies

@vercel
Copy link
Copy Markdown
Contributor

vercel bot commented Apr 10, 2026

@shishirsharma is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

- Create packages/adapter-zoom/ with package.json, tsconfig.json,
  tsup.config.ts, vitest.config.ts modeled on adapter-whatsapp
- Add @zoom/rivet@^0.4.0 as dependency (0.3.0 doesn't exist; 0.4.0 is latest)
- Add .npmrc routing @zoom scope to public npm registry (Zoom internal
  Artifactory blocks tarball downloads in this environment)
- pnpm install succeeds with all 19 workspace packages resolved

[Rule 1 - Bug] @zoom/rivet@0.3.0 does not exist; updated to ^0.4.0
[Rule 3 - Blocker] Added .npmrc @zoom:registry override for npm access
- src/types.ts: export ZoomAdapterConfig, ZoomAdapterInternalConfig,
  ZoomCrcPayload, ZoomWebhookPayload with sorted interface members
- src/index.ts: ZoomAdapter class stub implementing full Adapter interface
  with NotImplementedError for unimplemented methods; createZoomAdapter
  factory validates all 5 required credentials at construction time
- src/index.test.ts: 14 it.todo() stubs covering WBHK-01/02/03 and
  AUTH-01/02/03/04 behaviors for Plans 02 and 03 to fill in
- pnpm typecheck: exit 0, no errors
- pnpm test: 14 todo, 0 failures
- WBHK-01: CRC challenge returns { plainToken, encryptedToken } with HTTP 200
- WBHK-02: tampered signature returns 401
- WBHK-02: missing signature returns 401
- WBHK-02: stale timestamp >5 minutes returns 401
- WBHK-03: valid signature passes (non-401 response)
…er (GREEN)

- handleWebhook: captures raw body via request.text() before JSON.parse (WBHK-03)
- CRC endpoint.url_validation handled BEFORE signature check (WBHK-01)
- verifySignature: HMAC-SHA256 via timingSafeEqual, 5-minute staleness check (WBHK-02)
- ZOOM-506645 mitigation: log body hex on timingSafeEqual length-mismatch throws
- processEvent stub ready for Phase 2 event routing
- Add public getAccessToken() with in-memory cache (60s early-expiry buffer)
- Use raw fetch with Basic auth for client_credentials grant
- All 14 Phase 1 unit tests pass (5 webhook + 4 auth + 5 factory)
- cachedToken private field with expiresAt comparison
- Fix import order and formatting in index.test.ts (ultracite check --write)
- Add @zoom/rivet to knip ignoreDependencies (reserved for Phase 3 message sending)
- pnpm validate exits 0: knip clean, check clean, all 14 zoom tests pass
- dist/index.js (7KB) and dist/index.d.ts (3.2KB) built successfully
- 3 describe blocks covering toAst, fromAst, and round-trip scenarios
- 17 it.todo stubs (no assertions yet — Plan 02 fills them in)
- Zero test failures; Vitest reports all 17 as todo
…WBHK-05, THRD-01, THRD-02, THRD-03)

- Add ZoomBotNotificationPayload, ZoomAppMentionPayload, ZoomThreadId types to types.ts
- Implement encodeThreadId/decodeThreadId with ValidationError on bad input
- Implement initialize() to store ChatInstance
- Implement processEvent() routing: bot_notification and team_chat.app_mention handlers
- Add DM thread reply limitation comment (ZOOM PLATFORM LIMITATION)
- Add 10 new tests covering THRD-01, WBHK-04, WBHK-05, THRD-02, THRD-03
- Add @types/mdast devDependency for UnderlineNode type definition
- All 24 tests pass, pnpm validate exits 0
…erage

- New markdown.ts with ZoomFormatConverter extending BaseFormatConverter
- toAst(): converts __underline__ (link-sentinel approach) and ~strikethrough~ (single→double tilde) to mdast nodes
- fromAst(): converts underline node to __text__ (html inline), ~~strikethrough~~ post-processed to ~strikethrough~
- All 17 FMT-01/FMT-02/FMT-03 tests pass (replacing it.todo stubs)
- Round-trips for __underline__ and ~strikethrough~ verified
- Add formatConverter field (ZoomFormatConverter) to ZoomAdapter class
- Replace temporary parseMarkdown() calls in handleBotNotification and handleAppMention with this.formatConverter.toAst()
- Implement renderFormatted() using this.formatConverter.fromAst() — no longer throws NotImplementedError
- Remove parseMarkdown import (no longer needed); add Root type import
- Fix type comparison for custom underline node in fromAst() to avoid TS2367
- All 41 tests pass, pnpm validate exits 0
…MSG-01, MSG-02)

- Add describe block "ZoomAdapter — postMessage (MSG-01, MSG-02)" with 6 test cases
- MSG-01-a/b/c: channel and DM routing, RawMessage return shape
- MSG-02-a/b: replyTo adds reply_main_message_id; DM+replyTo logs THRD-03 warning
- zoomFetch-error: non-2xx throws Error with operation and status code
- All 6 tests fail RED with NotImplementedError (TDD phase confirmed)
…Chat API (MSG-01, MSG-02)

- Add private zoomFetch() that fetches Bearer token, sets headers, throws descriptive Error on non-2xx
- Replace postMessage() stub: calls POST /v2/chat/users/{robotJid}/messages with to_jid routing
- Support threaded replies via reply_main_message_id (MSG-02) with THRD-03 debug warning for DMs
- Return RawMessage with id (from message_id field), threadId, and raw Zoom API response
- All 30 tests pass; pnpm validate succeeds
…-03, MSG-04)

- MSG-03: editMessage PATCH URL, Bearer auth, body, RawMessage return, error handling
- MSG-04: deleteMessage DELETE URL, Bearer auth, void return, error handling
…-04)

- editMessage: PATCH /v2/chat/messages/{messageId} with message text, returns RawMessage
- deleteMessage: DELETE /v2/chat/messages/{messageId}, returns void
- No to_jid in edit/delete body per locked decision
- No body in DELETE options per Pitfall 4
- Move error regex patterns to top level to satisfy Biome useTopLevelRegex rule
- Prefix unused threadId param with _ in deleteMessage per Biome noUnusedParameters
…-tests

- Add "@chat-adapter/zoom": "workspace:*" to integration-tests/package.json dependencies
- Add "@chat-adapter/zoom" to VALID_PACKAGE_README_IMPORTS in documentation-test-utils.ts
- Create fixtures/replay/zoom/zoom.json with botNotification and appMention payloads
- Create zoom-utils.ts with ZOOM_CREDENTIALS, createZoomWebhookRequest (HMAC v0:seconds:body), and setupZoomFetchMock
- Create replay-zoom.test.ts with 4 passing tests for both event types (bot_notification and team_chat.app_mention)
- Documents Zoom Marketplace setup (Server-to-Server OAuth + Chatbot app)
- Covers all env vars, required scopes, and configuration options
- Includes Known Limitations for DM thread replies and Unicode HMAC bug
- TypeScript code blocks validated by package-readmes.test.ts (16/16 pass)
- [Rule 1 - Bug] Add userName to ZoomAdapterConfig/ZoomAdapterInternalConfig and wire
  it in createZoomAdapter() factory with ZOOM_BOT_USERNAME env fallback
- [Rule 3 - Blocking] Remove export from ZOOM_WEBHOOK_SECRET in zoom-utils.ts (knip unused)
- [Rule 3 - Blocking] Extract /.*/ regex to top-level constant in replay-zoom.test.ts
- [Rule 3 - Blocking] Apply ultracite formatter fixes to zoom-utils.ts and replay-zoom.test.ts
- Change line 118 from zoom:{userJid}:{userJid} to zoom:{userJid}:{event_ts}
- Matches implementation in index.ts:226-229 where messageId = String(eventTs)
- Add third describe block to replay-zoom.test.ts verifying thread.subscribe()
  stores state and onSubscribedMessage fires with the Zoom adapter
- Use handleIncomingMessage() for follow-up to avoid dedup (Zoom assigns
  per-message thread IDs via event_ts; replay approach hits SDK deduplication)
- Import Message class (not just type) to construct the follow-up message
…o types.ts

- Export ZoomMessageWithReply interface with JSDoc (MSG-02 named cast target)
- Add JSDoc to accountId in ZoomAdapterInternalConfig explaining why it is not
  sent in the S2S OAuth token request body
…eply in postMessage()

- Add ZoomMessageWithReply to import in index.ts; re-export for consumers
- Replace (message as { metadata?: { replyTo?: string } }) with named cast to ZoomMessageWithReply
- Update MSG-02-a and MSG-02-b tests to use ZoomMessageWithReply typed variable
- Auto-fix linting: sort interface members, format call site
- Remove @zoom/rivet from packages/adapter-zoom/package.json (Phase 3 used raw fetch instead)
- Remove @zoom/rivet from knip.json ignoreDependencies
- Update pnpm-lock.yaml
- Fix trailing newline in .planning/config.json (Rule 3 - blocking issue for pnpm validate)
@cramforce
Copy link
Copy Markdown
Collaborator

@shishirsharma This is great. Thanks. What is the chance that this is usable to join zoom calls? I looked at the API a while ago and it wasn't possible

- Add isDM() method to ZoomAdapter for correct DM detection
- Switch postMessage to /v2/im/chat/messages (client_credentials endpoint)
- Handle channel-level thread IDs in decodeThreadId (no messageId)
- Guard against empty text in postMessage (Zoom rejects empty messages)
- Add renderPostable override to convert emoji placeholders to Unicode
- Add toZoomContentBody() to ZoomFormatConverter — converts mdast to
  Zoom's content body array format with per-segment bold/italic styles
- postMessage now uses structured content body instead of markdown string
- Heading blocks render as bold segments; list items get bullet prefix
- Mixed bold/plain paragraphs render as plain text (Zoom limitation:
  each content.body item renders on its own line, no inline mixed styles)
- Add renderPostable override with convertEmojiPlaceholders (Unicode)
afterEach(() => vi.restoreAllMocks());

it("MSG-01-a: postMessage to channel JID calls POST to correct URL with Bearer token and to_jid", async () => {
const fetchMock = mockFetch(200, { message_id: "msg-uuid-123" });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests MSG-01-a and MSG-01-b assert the old Zoom chat API URL and request body format, which no longer matches the implementation after it was changed to use the chatbot IM API.

Fix on Vercel

Comment on lines +257 to +260
it("THRD-01: decodeThreadId throws ValidationError on missing messageId", () => {
expect(() => adapter.decodeThreadId("zoom:only-one-part")).toThrow(
ValidationError
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
it("THRD-01: decodeThreadId throws ValidationError on missing messageId", () => {
expect(() => adapter.decodeThreadId("zoom:only-one-part")).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: "",
});

Test expects decodeThreadId("zoom:only-one-part") to throw ValidationError, but the implementation was changed to return { channelId, messageId: "" } for channel-level IDs, causing the test to fail.

Fix on Vercel

{ method: "PATCH", body: JSON.stringify({ message: text }) },
"editMessage"
);
const data = (await response.json()) as Record<string, unknown>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

editMessage uses the wrong Zoom API endpoint (user-level /v2/chat/messages/ instead of chatbot /v2/im/chat/messages/) and unconditionally calls response.json() which crashes on 204 No Content responses.

Fix on Vercel

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants