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>
This commit is contained in:
John Lin 2026-01-27 17:30:52 +08:00
parent 01e0d3a320
commit 352227f435
No known key found for this signature in database
14 changed files with 2326 additions and 0 deletions

4
.gitignore vendored
View File

@ -71,3 +71,7 @@ USER.md
# local tooling
.serena/
# RingCentral credentials (never commit)
rc-credentials.json
*-credentials.json

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, unknown> = {};
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<void> {
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<RingCentralChat | null> {
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<RingCentralChat[]> {
const { account, type, limit } = params;
const platform = await getRingCentralPlatform(account);
const queryParams: Record<string, string> = {};
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<RingCentralUser | null> {
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<RingCentralUser | null> {
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<void> {
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,
};
}
}

View File

@ -0,0 +1,92 @@
import { SDK } from "@ringcentral/sdk";
import type { ResolvedRingCentralAccount } from "./accounts.js";
export type SDKInstance = InstanceType<typeof SDK>;
const sdkCache = new Map<string, { key: string; sdk: SDKInstance; platform: ReturnType<SDKInstance["platform"]> }>();
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<SDKInstance["platform"]>;
}> {
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<SDKInstance> {
const { sdk } = await getSDKInstance(account);
return sdk;
}
export async function getRingCentralPlatform(
account: ResolvedRingCentralAccount,
): Promise<ReturnType<SDKInstance["platform"]>> {
const { platform } = await getSDKInstance(account);
return platform;
}
export async function getRingCentralAccessToken(
account: ResolvedRingCentralAccount,
): Promise<string> {
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<void> {
const { platform } = await getSDKInstance(account);
await platform.refresh();
}
export function clearRingCentralAuth(accountId: string): void {
sdkCache.delete(accountId);
}

View File

@ -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<ResolvedRingCentralAccount> = {
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: "<chatId>",
},
},
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:<id> or rc:user:<id>",
};
});
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",
"<chatId> 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",
"<chatId> 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<string, { mediaMaxMb?: number }>; 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(),
});
};
},
},
};

View File

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

View File

@ -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<typeof getRingCentralRuntime>;
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<string, { requireMention?: boolean; allow?: boolean; enabled?: boolean; users?: Array<string | number>; 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<void> {
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<void> {
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 ? "<media:attachment>" : "");
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<void> {
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)";
}

View File

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

View File

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

View File

@ -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<string | number>;
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<string | number>;
dm?: { policy?: DmPolicy; allowFrom?: Array<string | number>; enabled?: boolean };
groupPolicy?: GroupPolicy;
groups?: Record<string, RingCentralGroupConfig>;
groupAllowFrom?: Array<string | number>;
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<string, RingCentralAccountConfig>;
defaultAccount?: string;
};

137
pnpm-lock.yaml generated
View File

@ -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: {}