@clawling/clawchat-plugin-openclaw 2026.5.12-28
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/INSTALL.md +64 -0
- package/README.md +227 -0
- package/dist/index.js +20 -0
- package/dist/setup-entry.js +3 -0
- package/dist/src/api-client.js +263 -0
- package/dist/src/api-types.js +17 -0
- package/dist/src/api-types.test-d.js +10 -0
- package/dist/src/buffered-stream.js +177 -0
- package/dist/src/channel.js +66 -0
- package/dist/src/channel.setup.js +119 -0
- package/dist/src/clawchat-memory.js +403 -0
- package/dist/src/clawchat-metadata.js +310 -0
- package/dist/src/client.js +35 -0
- package/dist/src/commands.js +35 -0
- package/dist/src/config.js +274 -0
- package/dist/src/group-message-coalescer.js +119 -0
- package/dist/src/inbound.js +170 -0
- package/dist/src/llm-context-debug.js +86 -0
- package/dist/src/login.runtime.js +204 -0
- package/dist/src/media-runtime.js +85 -0
- package/dist/src/message-mapper.js +146 -0
- package/dist/src/mock-transport.js +31 -0
- package/dist/src/outbound.js +628 -0
- package/dist/src/plugin-prompts.js +89 -0
- package/dist/src/profile-prompt.js +269 -0
- package/dist/src/profile-sync.js +110 -0
- package/dist/src/prompt-injection.js +25 -0
- package/dist/src/protocol-types.js +63 -0
- package/dist/src/protocol-types.typecheck.js +1 -0
- package/dist/src/protocol.js +33 -0
- package/dist/src/reply-dispatcher.js +422 -0
- package/dist/src/runtime.js +1254 -0
- package/dist/src/storage.js +525 -0
- package/dist/src/streaming.js +65 -0
- package/dist/src/terminal-send.js +36 -0
- package/dist/src/tools-schema.js +208 -0
- package/dist/src/tools.js +920 -0
- package/dist/src/ws-alignment.js +178 -0
- package/dist/src/ws-client.js +588 -0
- package/dist/src/ws-log.js +19 -0
- package/index.ts +24 -0
- package/openclaw.plugin.json +169 -0
- package/package.json +80 -0
- package/prompts/default-group-bio.md +19 -0
- package/prompts/default-owner-behavior.md +27 -0
- package/prompts/platform.md +13 -0
- package/setup-entry.ts +4 -0
- package/skills/clawchat/SKILL.md +91 -0
- package/src/api-client.test.ts +827 -0
- package/src/api-client.ts +414 -0
- package/src/api-types.ts +146 -0
- package/src/channel.outbound.test.ts +433 -0
- package/src/channel.setup.ts +145 -0
- package/src/channel.test.ts +262 -0
- package/src/channel.ts +81 -0
- package/src/clawchat-memory.test.ts +480 -0
- package/src/clawchat-memory.ts +533 -0
- package/src/clawchat-metadata.test.ts +477 -0
- package/src/clawchat-metadata.ts +429 -0
- package/src/client.test.ts +169 -0
- package/src/client.ts +56 -0
- package/src/commands.test.ts +39 -0
- package/src/commands.ts +41 -0
- package/src/config.test.ts +344 -0
- package/src/config.ts +404 -0
- package/src/group-message-coalescer.test.ts +237 -0
- package/src/group-message-coalescer.ts +171 -0
- package/src/inbound.test.ts +508 -0
- package/src/inbound.ts +278 -0
- package/src/llm-context-debug.test.ts +55 -0
- package/src/llm-context-debug.ts +139 -0
- package/src/login.runtime.test.ts +737 -0
- package/src/login.runtime.ts +277 -0
- package/src/manifest.test.ts +352 -0
- package/src/media-runtime.test.ts +207 -0
- package/src/media-runtime.ts +152 -0
- package/src/message-mapper.test.ts +201 -0
- package/src/message-mapper.ts +174 -0
- package/src/mock-transport.test.ts +35 -0
- package/src/mock-transport.ts +38 -0
- package/src/outbound.test.ts +1269 -0
- package/src/outbound.ts +803 -0
- package/src/plugin-entry.test.ts +38 -0
- package/src/plugin-prompts.test.ts +94 -0
- package/src/plugin-prompts.ts +107 -0
- package/src/profile-prompt.test.ts +274 -0
- package/src/profile-prompt.ts +351 -0
- package/src/profile-sync.test.ts +539 -0
- package/src/profile-sync.ts +191 -0
- package/src/prompt-injection.test.ts +39 -0
- package/src/prompt-injection.ts +45 -0
- package/src/protocol-types.test.ts +69 -0
- package/src/protocol-types.ts +296 -0
- package/src/protocol-types.typecheck.ts +89 -0
- package/src/protocol.test.ts +39 -0
- package/src/protocol.ts +42 -0
- package/src/reply-dispatcher.test.ts +1324 -0
- package/src/reply-dispatcher.ts +555 -0
- package/src/runtime.test.ts +4719 -0
- package/src/runtime.ts +1493 -0
- package/src/scripts.test.ts +85 -0
- package/src/storage.test.ts +560 -0
- package/src/storage.ts +807 -0
- package/src/terminal-send.test.ts +81 -0
- package/src/terminal-send.ts +56 -0
- package/src/tools-schema.ts +337 -0
- package/src/tools.test.ts +933 -0
- package/src/tools.ts +1185 -0
- package/src/ws-alignment.test.ts +103 -0
- package/src/ws-alignment.ts +275 -0
- package/src/ws-client.test.ts +1217 -0
- package/src/ws-client.ts +662 -0
- package/src/ws-log.test.ts +32 -0
- package/src/ws-log.ts +31 -0
|
@@ -0,0 +1,207 @@
|
|
|
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
|
+
describe("inferMediaKindFromMime", () => {
|
|
10
|
+
it("maps image/* to image", () => {
|
|
11
|
+
expect(inferMediaKindFromMime("image/png")).toBe("image");
|
|
12
|
+
expect(inferMediaKindFromMime("image/jpeg")).toBe("image");
|
|
13
|
+
});
|
|
14
|
+
it("maps audio/* to audio", () => {
|
|
15
|
+
expect(inferMediaKindFromMime("audio/mpeg")).toBe("audio");
|
|
16
|
+
});
|
|
17
|
+
it("maps video/* to video", () => {
|
|
18
|
+
expect(inferMediaKindFromMime("video/mp4")).toBe("video");
|
|
19
|
+
});
|
|
20
|
+
it("maps anything else to file", () => {
|
|
21
|
+
expect(inferMediaKindFromMime("application/pdf")).toBe("file");
|
|
22
|
+
expect(inferMediaKindFromMime("text/plain")).toBe("file");
|
|
23
|
+
});
|
|
24
|
+
it("maps undefined / empty to file", () => {
|
|
25
|
+
expect(inferMediaKindFromMime(undefined)).toBe("file");
|
|
26
|
+
expect(inferMediaKindFromMime("")).toBe("file");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("fetchInboundMedia", () => {
|
|
31
|
+
function buildRuntime() {
|
|
32
|
+
const fetchRemoteMedia = vi.fn().mockImplementation(async ({ url }: { url: string }) => ({
|
|
33
|
+
buffer: Buffer.from(`bytes-of-${url}`),
|
|
34
|
+
contentType: "image/png",
|
|
35
|
+
fileName: "x.png",
|
|
36
|
+
}));
|
|
37
|
+
const saveMediaBuffer = vi
|
|
38
|
+
.fn()
|
|
39
|
+
.mockImplementation(
|
|
40
|
+
async (_buf: Buffer, _ct?: string, _sub?: string, _max?: number, name?: string) => ({
|
|
41
|
+
path: `/cache/${name ?? "auto"}`,
|
|
42
|
+
contentType: "image/png",
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
const runtime = {
|
|
46
|
+
channel: {
|
|
47
|
+
media: { fetchRemoteMedia, saveMediaBuffer },
|
|
48
|
+
},
|
|
49
|
+
} as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
|
|
50
|
+
return { runtime, fetchRemoteMedia, saveMediaBuffer };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
it("fetches each item and returns local paths", async () => {
|
|
54
|
+
const { runtime } = buildRuntime();
|
|
55
|
+
const items: MediaItem[] = [
|
|
56
|
+
{ kind: "image", url: "https://cdn/a.png", mime: "image/png" },
|
|
57
|
+
{ kind: "image", url: "https://cdn/b.png", mime: "image/png" },
|
|
58
|
+
];
|
|
59
|
+
const paths = await fetchInboundMedia(items, { runtime, maxBytes: 1000 });
|
|
60
|
+
expect(paths).toEqual(["/cache/x.png", "/cache/x.png"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("drops a single item that fails, keeps others", async () => {
|
|
64
|
+
const { runtime, fetchRemoteMedia } = buildRuntime();
|
|
65
|
+
fetchRemoteMedia.mockImplementationOnce(async () => {
|
|
66
|
+
throw new Error("network");
|
|
67
|
+
});
|
|
68
|
+
const items: MediaItem[] = [
|
|
69
|
+
{ kind: "image", url: "https://cdn/bad.png" },
|
|
70
|
+
{ kind: "image", url: "https://cdn/good.png" },
|
|
71
|
+
];
|
|
72
|
+
const log = { info: vi.fn(), error: vi.fn() };
|
|
73
|
+
const paths = await fetchInboundMedia(items, { runtime, log });
|
|
74
|
+
expect(paths).toHaveLength(1);
|
|
75
|
+
expect(log.info).toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns empty array for empty input", async () => {
|
|
79
|
+
const { runtime } = buildRuntime();
|
|
80
|
+
expect(await fetchInboundMedia([], { runtime })).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("uploadOutboundMedia", () => {
|
|
85
|
+
function buildApiClient() {
|
|
86
|
+
return {
|
|
87
|
+
uploadMedia: vi.fn().mockResolvedValue({
|
|
88
|
+
kind: "image",
|
|
89
|
+
url: "https://cdn/uploaded.png",
|
|
90
|
+
name: "uploaded.png",
|
|
91
|
+
size: 12,
|
|
92
|
+
mime: "image/png",
|
|
93
|
+
}),
|
|
94
|
+
} as unknown as ReturnType<typeof import("./api-client.ts").createOpenclawClawlingApiClient>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildRuntime() {
|
|
98
|
+
const loadWebMedia = vi.fn().mockResolvedValue({
|
|
99
|
+
buffer: Buffer.from("loaded-bytes"),
|
|
100
|
+
contentType: "image/png",
|
|
101
|
+
fileName: "img.png",
|
|
102
|
+
});
|
|
103
|
+
const runtime = {
|
|
104
|
+
media: { loadWebMedia },
|
|
105
|
+
} as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
|
|
106
|
+
return { runtime, loadWebMedia };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
it("loads each url and uploads", async () => {
|
|
110
|
+
const { runtime, loadWebMedia } = buildRuntime();
|
|
111
|
+
const apiClient = buildApiClient();
|
|
112
|
+
const fragments = await uploadOutboundMedia(["https://cdn/in.png", "https://cdn/in2.png"], {
|
|
113
|
+
apiClient,
|
|
114
|
+
runtime,
|
|
115
|
+
});
|
|
116
|
+
expect(loadWebMedia).toHaveBeenCalledTimes(2);
|
|
117
|
+
expect(fragments).toEqual([
|
|
118
|
+
{
|
|
119
|
+
kind: "image",
|
|
120
|
+
url: "https://cdn/uploaded.png",
|
|
121
|
+
mime: "image/png",
|
|
122
|
+
size: 12,
|
|
123
|
+
name: "uploaded.png",
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
kind: "image",
|
|
127
|
+
url: "https://cdn/uploaded.png",
|
|
128
|
+
mime: "image/png",
|
|
129
|
+
size: 12,
|
|
130
|
+
name: "uploaded.png",
|
|
131
|
+
},
|
|
132
|
+
]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("uses server-returned kind and name for uploaded media fragments", async () => {
|
|
136
|
+
const { runtime } = buildRuntime();
|
|
137
|
+
const apiClient = buildApiClient();
|
|
138
|
+
(apiClient.uploadMedia as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
139
|
+
kind: "file",
|
|
140
|
+
url: "https://cdn/server.bin",
|
|
141
|
+
name: "server.bin",
|
|
142
|
+
size: 12,
|
|
143
|
+
mime: "image/png",
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const fragments = await uploadOutboundMedia(["https://cdn/in.png"], {
|
|
147
|
+
apiClient,
|
|
148
|
+
runtime,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(fragments).toEqual([
|
|
152
|
+
{
|
|
153
|
+
kind: "file",
|
|
154
|
+
url: "https://cdn/server.bin",
|
|
155
|
+
mime: "image/png",
|
|
156
|
+
size: 12,
|
|
157
|
+
name: "server.bin",
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("passes host media access options to loadWebMedia", async () => {
|
|
163
|
+
const { runtime, loadWebMedia } = buildRuntime();
|
|
164
|
+
const apiClient = buildApiClient();
|
|
165
|
+
const readFile = vi.fn(async () => Buffer.from("host-read"));
|
|
166
|
+
|
|
167
|
+
await uploadOutboundMedia(["relative/image.png"], {
|
|
168
|
+
apiClient,
|
|
169
|
+
runtime,
|
|
170
|
+
mediaAccess: {
|
|
171
|
+
localRoots: ["/workspace"],
|
|
172
|
+
readFile,
|
|
173
|
+
workspaceDir: "/workspace",
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(loadWebMedia).toHaveBeenCalledWith(
|
|
178
|
+
"relative/image.png",
|
|
179
|
+
expect.objectContaining({
|
|
180
|
+
localRoots: ["/workspace"],
|
|
181
|
+
readFile,
|
|
182
|
+
hostReadCapability: true,
|
|
183
|
+
workspaceDir: "/workspace",
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("drops a single failed upload, returns the rest", async () => {
|
|
189
|
+
const { runtime } = buildRuntime();
|
|
190
|
+
const apiClient = buildApiClient();
|
|
191
|
+
(apiClient.uploadMedia as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("boom"));
|
|
192
|
+
const log = { info: vi.fn(), error: vi.fn() };
|
|
193
|
+
const fragments = await uploadOutboundMedia(["https://cdn/a.png", "https://cdn/b.png"], {
|
|
194
|
+
apiClient,
|
|
195
|
+
runtime,
|
|
196
|
+
log,
|
|
197
|
+
});
|
|
198
|
+
expect(fragments).toHaveLength(1);
|
|
199
|
+
expect(log.error).toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("returns empty array for empty input", async () => {
|
|
203
|
+
const apiClient = buildApiClient();
|
|
204
|
+
const { runtime } = buildRuntime();
|
|
205
|
+
expect(await uploadOutboundMedia([], { apiClient, runtime })).toEqual([]);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import {
|
|
3
|
+
buildOutboundMediaLoadOptions,
|
|
4
|
+
type OutboundMediaAccess,
|
|
5
|
+
type OutboundMediaReadFile,
|
|
6
|
+
} from "openclaw/plugin-sdk/media-runtime";
|
|
7
|
+
import type { OpenclawClawlingApiClient } from "./api-client.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Local structural superset of the protocol media fragment narrow types.
|
|
11
|
+
*
|
|
12
|
+
* Each media fragment has a literal `kind` that distinguishes it. Building the
|
|
13
|
+
* wide shape locally avoids per-kind switch statements when constructing
|
|
14
|
+
* outbound fragments; structural compatibility lets a single object satisfy
|
|
15
|
+
* whichever narrow type matches its runtime `kind`.
|
|
16
|
+
*/
|
|
17
|
+
export interface MediaItem {
|
|
18
|
+
kind: "image" | "file" | "audio" | "video";
|
|
19
|
+
url: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
mime?: string;
|
|
22
|
+
size?: number;
|
|
23
|
+
width?: number;
|
|
24
|
+
height?: number;
|
|
25
|
+
duration?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Outbound fragment shape sent in `body.fragments`. Same wide shape as `MediaItem`. */
|
|
29
|
+
export type ClawlingMediaFragment = MediaItem;
|
|
30
|
+
|
|
31
|
+
export interface LogSink {
|
|
32
|
+
info?: (m: string) => void;
|
|
33
|
+
error?: (m: string) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FetchInboundCtx {
|
|
37
|
+
runtime: PluginRuntime;
|
|
38
|
+
log?: LogSink;
|
|
39
|
+
maxBytes?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface UploadOutboundCtx {
|
|
43
|
+
apiClient: OpenclawClawlingApiClient;
|
|
44
|
+
runtime: PluginRuntime;
|
|
45
|
+
log?: LogSink;
|
|
46
|
+
maxBytes?: number;
|
|
47
|
+
/** Host-authorized outbound media access from OpenClaw message delivery. */
|
|
48
|
+
mediaAccess?: OutboundMediaAccess;
|
|
49
|
+
/** Allowed local roots for path-based uploads. Empty/undefined = use loadWebMedia defaults. */
|
|
50
|
+
mediaLocalRoots?: readonly string[] | "any";
|
|
51
|
+
/** Host-provided read bridge for sandbox/allowed local media paths. */
|
|
52
|
+
mediaReadFile?: OutboundMediaReadFile;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function inferMediaKindFromMime(mime: string | undefined): MediaItem["kind"] {
|
|
56
|
+
if (!mime) return "file";
|
|
57
|
+
if (mime.startsWith("image/")) return "image";
|
|
58
|
+
if (mime.startsWith("audio/")) return "audio";
|
|
59
|
+
if (mime.startsWith("video/")) return "video";
|
|
60
|
+
return "file";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DEFAULT_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Fetch each remote URL via the shared media runtime, persist to a local
|
|
67
|
+
* cache, and return the list of local paths.
|
|
68
|
+
*
|
|
69
|
+
* Failed items are logged at info level and dropped; the remaining items
|
|
70
|
+
* still resolve so a single bad URL doesn't blow up the whole inbound turn.
|
|
71
|
+
*/
|
|
72
|
+
export async function fetchInboundMedia(
|
|
73
|
+
items: MediaItem[],
|
|
74
|
+
ctx: FetchInboundCtx,
|
|
75
|
+
): Promise<string[]> {
|
|
76
|
+
if (items.length === 0) return [];
|
|
77
|
+
const maxBytes = ctx.maxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
|
|
78
|
+
const paths: string[] = [];
|
|
79
|
+
for (const item of items) {
|
|
80
|
+
try {
|
|
81
|
+
const fetched = await ctx.runtime.channel.media.fetchRemoteMedia({
|
|
82
|
+
url: item.url,
|
|
83
|
+
maxBytes,
|
|
84
|
+
});
|
|
85
|
+
const saved = await ctx.runtime.channel.media.saveMediaBuffer(
|
|
86
|
+
fetched.buffer,
|
|
87
|
+
fetched.contentType ?? item.mime,
|
|
88
|
+
"clawchat-plugin-openclaw-inbound",
|
|
89
|
+
maxBytes,
|
|
90
|
+
item.name ?? fetched.fileName,
|
|
91
|
+
);
|
|
92
|
+
paths.push(saved.path);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
ctx.log?.info?.(
|
|
95
|
+
`clawchat-plugin-openclaw inbound media skipped: ${item.url} (${err instanceof Error ? err.message : String(err)})`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return paths;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Upload each URL (remote or local path) to /media/upload via the api
|
|
104
|
+
* client and return a fragment ready to splice into `body.fragments`.
|
|
105
|
+
*
|
|
106
|
+
* Uses the host runtime's `runtime.media.loadWebMedia`, so local-root
|
|
107
|
+
* enforcement and media-loading policy stay aligned with the current
|
|
108
|
+
* OpenClaw runtime instead of a directly imported helper.
|
|
109
|
+
*
|
|
110
|
+
* Single-upload failures log at error and are dropped; the remaining
|
|
111
|
+
* fragments still come back so a partially-failing batch still sends the
|
|
112
|
+
* working media.
|
|
113
|
+
*/
|
|
114
|
+
export async function uploadOutboundMedia(
|
|
115
|
+
urls: string[],
|
|
116
|
+
ctx: UploadOutboundCtx,
|
|
117
|
+
): Promise<ClawlingMediaFragment[]> {
|
|
118
|
+
if (urls.length === 0) return [];
|
|
119
|
+
const maxBytes = ctx.maxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
|
|
120
|
+
const out: ClawlingMediaFragment[] = [];
|
|
121
|
+
for (const url of urls) {
|
|
122
|
+
try {
|
|
123
|
+
const loaded = await ctx.runtime.media.loadWebMedia(
|
|
124
|
+
url,
|
|
125
|
+
buildOutboundMediaLoadOptions({
|
|
126
|
+
maxBytes,
|
|
127
|
+
...(ctx.mediaAccess ? { mediaAccess: ctx.mediaAccess } : {}),
|
|
128
|
+
...(ctx.mediaLocalRoots ? { mediaLocalRoots: ctx.mediaLocalRoots } : {}),
|
|
129
|
+
...(ctx.mediaReadFile ? { mediaReadFile: ctx.mediaReadFile } : {}),
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
const uploaded = await ctx.apiClient.uploadMedia({
|
|
133
|
+
buffer: loaded.buffer,
|
|
134
|
+
filename: loaded.fileName ?? "upload.bin",
|
|
135
|
+
mime: loaded.contentType,
|
|
136
|
+
});
|
|
137
|
+
const fragment: ClawlingMediaFragment = {
|
|
138
|
+
kind: uploaded.kind,
|
|
139
|
+
url: uploaded.url,
|
|
140
|
+
name: uploaded.name,
|
|
141
|
+
mime: uploaded.mime,
|
|
142
|
+
size: uploaded.size,
|
|
143
|
+
};
|
|
144
|
+
out.push(fragment);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
ctx.log?.error?.(
|
|
147
|
+
`clawchat-plugin-openclaw outbound media upload failed: ${url} (${err instanceof Error ? err.message : String(err)})`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { Fragment } from "./protocol-types.ts";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
buildMentionMessageFragments,
|
|
5
|
+
extractMediaFragments,
|
|
6
|
+
fragmentsToText,
|
|
7
|
+
normalizeMentionTargets,
|
|
8
|
+
textToFragments,
|
|
9
|
+
} from "./message-mapper.ts";
|
|
10
|
+
|
|
11
|
+
describe("clawchat-plugin-openclaw message-mapper", () => {
|
|
12
|
+
it("flattens text fragments", () => {
|
|
13
|
+
const fragments: Fragment[] = [
|
|
14
|
+
{ kind: "text", text: "hello " },
|
|
15
|
+
{ kind: "text", text: "world" },
|
|
16
|
+
];
|
|
17
|
+
expect(fragmentsToText(fragments)).toBe("hello world");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("renders mention fragments using display text", () => {
|
|
21
|
+
const fragments: Fragment[] = [
|
|
22
|
+
{ kind: "text", text: "hi " },
|
|
23
|
+
{ kind: "mention", display: "@Clawling Assistant" } as unknown as Fragment,
|
|
24
|
+
{ kind: "text", text: "!" },
|
|
25
|
+
];
|
|
26
|
+
expect(fragmentsToText(fragments)).toBe("hi @Clawling Assistant!");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("adds visible @ for mention display labels that omit it", () => {
|
|
30
|
+
const fragments: Fragment[] = [
|
|
31
|
+
{ kind: "text", text: "请 " },
|
|
32
|
+
{ kind: "mention", user_id: "usr_bob_456", display: "Bob" } as unknown as Fragment,
|
|
33
|
+
{ kind: "text", text: " 下午处理" },
|
|
34
|
+
];
|
|
35
|
+
expect(fragmentsToText(fragments)).toBe("请 @Bob 下午处理");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("falls back to mention ids when display missing", () => {
|
|
39
|
+
const fragments: Fragment[] = [
|
|
40
|
+
{ kind: "text", text: "hi " },
|
|
41
|
+
{ kind: "mention", user_id: "agent-1" } as unknown as Fragment,
|
|
42
|
+
];
|
|
43
|
+
expect(fragmentsToText(fragments, { mentionFallbackIds: ["agent-1"] })).toBe("hi @agent-1");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("normalizes mention targets with optional display and dedupe", () => {
|
|
47
|
+
expect(
|
|
48
|
+
normalizeMentionTargets([
|
|
49
|
+
{ userId: " user-a ", display: "Alice" },
|
|
50
|
+
{ userId: "user-a", display: "Ignored" },
|
|
51
|
+
{ userId: "user-b", display: "@Bob" },
|
|
52
|
+
{ userId: "user-c" },
|
|
53
|
+
]),
|
|
54
|
+
).toEqual([
|
|
55
|
+
{ userId: "user-a", display: "Alice" },
|
|
56
|
+
{ userId: "user-b", display: "Bob" },
|
|
57
|
+
{ userId: "user-c" },
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("builds mention fragments followed by spaced text", () => {
|
|
62
|
+
expect(
|
|
63
|
+
buildMentionMessageFragments({
|
|
64
|
+
mentions: [{ userId: "user-a", display: "Alice" }],
|
|
65
|
+
text: "请看这个",
|
|
66
|
+
}),
|
|
67
|
+
).toEqual([
|
|
68
|
+
{ kind: "mention", user_id: "user-a", display: "Alice" },
|
|
69
|
+
{ kind: "text", text: " 请看这个" },
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("uses leading visible @text as mention display without duplicating text", () => {
|
|
74
|
+
expect(
|
|
75
|
+
buildMentionMessageFragments({
|
|
76
|
+
mentions: [{ userId: "user-a" }],
|
|
77
|
+
text: "@Alice",
|
|
78
|
+
}),
|
|
79
|
+
).toEqual([
|
|
80
|
+
{ kind: "mention", user_id: "user-a", display: "Alice" },
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("keeps spaces in a text-only mention display label", () => {
|
|
85
|
+
expect(
|
|
86
|
+
buildMentionMessageFragments({
|
|
87
|
+
mentions: [{ userId: "user-a" }],
|
|
88
|
+
text: "@Super Zero",
|
|
89
|
+
}),
|
|
90
|
+
).toEqual([
|
|
91
|
+
{ kind: "mention", user_id: "user-a", display: "Super Zero" },
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("throws when mention targets are missing user ids", () => {
|
|
96
|
+
expect(() => normalizeMentionTargets([{ userId: " " }])).toThrow(
|
|
97
|
+
"clawchat_mention_message requires mentions[0].userId",
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("renders image fragments inline and skips unknown kinds without crashing", () => {
|
|
102
|
+
const fragments: Fragment[] = [
|
|
103
|
+
{ kind: "text", text: "see " },
|
|
104
|
+
{ kind: "image", url: "https://example/i.png" } as unknown as Fragment,
|
|
105
|
+
{ kind: "text", text: "this" },
|
|
106
|
+
];
|
|
107
|
+
expect(fragmentsToText(fragments)).toBe("see this");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("trims the final result", () => {
|
|
111
|
+
const fragments: Fragment[] = [{ kind: "text", text: " hi " }];
|
|
112
|
+
expect(fragmentsToText(fragments)).toBe("hi");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("textToFragments produces a single text fragment", () => {
|
|
116
|
+
expect(textToFragments("ok")).toEqual([{ kind: "text", text: "ok" }]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("textToFragments returns empty array for empty input", () => {
|
|
120
|
+
expect(textToFragments("")).toEqual([]);
|
|
121
|
+
expect(textToFragments(" ")).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("renders image fragments as markdown image", () => {
|
|
125
|
+
const fragments: Fragment[] = [
|
|
126
|
+
{ kind: "text", text: "look: " },
|
|
127
|
+
{ kind: "image", url: "https://cdn/x.png", name: "logo" } as unknown as Fragment,
|
|
128
|
+
];
|
|
129
|
+
expect(fragmentsToText(fragments)).toBe("look: ");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("renders file/audio/video fragments as markdown link", () => {
|
|
133
|
+
const fragments: Fragment[] = [
|
|
134
|
+
{ kind: "file", url: "https://cdn/a.pdf", name: "doc.pdf" } as unknown as Fragment,
|
|
135
|
+
{ kind: "audio", url: "https://cdn/b.mp3" } as unknown as Fragment,
|
|
136
|
+
{ kind: "video", url: "https://cdn/c.mp4", name: "demo" } as unknown as Fragment,
|
|
137
|
+
];
|
|
138
|
+
expect(fragmentsToText(fragments)).toBe(
|
|
139
|
+
"[doc.pdf](https://cdn/a.pdf)[audio](https://cdn/b.mp3)[demo](https://cdn/c.mp4)",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("renders image without name as ", () => {
|
|
144
|
+
const fragments: Fragment[] = [
|
|
145
|
+
{ kind: "image", url: "https://cdn/x.png" } as unknown as Fragment,
|
|
146
|
+
];
|
|
147
|
+
expect(fragmentsToText(fragments)).toBe("");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("extractMediaFragments returns only media fragments", () => {
|
|
151
|
+
const fragments: Fragment[] = [
|
|
152
|
+
{ kind: "text", text: "hi " },
|
|
153
|
+
{ kind: "image", url: "https://cdn/a.png", mime: "image/png" } as unknown as Fragment,
|
|
154
|
+
{ kind: "mention", display: "@bob" } as unknown as Fragment,
|
|
155
|
+
{ kind: "file", url: "https://cdn/b.pdf", name: "b.pdf" } as unknown as Fragment,
|
|
156
|
+
];
|
|
157
|
+
const items = extractMediaFragments(fragments);
|
|
158
|
+
expect(items).toEqual([
|
|
159
|
+
{ kind: "image", url: "https://cdn/a.png", mime: "image/png" },
|
|
160
|
+
{ kind: "file", url: "https://cdn/b.pdf", name: "b.pdf" },
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("extractMediaFragments preserves all optional metadata", () => {
|
|
165
|
+
const fragments: Fragment[] = [
|
|
166
|
+
{
|
|
167
|
+
kind: "video",
|
|
168
|
+
url: "https://cdn/v.mp4",
|
|
169
|
+
name: "v",
|
|
170
|
+
mime: "video/mp4",
|
|
171
|
+
size: 1234,
|
|
172
|
+
width: 1280,
|
|
173
|
+
height: 720,
|
|
174
|
+
duration: 30,
|
|
175
|
+
} as unknown as Fragment,
|
|
176
|
+
];
|
|
177
|
+
expect(extractMediaFragments(fragments)).toEqual([
|
|
178
|
+
{
|
|
179
|
+
kind: "video",
|
|
180
|
+
url: "https://cdn/v.mp4",
|
|
181
|
+
name: "v",
|
|
182
|
+
mime: "video/mp4",
|
|
183
|
+
size: 1234,
|
|
184
|
+
width: 1280,
|
|
185
|
+
height: 720,
|
|
186
|
+
duration: 30,
|
|
187
|
+
},
|
|
188
|
+
]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("extractMediaFragments skips media fragments without url", () => {
|
|
192
|
+
// Note: the protocol type requires `url` on image fragments — using a partial
|
|
193
|
+
// object here exercises the runtime guard. Casting here is intentional
|
|
194
|
+
// (we want to verify defensive handling of malformed input).
|
|
195
|
+
const fragments: Fragment[] = [
|
|
196
|
+
{ kind: "image" } as unknown as Fragment,
|
|
197
|
+
{ kind: "image", url: "https://cdn/a.png" } as unknown as Fragment,
|
|
198
|
+
];
|
|
199
|
+
expect(extractMediaFragments(fragments)).toEqual([{ kind: "image", url: "https://cdn/a.png" }]);
|
|
200
|
+
});
|
|
201
|
+
});
|