@newbase-clawchat/openclaw-clawchat 2026.4.15

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.
@@ -0,0 +1,257 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { ClawlingApiError } from "./api-types.ts";
4
+ import { runOpenclawClawlingLogin } from "./login.runtime.ts";
5
+
6
+ const CHANNEL_ID = "openclaw-clawchat";
7
+
8
+ function buildCfg(
9
+ section: Record<string, unknown> = { baseUrl: "https://api.example.com" },
10
+ ): OpenClawConfig {
11
+ return {
12
+ channels: { [CHANNEL_ID]: section },
13
+ } as unknown as OpenClawConfig;
14
+ }
15
+
16
+ function makeApiClient(overrides: {
17
+ agentsConnect: ReturnType<typeof vi.fn>;
18
+ }): ReturnType<typeof import("./api-client.ts").createOpenclawClawlingApiClient> {
19
+ return {
20
+ getMyProfile: vi.fn(),
21
+ getUserInfo: vi.fn(),
22
+ listFriends: vi.fn(),
23
+ updateMyProfile: vi.fn(),
24
+ uploadMedia: vi.fn(),
25
+ agentsConnect: overrides.agentsConnect,
26
+ } as never;
27
+ }
28
+
29
+ describe("runOpenclawClawlingLogin", () => {
30
+ it("works with no prior setup (baseUrl / websocketUrl default built-in)", async () => {
31
+ const cfg = buildCfg({}); // empty section — resolver provides defaults
32
+ const agentsConnect = vi.fn().mockResolvedValue({
33
+ agent: { user_id: "u" },
34
+ access_token: "t",
35
+ refresh_token: "r",
36
+ });
37
+ let capturedBaseUrl = "";
38
+ await runOpenclawClawlingLogin({
39
+ cfg,
40
+ runtime: { log: vi.fn() },
41
+ readInviteCode: async () => "INV",
42
+ apiClientFactory: (opts) => {
43
+ capturedBaseUrl = opts.baseUrl;
44
+ return makeApiClient({ agentsConnect });
45
+ },
46
+ persistConfig: vi.fn(),
47
+ });
48
+ // The api-client was constructed with a non-empty baseUrl sourced
49
+ // from the default, even though the cfg had none.
50
+ expect(capturedBaseUrl).toMatch(/^https?:\/\//);
51
+ });
52
+
53
+ it("errors if invite code is blank", async () => {
54
+ const cfg = buildCfg({ baseUrl: "https://api.example.com" });
55
+ await expect(
56
+ runOpenclawClawlingLogin({
57
+ cfg,
58
+ runtime: { log: vi.fn() },
59
+ readInviteCode: async () => " ",
60
+ }),
61
+ ).rejects.toThrow(/invite code is required/);
62
+ });
63
+
64
+ it("calls agents/connect with the invite code and persists returned credentials", async () => {
65
+ const cfg = buildCfg({
66
+ baseUrl: "https://api.example.com",
67
+ websocketUrl: "wss://ws.example.com/v2/client",
68
+ });
69
+ const agentsConnect = vi.fn().mockResolvedValue({
70
+ agent: {
71
+ id: "ag-1",
72
+ owner_id: "owner-1",
73
+ user_id: "agent-123",
74
+ type: "bot",
75
+ nickname: "Bot",
76
+ avatar_url: "",
77
+ bio: "",
78
+ visibility: "public",
79
+ status: "active",
80
+ platform: "clawchat",
81
+ created_at: "2026-04-17T00:00:00Z",
82
+ },
83
+ access_token: "access-tok",
84
+ refresh_token: "refresh-tok",
85
+ });
86
+ const persistConfig = vi.fn();
87
+ const log = vi.fn();
88
+
89
+ await runOpenclawClawlingLogin({
90
+ cfg,
91
+ runtime: { log },
92
+ readInviteCode: async () => "INV-ABC",
93
+ apiClientFactory: () => makeApiClient({ agentsConnect }),
94
+ persistConfig,
95
+ });
96
+
97
+ expect(agentsConnect).toHaveBeenCalledWith({
98
+ inviteCode: "INV-ABC",
99
+ platform: "openclaw",
100
+ type: "clawbot",
101
+ });
102
+ expect(persistConfig).toHaveBeenCalledTimes(1);
103
+ const savedCfg = persistConfig.mock.calls[0]![0] as OpenClawConfig;
104
+ const section = (savedCfg.channels as Record<string, Record<string, unknown>>)[CHANNEL_ID]!;
105
+ expect(section.token).toBe("access-tok");
106
+ expect(section.refreshToken).toBe("refresh-tok");
107
+ expect(section.userId).toBe("agent-123");
108
+ // Existing baseUrl and websocketUrl are preserved — agents/connect
109
+ // does not return them.
110
+ expect(section.baseUrl).toBe("https://api.example.com");
111
+ expect(section.websocketUrl).toBe("wss://ws.example.com/v2/client");
112
+ expect(log).toHaveBeenCalledWith(expect.stringContaining("login succeeded"));
113
+ });
114
+
115
+ it("surfaces agents/connect API errors with the kind and message", async () => {
116
+ const cfg = buildCfg({ baseUrl: "https://api.example.com" });
117
+ const agentsConnect = vi.fn().mockRejectedValue(
118
+ new ClawlingApiError("api", "invite code expired", { code: 4002 }),
119
+ );
120
+ await expect(
121
+ runOpenclawClawlingLogin({
122
+ cfg,
123
+ runtime: { log: vi.fn() },
124
+ readInviteCode: async () => "expired",
125
+ apiClientFactory: () => makeApiClient({ agentsConnect }),
126
+ persistConfig: vi.fn(),
127
+ }),
128
+ ).rejects.toThrow(/agents\/connect failed \(api\): invite code expired/);
129
+ });
130
+
131
+ it("rejects responses that omit required fields", async () => {
132
+ const cfg = buildCfg({ baseUrl: "https://api.example.com" });
133
+ const agentsConnect = vi.fn().mockResolvedValue({
134
+ agent: { user_id: "" },
135
+ access_token: "t",
136
+ refresh_token: "r",
137
+ });
138
+ await expect(
139
+ runOpenclawClawlingLogin({
140
+ cfg,
141
+ runtime: { log: vi.fn() },
142
+ readInviteCode: async () => "ok",
143
+ apiClientFactory: () => makeApiClient({ agentsConnect }),
144
+ persistConfig: vi.fn(),
145
+ }),
146
+ ).rejects.toThrow(/missing required fields/);
147
+ });
148
+
149
+ it("persists the config automatically after receiving the token (no further prompts)", async () => {
150
+ const cfg = buildCfg({
151
+ baseUrl: "https://api.example.com",
152
+ websocketUrl: "wss://ws.example.com",
153
+ });
154
+ const agentsConnect = vi.fn().mockResolvedValue({
155
+ agent: { user_id: "agent-9", nickname: "Nine" },
156
+ access_token: "ACC-0123456789",
157
+ refresh_token: "REF-xyz",
158
+ });
159
+ const persistCalls: OpenClawConfig[] = [];
160
+ const logMessages: string[] = [];
161
+ await runOpenclawClawlingLogin({
162
+ cfg,
163
+ runtime: { log: (m) => logMessages.push(m) },
164
+ readInviteCode: async () => "INV-AUTO",
165
+ apiClientFactory: () => makeApiClient({ agentsConnect }),
166
+ persistConfig: (next) => {
167
+ persistCalls.push(next);
168
+ },
169
+ });
170
+ // persistConfig is invoked exactly once with the merged credentials.
171
+ expect(persistCalls).toHaveLength(1);
172
+ const section = (persistCalls[0]!.channels as Record<string, Record<string, unknown>>)[CHANNEL_ID]!;
173
+ expect(section.token).toBe("ACC-0123456789");
174
+ expect(section.userId).toBe("agent-9");
175
+ expect(section.refreshToken).toBe("REF-xyz");
176
+ // Existing URL fields are preserved.
177
+ expect(section.baseUrl).toBe("https://api.example.com");
178
+ expect(section.websocketUrl).toBe("wss://ws.example.com");
179
+ // Operator-visible log confirms the automatic config write.
180
+ expect(logMessages.some((m) => /Updating config/.test(m))).toBe(true);
181
+ expect(logMessages.some((m) => /Config file updated/.test(m))).toBe(true);
182
+ // Token in logs is redacted.
183
+ expect(logMessages.every((m) => !m.includes("ACC-0123456789"))).toBe(true);
184
+ });
185
+
186
+ it("login logs don't leak the /agents/connect endpoint path or base URL", async () => {
187
+ const cfg = buildCfg({ baseUrl: "https://api.example.com" });
188
+ const agentsConnect = vi.fn().mockResolvedValue({
189
+ agent: { user_id: "u" },
190
+ access_token: "t",
191
+ refresh_token: "r",
192
+ });
193
+ const logMessages: string[] = [];
194
+ await runOpenclawClawlingLogin({
195
+ cfg,
196
+ runtime: { log: (m) => logMessages.push(m) },
197
+ readInviteCode: async () => "INV",
198
+ apiClientFactory: () => makeApiClient({ agentsConnect }),
199
+ persistConfig: vi.fn(),
200
+ });
201
+ expect(logMessages.every((m) => !m.includes("/agents/connect"))).toBe(true);
202
+ expect(logMessages.every((m) => !m.includes(cfg.channels!["openclaw-clawchat"].baseUrl))).toBe(
203
+ true,
204
+ );
205
+ });
206
+
207
+ it("trims the invite code before sending", async () => {
208
+ const cfg = buildCfg({ baseUrl: "https://api.example.com" });
209
+ const agentsConnect = vi.fn().mockResolvedValue({
210
+ agent: { user_id: "u" },
211
+ access_token: "t",
212
+ refresh_token: "r",
213
+ });
214
+ await runOpenclawClawlingLogin({
215
+ cfg,
216
+ runtime: { log: vi.fn() },
217
+ readInviteCode: async () => " INV-TRIM ",
218
+ apiClientFactory: () => makeApiClient({ agentsConnect }),
219
+ persistConfig: vi.fn(),
220
+ });
221
+ expect(agentsConnect).toHaveBeenCalledWith({
222
+ inviteCode: "INV-TRIM",
223
+ platform: "openclaw",
224
+ type: "clawbot",
225
+ });
226
+ });
227
+ });
228
+
229
+ describe("runOpenclawClawlingLogin (non-interactive via readInviteCode)", () => {
230
+ it("performs a full login when called with a fixed readInviteCode (programmatic path)", async () => {
231
+ const cfg = buildCfg({ baseUrl: "https://api.example.com" });
232
+ const agentsConnect = vi.fn().mockResolvedValue({
233
+ agent: { user_id: "agent-7", nickname: "Seven" },
234
+ access_token: "acc-non-interactive",
235
+ refresh_token: "ref-ni",
236
+ });
237
+ const persistConfig = vi.fn();
238
+ await runOpenclawClawlingLogin({
239
+ cfg,
240
+ runtime: { log: () => {} },
241
+ readInviteCode: async () => "INV-PROGRAMMATIC",
242
+ apiClientFactory: () => makeApiClient({ agentsConnect }),
243
+ persistConfig,
244
+ });
245
+ expect(agentsConnect).toHaveBeenCalledWith({
246
+ inviteCode: "INV-PROGRAMMATIC",
247
+ platform: "openclaw",
248
+ type: "clawbot",
249
+ });
250
+ expect(persistConfig).toHaveBeenCalledTimes(1);
251
+ const section = (persistConfig.mock.calls[0]![0] as OpenClawConfig).channels!
252
+ ["openclaw-clawchat"] as Record<string, unknown>;
253
+ expect(section.token).toBe("acc-non-interactive");
254
+ expect(section.userId).toBe("agent-7");
255
+ expect(section.refreshToken).toBe("ref-ni");
256
+ });
257
+ });
@@ -0,0 +1,153 @@
1
+ import { createInterface, type Interface as ReadlineInterface } from "node:readline/promises";
2
+ import { writeConfigFile } from "openclaw/plugin-sdk/config-runtime";
3
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
4
+ import { createOpenclawClawlingApiClient } from "./api-client.ts";
5
+ import { ClawlingApiError } from "./api-types.ts";
6
+ import { CHANNEL_ID, resolveOpenclawClawlingAccount } from "./config.ts";
7
+
8
+ /**
9
+ * Platform tag sent to `/v1/agents/connect`. Identifies the host of this
10
+ * agent runtime — openclaw's bundled clawchat channel.
11
+ */
12
+ export const AGENTS_CONNECT_PLATFORM = "openclaw" as const;
13
+ /**
14
+ * Agent type tag sent to `/v1/agents/connect`. The clawchat channel is
15
+ * always a bot; humans don't log in through this flow.
16
+ */
17
+ export const AGENTS_CONNECT_TYPE = "clawbot" as const;
18
+
19
+ export interface LoginParams {
20
+ cfg: OpenClawConfig;
21
+ accountId?: string | null;
22
+ runtime: { log: (message: string) => void };
23
+ /**
24
+ * Override for the invite-code prompt — used by tests. Defaults to
25
+ * stdin via `node:readline/promises`.
26
+ */
27
+ readInviteCode?: () => Promise<string>;
28
+ /** Override for the HTTP client — used by tests. */
29
+ apiClientFactory?: typeof createOpenclawClawlingApiClient;
30
+ /** Override for config persistence — used by tests. */
31
+ persistConfig?: (cfg: OpenClawConfig) => Promise<void> | void;
32
+ }
33
+
34
+ /**
35
+ * Prompt the operator for an invite code.
36
+ *
37
+ * The prompt text is emitted via `runtime.log` so it flows through the
38
+ * same openclaw logging pipeline every other channel plugin uses (no
39
+ * clack frame, no raw-mode takeover, no TTY detection). Input is read
40
+ * from stdin with `node:readline` — Enter-to-submit is plain language in
41
+ * the prompt so any upstream LLM / orchestrator reading the log stream
42
+ * knows a newline is expected, and the behavior is identical under a
43
+ * TTY, piped stdin, or a test harness.
44
+ */
45
+ async function promptInviteCodeFromStdin(runtime: {
46
+ log: (message: string) => void;
47
+ }): Promise<string> {
48
+ runtime.log("Please enter your ClawChat invite code (press Enter to submit):");
49
+ let rl: ReadlineInterface | undefined;
50
+ try {
51
+ rl = createInterface({ input: process.stdin, output: process.stdout });
52
+ const answer = await rl.question("> ");
53
+ return answer.trim();
54
+ } finally {
55
+ rl?.close();
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Run the `openclaw channels login --channel openclaw-clawchat` flow:
61
+ * 1. Read the existing channel section; require `baseUrl` to be set so we
62
+ * know which server to hit.
63
+ * 2. Prompt the user for an invite code on stdin.
64
+ * 3. POST it to `${baseUrl}/v1/agents/connect`.
65
+ * 4. Write the returned `websocket_url` / `token` / `user_id` back into
66
+ * the config so subsequent `openclaw gateway run` picks them up.
67
+ *
68
+ * Errors surface with clear messages (missing baseUrl, empty invite,
69
+ * server-side rejection) so the caller can relay them to the operator.
70
+ */
71
+ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<void> {
72
+ const { cfg, runtime } = params;
73
+
74
+ // `resolveOpenclawClawlingAccount` falls back to the built-in
75
+ // `DEFAULT_BASE_URL` / `DEFAULT_WEBSOCKET_URL` when the operator has not
76
+ // overridden them, so login works without a prior `openclaw channels setup --channel openclaw-clawchat`.
77
+ const account = resolveOpenclawClawlingAccount(cfg);
78
+
79
+ const inviteCode = (
80
+ await (params.readInviteCode ?? (() => promptInviteCodeFromStdin(runtime)))()
81
+ ).trim();
82
+ if (!inviteCode) {
83
+ throw new Error("Login aborted: invite code is required.");
84
+ }
85
+
86
+ const apiClient = (params.apiClientFactory ?? createOpenclawClawlingApiClient)({
87
+ baseUrl: account.baseUrl,
88
+ // Pre-login we may not have a token yet. Send the current one (or empty)
89
+ // — the server should accept an unauthenticated invite-code exchange.
90
+ token: account.token || "",
91
+ });
92
+
93
+ runtime.log("Verifying invite code …");
94
+ let result;
95
+ try {
96
+ result = await apiClient.agentsConnect({
97
+ inviteCode,
98
+ platform: AGENTS_CONNECT_PLATFORM,
99
+ type: AGENTS_CONNECT_TYPE,
100
+ });
101
+ } catch (err) {
102
+ if (err instanceof ClawlingApiError) {
103
+ throw new Error(`agents/connect failed (${err.kind}): ${err.message}`);
104
+ }
105
+ throw err;
106
+ }
107
+
108
+ if (!result?.access_token || !result?.agent?.user_id) {
109
+ throw new Error(
110
+ `agents/connect response missing required fields (access_token / agent.user_id): ${JSON.stringify(result)}`,
111
+ );
112
+ }
113
+
114
+ // Merge credentials into cfg.channels.openclaw-clawchat and persist
115
+ // immediately so a subsequent `openclaw gateway run` picks them up
116
+ // without any manual edit. `baseUrl` / `websocketUrl` stay untouched —
117
+ // the built-in defaults (or operator overrides) remain authoritative
118
+ // because `/v1/agents/connect` doesn't return them.
119
+ const channels = (cfg.channels ?? {}) as Record<string, unknown>;
120
+ const existing = (channels[CHANNEL_ID] ?? {}) as Record<string, unknown>;
121
+ const nextSection: Record<string, unknown> = {
122
+ ...existing,
123
+ token: result.access_token,
124
+ userId: result.agent.user_id,
125
+ };
126
+ if (result.refresh_token) {
127
+ nextSection.refreshToken = result.refresh_token;
128
+ }
129
+ const nextCfg: OpenClawConfig = {
130
+ ...cfg,
131
+ channels: { ...channels, [CHANNEL_ID]: nextSection },
132
+ };
133
+
134
+ const tokenPreview = redactToken(result.access_token);
135
+ runtime.log(
136
+ `Updating config: channels.${CHANNEL_ID}.token=${tokenPreview} userId=${result.agent.user_id}${
137
+ result.refresh_token ? " refreshToken=***" : ""
138
+ } …`,
139
+ );
140
+ await (params.persistConfig ?? writeConfigFile)(nextCfg);
141
+ runtime.log(`Config file updated.`);
142
+
143
+ runtime.log(
144
+ `openclaw-clawchat login succeeded (user_id=${result.agent.user_id}, nickname=${result.agent.nickname || "-"}).`,
145
+ );
146
+ }
147
+
148
+ /** Shortens a token for display logs without revealing the full secret. */
149
+ function redactToken(token: string): string {
150
+ if (!token) return "(empty)";
151
+ if (token.length <= 8) return "***";
152
+ return `${token.slice(0, 4)}…${token.slice(-4)}`;
153
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import pluginManifest from "../openclaw.plugin.json" with { type: "json" };
3
+ import packageJson from "../package.json" with { type: "json" };
4
+
5
+ interface PackageJsonWithOpenclaw {
6
+ name: string;
7
+ openclaw: {
8
+ channel: { id: string };
9
+ install: { npmSpec: string };
10
+ };
11
+ }
12
+
13
+ describe("openclaw-clawchat manifest", () => {
14
+ it("keeps plugin id / channel id / package name aligned", () => {
15
+ expect(pluginManifest.id).toBe("openclaw-clawchat");
16
+ expect(pluginManifest.channels).toContain("openclaw-clawchat");
17
+ expect(packageJson.name).toBe("openclaw-clawchat");
18
+ const pkg = packageJson as PackageJsonWithOpenclaw;
19
+ expect(pkg.openclaw.channel.id).toBe("openclaw-clawchat");
20
+ expect(pkg.openclaw.install.npmSpec).toBe("openclaw-clawchat");
21
+ });
22
+ });
@@ -0,0 +1,159 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ inferMediaKindFromMime,
4
+ fetchInboundMedia,
5
+ uploadOutboundMedia,
6
+ type MediaItem,
7
+ } from "./media-runtime.ts";
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
+ describe("inferMediaKindFromMime", () => {
17
+ it("maps image/* to image", () => {
18
+ expect(inferMediaKindFromMime("image/png")).toBe("image");
19
+ expect(inferMediaKindFromMime("image/jpeg")).toBe("image");
20
+ });
21
+ it("maps audio/* to audio", () => {
22
+ expect(inferMediaKindFromMime("audio/mpeg")).toBe("audio");
23
+ });
24
+ it("maps video/* to video", () => {
25
+ expect(inferMediaKindFromMime("video/mp4")).toBe("video");
26
+ });
27
+ it("maps anything else to file", () => {
28
+ expect(inferMediaKindFromMime("application/pdf")).toBe("file");
29
+ expect(inferMediaKindFromMime("text/plain")).toBe("file");
30
+ });
31
+ it("maps undefined / empty to file", () => {
32
+ expect(inferMediaKindFromMime(undefined)).toBe("file");
33
+ expect(inferMediaKindFromMime("")).toBe("file");
34
+ });
35
+ });
36
+
37
+ describe("fetchInboundMedia", () => {
38
+ function buildRuntime() {
39
+ const fetchRemoteMedia = vi.fn().mockImplementation(async ({ url }: { url: string }) => ({
40
+ buffer: Buffer.from(`bytes-of-${url}`),
41
+ contentType: "image/png",
42
+ fileName: "x.png",
43
+ }));
44
+ const saveMediaBuffer = vi
45
+ .fn()
46
+ .mockImplementation(
47
+ async (_buf: Buffer, _ct?: string, _sub?: string, _max?: number, name?: string) => ({
48
+ path: `/cache/${name ?? "auto"}`,
49
+ contentType: "image/png",
50
+ }),
51
+ );
52
+ const runtime = {
53
+ channel: {
54
+ media: { fetchRemoteMedia, saveMediaBuffer },
55
+ },
56
+ } as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
57
+ return { runtime, fetchRemoteMedia, saveMediaBuffer };
58
+ }
59
+
60
+ it("fetches each item and returns local paths", async () => {
61
+ const { runtime } = buildRuntime();
62
+ const items: MediaItem[] = [
63
+ { kind: "image", url: "https://cdn/a.png", mime: "image/png" },
64
+ { kind: "image", url: "https://cdn/b.png", mime: "image/png" },
65
+ ];
66
+ const paths = await fetchInboundMedia(items, { runtime, maxBytes: 1000 });
67
+ expect(paths).toEqual(["/cache/x.png", "/cache/x.png"]);
68
+ });
69
+
70
+ it("drops a single item that fails, keeps others", async () => {
71
+ const { runtime, fetchRemoteMedia } = buildRuntime();
72
+ fetchRemoteMedia.mockImplementationOnce(async () => {
73
+ throw new Error("network");
74
+ });
75
+ const items: MediaItem[] = [
76
+ { kind: "image", url: "https://cdn/bad.png" },
77
+ { kind: "image", url: "https://cdn/good.png" },
78
+ ];
79
+ const log = { info: vi.fn(), error: vi.fn() };
80
+ const paths = await fetchInboundMedia(items, { runtime, log });
81
+ expect(paths).toHaveLength(1);
82
+ expect(log.info).toHaveBeenCalled();
83
+ });
84
+
85
+ it("returns empty array for empty input", async () => {
86
+ const { runtime } = buildRuntime();
87
+ expect(await fetchInboundMedia([], { runtime })).toEqual([]);
88
+ });
89
+ });
90
+
91
+ describe("uploadOutboundMedia", () => {
92
+ function buildApiClient() {
93
+ return {
94
+ uploadMedia: vi.fn().mockResolvedValue({
95
+ url: "https://cdn/uploaded.png",
96
+ size: 12,
97
+ mime: "image/png",
98
+ }),
99
+ } as unknown as ReturnType<typeof import("./api-client.ts").createOpenclawClawlingApiClient>;
100
+ }
101
+
102
+ function setupLoadWebMedia() {
103
+ loadWebMediaMock.mockResolvedValue({
104
+ buffer: Buffer.from("loaded-bytes"),
105
+ contentType: "image/png",
106
+ fileName: "img.png",
107
+ });
108
+ return loadWebMediaMock;
109
+ }
110
+
111
+ it("loads each url and uploads", async () => {
112
+ const loadWebMedia = setupLoadWebMedia();
113
+ 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
+ const fragments = await uploadOutboundMedia(["https://cdn/in.png", "https://cdn/in2.png"], {
117
+ apiClient,
118
+ runtime,
119
+ });
120
+ expect(loadWebMedia).toHaveBeenCalledTimes(2);
121
+ expect(fragments).toEqual([
122
+ {
123
+ kind: "image",
124
+ url: "https://cdn/uploaded.png",
125
+ mime: "image/png",
126
+ size: 12,
127
+ name: "img.png",
128
+ },
129
+ {
130
+ kind: "image",
131
+ url: "https://cdn/uploaded.png",
132
+ mime: "image/png",
133
+ size: 12,
134
+ name: "img.png",
135
+ },
136
+ ]);
137
+ });
138
+
139
+ it("drops a single failed upload, returns the rest", async () => {
140
+ setupLoadWebMedia();
141
+ const apiClient = buildApiClient();
142
+ (apiClient.uploadMedia as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("boom"));
143
+ const log = { info: vi.fn(), error: vi.fn() };
144
+ const runtime = {} as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
145
+ const fragments = await uploadOutboundMedia(["https://cdn/a.png", "https://cdn/b.png"], {
146
+ apiClient,
147
+ runtime,
148
+ log,
149
+ });
150
+ expect(fragments).toHaveLength(1);
151
+ expect(log.error).toHaveBeenCalled();
152
+ });
153
+
154
+ it("returns empty array for empty input", async () => {
155
+ const apiClient = buildApiClient();
156
+ const runtime = {} as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
157
+ expect(await uploadOutboundMedia([], { apiClient, runtime })).toEqual([]);
158
+ });
159
+ });