@newbase-clawchat/openclaw-clawchat 2026.5.12-2 → 2026.5.12-21

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 (99) hide show
  1. package/README.md +39 -17
  2. package/dist/index.js +3 -1
  3. package/dist/src/api-client.js +71 -12
  4. package/dist/src/api-types.test-d.js +10 -0
  5. package/dist/src/channel.js +5 -5
  6. package/dist/src/channel.setup.js +4 -17
  7. package/dist/src/clawchat-memory.js +290 -0
  8. package/dist/src/clawchat-metadata.js +235 -0
  9. package/dist/src/client.js +31 -93
  10. package/dist/src/commands.js +3 -3
  11. package/dist/src/config.js +58 -3
  12. package/dist/src/group-message-coalescer.js +107 -0
  13. package/dist/src/inbound.js +24 -28
  14. package/dist/src/login.runtime.js +82 -19
  15. package/dist/src/media-runtime.js +2 -3
  16. package/dist/src/message-mapper.js +1 -1
  17. package/dist/src/mock-transport.js +31 -0
  18. package/dist/src/outbound.js +281 -56
  19. package/dist/src/plugin-prompts.js +76 -0
  20. package/dist/src/profile-prompt.js +150 -0
  21. package/dist/src/profile-sync.js +169 -0
  22. package/dist/src/prompt-injection.js +25 -0
  23. package/dist/src/protocol-types.js +63 -0
  24. package/dist/src/protocol-types.typecheck.js +1 -0
  25. package/dist/src/protocol.js +2 -2
  26. package/dist/src/reply-dispatcher.js +143 -40
  27. package/dist/src/runtime.js +813 -109
  28. package/dist/src/storage.js +636 -0
  29. package/dist/src/tools-schema.js +70 -10
  30. package/dist/src/tools.js +600 -112
  31. package/dist/src/ws-alignment.js +8 -0
  32. package/dist/src/ws-client.js +588 -0
  33. package/index.ts +6 -1
  34. package/openclaw.plugin.json +44 -4
  35. package/package.json +4 -3
  36. package/prompts/platform.md +7 -0
  37. package/skills/clawchat/SKILL.md +90 -0
  38. package/src/api-client.test.ts +360 -15
  39. package/src/api-client.ts +127 -25
  40. package/src/api-types.test-d.ts +12 -0
  41. package/src/api-types.ts +71 -4
  42. package/src/buffered-stream.test.ts +1 -1
  43. package/src/buffered-stream.ts +1 -1
  44. package/src/channel.outbound.test.ts +270 -60
  45. package/src/channel.setup.ts +9 -18
  46. package/src/channel.test.ts +33 -25
  47. package/src/channel.ts +5 -7
  48. package/src/clawchat-memory.test.ts +372 -0
  49. package/src/clawchat-memory.ts +363 -0
  50. package/src/clawchat-metadata.test.ts +350 -0
  51. package/src/clawchat-metadata.ts +352 -0
  52. package/src/client.test.ts +57 -48
  53. package/src/client.ts +37 -129
  54. package/src/commands.test.ts +2 -2
  55. package/src/commands.ts +3 -3
  56. package/src/config.test.ts +169 -4
  57. package/src/config.ts +86 -6
  58. package/src/group-message-coalescer.test.ts +223 -0
  59. package/src/group-message-coalescer.ts +154 -0
  60. package/src/inbound.test.ts +106 -19
  61. package/src/inbound.ts +31 -35
  62. package/src/login.runtime.test.ts +294 -11
  63. package/src/login.runtime.ts +90 -21
  64. package/src/manifest.test.ts +86 -14
  65. package/src/media-runtime.test.ts +31 -2
  66. package/src/media-runtime.ts +7 -10
  67. package/src/message-mapper.test.ts +2 -2
  68. package/src/message-mapper.ts +2 -2
  69. package/src/mock-transport.test.ts +35 -0
  70. package/src/mock-transport.ts +38 -0
  71. package/src/outbound.test.ts +811 -95
  72. package/src/outbound.ts +332 -65
  73. package/src/plugin-entry.test.ts +3 -1
  74. package/src/plugin-prompts.test.ts +78 -0
  75. package/src/plugin-prompts.ts +92 -0
  76. package/src/profile-prompt.test.ts +435 -0
  77. package/src/profile-prompt.ts +208 -0
  78. package/src/profile-sync.test.ts +611 -0
  79. package/src/profile-sync.ts +268 -0
  80. package/src/prompt-injection.test.ts +39 -0
  81. package/src/prompt-injection.ts +45 -0
  82. package/src/protocol-types.test.ts +69 -0
  83. package/src/protocol-types.ts +296 -0
  84. package/src/protocol-types.typecheck.ts +89 -0
  85. package/src/protocol.ts +2 -2
  86. package/src/reply-dispatcher.test.ts +720 -135
  87. package/src/reply-dispatcher.ts +174 -42
  88. package/src/runtime.test.ts +3884 -337
  89. package/src/runtime.ts +956 -128
  90. package/src/storage.test.ts +692 -0
  91. package/src/storage.ts +989 -0
  92. package/src/streaming.test.ts +1 -1
  93. package/src/streaming.ts +1 -1
  94. package/src/tools-schema.ts +115 -13
  95. package/src/tools.test.ts +501 -10
  96. package/src/tools.ts +739 -133
  97. package/src/ws-alignment.ts +9 -0
  98. package/src/ws-client.test.ts +1218 -0
  99. package/src/ws-client.ts +662 -0
