openclaw/extensions/ringcentral/src/monitor.ts
John Lin 1d91d0ab73
feat(ringcentral): restrict selfOnly mode to Personal chat by default
In selfOnly mode, now only allows messages from 'Personal' chat
(conversation with yourself). Set allowOtherChats: true to enable
DMs and group chats.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-01-28 23:11:37 +08:00

755 lines
25 KiB
TypeScript

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;
};
// Track recently sent message IDs to avoid processing bot's own replies
const recentlySentMessageIds = new Set<string>();
const MESSAGE_ID_TTL = 60000; // 60 seconds
function trackSentMessageId(messageId: string): void {
recentlySentMessageIds.add(messageId);
setTimeout(() => recentlySentMessageIds.delete(messageId), MESSAGE_ID_TTL);
}
function isOwnSentMessage(messageId: string): boolean {
return recentlySentMessageIds.has(messageId);
}
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;
// Check event type - can be from eventType field or inferred from event path
const eventType = eventBody.eventType;
const eventPath = event.event ?? "";
const isPostEvent = eventPath.includes("/glip/posts") || eventPath.includes("/team-messaging") || eventType === "PostAdded";
if (!isPostEvent) {
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 bot's own messages to avoid infinite loop
// Check 1: Skip if this is a message we recently sent
const messageId = eventBody.id ?? "";
if (messageId && isOwnSentMessage(messageId)) {
logVerbose(core, runtime, `skip own sent message: ${messageId}`);
return;
}
// Check 2: Skip typing/thinking indicators (pattern-based)
if (rawBody.includes("thinking...") || rawBody.includes("typing...")) {
logVerbose(core, runtime, "skip typing indicator message");
return;
}
// In JWT mode (selfOnly), only accept messages from the JWT user themselves
// This is because the bot uses the JWT user's identity, so we're essentially
// having a conversation with ourselves (the AI assistant)
const selfOnly = account.config.selfOnly !== false; // default true
runtime.log?.(`[${account.accountId}] Processing message: senderId=${senderId}, ownerId=${ownerId}, selfOnly=${selfOnly}, chatId=${chatId}, text=${rawBody.slice(0, 50)}`);
if (selfOnly && ownerId) {
if (senderId !== ownerId) {
logVerbose(core, runtime, `ignore message from non-owner: ${senderId} (selfOnly mode)`);
return;
}
}
runtime.log?.(`[${account.accountId}] Message passed selfOnly check`);
// 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
}
// Personal, PersonalChat, Direct are all DM types
const isPersonalChat = chatType === "Personal" || chatType === "PersonalChat";
const isGroup = chatType !== "Direct" && chatType !== "PersonalChat" && chatType !== "Personal";
runtime.log?.(`[${account.accountId}] Chat type: ${chatType}, isGroup: ${isGroup}`);
// In selfOnly mode, by default only allow "Personal" chat (conversation with yourself)
// Set allowOtherChats: true to allow DMs and groups
if (selfOnly) {
const allowOtherChats = account.config.allowOtherChats === true; // default false
if (!allowOtherChats && !isPersonalChat) {
logVerbose(core, runtime, `ignore non-personal chat in selfOnly mode: chatType=${chatType}`);
return;
}
}
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;
}
// In selfOnly mode, always allow the owner
const isOwner = selfOnly && ownerId && senderId === ownerId;
if (dmPolicy !== "open" && !isOwner) {
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 thinking..._`,
});
typingPostId = result?.postId;
// Track sent message to avoid processing it as incoming
if (typingPostId) trackSentMessageId(typingPostId);
} 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");
}
const sendResult = await sendRingCentralMessage({
account,
chatId,
text: caption,
attachments: [{ id: upload.attachmentId }],
});
if (sendResult?.postId) trackSentMessageId(sendResult.postId);
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) {
const updateResult = await updateRingCentralMessage({
account,
chatId,
postId: typingPostId,
text: chunk,
});
if (updateResult?.postId) trackSentMessageId(updateResult.postId);
} else {
const sendResult = await sendRingCentralMessage({
account,
chatId,
text: chunk,
});
if (sendResult?.postId) trackSentMessageId(sendResult.postId);
}
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) => {
runtime.log?.(`[${account.accountId}] WebSocket notification received: ${JSON.stringify(event).slice(0, 500)}`);
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)";
}