diff --git a/.github/labeler.yml b/.github/labeler.yml index 5c19fa418..88cd8873f 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -77,6 +77,11 @@ - changed-files: - any-glob-to-any-file: - "extensions/voice-call/**" +"channel: zoom": + - changed-files: + - any-glob-to-any-file: + - "extensions/zoom/**" + - "docs/channels/zoom.md" "channel: whatsapp-web": - changed-files: - any-glob-to-any-file: diff --git a/extensions/zoom/index.ts b/extensions/zoom/index.ts new file mode 100644 index 000000000..db86d7798 --- /dev/null +++ b/extensions/zoom/index.ts @@ -0,0 +1,20 @@ +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"; + +export { monitorZoomProvider } from "./src/monitor.js"; + +const plugin = { + id: "zoom", + name: "Zoom Team Chat", + description: "Zoom Team Chat channel plugin (S2S OAuth)", + configSchema: emptyPluginConfigSchema(), + register(api: MoltbotPluginApi) { + setZoomRuntime(api.runtime); + api.registerChannel({ plugin: zoomPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/zoom/moltbot.plugin.json b/extensions/zoom/moltbot.plugin.json new file mode 100644 index 000000000..47c92ea11 --- /dev/null +++ b/extensions/zoom/moltbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "zoom", + "channels": [ + "zoom" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/zoom/package.json b/extensions/zoom/package.json new file mode 100644 index 000000000..0694c8a14 --- /dev/null +++ b/extensions/zoom/package.json @@ -0,0 +1,32 @@ +{ + "name": "@moltbot/zoom", + "version": "2026.1.27-beta.1", + "type": "module", + "description": "Moltbot Zoom Team Chat channel plugin", + "moltbot": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "zoom", + "label": "Zoom Team Chat", + "selectionLabel": "Zoom Team Chat (S2S OAuth)", + "docsPath": "/channels/zoom", + "docsLabel": "zoom", + "blurb": "S2S OAuth; enterprise messaging.", + "aliases": [], + "order": 70 + }, + "install": { + "npmSpec": "@moltbot/zoom", + "localPath": "extensions/zoom", + "defaultChoice": "npm" + } + }, + "dependencies": { + "express": "^5.2.1" + }, + "devDependencies": { + "moltbot": "workspace:*" + } +} diff --git a/extensions/zoom/src/api.ts b/extensions/zoom/src/api.ts new file mode 100644 index 000000000..337774eb0 --- /dev/null +++ b/extensions/zoom/src/api.ts @@ -0,0 +1,145 @@ +import type { ZoomCredentials } from "./types.js"; +import { getZoomAccessToken } from "./token.js"; + +export type ZoomApiOptions = { + method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; + body?: unknown; + headers?: Record; +}; + +export type ZoomApiResponse = { + ok: boolean; + status: number; + data?: T; + error?: string; +}; + +const ZOOM_API_BASE = "https://api.zoom.us/v2"; + +/** + * Make an authenticated request to the Zoom API. + */ +export async function zoomApiFetch( + creds: ZoomCredentials, + endpoint: string, + options: ZoomApiOptions = {}, +): Promise> { + const accessToken = await getZoomAccessToken(creds); + + const url = endpoint.startsWith("http") ? endpoint : `${ZOOM_API_BASE}${endpoint}`; + + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + ...options.headers, + }; + + const fetchOptions: RequestInit = { + method: options.method ?? "GET", + headers, + }; + + if (options.body !== undefined) { + fetchOptions.body = JSON.stringify(options.body); + } + + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + return { + ok: false, + status: response.status, + error: text || `HTTP ${response.status}`, + }; + } + + // Handle empty responses (204 No Content, etc.) + const contentLength = response.headers.get("content-length"); + if (contentLength === "0" || response.status === 204) { + return { ok: true, status: response.status }; + } + + try { + const data = (await response.json()) as T; + return { ok: true, status: response.status, data }; + } catch { + return { ok: true, status: response.status }; + } +} + +export type ZoomSendMessageParams = { + /** Robot JID (bot's JID) */ + robotJid: string; + /** Recipient JID (user or channel) */ + toJid: string; + /** Account ID */ + accountId: string; + /** Message content */ + content: { + head?: { + text?: string; + sub_head?: { text?: string }; + }; + body?: Array<{ + type: "message" | "attachments" | "section" | "actions"; + text?: string; + resource_url?: string; + information?: { title?: { text?: string }; description?: { text?: string } }; + }>; + }; + /** Optional: whether this is a channel message */ + isChannel?: boolean; + /** Optional: reply to a specific message */ + replyMainMessageId?: string; +}; + +export type ZoomSendMessageResponse = { + message_id: string; +}; + +/** + * Send a message via Zoom Team Chat API. + * https://developers.zoom.us/docs/team-chat-apps/send-a-chat-message/ + */ +export async function sendZoomMessage( + creds: ZoomCredentials, + params: ZoomSendMessageParams, +): Promise> { + const body: Record = { + robot_jid: params.robotJid, + to_jid: params.toJid, + account_id: params.accountId, + content: params.content, + }; + + // user_jid is required for DMs but not for channels + if (!params.isChannel) { + body.user_jid = params.toJid; + } + + if (params.replyMainMessageId) { + body.reply_main_message_id = params.replyMainMessageId; + } + + return zoomApiFetch(creds, "/im/chat/messages", { + method: "POST", + body, + }); +} + +export type ZoomBotInfo = { + robot_jid: string; + display_name: string; +}; + +/** + * Get bot info to verify credentials. + */ +export async function getZoomBotInfo( + creds: ZoomCredentials, +): Promise> { + // There's no direct "get bot info" endpoint, but we can verify by trying to + // get user info or just validating the token works + return zoomApiFetch(creds, "/users/me"); +} diff --git a/extensions/zoom/src/channel.ts b/extensions/zoom/src/channel.ts new file mode 100644 index 000000000..be63832aa --- /dev/null +++ b/extensions/zoom/src/channel.ts @@ -0,0 +1,259 @@ +import type { ChannelPlugin, MoltbotConfig } from "clawdbot/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "clawdbot/plugin-sdk"; + +import { zoomOnboardingAdapter } from "./onboarding.js"; +import { zoomOutbound } from "./outbound.js"; +import { probeZoom } from "./probe.js"; +import { resolveZoomGroupToolPolicy } from "./policy.js"; +import { sendZoomTextMessage } from "./send.js"; +import { resolveZoomCredentials } from "./token.js"; +import type { ZoomConfig } from "./types.js"; + +type ResolvedZoomAccount = { + accountId: string; + enabled: boolean; + configured: boolean; +}; + +const meta = { + id: "zoom", + label: "Zoom Team Chat", + selectionLabel: "Zoom Team Chat (S2S OAuth)", + docsPath: "/channels/zoom", + docsLabel: "zoom", + blurb: "S2S OAuth; enterprise messaging.", + aliases: [], + order: 70, +} as const; + +export const zoomPlugin: ChannelPlugin = { + id: "zoom", + meta: { + ...meta, + }, + onboarding: zoomOnboardingAdapter, + pairing: { + idLabel: "zoomUserJid", + normalizeAllowEntry: (entry) => entry.replace(/^(zoom|user):/i, ""), + notifyApproval: async ({ cfg, id }) => { + await sendZoomTextMessage({ + cfg, + to: id, + text: PAIRING_APPROVED_MESSAGE, + }); + }, + }, + capabilities: { + chatTypes: ["direct", "channel"], + threads: false, + media: false, + }, + groups: { + resolveToolPolicy: resolveZoomGroupToolPolicy, + }, + reload: { configPrefixes: ["channels.zoom"] }, + config: { + listAccountIds: () => [DEFAULT_ACCOUNT_ID], + resolveAccount: (cfg) => { + const zoomCfg = cfg.channels?.zoom as ZoomConfig | undefined; + return { + accountId: DEFAULT_ACCOUNT_ID, + enabled: zoomCfg?.enabled !== false, + configured: Boolean(resolveZoomCredentials(zoomCfg)), + }; + }, + defaultAccountId: () => DEFAULT_ACCOUNT_ID, + setAccountEnabled: ({ cfg, enabled }) => { + const zoomCfg = cfg.channels?.zoom as ZoomConfig | undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + zoom: { + ...zoomCfg, + enabled, + }, + }, + }; + }, + deleteAccount: ({ cfg }) => { + const next = { ...cfg } as MoltbotConfig; + const nextChannels = { ...cfg.channels }; + delete (nextChannels as Record).zoom; + if (Object.keys(nextChannels).length > 0) { + next.channels = nextChannels; + } else { + delete next.channels; + } + return next; + }, + isConfigured: (_account, cfg) => { + const zoomCfg = cfg.channels?.zoom as ZoomConfig | undefined; + return Boolean(resolveZoomCredentials(zoomCfg)); + }, + describeAccount: (account) => ({ + accountId: account.accountId, + enabled: account.enabled, + configured: account.configured, + }), + resolveAllowFrom: ({ cfg }) => { + const zoomCfg = cfg.channels?.zoom as ZoomConfig | undefined; + return zoomCfg?.allowFrom ?? []; + }, + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.toLowerCase()), + }, + security: { + collectWarnings: ({ cfg }) => { + const zoomCfg = cfg.channels?.zoom as ZoomConfig | undefined; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = zoomCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy !== "open") return []; + return [ + `- Zoom groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.zoom.groupPolicy="allowlist" + channels.zoom.groupAllowFrom to restrict senders.`, + ]; + }, + }, + setup: { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg }) => { + const zoomCfg = cfg.channels?.zoom as ZoomConfig | undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + zoom: { + ...zoomCfg, + enabled: true, + }, + }, + }; + }, + }, + messaging: { + normalizeTarget: (raw) => { + const trimmed = raw.trim(); + if (!trimmed) return null; + // Remove common prefixes + if (/^(zoom|user|channel):/i.test(trimmed)) { + return trimmed.replace(/^(zoom|user|channel):/i, "").trim(); + } + return trimmed; + }, + targetResolver: { + looksLikeId: (raw) => { + const trimmed = raw.trim(); + if (!trimmed) return false; + // Zoom JIDs typically contain @xmpp.zoom.us + if (trimmed.includes("@xmpp.zoom.us")) return true; + // Or look like user:/channel: prefixed + if (/^(user|channel):/i.test(trimmed)) return true; + return false; + }, + hint: "", + }, + }, + directory: { + self: async () => null, + listPeers: async ({ cfg, query, limit }) => { + const zoomCfg = cfg.channels?.zoom as ZoomConfig | undefined; + const q = query?.trim().toLowerCase() || ""; + const ids = new Set(); + for (const entry of zoomCfg?.allowFrom ?? []) { + const trimmed = String(entry).trim(); + if (trimmed && trimmed !== "*") ids.add(trimmed); + } + for (const userId of Object.keys(zoomCfg?.dms ?? {})) { + const trimmed = userId.trim(); + if (trimmed) ids.add(trimmed); + } + return Array.from(ids) + .map((raw) => raw.trim()) + .filter(Boolean) + .map((raw) => { + const lowered = raw.toLowerCase(); + if (lowered.startsWith("user:")) return raw; + return `user:${raw}`; + }) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "user", id }) as const); + }, + listGroups: async ({ cfg, query, limit }) => { + const zoomCfg = cfg.channels?.zoom as ZoomConfig | undefined; + const q = query?.trim().toLowerCase() || ""; + const ids = new Set(); + for (const channelId of Object.keys(zoomCfg?.channels ?? {})) { + const trimmed = channelId.trim(); + if (trimmed && trimmed !== "*") ids.add(trimmed); + } + return Array.from(ids) + .map((raw) => raw.trim()) + .filter(Boolean) + .map((raw) => raw.replace(/^channel:/i, "").trim()) + .map((id) => `channel:${id}`) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "group", id }) as const); + }, + }, + outbound: zoomOutbound, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + port: null, + }, + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + port: snapshot.port ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ cfg }) => { + const zoomCfg = cfg.channels?.zoom as ZoomConfig | undefined; + return await probeZoom(zoomCfg); + }, + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + enabled: account.enabled, + configured: account.configured, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + port: runtime?.port ?? null, + probe, + }), + }, + gateway: { + startAccount: async (ctx) => { + const { monitorZoomProvider } = await import("./monitor.js"); + const zoomCfg = ctx.cfg.channels?.zoom as ZoomConfig | undefined; + const port = zoomCfg?.webhook?.port ?? 4000; + ctx.setStatus({ + accountId: ctx.accountId, + port, + running: true, + lastStartAt: Date.now(), + lastError: null, + }); + ctx.log?.info(`starting provider (port ${port})`); + return monitorZoomProvider({ + cfg: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + }); + }, + }, +}; diff --git a/extensions/zoom/src/conversation-store-fs.ts b/extensions/zoom/src/conversation-store-fs.ts new file mode 100644 index 000000000..e25e42213 --- /dev/null +++ b/extensions/zoom/src/conversation-store-fs.ts @@ -0,0 +1,146 @@ +import type { + ZoomConversationStore, + ZoomConversationStoreEntry, + StoredConversationReference, +} from "./conversation-store.js"; +import { resolveZoomStorePath } from "./storage.js"; +import { readJsonFile, withFileLock, writeJsonFile } from "./store-fs.js"; + +type ConversationStoreData = { + version: 1; + conversations: Record; +}; + +const STORE_FILENAME = "zoom-conversations.json"; +const MAX_CONVERSATIONS = 1000; +const CONVERSATION_TTL_MS = 365 * 24 * 60 * 60 * 1000; + +function parseTimestamp(value: string | undefined): number | null { + if (!value) return null; + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return null; + return parsed; +} + +function pruneToLimit( + conversations: Record, +) { + const entries = Object.entries(conversations); + if (entries.length <= MAX_CONVERSATIONS) return conversations; + + entries.sort((a, b) => { + const aTs = parseTimestamp(a[1].lastSeenAt) ?? 0; + const bTs = parseTimestamp(b[1].lastSeenAt) ?? 0; + return aTs - bTs; + }); + + const keep = entries.slice(entries.length - MAX_CONVERSATIONS); + return Object.fromEntries(keep); +} + +function pruneExpired( + conversations: Record, + nowMs: number, + ttlMs: number, +) { + let removed = false; + const kept: typeof conversations = {}; + for (const [conversationId, reference] of Object.entries(conversations)) { + const lastSeenAt = parseTimestamp(reference.lastSeenAt); + // Preserve legacy entries that have no lastSeenAt until they're seen again. + if (lastSeenAt != null && nowMs - lastSeenAt > ttlMs) { + removed = true; + continue; + } + kept[conversationId] = reference; + } + return { conversations: kept, removed }; +} + +export function createZoomConversationStoreFs(params?: { + env?: NodeJS.ProcessEnv; + homedir?: () => string; + ttlMs?: number; + stateDir?: string; + storePath?: string; +}): ZoomConversationStore { + const ttlMs = params?.ttlMs ?? CONVERSATION_TTL_MS; + const filePath = resolveZoomStorePath({ + filename: STORE_FILENAME, + env: params?.env, + homedir: params?.homedir, + stateDir: params?.stateDir, + storePath: params?.storePath, + }); + + const empty: ConversationStoreData = { version: 1, conversations: {} }; + + const readStore = async (): Promise => { + const { value } = await readJsonFile(filePath, empty); + if ( + value.version !== 1 || + !value.conversations || + typeof value.conversations !== "object" || + Array.isArray(value.conversations) + ) { + return empty; + } + const nowMs = Date.now(); + const pruned = pruneExpired(value.conversations, nowMs, ttlMs).conversations; + return { version: 1, conversations: pruneToLimit(pruned) }; + }; + + const list = async (): Promise => { + const store = await readStore(); + return Object.entries(store.conversations).map(([conversationId, reference]) => ({ + conversationId, + reference, + })); + }; + + const get = async (conversationId: string): Promise => { + const store = await readStore(); + return store.conversations[conversationId] ?? null; + }; + + const findByUserJid = async (jid: string): Promise => { + const target = jid.trim(); + if (!target) return null; + for (const entry of await list()) { + const { conversationId, reference } = entry; + if (reference.userJid === target) { + return { conversationId, reference }; + } + } + return null; + }; + + const upsert = async ( + conversationId: string, + reference: StoredConversationReference, + ): Promise => { + await withFileLock(filePath, empty, async () => { + const store = await readStore(); + store.conversations[conversationId] = { + ...reference, + lastSeenAt: new Date().toISOString(), + }; + const nowMs = Date.now(); + store.conversations = pruneExpired(store.conversations, nowMs, ttlMs).conversations; + store.conversations = pruneToLimit(store.conversations); + await writeJsonFile(filePath, store); + }); + }; + + const remove = async (conversationId: string): Promise => { + return await withFileLock(filePath, empty, async () => { + const store = await readStore(); + if (!(conversationId in store.conversations)) return false; + delete store.conversations[conversationId]; + await writeJsonFile(filePath, store); + return true; + }); + }; + + return { upsert, get, list, remove, findByUserJid }; +} diff --git a/extensions/zoom/src/conversation-store.ts b/extensions/zoom/src/conversation-store.ts new file mode 100644 index 000000000..c9f5e9415 --- /dev/null +++ b/extensions/zoom/src/conversation-store.ts @@ -0,0 +1,41 @@ +/** + * Conversation store for Zoom Team Chat proactive messaging. + * + * Stores conversation references keyed by channel/user JID so we can + * send proactive messages later. + */ + +/** Stored conversation reference for proactive messaging */ +export type StoredConversationReference = { + /** User JID (sender) */ + userJid?: string; + /** User display name */ + userName?: string; + /** User email */ + userEmail?: string; + /** Channel JID (for channel messages) */ + channelJid?: string; + /** Channel name */ + channelName?: string; + /** Account ID */ + accountId?: string; + /** Robot JID (bot's JID) */ + robotJid?: string; + /** Conversation type: "direct" or "channel" */ + conversationType?: "direct" | "channel"; + /** Last message ID */ + lastMessageId?: string; +}; + +export type ZoomConversationStoreEntry = { + conversationId: string; + reference: StoredConversationReference; +}; + +export type ZoomConversationStore = { + upsert: (conversationId: string, reference: StoredConversationReference) => Promise; + get: (conversationId: string) => Promise; + list: () => Promise; + remove: (conversationId: string) => Promise; + findByUserJid: (jid: string) => Promise; +}; diff --git a/extensions/zoom/src/errors.ts b/extensions/zoom/src/errors.ts new file mode 100644 index 000000000..38e9b0aae --- /dev/null +++ b/extensions/zoom/src/errors.ts @@ -0,0 +1,152 @@ +export function formatUnknownError(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === "string") return err; + if (err === null) return "null"; + if (err === undefined) return "undefined"; + if (typeof err === "number" || typeof err === "boolean" || typeof err === "bigint") { + return String(err); + } + if (typeof err === "symbol") return err.description ?? err.toString(); + if (typeof err === "function") { + return err.name ? `[function ${err.name}]` : "[function]"; + } + try { + return JSON.stringify(err) ?? "unknown error"; + } catch { + return "unknown error"; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function extractStatusCode(err: unknown): number | null { + if (!isRecord(err)) return null; + const direct = err.statusCode ?? err.status; + if (typeof direct === "number" && Number.isFinite(direct)) return direct; + if (typeof direct === "string") { + const parsed = Number.parseInt(direct, 10); + if (Number.isFinite(parsed)) return parsed; + } + + const response = err.response; + if (isRecord(response)) { + const status = response.status; + if (typeof status === "number" && Number.isFinite(status)) return status; + if (typeof status === "string") { + const parsed = Number.parseInt(status, 10); + if (Number.isFinite(parsed)) return parsed; + } + } + + return null; +} + +function extractRetryAfterMs(err: unknown): number | null { + if (!isRecord(err)) return null; + + const direct = err.retryAfterMs ?? err.retry_after_ms; + if (typeof direct === "number" && Number.isFinite(direct) && direct >= 0) { + return direct; + } + + const retryAfter = err.retryAfter ?? err.retry_after; + if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) { + return retryAfter >= 0 ? retryAfter * 1000 : null; + } + if (typeof retryAfter === "string") { + const parsed = Number.parseFloat(retryAfter); + if (Number.isFinite(parsed) && parsed >= 0) return parsed * 1000; + } + + const response = err.response; + if (!isRecord(response)) return null; + + const headers = response.headers; + if (!headers) return null; + + if (isRecord(headers)) { + const raw = headers["retry-after"] ?? headers["Retry-After"]; + if (typeof raw === "string") { + const parsed = Number.parseFloat(raw); + if (Number.isFinite(parsed) && parsed >= 0) return parsed * 1000; + } + } + + if ( + typeof headers === "object" && + headers !== null && + "get" in headers && + typeof (headers as { get?: unknown }).get === "function" + ) { + const raw = (headers as { get: (name: string) => string | null }).get("retry-after"); + if (raw) { + const parsed = Number.parseFloat(raw); + if (Number.isFinite(parsed) && parsed >= 0) return parsed * 1000; + } + } + + return null; +} + +export type ZoomSendErrorKind = "auth" | "throttled" | "transient" | "permanent" | "unknown"; + +export type ZoomSendErrorClassification = { + kind: ZoomSendErrorKind; + statusCode?: number; + retryAfterMs?: number; +}; + +/** + * Classify outbound send errors for safe retries and actionable logs. + */ +export function classifyZoomSendError(err: unknown): ZoomSendErrorClassification { + const statusCode = extractStatusCode(err); + const retryAfterMs = extractRetryAfterMs(err); + + if (statusCode === 401 || statusCode === 403) { + return { kind: "auth", statusCode }; + } + + if (statusCode === 429) { + return { + kind: "throttled", + statusCode, + retryAfterMs: retryAfterMs ?? undefined, + }; + } + + if (statusCode === 408 || (statusCode != null && statusCode >= 500)) { + return { + kind: "transient", + statusCode, + retryAfterMs: retryAfterMs ?? undefined, + }; + } + + if (statusCode != null && statusCode >= 400) { + return { kind: "permanent", statusCode }; + } + + return { + kind: "unknown", + statusCode: statusCode ?? undefined, + retryAfterMs: retryAfterMs ?? undefined, + }; +} + +export function formatZoomSendErrorHint( + classification: ZoomSendErrorClassification, +): string | undefined { + if (classification.kind === "auth") { + return "check zoom clientId/clientSecret/accountId (or env vars ZOOM_CLIENT_ID/ZOOM_CLIENT_SECRET/ZOOM_ACCOUNT_ID)"; + } + if (classification.kind === "throttled") { + return "Zoom throttled the bot; backing off may help"; + } + if (classification.kind === "transient") { + return "transient Zoom API error; retry may succeed"; + } + return undefined; +} diff --git a/extensions/zoom/src/monitor-handler.ts b/extensions/zoom/src/monitor-handler.ts new file mode 100644 index 000000000..866ecd0bb --- /dev/null +++ b/extensions/zoom/src/monitor-handler.ts @@ -0,0 +1,355 @@ +import type { MoltbotConfig, RuntimeEnv, GroupPolicy } from "clawdbot/plugin-sdk"; + +import type { ZoomConversationStore } from "./conversation-store.js"; +import { formatUnknownError } from "./errors.js"; +import type { ZoomMonitorLogger } from "./monitor-types.js"; +import { isZoomGroupAllowed, resolveZoomAllowlistMatch, resolveZoomReplyPolicy } from "./policy.js"; +import type { ZoomConfig, ZoomCredentials, ZoomWebhookEvent } from "./types.js"; +import { getZoomRuntime } from "./runtime.js"; + +export type ZoomMessageHandlerDeps = { + cfg: MoltbotConfig; + runtime: RuntimeEnv; + creds: ZoomCredentials; + textLimit: number; + conversationStore: ZoomConversationStore; + log: ZoomMonitorLogger; +}; + +/** + * Extract mention of the bot from message text. + * Zoom mentions format: @ or uses robot_jid in payload + */ +function extractBotMention(params: { + text: string; + botJid: string; + robotJidInPayload?: string; +}): { mentioned: boolean; cleanText: string } { + const { text, botJid, robotJidInPayload } = params; + + // Check if robot_jid in payload matches our bot + if (robotJidInPayload && robotJidInPayload === botJid) { + return { mentioned: true, cleanText: text.trim() }; + } + + // Clean up any @mention patterns (Zoom uses @DisplayName format) + // The bot might be mentioned as @BotName - we'll preserve the text as-is + // since the mention check already passed via robot_jid + return { mentioned: false, cleanText: text.trim() }; +} + +export function createZoomMessageHandler(deps: ZoomMessageHandlerDeps) { + const { cfg, runtime, creds, textLimit, conversationStore, log } = deps; + const zoomCfg = cfg.channels?.zoom as ZoomConfig | undefined; + const core = getZoomRuntime(); + + return async (event: ZoomWebhookEvent) => { + const eventType = event.event; + + log.debug("received webhook event", { event: eventType }); + + // Handle bot notification (slash commands / direct messages to bot) + if (eventType === "bot_notification") { + await handleBotNotification(event); + return; + } + + // Handle channel mentions (when bot is @mentioned in a channel) + if (eventType === "chat_message.posted" || eventType === "team_chat.app_mention") { + await handleChannelMessage(event); + return; + } + + log.info(`ignoring unhandled event type: ${eventType}`); + }; + + async function handleBotNotification(event: ZoomWebhookEvent) { + // Debug: log full event structure + log.info(`bot_notification event: ${JSON.stringify(event)}`); + + const payload = event.payload?.object ?? event.payload; + if (!payload) { + log.debug("bot_notification missing payload object"); + return; + } + + const userJid = payload.userJid ?? payload.operator; + const userName = payload.userName ?? payload.user_name ?? payload.operator; + const userEmail = payload.user_email; + const toJid = payload.toJid; + const channelName = payload.channelName; + // For bot notifications, text comes from payload.text or payload.cmd + const messageText = payload.text ?? payload.cmd ?? ""; + + if (!userJid || !messageText) { + log.debug("bot_notification missing required fields", { userJid, hasMessage: Boolean(messageText) }); + return; + } + + // Detect if this is a channel message (toJid contains @conference.) + const isChannelMessage = toJid?.includes("@conference.") ?? false; + const conversationId = isChannelMessage ? toJid : userJid; + + log.debug("processing bot notification", { + userJid, + userName, + toJid, + isChannelMessage, + textLength: messageText.length, + }); + + if (isChannelMessage) { + // Channel message - check group policy + const groupPolicy: GroupPolicy = zoomCfg?.groupPolicy ?? "open"; + + if (groupPolicy === "disabled") { + log.debug("group policy disabled, ignoring channel message"); + return; + } + + // Store channel conversation reference + await conversationStore.upsert(toJid, { + channelJid: toJid, + channelName, + robotJid: creds.botJid, + accountId: creds.accountId, + conversationType: "channel", + }); + + // Route to agent with channel context + await routeToAgent({ + conversationId: toJid, + senderId: userJid, + senderName: userName, + senderEmail: userEmail, + text: messageText, + isDirect: false, + channelJid: toJid, + channelName, + }); + } else { + // Direct message - check DM policy + const dmPolicy = zoomCfg?.dmPolicy ?? "pairing"; + const allowFrom = zoomCfg?.allowFrom ?? []; + + if (dmPolicy === "disabled") { + log.debug("dm policy disabled, ignoring message"); + return; + } + + if (dmPolicy !== "open") { + const match = resolveZoomAllowlistMatch({ + allowFrom, + senderId: userJid, + senderName: userName, + senderEmail: userEmail, + }); + + if (!match.allowed) { + log.debug("sender not in allowlist", { userJid, userName }); + return; + } + } + + // Store DM conversation reference + await conversationStore.upsert(userJid, { + userJid, + userName, + userEmail, + robotJid: creds.botJid, + accountId: creds.accountId, + conversationType: "direct", + }); + + // Route to agent + await routeToAgent({ + conversationId: userJid, + senderId: userJid, + senderName: userName, + senderEmail: userEmail, + text: messageText, + isDirect: true, + }); + } + } + + async function handleChannelMessage(event: ZoomWebhookEvent) { + const payload = event.payload?.object; + if (!payload) { + log.debug("channel message missing payload object"); + return; + } + + const channelJid = payload.channel_jid; + const channelName = payload.channel_name; + const message = payload.message; + const senderId = message?.sender ?? message?.sender_member_id; + const senderName = message?.sender_display_name; + const robotJidInPayload = message?.robot_jid ?? payload.robot_jid; + const messageText = message?.message ?? ""; + const messageId = message?.id; + + if (!channelJid || !senderId || !messageText) { + log.debug("channel message missing required fields", { + hasChannel: Boolean(channelJid), + hasSender: Boolean(senderId), + hasMessage: Boolean(messageText), + }); + return; + } + + log.debug("processing channel message", { + channelJid, + channelName, + senderId, + textLength: messageText.length, + }); + + // Check group policy + const groupPolicy: GroupPolicy = zoomCfg?.groupPolicy ?? "allowlist"; + const groupAllowFrom = zoomCfg?.groupAllowFrom ?? []; + + if (!isZoomGroupAllowed({ + groupPolicy, + allowFrom: groupAllowFrom, + senderId, + senderName, + })) { + log.debug("sender not allowed in group", { senderId, senderName, groupPolicy }); + return; + } + + // Check mention requirement + const replyPolicy = resolveZoomReplyPolicy({ + isDirectMessage: false, + globalConfig: zoomCfg, + }); + + const { mentioned, cleanText } = extractBotMention({ + text: messageText, + botJid: creds.botJid, + robotJidInPayload, + }); + + if (replyPolicy.requireMention && !mentioned) { + log.debug("message does not mention bot, ignoring", { channelJid }); + return; + } + + // Store conversation reference + await conversationStore.upsert(channelJid, { + channelJid, + channelName, + robotJid: creds.botJid, + accountId: creds.accountId, + conversationType: "channel", + lastMessageId: messageId, + }); + + // Route to Moltbot agent + await routeToAgent({ + conversationId: channelJid, + senderId, + senderName, + text: cleanText, + isDirect: false, + channelJid, + channelName, + replyToMessageId: messageId, + }); + } + + async function routeToAgent(params: { + conversationId: string; + senderId: string; + senderName?: string; + senderEmail?: string; + text: string; + isDirect: boolean; + channelJid?: string; + channelName?: string; + replyToMessageId?: string; + }) { + try { + const { conversationId, senderId, senderName, text, isDirect, channelJid, channelName } = params; + + log.debug("routing to agent", { + conversationId, + senderId, + isDirect, + textLength: text.length, + }); + + // Resolve the agent route + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "zoom", + chatType: isDirect ? "direct" : "channel", + from: senderId, + to: conversationId, + groupId: channelJid, + }); + + // Build finalized inbound context + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: text, + RawBody: text, + CommandBody: text, + From: isDirect ? `zoom:${senderId}` : `zoom:channel:${channelJid}`, + To: conversationId, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isDirect ? "direct" : "channel", + ConversationLabel: senderName ?? senderId, + SenderName: senderName, + SenderId: senderId, + GroupSubject: isDirect ? undefined : channelName, + GroupChannel: isDirect ? undefined : channelJid, + Provider: "zoom" as const, + Surface: "zoom" as const, + CommandAuthorized: true, + CommandSource: "text" as const, + OriginatingChannel: "zoom" as const, + OriginatingTo: conversationId, + }); + + // Create reply dispatcher with delivery function + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + deliver: async (payload) => { + const { sendZoomTextMessage } = await import("./send.js"); + if (payload.text) { + await sendZoomTextMessage({ + cfg, + to: conversationId, + text: payload.text, + isChannel: !isDirect, + }); + } + }, + onError: (err, info) => { + const errMsg = err instanceof Error ? err.message : String(err); + log.error(`zoom ${info.kind} reply failed: ${errMsg}`); + }, + }); + + // Dispatch to agent + const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions, + }); + + markDispatchIdle(); + + if (queuedFinal) { + const finalCount = counts.final; + log.info(`delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${conversationId}`); + } + } catch (err) { + log.error("failed to route to agent", { error: formatUnknownError(err) }); + } + } +} diff --git a/extensions/zoom/src/monitor-types.ts b/extensions/zoom/src/monitor-types.ts new file mode 100644 index 000000000..49b776c79 --- /dev/null +++ b/extensions/zoom/src/monitor-types.ts @@ -0,0 +1,7 @@ +/** Logger interface for Zoom webhook monitor */ +export type ZoomMonitorLogger = { + debug: (message: string, context?: Record) => void; + info: (message: string, context?: Record) => void; + warn: (message: string, context?: Record) => void; + error: (message: string, context?: Record) => void; +}; diff --git a/extensions/zoom/src/monitor.ts b/extensions/zoom/src/monitor.ts new file mode 100644 index 000000000..20a3e6d30 --- /dev/null +++ b/extensions/zoom/src/monitor.ts @@ -0,0 +1,184 @@ +import type { Request, Response } from "express"; +import type { MoltbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk"; + +import type { ZoomConversationStore } from "./conversation-store.js"; +import { createZoomConversationStoreFs } from "./conversation-store-fs.js"; +import { formatUnknownError } from "./errors.js"; +import { createZoomMessageHandler } from "./monitor-handler.js"; +import type { ZoomMonitorLogger } from "./monitor-types.js"; +import { resolveZoomCredentials } from "./token.js"; +import type { ZoomConfig, ZoomCredentials } from "./types.js"; +import { handleZoomChallenge, verifyZoomWebhook } from "./webhook.js"; +import { getZoomRuntime } from "./runtime.js"; + +export type MonitorZoomOpts = { + cfg: MoltbotConfig; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + conversationStore?: ZoomConversationStore; +}; + +export type MonitorZoomResult = { + app: unknown; + shutdown: () => Promise; +}; + +export async function monitorZoomProvider( + opts: MonitorZoomOpts, +): Promise { + const core = getZoomRuntime(); + const log = core.logging.getChildLogger({ name: "zoom" }); + const cfg = opts.cfg; + const zoomCfg = cfg.channels?.zoom as ZoomConfig | undefined; + + if (!zoomCfg?.enabled) { + log.debug("zoom provider disabled"); + return { app: null, shutdown: async () => {} }; + } + + const creds = resolveZoomCredentials(zoomCfg); + if (!creds) { + log.error("zoom credentials not configured"); + return { app: null, shutdown: async () => {} }; + } + + const runtime: RuntimeEnv = opts.runtime ?? { + log: console.log, + error: console.error, + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }; + + const port = zoomCfg.webhook?.port ?? 4000; + const webhookPath = zoomCfg.webhook?.path ?? "/zoom/webhook"; + const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "zoom"); + const conversationStore = opts.conversationStore ?? createZoomConversationStoreFs(); + + log.info(`starting provider (port ${port})`); + + // Dynamic import to avoid loading express when provider is disabled + const express = await import("express"); + + const expressApp = express.default(); + + // Custom body parser to get raw body for signature verification + expressApp.use( + webhookPath, + express.json({ + verify: (req: Request, _res: Response, buf: Buffer) => { + // Store raw body for signature verification + (req as Request & { rawBody?: string }).rawBody = buf.toString("utf8"); + }, + }), + ); + + // Also add general JSON parser for other routes + expressApp.use(express.json()); + + const handleMessage = createZoomMessageHandler({ + cfg, + runtime, + creds, + textLimit, + conversationStore, + log: log as ZoomMonitorLogger, + }); + + // Webhook endpoint + expressApp.post(webhookPath, async (req: Request, res: Response) => { + try { + const rawBody = (req as Request & { rawBody?: string }).rawBody ?? JSON.stringify(req.body); + const signature = req.headers["x-zm-signature"] as string | undefined; + const timestamp = req.headers["x-zm-request-timestamp"] as string | undefined; + + // Handle URL validation challenge + if (req.body?.event === "endpoint.url_validation") { + const plainToken = req.body.payload?.plainToken; + if (plainToken && creds.webhookSecretToken) { + const challenge = handleZoomChallenge({ + plainToken, + secret: creds.webhookSecretToken, + }); + log.debug("responding to URL validation challenge"); + res.status(200).json(challenge); + return; + } + log.warn("URL validation received but missing plainToken or secret"); + res.status(400).json({ error: "missing challenge data" }); + return; + } + + // Verify webhook signature if secret is configured + if (creds.webhookSecretToken) { + if (!signature || !timestamp) { + log.warn("missing webhook signature headers"); + res.status(401).json({ error: "missing signature" }); + return; + } + + const valid = verifyZoomWebhook({ + payload: rawBody, + signature, + timestamp, + secret: creds.webhookSecretToken, + }); + + if (!valid) { + log.warn("invalid webhook signature"); + res.status(401).json({ error: "invalid signature" }); + return; + } + } + + // Acknowledge webhook immediately + res.status(200).json({ status: "ok" }); + + // Process message asynchronously + await handleMessage(req.body); + } catch (err) { + log.error("webhook handler failed", { error: formatUnknownError(err) }); + if (!res.headersSent) { + res.status(500).json({ error: "internal error" }); + } + } + }); + + // Health check endpoint + expressApp.get("/health", (_req: Request, res: Response) => { + res.status(200).json({ status: "ok", channel: "zoom" }); + }); + + log.debug("listening on path", { path: webhookPath }); + + // Return a promise that stays pending until shutdown + return new Promise((resolve) => { + const httpServer = expressApp.listen(port, () => { + log.info(`zoom provider started on port ${port}`); + }); + + httpServer.on("error", (err) => { + log.error("zoom server error", { error: String(err) }); + }); + + const shutdown = async () => { + log.info("shutting down zoom provider"); + return new Promise((resolveShutdown) => { + httpServer.close((err) => { + if (err) { + log.debug("zoom server close error", { error: String(err) }); + } + resolveShutdown(); + resolve({ app: expressApp, shutdown }); + }); + }); + }; + + // Handle abort signal - this is the only way the provider stops + if (opts.abortSignal) { + opts.abortSignal.addEventListener("abort", () => { + void shutdown(); + }); + } + }); +} diff --git a/extensions/zoom/src/onboarding.ts b/extensions/zoom/src/onboarding.ts new file mode 100644 index 000000000..285e35e1a --- /dev/null +++ b/extensions/zoom/src/onboarding.ts @@ -0,0 +1,345 @@ +import type { + ChannelOnboardingAdapter, + ChannelOnboardingDmPolicy, + MoltbotConfig, + DmPolicy, + WizardPrompter, +} from "clawdbot/plugin-sdk"; +import { + addWildcardAllowFrom, + DEFAULT_ACCOUNT_ID, + formatDocsLink, + promptChannelAccessConfig, +} from "clawdbot/plugin-sdk"; + +import { resolveZoomCredentials } from "./token.js"; +import type { ZoomConfig } from "./types.js"; + +const channel = "zoom" as const; + +function setZoomDmPolicy(cfg: MoltbotConfig, dmPolicy: DmPolicy) { + const zoomCfg = cfg.channels?.zoom as ZoomConfig | undefined; + const allowFrom = + dmPolicy === "open" + ? addWildcardAllowFrom(zoomCfg?.allowFrom)?.map((entry) => String(entry)) + : undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + zoom: { + ...zoomCfg, + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }; +} + +function setZoomAllowFrom(cfg: MoltbotConfig, allowFrom: string[]): MoltbotConfig { + const zoomCfg = cfg.channels?.zoom as ZoomConfig | undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + zoom: { + ...zoomCfg, + allowFrom, + }, + }, + }; +} + +function parseAllowFromInput(raw: string): string[] { + return raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +async function promptZoomAllowFrom(params: { + cfg: MoltbotConfig; + prompter: WizardPrompter; +}): Promise { + const zoomCfg = params.cfg.channels?.zoom as ZoomConfig | undefined; + const existing = zoomCfg?.allowFrom ?? []; + await params.prompter.note( + [ + "Allowlist Zoom Team Chat DMs by user JID or email.", + "Examples:", + "- user@example.com", + "- abcd1234@xmpp.zoom.us", + ].join("\n"), + "Zoom allowlist", + ); + + while (true) { + const entry = await params.prompter.text({ + message: "Zoom allowFrom (user JIDs or emails)", + placeholder: "user@example.com", + initialValue: existing[0] ? String(existing[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const parts = parseAllowFromInput(String(entry)); + if (parts.length === 0) { + await params.prompter.note("Enter at least one user.", "Zoom allowlist"); + continue; + } + + const unique = [ + ...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...parts]), + ]; + return setZoomAllowFrom(params.cfg, unique); + } +} + +async function noteZoomCredentialHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "1) Create a Team Chat app in Zoom Marketplace", + "2) Enable Team Chat feature to get Bot JID", + "3) Add required scopes: imchat:bot", + "4) Copy Client ID, Client Secret, Account ID, Bot JID", + "5) Configure webhook URL and secret token", + "Tip: you can also set ZOOM_CLIENT_ID / ZOOM_CLIENT_SECRET / ZOOM_ACCOUNT_ID / ZOOM_BOT_JID.", + `Docs: ${formatDocsLink("/channels/zoom", "zoom")}`, + ].join("\n"), + "Zoom credentials", + ); +} + +/** Prompt user for all Zoom OAuth credentials */ +async function promptZoomCredentials(prompter: WizardPrompter): Promise<{ + clientId: string; + clientSecret: string; + accountId: string; + botJid: string; +}> { + const clientId = String( + await prompter.text({ + message: "Enter Zoom Client ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + const clientSecret = String( + await prompter.text({ + message: "Enter Zoom Client Secret", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + const accountId = String( + await prompter.text({ + message: "Enter Zoom Account ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + const botJid = String( + await prompter.text({ + message: "Enter Zoom Bot JID", + placeholder: "xxx@xmpp.zoom.us", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + return { clientId, clientSecret, accountId, botJid }; +} + +function setZoomGroupPolicy( + cfg: MoltbotConfig, + groupPolicy: "open" | "allowlist" | "disabled", +): MoltbotConfig { + const zoomCfg = cfg.channels?.zoom as ZoomConfig | undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + zoom: { + ...zoomCfg, + enabled: true, + groupPolicy, + }, + }, + }; +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "Zoom", + channel, + policyKey: "channels.zoom.dmPolicy", + allowFromKey: "channels.zoom.allowFrom", + getCurrent: (cfg) => (cfg.channels?.zoom as ZoomConfig | undefined)?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setZoomDmPolicy(cfg, policy), + promptAllowFrom: promptZoomAllowFrom, +}; + +export const zoomOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + getStatus: async ({ cfg }) => { + const zoomCfg = cfg.channels?.zoom as ZoomConfig | undefined; + const configured = Boolean(resolveZoomCredentials(zoomCfg)); + return { + channel, + configured, + statusLines: [`Zoom: ${configured ? "configured" : "needs app credentials"}`], + selectionHint: configured ? "configured" : "needs app creds", + quickstartScore: configured ? 2 : 0, + }; + }, + configure: async ({ cfg, prompter }) => { + const zoomCfg = cfg.channels?.zoom as ZoomConfig | undefined; + const resolved = resolveZoomCredentials(zoomCfg); + const hasConfigCreds = Boolean( + zoomCfg?.clientId?.trim() && + zoomCfg?.clientSecret?.trim() && + zoomCfg?.accountId?.trim() && + zoomCfg?.botJid?.trim(), + ); + const canUseEnv = Boolean( + !hasConfigCreds && + process.env.ZOOM_CLIENT_ID?.trim() && + process.env.ZOOM_CLIENT_SECRET?.trim() && + process.env.ZOOM_ACCOUNT_ID?.trim() && + process.env.ZOOM_BOT_JID?.trim(), + ); + + let next = cfg; + let clientId: string | null = null; + let clientSecret: string | null = null; + let accountId: string | null = null; + let botJid: string | null = null; + let webhookSecretToken: string | null = null; + + if (!resolved) { + await noteZoomCredentialHelp(prompter); + } + + if (canUseEnv) { + const keepEnv = await prompter.confirm({ + message: + "ZOOM_CLIENT_ID + ZOOM_CLIENT_SECRET + ZOOM_ACCOUNT_ID + ZOOM_BOT_JID detected. Use env vars?", + initialValue: true, + }); + if (keepEnv) { + next = { + ...next, + channels: { + ...next.channels, + zoom: { ...(next.channels?.zoom as ZoomConfig), enabled: true }, + }, + }; + } else { + const creds = await promptZoomCredentials(prompter); + clientId = creds.clientId; + clientSecret = creds.clientSecret; + accountId = creds.accountId; + botJid = creds.botJid; + } + } else if (hasConfigCreds) { + const keep = await prompter.confirm({ + message: "Zoom credentials already configured. Keep them?", + initialValue: true, + }); + if (!keep) { + const creds = await promptZoomCredentials(prompter); + clientId = creds.clientId; + clientSecret = creds.clientSecret; + accountId = creds.accountId; + botJid = creds.botJid; + } + } else { + const creds = await promptZoomCredentials(prompter); + clientId = creds.clientId; + clientSecret = creds.clientSecret; + accountId = creds.accountId; + botJid = creds.botJid; + } + + // Optionally prompt for webhook secret + const wantsWebhookSecret = await prompter.confirm({ + message: "Configure webhook secret token for signature verification?", + initialValue: true, + }); + if (wantsWebhookSecret) { + webhookSecretToken = String( + await prompter.text({ + message: "Enter Zoom Webhook Secret Token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + + if (clientId && clientSecret && accountId && botJid) { + next = { + ...next, + channels: { + ...next.channels, + zoom: { + ...(next.channels?.zoom as ZoomConfig), + enabled: true, + clientId, + clientSecret, + accountId, + botJid, + ...(webhookSecretToken ? { webhookSecretToken } : {}), + }, + }, + }; + } else if (webhookSecretToken) { + next = { + ...next, + channels: { + ...next.channels, + zoom: { + ...(next.channels?.zoom as ZoomConfig), + webhookSecretToken, + }, + }, + }; + } + + // Configure channel access + const nextZoomCfg = next.channels?.zoom as ZoomConfig | undefined; + const currentEntries = Object.keys(nextZoomCfg?.channels ?? {}); + const accessConfig = await promptChannelAccessConfig({ + prompter, + label: "Zoom channels", + currentPolicy: nextZoomCfg?.groupPolicy ?? "allowlist", + currentEntries, + placeholder: "channel-jid, channel-name", + updatePrompt: Boolean(nextZoomCfg?.channels), + }); + if (accessConfig) { + if (accessConfig.policy !== "allowlist") { + next = setZoomGroupPolicy(next, accessConfig.policy); + } else { + const channels: Record = {}; + for (const entry of accessConfig.entries) { + channels[entry] = {}; + } + next = setZoomGroupPolicy(next, "allowlist"); + next = { + ...next, + channels: { + ...next.channels, + zoom: { + ...(next.channels?.zoom as ZoomConfig), + channels, + }, + }, + }; + } + } + + return { cfg: next, accountId: DEFAULT_ACCOUNT_ID }; + }, + dmPolicy, + disable: (cfg) => { + const zoomCfg = cfg.channels?.zoom as ZoomConfig | undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + zoom: { ...zoomCfg, enabled: false }, + }, + }; + }, +}; diff --git a/extensions/zoom/src/outbound.ts b/extensions/zoom/src/outbound.ts new file mode 100644 index 000000000..5728c2f72 --- /dev/null +++ b/extensions/zoom/src/outbound.ts @@ -0,0 +1,27 @@ +import type { ChannelOutboundAdapter } from "clawdbot/plugin-sdk"; + +import { getZoomRuntime } from "./runtime.js"; +import { sendZoomTextMessage } from "./send.js"; + +export const zoomOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + chunker: (text, limit) => getZoomRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + + sendText: async ({ cfg, to, text, deps }) => { + const send = + deps?.sendZoom ?? + ((to: string, text: string) => sendZoomTextMessage({ cfg, to, text })); + const result = await send(to, text); + return { channel: "zoom", ...result }; + }, + + sendMedia: async ({ cfg, to, text, mediaUrl }) => { + // Zoom Team Chat has limited inline media support + // Send media URL as a link in the message + const mediaText = mediaUrl ? `${text ? `${text}\n\n` : ""}${mediaUrl}` : text; + const result = await sendZoomTextMessage({ cfg, to, text: mediaText }); + return { channel: "zoom", ...result }; + }, +}; diff --git a/extensions/zoom/src/policy.test.ts b/extensions/zoom/src/policy.test.ts new file mode 100644 index 000000000..78c5a3d83 --- /dev/null +++ b/extensions/zoom/src/policy.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect } from "vitest"; +import { + resolveZoomAllowlistMatch, + resolveZoomReplyPolicy, + isZoomGroupAllowed, +} from "./policy.js"; + +describe("resolveZoomAllowlistMatch", () => { + it("returns allowed=false for empty allowFrom", () => { + const result = resolveZoomAllowlistMatch({ + allowFrom: [], + senderId: "user@xmpp.zoom.us", + }); + expect(result.allowed).toBe(false); + }); + + it("matches wildcard", () => { + const result = resolveZoomAllowlistMatch({ + allowFrom: ["*"], + senderId: "user@xmpp.zoom.us", + }); + expect(result).toEqual({ + allowed: true, + matchKey: "*", + matchSource: "wildcard", + }); + }); + + it("matches by sender id", () => { + const result = resolveZoomAllowlistMatch({ + allowFrom: ["user@xmpp.zoom.us"], + senderId: "user@xmpp.zoom.us", + }); + expect(result).toEqual({ + allowed: true, + matchKey: "user@xmpp.zoom.us", + matchSource: "id", + }); + }); + + it("matches by sender id case-insensitively", () => { + const result = resolveZoomAllowlistMatch({ + allowFrom: ["USER@XMPP.ZOOM.US"], + senderId: "user@xmpp.zoom.us", + }); + expect(result).toEqual({ + allowed: true, + matchKey: "user@xmpp.zoom.us", + matchSource: "id", + }); + }); + + it("matches by sender name", () => { + const result = resolveZoomAllowlistMatch({ + allowFrom: ["John Doe"], + senderId: "user@xmpp.zoom.us", + senderName: "John Doe", + }); + expect(result).toEqual({ + allowed: true, + matchKey: "john doe", + matchSource: "name", + }); + }); + + it("matches by sender email", () => { + const result = resolveZoomAllowlistMatch({ + allowFrom: ["john@example.com"], + senderId: "user@xmpp.zoom.us", + senderEmail: "john@example.com", + }); + expect(result).toEqual({ + allowed: true, + matchKey: "john@example.com", + matchSource: "email", + }); + }); + + it("returns allowed=false when no match", () => { + const result = resolveZoomAllowlistMatch({ + allowFrom: ["other@xmpp.zoom.us"], + senderId: "user@xmpp.zoom.us", + senderName: "John Doe", + senderEmail: "john@example.com", + }); + expect(result.allowed).toBe(false); + }); + + it("handles numeric entries in allowFrom", () => { + const result = resolveZoomAllowlistMatch({ + allowFrom: [12345], + senderId: "12345", + }); + expect(result.allowed).toBe(true); + }); +}); + +describe("resolveZoomReplyPolicy", () => { + it("returns requireMention=false for DMs", () => { + const result = resolveZoomReplyPolicy({ + isDirectMessage: true, + globalConfig: { requireMention: true }, + }); + expect(result.requireMention).toBe(false); + }); + + it("defaults to requireMention=true for channels", () => { + const result = resolveZoomReplyPolicy({ + isDirectMessage: false, + }); + expect(result.requireMention).toBe(true); + }); + + it("respects global config requireMention", () => { + const result = resolveZoomReplyPolicy({ + isDirectMessage: false, + globalConfig: { requireMention: false }, + }); + expect(result.requireMention).toBe(false); + }); + + it("channel config overrides global config", () => { + const result = resolveZoomReplyPolicy({ + isDirectMessage: false, + globalConfig: { requireMention: true }, + channelConfig: { requireMention: false }, + }); + expect(result.requireMention).toBe(false); + }); +}); + +describe("isZoomGroupAllowed", () => { + it("returns false when groupPolicy is disabled", () => { + const result = isZoomGroupAllowed({ + groupPolicy: "disabled", + allowFrom: ["*"], + senderId: "user@xmpp.zoom.us", + }); + expect(result).toBe(false); + }); + + it("returns true when groupPolicy is open", () => { + const result = isZoomGroupAllowed({ + groupPolicy: "open", + allowFrom: [], + senderId: "user@xmpp.zoom.us", + }); + expect(result).toBe(true); + }); + + it("checks allowlist when groupPolicy is allowlist", () => { + expect( + isZoomGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: ["user@xmpp.zoom.us"], + senderId: "user@xmpp.zoom.us", + }), + ).toBe(true); + + expect( + isZoomGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: ["other@xmpp.zoom.us"], + senderId: "user@xmpp.zoom.us", + }), + ).toBe(false); + }); + + it("supports wildcard in allowlist", () => { + const result = isZoomGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: ["*"], + senderId: "anyone@xmpp.zoom.us", + }); + expect(result).toBe(true); + }); +}); diff --git a/extensions/zoom/src/policy.ts b/extensions/zoom/src/policy.ts new file mode 100644 index 000000000..faa3a9ff9 --- /dev/null +++ b/extensions/zoom/src/policy.ts @@ -0,0 +1,158 @@ +import type { + AllowlistMatch, + ChannelGroupContext, + GroupPolicy, + GroupToolPolicyConfig, +} from "clawdbot/plugin-sdk"; +import { + buildChannelKeyCandidates, + normalizeChannelSlug, + resolveToolsBySender, + resolveChannelEntryMatchWithFallback, + resolveNestedAllowlistDecision, +} from "clawdbot/plugin-sdk"; + +import type { ZoomConfig, ZoomChannelConfig } from "./types.js"; + +export type ZoomResolvedRouteConfig = { + channelConfig?: ZoomChannelConfig; + allowlistConfigured: boolean; + allowed: boolean; + channelKey?: string; + channelMatchKey?: string; + channelMatchSource?: "direct" | "wildcard"; +}; + +export function resolveZoomRouteConfig(params: { + cfg?: ZoomConfig; + channelJid?: string | null | undefined; + channelName?: string | null | undefined; +}): ZoomResolvedRouteConfig { + const channelJid = params.channelJid?.trim(); + const channelName = params.channelName?.trim(); + const channels = params.cfg?.channels ?? {}; + const allowlistConfigured = Object.keys(channels).length > 0; + + const channelCandidates = buildChannelKeyCandidates( + channelJid, + channelName, + channelName ? normalizeChannelSlug(channelName) : undefined, + ); + const channelMatch = resolveChannelEntryMatchWithFallback({ + entries: channels, + keys: channelCandidates, + wildcardKey: "*", + normalizeKey: normalizeChannelSlug, + }); + const channelConfig = channelMatch.entry; + + const allowed = resolveNestedAllowlistDecision({ + outerConfigured: allowlistConfigured, + outerMatched: Boolean(channelConfig), + innerConfigured: false, + innerMatched: false, + }); + + return { + channelConfig, + allowlistConfigured, + allowed, + channelKey: channelMatch.matchKey ?? channelMatch.key, + channelMatchKey: channelMatch.matchKey, + channelMatchSource: + channelMatch.matchSource === "direct" || channelMatch.matchSource === "wildcard" + ? channelMatch.matchSource + : undefined, + }; +} + +export function resolveZoomGroupToolPolicy( + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + const cfg = params.cfg.channels?.zoom as ZoomConfig | undefined; + if (!cfg) return undefined; + const groupId = params.groupId?.trim(); + const groupChannel = params.groupChannel?.trim(); + + const resolved = resolveZoomRouteConfig({ + cfg, + channelJid: groupId, + channelName: groupChannel, + }); + + if (resolved.channelConfig) { + const senderPolicy = resolveToolsBySender({ + toolsBySender: resolved.channelConfig.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + if (senderPolicy) return senderPolicy; + if (resolved.channelConfig.tools) return resolved.channelConfig.tools; + } + + return undefined; +} + +export type ZoomAllowlistMatch = AllowlistMatch<"wildcard" | "id" | "name" | "email">; + +export function resolveZoomAllowlistMatch(params: { + allowFrom: Array; + senderId: string; + senderName?: string | null; + senderEmail?: string | null; +}): ZoomAllowlistMatch { + const allowFrom = params.allowFrom + .map((entry) => String(entry).trim().toLowerCase()) + .filter(Boolean); + if (allowFrom.length === 0) return { allowed: false }; + if (allowFrom.includes("*")) { + return { allowed: true, matchKey: "*", matchSource: "wildcard" }; + } + const senderId = params.senderId.toLowerCase(); + if (allowFrom.includes(senderId)) { + return { allowed: true, matchKey: senderId, matchSource: "id" }; + } + const senderName = params.senderName?.toLowerCase(); + if (senderName && allowFrom.includes(senderName)) { + return { allowed: true, matchKey: senderName, matchSource: "name" }; + } + const senderEmail = params.senderEmail?.toLowerCase(); + if (senderEmail && allowFrom.includes(senderEmail)) { + return { allowed: true, matchKey: senderEmail, matchSource: "email" }; + } + return { allowed: false }; +} + +export type ZoomReplyPolicy = { + requireMention: boolean; +}; + +export function resolveZoomReplyPolicy(params: { + isDirectMessage: boolean; + globalConfig?: ZoomConfig; + channelConfig?: ZoomChannelConfig; +}): ZoomReplyPolicy { + if (params.isDirectMessage) { + return { requireMention: false }; + } + + const requireMention = + params.channelConfig?.requireMention ?? params.globalConfig?.requireMention ?? true; + + return { requireMention }; +} + +export function isZoomGroupAllowed(params: { + groupPolicy: GroupPolicy; + allowFrom: Array; + senderId: string; + senderName?: string | null; + senderEmail?: string | null; +}): boolean { + const { groupPolicy } = params; + if (groupPolicy === "disabled") return false; + if (groupPolicy === "open") return true; + return resolveZoomAllowlistMatch(params).allowed; +} diff --git a/extensions/zoom/src/probe.ts b/extensions/zoom/src/probe.ts new file mode 100644 index 000000000..45419f75d --- /dev/null +++ b/extensions/zoom/src/probe.ts @@ -0,0 +1,77 @@ +import { zoomApiFetch } from "./api.js"; +import { formatUnknownError } from "./errors.js"; +import { resolveZoomCredentials, fetchZoomAccessToken } from "./token.js"; +import type { ZoomConfig } from "./types.js"; + +export type ProbeZoomResult = { + ok: boolean; + error?: string; + botJid?: string; + accountId?: string; + tokenValid?: boolean; +}; + +/** + * Probe Zoom connection by validating credentials and token. + */ +export async function probeZoom(cfg?: ZoomConfig): Promise { + const creds = resolveZoomCredentials(cfg); + if (!creds) { + return { + ok: false, + error: "missing credentials (clientId, clientSecret, accountId, botJid)", + }; + } + + try { + // Test token fetch + const token = await fetchZoomAccessToken(creds); + if (!token.accessToken) { + return { + ok: false, + error: "failed to obtain access token", + botJid: creds.botJid, + accountId: creds.accountId, + }; + } + + // Verify the token works by making an API call + // Try to get user info (requires user:read scope) + const result = await zoomApiFetch(creds, "/users/me"); + + if (!result.ok) { + // Token is valid but may not have the right scopes + // This is still considered "ok" for basic connectivity + if (result.status === 403 || result.status === 401) { + return { + ok: true, + botJid: creds.botJid, + accountId: creds.accountId, + tokenValid: true, + }; + } + + return { + ok: false, + error: `API check failed: ${result.error}`, + botJid: creds.botJid, + accountId: creds.accountId, + tokenValid: true, + }; + } + + return { + ok: true, + botJid: creds.botJid, + accountId: creds.accountId, + tokenValid: true, + }; + } catch (err) { + return { + ok: false, + botJid: creds.botJid, + accountId: creds.accountId, + error: formatUnknownError(err), + }; + } +} diff --git a/extensions/zoom/src/runtime.ts b/extensions/zoom/src/runtime.ts new file mode 100644 index 000000000..f63399aa6 --- /dev/null +++ b/extensions/zoom/src/runtime.ts @@ -0,0 +1,18 @@ +/** + * Zoom plugin runtime singleton. + * Provides access to the Moltbot plugin runtime for logging, state, and channel utilities. + */ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +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; +} diff --git a/extensions/zoom/src/send.ts b/extensions/zoom/src/send.ts new file mode 100644 index 000000000..1cfb52cde --- /dev/null +++ b/extensions/zoom/src/send.ts @@ -0,0 +1,135 @@ +import type { MoltbotConfig } from "clawdbot/plugin-sdk"; + +import { sendZoomMessage } from "./api.js"; +import { createZoomConversationStoreFs } from "./conversation-store-fs.js"; +import { + classifyZoomSendError, + formatZoomSendErrorHint, + formatUnknownError, +} from "./errors.js"; +import { getZoomRuntime } from "./runtime.js"; +import { resolveZoomCredentials } from "./token.js"; +import type { ZoomConfig } from "./types.js"; + +export type SendZoomMessageParams = { + /** Full config (for credentials) */ + cfg: MoltbotConfig; + /** Conversation ID (user JID or channel JID) to send to */ + to: string; + /** Message text */ + text: string; + /** Whether this is a channel message */ + isChannel?: boolean; + /** Optional: reply to a specific message */ + replyToMessageId?: string; +}; + +export type SendZoomMessageResult = { + messageId: string; + conversationId: string; +}; + +/** Zoom Team Chat message limit */ +const ZOOM_TEXT_CHUNK_LIMIT = 4000; + +/** + * Send a text message to a Zoom Team Chat conversation. + */ +export async function sendZoomTextMessage( + params: SendZoomMessageParams, +): Promise { + const { cfg, to, text, isChannel, replyToMessageId } = params; + const zoomCfg = cfg.channels?.zoom as ZoomConfig | undefined; + const creds = resolveZoomCredentials(zoomCfg); + + if (!creds) { + throw new Error("Zoom credentials not configured"); + } + + const core = getZoomRuntime(); + const log = core.logging.getChildLogger({ name: "zoom" }); + + // Get conversation reference for account_id if needed + const conversationStore = createZoomConversationStoreFs(); + const storedRef = await conversationStore.get(to); + const accountId = storedRef?.accountId ?? creds.accountId; + + log.debug("sending message", { + to, + isChannel, + textLength: text.length, + }); + + // Build message content using Zoom's format + // Zoom requires both head (title) and body (message content) + const content = { + head: { + text: "Moltbot", + }, + body: [ + { + type: "message" as const, + text: text.slice(0, ZOOM_TEXT_CHUNK_LIMIT), + }, + ], + }; + + try { + const result = await sendZoomMessage(creds, { + robotJid: creds.botJid, + toJid: to, + accountId, + content, + isChannel, + replyMainMessageId: replyToMessageId, + }); + + if (!result.ok) { + const err = { statusCode: result.status, message: result.error }; + const classification = classifyZoomSendError(err); + const hint = formatZoomSendErrorHint(classification); + const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : ""; + throw new Error( + `zoom send failed${status}: ${result.error ?? "unknown error"}${hint ? ` (${hint})` : ""}`, + ); + } + + const messageId = result.data?.message_id ?? "unknown"; + + log.info("sent message", { to, messageId }); + + return { + messageId, + conversationId: to, + }; + } catch (err) { + if (err instanceof Error && err.message.startsWith("zoom send failed")) { + throw err; + } + const classification = classifyZoomSendError(err); + const hint = formatZoomSendErrorHint(classification); + const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : ""; + throw new Error( + `zoom send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`, + ); + } +} + +/** + * List all known conversation references (for debugging/CLI). + */ +export async function listZoomConversations(): Promise< + Array<{ + conversationId: string; + userName?: string; + conversationType?: string; + }> +> { + const store = createZoomConversationStoreFs(); + const all = await store.list(); + return all.map(({ conversationId, reference }) => ({ + conversationId, + userName: reference.userName, + conversationType: reference.conversationType, + })); +} diff --git a/extensions/zoom/src/storage.ts b/extensions/zoom/src/storage.ts new file mode 100644 index 000000000..6a0bdff49 --- /dev/null +++ b/extensions/zoom/src/storage.ts @@ -0,0 +1,25 @@ +/** + * Storage path resolution for Zoom plugin state files. + */ +import path from "node:path"; + +import { getZoomRuntime } from "./runtime.js"; + +export type ZoomStorePathOptions = { + env?: NodeJS.ProcessEnv; + homedir?: () => string; + stateDir?: string; + storePath?: string; + filename: string; +}; + +export function resolveZoomStorePath(params: ZoomStorePathOptions): string { + if (params.storePath) return params.storePath; + if (params.stateDir) return path.join(params.stateDir, params.filename); + + const env = params.env ?? process.env; + const stateDir = params.homedir + ? getZoomRuntime().state.resolveStateDir(env, params.homedir) + : getZoomRuntime().state.resolveStateDir(env); + return path.join(stateDir, params.filename); +} diff --git a/extensions/zoom/src/store-fs.ts b/extensions/zoom/src/store-fs.ts new file mode 100644 index 000000000..b429b0dd0 --- /dev/null +++ b/extensions/zoom/src/store-fs.ts @@ -0,0 +1,86 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +function safeParseJson(raw: string): T | null { + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +export async function readJsonFile( + filePath: string, + fallback: T, +): Promise<{ value: T; exists: boolean }> { + try { + const raw = await fs.promises.readFile(filePath, "utf-8"); + const parsed = safeParseJson(raw); + if (parsed == null) return { value: fallback, exists: true }; + return { value: parsed, exists: true }; + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "ENOENT") return { value: fallback, exists: false }; + return { value: fallback, exists: false }; + } +} + +export async function writeJsonFile(filePath: string, value: unknown): Promise { + const dir = path.dirname(filePath); + await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }); + const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`); + await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { + encoding: "utf-8", + }); + await fs.promises.chmod(tmp, 0o600); + await fs.promises.rename(tmp, filePath); +} + +async function ensureJsonFile(filePath: string, fallback: unknown) { + try { + await fs.promises.access(filePath); + } catch { + await writeJsonFile(filePath, fallback); + } +} + +/** + * Simple file lock using mkdir (atomic on most filesystems). + * Falls back gracefully if lock can't be acquired. + */ +export async function withFileLock( + filePath: string, + fallback: unknown, + fn: () => Promise, +): Promise { + await ensureJsonFile(filePath, fallback); + const lockPath = `${filePath}.lock`; + let acquired = false; + + // Try to acquire lock with retries + for (let attempt = 0; attempt < 10; attempt++) { + try { + await fs.promises.mkdir(lockPath, { recursive: false }); + acquired = true; + break; + } catch (err) { + const code = (err as { code?: string }).code; + if (code !== "EEXIST") throw err; + // Lock exists, wait and retry + await new Promise((resolve) => setTimeout(resolve, 100 + Math.random() * 100)); + } + } + + try { + return await fn(); + } finally { + if (acquired) { + try { + await fs.promises.rmdir(lockPath); + } catch { + // Ignore unlock errors + } + } + } +} diff --git a/extensions/zoom/src/token.test.ts b/extensions/zoom/src/token.test.ts new file mode 100644 index 000000000..06080ac55 --- /dev/null +++ b/extensions/zoom/src/token.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { resolveZoomCredentials, clearZoomTokenCache } from "./token.js"; + +describe("resolveZoomCredentials", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + clearZoomTokenCache(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("returns undefined when no credentials configured", () => { + delete process.env.ZOOM_CLIENT_ID; + delete process.env.ZOOM_CLIENT_SECRET; + delete process.env.ZOOM_ACCOUNT_ID; + delete process.env.ZOOM_BOT_JID; + + const result = resolveZoomCredentials({}); + expect(result).toBeUndefined(); + }); + + it("resolves credentials from config", () => { + const result = resolveZoomCredentials({ + clientId: "test-client-id", + clientSecret: "test-client-secret", + accountId: "test-account-id", + botJid: "bot@xmpp.zoom.us", + }); + + expect(result).toEqual({ + clientId: "test-client-id", + clientSecret: "test-client-secret", + accountId: "test-account-id", + botJid: "bot@xmpp.zoom.us", + webhookSecretToken: undefined, + }); + }); + + it("resolves credentials from environment variables", () => { + process.env.ZOOM_CLIENT_ID = "env-client-id"; + process.env.ZOOM_CLIENT_SECRET = "env-client-secret"; + process.env.ZOOM_ACCOUNT_ID = "env-account-id"; + process.env.ZOOM_BOT_JID = "envbot@xmpp.zoom.us"; + + const result = resolveZoomCredentials({}); + + expect(result).toEqual({ + clientId: "env-client-id", + clientSecret: "env-client-secret", + accountId: "env-account-id", + botJid: "envbot@xmpp.zoom.us", + webhookSecretToken: undefined, + }); + }); + + it("prefers config over environment variables", () => { + process.env.ZOOM_CLIENT_ID = "env-client-id"; + process.env.ZOOM_CLIENT_SECRET = "env-client-secret"; + process.env.ZOOM_ACCOUNT_ID = "env-account-id"; + process.env.ZOOM_BOT_JID = "envbot@xmpp.zoom.us"; + + const result = resolveZoomCredentials({ + clientId: "config-client-id", + clientSecret: "config-client-secret", + accountId: "config-account-id", + botJid: "configbot@xmpp.zoom.us", + }); + + expect(result).toEqual({ + clientId: "config-client-id", + clientSecret: "config-client-secret", + accountId: "config-account-id", + botJid: "configbot@xmpp.zoom.us", + webhookSecretToken: undefined, + }); + }); + + it("includes webhook secret token when provided", () => { + const result = resolveZoomCredentials({ + clientId: "test-client-id", + clientSecret: "test-client-secret", + accountId: "test-account-id", + botJid: "bot@xmpp.zoom.us", + webhookSecretToken: "webhook-secret", + }); + + expect(result?.webhookSecretToken).toBe("webhook-secret"); + }); + + it("returns undefined when missing clientId", () => { + const result = resolveZoomCredentials({ + clientSecret: "test-client-secret", + accountId: "test-account-id", + botJid: "bot@xmpp.zoom.us", + }); + expect(result).toBeUndefined(); + }); + + it("returns undefined when missing clientSecret", () => { + const result = resolveZoomCredentials({ + clientId: "test-client-id", + accountId: "test-account-id", + botJid: "bot@xmpp.zoom.us", + }); + expect(result).toBeUndefined(); + }); + + it("returns undefined when missing accountId", () => { + const result = resolveZoomCredentials({ + clientId: "test-client-id", + clientSecret: "test-client-secret", + botJid: "bot@xmpp.zoom.us", + }); + expect(result).toBeUndefined(); + }); + + it("returns undefined when missing botJid", () => { + const result = resolveZoomCredentials({ + clientId: "test-client-id", + clientSecret: "test-client-secret", + accountId: "test-account-id", + }); + expect(result).toBeUndefined(); + }); + + it("trims whitespace from credentials", () => { + const result = resolveZoomCredentials({ + clientId: " test-client-id ", + clientSecret: " test-client-secret ", + accountId: " test-account-id ", + botJid: " bot@xmpp.zoom.us ", + }); + + expect(result).toEqual({ + clientId: "test-client-id", + clientSecret: "test-client-secret", + accountId: "test-account-id", + botJid: "bot@xmpp.zoom.us", + webhookSecretToken: undefined, + }); + }); +}); diff --git a/extensions/zoom/src/token.ts b/extensions/zoom/src/token.ts new file mode 100644 index 000000000..63d43b131 --- /dev/null +++ b/extensions/zoom/src/token.ts @@ -0,0 +1,77 @@ +import type { ZoomConfig, ZoomCredentials, ZoomAccessToken } from "./types.js"; + +// Token cache with expiry buffer (refresh 5 minutes before expiry) +const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; + +let cachedToken: ZoomAccessToken | null = null; + +export function resolveZoomCredentials(cfg?: ZoomConfig): ZoomCredentials | undefined { + const clientId = cfg?.clientId?.trim() || process.env.ZOOM_CLIENT_ID?.trim(); + const clientSecret = cfg?.clientSecret?.trim() || process.env.ZOOM_CLIENT_SECRET?.trim(); + const accountId = cfg?.accountId?.trim() || process.env.ZOOM_ACCOUNT_ID?.trim(); + const botJid = cfg?.botJid?.trim() || process.env.ZOOM_BOT_JID?.trim(); + const webhookSecretToken = + cfg?.webhookSecretToken?.trim() || process.env.ZOOM_WEBHOOK_SECRET_TOKEN?.trim(); + + if (!clientId || !clientSecret || !accountId || !botJid) { + return undefined; + } + + return { clientId, clientSecret, accountId, botJid, webhookSecretToken }; +} + +/** + * Fetch a new access token using S2S OAuth credentials. + * https://developers.zoom.us/docs/internal-apps/s2s-oauth/ + */ +export async function fetchZoomAccessToken(creds: ZoomCredentials): Promise { + const authHeader = Buffer.from(`${creds.clientId}:${creds.clientSecret}`).toString("base64"); + + const response = await fetch("https://zoom.us/oauth/token?grant_type=client_credentials", { + method: "POST", + headers: { + Authorization: `Basic ${authHeader}`, + }, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Zoom token fetch failed (${response.status}): ${text}`); + } + + const data = (await response.json()) as { + access_token: string; + token_type: string; + expires_in: number; + scope: string; + }; + + if (!data.access_token) { + throw new Error("Zoom token response missing access_token"); + } + + return { + accessToken: data.access_token, + expiresAt: Date.now() + data.expires_in * 1000, + }; +} + +/** + * Get a valid access token, fetching a new one if needed. + * Caches the token and refreshes before expiry. + */ +export async function getZoomAccessToken(creds: ZoomCredentials): Promise { + if (cachedToken && Date.now() < cachedToken.expiresAt - TOKEN_EXPIRY_BUFFER_MS) { + return cachedToken.accessToken; + } + + cachedToken = await fetchZoomAccessToken(creds); + return cachedToken.accessToken; +} + +/** + * Clear the cached token (useful for testing or forced refresh). + */ +export function clearZoomTokenCache(): void { + cachedToken = null; +} diff --git a/extensions/zoom/src/types.ts b/extensions/zoom/src/types.ts new file mode 100644 index 000000000..cbdf38467 --- /dev/null +++ b/extensions/zoom/src/types.ts @@ -0,0 +1,91 @@ +import type { DmPolicy, GroupPolicy } from "clawdbot/plugin-sdk"; + +export type ZoomChannelConfig = { + requireMention?: boolean; + tools?: { + allow?: string[]; + deny?: string[]; + }; + toolsBySender?: Record< + string, + { + allow?: string[]; + deny?: string[]; + } + >; +}; + +export type ZoomConfig = { + enabled?: boolean; + clientId?: string; + clientSecret?: string; + accountId?: string; + botJid?: string; + webhookSecretToken?: string; + webhook?: { + port?: number; + path?: string; + }; + dmPolicy?: DmPolicy; + allowFrom?: Array; + groupAllowFrom?: Array; + groupPolicy?: GroupPolicy; + requireMention?: boolean; + textChunkLimit?: number; + chunkMode?: "length" | "newline"; + mediaMaxMb?: number; + historyLimit?: number; + dmHistoryLimit?: number; + channels?: Record; + dms?: Record; +}; + +export type ZoomCredentials = { + clientId: string; + clientSecret: string; + accountId: string; + botJid: string; + webhookSecretToken?: string; +}; + +export type ZoomChatMessage = { + id?: string; + message?: string; + timestamp?: number; + sender?: string; + sender_member_id?: string; + sender_display_name?: string; + robot_jid?: string; +}; + +export type ZoomWebhookEvent = { + event: string; + event_ts: number; + payload: { + accountId?: string; + object?: { + type?: string; + channel_jid?: string; + channel_name?: string; + operator?: string; + operator_id?: string; + operator_member_id?: string; + /** For channel messages, this is the message object */ + message?: ZoomChatMessage; + userJid?: string; + user_id?: string; + user_name?: string; + user_email?: string; + robot_jid?: string; + /** For bot notifications, this is the message text */ + text?: string; + cmd?: string; + timestamp?: number; + }; + }; +}; + +export type ZoomAccessToken = { + accessToken: string; + expiresAt: number; +}; diff --git a/extensions/zoom/src/webhook.test.ts b/extensions/zoom/src/webhook.test.ts new file mode 100644 index 000000000..6fa32f103 --- /dev/null +++ b/extensions/zoom/src/webhook.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { verifyZoomWebhook, handleZoomChallenge } from "./webhook.js"; + +describe("verifyZoomWebhook", () => { + const secret = "test-secret-token"; + + it("returns true for valid signature", () => { + const timestamp = "1234567890"; + const payload = '{"event":"bot_notification"}'; + // Pre-computed: v0:1234567890:{"event":"bot_notification"} with secret "test-secret-token" + const crypto = require("node:crypto"); + const message = `v0:${timestamp}:${payload}`; + const hash = crypto.createHmac("sha256", secret).update(message).digest("hex"); + const signature = `v0=${hash}`; + + const result = verifyZoomWebhook({ payload, signature, timestamp, secret }); + expect(result).toBe(true); + }); + + it("returns false for invalid signature", () => { + const result = verifyZoomWebhook({ + payload: '{"event":"bot_notification"}', + signature: "v0=invalid", + timestamp: "1234567890", + secret, + }); + expect(result).toBe(false); + }); + + it("returns false for missing signature", () => { + const result = verifyZoomWebhook({ + payload: '{"event":"bot_notification"}', + signature: "", + timestamp: "1234567890", + secret, + }); + expect(result).toBe(false); + }); + + it("returns false for missing timestamp", () => { + const result = verifyZoomWebhook({ + payload: '{"event":"bot_notification"}', + signature: "v0=abc", + timestamp: "", + secret, + }); + expect(result).toBe(false); + }); + + it("returns false for missing secret", () => { + const result = verifyZoomWebhook({ + payload: '{"event":"bot_notification"}', + signature: "v0=abc", + timestamp: "1234567890", + secret: "", + }); + expect(result).toBe(false); + }); +}); + +describe("handleZoomChallenge", () => { + it("returns correct encrypted token", () => { + const plainToken = "test-plain-token"; + const secret = "test-secret"; + + const result = handleZoomChallenge({ plainToken, secret }); + + expect(result.plainToken).toBe(plainToken); + expect(result.encryptedToken).toBeDefined(); + expect(result.encryptedToken.length).toBe(64); // SHA256 hex is 64 chars + + // Verify it's deterministic + const result2 = handleZoomChallenge({ plainToken, secret }); + expect(result2.encryptedToken).toBe(result.encryptedToken); + }); + + it("produces different tokens for different secrets", () => { + const plainToken = "test-plain-token"; + + const result1 = handleZoomChallenge({ plainToken, secret: "secret1" }); + const result2 = handleZoomChallenge({ plainToken, secret: "secret2" }); + + expect(result1.encryptedToken).not.toBe(result2.encryptedToken); + }); +}); diff --git a/extensions/zoom/src/webhook.ts b/extensions/zoom/src/webhook.ts new file mode 100644 index 000000000..937f442d0 --- /dev/null +++ b/extensions/zoom/src/webhook.ts @@ -0,0 +1,66 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** + * Verify Zoom webhook signature using HMAC-SHA256. + * https://developers.zoom.us/docs/api/rest/webhook-reference/#verify-webhook-events + * + * Zoom sends: + * - x-zm-signature: v0= + * - x-zm-request-timestamp: + * + * Message format: v0:: + */ +export function verifyZoomWebhook(params: { + payload: string; + signature: string; + timestamp: string; + secret: string; +}): boolean { + const { payload, signature, timestamp, secret } = params; + + if (!signature || !timestamp || !secret) { + return false; + } + + // Build message: v0:: + const message = `v0:${timestamp}:${payload}`; + + // Compute expected hash + const hash = createHmac("sha256", secret).update(message).digest("hex"); + const expected = `v0=${hash}`; + + // Use timing-safe comparison + try { + const sigBuffer = Buffer.from(signature); + const expectedBuffer = Buffer.from(expected); + + if (sigBuffer.length !== expectedBuffer.length) { + return false; + } + + return timingSafeEqual(sigBuffer, expectedBuffer); + } catch { + return false; + } +} + +/** + * Handle Zoom URL validation challenge. + * When Zoom validates the webhook URL, it sends a challenge that must be + * hashed and returned. + * + * https://developers.zoom.us/docs/api/rest/webhook-reference/#validate-your-webhook-endpoint + */ +export function handleZoomChallenge(params: { + plainToken: string; + secret: string; +}): { plainToken: string; encryptedToken: string } { + const { plainToken, secret } = params; + + const encryptedToken = createHmac("sha256", secret).update(plainToken).digest("hex"); + + return { + plainToken, + encryptedToken, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c0f99928..e2ce86f39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -383,12 +383,12 @@ importers: '@microsoft/agents-hosting-extensions-teams': specifier: ^1.2.2 version: 1.2.2 - moltbot: - specifier: workspace:* - version: link:../.. express: specifier: ^5.2.1 version: 5.2.1 + moltbot: + specifier: workspace:* + version: link:../.. proper-lockfile: specifier: ^4.1.2 version: 4.1.2 @@ -475,6 +475,15 @@ importers: specifier: workspace:* version: link:../.. + extensions/zoom: + dependencies: + express: + specifier: ^5.2.1 + version: 5.2.1 + moltbot: + specifier: workspace:* + version: link:../.. + packages/clawdbot: dependencies: moltbot: @@ -3214,11 +3223,6 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - clawdbot@2026.1.24-3: - resolution: {integrity: sha512-zt9BzhWXduq8ZZR4rfzQDurQWAgmijTTyPZCQGrn5ew6wCEwhxxEr2/NHG7IlCwcfRsKymsY4se9KMhoNz0JtQ==} - engines: {node: '>=22.12.0'} - hasBin: true - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -9098,84 +9102,6 @@ snapshots: dependencies: clsx: 2.1.1 - clawdbot@2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3): - dependencies: - '@agentclientprotocol/sdk': 0.13.1(zod@4.3.6) - '@aws-sdk/client-bedrock': 3.975.0 - '@buape/carbon': 0.14.0(hono@4.11.4) - '@clack/prompts': 0.11.0 - '@grammyjs/runner': 2.0.3(grammy@1.39.3) - '@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3) - '@homebridge/ciao': 1.3.4 - '@line/bot-sdk': 10.6.0 - '@lydell/node-pty': 1.2.0-beta.3 - '@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.49.3 - '@mozilla/readability': 0.6.0 - '@sinclair/typebox': 0.34.47 - '@slack/bolt': 4.6.0(@types/express@5.0.6) - '@slack/web-api': 7.13.0 - '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) - ajv: 8.17.1 - body-parser: 2.2.2 - chalk: 5.6.2 - chokidar: 5.0.0 - chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482) - cli-highlight: 2.1.11 - commander: 14.0.2 - croner: 9.1.0 - detect-libc: 2.1.2 - discord-api-types: 0.38.37 - dotenv: 17.2.3 - express: 5.2.1 - file-type: 21.3.0 - grammy: 1.39.3 - hono: 4.11.4 - jiti: 2.6.1 - json5: 2.2.3 - jszip: 3.10.1 - linkedom: 0.18.12 - long: 5.3.2 - markdown-it: 14.1.0 - node-edge-tts: 1.2.9 - osc-progress: 0.3.0 - pdfjs-dist: 5.4.530 - playwright-core: 1.58.0 - proper-lockfile: 4.1.2 - qrcode-terminal: 0.12.0 - sharp: 0.34.5 - sqlite-vec: 0.1.7-alpha.2 - tar: 7.5.4 - tslog: 4.10.2 - undici: 7.19.0 - ws: 8.19.0 - yaml: 2.8.2 - zod: 4.3.6 - optionalDependencies: - '@napi-rs/canvas': 0.1.88 - node-llama-cpp: 3.15.0(typescript@5.9.3) - transitivePeerDependencies: - - '@discordjs/opus' - - '@modelcontextprotocol/sdk' - - '@types/express' - - audio-decode - - aws-crt - - bufferutil - - canvas - - debug - - devtools-protocol - - encoding - - ffmpeg-static - - jimp - - link-preview-js - - node-opus - - opusscript - - supports-color - - typescript - - utf-8-validate - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0