@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
@@ -3,12 +3,33 @@
3
3
  "channels": ["openclaw-clawchat"],
4
4
  "skills": ["./skills"],
5
5
  "activation": {
6
+ "onStartup": true,
6
7
  "onChannels": ["openclaw-clawchat"],
7
8
  "onCommands": ["clawchat-login"]
8
9
  },
10
+ "channelEnvVars": {
11
+ "openclaw-clawchat": [
12
+ "CLAWCHAT_TOKEN",
13
+ "CLAWCHAT_USER_ID",
14
+ "CLAWCHAT_REFRESH_TOKEN",
15
+ "CLAWCHAT_BASE_URL",
16
+ "CLAWCHAT_WEBSOCKET_URL"
17
+ ]
18
+ },
9
19
  "commandAliases": [
10
20
  { "name": "clawchat-login", "kind": "runtime-slash" }
11
21
  ],
22
+ "contracts": {
23
+ "tools": [
24
+ "clawchat_activate",
25
+ "clawchat_get_account_profile",
26
+ "clawchat_get_user_profile",
27
+ "clawchat_list_account_friends",
28
+ "clawchat_update_account_profile",
29
+ "clawchat_upload_avatar_image",
30
+ "clawchat_upload_media_file"
31
+ ]
32
+ },
12
33
  "configSchema": {
13
34
  "type": "object",
14
35
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@newbase-clawchat/openclaw-clawchat",
3
- "version": "2026.4.29",
3
+ "version": "2026.5.4",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "files": [
6
+ "dist",
6
7
  "index.ts",
7
8
  "src",
8
9
  "skills",
@@ -11,6 +12,14 @@
11
12
  ],
12
13
  "type": "module",
13
14
  "scripts": {
15
+ "build": "tsc -p tsconfig.build.json",
16
+ "test": "vitest",
17
+ "test:e2e:install-clawchat-plugin": "bash .e2e/run-install-clawchat-plugin-e2e.sh",
18
+ "test:e2e:install-clawchat-plugin:agent": "bash .e2e/run-install-clawchat-plugin-agent-e2e.sh",
19
+ "test:e2e:install-clawchat-plugin:agent:smoke": "node --test .e2e/run-install-clawchat-plugin-agent-e2e.test.mjs",
20
+ "test:e2e:install-clawchat-plugin:smoke": "node --test .e2e/run-install-clawchat-plugin-e2e.test.mjs",
21
+ "dev:openclaw-source": "test -d tmp/openclaw || git clone --depth=1 https://github.com/openclaw/openclaw.git tmp/openclaw",
22
+ "prepack": "npm run build",
14
23
  "typecheck": "tsc --noEmit",
15
24
  "prepublishOnly": "npm run typecheck",
16
25
  "release": "npm run prepublishOnly && npm publish"
@@ -21,11 +30,12 @@
21
30
  },
22
31
  "devDependencies": {
23
32
  "@types/node": "^25.5.0",
24
- "openclaw": "^2026.3.23",
25
- "typescript": "^5.4.0"
33
+ "openclaw": "2026.5.4",
34
+ "typescript": "^5.4.0",
35
+ "vitest": "^4.1.5"
26
36
  },
27
37
  "peerDependencies": {
28
- "openclaw": "^2026.3.23"
38
+ "openclaw": ">=2026.5.4"
29
39
  },
30
40
  "peerDependenciesMeta": {
31
41
  "openclaw": {
@@ -39,10 +49,22 @@
39
49
  "extensions": [
40
50
  "./index.ts"
41
51
  ],
52
+ "runtimeExtensions": [
53
+ "./dist/index.js"
54
+ ],
55
+ "channel": {
56
+ "id": "openclaw-clawchat",
57
+ "label": "Clawling Chat",
58
+ "selectionLabel": "Clawling Chat",
59
+ "docsPath": "/channels/openclaw-clawchat",
60
+ "docsLabel": "openclaw-clawchat",
61
+ "blurb": "Clawling Protocol v2 over WebSocket (chat-sdk).",
62
+ "order": 110
63
+ },
42
64
  "install": {
43
65
  "npmSpec": "@newbase-clawchat/openclaw-clawchat",
44
66
  "defaultChoice": "npm",
45
- "minHostVersion": ">=2026.3.23"
67
+ "minHostVersion": ">=2026.5.4"
46
68
  }
47
69
  }
48
70
  }
@@ -6,24 +6,33 @@ description: |
6
6
 
7
7
  # ClawChat Activation
8
8
 
9
- Activation must execute the OpenClaw channel login command. Do not edit config files manually, do not call tools for activation, and do not try to execute slash commands from this skill.
9
+ Activation should use the `clawchat_activate` tool when that tool is available. The tool runs the same invite-code login runtime as OpenClaw channel login and persists credentials into `channels.openclaw-clawchat`.
10
+
11
+ If the tool is not available because the Gateway has not loaded this plugin yet, fall back to OpenClaw channel add for first-time CLI activation. Do not edit config files manually and do not try to execute slash commands from this skill.
10
12
 
11
13
  ## Workflow
12
14
 
13
15
  1. Check whether the user is trying to activate or log in ClawChat.
14
- 2. Execute the channel login command:
16
+ 2. If `clawchat_activate` is available and the invite code is known, call it with the invite code.
17
+ 3. If the invite code is not known, ask the user for it before calling `clawchat_activate`.
18
+ 4. If `clawchat_activate` is not available, execute the channel add command with the invite code:
19
+
20
+ ```bash
21
+ openclaw channels add --channel openclaw-clawchat --token "$CLAWCHAT_INVITE_CODE"
22
+ ```
23
+ 5. `channels add --token` is the first-time CLI activation path; the token argument is the ClawChat invite code on this setup path.
24
+ 6. Use `openclaw channels login --channel openclaw-clawchat` only to refresh credentials later after the channel already exists.
25
+ 7. After activation succeeds, rely on OpenClaw's `channels.*` config hot reload when the plugin is already loaded. Verify channel status if available:
15
26
 
16
27
  ```bash
17
- openclaw channels login --channel openclaw-clawchat
28
+ openclaw channels status --probe
18
29
  ```
19
- 3. Do not append the invite code to the command; `openclaw channels login` has no code argument.
20
- 4. When the command prompts for an invite code, provide the user's invite code if one was already present, otherwise ask the user for it.
21
- 5. After channel login succeeds, execute the Gateway restart command:
30
+ 8. Execute the Gateway restart command only when the plugin was just installed/updated, the tool was unavailable because the running Gateway had not loaded the plugin, config reload is disabled, or the probe does not become healthy:
22
31
 
23
32
  ```bash
24
33
  openclaw gateway restart
25
34
  ```
26
- 6. Tell the user activation completes after channel login and Gateway restart both succeed.
35
+ 9. Tell the user activation completes after channel add/login succeeds and either config hot reload/probe succeeds or Gateway restart succeeds.
27
36
 
28
37
  ## Trigger Examples
29
38
 
@@ -33,6 +42,6 @@ openclaw gateway restart
33
42
  - `绑定 ClawChat,邀请码 A1B2C3`
34
43
  - `激活 ClawChat`
35
44
 
36
- Do not ask the user to enter a bare ClawChat command. If activation is requested, execute `openclaw channels login --channel openclaw-clawchat` yourself, then execute `openclaw gateway restart`.
45
+ Do not ask the user to enter a bare ClawChat command. If activation is requested and `clawchat_activate` is available, call that tool yourself. If the tool is unavailable, execute `openclaw channels add --channel openclaw-clawchat --token "$CLAWCHAT_INVITE_CODE"` yourself, then probe channel status and restart the Gateway only when needed.
37
46
 
38
- When the user asks to activate ClawChat without including a code, run channel login and ask for the invite code when the command needs it.
47
+ When the user asks to activate ClawChat without including a code, ask for the invite code before calling `clawchat_activate` or falling back to channel add.
@@ -53,6 +53,7 @@ describe("openBufferedStreamingSession", () => {
53
53
  maxBufferChars: 1000,
54
54
  });
55
55
  expect(typing).toEqual([["u1", true]]);
56
+ expect((client.typing as ReturnType<typeof vi.fn>).mock.calls).toEqual([["u1", true]]);
56
57
  expect(sent.map((s) => s.event)).toEqual(["message.created"]);
57
58
  });
