@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 +3 -5
- package/src/api-client.test.ts +19 -0
- package/src/api-client.ts +2 -2
- package/src/api-types.ts +3 -3
- package/src/channel.outbound.test.ts +203 -0
- package/src/channel.test.ts +19 -0
- package/src/channel.ts +16 -72
- package/src/inbound.test.ts +17 -0
- package/src/inbound.ts +3 -1
- package/src/media-runtime.test.ts +9 -16
- package/src/media-runtime.ts +4 -4
- package/src/outbound.ts +115 -2
- package/src/protocol.test.ts +3 -1
- package/src/protocol.ts +7 -3
- package/src/reply-dispatcher.test.ts +252 -0
- package/src/reply-dispatcher.ts +37 -3
- package/src/runtime.test.ts +298 -0
- package/src/runtime.ts +53 -11
- package/src/tools-schema.ts +10 -2
- package/src/tools.test.ts +31 -2
- package/src/tools.ts +59 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@newbase-clawchat/openclaw-clawchat",
|
|
3
|
-
"version": "2026.4.
|
|
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":
|
|
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
|
}
|
package/src/api-client.test.ts
CHANGED
|
@@ -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;
|
|
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/
|
|
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
|
@@ -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
|
+
});
|
package/src/channel.test.ts
CHANGED
|
@@ -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
|
|
23
|
-
import {
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
});
|
package/src/inbound.test.ts
CHANGED
|
@@ -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("");
|
|
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?.(
|
|
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
|
|
103
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
149
|
+
const { runtime } = buildRuntime();
|
|
157
150
|
expect(await uploadOutboundMedia([], { apiClient, runtime })).toEqual([]);
|
|
158
151
|
});
|
|
159
152
|
});
|
package/src/media-runtime.ts
CHANGED
|
@@ -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
|
|
102
|
-
*
|
|
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 }
|