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 +2 -1
- package/src/accounts.ts +1 -1
- package/src/api.ts +95 -1
- package/src/channel.ts +5 -4
- package/src/monitor.ts +244 -59
- package/src/openclaw.d.ts +357 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-ringcentral",
|
|
3
|
-
"version": "2026.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
|
-
...(
|
|
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 ||
|
|
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 =
|
|
35
|
-
const RECONNECT_MAX_DELAY =
|
|
36
|
-
const RECONNECT_MAX_ATTEMPTS =
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
320
|
+
logVerbose(core, `ignore message from non-owner: ${senderId} (selfOnly mode)`);
|
|
212
321
|
return;
|
|
213
322
|
}
|
|
214
323
|
}
|
|
215
324
|
|
|
216
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
452
|
+
logVerbose(core, `ignore DM (dmPolicy=disabled)`);
|
|
345
453
|
return;
|
|
346
454
|
}
|
|
347
455
|
if (dmPolicy === "allowlist" && !isSenderAllowed(senderId, effectiveAllowFrom)) {
|
|
348
|
-
logVerbose(core,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
//
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
792
|
+
logger.info(`[${account.accountId}] Authenticated as extension: ${ownerId}`);
|
|
649
793
|
} catch (err) {
|
|
650
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
826
|
+
logger.info(`[${account.accountId}] RingCentral WebSocket subscription established`);
|
|
680
827
|
reconnectAttempts = 0; // Reset on success
|
|
681
828
|
|
|
682
829
|
} catch (err) {
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
875
|
+
logger.error(`[${account.accountId}] Max reconnection attempts (${RECONNECT_MAX_ATTEMPTS}) reached. Giving up.`);
|
|
693
876
|
return;
|
|
694
877
|
}
|
|
695
878
|
|
|
696
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?:
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
export type
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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:
|
|
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:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
export function
|
|
58
|
-
export function
|
|
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;
|