@newbase-clawchat/openclaw-clawchat 2026.4.21 → 2026.4.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newbase-clawchat/openclaw-clawchat",
3
- "version": "2026.4.21",
3
+ "version": "2026.4.22",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "files": [
6
6
  "index.ts",
@@ -96,6 +96,25 @@ describe("openclaw-clawchat api-client", () => {
96
96
  expect((init.headers as Record<string, string>)["content-type"]).toBe("application/json");
97
97
  });
98
98
 
99
+ it("updateMyProfile forwards bio in the JSON body", async () => {
100
+ const fetchImpl = vi.fn().mockResolvedValue(
101
+ jsonResponse({
102
+ code: 0,
103
+ message: "ok",
104
+ data: { user_id: "u1", bio: "hello there" },
105
+ }),
106
+ );
107
+ const client = createOpenclawClawlingApiClient({
108
+ baseUrl: "https://api.example.com",
109
+ token: "tk",
110
+ userId: "agent-1",
111
+ fetchImpl,
112
+ });
113
+ await client.updateMyProfile({ bio: "hello there" });
114
+ const init = fetchImpl.mock.calls[0]![1] as RequestInit;
115
+ expect(JSON.parse(init.body as string)).toEqual({ bio: "hello there" });
116
+ });
117
+
99
118
  it("updateMyProfile throws validation error when userId is not configured", async () => {
100
119
  const fetchImpl = vi.fn();
101
120
  const client = createOpenclawClawlingApiClient({
package/src/api-client.ts CHANGED
@@ -24,7 +24,7 @@ export interface OpenclawClawlingApiClient {
24
24
  getMyProfile(): Promise<Profile>;
25
25
  getUserInfo(userId: string): Promise<Profile>;
26
26
  listFriends(params: { page?: number; pageSize?: number }): Promise<FriendList>;
27
- updateMyProfile(patch: { nick_name?: string; avatar?: string }): Promise<Profile>;
27
+ updateMyProfile(patch: { nick_name?: string; avatar_url?: string; bio?: string }): Promise<Profile>;
28
28
  uploadMedia(params: { buffer: Buffer; filename: string; mime?: string }): Promise<UploadResult>;
29
29
  /**
30
30
  * Exchange an invite code for an agent token.
@@ -172,7 +172,7 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
172
172
  }
173
173
  return await call<Profile>(
174
174
  "PATCH",
175
- `/v1/agents/${encodeURIComponent(opts.userId.trim())}`,
175
+ `/v1/users/me`,
176
176
  {
177
177
  body: JSON.stringify(patch),
178
178
  headers: { "content-type": "application/json" },
package/src/api-types.ts CHANGED
@@ -7,9 +7,9 @@
7
7
  */
8
8
 
9
9
  export interface Profile {
10
- user_id: string;
11
- nick_name: string;
12
- avatar?: string;
10
+ id: string;
11
+ nickname?: string;
12
+ avatar_url?: string;
13
13
  bio?: string;
14
14
  }
15
15
 
@@ -0,0 +1,116 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const getClientMock = vi.hoisted(() => vi.fn());
4
+ const getRuntimeMock = vi.hoisted(() => vi.fn());
5
+ const uploadOutboundMediaMock = vi.hoisted(() => vi.fn());
6
+ const createApiClientMock = vi.hoisted(() => vi.fn());
7
+ const sendTextMock = vi.hoisted(() => vi.fn());
8
+ const sendMediaMock = vi.hoisted(() => vi.fn());
9
+
10
+ vi.mock("./runtime.ts", () => ({
11
+ getOpenclawClawlingClient: getClientMock,
12
+ getOpenclawClawlingRuntime: getRuntimeMock,
13
+ startOpenclawClawlingGateway: vi.fn(),
14
+ }));
15
+
16
+ vi.mock("./media-runtime.ts", () => ({
17
+ uploadOutboundMedia: uploadOutboundMediaMock,
18
+ }));
19
+
20
+ vi.mock("./api-client.ts", () => ({
21
+ createOpenclawClawlingApiClient: createApiClientMock,
22
+ }));
23
+
24
+ vi.mock("./outbound.ts", () => ({
25
+ sendOpenclawClawlingText: sendTextMock,
26
+ sendOpenclawClawlingMedia: sendMediaMock,
27
+ }));
28
+
29
+ describe("openclaw-clawchat channel outbound", () => {
30
+ beforeEach(() => {
31
+ vi.resetModules();
32
+ getClientMock.mockReset();
33
+ getRuntimeMock.mockReset();
34
+ uploadOutboundMediaMock.mockReset();
35
+ createApiClientMock.mockReset();
36
+ sendTextMock.mockReset();
37
+ sendMediaMock.mockReset();
38
+ });
39
+
40
+ it("sendMedia uploads mediaUrl and sends resulting fragments", async () => {
41
+ const client = { sendMessage: vi.fn() };
42
+ const runtime = { media: { loadWebMedia: vi.fn() } };
43
+ const apiClient = { uploadMedia: vi.fn() };
44
+ getClientMock.mockReturnValue(client);
45
+ getRuntimeMock.mockReturnValue(runtime);
46
+ createApiClientMock.mockReturnValue(apiClient);
47
+ uploadOutboundMediaMock.mockResolvedValue([
48
+ { kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
49
+ ]);
50
+ sendMediaMock.mockResolvedValue({ messageId: "m-1", acceptedAt: 123 });
51
+
52
+ const { openclawClawlingOutbound } = await import("./outbound.ts");
53
+ const result = await openclawClawlingOutbound.sendMedia!({
54
+ cfg: {
55
+ channels: {
56
+ "openclaw-clawchat": {
57
+ enabled: true,
58
+ websocketUrl: "ws://t",
59
+ baseUrl: "https://api.example.com",
60
+ token: "tk",
61
+ userId: "agent-1",
62
+ },
63
+ },
64
+ } as never,
65
+ to: "cc:group:room-1",
66
+ text: "caption",
67
+ mediaUrl: "/tmp/photo.png",
68
+ mediaLocalRoots: ["/tmp"],
69
+ });
70
+
71
+ expect(createApiClientMock).toHaveBeenCalledWith({
72
+ baseUrl: "https://api.example.com",
73
+ token: "tk",
74
+ userId: "agent-1",
75
+ });
76
+ expect(uploadOutboundMediaMock).toHaveBeenCalledWith(["/tmp/photo.png"], {
77
+ apiClient,
78
+ runtime,
79
+ mediaLocalRoots: ["/tmp"],
80
+ });
81
+ expect(sendMediaMock).toHaveBeenCalledWith({
82
+ client,
83
+ account: expect.objectContaining({ userId: "agent-1", baseUrl: "https://api.example.com" }),
84
+ to: { chatId: "room-1", chatType: "group" },
85
+ text: "caption",
86
+ mediaFragments: [{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" }],
87
+ });
88
+ expect(result).toEqual({
89
+ channel: "openclaw-clawchat",
90
+ to: "cc:group:room-1",
91
+ messageId: "m-1",
92
+ });
93
+ });
94
+
95
+ it("sendMedia rejects missing mediaUrl", async () => {
96
+ getClientMock.mockReturnValue({ sendMessage: vi.fn() });
97
+ const { openclawClawlingOutbound } = await import("./outbound.ts");
98
+ await expect(
99
+ openclawClawlingOutbound.sendMedia!({
100
+ cfg: {
101
+ channels: {
102
+ "openclaw-clawchat": {
103
+ enabled: true,
104
+ websocketUrl: "ws://t",
105
+ baseUrl: "https://api.example.com",
106
+ token: "tk",
107
+ userId: "agent-1",
108
+ },
109
+ },
110
+ } as never,
111
+ to: "cc:user-1",
112
+ text: "caption",
113
+ }),
114
+ ).rejects.toThrow(/requires mediaUrl/);
115
+ });
116
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { openclawClawlingPlugin } from "./channel.ts";
3
+ import { parseOpenclawRecipient } from "./outbound.ts";
3
4
 
4
5
  describe("openclaw-clawchat plugin", () => {
5
6
  it("publishes openclaw-clawchat channel metadata", () => {
@@ -68,5 +69,23 @@ describe("openclaw-clawchat plugin", () => {
68
69
  });
69
70
  expect(Array.isArray(hints) && hints.length).toBeGreaterThan(0);
70
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
+ );
76
+ });
77
+
78
+ it("normalizes openclaw-clawchat targets for host resolution", () => {
79
+ const normalized = openclawClawlingPlugin.messaging?.normalizeTarget?.(
80
+ "openclaw-clawchat:usr_01KPN6SQFQEGM9HR11CHRHPMMT",
81
+ );
82
+ expect(normalized).toBe("usr_01KPN6SQFQEGM9HR11CHRHPMMT");
83
+ });
84
+
85
+ it("parses openclaw-clawchat target prefix as a direct recipient", () => {
86
+ expect(parseOpenclawRecipient("openclaw-clawchat:usr_01KPN6SQFQEGM9HR11CHRHPMMT")).toEqual({
87
+ chatId: "usr_01KPN6SQFQEGM9HR11CHRHPMMT",
88
+ chatType: "direct",
89
+ });
71
90
  });
72
91
  });
package/src/channel.ts CHANGED
@@ -6,7 +6,6 @@ import {
6
6
  type OpenClawConfig,
7
7
  } from "openclaw/plugin-sdk/core";
8
8
  import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
9
- import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
10
9
  import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
11
10
  import {
12
11
  createComputedAccountStatusAdapter,
@@ -19,53 +18,8 @@ import {
19
18
  resolveOpenclawClawlingAccount,
20
19
  type ResolvedOpenclawClawlingAccount,
21
20
  } 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
- }
21
+ import { openclawClawlingOutbound } from "./outbound.ts";
22
+ import { startOpenclawClawlingGateway } from "./runtime.ts";
69
23
 
70
24
  const configAdapter = createTopLevelChannelConfigAdapter<ResolvedOpenclawClawlingAccount>({
71
25
  sectionKey: CHANNEL_ID,
@@ -244,35 +198,25 @@ export const openclawClawlingPlugin: ChannelPlugin<ResolvedOpenclawClawlingAccou
244
198
  messageToolHints: () => [
245
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.",
246
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`.",
247
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:`.",
248
204
  "- ClawChat supports media fragments (image / file / audio / video) alongside text in the same message.",
249
205
  "- ClawChat stream mode emits `message.created` → progressive `message.add` deltas → `message.done`, followed by a consolidated `message.reply` with the merged text.",
250
206
  ],
251
207
  },
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
- };
208
+ messaging: {
209
+ normalizeTarget: (target) =>
210
+ target
211
+ .trim()
212
+ .replace(/^openclaw-clawchat:/i, "")
213
+ .replace(/^clawchat:/i, "")
214
+ .replace(/^cc:/i, ""),
215
+ targetResolver: {
216
+ looksLikeId: (raw, normalized) => Boolean((normalized ?? raw).trim()),
217
+ hint: "active-session",
218
+ },
276
219
  },
277
220
  },
221
+ outbound: openclawClawlingOutbound,
278
222
  });
@@ -230,6 +230,23 @@ describe("openclaw-clawchat inbound", () => {
230
230
  ]);
231
231
  });
232
232
 
233
+ it("dispatches image-only messages", async () => {
234
+ const ingest = vi.fn().mockResolvedValue(undefined);
235
+ const env = buildSendEnvelope({ text: " " });
236
+ env.payload.message.body.fragments = [{ kind: "image", url: "https://cdn/x.png" }];
237
+ await dispatchOpenclawClawlingInbound({
238
+ envelope: env,
239
+ cfg: {} as never,
240
+ runtime: {} as never,
241
+ account: baseAccount(),
242
+ ingest,
243
+ });
244
+ expect(ingest).toHaveBeenCalledTimes(1);
245
+ const call = ingest.mock.calls[0]![0];
246
+ expect(call.rawBody).toBe("![image](https://cdn/x.png)");
247
+ expect(call.mediaItems).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
248
+ });
249
+
233
250
  it("dispatches when sender is only on envelope.sender (real wire, no message.sender)", async () => {
234
251
  const ingest = vi.fn().mockResolvedValue(undefined);
235
252
  const env = buildSendEnvelope({ text: "hello" });
package/src/inbound.ts CHANGED
@@ -131,7 +131,9 @@ export async function dispatchOpenclawClawlingInbound(
131
131
  return;
132
132
  }
133
133
  if (!hasRenderableText(message)) {
134
- log?.info?.(`[${account.accountId}] openclaw-clawchat skip empty msg=${payload.message_id}`);
134
+ log?.info?.(
135
+ `[${account.accountId}] openclaw-clawchat skip empty msg=${payload.message_id}`,
136
+ );
135
137
  return;
136
138
  }
137
139
  if (rememberAndCheck(payload.message_id)) {
@@ -6,13 +6,6 @@ import {
6
6
  type MediaItem,
7
7
  } from "./media-runtime.ts";
8
8
 
9
- // loadWebMedia is imported directly in media-runtime.ts (not via runtime.channel.media)
10
- // because the channel runtime only exposes fetchRemoteMedia/saveMediaBuffer.
11
- const loadWebMediaMock = vi.hoisted(() => vi.fn());
12
- vi.mock("openclaw/plugin-sdk/web-media", () => ({
13
- loadWebMedia: loadWebMediaMock,
14
- }));
15
-
16
9
  describe("inferMediaKindFromMime", () => {
17
10
  it("maps image/* to image", () => {
18
11
  expect(inferMediaKindFromMime("image/png")).toBe("image");
@@ -99,20 +92,21 @@ describe("uploadOutboundMedia", () => {
99
92
  } as unknown as ReturnType<typeof import("./api-client.ts").createOpenclawClawlingApiClient>;
100
93
  }
101
94
 
102
- function setupLoadWebMedia() {
103
- loadWebMediaMock.mockResolvedValue({
95
+ function buildRuntime() {
96
+ const loadWebMedia = vi.fn().mockResolvedValue({
104
97
  buffer: Buffer.from("loaded-bytes"),
105
98
  contentType: "image/png",
106
99
  fileName: "img.png",
107
100
  });
108
- return loadWebMediaMock;
101
+ const runtime = {
102
+ media: { loadWebMedia },
103
+ } as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
104
+ return { runtime, loadWebMedia };
109
105
  }
110
106
 
111
107
  it("loads each url and uploads", async () => {
112
- const loadWebMedia = setupLoadWebMedia();
108
+ const { runtime, loadWebMedia } = buildRuntime();
113
109
  const apiClient = buildApiClient();
114
- // runtime is unused for uploadOutboundMedia (loadWebMedia is a direct import)
115
- const runtime = {} as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
116
110
  const fragments = await uploadOutboundMedia(["https://cdn/in.png", "https://cdn/in2.png"], {
117
111
  apiClient,
118
112
  runtime,
@@ -137,11 +131,10 @@ describe("uploadOutboundMedia", () => {
137
131
  });
138
132
 
139
133
  it("drops a single failed upload, returns the rest", async () => {
140
- setupLoadWebMedia();
134
+ const { runtime } = buildRuntime();
141
135
  const apiClient = buildApiClient();
142
136
  (apiClient.uploadMedia as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("boom"));
143
137
  const log = { info: vi.fn(), error: vi.fn() };
144
- const runtime = {} as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
145
138
  const fragments = await uploadOutboundMedia(["https://cdn/a.png", "https://cdn/b.png"], {
146
139
  apiClient,
147
140
  runtime,
@@ -153,7 +146,7 @@ describe("uploadOutboundMedia", () => {
153
146
 
154
147
  it("returns empty array for empty input", async () => {
155
148
  const apiClient = buildApiClient();
156
- const runtime = {} as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
149
+ const { runtime } = buildRuntime();
157
150
  expect(await uploadOutboundMedia([], { apiClient, runtime })).toEqual([]);
158
151
  });
159
152
  });
@@ -1,5 +1,4 @@
1
1
  import type { PluginRuntime } from "openclaw/plugin-sdk/core";
2
- import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
3
2
  import type { OpenclawClawlingApiClient } from "./api-client.ts";
4
3
 
5
4
  /**
@@ -98,8 +97,9 @@ export async function fetchInboundMedia(
98
97
  * Upload each URL (remote or local path) to /media/upload via the api
99
98
  * client and return a fragment ready to splice into `body.fragments`.
100
99
  *
101
- * Uses `loadWebMedia` from `openclaw/plugin-sdk/web-media` (not the channel
102
- * runtime media helpers, which only expose fetchRemoteMedia/saveMediaBuffer).
100
+ * Uses the host runtime's `runtime.media.loadWebMedia`, so local-root
101
+ * enforcement and media-loading policy stay aligned with the current
102
+ * OpenClaw runtime instead of a directly imported helper.
103
103
  *
104
104
  * Single-upload failures log at error and are dropped; the remaining
105
105
  * fragments still come back so a partially-failing batch still sends the
@@ -114,7 +114,7 @@ export async function uploadOutboundMedia(
114
114
  const out: ClawlingMediaFragment[] = [];
115
115
  for (const url of urls) {
116
116
  try {
117
- const loaded = await loadWebMedia(url, {
117
+ const loaded = await ctx.runtime.media.loadWebMedia(url, {
118
118
  maxBytes,
119
119
  ...(ctx.mediaLocalRoots && ctx.mediaLocalRoots.length > 0
120
120
  ? { localRoots: ctx.mediaLocalRoots }
package/src/outbound.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  import type { ClawlingChatClient, Envelope, Fragment, MessageAckPayload } from "@newbase-clawchat/sdk";
2
+ import {
3
+ createAttachedChannelResultAdapter,
4
+ type ChannelOutboundAdapter,
5
+ } from "openclaw/plugin-sdk/channel-send-result";
6
+ import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
2
7
  import type { ChatType } from "./client.ts";
3
- import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
4
- import type { ClawlingMediaFragment } from "./media-runtime.ts";
8
+ import { createOpenclawClawlingApiClient } from "./api-client.ts";
9
+ import { CHANNEL_ID, resolveOpenclawClawlingAccount, type ResolvedOpenclawClawlingAccount } from "./config.ts";
5
10
  import { textToFragments } from "./message-mapper.ts";
11
+ import { uploadOutboundMedia, type ClawlingMediaFragment } from "./media-runtime.ts";
12
+ import { getOpenclawClawlingClient, getOpenclawClawlingRuntime } from "./runtime.ts";
6
13
 
7
14
  export interface OutboundTarget {
8
15
  chatId: string;
@@ -37,6 +44,48 @@ export interface SendResult {
37
44
  acceptedAt: number;
38
45
  }
39
46
 
47
+ /**
48
+ * Parse an agent-initiated outbound recipient string into the new-protocol
49
+ * `chat_id` + `chat_type` pair.
50
+ *
51
+ * Accepted forms (case-insensitive prefix):
52
+ * - `cc:{chat_id}` → direct
53
+ * - `clawchat:{chat_id}` → direct
54
+ * - `openclaw-clawchat:{chat_id}` → direct
55
+ * - `cc:direct:{chat_id}` → direct
56
+ * - `cc:group:{chat_id}` → group
57
+ * - `clawchat:direct:{chat_id}` → direct
58
+ * - `clawchat:group:{chat_id}` → group
59
+ * - `openclaw-clawchat:direct:{chat_id}` → direct
60
+ * - `openclaw-clawchat:group:{chat_id}` → group
61
+ * - bare `{chat_id}` → direct (backward compat)
62
+ */
63
+ export function parseOpenclawRecipient(to: string): { chatId: string; chatType: ChatType } {
64
+ const raw = (to ?? "").trim();
65
+ if (!raw) throw new Error("openclaw-clawchat: outbound `to` is empty");
66
+
67
+ const firstColon = raw.indexOf(":");
68
+ if (firstColon < 0) return { chatId: raw, chatType: "direct" };
69
+
70
+ const scheme = raw.slice(0, firstColon).toLowerCase();
71
+ const rest = raw.slice(firstColon + 1);
72
+ if (scheme !== "cc" && scheme !== "clawchat" && scheme !== CHANNEL_ID) {
73
+ return { chatId: raw, chatType: "direct" };
74
+ }
75
+
76
+ const secondColon = rest.indexOf(":");
77
+ if (secondColon >= 0) {
78
+ const typeToken = rest.slice(0, secondColon).toLowerCase();
79
+ const chatId = rest.slice(secondColon + 1).trim();
80
+ if ((typeToken === "direct" || typeToken === "group") && chatId) {
81
+ return { chatId, chatType: typeToken };
82
+ }
83
+ }
84
+ const chatId = rest.trim();
85
+ if (!chatId) throw new Error(`openclaw-clawchat: missing chat_id in "${to}"`);
86
+ return { chatId, chatType: "direct" };
87
+ }
88
+
40
89
  export async function sendOpenclawClawlingText(params: SendParams): Promise<SendResult | null> {
41
90
  const text = (params.text ?? "").trim();
42
91
  const mediaFragments = params.mediaFragments ?? [];
@@ -139,3 +188,65 @@ export async function sendOpenclawClawlingMedia(
139
188
  ...(params.log ? { log: params.log } : {}),
140
189
  });
141
190
  }
191
+
192
+ export const openclawClawlingOutbound: ChannelOutboundAdapter = {
193
+ deliveryMode: "direct",
194
+ chunker: (text, limit) => chunkMarkdownText(text, limit),
195
+ chunkerMode: "markdown",
196
+ textChunkLimit: 4000,
197
+ ...createAttachedChannelResultAdapter({
198
+ channel: CHANNEL_ID,
199
+ sendText: async ({ cfg, to, text }) => {
200
+ const account = resolveOpenclawClawlingAccount(cfg);
201
+ const client = getOpenclawClawlingClient(account.accountId);
202
+ if (!client) {
203
+ throw new Error(`openclaw-clawchat client not running for account ${account.accountId}`);
204
+ }
205
+ const result = await sendOpenclawClawlingText({
206
+ client,
207
+ account,
208
+ to: parseOpenclawRecipient(to),
209
+ text,
210
+ });
211
+ return {
212
+ to,
213
+ messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
214
+ };
215
+ },
216
+ sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots }) => {
217
+ const account = resolveOpenclawClawlingAccount(cfg);
218
+ const client = getOpenclawClawlingClient(account.accountId);
219
+ if (!client) {
220
+ throw new Error(`openclaw-clawchat client not running for account ${account.accountId}`);
221
+ }
222
+ if (!mediaUrl?.trim()) {
223
+ throw new Error("openclaw-clawchat sendMedia requires mediaUrl");
224
+ }
225
+ const runtime = getOpenclawClawlingRuntime();
226
+ const apiClient = createOpenclawClawlingApiClient({
227
+ baseUrl: account.baseUrl,
228
+ token: account.token,
229
+ userId: account.userId,
230
+ });
231
+ const mediaFragments = await uploadOutboundMedia([mediaUrl.trim()], {
232
+ apiClient,
233
+ runtime,
234
+ ...(mediaLocalRoots ? { mediaLocalRoots } : {}),
235
+ });
236
+ if (mediaFragments.length === 0) {
237
+ throw new Error(`openclaw-clawchat failed to upload media: ${mediaUrl}`);
238
+ }
239
+ const result = await sendOpenclawClawlingMedia({
240
+ client,
241
+ account,
242
+ to: parseOpenclawRecipient(to),
243
+ text,
244
+ mediaFragments,
245
+ });
246
+ return {
247
+ to,
248
+ messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
249
+ };
250
+ },
251
+ }),
252
+ };
@@ -36,7 +36,9 @@ describe("openclaw-clawchat protocol guards", () => {
36
36
 
37
37
  it("detects renderable text", () => {
38
38
  expect(hasRenderableText({ body: { fragments: [{ kind: "text", text: "hi" }] } })).toBe(true);
39
- expect(hasRenderableText({ body: { fragments: [{ kind: "image", url: "x" }] } })).toBe(false);
39
+ expect(hasRenderableText({ body: { fragments: [{ kind: "image", url: "x" }] } })).toBe(true);
40
+ expect(hasRenderableText({ body: { fragments: [{ kind: "file", url: "x" }] } })).toBe(true);
40
41
  expect(hasRenderableText({ body: { fragments: [{ kind: "text", text: " " }] } })).toBe(false);
42
+ expect(hasRenderableText({ body: { fragments: [{ kind: "image" }] } })).toBe(false);
41
43
  });
42
44
  });
package/src/protocol.ts CHANGED
@@ -31,8 +31,12 @@ export function hasRenderableText(message: {
31
31
  const fragments = message?.body?.fragments ?? [];
32
32
  return fragments.some(
33
33
  (f) =>
34
- (f as { kind?: string }).kind === "text" &&
35
- typeof (f as { text?: unknown }).text === "string" &&
36
- (f as { text: string }).text.trim().length > 0,
34
+ (((f as { kind?: string }).kind === "text" &&
35
+ typeof (f as { text?: unknown }).text === "string" &&
36
+ (f as { text: string }).text.trim().length > 0) ||
37
+ (typeof (f as { kind?: unknown }).kind === "string" &&
38
+ ["image", "file", "audio", "video"].includes((f as { kind: string }).kind) &&
39
+ typeof (f as { url?: unknown }).url === "string" &&
40
+ (f as { url: string }).url.trim().length > 0)),
37
41
  );
38
42
  }
@@ -166,6 +166,8 @@ describe("openclaw-clawchat runtime media ingest", () => {
166
166
  event: "message.send",
167
167
  trace_id: "ti",
168
168
  emitted_at: Date.now(),
169
+ chat_id: "chat-1",
170
+ chat_type: "direct",
169
171
  to: { id: "u", type: "direct" },
170
172
  sender: { sender_id: "user-1", type: "direct", display_name: "User" },
171
173
  payload: {
@@ -198,6 +200,152 @@ describe("openclaw-clawchat runtime media ingest", () => {
198
200
  expect(fetched).toEqual([{ url: "https://cdn/a.png" }]);
199
201
  expect(capturedCtx?.MediaPath).toBe("/cache/1.png");
200
202
  expect(capturedCtx?.MediaPaths).toEqual(["/cache/1.png"]);
203
+ expect(capturedCtx?.From).toBe("openclaw-clawchat:chat-1");
204
+ expect(capturedCtx?.ConversationLabel).toBe("chat-1");
205
+ expect(capturedCtx?.SenderId).toBe("user-1");
206
+ });
207
+
208
+ it("uses group chat_id as the canonical conversation identity", async () => {
209
+ let capturedCtx: Record<string, unknown> | undefined;
210
+ const finalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => {
211
+ capturedCtx = ctx;
212
+ return ctx;
213
+ });
214
+
215
+ const runtime = {
216
+ channel: {
217
+ routing: {
218
+ resolveAgentRoute: vi.fn(() => ({
219
+ agentId: "u",
220
+ accountId: "default",
221
+ sessionKey: "s",
222
+ })),
223
+ },
224
+ session: {
225
+ resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
226
+ recordInboundSession: vi.fn().mockResolvedValue(undefined),
227
+ },
228
+ reply: {
229
+ formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
230
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
231
+ finalizeInboundContext,
232
+ resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
233
+ createReplyDispatcherWithTyping: vi.fn(() => ({
234
+ dispatcher: {},
235
+ replyOptions: {},
236
+ markDispatchIdle: vi.fn(),
237
+ markRunComplete: vi.fn(),
238
+ })),
239
+ withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => {
240
+ await opts.run();
241
+ }),
242
+ dispatchReplyFromConfig: vi.fn().mockResolvedValue(undefined),
243
+ },
244
+ media: {
245
+ fetchRemoteMedia: vi.fn(),
246
+ saveMediaBuffer: vi.fn(),
247
+ loadWebMedia: vi.fn(),
248
+ },
249
+ },
250
+ } as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
251
+
252
+ setOpenclawClawlingRuntime(runtime);
253
+
254
+ const { startOpenclawClawlingGateway } = await import("./runtime.ts");
255
+ const transport = new MockTransport();
256
+ const abortController = new AbortController();
257
+
258
+ const startPromise = startOpenclawClawlingGateway({
259
+ cfg: {} as import("openclaw/plugin-sdk/core").OpenClawConfig,
260
+ account: {
261
+ accountId: "default",
262
+ name: "openclaw-clawchat",
263
+ enabled: true,
264
+ configured: true,
265
+ websocketUrl: "ws://t",
266
+ baseUrl: "https://api.example.com",
267
+ token: "tk",
268
+ userId: "u",
269
+ replyMode: "static",
270
+ groupMode: "all",
271
+ forwardThinking: true,
272
+ forwardToolCalls: false,
273
+ allowFrom: [],
274
+ stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
275
+ reconnect: {
276
+ initialDelay: 1000,
277
+ maxDelay: 30000,
278
+ jitterRatio: 0.3,
279
+ maxRetries: Number.POSITIVE_INFINITY,
280
+ },
281
+ heartbeat: { interval: 25000, timeout: 10000 },
282
+ ack: { timeout: 10000, autoResendOnTimeout: false },
283
+ },
284
+ abortSignal: abortController.signal,
285
+ setStatus: vi.fn(),
286
+ getStatus: vi.fn(() => ({ accountId: "default" })),
287
+ log: { info: () => {}, error: () => {} },
288
+ transport,
289
+ });
290
+
291
+ await new Promise((r) => setTimeout(r, 0));
292
+ transport.emitInbound(
293
+ JSON.stringify({
294
+ version: "2",
295
+ event: "connect.challenge",
296
+ trace_id: "tc",
297
+ emitted_at: Date.now(),
298
+ payload: { nonce: "n" },
299
+ }),
300
+ );
301
+ transport.emitInbound(
302
+ JSON.stringify({
303
+ version: "2",
304
+ event: "hello-ok",
305
+ trace_id: "th",
306
+ emitted_at: Date.now(),
307
+ payload: {},
308
+ }),
309
+ );
310
+ await new Promise((r) => setTimeout(r, 5));
311
+
312
+ transport.emitInbound(
313
+ JSON.stringify({
314
+ version: "2",
315
+ event: "message.send",
316
+ trace_id: "tg",
317
+ emitted_at: Date.now(),
318
+ chat_id: "grp-1",
319
+ chat_type: "group",
320
+ to: { id: "u", type: "group" },
321
+ sender: { sender_id: "user-1", type: "group", display_name: "Alice" },
322
+ payload: {
323
+ message_id: "m-group",
324
+ message_mode: "normal",
325
+ message: {
326
+ body: {
327
+ fragments: [{ kind: "text", text: "hello group" }],
328
+ },
329
+ context: { mentions: [], reply: null },
330
+ streaming: {
331
+ status: "static",
332
+ sequence: 0,
333
+ mutation_policy: "sealed",
334
+ started_at: null,
335
+ completed_at: null,
336
+ },
337
+ },
338
+ },
339
+ }),
340
+ );
341
+ await new Promise((r) => setTimeout(r, 30));
342
+ abortController.abort();
343
+ await startPromise;
344
+
345
+ expect(capturedCtx?.From).toBe("openclaw-clawchat:group:grp-1");
346
+ expect(capturedCtx?.ConversationLabel).toBe("group:grp-1");
347
+ expect(capturedCtx?.SenderId).toBe("user-1");
348
+ expect(capturedCtx?.ChatType).toBe("group");
201
349
  });
202
350
  });
203
351
 
package/src/runtime.ts CHANGED
@@ -80,6 +80,10 @@ export function classifyClawlingClientError(err: unknown): {
80
80
  };
81
81
  }
82
82
 
83
+ function formatConversationSubject(peer: { kind: "direct" | "group"; id: string }): string {
84
+ return peer.kind === "group" ? `group:${peer.id}` : peer.id;
85
+ }
86
+
83
87
  export interface StartGatewayParams {
84
88
  cfg: OpenClawConfig;
85
89
  account: ResolvedOpenclawClawlingAccount;
@@ -152,7 +156,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
152
156
  });
153
157
  const body = rt.reply.formatAgentEnvelope({
154
158
  channel: "Clawling Chat",
155
- from: turn.senderNickName || turn.senderId,
159
+ from: formatConversationSubject(turn.peer),
156
160
  body: turn.rawBody,
157
161
  timestamp: turn.timestamp,
158
162
  ...rt.reply.resolveEnvelopeFormatOptions(cfg),
@@ -162,12 +166,16 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
162
166
  BodyForAgent: turn.rawBody,
163
167
  RawBody: turn.rawBody,
164
168
  CommandBody: turn.rawBody,
165
- From: `${CHANNEL_ID}:${turn.senderId}`,
169
+ // Clawling v2 routes by chat_id. `senderId` is still preserved as
170
+ // structured metadata, but the conversation target must be based on
171
+ // `peer.id` so follow-up sends address the active chat, not merely
172
+ // the human sender identity.
173
+ From: `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`,
166
174
  To: `${CHANNEL_ID}:${account.userId}`,
167
175
  SessionKey: route.sessionKey,
168
176
  AccountId: route.accountId ?? accountId,
169
177
  ChatType: turn.peer.kind,
170
- ConversationLabel: turn.senderNickName || turn.senderId,
178
+ ConversationLabel: formatConversationSubject(turn.peer),
171
179
  SenderId: turn.senderId,
172
180
  Provider: CHANNEL_ID,
173
181
  Surface: CHANNEL_ID,
@@ -230,12 +238,14 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
230
238
  log?.info?.(
231
239
  `[${accountId}] openclaw-clawchat dispatching reply msg=${turn.messageId} session=${ctxPayload.SessionKey ?? route.sessionKey} agent=${route.agentId} agentsConfigured=[${agentsConfigured.join(",")}]`,
232
240
  );
241
+ const dispatchResult = await rt.reply.withReplyDispatcher({
242
+ dispatcher,
243
+ onSettled: () => markDispatchIdle(),
244
+ run: () =>
245
+ rt.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions }),
246
+ });
233
247
  try {
234
- const dispatchResult = await rt.reply.withReplyDispatcher({
235
- dispatcher,
236
- run: () =>
237
- rt.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions }),
238
- });
248
+
239
249
  const counts = (dispatchResult as { counts?: Record<string, number> } | undefined)?.counts ?? {};
240
250
  const queuedFinal = Boolean(
241
251
  (dispatchResult as { queuedFinal?: boolean } | undefined)?.queuedFinal,
@@ -255,8 +265,6 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
255
265
  `[${accountId}] openclaw-clawchat dispatch failed msg=${turn.messageId}: ${String(err)}`,
256
266
  );
257
267
  throw err;
258
- } finally {
259
- markDispatchIdle();
260
268
  }
261
269
  },
262
270
  }).catch((err) => {
@@ -22,9 +22,10 @@ export type ClawchatListFriendsParams = Static<typeof ClawchatListFriendsSchema>
22
22
 
23
23
  export const ClawchatUpdateMyProfileSchema = Type.Object({
24
24
  nickname: Type.Optional(Type.String({ description: "New Nick Name" })),
25
- avatar: Type.Optional(
26
- Type.String({ description: "Avatar URL (use clawchat_upload_file first to obtain)" }),
25
+ avatar_url: Type.Optional(
26
+ Type.String({ description: "Avatar URL (use clawchat_upload_avatar first to obtain)" }),
27
27
  ),
28
+ bio: Type.Optional(Type.String({ description: "New self-introduction / bio text" })),
28
29
  });
29
30
  export type ClawchatUpdateMyProfileParams = Static<typeof ClawchatUpdateMyProfileSchema>;
30
31
 
@@ -35,6 +36,13 @@ export const ClawchatUploadFileSchema = Type.Object({
35
36
  });
36
37
  export type ClawchatUploadFileParams = Static<typeof ClawchatUploadFileSchema>;
37
38
 
39
+ export const ClawchatUploadAvatarSchema = Type.Object({
40
+ filePath: Type.String({
41
+ description: "Absolute local path of the avatar image to upload (max 20MB)",
42
+ }),
43
+ });
44
+ export type ClawchatUploadAvatarParams = Static<typeof ClawchatUploadAvatarSchema>;
45
+
38
46
  export const ClawchatActivateSchema = Type.Object({
39
47
  code: Type.String({
40
48
  description:
package/src/tools.test.ts CHANGED
@@ -55,7 +55,7 @@ describe("registerOpenclawClawlingTools", () => {
55
55
  expect(registered).toHaveLength(0);
56
56
  });
57
57
 
58
- it("registers all six tools when configured (regardless of baseUrl)", () => {
58
+ it("registers all seven tools when configured (regardless of baseUrl)", () => {
59
59
  const { api, registered } = buildApi({
60
60
  configChannel: configuredChannel(/* no baseUrl */),
61
61
  });
@@ -67,6 +67,7 @@ describe("registerOpenclawClawlingTools", () => {
67
67
  "clawchat_get_user_info",
68
68
  "clawchat_list_friends",
69
69
  "clawchat_update_my_profile",
70
+ "clawchat_upload_avatar",
70
71
  "clawchat_upload_file",
71
72
  ]);
72
73
  });
@@ -91,7 +92,7 @@ describe("registerOpenclawClawlingTools", () => {
91
92
  expect(activate.description).toMatch(/verbatim/i);
92
93
  });
93
94
 
94
- it("clawchat_update_my_profile description names name + avatar triggers (EN + ZH)", () => {
95
+ it("clawchat_update_my_profile description names name + avatar + bio triggers (EN + ZH)", () => {
95
96
  const { api } = buildApi({ configChannel: configuredChannel() });
96
97
  const fullTools: Array<{ name: string; description?: string }> = [];
97
98
  api.registerTool = (tool: { name: string; description?: string }) => {
@@ -104,6 +105,34 @@ describe("registerOpenclawClawlingTools", () => {
104
105
  expect(update.description).toMatch(/你叫/);
105
106
  expect(update.description).toMatch(/avatar/i);
106
107
  expect(update.description).toMatch(/生成头像|换个头像/);
108
+ expect(update.description).toMatch(/clawchat_upload_avatar/);
109
+ expect(update.description).toMatch(/bio|self introduction/i);
110
+ expect(update.description).toMatch(/自我介绍|个人简介/);
111
+ });
112
+
113
+ it("clawchat_upload_avatar rejects oversized files before upload", async () => {
114
+ const fs = await import("node:fs/promises");
115
+ const path = await import("node:path");
116
+ const os = await import("node:os");
117
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawchat-"));
118
+ const big = path.join(tmp, "avatar-big.bin");
119
+ const handle = await fs.open(big, "w");
120
+ await handle.truncate(21 * 1024 * 1024);
121
+ await handle.close();
122
+ try {
123
+ const { api, registered } = buildApi({
124
+ configChannel: configuredChannel({ baseUrl: "https://api.example.com" }),
125
+ });
126
+ registerOpenclawClawlingTools(api);
127
+ const tool = registered.find((t) => t.name === "clawchat_upload_avatar")!;
128
+ const result = await tool.execute("call-1", { filePath: big });
129
+ const text = (result as { content: { text: string }[] }).content[0]!.text;
130
+ const parsed = JSON.parse(text) as { error?: string; message?: string };
131
+ expect(parsed.error).toBe("validation");
132
+ expect(parsed.message).toMatch(/20 ?MB|too large/i);
133
+ } finally {
134
+ await fs.rm(tmp, { recursive: true, force: true });
135
+ }
107
136
  });
108
137
 
109
138
 
package/src/tools.ts CHANGED
@@ -11,11 +11,13 @@ import {
11
11
  ClawchatGetUserInfoSchema,
12
12
  ClawchatListFriendsSchema,
13
13
  ClawchatUpdateMyProfileSchema,
14
+ ClawchatUploadAvatarSchema,
14
15
  ClawchatUploadFileSchema,
15
16
  type ClawchatActivateParams,
16
17
  type ClawchatGetUserInfoParams,
17
18
  type ClawchatListFriendsParams,
18
19
  type ClawchatUpdateMyProfileParams,
20
+ type ClawchatUploadAvatarParams,
19
21
  type ClawchatUploadFileParams,
20
22
  } from "./tools-schema.ts";
21
23
 
@@ -188,7 +190,7 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
188
190
  api.registerTool(
189
191
  {
190
192
  name: "clawchat_get_my_profile",
191
- label: "Clawling: Get My Profile",
193
+ label: "Get Profile",
192
194
  description: "Fetch the agent's own Clawling profile (id, display name, avatar, bio).",
193
195
  parameters: ClawchatGetMyProfileSchema,
194
196
  async execute(_callId, _params) {
@@ -201,7 +203,7 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
201
203
  api.registerTool(
202
204
  {
203
205
  name: "clawchat_get_user_info",
204
- label: "Clawling: Get User Info",
206
+ label: "Get User Info",
205
207
  description: "Fetch a Clawling user's public profile by userId.",
206
208
  parameters: ClawchatGetUserInfoSchema,
207
209
  async execute(_callId, params) {
@@ -215,7 +217,7 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
215
217
  api.registerTool(
216
218
  {
217
219
  name: "clawchat_list_friends",
218
- label: "Clawling: List Friends",
220
+ label: "List Friends",
219
221
  description: "List the agent's friends, paginated (page=1, pageSize=20 by default).",
220
222
  parameters: ClawchatListFriendsSchema,
221
223
  async execute(_callId, params) {
@@ -234,9 +236,9 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
234
236
  api.registerTool(
235
237
  {
236
238
  name: "clawchat_update_my_profile",
237
- label: "Clawling: Update My Profile",
239
+ label: "Update Profile",
238
240
  description:
239
- "Update this agent's own ClawChat profile (nickname and/or avatar). " +
241
+ "Update this agent's own ClawChat profile (nickname and/or avatar and/or bio). " +
240
242
  "TRIGGER — invoke this tool whenever the user's message matches ANY of: " +
241
243
  "(1) nickname/name change: 'change your name to X', 'your name is X', 'rename yourself to X', " +
242
244
  "'I'll call you X', 'from now on you are X', '你叫 X', '改名为 X', '我叫你 X', " +
@@ -244,19 +246,23 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
244
246
  "(2) avatar change or generation: 'change your avatar', 'update your profile picture', " +
245
247
  "'generate a new avatar', 'use this image as your avatar', '换个头像', '生成头像', " +
246
248
  "'把头像改为 …' → first obtain the avatar URL (generate + upload via " +
247
- "`clawchat_upload_file`, OR use a provided URL directly), then call this tool " +
248
- "with `avatar = <url>`. " +
249
- "You can pass `nickname` and `avatar` together in one call, or just one of them. " +
250
- "At least one of the two must be present.",
249
+ "`clawchat_upload_avatar`, OR use a provided URL directly), then call this tool " +
250
+ "with `avatar_url = <url>`; " +
251
+ "(3) bio/self-introduction change: 'update your bio', 'set your profile bio to X', " +
252
+ "'change your self introduction', '把简介改成 X', '更新自我介绍', '个人简介改为 X' " +
253
+ "→ call with `bio = X`. " +
254
+ "You can pass `nickname`, `avatar_url`, and `bio` together in one call, or just one of them. " +
255
+ "At least one of the three must be present.",
251
256
  parameters: ClawchatUpdateMyProfileSchema,
252
257
  async execute(_callId, params) {
253
258
  const p = (params ?? {}) as ClawchatUpdateMyProfileParams;
254
- const patch: { nick_name?: string; avatar?: string } = {};
255
- if (typeof p.nickname === "string") patch.nick_name = p.nickname;
256
- if (typeof p.avatar === "string") patch.avatar = p.avatar;
259
+ const patch: { nickname?: string; avatar_url?: string; bio?: string } = {};
260
+ if (typeof p.nickname === "string") patch.nickname = p.nickname;
261
+ if (typeof p.avatar_url === "string") patch.avatar_url = p.avatar_url;
262
+ if (typeof p.bio === "string") patch.bio = p.bio;
257
263
  if (Object.keys(patch).length === 0) {
258
264
  return validationError(
259
- "openclaw-clawchat: at least one of nickname / avatar is required",
265
+ "openclaw-clawchat: at least one of nickname / avatar / bio is required",
260
266
  );
261
267
  }
262
268
  return await withClient((c): Promise<Profile> => c.updateMyProfile(patch));
@@ -265,10 +271,48 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
265
271
  { name: "clawchat_update_my_profile" },
266
272
  );
267
273
 
274
+ api.registerTool(
275
+ {
276
+ name: "clawchat_upload_avatar",
277
+ label: "Upload Avatar",
278
+ description:
279
+ "Upload a local avatar image to Clawling avatar storage (max 20MB) and return the public URL. " +
280
+ "Use this before `clawchat_update_my_profile` when changing the profile picture.",
281
+ parameters: ClawchatUploadAvatarSchema,
282
+ async execute(_callId, params) {
283
+ const p = params as ClawchatUploadAvatarParams;
284
+ if (!p.filePath || !path.isAbsolute(p.filePath)) {
285
+ return validationError("openclaw-clawchat: filePath must be an absolute local path");
286
+ }
287
+ let stat: fs.Stats;
288
+ try {
289
+ stat = fs.statSync(p.filePath);
290
+ } catch (err) {
291
+ return validationError(
292
+ `openclaw-clawchat: cannot stat ${p.filePath}: ${err instanceof Error ? err.message : String(err)}`,
293
+ );
294
+ }
295
+ if (!stat.isFile()) {
296
+ return validationError(`openclaw-clawchat: ${p.filePath} is not a regular file`);
297
+ }
298
+ if (stat.size > MAX_UPLOAD_BYTES) {
299
+ return validationError(
300
+ `openclaw-clawchat: file too large (${stat.size} bytes; max 20MB)`,
301
+ );
302
+ }
303
+ const buffer = fs.readFileSync(p.filePath);
304
+ const filename = path.basename(p.filePath);
305
+ const mime = inferMimeFromPath(p.filePath);
306
+ return await withClient((c) => c.uploadAvatar({ buffer, filename, mime }));
307
+ },
308
+ },
309
+ { name: "clawchat_upload_avatar" },
310
+ );
311
+
268
312
  api.registerTool(
269
313
  {
270
314
  name: "clawchat_upload_file",
271
- label: "Clawling: Upload File",
315
+ label: "Upload File",
272
316
  description:
273
317
  "Upload a local file to Clawling media storage (max 20MB) and return the public URL.",
274
318
  parameters: ClawchatUploadFileSchema,
@@ -303,6 +347,6 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
303
347
  );
304
348
 
305
349
  api.logger.info?.(
306
- "openclaw-clawchat: registered 6 clawchat_* tools (activate, get_my_profile, get_user_info, list_friends, update_my_profile, upload_file)",
350
+ "openclaw-clawchat: registered 7 clawchat_* tools (activate, get_my_profile, get_user_info, list_friends, update_my_profile, upload_avatar, upload_file)",
307
351
  );
308
352
  }