openclaw-ringcentral 2026.1.30-beta.1 → 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.30-beta.1",
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 {
@@ -47,6 +52,88 @@ const RECONNECT_MAX_DELAY = 300000; // 5 minutes (increased for rate limiting)
47
52
  const RECONNECT_MAX_ATTEMPTS = 5; // Reduced attempts
48
53
  const RATE_LIMIT_BACKOFF = 60000; // 1 minute backoff on 429
49
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
+
136
+
50
137
  function trackSentMessageId(messageId: string): void {
51
138
  recentlySentMessageIds.add(messageId);
52
139
  setTimeout(() => recentlySentMessageIds.delete(messageId), MESSAGE_ID_TTL);
@@ -195,6 +282,7 @@ async function processMessageWithPipeline(params: {
195
282
  ownerId?: string;
196
283
  }): Promise<void> {
197
284
  const { eventBody, account, config, runtime, core, statusSink, ownerId } = params;
285
+ const logger = getLogger(core);
198
286
  const mediaMaxMb = account.config.mediaMaxMb ?? 20;
199
287
 
200
288
  const chatId = eventBody.groupId ?? "";
@@ -225,7 +313,7 @@ async function processMessageWithPipeline(params: {
225
313
  // This is because the bot uses the JWT user's identity, so we're essentially
226
314
  // having a conversation with ourselves (the AI assistant)
227
315
  const selfOnly = account.config.selfOnly !== false; // default true
228
- 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}`);
229
317
 
230
318
  if (selfOnly && ownerId) {
231
319
  if (senderId !== ownerId) {
@@ -234,7 +322,7 @@ async function processMessageWithPipeline(params: {
234
322
  }
235
323
  }
236
324
 
237
- runtime.log?.(`[${account.accountId}] Message passed selfOnly check`);
325
+ logger.debug(`[${account.accountId}] Message passed selfOnly check`);
238
326
 
239
327
  // Fetch chat info to determine type
240
328
  let chatType = "Group";
@@ -250,7 +338,7 @@ async function processMessageWithPipeline(params: {
250
338
  // Personal, PersonalChat, Direct are all DM types
251
339
  const isPersonalChat = chatType === "Personal" || chatType === "PersonalChat";
252
340
  const isGroup = chatType !== "Direct" && chatType !== "PersonalChat" && chatType !== "Personal";
253
- runtime.log?.(`[${account.accountId}] Chat type: ${chatType}, isGroup: ${isGroup}`);
341
+ logger.debug(`[${account.accountId}] Chat type: ${chatType}, isGroup: ${isGroup}`);
254
342
 
255
343
  // In selfOnly mode, only allow "Personal" chat (conversation with yourself)
256
344
  if (selfOnly && !isPersonalChat) {
@@ -281,7 +369,6 @@ async function processMessageWithPipeline(params: {
281
369
  if (!groupAllowlistConfigured) {
282
370
  logVerbose(
283
371
  core,
284
- runtime,
285
372
  `drop group message (groupPolicy=allowlist, no allowlist, chat=${chatId})`,
286
373
  );
287
374
  return;
@@ -452,11 +539,11 @@ async function processMessageWithPipeline(params: {
452
539
  void core.channel.session
453
540
  .recordSessionMetaFromInbound({
454
541
  storePath,
455
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
542
+ sessionKey: (ctxPayload.SessionKey as string | undefined) ?? route.sessionKey,
456
543
  ctx: ctxPayload,
457
544
  })
458
545
  .catch((err) => {
459
- runtime.error?.(`ringcentral: failed updating session meta: ${String(err)}`);
546
+ logger.error(`ringcentral: failed updating session meta: ${String(err)}`);
460
547
  });
461
548
 
462
549
  // Typing indicator disabled - respond directly without "thinking" message
@@ -470,7 +557,6 @@ async function processMessageWithPipeline(params: {
470
557
  payload,
471
558
  account,
472
559
  chatId,
473
- runtime,
474
560
  core,
475
561
  config,
476
562
  statusSink,
@@ -478,7 +564,7 @@ async function processMessageWithPipeline(params: {
478
564
  });
479
565
  },
480
566
  onError: (err, info) => {
481
- runtime.error?.(
567
+ logger.error(
482
568
  `[${account.accountId}] RingCentral ${info.kind} reply failed: ${String(err)}`,
483
569
  );
484
570
  },
@@ -510,13 +596,13 @@ async function deliverRingCentralReply(params: {
510
596
  payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
511
597
  account: ResolvedRingCentralAccount;
512
598
  chatId: string;
513
- runtime: RingCentralRuntimeEnv;
514
599
  core: RingCentralCoreRuntime;
515
600
  config: OpenClawConfig;
516
601
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
517
602
  typingPostId?: string;
518
603
  }): Promise<void> {
519
- 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);
520
606
  const mediaList = payload.mediaUrls?.length
521
607
  ? payload.mediaUrls
522
608
  : payload.mediaUrl
@@ -533,7 +619,8 @@ async function deliverRingCentralReply(params: {
533
619
  postId: typingPostId,
534
620
  });
535
621
  } catch (err) {
536
- runtime.error?.(`RingCentral typing cleanup failed: ${String(err)}`);
622
+ const errInfo = formatRcApiError(extractRcApiError(err, account.accountId));
623
+ logger.error(`RingCentral typing cleanup failed: ${errInfo}`);
537
624
  const fallbackText = payload.text?.trim()
538
625
  ? payload.text
539
626
  : mediaList.length > 1
@@ -548,7 +635,8 @@ async function deliverRingCentralReply(params: {
548
635
  });
549
636
  suppressCaption = Boolean(payload.text?.trim());
550
637
  } catch (updateErr) {
551
- runtime.error?.(`RingCentral typing update failed: ${String(updateErr)}`);
638
+ const updateErrInfo = formatRcApiError(extractRcApiError(updateErr, account.accountId));
639
+ logger.error(`RingCentral typing update failed: ${updateErrInfo}`);
552
640
  }
553
641
  }
554
642
  }
@@ -579,7 +667,8 @@ async function deliverRingCentralReply(params: {
579
667
  if (sendResult?.postId) trackSentMessageId(sendResult.postId);
580
668
  statusSink?.({ lastOutboundAt: Date.now() });
581
669
  } catch (err) {
582
- runtime.error?.(`RingCentral attachment send failed: ${String(err)}`);
670
+ const errInfo = formatRcApiError(extractRcApiError(err, account.accountId));
671
+ logger.error(`RingCentral attachment send failed: ${errInfo}`);
583
672
  }
584
673
  }
585
674
  return;
@@ -618,7 +707,8 @@ async function deliverRingCentralReply(params: {
618
707
  }
619
708
  statusSink?.({ lastOutboundAt: Date.now() });
620
709
  } catch (err) {
621
- runtime.error?.(`RingCentral message send failed: ${String(err)}`);
710
+ const errInfo = formatRcApiError(extractRcApiError(err, account.accountId));
711
+ logger.error(`RingCentral message send failed: ${errInfo}`);
622
712
  }
623
713
  }
624
714
  }
@@ -637,6 +727,9 @@ export async function startRingCentralMonitor(
637
727
  let isShuttingDown = false;
638
728
  let ownerId: string | undefined;
639
729
 
730
+ // Avoid hammering /oauth/wstoken (auth rate limit is very low, e.g. 5/min).
731
+ let nextAllowedWsConnectAt = 0;
732
+
640
733
  // Calculate delay with exponential backoff
641
734
  const getReconnectDelay = () => {
642
735
  const delay = Math.min(
@@ -652,18 +745,47 @@ export async function startRingCentralMonitor(
652
745
 
653
746
  logger.info(`[${account.accountId}] Starting RingCentral WebSocket subscription...`);
654
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
+ }
756
+
655
757
  try {
656
- // Get SDK instance
657
- const sdk = await getRingCentralSDK(account);
658
-
659
- // Create subscriptions manager
660
- const subscriptions = new Subscriptions({ sdk });
661
- 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
+ }
662
784
 
663
785
  // Track current user ID to filter out self messages
664
786
  if (!ownerId) {
665
787
  try {
666
- const platform = sdk.platform();
788
+ const platform = mgr.sdk.platform();
667
789
  const response = await platform.get("/restapi/v1.0/account/~/extension/~");
668
790
  const userInfo = await response.json();
669
791
  ownerId = userInfo?.id?.toString();
@@ -691,30 +813,57 @@ export async function startRingCentralMonitor(
691
813
  });
692
814
 
693
815
  // Subscribe to Team Messaging events and save WsSubscription for cleanup
694
- wsSubscription = await subscription
695
- .setEventFilters([
696
- "/restapi/v1.0/glip/posts",
697
- "/restapi/v1.0/glip/groups",
698
- ])
699
- .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
+ });
700
825
 
701
826
  logger.info(`[${account.accountId}] RingCentral WebSocket subscription established`);
702
827
  reconnectAttempts = 0; // Reset on success
703
828
 
704
829
  } catch (err) {
830
+ const e = err as any;
705
831
  const errStr = String(err);
706
- logger.error(`[${account.accountId}] Failed to create WebSocket subscription: ${errStr}`);
707
-
708
- // Check for rate limiting (429) or auth errors
709
- const isRateLimited = errStr.includes("429") || errStr.includes("rate") || errStr.includes("Rate");
832
+ const msg = e?.stack ? String(e.stack) : errStr;
833
+
834
+ // Check for auth errors - don't retry on auth failures
710
835
  const isAuthError = errStr.includes("401") || errStr.includes("Unauthorized") || errStr.includes("invalid_grant");
711
-
712
836
  if (isAuthError) {
713
837
  logger.error(`[${account.accountId}] Authentication failed. Please check your credentials.`);
714
- // Don't retry on auth errors - they won't self-resolve
715
838
  return;
716
839
  }
717
-
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
+ );
718
867
  scheduleReconnect(isRateLimited);
719
868
  }
720
869
  };
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
 
@@ -20,47 +50,344 @@ declare module "openclaw/plugin-sdk" {
20
50
  error: (message: string) => void;
21
51
  };
22
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
+
23
141
  export type PluginRuntime = {
24
142
  logging: {
25
143
  shouldLogVerbose: () => boolean;
26
144
  getChildLogger: (bindings: Record<string, unknown>, opts?: { level?: string }) => PluginLogger;
27
145
  };
146
+ channel: ChannelRuntime;
28
147
  [key: string]: unknown;
29
148
  };
30
149
 
31
- export type ChannelPlugin = {
32
- 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;
33
155
  [key: string]: unknown;
34
156
  };
35
157
 
158
+ // Channel Dock Type
36
159
  export type ChannelDock = {
37
160
  id: string;
38
- [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
+ };
39
182
  };
40
183
 
41
- export type DmPolicy = "open" | "allowlist" | "disabled";
42
- export type GroupPolicy = "open" | "allowlist" | "disabled";
43
- export type MarkdownConfig = {
44
- enabled?: boolean;
45
- [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>;
46
340
  };
47
341
 
48
342
  // Constants
49
343
  export const DEFAULT_ACCOUNT_ID: string;
50
344
 
51
345
  // Functions
52
- export function normalizeAccountId(accountId: string | undefined): string;
346
+ export function normalizeAccountId(accountId: string | null | undefined): string;
53
347
  export function emptyPluginConfigSchema(): z.ZodObject<Record<string, never>>;
54
- 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 };
55
359
  export function requireOpenAllowFrom(opts: unknown): void;
56
- export function applyAccountNameToChannelSection(opts: unknown): unknown;
57
- export function buildChannelConfigSchema(opts: unknown): unknown;
58
- export function deleteAccountFromConfigSection(opts: unknown): unknown;
59
- export function formatPairingApproveHint(opts: unknown): string;
60
- export function migrateBaseNameToDefaultAccount(opts: unknown): unknown;
61
- export function missingTargetError(opts: unknown): Error;
62
- export function setAccountEnabledInConfigSection(opts: unknown): unknown;
63
- 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;
64
391
 
65
392
  // Constants for messages
66
393
  export const PAIRING_APPROVED_MESSAGE: string;