@newbase-clawchat/openclaw-clawchat 2026.4.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -0
- package/index.ts +19 -0
- package/openclaw.plugin.json +52 -0
- package/package.json +58 -0
- package/src/api-client.test.ts +325 -0
- package/src/api-client.ts +225 -0
- package/src/api-types.ts +71 -0
- package/src/buffered-stream.test.ts +201 -0
- package/src/buffered-stream.ts +206 -0
- package/src/channel.test.ts +72 -0
- package/src/channel.ts +278 -0
- package/src/client.test.ts +174 -0
- package/src/client.ts +279 -0
- package/src/config.test.ts +110 -0
- package/src/config.ts +277 -0
- package/src/inbound.test.ts +264 -0
- package/src/inbound.ts +201 -0
- package/src/login.runtime.test.ts +257 -0
- package/src/login.runtime.ts +153 -0
- package/src/manifest.test.ts +22 -0
- package/src/media-runtime.test.ts +159 -0
- package/src/media-runtime.ts +143 -0
- package/src/message-mapper.test.ts +131 -0
- package/src/message-mapper.ts +82 -0
- package/src/outbound.test.ts +244 -0
- package/src/outbound.ts +141 -0
- package/src/protocol.test.ts +42 -0
- package/src/protocol.ts +38 -0
- package/src/reply-dispatcher.ts +387 -0
- package/src/runtime.test.ts +276 -0
- package/src/runtime.ts +316 -0
- package/src/streaming.test.ts +116 -0
- package/src/streaming.ts +89 -0
- package/src/tools-schema.ts +45 -0
- package/src/tools.test.ts +135 -0
- package/src/tools.ts +308 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createWSClient,
|
|
3
|
+
type ClawlingChatClient,
|
|
4
|
+
type CreateWSClientOptions,
|
|
5
|
+
type Fragment,
|
|
6
|
+
type Transport,
|
|
7
|
+
} from "@newbase-clawchat/sdk";
|
|
8
|
+
import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
|
|
9
|
+
|
|
10
|
+
export interface CreateClientOverrides {
|
|
11
|
+
/** Transport override — only intended for tests (e.g. MockTransport). */
|
|
12
|
+
transport?: Transport;
|
|
13
|
+
logger?: CreateWSClientOptions["logger"];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createOpenclawClawlingClient(
|
|
17
|
+
account: ResolvedOpenclawClawlingAccount,
|
|
18
|
+
overrides: CreateClientOverrides = {},
|
|
19
|
+
): ClawlingChatClient {
|
|
20
|
+
// Only forward a finite `maxRetries` to the SDK — the SDK's own default
|
|
21
|
+
// is already unbounded, so omitting the field keeps that behavior. This
|
|
22
|
+
// avoids forcing the SDK to special-case `Infinity`.
|
|
23
|
+
const maxRetries = account.reconnect.maxRetries;
|
|
24
|
+
const reconnect: CreateWSClientOptions["reconnect"] = {
|
|
25
|
+
enabled: true,
|
|
26
|
+
initialDelay: account.reconnect.initialDelay,
|
|
27
|
+
maxDelay: account.reconnect.maxDelay,
|
|
28
|
+
jitterRatio: account.reconnect.jitterRatio,
|
|
29
|
+
...(Number.isFinite(maxRetries) ? { maxRetries } : {}),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const options: CreateWSClientOptions = {
|
|
33
|
+
url: account.websocketUrl,
|
|
34
|
+
token: account.token,
|
|
35
|
+
reconnect,
|
|
36
|
+
heartbeat: {
|
|
37
|
+
enabled: true,
|
|
38
|
+
interval: account.heartbeat.interval,
|
|
39
|
+
timeout: account.heartbeat.timeout,
|
|
40
|
+
},
|
|
41
|
+
ack: {
|
|
42
|
+
timeout: account.ack.timeout,
|
|
43
|
+
autoResendOnTimeout: account.ack.autoResendOnTimeout,
|
|
44
|
+
},
|
|
45
|
+
// Buffer outbound sends during the tiny reconnect window so an inbound
|
|
46
|
+
// message isn't silently dropped while the socket is flapping.
|
|
47
|
+
queueWhileReconnecting: true,
|
|
48
|
+
...(overrides.transport ? { transport: overrides.transport } : {}),
|
|
49
|
+
...(overrides.logger ? { logger: overrides.logger } : {}),
|
|
50
|
+
};
|
|
51
|
+
return createWSClient(options);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type ChatType = "direct" | "group";
|
|
55
|
+
|
|
56
|
+
export interface StreamSender {
|
|
57
|
+
id: string;
|
|
58
|
+
type: ChatType;
|
|
59
|
+
nick_name: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface EnvelopeRouting {
|
|
63
|
+
chatId: string;
|
|
64
|
+
chatType: ChatType;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Emit a raw v2 envelope directly over the transport so we can carry
|
|
69
|
+
* `chat_id` + `chat_type` at envelope root (the new protocol). The SDK's
|
|
70
|
+
* `emitRaw` can't express `chat_type` and always writes `to`; we bypass it
|
|
71
|
+
* entirely for the events we construct ourselves (streaming lifecycle +
|
|
72
|
+
* message.reply finalize).
|
|
73
|
+
*/
|
|
74
|
+
function emitEnvelope(
|
|
75
|
+
client: ClawlingChatClient,
|
|
76
|
+
event: string,
|
|
77
|
+
payload: object,
|
|
78
|
+
routing: EnvelopeRouting,
|
|
79
|
+
): void {
|
|
80
|
+
const inner = client as unknown as {
|
|
81
|
+
opts: {
|
|
82
|
+
transport: { send: (data: string) => void };
|
|
83
|
+
traceIdFactory: () => string;
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
const env = {
|
|
87
|
+
version: "2" as const,
|
|
88
|
+
event,
|
|
89
|
+
trace_id: inner.opts.traceIdFactory(),
|
|
90
|
+
emitted_at: Date.now(),
|
|
91
|
+
chat_id: routing.chatId,
|
|
92
|
+
chat_type: routing.chatType,
|
|
93
|
+
payload,
|
|
94
|
+
};
|
|
95
|
+
inner.opts.transport.send(JSON.stringify(env));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Emit a minimal `message.created` envelope to open a streaming message.
|
|
100
|
+
*
|
|
101
|
+
* Payload is intentionally just `{ message_id }`: the message body, context,
|
|
102
|
+
* sender, and streaming metadata are not transmitted here — they live on the
|
|
103
|
+
* envelope (`chat_id`, `chat_type`, optional `sender`) and on the subsequent
|
|
104
|
+
* `message.add` / `message.done` frames.
|
|
105
|
+
*/
|
|
106
|
+
export function emitStreamCreated(
|
|
107
|
+
client: ClawlingChatClient,
|
|
108
|
+
params: {
|
|
109
|
+
messageId: string;
|
|
110
|
+
routing: EnvelopeRouting;
|
|
111
|
+
},
|
|
112
|
+
): void {
|
|
113
|
+
emitEnvelope(
|
|
114
|
+
client,
|
|
115
|
+
"message.created",
|
|
116
|
+
{ message_id: params.messageId },
|
|
117
|
+
params.routing,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Emit a `message.add` frame with a `fragments` array carrying both the
|
|
123
|
+
* delta (newly appended text) and the full running text ("from the start
|
|
124
|
+
* up to now"). Clients rendering the stream can use `delta` for animations
|
|
125
|
+
* and `text` for the current snapshot.
|
|
126
|
+
*
|
|
127
|
+
* Shape: `fragments: [{ kind: "text", text: <cumulative>, delta: <new> }]`
|
|
128
|
+
*/
|
|
129
|
+
export function emitStreamAdd(
|
|
130
|
+
client: ClawlingChatClient,
|
|
131
|
+
params: {
|
|
132
|
+
messageId: string;
|
|
133
|
+
routing: EnvelopeRouting;
|
|
134
|
+
sequence: number;
|
|
135
|
+
/** Running cumulative text after this delta is applied. */
|
|
136
|
+
fullText: string;
|
|
137
|
+
/** The newly appended text for this frame. */
|
|
138
|
+
textDelta: string;
|
|
139
|
+
},
|
|
140
|
+
): void {
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
emitEnvelope(
|
|
143
|
+
client,
|
|
144
|
+
"message.add",
|
|
145
|
+
{
|
|
146
|
+
message_id: params.messageId,
|
|
147
|
+
sequence: params.sequence,
|
|
148
|
+
mutation: { type: "append", target_fragment_index: null },
|
|
149
|
+
fragments: [
|
|
150
|
+
{ kind: "text", text: params.fullText, delta: params.textDelta },
|
|
151
|
+
],
|
|
152
|
+
streaming: {
|
|
153
|
+
status: "streaming",
|
|
154
|
+
sequence: params.sequence,
|
|
155
|
+
mutation_policy: "append_text_only",
|
|
156
|
+
started_at: null,
|
|
157
|
+
completed_at: null,
|
|
158
|
+
},
|
|
159
|
+
added_at: now,
|
|
160
|
+
},
|
|
161
|
+
params.routing,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Emit a `message.done` frame with the final merged text included as a
|
|
167
|
+
* single-element `fragments` array so clients can settle the streamed
|
|
168
|
+
* message on the full text without re-accumulating deltas.
|
|
169
|
+
*/
|
|
170
|
+
export function emitStreamDone(
|
|
171
|
+
client: ClawlingChatClient,
|
|
172
|
+
params: {
|
|
173
|
+
messageId: string;
|
|
174
|
+
routing: EnvelopeRouting;
|
|
175
|
+
finalSequence: number;
|
|
176
|
+
finalText: string;
|
|
177
|
+
},
|
|
178
|
+
): void {
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
emitEnvelope(
|
|
181
|
+
client,
|
|
182
|
+
"message.done",
|
|
183
|
+
{
|
|
184
|
+
message_id: params.messageId,
|
|
185
|
+
fragments: [{ kind: "text", text: params.finalText }],
|
|
186
|
+
streaming: {
|
|
187
|
+
status: "done",
|
|
188
|
+
sequence: params.finalSequence,
|
|
189
|
+
mutation_policy: "append_text_only",
|
|
190
|
+
started_at: null,
|
|
191
|
+
completed_at: now,
|
|
192
|
+
},
|
|
193
|
+
completed_at: now,
|
|
194
|
+
},
|
|
195
|
+
params.routing,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Emit a `message.reply` envelope that finalizes a streamed reply, carrying
|
|
201
|
+
* the same `payload.message_id` as the preceding `message.created` /
|
|
202
|
+
* `message.add` / `message.done` frames.
|
|
203
|
+
*
|
|
204
|
+
* The SDK's high-level `client.replyMessage()` disallows `payload.message_id`
|
|
205
|
+
* on outbound replies (the server normally assigns one via ack); for the
|
|
206
|
+
* streaming-finalize use case the backend expects the correlated id, so we
|
|
207
|
+
* bypass the SDK validator and write directly to the transport.
|
|
208
|
+
*/
|
|
209
|
+
export function emitFinalStreamReply(
|
|
210
|
+
client: ClawlingChatClient,
|
|
211
|
+
params: {
|
|
212
|
+
/** The streaming message_id — must equal the id used on created/add/done. */
|
|
213
|
+
messageId: string;
|
|
214
|
+
routing: EnvelopeRouting;
|
|
215
|
+
/** The user message this stream is a reply to (usually the inbound turn). */
|
|
216
|
+
replyTo: {
|
|
217
|
+
msgId: string;
|
|
218
|
+
senderId: string;
|
|
219
|
+
nickName: string;
|
|
220
|
+
fragments: Fragment[];
|
|
221
|
+
};
|
|
222
|
+
body: { fragments: Fragment[] };
|
|
223
|
+
mentions?: string[];
|
|
224
|
+
},
|
|
225
|
+
): void {
|
|
226
|
+
emitEnvelope(
|
|
227
|
+
client,
|
|
228
|
+
"message.reply",
|
|
229
|
+
{
|
|
230
|
+
message_id: params.messageId,
|
|
231
|
+
message_mode: "normal",
|
|
232
|
+
message: {
|
|
233
|
+
body: params.body,
|
|
234
|
+
context: {
|
|
235
|
+
mentions: params.mentions ?? [],
|
|
236
|
+
reply: {
|
|
237
|
+
reply_to_msg_id: params.replyTo.msgId,
|
|
238
|
+
reply_preview: {
|
|
239
|
+
id: params.replyTo.senderId,
|
|
240
|
+
nick_name: params.replyTo.nickName,
|
|
241
|
+
fragments: params.replyTo.fragments,
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
params.routing,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function emitStreamFailed(
|
|
252
|
+
client: ClawlingChatClient,
|
|
253
|
+
params: {
|
|
254
|
+
messageId: string;
|
|
255
|
+
routing: EnvelopeRouting;
|
|
256
|
+
sequence: number;
|
|
257
|
+
reason?: string;
|
|
258
|
+
},
|
|
259
|
+
): void {
|
|
260
|
+
const now = Date.now();
|
|
261
|
+
emitEnvelope(
|
|
262
|
+
client,
|
|
263
|
+
"message.failed",
|
|
264
|
+
{
|
|
265
|
+
message_id: params.messageId,
|
|
266
|
+
sequence: params.sequence,
|
|
267
|
+
reason: params.reason ?? "unknown",
|
|
268
|
+
streaming: {
|
|
269
|
+
status: "failed",
|
|
270
|
+
sequence: params.sequence,
|
|
271
|
+
mutation_policy: "append_text_only",
|
|
272
|
+
started_at: null,
|
|
273
|
+
completed_at: now,
|
|
274
|
+
},
|
|
275
|
+
failed_at: now,
|
|
276
|
+
},
|
|
277
|
+
params.routing,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
CHANNEL_ID,
|
|
5
|
+
DEFAULT_STREAM,
|
|
6
|
+
resolveOpenclawClawlingAccount,
|
|
7
|
+
listOpenclawClawlingAccountIds,
|
|
8
|
+
} from "./config.ts";
|
|
9
|
+
|
|
10
|
+
describe("openclaw-clawchat config", () => {
|
|
11
|
+
it("uses correct channel id", () => {
|
|
12
|
+
expect(CHANNEL_ID).toBe("openclaw-clawchat");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("resolves defaults when no config provided", () => {
|
|
16
|
+
const account = resolveOpenclawClawlingAccount({});
|
|
17
|
+
expect(account.accountId).toBe(DEFAULT_ACCOUNT_ID);
|
|
18
|
+
expect(account.enabled).toBe(true);
|
|
19
|
+
expect(account.configured).toBe(false);
|
|
20
|
+
expect(account.replyMode).toBe("static");
|
|
21
|
+
expect(account.forwardThinking).toBe(true);
|
|
22
|
+
expect(account.forwardToolCalls).toBe(false);
|
|
23
|
+
expect(account.stream).toEqual(DEFAULT_STREAM);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("resolves required fields and marks configured=true", () => {
|
|
27
|
+
const cfg = {
|
|
28
|
+
channels: {
|
|
29
|
+
"openclaw-clawchat": {
|
|
30
|
+
websocketUrl: "wss://chat.example.com/ws",
|
|
31
|
+
token: "secret",
|
|
32
|
+
userId: "agent-1",
|
|
33
|
+
replyMode: "stream",
|
|
34
|
+
forwardThinking: false,
|
|
35
|
+
forwardToolCalls: true,
|
|
36
|
+
stream: { flushIntervalMs: 500, minChunkChars: 50, maxBufferChars: 3000 },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
const account = resolveOpenclawClawlingAccount(cfg);
|
|
41
|
+
expect(account.configured).toBe(true);
|
|
42
|
+
expect(account.websocketUrl).toBe("wss://chat.example.com/ws");
|
|
43
|
+
expect(account.token).toBe("secret");
|
|
44
|
+
expect(account.userId).toBe("agent-1");
|
|
45
|
+
expect(account.replyMode).toBe("stream");
|
|
46
|
+
expect(account.forwardThinking).toBe(false);
|
|
47
|
+
expect(account.forwardToolCalls).toBe(true);
|
|
48
|
+
expect(account.stream.flushIntervalMs).toBe(500);
|
|
49
|
+
expect(account.stream.minChunkChars).toBe(50);
|
|
50
|
+
expect(account.stream.maxBufferChars).toBe(3000);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("falls back to static replyMode for unknown values", () => {
|
|
54
|
+
const cfg = {
|
|
55
|
+
channels: {
|
|
56
|
+
"openclaw-clawchat": { websocketUrl: "w", token: "t", userId: "a", replyMode: "weird" },
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
const account = resolveOpenclawClawlingAccount(cfg);
|
|
60
|
+
expect(account.replyMode).toBe("static");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("lists the default account id", () => {
|
|
64
|
+
expect(listOpenclawClawlingAccountIds()).toEqual([DEFAULT_ACCOUNT_ID]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("parses baseUrl when provided", () => {
|
|
68
|
+
const cfg = {
|
|
69
|
+
channels: {
|
|
70
|
+
"openclaw-clawchat": {
|
|
71
|
+
websocketUrl: "wss://w",
|
|
72
|
+
token: "t",
|
|
73
|
+
userId: "u",
|
|
74
|
+
baseUrl: "https://api.example.com",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
const account = resolveOpenclawClawlingAccount(cfg);
|
|
79
|
+
expect(account.baseUrl).toBe("https://api.example.com");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("falls back to the built-in DEFAULT_BASE_URL when unset", async () => {
|
|
83
|
+
const { DEFAULT_BASE_URL } = await import("./config.ts");
|
|
84
|
+
const account = resolveOpenclawClawlingAccount({});
|
|
85
|
+
expect(account.baseUrl).toBe(DEFAULT_BASE_URL);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("falls back to the built-in DEFAULT_WEBSOCKET_URL when unset", async () => {
|
|
89
|
+
const { DEFAULT_WEBSOCKET_URL } = await import("./config.ts");
|
|
90
|
+
const account = resolveOpenclawClawlingAccount({});
|
|
91
|
+
expect(account.websocketUrl).toBe(DEFAULT_WEBSOCKET_URL);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("does NOT include baseUrl in the configured predicate (channel still works without it)", async () => {
|
|
95
|
+
const { DEFAULT_BASE_URL } = await import("./config.ts");
|
|
96
|
+
const cfg = {
|
|
97
|
+
channels: {
|
|
98
|
+
"openclaw-clawchat": {
|
|
99
|
+
websocketUrl: "wss://w",
|
|
100
|
+
token: "t",
|
|
101
|
+
userId: "u",
|
|
102
|
+
// no baseUrl — resolver uses default
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
const account = resolveOpenclawClawlingAccount(cfg);
|
|
107
|
+
expect(account.configured).toBe(true);
|
|
108
|
+
expect(account.baseUrl).toBe(DEFAULT_BASE_URL);
|
|
109
|
+
});
|
|
110
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
|
3
|
+
|
|
4
|
+
export const CHANNEL_ID = "openclaw-clawchat" as const;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Built-in defaults for the Clawling Chat endpoints so `openclaw channel
|
|
8
|
+
* login` works out of the box without requiring a prior `openclaw channel
|
|
9
|
+
* setup` call. Operators can still override either one via config.
|
|
10
|
+
*
|
|
11
|
+
* TODO: replace these placeholders with the production URLs.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_BASE_URL = "https://api.clawling.chat" as const;
|
|
14
|
+
export const DEFAULT_WEBSOCKET_URL = "wss://api.clawling.chat/ws" as const;
|
|
15
|
+
|
|
16
|
+
export type ReplyMode = "static" | "stream";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Group-chat trigger policy.
|
|
20
|
+
* - "mention" (default): only trigger a reply when the inbound `context.mentions`
|
|
21
|
+
* list contains our `userId` (i.e. the sender @-mentioned us).
|
|
22
|
+
* - "all": trigger on every group message regardless of mentions (open listen).
|
|
23
|
+
*/
|
|
24
|
+
export type GroupMode = "mention" | "all";
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_STREAM = {
|
|
27
|
+
flushIntervalMs: 250,
|
|
28
|
+
minChunkChars: 40,
|
|
29
|
+
maxBufferChars: 2000,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_RECONNECT = {
|
|
33
|
+
// Snappier first retry on transient drops (vs. 1_000).
|
|
34
|
+
initialDelay: 500,
|
|
35
|
+
// Cap exponential backoff at 15s — a background gateway reconnecting
|
|
36
|
+
// every 30s feels unresponsive; 15s is the common IM chat bar.
|
|
37
|
+
maxDelay: 15_000,
|
|
38
|
+
// Standard jitter ratio to avoid thundering herd on server restart.
|
|
39
|
+
jitterRatio: 0.3,
|
|
40
|
+
// Never give up — the gateway is a long-lived background process.
|
|
41
|
+
maxRetries: Number.POSITIVE_INFINITY,
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_HEARTBEAT = {
|
|
45
|
+
// 20s keeps NAT/firewall state warm without wasting bandwidth.
|
|
46
|
+
interval: 20_000,
|
|
47
|
+
// Pong must arrive within 10s or we tear down and reconnect.
|
|
48
|
+
timeout: 10_000,
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
export const DEFAULT_ACK = {
|
|
52
|
+
// 15s tolerates a slow server + one retry without false timeouts.
|
|
53
|
+
timeout: 15_000,
|
|
54
|
+
// Keep false: auto-resend on timeout risks duplicate messages; the
|
|
55
|
+
// reconnect path re-queues via `queueWhileReconnecting` instead.
|
|
56
|
+
autoResendOnTimeout: false,
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
export type OpenclawClawlingStreamConfig = {
|
|
60
|
+
flushIntervalMs?: number;
|
|
61
|
+
minChunkChars?: number;
|
|
62
|
+
maxBufferChars?: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type OpenclawClawlingReconnectConfig = {
|
|
66
|
+
initialDelay?: number;
|
|
67
|
+
maxDelay?: number;
|
|
68
|
+
jitterRatio?: number;
|
|
69
|
+
/** Max reconnect attempts. Omit/Infinity = never give up. */
|
|
70
|
+
maxRetries?: number;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type OpenclawClawlingHeartbeatConfig = {
|
|
74
|
+
interval?: number;
|
|
75
|
+
timeout?: number;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type OpenclawClawlingAckConfig = {
|
|
79
|
+
timeout?: number;
|
|
80
|
+
autoResendOnTimeout?: boolean;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type OpenclawClawlingConfig = {
|
|
84
|
+
enabled?: boolean;
|
|
85
|
+
websocketUrl?: string;
|
|
86
|
+
baseUrl?: string;
|
|
87
|
+
token?: string;
|
|
88
|
+
/** Refresh token persisted by `openclaw channels login --channel openclaw-clawchat` (paired with `token`). */
|
|
89
|
+
refreshToken?: string;
|
|
90
|
+
userId?: string;
|
|
91
|
+
replyMode?: ReplyMode;
|
|
92
|
+
groupMode?: GroupMode;
|
|
93
|
+
forwardThinking?: boolean;
|
|
94
|
+
forwardToolCalls?: boolean;
|
|
95
|
+
stream?: OpenclawClawlingStreamConfig;
|
|
96
|
+
reconnect?: OpenclawClawlingReconnectConfig;
|
|
97
|
+
heartbeat?: OpenclawClawlingHeartbeatConfig;
|
|
98
|
+
ack?: OpenclawClawlingAckConfig;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const openclawClawlingConfigSchema = {
|
|
102
|
+
type: "object",
|
|
103
|
+
additionalProperties: false,
|
|
104
|
+
properties: {
|
|
105
|
+
enabled: { type: "boolean" },
|
|
106
|
+
websocketUrl: { type: "string" },
|
|
107
|
+
baseUrl: { type: "string" },
|
|
108
|
+
token: { type: "string" },
|
|
109
|
+
refreshToken: { type: "string" },
|
|
110
|
+
userId: { type: "string" },
|
|
111
|
+
replyMode: { type: "string", enum: ["static", "stream"] },
|
|
112
|
+
groupMode: { type: "string", enum: ["mention", "all"] },
|
|
113
|
+
forwardThinking: { type: "boolean" },
|
|
114
|
+
forwardToolCalls: { type: "boolean" },
|
|
115
|
+
stream: {
|
|
116
|
+
type: "object",
|
|
117
|
+
additionalProperties: false,
|
|
118
|
+
properties: {
|
|
119
|
+
flushIntervalMs: { type: "integer", minimum: 10 },
|
|
120
|
+
minChunkChars: { type: "integer", minimum: 1 },
|
|
121
|
+
maxBufferChars: { type: "integer", minimum: 1 },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
reconnect: {
|
|
125
|
+
type: "object",
|
|
126
|
+
additionalProperties: false,
|
|
127
|
+
properties: {
|
|
128
|
+
initialDelay: { type: "integer", minimum: 100 },
|
|
129
|
+
maxDelay: { type: "integer", minimum: 100 },
|
|
130
|
+
jitterRatio: { type: "number", minimum: 0 },
|
|
131
|
+
maxRetries: { type: "integer", minimum: 0 },
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
heartbeat: {
|
|
135
|
+
type: "object",
|
|
136
|
+
additionalProperties: false,
|
|
137
|
+
properties: {
|
|
138
|
+
interval: { type: "integer", minimum: 1000 },
|
|
139
|
+
timeout: { type: "integer", minimum: 1000 },
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
ack: {
|
|
143
|
+
type: "object",
|
|
144
|
+
additionalProperties: false,
|
|
145
|
+
properties: {
|
|
146
|
+
timeout: { type: "integer", minimum: 100 },
|
|
147
|
+
autoResendOnTimeout: { type: "boolean" },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
} as const;
|
|
152
|
+
|
|
153
|
+
export type ResolvedOpenclawClawlingAccount = {
|
|
154
|
+
accountId: string;
|
|
155
|
+
name: string;
|
|
156
|
+
enabled: boolean;
|
|
157
|
+
configured: boolean;
|
|
158
|
+
websocketUrl: string;
|
|
159
|
+
baseUrl: string;
|
|
160
|
+
token: string;
|
|
161
|
+
userId: string;
|
|
162
|
+
replyMode: ReplyMode;
|
|
163
|
+
groupMode: GroupMode;
|
|
164
|
+
forwardThinking: boolean;
|
|
165
|
+
forwardToolCalls: boolean;
|
|
166
|
+
allowFrom: string[];
|
|
167
|
+
stream: Required<OpenclawClawlingStreamConfig>;
|
|
168
|
+
reconnect: Required<OpenclawClawlingReconnectConfig>;
|
|
169
|
+
heartbeat: Required<OpenclawClawlingHeartbeatConfig>;
|
|
170
|
+
ack: Required<OpenclawClawlingAckConfig>;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
function readChannelSection(cfg: OpenClawConfig): Record<string, unknown> {
|
|
174
|
+
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
|
175
|
+
const channel = channels[CHANNEL_ID];
|
|
176
|
+
return channel && typeof channel === "object" ? (channel as Record<string, unknown>) : {};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function readOptionalString(value: unknown): string {
|
|
180
|
+
return typeof value === "string" ? value.trim() : "";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function readReplyMode(value: unknown): ReplyMode {
|
|
184
|
+
return value === "stream" ? "stream" : "static";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function readGroupMode(value: unknown): GroupMode {
|
|
188
|
+
return value === "all" ? "all" : "mention";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function readStream(raw: unknown): Required<OpenclawClawlingStreamConfig> {
|
|
192
|
+
const s = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
|
193
|
+
return {
|
|
194
|
+
flushIntervalMs:
|
|
195
|
+
typeof s.flushIntervalMs === "number" ? s.flushIntervalMs : DEFAULT_STREAM.flushIntervalMs,
|
|
196
|
+
minChunkChars:
|
|
197
|
+
typeof s.minChunkChars === "number" ? s.minChunkChars : DEFAULT_STREAM.minChunkChars,
|
|
198
|
+
maxBufferChars:
|
|
199
|
+
typeof s.maxBufferChars === "number" ? s.maxBufferChars : DEFAULT_STREAM.maxBufferChars,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function readReconnect(raw: unknown): Required<OpenclawClawlingReconnectConfig> {
|
|
204
|
+
const s = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
|
205
|
+
return {
|
|
206
|
+
initialDelay:
|
|
207
|
+
typeof s.initialDelay === "number" ? s.initialDelay : DEFAULT_RECONNECT.initialDelay,
|
|
208
|
+
maxDelay: typeof s.maxDelay === "number" ? s.maxDelay : DEFAULT_RECONNECT.maxDelay,
|
|
209
|
+
jitterRatio: typeof s.jitterRatio === "number" ? s.jitterRatio : DEFAULT_RECONNECT.jitterRatio,
|
|
210
|
+
maxRetries:
|
|
211
|
+
typeof s.maxRetries === "number" && Number.isFinite(s.maxRetries)
|
|
212
|
+
? s.maxRetries
|
|
213
|
+
: DEFAULT_RECONNECT.maxRetries,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readHeartbeat(raw: unknown): Required<OpenclawClawlingHeartbeatConfig> {
|
|
218
|
+
const s = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
|
219
|
+
return {
|
|
220
|
+
interval: typeof s.interval === "number" ? s.interval : DEFAULT_HEARTBEAT.interval,
|
|
221
|
+
timeout: typeof s.timeout === "number" ? s.timeout : DEFAULT_HEARTBEAT.timeout,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function readAck(raw: unknown): Required<OpenclawClawlingAckConfig> {
|
|
226
|
+
const s = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
|
227
|
+
return {
|
|
228
|
+
timeout: typeof s.timeout === "number" ? s.timeout : DEFAULT_ACK.timeout,
|
|
229
|
+
autoResendOnTimeout:
|
|
230
|
+
typeof s.autoResendOnTimeout === "boolean"
|
|
231
|
+
? s.autoResendOnTimeout
|
|
232
|
+
: DEFAULT_ACK.autoResendOnTimeout,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function resolveOpenclawClawlingAccount(
|
|
237
|
+
cfg: OpenClawConfig,
|
|
238
|
+
): ResolvedOpenclawClawlingAccount {
|
|
239
|
+
const channel = readChannelSection(cfg);
|
|
240
|
+
// Apply built-in defaults so login/gateway work without prior setup.
|
|
241
|
+
const websocketUrl = readOptionalString(channel.websocketUrl) || DEFAULT_WEBSOCKET_URL;
|
|
242
|
+
const baseUrl = readOptionalString(channel.baseUrl) || DEFAULT_BASE_URL;
|
|
243
|
+
const token = readOptionalString(channel.token);
|
|
244
|
+
const userId = readOptionalString(channel.userId);
|
|
245
|
+
const enabled = typeof channel.enabled === "boolean" ? channel.enabled : true;
|
|
246
|
+
const replyMode = readReplyMode(channel.replyMode);
|
|
247
|
+
const groupMode = readGroupMode(channel.groupMode);
|
|
248
|
+
const forwardThinking =
|
|
249
|
+
typeof channel.forwardThinking === "boolean" ? channel.forwardThinking : true;
|
|
250
|
+
const forwardToolCalls =
|
|
251
|
+
typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
255
|
+
name: CHANNEL_ID,
|
|
256
|
+
enabled,
|
|
257
|
+
configured: Boolean(websocketUrl && token && userId),
|
|
258
|
+
websocketUrl,
|
|
259
|
+
baseUrl,
|
|
260
|
+
token,
|
|
261
|
+
userId,
|
|
262
|
+
replyMode,
|
|
263
|
+
groupMode,
|
|
264
|
+
forwardThinking,
|
|
265
|
+
forwardToolCalls,
|
|
266
|
+
allowFrom: [],
|
|
267
|
+
stream: readStream(channel.stream),
|
|
268
|
+
reconnect: readReconnect(channel.reconnect),
|
|
269
|
+
heartbeat: readHeartbeat(channel.heartbeat),
|
|
270
|
+
ack: readAck(channel.ack),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function listOpenclawClawlingAccountIds(): string[] {
|
|
275
|
+
return [DEFAULT_ACCOUNT_ID];
|
|
276
|
+
}
|
|
277
|
+
|