@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.
- package/README.md +112 -0
- package/index.ts +19 -0
- package/openclaw.plugin.json +52 -0
- package/package.json +58 -0
- package/src/api-client.test.ts +325 -0
- package/src/api-client.ts +225 -0
- package/src/api-types.ts +71 -0
- package/src/buffered-stream.test.ts +201 -0
- package/src/buffered-stream.ts +206 -0
- package/src/channel.test.ts +72 -0
- package/src/channel.ts +278 -0
- package/src/client.test.ts +174 -0
- package/src/client.ts +279 -0
- package/src/config.test.ts +110 -0
- package/src/config.ts +277 -0
- package/src/inbound.test.ts +264 -0
- package/src/inbound.ts +201 -0
- package/src/login.runtime.test.ts +257 -0
- package/src/login.runtime.ts +153 -0
- package/src/manifest.test.ts +22 -0
- package/src/media-runtime.test.ts +159 -0
- package/src/media-runtime.ts +143 -0
- package/src/message-mapper.test.ts +131 -0
- package/src/message-mapper.ts +82 -0
- package/src/outbound.test.ts +244 -0
- package/src/outbound.ts +141 -0
- package/src/protocol.test.ts +42 -0
- package/src/protocol.ts +38 -0
- package/src/reply-dispatcher.ts +387 -0
- package/src/runtime.test.ts +276 -0
- package/src/runtime.ts +316 -0
- package/src/streaming.test.ts +116 -0
- package/src/streaming.ts +89 -0
- package/src/tools-schema.ts +45 -0
- package/src/tools.test.ts +135 -0
- package/src/tools.ts +308 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
|
|
3
|
+
import type { OpenclawClawlingApiClient } from "./api-client.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Local structural superset of the SDK's media fragment narrow types.
|
|
7
|
+
*
|
|
8
|
+
* `@clawling/chat-sdk@^0.2.0` exports each narrow type
|
|
9
|
+
* (`ImageFragment` / `FileFragment` / `AudioFragment` / `VideoFragment`)
|
|
10
|
+
* with a literal `kind` that distinguishes them. Building the wide
|
|
11
|
+
* shape locally avoids per-kind switch statements when constructing
|
|
12
|
+
* outbound fragments; structural compatibility lets a single object
|
|
13
|
+
* satisfy whichever narrow type matches its runtime `kind`. We cast
|
|
14
|
+
* to `Fragment[]` only at the SDK boundary (outbound.ts).
|
|
15
|
+
*/
|
|
16
|
+
export interface MediaItem {
|
|
17
|
+
kind: "image" | "file" | "audio" | "video";
|
|
18
|
+
url: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
mime?: string;
|
|
21
|
+
size?: number;
|
|
22
|
+
width?: number;
|
|
23
|
+
height?: number;
|
|
24
|
+
duration?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Outbound fragment shape sent in `body.fragments`. Same wide shape as `MediaItem`. */
|
|
28
|
+
export type ClawlingMediaFragment = MediaItem;
|
|
29
|
+
|
|
30
|
+
export interface LogSink {
|
|
31
|
+
info?: (m: string) => void;
|
|
32
|
+
error?: (m: string) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface FetchInboundCtx {
|
|
36
|
+
runtime: PluginRuntime;
|
|
37
|
+
log?: LogSink;
|
|
38
|
+
maxBytes?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UploadOutboundCtx {
|
|
42
|
+
apiClient: OpenclawClawlingApiClient;
|
|
43
|
+
runtime: PluginRuntime;
|
|
44
|
+
log?: LogSink;
|
|
45
|
+
maxBytes?: number;
|
|
46
|
+
/** Allowed local roots for path-based uploads. Empty/undefined = use loadWebMedia defaults. */
|
|
47
|
+
mediaLocalRoots?: readonly string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function inferMediaKindFromMime(mime: string | undefined): MediaItem["kind"] {
|
|
51
|
+
if (!mime) return "file";
|
|
52
|
+
if (mime.startsWith("image/")) return "image";
|
|
53
|
+
if (mime.startsWith("audio/")) return "audio";
|
|
54
|
+
if (mime.startsWith("video/")) return "video";
|
|
55
|
+
return "file";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fetch each remote URL via the shared media runtime, persist to a local
|
|
62
|
+
* cache, and return the list of local paths.
|
|
63
|
+
*
|
|
64
|
+
* Failed items are logged at info level and dropped; the remaining items
|
|
65
|
+
* still resolve so a single bad URL doesn't blow up the whole inbound turn.
|
|
66
|
+
*/
|
|
67
|
+
export async function fetchInboundMedia(
|
|
68
|
+
items: MediaItem[],
|
|
69
|
+
ctx: FetchInboundCtx,
|
|
70
|
+
): Promise<string[]> {
|
|
71
|
+
if (items.length === 0) return [];
|
|
72
|
+
const maxBytes = ctx.maxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
|
|
73
|
+
const paths: string[] = [];
|
|
74
|
+
for (const item of items) {
|
|
75
|
+
try {
|
|
76
|
+
const fetched = await ctx.runtime.channel.media.fetchRemoteMedia({
|
|
77
|
+
url: item.url,
|
|
78
|
+
maxBytes,
|
|
79
|
+
});
|
|
80
|
+
const saved = await ctx.runtime.channel.media.saveMediaBuffer(
|
|
81
|
+
fetched.buffer,
|
|
82
|
+
fetched.contentType ?? item.mime,
|
|
83
|
+
"openclaw-clawchat-inbound",
|
|
84
|
+
maxBytes,
|
|
85
|
+
item.name ?? fetched.fileName,
|
|
86
|
+
);
|
|
87
|
+
paths.push(saved.path);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
ctx.log?.info?.(
|
|
90
|
+
`openclaw-clawchat inbound media skipped: ${item.url} (${err instanceof Error ? err.message : String(err)})`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return paths;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Upload each URL (remote or local path) to /media/upload via the api
|
|
99
|
+
* client and return a fragment ready to splice into `body.fragments`.
|
|
100
|
+
*
|
|
101
|
+
* Uses `loadWebMedia` from `openclaw/plugin-sdk/web-media` (not the channel
|
|
102
|
+
* runtime media helpers, which only expose fetchRemoteMedia/saveMediaBuffer).
|
|
103
|
+
*
|
|
104
|
+
* Single-upload failures log at error and are dropped; the remaining
|
|
105
|
+
* fragments still come back so a partially-failing batch still sends the
|
|
106
|
+
* working media.
|
|
107
|
+
*/
|
|
108
|
+
export async function uploadOutboundMedia(
|
|
109
|
+
urls: string[],
|
|
110
|
+
ctx: UploadOutboundCtx,
|
|
111
|
+
): Promise<ClawlingMediaFragment[]> {
|
|
112
|
+
if (urls.length === 0) return [];
|
|
113
|
+
const maxBytes = ctx.maxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
|
|
114
|
+
const out: ClawlingMediaFragment[] = [];
|
|
115
|
+
for (const url of urls) {
|
|
116
|
+
try {
|
|
117
|
+
const loaded = await loadWebMedia(url, {
|
|
118
|
+
maxBytes,
|
|
119
|
+
...(ctx.mediaLocalRoots && ctx.mediaLocalRoots.length > 0
|
|
120
|
+
? { localRoots: ctx.mediaLocalRoots }
|
|
121
|
+
: {}),
|
|
122
|
+
});
|
|
123
|
+
const uploaded = await ctx.apiClient.uploadMedia({
|
|
124
|
+
buffer: loaded.buffer,
|
|
125
|
+
filename: loaded.fileName ?? "upload.bin",
|
|
126
|
+
mime: loaded.contentType,
|
|
127
|
+
});
|
|
128
|
+
const fragment: ClawlingMediaFragment = {
|
|
129
|
+
kind: inferMediaKindFromMime(uploaded.mime),
|
|
130
|
+
url: uploaded.url,
|
|
131
|
+
mime: uploaded.mime,
|
|
132
|
+
size: uploaded.size,
|
|
133
|
+
};
|
|
134
|
+
if (loaded.fileName) fragment.name = loaded.fileName;
|
|
135
|
+
out.push(fragment);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
ctx.log?.error?.(
|
|
138
|
+
`openclaw-clawchat outbound media upload failed: ${url} (${err instanceof Error ? err.message : String(err)})`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { Fragment } from "@newbase-clawchat/sdk";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { extractMediaFragments, fragmentsToText, textToFragments } from "./message-mapper.ts";
|
|
4
|
+
|
|
5
|
+
describe("openclaw-clawchat message-mapper", () => {
|
|
6
|
+
it("flattens text fragments", () => {
|
|
7
|
+
const fragments: Fragment[] = [
|
|
8
|
+
{ kind: "text", text: "hello " },
|
|
9
|
+
{ kind: "text", text: "world" },
|
|
10
|
+
];
|
|
11
|
+
expect(fragmentsToText(fragments)).toBe("hello world");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("renders mention fragments using display text", () => {
|
|
15
|
+
const fragments: Fragment[] = [
|
|
16
|
+
{ kind: "text", text: "hi " },
|
|
17
|
+
{ kind: "mention", display: "@Clawling Assistant" } as unknown as Fragment,
|
|
18
|
+
{ kind: "text", text: "!" },
|
|
19
|
+
];
|
|
20
|
+
expect(fragmentsToText(fragments)).toBe("hi @Clawling Assistant!");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("falls back to mention ids when display missing", () => {
|
|
24
|
+
const fragments: Fragment[] = [
|
|
25
|
+
{ kind: "text", text: "hi " },
|
|
26
|
+
{ kind: "mention", user_id: "agent-1" } as unknown as Fragment,
|
|
27
|
+
];
|
|
28
|
+
expect(fragmentsToText(fragments, { mentionFallbackIds: ["agent-1"] })).toBe("hi @agent-1");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("renders image fragments inline and skips unknown kinds without crashing", () => {
|
|
32
|
+
const fragments: Fragment[] = [
|
|
33
|
+
{ kind: "text", text: "see " },
|
|
34
|
+
{ kind: "image", url: "https://example/i.png" } as unknown as Fragment,
|
|
35
|
+
{ kind: "text", text: "this" },
|
|
36
|
+
];
|
|
37
|
+
expect(fragmentsToText(fragments)).toBe("see this");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("trims the final result", () => {
|
|
41
|
+
const fragments: Fragment[] = [{ kind: "text", text: " hi " }];
|
|
42
|
+
expect(fragmentsToText(fragments)).toBe("hi");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("textToFragments produces a single text fragment", () => {
|
|
46
|
+
expect(textToFragments("ok")).toEqual([{ kind: "text", text: "ok" }]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("textToFragments returns empty array for empty input", () => {
|
|
50
|
+
expect(textToFragments("")).toEqual([]);
|
|
51
|
+
expect(textToFragments(" ")).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("renders image fragments as markdown image", () => {
|
|
55
|
+
const fragments: Fragment[] = [
|
|
56
|
+
{ kind: "text", text: "look: " },
|
|
57
|
+
{ kind: "image", url: "https://cdn/x.png", name: "logo" } as unknown as Fragment,
|
|
58
|
+
];
|
|
59
|
+
expect(fragmentsToText(fragments)).toBe("look: ");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("renders file/audio/video fragments as markdown link", () => {
|
|
63
|
+
const fragments: Fragment[] = [
|
|
64
|
+
{ kind: "file", url: "https://cdn/a.pdf", name: "doc.pdf" } as unknown as Fragment,
|
|
65
|
+
{ kind: "audio", url: "https://cdn/b.mp3" } as unknown as Fragment,
|
|
66
|
+
{ kind: "video", url: "https://cdn/c.mp4", name: "demo" } as unknown as Fragment,
|
|
67
|
+
];
|
|
68
|
+
expect(fragmentsToText(fragments)).toBe(
|
|
69
|
+
"[doc.pdf](https://cdn/a.pdf)[audio](https://cdn/b.mp3)[demo](https://cdn/c.mp4)",
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("renders image without name as ", () => {
|
|
74
|
+
const fragments: Fragment[] = [
|
|
75
|
+
{ kind: "image", url: "https://cdn/x.png" } as unknown as Fragment,
|
|
76
|
+
];
|
|
77
|
+
expect(fragmentsToText(fragments)).toBe("");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("extractMediaFragments returns only media fragments", () => {
|
|
81
|
+
const fragments: Fragment[] = [
|
|
82
|
+
{ kind: "text", text: "hi " },
|
|
83
|
+
{ kind: "image", url: "https://cdn/a.png", mime: "image/png" } as unknown as Fragment,
|
|
84
|
+
{ kind: "mention", display: "@bob" } as unknown as Fragment,
|
|
85
|
+
{ kind: "file", url: "https://cdn/b.pdf", name: "b.pdf" } as unknown as Fragment,
|
|
86
|
+
];
|
|
87
|
+
const items = extractMediaFragments(fragments);
|
|
88
|
+
expect(items).toEqual([
|
|
89
|
+
{ kind: "image", url: "https://cdn/a.png", mime: "image/png" },
|
|
90
|
+
{ kind: "file", url: "https://cdn/b.pdf", name: "b.pdf" },
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("extractMediaFragments preserves all optional metadata", () => {
|
|
95
|
+
const fragments: Fragment[] = [
|
|
96
|
+
{
|
|
97
|
+
kind: "video",
|
|
98
|
+
url: "https://cdn/v.mp4",
|
|
99
|
+
name: "v",
|
|
100
|
+
mime: "video/mp4",
|
|
101
|
+
size: 1234,
|
|
102
|
+
width: 1280,
|
|
103
|
+
height: 720,
|
|
104
|
+
duration: 30,
|
|
105
|
+
} as unknown as Fragment,
|
|
106
|
+
];
|
|
107
|
+
expect(extractMediaFragments(fragments)).toEqual([
|
|
108
|
+
{
|
|
109
|
+
kind: "video",
|
|
110
|
+
url: "https://cdn/v.mp4",
|
|
111
|
+
name: "v",
|
|
112
|
+
mime: "video/mp4",
|
|
113
|
+
size: 1234,
|
|
114
|
+
width: 1280,
|
|
115
|
+
height: 720,
|
|
116
|
+
duration: 30,
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("extractMediaFragments skips media fragments without url", () => {
|
|
122
|
+
// Note: SDK 0.2.0 requires `url` on image fragments — using a partial
|
|
123
|
+
// object here exercises the runtime guard. Casting here is intentional
|
|
124
|
+
// (we want to verify defensive handling of malformed input).
|
|
125
|
+
const fragments: Fragment[] = [
|
|
126
|
+
{ kind: "image" } as unknown as Fragment,
|
|
127
|
+
{ kind: "image", url: "https://cdn/a.png" } as unknown as Fragment,
|
|
128
|
+
];
|
|
129
|
+
expect(extractMediaFragments(fragments)).toEqual([{ kind: "image", url: "https://cdn/a.png" }]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Fragment } from "@newbase-clawchat/sdk";
|
|
2
|
+
import type { MediaItem } from "./media-runtime.ts";
|
|
3
|
+
|
|
4
|
+
interface FlattenOptions {
|
|
5
|
+
mentionFallbackIds?: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const MEDIA_KINDS = new Set(["image", "file", "audio", "video"]);
|
|
9
|
+
|
|
10
|
+
function isMediaKind(kind: string): kind is MediaItem["kind"] {
|
|
11
|
+
return MEDIA_KINDS.has(kind);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function renderMediaPlaceholder(fragment: Record<string, unknown>): string {
|
|
15
|
+
const kind = String(fragment.kind ?? "");
|
|
16
|
+
const url = typeof fragment.url === "string" ? fragment.url : "";
|
|
17
|
+
if (!url) return "";
|
|
18
|
+
const name =
|
|
19
|
+
typeof fragment.name === "string" && fragment.name.trim()
|
|
20
|
+
? fragment.name
|
|
21
|
+
: kind === "image"
|
|
22
|
+
? "image"
|
|
23
|
+
: kind === "audio"
|
|
24
|
+
? "audio"
|
|
25
|
+
: kind === "video"
|
|
26
|
+
? "video"
|
|
27
|
+
: "file";
|
|
28
|
+
return kind === "image" ? `` : `[${name}](${url})`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function fragmentsToText(fragments: Fragment[], opts: FlattenOptions = {}): string {
|
|
32
|
+
const fallback = opts.mentionFallbackIds ?? [];
|
|
33
|
+
let fallbackCursor = 0;
|
|
34
|
+
const parts = fragments.map((fragment) => {
|
|
35
|
+
const f = fragment as unknown as Record<string, unknown>;
|
|
36
|
+
if (f.kind === "text" && typeof f.text === "string") {
|
|
37
|
+
return f.text;
|
|
38
|
+
}
|
|
39
|
+
if (f.kind === "mention") {
|
|
40
|
+
const display = typeof f.display === "string" ? f.display : undefined;
|
|
41
|
+
if (display && display.trim()) return display;
|
|
42
|
+
const id = typeof f.user_id === "string" ? f.user_id : undefined;
|
|
43
|
+
if (id && id.trim()) return `@${id}`;
|
|
44
|
+
const fallbackId = fallback[fallbackCursor++];
|
|
45
|
+
if (fallbackId) return `@${fallbackId}`;
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
if (typeof f.kind === "string" && isMediaKind(f.kind)) {
|
|
49
|
+
return renderMediaPlaceholder(f);
|
|
50
|
+
}
|
|
51
|
+
return "";
|
|
52
|
+
});
|
|
53
|
+
return parts.join("").trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function textToFragments(text: string): Fragment[] {
|
|
57
|
+
if (!text || !text.trim()) return [];
|
|
58
|
+
return [{ kind: "text", text }];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Extract media fragments from a body (image/file/audio/video). Skips
|
|
63
|
+
* entries missing `url`. Preserves all optional metadata fields the
|
|
64
|
+
* SDK passes through (mime/size/width/height/duration/name).
|
|
65
|
+
*/
|
|
66
|
+
export function extractMediaFragments(fragments: Fragment[]): MediaItem[] {
|
|
67
|
+
const out: MediaItem[] = [];
|
|
68
|
+
for (const fragment of fragments) {
|
|
69
|
+
const f = fragment as unknown as Record<string, unknown>;
|
|
70
|
+
if (typeof f.kind !== "string" || !isMediaKind(f.kind)) continue;
|
|
71
|
+
if (typeof f.url !== "string" || !f.url) continue;
|
|
72
|
+
const item: MediaItem = { kind: f.kind, url: f.url };
|
|
73
|
+
if (typeof f.name === "string") item.name = f.name;
|
|
74
|
+
if (typeof f.mime === "string") item.mime = f.mime;
|
|
75
|
+
if (typeof f.size === "number") item.size = f.size;
|
|
76
|
+
if (typeof f.width === "number") item.width = f.width;
|
|
77
|
+
if (typeof f.height === "number") item.height = f.height;
|
|
78
|
+
if (typeof f.duration === "number") item.duration = f.duration;
|
|
79
|
+
out.push(item);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { ClawlingChatClient } from "@newbase-clawchat/sdk";
|
|
2
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { ResolvedOpenclawClawlingAccount } from "./config.ts";
|
|
5
|
+
import { sendOpenclawClawlingMedia, sendOpenclawClawlingText } from "./outbound.ts";
|
|
6
|
+
|
|
7
|
+
function baseAccount(
|
|
8
|
+
overrides: Partial<ResolvedOpenclawClawlingAccount> = {},
|
|
9
|
+
): ResolvedOpenclawClawlingAccount {
|
|
10
|
+
return {
|
|
11
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
12
|
+
name: "openclaw-clawchat",
|
|
13
|
+
enabled: true,
|
|
14
|
+
configured: true,
|
|
15
|
+
websocketUrl: "ws://t",
|
|
16
|
+
baseUrl: "",
|
|
17
|
+
token: "tk",
|
|
18
|
+
userId: "agent-1",
|
|
19
|
+
replyMode: "static",
|
|
20
|
+
forwardThinking: true,
|
|
21
|
+
forwardToolCalls: false,
|
|
22
|
+
allowFrom: [],
|
|
23
|
+
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
24
|
+
reconnect: {
|
|
25
|
+
initialDelay: 1000,
|
|
26
|
+
maxDelay: 30000,
|
|
27
|
+
jitterRatio: 0.3,
|
|
28
|
+
maxRetries: Number.POSITIVE_INFINITY,
|
|
29
|
+
},
|
|
30
|
+
heartbeat: { interval: 25000, timeout: 10000 },
|
|
31
|
+
ack: { timeout: 10000, autoResendOnTimeout: false },
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function mockClient() {
|
|
37
|
+
return {
|
|
38
|
+
sendMessage: vi.fn().mockResolvedValue({
|
|
39
|
+
version: "2",
|
|
40
|
+
event: "message.ack",
|
|
41
|
+
trace_id: "trace-ack",
|
|
42
|
+
emitted_at: Date.now(),
|
|
43
|
+
payload: { message_id: "server-m1", accepted_at: 1234 },
|
|
44
|
+
}),
|
|
45
|
+
replyMessage: vi.fn().mockResolvedValue({
|
|
46
|
+
version: "2",
|
|
47
|
+
event: "message.ack",
|
|
48
|
+
trace_id: "trace-ack-r",
|
|
49
|
+
emitted_at: Date.now(),
|
|
50
|
+
payload: { message_id: "server-r1", accepted_at: 5678 },
|
|
51
|
+
}),
|
|
52
|
+
typing: vi.fn(),
|
|
53
|
+
emitRaw: vi.fn(),
|
|
54
|
+
} as unknown as ClawlingChatClient;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("openclaw-clawchat outbound", () => {
|
|
58
|
+
it("routes to sendMessage when no replyCtx", async () => {
|
|
59
|
+
const client = mockClient();
|
|
60
|
+
const result = await sendOpenclawClawlingText({
|
|
61
|
+
client,
|
|
62
|
+
account: baseAccount(),
|
|
63
|
+
to: { chatId: "user-1", chatType: "direct" },
|
|
64
|
+
text: "hello",
|
|
65
|
+
});
|
|
66
|
+
expect((client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).toMatchObject({
|
|
67
|
+
to: { id: "user-1", type: "direct" },
|
|
68
|
+
body: { fragments: [{ kind: "text", text: "hello" }] },
|
|
69
|
+
});
|
|
70
|
+
expect(client.replyMessage).not.toHaveBeenCalled();
|
|
71
|
+
expect(result?.messageId).toBe("server-m1");
|
|
72
|
+
expect(result?.acceptedAt).toBe(1234);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("routes to replyMessage when replyCtx is provided", async () => {
|
|
76
|
+
const client = mockClient();
|
|
77
|
+
await sendOpenclawClawlingText({
|
|
78
|
+
client,
|
|
79
|
+
account: baseAccount(),
|
|
80
|
+
to: { chatId: "user-1", chatType: "direct" },
|
|
81
|
+
text: "reply",
|
|
82
|
+
replyCtx: {
|
|
83
|
+
replyToMessageId: "m-orig",
|
|
84
|
+
replyPreviewSenderId: "user-2",
|
|
85
|
+
replyPreviewDisplayName: "Sender",
|
|
86
|
+
replyPreviewText: "original",
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
expect((client.replyMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).toMatchObject({
|
|
90
|
+
to: { id: "user-1", type: "direct" },
|
|
91
|
+
replyTo: {
|
|
92
|
+
msgId: "m-orig",
|
|
93
|
+
senderId: "user-2",
|
|
94
|
+
displayName: "Sender",
|
|
95
|
+
fragments: [{ kind: "text", text: "original" }],
|
|
96
|
+
},
|
|
97
|
+
body: { fragments: [{ kind: "text", text: "reply" }] },
|
|
98
|
+
});
|
|
99
|
+
expect(client.sendMessage).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("suppresses send when text is empty after trim", async () => {
|
|
103
|
+
const client = mockClient();
|
|
104
|
+
const result = await sendOpenclawClawlingText({
|
|
105
|
+
client,
|
|
106
|
+
account: baseAccount(),
|
|
107
|
+
to: { chatId: "u", chatType: "direct" },
|
|
108
|
+
text: " ",
|
|
109
|
+
});
|
|
110
|
+
expect(client.sendMessage).not.toHaveBeenCalled();
|
|
111
|
+
expect(client.replyMessage).not.toHaveBeenCalled();
|
|
112
|
+
expect(result).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("propagates SDK errors", async () => {
|
|
116
|
+
const client = {
|
|
117
|
+
...mockClient(),
|
|
118
|
+
sendMessage: vi.fn().mockRejectedValue(new Error("boom")),
|
|
119
|
+
} as unknown as ClawlingChatClient;
|
|
120
|
+
await expect(
|
|
121
|
+
sendOpenclawClawlingText({
|
|
122
|
+
client,
|
|
123
|
+
account: baseAccount(),
|
|
124
|
+
to: { chatId: "u", chatType: "direct" },
|
|
125
|
+
text: "hi",
|
|
126
|
+
}),
|
|
127
|
+
).rejects.toThrow("boom");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("appends mediaFragments after text in body.fragments", async () => {
|
|
131
|
+
const client = mockClient();
|
|
132
|
+
await sendOpenclawClawlingText({
|
|
133
|
+
client,
|
|
134
|
+
account: baseAccount(),
|
|
135
|
+
to: { chatId: "user-1", chatType: "direct" },
|
|
136
|
+
text: "look",
|
|
137
|
+
mediaFragments: [{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 }],
|
|
138
|
+
});
|
|
139
|
+
const callArg = (client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
140
|
+
expect(callArg.body.fragments).toEqual([
|
|
141
|
+
{ kind: "text", text: "look" },
|
|
142
|
+
{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 },
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("sends media-only message when text empty but mediaFragments present", async () => {
|
|
147
|
+
const client = mockClient();
|
|
148
|
+
const result = await sendOpenclawClawlingText({
|
|
149
|
+
client,
|
|
150
|
+
account: baseAccount(),
|
|
151
|
+
to: { chatId: "user-1", chatType: "direct" },
|
|
152
|
+
text: "",
|
|
153
|
+
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
154
|
+
});
|
|
155
|
+
const callArg = (client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
156
|
+
expect(callArg.body.fragments).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
|
|
157
|
+
expect(result?.messageId).toBe("server-m1");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("downgrades replyCtx + media to sendMessage", async () => {
|
|
161
|
+
const client = mockClient();
|
|
162
|
+
const log = { info: vi.fn(), error: vi.fn() };
|
|
163
|
+
await sendOpenclawClawlingText({
|
|
164
|
+
client,
|
|
165
|
+
account: baseAccount(),
|
|
166
|
+
to: { chatId: "user-1", chatType: "direct" },
|
|
167
|
+
text: "hi",
|
|
168
|
+
replyCtx: {
|
|
169
|
+
replyToMessageId: "m-orig",
|
|
170
|
+
replyPreviewSenderId: "user-2",
|
|
171
|
+
replyPreviewDisplayName: "Sender",
|
|
172
|
+
replyPreviewText: "original",
|
|
173
|
+
},
|
|
174
|
+
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
175
|
+
log,
|
|
176
|
+
});
|
|
177
|
+
expect(client.replyMessage).not.toHaveBeenCalled();
|
|
178
|
+
expect(client.sendMessage).toHaveBeenCalled();
|
|
179
|
+
expect(log.info).toHaveBeenCalledWith(
|
|
180
|
+
expect.stringMatching(/replyCtx \+ media: downgraded to sendMessage/),
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("suppresses send when both text and mediaFragments are empty", async () => {
|
|
185
|
+
const client = mockClient();
|
|
186
|
+
const result = await sendOpenclawClawlingText({
|
|
187
|
+
client,
|
|
188
|
+
account: baseAccount(),
|
|
189
|
+
to: { chatId: "u", chatType: "direct" },
|
|
190
|
+
text: " ",
|
|
191
|
+
mediaFragments: [],
|
|
192
|
+
});
|
|
193
|
+
expect(client.sendMessage).not.toHaveBeenCalled();
|
|
194
|
+
expect(result).toBeNull();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("sendOpenclawClawlingMedia with image and caption sends both fragments", async () => {
|
|
198
|
+
const client = mockClient();
|
|
199
|
+
const result = await sendOpenclawClawlingMedia({
|
|
200
|
+
client,
|
|
201
|
+
account: baseAccount(),
|
|
202
|
+
to: { chatId: "user-1", chatType: "direct" },
|
|
203
|
+
text: "look at this",
|
|
204
|
+
mediaFragments: [{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 }],
|
|
205
|
+
});
|
|
206
|
+
const callArg = (client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
207
|
+
expect(callArg.body.fragments).toEqual([
|
|
208
|
+
{ kind: "text", text: "look at this" },
|
|
209
|
+
{ kind: "image", url: "https://cdn/x.png", mime: "image/png", size: 12 },
|
|
210
|
+
]);
|
|
211
|
+
expect(result?.messageId).toBe("server-m1");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("sendOpenclawClawlingMedia with image only (no text) sends just the media fragment", async () => {
|
|
215
|
+
const client = mockClient();
|
|
216
|
+
const result = await sendOpenclawClawlingMedia({
|
|
217
|
+
client,
|
|
218
|
+
account: baseAccount(),
|
|
219
|
+
to: { chatId: "user-1", chatType: "direct" },
|
|
220
|
+
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
221
|
+
});
|
|
222
|
+
const callArg = (client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
223
|
+
expect(callArg.body.fragments).toEqual([{ kind: "image", url: "https://cdn/x.png" }]);
|
|
224
|
+
expect(result?.messageId).toBe("server-m1");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("sendOpenclawClawlingMedia returns null and does not call SDK when mediaFragments is empty", async () => {
|
|
228
|
+
const client = mockClient();
|
|
229
|
+
const log = { info: vi.fn(), error: vi.fn() };
|
|
230
|
+
const result = await sendOpenclawClawlingMedia({
|
|
231
|
+
client,
|
|
232
|
+
account: baseAccount(),
|
|
233
|
+
to: { chatId: "u", chatType: "direct" },
|
|
234
|
+
mediaFragments: [],
|
|
235
|
+
log,
|
|
236
|
+
});
|
|
237
|
+
expect(client.sendMessage).not.toHaveBeenCalled();
|
|
238
|
+
expect(client.replyMessage).not.toHaveBeenCalled();
|
|
239
|
+
expect(result).toBeNull();
|
|
240
|
+
expect(log.info).toHaveBeenCalledWith(
|
|
241
|
+
expect.stringMatching(/sendMedia called with empty mediaFragments/),
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
});
|