feat: add @chat-adapter/zoom — Zoom Team Chat adapter#360
Open
shishirsharma wants to merge 28 commits intovercel:mainfrom
Open
feat: add @chat-adapter/zoom — Zoom Team Chat adapter#360shishirsharma wants to merge 28 commits intovercel:mainfrom
shishirsharma wants to merge 28 commits intovercel:mainfrom
Conversation
Contributor
|
@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)
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)
373e875 to
16f51a8
Compare
| 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" }); |
Contributor
Comment on lines
+257
to
+260
| it("THRD-01: decodeThreadId throws ValidationError on missing messageId", () => { | ||
| expect(() => adapter.decodeThreadId("zoom:only-one-part")).toThrow( | ||
| ValidationError | ||
| ); |
Contributor
There was a problem hiding this comment.
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.
| { method: "PATCH", body: JSON.stringify({ message: text }) }, | ||
| "editMessage" | ||
| ); | ||
| const data = (await response.json()) as Record<string, unknown>; |
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
@chat-adapter/zoomas a first-party platform adapter alongside Slack, Teams, and Google Chatapps/docs/adapters.jsonandskills/chat/SKILL.mdWhat's included
Webhook verification
endpoint.url_validation) with HMAC-SHA256 responsex-zm-signatureheader and raw body capture (timing-safe comparison)Auth
Inbound
bot_notificationandteam_chat.app_mentionevents into normalized SDKMessageobjectszoom:{channelId}:{messageId}formatOutbound
thread.post(),thread.edit(),thread.delete(), and threaded replies viareply_toFormat conversion
ZoomFormatConverter: bidirectional Zoom markdown ↔ mdast (handles**bold**,_italic_,`code`,__underline__,~strikethrough~,# heading,* list)__underline__handled via link-sentinel pattern (no mdast underline node type)Testing
bot_notification,team_chat.app_mention)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
ChatSDK 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
chat_message.repliedfor 1:1 DMs; documented in README and codeTest plan
pnpm validate— all 17 tasks pass (build, typecheck, lint, test, docs build)bot_notificationandteam_chat.app_mentionfixturespnpm knip— no unused exports or dependencies