@newbase-clawchat/openclaw-clawchat 2026.5.4 → 2026.5.12-13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/INSTALL.md +64 -0
  2. package/README.md +121 -19
  3. package/dist/index.js +10 -19
  4. package/dist/setup-entry.js +3 -0
  5. package/dist/src/api-client.js +78 -10
  6. package/dist/src/api-types.test-d.js +10 -0
  7. package/dist/src/channel.js +25 -156
  8. package/dist/src/channel.setup.js +120 -0
  9. package/dist/src/client.js +37 -41
  10. package/dist/src/config.js +75 -17
  11. package/dist/src/inbound.js +79 -61
  12. package/dist/src/login.runtime.js +84 -19
  13. package/dist/src/media-runtime.js +8 -8
  14. package/dist/src/message-mapper.js +1 -1
  15. package/dist/src/mock-transport.js +31 -0
  16. package/dist/src/outbound.js +410 -26
  17. package/dist/src/protocol-types.js +63 -0
  18. package/dist/src/protocol-types.typecheck.js +1 -0
  19. package/dist/src/protocol.js +2 -7
  20. package/dist/src/reply-dispatcher.js +157 -54
  21. package/dist/src/runtime.js +795 -119
  22. package/dist/src/storage.js +689 -0
  23. package/dist/src/tools-schema.js +98 -16
  24. package/dist/src/tools.js +422 -135
  25. package/dist/src/ws-alignment.js +178 -0
  26. package/dist/src/ws-client.js +588 -0
  27. package/dist/src/ws-log.js +19 -0
  28. package/index.ts +10 -22
  29. package/openclaw.plugin.json +37 -2
  30. package/package.json +17 -4
  31. package/setup-entry.ts +4 -0
  32. package/skills/clawchat/SKILL.md +88 -0
  33. package/src/api-client.test.ts +274 -14
  34. package/src/api-client.ts +138 -23
  35. package/src/api-types.test-d.ts +12 -0
  36. package/src/api-types.ts +90 -4
  37. package/src/buffered-stream.test.ts +14 -12
  38. package/src/buffered-stream.ts +1 -1
  39. package/src/channel.outbound.test.ts +269 -60
  40. package/src/channel.setup.ts +146 -0
  41. package/src/channel.test.ts +130 -24
  42. package/src/channel.ts +30 -186
  43. package/src/client.test.ts +197 -11
  44. package/src/client.ts +50 -57
  45. package/src/config.test.ts +108 -6
  46. package/src/config.ts +95 -24
  47. package/src/inbound.test.ts +288 -37
  48. package/src/inbound.ts +96 -84
  49. package/src/login.runtime.test.ts +347 -13
  50. package/src/login.runtime.ts +105 -23
  51. package/src/manifest.test.ts +146 -74
  52. package/src/media-runtime.test.ts +57 -2
  53. package/src/media-runtime.ts +26 -17
  54. package/src/message-mapper.test.ts +2 -2
  55. package/src/message-mapper.ts +2 -2
  56. package/src/mock-transport.test.ts +35 -0
  57. package/src/mock-transport.ts +38 -0
  58. package/src/outbound.test.ts +694 -73
  59. package/src/outbound.ts +484 -31
  60. package/src/plugin-entry.test.ts +1 -0
  61. package/src/protocol-types.test.ts +69 -0
  62. package/src/protocol-types.ts +296 -0
  63. package/src/protocol-types.typecheck.ts +89 -0
  64. package/src/protocol.test.ts +1 -6
  65. package/src/protocol.ts +2 -7
  66. package/src/reply-dispatcher.test.ts +819 -119
  67. package/src/reply-dispatcher.ts +202 -60
  68. package/src/runtime.test.ts +2120 -41
  69. package/src/runtime.ts +935 -142
  70. package/src/scripts.test.ts +85 -0
  71. package/src/storage.test.ts +793 -0
  72. package/src/storage.ts +1095 -0
  73. package/src/streaming.test.ts +9 -8
  74. package/src/streaming.ts +1 -1
  75. package/src/tools-schema.ts +148 -20
  76. package/src/tools.test.ts +377 -50
  77. package/src/tools.ts +574 -154
  78. package/src/ws-alignment.test.ts +103 -0
  79. package/src/ws-alignment.ts +275 -0
  80. package/src/ws-client.test.ts +1218 -0
  81. package/src/ws-client.ts +662 -0
  82. package/src/ws-log.test.ts +32 -0
  83. package/src/ws-log.ts +31 -0
  84. package/skills/clawchat-account-tools/SKILL.md +0 -26
  85. package/skills/clawchat-activate/SKILL.md +0 -47
