@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.
Files changed (85) hide show
  1. package/INSTALL.md +64 -0
  2. package/README.md +121 -19
  3. package/dist/index.js +10 -19
  4. package/dist/setup-entry.js +3 -0
  5. package/dist/src/api-client.js +78 -10
  6. package/dist/src/api-types.test-d.js +10 -0
  7. package/dist/src/channel.js +25 -156
  8. package/dist/src/channel.setup.js +120 -0
  9. package/dist/src/client.js +37 -41
  10. package/dist/src/config.js +75 -17
  11. package/dist/src/inbound.js +79 -61
  12. package/dist/src/login.runtime.js +84 -19
  13. package/dist/src/media-runtime.js +8 -8
  14. package/dist/src/message-mapper.js +1 -1
  15. package/dist/src/mock-transport.js +31 -0
  16. package/dist/src/outbound.js +410 -26
  17. package/dist/src/protocol-types.js +63 -0
  18. package/dist/src/protocol-types.typecheck.js +1 -0
  19. package/dist/src/protocol.js +2 -7
  20. package/dist/src/reply-dispatcher.js +157 -54
  21. package/dist/src/runtime.js +795 -119
  22. package/dist/src/storage.js +689 -0
  23. package/dist/src/tools-schema.js +98 -16
  24. package/dist/src/tools.js +422 -135
  25. package/dist/src/ws-alignment.js +178 -0
  26. package/dist/src/ws-client.js +588 -0
  27. package/dist/src/ws-log.js +19 -0
  28. package/index.ts +10 -22
  29. package/openclaw.plugin.json +37 -2
  30. package/package.json +17 -4
  31. package/setup-entry.ts +4 -0
  32. package/skills/clawchat/SKILL.md +88 -0
  33. package/src/api-client.test.ts +274 -14
  34. package/src/api-client.ts +138 -23
  35. package/src/api-types.test-d.ts +12 -0
  36. package/src/api-types.ts +90 -4
  37. package/src/buffered-stream.test.ts +14 -12
  38. package/src/buffered-stream.ts +1 -1
  39. package/src/channel.outbound.test.ts +269 -60
  40. package/src/channel.setup.ts +146 -0
  41. package/src/channel.test.ts +130 -24
  42. package/src/channel.ts +30 -186
  43. package/src/client.test.ts +197 -11
  44. package/src/client.ts +50 -57
  45. package/src/config.test.ts +108 -6
  46. package/src/config.ts +95 -24
  47. package/src/inbound.test.ts +288 -37
  48. package/src/inbound.ts +96 -84
  49. package/src/login.runtime.test.ts +347 -13
  50. package/src/login.runtime.ts +105 -23
  51. package/src/manifest.test.ts +146 -74
  52. package/src/media-runtime.test.ts +57 -2
  53. package/src/media-runtime.ts +26 -17
  54. package/src/message-mapper.test.ts +2 -2
  55. package/src/message-mapper.ts +2 -2
  56. package/src/mock-transport.test.ts +35 -0
  57. package/src/mock-transport.ts +38 -0
  58. package/src/outbound.test.ts +694 -73
  59. package/src/outbound.ts +484 -31
  60. package/src/plugin-entry.test.ts +1 -0
  61. package/src/protocol-types.test.ts +69 -0
  62. package/src/protocol-types.ts +296 -0
  63. package/src/protocol-types.typecheck.ts +89 -0
  64. package/src/protocol.test.ts +1 -6
  65. package/src/protocol.ts +2 -7
  66. package/src/reply-dispatcher.test.ts +819 -119
  67. package/src/reply-dispatcher.ts +202 -60
  68. package/src/runtime.test.ts +2120 -41
  69. package/src/runtime.ts +935 -142
  70. package/src/scripts.test.ts +85 -0
  71. package/src/storage.test.ts +793 -0
  72. package/src/storage.ts +1095 -0
  73. package/src/streaming.test.ts +9 -8
  74. package/src/streaming.ts +1 -1
  75. package/src/tools-schema.ts +148 -20
  76. package/src/tools.test.ts +377 -50
  77. package/src/tools.ts +574 -154
  78. package/src/ws-alignment.test.ts +103 -0
  79. package/src/ws-alignment.ts +275 -0
  80. package/src/ws-client.test.ts +1218 -0
  81. package/src/ws-client.ts +662 -0
  82. package/src/ws-log.test.ts +32 -0
  83. package/src/ws-log.ts +31 -0
  84. package/skills/clawchat-account-tools/SKILL.md +0 -26
  85. 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 = "http://company.newbaselab.com:10086" as const;
