@newbase-clawchat/openclaw-clawchat 2026.4.23 → 2026.4.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 = { sendMessage: vi.fn() };
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(sendTextMock).toHaveBeenCalledWith({
68
- client,
69
- account: expect.objectContaining({ userId: "agent-1" }),
70
- to: { chatId: "user-1", chatType: "direct" },
71
- text: "hello",
72
- });
62
+ expect(client.sendMessage).toHaveBeenCalledWith(
63
+ expect.objectContaining({
64
+ chat_id: "user-1",
65
+ chat_type: "direct",
66
+ body: { fragments: [{ kind: "text", text: "hello" }] },
67
+ }),
68
+ );
73
69
  expect(result).toEqual({
74
70
  channel: "openclaw-clawchat",
75
71
  to: "cc:user-1",
@@ -78,7 +74,12 @@ describe("openclaw-clawchat channel outbound", () => {
78
74
  });
79
75
 
80
76
  it("sendMedia uploads mediaUrl and sends resulting fragments", async () => {
81
- const client = { sendMessage: vi.fn() };
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(sendMediaMock).toHaveBeenCalledWith({
122
- client,
123
- account: expect.objectContaining({ userId: "agent-1", baseUrl: "https://api.example.com" }),
124
- to: { chatId: "room-1", chatType: "group" },
125
- text: "caption",
126
- mediaFragments: [{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" }],
127
- });
121
+ expect(client.sendMessage).toHaveBeenCalledWith(
122
+ expect.objectContaining({
123
+ chat_id: "room-1",
124
+ chat_type: "group",
125
+ body: {
126
+ fragments: [
127
+ { kind: "text", text: "caption" },
128
+ { kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
129
+ ],
130
+ },
131
+ }),
132
+ );
128
133
  expect(result).toEqual({
129
134
  channel: "openclaw-clawchat",
130
135
  to: "cc:group:room-1",
@@ -155,7 +160,12 @@ describe("openclaw-clawchat channel outbound", () => {
155
160
  });
156
161
 
157
162
  it("sendMedia waits for client activation when no active client exists yet", async () => {
158
- const client = { sendMessage: vi.fn() };
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(sendMediaMock).toHaveBeenCalledWith({
191
- client,
192
- account: expect.objectContaining({ userId: "agent-1", baseUrl: "https://api.example.com" }),
193
- to: { chatId: "room-1", chatType: "group" },
194
- text: "caption",
195
- mediaFragments: [{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" }],
196
- });
199
+ expect(client.sendMessage).toHaveBeenCalledWith(
200
+ expect.objectContaining({
201
+ chat_id: "room-1",
202
+ chat_type: "group",
203
+ body: {
204
+ fragments: [
205
+ { kind: "text", text: "caption" },
206
+ { kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
207
+ ],
208
+ },
209
+ }),
210
+ );
197
211
  expect(result).toEqual({
198
212
  channel: "openclaw-clawchat",
199
213
  to: "cc:group:room-1",
@@ -17,20 +17,20 @@ describe("openclaw-clawchat plugin", () => {
17
17
  expect(openclawClawlingPlugin.capabilities.media).toBe(true);
18
18
  });
19
19
 
20
- it("setup.validateInput requires --code", () => {
20
+ it("setup.validateInput requires an invite code", () => {
21
21
  const validate = openclawClawlingPlugin.setup?.validateInput as
22
22
  | ((args: { cfg: unknown; accountId: string; input: Record<string, unknown> }) => string | null)
23
23
  | undefined;
24
24
  expect(validate).toBeDefined();
25
25
  expect(validate!({ cfg: {}, accountId: "default", input: {} })).toMatch(
26
- /--code \(invite code/,
26
+ /invite code is required/i,
27
27
  );
28
28
  expect(
29
29
  validate!({ cfg: {}, accountId: "default", input: { code: " " } }),
30
- ).toMatch(/--code \(invite code/);
30
+ ).toMatch(/invite code is required/i);
31
31
  });
32
32
 
33
- it("setup.validateInput passes when --code is present", () => {
33
+ it("setup.validateInput passes when code is present", () => {
34
34
  const validate = openclawClawlingPlugin.setup?.validateInput as (args: {
35
35
  cfg: unknown;
36
36
  accountId: string;
@@ -62,17 +62,52 @@ describe("openclaw-clawchat plugin", () => {
62
62
  expect(section.baseUrl).toBeUndefined();
63
63
  });
64
64
 
65
+ it("setup.applyAccountConfig allows openclaw-clawchat plugin tools without replacing policy", () => {
66
+ const apply = openclawClawlingPlugin.setup?.applyAccountConfig as (args: {
67
+ cfg: unknown;
68
+ accountId: string;
69
+ input: Record<string, unknown>;
70
+ }) => Record<string, unknown>;
71
+ const next = apply({
72
+ cfg: {
73
+ tools: {
74
+ profile: "coding",
75
+ allow: [],
76
+ deny: ["exec"],
77
+ alsoAllow: ["browser"],
78
+ },
79
+ },
80
+ accountId: "default",
81
+ input: { code: "INV-XXXX" },
82
+ }) as { tools: Record<string, unknown> };
83
+
84
+ expect(next.tools.profile).toBe("coding");
85
+ expect(next.tools.allow).toEqual([]);
86
+ expect(next.tools.deny).toEqual(["exec"]);
87
+ expect(next.tools.alsoAllow).toEqual(["browser", "openclaw-clawchat"]);
88
+ });
89
+
65
90
  it("publishes clawchat-specific agentPrompt hints", () => {
66
91
  const hints = openclawClawlingPlugin.agentPrompt?.messageToolHints?.({
67
92
  cfg: {} as never,
68
93
  accountId: "default",
69
94
  });
70
- expect(Array.isArray(hints) && hints.length).toBeGreaterThan(0);
71
- expect(hints!.some((h) => /ClawChat/.test(h))).toBe(true);
72
- expect(hints!.some((h) => /update.*profile|profile.*bio/i.test(h))).toBe(true);
73
- expect(hints!.some((h) => /update.*avatar|profile picture|clawchat_upload_avatar/i.test(h))).toBe(
74
- true,
75
- );
95
+ expect(hints).toEqual([
96
+ "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.",
97
+ "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.",
98
+ "For configured ClawChat account profile, user profile, friends, avatar, or standalone media upload/share-link workflows, use `clawchat-account-tools` for tool-selection details.",
99
+ "For ClawChat account avatar changes using a local image, call `clawchat_upload_avatar_image` first, then `clawchat_update_account_profile` with `avatar_url`.",
100
+ "- 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.",
101
+ "- ClawChat supports image / file / audio / video media alongside text.",
102
+ ]);
103
+ const joined = hints!.join("\n");
104
+ expect(joined).not.toMatch(/clawchat_get_account_profile/);
105
+ expect(joined).not.toMatch(/clawchat_get_user_profile/);
106
+ expect(joined).not.toMatch(/clawchat_list_account_friends/);
107
+ expect(joined).not.toMatch(/clawchat_upload_media_file/);
108
+ expect(joined).not.toMatch(/stream mode/i);
109
+ expect(joined).not.toMatch(/clawchat:/);
110
+ expect(joined).not.toMatch(/specify 'to'/);
76
111
  });
77
112
 
78
113
  it("normalizes openclaw-clawchat targets for host resolution", () => {
package/src/channel.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  import {
15
15
  CHANNEL_ID,
16
16
  listOpenclawClawlingAccountIds,
17
+ mergeOpenclawClawchatToolAllow,
17
18
  openclawClawlingConfigSchema,
18
19
  resolveOpenclawClawlingAccount,
19
20
  type ResolvedOpenclawClawlingAccount,
@@ -42,13 +43,14 @@ const configAdapter = createTopLevelChannelConfigAdapter<ResolvedOpenclawClawlin
42
43
  });
43
44
 
44
45
  /**
45
- * `openclaw channels setup --channel openclaw-clawchat` adapter.
46
+ * Invite-code setup adapter used by OpenClaw setup surfaces that already have
47
+ * a concrete plugin instance. This plugin does not advertise catalog-driven
48
+ * one-shot setup metadata because current hosts do not discover channels from
49
+ * `plugins.load.paths`.
46
50
  *
47
51
  * Setup takes exactly ONE input: `code` (an invite code). URL + token +
48
52
  * userId come from the login flow which is triggered automatically in
49
- * `afterAccountConfigWritten`:
50
- *
51
- * openclaw channels setup --channel openclaw-clawchat --code INV-XXXX
53
+ * `afterAccountConfigWritten`.
52
54
  *
53
55
  * `applyAccountConfig` itself only marks the section `enabled: true`;
54
56
  * credentials are written by `runOpenclawClawlingLogin` which calls `writeConfigFile`
@@ -58,7 +60,7 @@ const setupAdapter = {
58
60
  resolveAccountId: () => DEFAULT_ACCOUNT_ID,
59
61
  validateInput: ({ input }: { cfg: unknown; accountId: string; input: ChannelSetupInput }) => {
60
62
  if (!input.code?.trim()) {
61
- return "Clawling Chat setup requires --code (invite code from your admin).";
63
+ return "ClawChat invite code is required.";
62
64
  }
63
65
  return null;
64
66
  },
@@ -73,13 +75,13 @@ const setupAdapter = {
73
75
  // `afterAccountConfigWritten` → `runOpenclawClawlingLogin`.
74
76
  const channels = (cfg.channels ?? {}) as Record<string, unknown>;
75
77
  const current = (channels[CHANNEL_ID] ?? {}) as Record<string, unknown>;
76
- return {
78
+ return mergeOpenclawClawchatToolAllow({
77
79
  ...cfg,
78
80
  channels: {
79
81
  ...channels,
80
82
  [CHANNEL_ID]: { ...current, enabled: true },
81
83
  },
82
- };
84
+ });
83
85
  },
84
86
  afterAccountConfigWritten: async ({
85
87
  cfg,
@@ -196,13 +198,12 @@ export const openclawClawlingPlugin: ChannelPlugin<ResolvedOpenclawClawlingAccou
196
198
  },
197
199
  agentPrompt: {
198
200
  messageToolHints: () => [
199
- "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.",
200
- "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.",
201
- "When the user asks you to update your profile, nickname, bio, self-introduction, or personal introduction, use `clawchat_update_my_profile` with the relevant fields (`nickname`, `bio`, and/or `avatar_url`).",
202
- "When the user asks you to change or update your avatar/profile picture using a local image, first use `clawchat_upload_avatar` to get the avatar URL, then call `clawchat_update_my_profile` with `avatar_url`.",
203
- "- 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:`.",
204
- "- ClawChat supports media fragments (image / file / audio / video) alongside text in the same message.",
205
- "- ClawChat stream mode emits `message.created` → progressive `message.add` deltas → `message.done`, followed by a consolidated `message.reply` with the merged text.",
201
+ "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.",
202
+ "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.",
203
+ "For configured ClawChat account profile, user profile, friends, avatar, or standalone media upload/share-link workflows, use `clawchat-account-tools` for tool-selection details.",
204
+ "For ClawChat account avatar changes using a local image, call `clawchat_upload_avatar_image` first, then `clawchat_update_account_profile` with `avatar_url`.",
205
+ "- 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.",
206
+ "- ClawChat supports image / file / audio / video media alongside text.",
206
207
  ],
207
208
  },
208
209
  messaging: {
@@ -91,7 +91,8 @@ describe("openclaw-clawchat client", () => {
91
91
  expect(transport.sent).toHaveLength(1);
92
92
  const env = JSON.parse(transport.sent[0]!);
93
93
  expect(env.event).toBe("message.created");
94
- expect(env.to).toEqual({ id: "user-1", type: "direct" });
94
+ expect(env.chat_id).toBe("user-1");
95
+ expect(env.chat_type).toBe("direct");
95
96
  // Payload is intentionally minimal: just message_id, no message body /
96
97
  // context / sender / streaming metadata.
97
98
  expect(env.payload).toEqual({ message_id: "msg-1" });
package/src/client.ts CHANGED
@@ -64,6 +64,17 @@ export interface EnvelopeRouting {
64
64
  chatType: ChatType;
65
65
  }
66
66
 
67
+ function normalizeRouting(params: {
68
+ routing?: EnvelopeRouting;
69
+ to?: { id?: string; type?: ChatType };
70
+ }): EnvelopeRouting {
71
+ if (params.routing) return params.routing;
72
+ if (params.to?.id) {
73
+ return { chatId: params.to.id, chatType: params.to.type ?? "direct" };
74
+ }
75
+ throw new Error("openclaw-clawchat streaming emit requires routing");
76
+ }
77
+
67
78
  /**
68
79
  * Emit a raw v2 envelope directly over the transport so we can carry
69
80
  * `chat_id` + `chat_type` at envelope root (the new protocol). The SDK's
@@ -78,11 +89,16 @@ function emitEnvelope(
78
89
  routing: EnvelopeRouting,
79
90
  ): void {
80
91
  const inner = client as unknown as {
81
- opts: {
92
+ opts?: {
82
93
  transport: { send: (data: string) => void };
83
94
  traceIdFactory: () => string;
84
95
  };
96
+ emitRaw?: (event: string, payload: object, routing?: { to?: { id: string; type: ChatType } }) => void;
85
97
  };
98
+ if (!inner.opts?.transport) {
99
+ inner.emitRaw?.(event, payload, { to: { id: routing.chatId, type: routing.chatType } });
100
+ return;
101
+ }
86
102
  const env = {
87
103
  version: "2" as const,
88
104
  event,
@@ -107,14 +123,16 @@ export function emitStreamCreated(
107
123
  client: ClawlingChatClient,
108
124
  params: {
109
125
  messageId: string;
110
- routing: EnvelopeRouting;
126
+ routing?: EnvelopeRouting;
127
+ to?: { id: string; type: ChatType };
111
128
  },
112
129
  ): void {
130
+ const routing = normalizeRouting(params);
113
131
  emitEnvelope(
114
132
  client,
115
133
  "message.created",
116
134
  { message_id: params.messageId },
117
- params.routing,
135
+ routing,
118
136
  );
119
137
  }
120
138
 
@@ -130,7 +148,8 @@ export function emitStreamAdd(
130
148
  client: ClawlingChatClient,
131
149
  params: {
132
150
  messageId: string;
133
- routing: EnvelopeRouting;
151
+ routing?: EnvelopeRouting;
152
+ to?: { id: string; type: ChatType };
134
153
  sequence: number;
135
154
  /** Running cumulative text after this delta is applied. */
136
155
  fullText: string;
@@ -139,6 +158,7 @@ export function emitStreamAdd(
139
158
  },
140
159
  ): void {
141
160
  const now = Date.now();
161
+ const routing = normalizeRouting(params);
142
162
  emitEnvelope(
143
163
  client,
144
164
  "message.add",
@@ -158,7 +178,7 @@ export function emitStreamAdd(
158
178
  },
159
179
  added_at: now,
160
180
  },
161
- params.routing,
181
+ routing,
162
182
  );
163
183
  }
164
184
 
@@ -171,12 +191,14 @@ export function emitStreamDone(
171
191
  client: ClawlingChatClient,
172
192
  params: {
173
193
  messageId: string;
174
- routing: EnvelopeRouting;
194
+ routing?: EnvelopeRouting;
195
+ to?: { id: string; type: ChatType };
175
196
  finalSequence: number;
176
197
  finalText: string;
177
198
  },
178
199
  ): void {
179
200
  const now = Date.now();
201
+ const routing = normalizeRouting(params);
180
202
  emitEnvelope(
181
203
  client,
182
204
  "message.done",
@@ -192,7 +214,7 @@ export function emitStreamDone(
192
214
  },
193
215
  completed_at: now,
194
216
  },
195
- params.routing,
217
+ routing,
196
218
  );
197
219
  }
198
220
 
@@ -211,7 +233,8 @@ export function emitFinalStreamReply(
211
233
  params: {
212
234
  /** The streaming message_id — must equal the id used on created/add/done. */
213
235
  messageId: string;
214
- routing: EnvelopeRouting;
236
+ routing?: EnvelopeRouting;
237
+ to?: { id: string; type: ChatType };
215
238
  /** The user message this stream is a reply to (usually the inbound turn). */
216
239
  replyTo: {
217
240
  msgId: string;
@@ -223,6 +246,7 @@ export function emitFinalStreamReply(
223
246
  mentions?: string[];
224
247
  },
225
248
  ): void {
249
+ const routing = normalizeRouting(params);
226
250
  emitEnvelope(
227
251
  client,
228
252
  "message.reply",
@@ -244,7 +268,7 @@ export function emitFinalStreamReply(
244
268
  },
245
269
  },
246
270
  },
247
- params.routing,
271
+ routing,
248
272
  );
249
273
  }
250
274
 
@@ -252,12 +276,14 @@ export function emitStreamFailed(
252
276
  client: ClawlingChatClient,
253
277
  params: {
254
278
  messageId: string;
255
- routing: EnvelopeRouting;
279
+ routing?: EnvelopeRouting;
280
+ to?: { id: string; type: ChatType };
256
281
  sequence: number;
257
282
  reason?: string;
258
283
  },
259
284
  ): void {
260
285
  const now = Date.now();
286
+ const routing = normalizeRouting(params);
261
287
  emitEnvelope(
262
288
  client,
263
289
  "message.failed",
@@ -274,6 +300,6 @@ export function emitStreamFailed(
274
300
  },
275
301
  failed_at: now,
276
302
  },
277
- params.routing,
303
+ routing,
278
304
  );
279
305
  }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { registerOpenclawClawlingCommands } from "./commands.ts";
3
+
4
+ const loginRuntime = vi.hoisted(() => ({
5
+ runOpenclawClawlingLogin: vi.fn(),
6
+ }));
7
+
8
+ vi.mock("./login.runtime.ts", () => loginRuntime);
9
+
10
+ describe("registerOpenclawClawlingCommands", () => {
11
+ it("registers a distinct slash login command that passes the invite code to login", async () => {
12
+ loginRuntime.runOpenclawClawlingLogin.mockResolvedValue(undefined);
13
+ const commands: Array<{ name: string; acceptsArgs?: boolean; handler: (ctx: unknown) => Promise<{ text: string }> }> = [];
14
+ const api = {
15
+ registerCommand: (command: (typeof commands)[number]) => commands.push(command),
16
+ } as never;
17
+
18
+ registerOpenclawClawlingCommands(api);
19
+
20
+ expect(commands.map((command) => command.name)).toEqual(["clawchat-login"]);
21
+ expect(commands[0]?.acceptsArgs).toBe(true);
22
+
23
+ const result = await commands[0]!.handler({
24
+ args: "A1B2C3",
25
+ config: { channels: { "openclaw-clawchat": { websocketUrl: "wss://w" } } },
26
+ });
27
+
28
+ expect(loginRuntime.runOpenclawClawlingLogin).toHaveBeenCalledTimes(1);
29
+ const params = loginRuntime.runOpenclawClawlingLogin.mock.calls[0]?.[0];
30
+ await expect(params.readInviteCode()).resolves.toBe("A1B2C3");
31
+ expect(result.text).toMatch(/activated successfully/i);
32
+ });
33
+ });
@@ -0,0 +1,37 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+
3
+ function extractInviteCode(value: unknown): string {
4
+ const raw = typeof value === "string" ? value.trim() : "";
5
+ return raw.match(/\b[A-Z0-9]{6}\b/u)?.[0] ?? "";
6
+ }
7
+
8
+ function errorMessage(err: unknown): string {
9
+ return err instanceof Error ? err.message : String(err);
10
+ }
11
+
12
+ export function registerOpenclawClawlingCommands(api: Pick<OpenClawPluginApi, "registerCommand" | "logger">): void {
13
+ api.registerCommand({
14
+ name: "clawchat-login",
15
+ description: "Activate ClawChat with an invite code, e.g. /clawchat-login A1B2C3.",
16
+ acceptsArgs: true,
17
+ requireAuth: true,
18
+ async handler(ctx) {
19
+ const code = extractInviteCode(ctx.args ?? ctx.commandBody);
20
+ if (!code) {
21
+ return { text: "ClawChat invite code is required. Usage: /clawchat-login A1B2C3" };
22
+ }
23
+ try {
24
+ const { runOpenclawClawlingLogin } = await import("./login.runtime.ts");
25
+ await runOpenclawClawlingLogin({
26
+ cfg: ctx.config,
27
+ accountId: ctx.accountId ?? null,
28
+ runtime: { log: (message: string) => api.logger?.info?.(message) },
29
+ readInviteCode: async () => code,
30
+ });
31
+ return { text: "✅ ClawChat activated successfully." };
32
+ } catch (err) {
33
+ return { text: `❌ ${errorMessage(err)}` };
34
+ }
35
+ },
36
+ });
37
+ }
@@ -2,7 +2,10 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  CHANNEL_ID,
5
+ DEFAULT_BASE_URL,
6
+ DEFAULT_WEBSOCKET_URL,
5
7
  DEFAULT_STREAM,
8
+ mergeOpenclawClawchatToolAllow,
6
9
  resolveOpenclawClawlingAccount,
7
10
  listOpenclawClawlingAccountIds,
8
11
  } from "./config.ts";
@@ -80,19 +83,21 @@ describe("openclaw-clawchat config", () => {
80
83
  });
81
84
 
82
85
  it("falls back to the built-in DEFAULT_BASE_URL when unset", async () => {
83
- const { DEFAULT_BASE_URL } = await import("./config.ts");
84
86
  const account = resolveOpenclawClawlingAccount({});
85
87
  expect(account.baseUrl).toBe(DEFAULT_BASE_URL);
86
88
  });
87
89
 
88
90
  it("falls back to the built-in DEFAULT_WEBSOCKET_URL when unset", async () => {
89
- const { DEFAULT_WEBSOCKET_URL } = await import("./config.ts");
90
91
  const account = resolveOpenclawClawlingAccount({});
91
92
  expect(account.websocketUrl).toBe(DEFAULT_WEBSOCKET_URL);
92
93
  });
93
94
 
95
+ it("uses the production ClawChat service as the built-in fallback endpoint", () => {
96
+ expect(DEFAULT_BASE_URL).toBe("http://company.newbaselab.com:10086");
97
+ expect(DEFAULT_WEBSOCKET_URL).toBe("ws://company.newbaselab.com:10086/ws");
98
+ });
99
+
94
100
  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
101
  const cfg = {
97
102
  channels: {
98
103
  "openclaw-clawchat": {
@@ -107,4 +112,33 @@ describe("openclaw-clawchat config", () => {
107
112
  expect(account.configured).toBe(true);
108
113
  expect(account.baseUrl).toBe(DEFAULT_BASE_URL);
109
114
  });
115
+
116
+ it("adds the plugin to tools.allow when an explicit allowlist is already in use", () => {
117
+ const cfg = mergeOpenclawClawchatToolAllow({
118
+ tools: {
119
+ allow: ["bash"],
120
+ deny: ["exec"],
121
+ },
122
+ } as never) as { tools: Record<string, unknown> };
123
+
124
+ expect(cfg.tools.allow).toEqual(["bash", "openclaw-clawchat"]);
125
+ expect(cfg.tools.deny).toEqual(["exec"]);
126
+ expect(cfg.tools.alsoAllow).toBeUndefined();
127
+ });
128
+
129
+ it("adds the plugin to tools.alsoAllow when no explicit allowlist is in use", () => {
130
+ const cfg = mergeOpenclawClawchatToolAllow({
131
+ tools: {
132
+ profile: "coding",
133
+ allow: [],
134
+ },
135
+ } as never) as { tools: Record<string, unknown> };
136
+
137
+ expect(cfg.tools).toEqual({
138
+ profile: "coding",
139
+ allow: [],
140
+ alsoAllow: ["openclaw-clawchat"],
141
+ });
142
+ });
143
+
110
144
  });