@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
@@ -0,0 +1,120 @@
1
+ import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
2
+ import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation";
3
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
4
+ import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers";
5
+ import { CHANNEL_ID, listOpenclawClawlingAccountIds, openclawClawlingConfigSchema, resolveOpenclawClawlingAccount, } from "./config.js";
6
+ const configAdapter = createTopLevelChannelConfigAdapter({
7
+ sectionKey: CHANNEL_ID,
8
+ resolveAccount: (cfg) => resolveOpenclawClawlingAccount(cfg),
9
+ listAccountIds: () => listOpenclawClawlingAccountIds(),
10
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
11
+ deleteMode: "clear-fields",
12
+ clearBaseFields: [
13
+ "websocketUrl",
14
+ "baseUrl",
15
+ "token",
16
+ "userId",
17
+ "replyMode",
18
+ "forwardThinking",
19
+ "forwardToolCalls",
20
+ "richInteractions",
21
+ "enabled",
22
+ ],
23
+ resolveAllowFrom: (account) => account.allowFrom,
24
+ formatAllowFrom: () => [],
25
+ });
26
+ /**
27
+ * Invite-code setup adapter used by OpenClaw setup surfaces.
28
+ *
29
+ * `channels add --token` passes the invite code as setup input. The setup
30
+ * write leaves channel config unchanged; `afterAccountConfigWritten` exchanges
31
+ * the invite code and persists token/userId through the host runtime mutator.
32
+ */
33
+ const setupAdapter = {
34
+ resolveAccountId: () => DEFAULT_ACCOUNT_ID,
35
+ validateInput: ({ input }) => {
36
+ const inviteCode = typeof input.code === "string" && input.code.trim()
37
+ ? input.code.trim()
38
+ : typeof input.token === "string"
39
+ ? input.token.trim()
40
+ : "";
41
+ if (!inviteCode) {
42
+ return "ClawChat invite code is required.";
43
+ }
44
+ return null;
45
+ },
46
+ applyAccountConfig: ({ cfg }) => cfg,
47
+ afterAccountConfigWritten: async ({ cfg, input, runtime }) => {
48
+ runtime.log("[default] openclaw-clawchat setup afterAccountConfigWritten invoked");
49
+ const code = typeof input.code === "string" && input.code.trim()
50
+ ? input.code.trim()
51
+ : typeof input.token === "string"
52
+ ? input.token.trim()
53
+ : "";
54
+ if (!code) {
55
+ runtime.log("[default] openclaw-clawchat setup afterAccountConfigWritten skipped: empty invite code");
56
+ return;
57
+ }
58
+ const { runOpenclawClawlingLogin } = await import("./login.runtime.js");
59
+ await runOpenclawClawlingLogin({
60
+ cfg,
61
+ accountId: null,
62
+ runtime: { log: (message) => runtime.log(message) },
63
+ readInviteCode: async () => code,
64
+ mutateConfigFile: mutateConfigFile,
65
+ });
66
+ runtime.log("[default] openclaw-clawchat setup afterAccountConfigWritten completed");
67
+ },
68
+ };
69
+ export const openclawClawlingSetupPlugin = {
70
+ id: CHANNEL_ID,
71
+ meta: {
72
+ id: CHANNEL_ID,
73
+ label: "Clawling Chat",
74
+ selectionLabel: "Clawling Chat",
75
+ docsPath: "/channels/openclaw-clawchat",
76
+ docsLabel: "openclaw-clawchat",
77
+ blurb: "ClawChat Protocol v2 over WebSocket.",
78
+ order: 110,
79
+ },
80
+ capabilities: {
81
+ chatTypes: ["direct", "group"],
82
+ media: true,
83
+ reactions: false,
84
+ threads: false,
85
+ polls: false,
86
+ blockStreaming: true,
87
+ },
88
+ configSchema: {
89
+ schema: openclawClawlingConfigSchema,
90
+ },
91
+ config: {
92
+ ...configAdapter,
93
+ isConfigured: (account) => account.configured,
94
+ describeAccount: (account) => ({
95
+ accountId: account.accountId,
96
+ name: account.name,
97
+ enabled: account.enabled,
98
+ configured: account.configured,
99
+ }),
100
+ },
101
+ setup: setupAdapter,
102
+ status: createComputedAccountStatusAdapter({
103
+ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, {
104
+ connected: false,
105
+ lastInboundAt: null,
106
+ lastOutboundAt: null,
107
+ }),
108
+ resolveAccountSnapshot: ({ account }) => ({
109
+ accountId: account.accountId,
110
+ name: account.name,
111
+ enabled: account.enabled,
112
+ configured: account.configured,
113
+ extra: {
114
+ websocketUrl: account.websocketUrl || null,
115
+ baseUrl: account.baseUrl || null,
116
+ userId: account.userId || null,
117
+ },
118
+ }),
119
+ }),
120
+ };
@@ -1,20 +1,17 @@
1
- import { createWSClient, } from "@newbase-clawchat/sdk";
1
+ import { createClawChatClient } from "./ws-client.js";
2
2
  export function createOpenclawClawlingClient(account, overrides = {}) {
3
- // Only forward a finite `maxRetries` to the SDK — the SDK's own default
4
- // is already unbounded, so omitting the field keeps that behavior. This
5
- // avoids forcing the SDK to special-case `Infinity`.
6
- const maxRetries = account.reconnect.maxRetries;
7
- const reconnect = {
8
- enabled: true,
9
- initialDelay: account.reconnect.initialDelay,
10
- maxDelay: account.reconnect.maxDelay,
11
- jitterRatio: account.reconnect.jitterRatio,
12
- ...(Number.isFinite(maxRetries) ? { maxRetries } : {}),
13
- };
14
- const options = {
3
+ const client = createClawChatClient({
15
4
  url: account.websocketUrl,
16
5
  token: account.token,
17
- reconnect,
6
+ deviceId: account.userId,
7
+ ...(overrides.transport ? { transport: overrides.transport } : {}),
8
+ reconnect: {
9
+ enabled: true,
10
+ initialDelay: account.reconnect.initialDelay,
11
+ maxDelay: account.reconnect.maxDelay,
12
+ jitterRatio: account.reconnect.jitterRatio,
13
+ maxRetries: account.reconnect.maxRetries,
14
+ },
18
15
  heartbeat: {
19
16
  enabled: true,
20
17
  interval: account.heartbeat.interval,
@@ -24,13 +21,17 @@ export function createOpenclawClawlingClient(account, overrides = {}) {
24
21
  timeout: account.ack.timeout,
25
22
  autoResendOnTimeout: account.ack.autoResendOnTimeout,
26
23
  },
27
- // Buffer outbound sends during the tiny reconnect window so an inbound
28
- // message isn't silently dropped while the socket is flapping.
29
- queueWhileReconnecting: true,
30
- ...(overrides.transport ? { transport: overrides.transport } : {}),
31
- ...(overrides.logger ? { logger: overrides.logger } : {}),
32
- };
33
- return createWSClient(options);
24
+ });
25
+ if (overrides.wsLifecycle?.onConnectFrameSent) {
26
+ const sendRawEnvelope = client.sendRawEnvelope.bind(client);
27
+ client.sendRawEnvelope = (env) => {
28
+ sendRawEnvelope(env);
29
+ if (env.event === "connect") {
30
+ overrides.wsLifecycle?.onConnectFrameSent?.(env);
31
+ }
32
+ };
33
+ }
34
+ return client;
34
35
  }
35
36
  function normalizeRouting(params) {
36
37
  if (params.routing)
@@ -41,24 +42,26 @@ function normalizeRouting(params) {
41
42
  throw new Error("openclaw-clawchat streaming emit requires routing");
42
43
  }
43
44
  /**
44
- * Emit a raw v2 envelope directly over the transport so we can carry top-level
45
- * `chat_id` routing without SDK-injected `to` metadata.
45
+ * Emit a raw v2 envelope through the local client so stream helpers carry
46
+ * top-level `chat_id` routing without legacy `to` metadata.
46
47
  */
47
- function emitEnvelope(client, event, payload, routing) {
48
- const inner = client;
49
- if (!inner.opts?.transport) {
50
- inner.emitRaw?.(event, payload, { to: { id: routing.chatId, type: routing.chatType } });
48
+ function emitEnvelope(client, event, payload, routing, options = {}) {
49
+ if (!options.forceRawTransport) {
50
+ client.emitRaw(event, payload, { chat_id: routing.chatId });
51
51
  return;
52
52
  }
53
+ if (typeof client.nextTraceId !== "function" || typeof client.sendRawEnvelope !== "function") {
54
+ throw new Error("openclaw-clawchat streaming emit requires local raw transport");
55
+ }
53
56
  const env = {
54
57
  version: "2",
55
58
  event,
56
- trace_id: inner.opts.traceIdFactory(),
59
+ trace_id: client.nextTraceId(),
57
60
  emitted_at: Date.now(),
58
61
  chat_id: routing.chatId,
59
62
  payload,
60
63
  };
61
- inner.opts.transport.send(JSON.stringify(env));
64
+ client.sendRawEnvelope(env);
62
65
  }
63
66
  /**
64
67
  * Emit a minimal `message.created` envelope to open a streaming message.
@@ -126,10 +129,8 @@ export function emitStreamDone(client, params) {
126
129
  * the same `payload.message_id` as the preceding `message.created` /
127
130
  * `message.add` / `message.done` frames.
128
131
  *
129
- * The SDK's high-level `client.replyMessage()` disallows `payload.message_id`
130
- * on outbound replies (the server normally assigns one via ack); for the
131
- * streaming-finalize use case the backend expects the correlated id, so we
132
- * bypass the SDK validator and write directly to the transport.
132
+ * Final stream replies include the correlated `payload.message_id`, so they
133
+ * use the local raw-envelope API instead of any higher-level ackable send.
133
134
  */
134
135
  export function emitFinalStreamReply(client, params) {
135
136
  const routing = normalizeRouting(params);
@@ -150,20 +151,15 @@ export function emitFinalStreamReply(client, params) {
150
151
  },
151
152
  },
152
153
  },
153
- }, routing);
154
+ }, routing, { forceRawTransport: true });
154
155
  }
