openclaw-ringcentral 2026.1.29-beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/monitor.ts ADDED
@@ -0,0 +1,742 @@
1
+ import { Subscriptions } from "@ringcentral/subscriptions";
2
+
3
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
4
+ import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk";
5
+
6
+ import type { ResolvedRingCentralAccount } from "./accounts.js";
7
+ import { getRingCentralSDK } from "./auth.js";
8
+ import {
9
+ sendRingCentralMessage,
10
+ updateRingCentralMessage,
11
+ deleteRingCentralMessage,
12
+ downloadRingCentralAttachment,
13
+ uploadRingCentralAttachment,
14
+ getRingCentralChat,
15
+ } from "./api.js";
16
+ import { getRingCentralRuntime } from "./runtime.js";
17
+ import type {
18
+ RingCentralWebhookEvent,
19
+ RingCentralEventBody,
20
+ RingCentralAttachment,
21
+ RingCentralMention,
22
+ } from "./types.js";
23
+
24
+ export type RingCentralRuntimeEnv = {
25
+ log?: (message: string) => void;
26
+ error?: (message: string) => void;
27
+ };
28
+
29
+ // Track recently sent message IDs to avoid processing bot's own replies
30
+ const recentlySentMessageIds = new Set<string>();
31
+ const MESSAGE_ID_TTL = 60000; // 60 seconds
32
+
33
+ // Reconnection settings
34
+ const RECONNECT_INITIAL_DELAY = 1000; // 1 second
35
+ const RECONNECT_MAX_DELAY = 60000; // 60 seconds
36
+ const RECONNECT_MAX_ATTEMPTS = 10;
37
+
38
+ function trackSentMessageId(messageId: string): void {
39
+ recentlySentMessageIds.add(messageId);
40
+ setTimeout(() => recentlySentMessageIds.delete(messageId), MESSAGE_ID_TTL);
41
+ }
42
+
43
+ function isOwnSentMessage(messageId: string): boolean {
44
+ return recentlySentMessageIds.has(messageId);
45
+ }
46
+
47
+ export type RingCentralMonitorOptions = {
48
+ account: ResolvedRingCentralAccount;
49
+ config: OpenClawConfig;
50
+ runtime: RingCentralRuntimeEnv;
51
+ abortSignal: AbortSignal;
52
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
53
+ };
54
+
55
+ type RingCentralCoreRuntime = ReturnType<typeof getRingCentralRuntime>;
56
+
57
+ function logVerbose(
58
+ core: RingCentralCoreRuntime,
59
+ runtime: RingCentralRuntimeEnv,
60
+ message: string,
61
+ ) {
62
+ if (core.logging.shouldLogVerbose()) {
63
+ runtime.log?.(`[ringcentral] ${message}`);
64
+ }
65
+ }
66
+
67
+ function normalizeUserId(raw?: string | null): string {
68
+ const trimmed = raw?.trim() ?? "";
69
+ if (!trimmed) return "";
70
+ return trimmed.toLowerCase();
71
+ }
72
+
73
+ export function isSenderAllowed(
74
+ senderId: string,
75
+ allowFrom: string[],
76
+ ): boolean {
77
+ if (allowFrom.includes("*")) return true;
78
+ const normalizedSenderId = normalizeUserId(senderId);
79
+ return allowFrom.some((entry) => {
80
+ const normalized = String(entry).trim().toLowerCase();
81
+ if (!normalized) return false;
82
+ if (normalized === normalizedSenderId) return true;
83
+ if (normalized.replace(/^(ringcentral|rc):/i, "") === normalizedSenderId) return true;
84
+ if (normalized.replace(/^user:/i, "") === normalizedSenderId) return true;
85
+ return false;
86
+ });
87
+ }
88
+
89
+ function resolveGroupConfig(params: {
90
+ groupId: string;
91
+ groupName?: string | null;
92
+ groups?: Record<string, { requireMention?: boolean; allow?: boolean; enabled?: boolean; users?: Array<string | number>; systemPrompt?: string }>;
93
+ }) {
94
+ const { groupId, groupName, groups } = params;
95
+ const entries = groups ?? {};
96
+ const keys = Object.keys(entries);
97
+ if (keys.length === 0) {
98
+ return { entry: undefined, allowlistConfigured: false };
99
+ }
100
+ const normalizedName = groupName?.trim().toLowerCase();
101
+ const candidates = [groupId, groupName ?? "", normalizedName ?? ""].filter(Boolean);
102
+ let entry = candidates.map((candidate) => entries[candidate]).find(Boolean);
103
+ if (!entry && normalizedName) {
104
+ entry = entries[normalizedName];
105
+ }
106
+ const fallback = entries["*"];
107
+ return { entry: entry ?? fallback, allowlistConfigured: true, fallback };
108
+ }
109
+
110
+ function extractMentionInfo(mentions: RingCentralMention[], botExtensionId?: string | null) {
111
+ const personMentions = mentions.filter((entry) => entry.type === "Person");
112
+ const hasAnyMention = personMentions.length > 0;
113
+ const wasMentioned = botExtensionId
114
+ ? personMentions.some((entry) => entry.id === botExtensionId)
115
+ : false;
116
+ return { hasAnyMention, wasMentioned };
117
+ }
118
+
119
+ function resolveBotDisplayName(params: {
120
+ accountName?: string;
121
+ agentId: string;
122
+ config: OpenClawConfig;
123
+ }): string {
124
+ const { accountName, agentId, config } = params;
125
+ if (accountName?.trim()) return accountName.trim();
126
+ const agent = config.agents?.list?.find((a) => a.id === agentId);
127
+ if (agent?.name?.trim()) return agent.name.trim();
128
+ return "OpenClaw";
129
+ }
130
+
131
+ async function processWebSocketEvent(params: {
132
+ event: RingCentralWebhookEvent;
133
+ account: ResolvedRingCentralAccount;
134
+ config: OpenClawConfig;
135
+ runtime: RingCentralRuntimeEnv;
136
+ core: RingCentralCoreRuntime;
137
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
138
+ ownerId?: string;
139
+ }): Promise<void> {
140
+ const { event, account, config, runtime, core, statusSink, ownerId } = params;
141
+
142
+ const eventBody = event.body;
143
+ if (!eventBody) return;
144
+
145
+ // Check event type - can be from eventType field or inferred from event path
146
+ const eventType = eventBody.eventType;
147
+ const eventPath = event.event ?? "";
148
+ const isPostEvent = eventPath.includes("/glip/posts") || eventPath.includes("/team-messaging") || eventType === "PostAdded";
149
+
150
+ if (!isPostEvent) {
151
+ return;
152
+ }
153
+
154
+ statusSink?.({ lastInboundAt: Date.now() });
155
+
156
+ await processMessageWithPipeline({
157
+ eventBody,
158
+ account,
159
+ config,
160
+ runtime,
161
+ core,
162
+ statusSink,
163
+ ownerId,
164
+ });
165
+ }
166
+
167
+ async function processMessageWithPipeline(params: {
168
+ eventBody: RingCentralEventBody;
169
+ account: ResolvedRingCentralAccount;
170
+ config: OpenClawConfig;
171
+ runtime: RingCentralRuntimeEnv;
172
+ core: RingCentralCoreRuntime;
173
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
174
+ ownerId?: string;
175
+ }): Promise<void> {
176
+ const { eventBody, account, config, runtime, core, statusSink, ownerId } = params;
177
+ const mediaMaxMb = account.config.mediaMaxMb ?? 20;
178
+
179
+ const chatId = eventBody.groupId ?? "";
180
+ if (!chatId) return;
181
+
182
+ const senderId = eventBody.creatorId ?? "";
183
+ const messageText = (eventBody.text ?? "").trim();
184
+ const attachments = eventBody.attachments ?? [];
185
+ const hasMedia = attachments.length > 0;
186
+ const rawBody = messageText || (hasMedia ? "<media:attachment>" : "");
187
+ if (!rawBody) return;
188
+
189
+ // Skip bot's own messages to avoid infinite loop
190
+ // Check 1: Skip if this is a message we recently sent
191
+ const messageId = eventBody.id ?? "";
192
+ if (messageId && isOwnSentMessage(messageId)) {
193
+ logVerbose(core, runtime, `skip own sent message: ${messageId}`);
194
+ return;
195
+ }
196
+
197
+ // Check 2: Skip typing/thinking indicators (pattern-based)
198
+ if (rawBody.includes("thinking...") || rawBody.includes("typing...")) {
199
+ logVerbose(core, runtime, "skip typing indicator message");
200
+ return;
201
+ }
202
+
203
+ // In JWT mode (selfOnly), only accept messages from the JWT user themselves
204
+ // This is because the bot uses the JWT user's identity, so we're essentially
205
+ // having a conversation with ourselves (the AI assistant)
206
+ const selfOnly = account.config.selfOnly !== false; // default true
207
+ runtime.log?.(`[${account.accountId}] Processing message: senderId=${senderId}, ownerId=${ownerId}, selfOnly=${selfOnly}, chatId=${chatId}`);
208
+
209
+ if (selfOnly && ownerId) {
210
+ if (senderId !== ownerId) {
211
+ logVerbose(core, runtime, `ignore message from non-owner: ${senderId} (selfOnly mode)`);
212
+ return;
213
+ }
214
+ }
215
+
216
+ runtime.log?.(`[${account.accountId}] Message passed selfOnly check`);
217
+
218
+ // Fetch chat info to determine type
219
+ let chatType = "Group";
220
+ let chatName: string | undefined;
221
+ try {
222
+ const chatInfo = await getRingCentralChat({ account, chatId });
223
+ chatType = chatInfo?.type ?? "Group";
224
+ chatName = chatInfo?.name ?? undefined;
225
+ } catch {
226
+ // If we can't fetch chat info, assume it's a group
227
+ }
228
+
229
+ // Personal, PersonalChat, Direct are all DM types
230
+ const isPersonalChat = chatType === "Personal" || chatType === "PersonalChat";
231
+ const isGroup = chatType !== "Direct" && chatType !== "PersonalChat" && chatType !== "Personal";
232
+ runtime.log?.(`[${account.accountId}] Chat type: ${chatType}, isGroup: ${isGroup}`);
233
+
234
+ // In selfOnly mode, only allow "Personal" chat (conversation with yourself)
235
+ if (selfOnly && !isPersonalChat) {
236
+ logVerbose(core, runtime, `ignore non-personal chat in selfOnly mode: chatType=${chatType}`);
237
+ return;
238
+ }
239
+
240
+ const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
241
+ const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
242
+ const groupConfigResolved = resolveGroupConfig({
243
+ groupId: chatId,
244
+ groupName: chatName ?? null,
245
+ groups: account.config.groups ?? undefined,
246
+ });
247
+ const groupEntry = groupConfigResolved.entry;
248
+ const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? [];
249
+ let effectiveWasMentioned: boolean | undefined;
250
+
251
+ if (isGroup) {
252
+ if (groupPolicy === "disabled") {
253
+ logVerbose(core, runtime, `drop group message (groupPolicy=disabled, chat=${chatId})`);
254
+ return;
255
+ }
256
+ const groupAllowlistConfigured = groupConfigResolved.allowlistConfigured;
257
+ const groupAllowed =
258
+ Boolean(groupEntry) || Boolean((account.config.groups ?? {})["*"]);
259
+ if (groupPolicy === "allowlist") {
260
+ if (!groupAllowlistConfigured) {
261
+ logVerbose(
262
+ core,
263
+ runtime,
264
+ `drop group message (groupPolicy=allowlist, no allowlist, chat=${chatId})`,
265
+ );
266
+ return;
267
+ }
268
+ if (!groupAllowed) {
269
+ logVerbose(core, runtime, `drop group message (not allowlisted, chat=${chatId})`);
270
+ return;
271
+ }
272
+ }
273
+ if (groupEntry?.enabled === false || groupEntry?.allow === false) {
274
+ logVerbose(core, runtime, `drop group message (chat disabled, chat=${chatId})`);
275
+ return;
276
+ }
277
+
278
+ if (groupUsers.length > 0) {
279
+ const ok = isSenderAllowed(senderId, groupUsers.map((v) => String(v)));
280
+ if (!ok) {
281
+ logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`);
282
+ return;
283
+ }
284
+ }
285
+ }
286
+
287
+ const dmPolicy = account.config.dm?.policy ?? account.config.dmPolicy ?? "pairing";
288
+ const configAllowFrom = account.config.dm?.allowFrom ?? account.config.allowFrom ?? [];
289
+ const configAllowFromStr = configAllowFrom.map((v) => String(v));
290
+ const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
291
+ const storeAllowFrom =
292
+ !isGroup && (dmPolicy !== "open" || shouldComputeAuth)
293
+ ? await core.channel.pairing.readAllowFromStore("ringcentral").catch(() => [])
294
+ : [];
295
+ const effectiveAllowFrom = [...configAllowFromStr, ...storeAllowFrom];
296
+ const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom;
297
+ const useAccessGroups = config.commands?.useAccessGroups !== false;
298
+ const senderAllowedForCommands = isSenderAllowed(senderId, commandAllowFrom);
299
+ const commandAuthorized = shouldComputeAuth
300
+ ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
301
+ useAccessGroups,
302
+ authorizers: [
303
+ { configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
304
+ ],
305
+ })
306
+ : undefined;
307
+
308
+ if (isGroup) {
309
+ const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true;
310
+ const mentions = eventBody.mentions ?? [];
311
+ const mentionInfo = extractMentionInfo(mentions, account.config.botExtensionId);
312
+ const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
313
+ cfg: config,
314
+ surface: "ringcentral",
315
+ });
316
+ const mentionGate = resolveMentionGatingWithBypass({
317
+ isGroup: true,
318
+ requireMention,
319
+ canDetectMention: Boolean(account.config.botExtensionId),
320
+ wasMentioned: mentionInfo.wasMentioned,
321
+ implicitMention: false,
322
+ hasAnyMention: mentionInfo.hasAnyMention,
323
+ allowTextCommands,
324
+ hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
325
+ commandAuthorized: commandAuthorized === true,
326
+ });
327
+ effectiveWasMentioned = mentionGate.effectiveWasMentioned;
328
+
329
+ // Response decision is now delegated to the AI based on SOUL/identity
330
+ // Plugin only handles mention gating; AI decides whether to respond or NO_REPLY
331
+
332
+ if (mentionGate.shouldSkip) {
333
+ logVerbose(core, runtime, `drop group message (mention required, chat=${chatId})`);
334
+ return;
335
+ }
336
+ }
337
+
338
+ // DM policy check
339
+ // - selfOnly=true (default): only Personal chat (self) is allowed (checked above via isPersonalChat)
340
+ // - selfOnly=false: allow DMs based on dmPolicy/allowFrom
341
+ if (!isGroup && !selfOnly) {
342
+ // Non-selfOnly mode: check dmPolicy and allowFrom
343
+ if (dmPolicy === "disabled") {
344
+ logVerbose(core, runtime, `ignore DM (dmPolicy=disabled)`);
345
+ return;
346
+ }
347
+ if (dmPolicy === "allowlist" && !isSenderAllowed(senderId, effectiveAllowFrom)) {
348
+ logVerbose(core, runtime, `ignore DM from ${senderId} (not in allowFrom)`);
349
+ return;
350
+ }
351
+ }
352
+
353
+ if (
354
+ isGroup &&
355
+ core.channel.commands.isControlCommandMessage(rawBody, config) &&
356
+ commandAuthorized !== true
357
+ ) {
358
+ logVerbose(core, runtime, `ringcentral: drop control command from ${senderId}`);
359
+ return;
360
+ }
361
+
362
+ const route = core.channel.routing.resolveAgentRoute({
363
+ cfg: config,
364
+ channel: "ringcentral",
365
+ accountId: account.accountId,
366
+ peer: {
367
+ kind: isGroup ? "group" : "dm",
368
+ id: chatId,
369
+ },
370
+ });
371
+
372
+ let mediaPath: string | undefined;
373
+ let mediaType: string | undefined;
374
+ if (attachments.length > 0) {
375
+ const first = attachments[0];
376
+ const attachmentData = await downloadAttachment(first, account, mediaMaxMb, core);
377
+ if (attachmentData) {
378
+ mediaPath = attachmentData.path;
379
+ mediaType = attachmentData.contentType;
380
+ }
381
+ }
382
+
383
+ const fromLabel = isGroup
384
+ ? chatName || `chat:${chatId}`
385
+ : `user:${senderId}`;
386
+ const storePath = core.channel.session.resolveStorePath(config.session?.store, {
387
+ agentId: route.agentId,
388
+ });
389
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
390
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
391
+ storePath,
392
+ sessionKey: route.sessionKey,
393
+ });
394
+ const body = core.channel.reply.formatAgentEnvelope({
395
+ channel: "RingCentral",
396
+ from: fromLabel,
397
+ timestamp: eventBody.creationTime ? Date.parse(eventBody.creationTime) : undefined,
398
+ previousTimestamp,
399
+ envelope: envelopeOptions,
400
+ body: rawBody,
401
+ });
402
+
403
+ const groupSystemPrompt = groupConfigResolved.entry?.systemPrompt?.trim() || undefined;
404
+
405
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
406
+ Body: body,
407
+ RawBody: rawBody,
408
+ CommandBody: rawBody,
409
+ From: `ringcentral:${senderId}`,
410
+ To: `ringcentral:${chatId}`,
411
+ SessionKey: route.sessionKey,
412
+ AccountId: route.accountId,
413
+ ChatType: isGroup ? "channel" : "direct",
414
+ ConversationLabel: fromLabel,
415
+ SenderId: senderId,
416
+ WasMentioned: isGroup ? effectiveWasMentioned : undefined,
417
+ CommandAuthorized: commandAuthorized,
418
+ Provider: "ringcentral",
419
+ Surface: "ringcentral",
420
+ MessageSid: eventBody.id,
421
+ MessageSidFull: eventBody.id,
422
+ MediaPath: mediaPath,
423
+ MediaType: mediaType,
424
+ MediaUrl: mediaPath,
425
+ GroupSpace: isGroup ? chatName ?? undefined : undefined,
426
+ GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
427
+ OriginatingChannel: "ringcentral",
428
+ OriginatingTo: `ringcentral:${chatId}`,
429
+ });
430
+
431
+ void core.channel.session
432
+ .recordSessionMetaFromInbound({
433
+ storePath,
434
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
435
+ ctx: ctxPayload,
436
+ })
437
+ .catch((err) => {
438
+ runtime.error?.(`ringcentral: failed updating session meta: ${String(err)}`);
439
+ });
440
+
441
+ // Typing indicator disabled - respond directly without "thinking" message
442
+
443
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
444
+ ctx: ctxPayload,
445
+ cfg: config,
446
+ dispatcherOptions: {
447
+ deliver: async (payload) => {
448
+ await deliverRingCentralReply({
449
+ payload,
450
+ account,
451
+ chatId,
452
+ runtime,
453
+ core,
454
+ config,
455
+ statusSink,
456
+ typingPostId: undefined,
457
+ });
458
+ },
459
+ onError: (err, info) => {
460
+ runtime.error?.(
461
+ `[${account.accountId}] RingCentral ${info.kind} reply failed: ${String(err)}`,
462
+ );
463
+ },
464
+ },
465
+ });
466
+ }
467
+
468
+ async function downloadAttachment(
469
+ attachment: RingCentralAttachment,
470
+ account: ResolvedRingCentralAccount,
471
+ mediaMaxMb: number,
472
+ core: RingCentralCoreRuntime,
473
+ ): Promise<{ path: string; contentType?: string } | null> {
474
+ const contentUri = attachment.contentUri;
475
+ if (!contentUri) return null;
476
+ const maxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
477
+ const downloaded = await downloadRingCentralAttachment({ account, contentUri, maxBytes });
478
+ const saved = await core.channel.media.saveMediaBuffer(
479
+ downloaded.buffer,
480
+ downloaded.contentType ?? attachment.contentType,
481
+ "inbound",
482
+ maxBytes,
483
+ attachment.name,
484
+ );
485
+ return { path: saved.path, contentType: saved.contentType };
486
+ }
487
+
488
+ async function deliverRingCentralReply(params: {
489
+ payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
490
+ account: ResolvedRingCentralAccount;
491
+ chatId: string;
492
+ runtime: RingCentralRuntimeEnv;
493
+ core: RingCentralCoreRuntime;
494
+ config: OpenClawConfig;
495
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
496
+ typingPostId?: string;
497
+ }): Promise<void> {
498
+ const { payload, account, chatId, runtime, core, config, statusSink, typingPostId } = params;
499
+ const mediaList = payload.mediaUrls?.length
500
+ ? payload.mediaUrls
501
+ : payload.mediaUrl
502
+ ? [payload.mediaUrl]
503
+ : [];
504
+
505
+ if (mediaList.length > 0) {
506
+ let suppressCaption = false;
507
+ if (typingPostId) {
508
+ try {
509
+ await deleteRingCentralMessage({
510
+ account,
511
+ chatId,
512
+ postId: typingPostId,
513
+ });
514
+ } catch (err) {
515
+ runtime.error?.(`RingCentral typing cleanup failed: ${String(err)}`);
516
+ const fallbackText = payload.text?.trim()
517
+ ? payload.text
518
+ : mediaList.length > 1
519
+ ? "Sent attachments."
520
+ : "Sent attachment.";
521
+ try {
522
+ await updateRingCentralMessage({
523
+ account,
524
+ chatId,
525
+ postId: typingPostId,
526
+ text: fallbackText,
527
+ });
528
+ suppressCaption = Boolean(payload.text?.trim());
529
+ } catch (updateErr) {
530
+ runtime.error?.(`RingCentral typing update failed: ${String(updateErr)}`);
531
+ }
532
+ }
533
+ }
534
+ let first = true;
535
+ for (const mediaUrl of mediaList) {
536
+ const caption = first && !suppressCaption ? payload.text : undefined;
537
+ first = false;
538
+ try {
539
+ const loaded = await core.channel.media.fetchRemoteMedia(mediaUrl, {
540
+ maxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
541
+ });
542
+ const upload = await uploadRingCentralAttachment({
543
+ account,
544
+ chatId,
545
+ filename: loaded.filename ?? "attachment",
546
+ buffer: loaded.buffer,
547
+ contentType: loaded.contentType,
548
+ });
549
+ if (!upload.attachmentId) {
550
+ throw new Error("missing attachment id");
551
+ }
552
+ const sendResult = await sendRingCentralMessage({
553
+ account,
554
+ chatId,
555
+ text: caption,
556
+ attachments: [{ id: upload.attachmentId }],
557
+ });
558
+ if (sendResult?.postId) trackSentMessageId(sendResult.postId);
559
+ statusSink?.({ lastOutboundAt: Date.now() });
560
+ } catch (err) {
561
+ runtime.error?.(`RingCentral attachment send failed: ${String(err)}`);
562
+ }
563
+ }
564
+ return;
565
+ }
566
+
567
+ if (payload.text) {
568
+ const chunkLimit = account.config.textChunkLimit ?? 4000;
569
+ const chunkMode = core.channel.text.resolveChunkMode(
570
+ config,
571
+ "ringcentral",
572
+ account.accountId,
573
+ );
574
+ const chunks = core.channel.text.chunkMarkdownTextWithMode(
575
+ payload.text,
576
+ chunkLimit,
577
+ chunkMode,
578
+ );
579
+ for (let i = 0; i < chunks.length; i++) {
580
+ const chunk = chunks[i];
581
+ try {
582
+ if (i === 0 && typingPostId) {
583
+ const updateResult = await updateRingCentralMessage({
584
+ account,
585
+ chatId,
586
+ postId: typingPostId,
587
+ text: chunk,
588
+ });
589
+ if (updateResult?.postId) trackSentMessageId(updateResult.postId);
590
+ } else {
591
+ const sendResult = await sendRingCentralMessage({
592
+ account,
593
+ chatId,
594
+ text: chunk,
595
+ });
596
+ if (sendResult?.postId) trackSentMessageId(sendResult.postId);
597
+ }
598
+ statusSink?.({ lastOutboundAt: Date.now() });
599
+ } catch (err) {
600
+ runtime.error?.(`RingCentral message send failed: ${String(err)}`);
601
+ }
602
+ }
603
+ }
604
+ }
605
+
606
+ export async function startRingCentralMonitor(
607
+ options: RingCentralMonitorOptions,
608
+ ): Promise<() => void> {
609
+ const { account, config, runtime, abortSignal, statusSink } = options;
610
+ const core = getRingCentralRuntime();
611
+
612
+ let wsSubscription: Awaited<ReturnType<ReturnType<InstanceType<typeof Subscriptions>["createSubscription"]>["register"]>> | null = null;
613
+ let reconnectAttempts = 0;
614
+ let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
615
+ let isShuttingDown = false;
616
+ let ownerId: string | undefined;
617
+
618
+ // Calculate delay with exponential backoff
619
+ const getReconnectDelay = () => {
620
+ const delay = Math.min(
621
+ RECONNECT_INITIAL_DELAY * Math.pow(2, reconnectAttempts),
622
+ RECONNECT_MAX_DELAY
623
+ );
624
+ return delay;
625
+ };
626
+
627
+ // Create and setup subscription
628
+ const createSubscription = async (): Promise<void> => {
629
+ if (isShuttingDown || abortSignal.aborted) return;
630
+
631
+ runtime.log?.(`[${account.accountId}] Starting RingCentral WebSocket subscription...`);
632
+
633
+ try {
634
+ // Get SDK instance
635
+ const sdk = await getRingCentralSDK(account);
636
+
637
+ // Create subscriptions manager
638
+ const subscriptions = new Subscriptions({ sdk });
639
+ const subscription = subscriptions.createSubscription();
640
+
641
+ // Track current user ID to filter out self messages
642
+ if (!ownerId) {
643
+ try {
644
+ const platform = sdk.platform();
645
+ const response = await platform.get("/restapi/v1.0/account/~/extension/~");
646
+ const userInfo = await response.json();
647
+ ownerId = userInfo?.id?.toString();
648
+ runtime.log?.(`[${account.accountId}] Authenticated as extension: ${ownerId}`);
649
+ } catch (err) {
650
+ runtime.error?.(`[${account.accountId}] Failed to get current user: ${String(err)}`);
651
+ }
652
+ }
653
+
654
+ // Handle notifications
655
+ subscription.on(subscription.events.notification, (event: unknown) => {
656
+ logVerbose(core, runtime, `WebSocket notification received: ${JSON.stringify(event).slice(0, 500)}`);
657
+ const evt = event as RingCentralWebhookEvent;
658
+ processWebSocketEvent({
659
+ event: evt,
660
+ account,
661
+ config,
662
+ runtime,
663
+ core,
664
+ statusSink,
665
+ ownerId,
666
+ }).catch((err) => {
667
+ runtime.error?.(`[${account.accountId}] WebSocket event processing failed: ${String(err)}`);
668
+ });
669
+ });
670
+
671
+ // Subscribe to Team Messaging events and save WsSubscription for cleanup
672
+ wsSubscription = await subscription
673
+ .setEventFilters([
674
+ "/restapi/v1.0/glip/posts",
675
+ "/restapi/v1.0/glip/groups",
676
+ ])
677
+ .register();
678
+
679
+ runtime.log?.(`[${account.accountId}] RingCentral WebSocket subscription established`);
680
+ reconnectAttempts = 0; // Reset on success
681
+
682
+ } catch (err) {
683
+ runtime.error?.(`[${account.accountId}] Failed to create WebSocket subscription: ${String(err)}`);
684
+ scheduleReconnect();
685
+ }
686
+ };
687
+
688
+ // Schedule reconnection with exponential backoff
689
+ const scheduleReconnect = () => {
690
+ if (isShuttingDown || abortSignal.aborted) return;
691
+ if (reconnectAttempts >= RECONNECT_MAX_ATTEMPTS) {
692
+ runtime.error?.(`[${account.accountId}] Max reconnection attempts (${RECONNECT_MAX_ATTEMPTS}) reached. Giving up.`);
693
+ return;
694
+ }
695
+
696
+ const delay = getReconnectDelay();
697
+ reconnectAttempts++;
698
+ runtime.log?.(`[${account.accountId}] Scheduling reconnection attempt ${reconnectAttempts}/${RECONNECT_MAX_ATTEMPTS} in ${delay}ms...`);
699
+
700
+ // Clean up existing WsSubscription
701
+ if (wsSubscription) {
702
+ wsSubscription.revoke().catch(() => {});
703
+ wsSubscription = null;
704
+ }
705
+
706
+ reconnectTimeout = setTimeout(() => {
707
+ reconnectTimeout = null;
708
+ createSubscription().catch((err) => {
709
+ runtime.error?.(`[${account.accountId}] Reconnection failed: ${String(err)}`);
710
+ });
711
+ }, delay);
712
+ };
713
+
714
+ // Initial connection
715
+ await createSubscription();
716
+
717
+ // Handle abort signal
718
+ const cleanup = () => {
719
+ isShuttingDown = true;
720
+ runtime.log?.(`[${account.accountId}] Stopping RingCentral WebSocket subscription...`);
721
+
722
+ if (reconnectTimeout) {
723
+ clearTimeout(reconnectTimeout);
724
+ reconnectTimeout = null;
725
+ }
726
+
727
+ if (wsSubscription) {
728
+ wsSubscription.revoke().catch((err) => {
729
+ runtime.error?.(`[${account.accountId}] Failed to revoke subscription: ${String(err)}`);
730
+ });
731
+ wsSubscription = null;
732
+ }
733
+ };
734
+
735
+ if (abortSignal.aborted) {
736
+ cleanup();
737
+ } else {
738
+ abortSignal.addEventListener("abort", cleanup, { once: true });
739
+ }
740
+
741
+ return cleanup;
742
+ }