@newbase-clawchat/openclaw-clawchat 2026.4.21 → 2026.4.23

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.23",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "files": [
6
6
  "index.ts",
@@ -12,8 +12,7 @@
12
12
  "scripts": {
13
13
  "typecheck": "tsc --noEmit",
14
14
  "prepublishOnly": "npm run typecheck",
15
- "release": "npm run prepublishOnly && npm publish",
16
- "test-ui": "node ./tools/standalone-webchat-server.mjs"
15
+ "release": "npm run prepublishOnly && npm publish"
17
16
  },
18
17
  "dependencies": {
19
18
  "@newbase-clawchat/sdk": "^0.1.0",
@@ -46,11 +45,10 @@
46
45
  "docsPath": "/channels/openclaw-clawchat",
47
46
  "docsLabel": "openclaw-clawchat",
48
47
  "blurb": "OpenClaw ClawChat channel plugin",
49
- "order": 110
48
+ "order": 70
50
49
  },
51
50
  "install": {
52
51
  "npmSpec": "@newbase-clawchat/openclaw-clawchat",
53
- "localPath": "extensions/openclaw-clawchat",
54
52
  "defaultChoice": "npm",
55
53
  "minHostVersion": ">=2026.3.23"
56
54
  }
@@ -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,203 @@
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 waitForClientMock = vi.hoisted(() => vi.fn());
6
+ const uploadOutboundMediaMock = vi.hoisted(() => vi.fn());
7
+ const createApiClientMock = vi.hoisted(() => vi.fn());
8
+ const sendTextMock = vi.hoisted(() => vi.fn());
9
+ const sendMediaMock = vi.hoisted(() => vi.fn());
10
+
11
+ vi.mock("./runtime.ts", () => ({
12
+ getOpenclawClawlingClient: getClientMock,
13
+ getOpenclawClawlingRuntime: getRuntimeMock,
14
+ waitForOpenclawClawlingClient: waitForClientMock,
15
+ startOpenclawClawlingGateway: vi.fn(),
16
+ }));
17
+
18
+ vi.mock("./media-runtime.ts", () => ({
19
+ uploadOutboundMedia: uploadOutboundMediaMock,
20
+ }));
21
+
22
+ vi.mock("./api-client.ts", () => ({
23
+ createOpenclawClawlingApiClient: createApiClientMock,
24
+ }));
25
+
26
+ vi.mock("./outbound.ts", () => ({
27
+ sendOpenclawClawlingText: sendTextMock,
28
+ sendOpenclawClawlingMedia: sendMediaMock,
29
+ }));
30
+
31
+ describe("openclaw-clawchat channel outbound", () => {
32
+ beforeEach(() => {
33
+ vi.resetModules();
34
+ getClientMock.mockReset();
35
+ getRuntimeMock.mockReset();
36
+ waitForClientMock.mockReset();
37
+ uploadOutboundMediaMock.mockReset();
38
+ createApiClientMock.mockReset();
39
+ sendTextMock.mockReset();
40
+ sendMediaMock.mockReset();
41
+ });
42
+
43
+ it("sendText waits for client activation when no active client exists yet", async () => {
44
+ const client = { sendMessage: vi.fn() };
45
+ getClientMock.mockReturnValue(undefined);
46
+ waitForClientMock.mockResolvedValue(client);
47
+ sendTextMock.mockResolvedValue({ messageId: "m-2", acceptedAt: 456 });
48
+
49
+ const { openclawClawlingOutbound } = await import("./outbound.ts");
50
+ const result = await openclawClawlingOutbound.sendText!({
51
+ cfg: {
52
+ channels: {
53
+ "openclaw-clawchat": {
54
+ enabled: true,
55
+ websocketUrl: "ws://t",
56
+ baseUrl: "https://api.example.com",
57
+ token: "tk",
58
+ userId: "agent-1",
59
+ },
60
+ },
61
+ } as never,
62
+ to: "cc:user-1",
63
+ text: "hello",
64
+ });
65
+
66
+ expect(waitForClientMock).toHaveBeenCalledWith("default");
67
+ expect(sendTextMock).toHaveBeenCalledWith({
68
+ client,
69
+ account: expect.objectContaining({ userId: "agent-1" }),
70
+ to: { chatId: "user-1", chatType: "direct" },
71
+ text: "hello",
72
+ });
73
+ expect(result).toEqual({
74
+ channel: "openclaw-clawchat",
75
+ to: "cc:user-1",
76
+ messageId: "m-2",
77
+ });
78
+ });
79
+
80
+ it("sendMedia uploads mediaUrl and sends resulting fragments", async () => {
81
+ const client = { sendMessage: vi.fn() };
82
+ const runtime = { media: { loadWebMedia: vi.fn() } };
83
+ const apiClient = { uploadMedia: vi.fn() };
84
+ getClientMock.mockReturnValue(client);
85
+ getRuntimeMock.mockReturnValue(runtime);
86
+ createApiClientMock.mockReturnValue(apiClient);
87
+ uploadOutboundMediaMock.mockResolvedValue([
88
+ { kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
89
+ ]);
90
+ sendMediaMock.mockResolvedValue({ messageId: "m-1", acceptedAt: 123 });
91
+
92
+ const { openclawClawlingOutbound } = await import("./outbound.ts");
93
+ const result = await openclawClawlingOutbound.sendMedia!({
94
+ cfg: {
95
+ channels: {
96
+ "openclaw-clawchat": {
97
+ enabled: true,
98
+ websocketUrl: "ws://t",
99
+ baseUrl: "https://api.example.com",
100
+ token: "tk",
101
+ userId: "agent-1",
102
+ },
103
+ },
104
+ } as never,
105
+ to: "cc:group:room-1",
106
+ text: "caption",
107
+ mediaUrl: "/tmp/photo.png",
108
+ mediaLocalRoots: ["/tmp"],
109
+ });
110
+
111
+ expect(createApiClientMock).toHaveBeenCalledWith({
112
+ baseUrl: "https://api.example.com",
113
+ token: "tk",
114
+ userId: "agent-1",
115
+ });
116
+ expect(uploadOutboundMediaMock).toHaveBeenCalledWith(["/tmp/photo.png"], {
117
+ apiClient,
118
+ runtime,
119
+ mediaLocalRoots: ["/tmp"],
120
+ });
121
+ expect(sendMediaMock).toHaveBeenCalledWith({
122
+ client,
123
+ account: expect.objectContaining({ userId: "agent-1", baseUrl: "https://api.example.com" }),
124
+ to: { chatId: "room-1", chatType: "group" },
125
+ text: "caption",
126
+ mediaFragments: [{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" }],
127
+ });
128
+ expect(result).toEqual({
129
+ channel: "openclaw-clawchat",
130
+ to: "cc:group:room-1",
131
+ messageId: "m-1",
132
+ });
133
+ });
134
+
135
+ it("sendMedia rejects missing mediaUrl", async () => {
136
+ getClientMock.mockReturnValue({ sendMessage: vi.fn() });
137
+ const { openclawClawlingOutbound } = await import("./outbound.ts");
138
+ await expect(
139
+ openclawClawlingOutbound.sendMedia!({
140
+ cfg: {
141
+ channels: {
142
+ "openclaw-clawchat": {
143
+ enabled: true,
144
+ websocketUrl: "ws://t",
145
+ baseUrl: "https://api.example.com",
146
+ token: "tk",
147
+ userId: "agent-1",
148
+ },
149
+ },
150
+ } as never,
151
+ to: "cc:user-1",
152
+ text: "caption",
153
+ }),
154
+ ).rejects.toThrow(/requires mediaUrl/);
155
+ });
156
+
157
+ it("sendMedia waits for client activation when no active client exists yet", async () => {
158
+ const client = { sendMessage: vi.fn() };
159
+ const runtime = { media: { loadWebMedia: vi.fn() } };
160
+ const apiClient = { uploadMedia: vi.fn() };
161
+ getClientMock.mockReturnValue(undefined);
162
+ waitForClientMock.mockResolvedValue(client);
163
+ getRuntimeMock.mockReturnValue(runtime);
164
+ createApiClientMock.mockReturnValue(apiClient);
165
+ uploadOutboundMediaMock.mockResolvedValue([
166
+ { kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
167
+ ]);
168
+ sendMediaMock.mockResolvedValue({ messageId: "m-3", acceptedAt: 789 });
169
+
170
+ const { openclawClawlingOutbound } = await import("./outbound.ts");
171
+ const result = await openclawClawlingOutbound.sendMedia!({
172
+ cfg: {
173
+ channels: {
174
+ "openclaw-clawchat": {
175
+ enabled: true,
176
+ websocketUrl: "ws://t",
177
+ baseUrl: "https://api.example.com",
178
+ token: "tk",
179
+ userId: "agent-1",
180
+ },
181
+ },
182
+ } as never,
183
+ to: "cc:group:room-1",
184
+ text: "caption",
185
+ mediaUrl: "/tmp/photo.png",
186
+ mediaLocalRoots: ["/tmp"],
187
+ });
188
+
189
+ expect(waitForClientMock).toHaveBeenCalledWith("default");
190
+ expect(sendMediaMock).toHaveBeenCalledWith({
191
+ client,
192
+ account: expect.objectContaining({ userId: "agent-1", baseUrl: "https://api.example.com" }),
193
+ to: { chatId: "room-1", chatType: "group" },
194
+ text: "caption",
195
+ mediaFragments: [{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" }],
196
+ });
197
+ expect(result).toEqual({
198
+ channel: "openclaw-clawchat",
199
+ to: "cc:group:room-1",
200
+ messageId: "m-3",
201
+ });
202
+ });
203
+ });
@@ -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 }