155
156
  export function emitStreamFailed(client, params) {
156
157
  const now = Date.now();
157
158
  const routing = normalizeRouting(params);
158
- const reason = params.reason ?? "unknown";
159
- const reasonFragment = params.reason?.trim()
160
- ? { fragments: [{ kind: "text", text: params.reason.trim() }] }
161
- : {};
159
+ const reasonText = params.reason?.trim();
162
160
  emitEnvelope(client, "message.failed", {
163
161
  message_id: params.messageId,
164
- sequence: params.sequence,
165
- reason,
166
- ...reasonFragment,
162
+ fragments: reasonText ? [{ kind: "text", text: reasonText }] : [],
167
163
  streaming: {
168
164
  status: "failed",
169
165
  sequence: params.sequence,
@@ -2,6 +2,7 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
2
2
  export const CHANNEL_ID = "openclaw-clawchat";
3
3
  export const CLAWCHAT_TOKEN_ENV = "CLAWCHAT_TOKEN";
4
4
  export const CLAWCHAT_USER_ID_ENV = "CLAWCHAT_USER_ID";
5
+ export const CLAWCHAT_OWNER_USER_ID_ENV = "CLAWCHAT_OWNER_USER_ID";
5
6
  export const CLAWCHAT_REFRESH_TOKEN_ENV = "CLAWCHAT_REFRESH_TOKEN";
6
7
  export const CLAWCHAT_BASE_URL_ENV = "CLAWCHAT_BASE_URL";
7
8
  export const CLAWCHAT_WEBSOCKET_URL_ENV = "CLAWCHAT_WEBSOCKET_URL";
@@ -11,8 +12,8 @@ export const CLAWCHAT_WEBSOCKET_URL_ENV = "CLAWCHAT_WEBSOCKET_URL";
11
12
  * setup` call. Operators can still override either one via config.
12
13
  *
13
14
  */
14
- export const DEFAULT_BASE_URL = "http://company.newbaselab.com:10086";
15
- export const DEFAULT_WEBSOCKET_URL = "ws://company.newbaselab.com:10086/ws";
15
+ export const DEFAULT_BASE_URL = "https://app.clawling.com";
16
+ export const DEFAULT_WEBSOCKET_URL = "wss://app.clawling.com/ws";
16
17
  export const DEFAULT_STREAM = {
17
18
  flushIntervalMs: 250,
18
19
  minChunkChars: 40,
@@ -52,8 +53,19 @@ export const openclawClawlingConfigSchema = {
52
53
  token: { type: "string" },
53
54
  refreshToken: { type: "string" },
54
55
  userId: { type: "string" },
56
+ ownerUserId: { type: "string" },
55
57
  replyMode: { type: "string", enum: ["static", "stream"] },
56
58
  groupMode: { type: "string", enum: ["mention", "all"] },
59
+ groups: {
60
+ type: "object",
61
+ additionalProperties: {
62
+ type: "object",
63
+ additionalProperties: false,
64
+ properties: {
65
+ groupMode: { type: "string", enum: ["mention", "all"] },
66
+ },
67
+ },
68
+ },
57
69
  forwardThinking: { type: "boolean" },
58
70
  forwardToolCalls: { type: "boolean" },
59
71
  richInteractions: { type: "boolean" },
@@ -97,39 +109,61 @@ export const openclawClawlingConfigSchema = {
97
109
  function isOpenclawClawchatToolAllowEntry(entry) {
98
110
  return entry === CHANNEL_ID || entry === "group:plugins";
99
111
  }
100
- function hasOpenclawClawchatToolAllow(cfg) {
101
- const currentTools = (cfg.tools ?? {});
102
- const currentAlsoAllow = Array.isArray(currentTools.alsoAllow) ? currentTools.alsoAllow : [];
103
- const currentAllow = Array.isArray(currentTools.allow) ? currentTools.allow : [];
104
- return [...currentAllow, ...currentAlsoAllow].some(isOpenclawClawchatToolAllowEntry);
105
- }
106
- function mergeToolPolicyEntryAllow(cfg, entry, isAlreadyCovered) {
112
+ function mergeToolPolicyEntryAlsoAllow(cfg, entry, isAlreadyCovered) {
107
113
  const currentTools = (cfg.tools ?? {});
108
114
  const currentAlsoAllow = Array.isArray(currentTools.alsoAllow)
109
115
  ? currentTools.alsoAllow.slice()
110
116
  : [];
111
117
  const currentAllow = Array.isArray(currentTools.allow) ? currentTools.allow.slice() : [];
112
- const alreadyAllowed = [...currentAllow, ...currentAlsoAllow].some(isAlreadyCovered);
113
- if (currentAllow.length > 0) {
118
+ const alreadyCovered = [...currentAllow, ...currentAlsoAllow].some(isAlreadyCovered);
119
+ if (alreadyCovered) {
114
120
  return {
115
121
  ...cfg,
116
122
  tools: {
117
123
  ...currentTools,
118
- allow: alreadyAllowed ? currentAllow : [...currentAllow, entry],
124
+ ...(Array.isArray(currentTools.allow) ? { allow: currentAllow } : {}),
125
+ ...(Array.isArray(currentTools.alsoAllow) ? { alsoAllow: currentAlsoAllow } : {}),
119
126
  },
120
127
  };
121
128
  }
122
- const alreadyAlsoAllowed = currentAlsoAllow.some(isAlreadyCovered);
123
129
  return {
124
130
  ...cfg,
125
131
  tools: {
126
132
  ...currentTools,
127
- alsoAllow: alreadyAlsoAllowed ? currentAlsoAllow : [...currentAlsoAllow, entry],
133
+ alsoAllow: [...currentAlsoAllow, entry],
128
134
  },
129
135
  };
130
136
  }
131
137
  export function mergeOpenclawClawchatToolAllow(cfg) {
132
- return mergeToolPolicyEntryAllow(cfg, CHANNEL_ID, isOpenclawClawchatToolAllowEntry);
138
+ return mergeToolPolicyEntryAlsoAllow(cfg, CHANNEL_ID, isOpenclawClawchatToolAllowEntry);
139
+ }
140
+ function readRecord(value) {
141
+ return value && typeof value === "object" && !Array.isArray(value)
142
+ ? value
143
+ : {};
144
+ }
145
+ export function mergeOpenclawClawchatRuntimePluginActivation(cfg) {
146
+ const currentPlugins = readRecord(cfg.plugins);
147
+ const currentEntries = readRecord(currentPlugins.entries);
148
+ const currentEntry = readRecord(currentEntries[CHANNEL_ID]);
149
+ const currentAllow = Array.isArray(currentPlugins.allow) ? currentPlugins.allow.slice() : [];
150
+ const nextPlugins = {
151
+ ...currentPlugins,
152
+ entries: {
153
+ ...currentEntries,
154
+ [CHANNEL_ID]: {
155
+ ...currentEntry,
156
+ enabled: true,
157
+ },
158
+ },
159
+ };
160
+ if (!currentAllow.includes(CHANNEL_ID)) {
161
+ nextPlugins.allow = [...currentAllow, CHANNEL_ID];
162
+ }
163
+ return {
164
+ ...cfg,
165
+ plugins: nextPlugins,
166
+ };
133
167
  }
134
168
  function readChannelSection(cfg) {
135
169
  const channels = (cfg.channels ?? {});
@@ -146,7 +180,27 @@ function readReplyMode(value) {
146
180
  return value === "stream" ? "stream" : "static";
147
181
  }
148
182
  function readGroupMode(value) {
149
- return value === "all" ? "all" : "mention";
183
+ return value === "mention" ? "mention" : "all";
184
+ }
185
+ function readGroups(value) {
186
+ const rawGroups = value && typeof value === "object" && !Array.isArray(value)
187
+ ? value
188
+ : {};
189
+ const groups = {};
190
+ for (const [chatId, rawGroup] of Object.entries(rawGroups)) {
191
+ if (!chatId)
192
+ continue;
193
+ const group = rawGroup && typeof rawGroup === "object" && !Array.isArray(rawGroup)
194
+ ? rawGroup
195
+ : {};
196
+ groups[chatId] = { groupMode: readGroupMode(group.groupMode) };
197
+ }
198
+ return groups;
199
+ }
200
+ export function effectiveGroupMode(account, chatId) {
201
+ return account.groups[chatId]?.groupMode
202
+ ?? account.groups["*"]?.groupMode
203
+ ?? account.groupMode;
150
204
  }
151
205
  function readStream(raw) {
152
206
  const s = raw && typeof raw === "object" ? raw : {};
@@ -194,9 +248,11 @@ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
194
248
  DEFAULT_BASE_URL;
195
249
  const token = readOptionalString(channel.token) || readEnvString(env, CLAWCHAT_TOKEN_ENV);
196
250
  const userId = readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV);
251
+ const ownerUserId = readOptionalString(channel.ownerUserId) || readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV);
197
252
  const enabled = typeof channel.enabled === "boolean" ? channel.enabled : true;
198
253
  const replyMode = readReplyMode(channel.replyMode);
199
254
  const groupMode = readGroupMode(channel.groupMode);
255
+ const groups = readGroups(channel.groups);
200
256
  const forwardThinking = typeof channel.forwardThinking === "boolean" ? channel.forwardThinking : true;
201
257
  const forwardToolCalls = typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
202
258
  const richInteractions = typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
@@ -204,13 +260,15 @@ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
204
260
  accountId: DEFAULT_ACCOUNT_ID,
205
261
  name: CHANNEL_ID,
206
262
  enabled,
207
- configured: Boolean(websocketUrl && token && userId),
263
+ configured: Boolean(websocketUrl && token && userId && ownerUserId),
208
264
  websocketUrl,
209
265
  baseUrl,
210
266
  token,
211
267
  userId,
268
+ ownerUserId,
212
269
  replyMode,
213
270
  groupMode,
271
+ groups,
214
272
  forwardThinking,
215
273
  forwardToolCalls,
216
274
  richInteractions,
@@ -1,72 +1,100 @@
1
- import { EVENT, } from "@newbase-clawchat/sdk";
1
+ import { EVENT, } from "./protocol-types.js";
2
+ import { effectiveGroupMode } from "./config.js";
2
3
  import { extractMediaFragments, fragmentsToText } from "./message-mapper.js";
3
4
  import { hasRenderableText, isInboundMessagePayload } from "./protocol.js";
4
- const DEDUP_MAX = 256;
5
- const dedupSeen = [];
6
- const dedupSet = new Set();
7
5
  function normalizeSender(sender) {
8
6
  if (!sender || typeof sender !== "object")
9
7
  return null;
10
8
  const s = sender;
11
- const id = typeof s.id === "string" ? s.id : typeof s.sender_id === "string" ? s.sender_id : "";
9
+ const id = typeof s.id === "string" ? s.id : "";
12
10
  if (!id)
13
11
  return null;
14
- const type = s.type === "group" || s.type === "direct" ? s.type : undefined;
15
- const nickName = typeof s.nick_name === "string"
16
- ? s.nick_name
17
- : typeof s.display_name === "string"
18
- ? s.display_name
19
- : id;
20
- return { id, nickName, ...(type ? { type } : {}) };
12
+ const nickName = typeof s.nick_name === "string" ? s.nick_name : id;
13
+ return { id, nickName };
21
14
  }
22
- export function _resetDedupForTest() {
23
- dedupSeen.length = 0;
24
- dedupSet.clear();
25
- }
26
- function rememberAndCheck(messageId) {
27
- if (dedupSet.has(messageId))
28
- return true;
29
- dedupSet.add(messageId);
30
- dedupSeen.push(messageId);
31
- if (dedupSeen.length > DEDUP_MAX) {
32
- const evict = dedupSeen.shift();
33
- if (evict)
34
- dedupSet.delete(evict);
15
+ function isStreamDonePayload(payload) {
16
+ if (!payload || typeof payload !== "object")
17
+ return false;
18
+ const p = payload;
19
+ if (typeof p.message_id !== "string" || p.message_id.length === 0)
20
+ return false;
21
+ if (!Array.isArray(p.fragments))
22
+ return false;
23
+ if (p.streaming !== undefined && p.streaming !== null) {
24
+ if (typeof p.streaming !== "object")
25
+ return false;
26
+ if (p.streaming.status !== "done")
27
+ return false;
35
28
  }
36
- return false;
29
+ return true;
30
+ }
31
+ function requireChatId(envelope) {
32
+ const chatId = envelope.chat_id;
33
+ return typeof chatId === "string" && chatId.trim() ? chatId : null;
34
+ }
35
+ function extractMentionIds(fragments) {
36
+ return fragments
37
+ .map((fragment) => fragment.kind === "mention" ? fragment.user_id : undefined)
38
+ .filter((userId) => typeof userId === "string" && userId.length > 0);
39
+ }
40
+ function normalizeMentionIds(mentions) {
41
+ return mentions
42
+ .map((mention) => {
43
+ if (typeof mention === "string")
44
+ return mention;
45
+ if (mention && typeof mention === "object") {
46
+ const userId = mention.user_id;
47
+ return typeof userId === "string" ? userId : undefined;
48
+ }
49
+ return undefined;
50
+ })
51
+ .filter((userId) => typeof userId === "string" && userId.length > 0);
37
52
  }
38
53
  /**
39
- * Exported for direct unit testing. Group-sender messages currently never
40
- * reach this function (filtered in dispatchOpenclawClawlingInbound), but the
41
- * `mentions` branch is exercised by tests now so the group-enable change is
42
- * a one-line filter removal later.
54
+ * Exported for direct unit testing. Direct chats always count as addressed;
55
+ * group chats require a mention unless config opts into all group messages.
43
56
  */
44
57
  export function detectMention(params) {
45
- if (params.senderType === "direct")
58
+ if (params.chatType === "direct")
46
59
  return true;
47
- return params.mentions.includes(params.userId);
60
+ return normalizeMentionIds(params.mentions).includes(params.userId);
48
61
  }
49
62
  export async function dispatchOpenclawClawlingInbound(params) {
50
63
  const { envelope, account, log } = params;
51
- if (!isInboundMessagePayload(envelope.payload)) {
64
+ const isMaterializedMessage = envelope.event === EVENT.MESSAGE_SEND || envelope.event === EVENT.MESSAGE_REPLY;
65
+ const isStreamDone = envelope.event === "message.done";
66
+ if (!isMaterializedMessage && !isStreamDone) {
67
+ log?.info?.(`[${account.accountId}] openclaw-clawchat skip non-business event=${envelope.event} trace=${envelope.trace_id}`);
68
+ return;
69
+ }
70
+ if (isMaterializedMessage && !isInboundMessagePayload(envelope.payload)) {
52
71
  log?.info?.(`[${account.accountId}] openclaw-clawchat skip: invalid payload trace=${envelope.trace_id}`);
53
72
  return;
54
73
  }
74
+ if (isStreamDone && !isStreamDonePayload(envelope.payload)) {
75
+ log?.info?.(`[${account.accountId}] openclaw-clawchat skip: invalid stream payload trace=${envelope.trace_id}`);
76
+ return;
77
+ }
78
+ const chatId = requireChatId(envelope);
79
+ if (!chatId) {
80
+ log?.info?.(`[${account.accountId}] openclaw-clawchat skip: missing chat_id trace=${envelope.trace_id}`);
81
+ return;
82
+ }
55
83
  const payload = envelope.payload;
56
- const message = payload.message;
57
- // v2 envelopes carry sender on the envelope (RoutingSender); the legacy
58
- // message.sender shape is accepted as a fallback for older fixtures.
59
- const sender = normalizeSender(envelope.sender ?? message.sender);
84
+ const message = (isMaterializedMessage
85
+ ? payload.message
86
+ : {
87
+ body: { fragments: payload.fragments ?? [] },
88
+ context: { mentions: extractMentionIds(payload.fragments ?? []), reply: null },
89
+ });
90
+ const sender = normalizeSender(envelope.sender);
60
91
  if (!sender) {
61
92
  log?.info?.(`[${account.accountId}] openclaw-clawchat skip: missing sender trace=${envelope.trace_id}`);
62
93
  return;
63
94
  }
64
- // `chat_type` is on the envelope in the new protocol. Default to "direct"
65
- // if the server didn't include it (defensive; shouldn't happen in practice).
66
- const legacyTo = envelope.to;
67
- const chatType = envelope.chat_type ?? sender.type ?? legacyTo?.type ?? "direct";
95
+ const chatType = envelope.chat_type === "group" ? "group" : "direct";
68
96
  const isGroup = chatType === "group";
69
- if (payload.message_mode !== "normal") {
97
+ if (isMaterializedMessage && payload.message_mode !== "normal") {
70
98
  log?.info?.(`[${account.accountId}] openclaw-clawchat skip non-normal mode=${payload.message_mode}`);
71
99
  return;
72
100
  }
@@ -74,41 +102,31 @@ export async function dispatchOpenclawClawlingInbound(params) {
74
102
  log?.info?.(`[${account.accountId}] openclaw-clawchat skip empty msg=${payload.message_id}`);
75
103
  return;
76
104
  }
77
- if (rememberAndCheck(payload.message_id)) {
78
- log?.info?.(`[${account.accountId}] openclaw-clawchat skip duplicate msg=${payload.message_id}`);
79
- return;
80
- }
105
+ const mentionIds = normalizeMentionIds(message.context.mentions);
81
106
  const rawBody = fragmentsToText(message.body.fragments, {
82
- mentionFallbackIds: message.context.mentions,
107
+ mentionFallbackIds: mentionIds,
83
108
  });
84
109
  const mediaItems = extractMediaFragments(message.body.fragments);
85
110
  const wasMentioned = detectMention({
86
- mentions: message.context.mentions,
87
- senderType: chatType,
111
+ mentions: mentionIds,
112
+ chatType,
88
113
  userId: account.userId,
89
114
  });
90
115
  // Group trigger policy: in "mention" mode we only handle group messages
91
116
  // that @-mention us; "all" listens open and processes every group msg.
92
117
  // Direct chats are unaffected (detectMention returns true).
93
- if (isGroup && account.groupMode === "mention" && !wasMentioned) {
118
+ const groupMode = isGroup ? effectiveGroupMode(account, chatId) : account.groupMode;
119
+ if (isGroup && groupMode === "mention" && !wasMentioned) {
94
120
  log?.info?.(`[${account.accountId}] openclaw-clawchat skip group (no mention) msg=${payload.message_id}`);
95
121
  return;
96
122
  }
97
- log?.info?.(`[${account.accountId}] openclaw-clawchat inbound event=${envelope.event === EVENT.MESSAGE_REPLY ? "reply" : "send"} msg=${payload.message_id} from=${sender.id} text_len=${rawBody.length} mentioned=${wasMentioned}`);
98
- // New protocol: `chat_id` is the routing primary; `to` is deprecated.
99
- // Fall back to sender.id if neither is present (defensive).
100
- const chatId = envelope.chat_id ??
101
- sender.id;
123
+ log?.info?.(`[${account.accountId}] openclaw-clawchat inbound event=${envelope.event} msg=${payload.message_id} from=${sender.id} text_len=${rawBody.length} mentioned=${wasMentioned}`);
102
124
  const replyCtx = message.context.reply
103
125
  ? {
104
126
  replyToMessageId: message.context.reply.reply_to_msg_id,
105
127
  replyPreviewChatId: chatId,
106
- replyPreviewSenderId: message.context.reply.reply_preview.id ??
107
- message.context.reply.reply_preview.sender_id ??
108
- "",
109
- replyPreviewNickName: message.context.reply.reply_preview.nick_name ??
110
- message.context.reply.reply_preview.display_name ??
111
- "",
128
+ replyPreviewSenderId: message.context.reply.reply_preview.id ?? "",
129
+ replyPreviewNickName: message.context.reply.reply_preview.nick_name ?? "",
112
130
  replyPreviewText: fragmentsToText(message.context.reply.reply_preview.fragments),
113
131
  }
114
132
  : undefined;