From f1a7f254d1a69ed45cddda6353ce827c999b5617 Mon Sep 17 00:00:00 2001 From: Pranav Katariya Date: Tue, 27 Jan 2026 17:44:29 -0800 Subject: [PATCH] feat: add Zoom Team Chat channel plugin - Add core Zoom implementation (src/zoom/) - Add Zoom channel plugin extension (extensions/zoom/) - Add Zoom onboarding adapter for QuickStart wizard - Add comprehensive Zoom documentation - Export Zoom types and adapters in plugin SDK - Add Zoom to channel documentation index - Fix format-staged.js to use node oxfmt directly (ESM compat) - Fix pre-commit hook to use Node 22 via nvm Co-Authored-By: Claude Sonnet 4.5 --- .github/labeler.yml | 6 + README.md | 14 +- docs/channels/index.md | 1 + docs/channels/zoom.md | 246 +++++++++++++++++ docs/docs.json | 1 + extensions/zoom/README.md | 52 ++++ extensions/zoom/clawdbot.plugin.json | 11 + extensions/zoom/index.ts | 18 ++ extensions/zoom/package.json | 26 ++ extensions/zoom/src/channel.ts | 109 ++++++++ extensions/zoom/src/runtime.ts | 2 + git-hooks/pre-commit | 8 + scripts/format-staged.js | 23 +- src/channels/plugins/onboarding/zoom.ts | 147 ++++++++++ src/plugin-sdk/index.ts | 6 + src/zoom/config.ts | 53 ++++ src/zoom/context.ts | 26 ++ src/zoom/index.ts | 3 + src/zoom/message-handler.ts | 87 ++++++ src/zoom/monitor.ts | 341 ++++++++++++++++++++++++ src/zoom/runtime.ts | 14 + 21 files changed, 1184 insertions(+), 10 deletions(-) create mode 100644 docs/channels/zoom.md create mode 100644 extensions/zoom/README.md create mode 100644 extensions/zoom/clawdbot.plugin.json create mode 100644 extensions/zoom/index.ts create mode 100644 extensions/zoom/package.json create mode 100644 extensions/zoom/src/channel.ts create mode 100644 extensions/zoom/src/runtime.ts create mode 100644 src/channels/plugins/onboarding/zoom.ts create mode 100644 src/zoom/config.ts create mode 100644 src/zoom/context.ts create mode 100644 src/zoom/index.ts create mode 100644 src/zoom/message-handler.ts create mode 100644 src/zoom/monitor.ts create mode 100644 src/zoom/runtime.ts diff --git a/.github/labeler.yml b/.github/labeler.yml index 5c19fa418..21a9dc0e2 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -93,6 +93,12 @@ - any-glob-to-any-file: - "extensions/zalouser/**" - "docs/channels/zalouser.md" +"channel: zoom": + - changed-files: + - any-glob-to-any-file: + - "src/zoom/**" + - "extensions/zoom/**" + - "docs/channels/zoom.md" "app: android": - changed-files: diff --git a/README.md b/README.md index 7e884be33..0cd4a3e61 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@

