@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
|
@@ -1,10 +1,46 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
1
2
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { Envelope, MessageAckPayload } from "./protocol-types.ts";
|
|
2
4
|
|
|
3
5
|
const getClientMock = vi.hoisted(() => vi.fn());
|
|
4
6
|
const getRuntimeMock = vi.hoisted(() => vi.fn());
|
|
5
7
|
const waitForClientMock = vi.hoisted(() => vi.fn());
|
|
6
8
|
const uploadOutboundMediaMock = vi.hoisted(() => vi.fn());
|
|
7
9
|
const createApiClientMock = vi.hoisted(() => vi.fn());
|
|
10
|
+
const getStoreMock = vi.hoisted(() => vi.fn());
|
|
11
|
+
const clawChatDbPathForStateDirMock = vi.hoisted(() => vi.fn((stateDir: string) => `${stateDir}/clawchat.sqlite`));
|
|
12
|
+
|
|
13
|
+
function mockClient() {
|
|
14
|
+
let trace = 0;
|
|
15
|
+
const sent: string[] = [];
|
|
16
|
+
const client = Object.assign(new EventEmitter(), {
|
|
17
|
+
sent,
|
|
18
|
+
state: "connected",
|
|
19
|
+
nextTraceId: vi.fn(() => `trace-${++trace}`),
|
|
20
|
+
sendWire: vi.fn((wire: string) => {
|
|
21
|
+
sent.push(wire);
|
|
22
|
+
}),
|
|
23
|
+
typing: vi.fn(),
|
|
24
|
+
emitRaw: vi.fn(),
|
|
25
|
+
sendRawEnvelope: vi.fn(),
|
|
26
|
+
});
|
|
27
|
+
Object.defineProperty(client, "transportState", { get: () => "open" });
|
|
28
|
+
return client;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function emitAck(
|
|
32
|
+
client: ReturnType<typeof mockClient>,
|
|
33
|
+
traceId: string,
|
|
34
|
+
payload: MessageAckPayload,
|
|
35
|
+
) {
|
|
36
|
+
client.emit("raw", {
|
|
37
|
+
version: "2",
|
|
38
|
+
event: "message.ack",
|
|
39
|
+
trace_id: traceId,
|
|
40
|
+
emitted_at: Date.now(),
|
|
41
|
+
payload,
|
|
42
|
+
} satisfies Envelope<MessageAckPayload>);
|
|
43
|
+
}
|
|
8
44
|
|
|
9
45
|
vi.mock("./runtime.ts", () => ({
|
|
10
46
|
getOpenclawClawlingClient: getClientMock,
|
|
@@ -21,28 +57,44 @@ vi.mock("./api-client.ts", () => ({
|
|
|
21
57
|
createOpenclawClawlingApiClient: createApiClientMock,
|
|
22
58
|
}));
|
|
23
59
|
|
|
60
|
+
vi.mock("./storage.ts", () => ({
|
|
61
|
+
clawChatDbPathForStateDir: clawChatDbPathForStateDirMock,
|
|
62
|
+
getClawChatStore: getStoreMock,
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
function configureClaim(result: true | false | null, runtimeExtras: Record<string, unknown> = {}) {
|
|
66
|
+
const claimMessageOnce = vi.fn(() => result);
|
|
67
|
+
const runtime = {
|
|
68
|
+
...runtimeExtras,
|
|
69
|
+
state: { resolveStateDir: vi.fn(() => "/state") },
|
|
70
|
+
};
|
|
71
|
+
getRuntimeMock.mockReturnValue(runtime);
|
|
72
|
+
getStoreMock.mockReturnValue({ claimMessageOnce });
|
|
73
|
+
return { claimMessageOnce, runtime };
|
|
74
|
+
}
|
|
75
|
+
|
|
24
76
|
describe("openclaw-clawchat channel outbound", () => {
|
|
25
77
|
beforeEach(() => {
|
|
78
|
+
vi.useRealTimers();
|
|
26
79
|
vi.resetModules();
|
|
27
80
|
getClientMock.mockReset();
|
|
28
81
|
getRuntimeMock.mockReset();
|
|
29
82
|
waitForClientMock.mockReset();
|
|
30
83
|
uploadOutboundMediaMock.mockReset();
|
|
31
84
|
createApiClientMock.mockReset();
|
|
85
|
+
getStoreMock.mockReset();
|
|
86
|
+
clawChatDbPathForStateDirMock.mockClear();
|
|
87
|
+
clawChatDbPathForStateDirMock.mockImplementation((stateDir: string) => `${stateDir}/clawchat.sqlite`);
|
|
32
88
|
});
|
|
33
89
|
|
|
34
|
-
it("sendText
|
|
35
|
-
const client =
|
|
36
|
-
sendMessage: vi.fn().mockResolvedValue({
|
|
37
|
-
payload: { message_id: "m-2", accepted_at: 456 },
|
|
38
|
-
trace_id: "trace-2",
|
|
39
|
-
}),
|
|
40
|
-
};
|
|
90
|
+
it("sendText claims a local message_id before sending", async () => {
|
|
91
|
+
const client = mockClient();
|
|
41
92
|
getClientMock.mockReturnValue(undefined);
|
|
42
93
|
waitForClientMock.mockResolvedValue(client);
|
|
94
|
+
const { claimMessageOnce } = configureClaim(true);
|
|
43
95
|
|
|
44
96
|
const { openclawClawlingOutbound } = await import("./outbound.ts");
|
|
45
|
-
const
|
|
97
|
+
const send = openclawClawlingOutbound.sendText!({
|
|
46
98
|
cfg: {
|
|
47
99
|
channels: {
|
|
48
100
|
"openclaw-clawchat": {
|
|
@@ -58,39 +110,119 @@ describe("openclaw-clawchat channel outbound", () => {
|
|
|
58
110
|
text: "hello",
|
|
59
111
|
});
|
|
60
112
|
|
|
113
|
+
await vi.waitFor(() => expect(client.sent).toHaveLength(1));
|
|
114
|
+
const frame = JSON.parse(client.sent[0]!) as Envelope;
|
|
115
|
+
const claimedInput = claimMessageOnce.mock.calls[0]?.[0] as { messageId?: string } | undefined;
|
|
116
|
+
const claimedMessageId = claimedInput?.messageId ?? "server-id";
|
|
117
|
+
emitAck(client, frame.trace_id, { message_id: claimedMessageId, accepted_at: 456 });
|
|
118
|
+
const result = await send;
|
|
119
|
+
|
|
61
120
|
expect(waitForClientMock).toHaveBeenCalledWith("default");
|
|
62
|
-
expect(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
121
|
+
expect(getStoreMock).toHaveBeenCalledWith({ dbPath: "/state/clawchat.sqlite" });
|
|
122
|
+
expect(claimedInput).toEqual(expect.objectContaining({
|
|
123
|
+
kind: "message",
|
|
124
|
+
direction: "outbound",
|
|
125
|
+
eventType: "message.send",
|
|
126
|
+
messageId: claimedMessageId,
|
|
127
|
+
text: "hello",
|
|
128
|
+
}));
|
|
129
|
+
expect(frame).toMatchObject({
|
|
130
|
+
event: "message.send",
|
|
131
|
+
chat_id: "user-1",
|
|
132
|
+
payload: {
|
|
133
|
+
message_id: claimedMessageId,
|
|
134
|
+
message: { body: { fragments: [{ kind: "text", text: "hello" }] } },
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
expect(frame).not.toHaveProperty("chat_type");
|
|
69
138
|
expect(result).toEqual({
|
|
70
139
|
channel: "openclaw-clawchat",
|
|
71
140
|
to: "cc:user-1",
|
|
72
|
-
messageId:
|
|
141
|
+
messageId: claimedMessageId,
|
|
73
142
|
});
|
|
74
143
|
});
|
|
75
144
|
|
|
76
|
-
it("
|
|
77
|
-
const client =
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
145
|
+
it("sendText rejects empty text instead of returning an unsent message id", async () => {
|
|
146
|
+
const client = mockClient();
|
|
147
|
+
getClientMock.mockReturnValue(client);
|
|
148
|
+
const { claimMessageOnce } = configureClaim(true);
|
|
149
|
+
|
|
150
|
+
const { openclawClawlingOutbound } = await import("./outbound.ts");
|
|
151
|
+
const send = openclawClawlingOutbound.sendText!({
|
|
152
|
+
cfg: {
|
|
153
|
+
channels: {
|
|
154
|
+
"openclaw-clawchat": {
|
|
155
|
+
enabled: true,
|
|
156
|
+
websocketUrl: "ws://t",
|
|
157
|
+
baseUrl: "https://api.example.com",
|
|
158
|
+
token: "tk",
|
|
159
|
+
userId: "agent-1",
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
} as never,
|
|
163
|
+
to: "cc:user-1",
|
|
164
|
+
text: " ",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await expect(send).rejects.toThrow("openclaw-clawchat sendText requires non-empty text");
|
|
168
|
+
expect(claimMessageOnce).not.toHaveBeenCalled();
|
|
169
|
+
expect(client.sent).toHaveLength(0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("sendText rejects when the storage claim is duplicate or unavailable", async () => {
|
|
173
|
+
for (const [claimResult, errorMessage] of [
|
|
174
|
+
[false, "openclaw-clawchat outbound duplicate claim; message not sent"],
|
|
175
|
+
[null, "openclaw-clawchat outbound message claim failed"],
|
|
176
|
+
] as const) {
|
|
177
|
+
const client = mockClient();
|
|
178
|
+
getClientMock.mockReturnValue(client);
|
|
179
|
+
const { claimMessageOnce } = configureClaim(claimResult);
|
|
180
|
+
const { openclawClawlingOutbound } = await import("./outbound.ts");
|
|
181
|
+
|
|
182
|
+
const send = openclawClawlingOutbound.sendText!({
|
|
183
|
+
cfg: {
|
|
184
|
+
channels: {
|
|
185
|
+
"openclaw-clawchat": {
|
|
186
|
+
enabled: true,
|
|
187
|
+
websocketUrl: "ws://t",
|
|
188
|
+
baseUrl: "https://api.example.com",
|
|
189
|
+
token: "tk",
|
|
190
|
+
userId: "agent-1",
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
} as never,
|
|
194
|
+
to: "cc:user-1",
|
|
195
|
+
text: "hello",
|
|
196
|
+
});
|
|
197
|
+
await expect(send).rejects.toThrow(errorMessage);
|
|
198
|
+
|
|
199
|
+
expect(claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
|
|
200
|
+
kind: "message",
|
|
201
|
+
direction: "outbound",
|
|
202
|
+
}));
|
|
203
|
+
expect(client.sent).toHaveLength(0);
|
|
204
|
+
vi.resetModules();
|
|
205
|
+
getClientMock.mockReset();
|
|
206
|
+
getRuntimeMock.mockReset();
|
|
207
|
+
getStoreMock.mockReset();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("sendMedia claims a local message_id before sending resulting fragments", async () => {
|
|
212
|
+
const client = mockClient();
|
|
213
|
+
const runtimeExtras = { media: { loadWebMedia: vi.fn() } };
|
|
84
214
|
const apiClient = { uploadMedia: vi.fn() };
|
|
85
215
|
getClientMock.mockReturnValue(client);
|
|
86
|
-
|
|
216
|
+
const { claimMessageOnce, runtime } = configureClaim(true, runtimeExtras);
|
|
87
217
|
createApiClientMock.mockReturnValue(apiClient);
|
|
88
218
|
uploadOutboundMediaMock.mockResolvedValue([
|
|
89
219
|
{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
|
|
90
220
|
]);
|
|
221
|
+
const mediaReadFile = vi.fn(async () => Buffer.from("host-read"));
|
|
222
|
+
const mediaAccess = { localRoots: ["/tmp"], workspaceDir: "/workspace" };
|
|
91
223
|
|
|
92
224
|
const { openclawClawlingOutbound } = await import("./outbound.ts");
|
|
93
|
-
const
|
|
225
|
+
const send = openclawClawlingOutbound.sendMedia!({
|
|
94
226
|
cfg: {
|
|
95
227
|
channels: {
|
|
96
228
|
"openclaw-clawchat": {
|
|
@@ -105,9 +237,18 @@ describe("openclaw-clawchat channel outbound", () => {
|
|
|
105
237
|
to: "cc:group:room-1",
|
|
106
238
|
text: "caption",
|
|
107
239
|
mediaUrl: "/tmp/photo.png",
|
|
240
|
+
mediaAccess,
|
|
108
241
|
mediaLocalRoots: ["/tmp"],
|
|
242
|
+
mediaReadFile,
|
|
109
243
|
});
|
|
110
244
|
|
|
245
|
+
await vi.waitFor(() => expect(client.sent).toHaveLength(1));
|
|
246
|
+
const frame = JSON.parse(client.sent[0]!) as Envelope;
|
|
247
|
+
const claimedInput = claimMessageOnce.mock.calls[0]?.[0] as { messageId?: string } | undefined;
|
|
248
|
+
const claimedMessageId = claimedInput?.messageId ?? "server-id";
|
|
249
|
+
emitAck(client, frame.trace_id, { message_id: claimedMessageId, accepted_at: 123 });
|
|
250
|
+
const result = await send;
|
|
251
|
+
|
|
111
252
|
expect(createApiClientMock).toHaveBeenCalledWith({
|
|
112
253
|
baseUrl: "https://api.example.com",
|
|
113
254
|
token: "tk",
|
|
@@ -116,29 +257,91 @@ describe("openclaw-clawchat channel outbound", () => {
|
|
|
116
257
|
expect(uploadOutboundMediaMock).toHaveBeenCalledWith(["/tmp/photo.png"], {
|
|
117
258
|
apiClient,
|
|
118
259
|
runtime,
|
|
260
|
+
mediaAccess,
|
|
119
261
|
mediaLocalRoots: ["/tmp"],
|
|
262
|
+
mediaReadFile,
|
|
120
263
|
});
|
|
121
|
-
expect(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
264
|
+
expect(claimedInput).toEqual(expect.objectContaining({
|
|
265
|
+
kind: "message",
|
|
266
|
+
direction: "outbound",
|
|
267
|
+
eventType: "message.send",
|
|
268
|
+
messageId: claimedMessageId,
|
|
269
|
+
text: "caption",
|
|
270
|
+
}));
|
|
271
|
+
expect(frame).toMatchObject({
|
|
272
|
+
event: "message.send",
|
|
273
|
+
chat_id: "room-1",
|
|
274
|
+
payload: {
|
|
275
|
+
message_id: claimedMessageId,
|
|
276
|
+
message: {
|
|
277
|
+
body: {
|
|
278
|
+
fragments: [
|
|
279
|
+
{ kind: "text", text: "caption" },
|
|
280
|
+
{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
|
|
281
|
+
],
|
|
282
|
+
},
|
|
129
283
|
},
|
|
130
|
-
}
|
|
131
|
-
);
|
|
132
|
-
expect(
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
expect(frame).not.toHaveProperty("chat_type");
|
|
133
287
|
expect(result).toEqual({
|
|
134
288
|
channel: "openclaw-clawchat",
|
|
135
289
|
to: "cc:group:room-1",
|
|
136
|
-
messageId:
|
|
290
|
+
messageId: claimedMessageId,
|
|
137
291
|
});
|
|
138
292
|
});
|
|
139
293
|
|
|
294
|
+
it("sendMedia rejects when the storage claim is duplicate or unavailable", async () => {
|
|
295
|
+
for (const [claimResult, errorMessage] of [
|
|
296
|
+
[false, "openclaw-clawchat outbound duplicate claim; message not sent"],
|
|
297
|
+
[null, "openclaw-clawchat outbound message claim failed"],
|
|
298
|
+
] as const) {
|
|
299
|
+
const client = mockClient();
|
|
300
|
+
const runtimeExtras = { media: { loadWebMedia: vi.fn() } };
|
|
301
|
+
const apiClient = { uploadMedia: vi.fn() };
|
|
302
|
+
getClientMock.mockReturnValue(client);
|
|
303
|
+
const { claimMessageOnce } = configureClaim(claimResult, runtimeExtras);
|
|
304
|
+
createApiClientMock.mockReturnValue(apiClient);
|
|
305
|
+
uploadOutboundMediaMock.mockResolvedValue([
|
|
306
|
+
{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
|
|
307
|
+
]);
|
|
308
|
+
const { openclawClawlingOutbound } = await import("./outbound.ts");
|
|
309
|
+
|
|
310
|
+
const send = openclawClawlingOutbound.sendMedia!({
|
|
311
|
+
cfg: {
|
|
312
|
+
channels: {
|
|
313
|
+
"openclaw-clawchat": {
|
|
314
|
+
enabled: true,
|
|
315
|
+
websocketUrl: "ws://t",
|
|
316
|
+
baseUrl: "https://api.example.com",
|
|
317
|
+
token: "tk",
|
|
318
|
+
userId: "agent-1",
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
} as never,
|
|
322
|
+
to: "cc:group:room-1",
|
|
323
|
+
text: "caption",
|
|
324
|
+
mediaUrl: "/tmp/photo.png",
|
|
325
|
+
});
|
|
326
|
+
await expect(send).rejects.toThrow(errorMessage);
|
|
327
|
+
|
|
328
|
+
expect(uploadOutboundMediaMock).toHaveBeenCalled();
|
|
329
|
+
expect(claimMessageOnce).toHaveBeenCalledWith(expect.objectContaining({
|
|
330
|
+
kind: "message",
|
|
331
|
+
direction: "outbound",
|
|
332
|
+
}));
|
|
333
|
+
expect(client.sent).toHaveLength(0);
|
|
334
|
+
vi.resetModules();
|
|
335
|
+
getClientMock.mockReset();
|
|
336
|
+
getRuntimeMock.mockReset();
|
|
337
|
+
getStoreMock.mockReset();
|
|
338
|
+
uploadOutboundMediaMock.mockReset();
|
|
339
|
+
createApiClientMock.mockReset();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
140
343
|
it("sendMedia rejects missing mediaUrl", async () => {
|
|
141
|
-
getClientMock.mockReturnValue(
|
|
344
|
+
getClientMock.mockReturnValue(mockClient());
|
|
142
345
|
const { openclawClawlingOutbound } = await import("./outbound.ts");
|
|
143
346
|
await expect(
|
|
144
347
|
openclawClawlingOutbound.sendMedia!({
|
|
@@ -160,24 +363,19 @@ describe("openclaw-clawchat channel outbound", () => {
|
|
|
160
363
|
});
|
|
161
364
|
|
|
162
365
|
it("sendMedia waits for client activation when no active client exists yet", async () => {
|
|
163
|
-
const client =
|
|
164
|
-
|
|
165
|
-
payload: { message_id: "m-3", accepted_at: 789 },
|
|
166
|
-
trace_id: "trace-3",
|
|
167
|
-
}),
|
|
168
|
-
};
|
|
169
|
-
const runtime = { media: { loadWebMedia: vi.fn() } };
|
|
366
|
+
const client = mockClient();
|
|
367
|
+
const runtimeExtras = { media: { loadWebMedia: vi.fn() } };
|
|
170
368
|
const apiClient = { uploadMedia: vi.fn() };
|
|
171
369
|
getClientMock.mockReturnValue(undefined);
|
|
172
370
|
waitForClientMock.mockResolvedValue(client);
|
|
173
|
-
|
|
371
|
+
const { claimMessageOnce, runtime } = configureClaim(true, runtimeExtras);
|
|
174
372
|
createApiClientMock.mockReturnValue(apiClient);
|
|
175
373
|
uploadOutboundMediaMock.mockResolvedValue([
|
|
176
374
|
{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
|
|
177
375
|
]);
|
|
178
376
|
|
|
179
377
|
const { openclawClawlingOutbound } = await import("./outbound.ts");
|
|
180
|
-
const
|
|
378
|
+
const send = openclawClawlingOutbound.sendMedia!({
|
|
181
379
|
cfg: {
|
|
182
380
|
channels: {
|
|
183
381
|
"openclaw-clawchat": {
|
|
@@ -195,23 +393,34 @@ describe("openclaw-clawchat channel outbound", () => {
|
|
|
195
393
|
mediaLocalRoots: ["/tmp"],
|
|
196
394
|
});
|
|
197
395
|
|
|
396
|
+
await vi.waitFor(() => expect(client.sent).toHaveLength(1));
|
|
397
|
+
const frame = JSON.parse(client.sent[0]!) as Envelope;
|
|
398
|
+
const claimedInput = claimMessageOnce.mock.calls[0]?.[0] as { messageId?: string } | undefined;
|
|
399
|
+
const claimedMessageId = claimedInput?.messageId ?? "server-id";
|
|
400
|
+
emitAck(client, frame.trace_id, { message_id: claimedMessageId, accepted_at: 789 });
|
|
401
|
+
const result = await send;
|
|
402
|
+
|
|
198
403
|
expect(waitForClientMock).toHaveBeenCalledWith("default");
|
|
199
|
-
expect(
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
404
|
+
expect(frame).toMatchObject({
|
|
405
|
+
event: "message.send",
|
|
406
|
+
chat_id: "room-1",
|
|
407
|
+
payload: {
|
|
408
|
+
message_id: claimedMessageId,
|
|
409
|
+
message: {
|
|
410
|
+
body: {
|
|
411
|
+
fragments: [
|
|
412
|
+
{ kind: "text", text: "caption" },
|
|
413
|
+
{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
|
|
414
|
+
],
|
|
415
|
+
},
|
|
207
416
|
},
|
|
208
|
-
}
|
|
209
|
-
);
|
|
210
|
-
expect(
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
expect(frame).not.toHaveProperty("chat_type");
|
|
211
420
|
expect(result).toEqual({
|
|
212
421
|
channel: "openclaw-clawchat",
|
|
213
422
|
to: "cc:group:room-1",
|
|
214
|
-
messageId:
|
|
423
|
+
messageId: claimedMessageId,
|
|
215
424
|
});
|
|
216
425
|
});
|
|
217
426
|
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
|
|
2
|
+
import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup";
|
|
3
|
+
import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
4
|
+
import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation";
|
|
5
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
|
6
|
+
import {
|
|
7
|
+
createComputedAccountStatusAdapter,
|
|
8
|
+
createDefaultChannelRuntimeState,
|
|
9
|
+
} from "openclaw/plugin-sdk/status-helpers";
|
|
10
|
+
import {
|
|
11
|
+
CHANNEL_ID,
|
|
12
|
+
listOpenclawClawlingAccountIds,
|
|
13
|
+
openclawClawlingConfigSchema,
|
|
14
|
+
resolveOpenclawClawlingAccount,
|
|
15
|
+
type ResolvedOpenclawClawlingAccount,
|
|
16
|
+
} from "./config.ts";
|
|
17
|
+
import type { OpenclawClawchatMutateConfigFile } from "./login.runtime.ts";
|
|
18
|
+
|
|
19
|
+
const configAdapter = createTopLevelChannelConfigAdapter<ResolvedOpenclawClawlingAccount>({
|
|
20
|
+
sectionKey: CHANNEL_ID,
|
|
21
|
+
resolveAccount: (cfg) => resolveOpenclawClawlingAccount(cfg),
|
|
22
|
+
listAccountIds: () => listOpenclawClawlingAccountIds(),
|
|
23
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
24
|
+
deleteMode: "clear-fields",
|
|
25
|
+
clearBaseFields: [
|
|
26
|
+
"websocketUrl",
|
|
27
|
+
"baseUrl",
|
|
28
|
+
"token",
|
|
29
|
+
"userId",
|
|
30
|
+
"replyMode",
|
|
31
|
+
"forwardThinking",
|
|
32
|
+
"forwardToolCalls",
|
|
33
|
+
"richInteractions",
|
|
34
|
+
"enabled",
|
|
35
|
+
],
|
|
36
|
+
resolveAllowFrom: (account) => account.allowFrom,
|
|
37
|
+
formatAllowFrom: () => [],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Invite-code setup adapter used by OpenClaw setup surfaces.
|
|
42
|
+
*
|
|
43
|
+
* `channels add --token` passes the invite code as setup input. The setup
|
|
44
|
+
* write leaves channel config unchanged; `afterAccountConfigWritten` exchanges
|
|
45
|
+
* the invite code and persists token/userId through the host runtime mutator.
|
|
46
|
+
*/
|
|
47
|
+
const setupAdapter: NonNullable<ChannelPlugin<ResolvedOpenclawClawlingAccount>["setup"]> = {
|
|
48
|
+
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
49
|
+
validateInput: ({ input }: { cfg: OpenClawConfig; accountId: string; input: ChannelSetupInput }) => {
|
|
50
|
+
const inviteCode =
|
|
51
|
+
typeof input.code === "string" && input.code.trim()
|
|
52
|
+
? input.code.trim()
|
|
53
|
+
: typeof input.token === "string"
|
|
54
|
+
? input.token.trim()
|
|
55
|
+
: "";
|
|
56
|
+
if (!inviteCode) {
|
|
57
|
+
return "ClawChat invite code is required.";
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
},
|
|
61
|
+
applyAccountConfig: ({ cfg }: {
|
|
62
|
+
cfg: OpenClawConfig;
|
|
63
|
+
accountId: string;
|
|
64
|
+
input: ChannelSetupInput;
|
|
65
|
+
}) => cfg,
|
|
66
|
+
afterAccountConfigWritten: async ({ cfg, input, runtime }) => {
|
|
67
|
+
runtime.log("[default] openclaw-clawchat setup afterAccountConfigWritten invoked");
|
|
68
|
+
const code =
|
|
69
|
+
typeof input.code === "string" && input.code.trim()
|
|
70
|
+
? input.code.trim()
|
|
71
|
+
: typeof input.token === "string"
|
|
72
|
+
? input.token.trim()
|
|
73
|
+
: "";
|
|
74
|
+
if (!code) {
|
|
75
|
+
runtime.log("[default] openclaw-clawchat setup afterAccountConfigWritten skipped: empty invite code");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const { runOpenclawClawlingLogin } = await import("./login.runtime.ts");
|
|
79
|
+
await runOpenclawClawlingLogin({
|
|
80
|
+
cfg,
|
|
81
|
+
accountId: null,
|
|
82
|
+
runtime: { log: (message: string) => runtime.log(message) },
|
|
83
|
+
readInviteCode: async () => code,
|
|
84
|
+
mutateConfigFile: mutateConfigFile as OpenclawClawchatMutateConfigFile,
|
|
85
|
+
});
|
|
86
|
+
runtime.log("[default] openclaw-clawchat setup afterAccountConfigWritten completed");
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
type OpenclawClawlingSetupPlugin = Pick<
|
|
91
|
+
ChannelPlugin<ResolvedOpenclawClawlingAccount>,
|
|
92
|
+
"id" | "meta" | "capabilities" | "configSchema" | "config" | "setup" | "status"
|
|
93
|
+
>;
|
|
94
|
+
|
|
95
|
+
export const openclawClawlingSetupPlugin: OpenclawClawlingSetupPlugin = {
|
|
96
|
+
id: CHANNEL_ID,
|
|
97
|
+
meta: {
|
|
98
|
+
id: CHANNEL_ID,
|
|
99
|
+
label: "Clawling Chat",
|
|
100
|
+
selectionLabel: "Clawling Chat",
|
|
101
|
+
docsPath: "/channels/openclaw-clawchat",
|
|
102
|
+
docsLabel: "openclaw-clawchat",
|
|
103
|
+
blurb: "ClawChat Protocol v2 over WebSocket.",
|
|
104
|
+
order: 110,
|
|
105
|
+
},
|
|
106
|
+
capabilities: {
|
|
107
|
+
chatTypes: ["direct", "group"],
|
|
108
|
+
media: true,
|
|
109
|
+
reactions: false,
|
|
110
|
+
threads: false,
|
|
111
|
+
polls: false,
|
|
112
|
+
blockStreaming: true,
|
|
113
|
+
},
|
|
114
|
+
configSchema: {
|
|
115
|
+
schema: openclawClawlingConfigSchema,
|
|
116
|
+
},
|
|
117
|
+
config: {
|
|
118
|
+
...configAdapter,
|
|
119
|
+
isConfigured: (account) => account.configured,
|
|
120
|
+
describeAccount: (account) => ({
|
|
121
|
+
accountId: account.accountId,
|
|
122
|
+
name: account.name,
|
|
123
|
+
enabled: account.enabled,
|
|
124
|
+
configured: account.configured,
|
|
125
|
+
}),
|
|
126
|
+
},
|
|
127
|
+
setup: setupAdapter,
|
|
128
|
+
status: createComputedAccountStatusAdapter<ResolvedOpenclawClawlingAccount>({
|
|
129
|
+
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, {
|
|
130
|
+
connected: false,
|
|
131
|
+
lastInboundAt: null,
|
|
132
|
+
lastOutboundAt: null,
|
|
133
|
+
}),
|
|
134
|
+
resolveAccountSnapshot: ({ account }) => ({
|
|
135
|
+
accountId: account.accountId,
|
|
136
|
+
name: account.name,
|
|
137
|
+
enabled: account.enabled,
|
|
138
|
+
configured: account.configured,
|
|
139
|
+
extra: {
|
|
140
|
+
websocketUrl: account.websocketUrl || null,
|
|
141
|
+
baseUrl: account.baseUrl || null,
|
|
142
|
+
userId: account.userId || null,
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
}),
|
|
146
|
+
};
|