From 352227f4359476fcdcaeddd721ff8f1ebc85e5bc Mon Sep 17 00:00:00 2001 From: John Lin Date: Tue, 27 Jan 2026 17:30:52 +0800 Subject: [PATCH] feat: add RingCentral Team Messaging channel extension - Add RingCentral channel plugin with WebSocket subscription support - Implement JWT authentication via @ringcentral/sdk - Support DM and group messaging with mention gating - Add credential file support (rc-credentials.json) - Implement full ChannelPlugin interface (pairing, security, messaging, etc.) - Add .gitignore entries for credential files Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .gitignore | 4 + extensions/ringcentral/clawdbot.plugin.json | 11 + extensions/ringcentral/index.ts | 19 + extensions/ringcentral/package.json | 42 ++ extensions/ringcentral/src/accounts.ts | 274 ++++++++ extensions/ringcentral/src/api.ts | 220 +++++++ extensions/ringcentral/src/auth.ts | 92 +++ extensions/ringcentral/src/channel.ts | 555 ++++++++++++++++ extensions/ringcentral/src/config-schema.ts | 75 +++ extensions/ringcentral/src/monitor.ts | 690 ++++++++++++++++++++ extensions/ringcentral/src/runtime.ts | 14 + extensions/ringcentral/src/targets.ts | 70 ++ extensions/ringcentral/src/types.ts | 123 ++++ pnpm-lock.yaml | 137 ++++ 14 files changed, 2326 insertions(+) create mode 100644 extensions/ringcentral/clawdbot.plugin.json create mode 100644 extensions/ringcentral/index.ts create mode 100644 extensions/ringcentral/package.json create mode 100644 extensions/ringcentral/src/accounts.ts create mode 100644 extensions/ringcentral/src/api.ts create mode 100644 extensions/ringcentral/src/auth.ts create mode 100644 extensions/ringcentral/src/channel.ts create mode 100644 extensions/ringcentral/src/config-schema.ts create mode 100644 extensions/ringcentral/src/monitor.ts create mode 100644 extensions/ringcentral/src/runtime.ts create mode 100644 extensions/ringcentral/src/targets.ts create mode 100644 extensions/ringcentral/src/types.ts diff --git a/.gitignore b/.gitignore index 9dc547c9c..b01470d40 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,7 @@ USER.md # local tooling .serena/ + +# RingCentral credentials (never commit) +rc-credentials.json +*-credentials.json diff --git a/extensions/ringcentral/clawdbot.plugin.json b/extensions/ringcentral/clawdbot.plugin.json new file mode 100644 index 000000000..ffae4ee49 --- /dev/null +++ b/extensions/ringcentral/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "ringcentral", + "channels": [ + "ringcentral" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/ringcentral/index.ts b/extensions/ringcentral/index.ts new file mode 100644 index 000000000..1e6c5c127 --- /dev/null +++ b/extensions/ringcentral/index.ts @@ -0,0 +1,19 @@ +import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; + +import { ringcentralDock, ringcentralPlugin } from "./src/channel.js"; +import { setRingCentralRuntime } from "./src/runtime.js"; + +const plugin = { + id: "ringcentral", + name: "RingCentral", + description: "Clawdbot RingCentral Team Messaging channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: ClawdbotPluginApi) { + setRingCentralRuntime(api.runtime); + api.registerChannel({ plugin: ringcentralPlugin, dock: ringcentralDock }); + // WebSocket mode: no HTTP handler needed + }, +}; + +export default plugin; diff --git a/extensions/ringcentral/package.json b/extensions/ringcentral/package.json new file mode 100644 index 000000000..f8f98e237 --- /dev/null +++ b/extensions/ringcentral/package.json @@ -0,0 +1,42 @@ +{ + "name": "@clawdbot/ringcentral", + "version": "2026.1.25", + "type": "module", + "description": "Clawdbot RingCentral Team Messaging channel plugin", + "clawdbot": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "ringcentral", + "label": "RingCentral", + "selectionLabel": "RingCentral Team Messaging", + "detailLabel": "RingCentral", + "docsPath": "/channels/ringcentral", + "docsLabel": "ringcentral", + "blurb": "RingCentral Team Messaging via REST API and WebSocket.", + "aliases": [ + "rc", + "ringcentral-tm" + ], + "order": 56 + }, + "install": { + "npmSpec": "@clawdbot/ringcentral", + "localPath": "extensions/ringcentral", + "defaultChoice": "npm" + } + }, + "dependencies": { + "@ringcentral/sdk": "^5.0.0", + "@ringcentral/subscriptions": "^5.0.0", + "isomorphic-ws": "^5.0.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "clawdbot": "workspace:*" + }, + "peerDependencies": { + "clawdbot": ">=2026.1.25" + } +} diff --git a/extensions/ringcentral/src/accounts.ts b/extensions/ringcentral/src/accounts.ts new file mode 100644 index 000000000..cfe29ca83 --- /dev/null +++ b/extensions/ringcentral/src/accounts.ts @@ -0,0 +1,274 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { homedir } from "node:os"; + +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk"; + +import type { RingCentralAccountConfig, RingCentralConfig } from "./types.js"; + +export type RingCentralCredentialSource = "config" | "file" | "env" | "none"; + +export type ResolvedRingCentralAccount = { + accountId: string; + name?: string; + enabled: boolean; + config: RingCentralAccountConfig; + credentialSource: RingCentralCredentialSource; + clientId?: string; + clientSecret?: string; + jwt?: string; + server: string; +}; + +const ENV_CLIENT_ID = "RINGCENTRAL_CLIENT_ID"; +const ENV_CLIENT_SECRET = "RINGCENTRAL_CLIENT_SECRET"; +const ENV_JWT = "RINGCENTRAL_JWT"; +const ENV_SERVER = "RINGCENTRAL_SERVER"; +const ENV_CREDENTIALS_FILE = "RINGCENTRAL_CREDENTIALS_FILE"; + +const DEFAULT_SERVER = "https://platform.ringcentral.com"; + +// Default credential file locations to check +const DEFAULT_CREDENTIALS_FILES = [ + "rc-credentials.json", + ".clawdbot/rc-credentials.json", +]; + +type CredentialsFile = { + clientId?: string; + clientSecret?: string; + jwt?: string; + server?: string; +}; + +let cachedFileCredentials: { path: string; data: CredentialsFile } | null = null; + +function findCredentialsFile(configFile?: string): { path: string; data: CredentialsFile } | null { + // If explicitly specified, use that + if (configFile?.trim()) { + const filePath = resolve(configFile.trim()); + if (existsSync(filePath)) { + try { + const content = readFileSync(filePath, "utf8"); + return { path: filePath, data: JSON.parse(content) as CredentialsFile }; + } catch { + return null; + } + } + return null; + } + + // Check env var for file path + const envFile = process.env[ENV_CREDENTIALS_FILE]?.trim(); + if (envFile) { + const filePath = resolve(envFile); + if (existsSync(filePath)) { + try { + const content = readFileSync(filePath, "utf8"); + return { path: filePath, data: JSON.parse(content) as CredentialsFile }; + } catch { + return null; + } + } + } + + // Check default locations + const searchPaths = [ + ...DEFAULT_CREDENTIALS_FILES.map((f) => resolve(process.cwd(), f)), + ...DEFAULT_CREDENTIALS_FILES.map((f) => resolve(homedir(), f)), + ]; + + for (const filePath of searchPaths) { + if (existsSync(filePath)) { + try { + const content = readFileSync(filePath, "utf8"); + return { path: filePath, data: JSON.parse(content) as CredentialsFile }; + } catch { + continue; + } + } + } + + return null; +} + +function loadFileCredentials(configFile?: string): CredentialsFile | null { + // Use cached if available and no specific file requested + if (!configFile && cachedFileCredentials) { + return cachedFileCredentials.data; + } + + const result = findCredentialsFile(configFile); + if (result) { + if (!configFile) { + cachedFileCredentials = result; + } + return result.data; + } + return null; +} + +function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] { + const accounts = (cfg.channels?.ringcentral as RingCentralConfig | undefined)?.accounts; + if (!accounts || typeof accounts !== "object") return []; + return Object.keys(accounts).filter(Boolean); +} + +export function listRingCentralAccountIds(cfg: ClawdbotConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; + return ids.sort((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultRingCentralAccountId(cfg: ClawdbotConfig): string { + const channel = cfg.channels?.ringcentral as RingCentralConfig | undefined; + if (channel?.defaultAccount?.trim()) return channel.defaultAccount.trim(); + const ids = listRingCentralAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function resolveAccountConfig( + cfg: ClawdbotConfig, + accountId: string, +): RingCentralAccountConfig | undefined { + const accounts = (cfg.channels?.ringcentral as RingCentralConfig | undefined)?.accounts; + if (!accounts || typeof accounts !== "object") return undefined; + return accounts[accountId] as RingCentralAccountConfig | undefined; +} + +function mergeRingCentralAccountConfig( + cfg: ClawdbotConfig, + accountId: string, +): RingCentralAccountConfig { + const raw = (cfg.channels?.ringcentral ?? {}) as RingCentralConfig; + const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account } as RingCentralAccountConfig; +} + +function resolveCredentialsFromConfig(params: { + accountId: string; + account: RingCentralAccountConfig; +}): { + clientId?: string; + clientSecret?: string; + jwt?: string; + server: string; + source: RingCentralCredentialSource; +} { + const { account, accountId } = params; + + const configClientId = account.clientId?.trim(); + const configClientSecret = account.clientSecret?.trim(); + const configJwt = account.jwt?.trim(); + const configServer = account.server?.trim() || DEFAULT_SERVER; + + // 1. Check inline config first + if (configClientId && configClientSecret && configJwt) { + return { + clientId: configClientId, + clientSecret: configClientSecret, + jwt: configJwt, + server: configServer, + source: "config", + }; + } + + // 2. Check credentials file (rc-credentials.json) + const fileCredentials = loadFileCredentials( + (account as { credentialsFile?: string }).credentialsFile, + ); + if (fileCredentials) { + const fileClientId = fileCredentials.clientId?.trim(); + const fileClientSecret = fileCredentials.clientSecret?.trim(); + const fileJwt = fileCredentials.jwt?.trim(); + const fileServer = fileCredentials.server?.trim() || DEFAULT_SERVER; + + if (fileClientId && fileClientSecret && fileJwt) { + return { + clientId: fileClientId, + clientSecret: fileClientSecret, + jwt: fileJwt, + server: fileServer, + source: "file", + }; + } + } + + // 3. Check environment variables (default account only) + if (accountId === DEFAULT_ACCOUNT_ID) { + const envClientId = process.env[ENV_CLIENT_ID]?.trim(); + const envClientSecret = process.env[ENV_CLIENT_SECRET]?.trim(); + const envJwt = process.env[ENV_JWT]?.trim(); + const envServer = process.env[ENV_SERVER]?.trim() || DEFAULT_SERVER; + + if (envClientId && envClientSecret && envJwt) { + return { + clientId: envClientId, + clientSecret: envClientSecret, + jwt: envJwt, + server: envServer, + source: "env", + }; + } + + // 4. Allow partial config + file + env fallback + const finalClientId = configClientId || fileCredentials?.clientId?.trim() || envClientId; + const finalClientSecret = configClientSecret || fileCredentials?.clientSecret?.trim() || envClientSecret; + const finalJwt = configJwt || fileCredentials?.jwt?.trim() || envJwt; + const finalServer = configServer !== DEFAULT_SERVER + ? configServer + : fileCredentials?.server?.trim() || envServer; + + if (finalClientId && finalClientSecret && finalJwt) { + const source: RingCentralCredentialSource = + configClientId || configClientSecret || configJwt + ? "config" + : fileCredentials?.clientId || fileCredentials?.clientSecret || fileCredentials?.jwt + ? "file" + : "env"; + return { + clientId: finalClientId, + clientSecret: finalClientSecret, + jwt: finalJwt, + server: finalServer, + source, + }; + } + } + + return { server: configServer, source: "none" }; +} + +export function resolveRingCentralAccount(params: { + cfg: ClawdbotConfig; + accountId?: string | null; +}): ResolvedRingCentralAccount { + const accountId = normalizeAccountId(params.accountId); + const baseEnabled = + (params.cfg.channels?.ringcentral as RingCentralConfig | undefined)?.enabled !== false; + const merged = mergeRingCentralAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + const credentials = resolveCredentialsFromConfig({ accountId, account: merged }); + + return { + accountId, + name: merged.name?.trim() || undefined, + enabled, + config: merged, + credentialSource: credentials.source, + clientId: credentials.clientId, + clientSecret: credentials.clientSecret, + jwt: credentials.jwt, + server: credentials.server, + }; +} + +export function listEnabledRingCentralAccounts(cfg: ClawdbotConfig): ResolvedRingCentralAccount[] { + return listRingCentralAccountIds(cfg) + .map((accountId) => resolveRingCentralAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/extensions/ringcentral/src/api.ts b/extensions/ringcentral/src/api.ts new file mode 100644 index 000000000..6b2311740 --- /dev/null +++ b/extensions/ringcentral/src/api.ts @@ -0,0 +1,220 @@ +import type { ResolvedRingCentralAccount } from "./accounts.js"; +import { getRingCentralPlatform } from "./auth.js"; +import type { + RingCentralChat, + RingCentralPost, + RingCentralUser, + RingCentralAttachment, +} from "./types.js"; + +// Team Messaging API endpoints +const TM_API_BASE = "/team-messaging/v1"; + +export async function sendRingCentralMessage(params: { + account: ResolvedRingCentralAccount; + chatId: string; + text?: string; + attachments?: Array<{ id: string; type?: string }>; +}): Promise<{ postId?: string } | null> { + const { account, chatId, text, attachments } = params; + const platform = await getRingCentralPlatform(account); + + const body: Record = {}; + if (text) body.text = text; + if (attachments && attachments.length > 0) { + body.attachments = attachments; + } + + const response = await platform.post(`${TM_API_BASE}/chats/${chatId}/posts`, body); + const result = (await response.json()) as RingCentralPost; + return result ? { postId: result.id } : null; +} + +export async function updateRingCentralMessage(params: { + account: ResolvedRingCentralAccount; + chatId: string; + postId: string; + text: string; +}): Promise<{ postId?: string }> { + const { account, chatId, postId, text } = params; + const platform = await getRingCentralPlatform(account); + + const response = await platform.patch( + `${TM_API_BASE}/chats/${chatId}/posts/${postId}`, + { text }, + ); + const result = (await response.json()) as RingCentralPost; + return { postId: result.id }; +} + +export async function deleteRingCentralMessage(params: { + account: ResolvedRingCentralAccount; + chatId: string; + postId: string; +}): Promise { + const { account, chatId, postId } = params; + const platform = await getRingCentralPlatform(account); + await platform.delete(`${TM_API_BASE}/chats/${chatId}/posts/${postId}`); +} + +export async function getRingCentralChat(params: { + account: ResolvedRingCentralAccount; + chatId: string; +}): Promise { + const { account, chatId } = params; + const platform = await getRingCentralPlatform(account); + + try { + const response = await platform.get(`${TM_API_BASE}/chats/${chatId}`); + return (await response.json()) as RingCentralChat; + } catch { + return null; + } +} + +export async function listRingCentralChats(params: { + account: ResolvedRingCentralAccount; + type?: string[]; + limit?: number; +}): Promise { + const { account, type, limit } = params; + const platform = await getRingCentralPlatform(account); + + const queryParams: Record = {}; + if (type && type.length > 0) queryParams.type = type.join(","); + if (limit) queryParams.recordCount = String(limit); + + const response = await platform.get(`${TM_API_BASE}/chats`, queryParams); + const result = (await response.json()) as { records?: RingCentralChat[] }; + return result.records ?? []; +} + +export async function getRingCentralUser(params: { + account: ResolvedRingCentralAccount; + userId: string; +}): Promise { + const { account, userId } = params; + const platform = await getRingCentralPlatform(account); + + try { + const response = await platform.get(`${TM_API_BASE}/persons/${userId}`); + return (await response.json()) as RingCentralUser; + } catch { + return null; + } +} + +export async function getCurrentRingCentralUser(params: { + account: ResolvedRingCentralAccount; +}): Promise { + const { account } = params; + const platform = await getRingCentralPlatform(account); + + try { + const response = await platform.get("/restapi/v1.0/account/~/extension/~"); + return (await response.json()) as RingCentralUser; + } catch { + return null; + } +} + +export async function uploadRingCentralAttachment(params: { + account: ResolvedRingCentralAccount; + chatId: string; + filename: string; + buffer: Buffer; + contentType?: string; +}): Promise<{ attachmentId?: string }> { + const { account, chatId, filename, buffer, contentType } = params; + const platform = await getRingCentralPlatform(account); + + // Create FormData for multipart upload + const formData = new FormData(); + const blob = new Blob([buffer], { type: contentType || "application/octet-stream" }); + formData.append("file", blob, filename); + + const response = await platform.post( + `${TM_API_BASE}/chats/${chatId}/files`, + formData, + ); + const result = (await response.json()) as RingCentralAttachment; + return { attachmentId: result.id }; +} + +export async function downloadRingCentralAttachment(params: { + account: ResolvedRingCentralAccount; + contentUri: string; + maxBytes?: number; +}): Promise<{ buffer: Buffer; contentType?: string }> { + const { account, contentUri, maxBytes } = params; + const platform = await getRingCentralPlatform(account); + + const response = await platform.get(contentUri); + const contentType = response.headers.get("content-type") ?? undefined; + const arrayBuffer = await response.arrayBuffer(); + + if (maxBytes && arrayBuffer.byteLength > maxBytes) { + throw new Error(`RingCentral attachment exceeds max bytes (${maxBytes})`); + } + + return { + buffer: Buffer.from(arrayBuffer), + contentType, + }; +} + +export async function createRingCentralWebhookSubscription(params: { + account: ResolvedRingCentralAccount; + webhookUrl: string; + eventFilters: string[]; + expiresIn?: number; +}): Promise<{ subscriptionId?: string; expiresIn?: number }> { + const { account, webhookUrl, eventFilters, expiresIn = 604799 } = params; + const platform = await getRingCentralPlatform(account); + + const body = { + eventFilters, + deliveryMode: { + transportType: "WebHook", + address: webhookUrl, + }, + expiresIn, + }; + + const response = await platform.post("/restapi/v1.0/subscription", body); + const result = (await response.json()) as { id?: string; expiresIn?: number }; + return { + subscriptionId: result.id, + expiresIn: result.expiresIn, + }; +} + +export async function deleteRingCentralWebhookSubscription(params: { + account: ResolvedRingCentralAccount; + subscriptionId: string; +}): Promise { + const { account, subscriptionId } = params; + const platform = await getRingCentralPlatform(account); + await platform.delete(`/restapi/v1.0/subscription/${subscriptionId}`); +} + +export async function probeRingCentral( + account: ResolvedRingCentralAccount, +): Promise<{ ok: boolean; error?: string; elapsedMs: number }> { + const start = Date.now(); + try { + const user = await getCurrentRingCentralUser({ account }); + const elapsedMs = Date.now() - start; + if (user?.id) { + return { ok: true, elapsedMs }; + } + return { ok: false, error: "Unable to fetch current user", elapsedMs }; + } catch (err) { + const elapsedMs = Date.now() - start; + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + elapsedMs, + }; + } +} diff --git a/extensions/ringcentral/src/auth.ts b/extensions/ringcentral/src/auth.ts new file mode 100644 index 000000000..166edefb7 --- /dev/null +++ b/extensions/ringcentral/src/auth.ts @@ -0,0 +1,92 @@ +import { SDK } from "@ringcentral/sdk"; + +import type { ResolvedRingCentralAccount } from "./accounts.js"; + +export type SDKInstance = InstanceType; + +const sdkCache = new Map }>(); + +function buildAuthKey(account: ResolvedRingCentralAccount): string { + return `${account.clientId}:${account.server}:${account.jwt?.slice(0, 20)}`; +} + +async function getSDKInstance(account: ResolvedRingCentralAccount): Promise<{ + sdk: SDKInstance; + platform: ReturnType; +}> { + const key = buildAuthKey(account); + const cached = sdkCache.get(account.accountId); + + if (cached && cached.key === key) { + // Check if still logged in + const platform = cached.platform; + try { + const loggedIn = await platform.loggedIn(); + if (loggedIn) { + return cached; + } + } catch { + // Token expired or invalid, need to re-login + } + } + + if (!account.clientId || !account.clientSecret) { + throw new Error("RingCentral clientId and clientSecret are required"); + } + + if (!account.jwt) { + throw new Error("RingCentral JWT token is required for authentication"); + } + + const sdk = new SDK({ + server: account.server, + clientId: account.clientId, + clientSecret: account.clientSecret, + }); + + const platform = sdk.platform(); + + // Login using JWT + await platform.login({ jwt: account.jwt }); + + const entry = { key, sdk, platform }; + sdkCache.set(account.accountId, entry); + return entry; +} + +export async function getRingCentralSDK( + account: ResolvedRingCentralAccount, +): Promise { + const { sdk } = await getSDKInstance(account); + return sdk; +} + +export async function getRingCentralPlatform( + account: ResolvedRingCentralAccount, +): Promise> { + const { platform } = await getSDKInstance(account); + return platform; +} + +export async function getRingCentralAccessToken( + account: ResolvedRingCentralAccount, +): Promise { + const { platform } = await getSDKInstance(account); + const authData = await platform.auth().data(); + const token = authData?.access_token; + if (!token) { + throw new Error("Missing RingCentral access token"); + } + return token; +} + +export async function refreshRingCentralToken( + account: ResolvedRingCentralAccount, +): Promise { + const { platform } = await getSDKInstance(account); + await platform.refresh(); +} + +export function clearRingCentralAuth(accountId: string): void { + sdkCache.delete(accountId); +} diff --git a/extensions/ringcentral/src/channel.ts b/extensions/ringcentral/src/channel.ts new file mode 100644 index 000000000..6b9fd05b4 --- /dev/null +++ b/extensions/ringcentral/src/channel.ts @@ -0,0 +1,555 @@ +import { + applyAccountNameToChannelSection, + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatPairingApproveHint, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, + missingTargetError, + normalizeAccountId, + PAIRING_APPROVED_MESSAGE, + resolveChannelMediaMaxBytes, + setAccountEnabledInConfigSection, + type ChannelDock, + type ChannelPlugin, + type ClawdbotConfig, +} from "clawdbot/plugin-sdk"; + +import { + listRingCentralAccountIds, + resolveDefaultRingCentralAccountId, + resolveRingCentralAccount, + type ResolvedRingCentralAccount, +} from "./accounts.js"; +import { RingCentralConfigSchema } from "./config-schema.js"; +import { + sendRingCentralMessage, + uploadRingCentralAttachment, + probeRingCentral, +} from "./api.js"; +import { getRingCentralRuntime } from "./runtime.js"; +import { resolveRingCentralWebhookPath, startRingCentralMonitor } from "./monitor.js"; +import { + normalizeRingCentralTarget, + isRingCentralChatTarget, + parseRingCentralTarget, +} from "./targets.js"; +import type { RingCentralConfig } from "./types.js"; + +const meta = getChatChannelMeta("ringcentral"); + +const formatAllowFromEntry = (entry: string) => + entry + .trim() + .replace(/^(ringcentral|rc):/i, "") + .replace(/^user:/i, "") + .toLowerCase(); + +export const ringcentralDock: ChannelDock = { + id: "ringcentral", + capabilities: { + chatTypes: ["direct", "group", "thread"], + reactions: false, + media: true, + threads: false, + blockStreaming: true, + }, + outbound: { textChunkLimit: 4000 }, + config: { + resolveAllowFrom: ({ cfg, accountId }) => + (resolveRingCentralAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.dm?.allowFrom ?? + [] + ).map((entry) => String(entry)), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry)) + .filter(Boolean) + .map(formatAllowFromEntry), + }, + groups: { + resolveRequireMention: ({ cfg, accountId }) => { + const account = resolveRingCentralAccount({ cfg: cfg as ClawdbotConfig, accountId }); + return account.config.requireMention ?? true; + }, + }, + threading: { + resolveReplyToMode: ({ cfg }) => + (cfg.channels?.ringcentral as RingCentralConfig | undefined)?.replyToMode ?? "off", + buildToolContext: ({ context, hasRepliedRef }) => ({ + currentChannelId: context.To?.trim() || undefined, + currentThreadTs: undefined, + hasRepliedRef, + }), + }, +}; + +export const ringcentralPlugin: ChannelPlugin = { + id: "ringcentral", + meta: { + ...meta, + label: "RingCentral", + selectionLabel: "RingCentral Team Messaging", + blurb: "RingCentral Team Messaging via REST API and webhooks.", + order: 56, + }, + pairing: { + idLabel: "ringcentralUserId", + normalizeAllowEntry: (entry) => formatAllowFromEntry(entry), + notifyApproval: async ({ cfg, id }) => { + const account = resolveRingCentralAccount({ cfg: cfg as ClawdbotConfig }); + if (account.credentialSource === "none") return; + const target = normalizeRingCentralTarget(id) ?? id; + // For DM approval, we need to find/create a direct chat + // This is a simplified version - in production you'd need to resolve the chat ID + try { + await sendRingCentralMessage({ + account, + chatId: target, + text: PAIRING_APPROVED_MESSAGE, + }); + } catch { + // Approval notification failed, but pairing still succeeds + } + }, + }, + capabilities: { + chatTypes: ["direct", "group"], + reactions: false, + threads: false, + media: true, + nativeCommands: false, + blockStreaming: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.ringcentral"] }, + configSchema: buildChannelConfigSchema(RingCentralConfigSchema), + config: { + listAccountIds: (cfg) => listRingCentralAccountIds(cfg as ClawdbotConfig), + resolveAccount: (cfg, accountId) => + resolveRingCentralAccount({ cfg: cfg as ClawdbotConfig, accountId }), + defaultAccountId: (cfg) => resolveDefaultRingCentralAccountId(cfg as ClawdbotConfig), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg: cfg as ClawdbotConfig, + sectionKey: "ringcentral", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg: cfg as ClawdbotConfig, + sectionKey: "ringcentral", + accountId, + clearBaseFields: [ + "clientId", + "clientSecret", + "jwt", + "server", + "webhookPath", + "webhookVerificationToken", + "name", + ], + }), + isConfigured: (account) => account.credentialSource !== "none", + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.credentialSource !== "none", + credentialSource: account.credentialSource, + server: account.server, + }), + resolveAllowFrom: ({ cfg, accountId }) => + (resolveRingCentralAccount({ + cfg: cfg as ClawdbotConfig, + accountId, + }).config.dm?.allowFrom ?? [] + ).map((entry) => String(entry)), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry)) + .filter(Boolean) + .map(formatAllowFromEntry), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean( + (cfg as ClawdbotConfig).channels?.ringcentral?.accounts?.[resolvedAccountId], + ); + const allowFromPath = useAccountPath + ? `channels.ringcentral.accounts.${resolvedAccountId}.dm.` + : "channels.ringcentral.dm."; + return { + policy: account.config.dm?.policy ?? "pairing", + allowFrom: account.config.dm?.allowFrom ?? [], + allowFromPath, + approveHint: formatPairingApproveHint("ringcentral"), + normalizeEntry: (raw) => formatAllowFromEntry(raw), + }; + }, + collectWarnings: ({ account, cfg }) => { + const warnings: string[] = []; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy === "open") { + warnings.push( + `- RingCentral chats: groupPolicy="open" allows any chat to trigger (mention-gated). Set channels.ringcentral.groupPolicy="allowlist" and configure channels.ringcentral.groups.`, + ); + } + if (account.config.dm?.policy === "open") { + warnings.push( + `- RingCentral DMs are open to anyone. Set channels.ringcentral.dm.policy="pairing" or "allowlist".`, + ); + } + return warnings; + }, + }, + groups: { + resolveRequireMention: ({ cfg, accountId }) => { + const account = resolveRingCentralAccount({ cfg: cfg as ClawdbotConfig, accountId }); + return account.config.requireMention ?? true; + }, + }, + threading: { + resolveReplyToMode: ({ cfg }) => + (cfg.channels?.ringcentral as RingCentralConfig | undefined)?.replyToMode ?? "off", + }, + messaging: { + normalizeTarget: normalizeRingCentralTarget, + targetResolver: { + looksLikeId: (raw, normalized) => { + const value = normalized ?? raw.trim(); + return isRingCentralChatTarget(value); + }, + hint: "", + }, + }, + directory: { + self: async () => null, + listPeers: async ({ cfg, accountId, query, limit }) => { + const account = resolveRingCentralAccount({ + cfg: cfg as ClawdbotConfig, + accountId, + }); + const q = query?.trim().toLowerCase() || ""; + const allowFrom = account.config.dm?.allowFrom ?? []; + const peers = Array.from( + new Set( + allowFrom + .map((entry) => String(entry).trim()) + .filter((entry) => Boolean(entry) && entry !== "*") + .map((entry) => normalizeRingCentralTarget(entry) ?? entry), + ), + ) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "user", id }) as const); + return peers; + }, + listGroups: async ({ cfg, accountId, query, limit }) => { + const account = resolveRingCentralAccount({ + cfg: cfg as ClawdbotConfig, + accountId, + }); + const groups = account.config.groups ?? {}; + const q = query?.trim().toLowerCase() || ""; + const entries = Object.keys(groups) + .filter((key) => key && key !== "*") + .filter((key) => (q ? key.toLowerCase().includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "group", id }) as const); + return entries; + }, + }, + resolver: { + resolveTargets: async ({ inputs, kind }) => { + const resolved = inputs.map((input) => { + const parsed = parseRingCentralTarget(input); + if (parsed.type === "unknown" || !parsed.id) { + return { input, resolved: false, note: "invalid target format" }; + } + if (kind === "user" && parsed.type === "user") { + return { input, resolved: true, id: parsed.id }; + } + if (kind === "group" && parsed.type === "chat") { + return { input, resolved: true, id: parsed.id }; + } + return { + input, + resolved: false, + note: "use rc:chat: or rc:user:", + }; + }); + return resolved; + }, + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg as ClawdbotConfig, + channelKey: "ringcentral", + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "RINGCENTRAL_* env vars can only be used for the default account."; + } + if (!input.useEnv && (!input.clientId || !input.clientSecret || !input.jwt)) { + return "RingCentral requires --client-id, --client-secret, and --jwt (or use --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg: cfg as ClawdbotConfig, + channelKey: "ringcentral", + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig as ClawdbotConfig, + channelKey: "ringcentral", + }) + : namedConfig; + const patch = input.useEnv + ? {} + : { + ...(input.clientId ? { clientId: input.clientId } : {}), + ...(input.clientSecret ? { clientSecret: input.clientSecret } : {}), + ...(input.jwt ? { jwt: input.jwt } : {}), + }; + const server = input.server?.trim(); + const webhookPath = input.webhookPath?.trim(); + const configPatch = { + ...patch, + ...(server ? { server } : {}), + ...(webhookPath ? { webhookPath } : {}), + }; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + ringcentral: { + ...(next.channels?.ringcentral ?? {}), + enabled: true, + ...configPatch, + }, + }, + } as ClawdbotConfig; + } + return { + ...next, + channels: { + ...next.channels, + ringcentral: { + ...(next.channels?.ringcentral ?? {}), + enabled: true, + accounts: { + ...(next.channels?.ringcentral?.accounts ?? {}), + [accountId]: { + ...(next.channels?.ringcentral?.accounts?.[accountId] ?? {}), + enabled: true, + ...configPatch, + }, + }, + }, + }, + } as ClawdbotConfig; + }, + }, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => + getRingCentralRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + resolveTarget: ({ to, allowFrom, mode }) => { + const trimmed = to?.trim() ?? ""; + const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean); + const allowList = allowListRaw + .filter((entry) => entry !== "*") + .map((entry) => normalizeRingCentralTarget(entry)) + .filter((entry): entry is string => Boolean(entry)); + + if (trimmed) { + const normalized = normalizeRingCentralTarget(trimmed); + if (!normalized) { + if ((mode === "implicit" || mode === "heartbeat") && allowList.length > 0) { + return { ok: true, to: allowList[0] }; + } + return { + ok: false, + error: missingTargetError( + "RingCentral", + " or channels.ringcentral.dm.allowFrom[0]", + ), + }; + } + return { ok: true, to: normalized }; + } + + if (allowList.length > 0) { + return { ok: true, to: allowList[0] }; + } + return { + ok: false, + error: missingTargetError( + "RingCentral", + " or channels.ringcentral.dm.allowFrom[0]", + ), + }; + }, + sendText: async ({ cfg, to, text, accountId }) => { + const account = resolveRingCentralAccount({ + cfg: cfg as ClawdbotConfig, + accountId, + }); + const result = await sendRingCentralMessage({ + account, + chatId: to, + text, + }); + return { + channel: "ringcentral", + messageId: result?.postId ?? "", + chatId: to, + }; + }, + sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => { + if (!mediaUrl) { + throw new Error("RingCentral mediaUrl is required."); + } + const account = resolveRingCentralAccount({ + cfg: cfg as ClawdbotConfig, + accountId, + }); + const runtime = getRingCentralRuntime(); + const maxBytes = resolveChannelMediaMaxBytes({ + cfg: cfg as ClawdbotConfig, + resolveChannelLimitMb: ({ cfg: c, accountId: aid }) => + (c.channels?.ringcentral as { accounts?: Record; mediaMaxMb?: number } | undefined) + ?.accounts?.[aid]?.mediaMaxMb ?? + (c.channels?.ringcentral as { mediaMaxMb?: number } | undefined)?.mediaMaxMb, + accountId, + }); + const loaded = await runtime.channel.media.fetchRemoteMedia(mediaUrl, { + maxBytes: maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024, + }); + const upload = await uploadRingCentralAttachment({ + account, + chatId: to, + filename: loaded.filename ?? "attachment", + buffer: loaded.buffer, + contentType: loaded.contentType, + }); + const result = await sendRingCentralMessage({ + account, + chatId: to, + text, + attachments: upload.attachmentId ? [{ id: upload.attachmentId }] : undefined, + }); + return { + channel: "ringcentral", + messageId: result?.postId ?? "", + chatId: to, + }; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: (accounts) => + accounts.flatMap((entry) => { + const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID); + const enabled = entry.enabled !== false; + const configured = entry.configured === true; + if (!enabled || !configured) return []; + const issues = []; + if (!entry.clientId) { + issues.push({ + channel: "ringcentral", + accountId, + kind: "config", + message: "RingCentral clientId is missing.", + fix: "Set channels.ringcentral.clientId or use rc-credentials.json.", + }); + } + return issues; + }), + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + credentialSource: snapshot.credentialSource ?? "none", + server: snapshot.server ?? null, + webhookPath: snapshot.webhookPath ?? null, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account }) => probeRingCentral(account), + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.credentialSource !== "none", + credentialSource: account.credentialSource, + server: account.server, + clientId: account.clientId ? `${account.clientId.slice(0, 8)}...` : undefined, + webhookPath: account.config.webhookPath, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + dmPolicy: account.config.dm?.policy ?? "pairing", + probe, + }), + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + ctx.log?.info(`[${account.accountId}] starting RingCentral webhook`); + ctx.setStatus({ + accountId: account.accountId, + running: true, + lastStartAt: Date.now(), + webhookPath: resolveRingCentralWebhookPath({ account }), + server: account.server, + }); + const unregister = await startRingCentralMonitor({ + account, + config: ctx.cfg as ClawdbotConfig, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + webhookPath: account.config.webhookPath, + statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }), + }); + return () => { + unregister?.(); + ctx.setStatus({ + accountId: account.accountId, + running: false, + lastStopAt: Date.now(), + }); + }; + }, + }, +}; diff --git a/extensions/ringcentral/src/config-schema.ts b/extensions/ringcentral/src/config-schema.ts new file mode 100644 index 000000000..e37b9a3af --- /dev/null +++ b/extensions/ringcentral/src/config-schema.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +import { + BlockStreamingCoalesceSchema, + DmPolicySchema, + GroupPolicySchema, + MarkdownConfigSchema, + requireOpenAllowFrom, +} from "clawdbot/plugin-sdk"; + +const RingCentralGroupConfigSchema = z + .object({ + chatId: z.string().optional(), + requireMention: z.boolean().optional(), + allow: z.boolean().optional(), + enabled: z.boolean().optional(), + users: z.array(z.union([z.string(), z.number()])).optional(), + systemPrompt: z.string().optional(), + }) + .strict(); + +const RingCentralAccountSchemaBase = z + .object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + clientId: z.string().optional(), + clientSecret: z.string().optional(), + jwt: z.string().optional(), + server: z.string().optional(), + credentialsFile: z.string().optional(), + webhookPath: z.string().optional(), + webhookVerificationToken: z.string().optional(), + markdown: MarkdownConfigSchema, + dmPolicy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groups: z.record(z.string(), RingCentralGroupConfigSchema.optional()).optional(), + requireMention: z.boolean().optional(), + mediaMaxMb: z.number().int().positive().optional(), + textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + allowBots: z.boolean().optional(), + botExtensionId: z.string().optional(), + }) + .strict(); + +const RingCentralAccountSchema = RingCentralAccountSchemaBase.superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.ringcentral.dmPolicy="open" requires channels.ringcentral.allowFrom to include "*"', + }); +}); + +export const RingCentralConfigSchema = RingCentralAccountSchemaBase.extend({ + accounts: z.record(z.string(), RingCentralAccountSchema.optional()).optional(), + defaultAccount: z.string().optional(), +}).superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.ringcentral.dmPolicy="open" requires channels.ringcentral.allowFrom to include "*"', + }); +}); + +export type { RingCentralAccountConfig, RingCentralConfig } from "./types.js"; diff --git a/extensions/ringcentral/src/monitor.ts b/extensions/ringcentral/src/monitor.ts new file mode 100644 index 000000000..c1320e0fb --- /dev/null +++ b/extensions/ringcentral/src/monitor.ts @@ -0,0 +1,690 @@ +import { Subscriptions } from "@ringcentral/subscriptions"; + +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { resolveMentionGatingWithBypass } from "clawdbot/plugin-sdk"; + +import type { ResolvedRingCentralAccount } from "./accounts.js"; +import { getRingCentralSDK } from "./auth.js"; +import { + sendRingCentralMessage, + updateRingCentralMessage, + deleteRingCentralMessage, + downloadRingCentralAttachment, + uploadRingCentralAttachment, + getRingCentralChat, +} from "./api.js"; +import { getRingCentralRuntime } from "./runtime.js"; +import type { + RingCentralWebhookEvent, + RingCentralEventBody, + RingCentralAttachment, + RingCentralMention, +} from "./types.js"; + +export type RingCentralRuntimeEnv = { + log?: (message: string) => void; + error?: (message: string) => void; +}; + +export type RingCentralMonitorOptions = { + account: ResolvedRingCentralAccount; + config: ClawdbotConfig; + runtime: RingCentralRuntimeEnv; + abortSignal: AbortSignal; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}; + +type RingCentralCoreRuntime = ReturnType; + +function logVerbose( + core: RingCentralCoreRuntime, + runtime: RingCentralRuntimeEnv, + message: string, +) { + if (core.logging.shouldLogVerbose()) { + runtime.log?.(`[ringcentral] ${message}`); + } +} + +function normalizeUserId(raw?: string | null): string { + const trimmed = raw?.trim() ?? ""; + if (!trimmed) return ""; + return trimmed.toLowerCase(); +} + +export function isSenderAllowed( + senderId: string, + allowFrom: string[], +): boolean { + if (allowFrom.includes("*")) return true; + const normalizedSenderId = normalizeUserId(senderId); + return allowFrom.some((entry) => { + const normalized = String(entry).trim().toLowerCase(); + if (!normalized) return false; + if (normalized === normalizedSenderId) return true; + if (normalized.replace(/^(ringcentral|rc):/i, "") === normalizedSenderId) return true; + if (normalized.replace(/^user:/i, "") === normalizedSenderId) return true; + return false; + }); +} + +function resolveGroupConfig(params: { + groupId: string; + groupName?: string | null; + groups?: Record; systemPrompt?: string }>; +}) { + const { groupId, groupName, groups } = params; + const entries = groups ?? {}; + const keys = Object.keys(entries); + if (keys.length === 0) { + return { entry: undefined, allowlistConfigured: false }; + } + const normalizedName = groupName?.trim().toLowerCase(); + const candidates = [groupId, groupName ?? "", normalizedName ?? ""].filter(Boolean); + let entry = candidates.map((candidate) => entries[candidate]).find(Boolean); + if (!entry && normalizedName) { + entry = entries[normalizedName]; + } + const fallback = entries["*"]; + return { entry: entry ?? fallback, allowlistConfigured: true, fallback }; +} + +function extractMentionInfo(mentions: RingCentralMention[], botExtensionId?: string | null) { + const personMentions = mentions.filter((entry) => entry.type === "Person"); + const hasAnyMention = personMentions.length > 0; + const wasMentioned = botExtensionId + ? personMentions.some((entry) => entry.id === botExtensionId) + : false; + return { hasAnyMention, wasMentioned }; +} + +function resolveBotDisplayName(params: { + accountName?: string; + agentId: string; + config: ClawdbotConfig; +}): string { + const { accountName, agentId, config } = params; + if (accountName?.trim()) return accountName.trim(); + const agent = config.agents?.list?.find((a) => a.id === agentId); + if (agent?.name?.trim()) return agent.name.trim(); + return "Clawdbot"; +} + +async function processWebSocketEvent(params: { + event: RingCentralWebhookEvent; + account: ResolvedRingCentralAccount; + config: ClawdbotConfig; + runtime: RingCentralRuntimeEnv; + core: RingCentralCoreRuntime; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + ownerId?: string; +}): Promise { + const { event, account, config, runtime, core, statusSink, ownerId } = params; + + const eventBody = event.body; + if (!eventBody) return; + + const eventType = eventBody.eventType; + if (eventType !== "PostAdded") return; + + statusSink?.({ lastInboundAt: Date.now() }); + + await processMessageWithPipeline({ + eventBody, + account, + config, + runtime, + core, + statusSink, + ownerId, + }); +} + +async function processMessageWithPipeline(params: { + eventBody: RingCentralEventBody; + account: ResolvedRingCentralAccount; + config: ClawdbotConfig; + runtime: RingCentralRuntimeEnv; + core: RingCentralCoreRuntime; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + ownerId?: string; +}): Promise { + const { eventBody, account, config, runtime, core, statusSink, ownerId } = params; + const mediaMaxMb = account.config.mediaMaxMb ?? 20; + + const chatId = eventBody.groupId ?? ""; + if (!chatId) return; + + const senderId = eventBody.creatorId ?? ""; + const messageText = (eventBody.text ?? "").trim(); + const attachments = eventBody.attachments ?? []; + const hasMedia = attachments.length > 0; + const rawBody = messageText || (hasMedia ? "" : ""); + if (!rawBody) return; + + // Skip messages from self (bot) + if (ownerId && senderId === ownerId) { + logVerbose(core, runtime, "skip self-authored message"); + return; + } + + // Fetch chat info to determine type + let chatType = "Group"; + let chatName: string | undefined; + try { + const chatInfo = await getRingCentralChat({ account, chatId }); + chatType = chatInfo?.type ?? "Group"; + chatName = chatInfo?.name ?? undefined; + } catch { + // If we can't fetch chat info, assume it's a group + } + + const isGroup = chatType !== "Direct" && chatType !== "PersonalChat"; + + const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const groupConfigResolved = resolveGroupConfig({ + groupId: chatId, + groupName: chatName ?? null, + groups: account.config.groups ?? undefined, + }); + const groupEntry = groupConfigResolved.entry; + const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? []; + let effectiveWasMentioned: boolean | undefined; + + if (isGroup) { + if (groupPolicy === "disabled") { + logVerbose(core, runtime, `drop group message (groupPolicy=disabled, chat=${chatId})`); + return; + } + const groupAllowlistConfigured = groupConfigResolved.allowlistConfigured; + const groupAllowed = + Boolean(groupEntry) || Boolean((account.config.groups ?? {})["*"]); + if (groupPolicy === "allowlist") { + if (!groupAllowlistConfigured) { + logVerbose( + core, + runtime, + `drop group message (groupPolicy=allowlist, no allowlist, chat=${chatId})`, + ); + return; + } + if (!groupAllowed) { + logVerbose(core, runtime, `drop group message (not allowlisted, chat=${chatId})`); + return; + } + } + if (groupEntry?.enabled === false || groupEntry?.allow === false) { + logVerbose(core, runtime, `drop group message (chat disabled, chat=${chatId})`); + return; + } + + if (groupUsers.length > 0) { + const ok = isSenderAllowed(senderId, groupUsers.map((v) => String(v))); + if (!ok) { + logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`); + return; + } + } + } + + const dmPolicy = account.config.dm?.policy ?? account.config.dmPolicy ?? "pairing"; + const configAllowFrom = account.config.dm?.allowFrom ?? account.config.allowFrom ?? []; + const configAllowFromStr = configAllowFrom.map((v) => String(v)); + const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config); + const storeAllowFrom = + !isGroup && (dmPolicy !== "open" || shouldComputeAuth) + ? await core.channel.pairing.readAllowFromStore("ringcentral").catch(() => []) + : []; + const effectiveAllowFrom = [...configAllowFromStr, ...storeAllowFrom]; + const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom; + const useAccessGroups = config.commands?.useAccessGroups !== false; + const senderAllowedForCommands = isSenderAllowed(senderId, commandAllowFrom); + const commandAuthorized = shouldComputeAuth + ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers: [ + { configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands }, + ], + }) + : undefined; + + if (isGroup) { + const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true; + const mentions = eventBody.mentions ?? []; + const mentionInfo = extractMentionInfo(mentions, account.config.botExtensionId); + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg: config, + surface: "ringcentral", + }); + const mentionGate = resolveMentionGatingWithBypass({ + isGroup: true, + requireMention, + canDetectMention: Boolean(account.config.botExtensionId), + wasMentioned: mentionInfo.wasMentioned, + implicitMention: false, + hasAnyMention: mentionInfo.hasAnyMention, + allowTextCommands, + hasControlCommand: core.channel.text.hasControlCommand(rawBody, config), + commandAuthorized: commandAuthorized === true, + }); + effectiveWasMentioned = mentionGate.effectiveWasMentioned; + if (mentionGate.shouldSkip) { + logVerbose(core, runtime, `drop group message (mention required, chat=${chatId})`); + return; + } + } + + if (!isGroup) { + const dmEnabled = account.config.dm?.enabled !== false; + if (dmPolicy === "disabled" || !dmEnabled) { + logVerbose(core, runtime, `Blocked RingCentral DM from ${senderId} (dmPolicy=disabled)`); + return; + } + + if (dmPolicy !== "open") { + const allowed = senderAllowedForCommands; + if (!allowed) { + if (dmPolicy === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "ringcentral", + id: senderId, + meta: {}, + }); + if (created) { + logVerbose(core, runtime, `ringcentral pairing request sender=${senderId}`); + try { + await sendRingCentralMessage({ + account, + chatId, + text: core.channel.pairing.buildPairingReply({ + channel: "ringcentral", + idLine: `Your RingCentral user id: ${senderId}`, + code, + }), + }); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + logVerbose(core, runtime, `pairing reply failed for ${senderId}: ${String(err)}`); + } + } + } else { + logVerbose( + core, + runtime, + `Blocked unauthorized RingCentral sender ${senderId} (dmPolicy=${dmPolicy})`, + ); + } + return; + } + } + } + + if ( + isGroup && + core.channel.commands.isControlCommandMessage(rawBody, config) && + commandAuthorized !== true + ) { + logVerbose(core, runtime, `ringcentral: drop control command from ${senderId}`); + return; + } + + const route = core.channel.routing.resolveAgentRoute({ + cfg: config, + channel: "ringcentral", + accountId: account.accountId, + peer: { + kind: isGroup ? "group" : "dm", + id: chatId, + }, + }); + + let mediaPath: string | undefined; + let mediaType: string | undefined; + if (attachments.length > 0) { + const first = attachments[0]; + const attachmentData = await downloadAttachment(first, account, mediaMaxMb, core); + if (attachmentData) { + mediaPath = attachmentData.path; + mediaType = attachmentData.contentType; + } + } + + const fromLabel = isGroup + ? chatName || `chat:${chatId}` + : `user:${senderId}`; + const storePath = core.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const body = core.channel.reply.formatAgentEnvelope({ + channel: "RingCentral", + from: fromLabel, + timestamp: eventBody.creationTime ? Date.parse(eventBody.creationTime) : undefined, + previousTimestamp, + envelope: envelopeOptions, + body: rawBody, + }); + + const groupSystemPrompt = groupConfigResolved.entry?.systemPrompt?.trim() || undefined; + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: rawBody, + CommandBody: rawBody, + From: `ringcentral:${senderId}`, + To: `ringcentral:${chatId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isGroup ? "channel" : "direct", + ConversationLabel: fromLabel, + SenderId: senderId, + WasMentioned: isGroup ? effectiveWasMentioned : undefined, + CommandAuthorized: commandAuthorized, + Provider: "ringcentral", + Surface: "ringcentral", + MessageSid: eventBody.id, + MessageSidFull: eventBody.id, + MediaPath: mediaPath, + MediaType: mediaType, + MediaUrl: mediaPath, + GroupSpace: isGroup ? chatName ?? undefined : undefined, + GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined, + OriginatingChannel: "ringcentral", + OriginatingTo: `ringcentral:${chatId}`, + }); + + void core.channel.session + .recordSessionMetaFromInbound({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + }) + .catch((err) => { + runtime.error?.(`ringcentral: failed updating session meta: ${String(err)}`); + }); + + let typingPostId: string | undefined; + + // Send typing indicator message + try { + const botName = resolveBotDisplayName({ + accountName: account.config.name, + agentId: route.agentId, + config, + }); + const result = await sendRingCentralMessage({ + account, + chatId, + text: `_${botName} is typing..._`, + }); + typingPostId = result?.postId; + } catch (err) { + runtime.error?.(`Failed sending typing message: ${String(err)}`); + } + + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: config, + dispatcherOptions: { + deliver: async (payload) => { + await deliverRingCentralReply({ + payload, + account, + chatId, + runtime, + core, + config, + statusSink, + typingPostId, + }); + typingPostId = undefined; + }, + onError: (err, info) => { + runtime.error?.( + `[${account.accountId}] RingCentral ${info.kind} reply failed: ${String(err)}`, + ); + }, + }, + }); +} + +async function downloadAttachment( + attachment: RingCentralAttachment, + account: ResolvedRingCentralAccount, + mediaMaxMb: number, + core: RingCentralCoreRuntime, +): Promise<{ path: string; contentType?: string } | null> { + const contentUri = attachment.contentUri; + if (!contentUri) return null; + const maxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024; + const downloaded = await downloadRingCentralAttachment({ account, contentUri, maxBytes }); + const saved = await core.channel.media.saveMediaBuffer( + downloaded.buffer, + downloaded.contentType ?? attachment.contentType, + "inbound", + maxBytes, + attachment.name, + ); + return { path: saved.path, contentType: saved.contentType }; +} + +async function deliverRingCentralReply(params: { + payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }; + account: ResolvedRingCentralAccount; + chatId: string; + runtime: RingCentralRuntimeEnv; + core: RingCentralCoreRuntime; + config: ClawdbotConfig; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + typingPostId?: string; +}): Promise { + const { payload, account, chatId, runtime, core, config, statusSink, typingPostId } = params; + const mediaList = payload.mediaUrls?.length + ? payload.mediaUrls + : payload.mediaUrl + ? [payload.mediaUrl] + : []; + + if (mediaList.length > 0) { + let suppressCaption = false; + if (typingPostId) { + try { + await deleteRingCentralMessage({ + account, + chatId, + postId: typingPostId, + }); + } catch (err) { + runtime.error?.(`RingCentral typing cleanup failed: ${String(err)}`); + const fallbackText = payload.text?.trim() + ? payload.text + : mediaList.length > 1 + ? "Sent attachments." + : "Sent attachment."; + try { + await updateRingCentralMessage({ + account, + chatId, + postId: typingPostId, + text: fallbackText, + }); + suppressCaption = Boolean(payload.text?.trim()); + } catch (updateErr) { + runtime.error?.(`RingCentral typing update failed: ${String(updateErr)}`); + } + } + } + let first = true; + for (const mediaUrl of mediaList) { + const caption = first && !suppressCaption ? payload.text : undefined; + first = false; + try { + const loaded = await core.channel.media.fetchRemoteMedia(mediaUrl, { + maxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024, + }); + const upload = await uploadRingCentralAttachment({ + account, + chatId, + filename: loaded.filename ?? "attachment", + buffer: loaded.buffer, + contentType: loaded.contentType, + }); + if (!upload.attachmentId) { + throw new Error("missing attachment id"); + } + await sendRingCentralMessage({ + account, + chatId, + text: caption, + attachments: [{ id: upload.attachmentId }], + }); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error?.(`RingCentral attachment send failed: ${String(err)}`); + } + } + return; + } + + if (payload.text) { + const chunkLimit = account.config.textChunkLimit ?? 4000; + const chunkMode = core.channel.text.resolveChunkMode( + config, + "ringcentral", + account.accountId, + ); + const chunks = core.channel.text.chunkMarkdownTextWithMode( + payload.text, + chunkLimit, + chunkMode, + ); + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + try { + if (i === 0 && typingPostId) { + await updateRingCentralMessage({ + account, + chatId, + postId: typingPostId, + text: chunk, + }); + } else { + await sendRingCentralMessage({ + account, + chatId, + text: chunk, + }); + } + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error?.(`RingCentral message send failed: ${String(err)}`); + } + } + } +} + +export async function startRingCentralMonitor( + options: RingCentralMonitorOptions, +): Promise<() => void> { + const { account, config, runtime, abortSignal, statusSink } = options; + const core = getRingCentralRuntime(); + + runtime.log?.(`[${account.accountId}] Starting RingCentral WebSocket subscription...`); + + // Get SDK instance + const sdk = await getRingCentralSDK(account); + + // Create subscriptions manager + const subscriptions = new Subscriptions({ sdk }); + const subscription = subscriptions.createSubscription(); + + // Track current user ID to filter out self messages + let ownerId: string | undefined; + try { + const platform = sdk.platform(); + const response = await platform.get("/restapi/v1.0/account/~/extension/~"); + const userInfo = await response.json(); + ownerId = userInfo?.id?.toString(); + runtime.log?.(`[${account.accountId}] Authenticated as extension: ${ownerId}`); + } catch (err) { + runtime.error?.(`[${account.accountId}] Failed to get current user: ${String(err)}`); + } + + // Handle notifications + subscription.on(subscription.events.notification, (event: unknown) => { + const evt = event as RingCentralWebhookEvent; + processWebSocketEvent({ + event: evt, + account, + config, + runtime, + core, + statusSink, + ownerId, + }).catch((err) => { + runtime.error?.(`[${account.accountId}] WebSocket event processing failed: ${String(err)}`); + }); + }); + + // Handle subscription status changes + subscription.on(subscription.events.subscribeSuccess, () => { + runtime.log?.(`[${account.accountId}] WebSocket subscription active`); + }); + + subscription.on(subscription.events.subscribeError, (err: unknown) => { + runtime.error?.(`[${account.accountId}] WebSocket subscription error: ${String(err)}`); + }); + + subscription.on(subscription.events.renewSuccess, () => { + logVerbose(core, runtime, "WebSocket subscription renewed"); + }); + + subscription.on(subscription.events.renewError, (err: unknown) => { + runtime.error?.(`[${account.accountId}] WebSocket subscription renew error: ${String(err)}`); + }); + + // Subscribe to Team Messaging events + // - /restapi/v1.0/glip/posts: New posts in chats + // - /restapi/v1.0/glip/groups: Chat/group changes + try { + await subscription + .setEventFilters([ + "/restapi/v1.0/glip/posts", + "/restapi/v1.0/glip/groups", + ]) + .register(); + + runtime.log?.(`[${account.accountId}] RingCentral WebSocket subscription established`); + } catch (err) { + runtime.error?.(`[${account.accountId}] Failed to create WebSocket subscription: ${String(err)}`); + throw err; + } + + // Handle abort signal + const cleanup = () => { + runtime.log?.(`[${account.accountId}] Stopping RingCentral WebSocket subscription...`); + subscription.reset().catch((err) => { + runtime.error?.(`[${account.accountId}] Failed to reset subscription: ${String(err)}`); + }); + }; + + if (abortSignal.aborted) { + cleanup(); + } else { + abortSignal.addEventListener("abort", cleanup, { once: true }); + } + + return cleanup; +} + +// Keep the webhook path resolver for status display +export function resolveRingCentralWebhookPath(_params: { + account: ResolvedRingCentralAccount; +}): string { + return "(WebSocket)"; +} diff --git a/extensions/ringcentral/src/runtime.ts b/extensions/ringcentral/src/runtime.ts new file mode 100644 index 000000000..2a7335d5d --- /dev/null +++ b/extensions/ringcentral/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setRingCentralRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getRingCentralRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("RingCentral runtime not initialized"); + } + return runtime; +} diff --git a/extensions/ringcentral/src/targets.ts b/extensions/ringcentral/src/targets.ts new file mode 100644 index 000000000..0d7fcaf07 --- /dev/null +++ b/extensions/ringcentral/src/targets.ts @@ -0,0 +1,70 @@ +// Target ID normalization and resolution for RingCentral + +export function normalizeRingCentralTarget(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + + // Remove common prefixes + let normalized = trimmed + .replace(/^(ringcentral|rc):/i, "") + .replace(/^(chat|user|group|team):/i, "") + .trim(); + + if (!normalized) return null; + return normalized; +} + +export function isRingCentralChatTarget(target: string): boolean { + const normalized = normalizeRingCentralTarget(target); + if (!normalized) return false; + // RingCentral chat IDs are typically numeric strings + return /^\d+$/.test(normalized); +} + +export function isRingCentralUserTarget(target: string): boolean { + const normalized = normalizeRingCentralTarget(target); + if (!normalized) return false; + // User IDs can be numeric or prefixed + return /^\d+$/.test(normalized) || normalized.toLowerCase().startsWith("user:"); +} + +export function formatRingCentralChatTarget(chatId: string): string { + return `rc:chat:${chatId}`; +} + +export function formatRingCentralUserTarget(userId: string): string { + return `rc:user:${userId}`; +} + +export function parseRingCentralTarget(target: string): { + type: "chat" | "user" | "unknown"; + id: string; +} { + const trimmed = target.trim(); + + // Check for explicit type prefixes + const chatMatch = trimmed.match(/^(?:ringcentral|rc)?:?chat:(.+)$/i); + if (chatMatch) { + return { type: "chat", id: chatMatch[1].trim() }; + } + + const userMatch = trimmed.match(/^(?:ringcentral|rc)?:?user:(.+)$/i); + if (userMatch) { + return { type: "user", id: userMatch[1].trim() }; + } + + const groupMatch = trimmed.match(/^(?:ringcentral|rc)?:?(?:group|team):(.+)$/i); + if (groupMatch) { + return { type: "chat", id: groupMatch[1].trim() }; + } + + // Remove any remaining prefix + const cleaned = trimmed.replace(/^(?:ringcentral|rc):/i, "").trim(); + + // Default to chat for numeric IDs + if (/^\d+$/.test(cleaned)) { + return { type: "chat", id: cleaned }; + } + + return { type: "unknown", id: cleaned }; +} diff --git a/extensions/ringcentral/src/types.ts b/extensions/ringcentral/src/types.ts new file mode 100644 index 000000000..c870a3c0d --- /dev/null +++ b/extensions/ringcentral/src/types.ts @@ -0,0 +1,123 @@ +import type { DmPolicy, GroupPolicy, MarkdownConfig } from "clawdbot/plugin-sdk"; + +// RingCentral Team Messaging API types + +export type RingCentralUser = { + id?: string; + firstName?: string; + lastName?: string; + email?: string; +}; + +export type RingCentralChat = { + id?: string; + name?: string; + description?: string; + type?: "Everyone" | "Team" | "Group" | "Personal" | "Direct" | "PersonalChat"; + status?: "Active" | "Archived"; + members?: string[]; + isPublic?: boolean; + creationTime?: string; + lastModifiedTime?: string; +}; + +export type RingCentralPost = { + id?: string; + groupId?: string; + type?: "TextMessage" | "PersonJoined" | "PersonsAdded"; + text?: string; + creatorId?: string; + addedPersonIds?: string[]; + creationTime?: string; + lastModifiedTime?: string; + attachments?: RingCentralAttachment[]; + activity?: string; + title?: string; + iconUri?: string; + iconEmoji?: string; + mentions?: RingCentralMention[]; +}; + +export type RingCentralAttachment = { + id?: string; + type?: string; + name?: string; + contentUri?: string; + contentType?: string; + size?: number; +}; + +export type RingCentralMention = { + id?: string; + type?: "Person" | "Team" | "File" | "Link" | "Event" | "Task" | "Note" | "Card"; + name?: string; +}; + +export type RingCentralWebhookEvent = { + uuid?: string; + event?: string; + timestamp?: string; + subscriptionId?: string; + ownerId?: string; + body?: RingCentralEventBody; +}; + +export type RingCentralEventBody = { + id?: string; + groupId?: string; + type?: string; + text?: string; + creatorId?: string; + eventType?: "PostAdded" | "PostChanged" | "PostRemoved" | "GroupJoined" | "GroupLeft" | "GroupChanged"; + creationTime?: string; + lastModifiedTime?: string; + attachments?: RingCentralAttachment[]; + mentions?: RingCentralMention[]; + name?: string; + members?: string[]; + status?: string; +}; + +// Config types + +export type RingCentralGroupConfig = { + chatId?: string; + requireMention?: boolean; + allow?: boolean; + enabled?: boolean; + users?: Array; + systemPrompt?: string; +}; + +export type RingCentralAccountConfig = { + enabled?: boolean; + name?: string; + clientId?: string; + clientSecret?: string; + jwt?: string; + server?: string; + credentialsFile?: string; + webhookPath?: string; + webhookVerificationToken?: string; + markdown?: MarkdownConfig; + dmPolicy?: DmPolicy; + allowFrom?: Array; + dm?: { policy?: DmPolicy; allowFrom?: Array; enabled?: boolean }; + groupPolicy?: GroupPolicy; + groups?: Record; + groupAllowFrom?: Array; + requireMention?: boolean; + mediaMaxMb?: number; + textChunkLimit?: number; + chunkMode?: "length" | "newline"; + blockStreaming?: boolean; + blockStreamingCoalesce?: { minChars?: number; idleMs?: number }; + allowBots?: boolean; + botExtensionId?: string; + replyToMode?: "off" | "all"; +}; + +export type RingCentralConfig = RingCentralAccountConfig & { + accounts?: Record; + defaultAccount?: string; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c0f99928..91ba9a166 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -409,6 +409,25 @@ importers: extensions/open-prose: {} + extensions/ringcentral: + dependencies: + '@ringcentral/sdk': + specifier: ^5.0.0 + version: 5.0.6 + '@ringcentral/subscriptions': + specifier: ^5.0.0 + version: 5.0.6 + isomorphic-ws: + specifier: ^5.0.0 + version: 5.0.0(ws@8.19.0) + ws: + specifier: ^8.18.0 + version: 8.19.0 + devDependencies: + clawdbot: + specifier: workspace:* + version: link:../.. + extensions/signal: {} extensions/slack: {} @@ -2101,6 +2120,15 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@rc-ex/core@1.6.17': + resolution: {integrity: sha512-+Bnp/cpTyfm9wAs+QP//ocK1E0lKZbS4iDdzL6qwFs3CZuiGhnokei99nU2qeS73aDgIvnLHqPb+BwSCetataQ==} + + '@rc-ex/rcsdk@1.3.13': + resolution: {integrity: sha512-qz4YEslBsK0a1yqDnqIUY2khZGISS1Tz+NMXJPkilEzyS9vfbWHpGk+KVZAKfyciLHSizE3TIszFposQZdiMgw==} + + '@rc-ex/ws@1.3.17': + resolution: {integrity: sha512-M4gLkdzT9UJ0WYhumvmE49VHR8gRm5aY3kOifMH6zfrkDS7vvf7bKrPA5kV07a8/3aYfp+8CcclRq7JDhOdVAA==} + '@reflink/reflink-darwin-arm64@0.1.19': resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==} engines: {node: '>= 10'} @@ -2153,6 +2181,14 @@ packages: resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==} engines: {node: '>= 10'} + '@ringcentral/sdk@5.0.6': + resolution: {integrity: sha512-748RqYpO+ot0INmyewzBkDwAZyXZpOcgNzSCRmOtbvk+cWtj5E8U2sUlCMa+astqcUXobEQiRVTlpPydDEE1fw==} + engines: {node: '>=4'} + + '@ringcentral/subscriptions@5.0.6': + resolution: {integrity: sha512-yYy+efUO9MTp+juK2qRFHD+T0Wmi1n8lWUOOqUXaVCVvsWGBBPeJQftQnfvkOYPLRk1eFWdUCupTH0UkZ7tnYQ==} + engines: {node: '>=4'} + '@rolldown/binding-android-arm64@1.0.0-rc.1': resolution: {integrity: sha512-He6ZoCfv5D7dlRbrhNBkuMVIHd0GDnjJwbICE1OWpG7G3S2gmJ+eXkcNLJjzjNDpeI2aRy56ou39AJM9AD8YFA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3144,6 +3180,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -3416,6 +3455,9 @@ packages: dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dom-storage@2.1.0: + resolution: {integrity: sha512-g6RpyWXzl0RR6OTElHKBl7nwnK87GUyZMYC7JWsB/IA73vpqK2K6LT39x4VepLxlSsWBFrPVLnsSR5Jyty0+2Q==} + domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} @@ -3834,10 +3876,16 @@ packages: resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} engines: {node: '>=0.8', npm: '>=1.3.7'} + http-status-codes@2.3.0: + resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + hyperid@3.3.0: + resolution: {integrity: sha512-7qhCVT4MJIoEsNcbhglhdmBKb09QtcmJNiIQGq7js/Khf5FtQQ9bzcAuloeqBeee7XD7JqDeve9KNlQya5tSGQ==} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -3908,6 +3956,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + is-plain-object@5.0.0: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} @@ -3946,6 +3998,15 @@ packages: resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} engines: {node: '>=16'} + isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + isstream@0.1.2: resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} @@ -5358,6 +5419,9 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid-parse@1.1.0: + resolution: {integrity: sha512-OdmXxA8rDsQ7YpNVbKSJkNzTw2I+S5WsbMDnCtIWSQaosNAcWtFuI/YK1TjzUI6nbkgiqEyh8gWngfcv8Asd9A==} + uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -5457,6 +5521,9 @@ packages: jsdom: optional: true + wait-for-async@0.7.13: + resolution: {integrity: sha512-sBw2C85eWcRvTWbUmtcMUdVz6KvDkDkRtE5a5YhBANSijs7M6DrHK13lHAxwkTPTcrjVRjEQ/qH9RpK7Q7vlYA==} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -7741,6 +7808,28 @@ snapshots: '@protobufjs/utf8@1.1.0': {} + '@rc-ex/core@1.6.17': + dependencies: + '@types/qs': 6.14.0 + axios: 1.13.2(debug@4.4.3) + qs: 6.14.1 + transitivePeerDependencies: + - debug + + '@rc-ex/rcsdk@1.3.13': {} + + '@rc-ex/ws@1.3.17': + dependencies: + '@types/ws': 8.18.1 + http-status-codes: 2.3.0 + hyperid: 3.3.0 + isomorphic-ws: 5.0.0(ws@8.19.0) + wait-for-async: 0.7.13 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@reflink/reflink-darwin-arm64@0.1.19': optional: true @@ -7777,6 +7866,25 @@ snapshots: '@reflink/reflink-win32-x64-msvc': 0.1.19 optional: true + '@ringcentral/sdk@5.0.6': + dependencies: + dom-storage: 2.1.0 + is-plain-object: 2.0.4 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + '@ringcentral/subscriptions@5.0.6': + dependencies: + '@rc-ex/core': 1.6.17 + '@rc-ex/rcsdk': 1.3.13 + '@rc-ex/ws': 1.3.17 + wait-for-async: 0.7.13 + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + '@rolldown/binding-android-arm64@1.0.0-rc.1': optional: true @@ -9017,6 +9125,11 @@ snapshots: buffer-from@1.1.2: {} + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -9361,6 +9474,8 @@ snapshots: domhandler: 5.0.3 entities: 4.5.0 + dom-storage@2.1.0: {} + domelementtype@2.3.0: {} domhandler@5.0.3: @@ -9906,6 +10021,8 @@ snapshots: jsprim: 1.4.2 sshpk: 1.18.0 + http-status-codes@2.3.0: {} + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -9913,6 +10030,12 @@ snapshots: transitivePeerDependencies: - supports-color + hyperid@3.3.0: + dependencies: + buffer: 5.7.1 + uuid: 8.3.2 + uuid-parse: 1.1.0 + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -10004,6 +10127,10 @@ snapshots: is-number@7.0.0: {} + is-plain-object@2.0.4: + dependencies: + isobject: 3.0.1 + is-plain-object@5.0.0: {} is-promise@2.2.2: {} @@ -10029,6 +10156,12 @@ snapshots: isexe@3.1.1: optional: true + isobject@3.0.1: {} + + isomorphic-ws@5.0.0(ws@8.19.0): + dependencies: + ws: 8.19.0 + isstream@0.1.2: {} istanbul-lib-coverage@3.2.2: {} @@ -11617,6 +11750,8 @@ snapshots: utils-merge@1.0.1: {} + uuid-parse@1.1.0: {} + uuid@11.1.0: {} uuid@3.4.0: {} @@ -11689,6 +11824,8 @@ snapshots: - tsx - yaml + wait-for-async@0.7.13: {} + web-streams-polyfill@3.3.3: {} webidl-conversions@3.0.1: {}