@clawling/clawchat-plugin-openclaw 2026.5.12-28

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 (114) hide show
  1. package/INSTALL.md +64 -0
  2. package/README.md +227 -0
  3. package/dist/index.js +20 -0
  4. package/dist/setup-entry.js +3 -0
  5. package/dist/src/api-client.js +263 -0
  6. package/dist/src/api-types.js +17 -0
  7. package/dist/src/api-types.test-d.js +10 -0
  8. package/dist/src/buffered-stream.js +177 -0
  9. package/dist/src/channel.js +66 -0
  10. package/dist/src/channel.setup.js +119 -0
  11. package/dist/src/clawchat-memory.js +403 -0
  12. package/dist/src/clawchat-metadata.js +310 -0
  13. package/dist/src/client.js +35 -0
  14. package/dist/src/commands.js +35 -0
  15. package/dist/src/config.js +274 -0
  16. package/dist/src/group-message-coalescer.js +119 -0
  17. package/dist/src/inbound.js +170 -0
  18. package/dist/src/llm-context-debug.js +86 -0
  19. package/dist/src/login.runtime.js +204 -0
  20. package/dist/src/media-runtime.js +85 -0
  21. package/dist/src/message-mapper.js +146 -0
  22. package/dist/src/mock-transport.js +31 -0
  23. package/dist/src/outbound.js +628 -0
  24. package/dist/src/plugin-prompts.js +89 -0
  25. package/dist/src/profile-prompt.js +269 -0
  26. package/dist/src/profile-sync.js +110 -0
  27. package/dist/src/prompt-injection.js +25 -0
  28. package/dist/src/protocol-types.js +63 -0
  29. package/dist/src/protocol-types.typecheck.js +1 -0
  30. package/dist/src/protocol.js +33 -0
  31. package/dist/src/reply-dispatcher.js +422 -0
  32. package/dist/src/runtime.js +1254 -0
  33. package/dist/src/storage.js +525 -0
  34. package/dist/src/streaming.js +65 -0
  35. package/dist/src/terminal-send.js +36 -0
  36. package/dist/src/tools-schema.js +208 -0
  37. package/dist/src/tools.js +920 -0
  38. package/dist/src/ws-alignment.js +178 -0
  39. package/dist/src/ws-client.js +588 -0
  40. package/dist/src/ws-log.js +19 -0
  41. package/index.ts +24 -0
  42. package/openclaw.plugin.json +169 -0
  43. package/package.json +80 -0
  44. package/prompts/default-group-bio.md +19 -0
  45. package/prompts/default-owner-behavior.md +27 -0
  46. package/prompts/platform.md +13 -0
  47. package/setup-entry.ts +4 -0
  48. package/skills/clawchat/SKILL.md +91 -0
  49. package/src/api-client.test.ts +827 -0
  50. package/src/api-client.ts +414 -0
  51. package/src/api-types.ts +146 -0
  52. package/src/channel.outbound.test.ts +433 -0
  53. package/src/channel.setup.ts +145 -0
  54. package/src/channel.test.ts +262 -0
  55. package/src/channel.ts +81 -0
  56. package/src/clawchat-memory.test.ts +480 -0
  57. package/src/clawchat-memory.ts +533 -0
  58. package/src/clawchat-metadata.test.ts +477 -0
  59. package/src/clawchat-metadata.ts +429 -0
  60. package/src/client.test.ts +169 -0
  61. package/src/client.ts +56 -0
  62. package/src/commands.test.ts +39 -0
  63. package/src/commands.ts +41 -0
  64. package/src/config.test.ts +344 -0
  65. package/src/config.ts +404 -0
  66. package/src/group-message-coalescer.test.ts +237 -0
  67. package/src/group-message-coalescer.ts +171 -0
  68. package/src/inbound.test.ts +508 -0
  69. package/src/inbound.ts +278 -0
  70. package/src/llm-context-debug.test.ts +55 -0
  71. package/src/llm-context-debug.ts +139 -0
  72. package/src/login.runtime.test.ts +737 -0
  73. package/src/login.runtime.ts +277 -0
  74. package/src/manifest.test.ts +352 -0
  75. package/src/media-runtime.test.ts +207 -0
  76. package/src/media-runtime.ts +152 -0
  77. package/src/message-mapper.test.ts +201 -0
  78. package/src/message-mapper.ts +174 -0
  79. package/src/mock-transport.test.ts +35 -0
  80. package/src/mock-transport.ts +38 -0
  81. package/src/outbound.test.ts +1269 -0
  82. package/src/outbound.ts +803 -0
  83. package/src/plugin-entry.test.ts +38 -0
  84. package/src/plugin-prompts.test.ts +94 -0
  85. package/src/plugin-prompts.ts +107 -0
  86. package/src/profile-prompt.test.ts +274 -0
  87. package/src/profile-prompt.ts +351 -0
  88. package/src/profile-sync.test.ts +539 -0
  89. package/src/profile-sync.ts +191 -0
  90. package/src/prompt-injection.test.ts +39 -0
  91. package/src/prompt-injection.ts +45 -0
  92. package/src/protocol-types.test.ts +69 -0
  93. package/src/protocol-types.ts +296 -0
  94. package/src/protocol-types.typecheck.ts +89 -0
  95. package/src/protocol.test.ts +39 -0
  96. package/src/protocol.ts +42 -0
  97. package/src/reply-dispatcher.test.ts +1324 -0
  98. package/src/reply-dispatcher.ts +555 -0
  99. package/src/runtime.test.ts +4719 -0
  100. package/src/runtime.ts +1493 -0
  101. package/src/scripts.test.ts +85 -0
  102. package/src/storage.test.ts +560 -0
  103. package/src/storage.ts +807 -0
  104. package/src/terminal-send.test.ts +81 -0
  105. package/src/terminal-send.ts +56 -0
  106. package/src/tools-schema.ts +337 -0
  107. package/src/tools.test.ts +933 -0
  108. package/src/tools.ts +1185 -0
  109. package/src/ws-alignment.test.ts +103 -0
  110. package/src/ws-alignment.ts +275 -0
  111. package/src/ws-client.test.ts +1217 -0
  112. package/src/ws-client.ts +662 -0
  113. package/src/ws-log.test.ts +32 -0
  114. package/src/ws-log.ts +31 -0