58
59
 
@@ -101,6 +102,7 @@ describe("openBufferedStreamingSession", () => {
101
102
  [{ kind: "text", text: "Hello ", delta: "Hello " }],
102
103
  [{ kind: "text", text: "Hello world", delta: "world" }],
103
104
  ]);
105
+ expect(adds.map((a) => a.payload.sequence)).toEqual([0, 1]);
104
106
  expect(session.currentText).toBe("Hello world");
105
107
  });
106
108
 
@@ -125,6 +127,10 @@ describe("openBufferedStreamingSession", () => {
125
127
  ["u1", true],
126
128
  ["u1", false],
127
129
  ]);
130
+ expect((client.typing as ReturnType<typeof vi.fn>).mock.calls).toEqual([
131
+ ["u1", true],
132
+ ["u1", false],
133
+ ]);
128
134
  });
129
135
 
130
136
  it("done() is idempotent", async () => {
@@ -158,7 +164,11 @@ describe("openBufferedStreamingSession", () => {
158
164
  await session.fail("boom");
159
165
  const failed = sent.find((s) => s.event === "message.failed")!;
160
166
  expect(failed.payload.reason).toBe("boom");
167
+ expect(failed.payload.sequence).toBe(0);
168
+ expect(failed.payload).toHaveProperty("completed_at");
169
+ expect(failed.payload).not.toHaveProperty("failed_at");
161
170
  expect(typing.at(-1)).toEqual(["u1", false]);
171
+ expect((client.typing as ReturnType<typeof vi.fn>).mock.calls.at(-1)).toEqual(["u1", false]);
162
172
  });
163
173
 
164
174
  it("deduplicates a snapshot that is a substring of the buffered snapshot", async () => {
@@ -93,7 +93,7 @@ export function openBufferedStreamingSession(
93
93
  const routing = resolveRouting(options);
94
94
  const emitTyping = options.emitTyping !== false;
95
95
  if (emitTyping)
96
- options.client.typing(routing.chatId, true, routing.chatType);
96
+ options.client.typing(routing.chatId, true);
97
97
  emitStreamCreated(options.client, {
98
98
  messageId: options.messageId,
99
99
  routing,
@@ -101,7 +101,7 @@ export function openBufferedStreamingSession(
101
101
 
102
102
  let bufferedSnapshot = "";
103
103
  let flushedSnapshot = "";
104
- let sequence = 0;
104
+ let sequence = -1;
105
105
  let flushTimer: ReturnType<typeof setTimeout> | null = null;
106
106
  let pendingFlush: Promise<void> = Promise.resolve();
107
107
  let closed = false;
@@ -177,11 +177,11 @@ export function openBufferedStreamingSession(
177
177
  emitStreamDone(options.client, {
178
178
  messageId: options.messageId,
179
179
  routing,
180
- finalSequence: sequence,
180
+ finalSequence: Math.max(sequence, 0),
181
181
  finalText: bufferedSnapshot,
182
182
  });
183
183
  if (emitTyping)
184
- options.client.typing(routing.chatId, false, routing.chatType);
184
+ options.client.typing(routing.chatId, false);
185
185
  };
186
186
 
187
187
  const fail = async (reason?: string): Promise<void> => {
@@ -191,11 +191,11 @@ export function openBufferedStreamingSession(
191
191
  emitStreamFailed(options.client, {
192
192
  messageId: options.messageId,
193
193
  routing,
194
- sequence: sequence + 1,
194
+ sequence: Math.max(sequence, 0),
195
195
  ...(reason !== undefined ? { reason } : {}),
196
196
  });
197
197
  if (emitTyping)
198
- options.client.typing(routing.chatId, false, routing.chatType);
198
+ options.client.typing(routing.chatId, false);
199
199
  };
200
200
 
201
201
  return {
@@ -62,10 +62,10 @@ describe("openclaw-clawchat channel outbound", () => {
62
62
  expect(client.sendMessage).toHaveBeenCalledWith(
63
63
  expect.objectContaining({
64
64
  chat_id: "user-1",
65
- chat_type: "direct",
66
65
  body: { fragments: [{ kind: "text", text: "hello" }] },
67
66
  }),
68
67
  );
68
+ expect(client.sendMessage.mock.calls[0][0]).not.toHaveProperty("chat_type");
69
69
  expect(result).toEqual({
70
70
  channel: "openclaw-clawchat",
71
71
  to: "cc:user-1",
@@ -121,7 +121,6 @@ describe("openclaw-clawchat channel outbound", () => {
121
121
  expect(client.sendMessage).toHaveBeenCalledWith(
122
122
  expect.objectContaining({
123
123
  chat_id: "room-1",
124
- chat_type: "group",
125
124
  body: {
126
125
  fragments: [
127
126
  { kind: "text", text: "caption" },
@@ -130,6 +129,7 @@ describe("openclaw-clawchat channel outbound", () => {
130
129
  },
131
130
  }),
132
131
  );
132
+ expect(client.sendMessage.mock.calls[0][0]).not.toHaveProperty("chat_type");
133
133
  expect(result).toEqual({
134
134
  channel: "openclaw-clawchat",
135
135
  to: "cc:group:room-1",
@@ -199,7 +199,6 @@ describe("openclaw-clawchat channel outbound", () => {
199
199
  expect(client.sendMessage).toHaveBeenCalledWith(
200
200
  expect.objectContaining({
201
201
  chat_id: "room-1",
202
- chat_type: "group",
203
202
  body: {
204
203
  fragments: [
205
204
  { kind: "text", text: "caption" },
@@ -208,6 +207,7 @@ describe("openclaw-clawchat channel outbound", () => {
208
207
  },
209
208
  }),
210
209
  );
210
+ expect(client.sendMessage.mock.calls[0][0]).not.toHaveProperty("chat_type");
211
211
  expect(result).toEqual({
212
212
  channel: "openclaw-clawchat",
213
213
  to: "cc:group:room-1",
@@ -28,9 +28,12 @@ describe("openclaw-clawchat plugin", () => {
28
28
  expect(
29
29
  validate!({ cfg: {}, accountId: "default", input: { code: " " } }),
30
30
  ).toMatch(/invite code is required/i);
31
+ expect(
32
+ validate!({ cfg: {}, accountId: "default", input: { token: " " } }),
33
+ ).toMatch(/invite code is required/i);
31
34
  });
32
35
 
33
- it("setup.validateInput passes when code is present", () => {
36
+ it("setup.validateInput passes when code or token is present", () => {
34
37
  const validate = openclawClawlingPlugin.setup?.validateInput as (args: {
35
38
  cfg: unknown;
36
39
  accountId: string;
@@ -39,6 +42,9 @@ describe("openclaw-clawchat plugin", () => {
39
42
  expect(
40
43
  validate({ cfg: {}, accountId: "default", input: { code: "INV-XXXX" } }),
41
44
  ).toBeNull();
45
+ expect(
46
+ validate({ cfg: {}, accountId: "default", input: { token: "INV-XXXX" } }),
47
+ ).toBeNull();
42
48
  });
43
49
 
44
50
  it("setup.applyAccountConfig marks the channel enabled without touching credentials", () => {
package/src/channel.ts CHANGED
@@ -19,8 +19,9 @@ import {
19
19
  resolveOpenclawClawlingAccount,
20
20
  type ResolvedOpenclawClawlingAccount,
21
21
  } from "./config.ts";
22
+ import type { OpenclawClawchatMutateConfigFile } from "./login.runtime.ts";
22
23
  import { openclawClawlingOutbound } from "./outbound.ts";
23
- import { startOpenclawClawlingGateway } from "./runtime.ts";
24
+ import { getOpenclawClawlingRuntime, startOpenclawClawlingGateway } from "./runtime.ts";
24
25
 
25
26
  const configAdapter = createTopLevelChannelConfigAdapter<ResolvedOpenclawClawlingAccount>({
26
27
  sectionKey: CHANNEL_ID,
@@ -36,6 +37,7 @@ const configAdapter = createTopLevelChannelConfigAdapter<ResolvedOpenclawClawlin
36
37
  "replyMode",
37
38
  "forwardThinking",
38
39
  "forwardToolCalls",
40
+ "richInteractions",
39
41
  "enabled",
40
42
  ],
41
43
  resolveAllowFrom: (account) => account.allowFrom,
@@ -48,18 +50,24 @@ const configAdapter = createTopLevelChannelConfigAdapter<ResolvedOpenclawClawlin
48
50
  * one-shot setup metadata because current hosts do not discover channels from
49
51
  * `plugins.load.paths`.
50
52
  *
51
- * Setup takes exactly ONE input: `code` (an invite code). URL + token +
52
- * userId come from the login flow which is triggered automatically in
53
- * `afterAccountConfigWritten`.
53
+ * Setup takes an invite code from `code` or from OpenClaw's generic
54
+ * `channels add --token` input. URL + token + userId come from the login flow
55
+ * which is triggered automatically in `afterAccountConfigWritten`.
54
56
  *
55
57
  * `applyAccountConfig` itself only marks the section `enabled: true`;
56
- * credentials are written by `runOpenclawClawlingLogin` which calls `writeConfigFile`
57
- * on its own after the `/v1/agents/connect` response lands.
58
+ * credentials are written by `runOpenclawClawlingLogin` via the runtime config
59
+ * mutator after the `/v1/agents/connect` response lands.
58
60
  */
59
61
  const setupAdapter = {
60
62
  resolveAccountId: () => DEFAULT_ACCOUNT_ID,
61
63
  validateInput: ({ input }: { cfg: unknown; accountId: string; input: ChannelSetupInput }) => {
62
- if (!input.code?.trim()) {
64
+ const inviteCode =
65
+ typeof input.code === "string" && input.code.trim()
66
+ ? input.code.trim()
67
+ : typeof input.token === "string"
68
+ ? input.token.trim()
69
+ : "";
70
+ if (!inviteCode) {
63
71
  return "ClawChat invite code is required.";
64
72
  }
65
73
  return null;
@@ -94,7 +102,12 @@ const setupAdapter = {
94
102
  runtime: { log: (message: string) => void };
95
103
  previousCfg: OpenClawConfig;
96
104
  }) => {
97
- const code = input.code?.trim();
105
+ const code =
106
+ typeof input.code === "string" && input.code.trim()
107
+ ? input.code.trim()
108
+ : typeof input.token === "string"
109
+ ? input.token.trim()
110
+ : "";
98
111
  if (!code) return;
99
112
  // Lazy-import the login runtime to keep @clack/prompts / readline /
100
113
  // config-runtime off the plugin's cold-start path. `readInviteCode`
@@ -105,6 +118,9 @@ const setupAdapter = {
105
118
  accountId: null,
106
119
  runtime: { log: (message: string) => runtime.log(message) },
107
120
  readInviteCode: async () => code,
121
+ mutateConfigFile: (getOpenclawClawlingRuntime().config as unknown as {
122
+ mutateConfigFile: OpenclawClawchatMutateConfigFile;
123
+ }).mutateConfigFile,
108
124
  });
109
125
  },
110
126
  };
@@ -177,6 +193,9 @@ export const openclawClawlingPlugin: ChannelPlugin<ResolvedOpenclawClawlingAccou
177
193
  cfg,
178
194
  accountId: accountId ?? null,
179
195
  runtime: { log: (message: string) => runtime.log(message) },
196
+ mutateConfigFile: (getOpenclawClawlingRuntime().config as unknown as {
197
+ mutateConfigFile: OpenclawClawchatMutateConfigFile;
198
+ }).mutateConfigFile,
180
199
  });
181
200
  },
182
201
  },
@@ -92,7 +92,8 @@ describe("openclaw-clawchat client", () => {
92
92
  const env = JSON.parse(transport.sent[0]!);
93
93
  expect(env.event).toBe("message.created");
94
94
  expect(env.chat_id).toBe("user-1");
95
- expect(env.chat_type).toBe("direct");
95
+ expect(env).not.toHaveProperty("chat_type");
96
+ expect(env).not.toHaveProperty("sender");
96
97
  // Payload is intentionally minimal: just message_id, no message body /
97
98
  // context / sender / streaming metadata.
98
99
  expect(env.payload).toEqual({ message_id: "msg-1" });
@@ -118,6 +119,9 @@ describe("openclaw-clawchat client", () => {
118
119
  const env = JSON.parse(transport.sent[0]!);
119
120
  expect(env.event).toBe("message.add");
120
121
  expect(env.payload.sequence).toBe(3);
122
+ expect(env.chat_id).toBe("user-1");
123
+ expect(env).not.toHaveProperty("chat_type");
124
+ expect(env).not.toHaveProperty("sender");
121
125
  expect(env.payload.fragments).toEqual([
122
126
  { kind: "text", text: "Hello, wor", delta: "wor" },
123
127
  ]);
@@ -169,7 +173,10 @@ describe("openclaw-clawchat client", () => {
169
173
  expect(env.event).toBe("message.failed");
170
174
  expect(env.payload.message_id).toBe("msg-1");
171
175
  expect(env.payload.reason).toBe("upstream_error");
176
+ expect(env.payload.fragments).toEqual([{ kind: "text", text: "upstream_error" }]);
172
177
  expect(env.payload.streaming.status).toBe("failed");
178
+ expect(env.payload.streaming.completed_at).toBe(env.payload.completed_at);
179
+ expect(env.payload).not.toHaveProperty("failed_at");
173
180
  client.close();
174
181
  });
175
182
  });
package/src/client.ts CHANGED
@@ -76,11 +76,8 @@ function normalizeRouting(params: {
76
76
  }
77
77
 
78
78
  /**
79
- * Emit a raw v2 envelope directly over the transport so we can carry
80
- * `chat_id` + `chat_type` at envelope root (the new protocol). The SDK's
81
- * `emitRaw` can't express `chat_type` and always writes `to`; we bypass it
82
- * entirely for the events we construct ourselves (streaming lifecycle +
83
- * message.reply finalize).
79
+ * Emit a raw v2 envelope directly over the transport so we can carry top-level
80
+ * `chat_id` routing without SDK-injected `to` metadata.
84
81
  */
85
82
  function emitEnvelope(
86
83
  client: ClawlingChatClient,
@@ -105,7 +102,6 @@ function emitEnvelope(
105
102
  trace_id: inner.opts.traceIdFactory(),
106
103
  emitted_at: Date.now(),
107
104
  chat_id: routing.chatId,
108
- chat_type: routing.chatType,
109
105
  payload,
110
106
  };
111
107
  inner.opts.transport.send(JSON.stringify(env));
@@ -238,7 +234,7 @@ export function emitFinalStreamReply(
238
234
  /** The user message this stream is a reply to (usually the inbound turn). */
239
235
  replyTo: {
240
236
  msgId: string;
241
- senderId: string;
237
+ previewId: string;
242
238
  nickName: string;
243
239
  fragments: Fragment[];
244
240
  };
@@ -260,7 +256,7 @@ export function emitFinalStreamReply(
260
256
  reply: {
261
257
  reply_to_msg_id: params.replyTo.msgId,
262
258
  reply_preview: {
263
- id: params.replyTo.senderId,
259
+ id: params.replyTo.previewId,
264
260
  nick_name: params.replyTo.nickName,
265
261
  fragments: params.replyTo.fragments,
266
262
  },
@@ -284,13 +280,18 @@ export function emitStreamFailed(
284
280
  ): void {
285
281
  const now = Date.now();
286
282
  const routing = normalizeRouting(params);
283
+ const reason = params.reason ?? "unknown";
284
+ const reasonFragment = params.reason?.trim()
285
+ ? { fragments: [{ kind: "text", text: params.reason.trim() }] }
286
+ : {};
287
287
  emitEnvelope(
288
288
  client,
289
289
  "message.failed",
290
290
  {
291
291
  message_id: params.messageId,
292
292
  sequence: params.sequence,
293
- reason: params.reason ?? "unknown",
293
+ reason,
294
+ ...reasonFragment,
294
295
  streaming: {
295
296
  status: "failed",
296
297
  sequence: params.sequence,
@@ -298,7 +299,7 @@ export function emitStreamFailed(
298
299
  started_at: null,
299
300
  completed_at: now,
300
301
  },
301
- failed_at: now,
302
+ completed_at: now,
302
303
  },
303
304
  routing,
304
305
  );
@@ -13,6 +13,11 @@ describe("registerOpenclawClawlingCommands", () => {
13
13
  const commands: Array<{ name: string; acceptsArgs?: boolean; handler: (ctx: unknown) => Promise<{ text: string }> }> = [];
14
14
  const api = {
15
15
  registerCommand: (command: (typeof commands)[number]) => commands.push(command),
16
+ runtime: {
17
+ config: {
18
+ mutateConfigFile: vi.fn(),
19
+ },
20
+ },
16
21
  } as never;
17
22
 
18
23
  registerOpenclawClawlingCommands(api);
@@ -27,6 +32,7 @@ describe("registerOpenclawClawlingCommands", () => {
27
32
 
28
33
  expect(loginRuntime.runOpenclawClawlingLogin).toHaveBeenCalledTimes(1);
29
34
  const params = loginRuntime.runOpenclawClawlingLogin.mock.calls[0]?.[0];
35
+ expect(params.mutateConfigFile).toBe(api.runtime.config.mutateConfigFile);
30
36
  await expect(params.readInviteCode()).resolves.toBe("A1B2C3");
31
37
  expect(result.text).toMatch(/activated successfully/i);
32
38
  });
package/src/commands.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import type { OpenclawClawchatMutateConfigFile } from "./login.runtime.ts";
2
3
 
3
4
  function extractInviteCode(value: unknown): string {
4
5
  const raw = typeof value === "string" ? value.trim() : "";
@@ -9,7 +10,7 @@ function errorMessage(err: unknown): string {
9
10
  return err instanceof Error ? err.message : String(err);
10
11
  }
11
12
 
12
- export function registerOpenclawClawlingCommands(api: Pick<OpenClawPluginApi, "registerCommand" | "logger">): void {
13
+ export function registerOpenclawClawlingCommands(api: Pick<OpenClawPluginApi, "registerCommand" | "logger" | "runtime">): void {
13
14
  api.registerCommand({
14
15
  name: "clawchat-login",
15
16
  description: "Activate ClawChat with an invite code, e.g. /clawchat-login A1B2C3.",
@@ -27,6 +28,9 @@ export function registerOpenclawClawlingCommands(api: Pick<OpenClawPluginApi, "r
27
28
  accountId: ctx.accountId ?? null,
28
29
  runtime: { log: (message: string) => api.logger?.info?.(message) },
29
30
  readInviteCode: async () => code,
31
+ mutateConfigFile: (api.runtime.config as unknown as {
32
+ mutateConfigFile: OpenclawClawchatMutateConfigFile;
33
+ }).mutateConfigFile,
30
34
  });
31
35
  return { text: "✅ ClawChat activated successfully." };
32
36
  } catch (err) {
@@ -23,6 +23,7 @@ describe("openclaw-clawchat config", () => {
23
23
  expect(account.replyMode).toBe("static");
24
24
  expect(account.forwardThinking).toBe(true);
25
25
  expect(account.forwardToolCalls).toBe(false);
26
+ expect(account.richInteractions).toBe(false);
26
27
  expect(account.stream).toEqual(DEFAULT_STREAM);
27
28
  });
28
29
 
@@ -36,6 +37,7 @@ describe("openclaw-clawchat config", () => {
36
37
  replyMode: "stream",
37
38
  forwardThinking: false,
38
39
  forwardToolCalls: true,
40
+ richInteractions: true,
39
41
  stream: { flushIntervalMs: 500, minChunkChars: 50, maxBufferChars: 3000 },
40
42
  },
41
43
  },
@@ -48,11 +50,56 @@ describe("openclaw-clawchat config", () => {
48
50
  expect(account.replyMode).toBe("stream");
49
51
  expect(account.forwardThinking).toBe(false);
50
52
  expect(account.forwardToolCalls).toBe(true);
53
+ expect(account.richInteractions).toBe(true);
51
54
  expect(account.stream.flushIntervalMs).toBe(500);
52
55
  expect(account.stream.minChunkChars).toBe(50);
53
56
  expect(account.stream.maxBufferChars).toBe(3000);
54
57
  });
55
58
 
59
+ it("resolves credentials from ClawChat environment variables when config omits them", () => {
60
+ const account = resolveOpenclawClawlingAccount(
61
+ {},
62
+ {
63
+ CLAWCHAT_TOKEN: "env-token",
64
+ CLAWCHAT_USER_ID: "env-user",
65
+ CLAWCHAT_BASE_URL: "https://api.env.example",
66
+ CLAWCHAT_WEBSOCKET_URL: "wss://ws.env.example/ws",
67
+ },
68
+ );
69
+
70
+ expect(account.configured).toBe(true);
71
+ expect(account.token).toBe("env-token");
72
+ expect(account.userId).toBe("env-user");
73
+ expect(account.baseUrl).toBe("https://api.env.example");
74
+ expect(account.websocketUrl).toBe("wss://ws.env.example/ws");
75
+ });
76
+
77
+ it("prefers explicit channel config over ClawChat environment variables", () => {
78
+ const account = resolveOpenclawClawlingAccount(
79
+ {
80
+ channels: {
81
+ "openclaw-clawchat": {
82
+ token: "config-token",
83
+ userId: "config-user",
84
+ baseUrl: "https://api.config.example",
85
+ websocketUrl: "wss://ws.config.example/ws",
86
+ },
87
+ },
88
+ },
89
+ {
90
+ CLAWCHAT_TOKEN: "env-token",
91
+ CLAWCHAT_USER_ID: "env-user",
92
+ CLAWCHAT_BASE_URL: "https://api.env.example",
93
+ CLAWCHAT_WEBSOCKET_URL: "wss://ws.env.example/ws",
94
+ },
95
+ );
96
+
97
+ expect(account.token).toBe("config-token");
98
+ expect(account.userId).toBe("config-user");
99
+ expect(account.baseUrl).toBe("https://api.config.example");
100
+ expect(account.websocketUrl).toBe("wss://ws.config.example/ws");
101
+ });
102
+
56
103
  it("falls back to static replyMode for unknown values", () => {
57
104
  const cfg = {
58
105
  channels: {