openclaw/extensions/zoom/src/send.ts
Doug Ruby dcd1257edd 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>
2026-01-28 15:50:21 -08:00

136 lines
3.8 KiB
TypeScript

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