@newbase-clawchat/openclaw-clawchat 2026.4.21 → 2026.4.23
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/package.json +3 -5
- package/src/api-client.test.ts +19 -0
- package/src/api-client.ts +2 -2
- package/src/api-types.ts +3 -3
- package/src/channel.outbound.test.ts +203 -0
- package/src/channel.test.ts +19 -0
- package/src/channel.ts +16 -72
- package/src/inbound.test.ts +17 -0
- package/src/inbound.ts +3 -1
- package/src/media-runtime.test.ts +9 -16
- package/src/media-runtime.ts +4 -4
- package/src/outbound.ts +115 -2
- package/src/protocol.test.ts +3 -1
- package/src/protocol.ts +7 -3
- package/src/reply-dispatcher.test.ts +252 -0
- package/src/reply-dispatcher.ts +37 -3
- package/src/runtime.test.ts +298 -0
- package/src/runtime.ts +53 -11
- package/src/tools-schema.ts +10 -2
- package/src/tools.test.ts +31 -2
- package/src/tools.ts +59 -15
package/src/runtime.test.ts
CHANGED
|
@@ -166,6 +166,8 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
166
166
|
event: "message.send",
|
|
167
167
|
trace_id: "ti",
|
|
168
168
|
emitted_at: Date.now(),
|
|
169
|
+
chat_id: "chat-1",
|
|
170
|
+
chat_type: "direct",
|
|
169
171
|
to: { id: "u", type: "direct" },
|
|
170
172
|
sender: { sender_id: "user-1", type: "direct", display_name: "User" },
|
|
171
173
|
payload: {
|
|
@@ -198,6 +200,302 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
198
200
|
expect(fetched).toEqual([{ url: "https://cdn/a.png" }]);
|
|
199
201
|
expect(capturedCtx?.MediaPath).toBe("/cache/1.png");
|
|
200
202
|
expect(capturedCtx?.MediaPaths).toEqual(["/cache/1.png"]);
|
|
203
|
+
expect(capturedCtx?.From).toBe("openclaw-clawchat:chat-1");
|
|
204
|
+
expect(capturedCtx?.ConversationLabel).toBe("chat-1");
|
|
205
|
+
expect(capturedCtx?.SenderId).toBe("user-1");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("uses group chat_id as the canonical conversation identity", async () => {
|
|
209
|
+
let capturedCtx: Record<string, unknown> | undefined;
|
|
210
|
+
const finalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => {
|
|
211
|
+
capturedCtx = ctx;
|
|
212
|
+
return ctx;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const runtime = {
|
|
216
|
+
channel: {
|
|
217
|
+
routing: {
|
|
218
|
+
resolveAgentRoute: vi.fn(() => ({
|
|
219
|
+
agentId: "u",
|
|
220
|
+
accountId: "default",
|
|
221
|
+
sessionKey: "s",
|
|
222
|
+
})),
|
|
223
|
+
},
|
|
224
|
+
session: {
|
|
225
|
+
resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
|
|
226
|
+
recordInboundSession: vi.fn().mockResolvedValue(undefined),
|
|
227
|
+
},
|
|
228
|
+
reply: {
|
|
229
|
+
formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
|
|
230
|
+
resolveEnvelopeFormatOptions: vi.fn(() => ({})),
|
|
231
|
+
finalizeInboundContext,
|
|
232
|
+
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
233
|
+
createReplyDispatcherWithTyping: vi.fn(() => ({
|
|
234
|
+
dispatcher: {},
|
|
235
|
+
replyOptions: {},
|
|
236
|
+
markDispatchIdle: vi.fn(),
|
|
237
|
+
markRunComplete: vi.fn(),
|
|
238
|
+
})),
|
|
239
|
+
withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => {
|
|
240
|
+
await opts.run();
|
|
241
|
+
}),
|
|
242
|
+
dispatchReplyFromConfig: vi.fn().mockResolvedValue(undefined),
|
|
243
|
+
},
|
|
244
|
+
media: {
|
|
245
|
+
fetchRemoteMedia: vi.fn(),
|
|
246
|
+
saveMediaBuffer: vi.fn(),
|
|
247
|
+
loadWebMedia: vi.fn(),
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
} as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
|
|
251
|
+
|
|
252
|
+
setOpenclawClawlingRuntime(runtime);
|
|
253
|
+
|
|
254
|
+
const { startOpenclawClawlingGateway } = await import("./runtime.ts");
|
|
255
|
+
const transport = new MockTransport();
|
|
256
|
+
const abortController = new AbortController();
|
|
257
|
+
|
|
258
|
+
const startPromise = startOpenclawClawlingGateway({
|
|
259
|
+
cfg: {} as import("openclaw/plugin-sdk/core").OpenClawConfig,
|
|
260
|
+
account: {
|
|
261
|
+
accountId: "default",
|
|
262
|
+
name: "openclaw-clawchat",
|
|
263
|
+
enabled: true,
|
|
264
|
+
configured: true,
|
|
265
|
+
websocketUrl: "ws://t",
|
|
266
|
+
baseUrl: "https://api.example.com",
|
|
267
|
+
token: "tk",
|
|
268
|
+
userId: "u",
|
|
269
|
+
replyMode: "static",
|
|
270
|
+
groupMode: "all",
|
|
271
|
+
forwardThinking: true,
|
|
272
|
+
forwardToolCalls: false,
|
|
273
|
+
allowFrom: [],
|
|
274
|
+
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
275
|
+
reconnect: {
|
|
276
|
+
initialDelay: 1000,
|
|
277
|
+
maxDelay: 30000,
|
|
278
|
+
jitterRatio: 0.3,
|
|
279
|
+
maxRetries: Number.POSITIVE_INFINITY,
|
|
280
|
+
},
|
|
281
|
+
heartbeat: { interval: 25000, timeout: 10000 },
|
|
282
|
+
ack: { timeout: 10000, autoResendOnTimeout: false },
|
|
283
|
+
},
|
|
284
|
+
abortSignal: abortController.signal,
|
|
285
|
+
setStatus: vi.fn(),
|
|
286
|
+
getStatus: vi.fn(() => ({ accountId: "default" })),
|
|
287
|
+
log: { info: () => {}, error: () => {} },
|
|
288
|
+
transport,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
292
|
+
transport.emitInbound(
|
|
293
|
+
JSON.stringify({
|
|
294
|
+
version: "2",
|
|
295
|
+
event: "connect.challenge",
|
|
296
|
+
trace_id: "tc",
|
|
297
|
+
emitted_at: Date.now(),
|
|
298
|
+
payload: { nonce: "n" },
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
transport.emitInbound(
|
|
302
|
+
JSON.stringify({
|
|
303
|
+
version: "2",
|
|
304
|
+
event: "hello-ok",
|
|
305
|
+
trace_id: "th",
|
|
306
|
+
emitted_at: Date.now(),
|
|
307
|
+
payload: {},
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
311
|
+
|
|
312
|
+
transport.emitInbound(
|
|
313
|
+
JSON.stringify({
|
|
314
|
+
version: "2",
|
|
315
|
+
event: "message.send",
|
|
316
|
+
trace_id: "tg",
|
|
317
|
+
emitted_at: Date.now(),
|
|
318
|
+
chat_id: "grp-1",
|
|
319
|
+
chat_type: "group",
|
|
320
|
+
to: { id: "u", type: "group" },
|
|
321
|
+
sender: { sender_id: "user-1", type: "group", display_name: "Alice" },
|
|
322
|
+
payload: {
|
|
323
|
+
message_id: "m-group",
|
|
324
|
+
message_mode: "normal",
|
|
325
|
+
message: {
|
|
326
|
+
body: {
|
|
327
|
+
fragments: [{ kind: "text", text: "hello group" }],
|
|
328
|
+
},
|
|
329
|
+
context: { mentions: [], reply: null },
|
|
330
|
+
streaming: {
|
|
331
|
+
status: "static",
|
|
332
|
+
sequence: 0,
|
|
333
|
+
mutation_policy: "sealed",
|
|
334
|
+
started_at: null,
|
|
335
|
+
completed_at: null,
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
}),
|
|
340
|
+
);
|
|
341
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
342
|
+
abortController.abort();
|
|
343
|
+
await startPromise;
|
|
344
|
+
|
|
345
|
+
expect(capturedCtx?.From).toBe("openclaw-clawchat:group:grp-1");
|
|
346
|
+
expect(capturedCtx?.ConversationLabel).toBe("group:grp-1");
|
|
347
|
+
expect(capturedCtx?.SenderId).toBe("user-1");
|
|
348
|
+
expect(capturedCtx?.ChatType).toBe("group");
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("openclaw-clawchat runtime reply dispatch lifecycle", () => {
|
|
353
|
+
it("marks dispatch idle when reply dispatch fails", async () => {
|
|
354
|
+
const markDispatchIdle = vi.fn();
|
|
355
|
+
const withReplyDispatcher = vi.fn(
|
|
356
|
+
async (opts: { run: () => Promise<unknown>; onSettled?: () => void | Promise<void> }) => {
|
|
357
|
+
try {
|
|
358
|
+
await opts.run();
|
|
359
|
+
} finally {
|
|
360
|
+
await opts.onSettled?.();
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
);
|
|
364
|
+
const dispatchReplyFromConfig = vi.fn().mockRejectedValue(new Error("dispatch boom"));
|
|
365
|
+
const logError = vi.fn();
|
|
366
|
+
|
|
367
|
+
const runtime = {
|
|
368
|
+
channel: {
|
|
369
|
+
routing: {
|
|
370
|
+
resolveAgentRoute: vi.fn(() => ({
|
|
371
|
+
agentId: "u",
|
|
372
|
+
accountId: "default",
|
|
373
|
+
sessionKey: "s",
|
|
374
|
+
})),
|
|
375
|
+
},
|
|
376
|
+
session: {
|
|
377
|
+
resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
|
|
378
|
+
recordInboundSession: vi.fn().mockResolvedValue(undefined),
|
|
379
|
+
},
|
|
380
|
+
reply: {
|
|
381
|
+
formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
|
|
382
|
+
resolveEnvelopeFormatOptions: vi.fn(() => ({})),
|
|
383
|
+
finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
|
|
384
|
+
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
385
|
+
createReplyDispatcherWithTyping: vi.fn(() => ({
|
|
386
|
+
dispatcher: {},
|
|
387
|
+
replyOptions: {},
|
|
388
|
+
markDispatchIdle,
|
|
389
|
+
})),
|
|
390
|
+
withReplyDispatcher,
|
|
391
|
+
dispatchReplyFromConfig,
|
|
392
|
+
},
|
|
393
|
+
media: {
|
|
394
|
+
fetchRemoteMedia: vi.fn(),
|
|
395
|
+
saveMediaBuffer: vi.fn(),
|
|
396
|
+
loadWebMedia: vi.fn(),
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
} as unknown as PluginRuntime;
|
|
400
|
+
|
|
401
|
+
setOpenclawClawlingRuntime(runtime);
|
|
402
|
+
|
|
403
|
+
const { startOpenclawClawlingGateway } = await import("./runtime.ts");
|
|
404
|
+
const transport = new MockTransport();
|
|
405
|
+
const abortController = new AbortController();
|
|
406
|
+
|
|
407
|
+
const startPromise = startOpenclawClawlingGateway({
|
|
408
|
+
cfg: {} as OpenClawConfig,
|
|
409
|
+
account: {
|
|
410
|
+
accountId: "default",
|
|
411
|
+
name: "openclaw-clawchat",
|
|
412
|
+
enabled: true,
|
|
413
|
+
configured: true,
|
|
414
|
+
websocketUrl: "ws://t",
|
|
415
|
+
baseUrl: "https://api.example.com",
|
|
416
|
+
token: "tk",
|
|
417
|
+
userId: "u",
|
|
418
|
+
replyMode: "static",
|
|
419
|
+
forwardThinking: true,
|
|
420
|
+
forwardToolCalls: false,
|
|
421
|
+
allowFrom: [],
|
|
422
|
+
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
423
|
+
reconnect: {
|
|
424
|
+
initialDelay: 1000,
|
|
425
|
+
maxDelay: 30000,
|
|
426
|
+
jitterRatio: 0.3,
|
|
427
|
+
maxRetries: Number.POSITIVE_INFINITY,
|
|
428
|
+
},
|
|
429
|
+
heartbeat: { interval: 25000, timeout: 10000 },
|
|
430
|
+
ack: { timeout: 10000, autoResendOnTimeout: false },
|
|
431
|
+
},
|
|
432
|
+
abortSignal: abortController.signal,
|
|
433
|
+
setStatus: vi.fn(),
|
|
434
|
+
getStatus: vi.fn(() => ({ accountId: "default" })),
|
|
435
|
+
log: { info: vi.fn(), error: logError },
|
|
436
|
+
transport,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
440
|
+
transport.emitInbound(
|
|
441
|
+
JSON.stringify({
|
|
442
|
+
version: "2",
|
|
443
|
+
event: "connect.challenge",
|
|
444
|
+
trace_id: "tc",
|
|
445
|
+
emitted_at: Date.now(),
|
|
446
|
+
payload: { nonce: "n" },
|
|
447
|
+
}),
|
|
448
|
+
);
|
|
449
|
+
transport.emitInbound(
|
|
450
|
+
JSON.stringify({
|
|
451
|
+
version: "2",
|
|
452
|
+
event: "hello-ok",
|
|
453
|
+
trace_id: "th",
|
|
454
|
+
emitted_at: Date.now(),
|
|
455
|
+
payload: {},
|
|
456
|
+
}),
|
|
457
|
+
);
|
|
458
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
459
|
+
|
|
460
|
+
transport.emitInbound(
|
|
461
|
+
JSON.stringify({
|
|
462
|
+
version: "2",
|
|
463
|
+
event: "message.send",
|
|
464
|
+
trace_id: "tm",
|
|
465
|
+
emitted_at: Date.now(),
|
|
466
|
+
chat_id: "chat-1",
|
|
467
|
+
chat_type: "direct",
|
|
468
|
+
to: { id: "u", type: "direct" },
|
|
469
|
+
sender: { sender_id: "user-1", type: "direct", display_name: "User" },
|
|
470
|
+
payload: {
|
|
471
|
+
message_id: "m-fail",
|
|
472
|
+
message_mode: "normal",
|
|
473
|
+
message: {
|
|
474
|
+
body: {
|
|
475
|
+
fragments: [{ kind: "text", text: "hello" }],
|
|
476
|
+
},
|
|
477
|
+
context: { mentions: [], reply: null },
|
|
478
|
+
streaming: {
|
|
479
|
+
status: "static",
|
|
480
|
+
sequence: 0,
|
|
481
|
+
mutation_policy: "sealed",
|
|
482
|
+
started_at: null,
|
|
483
|
+
completed_at: null,
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
}),
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
491
|
+
abortController.abort();
|
|
492
|
+
await startPromise;
|
|
493
|
+
|
|
494
|
+
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
495
|
+
expect(markDispatchIdle).toHaveBeenCalledTimes(1);
|
|
496
|
+
expect(logError).toHaveBeenCalledWith(
|
|
497
|
+
expect.stringContaining("openclaw-clawchat message handler error:"),
|
|
498
|
+
);
|
|
201
499
|
});
|
|
202
500
|
});
|
|
203
501
|
|
package/src/runtime.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { dispatchOpenclawClawlingInbound } from "./inbound.ts";
|
|
|
19
19
|
import { fetchInboundMedia } from "./media-runtime.ts";
|
|
20
20
|
import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
|
|
21
21
|
import { sendStreamingText } from "./streaming.ts";
|
|
22
|
+
import { sendOpenclawClawlingText } from "./outbound.ts";
|
|
22
23
|
|
|
23
24
|
type Log = { info?: (m: string) => void; error?: (m: string) => void };
|
|
24
25
|
|
|
@@ -33,6 +34,28 @@ export function getOpenclawClawlingClient(accountId: string): ClawlingChatClient
|
|
|
33
34
|
return activeClients.get(accountId);
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
export async function waitForOpenclawClawlingClient(
|
|
38
|
+
accountId: string,
|
|
39
|
+
options: { timeoutMs?: number; pollMs?: number } = {},
|
|
40
|
+
): Promise<ClawlingChatClient> {
|
|
41
|
+
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
42
|
+
const pollMs = options.pollMs ?? 100;
|
|
43
|
+
const deadline = Date.now() + timeoutMs;
|
|
44
|
+
|
|
45
|
+
for (;;) {
|
|
46
|
+
const client = activeClients.get(accountId);
|
|
47
|
+
if (client && (client as { state?: string }).state === "connected") {
|
|
48
|
+
return client;
|
|
49
|
+
}
|
|
50
|
+
if (Date.now() >= deadline) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`openclaw-clawchat client did not activate within ${timeoutMs}ms for account ${accountId}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
export type ClawlingState =
|
|
37
60
|
| "idle"
|
|
38
61
|
| "connecting"
|
|
@@ -80,6 +103,10 @@ export function classifyClawlingClientError(err: unknown): {
|
|
|
80
103
|
};
|
|
81
104
|
}
|
|
82
105
|
|
|
106
|
+
function formatConversationSubject(peer: { kind: "direct" | "group"; id: string }): string {
|
|
107
|
+
return peer.kind === "group" ? `group:${peer.id}` : peer.id;
|
|
108
|
+
}
|
|
109
|
+
|
|
83
110
|
export interface StartGatewayParams {
|
|
84
111
|
cfg: OpenClawConfig;
|
|
85
112
|
account: ResolvedOpenclawClawlingAccount;
|
|
@@ -134,8 +161,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
134
161
|
}
|
|
135
162
|
});
|
|
136
163
|
|
|
137
|
-
client.on("message", (env: Envelope) => {
|
|
138
|
-
|
|
164
|
+
client.on("message", async (env: Envelope) => {
|
|
165
|
+
try {
|
|
166
|
+
await dispatchOpenclawClawlingInbound({
|
|
139
167
|
envelope: env as Envelope<DownlinkMessageSendPayload>,
|
|
140
168
|
cfg,
|
|
141
169
|
runtime,
|
|
@@ -152,7 +180,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
152
180
|
});
|
|
153
181
|
const body = rt.reply.formatAgentEnvelope({
|
|
154
182
|
channel: "Clawling Chat",
|
|
155
|
-
from: turn.
|
|
183
|
+
from: formatConversationSubject(turn.peer),
|
|
156
184
|
body: turn.rawBody,
|
|
157
185
|
timestamp: turn.timestamp,
|
|
158
186
|
...rt.reply.resolveEnvelopeFormatOptions(cfg),
|
|
@@ -162,12 +190,16 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
162
190
|
BodyForAgent: turn.rawBody,
|
|
163
191
|
RawBody: turn.rawBody,
|
|
164
192
|
CommandBody: turn.rawBody,
|
|
165
|
-
|
|
193
|
+
// Clawling v2 routes by chat_id. `senderId` is still preserved as
|
|
194
|
+
// structured metadata, but the conversation target must be based on
|
|
195
|
+
// `peer.id` so follow-up sends address the active chat, not merely
|
|
196
|
+
// the human sender identity.
|
|
197
|
+
From: `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`,
|
|
166
198
|
To: `${CHANNEL_ID}:${account.userId}`,
|
|
167
199
|
SessionKey: route.sessionKey,
|
|
168
200
|
AccountId: route.accountId ?? accountId,
|
|
169
201
|
ChatType: turn.peer.kind,
|
|
170
|
-
ConversationLabel: turn.
|
|
202
|
+
ConversationLabel: formatConversationSubject(turn.peer),
|
|
171
203
|
SenderId: turn.senderId,
|
|
172
204
|
Provider: CHANNEL_ID,
|
|
173
205
|
Surface: CHANNEL_ID,
|
|
@@ -230,9 +262,11 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
230
262
|
log?.info?.(
|
|
231
263
|
`[${accountId}] openclaw-clawchat dispatching reply msg=${turn.messageId} session=${ctxPayload.SessionKey ?? route.sessionKey} agent=${route.agentId} agentsConfigured=[${agentsConfigured.join(",")}]`,
|
|
232
264
|
);
|
|
265
|
+
|
|
233
266
|
try {
|
|
234
267
|
const dispatchResult = await rt.reply.withReplyDispatcher({
|
|
235
268
|
dispatcher,
|
|
269
|
+
onSettled: () => markDispatchIdle(),
|
|
236
270
|
run: () =>
|
|
237
271
|
rt.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions }),
|
|
238
272
|
});
|
|
@@ -254,16 +288,24 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
254
288
|
log?.error?.(
|
|
255
289
|
`[${accountId}] openclaw-clawchat dispatch failed msg=${turn.messageId}: ${String(err)}`,
|
|
256
290
|
);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
291
|
+
await sendOpenclawClawlingText({
|
|
292
|
+
client,
|
|
293
|
+
account: turn.account,
|
|
294
|
+
to: {
|
|
295
|
+
chatId: turn.peer.id,
|
|
296
|
+
chatType: turn.peer.kind === "group" ? "group" : "direct",
|
|
297
|
+
},
|
|
298
|
+
text: String(err),
|
|
299
|
+
...(turn.replyCtx ? { replyCtx: turn.replyCtx } : {}),
|
|
300
|
+
});
|
|
260
301
|
}
|
|
261
302
|
},
|
|
262
|
-
|
|
303
|
+
})
|
|
304
|
+
} catch (err) {
|
|
263
305
|
log?.error?.(
|
|
264
|
-
`[${accountId}] openclaw-clawchat
|
|
306
|
+
`[${accountId}] openclaw-clawchat message handler error: ${err instanceof Error ? err.stack || err.message : String(err)}`,
|
|
265
307
|
);
|
|
266
|
-
}
|
|
308
|
+
}
|
|
267
309
|
});
|
|
268
310
|
|
|
269
311
|
// `client.connect()` resolves on `hello-ok` or rejects on `hello-fail`
|
package/src/tools-schema.ts
CHANGED
|
@@ -22,9 +22,10 @@ export type ClawchatListFriendsParams = Static<typeof ClawchatListFriendsSchema>
|
|
|
22
22
|
|
|
23
23
|
export const ClawchatUpdateMyProfileSchema = Type.Object({
|
|
24
24
|
nickname: Type.Optional(Type.String({ description: "New Nick Name" })),
|
|
25
|
-
|
|
26
|
-
Type.String({ description: "Avatar URL (use
|
|
25
|
+
avatar_url: Type.Optional(
|
|
26
|
+
Type.String({ description: "Avatar URL (use clawchat_upload_avatar first to obtain)" }),
|
|
27
27
|
),
|
|
28
|
+
bio: Type.Optional(Type.String({ description: "New self-introduction / bio text" })),
|
|
28
29
|
});
|
|
29
30
|
export type ClawchatUpdateMyProfileParams = Static<typeof ClawchatUpdateMyProfileSchema>;
|
|
30
31
|
|
|
@@ -35,6 +36,13 @@ export const ClawchatUploadFileSchema = Type.Object({
|
|
|
35
36
|
});
|
|
36
37
|
export type ClawchatUploadFileParams = Static<typeof ClawchatUploadFileSchema>;
|
|
37
38
|
|
|
39
|
+
export const ClawchatUploadAvatarSchema = Type.Object({
|
|
40
|
+
filePath: Type.String({
|
|
41
|
+
description: "Absolute local path of the avatar image to upload (max 20MB)",
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
export type ClawchatUploadAvatarParams = Static<typeof ClawchatUploadAvatarSchema>;
|
|
45
|
+
|
|
38
46
|
export const ClawchatActivateSchema = Type.Object({
|
|
39
47
|
code: Type.String({
|
|
40
48
|
description:
|
package/src/tools.test.ts
CHANGED
|
@@ -55,7 +55,7 @@ describe("registerOpenclawClawlingTools", () => {
|
|
|
55
55
|
expect(registered).toHaveLength(0);
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
it("registers all
|
|
58
|
+
it("registers all seven tools when configured (regardless of baseUrl)", () => {
|
|
59
59
|
const { api, registered } = buildApi({
|
|
60
60
|
configChannel: configuredChannel(/* no baseUrl */),
|
|
61
61
|
});
|
|
@@ -67,6 +67,7 @@ describe("registerOpenclawClawlingTools", () => {
|
|
|
67
67
|
"clawchat_get_user_info",
|
|
68
68
|
"clawchat_list_friends",
|
|
69
69
|
"clawchat_update_my_profile",
|
|
70
|
+
"clawchat_upload_avatar",
|
|
70
71
|
"clawchat_upload_file",
|
|
71
72
|
]);
|
|
72
73
|
});
|
|
@@ -91,7 +92,7 @@ describe("registerOpenclawClawlingTools", () => {
|
|
|
91
92
|
expect(activate.description).toMatch(/verbatim/i);
|
|
92
93
|
});
|
|
93
94
|
|
|
94
|
-
it("clawchat_update_my_profile description names name + avatar triggers (EN + ZH)", () => {
|
|
95
|
+
it("clawchat_update_my_profile description names name + avatar + bio triggers (EN + ZH)", () => {
|
|
95
96
|
const { api } = buildApi({ configChannel: configuredChannel() });
|
|
96
97
|
const fullTools: Array<{ name: string; description?: string }> = [];
|
|
97
98
|
api.registerTool = (tool: { name: string; description?: string }) => {
|
|
@@ -104,6 +105,34 @@ describe("registerOpenclawClawlingTools", () => {
|
|
|
104
105
|
expect(update.description).toMatch(/你叫/);
|
|
105
106
|
expect(update.description).toMatch(/avatar/i);
|
|
106
107
|
expect(update.description).toMatch(/生成头像|换个头像/);
|
|
108
|
+
expect(update.description).toMatch(/clawchat_upload_avatar/);
|
|
109
|
+
expect(update.description).toMatch(/bio|self introduction/i);
|
|
110
|
+
expect(update.description).toMatch(/自我介绍|个人简介/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("clawchat_upload_avatar rejects oversized files before upload", async () => {
|
|
114
|
+
const fs = await import("node:fs/promises");
|
|
115
|
+
const path = await import("node:path");
|
|
116
|
+
const os = await import("node:os");
|
|
117
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawchat-"));
|
|
118
|
+
const big = path.join(tmp, "avatar-big.bin");
|
|
119
|
+
const handle = await fs.open(big, "w");
|
|
120
|
+
await handle.truncate(21 * 1024 * 1024);
|
|
121
|
+
await handle.close();
|
|
122
|
+
try {
|
|
123
|
+
const { api, registered } = buildApi({
|
|
124
|
+
configChannel: configuredChannel({ baseUrl: "https://api.example.com" }),
|
|
125
|
+
});
|
|
126
|
+
registerOpenclawClawlingTools(api);
|
|
127
|
+
const tool = registered.find((t) => t.name === "clawchat_upload_avatar")!;
|
|
128
|
+
const result = await tool.execute("call-1", { filePath: big });
|
|
129
|
+
const text = (result as { content: { text: string }[] }).content[0]!.text;
|
|
130
|
+
const parsed = JSON.parse(text) as { error?: string; message?: string };
|
|
131
|
+
expect(parsed.error).toBe("validation");
|
|
132
|
+
expect(parsed.message).toMatch(/20 ?MB|too large/i);
|
|
133
|
+
} finally {
|
|
134
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
135
|
+
}
|
|
107
136
|
});
|
|
108
137
|
|
|
109
138
|
|
package/src/tools.ts
CHANGED
|
@@ -11,11 +11,13 @@ import {
|
|
|
11
11
|
ClawchatGetUserInfoSchema,
|
|
12
12
|
ClawchatListFriendsSchema,
|
|
13
13
|
ClawchatUpdateMyProfileSchema,
|
|
14
|
+
ClawchatUploadAvatarSchema,
|
|
14
15
|
ClawchatUploadFileSchema,
|
|
15
16
|
type ClawchatActivateParams,
|
|
16
17
|
type ClawchatGetUserInfoParams,
|
|
17
18
|
type ClawchatListFriendsParams,
|
|
18
19
|
type ClawchatUpdateMyProfileParams,
|
|
20
|
+
type ClawchatUploadAvatarParams,
|
|
19
21
|
type ClawchatUploadFileParams,
|
|
20
22
|
} from "./tools-schema.ts";
|
|
21
23
|
|
|
@@ -188,7 +190,7 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
|
|
|
188
190
|
api.registerTool(
|
|
189
191
|
{
|
|
190
192
|
name: "clawchat_get_my_profile",
|
|
191
|
-
label: "
|
|
193
|
+
label: "Get Profile",
|
|
192
194
|
description: "Fetch the agent's own Clawling profile (id, display name, avatar, bio).",
|
|
193
195
|
parameters: ClawchatGetMyProfileSchema,
|
|
194
196
|
async execute(_callId, _params) {
|
|
@@ -201,7 +203,7 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
|
|
|
201
203
|
api.registerTool(
|
|
202
204
|
{
|
|
203
205
|
name: "clawchat_get_user_info",
|
|
204
|
-
label: "
|
|
206
|
+
label: "Get User Info",
|
|
205
207
|
description: "Fetch a Clawling user's public profile by userId.",
|
|
206
208
|
parameters: ClawchatGetUserInfoSchema,
|
|
207
209
|
async execute(_callId, params) {
|
|
@@ -215,7 +217,7 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
|
|
|
215
217
|
api.registerTool(
|
|
216
218
|
{
|
|
217
219
|
name: "clawchat_list_friends",
|
|
218
|
-
label: "
|
|
220
|
+
label: "List Friends",
|
|
219
221
|
description: "List the agent's friends, paginated (page=1, pageSize=20 by default).",
|
|
220
222
|
parameters: ClawchatListFriendsSchema,
|
|
221
223
|
async execute(_callId, params) {
|
|
@@ -234,9 +236,9 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
|
|
|
234
236
|
api.registerTool(
|
|
235
237
|
{
|
|
236
238
|
name: "clawchat_update_my_profile",
|
|
237
|
-
label: "
|
|
239
|
+
label: "Update Profile",
|
|
238
240
|
description:
|
|
239
|
-
"Update this agent's own ClawChat profile (nickname and/or avatar). " +
|
|
241
|
+
"Update this agent's own ClawChat profile (nickname and/or avatar and/or bio). " +
|
|
240
242
|
"TRIGGER — invoke this tool whenever the user's message matches ANY of: " +
|
|
241
243
|
"(1) nickname/name change: 'change your name to X', 'your name is X', 'rename yourself to X', " +
|
|
242
244
|
"'I'll call you X', 'from now on you are X', '你叫 X', '改名为 X', '我叫你 X', " +
|
|
@@ -244,19 +246,23 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
|
|
|
244
246
|
"(2) avatar change or generation: 'change your avatar', 'update your profile picture', " +
|
|
245
247
|
"'generate a new avatar', 'use this image as your avatar', '换个头像', '生成头像', " +
|
|
246
248
|
"'把头像改为 …' → first obtain the avatar URL (generate + upload via " +
|
|
247
|
-
"`
|
|
248
|
-
"with `
|
|
249
|
-
"
|
|
250
|
-
"
|
|
249
|
+
"`clawchat_upload_avatar`, OR use a provided URL directly), then call this tool " +
|
|
250
|
+
"with `avatar_url = <url>`; " +
|
|
251
|
+
"(3) bio/self-introduction change: 'update your bio', 'set your profile bio to X', " +
|
|
252
|
+
"'change your self introduction', '把简介改成 X', '更新自我介绍', '个人简介改为 X' " +
|
|
253
|
+
"→ call with `bio = X`. " +
|
|
254
|
+
"You can pass `nickname`, `avatar_url`, and `bio` together in one call, or just one of them. " +
|
|
255
|
+
"At least one of the three must be present.",
|
|
251
256
|
parameters: ClawchatUpdateMyProfileSchema,
|
|
252
257
|
async execute(_callId, params) {
|
|
253
258
|
const p = (params ?? {}) as ClawchatUpdateMyProfileParams;
|
|
254
|
-
const patch: {
|
|
255
|
-
if (typeof p.nickname === "string") patch.
|
|
256
|
-
if (typeof p.
|
|
259
|
+
const patch: { nickname?: string; avatar_url?: string; bio?: string } = {};
|
|
260
|
+
if (typeof p.nickname === "string") patch.nickname = p.nickname;
|
|
261
|
+
if (typeof p.avatar_url === "string") patch.avatar_url = p.avatar_url;
|
|
262
|
+
if (typeof p.bio === "string") patch.bio = p.bio;
|
|
257
263
|
if (Object.keys(patch).length === 0) {
|
|
258
264
|
return validationError(
|
|
259
|
-
"openclaw-clawchat: at least one of nickname / avatar is required",
|
|
265
|
+
"openclaw-clawchat: at least one of nickname / avatar / bio is required",
|
|
260
266
|
);
|
|
261
267
|
}
|
|
262
268
|
return await withClient((c): Promise<Profile> => c.updateMyProfile(patch));
|
|
@@ -265,10 +271,48 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
|
|
|
265
271
|
{ name: "clawchat_update_my_profile" },
|
|
266
272
|
);
|
|
267
273
|
|
|
274
|
+
api.registerTool(
|
|
275
|
+
{
|
|
276
|
+
name: "clawchat_upload_avatar",
|
|
277
|
+
label: "Upload Avatar",
|
|
278
|
+
description:
|
|
279
|
+
"Upload a local avatar image to Clawling avatar storage (max 20MB) and return the public URL. " +
|
|
280
|
+
"Use this before `clawchat_update_my_profile` when changing the profile picture.",
|
|
281
|
+
parameters: ClawchatUploadAvatarSchema,
|
|
282
|
+
async execute(_callId, params) {
|
|
283
|
+
const p = params as ClawchatUploadAvatarParams;
|
|
284
|
+
if (!p.filePath || !path.isAbsolute(p.filePath)) {
|
|
285
|
+
return validationError("openclaw-clawchat: filePath must be an absolute local path");
|
|
286
|
+
}
|
|
287
|
+
let stat: fs.Stats;
|
|
288
|
+
try {
|
|
289
|
+
stat = fs.statSync(p.filePath);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
return validationError(
|
|
292
|
+
`openclaw-clawchat: cannot stat ${p.filePath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (!stat.isFile()) {
|
|
296
|
+
return validationError(`openclaw-clawchat: ${p.filePath} is not a regular file`);
|
|
297
|
+
}
|
|
298
|
+
if (stat.size > MAX_UPLOAD_BYTES) {
|
|
299
|
+
return validationError(
|
|
300
|
+
`openclaw-clawchat: file too large (${stat.size} bytes; max 20MB)`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
const buffer = fs.readFileSync(p.filePath);
|
|
304
|
+
const filename = path.basename(p.filePath);
|
|
305
|
+
const mime = inferMimeFromPath(p.filePath);
|
|
306
|
+
return await withClient((c) => c.uploadAvatar({ buffer, filename, mime }));
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
{ name: "clawchat_upload_avatar" },
|
|
310
|
+
);
|
|
311
|
+
|
|
268
312
|
api.registerTool(
|
|
269
313
|
{
|
|
270
314
|
name: "clawchat_upload_file",
|
|
271
|
-
label: "
|
|
315
|
+
label: "Upload File",
|
|
272
316
|
description:
|
|
273
317
|
"Upload a local file to Clawling media storage (max 20MB) and return the public URL.",
|
|
274
318
|
parameters: ClawchatUploadFileSchema,
|
|
@@ -303,6 +347,6 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
|
|
|
303
347
|
);
|
|
304
348
|
|
|
305
349
|
api.logger.info?.(
|
|
306
|
-
"openclaw-clawchat: registered
|
|
350
|
+
"openclaw-clawchat: registered 7 clawchat_* tools (activate, get_my_profile, get_user_info, list_friends, update_my_profile, upload_avatar, upload_file)",
|
|
307
351
|
);
|
|
308
352
|
}
|