@clawling/clawchat-plugin-openclaw 2026.5.12-28
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/INSTALL.md +64 -0
- package/README.md +227 -0
- package/dist/index.js +20 -0
- package/dist/setup-entry.js +3 -0
- package/dist/src/api-client.js +263 -0
- package/dist/src/api-types.js +17 -0
- package/dist/src/api-types.test-d.js +10 -0
- package/dist/src/buffered-stream.js +177 -0
- package/dist/src/channel.js +66 -0
- package/dist/src/channel.setup.js +119 -0
- package/dist/src/clawchat-memory.js +403 -0
- package/dist/src/clawchat-metadata.js +310 -0
- package/dist/src/client.js +35 -0
- package/dist/src/commands.js +35 -0
- package/dist/src/config.js +274 -0
- package/dist/src/group-message-coalescer.js +119 -0
- package/dist/src/inbound.js +170 -0
- package/dist/src/llm-context-debug.js +86 -0
- package/dist/src/login.runtime.js +204 -0
- package/dist/src/media-runtime.js +85 -0
- package/dist/src/message-mapper.js +146 -0
- package/dist/src/mock-transport.js +31 -0
- package/dist/src/outbound.js +628 -0
- package/dist/src/plugin-prompts.js +89 -0
- package/dist/src/profile-prompt.js +269 -0
- package/dist/src/profile-sync.js +110 -0
- package/dist/src/prompt-injection.js +25 -0
- package/dist/src/protocol-types.js +63 -0
- package/dist/src/protocol-types.typecheck.js +1 -0
- package/dist/src/protocol.js +33 -0
- package/dist/src/reply-dispatcher.js +422 -0
- package/dist/src/runtime.js +1254 -0
- package/dist/src/storage.js +525 -0
- package/dist/src/streaming.js +65 -0
- package/dist/src/terminal-send.js +36 -0
- package/dist/src/tools-schema.js +208 -0
- package/dist/src/tools.js +920 -0
- package/dist/src/ws-alignment.js +178 -0
- package/dist/src/ws-client.js +588 -0
- package/dist/src/ws-log.js +19 -0
- package/index.ts +24 -0
- package/openclaw.plugin.json +169 -0
- package/package.json +80 -0
- package/prompts/default-group-bio.md +19 -0
- package/prompts/default-owner-behavior.md +27 -0
- package/prompts/platform.md +13 -0
- package/setup-entry.ts +4 -0
- package/skills/clawchat/SKILL.md +91 -0
- package/src/api-client.test.ts +827 -0
- package/src/api-client.ts +414 -0
- package/src/api-types.ts +146 -0
- package/src/channel.outbound.test.ts +433 -0
- package/src/channel.setup.ts +145 -0
- package/src/channel.test.ts +262 -0
- package/src/channel.ts +81 -0
- package/src/clawchat-memory.test.ts +480 -0
- package/src/clawchat-memory.ts +533 -0
- package/src/clawchat-metadata.test.ts +477 -0
- package/src/clawchat-metadata.ts +429 -0
- package/src/client.test.ts +169 -0
- package/src/client.ts +56 -0
- package/src/commands.test.ts +39 -0
- package/src/commands.ts +41 -0
- package/src/config.test.ts +344 -0
- package/src/config.ts +404 -0
- package/src/group-message-coalescer.test.ts +237 -0
- package/src/group-message-coalescer.ts +171 -0
- package/src/inbound.test.ts +508 -0
- package/src/inbound.ts +278 -0
- package/src/llm-context-debug.test.ts +55 -0
- package/src/llm-context-debug.ts +139 -0
- package/src/login.runtime.test.ts +737 -0
- package/src/login.runtime.ts +277 -0
- package/src/manifest.test.ts +352 -0
- package/src/media-runtime.test.ts +207 -0
- package/src/media-runtime.ts +152 -0
- package/src/message-mapper.test.ts +201 -0
- package/src/message-mapper.ts +174 -0
- package/src/mock-transport.test.ts +35 -0
- package/src/mock-transport.ts +38 -0
- package/src/outbound.test.ts +1269 -0
- package/src/outbound.ts +803 -0
- package/src/plugin-entry.test.ts +38 -0
- package/src/plugin-prompts.test.ts +94 -0
- package/src/plugin-prompts.ts +107 -0
- package/src/profile-prompt.test.ts +274 -0
- package/src/profile-prompt.ts +351 -0
- package/src/profile-sync.test.ts +539 -0
- package/src/profile-sync.ts +191 -0
- package/src/prompt-injection.test.ts +39 -0
- package/src/prompt-injection.ts +45 -0
- package/src/protocol-types.test.ts +69 -0
- package/src/protocol-types.ts +296 -0
- package/src/protocol-types.typecheck.ts +89 -0
- package/src/protocol.test.ts +39 -0
- package/src/protocol.ts +42 -0
- package/src/reply-dispatcher.test.ts +1324 -0
- package/src/reply-dispatcher.ts +555 -0
- package/src/runtime.test.ts +4719 -0
- package/src/runtime.ts +1493 -0
- package/src/scripts.test.ts +85 -0
- package/src/storage.test.ts +560 -0
- package/src/storage.ts +807 -0
- package/src/terminal-send.test.ts +81 -0
- package/src/terminal-send.ts +56 -0
- package/src/tools-schema.ts +337 -0
- package/src/tools.test.ts +933 -0
- package/src/tools.ts +1185 -0
- package/src/ws-alignment.test.ts +103 -0
- package/src/ws-alignment.ts +275 -0
- package/src/ws-client.test.ts +1217 -0
- package/src/ws-client.ts +662 -0
- package/src/ws-log.test.ts +32 -0
- package/src/ws-log.ts +31 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
|
3
|
+
|
|
4
|
+
export const CHANNEL_ID = "clawchat-plugin-openclaw" as const;
|
|
5
|
+
export const CLAWCHAT_TOKEN_ENV = "CLAWCHAT_TOKEN" as const;
|
|
6
|
+
export const CLAWCHAT_AGENT_ID_ENV = "CLAWCHAT_AGENT_ID" as const;
|
|
7
|
+
export const CLAWCHAT_USER_ID_ENV = "CLAWCHAT_USER_ID" as const;
|
|
8
|
+
export const CLAWCHAT_OWNER_USER_ID_ENV = "CLAWCHAT_OWNER_USER_ID" as const;
|
|
9
|
+
export const CLAWCHAT_REFRESH_TOKEN_ENV = "CLAWCHAT_REFRESH_TOKEN" as const;
|
|
10
|
+
export const CLAWCHAT_BASE_URL_ENV = "CLAWCHAT_BASE_URL" as const;
|
|
11
|
+
export const CLAWCHAT_WEBSOCKET_URL_ENV = "CLAWCHAT_WEBSOCKET_URL" as const;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Built-in defaults for the Clawling Chat endpoints so `openclaw channel
|
|
15
|
+
* login` works out of the box without requiring a prior `openclaw channel
|
|
16
|
+
* setup` call. Operators can still override either one via config.
|
|
17
|
+
*
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_BASE_URL = "https://app.clawling.com" as const;
|
|
20
|
+
export const DEFAULT_WEBSOCKET_URL = "wss://app.clawling.com/ws" as const;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Group-chat trigger policy.
|
|
24
|
+
* - "all" (default): trigger on every group message regardless of mentions (open listen).
|
|
25
|
+
* - "mention": only trigger a reply when the inbound `context.mentions`
|
|
26
|
+
* list includes our `userId` (i.e. the sender @-mentioned us).
|
|
27
|
+
*/
|
|
28
|
+
export type GroupMode = "mention" | "all";
|
|
29
|
+
export type GroupCommandMode = "owner" | "all" | "off";
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_RECONNECT = {
|
|
32
|
+
// Snappier first retry on transient drops (vs. 1_000).
|
|
33
|
+
initialDelay: 500,
|
|
34
|
+
// Cap exponential backoff at 15s — a background gateway reconnecting
|
|
35
|
+
// every 30s feels unresponsive; 15s is the common IM chat bar.
|
|
36
|
+
maxDelay: 15_000,
|
|
37
|
+
// Standard jitter ratio to avoid thundering herd on server restart.
|
|
38
|
+
jitterRatio: 0.3,
|
|
39
|
+
// Never give up — the gateway is a long-lived background process.
|
|
40
|
+
maxRetries: Number.POSITIVE_INFINITY,
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_HEARTBEAT = {
|
|
44
|
+
// 20s keeps NAT/firewall state warm without wasting bandwidth.
|
|
45
|
+
interval: 20_000,
|
|
46
|
+
// Pong must arrive within 10s or we tear down and reconnect.
|
|
47
|
+
timeout: 10_000,
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
export const DEFAULT_ACK = {
|
|
51
|
+
// 15s tolerates a slow server + one retry without false timeouts.
|
|
52
|
+
timeout: 15_000,
|
|
53
|
+
// Keep false: auto-resend on timeout risks duplicate messages; the
|
|
54
|
+
// reconnect path re-queues via `queueWhileReconnecting` instead.
|
|
55
|
+
autoResendOnTimeout: false,
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
export type OpenclawClawlingGroupConfig = {
|
|
59
|
+
groupMode: GroupMode;
|
|
60
|
+
groupCommandMode: GroupCommandMode;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type OpenclawClawlingReconnectConfig = {
|
|
64
|
+
initialDelay?: number;
|
|
65
|
+
maxDelay?: number;
|
|
66
|
+
jitterRatio?: number;
|
|
67
|
+
/** Max reconnect attempts. Omit/Infinity = never give up. */
|
|
68
|
+
maxRetries?: number;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type OpenclawClawlingHeartbeatConfig = {
|
|
72
|
+
interval?: number;
|
|
73
|
+
timeout?: number;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type OpenclawClawlingAckConfig = {
|
|
77
|
+
timeout?: number;
|
|
78
|
+
autoResendOnTimeout?: boolean;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type OpenclawClawlingConfig = {
|
|
82
|
+
enabled?: boolean;
|
|
83
|
+
websocketUrl?: string;
|
|
84
|
+
baseUrl?: string;
|
|
85
|
+
token?: string;
|
|
86
|
+
/** Refresh token persisted by ClawChat activation/login (paired with `token`). */
|
|
87
|
+
refreshToken?: string;
|
|
88
|
+
/** Server agent id used with `/v1/agents/{id}`. */
|
|
89
|
+
agentId?: string;
|
|
90
|
+
userId?: string;
|
|
91
|
+
ownerUserId?: string;
|
|
92
|
+
groupMode?: GroupMode;
|
|
93
|
+
groupCommandMode?: GroupCommandMode;
|
|
94
|
+
groups?: Record<string, Partial<OpenclawClawlingGroupConfig>>;
|
|
95
|
+
forwardThinking?: boolean;
|
|
96
|
+
forwardToolCalls?: boolean;
|
|
97
|
+
/** Emit approval/action rich fragments instead of plain fallback text. */
|
|
98
|
+
richInteractions?: boolean;
|
|
99
|
+
reconnect?: OpenclawClawlingReconnectConfig;
|
|
100
|
+
heartbeat?: OpenclawClawlingHeartbeatConfig;
|
|
101
|
+
ack?: OpenclawClawlingAckConfig;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const openclawClawlingConfigSchema = {
|
|
105
|
+
type: "object",
|
|
106
|
+
additionalProperties: false,
|
|
107
|
+
properties: {
|
|
108
|
+
enabled: { type: "boolean" },
|
|
109
|
+
websocketUrl: { type: "string" },
|
|
110
|
+
baseUrl: { type: "string" },
|
|
111
|
+
token: { type: "string" },
|
|
112
|
+
refreshToken: { type: "string" },
|
|
113
|
+
agentId: { type: "string" },
|
|
114
|
+
userId: { type: "string" },
|
|
115
|
+
ownerUserId: { type: "string" },
|
|
116
|
+
groupMode: { type: "string", enum: ["mention", "all"] },
|
|
117
|
+
groupCommandMode: { type: "string", enum: ["owner", "all", "off"] },
|
|
118
|
+
groups: {
|
|
119
|
+
type: "object",
|
|
120
|
+
additionalProperties: {
|
|
121
|
+
type: "object",
|
|
122
|
+
additionalProperties: false,
|
|
123
|
+
properties: {
|
|
124
|
+
groupMode: { type: "string", enum: ["mention", "all"] },
|
|
125
|
+
groupCommandMode: { type: "string", enum: ["owner", "all", "off"] },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
forwardThinking: { type: "boolean" },
|
|
130
|
+
forwardToolCalls: { type: "boolean" },
|
|
131
|
+
richInteractions: { type: "boolean" },
|
|
132
|
+
reconnect: {
|
|
133
|
+
type: "object",
|
|
134
|
+
additionalProperties: false,
|
|
135
|
+
properties: {
|
|
136
|
+
initialDelay: { type: "integer", minimum: 100 },
|
|
137
|
+
maxDelay: { type: "integer", minimum: 100 },
|
|
138
|
+
jitterRatio: { type: "number", minimum: 0 },
|
|
139
|
+
maxRetries: { type: "integer", minimum: 0 },
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
heartbeat: {
|
|
143
|
+
type: "object",
|
|
144
|
+
additionalProperties: false,
|
|
145
|
+
properties: {
|
|
146
|
+
interval: { type: "integer", minimum: 1000 },
|
|
147
|
+
timeout: { type: "integer", minimum: 1000 },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
ack: {
|
|
151
|
+
type: "object",
|
|
152
|
+
additionalProperties: false,
|
|
153
|
+
properties: {
|
|
154
|
+
timeout: { type: "integer", minimum: 100 },
|
|
155
|
+
autoResendOnTimeout: { type: "boolean" },
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
} as const;
|
|
160
|
+
|
|
161
|
+
function isOpenclawClawchatToolAllowEntry(entry: unknown): boolean {
|
|
162
|
+
return entry === CHANNEL_ID || entry === "group:plugins";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function mergeToolPolicyEntryAlsoAllow(
|
|
166
|
+
cfg: OpenClawConfig,
|
|
167
|
+
entry: string,
|
|
168
|
+
isAlreadyCovered: (value: unknown) => boolean,
|
|
169
|
+
): OpenClawConfig {
|
|
170
|
+
const currentTools = ((cfg as { tools?: Record<string, unknown> }).tools ?? {}) as Record<
|
|
171
|
+
string,
|
|
172
|
+
unknown
|
|
173
|
+
>;
|
|
174
|
+
const currentAlsoAllow = Array.isArray(currentTools.alsoAllow)
|
|
175
|
+
? currentTools.alsoAllow.slice()
|
|
176
|
+
: [];
|
|
177
|
+
const currentAllow = Array.isArray(currentTools.allow) ? currentTools.allow.slice() : [];
|
|
178
|
+
const alreadyCovered = [...currentAllow, ...currentAlsoAllow].some(isAlreadyCovered);
|
|
179
|
+
if (alreadyCovered) {
|
|
180
|
+
return {
|
|
181
|
+
...cfg,
|
|
182
|
+
tools: {
|
|
183
|
+
...currentTools,
|
|
184
|
+
...(Array.isArray(currentTools.allow) ? { allow: currentAllow } : {}),
|
|
185
|
+
...(Array.isArray(currentTools.alsoAllow) ? { alsoAllow: currentAlsoAllow } : {}),
|
|
186
|
+
},
|
|
187
|
+
} as OpenClawConfig;
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
...cfg,
|
|
191
|
+
tools: {
|
|
192
|
+
...currentTools,
|
|
193
|
+
alsoAllow: [...currentAlsoAllow, entry],
|
|
194
|
+
},
|
|
195
|
+
} as OpenClawConfig;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function mergeOpenclawClawchatToolAllow(cfg: OpenClawConfig): OpenClawConfig {
|
|
199
|
+
return mergeToolPolicyEntryAlsoAllow(cfg, CHANNEL_ID, isOpenclawClawchatToolAllowEntry);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function readRecord(value: unknown): Record<string, unknown> {
|
|
203
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
204
|
+
? (value as Record<string, unknown>)
|
|
205
|
+
: {};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function mergeOpenclawClawchatRuntimePluginActivation(
|
|
209
|
+
cfg: OpenClawConfig,
|
|
210
|
+
): OpenClawConfig {
|
|
211
|
+
const currentPlugins = readRecord((cfg as { plugins?: unknown }).plugins);
|
|
212
|
+
const currentEntries = readRecord(currentPlugins.entries);
|
|
213
|
+
const currentEntry = readRecord(currentEntries[CHANNEL_ID]);
|
|
214
|
+
const currentAllow = Array.isArray(currentPlugins.allow) ? currentPlugins.allow.slice() : [];
|
|
215
|
+
const nextPlugins: Record<string, unknown> = {
|
|
216
|
+
...currentPlugins,
|
|
217
|
+
entries: {
|
|
218
|
+
...currentEntries,
|
|
219
|
+
[CHANNEL_ID]: {
|
|
220
|
+
...currentEntry,
|
|
221
|
+
enabled: true,
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
if (!currentAllow.includes(CHANNEL_ID)) {
|
|
226
|
+
nextPlugins.allow = [...currentAllow, CHANNEL_ID];
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
...cfg,
|
|
230
|
+
plugins: nextPlugins,
|
|
231
|
+
} as OpenClawConfig;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export type ResolvedOpenclawClawlingAccount = {
|
|
235
|
+
accountId: string;
|
|
236
|
+
name: string;
|
|
237
|
+
enabled: boolean;
|
|
238
|
+
configured: boolean;
|
|
239
|
+
websocketUrl: string;
|
|
240
|
+
baseUrl: string;
|
|
241
|
+
token: string;
|
|
242
|
+
agentId: string;
|
|
243
|
+
userId: string;
|
|
244
|
+
ownerUserId: string;
|
|
245
|
+
groupMode: GroupMode;
|
|
246
|
+
groupCommandMode: GroupCommandMode;
|
|
247
|
+
groups: Record<string, OpenclawClawlingGroupConfig>;
|
|
248
|
+
forwardThinking: boolean;
|
|
249
|
+
forwardToolCalls: boolean;
|
|
250
|
+
richInteractions: boolean;
|
|
251
|
+
allowFrom: string[];
|
|
252
|
+
reconnect: Required<OpenclawClawlingReconnectConfig>;
|
|
253
|
+
heartbeat: Required<OpenclawClawlingHeartbeatConfig>;
|
|
254
|
+
ack: Required<OpenclawClawlingAckConfig>;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
function readChannelSection(cfg: OpenClawConfig): Record<string, unknown> {
|
|
258
|
+
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
|
259
|
+
const channel = channels[CHANNEL_ID];
|
|
260
|
+
return channel && typeof channel === "object" ? (channel as Record<string, unknown>) : {};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function readOptionalString(value: unknown): string {
|
|
264
|
+
return typeof value === "string" ? value.trim() : "";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function readEnvString(env: Record<string, string | undefined>, key: string): string {
|
|
268
|
+
return readOptionalString(env[key]);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function readGroupMode(value: unknown): GroupMode {
|
|
272
|
+
return value === "mention" ? "mention" : "all";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function readGroupCommandMode(value: unknown): GroupCommandMode {
|
|
276
|
+
return value === "all" || value === "off" ? value : "owner";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function readGroups(value: unknown): Record<string, OpenclawClawlingGroupConfig> {
|
|
280
|
+
const rawGroups = value && typeof value === "object" && !Array.isArray(value)
|
|
281
|
+
? (value as Record<string, unknown>)
|
|
282
|
+
: {};
|
|
283
|
+
const groups: Record<string, OpenclawClawlingGroupConfig> = {};
|
|
284
|
+
for (const [chatId, rawGroup] of Object.entries(rawGroups)) {
|
|
285
|
+
if (!chatId) continue;
|
|
286
|
+
const group = rawGroup && typeof rawGroup === "object" && !Array.isArray(rawGroup)
|
|
287
|
+
? (rawGroup as Record<string, unknown>)
|
|
288
|
+
: {};
|
|
289
|
+
groups[chatId] = {
|
|
290
|
+
groupMode: readGroupMode(group.groupMode),
|
|
291
|
+
groupCommandMode: readGroupCommandMode(group.groupCommandMode),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return groups;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function effectiveGroupMode(
|
|
298
|
+
account: Pick<ResolvedOpenclawClawlingAccount, "groupMode" | "groups">,
|
|
299
|
+
chatId: string,
|
|
300
|
+
): GroupMode {
|
|
301
|
+
return account.groups[chatId]?.groupMode
|
|
302
|
+
?? account.groups["*"]?.groupMode
|
|
303
|
+
?? account.groupMode;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function effectiveGroupCommandMode(
|
|
307
|
+
account: Pick<ResolvedOpenclawClawlingAccount, "groupCommandMode" | "groups">,
|
|
308
|
+
chatId: string,
|
|
309
|
+
): GroupCommandMode {
|
|
310
|
+
return account.groups[chatId]?.groupCommandMode
|
|
311
|
+
?? account.groups["*"]?.groupCommandMode
|
|
312
|
+
?? account.groupCommandMode;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function readReconnect(raw: unknown): Required<OpenclawClawlingReconnectConfig> {
|
|
316
|
+
const s = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
|
317
|
+
return {
|
|
318
|
+
initialDelay:
|
|
319
|
+
typeof s.initialDelay === "number" ? s.initialDelay : DEFAULT_RECONNECT.initialDelay,
|
|
320
|
+
maxDelay: typeof s.maxDelay === "number" ? s.maxDelay : DEFAULT_RECONNECT.maxDelay,
|
|
321
|
+
jitterRatio: typeof s.jitterRatio === "number" ? s.jitterRatio : DEFAULT_RECONNECT.jitterRatio,
|
|
322
|
+
maxRetries:
|
|
323
|
+
typeof s.maxRetries === "number" && Number.isFinite(s.maxRetries)
|
|
324
|
+
? s.maxRetries
|
|
325
|
+
: DEFAULT_RECONNECT.maxRetries,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function readHeartbeat(raw: unknown): Required<OpenclawClawlingHeartbeatConfig> {
|
|
330
|
+
const s = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
|
331
|
+
return {
|
|
332
|
+
interval: typeof s.interval === "number" ? s.interval : DEFAULT_HEARTBEAT.interval,
|
|
333
|
+
timeout: typeof s.timeout === "number" ? s.timeout : DEFAULT_HEARTBEAT.timeout,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function readAck(raw: unknown): Required<OpenclawClawlingAckConfig> {
|
|
338
|
+
const s = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
|
339
|
+
return {
|
|
340
|
+
timeout: typeof s.timeout === "number" ? s.timeout : DEFAULT_ACK.timeout,
|
|
341
|
+
autoResendOnTimeout:
|
|
342
|
+
typeof s.autoResendOnTimeout === "boolean"
|
|
343
|
+
? s.autoResendOnTimeout
|
|
344
|
+
: DEFAULT_ACK.autoResendOnTimeout,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function resolveOpenclawClawlingAccount(
|
|
349
|
+
cfg: OpenClawConfig,
|
|
350
|
+
env: Record<string, string | undefined> = process.env,
|
|
351
|
+
): ResolvedOpenclawClawlingAccount {
|
|
352
|
+
const channel = readChannelSection(cfg);
|
|
353
|
+
// Apply built-in defaults so login/gateway work without prior setup.
|
|
354
|
+
const websocketUrl =
|
|
355
|
+
readOptionalString(channel.websocketUrl) ||
|
|
356
|
+
readEnvString(env, CLAWCHAT_WEBSOCKET_URL_ENV) ||
|
|
357
|
+
DEFAULT_WEBSOCKET_URL;
|
|
358
|
+
const baseUrl =
|
|
359
|
+
readOptionalString(channel.baseUrl) ||
|
|
360
|
+
readEnvString(env, CLAWCHAT_BASE_URL_ENV) ||
|
|
361
|
+
DEFAULT_BASE_URL;
|
|
362
|
+
const token = readOptionalString(channel.token) || readEnvString(env, CLAWCHAT_TOKEN_ENV);
|
|
363
|
+
const agentId = readOptionalString(channel.agentId) || readEnvString(env, CLAWCHAT_AGENT_ID_ENV);
|
|
364
|
+
const userId = readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV);
|
|
365
|
+
const ownerUserId =
|
|
366
|
+
readOptionalString(channel.ownerUserId) || readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV);
|
|
367
|
+
const enabled = typeof channel.enabled === "boolean" ? channel.enabled : true;
|
|
368
|
+
const groupMode = readGroupMode(channel.groupMode);
|
|
369
|
+
const groupCommandMode = readGroupCommandMode(channel.groupCommandMode);
|
|
370
|
+
const groups = readGroups(channel.groups);
|
|
371
|
+
const forwardThinking =
|
|
372
|
+
typeof channel.forwardThinking === "boolean" ? channel.forwardThinking : true;
|
|
373
|
+
const forwardToolCalls =
|
|
374
|
+
typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
|
|
375
|
+
const richInteractions =
|
|
376
|
+
typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
380
|
+
name: CHANNEL_ID,
|
|
381
|
+
enabled,
|
|
382
|
+
configured: Boolean(websocketUrl && token && userId && ownerUserId),
|
|
383
|
+
websocketUrl,
|
|
384
|
+
baseUrl,
|
|
385
|
+
token,
|
|
386
|
+
agentId,
|
|
387
|
+
userId,
|
|
388
|
+
ownerUserId,
|
|
389
|
+
groupMode,
|
|
390
|
+
groupCommandMode,
|
|
391
|
+
groups,
|
|
392
|
+
forwardThinking,
|
|
393
|
+
forwardToolCalls,
|
|
394
|
+
richInteractions,
|
|
395
|
+
allowFrom: [],
|
|
396
|
+
reconnect: readReconnect(channel.reconnect),
|
|
397
|
+
heartbeat: readHeartbeat(channel.heartbeat),
|
|
398
|
+
ack: readAck(channel.ack),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function listOpenclawClawlingAccountIds(): string[] {
|
|
403
|
+
return [DEFAULT_ACCOUNT_ID];
|
|
404
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createGroupMessageCoalescer,
|
|
4
|
+
formatCoalescedGroupBody,
|
|
5
|
+
type CoalescableGroupTurn,
|
|
6
|
+
} from "./group-message-coalescer.ts";
|
|
7
|
+
|
|
8
|
+
function turn(overrides: Partial<CoalescableGroupTurn> = {}): CoalescableGroupTurn {
|
|
9
|
+
return {
|
|
10
|
+
peer: { kind: "group", id: "room-1" },
|
|
11
|
+
senderId: "user-a",
|
|
12
|
+
senderNickName: "Alice",
|
|
13
|
+
rawBody: "hello",
|
|
14
|
+
messageId: "msg-1",
|
|
15
|
+
traceId: "trace-1",
|
|
16
|
+
timestamp: 1000,
|
|
17
|
+
wasMentioned: false,
|
|
18
|
+
mentionedUserIds: [],
|
|
19
|
+
mediaItems: [],
|
|
20
|
+
envelope: { event: "message.send", trace_id: "trace-1", emitted_at: 1000 },
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeEach(() => vi.useFakeTimers());
|
|
26
|
+
afterEach(() => vi.useRealTimers());
|
|
27
|
+
|
|
28
|
+
describe("group message coalescer", () => {
|
|
29
|
+
it("flushes after ten seconds of group inactivity", async () => {
|
|
30
|
+
const dispatched: CoalescableGroupTurn[] = [];
|
|
31
|
+
const coalescer = createGroupMessageCoalescer({
|
|
32
|
+
idleMs: 10000,
|
|
33
|
+
maxWaitMs: 30000,
|
|
34
|
+
dispatch: async (merged) => {
|
|
35
|
+
dispatched.push(merged);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
coalescer.enqueue(turn({ messageId: "msg-1", rawBody: "one" }));
|
|
40
|
+
await vi.advanceTimersByTimeAsync(9999);
|
|
41
|
+
|
|
42
|
+
coalescer.enqueue(
|
|
43
|
+
turn({
|
|
44
|
+
messageId: "msg-2",
|
|
45
|
+
senderId: "user-b",
|
|
46
|
+
senderNickName: "Bob",
|
|
47
|
+
rawBody: "two",
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
await vi.advanceTimersByTimeAsync(9999);
|
|
52
|
+
expect(dispatched).toEqual([]);
|
|
53
|
+
|
|
54
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
55
|
+
expect(dispatched).toHaveLength(1);
|
|
56
|
+
expect(dispatched[0]?.messageId).toBe("msg-2");
|
|
57
|
+
expect(dispatched[0]?.rawBody).toContain("ClawChat group batch (2 messages, 10s idle, 30s max)");
|
|
58
|
+
expect(dispatched[0]?.rawBody).toContain("[message]\nsender_id: user-a\nsender_name: Alice\nsender_profile_type: user\nsender_is_agent_owner: false\nsender_is_group_owner: false\nmentions_current_agent: false\nmentioned_users: -\ntext:\none");
|
|
59
|
+
expect(dispatched[0]?.rawBody).toContain("[message]\nsender_id: user-b\nsender_name: Bob\nsender_profile_type: user\nsender_is_agent_owner: false\nsender_is_group_owner: false\nmentions_current_agent: false\nmentioned_users: -\ntext:\ntwo");
|
|
60
|
+
expect(dispatched[0]?.rawBody).not.toContain("sender_relation");
|
|
61
|
+
expect(dispatched[0]?.rawBody).not.toContain("[msg-");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("flushes at thirty seconds even while group messages keep arriving", async () => {
|
|
65
|
+
const dispatched: CoalescableGroupTurn[] = [];
|
|
66
|
+
const coalescer = createGroupMessageCoalescer({
|
|
67
|
+
idleMs: 10000,
|
|
68
|
+
maxWaitMs: 30000,
|
|
69
|
+
dispatch: async (merged) => {
|
|
70
|
+
dispatched.push(merged);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
coalescer.enqueue(turn({ messageId: "msg-1", rawBody: "one" }));
|
|
75
|
+
await vi.advanceTimersByTimeAsync(9000);
|
|
76
|
+
coalescer.enqueue(turn({ messageId: "msg-2", rawBody: "two" }));
|
|
77
|
+
await vi.advanceTimersByTimeAsync(9000);
|
|
78
|
+
coalescer.enqueue(turn({ messageId: "msg-3", rawBody: "three" }));
|
|
79
|
+
await vi.advanceTimersByTimeAsync(9000);
|
|
80
|
+
coalescer.enqueue(turn({ messageId: "msg-4", rawBody: "four" }));
|
|
81
|
+
|
|
82
|
+
await vi.advanceTimersByTimeAsync(2999);
|
|
83
|
+
expect(dispatched).toEqual([]);
|
|
84
|
+
|
|
85
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
86
|
+
expect(dispatched).toHaveLength(1);
|
|
87
|
+
expect(dispatched[0]?.messageId).toBe("msg-4");
|
|
88
|
+
expect(dispatched[0]?.rawBody).toContain("ClawChat group batch (4 messages, 10s idle, 30s max)");
|
|
89
|
+
expect(dispatched[0]?.rawBody).toContain("[message]\nsender_id: user-a\nsender_name: Alice\nsender_profile_type: user\nsender_is_agent_owner: false\nsender_is_group_owner: false\nmentions_current_agent: false\nmentioned_users: -\ntext:\nfour");
|
|
90
|
+
expect(dispatched[0]?.rawBody).not.toContain("[msg-");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("flushes one merged turn per group after the idle window", async () => {
|
|
94
|
+
const dispatched: CoalescableGroupTurn[] = [];
|
|
95
|
+
const coalescer = createGroupMessageCoalescer({
|
|
96
|
+
idleMs: 10000,
|
|
97
|
+
maxWaitMs: 30000,
|
|
98
|
+
dispatch: async (merged) => {
|
|
99
|
+
dispatched.push(merged);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const latestEnvelope = { event: "message.send", trace_id: "trace-2", emitted_at: 2000 };
|
|
104
|
+
|
|
105
|
+
coalescer.enqueue(
|
|
106
|
+
turn({
|
|
107
|
+
messageId: "msg-1",
|
|
108
|
+
senderNickName: "Alice",
|
|
109
|
+
rawBody: "one",
|
|
110
|
+
mentionedUserIds: ["other-user"],
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
coalescer.enqueue(
|
|
114
|
+
turn({
|
|
115
|
+
messageId: "msg-2",
|
|
116
|
+
senderId: "user-b",
|
|
117
|
+
senderNickName: "Bob",
|
|
118
|
+
rawBody: "two",
|
|
119
|
+
traceId: "trace-2",
|
|
120
|
+
timestamp: 2000,
|
|
121
|
+
envelope: latestEnvelope,
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await vi.advanceTimersByTimeAsync(9999);
|
|
126
|
+
expect(dispatched).toEqual([]);
|
|
127
|
+
|
|
128
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
129
|
+
expect(dispatched).toHaveLength(1);
|
|
130
|
+
expect(dispatched[0]?.messageId).toBe("msg-2");
|
|
131
|
+
expect(dispatched[0]?.senderId).toBe("user-b");
|
|
132
|
+
expect(dispatched[0]?.traceId).toBe("trace-2");
|
|
133
|
+
expect(dispatched[0]?.timestamp).toBe(2000);
|
|
134
|
+
expect(dispatched[0]?.envelope).toBe(latestEnvelope);
|
|
135
|
+
expect(dispatched[0]?.mentionedUserIds).toEqual(["other-user"]);
|
|
136
|
+
expect(dispatched[0]?.rawBody).toContain("ClawChat group batch (2 messages, 10s idle, 30s max)");
|
|
137
|
+
expect(dispatched[0]?.rawBody).toContain("[message]\nsender_id: user-a\nsender_name: Alice\nsender_profile_type: user\nsender_is_agent_owner: false\nsender_is_group_owner: false\nmentions_current_agent: false\nmentioned_users: other-user\ntext:\none");
|
|
138
|
+
expect(dispatched[0]?.rawBody).toContain("[message]\nsender_id: user-b\nsender_name: Bob\nsender_profile_type: user\nsender_is_agent_owner: false\nsender_is_group_owner: false\nmentions_current_agent: false\nmentioned_users: -\ntext:\ntwo");
|
|
139
|
+
expect(dispatched[0]?.rawBody).not.toContain("[msg-");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("keeps independent timers per group", async () => {
|
|
143
|
+
const dispatched: CoalescableGroupTurn[] = [];
|
|
144
|
+
const coalescer = createGroupMessageCoalescer({
|
|
145
|
+
idleMs: 10000,
|
|
146
|
+
maxWaitMs: 30000,
|
|
147
|
+
dispatch: async (merged) => dispatched.push(merged),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
coalescer.enqueue(turn({ peer: { kind: "group", id: "room-1" }, messageId: "r1-a" }));
|
|
151
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
152
|
+
|
|
153
|
+
coalescer.enqueue(turn({ peer: { kind: "group", id: "room-2" }, messageId: "r2-a" }));
|
|
154
|
+
|
|
155
|
+
await vi.advanceTimersByTimeAsync(8999);
|
|
156
|
+
expect(dispatched).toEqual([]);
|
|
157
|
+
|
|
158
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
159
|
+
expect(dispatched.map((item) => item.peer.id)).toEqual(["room-1"]);
|
|
160
|
+
|
|
161
|
+
await vi.advanceTimersByTimeAsync(999);
|
|
162
|
+
expect(dispatched.map((item) => item.peer.id)).toEqual(["room-1"]);
|
|
163
|
+
|
|
164
|
+
await vi.advanceTimersByTimeAsync(8001);
|
|
165
|
+
expect(dispatched.map((item) => item.peer.id)).toEqual(["room-1", "room-2"]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("drops pending batches on cancel without dispatching after shutdown", async () => {
|
|
169
|
+
const dispatched: CoalescableGroupTurn[] = [];
|
|
170
|
+
const coalescer = createGroupMessageCoalescer({
|
|
171
|
+
idleMs: 10000,
|
|
172
|
+
maxWaitMs: 30000,
|
|
173
|
+
dispatch: async (merged) => dispatched.push(merged),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
coalescer.enqueue(turn());
|
|
177
|
+
coalescer.cancelAll();
|
|
178
|
+
await vi.advanceTimersByTimeAsync(30000);
|
|
179
|
+
|
|
180
|
+
expect(dispatched).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("formatCoalescedGroupBody", () => {
|
|
185
|
+
it("keeps sender name and sender identity visible without message ids", () => {
|
|
186
|
+
expect(
|
|
187
|
+
formatCoalescedGroupBody([
|
|
188
|
+
turn({ messageId: "msg-1", senderId: "u1", senderNickName: "Ann", rawBody: "first" }),
|
|
189
|
+
turn({
|
|
190
|
+
messageId: "msg-2",
|
|
191
|
+
senderId: "u2",
|
|
192
|
+
senderNickName: "Ben",
|
|
193
|
+
senderIsOwner: true,
|
|
194
|
+
senderProfileType: "user",
|
|
195
|
+
rawBody: "second",
|
|
196
|
+
}),
|
|
197
|
+
]),
|
|
198
|
+
).toBe(
|
|
199
|
+
[
|
|
200
|
+
"ClawChat group batch (2 messages, 10s idle, 30s max):",
|
|
201
|
+
"[message]",
|
|
202
|
+
"sender_id: u1",
|
|
203
|
+
"sender_name: Ann",
|
|
204
|
+
"sender_profile_type: user",
|
|
205
|
+
"sender_is_agent_owner: false",
|
|
206
|
+
"sender_is_group_owner: false",
|
|
207
|
+
"mentions_current_agent: false",
|
|
208
|
+
"mentioned_users: -",
|
|
209
|
+
"text:",
|
|
210
|
+
"first",
|
|
211
|
+
"",
|
|
212
|
+
"[message]",
|
|
213
|
+
"sender_id: u2",
|
|
214
|
+
"sender_name: Ben",
|
|
215
|
+
"sender_profile_type: user",
|
|
216
|
+
"sender_is_agent_owner: true",
|
|
217
|
+
"sender_is_group_owner: false",
|
|
218
|
+
"mentions_current_agent: false",
|
|
219
|
+
"mentioned_users: -",
|
|
220
|
+
"text:",
|
|
221
|
+
"second",
|
|
222
|
+
].join("\n"),
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("formats mentioned users with display labels", () => {
|
|
227
|
+
expect(
|
|
228
|
+
formatCoalescedGroupBody([
|
|
229
|
+
turn({
|
|
230
|
+
rawBody: "请 @Bob 下午处理",
|
|
231
|
+
mentionedUserIds: ["usr_bob_456"],
|
|
232
|
+
mentionedUsers: [{ id: "usr_bob_456", display: "Bob" }],
|
|
233
|
+
}),
|
|
234
|
+
]),
|
|
235
|
+
).toContain("mentioned_users: usr_bob_456(Bob)");
|
|
236
|
+
});
|
|
237
|
+
});
|