@newbase-clawchat/openclaw-clawchat 2026.4.24 → 2026.4.29
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 +40 -13
- package/index.ts +2 -1
- package/openclaw.plugin.json +69 -1
- package/package.json +3 -11
- package/skills/clawchat-account-tools/SKILL.md +26 -0
- package/skills/clawchat-activate/SKILL.md +38 -0
- package/src/api-client.test.ts +6 -5
- package/src/api-client.ts +8 -3
- package/src/buffered-stream.test.ts +4 -4
- package/src/buffered-stream.ts +16 -8
- package/src/channel.outbound.test.ts +49 -35
- package/src/channel.test.ts +45 -10
- package/src/channel.ts +15 -14
- package/src/client.test.ts +2 -1
- package/src/client.ts +37 -11
- package/src/commands.test.ts +33 -0
- package/src/commands.ts +37 -0
- package/src/config.test.ts +37 -3
- package/src/config.ts +53 -4
- package/src/inbound.test.ts +5 -5
- package/src/inbound.ts +43 -9
- package/src/login.runtime.test.ts +106 -3
- package/src/login.runtime.ts +8 -3
- package/src/manifest.test.ts +106 -4
- package/src/outbound.test.ts +7 -5
- package/src/plugin-entry.test.ts +27 -0
- package/src/protocol.ts +5 -0
- package/src/reply-dispatcher.test.ts +4 -2
- package/src/runtime.test.ts +23 -7
- package/src/runtime.ts +12 -1
- package/src/streaming.test.ts +3 -3
- package/src/streaming.ts +19 -9
- package/src/tools-schema.ts +28 -19
- package/src/tools.test.ts +115 -37
- package/src/tools.ts +137 -116
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @newbase-clawchat/openclaw-clawchat
|
|
2
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
|
|
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 for profile / social / media operations (`/v1/*` plus unversioned `/media/upload`).
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
@@ -10,31 +10,52 @@ OpenClaw channel plugin that connects an agent to ClawChat over the ClawChat Pro
|
|
|
10
10
|
- Outbound text replies in `static` or `stream` mode, with a consolidated final `message.reply`
|
|
11
11
|
- Typing indicators and filtered forwarding for thinking / tool-call content
|
|
12
12
|
- Media fragments (image / file / audio / video) in either direction
|
|
13
|
-
- `clawchat_*` agent tools for profile, friends, media upload
|
|
13
|
+
- Channel-login onboarding plus `clawchat_*` agent tools for the configured ClawChat account profile, friends, and media upload
|
|
14
14
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
#
|
|
19
|
-
openclaw channels setup --channel openclaw-clawchat --code INV-ABC123
|
|
20
|
-
|
|
21
|
-
# Or add as a local extension
|
|
18
|
+
# Add as an OpenClaw extension
|
|
22
19
|
npm i @newbase-clawchat/openclaw-clawchat
|
|
23
20
|
```
|
|
24
21
|
|
|
25
22
|
Requires `openclaw >= 2026.3.23` as a peer host.
|
|
26
23
|
|
|
24
|
+
For the OpenClaw plugin install/update flow, see [`INSTALL.md`](./INSTALL.md).
|
|
25
|
+
|
|
26
|
+
Example LLM prompt:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
Use https://raw.githubusercontent.com/clawling/openclaw-clawchat/refs/heads/main/INSTALL.md to install and activate the ClawChat plugin. The invite code is XXXXXX.
|
|
30
|
+
```
|
|
31
|
+
|
|
27
32
|
## Quick start
|
|
28
33
|
|
|
34
|
+
Send one of these in chat:
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
activate ClawChat with invite code A1B2C3
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The activation skill executes OpenClaw channel login:
|
|
41
|
+
|
|
29
42
|
```bash
|
|
30
|
-
|
|
31
|
-
openclaw
|
|
43
|
+
openclaw channels login --channel openclaw-clawchat
|
|
44
|
+
openclaw gateway restart
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
If you run the gateway manually instead of as a service, start it after login:
|
|
32
48
|
|
|
33
|
-
|
|
49
|
+
```bash
|
|
34
50
|
openclaw gateway run
|
|
35
51
|
```
|
|
36
52
|
|
|
37
|
-
|
|
53
|
+
The invite code is not a token; token fields are written only after login. Before
|
|
54
|
+
login, the plugin registers no ClawChat account tools; after activation/login,
|
|
55
|
+
the channel is enabled and the `clawchat_*` account tools register on config
|
|
56
|
+
reload.
|
|
57
|
+
|
|
58
|
+
After activation/login, the channel section is enabled and has credentials:
|
|
38
59
|
|
|
39
60
|
```json5
|
|
40
61
|
{
|
|
@@ -44,7 +65,9 @@ Minimal `~/.openclaw/openclaw.json`:
|
|
|
44
65
|
replyMode: "stream",
|
|
45
66
|
forwardThinking: true,
|
|
46
67
|
forwardToolCalls: false
|
|
47
|
-
|
|
68
|
+
token: "...",
|
|
69
|
+
userId: "...",
|
|
70
|
+
refreshToken: "..."
|
|
48
71
|
}
|
|
49
72
|
}
|
|
50
73
|
}
|
|
@@ -55,10 +78,11 @@ Minimal `~/.openclaw/openclaw.json`:
|
|
|
55
78
|
A minimal browser test harness is bundled under `tools/`:
|
|
56
79
|
|
|
57
80
|
```bash
|
|
58
|
-
|
|
81
|
+
node tools/standalone-webchat-server.mjs
|
|
82
|
+
# Options: --host (default 127.0.0.1), --port (default 4318), --default-ws-url
|
|
59
83
|
```
|
|
60
84
|
|
|
61
|
-
Then open the printed URL
|
|
85
|
+
Then open the printed URL (default `http://127.0.0.1:4318`) to exercise the plugin end to end against a WebSocket relay.
|
|
62
86
|
|
|
63
87
|
## Layout
|
|
64
88
|
|
|
@@ -103,6 +127,9 @@ See [`docs/openclaw-clawchat.md`](./docs/openclaw-clawchat.md) for:
|
|
|
103
127
|
```bash
|
|
104
128
|
# Tests
|
|
105
129
|
npx vitest run
|
|
130
|
+
|
|
131
|
+
# Typecheck
|
|
132
|
+
npm run typecheck
|
|
106
133
|
```
|
|
107
134
|
|
|
108
135
|
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.
|
package/index.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
|
|
2
2
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
3
|
import { openclawClawlingPlugin } from "./src/channel.ts";
|
|
4
|
+
import { registerOpenclawClawlingCommands } from "./src/commands.ts";
|
|
4
5
|
import { setOpenclawClawlingRuntime } from "./src/runtime.ts";
|
|
5
6
|
import { registerOpenclawClawlingTools } from "./src/tools.ts";
|
|
6
7
|
import { openclawClawlingConfigSchema } from "./src/config.ts";
|
|
7
8
|
|
|
8
|
-
|
|
9
9
|
export default {
|
|
10
10
|
id: "openclaw-clawchat",
|
|
11
11
|
name: "Clawling Chat",
|
|
@@ -14,6 +14,7 @@ export default {
|
|
|
14
14
|
register(api: OpenClawPluginApi) {
|
|
15
15
|
setOpenclawClawlingRuntime(api.runtime);
|
|
16
16
|
api.registerChannel({ plugin: openclawClawlingPlugin });
|
|
17
|
+
registerOpenclawClawlingCommands(api);
|
|
17
18
|
registerOpenclawClawlingTools(api);
|
|
18
19
|
}
|
|
19
20
|
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-clawchat",
|
|
3
3
|
"channels": ["openclaw-clawchat"],
|
|
4
|
+
"skills": ["./skills"],
|
|
5
|
+
"activation": {
|
|
6
|
+
"onChannels": ["openclaw-clawchat"],
|
|
7
|
+
"onCommands": ["clawchat-login"]
|
|
8
|
+
},
|
|
9
|
+
"commandAliases": [
|
|
10
|
+
{ "name": "clawchat-login", "kind": "runtime-slash" }
|
|
11
|
+
],
|
|
4
12
|
"configSchema": {
|
|
5
13
|
"type": "object",
|
|
6
14
|
"additionalProperties": false,
|
|
@@ -9,8 +17,10 @@
|
|
|
9
17
|
"websocketUrl": { "type": "string" },
|
|
10
18
|
"baseUrl": { "type": "string" },
|
|
11
19
|
"token": { "type": "string" },
|
|
20
|
+
"refreshToken": { "type": "string" },
|
|
12
21
|
"userId": { "type": "string" },
|
|
13
22
|
"replyMode": { "type": "string", "enum": ["static", "stream"] },
|
|
23
|
+
"groupMode": { "type": "string", "enum": ["mention", "all"] },
|
|
14
24
|
"forwardThinking": { "type": "boolean" },
|
|
15
25
|
"forwardToolCalls": { "type": "boolean" },
|
|
16
26
|
"stream": {
|
|
@@ -28,7 +38,8 @@
|
|
|
28
38
|
"properties": {
|
|
29
39
|
"initialDelay": { "type": "integer", "minimum": 100 },
|
|
30
40
|
"maxDelay": { "type": "integer", "minimum": 100 },
|
|
31
|
-
"jitterRatio": { "type": "number", "minimum": 0 }
|
|
41
|
+
"jitterRatio": { "type": "number", "minimum": 0 },
|
|
42
|
+
"maxRetries": { "type": "integer", "minimum": 0 }
|
|
32
43
|
}
|
|
33
44
|
},
|
|
34
45
|
"heartbeat": {
|
|
@@ -48,5 +59,62 @@
|
|
|
48
59
|
}
|
|
49
60
|
}
|
|
50
61
|
}
|
|
62
|
+
},
|
|
63
|
+
"channelConfigs": {
|
|
64
|
+
"openclaw-clawchat": {
|
|
65
|
+
"label": "Clawling Chat",
|
|
66
|
+
"description": "Clawling Protocol v2 over WebSocket (chat-sdk).",
|
|
67
|
+
"schema": {
|
|
68
|
+
"type": "object",
|
|
69
|
+
"additionalProperties": false,
|
|
70
|
+
"properties": {
|
|
71
|
+
"enabled": { "type": "boolean" },
|
|
72
|
+
"websocketUrl": { "type": "string" },
|
|
73
|
+
"baseUrl": { "type": "string" },
|
|
74
|
+
"token": { "type": "string" },
|
|
75
|
+
"refreshToken": { "type": "string" },
|
|
76
|
+
"userId": { "type": "string" },
|
|
77
|
+
"replyMode": { "type": "string", "enum": ["static", "stream"] },
|
|
78
|
+
"groupMode": { "type": "string", "enum": ["mention", "all"] },
|
|
79
|
+
"forwardThinking": { "type": "boolean" },
|
|
80
|
+
"forwardToolCalls": { "type": "boolean" },
|
|
81
|
+
"stream": {
|
|
82
|
+
"type": "object",
|
|
83
|
+
"additionalProperties": false,
|
|
84
|
+
"properties": {
|
|
85
|
+
"flushIntervalMs": { "type": "integer", "minimum": 10 },
|
|
86
|
+
"minChunkChars": { "type": "integer", "minimum": 1 },
|
|
87
|
+
"maxBufferChars": { "type": "integer", "minimum": 1 }
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
"reconnect": {
|
|
91
|
+
"type": "object",
|
|
92
|
+
"additionalProperties": false,
|
|
93
|
+
"properties": {
|
|
94
|
+
"initialDelay": { "type": "integer", "minimum": 100 },
|
|
95
|
+
"maxDelay": { "type": "integer", "minimum": 100 },
|
|
96
|
+
"jitterRatio": { "type": "number", "minimum": 0 },
|
|
97
|
+
"maxRetries": { "type": "integer", "minimum": 0 }
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"heartbeat": {
|
|
101
|
+
"type": "object",
|
|
102
|
+
"additionalProperties": false,
|
|
103
|
+
"properties": {
|
|
104
|
+
"interval": { "type": "integer", "minimum": 1000 },
|
|
105
|
+
"timeout": { "type": "integer", "minimum": 1000 }
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"ack": {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"additionalProperties": false,
|
|
111
|
+
"properties": {
|
|
112
|
+
"timeout": { "type": "integer", "minimum": 100 },
|
|
113
|
+
"autoResendOnTimeout": { "type": "boolean" }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
51
119
|
}
|
|
52
120
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@newbase-clawchat/openclaw-clawchat",
|
|
3
|
-
"version": "2026.4.
|
|
3
|
+
"version": "2026.4.29",
|
|
4
4
|
"description": "OpenClaw ClawChat channel plugin",
|
|
5
5
|
"files": [
|
|
6
6
|
"index.ts",
|
|
7
7
|
"src",
|
|
8
|
+
"skills",
|
|
8
9
|
"openclaw.plugin.json",
|
|
9
10
|
"README.md"
|
|
10
11
|
],
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
"typescript": "^5.4.0"
|
|
25
26
|
},
|
|
26
27
|
"peerDependencies": {
|
|
27
|
-
"openclaw": "
|
|
28
|
+
"openclaw": "^2026.3.23"
|
|
28
29
|
},
|
|
29
30
|
"peerDependenciesMeta": {
|
|
30
31
|
"openclaw": {
|
|
@@ -38,15 +39,6 @@
|
|
|
38
39
|
"extensions": [
|
|
39
40
|
"./index.ts"
|
|
40
41
|
],
|
|
41
|
-
"channel": {
|
|
42
|
-
"id": "openclaw-clawchat",
|
|
43
|
-
"label": "openclaw-clawchat",
|
|
44
|
-
"selectionLabel": "openclaw-clawchat",
|
|
45
|
-
"docsPath": "/channels/openclaw-clawchat",
|
|
46
|
-
"docsLabel": "openclaw-clawchat",
|
|
47
|
-
"blurb": "OpenClaw ClawChat channel plugin",
|
|
48
|
-
"order": 70
|
|
49
|
-
},
|
|
50
42
|
"install": {
|
|
51
43
|
"npmSpec": "@newbase-clawchat/openclaw-clawchat",
|
|
52
44
|
"defaultChoice": "npm",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: clawchat-account-tools
|
|
3
|
+
description: Use when a user asks to view or update the configured ClawChat account profile, inspect a ClawChat user profile, list account friends, change avatar/bio/nickname, or upload/share ClawChat media files.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ClawChat Account Tools
|
|
7
|
+
|
|
8
|
+
Use these tools for ClawChat account/profile, friends, avatar, and media-file requests. The configured ClawChat account is not the OpenClaw agent persona.
|
|
9
|
+
|
|
10
|
+
## Tool Selection
|
|
11
|
+
|
|
12
|
+
| User intent | Tool | Notes |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| View the configured ClawChat account profile | `clawchat_get_account_profile` | Returns account user id, nickname/display name, avatar, and bio. |
|
|
15
|
+
| Inspect another ClawChat user profile | `clawchat_get_user_profile` | Requires a concrete `userId`; do not infer it from nicknames. |
|
|
16
|
+
| List account friends or contacts | `clawchat_list_account_friends` | Use `page` and `pageSize` when paging through results. |
|
|
17
|
+
| Update account nickname, avatar URL, or bio | `clawchat_update_account_profile` | Pass at least one of `nickname`, `avatar_url`, or `bio`. |
|
|
18
|
+
| Upload a local avatar image | `clawchat_upload_avatar_image` | Returns a hosted avatar URL; then call `clawchat_update_account_profile` with `avatar_url` to set it. |
|
|
19
|
+
| Upload/share a non-avatar local media file | `clawchat_upload_media_file` | Returns a public URL. If the file should be sent in chat, use the message tool with `media`. |
|
|
20
|
+
|
|
21
|
+
## Boundaries
|
|
22
|
+
|
|
23
|
+
- Do not treat ClawChat account profile changes as OpenClaw agent persona changes.
|
|
24
|
+
- Use avatar upload only for account avatar/profile-picture changes.
|
|
25
|
+
- Use media upload for non-avatar local files.
|
|
26
|
+
- For activation, login, or invite-code requests, use the activation skill instead.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: clawchat-activate
|
|
3
|
+
description: |
|
|
4
|
+
Activate or log in OpenClaw ClawChat. Use when the user asks to activate, connect, bind, or log in ClawChat, or provides a ClawChat invite code (six uppercase letters/digits, e.g. A1B2C3).
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# ClawChat Activation
|
|
8
|
+
|
|
9
|
+
Activation must execute the OpenClaw channel login command. Do not edit config files manually, do not call tools for activation, and do not try to execute slash commands from this skill.
|
|
10
|
+
|
|
11
|
+
## Workflow
|
|
12
|
+
|
|
13
|
+
1. Check whether the user is trying to activate or log in ClawChat.
|
|
14
|
+
2. Execute the channel login command:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
openclaw channels login --channel openclaw-clawchat
|
|
18
|
+
```
|
|
19
|
+
3. Do not append the invite code to the command; `openclaw channels login` has no code argument.
|
|
20
|
+
4. When the command prompts for an invite code, provide the user's invite code if one was already present, otherwise ask the user for it.
|
|
21
|
+
5. After channel login succeeds, execute the Gateway restart command:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
openclaw gateway restart
|
|
25
|
+
```
|
|
26
|
+
6. Tell the user activation completes after channel login and Gateway restart both succeed.
|
|
27
|
+
|
|
28
|
+
## Trigger Examples
|
|
29
|
+
|
|
30
|
+
- `activate ClawChat with invite code A1B2C3`
|
|
31
|
+
- `login to ClawChat with invite code A1B2C3`
|
|
32
|
+
- `connect ClawChat using invite code A1B2C3`
|
|
33
|
+
- `绑定 ClawChat,邀请码 A1B2C3`
|
|
34
|
+
- `激活 ClawChat`
|
|
35
|
+
|
|
36
|
+
Do not ask the user to enter a bare ClawChat command. If activation is requested, execute `openclaw channels login --channel openclaw-clawchat` yourself, then execute `openclaw gateway restart`.
|
|
37
|
+
|
|
38
|
+
When the user asks to activate ClawChat without including a code, run channel login and ask for the invite code when the command needs it.
|
package/src/api-client.test.ts
CHANGED
|
@@ -89,7 +89,7 @@ describe("openclaw-clawchat api-client", () => {
|
|
|
89
89
|
fetchImpl,
|
|
90
90
|
});
|
|
91
91
|
await client.updateMyProfile({ display_name: "Alice2" });
|
|
92
|
-
expect(fetchImpl.mock.calls[0]![0]).toBe("https://api.example.com/v1/
|
|
92
|
+
expect(fetchImpl.mock.calls[0]![0]).toBe("https://api.example.com/v1/users/me");
|
|
93
93
|
const init = fetchImpl.mock.calls[0]![1] as RequestInit;
|
|
94
94
|
expect(init.method).toBe("PATCH");
|
|
95
95
|
expect(JSON.parse(init.body as string)).toEqual({ display_name: "Alice2" });
|
|
@@ -196,6 +196,7 @@ describe("openclaw-clawchat api-client", () => {
|
|
|
196
196
|
it("agentsConnect POSTs /agents/connect with { code, platform, type } body", async () => {
|
|
197
197
|
const fetchImpl = vi.fn().mockResolvedValue(
|
|
198
198
|
jsonResponse({
|
|
199
|
+
code: 0,
|
|
199
200
|
msg: "ok",
|
|
200
201
|
data: {
|
|
201
202
|
agent: {
|
|
@@ -222,7 +223,7 @@ describe("openclaw-clawchat api-client", () => {
|
|
|
222
223
|
fetchImpl,
|
|
223
224
|
});
|
|
224
225
|
const result = await client.agentsConnect({
|
|
225
|
-
|
|
226
|
+
code: "INV-1",
|
|
226
227
|
platform: "openclaw",
|
|
227
228
|
type: "bot",
|
|
228
229
|
});
|
|
@@ -252,13 +253,13 @@ describe("openclaw-clawchat api-client", () => {
|
|
|
252
253
|
fetchImpl,
|
|
253
254
|
});
|
|
254
255
|
await expect(
|
|
255
|
-
client.agentsConnect({
|
|
256
|
+
client.agentsConnect({ code: " ", platform: "openclaw", type: "bot" }),
|
|
256
257
|
).rejects.toMatchObject({ kind: "validation" });
|
|
257
258
|
await expect(
|
|
258
|
-
client.agentsConnect({
|
|
259
|
+
client.agentsConnect({ code: "INV", platform: "", type: "bot" }),
|
|
259
260
|
).rejects.toMatchObject({ kind: "validation" });
|
|
260
261
|
await expect(
|
|
261
|
-
client.agentsConnect({
|
|
262
|
+
client.agentsConnect({ code: "INV", platform: "openclaw", type: "" }),
|
|
262
263
|
).rejects.toMatchObject({ kind: "validation" });
|
|
263
264
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
264
265
|
});
|
package/src/api-client.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface OpenclawClawlingApiClient {
|
|
|
24
24
|
getMyProfile(): Promise<Profile>;
|
|
25
25
|
getUserInfo(userId: string): Promise<Profile>;
|
|
26
26
|
listFriends(params: { page?: number; pageSize?: number }): Promise<FriendList>;
|
|
27
|
-
updateMyProfile(patch: {
|
|
27
|
+
updateMyProfile(patch: { nickname?: string; avatar_url?: string; bio?: string }): Promise<Profile>;
|
|
28
28
|
uploadMedia(params: { buffer: Buffer; filename: string; mime?: string }): Promise<UploadResult>;
|
|
29
29
|
/**
|
|
30
30
|
* Exchange an invite code for an agent token.
|
|
@@ -102,9 +102,14 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
|
|
|
102
102
|
// Unified envelope: `{ code: number, msg: string, data: T }`.
|
|
103
103
|
// `code === 0` means success; any other value is a business error whose
|
|
104
104
|
// `msg` is surfaced to callers and `code` is preserved on the error meta.
|
|
105
|
-
const env = parsed as { code?: unknown; msg?: unknown; data?: T };
|
|
105
|
+
const env = parsed as { code?: unknown; msg?: unknown; message?: unknown; data?: T };
|
|
106
106
|
const code = typeof env.code === "number" ? env.code : Number.NaN;
|
|
107
|
-
const msg =
|
|
107
|
+
const msg =
|
|
108
|
+
typeof env.msg === "string"
|
|
109
|
+
? env.msg
|
|
110
|
+
: typeof env.message === "string"
|
|
111
|
+
? env.message
|
|
112
|
+
: "";
|
|
108
113
|
if (!Number.isFinite(code)) {
|
|
109
114
|
throw new ClawlingApiError("transport", "invalid envelope: missing numeric `code`", {
|
|
110
115
|
status: res.status,
|
|
@@ -52,7 +52,7 @@ describe("openBufferedStreamingSession", () => {
|
|
|
52
52
|
minChunkChars: 4,
|
|
53
53
|
maxBufferChars: 1000,
|
|
54
54
|
});
|
|
55
|
-
expect(typing).toEqual([[
|
|
55
|
+
expect(typing).toEqual([["u1", true]]);
|
|
56
56
|
expect(sent.map((s) => s.event)).toEqual(["message.created"]);
|
|
57
57
|
});
|
|
58
58
|
|
|
@@ -122,8 +122,8 @@ describe("openBufferedStreamingSession", () => {
|
|
|
122
122
|
const events = sent.map((s) => s.event);
|
|
123
123
|
expect(events).toEqual(["message.created", "message.add", "message.done"]);
|
|
124
124
|
expect(typing).toEqual([
|
|
125
|
-
[
|
|
126
|
-
[
|
|
125
|
+
["u1", true],
|
|
126
|
+
["u1", false],
|
|
127
127
|
]);
|
|
128
128
|
});
|
|
129
129
|
|
|
@@ -158,7 +158,7 @@ describe("openBufferedStreamingSession", () => {
|
|
|
158
158
|
await session.fail("boom");
|
|
159
159
|
const failed = sent.find((s) => s.event === "message.failed")!;
|
|
160
160
|
expect(failed.payload.reason).toBe("boom");
|
|
161
|
-
expect(typing.at(-1)).toEqual([
|
|
161
|
+
expect(typing.at(-1)).toEqual(["u1", false]);
|
|
162
162
|
});
|
|
163
163
|
|
|
164
164
|
it("deduplicates a snapshot that is a substring of the buffered snapshot", async () => {
|
package/src/buffered-stream.ts
CHANGED
|
@@ -43,7 +43,8 @@ export function mergeStreamingText(
|
|
|
43
43
|
|
|
44
44
|
export interface BufferedStreamOptions {
|
|
45
45
|
client: ClawlingChatClient;
|
|
46
|
-
routing
|
|
46
|
+
routing?: EnvelopeRouting;
|
|
47
|
+
to?: { id: string; type: "direct" | "group" };
|
|
47
48
|
sender: StreamSender;
|
|
48
49
|
messageId: string;
|
|
49
50
|
flushIntervalMs: number;
|
|
@@ -53,6 +54,12 @@ export interface BufferedStreamOptions {
|
|
|
53
54
|
emitTyping?: boolean;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
function resolveRouting(options: BufferedStreamOptions): EnvelopeRouting {
|
|
58
|
+
if (options.routing) return options.routing;
|
|
59
|
+
if (options.to) return { chatId: options.to.id, chatType: options.to.type };
|
|
60
|
+
throw new Error("openclaw-clawchat buffered stream requires routing");
|
|
61
|
+
}
|
|
62
|
+
|
|
56
63
|
export interface BufferedStreamSession {
|
|
57
64
|
/** The full accumulated text (even if not yet flushed to the wire). */
|
|
58
65
|
readonly currentText: string;
|
|
@@ -83,12 +90,13 @@ export interface BufferedStreamSession {
|
|
|
83
90
|
export function openBufferedStreamingSession(
|
|
84
91
|
options: BufferedStreamOptions,
|
|
85
92
|
): BufferedStreamSession {
|
|
93
|
+
const routing = resolveRouting(options);
|
|
86
94
|
const emitTyping = options.emitTyping !== false;
|
|
87
95
|
if (emitTyping)
|
|
88
|
-
options.client.typing(
|
|
96
|
+
options.client.typing(routing.chatId, true, routing.chatType);
|
|
89
97
|
emitStreamCreated(options.client, {
|
|
90
98
|
messageId: options.messageId,
|
|
91
|
-
routing
|
|
99
|
+
routing,
|
|
92
100
|
});
|
|
93
101
|
|
|
94
102
|
let bufferedSnapshot = "";
|
|
@@ -115,7 +123,7 @@ export function openBufferedStreamingSession(
|
|
|
115
123
|
sequence += 1;
|
|
116
124
|
emitStreamAdd(options.client, {
|
|
117
125
|
messageId: options.messageId,
|
|
118
|
-
routing
|
|
126
|
+
routing,
|
|
119
127
|
sequence,
|
|
120
128
|
fullText: snapshot,
|
|
121
129
|
textDelta: delta,
|
|
@@ -168,12 +176,12 @@ export function openBufferedStreamingSession(
|
|
|
168
176
|
clearTimer();
|
|
169
177
|
emitStreamDone(options.client, {
|
|
170
178
|
messageId: options.messageId,
|
|
171
|
-
routing
|
|
179
|
+
routing,
|
|
172
180
|
finalSequence: sequence,
|
|
173
181
|
finalText: bufferedSnapshot,
|
|
174
182
|
});
|
|
175
183
|
if (emitTyping)
|
|
176
|
-
options.client.typing(
|
|
184
|
+
options.client.typing(routing.chatId, false, routing.chatType);
|
|
177
185
|
};
|
|
178
186
|
|
|
179
187
|
const fail = async (reason?: string): Promise<void> => {
|
|
@@ -182,12 +190,12 @@ export function openBufferedStreamingSession(
|
|
|
182
190
|
clearTimer();
|
|
183
191
|
emitStreamFailed(options.client, {
|
|
184
192
|
messageId: options.messageId,
|
|
185
|
-
routing
|
|
193
|
+
routing,
|
|
186
194
|
sequence: sequence + 1,
|
|
187
195
|
...(reason !== undefined ? { reason } : {}),
|
|
188
196
|
});
|
|
189
197
|
if (emitTyping)
|
|
190
|
-
options.client.typing(
|
|
198
|
+
options.client.typing(routing.chatId, false, routing.chatType);
|
|
191
199
|
};
|
|
192
200
|
|
|
193
201
|
return {
|
|
@@ -5,8 +5,6 @@ const getRuntimeMock = vi.hoisted(() => vi.fn());
|
|
|
5
5
|
const waitForClientMock = vi.hoisted(() => vi.fn());
|
|
6
6
|
const uploadOutboundMediaMock = vi.hoisted(() => vi.fn());
|
|
7
7
|
const createApiClientMock = vi.hoisted(() => vi.fn());
|
|
8
|
-
const sendTextMock = vi.hoisted(() => vi.fn());
|
|
9
|
-
const sendMediaMock = vi.hoisted(() => vi.fn());
|
|
10
8
|
|
|
11
9
|
vi.mock("./runtime.ts", () => ({
|
|
12
10
|
getOpenclawClawlingClient: getClientMock,
|
|
@@ -23,11 +21,6 @@ vi.mock("./api-client.ts", () => ({
|
|
|
23
21
|
createOpenclawClawlingApiClient: createApiClientMock,
|
|
24
22
|
}));
|
|
25
23
|
|
|
26
|
-
vi.mock("./outbound.ts", () => ({
|
|
27
|
-
sendOpenclawClawlingText: sendTextMock,
|
|
28
|
-
sendOpenclawClawlingMedia: sendMediaMock,
|
|
29
|
-
}));
|
|
30
|
-
|
|
31
24
|
describe("openclaw-clawchat channel outbound", () => {
|
|
32
25
|
beforeEach(() => {
|
|
33
26
|
vi.resetModules();
|
|
@@ -36,15 +29,17 @@ describe("openclaw-clawchat channel outbound", () => {
|
|
|
36
29
|
waitForClientMock.mockReset();
|
|
37
30
|
uploadOutboundMediaMock.mockReset();
|
|
38
31
|
createApiClientMock.mockReset();
|
|
39
|
-
sendTextMock.mockReset();
|
|
40
|
-
sendMediaMock.mockReset();
|
|
41
32
|
});
|
|
42
33
|
|
|
43
34
|
it("sendText waits for client activation when no active client exists yet", async () => {
|
|
44
|
-
const client = {
|
|
35
|
+
const client = {
|
|
36
|
+
sendMessage: vi.fn().mockResolvedValue({
|
|
37
|
+
payload: { message_id: "m-2", accepted_at: 456 },
|
|
38
|
+
trace_id: "trace-2",
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
45
41
|
getClientMock.mockReturnValue(undefined);
|
|
46
42
|
waitForClientMock.mockResolvedValue(client);
|
|
47
|
-
sendTextMock.mockResolvedValue({ messageId: "m-2", acceptedAt: 456 });
|
|
48
43
|
|
|
49
44
|
const { openclawClawlingOutbound } = await import("./outbound.ts");
|
|
50
45
|
const result = await openclawClawlingOutbound.sendText!({
|
|
@@ -64,12 +59,13 @@ describe("openclaw-clawchat channel outbound", () => {
|
|
|
64
59
|
});
|
|
65
60
|
|
|
66
61
|
expect(waitForClientMock).toHaveBeenCalledWith("default");
|
|
67
|
-
expect(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
62
|
+
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
63
|
+
expect.objectContaining({
|
|
64
|
+
chat_id: "user-1",
|
|
65
|
+
chat_type: "direct",
|
|
66
|
+
body: { fragments: [{ kind: "text", text: "hello" }] },
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
73
69
|
expect(result).toEqual({
|
|
74
70
|
channel: "openclaw-clawchat",
|
|
75
71
|
to: "cc:user-1",
|
|
@@ -78,7 +74,12 @@ describe("openclaw-clawchat channel outbound", () => {
|
|
|
78
74
|
});
|
|
79
75
|
|
|
80
76
|
it("sendMedia uploads mediaUrl and sends resulting fragments", async () => {
|
|
81
|
-
const client = {
|
|
77
|
+
const client = {
|
|
78
|
+
sendMessage: vi.fn().mockResolvedValue({
|
|
79
|
+
payload: { message_id: "m-1", accepted_at: 123 },
|
|
80
|
+
trace_id: "trace-1",
|
|
81
|
+
}),
|
|
82
|
+
};
|
|
82
83
|
const runtime = { media: { loadWebMedia: vi.fn() } };
|
|
83
84
|
const apiClient = { uploadMedia: vi.fn() };
|
|
84
85
|
getClientMock.mockReturnValue(client);
|
|
@@ -87,7 +88,6 @@ describe("openclaw-clawchat channel outbound", () => {
|
|
|
87
88
|
uploadOutboundMediaMock.mockResolvedValue([
|
|
88
89
|
{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
|
|
89
90
|
]);
|
|
90
|
-
sendMediaMock.mockResolvedValue({ messageId: "m-1", acceptedAt: 123 });
|
|
91
91
|
|
|
92
92
|
const { openclawClawlingOutbound } = await import("./outbound.ts");
|
|
93
93
|
const result = await openclawClawlingOutbound.sendMedia!({
|
|
@@ -118,13 +118,18 @@ describe("openclaw-clawchat channel outbound", () => {
|
|
|
118
118
|
runtime,
|
|
119
119
|
mediaLocalRoots: ["/tmp"],
|
|
120
120
|
});
|
|
121
|
-
expect(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
121
|
+
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
122
|
+
expect.objectContaining({
|
|
123
|
+
chat_id: "room-1",
|
|
124
|
+
chat_type: "group",
|
|
125
|
+
body: {
|
|
126
|
+
fragments: [
|
|
127
|
+
{ kind: "text", text: "caption" },
|
|
128
|
+
{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
128
133
|
expect(result).toEqual({
|
|
129
134
|
channel: "openclaw-clawchat",
|
|
130
135
|
to: "cc:group:room-1",
|
|
@@ -155,7 +160,12 @@ describe("openclaw-clawchat channel outbound", () => {
|
|
|
155
160
|
});
|
|
156
161
|
|
|
157
162
|
it("sendMedia waits for client activation when no active client exists yet", async () => {
|
|
158
|
-
const client = {
|
|
163
|
+
const client = {
|
|
164
|
+
sendMessage: vi.fn().mockResolvedValue({
|
|
165
|
+
payload: { message_id: "m-3", accepted_at: 789 },
|
|
166
|
+
trace_id: "trace-3",
|
|
167
|
+
}),
|
|
168
|
+
};
|
|
159
169
|
const runtime = { media: { loadWebMedia: vi.fn() } };
|
|
160
170
|
const apiClient = { uploadMedia: vi.fn() };
|
|
161
171
|
getClientMock.mockReturnValue(undefined);
|
|
@@ -165,7 +175,6 @@ describe("openclaw-clawchat channel outbound", () => {
|
|
|
165
175
|
uploadOutboundMediaMock.mockResolvedValue([
|
|
166
176
|
{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
|
|
167
177
|
]);
|
|
168
|
-
sendMediaMock.mockResolvedValue({ messageId: "m-3", acceptedAt: 789 });
|
|
169
178
|
|
|
170
179
|
const { openclawClawlingOutbound } = await import("./outbound.ts");
|
|
171
180
|
const result = await openclawClawlingOutbound.sendMedia!({
|
|
@@ -187,13 +196,18 @@ describe("openclaw-clawchat channel outbound", () => {
|
|
|
187
196
|
});
|
|
188
197
|
|
|
189
198
|
expect(waitForClientMock).toHaveBeenCalledWith("default");
|
|
190
|
-
expect(
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
199
|
+
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
200
|
+
expect.objectContaining({
|
|
201
|
+
chat_id: "room-1",
|
|
202
|
+
chat_type: "group",
|
|
203
|
+
body: {
|
|
204
|
+
fragments: [
|
|
205
|
+
{ kind: "text", text: "caption" },
|
|
206
|
+
{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
197
211
|
expect(result).toEqual({
|
|
198
212
|
channel: "openclaw-clawchat",
|
|
199
213
|
to: "cc:group:room-1",
|