@@ -5,6 +5,8 @@ import {
5
5
  DEFAULT_BASE_URL,
6
6
  DEFAULT_WEBSOCKET_URL,
7
7
  DEFAULT_STREAM,
8
+ effectiveGroupCommandMode,
9
+ effectiveGroupMode,
8
10
  mergeOpenclawClawchatToolAllow,
9
11
  resolveOpenclawClawlingAccount,
10
12
  listOpenclawClawlingAccountIds,
@@ -20,7 +22,9 @@ describe("openclaw-clawchat config", () => {
20
22
  expect(account.accountId).toBe(DEFAULT_ACCOUNT_ID);
21
23
  expect(account.enabled).toBe(true);
22
24
  expect(account.configured).toBe(false);
23
- expect(account.replyMode).toBe("static");
25
+ expect(account.replyMode).toBe("stream");
26
+ expect(account.groupMode).toBe("all");
27
+ expect(account.groupCommandMode).toBe("owner");
24
28
  expect(account.forwardThinking).toBe(true);
25
29
  expect(account.forwardToolCalls).toBe(false);
26
30
  expect(account.richInteractions).toBe(false);
@@ -33,7 +37,9 @@ describe("openclaw-clawchat config", () => {
33
37
  "openclaw-clawchat": {
34
38
  websocketUrl: "wss://chat.example.com/ws",
35
39
  token: "secret",
40
+ agentId: "agt-1",
36
41
  userId: "agent-1",
42
+ ownerUserId: "owner-1",
37
43
  replyMode: "stream",
38
44
  forwardThinking: false,
39
45
  forwardToolCalls: true,
@@ -46,7 +52,9 @@ describe("openclaw-clawchat config", () => {
46
52
  expect(account.configured).toBe(true);
47
53
  expect(account.websocketUrl).toBe("wss://chat.example.com/ws");
48
54
  expect(account.token).toBe("secret");
55
+ expect(account.agentId).toBe("agt-1");
49
56
  expect(account.userId).toBe("agent-1");
57
+ expect(account.ownerUserId).toBe("owner-1");
50
58
  expect(account.replyMode).toBe("stream");
51
59
  expect(account.forwardThinking).toBe(false);
52
60
  expect(account.forwardToolCalls).toBe(true);
@@ -56,12 +64,29 @@ describe("openclaw-clawchat config", () => {
56
64
  expect(account.stream.maxBufferChars).toBe(3000);
57
65
  });
58
66
 
67
+ it("does not use userId as an ownerUserId fallback", () => {
68
+ const account = resolveOpenclawClawlingAccount({
69
+ channels: {
70
+ "openclaw-clawchat": {
71
+ token: "secret",
72
+ userId: "agent-user-id",
73
+ },
74
+ },
75
+ });
76
+
77
+ expect(account.userId).toBe("agent-user-id");
78
+ expect(account.ownerUserId).not.toBe("agent-user-id");
79
+ expect(account.configured).toBe(false);
80
+ });
81
+
59
82
  it("resolves credentials from ClawChat environment variables when config omits them", () => {
60
83
  const account = resolveOpenclawClawlingAccount(
61
84
  {},
62
85
  {
63
86
  CLAWCHAT_TOKEN: "env-token",
87
+ CLAWCHAT_AGENT_ID: "env-agent",
64
88
  CLAWCHAT_USER_ID: "env-user",
89
+ CLAWCHAT_OWNER_USER_ID: "env-owner",
65
90
  CLAWCHAT_BASE_URL: "https://api.env.example",
66
91
  CLAWCHAT_WEBSOCKET_URL: "wss://ws.env.example/ws",
67
92
  },
@@ -69,7 +94,9 @@ describe("openclaw-clawchat config", () => {
69
94
 
70
95
  expect(account.configured).toBe(true);
71
96
  expect(account.token).toBe("env-token");
97
+ expect(account.agentId).toBe("env-agent");
72
98
  expect(account.userId).toBe("env-user");
99
+ expect(account.ownerUserId).toBe("env-owner");
73
100
  expect(account.baseUrl).toBe("https://api.env.example");
74
101
  expect(account.websocketUrl).toBe("wss://ws.env.example/ws");
75
102
  });
@@ -80,7 +107,9 @@ describe("openclaw-clawchat config", () => {
80
107
  channels: {
81
108
  "openclaw-clawchat": {
82
109
  token: "config-token",
110
+ agentId: "config-agent",
83
111
  userId: "config-user",
112
+ ownerUserId: "config-owner",
84
113
  baseUrl: "https://api.config.example",
85
114
  websocketUrl: "wss://ws.config.example/ws",
86
115
  },
@@ -88,26 +117,160 @@ describe("openclaw-clawchat config", () => {
88
117
  },
89
118
  {
90
119
  CLAWCHAT_TOKEN: "env-token",
120
+ CLAWCHAT_AGENT_ID: "env-agent",
91
121
  CLAWCHAT_USER_ID: "env-user",
122
+ CLAWCHAT_OWNER_USER_ID: "env-owner",
92
123
  CLAWCHAT_BASE_URL: "https://api.env.example",
93
124
  CLAWCHAT_WEBSOCKET_URL: "wss://ws.env.example/ws",
94
125
  },
95
126
  );
96
127
 
97
128
  expect(account.token).toBe("config-token");
129
+ expect(account.agentId).toBe("config-agent");
98
130
  expect(account.userId).toBe("config-user");
131
+ expect(account.ownerUserId).toBe("config-owner");
99
132
  expect(account.baseUrl).toBe("https://api.config.example");
100
133
  expect(account.websocketUrl).toBe("wss://ws.config.example/ws");
101
134
  });
102
135
 
103
- it("falls back to static replyMode for unknown values", () => {
136
+ it("falls back to stream replyMode for unknown values", () => {
104
137
  const cfg = {
105
138
  channels: {
106
- "openclaw-clawchat": { websocketUrl: "w", token: "t", userId: "a", replyMode: "weird" },
139
+ "openclaw-clawchat": {
140
+ websocketUrl: "w",
141
+ token: "t",
142
+ userId: "a",
143
+ ownerUserId: "o",
144
+ replyMode: "weird",
145
+ },
107
146
  },
108
147
  };
109
148
  const account = resolveOpenclawClawlingAccount(cfg);
110
- expect(account.replyMode).toBe("static");
149
+ expect(account.replyMode).toBe("stream");
150
+ });
151
+
152
+ it("preserves explicit mention groupMode", () => {
153
+ const cfg = {
154
+ channels: {
155
+ "openclaw-clawchat": {
156
+ websocketUrl: "w",
157
+ token: "t",
158
+ userId: "a",
159
+ ownerUserId: "o",
160
+ groupMode: "mention",
161
+ },
162
+ },
163
+ };
164
+ const account = resolveOpenclawClawlingAccount(cfg);
165
+ expect(account.groupMode).toBe("mention");
166
+ });
167
+
168
+ it("falls back to all groupMode for unknown values", () => {
169
+ const cfg = {
170
+ channels: {
171
+ "openclaw-clawchat": {
172
+ websocketUrl: "w",
173
+ token: "t",
174
+ userId: "a",
175
+ ownerUserId: "o",
176
+ groupMode: "weird",
177
+ },
178
+ },
179
+ };
180
+ const account = resolveOpenclawClawlingAccount(cfg);
181
+ expect(account.groupMode).toBe("all");
182
+ });
183
+
184
+ it("resolves per-group groupMode overrides", () => {
185
+ const account = resolveOpenclawClawlingAccount({
186
+ channels: {
187
+ "openclaw-clawchat": {
188
+ groups: {
189
+ "group-1": { groupMode: "mention" },
190
+ "*": { groupMode: "all" },
191
+ },
192
+ },
193
+ },
194
+ });
195
+
196
+ expect(account.groups).toEqual({
197
+ "group-1": { groupMode: "mention", groupCommandMode: "owner" },
198
+ "*": { groupMode: "all", groupCommandMode: "owner" },
199
+ });
200
+ expect(effectiveGroupMode(account, "group-1")).toBe("mention");
201
+ expect(effectiveGroupMode(account, "group-2")).toBe("all");
202
+ });
203
+
204
+ it("resolves groupCommandMode and per-group overrides", () => {
205
+ const account = resolveOpenclawClawlingAccount({
206
+ channels: {
207
+ "openclaw-clawchat": {
208
+ groupCommandMode: "off",
209
+ groups: {
210
+ "group-open": { groupCommandMode: "all" },
211
+ "*": { groupCommandMode: "owner" },
212
+ },
213
+ },
214
+ },
215
+ });
216
+
217
+ expect(account.groupCommandMode).toBe("off");
218
+ expect(account.groups).toEqual({
219
+ "group-open": { groupMode: "all", groupCommandMode: "all" },
220
+ "*": { groupMode: "all", groupCommandMode: "owner" },
221
+ });
222
+ expect(effectiveGroupCommandMode(account, "group-open")).toBe("all");
223
+ expect(effectiveGroupCommandMode(account, "group-other")).toBe("owner");
224
+ });
225
+
226
+ it("normalizes invalid groupCommandMode values to owner", () => {
227
+ const account = resolveOpenclawClawlingAccount({
228
+ channels: {
229
+ "openclaw-clawchat": {
230
+ groupCommandMode: "weird",
231
+ groups: {
232
+ "group-weird": { groupCommandMode: "weird" },
233
+ },
234
+ },
235
+ },
236
+ });
237
+
238
+ expect(account.groupCommandMode).toBe("owner");
239
+ expect(account.groups["group-weird"]?.groupCommandMode).toBe("owner");
240
+ expect(effectiveGroupCommandMode(account, "group-weird")).toBe("owner");
241
+ });
242
+
243
+ it("lets exact per-group groupMode win over wildcard and channel defaults", () => {
244
+ const account = resolveOpenclawClawlingAccount({
245
+ channels: {
246
+ "openclaw-clawchat": {
247
+ groupMode: "mention",
248
+ groups: {
249
+ "group-open": { groupMode: "all" },
250
+ "*": { groupMode: "mention" },
251
+ },
252
+ },
253
+ },
254
+ });
255
+
256
+ expect(effectiveGroupMode(account, "group-open")).toBe("all");
257
+ expect(effectiveGroupMode(account, "group-other")).toBe("mention");
258
+ });
259
+
260
+ it("normalizes invalid per-group groupMode values to all", () => {
261
+ const account = resolveOpenclawClawlingAccount({
262
+ channels: {
263
+ "openclaw-clawchat": {
264
+ groupMode: "mention",
265
+ groups: {
266
+ "group-weird": { groupMode: "weird" },
267
+ },
268
+ },
269
+ },
270
+ });
271
+
272
+ expect(account.groups["group-weird"]?.groupMode).toBe("all");
273
+ expect(effectiveGroupMode(account, "group-weird")).toBe("all");
111
274
  });
112
275
 
113
276
  it("lists the default account id", () => {
@@ -121,6 +284,7 @@ describe("openclaw-clawchat config", () => {
121
284
  websocketUrl: "wss://w",
122
285
  token: "t",
123
286
  userId: "u",
287
+ ownerUserId: "o",
124
288
  baseUrl: "https://api.example.com",
125
289
  },
126
290
  },
@@ -151,6 +315,7 @@ describe("openclaw-clawchat config", () => {
151
315
  websocketUrl: "wss://w",
152
316
  token: "t",
153
317
  userId: "u",
318
+ ownerUserId: "o",
154
319
  // no baseUrl — resolver uses default
155
320
  },
156
321
  },
package/src/config.ts CHANGED
@@ -3,7 +3,9 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
3
3
 
4
4
  export const CHANNEL_ID = "openclaw-clawchat" as const;
5
5
  export const CLAWCHAT_TOKEN_ENV = "CLAWCHAT_TOKEN" as const;
6
+ export const CLAWCHAT_AGENT_ID_ENV = "CLAWCHAT_AGENT_ID" as const;
6
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;
7
9
  export const CLAWCHAT_REFRESH_TOKEN_ENV = "CLAWCHAT_REFRESH_TOKEN" as const;
8
10
  export const CLAWCHAT_BASE_URL_ENV = "CLAWCHAT_BASE_URL" as const;
9
11
  export const CLAWCHAT_WEBSOCKET_URL_ENV = "CLAWCHAT_WEBSOCKET_URL" as const;
@@ -21,11 +23,12 @@ export type ReplyMode = "static" | "stream";
21
23
 
22
24
  /**
23
25
  * 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).
26
+ * - "all" (default): trigger on every group message regardless of mentions (open listen).
27
+ * - "mention": only trigger a reply when the inbound `context.mentions`
28
+ * list includes our `userId` (i.e. the sender @-mentioned us).
27
29
  */
28
30
  export type GroupMode = "mention" | "all";
31
+ export type GroupCommandMode = "owner" | "all" | "off";
29
32
 
30
33
  export const DEFAULT_STREAM = {
31
34
  flushIntervalMs: 250,
@@ -66,6 +69,11 @@ export type OpenclawClawlingStreamConfig = {
66
69
  maxBufferChars?: number;
67
70
  };
68
71
 
72
+ export type OpenclawClawlingGroupConfig = {
73
+ groupMode: GroupMode;
74
+ groupCommandMode: GroupCommandMode;
75
+ };
76
+
69
77
  export type OpenclawClawlingReconnectConfig = {
70
78
  initialDelay?: number;
71
79
  maxDelay?: number;
@@ -91,9 +99,14 @@ export type OpenclawClawlingConfig = {
91
99
  token?: string;
92
100
  /** Refresh token persisted by ClawChat activation/login (paired with `token`). */
93
101
  refreshToken?: string;
102
+ /** Server agent id used with `/v1/agents/{id}`. */
103
+ agentId?: string;
94
104
  userId?: string;
105
+ ownerUserId?: string;
95
106
  replyMode?: ReplyMode;
96
107
  groupMode?: GroupMode;
108
+ groupCommandMode?: GroupCommandMode;
109
+ groups?: Record<string, Partial<OpenclawClawlingGroupConfig>>;
97
110
  forwardThinking?: boolean;
98
111
  forwardToolCalls?: boolean;
99
112
  /** Emit approval/action rich fragments instead of plain fallback text. */
@@ -113,9 +126,23 @@ export const openclawClawlingConfigSchema = {
113
126
  baseUrl: { type: "string" },
114
127
  token: { type: "string" },
115
128
  refreshToken: { type: "string" },
129
+ agentId: { type: "string" },
116
130
  userId: { type: "string" },
131
+ ownerUserId: { type: "string" },
117
132
  replyMode: { type: "string", enum: ["static", "stream"] },
118
133
  groupMode: { type: "string", enum: ["mention", "all"] },
134
+ groupCommandMode: { type: "string", enum: ["owner", "all", "off"] },
135
+ groups: {
136
+ type: "object",
137
+ additionalProperties: {
138
+ type: "object",
139
+ additionalProperties: false,
140
+ properties: {
141
+ groupMode: { type: "string", enum: ["mention", "all"] },
142
+ groupCommandMode: { type: "string", enum: ["owner", "all", "off"] },
143
+ },
144
+ },
145
+ },
119
146
  forwardThinking: { type: "boolean" },
120
147
  forwardToolCalls: { type: "boolean" },
121
148
  richInteractions: { type: "boolean" },
@@ -238,9 +265,13 @@ export type ResolvedOpenclawClawlingAccount = {
238
265
  websocketUrl: string;
239
266
  baseUrl: string;
240
267
  token: string;
268
+ agentId: string;
241
269
  userId: string;
270
+ ownerUserId: string;
242
271
  replyMode: ReplyMode;
243
272
  groupMode: GroupMode;
273
+ groupCommandMode: GroupCommandMode;
274
+ groups: Record<string, OpenclawClawlingGroupConfig>;
244
275
  forwardThinking: boolean;
245
276
  forwardToolCalls: boolean;
246
277
  richInteractions: boolean;
@@ -266,11 +297,51 @@ function readEnvString(env: Record<string, string | undefined>, key: string): st
266
297
  }
267
298
 
268
299
  function readReplyMode(value: unknown): ReplyMode {
269
- return value === "stream" ? "stream" : "static";
300
+ return value === "static" ? "static" : "stream";
270
301
  }
271
302
 
272
303
  function readGroupMode(value: unknown): GroupMode {
273
- return value === "all" ? "all" : "mention";
304
+ return value === "mention" ? "mention" : "all";
305
+ }
306
+
307
+ function readGroupCommandMode(value: unknown): GroupCommandMode {
308
+ return value === "all" || value === "off" ? value : "owner";
309
+ }
310
+
311
+ function readGroups(value: unknown): Record<string, OpenclawClawlingGroupConfig> {
312
+ const rawGroups = value && typeof value === "object" && !Array.isArray(value)
313
+ ? (value as Record<string, unknown>)
314
+ : {};
315
+ const groups: Record<string, OpenclawClawlingGroupConfig> = {};
316
+ for (const [chatId, rawGroup] of Object.entries(rawGroups)) {
317
+ if (!chatId) continue;
318
+ const group = rawGroup && typeof rawGroup === "object" && !Array.isArray(rawGroup)
319
+ ? (rawGroup as Record<string, unknown>)
320
+ : {};
321
+ groups[chatId] = {
322
+ groupMode: readGroupMode(group.groupMode),
323
+ groupCommandMode: readGroupCommandMode(group.groupCommandMode),
324
+ };
325
+ }
326
+ return groups;
327
+ }
328
+
329
+ export function effectiveGroupMode(
330
+ account: Pick<ResolvedOpenclawClawlingAccount, "groupMode" | "groups">,
331
+ chatId: string,
332
+ ): GroupMode {
333
+ return account.groups[chatId]?.groupMode
334
+ ?? account.groups["*"]?.groupMode
335
+ ?? account.groupMode;
336
+ }
337
+
338
+ export function effectiveGroupCommandMode(
339
+ account: Pick<ResolvedOpenclawClawlingAccount, "groupCommandMode" | "groups">,
340
+ chatId: string,
341
+ ): GroupCommandMode {
342
+ return account.groups[chatId]?.groupCommandMode
343
+ ?? account.groups["*"]?.groupCommandMode
344
+ ?? account.groupCommandMode;
274
345
  }
275
346
 
276
347
  function readStream(raw: unknown): Required<OpenclawClawlingStreamConfig> {
@@ -333,10 +404,15 @@ export function resolveOpenclawClawlingAccount(
333
404
  readEnvString(env, CLAWCHAT_BASE_URL_ENV) ||
334
405
  DEFAULT_BASE_URL;
335
406
  const token = readOptionalString(channel.token) || readEnvString(env, CLAWCHAT_TOKEN_ENV);
407
+ const agentId = readOptionalString(channel.agentId) || readEnvString(env, CLAWCHAT_AGENT_ID_ENV);
336
408
  const userId = readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV);
409
+ const ownerUserId =
410
+ readOptionalString(channel.ownerUserId) || readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV);
337
411
  const enabled = typeof channel.enabled === "boolean" ? channel.enabled : true;
338
412
  const replyMode = readReplyMode(channel.replyMode);
339
413
  const groupMode = readGroupMode(channel.groupMode);
414
+ const groupCommandMode = readGroupCommandMode(channel.groupCommandMode);
415
+ const groups = readGroups(channel.groups);
340
416
  const forwardThinking =
341
417
  typeof channel.forwardThinking === "boolean" ? channel.forwardThinking : true;
342
418
  const forwardToolCalls =
@@ -348,13 +424,17 @@ export function resolveOpenclawClawlingAccount(
348
424
  accountId: DEFAULT_ACCOUNT_ID,
349
425
  name: CHANNEL_ID,
350
426
  enabled,
351
- configured: Boolean(websocketUrl && token && userId),
427
+ configured: Boolean(websocketUrl && token && userId && ownerUserId),
352
428
  websocketUrl,
353
429
  baseUrl,
354
430
  token,
431
+ agentId,
355
432
  userId,
433
+ ownerUserId,
356
434
  replyMode,
357
435
  groupMode,
436
+ groupCommandMode,
437
+ groups,
358
438
  forwardThinking,
359
439
  forwardToolCalls,
360
440
  richInteractions,
@@ -0,0 +1,223 @@
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_owner: false\nmentions_current_agent: false\nmentioned_user_ids: -\ntext:\none");
59
+ expect(dispatched[0]?.rawBody).toContain("[message]\nsender_id: user-b\nsender_name: Bob\nsender_profile_type: user\nsender_is_owner: false\nmentions_current_agent: false\nmentioned_user_ids: -\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_owner: false\nmentions_current_agent: false\nmentioned_user_ids: -\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_owner: false\nmentions_current_agent: false\nmentioned_user_ids: other-user\ntext:\none");
138
+ expect(dispatched[0]?.rawBody).toContain("[message]\nsender_id: user-b\nsender_name: Bob\nsender_profile_type: user\nsender_is_owner: false\nmentions_current_agent: false\nmentioned_user_ids: -\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_owner: false",
206
+ "mentions_current_agent: false",
207
+ "mentioned_user_ids: -",
208
+ "text:",
209
+ "first",
210
+ "",
211
+ "[message]",
212
+ "sender_id: u2",
213
+ "sender_name: Ben",
214
+ "sender_profile_type: user",
215
+ "sender_is_owner: true",
216
+ "mentions_current_agent: false",
217
+ "mentioned_user_ids: -",
218
+ "text:",
219
+ "second",
220
+ ].join("\n"),
221
+ );
222
+ });
223
+ });