@newbase-clawchat/openclaw-clawchat 2026.4.21 → 2026.4.22
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 +1 -1
- 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 +116 -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 +113 -2
- package/src/protocol.test.ts +3 -1
- package/src/protocol.ts +7 -3
- package/src/runtime.test.ts +148 -0
- package/src/runtime.ts +18 -10
- package/src/tools-schema.ts +10 -2
- package/src/tools.test.ts +31 -2
- package/src/tools.ts +59 -15
package/package.json
CHANGED
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,116 @@
|
|
|
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 uploadOutboundMediaMock = vi.hoisted(() => vi.fn());
|
|
6
|
+
const createApiClientMock = vi.hoisted(() => vi.fn());
|
|
7
|
+
const sendTextMock = vi.hoisted(() => vi.fn());
|
|
8
|
+
const sendMediaMock = vi.hoisted(() => vi.fn());
|
|
9
|
+
|
|
10
|
+
vi.mock("./runtime.ts", () => ({
|
|
11
|
+
getOpenclawClawlingClient: getClientMock,
|
|
12
|
+
getOpenclawClawlingRuntime: getRuntimeMock,
|
|
13
|
+
startOpenclawClawlingGateway: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("./media-runtime.ts", () => ({
|
|
17
|
+
uploadOutboundMedia: uploadOutboundMediaMock,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("./api-client.ts", () => ({
|
|
21
|
+
createOpenclawClawlingApiClient: createApiClientMock,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("./outbound.ts", () => ({
|
|
25
|
+
sendOpenclawClawlingText: sendTextMock,
|
|
26
|
+
sendOpenclawClawlingMedia: sendMediaMock,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
describe("openclaw-clawchat channel outbound", () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
vi.resetModules();
|
|
32
|
+
getClientMock.mockReset();
|
|
33
|
+
getRuntimeMock.mockReset();
|
|
34
|
+
uploadOutboundMediaMock.mockReset();
|
|
35
|
+
createApiClientMock.mockReset();
|
|
36
|
+
sendTextMock.mockReset();
|
|
37
|
+
sendMediaMock.mockReset();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("sendMedia uploads mediaUrl and sends resulting fragments", async () => {
|
|
41
|
+
const client = { sendMessage: vi.fn() };
|
|
42
|
+
const runtime = { media: { loadWebMedia: vi.fn() } };
|
|
43
|
+
const apiClient = { uploadMedia: vi.fn() };
|
|
44
|
+
getClientMock.mockReturnValue(client);
|
|
45
|
+
getRuntimeMock.mockReturnValue(runtime);
|
|
46
|
+
createApiClientMock.mockReturnValue(apiClient);
|
|
47
|
+
uploadOutboundMediaMock.mockResolvedValue([
|
|
48
|
+
{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" },
|
|
49
|
+
]);
|
|
50
|
+
sendMediaMock.mockResolvedValue({ messageId: "m-1", acceptedAt: 123 });
|
|
51
|
+
|
|
52
|
+
const { openclawClawlingOutbound } = await import("./outbound.ts");
|
|
53
|
+
const result = await openclawClawlingOutbound.sendMedia!({
|
|
54
|
+
cfg: {
|
|
55
|
+
channels: {
|
|
56
|
+
"openclaw-clawchat": {
|
|
57
|
+
enabled: true,
|
|
58
|
+
websocketUrl: "ws://t",
|
|
59
|
+
baseUrl: "https://api.example.com",
|
|
60
|
+
token: "tk",
|
|
61
|
+
userId: "agent-1",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
} as never,
|
|
65
|
+
to: "cc:group:room-1",
|
|
66
|
+
text: "caption",
|
|
67
|
+
mediaUrl: "/tmp/photo.png",
|
|
68
|
+
mediaLocalRoots: ["/tmp"],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(createApiClientMock).toHaveBeenCalledWith({
|
|
72
|
+
baseUrl: "https://api.example.com",
|
|
73
|
+
token: "tk",
|
|
74
|
+
userId: "agent-1",
|
|
75
|
+
});
|
|
76
|
+
expect(uploadOutboundMediaMock).toHaveBeenCalledWith(["/tmp/photo.png"], {
|
|
77
|
+
apiClient,
|
|
78
|
+
runtime,
|
|
79
|
+
mediaLocalRoots: ["/tmp"],
|
|
80
|
+
});
|
|
81
|
+
expect(sendMediaMock).toHaveBeenCalledWith({
|
|
82
|
+
client,
|
|
83
|
+
account: expect.objectContaining({ userId: "agent-1", baseUrl: "https://api.example.com" }),
|
|
84
|
+
to: { chatId: "room-1", chatType: "group" },
|
|
85
|
+
text: "caption",
|
|
86
|
+
mediaFragments: [{ kind: "image", url: "https://cdn/uploaded.png", mime: "image/png" }],
|
|
87
|
+
});
|
|
88
|
+
expect(result).toEqual({
|
|
89
|
+
channel: "openclaw-clawchat",
|
|
90
|
+
to: "cc:group:room-1",
|
|
91
|
+
messageId: "m-1",
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("sendMedia rejects missing mediaUrl", async () => {
|
|
96
|
+
getClientMock.mockReturnValue({ sendMessage: vi.fn() });
|
|
97
|
+
const { openclawClawlingOutbound } = await import("./outbound.ts");
|
|
98
|
+
await expect(
|
|
99
|
+
openclawClawlingOutbound.sendMedia!({
|
|
100
|
+
cfg: {
|
|
101
|
+
channels: {
|
|
102
|
+
"openclaw-clawchat": {
|
|
103
|
+
enabled: true,
|
|
104
|
+
websocketUrl: "ws://t",
|
|
105
|
+
baseUrl: "https://api.example.com",
|
|
106
|
+
token: "tk",
|
|
107
|
+
userId: "agent-1",
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
} as never,
|
|
111
|
+
to: "cc:user-1",
|
|
112
|
+
text: "caption",
|
|
113
|
+
}),
|
|
114
|
+
).rejects.toThrow(/requires mediaUrl/);
|
|
115
|
+
});
|
|
116
|
+
});
|
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 }
|
package/src/outbound.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { ClawlingChatClient, Envelope, Fragment, MessageAckPayload } from "@newbase-clawchat/sdk";
|
|
2
|
+
import {
|
|
3
|
+
createAttachedChannelResultAdapter,
|
|
4
|
+
type ChannelOutboundAdapter,
|
|
5
|
+
} from "openclaw/plugin-sdk/channel-send-result";
|
|
6
|
+
import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
|
|
2
7
|
import type { ChatType } from "./client.ts";
|
|
3
|
-
import
|
|
4
|
-
import type
|
|
8
|
+
import { createOpenclawClawlingApiClient } from "./api-client.ts";
|
|
9
|
+
import { CHANNEL_ID, resolveOpenclawClawlingAccount, type ResolvedOpenclawClawlingAccount } from "./config.ts";
|
|
5
10
|
import { textToFragments } from "./message-mapper.ts";
|
|
11
|
+
import { uploadOutboundMedia, type ClawlingMediaFragment } from "./media-runtime.ts";
|
|
12
|
+
import { getOpenclawClawlingClient, getOpenclawClawlingRuntime } from "./runtime.ts";
|
|
6
13
|
|
|
7
14
|
export interface OutboundTarget {
|
|
8
15
|
chatId: string;
|
|
@@ -37,6 +44,48 @@ export interface SendResult {
|
|
|
37
44
|
acceptedAt: number;
|
|
38
45
|
}
|
|
39
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Parse an agent-initiated outbound recipient string into the new-protocol
|
|
49
|
+
* `chat_id` + `chat_type` pair.
|
|
50
|
+
*
|
|
51
|
+
* Accepted forms (case-insensitive prefix):
|
|
52
|
+
* - `cc:{chat_id}` → direct
|
|
53
|
+
* - `clawchat:{chat_id}` → direct
|
|
54
|
+
* - `openclaw-clawchat:{chat_id}` → direct
|
|
55
|
+
* - `cc:direct:{chat_id}` → direct
|
|
56
|
+
* - `cc:group:{chat_id}` → group
|
|
57
|
+
* - `clawchat:direct:{chat_id}` → direct
|
|
58
|
+
* - `clawchat:group:{chat_id}` → group
|
|
59
|
+
* - `openclaw-clawchat:direct:{chat_id}` → direct
|
|
60
|
+
* - `openclaw-clawchat:group:{chat_id}` → group
|
|
61
|
+
* - bare `{chat_id}` → direct (backward compat)
|
|
62
|
+
*/
|
|
63
|
+
export function parseOpenclawRecipient(to: string): { chatId: string; chatType: ChatType } {
|
|
64
|
+
const raw = (to ?? "").trim();
|
|
65
|
+
if (!raw) throw new Error("openclaw-clawchat: outbound `to` is empty");
|
|
66
|
+
|
|
67
|
+
const firstColon = raw.indexOf(":");
|
|
68
|
+
if (firstColon < 0) return { chatId: raw, chatType: "direct" };
|
|
69
|
+
|
|
70
|
+
const scheme = raw.slice(0, firstColon).toLowerCase();
|
|
71
|
+
const rest = raw.slice(firstColon + 1);
|
|
72
|
+
if (scheme !== "cc" && scheme !== "clawchat" && scheme !== CHANNEL_ID) {
|
|
73
|
+
return { chatId: raw, chatType: "direct" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const secondColon = rest.indexOf(":");
|
|
77
|
+
if (secondColon >= 0) {
|
|
78
|
+
const typeToken = rest.slice(0, secondColon).toLowerCase();
|
|
79
|
+
const chatId = rest.slice(secondColon + 1).trim();
|
|
80
|
+
if ((typeToken === "direct" || typeToken === "group") && chatId) {
|
|
81
|
+
return { chatId, chatType: typeToken };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const chatId = rest.trim();
|
|
85
|
+
if (!chatId) throw new Error(`openclaw-clawchat: missing chat_id in "${to}"`);
|
|
86
|
+
return { chatId, chatType: "direct" };
|
|
87
|
+
}
|
|
88
|
+
|
|
40
89
|
export async function sendOpenclawClawlingText(params: SendParams): Promise<SendResult | null> {
|
|
41
90
|
const text = (params.text ?? "").trim();
|
|
42
91
|
const mediaFragments = params.mediaFragments ?? [];
|
|
@@ -139,3 +188,65 @@ export async function sendOpenclawClawlingMedia(
|
|
|
139
188
|
...(params.log ? { log: params.log } : {}),
|
|
140
189
|
});
|
|
141
190
|
}
|
|
191
|
+
|
|
192
|
+
export const openclawClawlingOutbound: ChannelOutboundAdapter = {
|
|
193
|
+
deliveryMode: "direct",
|
|
194
|
+
chunker: (text, limit) => chunkMarkdownText(text, limit),
|
|
195
|
+
chunkerMode: "markdown",
|
|
196
|
+
textChunkLimit: 4000,
|
|
197
|
+
...createAttachedChannelResultAdapter({
|
|
198
|
+
channel: CHANNEL_ID,
|
|
199
|
+
sendText: async ({ cfg, to, text }) => {
|
|
200
|
+
const account = resolveOpenclawClawlingAccount(cfg);
|
|
201
|
+
const client = getOpenclawClawlingClient(account.accountId);
|
|
202
|
+
if (!client) {
|
|
203
|
+
throw new Error(`openclaw-clawchat client not running for account ${account.accountId}`);
|
|
204
|
+
}
|
|
205
|
+
const result = await sendOpenclawClawlingText({
|
|
206
|
+
client,
|
|
207
|
+
account,
|
|
208
|
+
to: parseOpenclawRecipient(to),
|
|
209
|
+
text,
|
|
210
|
+
});
|
|
211
|
+
return {
|
|
212
|
+
to,
|
|
213
|
+
messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots }) => {
|
|
217
|
+
const account = resolveOpenclawClawlingAccount(cfg);
|
|
218
|
+
const client = getOpenclawClawlingClient(account.accountId);
|
|
219
|
+
if (!client) {
|
|
220
|
+
throw new Error(`openclaw-clawchat client not running for account ${account.accountId}`);
|
|
221
|
+
}
|
|
222
|
+
if (!mediaUrl?.trim()) {
|
|
223
|
+
throw new Error("openclaw-clawchat sendMedia requires mediaUrl");
|
|
224
|
+
}
|
|
225
|
+
const runtime = getOpenclawClawlingRuntime();
|
|
226
|
+
const apiClient = createOpenclawClawlingApiClient({
|
|
227
|
+
baseUrl: account.baseUrl,
|
|
228
|
+
token: account.token,
|
|
229
|
+
userId: account.userId,
|
|
230
|
+
});
|
|
231
|
+
const mediaFragments = await uploadOutboundMedia([mediaUrl.trim()], {
|
|
232
|
+
apiClient,
|
|
233
|
+
runtime,
|
|
234
|
+
...(mediaLocalRoots ? { mediaLocalRoots } : {}),
|
|
235
|
+
});
|
|
236
|
+
if (mediaFragments.length === 0) {
|
|
237
|
+
throw new Error(`openclaw-clawchat failed to upload media: ${mediaUrl}`);
|
|
238
|
+
}
|
|
239
|
+
const result = await sendOpenclawClawlingMedia({
|
|
240
|
+
client,
|
|
241
|
+
account,
|
|
242
|
+
to: parseOpenclawRecipient(to),
|
|
243
|
+
text,
|
|
244
|
+
mediaFragments,
|
|
245
|
+
});
|
|
246
|
+
return {
|
|
247
|
+
to,
|
|
248
|
+
messageId: result?.messageId ?? `${account.userId}-${Date.now()}`,
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
}),
|
|
252
|
+
};
|
package/src/protocol.test.ts
CHANGED
|
@@ -36,7 +36,9 @@ describe("openclaw-clawchat protocol guards", () => {
|
|
|
36
36
|
|
|
37
37
|
it("detects renderable text", () => {
|
|
38
38
|
expect(hasRenderableText({ body: { fragments: [{ kind: "text", text: "hi" }] } })).toBe(true);
|
|
39
|
-
expect(hasRenderableText({ body: { fragments: [{ kind: "image", url: "x" }] } })).toBe(
|
|
39
|
+
expect(hasRenderableText({ body: { fragments: [{ kind: "image", url: "x" }] } })).toBe(true);
|
|
40
|
+
expect(hasRenderableText({ body: { fragments: [{ kind: "file", url: "x" }] } })).toBe(true);
|
|
40
41
|
expect(hasRenderableText({ body: { fragments: [{ kind: "text", text: " " }] } })).toBe(false);
|
|
42
|
+
expect(hasRenderableText({ body: { fragments: [{ kind: "image" }] } })).toBe(false);
|
|
41
43
|
});
|
|
42
44
|
});
|
package/src/protocol.ts
CHANGED
|
@@ -31,8 +31,12 @@ export function hasRenderableText(message: {
|
|
|
31
31
|
const fragments = message?.body?.fragments ?? [];
|
|
32
32
|
return fragments.some(
|
|
33
33
|
(f) =>
|
|
34
|
-
(f as { kind?: string }).kind === "text" &&
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
(((f as { kind?: string }).kind === "text" &&
|
|
35
|
+
typeof (f as { text?: unknown }).text === "string" &&
|
|
36
|
+
(f as { text: string }).text.trim().length > 0) ||
|
|
37
|
+
(typeof (f as { kind?: unknown }).kind === "string" &&
|
|
38
|
+
["image", "file", "audio", "video"].includes((f as { kind: string }).kind) &&
|
|
39
|
+
typeof (f as { url?: unknown }).url === "string" &&
|
|
40
|
+
(f as { url: string }).url.trim().length > 0)),
|
|
37
41
|
);
|
|
38
42
|
}
|
package/src/runtime.test.ts
CHANGED
|
@@ -166,6 +166,8 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
166
166
|
event: "message.send",
|
|
167
167
|
trace_id: "ti",
|
|
168
168
|
emitted_at: Date.now(),
|
|
169
|
+
chat_id: "chat-1",
|
|
170
|
+
chat_type: "direct",
|
|
169
171
|
to: { id: "u", type: "direct" },
|
|
170
172
|
sender: { sender_id: "user-1", type: "direct", display_name: "User" },
|
|
171
173
|
payload: {
|
|
@@ -198,6 +200,152 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
198
200
|
expect(fetched).toEqual([{ url: "https://cdn/a.png" }]);
|
|
199
201
|
expect(capturedCtx?.MediaPath).toBe("/cache/1.png");
|
|
200
202
|
expect(capturedCtx?.MediaPaths).toEqual(["/cache/1.png"]);
|
|
203
|
+
expect(capturedCtx?.From).toBe("openclaw-clawchat:chat-1");
|
|
204
|
+
expect(capturedCtx?.ConversationLabel).toBe("chat-1");
|
|
205
|
+
expect(capturedCtx?.SenderId).toBe("user-1");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("uses group chat_id as the canonical conversation identity", async () => {
|
|
209
|
+
let capturedCtx: Record<string, unknown> | undefined;
|
|
210
|
+
const finalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => {
|
|
211
|
+
capturedCtx = ctx;
|
|
212
|
+
return ctx;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const runtime = {
|
|
216
|
+
channel: {
|
|
217
|
+
routing: {
|
|
218
|
+
resolveAgentRoute: vi.fn(() => ({
|
|
219
|
+
agentId: "u",
|
|
220
|
+
accountId: "default",
|
|
221
|
+
sessionKey: "s",
|
|
222
|
+
})),
|
|
223
|
+
},
|
|
224
|
+
session: {
|
|
225
|
+
resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
|
|
226
|
+
recordInboundSession: vi.fn().mockResolvedValue(undefined),
|
|
227
|
+
},
|
|
228
|
+
reply: {
|
|
229
|
+
formatAgentEnvelope: vi.fn((p: { body: string }) => p.body),
|
|
230
|
+
resolveEnvelopeFormatOptions: vi.fn(() => ({})),
|
|
231
|
+
finalizeInboundContext,
|
|
232
|
+
resolveHumanDelayConfig: vi.fn(() => ({ enabled: false })),
|
|
233
|
+
createReplyDispatcherWithTyping: vi.fn(() => ({
|
|
234
|
+
dispatcher: {},
|
|
235
|
+
replyOptions: {},
|
|
236
|
+
markDispatchIdle: vi.fn(),
|
|
237
|
+
markRunComplete: vi.fn(),
|
|
238
|
+
})),
|
|
239
|
+
withReplyDispatcher: vi.fn(async (opts: { run: () => Promise<unknown> }) => {
|
|
240
|
+
await opts.run();
|
|
241
|
+
}),
|
|
242
|
+
dispatchReplyFromConfig: vi.fn().mockResolvedValue(undefined),
|
|
243
|
+
},
|
|
244
|
+
media: {
|
|
245
|
+
fetchRemoteMedia: vi.fn(),
|
|
246
|
+
saveMediaBuffer: vi.fn(),
|
|
247
|
+
loadWebMedia: vi.fn(),
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
} as unknown as import("openclaw/plugin-sdk/core").PluginRuntime;
|
|
251
|
+
|
|
252
|
+
setOpenclawClawlingRuntime(runtime);
|
|
253
|
+
|
|
254
|
+
const { startOpenclawClawlingGateway } = await import("./runtime.ts");
|
|
255
|
+
const transport = new MockTransport();
|
|
256
|
+
const abortController = new AbortController();
|
|
257
|
+
|
|
258
|
+
const startPromise = startOpenclawClawlingGateway({
|
|
259
|
+
cfg: {} as import("openclaw/plugin-sdk/core").OpenClawConfig,
|
|
260
|
+
account: {
|
|
261
|
+
accountId: "default",
|
|
262
|
+
name: "openclaw-clawchat",
|
|
263
|
+
enabled: true,
|
|
264
|
+
configured: true,
|
|
265
|
+
websocketUrl: "ws://t",
|
|
266
|
+
baseUrl: "https://api.example.com",
|
|
267
|
+
token: "tk",
|
|
268
|
+
userId: "u",
|
|
269
|
+
replyMode: "static",
|
|
270
|
+
groupMode: "all",
|
|
271
|
+
forwardThinking: true,
|
|
272
|
+
forwardToolCalls: false,
|
|
273
|
+
allowFrom: [],
|
|
274
|
+
stream: { flushIntervalMs: 250, minChunkChars: 40, maxBufferChars: 2000 },
|
|
275
|
+
reconnect: {
|
|
276
|
+
initialDelay: 1000,
|
|
277
|
+
maxDelay: 30000,
|
|
278
|
+
jitterRatio: 0.3,
|
|
279
|
+
maxRetries: Number.POSITIVE_INFINITY,
|
|
280
|
+
},
|
|
281
|
+
heartbeat: { interval: 25000, timeout: 10000 },
|
|
282
|
+
ack: { timeout: 10000, autoResendOnTimeout: false },
|
|
283
|
+
},
|
|
284
|
+
abortSignal: abortController.signal,
|
|
285
|
+
setStatus: vi.fn(),
|
|
286
|
+
getStatus: vi.fn(() => ({ accountId: "default" })),
|
|
287
|
+
log: { info: () => {}, error: () => {} },
|
|
288
|
+
transport,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
292
|
+
transport.emitInbound(
|
|
293
|
+
JSON.stringify({
|
|
294
|
+
version: "2",
|
|
295
|
+
event: "connect.challenge",
|
|
296
|
+
trace_id: "tc",
|
|
297
|
+
emitted_at: Date.now(),
|
|
298
|
+
payload: { nonce: "n" },
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
transport.emitInbound(
|
|
302
|
+
JSON.stringify({
|
|
303
|
+
version: "2",
|
|
304
|
+
event: "hello-ok",
|
|
305
|
+
trace_id: "th",
|
|
306
|
+
emitted_at: Date.now(),
|
|
307
|
+
payload: {},
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
311
|
+
|
|
312
|
+
transport.emitInbound(
|
|
313
|
+
JSON.stringify({
|
|
314
|
+
version: "2",
|
|
315
|
+
event: "message.send",
|
|
316
|
+
trace_id: "tg",
|
|
317
|
+
emitted_at: Date.now(),
|
|
318
|
+
chat_id: "grp-1",
|
|
319
|
+
chat_type: "group",
|
|
320
|
+
to: { id: "u", type: "group" },
|
|
321
|
+
sender: { sender_id: "user-1", type: "group", display_name: "Alice" },
|
|
322
|
+
payload: {
|
|
323
|
+
message_id: "m-group",
|
|
324
|
+
message_mode: "normal",
|
|
325
|
+
message: {
|
|
326
|
+
body: {
|
|
327
|
+
fragments: [{ kind: "text", text: "hello group" }],
|
|
328
|
+
},
|
|
329
|
+
context: { mentions: [], reply: null },
|
|
330
|
+
streaming: {
|
|
331
|
+
status: "static",
|
|
332
|
+
sequence: 0,
|
|
333
|
+
mutation_policy: "sealed",
|
|
334
|
+
started_at: null,
|
|
335
|
+
completed_at: null,
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
}),
|
|
340
|
+
);
|
|
341
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
342
|
+
abortController.abort();
|
|
343
|
+
await startPromise;
|
|
344
|
+
|
|
345
|
+
expect(capturedCtx?.From).toBe("openclaw-clawchat:group:grp-1");
|
|
346
|
+
expect(capturedCtx?.ConversationLabel).toBe("group:grp-1");
|
|
347
|
+
expect(capturedCtx?.SenderId).toBe("user-1");
|
|
348
|
+
expect(capturedCtx?.ChatType).toBe("group");
|
|
201
349
|
});
|
|
202
350
|
});
|
|
203
351
|
|
package/src/runtime.ts
CHANGED
|
@@ -80,6 +80,10 @@ export function classifyClawlingClientError(err: unknown): {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
function formatConversationSubject(peer: { kind: "direct" | "group"; id: string }): string {
|
|
84
|
+
return peer.kind === "group" ? `group:${peer.id}` : peer.id;
|
|
85
|
+
}
|
|
86
|
+
|
|
83
87
|
export interface StartGatewayParams {
|
|
84
88
|
cfg: OpenClawConfig;
|
|
85
89
|
account: ResolvedOpenclawClawlingAccount;
|
|
@@ -152,7 +156,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
152
156
|
});
|
|
153
157
|
const body = rt.reply.formatAgentEnvelope({
|
|
154
158
|
channel: "Clawling Chat",
|
|
155
|
-
from: turn.
|
|
159
|
+
from: formatConversationSubject(turn.peer),
|
|
156
160
|
body: turn.rawBody,
|
|
157
161
|
timestamp: turn.timestamp,
|
|
158
162
|
...rt.reply.resolveEnvelopeFormatOptions(cfg),
|
|
@@ -162,12 +166,16 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
162
166
|
BodyForAgent: turn.rawBody,
|
|
163
167
|
RawBody: turn.rawBody,
|
|
164
168
|
CommandBody: turn.rawBody,
|
|
165
|
-
|
|
169
|
+
// Clawling v2 routes by chat_id. `senderId` is still preserved as
|
|
170
|
+
// structured metadata, but the conversation target must be based on
|
|
171
|
+
// `peer.id` so follow-up sends address the active chat, not merely
|
|
172
|
+
// the human sender identity.
|
|
173
|
+
From: `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`,
|
|
166
174
|
To: `${CHANNEL_ID}:${account.userId}`,
|
|
167
175
|
SessionKey: route.sessionKey,
|
|
168
176
|
AccountId: route.accountId ?? accountId,
|
|
169
177
|
ChatType: turn.peer.kind,
|
|
170
|
-
ConversationLabel: turn.
|
|
178
|
+
ConversationLabel: formatConversationSubject(turn.peer),
|
|
171
179
|
SenderId: turn.senderId,
|
|
172
180
|
Provider: CHANNEL_ID,
|
|
173
181
|
Surface: CHANNEL_ID,
|
|
@@ -230,12 +238,14 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
230
238
|
log?.info?.(
|
|
231
239
|
`[${accountId}] openclaw-clawchat dispatching reply msg=${turn.messageId} session=${ctxPayload.SessionKey ?? route.sessionKey} agent=${route.agentId} agentsConfigured=[${agentsConfigured.join(",")}]`,
|
|
232
240
|
);
|
|
241
|
+
const dispatchResult = await rt.reply.withReplyDispatcher({
|
|
242
|
+
dispatcher,
|
|
243
|
+
onSettled: () => markDispatchIdle(),
|
|
244
|
+
run: () =>
|
|
245
|
+
rt.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions }),
|
|
246
|
+
});
|
|
233
247
|
try {
|
|
234
|
-
|
|
235
|
-
dispatcher,
|
|
236
|
-
run: () =>
|
|
237
|
-
rt.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions }),
|
|
238
|
-
});
|
|
248
|
+
|
|
239
249
|
const counts = (dispatchResult as { counts?: Record<string, number> } | undefined)?.counts ?? {};
|
|
240
250
|
const queuedFinal = Boolean(
|
|
241
251
|
(dispatchResult as { queuedFinal?: boolean } | undefined)?.queuedFinal,
|
|
@@ -255,8 +265,6 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
255
265
|
`[${accountId}] openclaw-clawchat dispatch failed msg=${turn.messageId}: ${String(err)}`,
|
|
256
266
|
);
|
|
257
267
|
throw err;
|
|
258
|
-
} finally {
|
|
259
|
-
markDispatchIdle();
|
|
260
268
|
}
|
|
261
269
|
},
|
|
262
270
|
}).catch((err) => {
|
package/src/tools-schema.ts
CHANGED
|
@@ -22,9 +22,10 @@ export type ClawchatListFriendsParams = Static<typeof ClawchatListFriendsSchema>
|
|
|
22
22
|
|
|
23
23
|
export const ClawchatUpdateMyProfileSchema = Type.Object({
|
|
24
24
|
nickname: Type.Optional(Type.String({ description: "New Nick Name" })),
|
|
25
|
-
|
|
26
|
-
Type.String({ description: "Avatar URL (use
|
|
25
|
+
avatar_url: Type.Optional(
|
|
26
|
+
Type.String({ description: "Avatar URL (use clawchat_upload_avatar first to obtain)" }),
|
|
27
27
|
),
|
|
28
|
+
bio: Type.Optional(Type.String({ description: "New self-introduction / bio text" })),
|
|
28
29
|
});
|
|
29
30
|
export type ClawchatUpdateMyProfileParams = Static<typeof ClawchatUpdateMyProfileSchema>;
|
|
30
31
|
|
|
@@ -35,6 +36,13 @@ export const ClawchatUploadFileSchema = Type.Object({
|
|
|
35
36
|
});
|
|
36
37
|
export type ClawchatUploadFileParams = Static<typeof ClawchatUploadFileSchema>;
|
|
37
38
|
|
|
39
|
+
export const ClawchatUploadAvatarSchema = Type.Object({
|
|
40
|
+
filePath: Type.String({
|
|
41
|
+
description: "Absolute local path of the avatar image to upload (max 20MB)",
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
export type ClawchatUploadAvatarParams = Static<typeof ClawchatUploadAvatarSchema>;
|
|
45
|
+
|
|
38
46
|
export const ClawchatActivateSchema = Type.Object({
|
|
39
47
|
code: Type.String({
|
|
40
48
|
description:
|
package/src/tools.test.ts
CHANGED
|
@@ -55,7 +55,7 @@ describe("registerOpenclawClawlingTools", () => {
|
|
|
55
55
|
expect(registered).toHaveLength(0);
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
it("registers all
|
|
58
|
+
it("registers all seven tools when configured (regardless of baseUrl)", () => {
|
|
59
59
|
const { api, registered } = buildApi({
|
|
60
60
|
configChannel: configuredChannel(/* no baseUrl */),
|
|
61
61
|
});
|
|
@@ -67,6 +67,7 @@ describe("registerOpenclawClawlingTools", () => {
|
|
|
67
67
|
"clawchat_get_user_info",
|
|
68
68
|
"clawchat_list_friends",
|
|
69
69
|
"clawchat_update_my_profile",
|
|
70
|
+
"clawchat_upload_avatar",
|
|
70
71
|
"clawchat_upload_file",
|
|
71
72
|
]);
|
|
72
73
|
});
|
|
@@ -91,7 +92,7 @@ describe("registerOpenclawClawlingTools", () => {
|
|
|
91
92
|
expect(activate.description).toMatch(/verbatim/i);
|
|
92
93
|
});
|
|
93
94
|
|
|
94
|
-
it("clawchat_update_my_profile description names name + avatar triggers (EN + ZH)", () => {
|
|
95
|
+
it("clawchat_update_my_profile description names name + avatar + bio triggers (EN + ZH)", () => {
|
|
95
96
|
const { api } = buildApi({ configChannel: configuredChannel() });
|
|
96
97
|
const fullTools: Array<{ name: string; description?: string }> = [];
|
|
97
98
|
api.registerTool = (tool: { name: string; description?: string }) => {
|
|
@@ -104,6 +105,34 @@ describe("registerOpenclawClawlingTools", () => {
|
|
|
104
105
|
expect(update.description).toMatch(/你叫/);
|
|
105
106
|
expect(update.description).toMatch(/avatar/i);
|
|
106
107
|
expect(update.description).toMatch(/生成头像|换个头像/);
|
|
108
|
+
expect(update.description).toMatch(/clawchat_upload_avatar/);
|
|
109
|
+
expect(update.description).toMatch(/bio|self introduction/i);
|
|
110
|
+
expect(update.description).toMatch(/自我介绍|个人简介/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("clawchat_upload_avatar rejects oversized files before upload", async () => {
|
|
114
|
+
const fs = await import("node:fs/promises");
|
|
115
|
+
const path = await import("node:path");
|
|
116
|
+
const os = await import("node:os");
|
|
117
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawchat-"));
|
|
118
|
+
const big = path.join(tmp, "avatar-big.bin");
|
|
119
|
+
const handle = await fs.open(big, "w");
|
|
120
|
+
await handle.truncate(21 * 1024 * 1024);
|
|
121
|
+
await handle.close();
|
|
122
|
+
try {
|
|
123
|
+
const { api, registered } = buildApi({
|
|
124
|
+
configChannel: configuredChannel({ baseUrl: "https://api.example.com" }),
|
|
125
|
+
});
|
|
126
|
+
registerOpenclawClawlingTools(api);
|
|
127
|
+
const tool = registered.find((t) => t.name === "clawchat_upload_avatar")!;
|
|
128
|
+
const result = await tool.execute("call-1", { filePath: big });
|
|
129
|
+
const text = (result as { content: { text: string }[] }).content[0]!.text;
|
|
130
|
+
const parsed = JSON.parse(text) as { error?: string; message?: string };
|
|
131
|
+
expect(parsed.error).toBe("validation");
|
|
132
|
+
expect(parsed.message).toMatch(/20 ?MB|too large/i);
|
|
133
|
+
} finally {
|
|
134
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
135
|
+
}
|
|
107
136
|
});
|
|
108
137
|
|
|
109
138
|
|
package/src/tools.ts
CHANGED
|
@@ -11,11 +11,13 @@ import {
|
|
|
11
11
|
ClawchatGetUserInfoSchema,
|
|
12
12
|
ClawchatListFriendsSchema,
|
|
13
13
|
ClawchatUpdateMyProfileSchema,
|
|
14
|
+
ClawchatUploadAvatarSchema,
|
|
14
15
|
ClawchatUploadFileSchema,
|
|
15
16
|
type ClawchatActivateParams,
|
|
16
17
|
type ClawchatGetUserInfoParams,
|
|
17
18
|
type ClawchatListFriendsParams,
|
|
18
19
|
type ClawchatUpdateMyProfileParams,
|
|
20
|
+
type ClawchatUploadAvatarParams,
|
|
19
21
|
type ClawchatUploadFileParams,
|
|
20
22
|
} from "./tools-schema.ts";
|
|
21
23
|
|
|
@@ -188,7 +190,7 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
|
|
|
188
190
|
api.registerTool(
|
|
189
191
|
{
|
|
190
192
|
name: "clawchat_get_my_profile",
|
|
191
|
-
label: "
|
|
193
|
+
label: "Get Profile",
|
|
192
194
|
description: "Fetch the agent's own Clawling profile (id, display name, avatar, bio).",
|
|
193
195
|
parameters: ClawchatGetMyProfileSchema,
|
|
194
196
|
async execute(_callId, _params) {
|
|
@@ -201,7 +203,7 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
|
|
|
201
203
|
api.registerTool(
|
|
202
204
|
{
|
|
203
205
|
name: "clawchat_get_user_info",
|
|
204
|
-
label: "
|
|
206
|
+
label: "Get User Info",
|
|
205
207
|
description: "Fetch a Clawling user's public profile by userId.",
|
|
206
208
|
parameters: ClawchatGetUserInfoSchema,
|
|
207
209
|
async execute(_callId, params) {
|
|
@@ -215,7 +217,7 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
|
|
|
215
217
|
api.registerTool(
|
|
216
218
|
{
|
|
217
219
|
name: "clawchat_list_friends",
|
|
218
|
-
label: "
|
|
220
|
+
label: "List Friends",
|
|
219
221
|
description: "List the agent's friends, paginated (page=1, pageSize=20 by default).",
|
|
220
222
|
parameters: ClawchatListFriendsSchema,
|
|
221
223
|
async execute(_callId, params) {
|
|
@@ -234,9 +236,9 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
|
|
|
234
236
|
api.registerTool(
|
|
235
237
|
{
|
|
236
238
|
name: "clawchat_update_my_profile",
|
|
237
|
-
label: "
|
|
239
|
+
label: "Update Profile",
|
|
238
240
|
description:
|
|
239
|
-
"Update this agent's own ClawChat profile (nickname and/or avatar). " +
|
|
241
|
+
"Update this agent's own ClawChat profile (nickname and/or avatar and/or bio). " +
|
|
240
242
|
"TRIGGER — invoke this tool whenever the user's message matches ANY of: " +
|
|
241
243
|
"(1) nickname/name change: 'change your name to X', 'your name is X', 'rename yourself to X', " +
|
|
242
244
|
"'I'll call you X', 'from now on you are X', '你叫 X', '改名为 X', '我叫你 X', " +
|
|
@@ -244,19 +246,23 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
|
|
|
244
246
|
"(2) avatar change or generation: 'change your avatar', 'update your profile picture', " +
|
|
245
247
|
"'generate a new avatar', 'use this image as your avatar', '换个头像', '生成头像', " +
|
|
246
248
|
"'把头像改为 …' → first obtain the avatar URL (generate + upload via " +
|
|
247
|
-
"`
|
|
248
|
-
"with `
|
|
249
|
-
"
|
|
250
|
-
"
|
|
249
|
+
"`clawchat_upload_avatar`, OR use a provided URL directly), then call this tool " +
|
|
250
|
+
"with `avatar_url = <url>`; " +
|
|
251
|
+
"(3) bio/self-introduction change: 'update your bio', 'set your profile bio to X', " +
|
|
252
|
+
"'change your self introduction', '把简介改成 X', '更新自我介绍', '个人简介改为 X' " +
|
|
253
|
+
"→ call with `bio = X`. " +
|
|
254
|
+
"You can pass `nickname`, `avatar_url`, and `bio` together in one call, or just one of them. " +
|
|
255
|
+
"At least one of the three must be present.",
|
|
251
256
|
parameters: ClawchatUpdateMyProfileSchema,
|
|
252
257
|
async execute(_callId, params) {
|
|
253
258
|
const p = (params ?? {}) as ClawchatUpdateMyProfileParams;
|
|
254
|
-
const patch: {
|
|
255
|
-
if (typeof p.nickname === "string") patch.
|
|
256
|
-
if (typeof p.
|
|
259
|
+
const patch: { nickname?: string; avatar_url?: string; bio?: string } = {};
|
|
260
|
+
if (typeof p.nickname === "string") patch.nickname = p.nickname;
|
|
261
|
+
if (typeof p.avatar_url === "string") patch.avatar_url = p.avatar_url;
|
|
262
|
+
if (typeof p.bio === "string") patch.bio = p.bio;
|
|
257
263
|
if (Object.keys(patch).length === 0) {
|
|
258
264
|
return validationError(
|
|
259
|
-
"openclaw-clawchat: at least one of nickname / avatar is required",
|
|
265
|
+
"openclaw-clawchat: at least one of nickname / avatar / bio is required",
|
|
260
266
|
);
|
|
261
267
|
}
|
|
262
268
|
return await withClient((c): Promise<Profile> => c.updateMyProfile(patch));
|
|
@@ -265,10 +271,48 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
|
|
|
265
271
|
{ name: "clawchat_update_my_profile" },
|
|
266
272
|
);
|
|
267
273
|
|
|
274
|
+
api.registerTool(
|
|
275
|
+
{
|
|
276
|
+
name: "clawchat_upload_avatar",
|
|
277
|
+
label: "Upload Avatar",
|
|
278
|
+
description:
|
|
279
|
+
"Upload a local avatar image to Clawling avatar storage (max 20MB) and return the public URL. " +
|
|
280
|
+
"Use this before `clawchat_update_my_profile` when changing the profile picture.",
|
|
281
|
+
parameters: ClawchatUploadAvatarSchema,
|
|
282
|
+
async execute(_callId, params) {
|
|
283
|
+
const p = params as ClawchatUploadAvatarParams;
|
|
284
|
+
if (!p.filePath || !path.isAbsolute(p.filePath)) {
|
|
285
|
+
return validationError("openclaw-clawchat: filePath must be an absolute local path");
|
|
286
|
+
}
|
|
287
|
+
let stat: fs.Stats;
|
|
288
|
+
try {
|
|
289
|
+
stat = fs.statSync(p.filePath);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
return validationError(
|
|
292
|
+
`openclaw-clawchat: cannot stat ${p.filePath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (!stat.isFile()) {
|
|
296
|
+
return validationError(`openclaw-clawchat: ${p.filePath} is not a regular file`);
|
|
297
|
+
}
|
|
298
|
+
if (stat.size > MAX_UPLOAD_BYTES) {
|
|
299
|
+
return validationError(
|
|
300
|
+
`openclaw-clawchat: file too large (${stat.size} bytes; max 20MB)`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
const buffer = fs.readFileSync(p.filePath);
|
|
304
|
+
const filename = path.basename(p.filePath);
|
|
305
|
+
const mime = inferMimeFromPath(p.filePath);
|
|
306
|
+
return await withClient((c) => c.uploadAvatar({ buffer, filename, mime }));
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
{ name: "clawchat_upload_avatar" },
|
|
310
|
+
);
|
|
311
|
+
|
|
268
312
|
api.registerTool(
|
|
269
313
|
{
|
|
270
314
|
name: "clawchat_upload_file",
|
|
271
|
-
label: "
|
|
315
|
+
label: "Upload File",
|
|
272
316
|
description:
|
|
273
317
|
"Upload a local file to Clawling media storage (max 20MB) and return the public URL.",
|
|
274
318
|
parameters: ClawchatUploadFileSchema,
|
|
@@ -303,6 +347,6 @@ export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
|
|
|
303
347
|
);
|
|
304
348
|
|
|
305
349
|
api.logger.info?.(
|
|
306
|
-
"openclaw-clawchat: registered
|
|
350
|
+
"openclaw-clawchat: registered 7 clawchat_* tools (activate, get_my_profile, get_user_info, list_friends, update_my_profile, upload_avatar, upload_file)",
|
|
307
351
|
);
|
|
308
352
|
}
|