18
- export const DEFAULT_WEBSOCKET_URL = "ws://company.newbaselab.com:10086/ws" as const;
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
- * - "mention" (default): only trigger a reply when the inbound `context.mentions`
25
- * list contains our `userId` (i.e. the sender @-mentioned us).
26
- * - "all": trigger on every group message regardless of mentions (open listen).
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 hasOpenclawClawchatToolAllow(cfg: OpenClawConfig): boolean {
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 alreadyAllowed = [...currentAllow, ...currentAlsoAllow].some(isAlreadyCovered);
188
- if (currentAllow.length > 0) {
195
+ const alreadyCovered = [...currentAllow, ...currentAlsoAllow].some(isAlreadyCovered);
196
+ if (alreadyCovered) {
189
197
  return {
190
198
  ...cfg,
191
199
  tools: {
192
200
  ...currentTools,
193
- allow: alreadyAllowed ? currentAllow : [...currentAllow, entry],
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: alreadyAlsoAllowed ? currentAlsoAllow : [...currentAlsoAllow, entry],
210
+ alsoAllow: [...currentAlsoAllow, entry],
203
211
  },
204
212
  } as OpenClawConfig;
205
213
  }
206
214
 
207
215
  export function mergeOpenclawClawchatToolAllow(cfg: OpenClawConfig): OpenClawConfig {
208
- return mergeToolPolicyEntryAllow(cfg, CHANNEL_ID, isOpenclawClawchatToolAllowEntry);
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 === "all" ? "all" : "mention";
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,
@@ -1,8 +1,8 @@
1
- import type { Envelope, DownlinkMessageSendPayload } from "@newbase-clawchat/sdk";
1
+ import type { Envelope, MessagePayload } from "./protocol-types.ts";
2
2
  import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
3
- import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { describe, expect, it, vi } from "vitest";
4
4
  import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
5
- import { dispatchOpenclawClawlingInbound, _resetDedupForTest } from "./inbound.ts";
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
- senderType: "direct" | "group";
41
- mentions: string[];
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<DownlinkMessageSendPayload> {
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: overrides.chatId,
53
- to: { id: "agent-1", type: overrides.senderType ?? "direct" },
59
+ ...(overrides.omitChatId ? {} : { chat_id: chatId }),
60
+ chat_type: chatType,
61
+ to: { id: chatId, type: chatType },
54
62
  sender: {
55
- sender_id: "user-1",
56
- type: overrides.senderType ?? "direct",
57
- display_name: "User One",
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
- sender: {
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<DownlinkMessageSendPayload>;
87
+ } as Envelope<MessagePayload>;
85
88
  }
86
89
 
87
- describe("openclaw-clawchat inbound", () => {
88
- beforeEach(() => {
89
- _resetDedupForTest();
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: "user-1" });
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({ senderType: "direct" }),
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
- senderType: "group",
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
- senderType: "group",
328
+ chatType: "group",
139
329
  userId: "agent-1",
140
330
  }),
141
331
  ).toBe(false);
142
332
  });
143
333
 
144
- it("skips group messages entirely", async () => {
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({ senderType: "group" }),
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
- sender_id: "user-2",
174
- display_name: "User Two",
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("deduplicates repeat message_ids", async () => {
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(1);
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 (SDK 0.2.0 typed union accepts these directly)
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 when sender is only on envelope.sender (real wire, no message.sender)", async () => {
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: "user-1" });
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 () => {