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
|
# local tooling
|
||||||
.serena/
|
.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/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/signal: {}
|
||||||
|
|
||||||
extensions/slack: {}
|
extensions/slack: {}
|
||||||
@ -2101,6 +2120,15 @@ packages:
|
|||||||
'@protobufjs/utf8@1.1.0':
|
'@protobufjs/utf8@1.1.0':
|
||||||
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
|
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':
|
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||||
resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==}
|
resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@ -2153,6 +2181,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==}
|
resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==}
|
||||||
engines: {node: '>= 10'}
|
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':
|
'@rolldown/binding-android-arm64@1.0.0-rc.1':
|
||||||
resolution: {integrity: sha512-He6ZoCfv5D7dlRbrhNBkuMVIHd0GDnjJwbICE1OWpG7G3S2gmJ+eXkcNLJjzjNDpeI2aRy56ou39AJM9AD8YFA==}
|
resolution: {integrity: sha512-He6ZoCfv5D7dlRbrhNBkuMVIHd0GDnjJwbICE1OWpG7G3S2gmJ+eXkcNLJjzjNDpeI2aRy56ou39AJM9AD8YFA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@ -3144,6 +3180,9 @@ packages:
|
|||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
|
buffer@5.7.1:
|
||||||
|
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||||
|
|
||||||
buffer@6.0.3:
|
buffer@6.0.3:
|
||||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||||
|
|
||||||
@ -3416,6 +3455,9 @@ packages:
|
|||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||||
|
|
||||||
|
dom-storage@2.1.0:
|
||||||
|
resolution: {integrity: sha512-g6RpyWXzl0RR6OTElHKBl7nwnK87GUyZMYC7JWsB/IA73vpqK2K6LT39x4VepLxlSsWBFrPVLnsSR5Jyty0+2Q==}
|
||||||
|
|
||||||
domelementtype@2.3.0:
|
domelementtype@2.3.0:
|
||||||
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
||||||
|
|
||||||
@ -3834,10 +3876,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==}
|
resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==}
|
||||||
engines: {node: '>=0.8', npm: '>=1.3.7'}
|
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:
|
https-proxy-agent@7.0.6:
|
||||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
|
hyperid@3.3.0:
|
||||||
|
resolution: {integrity: sha512-7qhCVT4MJIoEsNcbhglhdmBKb09QtcmJNiIQGq7js/Khf5FtQQ9bzcAuloeqBeee7XD7JqDeve9KNlQya5tSGQ==}
|
||||||
|
|
||||||
iconv-lite@0.4.24:
|
iconv-lite@0.4.24:
|
||||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -3908,6 +3956,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||||
engines: {node: '>=0.12.0'}
|
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:
|
is-plain-object@5.0.0:
|
||||||
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
|
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -3946,6 +3998,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
|
resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
|
||||||
engines: {node: '>=16'}
|
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:
|
isstream@0.1.2:
|
||||||
resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==}
|
resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==}
|
||||||
|
|
||||||
@ -5358,6 +5419,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||||
engines: {node: '>= 0.4.0'}
|
engines: {node: '>= 0.4.0'}
|
||||||
|
|
||||||
|
uuid-parse@1.1.0:
|
||||||
|
resolution: {integrity: sha512-OdmXxA8rDsQ7YpNVbKSJkNzTw2I+S5WsbMDnCtIWSQaosNAcWtFuI/YK1TjzUI6nbkgiqEyh8gWngfcv8Asd9A==}
|
||||||
|
|
||||||
uuid@11.1.0:
|
uuid@11.1.0:
|
||||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -5457,6 +5521,9 @@ packages:
|
|||||||
jsdom:
|
jsdom:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
wait-for-async@0.7.13:
|
||||||
|
resolution: {integrity: sha512-sBw2C85eWcRvTWbUmtcMUdVz6KvDkDkRtE5a5YhBANSijs7M6DrHK13lHAxwkTPTcrjVRjEQ/qH9RpK7Q7vlYA==}
|
||||||
|
|
||||||
web-streams-polyfill@3.3.3:
|
web-streams-polyfill@3.3.3:
|
||||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@ -7741,6 +7808,28 @@ snapshots:
|
|||||||
|
|
||||||
'@protobufjs/utf8@1.1.0': {}
|
'@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':
|
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@ -7777,6 +7866,25 @@ snapshots:
|
|||||||
'@reflink/reflink-win32-x64-msvc': 0.1.19
|
'@reflink/reflink-win32-x64-msvc': 0.1.19
|
||||||
optional: true
|
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':
|
'@rolldown/binding-android-arm64@1.0.0-rc.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@ -9017,6 +9125,11 @@ snapshots:
|
|||||||
|
|
||||||
buffer-from@1.1.2: {}
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
|
buffer@5.7.1:
|
||||||
|
dependencies:
|
||||||
|
base64-js: 1.5.1
|
||||||
|
ieee754: 1.2.1
|
||||||
|
|
||||||
buffer@6.0.3:
|
buffer@6.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
@ -9361,6 +9474,8 @@ snapshots:
|
|||||||
domhandler: 5.0.3
|
domhandler: 5.0.3
|
||||||
entities: 4.5.0
|
entities: 4.5.0
|
||||||
|
|
||||||
|
dom-storage@2.1.0: {}
|
||||||
|
|
||||||
domelementtype@2.3.0: {}
|
domelementtype@2.3.0: {}
|
||||||
|
|
||||||
domhandler@5.0.3:
|
domhandler@5.0.3:
|
||||||
@ -9906,6 +10021,8 @@ snapshots:
|
|||||||
jsprim: 1.4.2
|
jsprim: 1.4.2
|
||||||
sshpk: 1.18.0
|
sshpk: 1.18.0
|
||||||
|
|
||||||
|
http-status-codes@2.3.0: {}
|
||||||
|
|
||||||
https-proxy-agent@7.0.6:
|
https-proxy-agent@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.4
|
agent-base: 7.1.4
|
||||||
@ -9913,6 +10030,12 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
iconv-lite@0.4.24:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
@ -10004,6 +10127,10 @@ snapshots:
|
|||||||
|
|
||||||
is-number@7.0.0: {}
|
is-number@7.0.0: {}
|
||||||
|
|
||||||
|
is-plain-object@2.0.4:
|
||||||
|
dependencies:
|
||||||
|
isobject: 3.0.1
|
||||||
|
|
||||||
is-plain-object@5.0.0: {}
|
is-plain-object@5.0.0: {}
|
||||||
|
|
||||||
is-promise@2.2.2: {}
|
is-promise@2.2.2: {}
|
||||||
@ -10029,6 +10156,12 @@ snapshots:
|
|||||||
isexe@3.1.1:
|
isexe@3.1.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
isobject@3.0.1: {}
|
||||||
|
|
||||||
|
isomorphic-ws@5.0.0(ws@8.19.0):
|
||||||
|
dependencies:
|
||||||
|
ws: 8.19.0
|
||||||
|
|
||||||
isstream@0.1.2: {}
|
isstream@0.1.2: {}
|
||||||
|
|
||||||
istanbul-lib-coverage@3.2.2: {}
|
istanbul-lib-coverage@3.2.2: {}
|
||||||
@ -11617,6 +11750,8 @@ snapshots:
|
|||||||
|
|
||||||
utils-merge@1.0.1: {}
|
utils-merge@1.0.1: {}
|
||||||
|
|
||||||
|
uuid-parse@1.1.0: {}
|
||||||
|
|
||||||
uuid@11.1.0: {}
|
uuid@11.1.0: {}
|
||||||
|
|
||||||
uuid@3.4.0: {}
|
uuid@3.4.0: {}
|
||||||
@ -11689,6 +11824,8 @@ snapshots:
|
|||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
|
wait-for-async@0.7.13: {}
|
||||||
|
|
||||||
web-streams-polyfill@3.3.3: {}
|
web-streams-polyfill@3.3.3: {}
|
||||||
|
|
||||||
webidl-conversions@3.0.1: {}
|
webidl-conversions@3.0.1: {}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user