@@ -0,0 +1,628 @@
1
+ import { MessageSendError } from "./protocol-types.js";
2
+ import { createAttachedChannelResultAdapter, } from "openclaw/plugin-sdk/channel-send-result";
3
+ import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
4
+ import { createOpenclawClawlingApiClient } from "./api-client.js";
5
+ import { CHANNEL_ID, resolveOpenclawClawlingAccount } from "./config.js";
6
+ import { applyTextMentionLabels, buildMentionMessageFragments, normalizeMentionTargets, textToFragments, } from "./message-mapper.js";
7
+ import { uploadOutboundMedia } from "./media-runtime.js";
8
+ import { isClawChatNoopResponseText } from "./profile-prompt.js";
9
+ import { getOpenclawClawlingClient, getOpenclawClawlingRuntime, waitForOpenclawClawlingClient, } from "./runtime.js";
10
+ import { clawChatDbPathForStateDir, getClawChatStore, } from "./storage.js";
11
+ import { createAlignedWsQueue } from "./ws-alignment.js";
12
+ import { formatWsLog } from "./ws-log.js";
13
+ const alignedOutboundQueues = new WeakMap();
14
+ const alignedOutboundContexts = new WeakMap();
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
+ };
53
+ }
54
+ function getAlignedOutboundQueue(client, account, log) {
55
+ const existing = alignedOutboundQueues.get(client);
56
+ if (existing)
57
+ return existing;
58
+ const queue = createAlignedWsQueue({
59
+ accountId: account.accountId,
60
+ log: (msg) => log?.info?.(msg),
61
+ maxSize: 128,
62
+ context: () => alignedOutboundContexts.get(client)?.() ?? {
63
+ attempt: 1,
64
+ reconnectCount: 0,
65
+ state: "ready",
66
+ },
67
+ });
68
+ alignedOutboundQueues.set(client, queue);
69
+ return queue;
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
+ }
113
+ export function setAlignedOutboundLogContext(client, context) {
114
+ alignedOutboundContexts.set(client, context);
115
+ }
116
+ export function flushAlignedOutboundQueue(client) {
117
+ const queue = alignedOutboundQueues.get(client);
118
+ queue?.flush((wire) => client.sendWire(wire));
119
+ }
120
+ export function getAlignedOutboundQueueSize(client) {
121
+ return alignedOutboundQueues.get(client)?.snapshot().length ?? 0;
122
+ }
123
+ async function sendAlignedAckableEnvelope(params) {
124
+ const traceId = params.client.nextTraceId();
125
+ const env = {
126
+ version: "2",
127
+ event: params.eventName,
128
+ trace_id: traceId,
129
+ emitted_at: Date.now(),
130
+ chat_id: params.chatId,
131
+ payload: params.payload,
132
+ };
133
+ const wire = JSON.stringify(env);
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";
141
+ return await new Promise((resolve, reject) => {
142
+ let state = "queued";
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
+ };
159
+ const cleanup = () => {
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);
174
+ };
175
+ const logAck = (event, action, fields) => {
176
+ params.log?.info?.(formatWsLog({
177
+ event,
178
+ accountId: params.account.accountId,
179
+ ...(alignedOutboundContexts.get(params.client)?.() ?? {
180
+ attempt: 1,
181
+ reconnectCount: 0,
182
+ state: isReady() ? "ready" : "reconnecting",
183
+ }),
184
+ action,
185
+ fields: [
186
+ ["event_name", params.eventName],
187
+ ["trace_id", traceId],
188
+ ["chat_id", params.chatId],
189
+ ...fields,
190
+ ],
191
+ }));
192
+ };
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));
203
+ return;
204
+ }
205
+ if (ack.event !== "message.ack")
206
+ return;
207
+ if (state === "acked" || state === "failed")
208
+ return;
209
+ state = "acked";
210
+ cleanup();
211
+ const payload = ack.payload;
212
+ logAck("ack_received", "resolve", [["message_id", payload.message_id]]);
213
+ resolve(ack);
214
+ }
215
+ const startAckTimer = () => {
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;
224
+ timer = setTimeout(() => {
225
+ logAck("ack_timeout", "reject_no_reconnect", [["timeout_ms", params.account.ack.timeout]]);
226
+ fail(new Error(`ack timeout after ${params.account.ack.timeout}ms for trace_id=${traceId}`));
227
+ }, params.account.ack.timeout);
228
+ };
229
+ const item = {
230
+ eventName: params.eventName,
231
+ traceId,
232
+ chatId: params.chatId,
233
+ wire,
234
+ onWrite: startAckTimer,
235
+ onDrop: () => {
236
+ fail(new Error(`send queue full; dropped ${params.eventName} before write for trace_id=${traceId}`));
237
+ },
238
+ };
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
+ }
271
+ queue.enqueue(item);
272
+ return;
273
+ }
274
+ try {
275
+ queue.enqueue(item);
276
+ queue.flush((queuedWire) => params.client.sendWire(queuedWire));
277
+ }
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.
281
+ }
282
+ });
283
+ }
284
+ /**
285
+ * Parse an agent-initiated outbound recipient string into the new-protocol
286
+ * `chat_id` + `chat_type` pair.
287
+ *
288
+ * Accepted forms (case-insensitive prefix):
289
+ * - `cc:{chat_id}` → direct
290
+ * - `clawchat:{chat_id}` → direct
291
+ * - `clawchat-plugin-openclaw:{chat_id}` → direct
292
+ * - `cc:direct:{chat_id}` → direct
293
+ * - `cc:group:{chat_id}` → group
294
+ * - `clawchat:direct:{chat_id}` → direct
295
+ * - `clawchat:group:{chat_id}` → group
296
+ * - `clawchat-plugin-openclaw:direct:{chat_id}` → direct
297
+ * - `clawchat-plugin-openclaw:group:{chat_id}` → group
298
+ * - `direct:{chat_id}` → direct (host-normalized)
299
+ * - `group:{chat_id}` → group (host-normalized)
300
+ * - bare `{chat_id}` → direct (backward compat)
301
+ */
302
+ export function parseOpenclawRecipient(to) {
303
+ const raw = (to ?? "").trim();
304
+ if (!raw)
305
+ throw new Error("clawchat-plugin-openclaw: outbound `to` is empty");
306
+ const firstColon = raw.indexOf(":");
307
+ if (firstColon < 0)
308
+ return { chatId: raw, chatType: "direct" };
309
+ const scheme = raw.slice(0, firstColon).toLowerCase();
310
+ const rest = raw.slice(firstColon + 1);
311
+ if (scheme === "direct" || scheme === "group") {
312
+ const chatId = rest.trim();
313
+ if (!chatId)
314
+ throw new Error(`clawchat-plugin-openclaw: missing chat_id in "${to}"`);
315
+ return { chatId, chatType: scheme };
316
+ }
317
+ if (scheme !== "cc" && scheme !== "clawchat" && scheme !== CHANNEL_ID) {
318
+ return { chatId: raw, chatType: "direct" };
319
+ }
320
+ const secondColon = rest.indexOf(":");
321
+ if (secondColon >= 0) {
322
+ const typeToken = rest.slice(0, secondColon).toLowerCase();
323
+ const chatId = rest.slice(secondColon + 1).trim();
324
+ if ((typeToken === "direct" || typeToken === "group") && chatId) {
325
+ return { chatId, chatType: typeToken };
326
+ }
327
+ }
328
+ const chatId = rest.trim();
329
+ if (!chatId)
330
+ throw new Error(`clawchat-plugin-openclaw: missing chat_id in "${to}"`);
331
+ return { chatId, chatType: "direct" };
332
+ }
333
+ export async function sendOpenclawClawlingText(params) {
334
+ const text = (params.text ?? "").trim();
335
+ const richFragments = params.richFragments ?? [];
336
+ const mediaFragments = params.mediaFragments ?? [];
337
+ if (isClawChatNoopResponseText(text) &&
338
+ richFragments.length === 0 &&
339
+ mediaFragments.length === 0) {
340
+ params.log?.info?.(`[${params.account.accountId}] clawchat-plugin-openclaw outbound suppressed: silent response`);
341
+ return null;
342
+ }
343
+ if (!text && richFragments.length === 0 && mediaFragments.length === 0) {
344
+ params.log?.info?.(`[${params.account.accountId}] clawchat-plugin-openclaw outbound suppressed: empty text and no media`);
345
+ return null;
346
+ }
347
+ const mentions = params.mentions ?? [];
348
+ const textFragments = text ? textToFragments(text) : [];
349
+ // Each MediaItem object is structurally compatible
350
+ // with one of the local narrow Fragment members (ImageFragment / FileFragment /
351
+ // AudioFragment / VideoFragment) based on its runtime `kind`. The wide local
352
+ // shape lets us build a single uniform array without a per-kind switch.
353
+ const fragments = [...textFragments, ...richFragments, ...mediaFragments];
354
+ const useReply = Boolean(params.replyCtx?.replyPreviewSenderId
355
+ && params.replyCtx.replyPreviewNickName
356
+ && params.replyCtx.replyPreviewText);
357
+ const messageId = params.messageId;
358
+ let ack;
359
+ let mode;
360
+ if (useReply && params.replyCtx) {
361
+ mode = "reply";
362
+ const payload = {
363
+ ...(messageId ? { message_id: messageId } : {}),
364
+ message_mode: "normal",
365
+ message: {
366
+ body: { fragments },
367
+ context: {
368
+ mentions,
369
+ reply: {
370
+ reply_to_msg_id: params.replyCtx.replyToMessageId,
371
+ reply_preview: {
372
+ id: params.replyCtx.replyPreviewSenderId,
373
+ nick_name: params.replyCtx.replyPreviewNickName,
374
+ fragments: [{ kind: "text", text: params.replyCtx.replyPreviewText }],
375
+ },
376
+ },
377
+ },
378
+ },
379
+ };
380
+ ack = await sendAlignedAckableEnvelope({
381
+ client: params.client,
382
+ account: params.account,
383
+ eventName: "message.reply",
384
+ chatId: params.to.chatId,
385
+ payload,
386
+ ...(params.log ? { log: params.log } : {}),
387
+ });
388
+ }
389
+ else {
390
+ mode = "send";
391
+ const reply = params.replyCtx
392
+ ? {
393
+ reply_to_msg_id: params.replyCtx.replyToMessageId,
394
+ reply_preview: null,
395
+ }
396
+ : null;
397
+ const payload = {
398
+ ...(messageId ? { message_id: messageId } : {}),
399
+ message_mode: "normal",
400
+ message: {
401
+ body: { fragments },
402
+ context: { mentions, reply },
403
+ },
404
+ };
405
+ ack = await sendAlignedAckableEnvelope({
406
+ client: params.client,
407
+ account: params.account,
408
+ eventName: "message.send",
409
+ chatId: params.to.chatId,
410
+ payload,
411
+ ...(params.log ? { log: params.log } : {}),
412
+ });
413
+ }
414
+ if (messageId && ack.payload.message_id !== messageId) {
415
+ throw new Error(`ack message_id mismatch: expected ${messageId} got ${ack.payload.message_id}`);
416
+ }
417
+ params.log?.info?.(`[${params.account.accountId}] clawchat-plugin-openclaw outbound mode=${mode} msg=${ack.payload.message_id} text_len=${text.length} media=${mediaFragments.length} trace=${ack.trace_id}`);
418
+ return {
419
+ messageId: ack.payload.message_id,
420
+ acceptedAt: ack.payload.accepted_at,
421
+ };
422
+ }
423
+ export async function sendOpenclawClawlingMentionMessage(params) {
424
+ const normalized = normalizeMentionTargets(params.mentions);
425
+ const prepared = applyTextMentionLabels(normalized, params.text);
426
+ const fragments = buildMentionMessageFragments({
427
+ mentions: prepared.mentions,
428
+ ...(prepared.text ? { text: prepared.text } : {}),
429
+ });
430
+ const contextMentions = prepared.mentions.map((mention) => ({
431
+ kind: "mention",
432
+ user_id: mention.userId,
433
+ ...(mention.display ? { display: mention.display } : {}),
434
+ }));
435
+ const mentionIds = prepared.mentions.map((mention) => mention.userId);
436
+ const result = await sendOpenclawClawlingText({
437
+ client: params.client,
438
+ account: params.account,
439
+ to: params.to,
440
+ text: "",
441
+ richFragments: fragments,
442
+ mentions: contextMentions,
443
+ ...(params.messageId ? { messageId: params.messageId } : {}),
444
+ ...(params.replyCtx ? { replyCtx: params.replyCtx } : {}),
445
+ ...(params.log ? { log: params.log } : {}),
446
+ });
447
+ if (!result)
448
+ return null;
449
+ return { ...result, mentions: mentionIds };
450
+ }
451
+ /**
452
+ * Send one or more media fragments (image / file / audio / video) to the
453
+ * given target, with an optional text caption.
454
+ *
455
+ * Validates that mediaFragments is non-empty (returns null + info log
456
+ * otherwise) and delegates to {@link sendOpenclawClawlingText} for the
457
+ * actual envelope construction. Reuses the existing replyCtx-downgrade,
458
+ * ack backfill, and log shape.
459
+ */
460
+ export async function sendOpenclawClawlingMedia(params) {
461
+ if (params.mediaFragments.length === 0) {
462
+ params.log?.info?.(`[${params.account.accountId}] clawchat-plugin-openclaw sendMedia called with empty mediaFragments; suppressed`);
463
+ return null;
464
+ }
465
+ return await sendOpenclawClawlingText({
466
+ client: params.client,
467
+ account: params.account,
468
+ to: params.to,
469
+ text: params.text ?? "",
470
+ mediaFragments: params.mediaFragments,
471
+ ...(params.messageId ? { messageId: params.messageId } : {}),
472
+ ...(params.replyCtx ? { replyCtx: params.replyCtx } : {}),
473
+ ...(params.mentions ? { mentions: params.mentions } : {}),
474
+ ...(params.log ? { log: params.log } : {}),
475
+ });
476
+ }
477
+ function mintOutboundMessageId(account) {
478
+ return `${account.userId}-msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
479
+ }
480
+ function resolveChannelOutboundStore() {
481
+ try {
482
+ const runtime = getOpenclawClawlingRuntime();
483
+ const stateDir = runtime.state?.resolveStateDir?.();
484
+ return getClawChatStore({
485
+ ...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
486
+ });
487
+ }
488
+ catch {
489
+ return null;
490
+ }
491
+ }
492
+ function claimChannelOutbound(params) {
493
+ const store = resolveChannelOutboundStore();
494
+ if (!store)
495
+ return null;
496
+ try {
497
+ return store.claimMessageOnce({
498
+ platform: "openclaw",
499
+ accountId: params.account.accountId,
500
+ kind: "message",
501
+ direction: "outbound",
502
+ eventType: "message.send",
503
+ chatId: params.target.chatId,
504
+ messageId: params.messageId,
505
+ text: params.text,
506
+ raw: params.raw,
507
+ });
508
+ }
509
+ catch {
510
+ return null;
511
+ }
512
+ }
513
+ function markChannelOutboundAcknowledged(params) {
514
+ const store = resolveChannelOutboundStore();
515
+ if (!store || !params.result?.messageId)
516
+ return;
517
+ try {
518
+ store.markMessageAcknowledged?.({
519
+ accountId: params.account.accountId,
520
+ kind: "message",
521
+ direction: "outbound",
522
+ messageId: params.messageId,
523
+ protocolMessageId: params.result.messageId,
524
+ ackedAt: params.result.acceptedAt ?? Date.now(),
525
+ });
526
+ }
527
+ catch {
528
+ // Best-effort diagnostics only; ack has already succeeded.
529
+ }
530
+ }
531
+ export const openclawClawlingOutbound = {
532
+ deliveryMode: "direct",
533
+ chunker: (text, limit) => chunkMarkdownText(text, limit),
534
+ chunkerMode: "markdown",
535
+ textChunkLimit: 4000,
536
+ ...createAttachedChannelResultAdapter({
537
+ channel: CHANNEL_ID,
538
+ sendText: async ({ cfg, to, text }) => {
539
+ const account = resolveOpenclawClawlingAccount(cfg);
540
+ const client = getOpenclawClawlingClient(account.accountId) ??
541
+ (await waitForOpenclawClawlingClient(account.accountId));
542
+ const target = parseOpenclawRecipient(to);
543
+ const messageId = mintOutboundMessageId(account);
544
+ const trimmedText = text.trim();
545
+ if (!trimmedText) {
546
+ throw new Error("clawchat-plugin-openclaw sendText requires non-empty text");
547
+ }
548
+ const claimed = claimChannelOutbound({
549
+ account,
550
+ target,
551
+ messageId,
552
+ text: trimmedText,
553
+ raw: { target, mode: "channel-sendText" },
554
+ });
555
+ if (claimed === false) {
556
+ throw new Error("clawchat-plugin-openclaw outbound duplicate claim; message not sent");
557
+ }
558
+ if (claimed === null) {
559
+ throw new Error("clawchat-plugin-openclaw outbound message claim failed");
560
+ }
561
+ const result = await sendOpenclawClawlingText({
562
+ client,
563
+ account,
564
+ to: target,
565
+ text,
566
+ messageId,
567
+ });
568
+ markChannelOutboundAcknowledged({ account, messageId, result });
569
+ return {
570
+ to,
571
+ messageId: result?.messageId ?? messageId,
572
+ };
573
+ },
574
+ sendMedia: async ({ cfg, to, text, mediaUrl, mediaAccess, mediaLocalRoots, mediaReadFile }) => {
575
+ const account = resolveOpenclawClawlingAccount(cfg);
576
+ const client = getOpenclawClawlingClient(account.accountId) ??
577
+ (await waitForOpenclawClawlingClient(account.accountId));
578
+ if (!mediaUrl?.trim()) {
579
+ throw new Error("clawchat-plugin-openclaw sendMedia requires mediaUrl");
580
+ }
581
+ const runtime = getOpenclawClawlingRuntime();
582
+ const apiClient = createOpenclawClawlingApiClient({
583
+ baseUrl: account.baseUrl,
584
+ token: account.token,
585
+ userId: account.userId,
586
+ });
587
+ const mediaFragments = await uploadOutboundMedia([mediaUrl.trim()], {
588
+ apiClient,
589
+ runtime,
590
+ ...(mediaAccess ? { mediaAccess } : {}),
591
+ ...(mediaLocalRoots ? { mediaLocalRoots } : {}),
592
+ ...(mediaReadFile ? { mediaReadFile } : {}),
593
+ });
594
+ if (mediaFragments.length === 0) {
595
+ throw new Error(`clawchat-plugin-openclaw failed to upload media: ${mediaUrl}`);
596
+ }
597
+ const target = parseOpenclawRecipient(to);
598
+ const messageId = mintOutboundMessageId(account);
599
+ const claimText = (text ?? "").trim();
600
+ const claimed = claimChannelOutbound({
601
+ account,
602
+ target,
603
+ messageId,
604
+ text: claimText,
605
+ raw: { target, mode: "channel-sendMedia", mediaCount: mediaFragments.length },
606
+ });
607
+ if (claimed === false) {
608
+ throw new Error("clawchat-plugin-openclaw outbound duplicate claim; message not sent");
609
+ }
610
+ if (claimed === null) {
611
+ throw new Error("clawchat-plugin-openclaw outbound message claim failed");
612
+ }
613
+ const result = await sendOpenclawClawlingMedia({
614
+ client,
615
+ account,
616
+ to: target,
617
+ text,
618
+ mediaFragments,
619
+ messageId,
620
+ });
621
+ markChannelOutboundAcknowledged({ account, messageId, result });
622
+ return {
623
+ to,
624
+ messageId: result?.messageId ?? messageId,
625
+ };
626
+ },
627
+ }),
628
+ };