feat: add Zoom Team Chat plugin

Add a new channel plugin for Zoom Team Chat integration:

- Server-to-Server OAuth with client_credentials grant
- Webhook signature verification (HMAC-SHA256)
- Support for both DMs and channel messages
- Policy controls for DMs and group messaging
- Onboarding wizard for credentials setup
- Connection health probe

Requires a Zoom Team Chat app with `imchat:bot` scope.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Doug Ruby 2026-01-28 15:42:37 -08:00 committed by gerald Ruby
parent d0ef4d3b85
commit dcd1257edd
27 changed files with 2881 additions and 86 deletions

5
.github/labeler.yml vendored
View File

@ -77,6 +77,11 @@
- changed-files: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file:
- "extensions/voice-call/**" - "extensions/voice-call/**"
"channel: zoom":
- changed-files:
- any-glob-to-any-file:
- "extensions/zoom/**"
- "docs/channels/zoom.md"
"channel: whatsapp-web": "channel: whatsapp-web":
- changed-files: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file:

20
extensions/zoom/index.ts Normal file
View File

@ -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;

View File

@ -0,0 +1,11 @@
{
"id": "zoom",
"channels": [
"zoom"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@ -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:*"
}
}

145
extensions/zoom/src/api.ts Normal file
View File

@ -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<string, string>;
};
export type ZoomApiResponse<T> = {
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<T = unknown>(
creds: ZoomCredentials,
endpoint: string,
options: ZoomApiOptions = {},
): Promise<ZoomApiResponse<T>> {
const accessToken = await getZoomAccessToken(creds);
const url = endpoint.startsWith("http") ? endpoint : `${ZOOM_API_BASE}${endpoint}`;
const headers: Record<string, string> = {
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<ZoomApiResponse<ZoomSendMessageResponse>> {
const body: Record<string, unknown> = {
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<ZoomSendMessageResponse>(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<ZoomApiResponse<ZoomBotInfo>> {
// 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<ZoomBotInfo>(creds, "/users/me");
}

View File

@ -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<ResolvedZoomAccount> = {
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<string, unknown>).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: "<userJid|channelJid|user:JID|channel:JID>",
},
},
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<string>();
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<string>();
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,
});
},
},
};

View File

@ -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<string, StoredConversationReference & { lastSeenAt?: string }>;
};
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<string, StoredConversationReference & { lastSeenAt?: string }>,
) {
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<string, StoredConversationReference & { lastSeenAt?: string }>,
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<ConversationStoreData> => {
const { value } = await readJsonFile<ConversationStoreData>(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<ZoomConversationStoreEntry[]> => {
const store = await readStore();
return Object.entries(store.conversations).map(([conversationId, reference]) => ({
conversationId,
reference,
}));
};
const get = async (conversationId: string): Promise<StoredConversationReference | null> => {
const store = await readStore();
return store.conversations[conversationId] ?? null;
};
const findByUserJid = async (jid: string): Promise<ZoomConversationStoreEntry | null> => {
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<void> => {
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<boolean> => {
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 };
}

View File

@ -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<void>;
get: (conversationId: string) => Promise<StoredConversationReference | null>;
list: () => Promise<ZoomConversationStoreEntry[]>;
remove: (conversationId: string) => Promise<boolean>;
findByUserJid: (jid: string) => Promise<ZoomConversationStoreEntry | null>;
};

View File

@ -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<string, unknown> {
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;
}

View File

@ -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: @<display_name> 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) });
}
}
}

View File

@ -0,0 +1,7 @@
/** Logger interface for Zoom webhook monitor */
export type ZoomMonitorLogger = {
debug: (message: string, context?: Record<string, unknown>) => void;
info: (message: string, context?: Record<string, unknown>) => void;
warn: (message: string, context?: Record<string, unknown>) => void;
error: (message: string, context?: Record<string, unknown>) => void;
};

View File

@ -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<void>;
};
export async function monitorZoomProvider(
opts: MonitorZoomOpts,
): Promise<MonitorZoomResult> {
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<MonitorZoomResult>((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<void>((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();
});
}
});
}

View File

@ -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<MoltbotConfig> {
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<void> {
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<string, unknown> = {};
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 },
},
};
},
};

View File

@ -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 };
},
};

View File

@ -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);
});
});

View File

@ -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<string | number>;
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<string | number>;
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;
}

View File

@ -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<ProbeZoomResult> {
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),
};
}
}

View File

@ -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;
}

135
extensions/zoom/src/send.ts Normal file
View File

@ -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<SendZoomMessageResult> {
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,
}));
}

View File

@ -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);
}

View File

@ -0,0 +1,86 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
function safeParseJson<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
export async function readJsonFile<T>(
filePath: string,
fallback: T,
): Promise<{ value: T; exists: boolean }> {
try {
const raw = await fs.promises.readFile(filePath, "utf-8");
const parsed = safeParseJson<T>(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<void> {
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<T>(
filePath: string,
fallback: unknown,
fn: () => Promise<T>,
): Promise<T> {
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
}
}
}
}

View File

@ -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,
});
});
});

View File

@ -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<ZoomAccessToken> {
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<string> {
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;
}

View File

@ -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<string | number>;
groupAllowFrom?: Array<string | number>;
groupPolicy?: GroupPolicy;
requireMention?: boolean;
textChunkLimit?: number;
chunkMode?: "length" | "newline";
mediaMaxMb?: number;
historyLimit?: number;
dmHistoryLimit?: number;
channels?: Record<string, ZoomChannelConfig | undefined>;
dms?: Record<string, unknown>;
};
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;
};

View File

@ -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);
});
});

View File

@ -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=<hash>
* - x-zm-request-timestamp: <timestamp>
*
* Message format: v0:<timestamp>:<payload>
*/
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:<timestamp>:<payload>
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,
};
}

98
pnpm-lock.yaml generated
View File

@ -383,12 +383,12 @@ importers:
'@microsoft/agents-hosting-extensions-teams': '@microsoft/agents-hosting-extensions-teams':
specifier: ^1.2.2 specifier: ^1.2.2
version: 1.2.2 version: 1.2.2
moltbot:
specifier: workspace:*
version: link:../..
express: express:
specifier: ^5.2.1 specifier: ^5.2.1
version: 5.2.1 version: 5.2.1
moltbot:
specifier: workspace:*
version: link:../..
proper-lockfile: proper-lockfile:
specifier: ^4.1.2 specifier: ^4.1.2
version: 4.1.2 version: 4.1.2
@ -475,6 +475,15 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../.. version: link:../..
extensions/zoom:
dependencies:
express:
specifier: ^5.2.1
version: 5.2.1
moltbot:
specifier: workspace:*
version: link:../..
packages/clawdbot: packages/clawdbot:
dependencies: dependencies:
moltbot: moltbot:
@ -3214,11 +3223,6 @@ packages:
class-variance-authority@0.7.1: class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} 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: cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -9098,84 +9102,6 @@ snapshots:
dependencies: dependencies:
clsx: 2.1.1 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: cli-cursor@5.0.0:
dependencies: dependencies:
restore-cursor: 5.1.0 restore-cursor: 5.1.0