@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
@@ -1,3 +1,4 @@
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";
@@ -5,6 +6,280 @@ import { CHANNEL_ID, resolveOpenclawClawlingAccount } from "./config.js";
5
6
  import { textToFragments } from "./message-mapper.js";
6
7
  import { uploadOutboundMedia } from "./media-runtime.js";
7
8
  import { getOpenclawClawlingClient, getOpenclawClawlingRuntime, waitForOpenclawClawlingClient, } from "./runtime.js";
9
+ import { clawChatDbPathForStateDir, getClawChatStore, } from "./storage.js";
10
+ import { createAlignedWsQueue } from "./ws-alignment.js";
11
+ import { formatWsLog } from "./ws-log.js";
12
+ const alignedOutboundQueues = new WeakMap();
13
+ const alignedOutboundContexts = new WeakMap();
14
+ const alignedOutboundMessageErrorTrackers = new WeakMap();
15
+ const alignedOutboundCloseHandlers = new WeakMap();
16
+ const alignedOutboundStateHandlers = new WeakMap();
17
+ function addAlignedOutboundCloseHandler(client, handler) {
18
+ const key = client;
19
+ let entry = alignedOutboundCloseHandlers.get(key);
20
+ if (!entry) {
21
+ const handlers = new Set();
22
+ const listener = (close) => {
23
+ for (const current of [...handlers])
24
+ current(close);
25
+ };
26
+ entry = { handlers, listener };
27
+ alignedOutboundCloseHandlers.set(key, entry);
28
+ client.on("close", listener);
29
+ }
30
+ entry.handlers.add(handler);
31
+ return () => {
32
+ entry?.handlers.delete(handler);
33
+ };
34
+ }
35
+ function addAlignedOutboundStateHandler(client, handler) {
36
+ const key = client;
37
+ let entry = alignedOutboundStateHandlers.get(key);
38
+ if (!entry) {
39
+ const handlers = new Set();
40
+ const listener = (state) => {
41
+ for (const current of [...handlers])
42
+ current(state);
43
+ };
44
+ entry = { handlers, listener };
45
+ alignedOutboundStateHandlers.set(key, entry);
46
+ client.on("state", listener);
47
+ }
48
+ entry.handlers.add(handler);
49
+ return () => {
50
+ entry?.handlers.delete(handler);
51
+ };
52
+ }
53
+ function getAlignedOutboundQueue(client, account, log) {
54
+ const existing = alignedOutboundQueues.get(client);
55
+ if (existing)
56
+ return existing;
57
+ const queue = createAlignedWsQueue({
58
+ accountId: account.accountId,
59
+ log: (msg) => log?.info?.(msg),
60
+ maxSize: 128,
61
+ context: () => alignedOutboundContexts.get(client)?.() ?? {
62
+ attempt: 1,
63
+ reconnectCount: 0,
64
+ state: "ready",
65
+ },
66
+ });
67
+ alignedOutboundQueues.set(client, queue);
68
+ return queue;
69
+ }
70
+ function getAlignedMessageErrorTracker(client, account, log) {
71
+ const key = client;
72
+ const existing = alignedOutboundMessageErrorTrackers.get(key);
73
+ if (existing)
74
+ return existing;
75
+ const pending = new Set();
76
+ const listener = (env) => {
77
+ if (env.event !== "message.error")
78
+ return;
79
+ if (pending.has(env.trace_id)) {
80
+ client.markMessageErrorHandled?.(env.trace_id);
81
+ return;
82
+ }
83
+ if (client.hasPendingAckTrace?.(env.trace_id)) {
84
+ return;
85
+ }
86
+ const context = alignedOutboundContexts.get(key)?.() ?? {
87
+ attempt: 1,
88
+ reconnectCount: 0,
89
+ state: client.transportState === "open" ? "ready" : "reconnecting",
90
+ };
91
+ if (log?.info) {
92
+ log.info(formatWsLog({
93
+ event: "ack_unmatched",
94
+ accountId: account.accountId,
95
+ attempt: context.attempt,
96
+ reconnectCount: context.reconnectCount,
97
+ state: context.state,
98
+ action: "ignore",
99
+ fields: [
100
+ ["trace_id", env.trace_id],
101
+ ["chat_id", env.chat_id],
102
+ ],
103
+ }));
104
+ client.markMessageErrorHandled?.(env.trace_id);
105
+ }
106
+ };
107
+ client.on("raw", listener);
108
+ const tracker = { pending, listener };
109
+ alignedOutboundMessageErrorTrackers.set(key, tracker);
110
+ return tracker;
111
+ }
112
+ export function setAlignedOutboundLogContext(client, context) {
113
+ alignedOutboundContexts.set(client, context);
114
+ }
115
+ export function flushAlignedOutboundQueue(client) {
116
+ const queue = alignedOutboundQueues.get(client);
117
+ queue?.flush((wire) => client.sendWire(wire));
118
+ }
119
+ export function getAlignedOutboundQueueSize(client) {
120
+ return alignedOutboundQueues.get(client)?.snapshot().length ?? 0;
121
+ }
122
+ async function sendAlignedAckableEnvelope(params) {
123
+ const traceId = params.client.nextTraceId();
124
+ const env = {
125
+ version: "2",
126
+ event: params.eventName,
127
+ trace_id: traceId,
128
+ emitted_at: Date.now(),
129
+ chat_id: params.chatId,
130
+ payload: params.payload,
131
+ };
132
+ const wire = JSON.stringify(env);
133
+ const queue = getAlignedOutboundQueue(params.client, params.account, params.log);
134
+ const messageErrorTracker = getAlignedMessageErrorTracker(params.client, params.account, params.log);
135
+ const isReady = () => {
136
+ const state = params.client.state;
137
+ return params.client.transportState === "open" && (!state || state === "connected");
138
+ };
139
+ const isDisconnected = () => params.client.state === "disconnected";
140
+ return await new Promise((resolve, reject) => {
141
+ let state = "queued";
142
+ let timer;
143
+ let rawListenerRegistered = false;
144
+ let removeCloseListener;
145
+ let removeStateListener;
146
+ const clearAckTimer = () => {
147
+ if (!timer)
148
+ return;
149
+ clearTimeout(timer);
150
+ timer = undefined;
151
+ };
152
+ const removeRawListener = () => {
153
+ if (!rawListenerRegistered)
154
+ return;
155
+ params.client.off("raw", onRaw);
156
+ rawListenerRegistered = false;
157
+ };
158
+ const cleanup = () => {
159
+ messageErrorTracker.pending.delete(traceId);
160
+ clearAckTimer();
161
+ removeRawListener();
162
+ removeCloseListener?.();
163
+ removeCloseListener = undefined;
164
+ removeStateListener?.();
165
+ removeStateListener = undefined;
166
+ };
167
+ const fail = (err) => {
168
+ if (state === "acked" || state === "failed")
169
+ return;
170
+ state = "failed";
171
+ cleanup();
172
+ reject(err);
173
+ };
174
+ const logAck = (event, action, fields) => {
175
+ params.log?.info?.(formatWsLog({
176
+ event,
177
+ accountId: params.account.accountId,
178
+ ...(alignedOutboundContexts.get(params.client)?.() ?? {
179
+ attempt: 1,
180
+ reconnectCount: 0,
181
+ state: isReady() ? "ready" : "reconnecting",
182
+ }),
183
+ action,
184
+ fields: [
185
+ ["event_name", params.eventName],
186
+ ["trace_id", traceId],
187
+ ["chat_id", params.chatId],
188
+ ...fields,
189
+ ],
190
+ }));
191
+ };
192
+ function onRaw(ack) {
193
+ if (ack.trace_id !== traceId)
194
+ return;
195
+ if (ack.event === "message.error") {
196
+ if (state === "acked" || state === "failed")
197
+ return;
198
+ const payload = ack.payload;
199
+ const code = typeof payload.code === "string" && payload.code ? payload.code : "unknown";
200
+ const message = typeof payload.message === "string" && payload.message ? payload.message : "message send failed";
201
+ fail(new MessageSendError(traceId, code, message, ack.chat_id));
202
+ return;
203
+ }
204
+ if (ack.event !== "message.ack")
205
+ return;
206
+ if (state === "acked" || state === "failed")
207
+ return;
208
+ state = "acked";
209
+ cleanup();
210
+ const payload = ack.payload;
211
+ logAck("ack_received", "resolve", [["message_id", payload.message_id]]);
212
+ resolve(ack);
213
+ }
214
+ const startAckTimer = () => {
215
+ if (state === "acked" || state === "failed")
216
+ return;
217
+ state = "written_waiting_ack";
218
+ messageErrorTracker.pending.add(traceId);
219
+ clearAckTimer();
220
+ removeRawListener();
221
+ params.client.on("raw", onRaw);
222
+ rawListenerRegistered = true;
223
+ timer = setTimeout(() => {
224
+ logAck("ack_timeout", "reject_no_reconnect", [["timeout_ms", params.account.ack.timeout]]);
225
+ fail(new Error(`ack timeout after ${params.account.ack.timeout}ms for trace_id=${traceId}`));
226
+ }, params.account.ack.timeout);
227
+ };
228
+ const item = {
229
+ eventName: params.eventName,
230
+ traceId,
231
+ chatId: params.chatId,
232
+ wire,
233
+ onWrite: startAckTimer,
234
+ onDrop: () => {
235
+ fail(new Error(`send queue full; dropped ${params.eventName} before write for trace_id=${traceId}`));
236
+ },
237
+ };
238
+ function isTerminalClose(close) {
239
+ return close?.code === 1000 || close?.reason === "client close" || close?.reason === "auth failed";
240
+ }
241
+ function onClose(close) {
242
+ if (state === "acked" || state === "failed")
243
+ return;
244
+ if (isTerminalClose(close)) {
245
+ const reason = typeof close?.reason === "string" && close.reason ? close.reason : "websocket closed";
246
+ queue.remove(item);
247
+ fail(new Error(`send cancelled because ${reason}`));
248
+ return;
249
+ }
250
+ if (state !== "written_waiting_ack")
251
+ return;
252
+ clearAckTimer();
253
+ removeRawListener();
254
+ state = "queued";
255
+ queue.enqueue(item);
256
+ }
257
+ function onState(next) {
258
+ if (state === "acked" || state === "failed" || next?.to !== "disconnected")
259
+ return;
260
+ queue.remove(item);
261
+ fail(new Error("send cancelled because client disconnected"));
262
+ }
263
+ removeCloseListener = addAlignedOutboundCloseHandler(params.client, onClose);
264
+ removeStateListener = addAlignedOutboundStateHandler(params.client, onState);
265
+ if (!isReady()) {
266
+ if (isDisconnected()) {
267
+ fail(new Error("send cancelled because client disconnected"));
268
+ return;
269
+ }
270
+ queue.enqueue(item);
271
+ return;
272
+ }
273
+ try {
274
+ queue.enqueue(item);
275
+ queue.flush((queuedWire) => params.client.sendWire(queuedWire));
276
+ }
277
+ catch {
278
+ // The queue keeps the failed frame at the head for reconnect retry, so
279
+ // keep this promise pending until the frame is written+acked, dropped, or timed out.
280
+ }
281
+ });
282
+ }
8
283
  /**
9
284
  * Parse an agent-initiated outbound recipient string into the new-protocol
10
285
  * `chat_id` + `chat_type` pair.
@@ -19,6 +294,8 @@ import { getOpenclawClawlingClient, getOpenclawClawlingRuntime, waitForOpenclawC
19
294
  * - `clawchat:group:{chat_id}` → group
20
295
  * - `openclaw-clawchat:direct:{chat_id}` → direct
21
296
  * - `openclaw-clawchat:group:{chat_id}` → group
297
+ * - `direct:{chat_id}` → direct (host-normalized)
298
+ * - `group:{chat_id}` → group (host-normalized)
22
299
  * - bare `{chat_id}` → direct (backward compat)
23
300
  */
