@newbase-clawchat/openclaw-clawchat 2026.4.29 → 2026.5.4

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 (49) hide show
  1. package/README.md +37 -11
  2. package/dist/index.js +27 -0
  3. package/dist/src/api-client.js +156 -0
  4. package/dist/src/api-types.js +17 -0
  5. package/dist/src/buffered-stream.js +177 -0
  6. package/dist/src/channel.js +200 -0
  7. package/dist/src/client.js +176 -0
  8. package/dist/src/commands.js +35 -0
  9. package/dist/src/config.js +226 -0
  10. package/dist/src/inbound.js +133 -0
  11. package/dist/src/login.runtime.js +132 -0
  12. package/dist/src/media-runtime.js +85 -0
  13. package/dist/src/message-mapper.js +82 -0
  14. package/dist/src/outbound.js +181 -0
  15. package/dist/src/protocol.js +38 -0
  16. package/dist/src/reply-dispatcher.js +440 -0
  17. package/dist/src/runtime.js +288 -0
  18. package/dist/src/streaming.js +65 -0
  19. package/dist/src/tools-schema.js +38 -0
  20. package/dist/src/tools.js +287 -0
  21. package/openclaw.plugin.json +21 -0
  22. package/package.json +27 -5
  23. package/skills/clawchat-activate/SKILL.md +18 -9
  24. package/src/buffered-stream.test.ts +10 -0
  25. package/src/buffered-stream.ts +6 -6
  26. package/src/channel.outbound.test.ts +3 -3
  27. package/src/channel.test.ts +7 -1
  28. package/src/channel.ts +27 -8
  29. package/src/client.test.ts +8 -1
  30. package/src/client.ts +11 -10
  31. package/src/commands.test.ts +6 -0
  32. package/src/commands.ts +5 -1
  33. package/src/config.test.ts +47 -0
  34. package/src/config.ts +28 -5
  35. package/src/inbound.test.ts +4 -1
  36. package/src/inbound.ts +11 -10
  37. package/src/login.runtime.test.ts +36 -0
  38. package/src/login.runtime.ts +57 -27
  39. package/src/manifest.test.ts +156 -30
  40. package/src/outbound.test.ts +6 -5
  41. package/src/outbound.ts +8 -7
  42. package/src/plugin-entry.test.ts +7 -1
  43. package/src/reply-dispatcher.test.ts +418 -3
  44. package/src/reply-dispatcher.ts +137 -12
  45. package/src/runtime.ts +1 -0
  46. package/src/streaming.test.ts +12 -9
  47. package/src/streaming.ts +6 -6
  48. package/src/tools.test.ts +81 -18
  49. package/src/tools.ts +65 -74
