@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.
Files changed (49) hide show
  1. package/README.md +37 -11
  2. package/dist/index.js +27 -0
  3. package/dist/src/api-client.js +156 -0
  4. package/dist/src/api-types.js +17 -0
  5. package/dist/src/buffered-stream.js +177 -0
  6. package/dist/src/channel.js +200 -0
  7. package/dist/src/client.js +176 -0
  8. package/dist/src/commands.js +35 -0
  9. package/dist/src/config.js +226 -0
  10. package/dist/src/inbound.js +133 -0
  11. package/dist/src/login.runtime.js +132 -0
  12. package/dist/src/media-runtime.js +85 -0
  13. package/dist/src/message-mapper.js +82 -0
  14. package/dist/src/outbound.js +181 -0
  15. package/dist/src/protocol.js +38 -0
  16. package/dist/src/reply-dispatcher.js +440 -0
  17. package/dist/src/runtime.js +288 -0
  18. package/dist/src/streaming.js +65 -0
  19. package/dist/src/tools-schema.js +38 -0
  20. package/dist/src/tools.js +287 -0
  21. package/openclaw.plugin.json +21 -0
  22. package/package.json +27 -5
  23. package/skills/clawchat-activate/SKILL.md +18 -9
  24. package/src/buffered-stream.test.ts +10 -0
  25. package/src/buffered-stream.ts +6 -6
  26. package/src/channel.outbound.test.ts +3 -3
  27. package/src/channel.test.ts +7 -1
  28. package/src/channel.ts +27 -8
  29. package/src/client.test.ts +8 -1
  30. package/src/client.ts +11 -10
  31. package/src/commands.test.ts +6 -0
  32. package/src/commands.ts +5 -1
  33. package/src/config.test.ts +47 -0
  34. package/src/config.ts +28 -5
  35. package/src/inbound.test.ts +4 -1
  36. package/src/inbound.ts +11 -10
  37. package/src/login.runtime.test.ts +36 -0
  38. package/src/login.runtime.ts +57 -27
  39. package/src/manifest.test.ts +156 -30
  40. package/src/outbound.test.ts +6 -5
  41. package/src/outbound.ts +8 -7
  42. package/src/plugin-entry.test.ts +7 -1
  43. package/src/reply-dispatcher.test.ts +418 -3
  44. package/src/reply-dispatcher.ts +137 -12
  45. package/src/runtime.ts +1 -0
  46. package/src/streaming.test.ts +12 -9
  47. package/src/streaming.ts +6 -6
  48. package/src/tools.test.ts +81 -18
  49. package/src/tools.ts +65 -74
package/src/config.ts CHANGED
@@ -2,6 +2,11 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
2
2
  import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
3
3
 
4
4
  export const CHANNEL_ID = "openclaw-clawchat" as const;
5
+ export const CLAWCHAT_TOKEN_ENV = "CLAWCHAT_TOKEN" as const;
6
+ export const CLAWCHAT_USER_ID_ENV = "CLAWCHAT_USER_ID" as const;
7
+ export const CLAWCHAT_REFRESH_TOKEN_ENV = "CLAWCHAT_REFRESH_TOKEN" as const;
8
+ export const CLAWCHAT_BASE_URL_ENV = "CLAWCHAT_BASE_URL" as const;
9
+ export const CLAWCHAT_WEBSOCKET_URL_ENV = "CLAWCHAT_WEBSOCKET_URL" as const;
5
10
 
6
11
  /**
7
12
  * Built-in defaults for the Clawling Chat endpoints so `openclaw channel
@@ -84,13 +89,15 @@ export type OpenclawClawlingConfig = {
84
89
  websocketUrl?: string;
85
90
  baseUrl?: string;
86
91
  token?: string;
87
- /** Refresh token persisted by `openclaw channels login --channel openclaw-clawchat` (paired with `token`). */
92
+ /** Refresh token persisted by ClawChat activation/login (paired with `token`). */
88
93
  refreshToken?: string;
89
94
  userId?: string;
90
95
  replyMode?: ReplyMode;
91
96
  groupMode?: GroupMode;
92
97
  forwardThinking?: boolean;
93
98
  forwardToolCalls?: boolean;
99
+ /** Emit approval/action rich fragments instead of plain fallback text. */
100
+ richInteractions?: boolean;
94
101
  stream?: OpenclawClawlingStreamConfig;
95
102
  reconnect?: OpenclawClawlingReconnectConfig;
96
103
  heartbeat?: OpenclawClawlingHeartbeatConfig;
