@newbase-clawchat/openclaw-clawchat 2026.5.4 → 2026.5.12-13
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 +121 -19
- package/dist/index.js +10 -19
- package/dist/setup-entry.js +3 -0
- package/dist/src/api-client.js +78 -10
- package/dist/src/api-types.test-d.js +10 -0
- package/dist/src/channel.js +25 -156
- package/dist/src/channel.setup.js +120 -0
- package/dist/src/client.js +37 -41
- package/dist/src/config.js +75 -17
- package/dist/src/inbound.js +79 -61
- package/dist/src/login.runtime.js +84 -19
- package/dist/src/media-runtime.js +8 -8
- package/dist/src/message-mapper.js +1 -1
- package/dist/src/mock-transport.js +31 -0
- package/dist/src/outbound.js +410 -26
- package/dist/src/protocol-types.js +63 -0
- package/dist/src/protocol-types.typecheck.js +1 -0
- package/dist/src/protocol.js +2 -7
- package/dist/src/reply-dispatcher.js +157 -54
- package/dist/src/runtime.js +795 -119
- package/dist/src/storage.js +689 -0
- package/dist/src/tools-schema.js +98 -16
- package/dist/src/tools.js +422 -135
- 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 +10 -22
- package/openclaw.plugin.json +37 -2
- package/package.json +17 -4
- package/setup-entry.ts +4 -0
- package/skills/clawchat/SKILL.md +88 -0
- package/src/api-client.test.ts +274 -14
- package/src/api-client.ts +138 -23
- package/src/api-types.test-d.ts +12 -0
- package/src/api-types.ts +90 -4
- package/src/buffered-stream.test.ts +14 -12
- package/src/buffered-stream.ts +1 -1
- package/src/channel.outbound.test.ts +269 -60
- package/src/channel.setup.ts +146 -0
- package/src/channel.test.ts +130 -24
- package/src/channel.ts +30 -186
- package/src/client.test.ts +197 -11
- package/src/client.ts +50 -57
- package/src/config.test.ts +108 -6
- package/src/config.ts +95 -24
- package/src/inbound.test.ts +288 -37
- package/src/inbound.ts +96 -84
- package/src/login.runtime.test.ts +347 -13
- package/src/login.runtime.ts +105 -23
- package/src/manifest.test.ts +146 -74
- package/src/media-runtime.test.ts +57 -2
- package/src/media-runtime.ts +26 -17
- package/src/message-mapper.test.ts +2 -2
- package/src/message-mapper.ts +2 -2
- package/src/mock-transport.test.ts +35 -0
- package/src/mock-transport.ts +38 -0
- package/src/outbound.test.ts +694 -73
- package/src/outbound.ts +484 -31
- package/src/plugin-entry.test.ts +1 -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 +1 -6
- package/src/protocol.ts +2 -7
- package/src/reply-dispatcher.test.ts +819 -119
- package/src/reply-dispatcher.ts +202 -60
- package/src/runtime.test.ts +2120 -41
- package/src/runtime.ts +935 -142
- package/src/scripts.test.ts +85 -0
- package/src/storage.test.ts +793 -0
- package/src/storage.ts +1095 -0
- package/src/streaming.test.ts +9 -8
- package/src/streaming.ts +1 -1
- package/src/tools-schema.ts +148 -20
- package/src/tools.test.ts +377 -50
- package/src/tools.ts +574 -154
- package/src/ws-alignment.test.ts +103 -0
- package/src/ws-alignment.ts +275 -0
- package/src/ws-client.test.ts +1218 -0
- package/src/ws-client.ts +662 -0
- package/src/ws-log.test.ts +32 -0
- package/src/ws-log.ts +31 -0
- package/skills/clawchat-account-tools/SKILL.md +0 -26
- package/skills/clawchat-activate/SKILL.md +0 -47
package/src/config.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
|
|
4
4
|
export const CHANNEL_ID = "openclaw-clawchat" as const;
|
|
5
5
|
export const CLAWCHAT_TOKEN_ENV = "CLAWCHAT_TOKEN" as const;
|
|
6
6
|
export const CLAWCHAT_USER_ID_ENV = "CLAWCHAT_USER_ID" as const;
|
|
7
|
+
export const CLAWCHAT_OWNER_USER_ID_ENV = "CLAWCHAT_OWNER_USER_ID" as const;
|
|
7
8
|
export const CLAWCHAT_REFRESH_TOKEN_ENV = "CLAWCHAT_REFRESH_TOKEN" as const;
|
|
8
9
|
export const CLAWCHAT_BASE_URL_ENV = "CLAWCHAT_BASE_URL" as const;
|
|
9
10
|
export const CLAWCHAT_WEBSOCKET_URL_ENV = "CLAWCHAT_WEBSOCKET_URL" as const;
|
|
@@ -14,16 +15,16 @@ export const CLAWCHAT_WEBSOCKET_URL_ENV = "CLAWCHAT_WEBSOCKET_URL" as const;
|
|
|
14
15
|
* setup` call. Operators can still override either one via config.
|
|
15
16
|
*
|
|
16
17
|
*/
|
|
17
|
-
export const DEFAULT_BASE_URL = "
|
|
18
|
-
export const DEFAULT_WEBSOCKET_URL = "
|
|
18
|
+
export const DEFAULT_BASE_URL = "https://app.clawling.com" as const;
|
|
19
|
+
export const DEFAULT_WEBSOCKET_URL = "wss://app.clawling.com/ws" as const;
|
|
19
20
|
|
|
20
21
|
export type ReplyMode = "static" | "stream";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Group-chat trigger policy.
|
|
24
|
-
* - "
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* - "all" (default): trigger on every group message regardless of mentions (open listen).
|
|
26
|
+
* - "mention": only trigger a reply when the inbound `context.mentions`
|
|
27
|
+
* list includes our `userId` (i.e. the sender @-mentioned us).
|
|
27
28
|
*/
|
|
28
29
|
export type GroupMode = "mention" | "all";
|
|
29
30
|
|
|
@@ -66,6 +67,10 @@ export type OpenclawClawlingStreamConfig = {
|
|
|
66
67
|
maxBufferChars?: number;
|
|
67
68
|
};
|
|
68
69
|
|
|
70
|
+
export type OpenclawClawlingGroupConfig = {
|
|
71
|
+
groupMode: GroupMode;
|
|
72
|
+
};
|
|
73
|
+
|
|
69
74
|
export type OpenclawClawlingReconnectConfig = {
|
|
70
75
|
initialDelay?: number;
|
|
71
76
|
maxDelay?: number;
|
|
@@ -92,8 +97,10 @@ export type OpenclawClawlingConfig = {
|
|
|
92
97
|
/** Refresh token persisted by ClawChat activation/login (paired with `token`). */
|
|
93
98
|
refreshToken?: string;
|
|
94
99
|
userId?: string;
|
|
100
|
+
ownerUserId?: string;
|
|
95
101
|
replyMode?: ReplyMode;
|
|
96
102
|
groupMode?: GroupMode;
|
|
103
|
+
groups?: Record<string, Partial<OpenclawClawlingGroupConfig>>;
|
|
97
104
|
forwardThinking?: boolean;
|
|
98
105
|
forwardToolCalls?: boolean;
|
|
99
106
|
/** Emit approval/action rich fragments instead of plain fallback text. */
|
|
@@ -114,8 +121,19 @@ export const openclawClawlingConfigSchema = {
|
|
|
114
121
|
token: { type: "string" },
|
|
115
122
|
refreshToken: { type: "string" },
|
|
116
123
|
userId: { type: "string" },
|
|
124
|
+
ownerUserId: { type: "string" },
|
|
117
125
|
replyMode: { type: "string", enum: ["static", "stream"] },
|
|
118
126
|
groupMode: { type: "string", enum: ["mention", "all"] },
|
|
127
|
+
groups: {
|
|
128
|
+
type: "object",
|
|
129
|
+
additionalProperties: {
|
|
130
|
+
type: "object",
|
|
131
|
+
additionalProperties: false,
|
|
132
|
+
properties: {
|
|
133
|
+
groupMode: { type: "string", enum: ["mention", "all"] },
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
119
137
|
forwardThinking: { type: "boolean" },
|
|
120
138
|
forwardToolCalls: { type: "boolean" },
|
|
121
139
|
richInteractions: { type: "boolean" },
|
|
@@ -161,17 +179,7 @@ function isOpenclawClawchatToolAllowEntry(entry: unknown): boolean {
|
|
|
161
179
|
return entry === CHANNEL_ID || entry === "group:plugins";
|
|
162
180
|
}
|
|
163
181
|
|
|
164
|
-
function
|
|
165
|
-
const currentTools = ((cfg as { tools?: Record<string, unknown> }).tools ?? {}) as Record<
|
|
166
|
-
string,
|
|
167
|
-
unknown
|
|
168
|
-
>;
|
|
169
|
-
const currentAlsoAllow = Array.isArray(currentTools.alsoAllow) ? currentTools.alsoAllow : [];
|
|
170
|
-
const currentAllow = Array.isArray(currentTools.allow) ? currentTools.allow : [];
|
|
171
|
-
return [...currentAllow, ...currentAlsoAllow].some(isOpenclawClawchatToolAllowEntry);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function mergeToolPolicyEntryAllow(
|
|
182
|
+
function mergeToolPolicyEntryAlsoAllow(
|
|
175
183
|
cfg: OpenClawConfig,
|
|
176
184
|
entry: string,
|
|
177
185
|
isAlreadyCovered: (value: unknown) => boolean,
|
|
@@ -184,28 +192,60 @@ function mergeToolPolicyEntryAllow(
|
|
|
184
192
|
? currentTools.alsoAllow.slice()
|
|
185
193
|
: [];
|
|
186
194
|
const currentAllow = Array.isArray(currentTools.allow) ? currentTools.allow.slice() : [];
|
|
187
|
-
const
|
|
188
|
-
if (
|
|
195
|
+
const alreadyCovered = [...currentAllow, ...currentAlsoAllow].some(isAlreadyCovered);
|
|
196
|
+
if (alreadyCovered) {
|
|
189
197
|
return {
|
|
190
198
|
...cfg,
|
|
191
199
|
tools: {
|
|
192
200
|
...currentTools,
|
|
193
|
-
allow
|
|
201
|
+
...(Array.isArray(currentTools.allow) ? { allow: currentAllow } : {}),
|
|
202
|
+
...(Array.isArray(currentTools.alsoAllow) ? { alsoAllow: currentAlsoAllow } : {}),
|
|
194
203
|
},
|
|
195
204
|
} as OpenClawConfig;
|
|
196
205
|
}
|
|
197
|
-
const alreadyAlsoAllowed = currentAlsoAllow.some(isAlreadyCovered);
|
|
198
206
|
return {
|
|
199
207
|
...cfg,
|
|
200
208
|
tools: {
|
|
201
209
|
...currentTools,
|
|
202
|
-
alsoAllow:
|
|
210
|
+
alsoAllow: [...currentAlsoAllow, entry],
|
|
203
211
|
},
|
|
204
212
|
} as OpenClawConfig;
|
|
205
213
|
}
|
|
206
214
|
|
|
207
215
|
export function mergeOpenclawClawchatToolAllow(cfg: OpenClawConfig): OpenClawConfig {
|
|
208
|
-
return
|
|
216
|
+
return mergeToolPolicyEntryAlsoAllow(cfg, CHANNEL_ID, isOpenclawClawchatToolAllowEntry);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function readRecord(value: unknown): Record<string, unknown> {
|
|
220
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
221
|
+
? (value as Record<string, unknown>)
|
|
222
|
+
: {};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function mergeOpenclawClawchatRuntimePluginActivation(
|
|
226
|
+
cfg: OpenClawConfig,
|
|
227
|
+
): OpenClawConfig {
|
|
228
|
+
const currentPlugins = readRecord((cfg as { plugins?: unknown }).plugins);
|
|
229
|
+
const currentEntries = readRecord(currentPlugins.entries);
|
|
230
|
+
const currentEntry = readRecord(currentEntries[CHANNEL_ID]);
|
|
231
|
+
const currentAllow = Array.isArray(currentPlugins.allow) ? currentPlugins.allow.slice() : [];
|
|
232
|
+
const nextPlugins: Record<string, unknown> = {
|
|
233
|
+
...currentPlugins,
|
|
234
|
+
entries: {
|
|
235
|
+
...currentEntries,
|
|
236
|
+
[CHANNEL_ID]: {
|
|
237
|
+
...currentEntry,
|
|
238
|
+
enabled: true,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
if (!currentAllow.includes(CHANNEL_ID)) {
|
|
243
|
+
nextPlugins.allow = [...currentAllow, CHANNEL_ID];
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
...cfg,
|
|
247
|
+
plugins: nextPlugins,
|
|
248
|
+
} as OpenClawConfig;
|
|
209
249
|
}
|
|
210
250
|
|
|
211
251
|
export type ResolvedOpenclawClawlingAccount = {
|
|
@@ -217,8 +257,10 @@ export type ResolvedOpenclawClawlingAccount = {
|
|
|
217
257
|
baseUrl: string;
|
|
218
258
|
token: string;
|
|
219
259
|
userId: string;
|
|
260
|
+
ownerUserId: string;
|
|
220
261
|
replyMode: ReplyMode;
|
|
221
262
|
groupMode: GroupMode;
|
|
263
|
+
groups: Record<string, OpenclawClawlingGroupConfig>;
|
|
222
264
|
forwardThinking: boolean;
|
|
223
265
|
forwardToolCalls: boolean;
|
|
224
266
|
richInteractions: boolean;
|
|
@@ -248,7 +290,31 @@ function readReplyMode(value: unknown): ReplyMode {
|
|
|
248
290
|
}
|
|
249
291
|
|
|
250
292
|
function readGroupMode(value: unknown): GroupMode {
|
|
251
|
-
return value === "
|
|
293
|
+
return value === "mention" ? "mention" : "all";
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function readGroups(value: unknown): Record<string, OpenclawClawlingGroupConfig> {
|
|
297
|
+
const rawGroups = value && typeof value === "object" && !Array.isArray(value)
|
|
298
|
+
? (value as Record<string, unknown>)
|
|
299
|
+
: {};
|
|
300
|
+
const groups: Record<string, OpenclawClawlingGroupConfig> = {};
|
|
301
|
+
for (const [chatId, rawGroup] of Object.entries(rawGroups)) {
|
|
302
|
+
if (!chatId) continue;
|
|
303
|
+
const group = rawGroup && typeof rawGroup === "object" && !Array.isArray(rawGroup)
|
|
304
|
+
? (rawGroup as Record<string, unknown>)
|
|
305
|
+
: {};
|
|
306
|
+
groups[chatId] = { groupMode: readGroupMode(group.groupMode) };
|
|
307
|
+
}
|
|
308
|
+
return groups;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function effectiveGroupMode(
|
|
312
|
+
account: Pick<ResolvedOpenclawClawlingAccount, "groupMode" | "groups">,
|
|
313
|
+
chatId: string,
|
|
314
|
+
): GroupMode {
|
|
315
|
+
return account.groups[chatId]?.groupMode
|
|
316
|
+
?? account.groups["*"]?.groupMode
|
|
317
|
+
?? account.groupMode;
|
|
252
318
|
}
|
|
253
319
|
|
|
254
320
|
function readStream(raw: unknown): Required<OpenclawClawlingStreamConfig> {
|
|
@@ -312,9 +378,12 @@ export function resolveOpenclawClawlingAccount(
|
|
|
312
378
|
DEFAULT_BASE_URL;
|
|
313
379
|
const token = readOptionalString(channel.token) || readEnvString(env, CLAWCHAT_TOKEN_ENV);
|
|
314
380
|
const userId = readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV);
|
|
381
|
+
const ownerUserId =
|
|
382
|
+
readOptionalString(channel.ownerUserId) || readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV);
|
|
315
383
|
const enabled = typeof channel.enabled === "boolean" ? channel.enabled : true;
|
|
316
384
|
const replyMode = readReplyMode(channel.replyMode);
|
|
317
385
|
const groupMode = readGroupMode(channel.groupMode);
|
|
386
|
+
const groups = readGroups(channel.groups);
|
|
318
387
|
const forwardThinking =
|
|
319
388
|
typeof channel.forwardThinking === "boolean" ? channel.forwardThinking : true;
|
|
320
389
|
const forwardToolCalls =
|
|
@@ -326,13 +395,15 @@ export function resolveOpenclawClawlingAccount(
|
|
|
326
395
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
327
396
|
name: CHANNEL_ID,
|
|
328
397
|
enabled,
|
|
329
|
-
configured: Boolean(websocketUrl && token && userId),
|
|
398
|
+
configured: Boolean(websocketUrl && token && userId && ownerUserId),
|
|
330
399
|
websocketUrl,
|
|
331
400
|
baseUrl,
|
|
332
401
|
token,
|
|
333
402
|
userId,
|
|
403
|
+
ownerUserId,
|
|
334
404
|
replyMode,
|
|
335
405
|
groupMode,
|
|
406
|
+
groups,
|
|
336
407
|
forwardThinking,
|
|
337
408
|
forwardToolCalls,
|
|
338
409
|
richInteractions,
|
package/src/inbound.test.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { Envelope,
|
|
1
|
+
import type { Envelope, MessagePayload } from "./protocol-types.ts";
|
|
2
2
|
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
|
3
|
-
import {
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
4
|
import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
|
|
5
|
-
import { dispatchOpenclawClawlingInbound
|
|
5
|
+
import { dispatchOpenclawClawlingInbound } from "./inbound.ts";
|
|
6
6
|
|
|
7
7
|
function baseAccount(
|
|
8
8
|
overrides: Partial<ResolvedOpenclawClawlingAccount> = {},
|
|
@@ -16,7 +16,11 @@ function baseAccount(
|
|
|
16
16
|
baseUrl: "",
|
|
17
17
|
token: "tk",
|
|
18
18
|
userId: "agent-1",
|
|
19
|
+
ownerUserId: "owner-1",
|
|
19
20
|
replyMode: "static",
|
|
21
|
+
groupMode: "all",
|
|
22
|
+
groups: {},
|
|
23
|
+
richInteractions: false,
|
|
20
24
|
forwardThinking: true,
|
|
21
25
|
forwardToolCalls: false,
|
|
22
26
|
allowFrom: [],
|
|
@@ -37,24 +41,28 @@ function buildSendEnvelope(
|
|
|
37
41
|
overrides: Partial<{
|
|
38
42
|
event: "message.send" | "message.reply";
|
|
39
43
|
text: string;
|
|
40
|
-
|
|
41
|
-
mentions:
|
|
44
|
+
chatType: "direct" | "group";
|
|
45
|
+
mentions: unknown[];
|
|
42
46
|
reply: unknown;
|
|
43
47
|
messageId: string;
|
|
44
48
|
chatId: string;
|
|
49
|
+
omitChatId: boolean;
|
|
45
50
|
}> = {},
|
|
46
|
-
): Envelope<
|
|
51
|
+
): Envelope<MessagePayload> {
|
|
52
|
+
const chatId = overrides.chatId ?? "chat-1";
|
|
53
|
+
const chatType = overrides.chatType ?? "direct";
|
|
47
54
|
return {
|
|
48
55
|
version: "2",
|
|
49
56
|
event: overrides.event ?? "message.send",
|
|
50
57
|
trace_id: "trace-1",
|
|
51
58
|
emitted_at: 1776162600000,
|
|
52
|
-
chat_id:
|
|
53
|
-
|
|
59
|
+
...(overrides.omitChatId ? {} : { chat_id: chatId }),
|
|
60
|
+
chat_type: chatType,
|
|
61
|
+
to: { id: chatId, type: chatType },
|
|
54
62
|
sender: {
|
|
55
|
-
|
|
56
|
-
type:
|
|
57
|
-
|
|
63
|
+
id: "user-1",
|
|
64
|
+
type: "direct",
|
|
65
|
+
nick_name: "User One",
|
|
58
66
|
},
|
|
59
67
|
payload: {
|
|
60
68
|
message_id: overrides.messageId ?? "msg-1",
|
|
@@ -74,21 +82,63 @@ function buildSendEnvelope(
|
|
|
74
82
|
started_at: null,
|
|
75
83
|
completed_at: null,
|
|
76
84
|
},
|
|
77
|
-
|
|
78
|
-
sender_id: "user-1",
|
|
79
|
-
type: overrides.senderType ?? "direct",
|
|
80
|
-
display_name: "User One",
|
|
81
|
-
},
|
|
82
|
-
} as DownlinkMessageSendPayload["message"],
|
|
85
|
+
} as MessagePayload["message"],
|
|
83
86
|
},
|
|
84
|
-
} as Envelope<
|
|
87
|
+
} as Envelope<MessagePayload>;
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
function buildStreamEnvelope(
|
|
91
|
+
overrides: Partial<{
|
|
92
|
+
event: "message.created" | "message.add" | "message.done" | "message.failed";
|
|
93
|
+
fragments: Array<Record<string, unknown>>;
|
|
94
|
+
messageId: string;
|
|
95
|
+
sequence: number;
|
|
96
|
+
chatId: string;
|
|
97
|
+
chatType: "direct" | "group";
|
|
98
|
+
}> = {},
|
|
99
|
+
): Envelope<unknown> {
|
|
100
|
+
const event = overrides.event ?? "message.done";
|
|
101
|
+
const sequence = overrides.sequence ?? 0;
|
|
102
|
+
const payload: Record<string, unknown> = {
|
|
103
|
+
message_id: overrides.messageId ?? "stream-1",
|
|
104
|
+
};
|
|
105
|
+
if (event === "message.add") {
|
|
106
|
+
payload.sequence = sequence;
|
|
107
|
+
payload.mutation = { type: "append", target_fragment_index: null };
|
|
108
|
+
payload.fragments = overrides.fragments ?? [{ kind: "text", text: "Hel", delta: "Hel" }];
|
|
109
|
+
payload.streaming = {
|
|
110
|
+
status: "streaming",
|
|
111
|
+
sequence,
|
|
112
|
+
mutation_policy: "append_text_only",
|
|
113
|
+
started_at: null,
|
|
114
|
+
completed_at: null,
|
|
115
|
+
};
|
|
116
|
+
payload.added_at = 1776162600001;
|
|
117
|
+
}
|
|
118
|
+
if (event === "message.done" || event === "message.failed") {
|
|
119
|
+
payload.fragments = overrides.fragments ?? [{ kind: "text", text: "Hello" }];
|
|
120
|
+
payload.streaming = {
|
|
121
|
+
status: event === "message.done" ? "done" : "failed",
|
|
122
|
+
sequence,
|
|
123
|
+
mutation_policy: "append_text_only",
|
|
124
|
+
started_at: null,
|
|
125
|
+
completed_at: 1776162600002,
|
|
126
|
+
};
|
|
127
|
+
payload.completed_at = 1776162600002;
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
version: "2",
|
|
131
|
+
event,
|
|
132
|
+
trace_id: `trace-${event}`,
|
|
133
|
+
emitted_at: 1776162600000,
|
|
134
|
+
chat_id: overrides.chatId ?? "chat-1",
|
|
135
|
+
chat_type: overrides.chatType ?? "direct",
|
|
136
|
+
sender: { id: "user-1", type: "direct", nick_name: "User One" },
|
|
137
|
+
payload,
|
|
138
|
+
} as Envelope<unknown>;
|
|
139
|
+
}
|
|
91
140
|
|
|
141
|
+
describe("openclaw-clawchat inbound", () => {
|
|
92
142
|
it("dispatches a plain text message and flattens the body", async () => {
|
|
93
143
|
const ingest = vi.fn().mockResolvedValue(undefined);
|
|
94
144
|
await dispatchOpenclawClawlingInbound({
|
|
@@ -102,14 +152,137 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
102
152
|
const call = ingest.mock.calls[0]![0];
|
|
103
153
|
expect(call.channel).toBe("openclaw-clawchat");
|
|
104
154
|
expect(call.rawBody).toBe("hello there");
|
|
105
|
-
expect(call.peer).toEqual({ kind: "direct", id: "
|
|
155
|
+
expect(call.peer).toEqual({ kind: "direct", id: "chat-1" });
|
|
106
156
|
expect(call.messageId).toBe("msg-1");
|
|
107
157
|
});
|
|
108
158
|
|
|
159
|
+
it("dispatches materialized message.send and message.reply inbound", async () => {
|
|
160
|
+
for (const event of ["message.send", "message.reply"] as const) {
|
|
161
|
+
const ingest = vi.fn().mockResolvedValue(undefined);
|
|
162
|
+
await dispatchOpenclawClawlingInbound({
|
|
163
|
+
envelope: buildSendEnvelope({ event, messageId: `msg-${event}` }),
|
|
164
|
+
cfg: {},
|
|
165
|
+
runtime: {} as never,
|
|
166
|
+
account: baseAccount(),
|
|
167
|
+
ingest,
|
|
168
|
+
});
|
|
169
|
+
expect(ingest).toHaveBeenCalledTimes(1);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("dispatches message.done lifecycle frames as completed inbound messages", async () => {
|
|
174
|
+
const ingest = vi.fn().mockResolvedValue(undefined);
|
|
175
|
+
await dispatchOpenclawClawlingInbound({
|
|
176
|
+
envelope: buildStreamEnvelope({
|
|
177
|
+
event: "message.done",
|
|
178
|
+
messageId: "stream-1",
|
|
179
|
+
fragments: [{ kind: "text", text: "completed stream" }],
|
|
180
|
+
}) as Envelope<MessagePayload>,
|
|
181
|
+
cfg: {},
|
|
182
|
+
runtime: {} as never,
|
|
183
|
+
account: baseAccount(),
|
|
184
|
+
ingest,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(ingest).toHaveBeenCalledTimes(1);
|
|
188
|
+
const call = ingest.mock.calls[0]![0];
|
|
189
|
+
expect(call.messageId).toBe("stream-1");
|
|
190
|
+
expect(call.rawBody).toBe("completed stream");
|
|
191
|
+
expect(call.peer).toEqual({ kind: "direct", id: "chat-1" });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("tracks stream add frames and dispatches only the done payload", async () => {
|
|
195
|
+
const ingest = vi.fn().mockResolvedValue(undefined);
|
|
196
|
+
for (const envelope of [
|
|
197
|
+
buildStreamEnvelope({ event: "message.created", messageId: "stream-2" }),
|
|
198
|
+
buildStreamEnvelope({
|
|
199
|
+
event: "message.add",
|
|
200
|
+
messageId: "stream-2",
|
|
201
|
+
fragments: [{ kind: "text", text: "partial", delta: "partial" }],
|
|
202
|
+
}),
|
|
203
|
+
buildStreamEnvelope({
|
|
204
|
+
event: "message.done",
|
|
205
|
+
messageId: "stream-2",
|
|
206
|
+
fragments: [{ kind: "text", text: "final text" }],
|
|
207
|
+
}),
|
|
208
|
+
]) {
|
|
209
|
+
await dispatchOpenclawClawlingInbound({
|
|
210
|
+
envelope: envelope as Envelope<MessagePayload>,
|
|
211
|
+
cfg: {},
|
|
212
|
+
runtime: {} as never,
|
|
213
|
+
account: baseAccount(),
|
|
214
|
+
ingest,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
expect(ingest).toHaveBeenCalledTimes(1);
|
|
219
|
+
expect(ingest.mock.calls[0]![0].rawBody).toBe("final text");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("derives mentions from message.done fragments for group mention mode", async () => {
|
|
223
|
+
const ingest = vi.fn().mockResolvedValue(undefined);
|
|
224
|
+
await dispatchOpenclawClawlingInbound({
|
|
225
|
+
envelope: buildStreamEnvelope({
|
|
226
|
+
event: "message.done",
|
|
227
|
+
messageId: "stream-group-1",
|
|
228
|
+
chatId: "group-1",
|
|
229
|
+
chatType: "group",
|
|
230
|
+
fragments: [
|
|
231
|
+
{ kind: "mention", user_id: "agent-1", display: "@bot" },
|
|
232
|
+
{ kind: "text", text: " please help" },
|
|
233
|
+
],
|
|
234
|
+
}) as Envelope<MessagePayload>,
|
|
235
|
+
cfg: {},
|
|
236
|
+
runtime: {} as never,
|
|
237
|
+
account: baseAccount({ groupMode: "mention" }),
|
|
238
|
+
ingest,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(ingest).toHaveBeenCalledTimes(1);
|
|
242
|
+
const call = ingest.mock.calls[0]![0];
|
|
243
|
+
expect(call.peer).toEqual({ kind: "group", id: "group-1" });
|
|
244
|
+
expect(call.wasMentioned).toBe(true);
|
|
245
|
+
expect(call.rawBody).toBe("@bot please help");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("skips message.done payloads with a non-done streaming status", async () => {
|
|
249
|
+
const ingest = vi.fn().mockResolvedValue(undefined);
|
|
250
|
+
const envelope = buildStreamEnvelope({
|
|
251
|
+
event: "message.done",
|
|
252
|
+
messageId: "stream-bad-status",
|
|
253
|
+
fragments: [{ kind: "text", text: "bad status" }],
|
|
254
|
+
});
|
|
255
|
+
(envelope.payload as { streaming: { status: string } }).streaming.status = "failed";
|
|
256
|
+
|
|
257
|
+
await dispatchOpenclawClawlingInbound({
|
|
258
|
+
envelope: envelope as Envelope<MessagePayload>,
|
|
259
|
+
cfg: {},
|
|
260
|
+
runtime: {} as never,
|
|
261
|
+
account: baseAccount(),
|
|
262
|
+
ingest,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(ingest).not.toHaveBeenCalled();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("does not dispatch unfinished or failed stream lifecycle frames", async () => {
|
|
269
|
+
for (const event of ["message.created", "message.add", "message.failed"] as const) {
|
|
270
|
+
const ingest = vi.fn().mockResolvedValue(undefined);
|
|
271
|
+
await dispatchOpenclawClawlingInbound({
|
|
272
|
+
envelope: buildStreamEnvelope({ event, messageId: `msg-${event}` }) as Envelope<MessagePayload>,
|
|
273
|
+
cfg: {},
|
|
274
|
+
runtime: {} as never,
|
|
275
|
+
account: baseAccount(),
|
|
276
|
+
ingest,
|
|
277
|
+
});
|
|
278
|
+
expect(ingest).not.toHaveBeenCalled();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
109
282
|
it("marks wasMentioned=true when direct chat", async () => {
|
|
110
283
|
const ingest = vi.fn().mockResolvedValue(undefined);
|
|
111
284
|
await dispatchOpenclawClawlingInbound({
|
|
112
|
-
envelope: buildSendEnvelope({
|
|
285
|
+
envelope: buildSendEnvelope({ chatType: "direct" }),
|
|
113
286
|
cfg: {},
|
|
114
287
|
runtime: {} as never,
|
|
115
288
|
account: baseAccount({ groupMode: "mention" }),
|
|
@@ -124,27 +297,44 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
124
297
|
expect(
|
|
125
298
|
detectMention({
|
|
126
299
|
mentions: ["agent-1"],
|
|
127
|
-
|
|
300
|
+
chatType: "group",
|
|
128
301
|
userId: "agent-1",
|
|
129
302
|
}),
|
|
130
303
|
).toBe(true);
|
|
131
304
|
});
|
|
132
305
|
|
|
306
|
+
it("detects object-shaped context mentions in group mention mode", async () => {
|
|
307
|
+
const ingest = vi.fn().mockResolvedValue(undefined);
|
|
308
|
+
await dispatchOpenclawClawlingInbound({
|
|
309
|
+
envelope: buildSendEnvelope({
|
|
310
|
+
chatType: "group",
|
|
311
|
+
mentions: [{ user_id: "agent-1", display: "@bot" }],
|
|
312
|
+
}),
|
|
313
|
+
cfg: {},
|
|
314
|
+
runtime: {} as never,
|
|
315
|
+
account: baseAccount({ groupMode: "mention" }),
|
|
316
|
+
ingest,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
expect(ingest).toHaveBeenCalledTimes(1);
|
|
320
|
+
expect(ingest.mock.calls[0]![0].wasMentioned).toBe(true);
|
|
321
|
+
});
|
|
322
|
+
|
|
133
323
|
it("detectMention returns false for group chat when userId not mentioned", async () => {
|
|
134
324
|
const { detectMention } = await import("./inbound.ts");
|
|
135
325
|
expect(
|
|
136
326
|
detectMention({
|
|
137
327
|
mentions: ["user-2"],
|
|
138
|
-
|
|
328
|
+
chatType: "group",
|
|
139
329
|
userId: "agent-1",
|
|
140
330
|
}),
|
|
141
331
|
).toBe(false);
|
|
142
332
|
});
|
|
143
333
|
|
|
144
|
-
it("skips group messages
|
|
334
|
+
it("skips unmentioned group messages in mention mode", async () => {
|
|
145
335
|
const ingest = vi.fn().mockResolvedValue(undefined);
|
|
146
336
|
await dispatchOpenclawClawlingInbound({
|
|
147
|
-
envelope: buildSendEnvelope({
|
|
337
|
+
envelope: buildSendEnvelope({ chatType: "group" }),
|
|
148
338
|
cfg: {},
|
|
149
339
|
runtime: {} as never,
|
|
150
340
|
account: baseAccount({ groupMode: "mention" }),
|
|
@@ -153,6 +343,57 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
153
343
|
expect(ingest).not.toHaveBeenCalled();
|
|
154
344
|
});
|
|
155
345
|
|
|
346
|
+
it("uses exact per-group mention mode for matching group chat_id", async () => {
|
|
347
|
+
const ingest = vi.fn().mockResolvedValue(undefined);
|
|
348
|
+
await dispatchOpenclawClawlingInbound({
|
|
349
|
+
envelope: buildSendEnvelope({ chatType: "group", chatId: "group-quiet" }),
|
|
350
|
+
cfg: {},
|
|
351
|
+
runtime: {} as never,
|
|
352
|
+
account: baseAccount({
|
|
353
|
+
groupMode: "all",
|
|
354
|
+
groups: { "group-quiet": { groupMode: "mention" } },
|
|
355
|
+
}),
|
|
356
|
+
ingest,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
expect(ingest).not.toHaveBeenCalled();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("uses wildcard per-group mention mode when exact group is absent", async () => {
|
|
363
|
+
const ingest = vi.fn().mockResolvedValue(undefined);
|
|
364
|
+
await dispatchOpenclawClawlingInbound({
|
|
365
|
+
envelope: buildSendEnvelope({ chatType: "group", chatId: "group-any" }),
|
|
366
|
+
cfg: {},
|
|
367
|
+
runtime: {} as never,
|
|
368
|
+
account: baseAccount({
|
|
369
|
+
groupMode: "all",
|
|
370
|
+
groups: { "*": { groupMode: "mention" } },
|
|
371
|
+
}),
|
|
372
|
+
ingest,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(ingest).not.toHaveBeenCalled();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("lets exact per-group all mode override wildcard mention mode", async () => {
|
|
379
|
+
const ingest = vi.fn().mockResolvedValue(undefined);
|
|
380
|
+
await dispatchOpenclawClawlingInbound({
|
|
381
|
+
envelope: buildSendEnvelope({ chatType: "group", chatId: "group-open" }),
|
|
382
|
+
cfg: {},
|
|
383
|
+
runtime: {} as never,
|
|
384
|
+
account: baseAccount({
|
|
385
|
+
groupMode: "mention",
|
|
386
|
+
groups: {
|
|
387
|
+
"group-open": { groupMode: "all" },
|
|
388
|
+
"*": { groupMode: "mention" },
|
|
389
|
+
},
|
|
390
|
+
}),
|
|
391
|
+
ingest,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
expect(ingest).toHaveBeenCalledTimes(1);
|
|
395
|
+
});
|
|
396
|
+
|
|
156
397
|
it("skips messages with empty renderable text", async () => {
|
|
157
398
|
const ingest = vi.fn().mockResolvedValue(undefined);
|
|
158
399
|
await dispatchOpenclawClawlingInbound({
|
|
@@ -170,8 +411,8 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
170
411
|
const replyRef = {
|
|
171
412
|
reply_to_msg_id: "m-orig",
|
|
172
413
|
reply_preview: {
|
|
173
|
-
|
|
174
|
-
|
|
414
|
+
id: "user-2",
|
|
415
|
+
nick_name: "User Two",
|
|
175
416
|
fragments: [{ kind: "text", text: "original text" }],
|
|
176
417
|
},
|
|
177
418
|
};
|
|
@@ -192,7 +433,7 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
192
433
|
});
|
|
193
434
|
});
|
|
194
435
|
|
|
195
|
-
it("
|
|
436
|
+
it("does not own duplicate suppression when the same message_id is parsed twice", async () => {
|
|
196
437
|
const ingest = vi.fn().mockResolvedValue(undefined);
|
|
197
438
|
const env = buildSendEnvelope({ messageId: "dup-1" });
|
|
198
439
|
await dispatchOpenclawClawlingInbound({
|
|
@@ -209,13 +450,13 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
209
450
|
account: baseAccount(),
|
|
210
451
|
ingest,
|
|
211
452
|
});
|
|
212
|
-
expect(ingest).toHaveBeenCalledTimes(
|
|
453
|
+
expect(ingest).toHaveBeenCalledTimes(2);
|
|
213
454
|
});
|
|
214
455
|
|
|
215
456
|
it("passes mediaItems extracted from body fragments to ingest", async () => {
|
|
216
457
|
const ingest = vi.fn().mockResolvedValue(undefined);
|
|
217
458
|
const env = buildSendEnvelope({});
|
|
218
|
-
// Replace the body's fragments with text + image
|
|
459
|
+
// Replace the body's fragments with text + image; the local fragment union accepts these directly.
|
|
219
460
|
env.payload.message.body.fragments = [
|
|
220
461
|
{ kind: "text", text: "hello" },
|
|
221
462
|
{ kind: "image", url: "https://cdn/x.png", mime: "image/png" },
|
|
@@ -250,11 +491,9 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
250
491
|
expect(call.mediaItems).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
|
|
251
492
|
});
|
|
252
493
|
|
|
253
|
-
it("dispatches
|
|
494
|
+
it("dispatches with canonical envelope.sender shape", async () => {
|
|
254
495
|
const ingest = vi.fn().mockResolvedValue(undefined);
|
|
255
496
|
const env = buildSendEnvelope({ text: "hello" });
|
|
256
|
-
// Real chat-sdk envelopes carry sender on the envelope, not inside the message.
|
|
257
|
-
delete (env.payload.message as unknown as { sender?: unknown }).sender;
|
|
258
497
|
await dispatchOpenclawClawlingInbound({
|
|
259
498
|
envelope: env,
|
|
260
499
|
cfg: {} as never,
|
|
@@ -266,7 +505,19 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
266
505
|
const call = ingest.mock.calls[0]![0];
|
|
267
506
|
expect(call.senderId).toBe("user-1");
|
|
268
507
|
expect(call.senderNickName).toBe("User One");
|
|
269
|
-
expect(call.peer).toEqual({ kind: "direct", id: "
|
|
508
|
+
expect(call.peer).toEqual({ kind: "direct", id: "chat-1" });
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("skips business events without required chat_id", async () => {
|
|
512
|
+
const ingest = vi.fn().mockResolvedValue(undefined);
|
|
513
|
+
await dispatchOpenclawClawlingInbound({
|
|
514
|
+
envelope: buildSendEnvelope({ omitChatId: true }),
|
|
515
|
+
cfg: {} as never,
|
|
516
|
+
runtime: {} as never,
|
|
517
|
+
account: baseAccount(),
|
|
518
|
+
ingest,
|
|
519
|
+
});
|
|
520
|
+
expect(ingest).not.toHaveBeenCalled();
|
|
270
521
|
});
|
|
271
522
|
|
|
272
523
|
it("ingest receives mediaItems = [] when body has only text", async () => {
|