@newbase-clawchat/openclaw-clawchat 2026.4.29 → 2026.5.4
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 +37 -11
- 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 +200 -0
- package/dist/src/client.js +176 -0
- package/dist/src/commands.js +35 -0
- package/dist/src/config.js +226 -0
- package/dist/src/inbound.js +133 -0
- package/dist/src/login.runtime.js +132 -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/openclaw.plugin.json +21 -0
- package/package.json +27 -5
- package/skills/clawchat-activate/SKILL.md +18 -9
- package/src/buffered-stream.test.ts +10 -0
- package/src/buffered-stream.ts +6 -6
- package/src/channel.outbound.test.ts +3 -3
- package/src/channel.test.ts +7 -1
- package/src/channel.ts +27 -8
- package/src/client.test.ts +8 -1
- package/src/client.ts +11 -10
- package/src/commands.test.ts +6 -0
- package/src/commands.ts +5 -1
- package/src/config.test.ts +47 -0
- package/src/config.ts +28 -5
- package/src/inbound.test.ts +4 -1
- package/src/inbound.ts +11 -10
- package/src/login.runtime.test.ts +36 -0
- package/src/login.runtime.ts +57 -27
- package/src/manifest.test.ts +156 -30
- package/src/outbound.test.ts +6 -5
- package/src/outbound.ts +8 -7
- package/src/plugin-entry.test.ts +7 -1
- package/src/reply-dispatcher.test.ts +418 -3
- package/src/reply-dispatcher.ts +137 -12
- package/src/runtime.ts +1 -0
- package/src/streaming.test.ts +12 -9
- package/src/streaming.ts +6 -6
- package/src/tools.test.ts +81 -18
- package/src/tools.ts +65 -74
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ 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
|
-
-
|
|
13
|
+
- Invite-code onboarding via `clawchat_activate` or `channels add --token`, plus always-registered `clawchat_*` account/media tools
|
|
14
14
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
@@ -19,7 +19,7 @@ OpenClaw channel plugin that connects an agent to ClawChat over the ClawChat Pro
|
|
|
19
19
|
npm i @newbase-clawchat/openclaw-clawchat
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Requires `openclaw >= 2026.
|
|
22
|
+
Requires `openclaw >= 2026.5.4` as a peer host.
|
|
23
23
|
|
|
24
24
|
For the OpenClaw plugin install/update flow, see [`INSTALL.md`](./INSTALL.md).
|
|
25
25
|
|
|
@@ -37,23 +37,38 @@ Send one of these in chat:
|
|
|
37
37
|
activate ClawChat with invite code A1B2C3
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
If the plugin is already loaded by the Gateway, the activation skill calls the
|
|
41
|
+
`clawchat_activate` tool and OpenClaw hot-reloads the updated `channels.*`
|
|
42
|
+
credentials. If the tool is not available yet, use `channels add` as the
|
|
43
|
+
first-time CLI activation path:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
CLAWCHAT_INVITE_CODE="A1B2C3"
|
|
47
|
+
openclaw channels add --channel openclaw-clawchat --token "$CLAWCHAT_INVITE_CODE"
|
|
48
|
+
openclaw channels status --probe
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Restart the Gateway after installing/updating the plugin, when config reload is
|
|
52
|
+
disabled, or when the channel probe does not become healthy:
|
|
41
53
|
|
|
42
54
|
```bash
|
|
43
|
-
openclaw channels login --channel openclaw-clawchat
|
|
44
55
|
openclaw gateway restart
|
|
45
56
|
```
|
|
46
57
|
|
|
47
|
-
If you run the gateway manually instead of as a service
|
|
58
|
+
If you run the gateway manually instead of as a service and it is not already
|
|
59
|
+
running, start it after activation:
|
|
48
60
|
|
|
49
61
|
```bash
|
|
50
62
|
openclaw gateway run
|
|
51
63
|
```
|
|
52
64
|
|
|
53
|
-
The
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
65
|
+
The `--token` value above is the ClawChat invite code for OpenClaw's generic
|
|
66
|
+
`channels add` CLI surface; persisted token fields are written only after the
|
|
67
|
+
invite code exchange succeeds. The
|
|
68
|
+
plugin registers `clawchat_activate` and the six account/media tools at plugin
|
|
69
|
+
load time so they stay visible before activation. Before activation, account/media
|
|
70
|
+
tools return a config error instead of disappearing; after activation/login, the
|
|
71
|
+
channel is enabled and the same tools read the hot-reloaded token/userId.
|
|
57
72
|
|
|
58
73
|
After activation/login, the channel section is enabled and has credentials:
|
|
59
74
|
|
|
@@ -64,7 +79,7 @@ After activation/login, the channel section is enabled and has credentials:
|
|
|
64
79
|
enabled: true,
|
|
65
80
|
replyMode: "stream",
|
|
66
81
|
forwardThinking: true,
|
|
67
|
-
forwardToolCalls: false
|
|
82
|
+
forwardToolCalls: false,
|
|
68
83
|
token: "...",
|
|
69
84
|
userId: "...",
|
|
70
85
|
refreshToken: "..."
|
|
@@ -132,7 +147,18 @@ npx vitest run
|
|
|
132
147
|
npm run typecheck
|
|
133
148
|
```
|
|
134
149
|
|
|
135
|
-
Tests live next to the source they cover (`*.test.ts`). The
|
|
150
|
+
Tests live next to the source they cover (`*.test.ts`). The development entrypoint stays in TypeScript for the OpenClaw extension loader, while npm installs use the compiled runtime entrypoint generated by `npm run build` / `prepack` under `dist/`.
|
|
151
|
+
|
|
152
|
+
For OpenClaw host SDK/source lookup while developing this plugin, optionally
|
|
153
|
+
clone OpenClaw into `tmp/openclaw`:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
npm run dev:openclaw-source
|
|
157
|
+
# equivalent: git clone --depth=1 https://github.com/openclaw/openclaw.git tmp/openclaw
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
This checkout is local-only. It is ignored by git and is not required to run the
|
|
161
|
+
plugin tests or publish the package.
|
|
136
162
|
|
|
137
163
|
## License
|
|
138
164
|
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { openclawClawlingPlugin } from "./src/channel.js";
|
|
2
|
+
import { registerOpenclawClawlingCommands } from "./src/commands.js";
|
|
3
|
+
import { setOpenclawClawlingRuntime } from "./src/runtime.js";
|
|
4
|
+
import { registerOpenclawClawlingTools } from "./src/tools.js";
|
|
5
|
+
import { openclawClawlingConfigSchema } from "./src/config.js";
|
|
6
|
+
export default {
|
|
7
|
+
id: "openclaw-clawchat",
|
|
8
|
+
name: "Clawling Chat",
|
|
9
|
+
description: "Clawling Chat Protocol v2 channel plugin (chat-sdk)",
|
|
10
|
+
configSchema: openclawClawlingConfigSchema,
|
|
11
|
+
register(api) {
|
|
12
|
+
setOpenclawClawlingRuntime(api.runtime);
|
|
13
|
+
api.registerChannel({ plugin: openclawClawlingPlugin });
|
|
14
|
+
registerOpenclawClawlingCommands(api);
|
|
15
|
+
registerOpenclawClawlingTools(api);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
// export default defineChannelPluginEntry({
|
|
19
|
+
// id: "openclaw-clawchat",
|
|
20
|
+
// name: "Clawling Chat",
|
|
21
|
+
// description: "Clawling Chat Protocol v2 channel plugin (chat-sdk)",
|
|
22
|
+
// plugin: openclawClawlingPlugin,
|
|
23
|
+
// setRuntime: setOpenclawClawlingRuntime,
|
|
24
|
+
// registerFull(api) {
|
|
25
|
+
// registerOpenclawClawlingTools(api);
|
|
26
|
+
// },
|
|
27
|
+
// });
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { ClawlingApiError, } from "./api-types.js";
|
|
2
|
+
import { CHANNEL_ID } from "./config.js";
|
|
3
|
+
export function createOpenclawClawlingApiClient(opts) {
|
|
4
|
+
if (!/^https?:\/\//i.test(opts.baseUrl)) {
|
|
5
|
+
throw new ClawlingApiError("validation", `openclaw-clawchat baseUrl must start with http:// or https:// (got "${opts.baseUrl}")`);
|
|
6
|
+
}
|
|
7
|
+
const baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
8
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
9
|
+
function url(path) {
|
|
10
|
+
return `${baseUrl}${path}`;
|
|
11
|
+
}
|
|
12
|
+
function authHeaders(extra = {}) {
|
|
13
|
+
// `X-Device-Id` is sent on every request so the server can correlate
|
|
14
|
+
// activity back to this plugin instance. Callers may override via
|
|
15
|
+
// `extra` (e.g. tests) but the default is the channel id.
|
|
16
|
+
return {
|
|
17
|
+
authorization: `Bearer ${opts.token}`,
|
|
18
|
+
"x-device-id": CHANNEL_ID,
|
|
19
|
+
...extra,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async function readEnvelope(res, path) {
|
|
23
|
+
if (res.status === 401 || res.status === 403) {
|
|
24
|
+
throw new ClawlingApiError("auth", `unauthorized (status ${res.status})`, {
|
|
25
|
+
status: res.status,
|
|
26
|
+
path,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
const snippet = await res.text().catch(() => "");
|
|
31
|
+
throw new ClawlingApiError("transport", `http ${res.status} ${snippet.slice(0, 200)}`, {
|
|
32
|
+
status: res.status,
|
|
33
|
+
path,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = await res.json();
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
throw new ClawlingApiError("transport", `non-JSON response: ${err instanceof Error ? err.message : String(err)}`, { status: res.status, path });
|
|
42
|
+
}
|
|
43
|
+
// Unified envelope: `{ code: number, msg: string, data: T }`.
|
|
44
|
+
// `code === 0` means success; any other value is a business error whose
|
|
45
|
+
// `msg` is surfaced to callers and `code` is preserved on the error meta.
|
|
46
|
+
const env = parsed;
|
|
47
|
+
const code = typeof env.code === "number" ? env.code : Number.NaN;
|
|
48
|
+
const msg = typeof env.msg === "string"
|
|
49
|
+
? env.msg
|
|
50
|
+
: typeof env.message === "string"
|
|
51
|
+
? env.message
|
|
52
|
+
: "";
|
|
53
|
+
if (!Number.isFinite(code)) {
|
|
54
|
+
throw new ClawlingApiError("transport", "invalid envelope: missing numeric `code`", {
|
|
55
|
+
status: res.status,
|
|
56
|
+
path,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if (code !== 0) {
|
|
60
|
+
throw new ClawlingApiError("api", msg || `code=${code}`, {
|
|
61
|
+
code,
|
|
62
|
+
status: res.status,
|
|
63
|
+
path,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return env.data;
|
|
67
|
+
}
|
|
68
|
+
async function call(method, path, init) {
|
|
69
|
+
let res;
|
|
70
|
+
try {
|
|
71
|
+
const requestInit = {
|
|
72
|
+
method,
|
|
73
|
+
headers: authHeaders(init?.headers),
|
|
74
|
+
};
|
|
75
|
+
if (init?.body !== undefined) {
|
|
76
|
+
requestInit.body = init.body;
|
|
77
|
+
}
|
|
78
|
+
res = await fetchImpl(url(path), requestInit);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
throw new ClawlingApiError("transport", `fetch failed: ${err instanceof Error ? err.message : String(err)}`, { path });
|
|
82
|
+
}
|
|
83
|
+
return await readEnvelope(res, path);
|
|
84
|
+
}
|
|
85
|
+
// All JSON API endpoints live under `/v1/...`. Media upload is the one
|
|
86
|
+
// intentional exception — the upstream server mounts it at `/media/upload`
|
|
87
|
+
// without the version prefix.
|
|
88
|
+
return {
|
|
89
|
+
async getMyProfile() {
|
|
90
|
+
return await call("GET", "/v1/users/me");
|
|
91
|
+
},
|
|
92
|
+
async getUserInfo(userId) {
|
|
93
|
+
return await call("GET", `/v1/users/${encodeURIComponent(userId)}`);
|
|
94
|
+
},
|
|
95
|
+
async listFriends(params) {
|
|
96
|
+
const sp = new URLSearchParams();
|
|
97
|
+
if (typeof params.page === "number")
|
|
98
|
+
sp.set("page", String(params.page));
|
|
99
|
+
if (typeof params.pageSize === "number")
|
|
100
|
+
sp.set("pageSize", String(params.pageSize));
|
|
101
|
+
const q = sp.toString();
|
|
102
|
+
return await call("GET", q ? `/v1/friends?${q}` : "/v1/friends");
|
|
103
|
+
},
|
|
104
|
+
async updateMyProfile(patch) {
|
|
105
|
+
if (!opts.userId?.trim()) {
|
|
106
|
+
throw new ClawlingApiError("validation", "updateMyProfile: userId is required to target /v1/agents/{userId}");
|
|
107
|
+
}
|
|
108
|
+
return await call("PATCH", `/v1/users/me`, {
|
|
109
|
+
body: JSON.stringify(patch),
|
|
110
|
+
headers: { "content-type": "application/json" },
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
async agentsConnect({ code: inviteCode, platform, type }) {
|
|
114
|
+
if (!inviteCode?.trim()) {
|
|
115
|
+
throw new ClawlingApiError("validation", "agentsConnect: inviteCode is required");
|
|
116
|
+
}
|
|
117
|
+
if (!platform?.trim()) {
|
|
118
|
+
throw new ClawlingApiError("validation", "agentsConnect: platform is required");
|
|
119
|
+
}
|
|
120
|
+
if (!type?.trim()) {
|
|
121
|
+
throw new ClawlingApiError("validation", "agentsConnect: type is required");
|
|
122
|
+
}
|
|
123
|
+
return await call("POST", "/v1/agents/connect", {
|
|
124
|
+
// `X-Device-Id` is added globally via `authHeaders` on every request.
|
|
125
|
+
headers: { "content-type": "application/json" },
|
|
126
|
+
body: JSON.stringify({
|
|
127
|
+
code: inviteCode.trim(),
|
|
128
|
+
platform: platform.trim(),
|
|
129
|
+
type: type.trim(),
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
async uploadMedia(params) {
|
|
134
|
+
const blob = new Blob([new Uint8Array(params.buffer)], {
|
|
135
|
+
type: params.mime ?? "application/octet-stream",
|
|
136
|
+
});
|
|
137
|
+
const file = new File([blob], params.filename, {
|
|
138
|
+
type: params.mime ?? "application/octet-stream",
|
|
139
|
+
});
|
|
140
|
+
const fd = new FormData();
|
|
141
|
+
fd.set("file", file);
|
|
142
|
+
return await call("POST", "/media/upload", { body: fd });
|
|
143
|
+
},
|
|
144
|
+
async uploadAvatar(params) {
|
|
145
|
+
const blob = new Blob([new Uint8Array(params.buffer)], {
|
|
146
|
+
type: params.mime ?? "application/octet-stream",
|
|
147
|
+
});
|
|
148
|
+
const file = new File([blob], params.filename, {
|
|
149
|
+
type: params.mime ?? "application/octet-stream",
|
|
150
|
+
});
|
|
151
|
+
const fd = new FormData();
|
|
152
|
+
fd.set("file", file);
|
|
153
|
+
return await call("POST", "/v1/files/upload-url", { body: fd });
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public-facing types for the Clawling Chat HTTP API.
|
|
3
|
+
*
|
|
4
|
+
* Field names mirror the upstream API (snake_case) so we don't lose
|
|
5
|
+
* fidelity on responses. Tool schemas accept camelCase externally and
|
|
6
|
+
* translate at the api-client boundary.
|
|
7
|
+
*/
|
|
8
|
+
export class ClawlingApiError extends Error {
|
|
9
|
+
kind;
|
|
10
|
+
meta;
|
|
11
|
+
constructor(kind, message, meta) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.kind = kind;
|
|
14
|
+
this.meta = meta;
|
|
15
|
+
this.name = "ClawlingApiError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { emitStreamAdd, emitStreamCreated, emitStreamDone, emitStreamFailed, } from "./client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Merge two views of the same progressively-revealed text.
|
|
4
|
+
*
|
|
5
|
+
* The agent runner may give us either:
|
|
6
|
+
* - full snapshots ("Hel", "Hello", "Hello, world") where each item is
|
|
7
|
+
* a superset of the previous; or
|
|
8
|
+
* - overlapping slices ("hello ", "world hello ") that don't share a
|
|
9
|
+
* prefix but share an overlap at the join.
|
|
10
|
+
*
|
|
11
|
+
* This helper returns a longest-sensible combined string. Ported from
|
|
12
|
+
* `clawling-channel/src/reply-dispatcher.ts`.
|
|
13
|
+
*/
|
|
14
|
+
export function mergeStreamingText(previousText, nextText) {
|
|
15
|
+
const currentSnapshot = typeof previousText === "string" ? previousText : "";
|
|
16
|
+
const incomingText = typeof nextText === "string" ? nextText : "";
|
|
17
|
+
if (!incomingText)
|
|
18
|
+
return currentSnapshot;
|
|
19
|
+
if (!currentSnapshot || incomingText === currentSnapshot)
|
|
20
|
+
return incomingText;
|
|
21
|
+
if (incomingText.startsWith(currentSnapshot))
|
|
22
|
+
return incomingText;
|
|
23
|
+
if (currentSnapshot.startsWith(incomingText))
|
|
24
|
+
return currentSnapshot;
|
|
25
|
+
if (incomingText.includes(currentSnapshot))
|
|
26
|
+
return incomingText;
|
|
27
|
+
if (currentSnapshot.includes(incomingText))
|
|
28
|
+
return currentSnapshot;
|
|
29
|
+
const maxOverlap = Math.min(currentSnapshot.length, incomingText.length);
|
|
30
|
+
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
|
|
31
|
+
if (currentSnapshot.slice(-overlap) === incomingText.slice(0, overlap)) {
|
|
32
|
+
return `${currentSnapshot}${incomingText.slice(overlap)}`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return `${currentSnapshot}${incomingText}`;
|
|
36
|
+
}
|
|
37
|
+
function resolveRouting(options) {
|
|
38
|
+
if (options.routing)
|
|
39
|
+
return options.routing;
|
|
40
|
+
if (options.to)
|
|
41
|
+
return { chatId: options.to.id, chatType: options.to.type };
|
|
42
|
+
throw new Error("openclaw-clawchat buffered stream requires routing");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Build a streaming session wrapper around message.created/add/done events.
|
|
46
|
+
*
|
|
47
|
+
* Usage pattern (matching clawling-channel):
|
|
48
|
+
* const session = openBufferedStreamingSession({...});
|
|
49
|
+
* await session.queueSnapshot("Hel");
|
|
50
|
+
* await session.queueSnapshot("Hello");
|
|
51
|
+
* await session.queueDelta(", world");
|
|
52
|
+
* await session.done();
|
|
53
|
+
*/
|
|
54
|
+
export function openBufferedStreamingSession(options) {
|
|
55
|
+
const routing = resolveRouting(options);
|
|
56
|
+
const emitTyping = options.emitTyping !== false;
|
|
57
|
+
if (emitTyping)
|
|
58
|
+
options.client.typing(routing.chatId, true);
|
|
59
|
+
emitStreamCreated(options.client, {
|
|
60
|
+
messageId: options.messageId,
|
|
61
|
+
routing,
|
|
62
|
+
});
|
|
63
|
+
let bufferedSnapshot = "";
|
|
64
|
+
let flushedSnapshot = "";
|
|
65
|
+
let sequence = -1;
|
|
66
|
+
let flushTimer = null;
|
|
67
|
+
let pendingFlush = Promise.resolve();
|
|
68
|
+
let closed = false;
|
|
69
|
+
const clearTimer = () => {
|
|
70
|
+
if (flushTimer) {
|
|
71
|
+
clearTimeout(flushTimer);
|
|
72
|
+
flushTimer = null;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const performFlush = async () => {
|
|
76
|
+
clearTimer();
|
|
77
|
+
if (closed)
|
|
78
|
+
return;
|
|
79
|
+
if (bufferedSnapshot === flushedSnapshot)
|
|
80
|
+
return;
|
|
81
|
+
const snapshot = bufferedSnapshot;
|
|
82
|
+
const delta = snapshot.slice(flushedSnapshot.length);
|
|
83
|
+
if (!delta)
|
|
84
|
+
return;
|
|
85
|
+
sequence += 1;
|
|
86
|
+
emitStreamAdd(options.client, {
|
|
87
|
+
messageId: options.messageId,
|
|
88
|
+
routing,
|
|
89
|
+
sequence,
|
|
90
|
+
fullText: snapshot,
|
|
91
|
+
textDelta: delta,
|
|
92
|
+
});
|
|
93
|
+
flushedSnapshot = snapshot;
|
|
94
|
+
};
|
|
95
|
+
const flush = async () => {
|
|
96
|
+
pendingFlush = pendingFlush.then(performFlush);
|
|
97
|
+
await pendingFlush;
|
|
98
|
+
};
|
|
99
|
+
const scheduleFlush = () => {
|
|
100
|
+
if (flushTimer || closed)
|
|
101
|
+
return;
|
|
102
|
+
flushTimer = setTimeout(() => {
|
|
103
|
+
flushTimer = null;
|
|
104
|
+
void flush();
|
|
105
|
+
}, options.flushIntervalMs);
|
|
106
|
+
};
|
|
107
|
+
const queueSnapshot = async (snapshot) => {
|
|
108
|
+
if (closed || !snapshot)
|
|
109
|
+
return;
|
|
110
|
+
const base = bufferedSnapshot.length >= flushedSnapshot.length ? bufferedSnapshot : flushedSnapshot;
|
|
111
|
+
const merged = mergeStreamingText(base, snapshot);
|
|
112
|
+
if (merged === bufferedSnapshot)
|
|
113
|
+
return;
|
|
114
|
+
bufferedSnapshot = merged;
|
|
115
|
+
const deltaChars = Math.max(0, bufferedSnapshot.length - flushedSnapshot.length);
|
|
116
|
+
if (deltaChars >= options.minChunkChars || bufferedSnapshot.length >= options.maxBufferChars) {
|
|
117
|
+
await flush();
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
scheduleFlush();
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const queueDelta = async (delta) => {
|
|
124
|
+
if (closed || !delta)
|
|
125
|
+
return;
|
|
126
|
+
bufferedSnapshot = `${bufferedSnapshot}${delta}`;
|
|
127
|
+
const deltaChars = Math.max(0, bufferedSnapshot.length - flushedSnapshot.length);
|
|
128
|
+
if (deltaChars >= options.minChunkChars || bufferedSnapshot.length >= options.maxBufferChars) {
|
|
129
|
+
await flush();
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
scheduleFlush();
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const done = async () => {
|
|
136
|
+
if (closed)
|
|
137
|
+
return;
|
|
138
|
+
await flush();
|
|
139
|
+
closed = true;
|
|
140
|
+
clearTimer();
|
|
141
|
+
emitStreamDone(options.client, {
|
|
142
|
+
messageId: options.messageId,
|
|
143
|
+
routing,
|
|
144
|
+
finalSequence: Math.max(sequence, 0),
|
|
145
|
+
finalText: bufferedSnapshot,
|
|
146
|
+
});
|
|
147
|
+
if (emitTyping)
|
|
148
|
+
options.client.typing(routing.chatId, false);
|
|
149
|
+
};
|
|
150
|
+
const fail = async (reason) => {
|
|
151
|
+
if (closed)
|
|
152
|
+
return;
|
|
153
|
+
closed = true;
|
|
154
|
+
clearTimer();
|
|
155
|
+
emitStreamFailed(options.client, {
|
|
156
|
+
messageId: options.messageId,
|
|
157
|
+
routing,
|
|
158
|
+
sequence: Math.max(sequence, 0),
|
|
159
|
+
...(reason !== undefined ? { reason } : {}),
|
|
160
|
+
});
|
|
161
|
+
if (emitTyping)
|
|
162
|
+
options.client.typing(routing.chatId, false);
|
|
163
|
+
};
|
|
164
|
+
return {
|
|
165
|
+
get currentText() {
|
|
166
|
+
return bufferedSnapshot;
|
|
167
|
+
},
|
|
168
|
+
get flushedText() {
|
|
169
|
+
return flushedSnapshot;
|
|
170
|
+
},
|
|
171
|
+
queueSnapshot,
|
|
172
|
+
queueDelta,
|
|
173
|
+
flush,
|
|
174
|
+
done,
|
|
175
|
+
fail,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
|
|
2
|
+
import { createChatChannelPlugin, } from "openclaw/plugin-sdk/core";
|
|
3
|
+
import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
|
|
4
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
|
5
|
+
import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers";
|
|
6
|
+
import { CHANNEL_ID, listOpenclawClawlingAccountIds, mergeOpenclawClawchatToolAllow, openclawClawlingConfigSchema, resolveOpenclawClawlingAccount, } from "./config.js";
|
|
7
|
+
import { openclawClawlingOutbound } from "./outbound.js";
|
|
8
|
+
import { getOpenclawClawlingRuntime, startOpenclawClawlingGateway } from "./runtime.js";
|
|
9
|
+
const configAdapter = createTopLevelChannelConfigAdapter({
|
|
10
|
+
sectionKey: CHANNEL_ID,
|
|
11
|
+
resolveAccount: (cfg) => resolveOpenclawClawlingAccount(cfg),
|
|
12
|
+
listAccountIds: () => listOpenclawClawlingAccountIds(),
|
|
13
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
14
|
+
deleteMode: "clear-fields",
|
|
15
|
+
clearBaseFields: [
|
|
16
|
+
"websocketUrl",
|
|
17
|
+
"baseUrl",
|
|
18
|
+
"token",
|
|
19
|
+
"userId",
|
|
20
|
+
"replyMode",
|
|
21
|
+
"forwardThinking",
|
|
22
|
+
"forwardToolCalls",
|
|
23
|
+
"richInteractions",
|
|
24
|
+
"enabled",
|
|
25
|
+
],
|
|
26
|
+
resolveAllowFrom: (account) => account.allowFrom,
|
|
27
|
+
formatAllowFrom: () => [],
|
|
28
|
+
});
|
|
29
|
+
/**
|
|
30
|
+
* Invite-code setup adapter used by OpenClaw setup surfaces that already have
|
|
31
|
+
* a concrete plugin instance. This plugin does not advertise catalog-driven
|
|
32
|
+
* one-shot setup metadata because current hosts do not discover channels from
|
|
33
|
+
* `plugins.load.paths`.
|
|
34
|
+
*
|
|
35
|
+
* Setup takes an invite code from `code` or from OpenClaw's generic
|
|
36
|
+
* `channels add --token` input. URL + token + userId come from the login flow
|
|
37
|
+
* which is triggered automatically in `afterAccountConfigWritten`.
|
|
38
|
+
*
|
|
39
|
+
* `applyAccountConfig` itself only marks the section `enabled: true`;
|
|
40
|
+
* credentials are written by `runOpenclawClawlingLogin` via the runtime config
|
|
41
|
+
* mutator after the `/v1/agents/connect` response lands.
|
|
42
|
+
*/
|
|
43
|
+
const setupAdapter = {
|
|
44
|
+
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
45
|
+
validateInput: ({ input }) => {
|
|
46
|
+
const inviteCode = typeof input.code === "string" && input.code.trim()
|
|
47
|
+
? input.code.trim()
|
|
48
|
+
: typeof input.token === "string"
|
|
49
|
+
? input.token.trim()
|
|
50
|
+
: "";
|
|
51
|
+
if (!inviteCode) {
|
|
52
|
+
return "ClawChat invite code is required.";
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
},
|
|
56
|
+
applyAccountConfig: ({ cfg, }) => {
|
|
57
|
+
// Base config: just enable the channel. Credentials arrive via
|
|
58
|
+
// `afterAccountConfigWritten` → `runOpenclawClawlingLogin`.
|
|
59
|
+
const channels = (cfg.channels ?? {});
|
|
60
|
+
const current = (channels[CHANNEL_ID] ?? {});
|
|
61
|
+
return mergeOpenclawClawchatToolAllow({
|
|
62
|
+
...cfg,
|
|
63
|
+
channels: {
|
|
64
|
+
...channels,
|
|
65
|
+
[CHANNEL_ID]: { ...current, enabled: true },
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
afterAccountConfigWritten: async ({ cfg, input, runtime, }) => {
|
|
70
|
+
const code = typeof input.code === "string" && input.code.trim()
|
|
71
|
+
? input.code.trim()
|
|
72
|
+
: typeof input.token === "string"
|
|
73
|
+
? input.token.trim()
|
|
74
|
+
: "";
|
|
75
|
+
if (!code)
|
|
76
|
+
return;
|
|
77
|
+
// Lazy-import the login runtime to keep @clack/prompts / readline /
|
|
78
|
+
// config-runtime off the plugin's cold-start path. `readInviteCode`
|
|
79
|
+
// feeds the fixed code so the stdin prompt is skipped entirely.
|
|
80
|
+
const { runOpenclawClawlingLogin } = await import("./login.runtime.js");
|
|
81
|
+
await runOpenclawClawlingLogin({
|
|
82
|
+
cfg,
|
|
83
|
+
accountId: null,
|
|
84
|
+
runtime: { log: (message) => runtime.log(message) },
|
|
85
|
+
readInviteCode: async () => code,
|
|
86
|
+
mutateConfigFile: getOpenclawClawlingRuntime().config.mutateConfigFile,
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
export const openclawClawlingPlugin = createChatChannelPlugin({
|
|
91
|
+
base: {
|
|
92
|
+
id: CHANNEL_ID,
|
|
93
|
+
meta: {
|
|
94
|
+
id: CHANNEL_ID,
|
|
95
|
+
label: "Clawling Chat",
|
|
96
|
+
selectionLabel: "Clawling Chat",
|
|
97
|
+
docsPath: "/channels/openclaw-clawchat",
|
|
98
|
+
docsLabel: "openclaw-clawchat",
|
|
99
|
+
blurb: "Clawling Protocol v2 over WebSocket (chat-sdk).",
|
|
100
|
+
order: 110,
|
|
101
|
+
},
|
|
102
|
+
capabilities: {
|
|
103
|
+
chatTypes: ["direct", "group"],
|
|
104
|
+
media: true,
|
|
105
|
+
reactions: false,
|
|
106
|
+
threads: false,
|
|
107
|
+
polls: false,
|
|
108
|
+
blockStreaming: true,
|
|
109
|
+
},
|
|
110
|
+
reload: {
|
|
111
|
+
configPrefixes: [`channels.${CHANNEL_ID}`],
|
|
112
|
+
},
|
|
113
|
+
configSchema: {
|
|
114
|
+
schema: openclawClawlingConfigSchema,
|
|
115
|
+
},
|
|
116
|
+
config: {
|
|
117
|
+
...configAdapter,
|
|
118
|
+
isConfigured: (account) => account.configured,
|
|
119
|
+
describeAccount: (account) => ({
|
|
120
|
+
accountId: account.accountId,
|
|
121
|
+
name: account.name,
|
|
122
|
+
enabled: account.enabled,
|
|
123
|
+
configured: account.configured,
|
|
124
|
+
}),
|
|
125
|
+
},
|
|
126
|
+
directory: createEmptyChannelDirectoryAdapter(),
|
|
127
|
+
setup: setupAdapter,
|
|
128
|
+
status: createComputedAccountStatusAdapter({
|
|
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
|
+
auth: {
|
|
147
|
+
login: async ({ cfg, accountId, runtime }) => {
|
|
148
|
+
// Lazy-load login.runtime: it pulls in @clack/prompts and other
|
|
149
|
+
// heavy modules that have no business loading on every plugin
|
|
150
|
+
// boot. Only the rare `openclaw channels login --channel
|
|
151
|
+
// openclaw-clawchat` invocation pays the import cost.
|
|
152
|
+
const { runOpenclawClawlingLogin } = await import("./login.runtime.js");
|
|
153
|
+
await runOpenclawClawlingLogin({
|
|
154
|
+
cfg,
|
|
155
|
+
accountId: accountId ?? null,
|
|
156
|
+
runtime: { log: (message) => runtime.log(message) },
|
|
157
|
+
mutateConfigFile: getOpenclawClawlingRuntime().config.mutateConfigFile,
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
gateway: {
|
|
162
|
+
startAccount: async (ctx) => {
|
|
163
|
+
const account = ctx.account ?? resolveOpenclawClawlingAccount(ctx.cfg);
|
|
164
|
+
if (!account.configured) {
|
|
165
|
+
throw new Error("Clawling Chat websocketUrl/token/userId are required");
|
|
166
|
+
}
|
|
167
|
+
return await startOpenclawClawlingGateway({
|
|
168
|
+
cfg: ctx.cfg,
|
|
169
|
+
account,
|
|
170
|
+
abortSignal: ctx.abortSignal,
|
|
171
|
+
setStatus: ctx.setStatus,
|
|
172
|
+
getStatus: ctx.getStatus,
|
|
173
|
+
log: ctx.log,
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
agentPrompt: {
|
|
178
|
+
messageToolHints: () => [
|
|
179
|
+
"To send an image or file to the current chat, use the message tool with action='send' and set 'media' to a local file path or a remote URL.",
|
|
180
|
+
"When the user asks you to find an image from the web, find a suitable HTTPS image URL and send it using the message tool with 'media' set to that URL — do NOT download the image first.",
|
|
181
|
+
"For configured ClawChat account profile, user profile, friends, avatar, or standalone media upload/share-link workflows, use `clawchat-account-tools` for tool-selection details.",
|
|
182
|
+
"For ClawChat account avatar changes using a local image, call `clawchat_upload_avatar_image` first, then `clawchat_update_account_profile` with `avatar_url`.",
|
|
183
|
+
"- Targeting: omit `target` to reply here; for a different chat use `target=\"cc:{chat_id}\"` for direct or `target=\"cc:group:{chat_id}\"` for group.",
|
|
184
|
+
"- ClawChat supports image / file / audio / video media alongside text.",
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
messaging: {
|
|
188
|
+
normalizeTarget: (target) => target
|
|
189
|
+
.trim()
|
|
190
|
+
.replace(/^openclaw-clawchat:/i, "")
|
|
191
|
+
.replace(/^clawchat:/i, "")
|
|
192
|
+
.replace(/^cc:/i, ""),
|
|
193
|
+
targetResolver: {
|
|
194
|
+
looksLikeId: (raw, normalized) => Boolean((normalized ?? raw).trim()),
|
|
195
|
+
hint: "active-session",
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
outbound: openclawClawlingOutbound,
|
|
200
|
+
});
|