Skip to content

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

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

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

Conversation

@shishirsharma
Copy link
Copy Markdown

@shishirsharma shishirsharma commented Apr 10, 2026

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)
  • ZOOM-506645 mitigation: hex debug logging on HMAC mismatch for emoji/non-ASCII payloads

Auth

  • S2S OAuth chatbot token via client_credentials grant, 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_main_message_id
  • All outbound calls use /v2/im/chat/messages (verified against @zoom/rivet source)
  • Includes user_jid from incoming webhook context in all outbound requests

Format conversion

  • ZoomFormatConverter: bidirectional Zoom markdown ↔ mdast (handles **bold**, _italic_, `code`, __underline__, ~strikethrough~, # heading, * list)
  • Emoji placeholders converted to Unicode via convertEmojiPlaceholders
  • Styled content body segments (per-segment bold/italic via Zoom style object)

Testing

  • Unit tests covering all 25 requirements (53 tests pass)
  • 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. Happy to co-maintain.

Known limitations

  • DM thread reply subscriptions — Zoom platform does not fire chat_message.replied for 1:1 DMs
  • Live credential tests — post/edit/delete against a real Zoom workspace require manual execution
  • ZOOM-506645 — emoji/non-ASCII HMAC normalization edge case; mitigated with hex debug logging

Work in progress / follow-up

The following are known gaps being tracked for follow-up PRs:

  • onNewMention not triggered — Zoom has no separate mention event; bot_notification covers all interactions. Fix: set isMention=true on bot_notification messages
  • Markdown bold in card text — renderCard() not yet overridden; bold stripped during card-to-markdown conversion
  • App Card format — renderCard() needs to produce Zoom's structured content body (buttons, fields, sections) instead of plain text
  • Interactive events — interactive_message_actions, interactive_message_select, interactive_message_editable, bot_installed not yet handled

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
  • Manual: tested against real Zoom Marketplace app — DMs, channel slash commands, emoji rendering confirmed working

@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)
…oints and update tests

- editMessage: switch from PATCH /v2/chat/messages to PUT /v2/im/chat/messages
  (verified against @zoom/rivet editChatbotMessage endpoint)
- editMessage: no longer calls response.json() on 204 No Content
- deleteMessage: switch to DELETE /v2/im/chat/messages with robot_jid/account_id params
- Tests: update MSG-03-a/b to assert chatbot IM API URL and structured content body
- Tests: update MSG-04-a to assert correct im/chat endpoint
- Tests: fix THRD-01 decodeThreadId test — channel-level ID now returns empty messageId
Per Zoom chatbot API spec (verified against @zoom/rivet source):
- Store userJid from bot_notification and app_mention webhooks in
  threadUserJid map (keyed by threadId)
- Include user_jid in postMessage body when available
- Include user_jid in editMessage body when available
- Include user_jid as query param in deleteMessage when available

user_jid is the JID of the user who triggered the bot — required by
the Zoom chatbot API for correct message attribution and notifications
- Rewrite Marketplace app setup with accurate step-by-step navigation
  aligned with actual Zoom Marketplace UI (General App, not Account-level)
- Add Surface → Team Chat Subscription as correct location for Bot JID
- Add Features → Access → Token as correct location for Secret Token
- Add environment variables checklist with source locations
- Add ngrok local testing instructions
- Add Troubleshooting section covering common setup issues
@shishirsharma
Copy link
Copy Markdown
Author

Known gaps for future work (v2):

The adapter currently handles bot_notification and team_chat.app_mention events for text messaging. The following interactive events from Zoom's chatbot API are not yet implemented:

  • interactive_message_actions — button/action clicks
  • interactive_message_select — dropdown interactions
  • interactive_message_editable — inline message editing
  • interactive_message_fields_editable — field-level editing
  • bot_installed — bot installation lifecycle

These require implementing Zoom's App Card format (content with structured head/body components). Happy to tackle these in a follow-up PR once the base adapter is merged.

…r API)

Zoom Team Chat does not support typing indicators. Replace NotImplementedError
with a silent no-op so callers like onDirectMessage() don't need .catch() workarounds.
Zoom has no separate mention event — bot_notification fires for all bot
interactions (DMs and slash commands). Setting isMention=true ensures
onNewMention handlers fire consistently with other adapters.
@shishirsharma
Copy link
Copy Markdown
Author

shishirsharma commented Apr 11, 2026

@cramforce Great question! To clarify the scope: this adapter is Zoom Team Chat only — bots and slash commands don't work in the in-meeting chat UI, which is a current Zoom platform limitation.

However, if users have Continuous Meeting Chat (CMC) enabled, the meeting chat content syncs into a persistent Team Chat channel. In that case the bot works naturally — users can interact with it in the CMC channel using the same slash commands, and the bot can see and respond to messages that originated in the meeting chat. So CMC bridges the gap nicely for teams that have it enabled.

In short: in-meeting chat ❌, CMC channels (which surface meeting chat in Team Chat) ✅.

I'll also double-check this internally with the Zoom chat team to confirm the CMC behavior and will update if anything is different.

Consistent with bot_notification handling. team_chat.app_mention is an
explicit @mention so isMention should always be true. In practice
bot_notification covers all bot interactions but this handler is kept
as a defensive fallback.
… @zoom/rivet)

- thread.subscribe() does not work: Zoom bot_notification has no reply_to
  or parent_message_id field — confirmed against @zoom/rivet SDK which also
  has no thread reply handler
- Update conversations feature table: thread subscription = No, typing = No
- Clarify DM thread replies limitation
- replay-zoom tests now use onNewMention (not onNewMessage) to capture
  bot_notification and team_chat.app_mention events — SDK routes isMention
  messages exclusively to onNewMention handlers
- Remove unused ALL_MESSAGES_PATTERN constant
- Fix template literal lint error in markdown.ts (bullet list prefix)
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