@newbase-clawchat/openclaw-clawchat 2026.4.24 → 2026.4.30
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 +66 -16
- package/dist/index.js +27 -0
- package/dist/src/api-client.js +156 -0
- package/dist/src/api-types.js +17 -0
- package/dist/src/buffered-stream.js +177 -0
- package/dist/src/channel.js +191 -0
- package/dist/src/client.js +176 -0
- package/dist/src/commands.js +35 -0
- package/dist/src/config.js +214 -0
- package/dist/src/inbound.js +133 -0
- package/dist/src/login.runtime.js +130 -0
- package/dist/src/media-runtime.js +85 -0
- package/dist/src/message-mapper.js +82 -0
- package/dist/src/outbound.js +181 -0
- package/dist/src/protocol.js +38 -0
- package/dist/src/reply-dispatcher.js +440 -0
- package/dist/src/runtime.js +288 -0
- package/dist/src/streaming.js +65 -0
- package/dist/src/tools-schema.js +38 -0
- package/dist/src/tools.js +287 -0
- package/index.ts +2 -1
- package/openclaw.plugin.json +81 -1
- package/package.json +21 -9
- package/skills/clawchat-account-tools/SKILL.md +26 -0
- package/skills/clawchat-activate/SKILL.md +47 -0
- package/src/api-client.test.ts +6 -5
- package/src/api-client.ts +8 -3
- package/src/buffered-stream.test.ts +14 -4
- package/src/buffered-stream.ts +19 -11
- package/src/channel.outbound.test.ts +49 -35
- package/src/channel.test.ts +45 -10
- package/src/channel.ts +26 -17
- package/src/client.test.ts +9 -1
- package/src/client.ts +48 -21
- package/src/commands.test.ts +39 -0
- package/src/commands.ts +41 -0
- package/src/config.test.ts +40 -3
- package/src/config.ts +60 -4
- package/src/inbound.test.ts +9 -6
- package/src/inbound.ts +51 -16
- package/src/login.runtime.test.ts +142 -3
- package/src/login.runtime.ts +59 -26
- package/src/manifest.test.ts +183 -5
- package/src/outbound.test.ts +10 -7
- package/src/outbound.ts +8 -7
- package/src/plugin-entry.test.ts +27 -0
- package/src/protocol.ts +5 -0
- package/src/reply-dispatcher.test.ts +420 -3
- package/src/reply-dispatcher.ts +137 -12
- package/src/runtime.test.ts +23 -7
- package/src/runtime.ts +13 -1
- package/src/streaming.test.ts +12 -9
- package/src/streaming.ts +22 -12
- package/src/tools-schema.ts +28 -19
- package/src/tools.test.ts +181 -40
- package/src/tools.ts +107 -95
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,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-clawchat",
|
|
3
3
|
"channels": ["openclaw-clawchat"],
|
|
4
|
+
"skills": ["./skills"],
|
|
5
|
+
"activation": {
|
|
6
|
+
"onStartup": true,
|
|
7
|
+
"onChannels": ["openclaw-clawchat"],
|
|
8
|
+
"onCommands": ["clawchat-login"]
|
|
9
|
+
},
|
|
10
|
+
"commandAliases": [
|
|
11
|
+
{ "name": "clawchat-login", "kind": "runtime-slash" }
|
|
12
|
+
],
|
|
13
|
+
"contracts": {
|
|
14
|
+
"tools": [
|
|
15
|
+
"clawchat_activate",
|
|
16
|
+
"clawchat_get_account_profile",
|
|
17
|
+
"clawchat_get_user_profile",
|
|
18
|
+
"clawchat_list_account_friends",
|
|
19
|
+
"clawchat_update_account_profile",
|
|
20
|
+
"clawchat_upload_avatar_image",
|
|
21
|
+
"clawchat_upload_media_file"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
4
24
|
"configSchema": {
|
|
5
25
|
"type": "object",
|
|
6
26
|
"additionalProperties": false,
|
|
@@ -9,8 +29,10 @@
|
|
|
9
29
|
"websocketUrl": { "type": "string" },
|
|
10
30
|
"baseUrl": { "type": "string" },
|
|
11
31
|
"token": { "type": "string" },
|
|
32
|
+
"refreshToken": { "type": "string" },
|
|
12
33
|
"userId": { "type": "string" },
|
|
13
34
|
"replyMode": { "type": "string", "enum": ["static", "stream"] },
|
|
35
|
+
"groupMode": { "type": "string", "enum": ["mention", "all"] },
|
|
14
36
|
"forwardThinking": { "type": "boolean" },
|
|
15
37
|
"forwardToolCalls": { "type": "boolean" },
|
|
16
38
|
"stream": {
|
|
@@ -28,7 +50,8 @@
|
|
|
28
50
|
"properties": {
|
|
29
51
|
"initialDelay": { "type": "integer", "minimum": 100 },
|
|
30
52
|
"maxDelay": { "type": "integer", "minimum": 100 },
|
|
31
|
-
"jitterRatio": { "type": "number", "minimum": 0 }
|
|
53
|
+
"jitterRatio": { "type": "number", "minimum": 0 },
|
|
54
|
+
"maxRetries": { "type": "integer", "minimum": 0 }
|
|
32
55
|
}
|
|
33
56
|
},
|
|
34
57
|
"heartbeat": {
|
|
@@ -48,5 +71,62 @@
|
|
|
48
71
|
}
|
|
49
72
|
}
|
|
50
73
|
}
|
|
74
|
+
},
|
|
75
|
+
"channelConfigs": {
|
|
76
|
+
"openclaw-clawchat": {
|
|
77
|
+
"label": "Clawling Chat",
|
|
78
|
+
"description": "Clawling Protocol v2 over WebSocket (chat-sdk).",
|
|
79
|
+
"schema": {
|
|
80
|
+
"type": "object",
|
|
81
|
+
"additionalProperties": false,
|
|
82
|
+
"properties": {
|
|
83
|
+
"enabled": { "type": "boolean" },
|
|
84
|
+
"websocketUrl": { "type": "string" },
|
|
85
|
+
"baseUrl": { "type": "string" },
|
|
86
|
+
"token": { "type": "string" },
|
|
87
|
+
"refreshToken": { "type": "string" },
|
|
88
|
+
"userId": { "type": "string" },
|
|
89
|
+
"replyMode": { "type": "string", "enum": ["static", "stream"] },
|
|
90
|
+
"groupMode": { "type": "string", "enum": ["mention", "all"] },
|
|
91
|
+
"forwardThinking": { "type": "boolean" },
|
|
92
|
+
"forwardToolCalls": { "type": "boolean" },
|
|
93
|
+
"stream": {
|
|
94
|
+
"type": "object",
|
|
95
|
+
"additionalProperties": false,
|
|
96
|
+
"properties": {
|
|
97
|
+
"flushIntervalMs": { "type": "integer", "minimum": 10 },
|
|
98
|
+
"minChunkChars": { "type": "integer", "minimum": 1 },
|
|
99
|
+
"maxBufferChars": { "type": "integer", "minimum": 1 }
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
"reconnect": {
|
|
103
|
+
"type": "object",
|
|
104
|
+
"additionalProperties": false,
|
|
105
|
+
"properties": {
|
|
106
|
+
"initialDelay": { "type": "integer", "minimum": 100 },
|
|
107
|
+
"maxDelay": { "type": "integer", "minimum": 100 },
|
|
108
|
+
"jitterRatio": { "type": "number", "minimum": 0 },
|
|
109
|
+
"maxRetries": { "type": "integer", "minimum": 0 }
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
"heartbeat": {
|
|
113
|
+
"type": "object",
|
|
114
|
+
"additionalProperties": false,
|
|
115
|
+
"properties": {
|
|
116
|
+
"interval": { "type": "integer", "minimum": 1000 },
|
|
117
|
+
"timeout": { "type": "integer", "minimum": 1000 }
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
"ack": {
|
|
121
|
+
"type": "object",
|
|
122
|
+
"additionalProperties": false,
|
|
123
|
+
"properties": {
|
|
124
|
+
"timeout": { "type": "integer", "minimum": 100 },
|
|
125
|
+
"autoResendOnTimeout": { "type": "boolean" }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
51
131
|
}
|
|
52
132
|
}
|
package/package.json
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@newbase-clawchat/openclaw-clawchat",
|
|
3
|
-
"version": "2026.4.
|
|
3
|
+
"version": "2026.4.30",
|
|
4
4
|
"description": "OpenClaw ClawChat channel plugin",
|
|
5
5
|
"files": [
|
|
6
|
+
"dist",
|
|
6
7
|
"index.ts",
|
|
7
8
|
"src",
|
|
9
|
+
"skills",
|
|
8
10
|
"openclaw.plugin.json",
|
|
9
11
|
"README.md"
|
|
10
12
|
],
|
|
11
13
|
"type": "module",
|
|
12
14
|
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.build.json",
|
|
16
|
+
"test": "vitest",
|
|
17
|
+
"test:e2e:install-clawchat-plugin": "bash .e2e/run-install-clawchat-plugin-e2e.sh",
|
|
18
|
+
"test:e2e:install-clawchat-plugin:smoke": "node --test .e2e/run-install-clawchat-plugin-e2e.test.mjs",
|
|
19
|
+
"dev:openclaw-source": "test -d tmp/openclaw || git clone --depth=1 https://github.com/openclaw/openclaw.git tmp/openclaw",
|
|
20
|
+
"prepack": "npm run build",
|
|
13
21
|
"typecheck": "tsc --noEmit",
|
|
14
22
|
"prepublishOnly": "npm run typecheck",
|
|
15
23
|
"release": "npm run prepublishOnly && npm publish"
|
|
@@ -20,11 +28,12 @@
|
|
|
20
28
|
},
|
|
21
29
|
"devDependencies": {
|
|
22
30
|
"@types/node": "^25.5.0",
|
|
23
|
-
"openclaw": "
|
|
24
|
-
"typescript": "^5.4.0"
|
|
31
|
+
"openclaw": "2026.4.29",
|
|
32
|
+
"typescript": "^5.4.0",
|
|
33
|
+
"vitest": "^4.1.5"
|
|
25
34
|
},
|
|
26
35
|
"peerDependencies": {
|
|
27
|
-
"openclaw": "
|
|
36
|
+
"openclaw": "^2026.4.29"
|
|
28
37
|
},
|
|
29
38
|
"peerDependenciesMeta": {
|
|
30
39
|
"openclaw": {
|
|
@@ -38,19 +47,22 @@
|
|
|
38
47
|
"extensions": [
|
|
39
48
|
"./index.ts"
|
|
40
49
|
],
|
|
50
|
+
"runtimeExtensions": [
|
|
51
|
+
"./dist/index.js"
|
|
52
|
+
],
|
|
41
53
|
"channel": {
|
|
42
54
|
"id": "openclaw-clawchat",
|
|
43
|
-
"label": "
|
|
44
|
-
"selectionLabel": "
|
|
55
|
+
"label": "Clawling Chat",
|
|
56
|
+
"selectionLabel": "Clawling Chat",
|
|
45
57
|
"docsPath": "/channels/openclaw-clawchat",
|
|
46
58
|
"docsLabel": "openclaw-clawchat",
|
|
47
|
-
"blurb":
|
|
48
|
-
"order":
|
|
59
|
+
"blurb": "Clawling Protocol v2 over WebSocket (chat-sdk).",
|
|
60
|
+
"order": 110
|
|
49
61
|
},
|
|
50
62
|
"install": {
|
|
51
63
|
"npmSpec": "@newbase-clawchat/openclaw-clawchat",
|
|
52
64
|
"defaultChoice": "npm",
|
|
53
|
-
"minHostVersion": ">=2026.
|
|
65
|
+
"minHostVersion": ">=2026.4.29"
|
|
54
66
|
}
|
|
55
67
|
}
|
|
56
68
|
}
|
|
@@ -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,47 @@
|
|
|
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 should use the `clawchat_activate` tool when that tool is available. The tool runs the same invite-code login runtime as OpenClaw channel login and persists credentials into `channels.openclaw-clawchat`.
|
|
10
|
+
|
|
11
|
+
If the tool is not available because the Gateway has not loaded this plugin yet, fall back to OpenClaw channel login. Do not edit config files manually and do not try to execute slash commands from this skill.
|
|
12
|
+
|
|
13
|
+
## Workflow
|
|
14
|
+
|
|
15
|
+
1. Check whether the user is trying to activate or log in ClawChat.
|
|
16
|
+
2. If `clawchat_activate` is available and the invite code is known, call it with the invite code.
|
|
17
|
+
3. If the invite code is not known, ask the user for it before calling `clawchat_activate`.
|
|
18
|
+
4. If `clawchat_activate` is not available, execute the channel login command:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
openclaw channels login --channel openclaw-clawchat
|
|
22
|
+
```
|
|
23
|
+
5. Do not append the invite code to the command; `openclaw channels login` has no code argument.
|
|
24
|
+
6. 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.
|
|
25
|
+
7. After activation succeeds, rely on OpenClaw's `channels.*` config hot reload when the plugin is already loaded. Verify channel status if available:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
openclaw channels status --probe
|
|
29
|
+
```
|
|
30
|
+
8. Execute the Gateway restart command only when the plugin was just installed/updated, the tool was unavailable because the running Gateway had not loaded the plugin, config reload is disabled, or the probe does not become healthy:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
openclaw gateway restart
|
|
34
|
+
```
|
|
35
|
+
9. Tell the user activation completes after login succeeds and either config hot reload/probe succeeds or Gateway restart succeeds.
|
|
36
|
+
|
|
37
|
+
## Trigger Examples
|
|
38
|
+
|
|
39
|
+
- `activate ClawChat with invite code A1B2C3`
|
|
40
|
+
- `login to ClawChat with invite code A1B2C3`
|
|
41
|
+
- `connect ClawChat using invite code A1B2C3`
|
|
42
|
+
- `绑定 ClawChat,邀请码 A1B2C3`
|
|
43
|
+
- `激活 ClawChat`
|
|
44
|
+
|
|
45
|
+
Do not ask the user to enter a bare ClawChat command. If activation is requested and `clawchat_activate` is available, call that tool yourself. If the tool is unavailable, execute `openclaw channels login --channel openclaw-clawchat` yourself, then probe channel status and restart the Gateway only when needed.
|
|
46
|
+
|
|
47
|
+
When the user asks to activate ClawChat without including a code, ask for the invite code before calling `clawchat_activate`; if falling back to channel login, provide the 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,8 @@ describe("openBufferedStreamingSession", () => {
|
|
|
52
52
|
minChunkChars: 4,
|
|
53
53
|
maxBufferChars: 1000,
|
|
54
54
|
});
|
|
55
|
-
expect(typing).toEqual([[
|
|
55
|
+
expect(typing).toEqual([["u1", true]]);
|
|
56
|
+
expect((client.typing as ReturnType<typeof vi.fn>).mock.calls).toEqual([["u1", true]]);
|
|
56
57
|
expect(sent.map((s) => s.event)).toEqual(["message.created"]);
|
|
57
58
|
});
|
|
58
59
|
|
|
@@ -101,6 +102,7 @@ describe("openBufferedStreamingSession", () => {
|
|
|
101
102
|
[{ kind: "text", text: "Hello ", delta: "Hello " }],
|
|
102
103
|
[{ kind: "text", text: "Hello world", delta: "world" }],
|
|
103
104
|
]);
|
|
105
|
+
expect(adds.map((a) => a.payload.sequence)).toEqual([0, 1]);
|
|
104
106
|
expect(session.currentText).toBe("Hello world");
|
|
105
107
|
});
|
|
106
108
|
|
|
@@ -122,8 +124,12 @@ describe("openBufferedStreamingSession", () => {
|
|
|
122
124
|
const events = sent.map((s) => s.event);
|
|
123
125
|
expect(events).toEqual(["message.created", "message.add", "message.done"]);
|
|
124
126
|
expect(typing).toEqual([
|
|
125
|
-
[
|
|
126
|
-
[
|
|
127
|
+
["u1", true],
|
|
128
|
+
["u1", false],
|
|
129
|
+
]);
|
|
130
|
+
expect((client.typing as ReturnType<typeof vi.fn>).mock.calls).toEqual([
|
|
131
|
+
["u1", true],
|
|
132
|
+
["u1", false],
|
|
127
133
|
]);
|
|
128
134
|
});
|
|
129
135
|
|
|
@@ -158,7 +164,11 @@ describe("openBufferedStreamingSession", () => {
|
|
|
158
164
|
await session.fail("boom");
|
|
159
165
|
const failed = sent.find((s) => s.event === "message.failed")!;
|
|
160
166
|
expect(failed.payload.reason).toBe("boom");
|
|
161
|
-
expect(
|
|
167
|
+
expect(failed.payload.sequence).toBe(0);
|
|
168
|
+
expect(failed.payload).toHaveProperty("completed_at");
|
|
169
|
+
expect(failed.payload).not.toHaveProperty("failed_at");
|
|
170
|
+
expect(typing.at(-1)).toEqual(["u1", false]);
|
|
171
|
+
expect((client.typing as ReturnType<typeof vi.fn>).mock.calls.at(-1)).toEqual(["u1", false]);
|
|
162
172
|
});
|
|
163
173
|
|
|
164
174
|
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,17 +90,18 @@ 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);
|
|
89
97
|
emitStreamCreated(options.client, {
|
|
90
98
|
messageId: options.messageId,
|
|
91
|
-
routing
|
|
99
|
+
routing,
|
|
92
100
|
});
|
|
93
101
|
|
|
94
102
|
let bufferedSnapshot = "";
|
|
95
103
|
let flushedSnapshot = "";
|
|
96
|
-
let sequence =
|
|
104
|
+
let sequence = -1;
|
|
97
105
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
98
106
|
let pendingFlush: Promise<void> = Promise.resolve();
|
|
99
107
|
let closed = false;
|
|
@@ -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
|
|
172
|
-
finalSequence: sequence,
|
|
179
|
+
routing,
|
|
180
|
+
finalSequence: Math.max(sequence, 0),
|
|
173
181
|
finalText: bufferedSnapshot,
|
|
174
182
|
});
|
|
175
183
|
if (emitTyping)
|
|
176
|
-
options.client.typing(
|
|
184
|
+
options.client.typing(routing.chatId, false);
|
|
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
|
|
186
|
-
sequence: sequence
|
|
193
|
+
routing,
|
|
194
|
+
sequence: Math.max(sequence, 0),
|
|
187
195
|
...(reason !== undefined ? { reason } : {}),
|
|
188
196
|
});
|
|
189
197
|
if (emitTyping)
|
|
190
|
-
options.client.typing(
|
|
198
|
+
options.client.typing(routing.chatId, false);
|
|
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
|
+
body: { fragments: [{ kind: "text", text: "hello" }] },
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
expect(client.sendMessage.mock.calls[0][0]).not.toHaveProperty("chat_type");
|
|
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
|
+
body: {
|
|
125
|
+
fragments: [
|
|
126
|
+
{ kind: "text", text: "caption" },
|
|
127
|
+
{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
expect(client.sendMessage.mock.calls[0][0]).not.toHaveProperty("chat_type");
|
|
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
|
+
body: {
|
|
203
|
+
fragments: [
|
|
204
|
+
{ kind: "text", text: "caption" },
|
|
205
|
+
{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
expect(client.sendMessage.mock.calls[0][0]).not.toHaveProperty("chat_type");
|
|
197
211
|
expect(result).toEqual({
|
|
198
212
|
channel: "openclaw-clawchat",
|
|
199
213
|
to: "cc:group:room-1",
|