24
301
  export function parseOpenclawRecipient(to) {
@@ -30,6 +307,12 @@ export function parseOpenclawRecipient(to) {
30
307
  return { chatId: raw, chatType: "direct" };
31
308
  const scheme = raw.slice(0, firstColon).toLowerCase();
32
309
  const rest = raw.slice(firstColon + 1);
310
+ if (scheme === "direct" || scheme === "group") {
311
+ const chatId = rest.trim();
312
+ if (!chatId)
313
+ throw new Error(`openclaw-clawchat: missing chat_id in "${to}"`);
314
+ return { chatId, chatType: scheme };
315
+ }
33
316
  if (scheme !== "cc" && scheme !== "clawchat" && scheme !== CHANNEL_ID) {
34
317
  return { chatId: raw, chatType: "direct" };
35
318
  }
@@ -56,41 +339,66 @@ export async function sendOpenclawClawlingText(params) {
56
339
  }
57
340
  const mentions = params.mentions ?? [];
58
341
  const textFragments = text ? textToFragments(text) : [];
59
- // Cast at the SDK boundary: each MediaItem object is structurally compatible
60
- // with one of the SDK's narrow Fragment members (ImageFragment / FileFragment /
342
+ // Each MediaItem object is structurally compatible
343
+ // with one of the local narrow Fragment members (ImageFragment / FileFragment /
61
344
  // AudioFragment / VideoFragment) based on its runtime `kind`. The wide local
62
345
  // shape lets us build a single uniform array without a per-kind switch.
63
346
  const fragments = [...textFragments, ...richFragments, ...mediaFragments];
64
- const useReply = params.replyCtx && mediaFragments.length === 0;
65
- if (params.replyCtx && mediaFragments.length > 0) {
66
- params.log?.info?.(`[${params.account.accountId}] openclaw-clawchat replyCtx + media: downgraded to sendMessage`);
67
- }
347
+ const useReply = Boolean(params.replyCtx);
348
+ const messageId = params.messageId;
68
349
  let ack;
69
350
  let mode;
70
351
  if (useReply && params.replyCtx) {
71
352
  mode = "reply";
72
- ack = await params.client.replyMessage({
73
- chat_id: params.to.chatId,
74
- mode: "normal",
75
- replyTo: {
76
- msgId: params.replyCtx.replyToMessageId,
77
- senderId: params.replyCtx.replyPreviewChatId ?? params.replyCtx.replyPreviewSenderId,
78
- nickName: params.replyCtx.replyPreviewNickName,
79
- fragments: [{ kind: "text", text: params.replyCtx.replyPreviewText }],
353
+ const payload = {
354
+ ...(messageId ? { message_id: messageId } : {}),
355
+ message_mode: "normal",
356
+ message: {
357
+ body: { fragments },
358
+ context: {
359
+ mentions,
360
+ reply: {
361
+ reply_to_msg_id: params.replyCtx.replyToMessageId,
362
+ reply_preview: {
363
+ id: params.replyCtx.replyPreviewSenderId,
364
+ nick_name: params.replyCtx.replyPreviewNickName,
365
+ fragments: [{ kind: "text", text: params.replyCtx.replyPreviewText }],
366
+ },
367
+ },
368
+ },
80
369
  },
81
- body: { fragments },
82
- context: { mentions },
370
+ };
371
+ ack = await sendAlignedAckableEnvelope({
372
+ client: params.client,
373
+ account: params.account,
374
+ eventName: "message.reply",
375
+ chatId: params.to.chatId,
376
+ payload,
377
+ ...(params.log ? { log: params.log } : {}),
83
378
  });
84
379
  }
85
380
  else {
86
381
  mode = "send";
87
- ack = await params.client.sendMessage({
88
- chat_id: params.to.chatId,
89
- mode: "normal",
90
- body: { fragments },
91
- context: { mentions, reply: null },
382
+ const payload = {
383
+ ...(messageId ? { message_id: messageId } : {}),
384
+ message_mode: "normal",
385
+ message: {
386
+ body: { fragments },
387
+ context: { mentions, reply: null },
388
+ },
389
+ };
390
+ ack = await sendAlignedAckableEnvelope({
391
+ client: params.client,
392
+ account: params.account,
393
+ eventName: "message.send",
394
+ chatId: params.to.chatId,
395
+ payload,
396
+ ...(params.log ? { log: params.log } : {}),
92
397
  });
93
398
  }
399
+ if (messageId && ack.payload.message_id !== messageId) {
400
+ throw new Error(`ack message_id mismatch: expected ${messageId} got ${ack.payload.message_id}`);
401
+ }
94
402
  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}`);
95
403
  return {
96
404
  messageId: ack.payload.message_id,
@@ -117,11 +425,48 @@ export async function sendOpenclawClawlingMedia(params) {
117
425
  to: params.to,
118
426
  text: params.text ?? "",
119
427
  mediaFragments: params.mediaFragments,
428
+ ...(params.messageId ? { messageId: params.messageId } : {}),
120
429
  ...(params.replyCtx ? { replyCtx: params.replyCtx } : {}),
121
430
  ...(params.mentions ? { mentions: params.mentions } : {}),
122
431
  ...(params.log ? { log: params.log } : {}),
123
432
  });
124
433
  }
434
+ function mintOutboundMessageId(account) {
435
+ return `${account.userId}-msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
436
+ }
437
+ function resolveChannelOutboundStore() {
438
+ try {
439
+ const runtime = getOpenclawClawlingRuntime();
440
+ const stateDir = runtime.state?.resolveStateDir?.();
441
+ return getClawChatStore({
442
+ ...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
443
+ });
444
+ }
445
+ catch {
446
+ return null;
447
+ }
448
+ }
449
+ function claimChannelOutbound(params) {
450
+ const store = resolveChannelOutboundStore();
451
+ if (!store)
452
+ return null;
453
+ try {
454
+ return store.claimMessageOnce({
455
+ platform: "openclaw",
456
+ accountId: params.account.accountId,
457
+ kind: "message",
458
+ direction: "outbound",
459
+ eventType: "message.send",
460
+ chatId: params.target.chatId,
461
+ messageId: params.messageId,
462
+ text: params.text,
463
+ raw: params.raw,
464
+ });
465
+ }
466
+ catch {
467
+ return null;
468
+ }
469
+ }
125
470
  export const openclawClawlingOutbound = {
126
471
  deliveryMode: "direct",
127
472
  chunker: (text, limit) => chunkMarkdownText(text, limit),
@@ -133,18 +478,38 @@ export const openclawClawlingOutbound = {
133
478
  const account = resolveOpenclawClawlingAccount(cfg);
134
479
  const client = getOpenclawClawlingClient(account.accountId) ??
135
480
  (await waitForOpenclawClawlingClient(account.accountId));
481
+ const target = parseOpenclawRecipient(to);
482
+ const messageId = mintOutboundMessageId(account);
483
+ const trimmedText = text.trim();
484
+ if (!trimmedText) {
485
+ throw new Error("openclaw-clawchat sendText requires non-empty text");
486
+ }
487
+ const claimed = claimChannelOutbound({
488
+ account,
489
+ target,
490
+ messageId,
491
+ text: trimmedText,
492
+ raw: { target, mode: "channel-sendText" },
493
+ });
494
+ if (claimed === false) {
495
+ throw new Error("openclaw-clawchat outbound duplicate claim; message not sent");
496
+ }
497
+ if (claimed === null) {
498
+ throw new Error("openclaw-clawchat outbound message claim failed");
499
+ }
136
500
  const result = await sendOpenclawClawlingText({
137
501
  client,
138
502
  account,
139
- to: parseOpenclawRecipient(to),
503
+ to: target,
140
504
  text,
505
+ messageId,
141
506
  });
142
507
  return {
143
508
  to,
144
- messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
509
+ messageId: result?.messageId ?? messageId,
145
510
  };
146
511
  },
147
- sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots }) => {
512
+ sendMedia: async ({ cfg, to, text, mediaUrl, mediaAccess, mediaLocalRoots, mediaReadFile }) => {
148
513
  const account = resolveOpenclawClawlingAccount(cfg);
149
514
  const client = getOpenclawClawlingClient(account.accountId) ??
150
515
  (await waitForOpenclawClawlingClient(account.accountId));
@@ -160,21 +525,40 @@ export const openclawClawlingOutbound = {
160
525
  const mediaFragments = await uploadOutboundMedia([mediaUrl.trim()], {
161
526
  apiClient,
162
527
  runtime,
528
+ ...(mediaAccess ? { mediaAccess } : {}),
163
529
  ...(mediaLocalRoots ? { mediaLocalRoots } : {}),
530
+ ...(mediaReadFile ? { mediaReadFile } : {}),
164
531
  });
165
532
  if (mediaFragments.length === 0) {
166
533
  throw new Error(`openclaw-clawchat failed to upload media: ${mediaUrl}`);
167
534
  }
535
+ const target = parseOpenclawRecipient(to);
536
+ const messageId = mintOutboundMessageId(account);
537
+ const claimText = (text ?? "").trim();
538
+ const claimed = claimChannelOutbound({
539
+ account,
540
+ target,
541
+ messageId,
542
+ text: claimText,
543
+ raw: { target, mode: "channel-sendMedia", mediaCount: mediaFragments.length },
544
+ });
545
+ if (claimed === false) {
546
+ throw new Error("openclaw-clawchat outbound duplicate claim; message not sent");
547
+ }
548
+ if (claimed === null) {
549
+ throw new Error("openclaw-clawchat outbound message claim failed");
550
+ }
168
551
  const result = await sendOpenclawClawlingMedia({
169
552
  client,
170
553
  account,
171
- to: parseOpenclawRecipient(to),
554
+ to: target,
172
555
  text,
173
556
  mediaFragments,
557
+ messageId,
174
558
  });
175
559
  return {
176
560
  to,
177
- messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
561
+ messageId: result?.messageId ?? messageId,
178
562
  };
179
563
  },
180
564
  }),
@@ -0,0 +1,63 @@
1
+ export const EVENT = {
2
+ CONNECT_CHALLENGE: "connect.challenge",
3
+ CONNECT: "connect",
4
+ HELLO_OK: "hello-ok",
5
+ HELLO_FAIL: "hello-fail",
6
+ MESSAGE_SEND: "message.send",
7
+ MESSAGE_ACK: "message.ack",
8
+ MESSAGE_ERROR: "message.error",
9
+ MESSAGE_REPLY: "message.reply",
10
+ MESSAGE_CREATED: "message.created",
11
+ MESSAGE_ADD: "message.add",
12
+ MESSAGE_DONE: "message.done",
13
+ MESSAGE_FAILED: "message.failed",
14
+ TYPING_UPDATE: "typing.update",
15
+ CHAT_METADATA_INVALIDATED: "chat.metadata.invalidated",
16
+ OFFLINE_BATCH: "offline.batch",
17
+ OFFLINE_ACK: "offline.ack",
18
+ OFFLINE_DONE: "offline.done",
19
+ PING: "ping",
20
+ PONG: "pong",
21
+ };
22
+ export class AuthError extends Error {
23
+ name = "AuthError";
24
+ }
25
+ export class TransportError extends Error {
26
+ name = "TransportError";
27
+ }
28
+ export class ProtocolError extends Error {
29
+ envelope;
30
+ name = "ProtocolError";
31
+ constructor(message, envelope) {
32
+ super(message);
33
+ this.envelope = envelope;
34
+ }
35
+ }
36
+ export class AckTimeoutError extends Error {
37
+ traceId;
38
+ timeoutMs;
39
+ name = "AckTimeoutError";
40
+ constructor(traceId, timeoutMs) {
41
+ super(`ack timeout after ${timeoutMs}ms for trace_id=${traceId}`);
42
+ this.traceId = traceId;
43
+ this.timeoutMs = timeoutMs;
44
+ }
45
+ }
46
+ export class MessageSendError extends Error {
47
+ traceId;
48
+ code;
49
+ chatId;
50
+ name = "MessageSendError";
51
+ constructor(traceId, code, message, chatId) {
52
+ super(`message.error ${code}: ${message} for trace_id=${traceId}`);
53
+ this.traceId = traceId;
54
+ this.code = code;
55
+ this.chatId = chatId;
56
+ }
57
+ }
58
+ export class StateError extends Error {
59
+ name = "StateError";
60
+ }
61
+ export function isBusinessDispatchEvent(event) {
62
+ return event === EVENT.MESSAGE_SEND || event === EVENT.MESSAGE_REPLY || event === EVENT.MESSAGE_DONE;
63
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Local narrow guards for inbound protocol envelopes.
3
3
  *
4
- * The SDK's `message` event hands us `Envelope<unknown>`. Before casting the
5
- * payload to `DownlinkMessageSendPayload` we run these cheap structural checks
4
+ * Raw client events hand us `Envelope<unknown>`. Before casting the
5
+ * payload to `MessagePayload` we run these cheap structural checks
6
6
  * so runtime errors surface as skipped messages, not crashes.
7
7
  */
8
8
  export function isInboundMessagePayload(payload) {
@@ -31,8 +31,3 @@ export function hasRenderableText(message) {
31
31
  typeof f.url === "string" &&
32
32
  f.url.trim().length > 0)));
33
33
  }
34
- export function isGroupSender(sender) {
35
- if (!sender || typeof sender !== "object")
36
- return false;
37
- return sender.type === "group";
38
- }