@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
@@ -1,19 +1,55 @@
1
+ import { MessageSendError } from "./protocol-types.js";
1
2
  import { createAttachedChannelResultAdapter, } from "openclaw/plugin-sdk/channel-send-result";
2
3
  import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
3
4
  import { createOpenclawClawlingApiClient } from "./api-client.js";
4
5
  import { CHANNEL_ID, resolveOpenclawClawlingAccount } from "./config.js";
5
6
  import { textToFragments } from "./message-mapper.js";
6
7
  import { uploadOutboundMedia } from "./media-runtime.js";
8
+ import { isClawChatNoopResponseText } from "./profile-prompt.js";
7
9
  import { getOpenclawClawlingClient, getOpenclawClawlingRuntime, waitForOpenclawClawlingClient, } from "./runtime.js";
10
+ import { clawChatDbPathForStateDir, getClawChatStore, } from "./storage.js";
8
11
  import { createAlignedWsQueue } from "./ws-alignment.js";
9
12
  import { formatWsLog } from "./ws-log.js";
10
13
  const alignedOutboundQueues = new WeakMap();
11
14
  const alignedOutboundContexts = new WeakMap();
12
- function getRawClientInternals(client) {
13
- const raw = client;
14
- if (!raw.opts?.transport || !raw.opts.traceIdFactory || !raw.on)
15
- return null;
16
- return raw;
15
+ const alignedOutboundMessageErrorTrackers = new WeakMap();
16
+ const alignedOutboundCloseHandlers = new WeakMap();
17
+ const alignedOutboundStateHandlers = new WeakMap();
18
+ function addAlignedOutboundCloseHandler(client, handler) {
19
+ const key = client;
20
+ let entry = alignedOutboundCloseHandlers.get(key);
21
+ if (!entry) {
22
+ const handlers = new Set();
23
+ const listener = (close) => {
24
+ for (const current of [...handlers])
25
+ current(close);
26
+ };
27
+ entry = { handlers, listener };
28
+ alignedOutboundCloseHandlers.set(key, entry);
29
+ client.on("close", listener);
30
+ }
31
+ entry.handlers.add(handler);
32
+ return () => {
33
+ entry?.handlers.delete(handler);
34
+ };
35
+ }
36
+ function addAlignedOutboundStateHandler(client, handler) {
37
+ const key = client;
38
+ let entry = alignedOutboundStateHandlers.get(key);
39
+ if (!entry) {
40
+ const handlers = new Set();
41
+ const listener = (state) => {
42
+ for (const current of [...handlers])
43
+ current(state);
44
+ };
45
+ entry = { handlers, listener };
46
+ alignedOutboundStateHandlers.set(key, entry);
47
+ client.on("state", listener);
48
+ }
49
+ entry.handlers.add(handler);
50
+ return () => {
51
+ entry?.handlers.delete(handler);
52
+ };
17
53
  }
18
54
  function getAlignedOutboundQueue(client, account, log) {
19
55
  const existing = alignedOutboundQueues.get(client);
@@ -32,25 +68,60 @@ function getAlignedOutboundQueue(client, account, log) {
32
68
  alignedOutboundQueues.set(client, queue);
33
69
  return queue;
34
70
  }
71
+ function getAlignedMessageErrorTracker(client, account, log) {
72
+ const key = client;
73
+ const existing = alignedOutboundMessageErrorTrackers.get(key);
74
+ if (existing)
75
+ return existing;
76
+ const pending = new Set();
77
+ const listener = (env) => {
78
+ if (env.event !== "message.error")
79
+ return;
80
+ if (pending.has(env.trace_id)) {
81
+ client.markMessageErrorHandled?.(env.trace_id);
82
+ return;
83
+ }
84
+ if (client.hasPendingAckTrace?.(env.trace_id)) {
85
+ return;
86
+ }
87
+ const context = alignedOutboundContexts.get(key)?.() ?? {
88
+ attempt: 1,
89
+ reconnectCount: 0,
90
+ state: client.transportState === "open" ? "ready" : "reconnecting",
91
+ };
92
+ if (log?.info) {
93
+ log.info(formatWsLog({
94
+ event: "ack_unmatched",
95
+ accountId: account.accountId,
96
+ attempt: context.attempt,
97
+ reconnectCount: context.reconnectCount,
98
+ state: context.state,
99
+ action: "ignore",
100
+ fields: [
101
+ ["trace_id", env.trace_id],
102
+ ["chat_id", env.chat_id],
103
+ ],
104
+ }));
105
+ client.markMessageErrorHandled?.(env.trace_id);
106
+ }
107
+ };
108
+ client.on("raw", listener);
109
+ const tracker = { pending, listener };
110
+ alignedOutboundMessageErrorTrackers.set(key, tracker);
111
+ return tracker;
112
+ }
35
113
  export function setAlignedOutboundLogContext(client, context) {
36
114
  alignedOutboundContexts.set(client, context);
37
115
  }
38
116
  export function flushAlignedOutboundQueue(client) {
39
- const raw = getRawClientInternals(client);
40
- if (!raw?.opts?.transport)
41
- return;
42
117
  const queue = alignedOutboundQueues.get(client);
43
- queue?.flush((wire) => raw.opts.transport.send(wire));
118
+ queue?.flush((wire) => client.sendWire(wire));
44
119
  }
45
120
  export function getAlignedOutboundQueueSize(client) {
46
121
  return alignedOutboundQueues.get(client)?.snapshot().length ?? 0;
47
122
  }
48
123
  async function sendAlignedAckableEnvelope(params) {
49
- const raw = getRawClientInternals(params.client);
50
- if (!raw?.opts?.transport || !raw.opts.traceIdFactory)
51
- return null;
52
- const transport = raw.opts.transport;
53
- const traceId = raw.opts.traceIdFactory();
124
+ const traceId = params.client.nextTraceId();
54
125
  const env = {
55
126
  version: "2",
56
127
  event: params.eventName,
@@ -61,15 +132,45 @@ async function sendAlignedAckableEnvelope(params) {
61
132
  };
62
133
  const wire = JSON.stringify(env);
63
134
  const queue = getAlignedOutboundQueue(params.client, params.account, params.log);
135
+ const messageErrorTracker = getAlignedMessageErrorTracker(params.client, params.account, params.log);
136
+ const isReady = () => {
137
+ const state = params.client.state;
138
+ return params.client.transportState === "open" && (!state || state === "connected");
139
+ };
140
+ const isDisconnected = () => params.client.state === "disconnected";
64
141
  return await new Promise((resolve, reject) => {
142
+ let state = "queued";
65
143
  let timer;
144
+ let rawListenerRegistered = false;
145
+ let removeCloseListener;
146
+ let removeStateListener;
147
+ const clearAckTimer = () => {
148
+ if (!timer)
149
+ return;
150
+ clearTimeout(timer);
151
+ timer = undefined;
152
+ };
153
+ const removeRawListener = () => {
154
+ if (!rawListenerRegistered)
155
+ return;
156
+ params.client.off("raw", onRaw);
157
+ rawListenerRegistered = false;
158
+ };
66
159
  const cleanup = () => {
67
- if (timer)
68
- clearTimeout(timer);
69
- if (raw.off)
70
- raw.off("raw", onRaw);
71
- else
72
- raw.removeListener?.("raw", onRaw);
160
+ messageErrorTracker.pending.delete(traceId);
161
+ clearAckTimer();
162
+ removeRawListener();
163
+ removeCloseListener?.();
164
+ removeCloseListener = undefined;
165
+ removeStateListener?.();
166
+ removeStateListener = undefined;
167
+ };
168
+ const fail = (err) => {
169
+ if (state === "acked" || state === "failed")
170
+ return;
171
+ state = "failed";
172
+ cleanup();
173
+ reject(err);
73
174
  };
74
175
  const logAck = (event, action, fields) => {
75
176
  params.log?.info?.(formatWsLog({
@@ -78,7 +179,7 @@ async function sendAlignedAckableEnvelope(params) {
78
179
  ...(alignedOutboundContexts.get(params.client)?.() ?? {
79
180
  attempt: 1,
80
181
  reconnectCount: 0,
81
- state: transport.state === "open" ? "ready" : "reconnecting",
182
+ state: isReady() ? "ready" : "reconnecting",
82
183
  }),
83
184
  action,
84
185
  fields: [
@@ -89,20 +190,40 @@ async function sendAlignedAckableEnvelope(params) {
89
190
  ],
90
191
  }));
91
192
  };
92
- const onRaw = (ack) => {
93
- if (ack.event !== "message.ack" || ack.trace_id !== traceId)
193
+ function onRaw(ack) {
194
+ if (ack.trace_id !== traceId)
195
+ return;
196
+ if (ack.event === "message.error") {
197
+ if (state === "acked" || state === "failed")
198
+ return;
199
+ const payload = ack.payload;
200
+ const code = typeof payload.code === "string" && payload.code ? payload.code : "unknown";
201
+ const message = typeof payload.message === "string" && payload.message ? payload.message : "message send failed";
202
+ fail(new MessageSendError(traceId, code, message, ack.chat_id));
94
203
  return;
204
+ }
205
+ if (ack.event !== "message.ack")
206
+ return;
207
+ if (state === "acked" || state === "failed")
208
+ return;
209
+ state = "acked";
95
210
  cleanup();
96
211
  const payload = ack.payload;
97
212
  logAck("ack_received", "resolve", [["message_id", payload.message_id]]);
98
213
  resolve(ack);
99
- };
214
+ }
100
215
  const startAckTimer = () => {
101
- raw.on?.("raw", onRaw);
216
+ if (state === "acked" || state === "failed")
217
+ return;
218
+ state = "written_waiting_ack";
219
+ messageErrorTracker.pending.add(traceId);
220
+ clearAckTimer();
221
+ removeRawListener();
222
+ params.client.on("raw", onRaw);
223
+ rawListenerRegistered = true;
102
224
  timer = setTimeout(() => {
103
- cleanup();
104
225
  logAck("ack_timeout", "reject_no_reconnect", [["timeout_ms", params.account.ack.timeout]]);
105
- reject(new Error(`ack timeout after ${params.account.ack.timeout}ms for trace_id=${traceId}`));
226
+ fail(new Error(`ack timeout after ${params.account.ack.timeout}ms for trace_id=${traceId}`));
106
227
  }, params.account.ack.timeout);
107
228
  };
108
229
  const item = {
@@ -111,18 +232,52 @@ async function sendAlignedAckableEnvelope(params) {
111
232
  chatId: params.chatId,
112
233
  wire,
113
234
  onWrite: startAckTimer,
235
+ onDrop: () => {
236
+ fail(new Error(`send queue full; dropped ${params.eventName} before write for trace_id=${traceId}`));
237
+ },
114
238
  };
115
- if (transport.state !== "open") {
239
+ function isTerminalClose(close) {
240
+ return close?.code === 1000 || close?.reason === "client close" || close?.reason === "auth failed";
241
+ }
242
+ function onClose(close) {
243
+ if (state === "acked" || state === "failed")
244
+ return;
245
+ if (isTerminalClose(close)) {
246
+ const reason = typeof close?.reason === "string" && close.reason ? close.reason : "websocket closed";
247
+ queue.remove(item);
248
+ fail(new Error(`send cancelled because ${reason}`));
249
+ return;
250
+ }
251
+ if (state !== "written_waiting_ack")
252
+ return;
253
+ clearAckTimer();
254
+ removeRawListener();
255
+ state = "queued";
256
+ queue.enqueue(item);
257
+ }
258
+ function onState(next) {
259
+ if (state === "acked" || state === "failed" || next?.to !== "disconnected")
260
+ return;
261
+ queue.remove(item);
262
+ fail(new Error("send cancelled because client disconnected"));
263
+ }
264
+ removeCloseListener = addAlignedOutboundCloseHandler(params.client, onClose);
265
+ removeStateListener = addAlignedOutboundStateHandler(params.client, onState);
266
+ if (!isReady()) {
267
+ if (isDisconnected()) {
268
+ fail(new Error("send cancelled because client disconnected"));
269
+ return;
270
+ }
116
271
  queue.enqueue(item);
117
272
  return;
118
273
  }
119
274
  try {
120
275
  queue.enqueue(item);
121
- queue.flush((queuedWire) => transport.send(queuedWire));
276
+ queue.flush((queuedWire) => params.client.sendWire(queuedWire));
122
277
  }
123
- catch (err) {
124
- cleanup();
125
- reject(err);
278
+ catch {
279
+ // The queue keeps the failed frame at the head for reconnect retry, so
280
+ // keep this promise pending until the frame is written+acked, dropped, or timed out.
126
281
  }
127
282
  });
128
283
  }
@@ -179,23 +334,31 @@ export async function sendOpenclawClawlingText(params) {
179
334
  const text = (params.text ?? "").trim();
180
335
  const richFragments = params.richFragments ?? [];
181
336
  const mediaFragments = params.mediaFragments ?? [];
337
+ if (isClawChatNoopResponseText(text) &&
338
+ richFragments.length === 0 &&
339
+ mediaFragments.length === 0) {
340
+ params.log?.info?.(`[${params.account.accountId}] openclaw-clawchat outbound suppressed: silent response`);
341
+ return null;
342
+ }
182
343
  if (!text && richFragments.length === 0 && mediaFragments.length === 0) {
183
344
  params.log?.info?.(`[${params.account.accountId}] openclaw-clawchat outbound suppressed: empty text and no media`);
184
345
  return null;
185
346
  }
186
347
  const mentions = params.mentions ?? [];
187
348
  const textFragments = text ? textToFragments(text) : [];
188
- // Cast at the SDK boundary: each MediaItem object is structurally compatible
189
- // with one of the SDK's narrow Fragment members (ImageFragment / FileFragment /
349
+ // Each MediaItem object is structurally compatible
350
+ // with one of the local narrow Fragment members (ImageFragment / FileFragment /
190
351
  // AudioFragment / VideoFragment) based on its runtime `kind`. The wide local
191
352
  // shape lets us build a single uniform array without a per-kind switch.
192
353
  const fragments = [...textFragments, ...richFragments, ...mediaFragments];
193
354
  const useReply = Boolean(params.replyCtx);
355
+ const messageId = params.messageId;
194
356
  let ack;
195
357
  let mode;
196
358
  if (useReply && params.replyCtx) {
197
359
  mode = "reply";
198
360
  const payload = {
361
+ ...(messageId ? { message_id: messageId } : {}),
199
362
  message_mode: "normal",
200
363
  message: {
201
364
  body: { fragments },
@@ -212,49 +375,37 @@ export async function sendOpenclawClawlingText(params) {
212
375
  },
213
376
  },
214
377
  };
215
- ack = (await sendAlignedAckableEnvelope({
378
+ ack = await sendAlignedAckableEnvelope({
216
379
  client: params.client,
217
380
  account: params.account,
218
381
  eventName: "message.reply",
219
382
  chatId: params.to.chatId,
220
383
  payload,
221
384
  ...(params.log ? { log: params.log } : {}),
222
- })) ?? await params.client.replyMessage({
223
- chat_id: params.to.chatId,
224
- mode: "normal",
225
- replyTo: {
226
- msgId: params.replyCtx.replyToMessageId,
227
- senderId: params.replyCtx.replyPreviewSenderId,
228
- nickName: params.replyCtx.replyPreviewNickName,
229
- fragments: [{ kind: "text", text: params.replyCtx.replyPreviewText }],
230
- },
231
- body: { fragments },
232
- context: { mentions },
233
385
  });
234
386
  }
235
387
  else {
236
388
  mode = "send";
237
389
  const payload = {
390
+ ...(messageId ? { message_id: messageId } : {}),
238
391
  message_mode: "normal",
239
392
  message: {
240
393
  body: { fragments },
241
394
  context: { mentions, reply: null },
242
395
  },
243
396
  };
244
- ack = (await sendAlignedAckableEnvelope({
397
+ ack = await sendAlignedAckableEnvelope({
245
398
  client: params.client,
246
399
  account: params.account,
247
400
  eventName: "message.send",
248
401
  chatId: params.to.chatId,
249
402
  payload,
250
403
  ...(params.log ? { log: params.log } : {}),
251
- })) ?? await params.client.sendMessage({
252
- chat_id: params.to.chatId,
253
- mode: "normal",
254
- body: { fragments },
255
- context: { mentions, reply: null },
256
404
  });
257
405
  }
406
+ if (messageId && ack.payload.message_id !== messageId) {
407
+ throw new Error(`ack message_id mismatch: expected ${messageId} got ${ack.payload.message_id}`);
408
+ }
258
409
  params.log?.info?.(`[${params.account.accountId}] openclaw-clawchat outbound mode=${mode} msg=${ack.payload.message_id} text_len=${text.length} media=${mediaFragments.length} trace=${ack.trace_id}`);
259
410
  return {
260
411
  messageId: ack.payload.message_id,
@@ -281,11 +432,48 @@ export async function sendOpenclawClawlingMedia(params) {
281
432
  to: params.to,
282
433
  text: params.text ?? "",
283
434
  mediaFragments: params.mediaFragments,
435
+ ...(params.messageId ? { messageId: params.messageId } : {}),
284
436
  ...(params.replyCtx ? { replyCtx: params.replyCtx } : {}),
285
437
  ...(params.mentions ? { mentions: params.mentions } : {}),
286
438
  ...(params.log ? { log: params.log } : {}),
287
439
  });
288
440
  }
441
+ function mintOutboundMessageId(account) {
442
+ return `${account.userId}-msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
443
+ }
444
+ function resolveChannelOutboundStore() {
445
+ try {
446
+ const runtime = getOpenclawClawlingRuntime();
447
+ const stateDir = runtime.state?.resolveStateDir?.();
448
+ return getClawChatStore({
449
+ ...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
450
+ });
451
+ }
452
+ catch {
453
+ return null;
454
+ }
455
+ }
456
+ function claimChannelOutbound(params) {
457
+ const store = resolveChannelOutboundStore();
458
+ if (!store)
459
+ return null;
460
+ try {
461
+ return store.claimMessageOnce({
462
+ platform: "openclaw",
463
+ accountId: params.account.accountId,
464
+ kind: "message",
465
+ direction: "outbound",
466
+ eventType: "message.send",
467
+ chatId: params.target.chatId,
468
+ messageId: params.messageId,
469
+ text: params.text,
470
+ raw: params.raw,
471
+ });
472
+ }
473
+ catch {
474
+ return null;
475
+ }
476
+ }
289
477
  export const openclawClawlingOutbound = {
290
478
  deliveryMode: "direct",
291
479
  chunker: (text, limit) => chunkMarkdownText(text, limit),
@@ -297,15 +485,35 @@ export const openclawClawlingOutbound = {
297
485
  const account = resolveOpenclawClawlingAccount(cfg);
298
486
  const client = getOpenclawClawlingClient(account.accountId) ??
299
487
  (await waitForOpenclawClawlingClient(account.accountId));
488
+ const target = parseOpenclawRecipient(to);
489
+ const messageId = mintOutboundMessageId(account);
490
+ const trimmedText = text.trim();
491
+ if (!trimmedText) {
492
+ throw new Error("openclaw-clawchat sendText requires non-empty text");
493
+ }
494
+ const claimed = claimChannelOutbound({
495
+ account,
496
+ target,
497
+ messageId,
498
+ text: trimmedText,
499
+ raw: { target, mode: "channel-sendText" },
500
+ });
501
+ if (claimed === false) {
502
+ throw new Error("openclaw-clawchat outbound duplicate claim; message not sent");
503
+ }
504
+ if (claimed === null) {
505
+ throw new Error("openclaw-clawchat outbound message claim failed");
506
+ }
300
507
  const result = await sendOpenclawClawlingText({
301
508
  client,
302
509
  account,
303
- to: parseOpenclawRecipient(to),
510
+ to: target,
304
511
  text,
512
+ messageId,
305
513
  });
306
514
  return {
307
515
  to,
308
- messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
516
+ messageId: result?.messageId ?? messageId,
309
517
  };
310
518
  },
311
519
  sendMedia: async ({ cfg, to, text, mediaUrl, mediaAccess, mediaLocalRoots, mediaReadFile }) => {
@@ -331,16 +539,33 @@ export const openclawClawlingOutbound = {
331
539
  if (mediaFragments.length === 0) {
332
540
  throw new Error(`openclaw-clawchat failed to upload media: ${mediaUrl}`);
333
541
  }
542
+ const target = parseOpenclawRecipient(to);
543
+ const messageId = mintOutboundMessageId(account);
544
+ const claimText = (text ?? "").trim();
545
+ const claimed = claimChannelOutbound({
546
+ account,
547
+ target,
548
+ messageId,
549
+ text: claimText,
550
+ raw: { target, mode: "channel-sendMedia", mediaCount: mediaFragments.length },
551
+ });
552
+ if (claimed === false) {
553
+ throw new Error("openclaw-clawchat outbound duplicate claim; message not sent");
554
+ }
555
+ if (claimed === null) {
556
+ throw new Error("openclaw-clawchat outbound message claim failed");
557
+ }
334
558
  const result = await sendOpenclawClawlingMedia({
335
559
  client,
336
560
  account,
337
- to: parseOpenclawRecipient(to),
561
+ to: target,
338
562
  text,
339
563
  mediaFragments,
564
+ messageId,
340
565
  });
341
566
  return {
342
567
  to,
343
- messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
568
+ messageId: result?.messageId ?? messageId,
344
569
  };
345
570
  },
346
571
  }),
@@ -0,0 +1,76 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const REQUIRED_PROMPT_NAMES = ["platform"];
5
+ const OPTIONAL_PROMPT_NAMES = ["user", "group"];
6
+ function findDefaultPluginRoot() {
7
+ let current = path.dirname(fileURLToPath(import.meta.url));
8
+ const root = path.parse(current).root;
9
+ while (true) {
10
+ if (fs.existsSync(path.join(current, "prompts")) ||
11
+ fs.existsSync(path.join(current, "openclaw.plugin.json"))) {
12
+ return current;
13
+ }
14
+ if (current === root) {
15
+ return process.cwd();
16
+ }
17
+ current = path.dirname(current);
18
+ }
19
+ }
20
+ function readRequiredPrompt(root, name) {
21
+ const promptPath = path.join(root, "prompts", `${name}.md`);
22
+ let prompt = "";
23
+ try {
24
+ prompt = fs.readFileSync(promptPath, "utf8").trim();
25
+ }
26
+ catch (error) {
27
+ throw new Error(`missing or empty ClawChat prompt: ${name} at ${promptPath}`, {
28
+ cause: error,
29
+ });
30
+ }
31
+ if (prompt.length === 0) {
32
+ throw new Error(`missing or empty ClawChat prompt: ${name} at ${promptPath}`);
33
+ }
34
+ return prompt;
35
+ }
36
+ function readOptionalPrompt(root, name) {
37
+ const promptPath = path.join(root, "prompts", `${name}.md`);
38
+ if (!fs.existsSync(promptPath)) {
39
+ return "";
40
+ }
41
+ const prompt = fs.readFileSync(promptPath, "utf8").trim();
42
+ if (prompt.length === 0) {
43
+ throw new Error(`missing or empty ClawChat prompt: ${name} at ${promptPath}`);
44
+ }
45
+ return prompt;
46
+ }
47
+ export function loadClawChatPromptsFromRoot(root) {
48
+ const prompts = {
49
+ platform: "",
50
+ user: "",
51
+ group: "",
52
+ };
53
+ for (const name of REQUIRED_PROMPT_NAMES) {
54
+ prompts[name] = readRequiredPrompt(root, name);
55
+ }
56
+ for (const name of OPTIONAL_PROMPT_NAMES) {
57
+ prompts[name] = readOptionalPrompt(root, name);
58
+ }
59
+ return Object.freeze(prompts);
60
+ }
61
+ const CLAWCHAT_PROMPTS = loadClawChatPromptsFromRoot(findDefaultPluginRoot());
62
+ export function getClawChatPlatformPrompt() {
63
+ return CLAWCHAT_PROMPTS.platform;
64
+ }
65
+ export function getClawChatUserPrompt() {
66
+ return CLAWCHAT_PROMPTS.user;
67
+ }
68
+ export function getClawChatGroupPrompt() {
69
+ return CLAWCHAT_PROMPTS.group;
70
+ }
71
+ export function getClawChatModePrompt(mode) {
72
+ if (mode === "group") {
73
+ return getClawChatGroupPrompt();
74
+ }
75
+ return getClawChatUserPrompt();
76
+ }