openclaw-ringcentral 2026.1.29 → 2026.1.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-ringcentral",
3
- "version": "2026.1.29",
3
+ "version": "2026.1.30",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "description": "OpenClaw RingCentral Team Messaging channel plugin",
@@ -57,6 +57,7 @@
57
57
  }
58
58
  },
59
59
  "dependencies": {
60
+ "@rc-ex/ws": "^1.0.0",
60
61
  "@ringcentral/sdk": "^5.0.0",
61
62
  "@ringcentral/subscriptions": "^5.0.0",
62
63
  "isomorphic-ws": "^5.0.0",
package/src/accounts.ts CHANGED
@@ -139,7 +139,7 @@ export function resolveRingCentralAccount(params: {
139
139
  cfg: OpenClawConfig;
140
140
  accountId?: string | null;
141
141
  }): ResolvedRingCentralAccount {
142
- const accountId = normalizeAccountId(params.accountId);
142
+ const accountId = normalizeAccountId(params.accountId ?? undefined);
143
143
  const baseEnabled =
144
144
  (params.cfg.channels?.ringcentral as RingCentralConfig | undefined)?.enabled !== false;
145
145
  const merged = mergeRingCentralAccountConfig(params.cfg, accountId);
package/src/api.ts CHANGED
@@ -12,6 +12,100 @@ import type {
12
12
  // Team Messaging API endpoints
13
13
  const TM_API_BASE = "/team-messaging/v1";
14
14
 
15
+ export type RingCentralApiErrorInfo = {
16
+ httpStatus?: number;
17
+ requestId?: string;
18
+ errorCode?: string;
19
+ errorMessage?: string;
20
+ accountId?: string;
21
+ errors?: Array<{ errorCode?: string; message?: string; parameterName?: string }>;
22
+ };
23
+
24
+ export function extractRcApiError(err: unknown, accountId?: string): RingCentralApiErrorInfo {
25
+ const info: RingCentralApiErrorInfo = {};
26
+ if (accountId) info.accountId = accountId;
27
+
28
+ if (!err || typeof err !== "object") {
29
+ info.errorMessage = String(err);
30
+ return info;
31
+ }
32
+
33
+ const e = err as Record<string, unknown>;
34
+
35
+ // @ringcentral/sdk wraps errors with response object
36
+ const response = e.response as Record<string, unknown> | undefined;
37
+ if (response) {
38
+ info.httpStatus = typeof response.status === "number" ? response.status : undefined;
39
+
40
+ // Extract request ID from headers
41
+ const headers = response.headers as Record<string, unknown> | undefined;
42
+ if (headers) {
43
+ // headers can be a Headers object or plain object
44
+ if (typeof (headers as any).get === "function") {
45
+ info.requestId = (headers as any).get("x-request-id") ?? (headers as any).get("rcrequestid");
46
+ } else {
47
+ info.requestId = (headers["x-request-id"] ?? headers["rcrequestid"]) as string | undefined;
48
+ }
49
+ }
50
+ }
51
+
52
+ // Try to extract error body (SDK often attaches parsed JSON to error)
53
+ const body = (e._response as Record<string, unknown> | undefined) ??
54
+ (e.body as Record<string, unknown> | undefined) ??
55
+ (e.data as Record<string, unknown> | undefined);
56
+ if (body && typeof body === "object") {
57
+ info.errorCode = body.errorCode as string | undefined;
58
+ info.errorMessage = body.message as string | undefined;
59
+ if (Array.isArray(body.errors)) {
60
+ info.errors = body.errors;
61
+ }
62
+ }
63
+
64
+ // Fallback: parse message if it looks like JSON
65
+ if (!info.errorCode && typeof e.message === "string") {
66
+ const msg = e.message;
67
+ try {
68
+ const parsed = JSON.parse(msg);
69
+ if (parsed && typeof parsed === "object") {
70
+ info.errorCode = parsed.errorCode;
71
+ info.errorMessage = parsed.message ?? info.errorMessage;
72
+ if (Array.isArray(parsed.errors)) {
73
+ info.errors = parsed.errors;
74
+ }
75
+ }
76
+ } catch {
77
+ // Not JSON, use as-is
78
+ info.errorMessage = info.errorMessage ?? msg;
79
+ }
80
+ }
81
+
82
+ // Extract from standard Error properties
83
+ if (!info.errorMessage && typeof e.message === "string") {
84
+ info.errorMessage = e.message;
85
+ }
86
+
87
+ return info;
88
+ }
89
+
90
+ export function formatRcApiError(info: RingCentralApiErrorInfo): string {
91
+ const parts: string[] = [];
92
+
93
+ if (info.httpStatus) parts.push(`HTTP ${info.httpStatus}`);
94
+ if (info.errorCode) parts.push(`ErrorCode=${info.errorCode}`);
95
+ if (info.requestId) parts.push(`RequestId=${info.requestId}`);
96
+ if (info.accountId) parts.push(`AccountId=${info.accountId}`);
97
+ if (info.errorMessage) parts.push(`Message="${info.errorMessage}"`);
98
+
99
+ if (info.errors && info.errors.length > 0) {
100
+ const errDetails = info.errors
101
+ .map((e) => `${e.errorCode ?? "?"}: ${e.message ?? "?"}${e.parameterName ? ` (${e.parameterName})` : ""}`)
102
+ .join("; ");
103
+ parts.push(`Details=[${errDetails}]`);
104
+ }
105
+
106
+ return parts.length > 0 ? parts.join(" | ") : "Unknown error";
107
+ }
108
+
15
109
  export async function sendRingCentralMessage(params: {
16
110
  account: ResolvedRingCentralAccount;
17
111
  chatId: string;
@@ -132,7 +226,7 @@ export async function uploadRingCentralAttachment(params: {
132
226
 
133
227
  // Create FormData for multipart upload
134
228
  const formData = new FormData();
135
- const blob = new Blob([buffer], { type: contentType || "application/octet-stream" });
229
+ const blob = new Blob([new Uint8Array(buffer)], { type: contentType || "application/octet-stream" });
136
230
  formData.append("file", blob, filename);
137
231
 
138
232
  const response = await platform.post(
package/src/channel.ts CHANGED
@@ -74,7 +74,7 @@ export const ringcentralDock: ChannelDock = {
74
74
  resolveReplyToMode: ({ cfg }) =>
75
75
  (cfg.channels?.ringcentral as RingCentralConfig | undefined)?.replyToMode ?? "off",
76
76
  buildToolContext: ({ context, hasRepliedRef }) => ({
77
- currentChannelId: context.To?.trim() || undefined,
77
+ currentChannelId: (context.To as string | undefined)?.trim() || undefined,
78
78
  currentThreadTs: undefined,
79
79
  hasRepliedRef,
80
80
  }),
@@ -305,7 +305,7 @@ export const ringcentralPlugin: ChannelPlugin<ResolvedRingCentralAccount> = {
305
305
  cfg: cfg as OpenClawConfig,
306
306
  channelKey: "ringcentral",
307
307
  accountId,
308
- name: input.name,
308
+ name: input.name as string | undefined,
309
309
  });
310
310
  const next =
311
311
  accountId !== DEFAULT_ACCOUNT_ID
@@ -315,6 +315,7 @@ export const ringcentralPlugin: ChannelPlugin<ResolvedRingCentralAccount> = {
315
315
  })
316
316
  : namedConfig;
317
317
  // Build nested credentials block
318
+ const inputServer = input.server as string | undefined;
318
319
  const credentialsPatch = input.useEnv
319
320
  ? {}
320
321
  : {
@@ -322,11 +323,11 @@ export const ringcentralPlugin: ChannelPlugin<ResolvedRingCentralAccount> = {
322
323
  ...(input.clientId ? { clientId: input.clientId } : {}),
323
324
  ...(input.clientSecret ? { clientSecret: input.clientSecret } : {}),
324
325
  ...(input.jwt ? { jwt: input.jwt } : {}),
325
- ...(input.server?.trim() ? { server: input.server.trim() } : {}),
326
+ ...(inputServer?.trim() ? { server: inputServer.trim() } : {}),
326
327
  },
327
328
  };
328
329
  // Only include credentials if it has any values
329
- const hasCredentials = input.clientId || input.clientSecret || input.jwt || input.server?.trim();
330
+ const hasCredentials = input.clientId || input.clientSecret || input.jwt || inputServer?.trim();
330
331
  const configPatch = input.useEnv || !hasCredentials ? {} : credentialsPatch;
331
332
  if (accountId === DEFAULT_ACCOUNT_ID) {
332
333
  return {
package/src/monitor.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { Subscriptions } from "@ringcentral/subscriptions";
2
+ import RcWsExtension, { Events as RcWsEvents } from "@rc-ex/ws";
3
+ const WebSocketExtension = RcWsExtension.default ?? RcWsExtension;
4
+ type WebSocketExtension = InstanceType<typeof WebSocketExtension>;
2
5
 
3
6
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
4
7
  import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk";
@@ -12,6 +15,8 @@ import {
12
15
  downloadRingCentralAttachment,
13
16
  uploadRingCentralAttachment,
14
17
  getRingCentralChat,
18
+ extractRcApiError,
19
+ formatRcApiError,
15
20
  } from "./api.js";
16
21
  import { getRingCentralRuntime } from "./runtime.js";
17
22
  import type {
@@ -21,19 +26,113 @@ import type {
21
26
  RingCentralMention,
22
27
  } from "./types.js";
23
28
 
29
+ export type RingCentralLogger = {
30
+ debug: (message: string) => void;
31
+ info: (message: string) => void;
32
+ warn: (message: string) => void;
33
+ error: (message: string) => void;
34
+ };
35
+
24
36
  export type RingCentralRuntimeEnv = {
25
37
  log?: (message: string) => void;
26
38
  error?: (message: string) => void;
27
39
  };
28
40
 
41
+ function createLogger(core: RingCentralCoreRuntime): RingCentralLogger {
42
+ return core.logging.getChildLogger({ plugin: "ringcentral" });
43
+ }
44
+
29
45
  // Track recently sent message IDs to avoid processing bot's own replies
30
46
  const recentlySentMessageIds = new Set<string>();
31
47
  const MESSAGE_ID_TTL = 60000; // 60 seconds
32
48
 
33
49
  // Reconnection settings
34
- const RECONNECT_INITIAL_DELAY = 1000; // 1 second
35
- const RECONNECT_MAX_DELAY = 60000; // 60 seconds
36
- const RECONNECT_MAX_ATTEMPTS = 10;
50
+ const RECONNECT_INITIAL_DELAY = 5000; // 5 seconds (increased to avoid 429)
51
+ const RECONNECT_MAX_DELAY = 300000; // 5 minutes (increased for rate limiting)
52
+ const RECONNECT_MAX_ATTEMPTS = 5; // Reduced attempts
53
+ const RATE_LIMIT_BACKOFF = 60000; // 1 minute backoff on 429
54
+
55
+ // WebSocket singleton per account to avoid hammering /oauth/wstoken.
56
+ // @ringcentral/subscriptions + @rc-ex/ws can swallow initial connect errors and
57
+ // repeated new Subscriptions()/newWsExtension() will trigger new wstoken calls.
58
+ type WsManager = {
59
+ key: string;
60
+ sdk: any;
61
+ subscriptions: Subscriptions;
62
+ rc: any;
63
+ wsExt: WebSocketExtension;
64
+ connectPromise?: Promise<void>;
65
+ lastConnectAt?: number;
66
+ };
67
+
68
+ const wsManagers = new Map<string, WsManager>();
69
+
70
+ function buildWsManagerKey(account: ResolvedRingCentralAccount): string {
71
+ // Changes in credentials should force a new WS manager.
72
+ return `${account.clientId}:${account.server}:${account.jwt?.slice(0, 20)}`;
73
+ }
74
+
75
+ async function getOrCreateWsManager(
76
+ account: ResolvedRingCentralAccount,
77
+ logger: RingCentralLogger,
78
+ ): Promise<WsManager> {
79
+ const key = buildWsManagerKey(account);
80
+ const cached = wsManagers.get(account.accountId);
81
+ if (cached && cached.key === key) return cached;
82
+
83
+ // Replace cache entry on credential change.
84
+ const sdk = await getRingCentralSDK(account);
85
+ const subscriptions = new Subscriptions({ sdk });
86
+ await (subscriptions as any).init?.();
87
+
88
+ const wsExt = new WebSocketExtension({
89
+ debugMode: true,
90
+ autoRecover: { enabled: false },
91
+ });
92
+
93
+ const rc = (subscriptions as any).rc;
94
+ if (!rc || typeof rc.installExtension !== "function") {
95
+ throw new Error("Subscriptions.rc.installExtension is unavailable; cannot install WS extension");
96
+ }
97
+
98
+ logger.debug(`[${account.accountId}] Installing @rc-ex/ws extension (singleton)...`);
99
+ await rc.installExtension(wsExt);
100
+
101
+ const mgr: WsManager = { key, sdk, subscriptions, rc, wsExt };
102
+ wsManagers.set(account.accountId, mgr);
103
+ return mgr;
104
+ }
105
+
106
+ async function ensureWsConnected(
107
+ mgr: WsManager,
108
+ account: ResolvedRingCentralAccount,
109
+ logger: RingCentralLogger,
110
+ ): Promise<void> {
111
+ // If already connected/open, nothing to do.
112
+ const ws = mgr.wsExt.ws;
113
+ if (ws && (ws.readyState === 0 || ws.readyState === 1)) {
114
+ return;
115
+ }
116
+ if (mgr.connectPromise) {
117
+ return mgr.connectPromise;
118
+ }
119
+
120
+ mgr.connectPromise = (async () => {
121
+ logger.debug(`[${account.accountId}] Forcing WS connect() (singleton)...`);
122
+ await mgr.wsExt.connect(false);
123
+ mgr.lastConnectAt = Date.now();
124
+ if (!mgr.wsExt.ws) {
125
+ throw new Error("WS connect() returned but wsExt.ws is still undefined");
126
+ }
127
+ })();
128
+
129
+ try {
130
+ await mgr.connectPromise;
131
+ } finally {
132
+ mgr.connectPromise = undefined;
133
+ }
134
+ }
135
+
37
136
 
38
137
  function trackSentMessageId(messageId: string): void {
39
138
  recentlySentMessageIds.add(messageId);
@@ -54,13 +153,22 @@ export type RingCentralMonitorOptions = {
54
153
 
55
154
  type RingCentralCoreRuntime = ReturnType<typeof getRingCentralRuntime>;
56
155
 
156
+ // Shared logger instance (lazy initialized)
157
+ let sharedLogger: RingCentralLogger | null = null;
158
+
159
+ function getLogger(core: RingCentralCoreRuntime): RingCentralLogger {
160
+ if (!sharedLogger) {
161
+ sharedLogger = createLogger(core);
162
+ }
163
+ return sharedLogger;
164
+ }
165
+
57
166
  function logVerbose(
58
167
  core: RingCentralCoreRuntime,
59
- runtime: RingCentralRuntimeEnv,
60
168
  message: string,
61
169
  ) {
62
170
  if (core.logging.shouldLogVerbose()) {
63
- runtime.log?.(`[ringcentral] ${message}`);
171
+ getLogger(core).debug(message);
64
172
  }
65
173
  }
66
174
 
@@ -174,6 +282,7 @@ async function processMessageWithPipeline(params: {
174
282
  ownerId?: string;
175
283
  }): Promise<void> {
176
284
  const { eventBody, account, config, runtime, core, statusSink, ownerId } = params;
285
+ const logger = getLogger(core);
177
286
  const mediaMaxMb = account.config.mediaMaxMb ?? 20;
178
287
 
179
288
  const chatId = eventBody.groupId ?? "";
@@ -190,13 +299,13 @@ async function processMessageWithPipeline(params: {
190
299
  // Check 1: Skip if this is a message we recently sent
191
300
  const messageId = eventBody.id ?? "";
192
301
  if (messageId && isOwnSentMessage(messageId)) {
193
- logVerbose(core, runtime, `skip own sent message: ${messageId}`);
302
+ logVerbose(core, `skip own sent message: ${messageId}`);
194
303
  return;
195
304
  }
196
305
 
197
306
  // Check 2: Skip typing/thinking indicators (pattern-based)
198
307
  if (rawBody.includes("thinking...") || rawBody.includes("typing...")) {
199
- logVerbose(core, runtime, "skip typing indicator message");
308
+ logVerbose(core, "skip typing indicator message");
200
309
  return;
201
310
  }
202
311
 
@@ -204,16 +313,16 @@ async function processMessageWithPipeline(params: {
204
313
  // This is because the bot uses the JWT user's identity, so we're essentially
205
314
  // having a conversation with ourselves (the AI assistant)
206
315
  const selfOnly = account.config.selfOnly !== false; // default true
207
- runtime.log?.(`[${account.accountId}] Processing message: senderId=${senderId}, ownerId=${ownerId}, selfOnly=${selfOnly}, chatId=${chatId}`);
316
+ logger.debug(`[${account.accountId}] Processing message: senderId=${senderId}, ownerId=${ownerId}, selfOnly=${selfOnly}, chatId=${chatId}`);
208
317
 
209
318
  if (selfOnly && ownerId) {
210
319
  if (senderId !== ownerId) {
211
- logVerbose(core, runtime, `ignore message from non-owner: ${senderId} (selfOnly mode)`);
320
+ logVerbose(core, `ignore message from non-owner: ${senderId} (selfOnly mode)`);
212
321
  return;
213
322
  }
214
323
  }
215
324
 
216
- runtime.log?.(`[${account.accountId}] Message passed selfOnly check`);
325
+ logger.debug(`[${account.accountId}] Message passed selfOnly check`);
217
326
 
218
327
  // Fetch chat info to determine type
219
328
  let chatType = "Group";
@@ -229,11 +338,11 @@ async function processMessageWithPipeline(params: {
229
338
  // Personal, PersonalChat, Direct are all DM types
230
339
  const isPersonalChat = chatType === "Personal" || chatType === "PersonalChat";
231
340
  const isGroup = chatType !== "Direct" && chatType !== "PersonalChat" && chatType !== "Personal";
232
- runtime.log?.(`[${account.accountId}] Chat type: ${chatType}, isGroup: ${isGroup}`);
341
+ logger.debug(`[${account.accountId}] Chat type: ${chatType}, isGroup: ${isGroup}`);
233
342
 
234
343
  // In selfOnly mode, only allow "Personal" chat (conversation with yourself)
235
344
  if (selfOnly && !isPersonalChat) {
236
- logVerbose(core, runtime, `ignore non-personal chat in selfOnly mode: chatType=${chatType}`);
345
+ logVerbose(core, `ignore non-personal chat in selfOnly mode: chatType=${chatType}`);
237
346
  return;
238
347
  }
239
348
 
@@ -250,7 +359,7 @@ async function processMessageWithPipeline(params: {
250
359
 
251
360
  if (isGroup) {
252
361
  if (groupPolicy === "disabled") {
253
- logVerbose(core, runtime, `drop group message (groupPolicy=disabled, chat=${chatId})`);
362
+ logVerbose(core, `drop group message (groupPolicy=disabled, chat=${chatId})`);
254
363
  return;
255
364
  }
256
365
  const groupAllowlistConfigured = groupConfigResolved.allowlistConfigured;
@@ -260,25 +369,24 @@ async function processMessageWithPipeline(params: {
260
369
  if (!groupAllowlistConfigured) {
261
370
  logVerbose(
262
371
  core,
263
- runtime,
264
372
  `drop group message (groupPolicy=allowlist, no allowlist, chat=${chatId})`,
265
373
  );
266
374
  return;
267
375
  }
268
376
  if (!groupAllowed) {
269
- logVerbose(core, runtime, `drop group message (not allowlisted, chat=${chatId})`);
377
+ logVerbose(core, `drop group message (not allowlisted, chat=${chatId})`);
270
378
  return;
271
379
  }
272
380
  }
273
381
  if (groupEntry?.enabled === false || groupEntry?.allow === false) {
274
- logVerbose(core, runtime, `drop group message (chat disabled, chat=${chatId})`);
382
+ logVerbose(core, `drop group message (chat disabled, chat=${chatId})`);
275
383
  return;
276
384
  }
277
385
 
278
386
  if (groupUsers.length > 0) {
279
387
  const ok = isSenderAllowed(senderId, groupUsers.map((v) => String(v)));
280
388
  if (!ok) {
281
- logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`);
389
+ logVerbose(core, `drop group message (sender not allowed, ${senderId})`);
282
390
  return;
283
391
  }
284
392
  }
@@ -330,7 +438,7 @@ async function processMessageWithPipeline(params: {
330
438
  // Plugin only handles mention gating; AI decides whether to respond or NO_REPLY
331
439
 
332
440
  if (mentionGate.shouldSkip) {
333
- logVerbose(core, runtime, `drop group message (mention required, chat=${chatId})`);
441
+ logVerbose(core, `drop group message (mention required, chat=${chatId})`);
334
442
  return;
335
443
  }
336
444
  }
@@ -341,11 +449,11 @@ async function processMessageWithPipeline(params: {
341
449
  if (!isGroup && !selfOnly) {
342
450
  // Non-selfOnly mode: check dmPolicy and allowFrom
343
451
  if (dmPolicy === "disabled") {
344
- logVerbose(core, runtime, `ignore DM (dmPolicy=disabled)`);
452
+ logVerbose(core, `ignore DM (dmPolicy=disabled)`);
345
453
  return;
346
454
  }
347
455
  if (dmPolicy === "allowlist" && !isSenderAllowed(senderId, effectiveAllowFrom)) {
348
- logVerbose(core, runtime, `ignore DM from ${senderId} (not in allowFrom)`);
456
+ logVerbose(core, `ignore DM from ${senderId} (not in allowFrom)`);
349
457
  return;
350
458
  }
351
459
  }
@@ -355,7 +463,7 @@ async function processMessageWithPipeline(params: {
355
463
  core.channel.commands.isControlCommandMessage(rawBody, config) &&
356
464
  commandAuthorized !== true
357
465
  ) {
358
- logVerbose(core, runtime, `ringcentral: drop control command from ${senderId}`);
466
+ logVerbose(core, `ringcentral: drop control command from ${senderId}`);
359
467
  return;
360
468
  }
361
469
 
@@ -431,11 +539,11 @@ async function processMessageWithPipeline(params: {
431
539
  void core.channel.session
432
540
  .recordSessionMetaFromInbound({
433
541
  storePath,
434
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
542
+ sessionKey: (ctxPayload.SessionKey as string | undefined) ?? route.sessionKey,
435
543
  ctx: ctxPayload,
436
544
  })
437
545
  .catch((err) => {
438
- runtime.error?.(`ringcentral: failed updating session meta: ${String(err)}`);
546
+ logger.error(`ringcentral: failed updating session meta: ${String(err)}`);
439
547
  });
440
548
 
441
549
  // Typing indicator disabled - respond directly without "thinking" message
@@ -449,7 +557,6 @@ async function processMessageWithPipeline(params: {
449
557
  payload,
450
558
  account,
451
559
  chatId,
452
- runtime,
453
560
  core,
454
561
  config,
455
562
  statusSink,
@@ -457,7 +564,7 @@ async function processMessageWithPipeline(params: {
457
564
  });
458
565
  },
459
566
  onError: (err, info) => {
460
- runtime.error?.(
567
+ logger.error(
461
568
  `[${account.accountId}] RingCentral ${info.kind} reply failed: ${String(err)}`,
462
569
  );
463
570
  },
@@ -489,13 +596,13 @@ async function deliverRingCentralReply(params: {
489
596
  payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
490
597
  account: ResolvedRingCentralAccount;
491
598
  chatId: string;
492
- runtime: RingCentralRuntimeEnv;
493
599
  core: RingCentralCoreRuntime;
494
600
  config: OpenClawConfig;
495
601
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
496
602
  typingPostId?: string;
497
603
  }): Promise<void> {
498
- const { payload, account, chatId, runtime, core, config, statusSink, typingPostId } = params;
604
+ const { payload, account, chatId, core, config, statusSink, typingPostId } = params;
605
+ const logger = getLogger(core);
499
606
  const mediaList = payload.mediaUrls?.length
500
607
  ? payload.mediaUrls
501
608
  : payload.mediaUrl
@@ -512,7 +619,8 @@ async function deliverRingCentralReply(params: {
512
619
  postId: typingPostId,
513
620
  });
514
621
  } catch (err) {
515
- runtime.error?.(`RingCentral typing cleanup failed: ${String(err)}`);
622
+ const errInfo = formatRcApiError(extractRcApiError(err, account.accountId));
623
+ logger.error(`RingCentral typing cleanup failed: ${errInfo}`);
516
624
  const fallbackText = payload.text?.trim()
517
625
  ? payload.text
518
626
  : mediaList.length > 1
@@ -527,7 +635,8 @@ async function deliverRingCentralReply(params: {
527
635
  });
528
636
  suppressCaption = Boolean(payload.text?.trim());
529
637
  } catch (updateErr) {
530
- runtime.error?.(`RingCentral typing update failed: ${String(updateErr)}`);
638
+ const updateErrInfo = formatRcApiError(extractRcApiError(updateErr, account.accountId));
639
+ logger.error(`RingCentral typing update failed: ${updateErrInfo}`);
531
640
  }
532
641
  }
533
642
  }
@@ -558,7 +667,8 @@ async function deliverRingCentralReply(params: {
558
667
  if (sendResult?.postId) trackSentMessageId(sendResult.postId);
559
668
  statusSink?.({ lastOutboundAt: Date.now() });
560
669
  } catch (err) {
561
- runtime.error?.(`RingCentral attachment send failed: ${String(err)}`);
670
+ const errInfo = formatRcApiError(extractRcApiError(err, account.accountId));
671
+ logger.error(`RingCentral attachment send failed: ${errInfo}`);
562
672
  }
563
673
  }
564
674
  return;
@@ -597,7 +707,8 @@ async function deliverRingCentralReply(params: {
597
707
  }
598
708
  statusSink?.({ lastOutboundAt: Date.now() });
599
709
  } catch (err) {
600
- runtime.error?.(`RingCentral message send failed: ${String(err)}`);
710
+ const errInfo = formatRcApiError(extractRcApiError(err, account.accountId));
711
+ logger.error(`RingCentral message send failed: ${errInfo}`);
601
712
  }
602
713
  }
603
714
  }
@@ -608,6 +719,7 @@ export async function startRingCentralMonitor(
608
719
  ): Promise<() => void> {
609
720
  const { account, config, runtime, abortSignal, statusSink } = options;
610
721
  const core = getRingCentralRuntime();
722
+ const logger = createLogger(core);
611
723
 
612
724
  let wsSubscription: Awaited<ReturnType<ReturnType<InstanceType<typeof Subscriptions>["createSubscription"]>["register"]>> | null = null;
613
725
  let reconnectAttempts = 0;
@@ -615,6 +727,9 @@ export async function startRingCentralMonitor(
615
727
  let isShuttingDown = false;
616
728
  let ownerId: string | undefined;
617
729
 
730
+ // Avoid hammering /oauth/wstoken (auth rate limit is very low, e.g. 5/min).
731
+ let nextAllowedWsConnectAt = 0;
732
+
618
733
  // Calculate delay with exponential backoff
619
734
  const getReconnectDelay = () => {
620
735
  const delay = Math.min(
@@ -628,32 +743,61 @@ export async function startRingCentralMonitor(
628
743
  const createSubscription = async (): Promise<void> => {
629
744
  if (isShuttingDown || abortSignal.aborted) return;
630
745
 
631
- runtime.log?.(`[${account.accountId}] Starting RingCentral WebSocket subscription...`);
746
+ logger.info(`[${account.accountId}] Starting RingCentral WebSocket subscription...`);
747
+
748
+ if (Date.now() < nextAllowedWsConnectAt) {
749
+ const waitMs = nextAllowedWsConnectAt - Date.now();
750
+ logger.warn(
751
+ `[${account.accountId}] WS connect is rate-limited locally; will retry in ${Math.ceil(waitMs / 1000)}s`,
752
+ );
753
+ scheduleReconnect();
754
+ return;
755
+ }
632
756
 
633
757
  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();
758
+ // Get or create WS manager (singleton per account)
759
+ const mgr = await getOrCreateWsManager(account, logger);
760
+
761
+ // Force connect once per account (with in-flight de-dupe)
762
+ await ensureWsConnected(mgr, account, logger);
763
+
764
+ const subscription = mgr.subscriptions.createSubscription();
765
+
766
+ // IMPORTANT: @ringcentral/subscriptions will create *its own* WS extension instance internally
767
+ // when calling subscription.register(), which can still lead to `wse.ws` undefined and
768
+ // addEventListener crash. We bypass it by using our singleton wsExt directly.
769
+ // We'll keep `subscription` object for event emitter convenience, but registration uses wsExt.
770
+
771
+
772
+ // Extra diagnostics: log versions so we can pin a working combo.
773
+ try {
774
+ // @ts-ignore - dynamic import of package.json for version logging
775
+ const sdkVer = (await import("@ringcentral/sdk/package.json", { with: { type: "json" } } as any))?.default?.version;
776
+ // @ts-ignore - dynamic import of package.json for version logging
777
+ const subsVer = (await import("@ringcentral/subscriptions/package.json", { with: { type: "json" } } as any))?.default?.version;
778
+ // @ts-ignore - dynamic import of package.json for version logging
779
+ const rcwsVer = (await import("@rc-ex/ws/package.json", { with: { type: "json" } } as any))?.default?.version;
780
+ logger.debug(`[${account.accountId}] rc sdk versions: @ringcentral/sdk=${sdkVer} @ringcentral/subscriptions=${subsVer} @rc-ex/ws=${rcwsVer}`);
781
+ } catch {
782
+ // ignore
783
+ }
640
784
 
641
785
  // Track current user ID to filter out self messages
642
786
  if (!ownerId) {
643
787
  try {
644
- const platform = sdk.platform();
788
+ const platform = mgr.sdk.platform();
645
789
  const response = await platform.get("/restapi/v1.0/account/~/extension/~");
646
790
  const userInfo = await response.json();
647
791
  ownerId = userInfo?.id?.toString();
648
- runtime.log?.(`[${account.accountId}] Authenticated as extension: ${ownerId}`);
792
+ logger.info(`[${account.accountId}] Authenticated as extension: ${ownerId}`);
649
793
  } catch (err) {
650
- runtime.error?.(`[${account.accountId}] Failed to get current user: ${String(err)}`);
794
+ logger.error(`[${account.accountId}] Failed to get current user: ${String(err)}`);
651
795
  }
652
796
  }
653
797
 
654
798
  // Handle notifications
655
799
  subscription.on(subscription.events.notification, (event: unknown) => {
656
- logVerbose(core, runtime, `WebSocket notification received: ${JSON.stringify(event).slice(0, 500)}`);
800
+ logger.debug(`WebSocket notification received: ${JSON.stringify(event).slice(0, 500)}`);
657
801
  const evt = event as RingCentralWebhookEvent;
658
802
  processWebSocketEvent({
659
803
  event: evt,
@@ -664,38 +808,79 @@ export async function startRingCentralMonitor(
664
808
  statusSink,
665
809
  ownerId,
666
810
  }).catch((err) => {
667
- runtime.error?.(`[${account.accountId}] WebSocket event processing failed: ${String(err)}`);
811
+ logger.error(`[${account.accountId}] WebSocket event processing failed: ${String(err)}`);
668
812
  });
669
813
  });
670
814
 
671
815
  // 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();
816
+ // Register subscription via singleton wsExt (NOT via @ringcentral/subscriptions.register()).
817
+ // This avoids newWsExtension() being created per call.
818
+ const eventFilters = [
819
+ "/restapi/v1.0/glip/posts",
820
+ "/restapi/v1.0/glip/groups",
821
+ ];
822
+ wsSubscription = await mgr.wsExt.subscribe(eventFilters, (event: unknown) => {
823
+ subscription.emit(subscription.events.notification, event);
824
+ });
678
825
 
679
- runtime.log?.(`[${account.accountId}] RingCentral WebSocket subscription established`);
826
+ logger.info(`[${account.accountId}] RingCentral WebSocket subscription established`);
680
827
  reconnectAttempts = 0; // Reset on success
681
828
 
682
829
  } catch (err) {
683
- runtime.error?.(`[${account.accountId}] Failed to create WebSocket subscription: ${String(err)}`);
684
- scheduleReconnect();
830
+ const e = err as any;
831
+ const errStr = String(err);
832
+ const msg = e?.stack ? String(e.stack) : errStr;
833
+
834
+ // Check for auth errors - don't retry on auth failures
835
+ const isAuthError = errStr.includes("401") || errStr.includes("Unauthorized") || errStr.includes("invalid_grant");
836
+ if (isAuthError) {
837
+ logger.error(`[${account.accountId}] Authentication failed. Please check your credentials.`);
838
+ return;
839
+ }
840
+
841
+ // If we hit auth rate limit for /oauth/wstoken, back off according to retry-after.
842
+ const retryAfterHeader =
843
+ typeof e?.response?.headers?.get === "function" ? e.response.headers.get("retry-after") :
844
+ typeof e?.response?.headers?.["retry-after"] === "string" ? e.response.headers["retry-after"] :
845
+ undefined;
846
+ const retryAfterMs =
847
+ typeof e?.retryAfter === "number" ? e.retryAfter :
848
+ (retryAfterHeader ? (parseInt(retryAfterHeader, 10) * 1000) : undefined);
849
+
850
+ const isRateLimited = e?.message === "Request rate exceeded" || e?.response?.status === 429 ||
851
+ errStr.includes("429") || errStr.includes("rate") || errStr.includes("Rate");
852
+
853
+ if (isRateLimited) {
854
+ const backoffMs = Number.isFinite(retryAfterMs) && retryAfterMs! > 0 ? retryAfterMs! : RATE_LIMIT_BACKOFF;
855
+ nextAllowedWsConnectAt = Date.now() + backoffMs;
856
+ logger.error(
857
+ `[${account.accountId}] WS connect failed due to rate limit (wstoken). ` +
858
+ `Backing off for ${Math.ceil(backoffMs / 1000)}s before retrying.`,
859
+ );
860
+ }
861
+
862
+ logger.error(
863
+ `[${account.accountId}] WS subscription failed. ` +
864
+ `Reason=${e?.name ?? 'Error'}: ${e?.message ?? errStr}\n` +
865
+ `Stack:\n${msg}`,
866
+ );
867
+ scheduleReconnect(isRateLimited);
685
868
  }
686
869
  };
687
870
 
688
871
  // Schedule reconnection with exponential backoff
689
- const scheduleReconnect = () => {
872
+ const scheduleReconnect = (isRateLimited = false) => {
690
873
  if (isShuttingDown || abortSignal.aborted) return;
691
874
  if (reconnectAttempts >= RECONNECT_MAX_ATTEMPTS) {
692
- runtime.error?.(`[${account.accountId}] Max reconnection attempts (${RECONNECT_MAX_ATTEMPTS}) reached. Giving up.`);
875
+ logger.error(`[${account.accountId}] Max reconnection attempts (${RECONNECT_MAX_ATTEMPTS}) reached. Giving up.`);
693
876
  return;
694
877
  }
695
878
 
696
- const delay = getReconnectDelay();
879
+ // Use longer delay if rate limited
880
+ const baseDelay = isRateLimited ? RATE_LIMIT_BACKOFF : getReconnectDelay();
881
+ const delay = Math.min(baseDelay, RECONNECT_MAX_DELAY);
697
882
  reconnectAttempts++;
698
- runtime.log?.(`[${account.accountId}] Scheduling reconnection attempt ${reconnectAttempts}/${RECONNECT_MAX_ATTEMPTS} in ${delay}ms...`);
883
+ logger.warn(`[${account.accountId}] Scheduling reconnection attempt ${reconnectAttempts}/${RECONNECT_MAX_ATTEMPTS} in ${delay}ms${isRateLimited ? " (rate limited)" : ""}...`);
699
884
 
700
885
  // Clean up existing WsSubscription
701
886
  if (wsSubscription) {
@@ -706,7 +891,7 @@ export async function startRingCentralMonitor(
706
891
  reconnectTimeout = setTimeout(() => {
707
892
  reconnectTimeout = null;
708
893
  createSubscription().catch((err) => {
709
- runtime.error?.(`[${account.accountId}] Reconnection failed: ${String(err)}`);
894
+ logger.error(`[${account.accountId}] Reconnection failed: ${String(err)}`);
710
895
  });
711
896
  }, delay);
712
897
  };
@@ -717,7 +902,7 @@ export async function startRingCentralMonitor(
717
902
  // Handle abort signal
718
903
  const cleanup = () => {
719
904
  isShuttingDown = true;
720
- runtime.log?.(`[${account.accountId}] Stopping RingCentral WebSocket subscription...`);
905
+ logger.info(`[${account.accountId}] Stopping RingCentral WebSocket subscription...`);
721
906
 
722
907
  if (reconnectTimeout) {
723
908
  clearTimeout(reconnectTimeout);
@@ -726,7 +911,7 @@ export async function startRingCentralMonitor(
726
911
 
727
912
  if (wsSubscription) {
728
913
  wsSubscription.revoke().catch((err) => {
729
- runtime.error?.(`[${account.accountId}] Failed to revoke subscription: ${String(err)}`);
914
+ logger.error(`[${account.accountId}] Failed to revoke subscription: ${String(err)}`);
730
915
  });
731
916
  wsSubscription = null;
732
917
  }
package/src/openclaw.d.ts CHANGED
@@ -1,9 +1,39 @@
1
1
  declare module "openclaw/plugin-sdk" {
2
2
  import { z } from "zod";
3
3
 
4
- // Types
4
+ // Agent Types
5
+ export type AgentConfig = {
6
+ id: string;
7
+ name?: string;
8
+ [key: string]: unknown;
9
+ };
10
+
11
+ // Base Types
5
12
  export type OpenClawConfig = {
6
- channels?: Record<string, unknown>;
13
+ channels?: {
14
+ defaults?: {
15
+ groupPolicy?: GroupPolicy;
16
+ [key: string]: unknown;
17
+ };
18
+ ringcentral?: {
19
+ enabled?: boolean;
20
+ accounts?: Record<string, unknown>;
21
+ [key: string]: unknown;
22
+ };
23
+ [key: string]: unknown;
24
+ };
25
+ agents?: {
26
+ list?: AgentConfig[];
27
+ [key: string]: unknown;
28
+ };
29
+ commands?: {
30
+ useAccessGroups?: boolean;
31
+ [key: string]: unknown;
32
+ };
33
+ session?: {
34
+ store?: string;
35
+ [key: string]: unknown;
36
+ };
7
37
  [key: string]: unknown;
8
38
  };
9
39
 
@@ -13,49 +43,351 @@ declare module "openclaw/plugin-sdk" {
13
43
  [key: string]: unknown;
14
44
  };
15
45
 
46
+ export type PluginLogger = {
47
+ debug: (message: string) => void;
48
+ info: (message: string) => void;
49
+ warn: (message: string) => void;
50
+ error: (message: string) => void;
51
+ };
52
+
53
+ // Channel Runtime Types
54
+ export type ChannelRuntime = {
55
+ text: {
56
+ chunkMarkdownText: (text: string, limit: number) => string[];
57
+ chunkMarkdownTextWithMode: (text: string, limit: number, mode: string) => string[];
58
+ resolveChunkMode: (config: OpenClawConfig, channel: string, accountId: string) => string;
59
+ hasControlCommand: (body: string, config: OpenClawConfig) => boolean;
60
+ };
61
+ media: {
62
+ fetchRemoteMedia: (url: string, opts: { maxBytes?: number }) => Promise<{
63
+ buffer: Buffer;
64
+ filename?: string;
65
+ contentType?: string;
66
+ }>;
67
+ saveTempMedia: (opts: { buffer: Buffer; contentType?: string; filename?: string }) => Promise<string>;
68
+ saveMediaBuffer: (buffer: Buffer, contentType: string | undefined, direction: string, maxBytes: number, filename?: string) => Promise<{ path: string; contentType?: string }>;
69
+ };
70
+ commands: {
71
+ shouldComputeCommandAuthorized: (body: string, config: OpenClawConfig) => boolean;
72
+ shouldHandleTextCommands: (opts: { cfg: OpenClawConfig; surface: string }) => boolean;
73
+ isControlCommandMessage: (body: string, config: OpenClawConfig) => boolean;
74
+ resolveCommandAuthorizedFromAuthorizers: (opts: {
75
+ useAccessGroups: boolean;
76
+ authorizers: Array<{ configured: boolean; allowed: boolean }>;
77
+ }) => boolean | undefined;
78
+ };
79
+ pairing: {
80
+ readAllowFromStore: (channel: string) => Promise<string[]>;
81
+ };
82
+ routing: {
83
+ resolveAgentRoute: (opts: {
84
+ cfg: OpenClawConfig;
85
+ channel: string;
86
+ accountId: string;
87
+ peer: { kind: string; id: string };
88
+ }) => { agentId: string; sessionKey: string; accountId: string };
89
+ };
90
+ session: {
91
+ resolveStorePath: (store: string | undefined, opts: { agentId: string }) => string;
92
+ readSessionUpdatedAt: (opts: { storePath: string; sessionKey: string }) => number | undefined;
93
+ recordSessionMetaFromInbound: (opts: { storePath: string; sessionKey: string; ctx: Record<string, unknown> }) => Promise<void>;
94
+ };
95
+ reply: {
96
+ resolveEnvelopeFormatOptions: (config: OpenClawConfig) => Record<string, unknown>;
97
+ formatAgentEnvelope: (opts: {
98
+ channel: string;
99
+ from: string;
100
+ timestamp?: number;
101
+ previousTimestamp?: number;
102
+ envelope: Record<string, unknown>;
103
+ body: string;
104
+ }) => string;
105
+ finalizeInboundContext: (payload: Record<string, unknown>) => Record<string, unknown>;
106
+ dispatchReplyWithBufferedBlockDispatcher: (opts: {
107
+ ctx: Record<string, unknown>;
108
+ cfg: OpenClawConfig;
109
+ dispatcherOptions: {
110
+ deliver: (payload: { text?: string; mediaUrl?: string; error?: Error }) => Promise<void>;
111
+ onError: (err: unknown, info: { kind: string }) => void;
112
+ };
113
+ }) => Promise<void>;
114
+ };
115
+ inbound: {
116
+ handleInbound: (opts: {
117
+ channel: string;
118
+ context: Record<string, unknown>;
119
+ replyFn: (response: { message?: string; error?: Error }, info: Record<string, unknown>) => Promise<void>;
120
+ agentRoute?: { agentId: string; sessionKey: string };
121
+ sessionPath?: string;
122
+ groupSystemPrompt?: string;
123
+ abortSignal?: AbortSignal;
124
+ }) => Promise<void>;
125
+ };
126
+ groups: {
127
+ resolveGroupConfig: (opts: {
128
+ channel: string;
129
+ groupId: string;
130
+ accountId: string;
131
+ cfg: OpenClawConfig;
132
+ }) => {
133
+ isAllowed: boolean;
134
+ allowlist: string[];
135
+ users: string[];
136
+ entry?: { requireMention?: boolean; systemPrompt?: string; [key: string]: unknown };
137
+ };
138
+ };
139
+ };
140
+
16
141
  export type PluginRuntime = {
17
- log: {
18
- info(msg: string, ...args: unknown[]): void;
19
- warn(msg: string, ...args: unknown[]): void;
20
- error(msg: string, ...args: unknown[]): void;
21
- debug(msg: string, ...args: unknown[]): void;
142
+ logging: {
143
+ shouldLogVerbose: () => boolean;
144
+ getChildLogger: (bindings: Record<string, unknown>, opts?: { level?: string }) => PluginLogger;
22
145
  };
146
+ channel: ChannelRuntime;
23
147
  [key: string]: unknown;
24
148
  };
25
149
 
26
- export type ChannelPlugin = {
27
- id: string;
150
+ // Policy Types
151
+ export type DmPolicy = "open" | "allowlist" | "pairing" | "disabled";
152
+ export type GroupPolicy = "open" | "allowlist" | "disabled";
153
+ export type MarkdownConfig = {
154
+ enabled?: boolean;
28
155
  [key: string]: unknown;
29
156
  };
30
157
 
158
+ // Channel Dock Type
31
159
  export type ChannelDock = {
32
160
  id: string;
33
- [key: string]: unknown;
161
+ capabilities: {
162
+ chatTypes: string[];
163
+ reactions: boolean;
164
+ media: boolean;
165
+ threads: boolean;
166
+ blockStreaming: boolean;
167
+ };
168
+ outbound?: {
169
+ textChunkLimit?: number;
170
+ };
171
+ config?: {
172
+ resolveAllowFrom?: (opts: { cfg: OpenClawConfig; accountId: string }) => string[];
173
+ formatAllowFrom?: (opts: { allowFrom: string[] }) => string[];
174
+ };
175
+ groups?: {
176
+ resolveRequireMention?: (opts: { cfg: OpenClawConfig; accountId: string }) => boolean;
177
+ };
178
+ threading?: {
179
+ resolveReplyToMode?: (opts: { cfg: OpenClawConfig }) => string;
180
+ buildToolContext?: (opts: { context: Record<string, unknown>; hasRepliedRef: { current: boolean } }) => Record<string, unknown>;
181
+ };
34
182
  };
35
183
 
36
- export type DmPolicy = "open" | "allowlist" | "disabled";
37
- export type GroupPolicy = "open" | "allowlist" | "disabled";
38
- export type MarkdownConfig = {
39
- enabled?: boolean;
40
- [key: string]: unknown;
184
+ // Channel Plugin Types
185
+ export type ChannelPluginMeta = {
186
+ id: string;
187
+ label: string;
188
+ selectionLabel?: string;
189
+ docsPath?: string;
190
+ docsLabel?: string;
191
+ blurb?: string;
192
+ order?: number;
193
+ };
194
+
195
+ export type ChannelPluginCapabilities = {
196
+ chatTypes: string[];
197
+ reactions: boolean;
198
+ threads: boolean;
199
+ media: boolean;
200
+ nativeCommands?: boolean;
201
+ blockStreaming?: boolean;
202
+ };
203
+
204
+ export type ChannelPluginPairing<TAccount> = {
205
+ idLabel: string;
206
+ normalizeAllowEntry: (entry: string) => string;
207
+ notifyApproval: (opts: { cfg: OpenClawConfig; id: string; account?: TAccount }) => Promise<void>;
208
+ };
209
+
210
+ export type ChannelPluginConfig<TAccount> = {
211
+ listAccountIds: (cfg: OpenClawConfig) => string[];
212
+ resolveAccount: (cfg: OpenClawConfig, accountId?: string) => TAccount;
213
+ defaultAccountId: (cfg: OpenClawConfig) => string;
214
+ setAccountEnabled: (opts: { cfg: OpenClawConfig; accountId: string; enabled: boolean }) => OpenClawConfig;
215
+ deleteAccount: (opts: { cfg: OpenClawConfig; accountId: string }) => OpenClawConfig;
216
+ isConfigured: (account: TAccount) => boolean;
217
+ describeAccount: (account: TAccount) => Record<string, unknown>;
218
+ resolveAllowFrom: (opts: { cfg: OpenClawConfig; accountId: string }) => string[];
219
+ formatAllowFrom: (opts: { allowFrom: string[] }) => string[];
220
+ };
221
+
222
+ export type ChannelPluginSecurity<TAccount> = {
223
+ resolveDmPolicy: (opts: { cfg: OpenClawConfig; accountId?: string; account: TAccount }) => {
224
+ policy: DmPolicy;
225
+ allowFrom: (string | number)[];
226
+ allowFromPath: string;
227
+ approveHint: string;
228
+ normalizeEntry: (raw: string) => string;
229
+ };
230
+ collectWarnings: (opts: { account: TAccount; cfg: OpenClawConfig }) => string[];
231
+ };
232
+
233
+ export type ChannelPluginGroups = {
234
+ resolveRequireMention: (opts: { cfg: OpenClawConfig; accountId: string }) => boolean;
235
+ };
236
+
237
+ export type ChannelPluginThreading = {
238
+ resolveReplyToMode: (opts: { cfg: OpenClawConfig }) => string;
239
+ };
240
+
241
+ export type ChannelPluginMessaging = {
242
+ normalizeTarget: (target: string) => string | null;
243
+ targetResolver: {
244
+ looksLikeId: (raw: string, normalized: string | null) => boolean;
245
+ hint: string;
246
+ };
247
+ };
248
+
249
+ export type DirectoryPeer = { kind: "user"; id: string };
250
+ export type DirectoryGroup = { kind: "group"; id: string };
251
+
252
+ export type ChannelPluginDirectory<TAccount> = {
253
+ self: (opts: { account: TAccount }) => Promise<{ id: string; name?: string } | null>;
254
+ listPeers: (opts: { cfg: OpenClawConfig; accountId: string; query?: string; limit?: number }) => Promise<DirectoryPeer[]>;
255
+ listGroups: (opts: { cfg: OpenClawConfig; accountId: string; query?: string; limit?: number }) => Promise<DirectoryGroup[]>;
256
+ };
257
+
258
+ export type ResolvedTarget = { input: string; resolved: boolean; id?: string; note?: string };
259
+
260
+ export type ChannelPluginResolver = {
261
+ resolveTargets: (opts: { inputs: string[]; kind: "user" | "group" }) => Promise<ResolvedTarget[]>;
262
+ };
263
+
264
+ export type ChannelPluginSetup = {
265
+ resolveAccountId: (opts: { accountId?: string }) => string;
266
+ applyAccountName: (opts: { cfg: OpenClawConfig; accountId: string; name?: string }) => OpenClawConfig;
267
+ validateInput: (opts: { accountId: string; input: Record<string, unknown> }) => string | null;
268
+ applyAccountConfig: (opts: { cfg: OpenClawConfig; accountId: string; input: Record<string, unknown> }) => OpenClawConfig;
269
+ };
270
+
271
+ export type OutboundResult = {
272
+ channel: string;
273
+ messageId: string;
274
+ chatId: string;
275
+ };
276
+
277
+ export type ChannelPluginOutbound<TAccount> = {
278
+ deliveryMode: "direct" | "queued";
279
+ chunker: (text: string, limit: number) => string[];
280
+ chunkerMode?: "markdown" | "plain";
281
+ textChunkLimit: number;
282
+ resolveTarget: (opts: { to?: string; allowFrom?: string[]; mode?: string }) => { ok: true; to: string } | { ok: false; error: Error };
283
+ sendText: (opts: { cfg: OpenClawConfig; to: string; text: string; accountId?: string }) => Promise<OutboundResult>;
284
+ sendMedia: (opts: { cfg: OpenClawConfig; to: string; text?: string; mediaUrl?: string; accountId?: string }) => Promise<OutboundResult>;
285
+ };
286
+
287
+ export type StatusIssue = {
288
+ channel: string;
289
+ accountId: string;
290
+ kind: string;
291
+ message: string;
292
+ fix?: string;
293
+ };
294
+
295
+ export type ChannelPluginStatus<TAccount> = {
296
+ defaultRuntime: Record<string, unknown>;
297
+ collectStatusIssues: (accounts: Array<Record<string, unknown>>) => StatusIssue[];
298
+ buildChannelSummary: (opts: { snapshot: Record<string, unknown> }) => Record<string, unknown>;
299
+ probeAccount: (opts: { account: TAccount }) => Promise<{ ok: boolean; error?: string; elapsedMs: number }>;
300
+ buildAccountSnapshot: (opts: { account: TAccount; runtime?: Record<string, unknown>; probe?: Record<string, unknown> }) => Record<string, unknown>;
301
+ };
302
+
303
+ export type GatewayContext<TAccount> = {
304
+ account: TAccount;
305
+ cfg: OpenClawConfig;
306
+ runtime: { log?: (msg: string) => void; error?: (msg: string) => void; info?: (msg: string) => void };
307
+ abortSignal: AbortSignal;
308
+ setStatus: (patch: Record<string, unknown>) => void;
309
+ log?: PluginLogger;
310
+ };
311
+
312
+ export type ChannelPluginGateway<TAccount> = {
313
+ startAccount: (ctx: GatewayContext<TAccount>) => Promise<() => void>;
314
+ };
315
+
316
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
317
+ export type ChannelPlugin<TAccount = any> = {
318
+ id: string;
319
+ meta: ChannelPluginMeta;
320
+ pairing?: ChannelPluginPairing<TAccount>;
321
+ capabilities: ChannelPluginCapabilities;
322
+ streaming?: {
323
+ blockStreamingCoalesceDefaults?: { minChars?: number; idleMs?: number };
324
+ };
325
+ reload?: {
326
+ configPrefixes?: string[];
327
+ };
328
+ configSchema: z.ZodType<unknown>;
329
+ config: ChannelPluginConfig<TAccount>;
330
+ security?: ChannelPluginSecurity<TAccount>;
331
+ groups?: ChannelPluginGroups;
332
+ threading?: ChannelPluginThreading;
333
+ messaging?: ChannelPluginMessaging;
334
+ directory?: ChannelPluginDirectory<TAccount>;
335
+ resolver?: ChannelPluginResolver;
336
+ setup?: ChannelPluginSetup;
337
+ outbound: ChannelPluginOutbound<TAccount>;
338
+ status?: ChannelPluginStatus<TAccount>;
339
+ gateway?: ChannelPluginGateway<TAccount>;
41
340
  };
42
341
 
43
342
  // Constants
44
343
  export const DEFAULT_ACCOUNT_ID: string;
45
344
 
46
345
  // Functions
47
- export function normalizeAccountId(accountId: string | undefined): string;
346
+ export function normalizeAccountId(accountId: string | null | undefined): string;
48
347
  export function emptyPluginConfigSchema(): z.ZodObject<Record<string, never>>;
49
- export function resolveMentionGatingWithBypass(opts: unknown): unknown;
348
+ export function resolveMentionGatingWithBypass(opts: {
349
+ isGroup: boolean;
350
+ requireMention: boolean;
351
+ canDetectMention: boolean;
352
+ wasMentioned: boolean;
353
+ implicitMention: boolean;
354
+ hasAnyMention: boolean;
355
+ allowTextCommands: boolean;
356
+ hasControlCommand: boolean;
357
+ commandAuthorized: boolean;
358
+ }): { shouldSkip: boolean; effectiveWasMentioned?: boolean };
50
359
  export function requireOpenAllowFrom(opts: unknown): void;
51
- export function applyAccountNameToChannelSection(opts: unknown): unknown;
52
- export function buildChannelConfigSchema(opts: unknown): unknown;
53
- export function deleteAccountFromConfigSection(opts: unknown): unknown;
54
- export function formatPairingApproveHint(opts: unknown): string;
55
- export function migrateBaseNameToDefaultAccount(opts: unknown): unknown;
56
- export function missingTargetError(opts: unknown): Error;
57
- export function setAccountEnabledInConfigSection(opts: unknown): unknown;
58
- export function resolveChannelMediaMaxBytes(opts: unknown): number;
360
+ export function applyAccountNameToChannelSection(opts: {
361
+ cfg: OpenClawConfig;
362
+ channelKey: string;
363
+ accountId: string;
364
+ name?: string;
365
+ }): OpenClawConfig;
366
+ export function buildChannelConfigSchema(schema: z.ZodType<unknown>): z.ZodType<unknown>;
367
+ export function deleteAccountFromConfigSection(opts: {
368
+ cfg: OpenClawConfig;
369
+ sectionKey: string;
370
+ accountId: string;
371
+ clearBaseFields?: string[];
372
+ }): OpenClawConfig;
373
+ export function formatPairingApproveHint(channel: string): string;
374
+ export function migrateBaseNameToDefaultAccount(opts: {
375
+ cfg: OpenClawConfig;
376
+ channelKey: string;
377
+ }): OpenClawConfig;
378
+ export function missingTargetError(channel: string, hint: string): Error;
379
+ export function setAccountEnabledInConfigSection(opts: {
380
+ cfg: OpenClawConfig;
381
+ sectionKey: string;
382
+ accountId: string;
383
+ enabled: boolean;
384
+ allowTopLevel?: boolean;
385
+ }): OpenClawConfig;
386
+ export function resolveChannelMediaMaxBytes(opts: {
387
+ cfg: OpenClawConfig;
388
+ resolveChannelLimitMb: (opts: { cfg: OpenClawConfig; accountId: string }) => number | undefined;
389
+ accountId?: string;
390
+ }): number | undefined;
59
391
 
60
392
  // Constants for messages
61
393
  export const PAIRING_APPROVED_MESSAGE: string;