@@ -111,6 +118,7 @@ export const openclawClawlingConfigSchema = {
111
118
  groupMode: { type: "string", enum: ["mention", "all"] },
112
119
  forwardThinking: { type: "boolean" },
113
120
  forwardToolCalls: { type: "boolean" },
121
+ richInteractions: { type: "boolean" },
114
122
  stream: {
115
123
  type: "object",
116
124
  additionalProperties: false,
@@ -213,6 +221,7 @@ export type ResolvedOpenclawClawlingAccount = {
213
221
  groupMode: GroupMode;
214
222
  forwardThinking: boolean;
215
223
  forwardToolCalls: boolean;
224
+ richInteractions: boolean;
216
225
  allowFrom: string[];
217
226
  stream: Required<OpenclawClawlingStreamConfig>;
218
227
  reconnect: Required<OpenclawClawlingReconnectConfig>;
@@ -230,6 +239,10 @@ function readOptionalString(value: unknown): string {
230
239
  return typeof value === "string" ? value.trim() : "";
231
240
  }
232
241
 
242
+ function readEnvString(env: Record<string, string | undefined>, key: string): string {
243
+ return readOptionalString(env[key]);
244
+ }
245
+
233
246
  function readReplyMode(value: unknown): ReplyMode {
234
247
  return value === "stream" ? "stream" : "static";
235
248
  }
@@ -285,13 +298,20 @@ function readAck(raw: unknown): Required<OpenclawClawlingAckConfig> {
285
298
 
286
299
  export function resolveOpenclawClawlingAccount(
287
300
  cfg: OpenClawConfig,
301
+ env: Record<string, string | undefined> = process.env,
288
302
  ): ResolvedOpenclawClawlingAccount {
289
303
  const channel = readChannelSection(cfg);
290
304
  // Apply built-in defaults so login/gateway work without prior setup.
291
- const websocketUrl = readOptionalString(channel.websocketUrl) || DEFAULT_WEBSOCKET_URL;
292
- const baseUrl = readOptionalString(channel.baseUrl) || DEFAULT_BASE_URL;
293
- const token = readOptionalString(channel.token);
294
- const userId = readOptionalString(channel.userId);
305
+ const websocketUrl =
306
+ readOptionalString(channel.websocketUrl) ||
307
+ readEnvString(env, CLAWCHAT_WEBSOCKET_URL_ENV) ||
308
+ DEFAULT_WEBSOCKET_URL;
309
+ const baseUrl =
310
+ readOptionalString(channel.baseUrl) ||
311
+ readEnvString(env, CLAWCHAT_BASE_URL_ENV) ||
312
+ DEFAULT_BASE_URL;
313
+ const token = readOptionalString(channel.token) || readEnvString(env, CLAWCHAT_TOKEN_ENV);
314
+ const userId = readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV);
295
315
  const enabled = typeof channel.enabled === "boolean" ? channel.enabled : true;
296
316
  const replyMode = readReplyMode(channel.replyMode);
297
317
  const groupMode = readGroupMode(channel.groupMode);
@@ -299,6 +319,8 @@ export function resolveOpenclawClawlingAccount(
299
319
  typeof channel.forwardThinking === "boolean" ? channel.forwardThinking : true;
300
320
  const forwardToolCalls =
301
321
  typeof channel.forwardToolCalls === "boolean" ? channel.forwardToolCalls : false;
322
+ const richInteractions =
323
+ typeof channel.richInteractions === "boolean" ? channel.richInteractions : false;
302
324
 
303
325
  return {
304
326
  accountId: DEFAULT_ACCOUNT_ID,
@@ -313,6 +335,7 @@ export function resolveOpenclawClawlingAccount(
313
335
  groupMode,
314
336
  forwardThinking,
315
337
  forwardToolCalls,
338
+ richInteractions,
316
339
  allowFrom: [],
317
340
  stream: readStream(channel.stream),
318
341
  reconnect: readReconnect(channel.reconnect),
@@ -41,6 +41,7 @@ function buildSendEnvelope(
41
41
  mentions: string[];
42
42
  reply: unknown;
43
43
  messageId: string;
44
+ chatId: string;
44
45
  }> = {},
45
46
  ): Envelope<DownlinkMessageSendPayload> {
46
47
  return {
@@ -48,6 +49,7 @@ function buildSendEnvelope(
48
49
  event: overrides.event ?? "message.send",
49
50
  trace_id: "trace-1",
50
51
  emitted_at: 1776162600000,
52
+ chat_id: overrides.chatId,
51
53
  to: { id: "agent-1", type: overrides.senderType ?? "direct" },
52
54
  sender: {
53
55
  sender_id: "user-1",
@@ -174,7 +176,7 @@ describe("openclaw-clawchat inbound", () => {
174
176
  },
175
177
  };
176
178
  await dispatchOpenclawClawlingInbound({
177
- envelope: buildSendEnvelope({ event: "message.reply", reply: replyRef }),
179
+ envelope: buildSendEnvelope({ event: "message.reply", reply: replyRef, chatId: "chat-1" }),
178
180
  cfg: {},
179
181
  runtime: {} as never,
180
182
  account: baseAccount(),
@@ -183,6 +185,7 @@ describe("openclaw-clawchat inbound", () => {
183
185
  const { replyCtx } = ingest.mock.calls[0]![0];
184
186
  expect(replyCtx).toEqual({
185
187
  replyToMessageId: "m-orig",
188
+ replyPreviewChatId: "chat-1",
186
189
  replyPreviewSenderId: "user-2",
187
190
  replyPreviewNickName: "User Two",
188
191
  replyPreviewText: "original text",
package/src/inbound.ts CHANGED
@@ -29,6 +29,7 @@ export interface IngestTurnParams {
29
29
  mediaItems: MediaItem[];
30
30
  replyCtx?: {
31
31
  replyToMessageId: string;
32
+ replyPreviewChatId: string;
32
33
  replyPreviewSenderId: string;
33
34
  replyPreviewNickName: string;
34
35
  replyPreviewText: string;
@@ -191,9 +192,19 @@ export async function dispatchOpenclawClawlingInbound(
191
192
  return;
192
193
  }
193
194
 
195
+ log?.info?.(
196
+ `[${account.accountId}] openclaw-clawchat inbound event=${envelope.event === EVENT.MESSAGE_REPLY ? "reply" : "send"} msg=${payload.message_id} from=${sender.id} text_len=${rawBody.length} mentioned=${wasMentioned}`,
197
+ );
198
+
199
+ // New protocol: `chat_id` is the routing primary; `to` is deprecated.
200
+ // Fall back to sender.id if neither is present (defensive).
201
+ const chatId =
202
+ (envelope as Envelope<DownlinkMessageSendPayload> & { chat_id?: string }).chat_id ??
203
+ sender.id;
194
204
  const replyCtx = message.context.reply
195
205
  ? {
196
206
  replyToMessageId: message.context.reply.reply_to_msg_id,
207
+ replyPreviewChatId: chatId,
197
208
  replyPreviewSenderId:
198
209
  message.context.reply.reply_preview.id ??
199
210
  message.context.reply.reply_preview.sender_id ??
@@ -206,16 +217,6 @@ export async function dispatchOpenclawClawlingInbound(
206
217
  }
207
218
  : undefined;
208
219
 
209
- log?.info?.(
210
- `[${account.accountId}] openclaw-clawchat inbound event=${envelope.event === EVENT.MESSAGE_REPLY ? "reply" : "send"} msg=${payload.message_id} from=${sender.id} text_len=${rawBody.length} mentioned=${wasMentioned}`,
211
- );
212
-
213
- // New protocol: `chat_id` is the routing primary; `to` is deprecated.
214
- // Fall back to sender.id if neither is present (defensive).
215
- const chatId =
216
- (envelope as Envelope<DownlinkMessageSendPayload> & { chat_id?: string }).chat_id ??
217
- sender.id;
218
-
219
220
  await params.ingest({
220
221
  channel: "openclaw-clawchat",
221
222
  accountId: account.accountId,
@@ -112,6 +112,42 @@ describe("runOpenclawClawlingLogin", () => {
112
112
  expect(log).toHaveBeenCalledWith(expect.stringContaining("login succeeded"));
113
113
  });
114
114
 
115
+ it("uses the runtime config mutator with auto reload intent for config writes", async () => {
116
+ const cfg = buildCfg({
117
+ baseUrl: "https://api.example.com",
118
+ websocketUrl: "wss://ws.example.com/v2/client",
119
+ });
120
+ const agentsConnect = vi.fn().mockResolvedValue({
121
+ agent: { user_id: "agent-123", nickname: "Bot" },
122
+ access_token: "access-tok",
123
+ refresh_token: "refresh-tok",
124
+ });
125
+ let mutatedCfg: OpenClawConfig | undefined;
126
+ const mutateConfigFile = vi.fn(async (params) => {
127
+ expect(params.afterWrite).toEqual({ mode: "auto" });
128
+ const draft = structuredClone(cfg) as OpenClawConfig;
129
+ await params.mutate(draft, { snapshot: {} as never, previousHash: "before" });
130
+ mutatedCfg = draft;
131
+ return { nextConfig: draft } as never;
132
+ });
133
+
134
+ await runOpenclawClawlingLogin({
135
+ cfg,
136
+ runtime: { log: vi.fn() },
137
+ readInviteCode: async () => "INV-ABC",
138
+ apiClientFactory: () => makeApiClient({ agentsConnect }),
139
+ mutateConfigFile,
140
+ });
141
+
142
+ expect(mutateConfigFile).toHaveBeenCalledTimes(1);
143
+ const section = (mutatedCfg!.channels as Record<string, Record<string, unknown>>)[
144
+ CHANNEL_ID
145
+ ]!;
146
+ expect(section.token).toBe("access-tok");
147
+ expect(section.refreshToken).toBe("refresh-tok");
148
+ expect(section.userId).toBe("agent-123");
149
+ });
150
+
115
151
  it("preserves other configured channels when persisting ClawChat credentials", async () => {
116
152
  const cfg = {
117
153
  channels: {
@@ -1,8 +1,7 @@
1
1
  import { createInterface, type Interface as ReadlineInterface } from "node:readline/promises";
2
- import { writeConfigFile } from "openclaw/plugin-sdk/config-runtime";
3
2
  import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
4
3
  import { createOpenclawClawlingApiClient } from "./api-client.ts";
5
- import { ClawlingApiError } from "./api-types.ts";
4
+ import { ClawlingApiError, type AgentConnectResult } from "./api-types.ts";
6
5
  import {
7
6
  CHANNEL_ID,
8
7
  mergeOpenclawClawchatToolAllow,
@@ -20,6 +19,14 @@ export const AGENTS_CONNECT_PLATFORM = "openclaw" as const;
20
19
  */
21
20
  export const AGENTS_CONNECT_TYPE = "clawbot" as const;
22
21
 
22
+ export type OpenclawClawchatMutateConfigFile = <T = void>(params: {
23
+ afterWrite: { mode: "auto" } | { mode: "none" | "restart"; reason: string };
24
+ mutate: (
25
+ draft: OpenClawConfig,
26
+ context: { snapshot: unknown; previousHash: string | null },
27
+ ) => Promise<T | void> | T | void;
28
+ }) => Promise<unknown>;
29
+
23
30
  export interface LoginParams {
24
31
  cfg: OpenClawConfig;
25
32
  accountId?: string | null;
@@ -31,7 +38,9 @@ export interface LoginParams {
31
38
  readInviteCode?: () => Promise<string>;
32
39
  /** Override for the HTTP client — used by tests. */
33
40
  apiClientFactory?: typeof createOpenclawClawlingApiClient;
34
- /** Override for config persistence used by tests. */
41
+ /** Official runtime config mutator. Production callers must provide this. */
42
+ mutateConfigFile?: OpenclawClawchatMutateConfigFile;
43
+ /** Test-only config persistence override. */
35
44
  persistConfig?: (cfg: OpenClawConfig) => Promise<void> | void;
36
45
  }
37
46
 
@@ -60,14 +69,56 @@ async function promptInviteCodeFromStdin(runtime: {
60
69
  }
61
70
  }
62
71
 
72
+ function buildLoginConfig(cfg: OpenClawConfig, result: AgentConnectResult): OpenClawConfig {
73
+ const channels = (cfg.channels ?? {}) as Record<string, unknown>;
74
+ const existing = (channels[CHANNEL_ID] ?? {}) as Record<string, unknown>;
75
+ const nextSection: Record<string, unknown> = {
76
+ ...existing,
77
+ enabled: true,
78
+ token: result.access_token,
79
+ userId: result.agent.user_id,
80
+ };
81
+ if (result.refresh_token) {
82
+ nextSection.refreshToken = result.refresh_token;
83
+ }
84
+ return mergeOpenclawClawchatToolAllow({
85
+ ...cfg,
86
+ channels: { ...channels, [CHANNEL_ID]: nextSection },
87
+ });
88
+ }
89
+
90
+ async function persistLoginConfig(
91
+ params: LoginParams,
92
+ result: AgentConnectResult,
93
+ ): Promise<void> {
94
+ if (params.mutateConfigFile) {
95
+ await params.mutateConfigFile({
96
+ afterWrite: { mode: "auto" },
97
+ mutate(draft) {
98
+ Object.assign(draft, buildLoginConfig(draft, result));
99
+ },
100
+ });
101
+ return;
102
+ }
103
+
104
+ if (params.persistConfig) {
105
+ await params.persistConfig(buildLoginConfig(params.cfg, result));
106
+ return;
107
+ }
108
+
109
+ throw new Error("openclaw-clawchat: mutateConfigFile is required to persist login credentials");
110
+ }
111
+
63
112
  /**
64
- * Run the `openclaw channels login --channel openclaw-clawchat` flow:
113
+ * Run the invite-code credential exchange used by `clawchat_activate`,
114
+ * `openclaw channels add --channel openclaw-clawchat --token <invite-code>`,
115
+ * and `openclaw channels login --channel openclaw-clawchat`:
65
116
  * 1. Read the existing channel section; require `baseUrl` to be set so we
66
117
  * know which server to hit.
67
118
  * 2. Prompt the user for an invite code on stdin.
68
119
  * 3. POST it to `${baseUrl}/v1/agents/connect`.
69
120
  * 4. Write the returned `websocket_url` / `token` / `user_id` back into
70
- * the config so subsequent `openclaw gateway run` picks them up.
121
+ * the config so subsequent Gateway runs pick them up.
71
122
  *
72
123
  * Errors surface with clear messages (missing baseUrl, empty invite,
73
124
  * server-side rejection) so the caller can relay them to the operator.
@@ -115,34 +166,13 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
115
166
  );
116
167
  }
117
168
 
118
- // Merge credentials into cfg.channels.openclaw-clawchat and persist
119
- // immediately so a subsequent `openclaw gateway run` picks them up
120
- // without any manual edit. `baseUrl` / `websocketUrl` stay untouched —
121
- // the built-in defaults (or operator overrides) remain authoritative
122
- // because `/v1/agents/connect` doesn't return them.
123
- const channels = (cfg.channels ?? {}) as Record<string, unknown>;
124
- const existing = (channels[CHANNEL_ID] ?? {}) as Record<string, unknown>;
125
- const nextSection: Record<string, unknown> = {
126
- ...existing,
127
- enabled: true,
128
- token: result.access_token,
129
- userId: result.agent.user_id,
130
- };
131
- if (result.refresh_token) {
132
- nextSection.refreshToken = result.refresh_token;
133
- }
134
- const nextCfg: OpenClawConfig = mergeOpenclawClawchatToolAllow({
135
- ...cfg,
136
- channels: { ...channels, [CHANNEL_ID]: nextSection },
137
- });
138
-
139
169
  const tokenPreview = redactToken(result.access_token);
140
170
  runtime.log(
141
171
  `Updating config: channels.${CHANNEL_ID}.token=${tokenPreview} userId=${result.agent.user_id}${
142
172
  result.refresh_token ? " refreshToken=***" : ""
143
173
  } …`,
144
174
  );
145
- await (params.persistConfig ?? writeConfigFile)(nextCfg);
175
+ await persistLoginConfig(params, result);
146
176
  runtime.log(`Config file updated.`);
147
177
 
148
178
  runtime.log(
@@ -6,11 +6,24 @@ import packageJson from "../package.json" with { type: "json" };
6
6
  interface PackageJsonWithOpenclaw {
7
7
  name: string;
8
8
  files: string[];
9
+ scripts: Record<string, string>;
10
+ devDependencies: Record<string, string>;
11
+ peerDependencies: Record<string, string>;
9
12
  openclaw: {
10
13
  extensions: string[];
14
+ runtimeExtensions?: string[];
11
15
  setupEntry?: string;
12
- channel?: { id: string; aliases?: string[]; cliAddOptions?: Array<{ flags: string }> };
13
- install: { npmSpec: string };
16
+ channel?: {
17
+ id: string;
18
+ label: string;
19
+ selectionLabel?: string;
20
+ docsPath?: string;
21
+ docsLabel?: string;
22
+ blurb: string;
23
+ order?: number;
24
+ aliases?: string[];
25
+ };
26
+ install: { npmSpec: string; minHostVersion: string };
14
27
  };
15
28
  }
16
29
 
@@ -31,32 +44,73 @@ describe("openclaw-clawchat manifest", () => {
31
44
  expect(pkg.openclaw.install.npmSpec).toBe("@newbase-clawchat/openclaw-clawchat");
32
45
  });
33
46
 
47
+ it("requires an OpenClaw host with runtime config mutation support", () => {
48
+ const pkg = packageJson as PackageJsonWithOpenclaw;
49
+ expect(pkg.peerDependencies.openclaw).toBe(">=2026.5.4");
50
+ expect(pkg.devDependencies.openclaw).toBe("2026.5.4");
51
+ expect(pkg.openclaw.install.minHostVersion).toBe(">=2026.5.4");
52
+ });
53
+
54
+ it("publishes compiled runtime entrypoints for npm plugin installs", () => {
55
+ const pkg = packageJson as PackageJsonWithOpenclaw;
56
+ expect(pkg.openclaw.extensions).toEqual(["./index.ts"]);
57
+ expect(pkg.openclaw.runtimeExtensions).toEqual(["./dist/index.js"]);
58
+ expect(pkg.files).toContain("dist");
59
+ expect(pkg.scripts.build).toBe("tsc -p tsconfig.build.json");
60
+ expect(pkg.scripts.prepack).toBe("npm run build");
61
+ expect(fs.existsSync(new URL("../tsconfig.build.json", import.meta.url))).toBe(true);
62
+ });
63
+
64
+ it("publishes channel catalog metadata for OpenClaw CLI discovery", () => {
65
+ const pkg = packageJson as PackageJsonWithOpenclaw;
66
+ expect(pkg.openclaw.channel).toEqual({
67
+ id: "openclaw-clawchat",
68
+ label: "Clawling Chat",
69
+ selectionLabel: "Clawling Chat",
70
+ docsPath: "/channels/openclaw-clawchat",
71
+ docsLabel: "openclaw-clawchat",
72
+ blurb: "Clawling Protocol v2 over WebSocket (chat-sdk).",
73
+ order: 110,
74
+ });
75
+ });
76
+
34
77
  it("declares supported channel/command activation hints for plugin loading", () => {
35
78
  expect(pluginManifest.activation).toEqual({
79
+ onStartup: true,
36
80
  onChannels: ["openclaw-clawchat"],
37
81
  onCommands: ["clawchat-login"],
38
82
  });
39
- expect(pluginManifest.activation).not.toHaveProperty("onStartup");
40
83
  expect(pluginManifest.commandAliases).toEqual([
41
84
  { name: "clawchat-login", kind: "runtime-slash" },
42
85
  ]);
43
86
  });
44
87
 
45
- it("does not publish setup migration or setup-runtime channel metadata", () => {
88
+ it("declares env-driven ClawChat channel credentials for host setup/status surfaces", () => {
89
+ expect(pluginManifest.channelEnvVars).toEqual({
90
+ "openclaw-clawchat": [
91
+ "CLAWCHAT_TOKEN",
92
+ "CLAWCHAT_USER_ID",
93
+ "CLAWCHAT_REFRESH_TOKEN",
94
+ "CLAWCHAT_BASE_URL",
95
+ "CLAWCHAT_WEBSOCKET_URL",
96
+ ],
97
+ });
98
+ });
99
+
100
+ it("does not publish setup migration or setup-runtime entry metadata", () => {
46
101
  const pkg = packageJson as PackageJsonWithOpenclaw;
47
102
  expect(pkg.files).not.toContain("setup-api.ts");
48
103
  expect(pkg.files).not.toContain("setup-entry.ts");
49
104
  expect(pkg.openclaw.setupEntry).toBeUndefined();
50
- expect(pkg.openclaw.channel).toBeUndefined();
51
105
  expect(fs.existsSync(new URL("../setup-api.ts", import.meta.url))).toBe(false);
52
106
  expect(fs.existsSync(new URL("../setup-entry.ts", import.meta.url))).toBe(false);
53
107
  });
54
108
 
55
- it("does not document channels add as an activation path", () => {
109
+ it("documents channels add --token as the first-time CLI activation path", () => {
56
110
  const readme = fs.readFileSync(new URL("../README.md", import.meta.url), "utf8");
57
111
  const docs = fs.readFileSync(new URL("../docs/openclaw-clawchat.md", import.meta.url), "utf8");
58
- expect(readme).not.toMatch(/channels add --channel openclaw-clawchat/i);
59
- expect(docs).not.toMatch(/channels add --channel openclaw-clawchat/i);
112
+ expect(readme).toMatch(/openclaw channels add --channel openclaw-clawchat --token/i);
113
+ expect(docs).toMatch(/openclaw channels add --channel openclaw-clawchat --token/i);
60
114
  });
61
115
 
62
116
  it("publishes a ClawChat account tools skill for non-activation workflows", () => {
@@ -73,52 +127,124 @@ describe("openclaw-clawchat manifest", () => {
73
127
  expect(skill).not.toMatch(/clawchat_activate/);
74
128
  });
75
129
 
76
- it("keeps the activation skill on channel login without tool or slash-command dispatch", () => {
130
+ it("declares ownership of registered ClawChat agent tools", () => {
131
+ expect(pluginManifest.contracts?.tools).toEqual([
132
+ "clawchat_activate",
133
+ "clawchat_get_account_profile",
134
+ "clawchat_get_user_profile",
135
+ "clawchat_list_account_friends",
136
+ "clawchat_update_account_profile",
137
+ "clawchat_upload_avatar_image",
138
+ "clawchat_upload_media_file",
139
+ ]);
140
+ });
141
+
142
+ it("keeps the optional OpenClaw source checkout local-only", () => {
143
+ expect(fs.existsSync(new URL("../.gitmodules", import.meta.url))).toBe(false);
144
+
145
+ const gitignore = fs.readFileSync(new URL("../.gitignore", import.meta.url), "utf8");
146
+ expect(gitignore).toMatch(/^tmp\/openclaw\/$/m);
147
+
148
+ const pkg = packageJson as PackageJsonWithOpenclaw;
149
+ expect(pkg.scripts["dev:openclaw-source"]).toBe(
150
+ "test -d tmp/openclaw || git clone --depth=1 https://github.com/openclaw/openclaw.git tmp/openclaw",
151
+ );
152
+
153
+ const readme = fs.readFileSync(new URL("../README.md", import.meta.url), "utf8");
154
+ expect(readme).toMatch(/npm run dev:openclaw-source/);
155
+ expect(readme).toMatch(
156
+ /git clone --depth=1 https:\/\/github\.com\/openclaw\/openclaw\.git tmp\/openclaw/,
157
+ );
158
+ expect(readme).toMatch(/local-only/i);
159
+ });
160
+
161
+ it("keeps default Vitest discovery scoped to plugin sources", () => {
162
+ const configUrl = new URL("../vitest.config.ts", import.meta.url);
163
+ expect(fs.existsSync(configUrl)).toBe(true);
164
+ const config = fs.readFileSync(configUrl, "utf8");
165
+ expect(config).toMatch(/include:\s*\["src\/\*\*\/\*\.test\.ts"\]/);
166
+ expect(config).toMatch(/"tmp\/\*\*"/);
167
+ expect(config).toMatch(/"\.e2e\/\*\*"/);
168
+ });
169
+
170
+ it("keeps the activation skill on clawchat_activate with channels-add fallback", () => {
77
171
  const skill = fs.readFileSync(new URL("../skills/clawchat-activate/SKILL.md", import.meta.url), "utf8");
78
172
  expect(skill).toMatch(/name:\s*clawchat-activate/);
79
- expect(skill).not.toMatch(/clawchat_activate/);
80
- expect(skill).not.toMatch(/command-dispatch:\s*tool/);
81
- expect(skill).not.toMatch(/command-tool:/);
82
- expect(skill).not.toMatch(/command-dispatch:/);
83
- expect(skill).not.toMatch(/command-command:/);
84
- expect(skill).not.toMatch(/command-arg-mode:/);
85
- expect(skill).not.toMatch(/user-invocable:/);
173
+ expect(skill).toMatch(/clawchat_activate/);
86
174
  expect(skill).not.toMatch(/`clawchat\s+A1B2C3`/i);
87
175
  expect(skill).not.toMatch(/`clawchat\s*<code>`/i);
88
176
  expect(skill).not.toMatch(/\/clawchat_activate A1B2C3/);
89
177
  expect(skill).not.toMatch(/\/clawchat-activate A1B2C3/);
90
178
  expect(skill).not.toMatch(/\/clawchat-login A1B2C3/);
91
- expect(skill).toMatch(/openclaw channels login --channel openclaw-clawchat/);
92
- expect(skill).toMatch(/do not append/i);
93
- expect(skill).toMatch(/prompt[^\n]+invite code[^\n]+provide/i);
179
+ expect(skill).toMatch(/openclaw channels add --channel openclaw-clawchat --token/i);
180
+ expect(skill).toMatch(/first-time CLI activation/i);
181
+ expect(skill).toMatch(/channel add/i);
94
182
  expect(skill).toMatch(/channel login/i);
183
+ expect(skill).toMatch(/openclaw channels status --probe/);
95
184
  expect(skill).toMatch(/openclaw gateway restart/);
96
185
  expect(skill).not.toMatch(/ask the user to send/i);
97
186
  expect(skill).not.toMatch(/give the exact/i);
98
- expect(skill).toMatch(/execute[^\n]+openclaw channels login --channel openclaw-clawchat/i);
99
- expect(skill).toMatch(/execute[^\n]+openclaw gateway restart/i);
187
+ expect(skill).toMatch(/restart[^\n]+only when/i);
100
188
  });
101
189
 
102
- it("documents channel login as the natural-language activation path", () => {
190
+ it("documents clawchat_activate as the natural-language activation path with channels-add CLI fallback", () => {
103
191
  const readme = fs.readFileSync(new URL("../README.md", import.meta.url), "utf8");
104
192
  const docs = fs.readFileSync(new URL("../docs/openclaw-clawchat.md", import.meta.url), "utf8");
105
- expect(readme).toMatch(/openclaw channels login --channel openclaw-clawchat/i);
106
- expect(docs).toMatch(/openclaw channels login --channel openclaw-clawchat/i);
193
+ expect(readme).toMatch(/clawchat_activate/i);
194
+ expect(docs).toMatch(/clawchat_activate/i);
195
+ expect(readme).toMatch(/openclaw channels add --channel openclaw-clawchat --token/i);
196
+ expect(docs).toMatch(/openclaw channels add --channel openclaw-clawchat --token/i);
197
+ expect(readme).toMatch(/openclaw channels status --probe/i);
198
+ expect(docs).toMatch(/openclaw channels status --probe/i);
107
199
  expect(readme).toMatch(/openclaw gateway restart/i);
108
200
  expect(docs).toMatch(/openclaw gateway restart/i);
109
201
  expect(readme).not.toMatch(/activation skill[^.]+\/clawchat-login/i);
110
202
  expect(docs).not.toMatch(/natural-language activation requests[^.]+\/clawchat-login/i);
111
203
  expect(readme).not.toMatch(/\/clawchat-activate\s+A1B2C3/i);
112
204
  expect(docs).not.toMatch(/\/clawchat-activate\s+A1B2C3/i);
113
- expect(readme).not.toMatch(/clawchat_activate/);
114
- expect(docs).not.toMatch(/clawchat_activate/);
115
205
  expect(readme).not.toMatch(/\/clawchat_activate\s+A1B2C3/i);
116
206
  expect(docs).not.toMatch(/\/clawchat_activate\s+A1B2C3/i);
117
207
  expect(readme).not.toMatch(/direct users to/i);
118
208
  expect(docs).not.toMatch(/direct the\s+user/i);
119
- expect(docs).not.toMatch(/call the tool with the extracted code/i);
120
- expect(docs).not.toMatch(/AGT->>PLG: clawchat_activate/);
121
- expect(readme).toMatch(/activation skill[^.]+execute/i);
122
- expect(docs).toMatch(/natural-language activation requests[^.]+execute/i);
209
+ expect(readme).toMatch(/activation skill calls/i);
210
+ expect(docs).toMatch(/Natural-language activation requests should call `clawchat_activate`/i);
211
+ });
212
+
213
+ it("documents gateway restart as the required next step after plugin install or update", () => {
214
+ const install = fs.readFileSync(new URL("../INSTALL.md", import.meta.url), "utf8");
215
+ const installOrUpdate = install.indexOf("## Install or Update the Plugin");
216
+ const activate = install.indexOf("## Activate the Channel");
217
+ const installSection = install.slice(installOrUpdate, activate);
218
+
219
+ expect(installSection).toMatch(
220
+ /After installing or updating the plugin, restart the OpenClaw Gateway\. This\s+restart is required before OpenClaw can load the installed or updated ClawChat\s+plugin\.\n\n```bash\nopenclaw gateway restart/i,
221
+ );
222
+ expect(installSection).toMatch(/openclaw gateway restart/);
223
+ expect(installSection).toMatch(/If restarting the Gateway interrupts the current agent\/session/i);
224
+ expect(installSection).toMatch(/continue from \*\*Activate the Channel\*\*/i);
225
+ expect(installSection).not.toMatch(/runtime imports the plugin/i);
226
+ expect(installSection).not.toMatch(/If the Gateway is already running/i);
227
+ });
228
+
229
+ it("documents activation as a direct channels-add command after restarting the Openclaw Gateway", () => {
230
+ const install = fs.readFileSync(new URL("../INSTALL.md", import.meta.url), "utf8");
231
+ const activate = install.indexOf("## Activate the Channel");
232
+ const verify = install.indexOf("## Verify");
233
+ const activateSection = install.slice(activate, verify);
234
+
235
+ expect(activateSection).toMatch(/After the OpenClaw Gateway has restarted and is reachable, activate ClawChat by\s+adding the channel with the invite code/i);
236
+ expect(activateSection).toMatch(/openclaw channels add --channel openclaw-clawchat --token "\$CLAWCHAT_INVITE_CODE"/);
237
+ expect(activateSection).toMatch(/First-time CLI activation uses `channels add`/i);
238
+ expect(activateSection).toMatch(/refresh\s+credentials later/i);
239
+ expect(activateSection).not.toMatch(/clawchat_activate/i);
240
+ expect(activateSection).not.toMatch(/tools are visible/i);
241
+ expect(activateSection).not.toMatch(/openclaw channels status --probe/i);
242
+ expect(activateSection).not.toMatch(/verify the channel/i);
243
+
244
+ const verifySection = install.slice(verify);
245
+ expect(verifySection).toMatch(/openclaw channels status --probe/i);
246
+ expect(verifySection).toMatch(/enabled, configured, running, and\s+connected/i);
247
+ expect(verifySection).toMatch(/enabled, not configured, stopped, disconnected/i);
248
+ expect(verifySection).toMatch(/channel hot reload/i);
123
249
  });
124
250
  });
@@ -65,9 +65,9 @@ describe("openclaw-clawchat outbound", () => {
65
65
  });
66
66
  expect((client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).toMatchObject({
67
67
  chat_id: "user-1",
68
- chat_type: "direct",
69
68
  body: { fragments: [{ kind: "text", text: "hello" }] },
70
69
  });
70
+ expect((client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).not.toHaveProperty("chat_type");
71
71
  expect(client.replyMessage).not.toHaveBeenCalled();
72
72
  expect(result?.messageId).toBe("server-m1");
73
73
  expect(result?.acceptedAt).toBe(1234);
@@ -78,26 +78,27 @@ describe("openclaw-clawchat outbound", () => {
78
78
  await sendOpenclawClawlingText({
79
79
  client,
80
80
  account: baseAccount(),
81
- to: { chatId: "user-1", chatType: "direct" },
81
+ to: { chatId: "chat-1", chatType: "direct" },
82
82
  text: "reply",
83
83
  replyCtx: {
84
84
  replyToMessageId: "m-orig",
85
+ replyPreviewChatId: "chat-1",
85
86
  replyPreviewSenderId: "user-2",
86
87
  replyPreviewNickName: "Sender",
87
88
  replyPreviewText: "original",
88
89
  },
89
90
  });
90
91
  expect((client.replyMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).toMatchObject({
91
- chat_id: "user-1",
92
- chat_type: "direct",
92
+ chat_id: "chat-1",
93
93
  replyTo: {
94
94
  msgId: "m-orig",
95
- senderId: "user-2",
95
+ senderId: "chat-1",
96
96
  nickName: "Sender",
97
97
  fragments: [{ kind: "text", text: "original" }],
98
98
  },
99
99
  body: { fragments: [{ kind: "text", text: "reply" }] },
100
100
  });
101
+ expect((client.replyMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).not.toHaveProperty("chat_type");
101
102
  expect(client.sendMessage).not.toHaveBeenCalled();
102
103
  });
103
104