@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,803 @@
1
+ import { MessageSendError, type Envelope, type Fragment, type MentionFragment, type MessageAckPayload, type MessageErrorPayload } from "./protocol-types.ts";
2
+ import type { ClawlingChatClient } from "./ws-client.ts";
3
+ import {
4
+ createAttachedChannelResultAdapter,
5
+ type ChannelOutboundAdapter,
6
+ } from "openclaw/plugin-sdk/channel-send-result";
7
+ import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
8
+ import type { ChatType } from "./client.ts";
9
+ import { createOpenclawClawlingApiClient } from "./api-client.ts";
10
+ import { CHANNEL_ID, resolveOpenclawClawlingAccount, type ResolvedOpenclawClawlingAccount } from "./config.ts";
11
+ import {
12
+ applyTextMentionLabels,
13
+ buildMentionMessageFragments,
14
+ normalizeMentionTargets,
15
+ textToFragments,
16
+ type MentionTarget,
17
+ } from "./message-mapper.ts";
18
+ import { uploadOutboundMedia, type ClawlingMediaFragment } from "./media-runtime.ts";
19
+ import { isClawChatNoopResponseText } from "./profile-prompt.ts";
20
+ import {
21
+ getOpenclawClawlingClient,
22
+ getOpenclawClawlingRuntime,
23
+ waitForOpenclawClawlingClient,
24
+ } from "./runtime.ts";
25
+ import {
26
+ clawChatDbPathForStateDir,
27
+ getClawChatStore,
28
+ type ClawChatStore,
29
+ } from "./storage.ts";
30
+ import { createAlignedWsQueue, type WsLogContext } from "./ws-alignment.ts";
31
+ import { formatWsLog } from "./ws-log.ts";
32
+
33
+ export interface OutboundTarget {
34
+ chatId: string;
35
+ chatType: ChatType;
36
+ }
37
+
38
+ export interface OutboundReplyCtx {
39
+ replyToMessageId: string;
40
+ replyPreviewChatId?: string;
41
+ replyPreviewSenderId?: string;
42
+ replyPreviewNickName?: string;
43
+ replyPreviewText?: string;
44
+ }
45
+
46
+ export interface LogSink {
47
+ info?: (m: string) => void;
48
+ error?: (m: string) => void;
49
+ }
50
+
51
+ export interface SendParams {
52
+ client: ClawlingChatClient;
53
+ account: ResolvedOpenclawClawlingAccount;
54
+ to: OutboundTarget;
55
+ text: string;
56
+ replyCtx?: OutboundReplyCtx;
57
+ richFragments?: Fragment[];
58
+ mediaFragments?: ClawlingMediaFragment[];
59
+ mentions?: MentionFragment[];
60
+ messageId?: string;
61
+ log?: LogSink;
62
+ }
63
+
64
+ export interface SendResult {
65
+ messageId: string;
66
+ acceptedAt: number;
67
+ }
68
+
69
+ export interface SendMentionMessageParams {
70
+ client: ClawlingChatClient;
71
+ account: ResolvedOpenclawClawlingAccount;
72
+ to: OutboundTarget;
73
+ text?: string;
74
+ mentions: MentionTarget[];
75
+ replyCtx?: OutboundReplyCtx;
76
+ messageId?: string;
77
+ log?: LogSink;
78
+ }
79
+
80
+ export interface SendMentionMessageResult extends SendResult {
81
+ mentions: string[];
82
+ }
83
+
84
+ const alignedOutboundQueues = new WeakMap<object, ReturnType<typeof createAlignedWsQueue>>();
85
+ const alignedOutboundContexts = new WeakMap<object, () => WsLogContext>();
86
+ const alignedOutboundMessageErrorTrackers = new WeakMap<object, {
87
+ pending: Set<string>;
88
+ listener: (env: Envelope) => void;
89
+ }>();
90
+ type AlignedOutboundClose = { code?: unknown; reason?: unknown } | undefined;
91
+ type AlignedOutboundCloseHandler = (close?: AlignedOutboundClose) => void;
92
+ type AlignedOutboundState = { to?: unknown } | undefined;
93
+ type AlignedOutboundStateHandler = (state?: AlignedOutboundState) => void;
94
+ const alignedOutboundCloseHandlers = new WeakMap<
95
+ object,
96
+ { handlers: Set<AlignedOutboundCloseHandler>; listener: AlignedOutboundCloseHandler }
97
+ >();
98
+ const alignedOutboundStateHandlers = new WeakMap<
99
+ object,
100
+ { handlers: Set<AlignedOutboundStateHandler>; listener: AlignedOutboundStateHandler }
101
+ >();
102
+
103
+ function addAlignedOutboundCloseHandler(
104
+ client: ClawlingChatClient,
105
+ handler: AlignedOutboundCloseHandler,
106
+ ): () => void {
107
+ const key = client as object;
108
+ let entry = alignedOutboundCloseHandlers.get(key);
109
+ if (!entry) {
110
+ const handlers = new Set<AlignedOutboundCloseHandler>();
111
+ const listener: AlignedOutboundCloseHandler = (close) => {
112
+ for (const current of [...handlers]) current(close);
113
+ };
114
+ entry = { handlers, listener };
115
+ alignedOutboundCloseHandlers.set(key, entry);
116
+ client.on("close", listener);
117
+ }
118
+ entry.handlers.add(handler);
119
+ return () => {
120
+ entry?.handlers.delete(handler);
121
+ };
122
+ }
123
+
124
+ function addAlignedOutboundStateHandler(
125
+ client: ClawlingChatClient,
126
+ handler: AlignedOutboundStateHandler,
127
+ ): () => void {
128
+ const key = client as object;
129
+ let entry = alignedOutboundStateHandlers.get(key);
130
+ if (!entry) {
131
+ const handlers = new Set<AlignedOutboundStateHandler>();
132
+ const listener: AlignedOutboundStateHandler = (state) => {
133
+ for (const current of [...handlers]) current(state);
134
+ };
135
+ entry = { handlers, listener };
136
+ alignedOutboundStateHandlers.set(key, entry);
137
+ client.on("state", listener);
138
+ }
139
+ entry.handlers.add(handler);
140
+ return () => {
141
+ entry?.handlers.delete(handler);
142
+ };
143
+ }
144
+
145
+ function getAlignedOutboundQueue(
146
+ client: ClawlingChatClient,
147
+ account: ResolvedOpenclawClawlingAccount,
148
+ log: LogSink | undefined,
149
+ ) {
150
+ const existing = alignedOutboundQueues.get(client as object);
151
+ if (existing) return existing;
152
+ const queue = createAlignedWsQueue({
153
+ accountId: account.accountId,
154
+ log: (msg) => log?.info?.(msg),
155
+ maxSize: 128,
156
+ context: () => alignedOutboundContexts.get(client as object)?.() ?? {
157
+ attempt: 1,
158
+ reconnectCount: 0,
159
+ state: "ready",
160
+ },
161
+ });
162
+ alignedOutboundQueues.set(client as object, queue);
163
+ return queue;
164
+ }
165
+
166
+ function getAlignedMessageErrorTracker(
167
+ client: ClawlingChatClient,
168
+ account: ResolvedOpenclawClawlingAccount,
169
+ log: LogSink | undefined,
170
+ ) {
171
+ const key = client as object;
172
+ const existing = alignedOutboundMessageErrorTrackers.get(key);
173
+ if (existing) return existing;
174
+ const pending = new Set<string>();
175
+ const listener = (env: Envelope) => {
176
+ if (env.event !== "message.error") return;
177
+ if (pending.has(env.trace_id)) {
178
+ (client as { markMessageErrorHandled?: (traceId: string) => void }).markMessageErrorHandled?.(env.trace_id);
179
+ return;
180
+ }
181
+ if ((client as { hasPendingAckTrace?: (traceId: string) => boolean }).hasPendingAckTrace?.(env.trace_id)) {
182
+ return;
183
+ }
184
+ const context = alignedOutboundContexts.get(key)?.() ?? {
185
+ attempt: 1,
186
+ reconnectCount: 0,
187
+ state: client.transportState === "open" ? "ready" : "reconnecting",
188
+ };
189
+ if (log?.info) {
190
+ log.info(
191
+ formatWsLog({
192
+ event: "ack_unmatched",
193
+ accountId: account.accountId,
194
+ attempt: context.attempt,
195
+ reconnectCount: context.reconnectCount,
196
+ state: context.state,
197
+ action: "ignore",
198
+ fields: [
199
+ ["trace_id", env.trace_id],
200
+ ["chat_id", env.chat_id],
201
+ ],
202
+ }),
203
+ );
204
+ (client as { markMessageErrorHandled?: (traceId: string) => void }).markMessageErrorHandled?.(env.trace_id);
205
+ }
206
+ };
207
+ client.on("raw", listener);
208
+ const tracker = { pending, listener };
209
+ alignedOutboundMessageErrorTrackers.set(key, tracker);
210
+ return tracker;
211
+ }
212
+
213
+ export function setAlignedOutboundLogContext(
214
+ client: ClawlingChatClient,
215
+ context: () => WsLogContext,
216
+ ): void {
217
+ alignedOutboundContexts.set(client as object, context);
218
+ }
219
+
220
+ export function flushAlignedOutboundQueue(client: ClawlingChatClient): void {
221
+ const queue = alignedOutboundQueues.get(client as object);
222
+ queue?.flush((wire) => client.sendWire(wire));
223
+ }
224
+
225
+ export function getAlignedOutboundQueueSize(client: ClawlingChatClient): number {
226
+ return alignedOutboundQueues.get(client as object)?.snapshot().length ?? 0;
227
+ }
228
+
229
+ async function sendAlignedAckableEnvelope(params: {
230
+ client: ClawlingChatClient;
231
+ account: ResolvedOpenclawClawlingAccount;
232
+ eventName: "message.send" | "message.reply";
233
+ chatId: string;
234
+ payload: object;
235
+ log?: LogSink;
236
+ }): Promise<Envelope<MessageAckPayload>> {
237
+ const traceId = params.client.nextTraceId();
238
+ const env = {
239
+ version: "2" as const,
240
+ event: params.eventName,
241
+ trace_id: traceId,
242
+ emitted_at: Date.now(),
243
+ chat_id: params.chatId,
244
+ payload: params.payload,
245
+ };
246
+ const wire = JSON.stringify(env);
247
+ const queue = getAlignedOutboundQueue(params.client, params.account, params.log);
248
+ const messageErrorTracker = getAlignedMessageErrorTracker(params.client, params.account, params.log);
249
+ const isReady = () => {
250
+ const state = (params.client as { state?: string }).state;
251
+ return params.client.transportState === "open" && (!state || state === "connected");
252
+ };
253
+ const isDisconnected = () => (params.client as { state?: string }).state === "disconnected";
254
+
255
+ type AckableSendState = "queued" | "written_waiting_ack" | "acked" | "failed";
256
+
257
+ return await new Promise<Envelope<MessageAckPayload>>((resolve, reject) => {
258
+ let state: AckableSendState = "queued";
259
+ let timer: ReturnType<typeof setTimeout> | undefined;
260
+ let rawListenerRegistered = false;
261
+ let removeCloseListener: (() => void) | undefined;
262
+ let removeStateListener: (() => void) | undefined;
263
+
264
+ const clearAckTimer = () => {
265
+ if (!timer) return;
266
+ clearTimeout(timer);
267
+ timer = undefined;
268
+ };
269
+
270
+ const removeRawListener = () => {
271
+ if (!rawListenerRegistered) return;
272
+ params.client.off("raw", onRaw);
273
+ rawListenerRegistered = false;
274
+ };
275
+
276
+ const cleanup = () => {
277
+ messageErrorTracker.pending.delete(traceId);
278
+ clearAckTimer();
279
+ removeRawListener();
280
+ removeCloseListener?.();
281
+ removeCloseListener = undefined;
282
+ removeStateListener?.();
283
+ removeStateListener = undefined;
284
+ };
285
+
286
+ const fail = (err: Error) => {
287
+ if (state === "acked" || state === "failed") return;
288
+ state = "failed";
289
+ cleanup();
290
+ reject(err);
291
+ };
292
+
293
+ const logAck = (
294
+ event: "ack_received" | "ack_timeout",
295
+ action: "resolve" | "reject_no_reconnect",
296
+ fields: Array<[string, string | number | boolean | null | undefined]>,
297
+ ) => {
298
+ params.log?.info?.(
299
+ formatWsLog({
300
+ event,
301
+ accountId: params.account.accountId,
302
+ ...(
303
+ alignedOutboundContexts.get(params.client as object)?.() ?? {
304
+ attempt: 1,
305
+ reconnectCount: 0,
306
+ state: isReady() ? "ready" : "reconnecting",
307
+ }
308
+ ),
309
+ action,
310
+ fields: [
311
+ ["event_name", params.eventName],
312
+ ["trace_id", traceId],
313
+ ["chat_id", params.chatId],
314
+ ...fields,
315
+ ],
316
+ }),
317
+ );
318
+ };
319
+
320
+ function onRaw(ack: Envelope) {
321
+ if (ack.trace_id !== traceId) return;
322
+ if (ack.event === "message.error") {
323
+ if (state === "acked" || state === "failed") return;
324
+ const payload = ack.payload as Partial<MessageErrorPayload>;
325
+ const code = typeof payload.code === "string" && payload.code ? payload.code : "unknown";
326
+ const message = typeof payload.message === "string" && payload.message ? payload.message : "message send failed";
327
+ fail(new MessageSendError(traceId, code, message, ack.chat_id));
328
+ return;
329
+ }
330
+ if (ack.event !== "message.ack") return;
331
+ if (state === "acked" || state === "failed") return;
332
+ state = "acked";
333
+ cleanup();
334
+ const payload = ack.payload as MessageAckPayload;
335
+ logAck("ack_received", "resolve", [["message_id", payload.message_id]]);
336
+ resolve(ack as Envelope<MessageAckPayload>);
337
+ }
338
+
339
+ const startAckTimer = () => {
340
+ if (state === "acked" || state === "failed") return;
341
+ state = "written_waiting_ack";
342
+ messageErrorTracker.pending.add(traceId);
343
+ clearAckTimer();
344
+ removeRawListener();
345
+ params.client.on("raw", onRaw);
346
+ rawListenerRegistered = true;
347
+ timer = setTimeout(() => {
348
+ logAck("ack_timeout", "reject_no_reconnect", [["timeout_ms", params.account.ack.timeout]]);
349
+ fail(new Error(`ack timeout after ${params.account.ack.timeout}ms for trace_id=${traceId}`));
350
+ }, params.account.ack.timeout);
351
+ };
352
+
353
+ const item = {
354
+ eventName: params.eventName,
355
+ traceId,
356
+ chatId: params.chatId,
357
+ wire,
358
+ onWrite: startAckTimer,
359
+ onDrop: () => {
360
+ fail(new Error(`send queue full; dropped ${params.eventName} before write for trace_id=${traceId}`));
361
+ },
362
+ };
363
+
364
+ function isTerminalClose(close: AlignedOutboundClose): boolean {
365
+ return close?.code === 1000 || close?.reason === "client close" || close?.reason === "auth failed";
366
+ }
367
+
368
+ function onClose(close?: AlignedOutboundClose) {
369
+ if (state === "acked" || state === "failed") return;
370
+ if (isTerminalClose(close)) {
371
+ const reason = typeof close?.reason === "string" && close.reason ? close.reason : "websocket closed";
372
+ queue.remove(item);
373
+ fail(new Error(`send cancelled because ${reason}`));
374
+ return;
375
+ }
376
+ if (state !== "written_waiting_ack") return;
377
+ clearAckTimer();
378
+ removeRawListener();
379
+ state = "queued";
380
+ queue.enqueue(item);
381
+ }
382
+
383
+ function onState(next?: AlignedOutboundState) {
384
+ if (state === "acked" || state === "failed" || next?.to !== "disconnected") return;
385
+ queue.remove(item);
386
+ fail(new Error("send cancelled because client disconnected"));
387
+ }
388
+
389
+ removeCloseListener = addAlignedOutboundCloseHandler(params.client, onClose);
390
+ removeStateListener = addAlignedOutboundStateHandler(params.client, onState);
391
+
392
+ if (!isReady()) {
393
+ if (isDisconnected()) {
394
+ fail(new Error("send cancelled because client disconnected"));
395
+ return;
396
+ }
397
+ queue.enqueue(item);
398
+ return;
399
+ }
400
+
401
+ try {
402
+ queue.enqueue(item);
403
+ queue.flush((queuedWire) => params.client.sendWire(queuedWire));
404
+ } catch {
405
+ // The queue keeps the failed frame at the head for reconnect retry, so
406
+ // keep this promise pending until the frame is written+acked, dropped, or timed out.
407
+ }
408
+ });
409
+ }
410
+
411
+ /**
412
+ * Parse an agent-initiated outbound recipient string into the new-protocol
413
+ * `chat_id` + `chat_type` pair.
414
+ *
415
+ * Accepted forms (case-insensitive prefix):
416
+ * - `cc:{chat_id}` → direct
417
+ * - `clawchat:{chat_id}` → direct
418
+ * - `clawchat-plugin-openclaw:{chat_id}` → direct
419
+ * - `cc:direct:{chat_id}` → direct
420
+ * - `cc:group:{chat_id}` → group
421
+ * - `clawchat:direct:{chat_id}` → direct
422
+ * - `clawchat:group:{chat_id}` → group
423
+ * - `clawchat-plugin-openclaw:direct:{chat_id}` → direct
424
+ * - `clawchat-plugin-openclaw:group:{chat_id}` → group
425
+ * - `direct:{chat_id}` → direct (host-normalized)
426
+ * - `group:{chat_id}` → group (host-normalized)
427
+ * - bare `{chat_id}` → direct (backward compat)
428
+ */
429
+ export function parseOpenclawRecipient(to: string): { chatId: string; chatType: ChatType } {
430
+ const raw = (to ?? "").trim();
431
+ if (!raw) throw new Error("clawchat-plugin-openclaw: outbound `to` is empty");
432
+
433
+ const firstColon = raw.indexOf(":");
434
+ if (firstColon < 0) return { chatId: raw, chatType: "direct" };
435
+
436
+ const scheme = raw.slice(0, firstColon).toLowerCase();
437
+ const rest = raw.slice(firstColon + 1);
438
+ if (scheme === "direct" || scheme === "group") {
439
+ const chatId = rest.trim();
440
+ if (!chatId) throw new Error(`clawchat-plugin-openclaw: missing chat_id in "${to}"`);
441
+ return { chatId, chatType: scheme };
442
+ }
443
+ if (scheme !== "cc" && scheme !== "clawchat" && scheme !== CHANNEL_ID) {
444
+ return { chatId: raw, chatType: "direct" };
445
+ }
446
+
447
+ const secondColon = rest.indexOf(":");
448
+ if (secondColon >= 0) {
449
+ const typeToken = rest.slice(0, secondColon).toLowerCase();
450
+ const chatId = rest.slice(secondColon + 1).trim();
451
+ if ((typeToken === "direct" || typeToken === "group") && chatId) {
452
+ return { chatId, chatType: typeToken };
453
+ }
454
+ }
455
+ const chatId = rest.trim();
456
+ if (!chatId) throw new Error(`clawchat-plugin-openclaw: missing chat_id in "${to}"`);
457
+ return { chatId, chatType: "direct" };
458
+ }
459
+
460
+ export async function sendOpenclawClawlingText(params: SendParams): Promise<SendResult | null> {
461
+ const text = (params.text ?? "").trim();
462
+ const richFragments = params.richFragments ?? [];
463
+ const mediaFragments = params.mediaFragments ?? [];
464
+ if (
465
+ isClawChatNoopResponseText(text) &&
466
+ richFragments.length === 0 &&
467
+ mediaFragments.length === 0
468
+ ) {
469
+ params.log?.info?.(
470
+ `[${params.account.accountId}] clawchat-plugin-openclaw outbound suppressed: silent response`,
471
+ );
472
+ return null;
473
+ }
474
+ if (!text && richFragments.length === 0 && mediaFragments.length === 0) {
475
+ params.log?.info?.(
476
+ `[${params.account.accountId}] clawchat-plugin-openclaw outbound suppressed: empty text and no media`,
477
+ );
478
+ return null;
479
+ }
480
+
481
+ const mentions = params.mentions ?? [];
482
+ const textFragments = text ? textToFragments(text) : [];
483
+ // Each MediaItem object is structurally compatible
484
+ // with one of the local narrow Fragment members (ImageFragment / FileFragment /
485
+ // AudioFragment / VideoFragment) based on its runtime `kind`. The wide local
486
+ // shape lets us build a single uniform array without a per-kind switch.
487
+ const fragments = [...textFragments, ...richFragments, ...mediaFragments] as Fragment[];
488
+
489
+ const useReply = Boolean(
490
+ params.replyCtx?.replyPreviewSenderId
491
+ && params.replyCtx.replyPreviewNickName
492
+ && params.replyCtx.replyPreviewText,
493
+ );
494
+ const messageId = params.messageId;
495
+
496
+ let ack: Envelope<MessageAckPayload>;
497
+ let mode: "send" | "reply";
498
+ if (useReply && params.replyCtx) {
499
+ mode = "reply";
500
+ const payload = {
501
+ ...(messageId ? { message_id: messageId } : {}),
502
+ message_mode: "normal",
503
+ message: {
504
+ body: { fragments },
505
+ context: {
506
+ mentions,
507
+ reply: {
508
+ reply_to_msg_id: params.replyCtx.replyToMessageId,
509
+ reply_preview: {
510
+ id: params.replyCtx.replyPreviewSenderId,
511
+ nick_name: params.replyCtx.replyPreviewNickName,
512
+ fragments: [{ kind: "text", text: params.replyCtx.replyPreviewText }],
513
+ },
514
+ },
515
+ },
516
+ },
517
+ };
518
+ ack = await sendAlignedAckableEnvelope({
519
+ client: params.client,
520
+ account: params.account,
521
+ eventName: "message.reply",
522
+ chatId: params.to.chatId,
523
+ payload,
524
+ ...(params.log ? { log: params.log } : {}),
525
+ });
526
+ } else {
527
+ mode = "send";
528
+ const reply = params.replyCtx
529
+ ? {
530
+ reply_to_msg_id: params.replyCtx.replyToMessageId,
531
+ reply_preview: null,
532
+ }
533
+ : null;
534
+ const payload = {
535
+ ...(messageId ? { message_id: messageId } : {}),
536
+ message_mode: "normal",
537
+ message: {
538
+ body: { fragments },
539
+ context: { mentions, reply },
540
+ },
541
+ };
542
+ ack = await sendAlignedAckableEnvelope({
543
+ client: params.client,
544
+ account: params.account,
545
+ eventName: "message.send",
546
+ chatId: params.to.chatId,
547
+ payload,
548
+ ...(params.log ? { log: params.log } : {}),
549
+ });
550
+ }
551
+ if (messageId && ack.payload.message_id !== messageId) {
552
+ throw new Error(
553
+ `ack message_id mismatch: expected ${messageId} got ${ack.payload.message_id}`,
554
+ );
555
+ }
556
+ params.log?.info?.(
557
+ `[${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}`,
558
+ );
559
+ return {
560
+ messageId: ack.payload.message_id,
561
+ acceptedAt: ack.payload.accepted_at,
562
+ };
563
+ }
564
+
565
+ export async function sendOpenclawClawlingMentionMessage(
566
+ params: SendMentionMessageParams,
567
+ ): Promise<SendMentionMessageResult | null> {
568
+ const normalized = normalizeMentionTargets(params.mentions);
569
+ const prepared = applyTextMentionLabels(normalized, params.text);
570
+ const fragments = buildMentionMessageFragments({
571
+ mentions: prepared.mentions,
572
+ ...(prepared.text ? { text: prepared.text } : {}),
573
+ });
574
+ const contextMentions: MentionFragment[] = prepared.mentions.map((mention) => ({
575
+ kind: "mention",
576
+ user_id: mention.userId,
577
+ ...(mention.display ? { display: mention.display } : {}),
578
+ }));
579
+ const mentionIds = prepared.mentions.map((mention) => mention.userId);
580
+ const result = await sendOpenclawClawlingText({
581
+ client: params.client,
582
+ account: params.account,
583
+ to: params.to,
584
+ text: "",
585
+ richFragments: fragments,
586
+ mentions: contextMentions,
587
+ ...(params.messageId ? { messageId: params.messageId } : {}),
588
+ ...(params.replyCtx ? { replyCtx: params.replyCtx } : {}),
589
+ ...(params.log ? { log: params.log } : {}),
590
+ });
591
+ if (!result) return null;
592
+ return { ...result, mentions: mentionIds };
593
+ }
594
+
595
+ export interface SendMediaParams {
596
+ client: ClawlingChatClient;
597
+ account: ResolvedOpenclawClawlingAccount;
598
+ to: OutboundTarget;
599
+ mediaFragments: ClawlingMediaFragment[];
600
+ /** Optional caption alongside the media. */
601
+ text?: string;
602
+ replyCtx?: OutboundReplyCtx;
603
+ mentions?: MentionFragment[];
604
+ messageId?: string;
605
+ log?: LogSink;
606
+ }
607
+
608
+ /**
609
+ * Send one or more media fragments (image / file / audio / video) to the
610
+ * given target, with an optional text caption.
611
+ *
612
+ * Validates that mediaFragments is non-empty (returns null + info log
613
+ * otherwise) and delegates to {@link sendOpenclawClawlingText} for the
614
+ * actual envelope construction. Reuses the existing replyCtx-downgrade,
615
+ * ack backfill, and log shape.
616
+ */
617
+ export async function sendOpenclawClawlingMedia(
618
+ params: SendMediaParams,
619
+ ): Promise<SendResult | null> {
620
+ if (params.mediaFragments.length === 0) {
621
+ params.log?.info?.(
622
+ `[${params.account.accountId}] clawchat-plugin-openclaw sendMedia called with empty mediaFragments; suppressed`,
623
+ );
624
+ return null;
625
+ }
626
+ return await sendOpenclawClawlingText({
627
+ client: params.client,
628
+ account: params.account,
629
+ to: params.to,
630
+ text: params.text ?? "",
631
+ mediaFragments: params.mediaFragments,
632
+ ...(params.messageId ? { messageId: params.messageId } : {}),
633
+ ...(params.replyCtx ? { replyCtx: params.replyCtx } : {}),
634
+ ...(params.mentions ? { mentions: params.mentions } : {}),
635
+ ...(params.log ? { log: params.log } : {}),
636
+ });
637
+ }
638
+
639
+ type OutboundClaimStore = Pick<ClawChatStore, "claimMessageOnce" | "markMessageAcknowledged">;
640
+
641
+ function mintOutboundMessageId(account: ResolvedOpenclawClawlingAccount): string {
642
+ return `${account.userId}-msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
643
+ }
644
+
645
+ function resolveChannelOutboundStore(): OutboundClaimStore | null {
646
+ try {
647
+ const runtime = getOpenclawClawlingRuntime();
648
+ const stateDir = runtime.state?.resolveStateDir?.();
649
+ return getClawChatStore({
650
+ ...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
651
+ });
652
+ } catch {
653
+ return null;
654
+ }
655
+ }
656
+
657
+ function claimChannelOutbound(params: {
658
+ account: ResolvedOpenclawClawlingAccount;
659
+ target: OutboundTarget;
660
+ messageId: string;
661
+ text: string;
662
+ raw: unknown;
663
+ }): true | false | null {
664
+ const store = resolveChannelOutboundStore();
665
+ if (!store) return null;
666
+ try {
667
+ return store.claimMessageOnce({
668
+ platform: "openclaw",
669
+ accountId: params.account.accountId,
670
+ kind: "message",
671
+ direction: "outbound",
672
+ eventType: "message.send",
673
+ chatId: params.target.chatId,
674
+ messageId: params.messageId,
675
+ text: params.text,
676
+ raw: params.raw,
677
+ });
678
+ } catch {
679
+ return null;
680
+ }
681
+ }
682
+
683
+ function markChannelOutboundAcknowledged(params: {
684
+ account: ResolvedOpenclawClawlingAccount;
685
+ messageId: string;
686
+ result: SendResult | null;
687
+ }): void {
688
+ const store = resolveChannelOutboundStore();
689
+ if (!store || !params.result?.messageId) return;
690
+ try {
691
+ store.markMessageAcknowledged?.({
692
+ accountId: params.account.accountId,
693
+ kind: "message",
694
+ direction: "outbound",
695
+ messageId: params.messageId,
696
+ protocolMessageId: params.result.messageId,
697
+ ackedAt: params.result.acceptedAt ?? Date.now(),
698
+ });
699
+ } catch {
700
+ // Best-effort diagnostics only; ack has already succeeded.
701
+ }
702
+ }
703
+
704
+ export const openclawClawlingOutbound: ChannelOutboundAdapter = {
705
+ deliveryMode: "direct",
706
+ chunker: (text, limit) => chunkMarkdownText(text, limit),
707
+ chunkerMode: "markdown",
708
+ textChunkLimit: 4000,
709
+ ...createAttachedChannelResultAdapter({
710
+ channel: CHANNEL_ID,
711
+ sendText: async ({ cfg, to, text }) => {
712
+ const account = resolveOpenclawClawlingAccount(cfg);
713
+ const client =
714
+ getOpenclawClawlingClient(account.accountId) ??
715
+ (await waitForOpenclawClawlingClient(account.accountId));
716
+ const target = parseOpenclawRecipient(to);
717
+ const messageId = mintOutboundMessageId(account);
718
+ const trimmedText = text.trim();
719
+ if (!trimmedText) {
720
+ throw new Error("clawchat-plugin-openclaw sendText requires non-empty text");
721
+ }
722
+ const claimed = claimChannelOutbound({
723
+ account,
724
+ target,
725
+ messageId,
726
+ text: trimmedText,
727
+ raw: { target, mode: "channel-sendText" },
728
+ });
729
+ if (claimed === false) {
730
+ throw new Error("clawchat-plugin-openclaw outbound duplicate claim; message not sent");
731
+ }
732
+ if (claimed === null) {
733
+ throw new Error("clawchat-plugin-openclaw outbound message claim failed");
734
+ }
735
+ const result = await sendOpenclawClawlingText({
736
+ client,
737
+ account,
738
+ to: target,
739
+ text,
740
+ messageId,
741
+ });
742
+ markChannelOutboundAcknowledged({ account, messageId, result });
743
+ return {
744
+ to,
745
+ messageId: result?.messageId ?? messageId,
746
+ };
747
+ },
748
+ sendMedia: async ({ cfg, to, text, mediaUrl, mediaAccess, mediaLocalRoots, mediaReadFile }) => {
749
+ const account = resolveOpenclawClawlingAccount(cfg);
750
+ const client =
751
+ getOpenclawClawlingClient(account.accountId) ??
752
+ (await waitForOpenclawClawlingClient(account.accountId));
753
+ if (!mediaUrl?.trim()) {
754
+ throw new Error("clawchat-plugin-openclaw sendMedia requires mediaUrl");
755
+ }
756
+ const runtime = getOpenclawClawlingRuntime();
757
+ const apiClient = createOpenclawClawlingApiClient({
758
+ baseUrl: account.baseUrl,
759
+ token: account.token,
760
+ userId: account.userId,
761
+ });
762
+ const mediaFragments = await uploadOutboundMedia([mediaUrl.trim()], {
763
+ apiClient,
764
+ runtime,
765
+ ...(mediaAccess ? { mediaAccess } : {}),
766
+ ...(mediaLocalRoots ? { mediaLocalRoots } : {}),
767
+ ...(mediaReadFile ? { mediaReadFile } : {}),
768
+ });
769
+ if (mediaFragments.length === 0) {
770
+ throw new Error(`clawchat-plugin-openclaw failed to upload media: ${mediaUrl}`);
771
+ }
772
+ const target = parseOpenclawRecipient(to);
773
+ const messageId = mintOutboundMessageId(account);
774
+ const claimText = (text ?? "").trim();
775
+ const claimed = claimChannelOutbound({
776
+ account,
777
+ target,
778
+ messageId,
779
+ text: claimText,
780
+ raw: { target, mode: "channel-sendMedia", mediaCount: mediaFragments.length },
781
+ });
782
+ if (claimed === false) {
783
+ throw new Error("clawchat-plugin-openclaw outbound duplicate claim; message not sent");
784
+ }
785
+ if (claimed === null) {
786
+ throw new Error("clawchat-plugin-openclaw outbound message claim failed");
787
+ }
788
+ const result = await sendOpenclawClawlingMedia({
789
+ client,
790
+ account,
791
+ to: target,
792
+ text,
793
+ mediaFragments,
794
+ messageId,
795
+ });
796
+ markChannelOutboundAcknowledged({ account, messageId, result });
797
+ return {
798
+ to,
799
+ messageId: result?.messageId ?? messageId,
800
+ };
801
+ },
802
+ }),
803
+ };