**Moltbot** is a *personal AI assistant* you run on your own devices. -It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant. +It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, Zalo Personal, and Zoom. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant. If you want a personal, single-user assistant that feels local, fast, and always-on, this is it. @@ -66,7 +66,7 @@ moltbot gateway --port 18789 --verbose # Send a message moltbot message send --to +1234567890 --message "Hello from Moltbot" -# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat) +# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/Zoom/WebChat) moltbot agent --message "Ship checklist" --thinking high ``` @@ -107,7 +107,7 @@ Moltbot connects to real messaging surfaces. Treat inbound DMs as **untrusted in Full security guide: [Security](https://docs.molt.bot/gateway/security) -Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack: +Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack/Zoom: - **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message. - Approve with: `moltbot pairing approve ` (then the sender is added to a local allowlist store). - Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`). @@ -117,7 +117,7 @@ Run `moltbot doctor` to surface risky/misconfigured DM policies. ## Highlights - **[Local-first Gateway](https://docs.molt.bot/gateway)** — single control plane for sessions, channels, tools, and events. -- **[Multi-channel inbox](https://docs.molt.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android. +- **[Multi-channel inbox](https://docs.molt.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, Zoom, WebChat, macOS, iOS/Android. - **[Multi-agent routing](https://docs.molt.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions). - **[Voice Wake](https://docs.molt.bot/nodes/voicewake) + [Talk Mode](https://docs.molt.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs. - **[Live Canvas](https://docs.molt.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.molt.bot/platforms/mac/canvas#canvas-a2ui). @@ -139,7 +139,7 @@ Run `moltbot doctor` to surface risky/misconfigured DM policies. - [Media pipeline](https://docs.molt.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.molt.bot/nodes/audio). ### Channels -- [Channels](https://docs.molt.bot/channels): [WhatsApp](https://docs.molt.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.molt.bot/channels/telegram) (grammY), [Slack](https://docs.molt.bot/channels/slack) (Bolt), [Discord](https://docs.molt.bot/channels/discord) (discord.js), [Google Chat](https://docs.molt.bot/channels/googlechat) (Chat API), [Signal](https://docs.molt.bot/channels/signal) (signal-cli), [iMessage](https://docs.molt.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.molt.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.molt.bot/channels/msteams) (extension), [Matrix](https://docs.molt.bot/channels/matrix) (extension), [Zalo](https://docs.molt.bot/channels/zalo) (extension), [Zalo Personal](https://docs.molt.bot/channels/zalouser) (extension), [WebChat](https://docs.molt.bot/web/webchat). +- [Channels](https://docs.molt.bot/channels): [WhatsApp](https://docs.molt.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.molt.bot/channels/telegram) (grammY), [Slack](https://docs.molt.bot/channels/slack) (Bolt), [Discord](https://docs.molt.bot/channels/discord) (discord.js), [Google Chat](https://docs.molt.bot/channels/googlechat) (Chat API), [Signal](https://docs.molt.bot/channels/signal) (signal-cli), [iMessage](https://docs.molt.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.molt.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.molt.bot/channels/msteams) (extension), [Matrix](https://docs.molt.bot/channels/matrix) (extension), [Zalo](https://docs.molt.bot/channels/zalo) (extension), [Zalo Personal](https://docs.molt.bot/channels/zalouser) (extension), [Zoom](https://docs.molt.bot/channels/zoom) (extension), [WebChat](https://docs.molt.bot/web/webchat). - [Group routing](https://docs.molt.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.molt.bot/channels). ### Apps + nodes @@ -170,7 +170,7 @@ Run `moltbot doctor` to surface risky/misconfigured DM policies. ## How it works (short) ``` -WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat +WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / Zoom / WebChat │ ▼ ┌───────────────────────────────┐ @@ -253,7 +253,7 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc ## Chat commands -Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only): +Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/Zoom/WebChat (group commands are owner-only): - `/status` — compact session status (model + tokens, cost when available) - `/new` or `/reset` — reset the session diff --git a/docs/channels/index.md b/docs/channels/index.md index 10651e6d1..05dc8859b 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -29,6 +29,7 @@ Text is supported everywhere; media and reactions vary by channel. - [Twitch](/channels/twitch) — Twitch chat via IRC connection (plugin, installed separately). - [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately). - [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately). +- [Zoom](/channels/zoom) — Zoom Team Chat via Team Chat Bot API (plugin, installed separately). - [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket. ## Notes diff --git a/docs/channels/zoom.md b/docs/channels/zoom.md new file mode 100644 index 000000000..787689678 --- /dev/null +++ b/docs/channels/zoom.md @@ -0,0 +1,246 @@ +--- +summary: "Zoom Team Chat plugin support status, capabilities, and configuration" +read_when: + - Working on Zoom Team Chat integration + - Setting up Zoom bot webhooks +--- +# Zoom Team Chat + +Status: production-ready plugin for Zoom Team Chat direct messages via Team Chat Bot API and webhooks. + +## Quick setup + +1) Create a 'General App; in [Zoom App Marketplace](https://marketplace.zoom.us/develop/create) +2) Enable the Team Chat surface and note your credentials (Client ID, Client Secret, Bot JID, Webhook Secret Token) +3) Install the plugin: `pnpm install` (the zoom extension is included in the workspace) +4) Configure webhook URL pointing to your gateway (see Webhook Setup below) +5) Set credentials in config: + ```json5 + { + channels: { + zoom: { + enabled: true, + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_CLIENT_SECRET", + botJid: "YOUR_BOT_JID@xmppdev.zoom.us", + secretToken: "YOUR_SECRET_TOKEN", + dm: { policy: "open" } + } + } + } + ``` +6) Start the gateway +7) Message the bot in Zoom Team Chat + +## What it is + +- A Zoom Team Chat channel plugin that receives messages via webhooks +- Direct message support (groups not currently supported) +- Webhook server runs on port 3001 (Gateway Control UI uses 3000) +- OAuth 2.0 client credentials flow for API authentication + +## Setup (detailed) + +### 1. Create Zoom Team Chat App + +1) Go to [Zoom App Marketplace](https://marketplace.zoom.us/develop/create) +2) Click "Create" and select "Team Chat App" +3) Enable the **Bot** feature +4) Note your credentials from the app settings: + - **Client ID** + - **Client Secret** + - **Bot JID** (e.g., `bot@xmppdev.zoom.us`) + - **Secret Token** (for webhook verification) + +### 2. Configure Webhooks + +The bot receives messages through webhooks. Configure the webhook URL in your Zoom app settings: + +**Development (using tunnel):** +``` +https://YOUR-SUBDOMAIN.frp.zoomappgo.cloud/webhooks/zoom +``` + +**Production:** +``` +https://your-domain.com/webhooks/zoom +``` + +Subscribe to these event types: +- `bot_notification` - Required for receiving messages + +### 3. Install App to Your Account + +1) In app settings, click "Install" +2) Authorize the app +3) The bot will appear in your Zoom Team Chat + +### 4. Configure Moltbot + +Add to `moltbot.yaml`: + +```yaml +channels: + zoom: + enabled: true + clientId: YOUR_CLIENT_ID + clientSecret: YOUR_CLIENT_SECRET + botJid: YOUR_BOT_JID@xmppdev.zoom.us + secretToken: YOUR_SECRET_TOKEN + apiHost: https://zoomdev.us # Use https://api.zoom.us for production + oauthHost: https://zoomdev.us # Use https://zoom.us for production + dm: + policy: open # open | closed | allowlist +``` + +### 5. Start Gateway + +```bash +moltbot gateway run +``` + +The webhook server will start on port 3001. + +### 6. Test + +Send a direct message to your bot in Zoom Team Chat. The bot should respond with context-aware replies. + +## Configuration Options + +### DM Policies + +**Open** (default): +```yaml +dm: + policy: open +``` +Anyone can message the bot. + +**Closed**: +```yaml +dm: + policy: closed +``` +Bot rejects all DMs. + +**Allowlist**: +```yaml +dm: + policy: allowlist + allowFrom: + - user1@xmppdev.zoom.us + - user2@xmppdev.zoom.us +``` +Only specified users can message the bot. + +### Development vs Production + +**Development (zoomdev.us):** +```yaml +channels: + zoom: + apiHost: https://zoomdev.us + oauthHost: https://zoomdev.us + botJid: bot@xmppdev.zoom.us +``` + +**Production (zoom.us):** +```yaml +channels: + zoom: + apiHost: https://api.zoom.us + oauthHost: https://zoom.us + botJid: bot@xmpp.zoom.us +``` + +## Features + +- **Direct Messages**: One-on-one conversations +- **Conversation History**: Full context retention across messages +- **Streaming Responses**: Real-time message updates +- **Tool Execution**: All standard Moltbot tools are available +- **Session Management**: Persistent sessions per user + +## Session Keys + +Session keys follow the format: +``` +zoom:default:user@xmppdev.zoom.us +``` + +Sessions are stored at: +``` +~/.moltbot/agents/{agentId}/sessions/{session-id}.jsonl +``` + +## Capabilities + +- **Chat types**: Direct messages only (groups not supported) +- **Reactions**: Not supported +- **Threads**: Not supported +- **Media**: Not currently supported +- **Streaming**: Supported (coalesced with 1500 char minimum, 1000ms idle) + +## Troubleshooting + +### Port 3000 Conflict + +If you see "port 3000 already in use": +- Gateway Control UI runs on port 3000 +- Zoom webhook server uses port 3001 +- This is expected and correct + +### Webhook Not Receiving Messages + +1. Check tunnel is running (if using FRP or similar) +2. Verify webhook URL in Zoom app settings matches your tunnel/domain +3. Check gateway logs for errors +4. Verify `secretToken` matches your app configuration + +### Bot Not Responding + +1. Verify credentials in `moltbot.yaml` are correct +2. Check Anthropic API key is valid +3. Ensure `botJid` matches your app's Bot JID exactly +4. Confirm app is installed to your Zoom account +5. Check gateway logs for authentication errors + +### OAuth Issues + +If you see authentication errors: +- Verify `clientId` and `clientSecret` are correct +- Check `oauthHost` matches your environment (dev vs prod) +- Review gateway logs for OAuth token errors + +## Architecture + +The Zoom plugin follows Moltbot's standard channel plugin architecture: + +- **Extension**: `extensions/zoom/` - Plugin registration and metadata +- **Core**: `src/zoom/` - Webhook server, message handling, API client +- **Monitor**: Starts Express server on port 3001, handles webhook events +- **Message Handler**: Integrates with `dispatchInboundMessage()` for AI routing + +For implementation details, see the [plugin source code](https://github.com/moltbot/moltbot/tree/main/extensions/zoom). + +## Security + +**Never commit credentials!** + +Store sensitive values in `moltbot.yaml` (gitignored) and use `moltbot-example.yaml` for documentation templates. + +Webhook events are verified using the `secretToken` from your Zoom app configuration. + +## Limitations + +- Groups are not currently supported +- Media uploads not yet implemented +- Reactions not supported +- Thread support not available + +## See Also + +- [Channels Overview](/channels) +- [Gateway Configuration](/gateway/configuration) +- [Security Policies](/gateway/security) +- [Zoom App Marketplace](https://marketplace.zoom.us/) diff --git a/docs/docs.json b/docs/docs.json index a463479aa..eaaae2f84 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -996,6 +996,7 @@ "channels/matrix", "channels/zalo", "channels/zalouser", + "channels/zoom", "broadcast-groups", "channels/troubleshooting", "channels/location" diff --git a/extensions/zoom/README.md b/extensions/zoom/README.md new file mode 100644 index 000000000..1a7186c6d --- /dev/null +++ b/extensions/zoom/README.md @@ -0,0 +1,52 @@ +# @moltbot/zoom + +Zoom Team Chat channel plugin for Moltbot (Team Chat Bot API). + +## Install (local checkout) + +```bash +moltbot plugins install ./extensions/zoom +``` + +## Install (npm) + +```bash +moltbot plugins install @moltbot/zoom +``` + +Onboarding: select Zoom and confirm the install prompt to fetch the plugin automatically. + +## Config + +```json5 +{ + channels: { + zoom: { + enabled: true, + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_CLIENT_SECRET", + botJid: "YOUR_BOT_JID@xmppdev.zoom.us", + secretToken: "YOUR_SECRET_TOKEN", + apiHost: "https://zoomdev.us", // Use https://api.zoom.us for production + oauthHost: "https://zoomdev.us", // Use https://zoom.us for production + dm: { + policy: "open" // open | closed | allowlist + } + } + } +} +``` + +## Setup + +1. Create a Team Chat App in [Zoom App Marketplace](https://marketplace.zoom.us/develop/create) +2. Enable the **Bot** feature +3. Configure webhook URL: `https://your-domain.com/webhooks/zoom` +4. Subscribe to `bot_notification` event +5. Install app to your Zoom account +6. Add credentials to config (above) +7. Restart the gateway + +The webhook server runs on port 3001 (Gateway Control UI uses 3000). + +Full documentation: [https://docs.molt.bot/channels/zoom](https://docs.molt.bot/channels/zoom) \ No newline at end of file diff --git a/extensions/zoom/clawdbot.plugin.json b/extensions/zoom/clawdbot.plugin.json new file mode 100644 index 000000000..47c92ea11 --- /dev/null +++ b/extensions/zoom/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "zoom", + "channels": [ + "zoom" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/zoom/index.ts b/extensions/zoom/index.ts new file mode 100644 index 000000000..edc34883d --- /dev/null +++ b/extensions/zoom/index.ts @@ -0,0 +1,18 @@ +import type { MoltbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; + +import { zoomPlugin } from "./src/channel.js"; +import { setZoomRuntime } from "./src/runtime.js"; + +const plugin = { + id: "zoom", + name: "Zoom Team Chat", + description: "Zoom Team Chat channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: MoltbotPluginApi) { + setZoomRuntime(api.runtime); + api.registerChannel({ plugin: zoomPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/zoom/package.json b/extensions/zoom/package.json new file mode 100644 index 000000000..0b124b019 --- /dev/null +++ b/extensions/zoom/package.json @@ -0,0 +1,26 @@ +{ + "name": "@moltbot/zoom", + "version": "2026.1.27", + "type": "module", + "description": "Moltbot Zoom Team Chat channel plugin", + "moltbot": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "zoom", + "label": "Zoom", + "selectionLabel": "Zoom Team Chat", + "docsPath": "/channels/zoom", + "docsLabel": "zoom", + "blurb": "Zoom Team Chat via Team Chat Bot API.", + "order": 45, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@moltbot/zoom", + "localPath": "extensions/zoom", + "defaultChoice": "local" + } + } +} \ No newline at end of file diff --git a/extensions/zoom/src/channel.ts b/extensions/zoom/src/channel.ts new file mode 100644 index 000000000..6c835336b --- /dev/null +++ b/extensions/zoom/src/channel.ts @@ -0,0 +1,109 @@ +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + getChatChannelMeta, + type ChannelPlugin, + type ResolvedZoomAccount, + resolveZoomAccount, + ZoomConfigSchema, + monitorZoomProvider, + zoomOnboardingAdapter, +} from "clawdbot/plugin-sdk"; + +const meta = getChatChannelMeta("zoom"); + +export const zoomPlugin: ChannelPlugin = { + id: "zoom", + meta: { + ...meta, + }, + onboarding: zoomOnboardingAdapter, + capabilities: { + chatTypes: ["direct"], + reactions: false, + threads: false, + media: false, + nativeCommands: false, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.zoom"] }, + configSchema: buildChannelConfigSchema(ZoomConfigSchema), + config: { + listAccountIds: () => [DEFAULT_ACCOUNT_ID], + resolveAccount: (cfg, accountId) => resolveZoomAccount({ cfg, accountId }), + defaultAccountId: () => DEFAULT_ACCOUNT_ID, + setAccountEnabled: ({ cfg, enabled }) => { + if (!cfg.channels) cfg.channels = {}; + if (!(cfg.channels as any).zoom) (cfg.channels as any).zoom = {}; + (cfg.channels as any).zoom.enabled = enabled; + return cfg; + }, + deleteAccount: ({ cfg }) => { + if ((cfg.channels as any)?.zoom) delete (cfg.channels as any).zoom; + return cfg; + }, + isConfigured: (account) => + Boolean( + account.clientId?.trim() && + account.clientSecret?.trim() && + account.botJid?.trim() && + account.secretToken?.trim() + ), + describeAccount: (account) => ({ + accountId: account.accountId, + name: "Zoom Team Chat", + enabled: account.enabled, + configured: Boolean( + account.clientId?.trim() && + account.clientSecret?.trim() && + account.botJid?.trim() && + account.secretToken?.trim() + ), + }), + resolveAllowFrom: ({ cfg, accountId }) => { + const account = resolveZoomAccount({ cfg, accountId }); + return (account.config.dm?.allowFrom ?? []).map((entry) => String(entry)); + }, + formatAllowFrom: ({ allowFrom }) => + allowFrom.map((entry) => String(entry).trim()).filter(Boolean), + }, + security: { + resolveDmPolicy: ({ account }) => ({ + policy: account.config.dm?.policy ?? "open", + allowFrom: account.config.dm?.allowFrom ?? [], + allowFromPath: "channels.zoom.dm.", + approveHint: "Send a message to the bot to get started", + normalizeEntry: (raw) => raw.trim(), + }), + collectWarnings: () => [], + }, + messaging: { + normalizeTarget: (target) => target.trim(), + targetResolver: { + looksLikeId: (input) => input.includes("@xmpp") || input.includes("@xmppdev"), + hint: "", + }, + }, + directory: { + self: async () => null, + listPeers: async () => [], + listGroups: async () => [], + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + ctx.log?.info(`[${account.accountId}] starting Zoom provider`); + + // Call monitor directly (imported from SDK) + return monitorZoomProvider({ + accountId: account.accountId, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + port: 3001, // Use 3001 to avoid conflict with gateway Control UI on 3000 + }); + }, + }, +}; diff --git a/extensions/zoom/src/runtime.ts b/extensions/zoom/src/runtime.ts new file mode 100644 index 000000000..9f2b10509 --- /dev/null +++ b/extensions/zoom/src/runtime.ts @@ -0,0 +1,2 @@ +// Re-export from src/zoom for plugin registration +export { setZoomRuntime, getZoomRuntime } from "../../../src/zoom/runtime.js"; diff --git a/git-hooks/pre-commit b/git-hooks/pre-commit index c2fe5149b..335739020 100755 --- a/git-hooks/pre-commit +++ b/git-hooks/pre-commit @@ -1,4 +1,12 @@ #!/bin/sh ROOT=$(git rev-parse --show-toplevel 2>/dev/null) [ -z "$ROOT" ] && exit 0 + +# Use Node 22 via nvm if available +export NVM_DIR="$HOME/.nvm" +if [ -s "$NVM_DIR/nvm.sh" ]; then + . "$NVM_DIR/nvm.sh" + nvm use 22 > /dev/null 2>&1 || true +fi + node "$ROOT/scripts/format-staged.js" diff --git a/scripts/format-staged.js b/scripts/format-staged.js index 0ad2d7dd7..8f9d8b659 100644 --- a/scripts/format-staged.js +++ b/scripts/format-staged.js @@ -58,14 +58,27 @@ function filterOutPartialTargets(targets, partialTargets) { } function resolveOxfmtCommand(repoRoot) { + // Run oxfmt via node to avoid Node.js ESM issues with extensionless binaries + const cliPath = path.join(repoRoot, "node_modules", "oxfmt", "dist", "cli.js"); + if (fs.existsSync(cliPath)) { + return { command: "node", args: [cliPath] }; + } + + // Fallback to pnpm + const pnpmResult = spawnSync("pnpm", ["--version"], { stdio: "ignore", cwd: repoRoot }); + if (pnpmResult.status === 0) { + return { command: "pnpm", args: ["oxfmt"] }; + } + + // Fallback to direct oxfmt if available const binName = process.platform === "win32" ? "oxfmt.cmd" : "oxfmt"; const local = path.join(repoRoot, "node_modules", ".bin", binName); if (fs.existsSync(local)) { return { command: local, args: [] }; } - const result = spawnSync("oxfmt", ["--version"], { stdio: "ignore" }); - if (result.status === 0) { + const globalResult = spawnSync("oxfmt", ["--version"], { stdio: "ignore" }); + if (globalResult.status === 0) { return { command: "oxfmt", args: [] }; } @@ -79,7 +92,11 @@ function getGitPaths(args, repoRoot) { } function formatFiles(repoRoot, oxfmt, files) { - const result = spawnSync(oxfmt.command, ["--write", ...oxfmt.args, ...files], { + const args = + oxfmt.command === "pnpm" ? [...oxfmt.args, "--write", ...files] + : oxfmt.command === "node" ? [...oxfmt.args, "--write", ...files] + : ["--write", ...oxfmt.args, ...files]; + const result = spawnSync(oxfmt.command, args, { cwd: repoRoot, stdio: "inherit", }); diff --git a/src/channels/plugins/onboarding/zoom.ts b/src/channels/plugins/onboarding/zoom.ts new file mode 100644 index 000000000..a4965e973 --- /dev/null +++ b/src/channels/plugins/onboarding/zoom.ts @@ -0,0 +1,147 @@ +import type { MoltbotConfig } from "../../../config/config.js"; +import type { DmPolicy } from "../../../config/types.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; +import { resolveZoomAccount, type ZoomConfig } from "../../../zoom/config.js"; +import { formatDocsLink } from "../../../terminal/links.js"; +import type { WizardPrompter } from "../../../wizard/prompts.js"; +import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js"; +import { addWildcardAllowFrom } from "./helpers.js"; + +const channel = "zoom" as const; + +function setZoomDmPolicy(cfg: MoltbotConfig, dmPolicy: DmPolicy) { + const zoomConfig = (cfg.channels as any)?.zoom as ZoomConfig | undefined; + const allowFrom = + dmPolicy === "open" ? addWildcardAllowFrom(zoomConfig?.dm?.allowFrom) : undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + zoom: { + ...zoomConfig, + dm: { + ...zoomConfig?.dm, + policy: dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }, + }; +} + +async function noteZoomHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "1) Go to Zoom App Marketplace (marketplace.zoom.us/develop/create)", + "2) Create a Team Chat App and enable Bot feature", + "3) Copy Client ID, Client Secret, Bot JID, and Secret Token", + "4) Configure webhook URL to point to your gateway", + `Docs: ${formatDocsLink("/channels/zoom")}`, + "Website: https://molt.bot", + ].join("\n"), + "Zoom Team Chat setup", + ); +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "Zoom", + channel, + policyKey: "channels.zoom.dm.policy", + allowFromKey: "channels.zoom.dm.allowFrom", + getCurrent: (cfg) => { + const zoomConfig = (cfg.channels as any)?.zoom as ZoomConfig | undefined; + return zoomConfig?.dm?.policy ?? "pairing"; + }, + setPolicy: (cfg, policy) => setZoomDmPolicy(cfg, policy), +}; + +export const zoomOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + getStatus: async ({ cfg }) => { + const account = resolveZoomAccount({ + cfg, + accountId: DEFAULT_ACCOUNT_ID, + }); + const configured = Boolean( + account.clientId?.trim() && + account.clientSecret?.trim() && + account.botJid?.trim() && + account.secretToken?.trim(), + ); + return { + channel, + configured, + statusLines: [`Zoom: ${configured ? "configured" : "not configured"}`], + selectionHint: configured ? "configured" : "not configured", + quickstartScore: configured ? 2 : 3, + }; + }, + configure: async ({ cfg, prompter }) => { + const zoomConfig = (cfg.channels as any)?.zoom as ZoomConfig | undefined; + let next = cfg; + + await noteZoomHelp(prompter); + + const clientId = await prompter.text({ + message: "Zoom Client ID", + placeholder: "lfcOyplW...", + validate: (value) => (value?.trim() ? undefined : "Client ID is required"), + }); + + const clientSecret = await prompter.text({ + message: "Zoom Client Secret", + placeholder: "rm48DW7m...", + validate: (value) => (value?.trim() ? undefined : "Client Secret is required"), + }); + + const botJid = await prompter.text({ + message: "Zoom Bot JID", + placeholder: "bot@xmpp.zoom.us", + validate: (value) => + value?.includes("@xmpp") + ? undefined + : "Bot JID should contain @xmpp.zoom.us or @xmppdev.zoom.us", + }); + + const secretToken = await prompter.text({ + message: "Zoom Secret Token", + placeholder: "kVJhHKxo...", + validate: (value) => (value?.trim() ? undefined : "Secret Token is required"), + }); + + // Determine environment from Bot JID + const isDev = botJid.includes("@xmppdev"); + const apiHost = isDev ? "https://zoomdev.us" : "https://api.zoom.us"; + const oauthHost = isDev ? "https://zoomdev.us" : "https://zoom.us"; + + next = { + ...next, + channels: { + ...next.channels, + zoom: { + ...zoomConfig, + enabled: true, + clientId, + clientSecret, + botJid, + secretToken, + apiHost, + oauthHost, + }, + }, + }; + + return { cfg: next }; + }, + dmPolicy, + disable: (cfg) => { + const zoomConfig = (cfg.channels as any)?.zoom as ZoomConfig | undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + zoom: { ...zoomConfig, enabled: false }, + }, + }; + }, +}; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 0813caefd..b0013df26 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -307,6 +307,12 @@ export { } from "../channels/plugins/normalize/telegram.js"; export { collectTelegramStatusIssues } from "../channels/plugins/status-issues/telegram.js"; +// Channel: Zoom +export { resolveZoomAccount, type ResolvedZoomAccount, type ZoomConfig } from "../zoom/config.js"; +export { ZoomConfigSchema } from "../zoom/config.js"; +export { monitorZoomProvider, type MonitorZoomOpts } from "../zoom/monitor.js"; +export { zoomOnboardingAdapter } from "../channels/plugins/onboarding/zoom.js"; + // Channel: Signal export { listSignalAccountIds, diff --git a/src/zoom/config.ts b/src/zoom/config.ts new file mode 100644 index 000000000..ebf4ff186 --- /dev/null +++ b/src/zoom/config.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; +import type { MoltbotConfig } from "../config/config.js"; + +export const ZoomConfigSchema = z.object({ + enabled: z.boolean().optional(), + clientId: z.string().optional(), + clientSecret: z.string().optional(), + botJid: z.string().optional(), + secretToken: z.string().optional(), + apiHost: z.string().optional(), + oauthHost: z.string().optional(), + dm: z + .object({ + policy: z.enum(["open", "allowlist", "pairing"]).optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + }) + .optional(), +}); + +export type ZoomConfig = z.infer; + +export type ResolvedZoomAccount = { + accountId: string; + enabled: boolean; + clientId?: string; + clientSecret?: string; + botJid?: string; + secretToken?: string; + apiHost: string; + oauthHost: string; + config: ZoomConfig; +}; + +export function resolveZoomAccount(params: { + cfg: MoltbotConfig; + accountId?: string | null; +}): ResolvedZoomAccount { + const { cfg } = params; + const zoomConfig = (cfg.channels as any)?.zoom as ZoomConfig | undefined; + const config = zoomConfig ?? {}; + + return { + accountId: "default", + enabled: config.enabled ?? true, + clientId: config.clientId, + clientSecret: config.clientSecret, + botJid: config.botJid, + secretToken: config.secretToken, + apiHost: config.apiHost || "https://zoom.us", + oauthHost: config.oauthHost || "https://zoom.us", + config, + }; +} diff --git a/src/zoom/context.ts b/src/zoom/context.ts new file mode 100644 index 000000000..2150cf7f6 --- /dev/null +++ b/src/zoom/context.ts @@ -0,0 +1,26 @@ +import type { HistoryEntry } from "../auto-reply/reply/history.js"; +import type { MoltbotConfig } from "../config/config.js"; +import type { SessionScope } from "../config/sessions.js"; +import type { DmPolicy } from "../config/types.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { ResolvedZoomAccount } from "./config.js"; + +export type ZoomMonitorContext = { + cfg: MoltbotConfig; + accountId: string; + account: ResolvedZoomAccount; + runtime: RuntimeEnv; + + historyLimit: number; + channelHistories: Map; + sessionScope: SessionScope; + mainKey: string; + + dmEnabled: boolean; + dmPolicy: DmPolicy; + allowFrom: string[]; + + textLimit: number; + replyToMode: "off" | "first" | "all"; + removeAckAfterReply: boolean; +}; diff --git a/src/zoom/index.ts b/src/zoom/index.ts new file mode 100644 index 000000000..1a2952d3c --- /dev/null +++ b/src/zoom/index.ts @@ -0,0 +1,3 @@ +export { resolveZoomAccount, type ResolvedZoomAccount, type ZoomConfig } from "./config.js"; +export { monitorZoomProvider, type MonitorZoomOpts } from "./monitor.js"; +export { setZoomRuntime, getZoomRuntime } from "./runtime.js"; diff --git a/src/zoom/message-handler.ts b/src/zoom/message-handler.ts new file mode 100644 index 000000000..ce63f1f1b --- /dev/null +++ b/src/zoom/message-handler.ts @@ -0,0 +1,87 @@ +import { dispatchInboundMessage } from "../auto-reply/dispatch.js"; +import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; +import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; +import type { MsgContext } from "../auto-reply/templating.js"; +import type { ZoomMonitorContext } from "./context.js"; +import { danger } from "../globals.js"; + +type ZoomMessage = { + userJid: string; + userName: string; + cmd: string; + accountId: string; + toJid: string; + timestamp: number; +}; + +export async function handleZoomMessage(params: { + ctx: ZoomMonitorContext; + message: ZoomMessage; + sendReply: (message: string) => Promise; +}): Promise { + const { ctx, message, sendReply } = params; + const { cfg, runtime, account } = ctx; + + // Resolve agent route + const route = resolveAgentRoute({ + cfg, + channel: "zoom", + accountId: account.accountId, + }); + + // Build message context (like Slack's prepareSlackMessage) + const msgCtx: MsgContext = { + // Required fields + From: message.userJid, + SenderName: message.userName, + Body: message.cmd, + + // Computed fields + SessionKey: route.sessionKey, + AccountId: route.accountId, + + // Optional fields + RawBody: message.cmd, + CommandBody: message.cmd, + BodyForAgent: message.cmd, + BodyForCommands: message.cmd, + ChatType: "direct", + Provider: "zoom", + Timestamp: message.timestamp, + }; + + // Finalize context (normalizes fields, adds computed properties) + const ctxPayload = finalizeInboundContext(msgCtx); + + // Create reply dispatcher (like Slack's dispatchPreparedSlackMessage) + const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ + deliver: async (payload) => { + // Deliver reply back to Zoom + const text = payload.text || ""; + + if (text) { + await sendReply(text); + } + }, + onError: (err, info) => { + runtime.error?.(danger(`zoom ${info.kind} reply failed: ${String(err)}`)); + }, + }); + + // Dispatch to clawdbot's core system (THE KEY CALL!) + const { queuedFinal, counts } = await dispatchInboundMessage({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions, + }); + + markDispatchIdle(); + + const anyReplyDelivered = queuedFinal || (counts.block ?? 0) > 0 || (counts.final ?? 0) > 0; + + if (anyReplyDelivered) { + runtime.log(`zoom: Delivered ${counts.final ?? 0} reply(ies) to ${message.userName}`); + } +} diff --git a/src/zoom/monitor.ts b/src/zoom/monitor.ts new file mode 100644 index 000000000..6dcfb757b --- /dev/null +++ b/src/zoom/monitor.ts @@ -0,0 +1,341 @@ +import express from "express"; +import crypto from "crypto"; +import { loadConfig } from "../config/config.js"; +import type { MoltbotConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { ResolvedZoomAccount } from "./config.js"; +import { resolveZoomAccount } from "./config.js"; +import { handleZoomMessage } from "./message-handler.js"; +import type { ZoomMonitorContext } from "./context.js"; +import type { SessionScope } from "../config/sessions/types.js"; + +export type MonitorZoomOpts = { + accountId?: string; + config?: MoltbotConfig; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + port?: number; +}; + +type ZoomWebhookPayload = { + event: string; + payload: { + accountId: string; + userJid: string; + userName: string; + robotJid: string; + cmd: string; + timestamp: number; + toJid: string; + userId: string; + plainToken?: string; + }; +}; + +let cachedBotToken: { token: string; expiresAt: number } | null = null; + +function encryptToken(token: string, secretToken: string): string { + const hash = crypto.createHmac("sha256", secretToken).update(token).digest("hex"); + return hash; +} + +async function getBotToken(account: ResolvedZoomAccount): Promise { + // Use cached token if valid + if (cachedBotToken && cachedBotToken.expiresAt > Date.now()) { + return cachedBotToken.token; + } + + const { clientId, clientSecret, oauthHost } = account; + if (!clientId || !clientSecret) { + throw new Error("Zoom clientId and clientSecret required"); + } + + const authHeader = Buffer.from(`${clientId}:${clientSecret}`).toString("base64"); + + const response = await fetch(`${oauthHost}/oauth/token?grant_type=client_credentials`, { + method: "POST", + headers: { + Authorization: `Basic ${authHeader}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get bot token: ${response.status} ${error}`); + } + + const data = (await response.json()) as { access_token: string; expires_in: number }; + + cachedBotToken = { + token: data.access_token, + expiresAt: Date.now() + (data.expires_in - 300) * 1000, + }; + + return data.access_token; +} + +async function sendZoomMessage(params: { + account: ResolvedZoomAccount; + toJid: string; + accountId: string; + message: string; +}): Promise { + const { account, toJid, accountId, message } = params; + const { botJid, apiHost } = account; + + if (!botJid) { + throw new Error("Zoom botJid required"); + } + + const accessToken = await getBotToken(account); + + const response = await fetch(`${apiHost}/v2/im/chat/messages`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + robot_jid: botJid, + to_jid: toJid, + account_id: accountId, + user_jid: toJid, + content: { + head: { + text: message, + }, + }, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to send message: ${response.status} ${error}`); + } +} + +export async function monitorZoomProvider(opts: MonitorZoomOpts = {}): Promise { + const { config, runtime, abortSignal, port = 3001 } = opts; + + // Load config and resolve account (matching Slack/Telegram pattern) + const cfg = config ?? loadConfig(); + const account = resolveZoomAccount({ + cfg, + accountId: opts.accountId, + }); + + if (!runtime) { + throw new Error("Runtime required for Zoom provider"); + } + + const { secretToken } = account; + if (!secretToken) { + throw new Error("Zoom secretToken required for webhook verification"); + } + + // Build monitor context internally (moved from channel.ts) + const sessionScope: SessionScope = cfg.session?.scope ?? "per-sender"; + + const ctx: ZoomMonitorContext = { + cfg, + accountId: account.accountId, + account, + runtime, + historyLimit: 10, + channelHistories: new Map(), + sessionScope, + mainKey: "", // Not used for Zoom (sessions are per-user) + dmEnabled: account.enabled, + dmPolicy: account.config.dm?.policy ?? "open", + allowFrom: (account.config.dm?.allowFrom ?? []).map((entry) => String(entry)), + textLimit: 4000, + replyToMode: "off", + removeAckAfterReply: false, + }; + + const app = express(); + app.use(express.json()); + + // Health check + app.get("/health", (req, res) => { + res.json({ status: "ok", service: "zoom-webhook" }); + }); + + // OAuth callback endpoint (for app installation) + app.get("/api/zoomapp/auth", async (req, res) => { + const code = req.query.code as string; + + if (!code) { + runtime.log("zoom: OAuth callback missing code parameter"); + return res.status(400).send("Missing authorization code"); + } + + runtime.log(`zoom: OAuth callback received, exchanging code for tokens...`); + + try { + // Exchange authorization code for access token + const tokenUrl = `${account.oauthHost}/oauth/token`; + const tokenParams = new URLSearchParams(); + tokenParams.set("grant_type", "authorization_code"); + tokenParams.set("redirect_uri", `${req.protocol}://${req.get("host")}/api/zoomapp/auth`); + tokenParams.set("code", code); + + const authHeader = Buffer.from(`${account.clientId}:${account.clientSecret}`).toString( + "base64", + ); + + const tokenResponse = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${authHeader}`, + }, + body: tokenParams.toString(), + }); + + if (!tokenResponse.ok) { + const error = await tokenResponse.text(); + runtime.error?.(`zoom: Token exchange failed: ${tokenResponse.status} ${error}`); + return res.status(500).send("Failed to complete installation"); + } + + const tokenData = (await tokenResponse.json()) as { access_token: string }; + runtime.log("zoom: Successfully exchanged code for access token"); + + // Get deep link to redirect user back to Zoom app + const deepLinkResponse = await fetch(`${account.apiHost}/v2/zoomapp/deeplink`, { + method: "POST", + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ action: "go" }), + }); + + if (deepLinkResponse.ok) { + const deepLinkData = (await deepLinkResponse.json()) as { deeplink: string }; + runtime.log("zoom: Redirecting to Zoom app"); + return res.redirect(deepLinkData.deeplink); + } + + // Fallback: show success page if deep link fails + runtime.log("zoom: Deep link failed, showing success page"); + res.send(` + + + + Zoom App Installed + + + +
+