@@ -0,0 +1,176 @@
1
+ import { createWSClient, } from "@newbase-clawchat/sdk";
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 = {
15
+ url: account.websocketUrl,
16
+ token: account.token,
17
+ reconnect,
18
+ heartbeat: {
19
+ enabled: true,
20
+ interval: account.heartbeat.interval,
21
+ timeout: account.heartbeat.timeout,
22
+ },
23
+ ack: {
24
+ timeout: account.ack.timeout,
25
+ autoResendOnTimeout: account.ack.autoResendOnTimeout,
26
+ },
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);
34
+ }
35
+ function normalizeRouting(params) {
36
+ if (params.routing)
37
+ return params.routing;
38
+ if (params.to?.id) {
39
+ return { chatId: params.to.id, chatType: params.to.type ?? "direct" };
40
+ }
41
+ throw new Error("openclaw-clawchat streaming emit requires routing");
42
+ }
43
+ /**
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.
46
+ */
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 } });
51
+ return;
52
+ }
53
+ const env = {
54
+ version: "2",
55
+ event,
56
+ trace_id: inner.opts.traceIdFactory(),
57
+ emitted_at: Date.now(),
58
+ chat_id: routing.chatId,
59
+ payload,
60
+ };
61
+ inner.opts.transport.send(JSON.stringify(env));
62
+ }
63
+ /**
64
+ * Emit a minimal `message.created` envelope to open a streaming message.
65
+ *
66
+ * Payload is intentionally just `{ message_id }`: the message body, context,
67
+ * sender, and streaming metadata are not transmitted here — they live on the
68
+ * envelope (`chat_id`, `chat_type`, optional `sender`) and on the subsequent
69
+ * `message.add` / `message.done` frames.
70
+ */
71
+ export function emitStreamCreated(client, params) {
72
+ const routing = normalizeRouting(params);
73
+ emitEnvelope(client, "message.created", { message_id: params.messageId }, routing);
74
+ }
75
+ /**
76
+ * Emit a `message.add` frame with a `fragments` array carrying both the
77
+ * delta (newly appended text) and the full running text ("from the start
78
+ * up to now"). Clients rendering the stream can use `delta` for animations
79
+ * and `text` for the current snapshot.
80
+ *
81
+ * Shape: `fragments: [{ kind: "text", text: <cumulative>, delta: <new> }]`
82
+ */
83
+ export function emitStreamAdd(client, params) {
84
+ const now = Date.now();
85
+ const routing = normalizeRouting(params);
86
+ emitEnvelope(client, "message.add", {
87
+ message_id: params.messageId,
88
+ sequence: params.sequence,
89
+ mutation: { type: "append", target_fragment_index: null },
90
+ fragments: [
91
+ { kind: "text", text: params.fullText, delta: params.textDelta },
92
+ ],
93
+ streaming: {
94
+ status: "streaming",
95
+ sequence: params.sequence,
96
+ mutation_policy: "append_text_only",
97
+ started_at: null,
98
+ completed_at: null,
99
+ },
100
+ added_at: now,
101
+ }, routing);
102
+ }
103
+ /**
104
+ * Emit a `message.done` frame with the final merged text included as a
105
+ * single-element `fragments` array so clients can settle the streamed
106
+ * message on the full text without re-accumulating deltas.
107
+ */
108
+ export function emitStreamDone(client, params) {
109
+ const now = Date.now();
110
+ const routing = normalizeRouting(params);
111
+ emitEnvelope(client, "message.done", {
112
+ message_id: params.messageId,
113
+ fragments: [{ kind: "text", text: params.finalText }],
114
+ streaming: {
115
+ status: "done",
116
+ sequence: params.finalSequence,
117
+ mutation_policy: "append_text_only",
118
+ started_at: null,
119
+ completed_at: now,
120
+ },
121
+ completed_at: now,
122
+ }, routing);
123
+ }
124
+ /**
125
+ * Emit a `message.reply` envelope that finalizes a streamed reply, carrying
126
+ * the same `payload.message_id` as the preceding `message.created` /
127
+ * `message.add` / `message.done` frames.
128
+ *
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.
133
+ */
134
+ export function emitFinalStreamReply(client, params) {
135
+ const routing = normalizeRouting(params);
136
+ emitEnvelope(client, "message.reply", {
137
+ message_id: params.messageId,
138
+ message_mode: "normal",
139
+ message: {
140
+ body: params.body,
141
+ context: {
142
+ mentions: params.mentions ?? [],
143
+ reply: {
144
+ reply_to_msg_id: params.replyTo.msgId,
145
+ reply_preview: {
146
+ id: params.replyTo.previewId,
147
+ nick_name: params.replyTo.nickName,
148
+ fragments: params.replyTo.fragments,
149
+ },
150
+ },
151
+ },
152
+ },
153
+ }, routing);
154
+ }
155
+ export function emitStreamFailed(client, params) {
156
+ const now = Date.now();
157
+ 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
+ : {};
162
+ emitEnvelope(client, "message.failed", {
163
+ message_id: params.messageId,
164
+ sequence: params.sequence,
165
+ reason,
166
+ ...reasonFragment,
167
+ streaming: {
168
+ status: "failed",
169
+ sequence: params.sequence,
170
+ mutation_policy: "append_text_only",
171
+ started_at: null,
172
+ completed_at: now,
173
+ },
174
+ completed_at: now,
175
+ }, routing);
176
+ }
@@ -0,0 +1,35 @@
1
+ function extractInviteCode(value) {
2
+ const raw = typeof value === "string" ? value.trim() : "";
3
+ return raw.match(/\b[A-Z0-9]{6}\b/u)?.[0] ?? "";
4
+ }
5
+ function errorMessage(err) {
6
+ return err instanceof Error ? err.message : String(err);
7
+ }
8
+ export function registerOpenclawClawlingCommands(api) {
9
+ api.registerCommand({
10
+ name: "clawchat-login",
11
+ description: "Activate ClawChat with an invite code, e.g. /clawchat-login A1B2C3.",
12
+ acceptsArgs: true,
13
+ requireAuth: true,
14
+ async handler(ctx) {
15
+ const code = extractInviteCode(ctx.args ?? ctx.commandBody);
16
+ if (!code) {
17
+ return { text: "ClawChat invite code is required. Usage: /clawchat-login A1B2C3" };
18
+ }
19
+ try {
20
+ const { runOpenclawClawlingLogin } = await import("./login.runtime.js");
21
+ await runOpenclawClawlingLogin({
22
+ cfg: ctx.config,
23
+ accountId: ctx.accountId ?? null,
24
+ runtime: { log: (message) => api.logger?.info?.(message) },
25
+ readInviteCode: async () => code,
26
+ mutateConfigFile: api.runtime.config.mutateConfigFile,
27
+ });
28
+ return { text: "✅ ClawChat activated successfully." };
29
+ }
30
+ catch (err) {
31
+ return { text: `❌ ${errorMessage(err)}` };
32
+ }
33
+ },
34
+ });
35
+ }
@@ -0,0 +1,226 @@
1
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
2
+ export const CHANNEL_ID = "openclaw-clawchat";
3
+ export const CLAWCHAT_TOKEN_ENV = "CLAWCHAT_TOKEN";
4
+ export const CLAWCHAT_USER_ID_ENV = "CLAWCHAT_USER_ID";
5
+ export const CLAWCHAT_REFRESH_TOKEN_ENV = "CLAWCHAT_REFRESH_TOKEN";
6
+ export const CLAWCHAT_BASE_URL_ENV = "CLAWCHAT_BASE_URL";
7
+ export const CLAWCHAT_WEBSOCKET_URL_ENV = "CLAWCHAT_WEBSOCKET_URL";
8
+ /**
9
+ * Built-in defaults for the Clawling Chat endpoints so `openclaw channel
10
+ * login` works out of the box without requiring a prior `openclaw channel
11
+ * setup` call. Operators can still override either one via config.
12
+ *
13
+ */
14
+ export const DEFAULT_BASE_URL = "http://company.newbaselab.com:10086";
15
+ export const DEFAULT_WEBSOCKET_URL = "ws://company.newbaselab.com:10086/ws";
16
+ export const DEFAULT_STREAM = {
17
+ flushIntervalMs: 250,
18
+ minChunkChars: 40,
19
+ maxBufferChars: 2000,
20
+ };
21
+ export const DEFAULT_RECONNECT = {
22
+ // Snappier first retry on transient drops (vs. 1_000).
23
+ initialDelay: 500,
24
+ // Cap exponential backoff at 15s — a background gateway reconnecting
25
+ // every 30s feels unresponsive; 15s is the common IM chat bar.
26
+ maxDelay: 15_000,
27
+ // Standard jitter ratio to avoid thundering herd on server restart.
28
+ jitterRatio: 0.3,
29
+ // Never give up — the gateway is a long-lived background process.
30
+ maxRetries: Number.POSITIVE_INFINITY,
31
+ };
32
+ export const DEFAULT_HEARTBEAT = {
33
+ // 20s keeps NAT/firewall state warm without wasting bandwidth.
34
+ interval: 20_000,
35
+ // Pong must arrive within 10s or we tear down and reconnect.
36
+ timeout: 10_000,
37
+ };
38
+ export const DEFAULT_ACK = {
39
+ // 15s tolerates a slow server + one retry without false timeouts.
40
+ timeout: 15_000,
41
+ // Keep false: auto-resend on timeout risks duplicate messages; the
42
+ // reconnect path re-queues via `queueWhileReconnecting` instead.
43
+ autoResendOnTimeout: false,
44
+ };
45
+ export const openclawClawlingConfigSchema = {
46
+ type: "object",
47
+ additionalProperties: false,
48
+ properties: {
49
+ enabled: { type: "boolean" },
50
+ websocketUrl: { type: "string" },
51
+ baseUrl: { type: "string" },
52
+ token: { type: "string" },
53
+ refreshToken: { type: "string" },
54
+ userId: { type: "string" },
55
+ replyMode: { type: "string", enum: ["static", "stream"] },
56
+ groupMode: { type: "string", enum: ["mention", "all"] },
57
+ forwardThinking: { type: "boolean" },
58
+ forwardToolCalls: { type: "boolean" },
59
+ richInteractions: { type: "boolean" },
60
+ stream: {
61
+ type: "object",
62
+ additionalProperties: false,
63
+ properties: {
64
+ flushIntervalMs: { type: "integer", minimum: 10 },
65
+ minChunkChars: { type: "integer", minimum: 1 },
66
+ maxBufferChars: { type: "integer", minimum: 1 },
67
+ },
68
+ },
69
+ reconnect: {
70
+ type: "object",
71
+ additionalProperties: false,
72
+ properties: {
73
+ initialDelay: { type: "integer", minimum: 100 },
74
+ maxDelay: { type: "integer", minimum: 100 },
75
+ jitterRatio: { type: "number", minimum: 0 },
76
+ maxRetries: { type: "integer", minimum: 0 },
77
+ },
78
+ },
79
+ heartbeat: {
80
+ type: "object",
81
+ additionalProperties: false,
82
+ properties: {
83
+ interval: { type: "integer", minimum: 1000 },
84
+ timeout: { type: "integer", minimum: 1000 },
85
+ },
86
+ },
87
+ ack: {
88
+ type: "object",
89
+ additionalProperties: false,
90
+ properties: {
91
+ timeout: { type: "integer", minimum: 100 },
92
+ autoResendOnTimeout: { type: "boolean" },
93
+ },
94
+ },
95
+ },
96
+ };
97
+ function isOpenclawClawchatToolAllowEntry(entry) {
98
+ return entry === CHANNEL_ID || entry === "group:plugins";
99
+ }
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) {
107
+ const currentTools = (cfg.tools ?? {});
108
+ const currentAlsoAllow = Array.isArray(currentTools.alsoAllow)
109
+ ? currentTools.alsoAllow.slice()
110
+ : [];
111
+ const currentAllow = Array.isArray(currentTools.allow) ? currentTools.allow.slice() : [];
112
+ const alreadyAllowed = [...currentAllow, ...currentAlsoAllow].some(isAlreadyCovered);
113
+ if (currentAllow.length > 0) {
114
+ return {
115
+ ...cfg,
116
+ tools: {
117
+ ...currentTools,
118
+ allow: alreadyAllowed ? currentAllow : [...currentAllow, entry],
119
+ },
120
+ };
121
+ }
122
+ const alreadyAlsoAllowed = currentAlsoAllow.some(isAlreadyCovered);
123
+ return {
124
+ ...cfg,
125
+ tools: {
126
+ ...currentTools,
127
+ alsoAllow: alreadyAlsoAllowed ? currentAlsoAllow : [...currentAlsoAllow, entry],
128
+ },
129
+ };
130
+ }
131
+ export function mergeOpenclawClawchatToolAllow(cfg) {
132
+ return mergeToolPolicyEntryAllow(cfg, CHANNEL_ID, isOpenclawClawchatToolAllowEntry);
133
+ }
134
+ function readChannelSection(cfg) {
135
+ const channels = (cfg.channels ?? {});
136
+ const channel = channels[CHANNEL_ID];
137
+ return channel && typeof channel === "object" ? channel : {};
138
+ }
139
+ function readOptionalString(value) {
140
+ return typeof value === "string" ? value.trim() : "";
141
+ }
142
+ function readEnvString(env, key) {
143
+ return readOptionalString(env[key]);
144
+ }
145
+ function readReplyMode(value) {
146
+ return value === "stream" ? "stream" : "static";
147
+ }
148
+ function readGroupMode(value) {
149
+ return value === "all" ? "all" : "mention";
150
+ }
151
+ function readStream(raw) {
152
+ const s = raw && typeof raw === "object" ? raw : {};
153
+ return {
154
+ flushIntervalMs: typeof s.flushIntervalMs === "number" ? s.flushIntervalMs : DEFAULT_STREAM.flushIntervalMs,
155
+ minChunkChars: typeof s.minChunkChars === "number" ? s.minChunkChars : DEFAULT_STREAM.minChunkChars,
156
+ maxBufferChars: typeof s.maxBufferChars === "number" ? s.maxBufferChars : DEFAULT_STREAM.maxBufferChars,
157
+ };
158
+ }
159
+ function readReconnect(raw) {
160
+ const s = raw && typeof raw === "object" ? raw : {};
161
+ return {
162
+ initialDelay: typeof s.initialDelay === "number" ? s.initialDelay : DEFAULT_RECONNECT.initialDelay,
163
+ maxDelay: typeof s.maxDelay === "number" ? s.maxDelay : DEFAULT_RECONNECT.maxDelay,
164
+ jitterRatio: typeof s.jitterRatio === "number" ? s.jitterRatio : DEFAULT_RECONNECT.jitterRatio,
165
+ maxRetries: typeof s.maxRetries === "number" && Number.isFinite(s.maxRetries)
166
+ ? s.maxRetries
167
+ : DEFAULT_RECONNECT.maxRetries,
168
+ };
169
+ }
170
+ function readHeartbeat(raw) {
171
+ const s = raw && typeof raw === "object" ? raw : {};
172
+ return {
173
+ interval: typeof s.interval === "number" ? s.interval : DEFAULT_HEARTBEAT.interval,
174
+ timeout: typeof s.timeout === "number" ? s.timeout : DEFAULT_HEARTBEAT.timeout,
175
+ };
176
+ }
177
+ function readAck(raw) {
178
+ const s = raw && typeof raw === "object" ? raw : {};
179
+ return {
180
+ timeout: typeof s.timeout === "number" ? s.timeout : DEFAULT_ACK.timeout,
181
+ autoResendOnTimeout: typeof s.autoResendOnTimeout === "boolean"
182
+ ? s.autoResendOnTimeout
183
+ : DEFAULT_ACK.autoResendOnTimeout,
184
+ };
185
+ }
186
+ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
187
+ const channel = readChannelSection(cfg);
188
+ // Apply built-in defaults so login/gateway work without prior setup.
189
+ const websocketUrl = readOptionalString(channel.websocketUrl) ||
190
+ readEnvString(env, CLAWCHAT_WEBSOCKET_URL_ENV) ||
191
+ DEFAULT_WEBSOCKET_URL;
192
+ const baseUrl = readOptionalString(channel.baseUrl) ||
193
+ readEnvString(env, CLAWCHAT_BASE_URL_ENV) ||
194
+ DEFAULT_BASE_URL;
195
+ const token = readOptionalString(channel.token) || readEnvString(env, CLAWCHAT_TOKEN_ENV);
196
+ const userId = readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV);
197
+ const enabled = typeof channel.enabled === "boolean" ? channel.enabled : true;
198
+ const replyMode = readReplyMode(channel.replyMode);
199
+ const groupMode = readGroupMode(channel.groupMode);
200
+ const forwardThinking = typeof channel.forwardThinking === "boolean" ? channel.forwardThinking : true;
201
+ const forwardToolCalls = typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
202
+ const richInteractions = typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
203
+ return {
204
+ accountId: DEFAULT_ACCOUNT_ID,
205
+ name: CHANNEL_ID,
206
+ enabled,
207
+ configured: Boolean(websocketUrl && token && userId),
208
+ websocketUrl,
209
+ baseUrl,
210
+ token,
211
+ userId,
212
+ replyMode,
213
+ groupMode,
214
+ forwardThinking,
215
+ forwardToolCalls,
216
+ richInteractions,
217
+ allowFrom: [],
218
+ stream: readStream(channel.stream),
219
+ reconnect: readReconnect(channel.reconnect),
220
+ heartbeat: readHeartbeat(channel.heartbeat),
221
+ ack: readAck(channel.ack),
222
+ };
223
+ }
224
+ export function listOpenclawClawlingAccountIds() {
225
+ return [DEFAULT_ACCOUNT_ID];
226
+ }
@@ -0,0 +1,133 @@
1
+ import { EVENT, } from "@newbase-clawchat/sdk";
2
+ import { extractMediaFragments, fragmentsToText } from "./message-mapper.js";
3
+ import { hasRenderableText, isInboundMessagePayload } from "./protocol.js";
4
+ const DEDUP_MAX = 256;
5
+ const dedupSeen = [];
6
+ const dedupSet = new Set();
7
+ function normalizeSender(sender) {
8
+ if (!sender || typeof sender !== "object")
9
+ return null;
10
+ const s = sender;
11
+ const id = typeof s.id === "string" ? s.id : typeof s.sender_id === "string" ? s.sender_id : "";
12
+ if (!id)
13
+ 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 } : {}) };
21
+ }
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);
35
+ }
36
+ return false;
37
+ }
38
+ /**
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.
43
+ */
44
+ export function detectMention(params) {
45
+ if (params.senderType === "direct")
46
+ return true;
47
+ return params.mentions.includes(params.userId);
48
+ }
49
+ export async function dispatchOpenclawClawlingInbound(params) {
50
+ const { envelope, account, log } = params;
51
+ if (!isInboundMessagePayload(envelope.payload)) {
52
+ log?.info?.(`[${account.accountId}] openclaw-clawchat skip: invalid payload trace=${envelope.trace_id}`);
53
+ return;
54
+ }
55
+ 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);
60
+ if (!sender) {
61
+ log?.info?.(`[${account.accountId}] openclaw-clawchat skip: missing sender trace=${envelope.trace_id}`);
62
+ return;
63
+ }
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";
68
+ const isGroup = chatType === "group";
69
+ if (payload.message_mode !== "normal") {
70
+ log?.info?.(`[${account.accountId}] openclaw-clawchat skip non-normal mode=${payload.message_mode}`);
71
+ return;
72
+ }
73
+ if (!hasRenderableText(message)) {
74
+ log?.info?.(`[${account.accountId}] openclaw-clawchat skip empty msg=${payload.message_id}`);
75
+ return;
76
+ }
77
+ if (rememberAndCheck(payload.message_id)) {
78
+ log?.info?.(`[${account.accountId}] openclaw-clawchat skip duplicate msg=${payload.message_id}`);
79
+ return;
80
+ }
81
+ const rawBody = fragmentsToText(message.body.fragments, {
82
+ mentionFallbackIds: message.context.mentions,
83
+ });
84
+ const mediaItems = extractMediaFragments(message.body.fragments);
85
+ const wasMentioned = detectMention({
86
+ mentions: message.context.mentions,
87
+ senderType: chatType,
88
+ userId: account.userId,
89
+ });
90
+ // Group trigger policy: in "mention" mode we only handle group messages
91
+ // that @-mention us; "all" listens open and processes every group msg.
92
+ // Direct chats are unaffected (detectMention returns true).
93
+ if (isGroup && account.groupMode === "mention" && !wasMentioned) {
94
+ log?.info?.(`[${account.accountId}] openclaw-clawchat skip group (no mention) msg=${payload.message_id}`);
95
+ return;
96
+ }
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;
102
+ const replyCtx = message.context.reply
103
+ ? {
104
+ replyToMessageId: message.context.reply.reply_to_msg_id,
105
+ 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
+ "",
112
+ replyPreviewText: fragmentsToText(message.context.reply.reply_preview.fragments),
113
+ }
114
+ : undefined;
115
+ await params.ingest({
116
+ channel: "openclaw-clawchat",
117
+ accountId: account.accountId,
118
+ peer: { kind: isGroup ? "group" : "direct", id: chatId },
119
+ senderId: sender.id,
120
+ senderNickName: sender.nickName,
121
+ rawBody,
122
+ messageId: payload.message_id,
123
+ traceId: envelope.trace_id,
124
+ timestamp: envelope.emitted_at,
125
+ wasMentioned,
126
+ mediaItems,
127
+ ...(replyCtx ? { replyCtx } : {}),
128
+ cfg: params.cfg,
129
+ runtime: params.runtime,
130
+ account,
131
+ envelope,
132
+ });
133
+ }