@newbase-clawchat/openclaw-clawchat 2026.4.15
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/README.md +112 -0
- package/index.ts +19 -0
- package/openclaw.plugin.json +52 -0
- package/package.json +58 -0
- package/src/api-client.test.ts +325 -0
- package/src/api-client.ts +225 -0
- package/src/api-types.ts +71 -0
- package/src/buffered-stream.test.ts +201 -0
- package/src/buffered-stream.ts +206 -0
- package/src/channel.test.ts +72 -0
- package/src/channel.ts +278 -0
- package/src/client.test.ts +174 -0
- package/src/client.ts +279 -0
- package/src/config.test.ts +110 -0
- package/src/config.ts +277 -0
- package/src/inbound.test.ts +264 -0
- package/src/inbound.ts +201 -0
- package/src/login.runtime.test.ts +257 -0
- package/src/login.runtime.ts +153 -0
- package/src/manifest.test.ts +22 -0
- package/src/media-runtime.test.ts +159 -0
- package/src/media-runtime.ts +143 -0
- package/src/message-mapper.test.ts +131 -0
- package/src/message-mapper.ts +82 -0
- package/src/outbound.test.ts +244 -0
- package/src/outbound.ts +141 -0
- package/src/protocol.test.ts +42 -0
- package/src/protocol.ts +38 -0
- package/src/reply-dispatcher.ts +387 -0
- package/src/runtime.test.ts +276 -0
- package/src/runtime.ts +316 -0
- package/src/streaming.test.ts +116 -0
- package/src/streaming.ts +89 -0
- package/src/tools-schema.ts +45 -0
- package/src/tools.test.ts +135 -0
- package/src/tools.ts +308 -0
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# @newbase-clawchat/openclaw-clawchat
|
|
2
|
+
|
|
3
|
+
OpenClaw channel plugin that connects an agent to ClawChat over the ClawChat Protocol v2, using [`@newbase-clawchat/sdk`](https://www.npmjs.com/package/@newbase-clawchat/sdk) for the WebSocket transport plus a small REST surface under `/v1/*` for profile / social / media operations.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- WebSocket transport with auto-reconnect (exponential backoff + jitter), heartbeat, and ack tracking
|
|
8
|
+
- Invite-code onboarding — no raw credentials required
|
|
9
|
+
- Inbound `message.send` / `message.reply` with reply context
|
|
10
|
+
- Outbound text replies in `static` or `stream` mode, with a consolidated final `message.reply`
|
|
11
|
+
- Typing indicators and filtered forwarding for thinking / tool-call content
|
|
12
|
+
- Media fragments (image / file / audio / video) in either direction
|
|
13
|
+
- `clawchat_*` agent tools for profile, friends, media upload, and self-activation
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Via the OpenClaw CLI (recommended)
|
|
19
|
+
openclaw channels setup --channel openclaw-clawchat --code INV-ABC123
|
|
20
|
+
|
|
21
|
+
# Or add as a local extension
|
|
22
|
+
npm i @newbase-clawchat/openclaw-clawchat
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Requires `openclaw >= 2026.3.23` as a peer host.
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# One-shot: apply config + exchange invite code for an access token
|
|
31
|
+
openclaw channels setup --channel openclaw-clawchat --code INV-ABC123
|
|
32
|
+
|
|
33
|
+
# Run the gateway
|
|
34
|
+
openclaw gateway run
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Minimal `~/.openclaw/openclaw.json`:
|
|
38
|
+
|
|
39
|
+
```json5
|
|
40
|
+
{
|
|
41
|
+
channels: {
|
|
42
|
+
"openclaw-clawchat": {
|
|
43
|
+
enabled: true,
|
|
44
|
+
replyMode: "stream",
|
|
45
|
+
forwardThinking: true,
|
|
46
|
+
forwardToolCalls: false
|
|
47
|
+
// token / userId / refreshToken are written by the login flow.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Standalone web chat (dev)
|
|
54
|
+
|
|
55
|
+
A minimal browser test harness is bundled under `tools/`:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npm run test-ui
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then open the printed URL — it mounts `tools/standalone-webchat.html` against a local relay so you can exercise the plugin end to end without a full ClawChat backend.
|
|
62
|
+
|
|
63
|
+
## Layout
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
src/
|
|
67
|
+
channel.ts plugin adapter (setup, auth.login, gateway, agentPrompt)
|
|
68
|
+
runtime.ts inbound dispatch + reply dispatcher
|
|
69
|
+
client.ts chat-sdk WebSocket client wrapper
|
|
70
|
+
api-client.ts REST client for /v1/* + /media/upload
|
|
71
|
+
inbound.ts envelope → agent turn
|
|
72
|
+
outbound.ts agent reply → envelope
|
|
73
|
+
streaming.ts progressive reply emitter
|
|
74
|
+
buffered-stream.ts chunk coalescing + flush policy
|
|
75
|
+
message-mapper.ts fragment ↔ text/media mapping
|
|
76
|
+
login.runtime.ts invite-code exchange flow
|
|
77
|
+
media-runtime.ts media download/upload pipeline
|
|
78
|
+
reply-dispatcher.ts static vs stream routing
|
|
79
|
+
tools.ts clawchat_* agent tools
|
|
80
|
+
protocol.ts v2 envelope shapes
|
|
81
|
+
config.ts defaults + typebox schema
|
|
82
|
+
tools/
|
|
83
|
+
standalone-webchat-server.mjs
|
|
84
|
+
standalone-webchat.html
|
|
85
|
+
docs/
|
|
86
|
+
openclaw-clawchat.md full docs (protocol, diagrams, troubleshooting)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Documentation
|
|
90
|
+
|
|
91
|
+
See [`docs/openclaw-clawchat.md`](./docs/openclaw-clawchat.md) for:
|
|
92
|
+
|
|
93
|
+
- Full configuration reference
|
|
94
|
+
- Onboarding / activation details
|
|
95
|
+
- REST endpoint table
|
|
96
|
+
- Streaming frame shapes (`message.created` / `message.add` / `message.done` / `message.reply`)
|
|
97
|
+
- End-to-end sequence diagram
|
|
98
|
+
- Media pipeline (inbound download / outbound upload)
|
|
99
|
+
- Troubleshooting
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# Tests
|
|
105
|
+
npx vitest run
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Tests live next to the source they cover (`*.test.ts`). The plugin is pure TypeScript and is consumed via the OpenClaw host's extension loader — no bundling step is required.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
See the repository root.
|
package/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { openclawClawlingPlugin } from "./src/channel.ts";
|
|
3
|
+
import { setOpenclawClawlingRuntime } from "./src/runtime.ts";
|
|
4
|
+
import { registerOpenclawClawlingTools } from "./src/tools.ts";
|
|
5
|
+
|
|
6
|
+
export { openclawClawlingPlugin } from "./src/channel.ts";
|
|
7
|
+
export { setOpenclawClawlingRuntime } from "./src/runtime.ts";
|
|
8
|
+
export { registerOpenclawClawlingTools } from "./src/tools.ts";
|
|
9
|
+
|
|
10
|
+
export default defineChannelPluginEntry({
|
|
11
|
+
id: "openclaw-clawchat",
|
|
12
|
+
name: "Clawling Chat",
|
|
13
|
+
description: "Clawling Chat Protocol v2 channel plugin (chat-sdk)",
|
|
14
|
+
plugin: openclawClawlingPlugin,
|
|
15
|
+
setRuntime: setOpenclawClawlingRuntime,
|
|
16
|
+
registerFull(api) {
|
|
17
|
+
registerOpenclawClawlingTools(api);
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "openclaw-clawchat",
|
|
3
|
+
"channels": ["openclaw-clawchat"],
|
|
4
|
+
"configSchema": {
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"properties": {
|
|
8
|
+
"enabled": { "type": "boolean" },
|
|
9
|
+
"websocketUrl": { "type": "string" },
|
|
10
|
+
"baseUrl": { "type": "string" },
|
|
11
|
+
"token": { "type": "string" },
|
|
12
|
+
"userId": { "type": "string" },
|
|
13
|
+
"replyMode": { "type": "string", "enum": ["static", "stream"] },
|
|
14
|
+
"forwardThinking": { "type": "boolean" },
|
|
15
|
+
"forwardToolCalls": { "type": "boolean" },
|
|
16
|
+
"stream": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"additionalProperties": false,
|
|
19
|
+
"properties": {
|
|
20
|
+
"flushIntervalMs": { "type": "integer", "minimum": 10 },
|
|
21
|
+
"minChunkChars": { "type": "integer", "minimum": 1 },
|
|
22
|
+
"maxBufferChars": { "type": "integer", "minimum": 1 }
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"reconnect": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"properties": {
|
|
29
|
+
"initialDelay": { "type": "integer", "minimum": 100 },
|
|
30
|
+
"maxDelay": { "type": "integer", "minimum": 100 },
|
|
31
|
+
"jitterRatio": { "type": "number", "minimum": 0 }
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"heartbeat": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"additionalProperties": false,
|
|
37
|
+
"properties": {
|
|
38
|
+
"interval": { "type": "integer", "minimum": 1000 },
|
|
39
|
+
"timeout": { "type": "integer", "minimum": 1000 }
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"ack": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"additionalProperties": false,
|
|
45
|
+
"properties": {
|
|
46
|
+
"timeout": { "type": "integer", "minimum": 100 },
|
|
47
|
+
"autoResendOnTimeout": { "type": "boolean" }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@newbase-clawchat/openclaw-clawchat",
|
|
3
|
+
"version": "2026.4.15",
|
|
4
|
+
"description": "OpenClaw ClawChat channel plugin",
|
|
5
|
+
"files": [
|
|
6
|
+
"index.ts",
|
|
7
|
+
"src",
|
|
8
|
+
"openclaw.plugin.json",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"scripts": {
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"prepublishOnly": "npm run typecheck",
|
|
15
|
+
"release": "npm run prepublishOnly && npm publish",
|
|
16
|
+
"test-ui": "node ./tools/standalone-webchat-server.mjs"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@newbase-clawchat/sdk": "^0.1.0",
|
|
20
|
+
"@sinclair/typebox": "0.34.48"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^25.5.0",
|
|
24
|
+
"openclaw": "^2026.3.23",
|
|
25
|
+
"typescript": "^5.4.0"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"openclaw": ">=2026.3.23"
|
|
29
|
+
},
|
|
30
|
+
"peerDependenciesMeta": {
|
|
31
|
+
"openclaw": {
|
|
32
|
+
"optional": true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"openclaw": {
|
|
39
|
+
"extensions": [
|
|
40
|
+
"./index.ts"
|
|
41
|
+
],
|
|
42
|
+
"channel": {
|
|
43
|
+
"id": "openclaw-clawchat",
|
|
44
|
+
"label": "Clawling Chat",
|
|
45
|
+
"selectionLabel": "Clawling Chat",
|
|
46
|
+
"docsPath": "/channels/openclaw-clawchat",
|
|
47
|
+
"docsLabel": "openclaw-clawchat",
|
|
48
|
+
"blurb": "OpenClaw ClawChat channel plugin",
|
|
49
|
+
"order": 110
|
|
50
|
+
},
|
|
51
|
+
"install": {
|
|
52
|
+
"npmSpec": "@newbase-clawchat/openclaw-clawchat",
|
|
53
|
+
"localPath": "extensions/openclaw-clawchat",
|
|
54
|
+
"defaultChoice": "npm",
|
|
55
|
+
"minHostVersion": ">=2026.3.23"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createOpenclawClawlingApiClient } from "./api-client.ts";
|
|
3
|
+
import { ClawlingApiError } from "./api-types.ts";
|
|
4
|
+
|
|
5
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
6
|
+
return new Response(JSON.stringify(body), {
|
|
7
|
+
status,
|
|
8
|
+
headers: { "content-type": "application/json" },
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("openclaw-clawchat api-client", () => {
|
|
13
|
+
it("getMyProfile sends GET /me with bearer token and unwraps data", async () => {
|
|
14
|
+
const fetchImpl = vi.fn().mockResolvedValue(
|
|
15
|
+
jsonResponse({
|
|
16
|
+
code: 0,
|
|
17
|
+
message: "ok",
|
|
18
|
+
data: { user_id: "u1", display_name: "Alice" },
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
const client = createOpenclawClawlingApiClient({
|
|
22
|
+
baseUrl: "https://api.example.com",
|
|
23
|
+
token: "tk",
|
|
24
|
+
fetchImpl,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const profile = await client.getMyProfile();
|
|
28
|
+
|
|
29
|
+
expect(fetchImpl).toHaveBeenCalledWith(
|
|
30
|
+
"https://api.example.com/v1/users/me",
|
|
31
|
+
expect.objectContaining({
|
|
32
|
+
method: "GET",
|
|
33
|
+
headers: expect.objectContaining({
|
|
34
|
+
authorization: "Bearer tk",
|
|
35
|
+
// Global X-Device-Id is sent on every request, not just connect.
|
|
36
|
+
"x-device-id": "openclaw-clawchat",
|
|
37
|
+
}),
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
expect(profile).toEqual({ user_id: "u1", display_name: "Alice" });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("getUserInfo url-encodes the userId", async () => {
|
|
44
|
+
const fetchImpl = vi
|
|
45
|
+
.fn()
|
|
46
|
+
.mockResolvedValue(
|
|
47
|
+
jsonResponse({ code: 0, message: "ok", data: { user_id: "u/2", display_name: "Bob" } }),
|
|
48
|
+
);
|
|
49
|
+
const client = createOpenclawClawlingApiClient({
|
|
50
|
+
baseUrl: "https://api.example.com",
|
|
51
|
+
token: "tk",
|
|
52
|
+
fetchImpl,
|
|
53
|
+
});
|
|
54
|
+
await client.getUserInfo("u/2");
|
|
55
|
+
expect(fetchImpl.mock.calls[0]![0]).toBe("https://api.example.com/v1/users/u%2F2");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("listFriends sends page + pageSize as query string", async () => {
|
|
59
|
+
const fetchImpl = vi.fn().mockResolvedValue(
|
|
60
|
+
jsonResponse({
|
|
61
|
+
code: 0,
|
|
62
|
+
message: "ok",
|
|
63
|
+
data: { items: [], page: 2, pageSize: 50 },
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
const client = createOpenclawClawlingApiClient({
|
|
67
|
+
baseUrl: "https://api.example.com",
|
|
68
|
+
token: "tk",
|
|
69
|
+
fetchImpl,
|
|
70
|
+
});
|
|
71
|
+
await client.listFriends({ page: 2, pageSize: 50 });
|
|
72
|
+
expect(fetchImpl.mock.calls[0]![0]).toBe(
|
|
73
|
+
"https://api.example.com/v1/friends?page=2&pageSize=50",
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("updateMyProfile sends PATCH /me with JSON body", async () => {
|
|
78
|
+
const fetchImpl = vi.fn().mockResolvedValue(
|
|
79
|
+
jsonResponse({
|
|
80
|
+
code: 0,
|
|
81
|
+
message: "ok",
|
|
82
|
+
data: { user_id: "u1", display_name: "Alice2" },
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
const client = createOpenclawClawlingApiClient({
|
|
86
|
+
baseUrl: "https://api.example.com",
|
|
87
|
+
token: "tk",
|
|
88
|
+
userId: "agent-1",
|
|
89
|
+
fetchImpl,
|
|
90
|
+
});
|
|
91
|
+
await client.updateMyProfile({ display_name: "Alice2" });
|
|
92
|
+
expect(fetchImpl.mock.calls[0]![0]).toBe("https://api.example.com/v1/agents/agent-1");
|
|
93
|
+
const init = fetchImpl.mock.calls[0]![1] as RequestInit;
|
|
94
|
+
expect(init.method).toBe("PATCH");
|
|
95
|
+
expect(JSON.parse(init.body as string)).toEqual({ display_name: "Alice2" });
|
|
96
|
+
expect((init.headers as Record<string, string>)["content-type"]).toBe("application/json");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("updateMyProfile throws validation error when userId is not configured", async () => {
|
|
100
|
+
const fetchImpl = vi.fn();
|
|
101
|
+
const client = createOpenclawClawlingApiClient({
|
|
102
|
+
baseUrl: "https://api.example.com",
|
|
103
|
+
token: "tk",
|
|
104
|
+
// userId intentionally omitted
|
|
105
|
+
fetchImpl,
|
|
106
|
+
});
|
|
107
|
+
await expect(client.updateMyProfile({ display_name: "x" })).rejects.toMatchObject({
|
|
108
|
+
kind: "validation",
|
|
109
|
+
});
|
|
110
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("uploadMedia POSTs multipart with field name 'file'", async () => {
|
|
114
|
+
const fetchImpl = vi.fn().mockResolvedValue(
|
|
115
|
+
jsonResponse({
|
|
116
|
+
code: 0,
|
|
117
|
+
message: "ok",
|
|
118
|
+
data: { url: "https://cdn/x.png", size: 12, mime: "image/png" },
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
const client = createOpenclawClawlingApiClient({
|
|
122
|
+
baseUrl: "https://api.example.com",
|
|
123
|
+
token: "tk",
|
|
124
|
+
fetchImpl,
|
|
125
|
+
});
|
|
126
|
+
const result = await client.uploadMedia({
|
|
127
|
+
buffer: Buffer.from("hi-bytes-12!"),
|
|
128
|
+
filename: "x.png",
|
|
129
|
+
mime: "image/png",
|
|
130
|
+
});
|
|
131
|
+
// uploadMedia intentionally targets `/media/upload` WITHOUT the `/v1`
|
|
132
|
+
// prefix — the upstream mount is unversioned.
|
|
133
|
+
expect(fetchImpl.mock.calls[0]![0]).toBe("https://api.example.com/media/upload");
|
|
134
|
+
const init = fetchImpl.mock.calls[0]![1] as RequestInit;
|
|
135
|
+
expect(init.method).toBe("POST");
|
|
136
|
+
expect(init.body).toBeInstanceOf(FormData);
|
|
137
|
+
const fd = init.body as FormData;
|
|
138
|
+
const file = fd.get("file") as File;
|
|
139
|
+
expect(file).toBeInstanceOf(File);
|
|
140
|
+
expect(file.name).toBe("x.png");
|
|
141
|
+
expect(file.type).toBe("image/png");
|
|
142
|
+
expect(result).toEqual({ url: "https://cdn/x.png", size: 12, mime: "image/png" });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("uploadAvatar POSTs multipart to /v1/files/upload-url", async () => {
|
|
146
|
+
const fetchImpl = vi.fn().mockResolvedValue(
|
|
147
|
+
jsonResponse({
|
|
148
|
+
code: 0,
|
|
149
|
+
message: "ok",
|
|
150
|
+
data: { url: "https://cdn/avatars/a.png", size: 99, mime: "image/png" },
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
const client = createOpenclawClawlingApiClient({
|
|
154
|
+
baseUrl: "https://api.example.com",
|
|
155
|
+
token: "tk",
|
|
156
|
+
fetchImpl,
|
|
157
|
+
});
|
|
158
|
+
const result = await client.uploadAvatar({
|
|
159
|
+
buffer: Buffer.from("avatar-bytes-12"),
|
|
160
|
+
filename: "avatar.png",
|
|
161
|
+
mime: "image/png",
|
|
162
|
+
});
|
|
163
|
+
expect(fetchImpl.mock.calls[0]![0]).toBe("https://api.example.com/v1/files/upload-url");
|
|
164
|
+
const init = fetchImpl.mock.calls[0]![1] as RequestInit;
|
|
165
|
+
expect(init.method).toBe("POST");
|
|
166
|
+
expect(init.body).toBeInstanceOf(FormData);
|
|
167
|
+
const fd = init.body as FormData;
|
|
168
|
+
const file = fd.get("file") as File;
|
|
169
|
+
expect(file).toBeInstanceOf(File);
|
|
170
|
+
expect(file.name).toBe("avatar.png");
|
|
171
|
+
expect(file.type).toBe("image/png");
|
|
172
|
+
// X-Device-Id is present on avatar uploads too.
|
|
173
|
+
expect((init.headers as Record<string, string>)["x-device-id"]).toBe("openclaw-clawchat");
|
|
174
|
+
expect(result).toEqual({ url: "https://cdn/avatars/a.png", size: 99, mime: "image/png" });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("agentsConnect POSTs /agents/connect with { code, platform, type } body", async () => {
|
|
178
|
+
const fetchImpl = vi.fn().mockResolvedValue(
|
|
179
|
+
jsonResponse({
|
|
180
|
+
msg: "ok",
|
|
181
|
+
data: {
|
|
182
|
+
agent: {
|
|
183
|
+
id: "ag-1",
|
|
184
|
+
owner_id: "owner-1",
|
|
185
|
+
user_id: "agent-1",
|
|
186
|
+
type: "bot",
|
|
187
|
+
nickname: "Bot",
|
|
188
|
+
avatar_url: "",
|
|
189
|
+
bio: "",
|
|
190
|
+
visibility: "public",
|
|
191
|
+
status: "active",
|
|
192
|
+
platform: "openclaw",
|
|
193
|
+
created_at: "2026-04-17T00:00:00Z",
|
|
194
|
+
},
|
|
195
|
+
access_token: "tk-access",
|
|
196
|
+
refresh_token: "tk-refresh",
|
|
197
|
+
},
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
const client = createOpenclawClawlingApiClient({
|
|
201
|
+
baseUrl: "https://api.example.com",
|
|
202
|
+
token: "tk",
|
|
203
|
+
fetchImpl,
|
|
204
|
+
});
|
|
205
|
+
const result = await client.agentsConnect({
|
|
206
|
+
inviteCode: "INV-1",
|
|
207
|
+
platform: "openclaw",
|
|
208
|
+
type: "bot",
|
|
209
|
+
});
|
|
210
|
+
expect(fetchImpl).toHaveBeenCalledWith(
|
|
211
|
+
"https://api.example.com/v1/agents/connect",
|
|
212
|
+
expect.objectContaining({
|
|
213
|
+
method: "POST",
|
|
214
|
+
headers: expect.objectContaining({
|
|
215
|
+
authorization: "Bearer tk",
|
|
216
|
+
"content-type": "application/json",
|
|
217
|
+
// Fixed device id — pins the issued credentials to this plugin.
|
|
218
|
+
"x-device-id": "openclaw-clawchat",
|
|
219
|
+
}),
|
|
220
|
+
body: JSON.stringify({ code: "INV-1", platform: "openclaw", type: "bot" }),
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
expect(result.agent.user_id).toBe("agent-1");
|
|
224
|
+
expect(result.access_token).toBe("tk-access");
|
|
225
|
+
expect(result.refresh_token).toBe("tk-refresh");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("agentsConnect rejects empty invite code / platform / type locally", async () => {
|
|
229
|
+
const fetchImpl = vi.fn();
|
|
230
|
+
const client = createOpenclawClawlingApiClient({
|
|
231
|
+
baseUrl: "https://api.example.com",
|
|
232
|
+
token: "tk",
|
|
233
|
+
fetchImpl,
|
|
234
|
+
});
|
|
235
|
+
await expect(
|
|
236
|
+
client.agentsConnect({ inviteCode: " ", platform: "openclaw", type: "bot" }),
|
|
237
|
+
).rejects.toMatchObject({ kind: "validation" });
|
|
238
|
+
await expect(
|
|
239
|
+
client.agentsConnect({ inviteCode: "INV", platform: "", type: "bot" }),
|
|
240
|
+
).rejects.toMatchObject({ kind: "validation" });
|
|
241
|
+
await expect(
|
|
242
|
+
client.agentsConnect({ inviteCode: "INV", platform: "openclaw", type: "" }),
|
|
243
|
+
).rejects.toMatchObject({ kind: "validation" });
|
|
244
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("throws ClawlingApiError(api) on code !== 0", async () => {
|
|
248
|
+
const fetchImpl = vi
|
|
249
|
+
.fn()
|
|
250
|
+
.mockResolvedValue(jsonResponse({ code: 4001, message: "user not found", data: null }));
|
|
251
|
+
const client = createOpenclawClawlingApiClient({
|
|
252
|
+
baseUrl: "https://api.example.com",
|
|
253
|
+
token: "tk",
|
|
254
|
+
fetchImpl,
|
|
255
|
+
});
|
|
256
|
+
await expect(client.getUserInfo("missing")).rejects.toMatchObject({
|
|
257
|
+
kind: "api",
|
|
258
|
+
message: "user not found",
|
|
259
|
+
meta: expect.objectContaining({ code: 4001 }),
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("throws ClawlingApiError(auth) on 401", async () => {
|
|
264
|
+
const fetchImpl = vi.fn().mockResolvedValue(new Response("unauthorized", { status: 401 }));
|
|
265
|
+
const client = createOpenclawClawlingApiClient({
|
|
266
|
+
baseUrl: "https://api.example.com",
|
|
267
|
+
token: "tk",
|
|
268
|
+
fetchImpl,
|
|
269
|
+
});
|
|
270
|
+
await expect(client.getMyProfile()).rejects.toMatchObject({
|
|
271
|
+
kind: "auth",
|
|
272
|
+
meta: expect.objectContaining({ status: 401 }),
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("throws ClawlingApiError(transport) on 500", async () => {
|
|
277
|
+
const fetchImpl = vi.fn().mockResolvedValue(new Response("internal error", { status: 500 }));
|
|
278
|
+
const client = createOpenclawClawlingApiClient({
|
|
279
|
+
baseUrl: "https://api.example.com",
|
|
280
|
+
token: "tk",
|
|
281
|
+
fetchImpl,
|
|
282
|
+
});
|
|
283
|
+
await expect(client.getMyProfile()).rejects.toMatchObject({
|
|
284
|
+
kind: "transport",
|
|
285
|
+
meta: expect.objectContaining({ status: 500 }),
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("throws ClawlingApiError(transport) when fetch itself rejects", async () => {
|
|
290
|
+
const fetchImpl = vi.fn().mockRejectedValue(new Error("ENETDOWN"));
|
|
291
|
+
const client = createOpenclawClawlingApiClient({
|
|
292
|
+
baseUrl: "https://api.example.com",
|
|
293
|
+
token: "tk",
|
|
294
|
+
fetchImpl,
|
|
295
|
+
});
|
|
296
|
+
await expect(client.getMyProfile()).rejects.toMatchObject({
|
|
297
|
+
kind: "transport",
|
|
298
|
+
message: expect.stringContaining("ENETDOWN"),
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("strips trailing slash from baseUrl", async () => {
|
|
303
|
+
const fetchImpl = vi
|
|
304
|
+
.fn()
|
|
305
|
+
.mockResolvedValue(
|
|
306
|
+
jsonResponse({ code: 0, message: "ok", data: { user_id: "u1", display_name: "A" } }),
|
|
307
|
+
);
|
|
308
|
+
const client = createOpenclawClawlingApiClient({
|
|
309
|
+
baseUrl: "https://api.example.com/",
|
|
310
|
+
token: "tk",
|
|
311
|
+
fetchImpl,
|
|
312
|
+
});
|
|
313
|
+
await client.getMyProfile();
|
|
314
|
+
expect(fetchImpl.mock.calls[0]![0]).toBe("https://api.example.com/v1/users/me");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("rejects baseUrl without http(s) scheme", () => {
|
|
318
|
+
expect(() =>
|
|
319
|
+
createOpenclawClawlingApiClient({
|
|
320
|
+
baseUrl: "ftp://wrong.example.com",
|
|
321
|
+
token: "tk",
|
|
322
|
+
}),
|
|
323
|
+
).toThrow(ClawlingApiError);
|
|
324
|
+
});
|
|
325
|
+
});
|