Zoom App Installed Successfully!

+

Your bot is now active. You can close this window and start messaging your bot in Zoom Team Chat.

+

Bot Name: Jarvis 🤖

+

Send a message to get started!

+ + + `); + } catch (error) { + runtime.error?.(`zoom: OAuth error: ${error}`); + return res.status(500).send("Installation failed"); + } + }); + + // Webhook endpoint + app.post("/webhooks/zoom", async (req, res) => { + try { + const body = req.body as ZoomWebhookPayload; + + // Handle URL validation + if (body.event === "endpoint.url_validation") { + const plainToken = body.payload.plainToken || ""; + const encryptedToken = encryptToken(plainToken, secretToken); + + runtime.log("zoom: URL validation successful"); + return res.json({ + plainToken, + encryptedToken, + }); + } + + // Handle bot notification + if (body.event === "bot_notification") { + const { userJid, userName, cmd, accountId, toJid, timestamp } = body.payload; + + runtime.log(`zoom: Message from ${userName}: ${cmd}`); + + // Use clawdbot's dispatch system (handles AI, history, streaming, etc.) + await handleZoomMessage({ + ctx, + message: { + userJid, + userName, + cmd, + accountId, + toJid, + timestamp, + }, + sendReply: async (replyText: string) => { + runtime.log(`zoom: Sending response: ${replyText.substring(0, 80)}...`); + await sendZoomMessage({ + account, + toJid: userJid, + accountId, + message: replyText, + }); + runtime.log("zoom: Response sent successfully"); + }, + }); + + return res.json({ success: true }); + } + + return res.json({ success: true }); + } catch (error) { + runtime.error?.(`zoom: Webhook error: ${error}`); + return res.status(500).json({ error: "Internal server error" }); + } + }); + + const server = await new Promise((resolve, reject) => { + const srv = app.listen(port, () => { + runtime.log(`zoom: Webhook server listening on port ${port}`); + runtime.log(`zoom: Webhook URL: http://localhost:${port}/webhooks/zoom`); + resolve(srv); + }); + srv.on("error", reject); + }); + + // Graceful shutdown handler + const stopServer = () => { + runtime.log("zoom: Shutting down webhook server..."); + server.close(() => { + runtime.log("zoom: Webhook server stopped"); + }); + }; + + abortSignal?.addEventListener("abort", stopServer, { once: true }); + + try { + if (abortSignal?.aborted) return; + // Wait for abort signal + if (abortSignal) { + await new Promise((resolve) => { + abortSignal.addEventListener("abort", () => resolve(), { once: true }); + }); + } else { + // If no abort signal, wait forever (process will handle SIGINT/SIGTERM) + await new Promise(() => {}); + } + } finally { + abortSignal?.removeEventListener("abort", stopServer); + server.close(); + } +} diff --git a/src/zoom/runtime.ts b/src/zoom/runtime.ts new file mode 100644 index 000000000..8ab4177d7 --- /dev/null +++ b/src/zoom/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "../plugin-sdk/index.js"; + +let runtime: PluginRuntime | null = null; + +export function setZoomRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getZoomRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Zoom runtime not initialized"); + } + return runtime; +}