package/src/outbound.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { ClawlingChatClient, Envelope, Fragment, MessageAckPayload } from "@newbase-clawchat/sdk";
1
+ import { MessageSendError, type Envelope, type Fragment, type MessageAckPayload, type MessageErrorPayload } from "./protocol-types.ts";
2
+ import type { ClawlingChatClient } from "./ws-client.ts";
2
3
  import {
3
4
  createAttachedChannelResultAdapter,
4
5
  type ChannelOutboundAdapter,
@@ -14,6 +15,13 @@ import {
14
15
  getOpenclawClawlingRuntime,
15
16
  waitForOpenclawClawlingClient,
16
17
  } from "./runtime.ts";
18
+ import {
19
+ clawChatDbPathForStateDir,
20
+ getClawChatStore,
21
+ type ClawChatStore,
22
+ } from "./storage.ts";
23
+ import { createAlignedWsQueue, type WsLogContext } from "./ws-alignment.ts";
24
+ import { formatWsLog } from "./ws-log.ts";
17
25
 
18
26
  export interface OutboundTarget {
19
27
  chatId: string;
@@ -42,6 +50,7 @@ export interface SendParams {
42
50
  richFragments?: Fragment[];
43
51
  mediaFragments?: ClawlingMediaFragment[];
44
52
  mentions?: string[];
53
+ messageId?: string;
45
54
  log?: LogSink;
46
55
  }
47
56
 
@@ -50,6 +59,333 @@ export interface SendResult {
50
59
  acceptedAt: number;
51
60
  }
52
61
 
62
+ const alignedOutboundQueues = new WeakMap<object, ReturnType<typeof createAlignedWsQueue>>();
63
+ const alignedOutboundContexts = new WeakMap<object, () => WsLogContext>();
64
+ const alignedOutboundMessageErrorTrackers = new WeakMap<object, {
65
+ pending: Set<string>;
66
+ listener: (env: Envelope) => void;
67
+ }>();
68
+ type AlignedOutboundClose = { code?: unknown; reason?: unknown } | undefined;
69
+ type AlignedOutboundCloseHandler = (close?: AlignedOutboundClose) => void;
70
+ type AlignedOutboundState = { to?: unknown } | undefined;
71
+ type AlignedOutboundStateHandler = (state?: AlignedOutboundState) => void;
72
+ const alignedOutboundCloseHandlers = new WeakMap<
73
+ object,
74
+ { handlers: Set<AlignedOutboundCloseHandler>; listener: AlignedOutboundCloseHandler }
75
+ >();
76
+ const alignedOutboundStateHandlers = new WeakMap<
77
+ object,
78
+ { handlers: Set<AlignedOutboundStateHandler>; listener: AlignedOutboundStateHandler }
79
+ >();
80
+
81
+ function addAlignedOutboundCloseHandler(
82
+ client: ClawlingChatClient,
83
+ handler: AlignedOutboundCloseHandler,
84
+ ): () => void {
85
+ const key = client as object;
86
+ let entry = alignedOutboundCloseHandlers.get(key);
87
+ if (!entry) {
88
+ const handlers = new Set<AlignedOutboundCloseHandler>();
89
+ const listener: AlignedOutboundCloseHandler = (close) => {
90
+ for (const current of [...handlers]) current(close);
91
+ };
92
+ entry = { handlers, listener };
93
+ alignedOutboundCloseHandlers.set(key, entry);
94
+ client.on("close", listener);
95
+ }
96
+ entry.handlers.add(handler);
97
+ return () => {
98
+ entry?.handlers.delete(handler);
99
+ };
100
+ }
101
+
102
+ function addAlignedOutboundStateHandler(
103
+ client: ClawlingChatClient,
104
+ handler: AlignedOutboundStateHandler,
105
+ ): () => void {
106
+ const key = client as object;
107
+ let entry = alignedOutboundStateHandlers.get(key);
108
+ if (!entry) {
109
+ const handlers = new Set<AlignedOutboundStateHandler>();
110
+ const listener: AlignedOutboundStateHandler = (state) => {
111
+ for (const current of [...handlers]) current(state);
112
+ };
113
+ entry = { handlers, listener };
114
+ alignedOutboundStateHandlers.set(key, entry);
115
+ client.on("state", listener);
116
+ }
117
+ entry.handlers.add(handler);
118
+ return () => {
119
+ entry?.handlers.delete(handler);
120
+ };
121
+ }
122
+
123
+ function getAlignedOutboundQueue(
124
+ client: ClawlingChatClient,
125
+ account: ResolvedOpenclawClawlingAccount,
126
+ log: LogSink | undefined,
127
+ ) {
128
+ const existing = alignedOutboundQueues.get(client as object);
129
+ if (existing) return existing;
130
+ const queue = createAlignedWsQueue({
131
+ accountId: account.accountId,
132
+ log: (msg) => log?.info?.(msg),
133
+ maxSize: 128,
134
+ context: () => alignedOutboundContexts.get(client as object)?.() ?? {
135
+ attempt: 1,
136
+ reconnectCount: 0,
137
+ state: "ready",
138
+ },
139
+ });
140
+ alignedOutboundQueues.set(client as object, queue);
141
+ return queue;
142
+ }
143
+
144
+ function getAlignedMessageErrorTracker(
145
+ client: ClawlingChatClient,
146
+ account: ResolvedOpenclawClawlingAccount,
147
+ log: LogSink | undefined,
148
+ ) {
149
+ const key = client as object;
150
+ const existing = alignedOutboundMessageErrorTrackers.get(key);
151
+ if (existing) return existing;
152
+ const pending = new Set<string>();
153
+ const listener = (env: Envelope) => {
154
+ if (env.event !== "message.error") return;
155
+ if (pending.has(env.trace_id)) {
156
+ (client as { markMessageErrorHandled?: (traceId: string) => void }).markMessageErrorHandled?.(env.trace_id);
157
+ return;
158
+ }
159
+ if ((client as { hasPendingAckTrace?: (traceId: string) => boolean }).hasPendingAckTrace?.(env.trace_id)) {
160
+ return;
161
+ }
162
+ const context = alignedOutboundContexts.get(key)?.() ?? {
163
+ attempt: 1,
164
+ reconnectCount: 0,
165
+ state: client.transportState === "open" ? "ready" : "reconnecting",
166
+ };
167
+ if (log?.info) {
168
+ log.info(
169
+ formatWsLog({
170
+ event: "ack_unmatched",
171
+ accountId: account.accountId,
172
+ attempt: context.attempt,
173
+ reconnectCount: context.reconnectCount,
174
+ state: context.state,
175
+ action: "ignore",
176
+ fields: [
177
+ ["trace_id", env.trace_id],
178
+ ["chat_id", env.chat_id],
179
+ ],
180
+ }),
181
+ );
182
+ (client as { markMessageErrorHandled?: (traceId: string) => void }).markMessageErrorHandled?.(env.trace_id);
183
+ }
184
+ };
185
+ client.on("raw", listener);
186
+ const tracker = { pending, listener };
187
+ alignedOutboundMessageErrorTrackers.set(key, tracker);
188
+ return tracker;
189
+ }
190
+
191
+ export function setAlignedOutboundLogContext(
192
+ client: ClawlingChatClient,
193
+ context: () => WsLogContext,
194
+ ): void {
195
+ alignedOutboundContexts.set(client as object, context);
196
+ }
197
+
198
+ export function flushAlignedOutboundQueue(client: ClawlingChatClient): void {
199
+ const queue = alignedOutboundQueues.get(client as object);
200
+ queue?.flush((wire) => client.sendWire(wire));
201
+ }
202
+
203
+ export function getAlignedOutboundQueueSize(client: ClawlingChatClient): number {
204
+ return alignedOutboundQueues.get(client as object)?.snapshot().length ?? 0;
205
+ }
206
+
207
+ async function sendAlignedAckableEnvelope(params: {
208
+ client: ClawlingChatClient;
209
+ account: ResolvedOpenclawClawlingAccount;
210
+ eventName: "message.send" | "message.reply";
211
+ chatId: string;
212
+ payload: object;
213
+ log?: LogSink;
214
+ }): Promise<Envelope<MessageAckPayload>> {
215
+ const traceId = params.client.nextTraceId();
216
+ const env = {
217
+ version: "2" as const,
218
+ event: params.eventName,
219
+ trace_id: traceId,
220
+ emitted_at: Date.now(),
221
+ chat_id: params.chatId,
222
+ payload: params.payload,
223
+ };
224
+ const wire = JSON.stringify(env);
225
+ const queue = getAlignedOutboundQueue(params.client, params.account, params.log);
226
+ const messageErrorTracker = getAlignedMessageErrorTracker(params.client, params.account, params.log);
227
+ const isReady = () => {
228
+ const state = (params.client as { state?: string }).state;
229
+ return params.client.transportState === "open" && (!state || state === "connected");
230
+ };
231
+ const isDisconnected = () => (params.client as { state?: string }).state === "disconnected";
232
+
233
+ type AckableSendState = "queued" | "written_waiting_ack" | "acked" | "failed";
234
+
235
+ return await new Promise<Envelope<MessageAckPayload>>((resolve, reject) => {
236
+ let state: AckableSendState = "queued";
237
+ let timer: ReturnType<typeof setTimeout> | undefined;
238
+ let rawListenerRegistered = false;
239
+ let removeCloseListener: (() => void) | undefined;
240
+ let removeStateListener: (() => void) | undefined;
241
+
242
+ const clearAckTimer = () => {
243
+ if (!timer) return;
244
+ clearTimeout(timer);
245
+ timer = undefined;
246
+ };
247
+
248
+ const removeRawListener = () => {
249
+ if (!rawListenerRegistered) return;
250
+ params.client.off("raw", onRaw);
251
+ rawListenerRegistered = false;
252
+ };
253
+
254
+ const cleanup = () => {
255
+ messageErrorTracker.pending.delete(traceId);
256
+ clearAckTimer();
257
+ removeRawListener();
258
+ removeCloseListener?.();
259
+ removeCloseListener = undefined;
260
+ removeStateListener?.();
261
+ removeStateListener = undefined;
262
+ };
263
+
264
+ const fail = (err: Error) => {
265
+ if (state === "acked" || state === "failed") return;
266
+ state = "failed";
267
+ cleanup();
268
+ reject(err);
269
+ };
270
+
271
+ const logAck = (
272
+ event: "ack_received" | "ack_timeout",
273
+ action: "resolve" | "reject_no_reconnect",
274
+ fields: Array<[string, string | number | boolean | null | undefined]>,
275
+ ) => {
276
+ params.log?.info?.(
277
+ formatWsLog({
278
+ event,
279
+ accountId: params.account.accountId,
280
+ ...(
281
+ alignedOutboundContexts.get(params.client as object)?.() ?? {
282
+ attempt: 1,
283
+ reconnectCount: 0,
284
+ state: isReady() ? "ready" : "reconnecting",
285
+ }
286
+ ),
287
+ action,
288
+ fields: [
289
+ ["event_name", params.eventName],
290
+ ["trace_id", traceId],
291
+ ["chat_id", params.chatId],
292
+ ...fields,
293
+ ],
294
+ }),
295
+ );
296
+ };
297
+
298
+ function onRaw(ack: Envelope) {
299
+ if (ack.trace_id !== traceId) return;
300
+ if (ack.event === "message.error") {
301
+ if (state === "acked" || state === "failed") return;
302
+ const payload = ack.payload as Partial<MessageErrorPayload>;
303
+ const code = typeof payload.code === "string" && payload.code ? payload.code : "unknown";
304
+ const message = typeof payload.message === "string" && payload.message ? payload.message : "message send failed";
305
+ fail(new MessageSendError(traceId, code, message, ack.chat_id));
306
+ return;
307
+ }
308
+ if (ack.event !== "message.ack") return;
309
+ if (state === "acked" || state === "failed") return;
310
+ state = "acked";
311
+ cleanup();
312
+ const payload = ack.payload as MessageAckPayload;
313
+ logAck("ack_received", "resolve", [["message_id", payload.message_id]]);
314
+ resolve(ack as Envelope<MessageAckPayload>);
315
+ }
316
+
317
+ const startAckTimer = () => {
318
+ if (state === "acked" || state === "failed") return;
319
+ state = "written_waiting_ack";
320
+ messageErrorTracker.pending.add(traceId);
321
+ clearAckTimer();
322
+ removeRawListener();
323
+ params.client.on("raw", onRaw);
324
+ rawListenerRegistered = true;
325
+ timer = setTimeout(() => {
326
+ logAck("ack_timeout", "reject_no_reconnect", [["timeout_ms", params.account.ack.timeout]]);
327
+ fail(new Error(`ack timeout after ${params.account.ack.timeout}ms for trace_id=${traceId}`));
328
+ }, params.account.ack.timeout);
329
+ };
330
+
331
+ const item = {
332
+ eventName: params.eventName,
333
+ traceId,
334
+ chatId: params.chatId,
335
+ wire,
336
+ onWrite: startAckTimer,
337
+ onDrop: () => {
338
+ fail(new Error(`send queue full; dropped ${params.eventName} before write for trace_id=${traceId}`));
339
+ },
340
+ };
341
+
342
+ function isTerminalClose(close: AlignedOutboundClose): boolean {
343
+ return close?.code === 1000 || close?.reason === "client close" || close?.reason === "auth failed";
344
+ }
345
+
346
+ function onClose(close?: AlignedOutboundClose) {
347
+ if (state === "acked" || state === "failed") return;
348
+ if (isTerminalClose(close)) {
349
+ const reason = typeof close?.reason === "string" && close.reason ? close.reason : "websocket closed";
350
+ queue.remove(item);
351
+ fail(new Error(`send cancelled because ${reason}`));
352
+ return;
353
+ }
354
+ if (state !== "written_waiting_ack") return;
355
+ clearAckTimer();
356
+ removeRawListener();
357
+ state = "queued";
358
+ queue.enqueue(item);
359
+ }
360
+
361
+ function onState(next?: AlignedOutboundState) {
362
+ if (state === "acked" || state === "failed" || next?.to !== "disconnected") return;
363
+ queue.remove(item);
364
+ fail(new Error("send cancelled because client disconnected"));
365
+ }
366
+
367
+ removeCloseListener = addAlignedOutboundCloseHandler(params.client, onClose);
368
+ removeStateListener = addAlignedOutboundStateHandler(params.client, onState);
369
+
370
+ if (!isReady()) {
371
+ if (isDisconnected()) {
372
+ fail(new Error("send cancelled because client disconnected"));
373
+ return;
374
+ }
375
+ queue.enqueue(item);
376
+ return;
377
+ }
378
+
379
+ try {
380
+ queue.enqueue(item);
381
+ queue.flush((queuedWire) => params.client.sendWire(queuedWire));
382
+ } catch {
383
+ // The queue keeps the failed frame at the head for reconnect retry, so
384
+ // keep this promise pending until the frame is written+acked, dropped, or timed out.
385
+ }
386
+ });
387
+ }
388
+
53
389
  /**
54
390
  * Parse an agent-initiated outbound recipient string into the new-protocol
55
391
  * `chat_id` + `chat_type` pair.
@@ -64,6 +400,8 @@ export interface SendResult {
64
400
  * - `clawchat:group:{chat_id}` → group
65
401
  * - `openclaw-clawchat:direct:{chat_id}` → direct
66
402
  * - `openclaw-clawchat:group:{chat_id}` → group
403
+ * - `direct:{chat_id}` → direct (host-normalized)
404
+ * - `group:{chat_id}` → group (host-normalized)
67
405
  * - bare `{chat_id}` → direct (backward compat)
68
406
  */
69
407
  export function parseOpenclawRecipient(to: string): { chatId: string; chatType: ChatType } {
@@ -75,6 +413,11 @@ export function parseOpenclawRecipient(to: string): { chatId: string; chatType:
75
413
 
76
414
  const scheme = raw.slice(0, firstColon).toLowerCase();
77
415
  const rest = raw.slice(firstColon + 1);
416
+ if (scheme === "direct" || scheme === "group") {
417
+ const chatId = rest.trim();
418
+ if (!chatId) throw new Error(`openclaw-clawchat: missing chat_id in "${to}"`);
419
+ return { chatId, chatType: scheme };
420
+ }
78
421
  if (scheme !== "cc" && scheme !== "clawchat" && scheme !== CHANNEL_ID) {
79
422
  return { chatId: raw, chatType: "direct" };
80
423
  }
@@ -105,43 +448,68 @@ export async function sendOpenclawClawlingText(params: SendParams): Promise<Send
105
448
 
106
449
  const mentions = params.mentions ?? [];
107
450
  const textFragments = text ? textToFragments(text) : [];
108
- // Cast at the SDK boundary: each MediaItem object is structurally compatible
109
- // with one of the SDK's narrow Fragment members (ImageFragment / FileFragment /
451
+ // Each MediaItem object is structurally compatible
452
+ // with one of the local narrow Fragment members (ImageFragment / FileFragment /
110
453
  // AudioFragment / VideoFragment) based on its runtime `kind`. The wide local
111
454
  // shape lets us build a single uniform array without a per-kind switch.
112
455
  const fragments = [...textFragments, ...richFragments, ...mediaFragments] as Fragment[];
113
456
 
114
- const useReply = params.replyCtx && mediaFragments.length === 0;
115
- if (params.replyCtx && mediaFragments.length > 0) {
116
- params.log?.info?.(
117
- `[${params.account.accountId}] openclaw-clawchat replyCtx + media: downgraded to sendMessage`,
118
- );
119
- }
457
+ const useReply = Boolean(params.replyCtx);
458
+ const messageId = params.messageId;
120
459
 
121
460
  let ack: Envelope<MessageAckPayload>;
122
461
  let mode: "send" | "reply";
123
462
  if (useReply && params.replyCtx) {
124
463
  mode = "reply";
125
- ack = await params.client.replyMessage({
126
- chat_id: params.to.chatId,
127
- mode: "normal",
128
- replyTo: {
129
- msgId: params.replyCtx.replyToMessageId,
130
- senderId: params.replyCtx.replyPreviewChatId ?? params.replyCtx.replyPreviewSenderId,
131
- nickName: params.replyCtx.replyPreviewNickName,
132
- fragments: [{ kind: "text", text: params.replyCtx.replyPreviewText }],
464
+ const payload = {
465
+ ...(messageId ? { message_id: messageId } : {}),
466
+ message_mode: "normal",
467
+ message: {
468
+ body: { fragments },
469
+ context: {
470
+ mentions,
471
+ reply: {
472
+ reply_to_msg_id: params.replyCtx.replyToMessageId,
473
+ reply_preview: {
474
+ id: params.replyCtx.replyPreviewSenderId,
475
+ nick_name: params.replyCtx.replyPreviewNickName,
476
+ fragments: [{ kind: "text", text: params.replyCtx.replyPreviewText }],
477
+ },
478
+ },
479
+ },
133
480
  },
134
- body: { fragments },
135
- context: { mentions },
136
- } as Parameters<ClawlingChatClient["replyMessage"]>[0]);
481
+ };
482
+ ack = await sendAlignedAckableEnvelope({
483
+ client: params.client,
484
+ account: params.account,
485
+ eventName: "message.reply",
486
+ chatId: params.to.chatId,
487
+ payload,
488
+ ...(params.log ? { log: params.log } : {}),
489
+ });
137
490
  } else {
138
491
  mode = "send";
139
- ack = await params.client.sendMessage({
140
- chat_id: params.to.chatId,
141
- mode: "normal",
142
- body: { fragments },
143
- context: { mentions, reply: null },
144
- } as Parameters<ClawlingChatClient["sendMessage"]>[0]);
492
+ const payload = {
493
+ ...(messageId ? { message_id: messageId } : {}),
494
+ message_mode: "normal",
495
+ message: {
496
+ body: { fragments },
497
+ context: { mentions, reply: null },
498
+ },
499
+ };
500
+ ack = await sendAlignedAckableEnvelope({
501
+ client: params.client,
502
+ account: params.account,
503
+ eventName: "message.send",
504
+ chatId: params.to.chatId,
505
+ payload,
506
+ ...(params.log ? { log: params.log } : {}),
507
+ });
508
+ }
509
+ if (messageId && ack.payload.message_id !== messageId) {
510
+ throw new Error(
511
+ `ack message_id mismatch: expected ${messageId} got ${ack.payload.message_id}`,
512
+ );
145
513
  }
146
514
  params.log?.info?.(
147
515
  `[${params.account.accountId}] openclaw-clawchat outbound mode=${mode} msg=${ack.payload.message_id} text_len=${text.length} media=${mediaFragments.length} trace=${ack.trace_id}`,
@@ -161,6 +529,7 @@ export interface SendMediaParams {
161
529
  text?: string;
162
530
  replyCtx?: OutboundReplyCtx;
163
531
  mentions?: string[];
532
+ messageId?: string;
164
533
  log?: LogSink;
165
534
  }
166
535
 
@@ -188,12 +557,57 @@ export async function sendOpenclawClawlingMedia(
188
557
  to: params.to,
189
558
  text: params.text ?? "",
190
559
  mediaFragments: params.mediaFragments,
560
+ ...(params.messageId ? { messageId: params.messageId } : {}),
191
561
  ...(params.replyCtx ? { replyCtx: params.replyCtx } : {}),
192
562
  ...(params.mentions ? { mentions: params.mentions } : {}),
193
563
  ...(params.log ? { log: params.log } : {}),
194
564
  });
195
565
  }
196
566
 
567
+ type OutboundClaimStore = Pick<ClawChatStore, "claimMessageOnce">;
568
+
569
+ function mintOutboundMessageId(account: ResolvedOpenclawClawlingAccount): string {
570
+ return `${account.userId}-msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
571
+ }
572
+
573
+ function resolveChannelOutboundStore(): OutboundClaimStore | null {
574
+ try {
575
+ const runtime = getOpenclawClawlingRuntime();
576
+ const stateDir = runtime.state?.resolveStateDir?.();
577
+ return getClawChatStore({
578
+ ...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
579
+ });
580
+ } catch {
581
+ return null;
582
+ }
583
+ }
584
+
585
+ function claimChannelOutbound(params: {
586
+ account: ResolvedOpenclawClawlingAccount;
587
+ target: OutboundTarget;
588
+ messageId: string;
589
+ text: string;
590
+ raw: unknown;
591
+ }): true | false | null {
592
+ const store = resolveChannelOutboundStore();
593
+ if (!store) return null;
594
+ try {
595
+ return store.claimMessageOnce({
596
+ platform: "openclaw",
597
+ accountId: params.account.accountId,
598
+ kind: "message",
599
+ direction: "outbound",
600
+ eventType: "message.send",
601
+ chatId: params.target.chatId,
602
+ messageId: params.messageId,
603
+ text: params.text,
604
+ raw: params.raw,
605
+ });
606
+ } catch {
607
+ return null;
608
+ }
609
+ }
610
+
197
611
  export const openclawClawlingOutbound: ChannelOutboundAdapter = {
198
612
  deliveryMode: "direct",
199
613
  chunker: (text, limit) => chunkMarkdownText(text, limit),
@@ -206,18 +620,38 @@ export const openclawClawlingOutbound: ChannelOutboundAdapter = {
206
620
  const client =
207
621
  getOpenclawClawlingClient(account.accountId) ??
208
622
  (await waitForOpenclawClawlingClient(account.accountId));
623
+ const target = parseOpenclawRecipient(to);
624
+ const messageId = mintOutboundMessageId(account);
625
+ const trimmedText = text.trim();
626
+ if (!trimmedText) {
627
+ throw new Error("openclaw-clawchat sendText requires non-empty text");
628
+ }
629
+ const claimed = claimChannelOutbound({
630
+ account,
631
+ target,
632
+ messageId,
633
+ text: trimmedText,
634
+ raw: { target, mode: "channel-sendText" },
635
+ });
636
+ if (claimed === false) {
637
+ throw new Error("openclaw-clawchat outbound duplicate claim; message not sent");
638
+ }
639
+ if (claimed === null) {
640
+ throw new Error("openclaw-clawchat outbound message claim failed");
641
+ }
209
642
  const result = await sendOpenclawClawlingText({
210
643
  client,
211
644
  account,
212
- to: parseOpenclawRecipient(to),
645
+ to: target,
213
646
  text,
647
+ messageId,
214
648
  });
215
649
  return {
216
650
  to,
217
- messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
651
+ messageId: result?.messageId ?? messageId,
218
652
  };
219
653
  },
220
- sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots }) => {
654
+ sendMedia: async ({ cfg, to, text, mediaUrl, mediaAccess, mediaLocalRoots, mediaReadFile }) => {
221
655
  const account = resolveOpenclawClawlingAccount(cfg);
222
656
  const client =
223
657
  getOpenclawClawlingClient(account.accountId) ??
@@ -234,21 +668,40 @@ export const openclawClawlingOutbound: ChannelOutboundAdapter = {
234
668
  const mediaFragments = await uploadOutboundMedia([mediaUrl.trim()], {
235
669
  apiClient,
236
670
  runtime,
671
+ ...(mediaAccess ? { mediaAccess } : {}),
237
672
  ...(mediaLocalRoots ? { mediaLocalRoots } : {}),
673
+ ...(mediaReadFile ? { mediaReadFile } : {}),
238
674
  });
239
675
  if (mediaFragments.length === 0) {
240
676
  throw new Error(`openclaw-clawchat failed to upload media: ${mediaUrl}`);
241
677
  }
678
+ const target = parseOpenclawRecipient(to);
679
+ const messageId = mintOutboundMessageId(account);
680
+ const claimText = (text ?? "").trim();
681
+ const claimed = claimChannelOutbound({
682
+ account,
683
+ target,
684
+ messageId,
685
+ text: claimText,
686
+ raw: { target, mode: "channel-sendMedia", mediaCount: mediaFragments.length },
687
+ });
688
+ if (claimed === false) {
689
+ throw new Error("openclaw-clawchat outbound duplicate claim; message not sent");
690
+ }
691
+ if (claimed === null) {
692
+ throw new Error("openclaw-clawchat outbound message claim failed");
693
+ }
242
694
  const result = await sendOpenclawClawlingMedia({
243
695
  client,
244
696
  account,
245
- to: parseOpenclawRecipient(to),
697
+ to: target,
246
698
  text,
247
699
  mediaFragments,
700
+ messageId,
248
701
  });
249
702
  return {
250
703
  to,
251
- messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
704
+ messageId: result?.messageId ?? messageId,
252
705
  };
253
706
  },
254
707
  }),
@@ -5,6 +5,7 @@ describe("openclaw-clawchat plugin entry", () => {
5
5
  it("registers the channel/tools and native activation command without bootstrap migration", () => {
6
6
  const mutateConfigFile = vi.fn();
7
7
  const api = {
8
+ registrationMode: "full",
8
9
  config: {},
9
10
  runtime: {
10
11
  config: {