@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.
- package/INSTALL.md +64 -0
- package/README.md +121 -19
- package/dist/index.js +10 -19
- package/dist/setup-entry.js +3 -0
- package/dist/src/api-client.js +78 -10
- package/dist/src/api-types.test-d.js +10 -0
- package/dist/src/channel.js +25 -156
- package/dist/src/channel.setup.js +120 -0
- package/dist/src/client.js +37 -41
- package/dist/src/config.js +75 -17
- package/dist/src/inbound.js +79 -61
- package/dist/src/login.runtime.js +84 -19
- package/dist/src/media-runtime.js +8 -8
- package/dist/src/message-mapper.js +1 -1
- package/dist/src/mock-transport.js +31 -0
- package/dist/src/outbound.js +410 -26
- package/dist/src/protocol-types.js +63 -0
- package/dist/src/protocol-types.typecheck.js +1 -0
- package/dist/src/protocol.js +2 -7
- package/dist/src/reply-dispatcher.js +157 -54
- package/dist/src/runtime.js +795 -119
- package/dist/src/storage.js +689 -0
- package/dist/src/tools-schema.js +98 -16
- package/dist/src/tools.js +422 -135
- package/dist/src/ws-alignment.js +178 -0
- package/dist/src/ws-client.js +588 -0
- package/dist/src/ws-log.js +19 -0
- package/index.ts +10 -22
- package/openclaw.plugin.json +37 -2
- package/package.json +17 -4
- package/setup-entry.ts +4 -0
- package/skills/clawchat/SKILL.md +88 -0
- package/src/api-client.test.ts +274 -14
- package/src/api-client.ts +138 -23
- package/src/api-types.test-d.ts +12 -0
- package/src/api-types.ts +90 -4
- package/src/buffered-stream.test.ts +14 -12
- package/src/buffered-stream.ts +1 -1
- package/src/channel.outbound.test.ts +269 -60
- package/src/channel.setup.ts +146 -0
- package/src/channel.test.ts +130 -24
- package/src/channel.ts +30 -186
- package/src/client.test.ts +197 -11
- package/src/client.ts +50 -57
- package/src/config.test.ts +108 -6
- package/src/config.ts +95 -24
- package/src/inbound.test.ts +288 -37
- package/src/inbound.ts +96 -84
- package/src/login.runtime.test.ts +347 -13
- package/src/login.runtime.ts +105 -23
- package/src/manifest.test.ts +146 -74
- package/src/media-runtime.test.ts +57 -2
- package/src/media-runtime.ts +26 -17
- package/src/message-mapper.test.ts +2 -2
- package/src/message-mapper.ts +2 -2
- package/src/mock-transport.test.ts +35 -0
- package/src/mock-transport.ts +38 -0
- package/src/outbound.test.ts +694 -73
- package/src/outbound.ts +484 -31
- package/src/plugin-entry.test.ts +1 -0
- package/src/protocol-types.test.ts +69 -0
- package/src/protocol-types.ts +296 -0
- package/src/protocol-types.typecheck.ts +89 -0
- package/src/protocol.test.ts +1 -6
- package/src/protocol.ts +2 -7
- package/src/reply-dispatcher.test.ts +819 -119
- package/src/reply-dispatcher.ts +202 -60
- package/src/runtime.test.ts +2120 -41
- package/src/runtime.ts +935 -142
- package/src/scripts.test.ts +85 -0
- package/src/storage.test.ts +793 -0
- package/src/storage.ts +1095 -0
- package/src/streaming.test.ts +9 -8
- package/src/streaming.ts +1 -1
- package/src/tools-schema.ts +148 -20
- package/src/tools.test.ts +377 -50
- package/src/tools.ts +574 -154
- package/src/ws-alignment.test.ts +103 -0
- package/src/ws-alignment.ts +275 -0
- package/src/ws-client.test.ts +1218 -0
- package/src/ws-client.ts +662 -0
- package/src/ws-log.test.ts +32 -0
- package/src/ws-log.ts +31 -0
- package/skills/clawchat-account-tools/SKILL.md +0 -26
- package/skills/clawchat-activate/SKILL.md +0 -47
package/src/outbound.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
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
|
-
//
|
|
109
|
-
// with one of the
|
|
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
|
|
115
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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:
|
|
645
|
+
to: target,
|
|
213
646
|
text,
|
|
647
|
+
messageId,
|
|
214
648
|
});
|
|
215
649
|
return {
|
|
216
650
|
to,
|
|
217
|
-
messageId: result?.messageId ??
|
|
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:
|
|
697
|
+
to: target,
|
|
246
698
|
text,
|
|
247
699
|
mediaFragments,
|
|
700
|
+
messageId,
|
|
248
701
|
});
|
|
249
702
|
return {
|
|
250
703
|
to,
|
|
251
|
-
messageId: result?.messageId ??
|
|
704
|
+
messageId: result?.messageId ?? messageId,
|
|
252
705
|
};
|
|
253
706
|
},
|
|
254
707
|
}),
|
package/src/plugin-entry.test.ts
CHANGED
|
@@ -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: {
|