@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.
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { openclawClawlingPlugin } from "./channel.ts";
3
+
4
+ describe("openclaw-clawchat plugin", () => {
5
+ it("publishes openclaw-clawchat channel metadata", () => {
6
+ expect(openclawClawlingPlugin.meta.id).toBe("openclaw-clawchat");
7
+ expect(openclawClawlingPlugin.meta.label).toBe("Clawling Chat");
8
+ expect(openclawClawlingPlugin.capabilities.blockStreaming).toBe(true);
9
+ });
10
+
11
+ it("reloads on channel config changes", () => {
12
+ expect(openclawClawlingPlugin.reload?.configPrefixes).toContain("channels.openclaw-clawchat");
13
+ });
14
+
15
+ it("declares media capability", () => {
16
+ expect(openclawClawlingPlugin.capabilities.media).toBe(true);
17
+ });
18
+
19
+ it("setup.validateInput requires --code", () => {
20
+ const validate = openclawClawlingPlugin.setup?.validateInput as
21
+ | ((args: { cfg: unknown; accountId: string; input: Record<string, unknown> }) => string | null)
22
+ | undefined;
23
+ expect(validate).toBeDefined();
24
+ expect(validate!({ cfg: {}, accountId: "default", input: {} })).toMatch(
25
+ /--code \(invite code/,
26
+ );
27
+ expect(
28
+ validate!({ cfg: {}, accountId: "default", input: { code: " " } }),
29
+ ).toMatch(/--code \(invite code/);
30
+ });
31
+
32
+ it("setup.validateInput passes when --code is present", () => {
33
+ const validate = openclawClawlingPlugin.setup?.validateInput as (args: {
34
+ cfg: unknown;
35
+ accountId: string;
36
+ input: Record<string, unknown>;
37
+ }) => string | null;
38
+ expect(
39
+ validate({ cfg: {}, accountId: "default", input: { code: "INV-XXXX" } }),
40
+ ).toBeNull();
41
+ });
42
+
43
+ it("setup.applyAccountConfig marks the channel enabled without touching credentials", () => {
44
+ const apply = openclawClawlingPlugin.setup?.applyAccountConfig as (args: {
45
+ cfg: unknown;
46
+ accountId: string;
47
+ input: Record<string, unknown>;
48
+ }) => Record<string, unknown>;
49
+ const next = apply({
50
+ cfg: {},
51
+ accountId: "default",
52
+ input: { code: "INV-XXXX" },
53
+ }) as { channels: Record<string, Record<string, unknown>> };
54
+ const section = next.channels["openclaw-clawchat"]!;
55
+ expect(section.enabled).toBe(true);
56
+ // Credentials are untouched — they arrive via runOpenclawClawlingLogin
57
+ // in the afterAccountConfigWritten hook.
58
+ expect(section.token).toBeUndefined();
59
+ expect(section.userId).toBeUndefined();
60
+ expect(section.websocketUrl).toBeUndefined();
61
+ expect(section.baseUrl).toBeUndefined();
62
+ });
63
+
64
+ it("publishes clawchat-specific agentPrompt hints", () => {
65
+ const hints = openclawClawlingPlugin.agentPrompt?.messageToolHints?.({
66
+ cfg: {} as never,
67
+ accountId: "default",
68
+ });
69
+ expect(Array.isArray(hints) && hints.length).toBeGreaterThan(0);
70
+ expect(hints!.some((h) => /ClawChat/.test(h))).toBe(true);
71
+ });
72
+ });
package/src/channel.ts ADDED
@@ -0,0 +1,278 @@
1
+ import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
2
+ import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup";
3
+ import {
4
+ createChatChannelPlugin,
5
+ type ChannelPlugin,
6
+ type OpenClawConfig,
7
+ } from "openclaw/plugin-sdk/core";
8
+ import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
9
+ import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
10
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
11
+ import {
12
+ createComputedAccountStatusAdapter,
13
+ createDefaultChannelRuntimeState,
14
+ } from "openclaw/plugin-sdk/status-helpers";
15
+ import {
16
+ CHANNEL_ID,
17
+ listOpenclawClawlingAccountIds,
18
+ openclawClawlingConfigSchema,
19
+ resolveOpenclawClawlingAccount,
20
+ type ResolvedOpenclawClawlingAccount,
21
+ } from "./config.ts";
22
+ import type { ChatType } from "./client.ts";
23
+ import { sendOpenclawClawlingText } from "./outbound.ts";
24
+ import { startOpenclawClawlingGateway, getOpenclawClawlingClient } from "./runtime.ts";
25
+
26
+ /**
27
+ * Parse an agent-initiated outbound recipient string into the new-protocol
28
+ * `chat_id` + `chat_type` pair.
29
+ *
30
+ * Accepted forms (case-insensitive prefix):
31
+ * - `cc:{chat_id}` → direct
32
+ * - `clawchat:{chat_id}` → direct
33
+ * - `cc:direct:{chat_id}` → direct
34
+ * - `cc:group:{chat_id}` → group
35
+ * - `clawchat:direct:{chat_id}` → direct
36
+ * - `clawchat:group:{chat_id}` → group
37
+ * - bare `{chat_id}` → direct (backward compat)
38
+ */
39
+ export function parseOpenclawRecipient(to: string): { chatId: string; chatType: ChatType } {
40
+ const raw = (to ?? "").trim();
41
+ if (!raw) throw new Error("openclaw-clawchat: outbound `to` is empty");
42
+
43
+ // Split on the first ":" to detect the scheme. If no scheme, treat as a
44
+ // bare chat_id defaulting to direct.
45
+ const firstColon = raw.indexOf(":");
46
+ if (firstColon < 0) return { chatId: raw, chatType: "direct" };
47
+
48
+ const scheme = raw.slice(0, firstColon).toLowerCase();
49
+ const rest = raw.slice(firstColon + 1);
50
+ if (scheme !== "cc" && scheme !== "clawchat") {
51
+ // Unknown scheme — treat the whole string as the chat_id for robustness.
52
+ return { chatId: raw, chatType: "direct" };
53
+ }
54
+
55
+ // After the scheme we optionally accept `direct:` / `group:` type prefix.
56
+ const secondColon = rest.indexOf(":");
57
+ if (secondColon >= 0) {
58
+ const typeToken = rest.slice(0, secondColon).toLowerCase();
59
+ const chatId = rest.slice(secondColon + 1).trim();
60
+ if ((typeToken === "direct" || typeToken === "group") && chatId) {
61
+ return { chatId, chatType: typeToken };
62
+ }
63
+ }
64
+ // No explicit type token → default to direct, chat_id is `rest`.
65
+ const chatId = rest.trim();
66
+ if (!chatId) throw new Error(`openclaw-clawchat: missing chat_id in \"${to}\"`);
67
+ return { chatId, chatType: "direct" };
68
+ }
69
+
70
+ const configAdapter = createTopLevelChannelConfigAdapter<ResolvedOpenclawClawlingAccount>({
71
+ sectionKey: CHANNEL_ID,
72
+ resolveAccount: (cfg) => resolveOpenclawClawlingAccount(cfg),
73
+ listAccountIds: () => listOpenclawClawlingAccountIds(),
74
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
75
+ deleteMode: "clear-fields",
76
+ clearBaseFields: [
77
+ "websocketUrl",
78
+ "baseUrl",
79
+ "token",
80
+ "userId",
81
+ "replyMode",
82
+ "forwardThinking",
83
+ "forwardToolCalls",
84
+ "enabled",
85
+ ],
86
+ resolveAllowFrom: (account) => account.allowFrom,
87
+ formatAllowFrom: () => [],
88
+ });
89
+
90
+ /**
91
+ * `openclaw channels setup --channel openclaw-clawchat` adapter.
92
+ *
93
+ * Setup takes exactly ONE input: `code` (an invite code). URL + token +
94
+ * userId come from the login flow which is triggered automatically in
95
+ * `afterAccountConfigWritten`:
96
+ *
97
+ * openclaw channels setup --channel openclaw-clawchat --code INV-XXXX
98
+ *
99
+ * `applyAccountConfig` itself only marks the section `enabled: true`;
100
+ * credentials are written by `runOpenclawClawlingLogin` which calls `writeConfigFile`
101
+ * on its own after the `/v1/agents/connect` response lands.
102
+ */
103
+ const setupAdapter = {
104
+ resolveAccountId: () => DEFAULT_ACCOUNT_ID,
105
+ validateInput: ({ input }: { cfg: unknown; accountId: string; input: ChannelSetupInput }) => {
106
+ if (!input.code?.trim()) {
107
+ return "Clawling Chat setup requires --code (invite code from your admin).";
108
+ }
109
+ return null;
110
+ },
111
+ applyAccountConfig: ({
112
+ cfg,
113
+ }: {
114
+ cfg: OpenClawConfig;
115
+ accountId: string;
116
+ input: ChannelSetupInput;
117
+ }): OpenClawConfig => {
118
+ // Base config: just enable the channel. Credentials arrive via
119
+ // `afterAccountConfigWritten` → `runOpenclawClawlingLogin`.
120
+ const channels = (cfg.channels ?? {}) as Record<string, unknown>;
121
+ const current = (channels[CHANNEL_ID] ?? {}) as Record<string, unknown>;
122
+ return {
123
+ ...cfg,
124
+ channels: {
125
+ ...channels,
126
+ [CHANNEL_ID]: { ...current, enabled: true },
127
+ },
128
+ };
129
+ },
130
+ afterAccountConfigWritten: async ({
131
+ cfg,
132
+ input,
133
+ runtime,
134
+ }: {
135
+ cfg: OpenClawConfig;
136
+ accountId: string;
137
+ input: ChannelSetupInput;
138
+ runtime: { log: (message: string) => void };
139
+ previousCfg: OpenClawConfig;
140
+ }) => {
141
+ const code = input.code?.trim();
142
+ if (!code) return;
143
+ // Lazy-import the login runtime to keep @clack/prompts / readline /
144
+ // config-runtime off the plugin's cold-start path. `readInviteCode`
145
+ // feeds the fixed code so the stdin prompt is skipped entirely.
146
+ const { runOpenclawClawlingLogin } = await import("./login.runtime.ts");
147
+ await runOpenclawClawlingLogin({
148
+ cfg,
149
+ accountId: null,
150
+ runtime: { log: (message: string) => runtime.log(message) },
151
+ readInviteCode: async () => code,
152
+ });
153
+ },
154
+ };
155
+
156
+ export const openclawClawlingPlugin: ChannelPlugin<ResolvedOpenclawClawlingAccount> =
157
+ createChatChannelPlugin({
158
+ base: {
159
+ id: CHANNEL_ID,
160
+ meta: {
161
+ id: CHANNEL_ID,
162
+ label: "Clawling Chat",
163
+ selectionLabel: "Clawling Chat",
164
+ docsPath: "/channels/openclaw-clawchat",
165
+ docsLabel: "openclaw-clawchat",
166
+ blurb: "Clawling Protocol v2 over WebSocket (chat-sdk).",
167
+ order: 110,
168
+ },
169
+ capabilities: {
170
+ chatTypes: ["direct", "group"],
171
+ media: true,
172
+ reactions: false,
173
+ threads: false,
174
+ polls: false,
175
+ blockStreaming: true,
176
+ },
177
+ reload: {
178
+ configPrefixes: [`channels.${CHANNEL_ID}`],
179
+ },
180
+ configSchema: {
181
+ schema: openclawClawlingConfigSchema,
182
+ },
183
+ config: {
184
+ ...configAdapter,
185
+ isConfigured: (account) => account.configured,
186
+ describeAccount: (account) => ({
187
+ accountId: account.accountId,
188
+ name: account.name,
189
+ enabled: account.enabled,
190
+ configured: account.configured,
191
+ }),
192
+ },
193
+ directory: createEmptyChannelDirectoryAdapter(),
194
+ setup: setupAdapter,
195
+ status: createComputedAccountStatusAdapter<ResolvedOpenclawClawlingAccount>({
196
+ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, {
197
+ connected: false,
198
+ lastInboundAt: null,
199
+ lastOutboundAt: null,
200
+ }),
201
+ resolveAccountSnapshot: ({ account }) => ({
202
+ accountId: account.accountId,
203
+ name: account.name,
204
+ enabled: account.enabled,
205
+ configured: account.configured,
206
+ extra: {
207
+ websocketUrl: account.websocketUrl || null,
208
+ baseUrl: account.baseUrl || null,
209
+ userId: account.userId || null,
210
+ },
211
+ }),
212
+ }),
213
+ auth: {
214
+ login: async ({ cfg, accountId, runtime }) => {
215
+ // Lazy-load login.runtime: it pulls in @clack/prompts and other
216
+ // heavy modules that have no business loading on every plugin
217
+ // boot. Only the rare `openclaw channels login --channel
218
+ // openclaw-clawchat` invocation pays the import cost.
219
+ const { runOpenclawClawlingLogin } = await import("./login.runtime.ts");
220
+ await runOpenclawClawlingLogin({
221
+ cfg,
222
+ accountId: accountId ?? null,
223
+ runtime: { log: (message: string) => runtime.log(message) },
224
+ });
225
+ },
226
+ },
227
+ gateway: {
228
+ startAccount: async (ctx) => {
229
+ const account = ctx.account ?? resolveOpenclawClawlingAccount(ctx.cfg);
230
+ if (!account.configured) {
231
+ throw new Error("Clawling Chat websocketUrl/token/userId are required");
232
+ }
233
+ return await startOpenclawClawlingGateway({
234
+ cfg: ctx.cfg,
235
+ account,
236
+ abortSignal: ctx.abortSignal,
237
+ setStatus: ctx.setStatus,
238
+ getStatus: ctx.getStatus,
239
+ log: ctx.log,
240
+ });
241
+ },
242
+ },
243
+ agentPrompt: {
244
+ messageToolHints: () => [
245
+ "To send an image or file to the current user, use the message tool with action='send' and set 'media' to a local file path or a remote URL. You do not need to specify 'to' — the current conversation recipient is used automatically.",
246
+ "When the user asks you to find an image from the web, use a web search or browser tool to find a suitable image URL, then send it using the message tool with 'media' set to that HTTPS image URL — do NOT download the image first.",
247
+ "- ClawChat targeting: omit `target` to reply to the current chat (auto-inferred). To send to a specific chat, use `cc:{chat_id}` (direct, default) or `cc:group:{chat_id}` (group). `clawchat:` is accepted as a synonym of `cc:`.",
248
+ "- ClawChat supports media fragments (image / file / audio / video) alongside text in the same message.",
249
+ "- ClawChat stream mode emits `message.created` → progressive `message.add` deltas → `message.done`, followed by a consolidated `message.reply` with the merged text.",
250
+ ],
251
+ },
252
+ },
253
+ outbound: {
254
+ deliveryMode: "direct",
255
+ chunker: (text, limit) => chunkMarkdownText(text, limit),
256
+ chunkerMode: "markdown",
257
+ textChunkLimit: 4000,
258
+ sendText: async ({ cfg, to, text }) => {
259
+ const account = resolveOpenclawClawlingAccount(cfg);
260
+ const client = getOpenclawClawlingClient(account.accountId);
261
+ if (!client) {
262
+ throw new Error(`openclaw-clawchat client not running for account ${account.accountId}`);
263
+ }
264
+ const recipient = parseOpenclawRecipient(to);
265
+ const result = await sendOpenclawClawlingText({
266
+ client,
267
+ account,
268
+ to: recipient,
269
+ text,
270
+ });
271
+ return {
272
+ channel: CHANNEL_ID,
273
+ to,
274
+ messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
275
+ };
276
+ },
277
+ },
278
+ });
@@ -0,0 +1,174 @@
1
+ import { MockTransport } from "@newbase-clawchat/sdk";
2
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ createOpenclawClawlingClient,
6
+ emitStreamCreated,
7
+ emitStreamAdd,
8
+ emitStreamDone,
9
+ emitStreamFailed,
10
+ } from "./client.ts";
11
+ import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
12
+
13
+ function baseAccount(): ResolvedOpenclawClawlingAccount {
14
+ return {
15
+ accountId: DEFAULT_ACCOUNT_ID,
16
+ name: "openclaw-clawchat",
17
+ enabled: true,
18
+ configured: true,
19
+ websocketUrl: "ws://test",
20
+ baseUrl: "",
21
+ token: "t",
22
+ userId: "agent-1",
23
+ replyMode: "static",
24
+ forwardThinking: true,
25
+ forwardToolCalls: false,
26
+ allowFrom: [],
27
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
28
+ reconnect: {
29
+ initialDelay: 1000,
30
+ maxDelay: 30000,
31
+ jitterRatio: 0.3,
32
+ maxRetries: Number.POSITIVE_INFINITY,
33
+ },
34
+ heartbeat: { interval: 25000, timeout: 10000 },
35
+ ack: { timeout: 10000, autoResendOnTimeout: false },
36
+ };
37
+ }
38
+
39
+ async function authenticate(transport: MockTransport) {
40
+ // Yield to allow MockTransport.connect's internal Promise.resolve() to fire
41
+ // onOpen (which transitions state: connecting -> challenging) before we send
42
+ // the challenge envelope.
43
+ await Promise.resolve();
44
+ // Challenge from server
45
+ transport.emitInbound(
46
+ JSON.stringify({
47
+ version: "2",
48
+ event: "connect.challenge",
49
+ trace_id: "t-challenge",
50
+ emitted_at: Date.now(),
51
+ payload: { nonce: "nonce-1" },
52
+ }),
53
+ );
54
+ // hello-ok
55
+ transport.emitInbound(
56
+ JSON.stringify({
57
+ version: "2",
58
+ event: "hello-ok",
59
+ trace_id: "t-hello",
60
+ emitted_at: Date.now(),
61
+ payload: {},
62
+ }),
63
+ );
64
+ }
65
+
66
+ describe("openclaw-clawchat client", () => {
67
+ it("connects via MockTransport and completes hello handshake", async () => {
68
+ const transport = new MockTransport();
69
+ const client = createOpenclawClawlingClient(baseAccount(), { transport });
70
+ const p = client.connect();
71
+ await authenticate(transport);
72
+ await p;
73
+ expect(client.state).toBe("connected");
74
+ client.close();
75
+ });
76
+
77
+ it("emitStreamCreated emits a minimal message.created envelope with just message_id", async () => {
78
+ const transport = new MockTransport();
79
+ const client = createOpenclawClawlingClient(baseAccount(), { transport });
80
+ const p = client.connect();
81
+ await authenticate(transport);
82
+ await p;
83
+ transport.sent.length = 0;
84
+
85
+ emitStreamCreated(client, {
86
+ messageId: "msg-1",
87
+ to: { id: "user-1", type: "direct" },
88
+ sender: { sender_id: "agent-1", type: "direct", display_name: "Clawling Assistant" },
89
+ });
90
+
91
+ expect(transport.sent).toHaveLength(1);
92
+ const env = JSON.parse(transport.sent[0]!);
93
+ expect(env.event).toBe("message.created");
94
+ expect(env.to).toEqual({ id: "user-1", type: "direct" });
95
+ // Payload is intentionally minimal: just message_id, no message body /
96
+ // context / sender / streaming metadata.
97
+ expect(env.payload).toEqual({ message_id: "msg-1" });
98
+ client.close();
99
+ });
100
+
101
+ it("emitStreamAdd emits message.add with fragments: [{ text: full, delta: new }]", async () => {
102
+ const transport = new MockTransport();
103
+ const client = createOpenclawClawlingClient(baseAccount(), { transport });
104
+ const p = client.connect();
105
+ await authenticate(transport);
106
+ await p;
107
+ transport.sent.length = 0;
108
+
109
+ emitStreamAdd(client, {
110
+ messageId: "msg-1",
111
+ to: { id: "user-1", type: "direct" },
112
+ sequence: 3,
113
+ fullText: "Hello, wor",
114
+ textDelta: "wor",
115
+ });
116
+
117
+ const env = JSON.parse(transport.sent[0]!);
118
+ expect(env.event).toBe("message.add");
119
+ expect(env.payload.sequence).toBe(3);
120
+ expect(env.payload.fragments).toEqual([
121
+ { kind: "text", text: "Hello, wor", delta: "wor" },
122
+ ]);
123
+ expect(env.payload.mutation).toEqual({ type: "append", target_fragment_index: null });
124
+ client.close();
125
+ });
126
+
127
+ it("emitStreamDone marks streaming.status = done with completed_at", async () => {
128
+ const transport = new MockTransport();
129
+ const client = createOpenclawClawlingClient(baseAccount(), { transport });
130
+ const p = client.connect();
131
+ await authenticate(transport);
132
+ await p;
133
+ transport.sent.length = 0;
134
+
135
+ emitStreamDone(client, {
136
+ messageId: "msg-1",
137
+ to: { id: "user-1", type: "direct" },
138
+ finalSequence: 3,
139
+ finalText: "Hello",
140
+ });
141
+
142
+ const env = JSON.parse(transport.sent[0]!);
143
+ expect(env.event).toBe("message.done");
144
+ expect(env.payload.message_id).toBe("msg-1");
145
+ expect(env.payload.fragments).toEqual([{ kind: "text", text: "Hello" }]);
146
+ expect(env.payload.streaming.status).toBe("done");
147
+ expect(env.payload.streaming.sequence).toBe(3);
148
+ expect(typeof env.payload.completed_at).toBe("number");
149
+ client.close();
150
+ });
151
+
152
+ it("emitStreamFailed emits message.failed with reason", async () => {
153
+ const transport = new MockTransport();
154
+ const client = createOpenclawClawlingClient(baseAccount(), { transport });
155
+ const p = client.connect();
156
+ await authenticate(transport);
157
+ await p;
158
+ transport.sent.length = 0;
159
+
160
+ emitStreamFailed(client, {
161
+ messageId: "msg-1",
162
+ to: { id: "user-1", type: "direct" },
163
+ sequence: 4,
164
+ reason: "upstream_error",
165
+ });
166
+
167
+ const env = JSON.parse(transport.sent[0]!);
168
+ expect(env.event).toBe("message.failed");
169
+ expect(env.payload.message_id).toBe("msg-1");
170
+ expect(env.payload.reason).toBe("upstream_error");
171
+ expect(env.payload.streaming.status).toBe("failed");
172
+ client.close();
173
+ });
174
+ });