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:
parent
01e0d3a320
commit
352227f435
4
.gitignore
vendored
4
.gitignore
vendored
@ -71,3 +71,7 @@ USER.md
|
||||
|
||||
# local tooling
|
||||
.serena/
|
||||
|
||||
# RingCentral credentials (never commit)
|
||||
rc-credentials.json
|
||||
*-credentials.json
|
||||
|
||||
11
extensions/ringcentral/clawdbot.plugin.json
Normal file
11
extensions/ringcentral/clawdbot.plugin.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "ringcentral",
|
||||
"channels": [
|
||||
"ringcentral"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
19
extensions/ringcentral/index.ts
Normal file
19
extensions/ringcentral/index.ts
Normal 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;
|
||||
42
extensions/ringcentral/package.json
Normal file
42
extensions/ringcentral/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
274
extensions/ringcentral/src/accounts.ts
Normal file
274
extensions/ringcentral/src/accounts.ts
Normal 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);
|
||||
}
|
||||
220
extensions/ringcentral/src/api.ts
Normal file
220
extensions/ringcentral/src/api.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
92
extensions/ringcentral/src/auth.ts
Normal file
92
extensions/ringcentral/src/auth.ts
Normal 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);
|
||||
}
|
||||
555
extensions/ringcentral/src/channel.ts
Normal file
555
extensions/ringcentral/src/channel.ts
Normal 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(),
|
||||
});
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
75
extensions/ringcentral/src/config-schema.ts
Normal file
75
extensions/ringcentral/src/config-schema.ts
Normal 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";
|
||||
690
extensions/ringcentral/src/monitor.ts
Normal file
690
extensions/ringcentral/src/monitor.ts
Normal 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)";
|
||||
}
|
||||
14
extensions/ringcentral/src/runtime.ts
Normal file
14
extensions/ringcentral/src/runtime.ts
Normal 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;
|
||||
}
|
||||
70
extensions/ringcentral/src/targets.ts
Normal file
70
extensions/ringcentral/src/targets.ts
Normal 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 };
|
||||
}
|
||||
123
extensions/ringcentral/src/types.ts
Normal file
123
extensions/ringcentral/src/types.ts
Normal 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
137
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user