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:
parent
d0ef4d3b85
commit
dcd1257edd
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@ -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
20
extensions/zoom/index.ts
Normal 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;
|
||||||
11
extensions/zoom/moltbot.plugin.json
Normal file
11
extensions/zoom/moltbot.plugin.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "zoom",
|
||||||
|
"channels": [
|
||||||
|
"zoom"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
extensions/zoom/package.json
Normal file
32
extensions/zoom/package.json
Normal 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
145
extensions/zoom/src/api.ts
Normal 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");
|
||||||
|
}
|
||||||
259
extensions/zoom/src/channel.ts
Normal file
259
extensions/zoom/src/channel.ts
Normal 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
146
extensions/zoom/src/conversation-store-fs.ts
Normal file
146
extensions/zoom/src/conversation-store-fs.ts
Normal 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 };
|
||||||
|
}
|
||||||
41
extensions/zoom/src/conversation-store.ts
Normal file
41
extensions/zoom/src/conversation-store.ts
Normal 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>;
|
||||||
|
};
|
||||||
152
extensions/zoom/src/errors.ts
Normal file
152
extensions/zoom/src/errors.ts
Normal 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;
|
||||||
|
}
|
||||||
355
extensions/zoom/src/monitor-handler.ts
Normal file
355
extensions/zoom/src/monitor-handler.ts
Normal 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) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
extensions/zoom/src/monitor-types.ts
Normal file
7
extensions/zoom/src/monitor-types.ts
Normal 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;
|
||||||
|
};
|
||||||
184
extensions/zoom/src/monitor.ts
Normal file
184
extensions/zoom/src/monitor.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
345
extensions/zoom/src/onboarding.ts
Normal file
345
extensions/zoom/src/onboarding.ts
Normal 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 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
27
extensions/zoom/src/outbound.ts
Normal file
27
extensions/zoom/src/outbound.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
177
extensions/zoom/src/policy.test.ts
Normal file
177
extensions/zoom/src/policy.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
158
extensions/zoom/src/policy.ts
Normal file
158
extensions/zoom/src/policy.ts
Normal 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;
|
||||||
|
}
|
||||||
77
extensions/zoom/src/probe.ts
Normal file
77
extensions/zoom/src/probe.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
18
extensions/zoom/src/runtime.ts
Normal file
18
extensions/zoom/src/runtime.ts
Normal 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
135
extensions/zoom/src/send.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
25
extensions/zoom/src/storage.ts
Normal file
25
extensions/zoom/src/storage.ts
Normal 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);
|
||||||
|
}
|
||||||
86
extensions/zoom/src/store-fs.ts
Normal file
86
extensions/zoom/src/store-fs.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
extensions/zoom/src/token.test.ts
Normal file
145
extensions/zoom/src/token.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
77
extensions/zoom/src/token.ts
Normal file
77
extensions/zoom/src/token.ts
Normal 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;
|
||||||
|
}
|
||||||
91
extensions/zoom/src/types.ts
Normal file
91
extensions/zoom/src/types.ts
Normal 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;
|
||||||
|
};
|
||||||
85
extensions/zoom/src/webhook.test.ts
Normal file
85
extensions/zoom/src/webhook.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
66
extensions/zoom/src/webhook.ts
Normal file
66
extensions/zoom/src/webhook.ts
Normal 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
98
pnpm-lock.yaml
generated
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user