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/LICENSE +21 -0
- package/README.md +186 -0
- package/index.ts +18 -0
- package/openclaw.plugin.json +200 -0
- package/package.json +72 -0
- package/src/accounts.test.ts +311 -0
- package/src/accounts.ts +167 -0
- package/src/api.ts +241 -0
- package/src/auth.ts +92 -0
- package/src/channel.ts +545 -0
- package/src/config-schema.ts +78 -0
- package/src/markdown.test.ts +168 -0
- package/src/markdown.ts +158 -0
- package/src/monitor.test.ts +47 -0
- package/src/monitor.ts +742 -0
- package/src/openclaw.d.ts +68 -0
- package/src/runtime.ts +14 -0
- package/src/targets.test.ts +118 -0
- package/src/targets.ts +70 -0
- package/src/types.ts +174 -0
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
|
+
}
|