@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,116 @@
|
|
|
1
|
+
import type { ClawlingChatClient } from "@newbase-clawchat/sdk";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { sendStreamingText } from "./streaming.ts";
|
|
4
|
+
|
|
5
|
+
type EmittedEnvelope = { event: string; payload: Record<string, unknown>; to: unknown };
|
|
6
|
+
|
|
7
|
+
function mockClient() {
|
|
8
|
+
const sent: EmittedEnvelope[] = [];
|
|
9
|
+
const client = {
|
|
10
|
+
emitRaw: vi.fn(
|
|
11
|
+
(event: string, payload: Record<string, unknown>, routing?: { to?: unknown }) => {
|
|
12
|
+
sent.push({ event, payload, to: routing?.to });
|
|
13
|
+
},
|
|
14
|
+
),
|
|
15
|
+
typing: vi.fn(),
|
|
16
|
+
} as unknown as ClawlingChatClient;
|
|
17
|
+
return { client, sent };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("openclaw-clawchat streaming", () => {
|
|
21
|
+
it("emits typing(true) -> created -> N x add -> done -> typing(false)", async () => {
|
|
22
|
+
const { client, sent } = mockClient();
|
|
23
|
+
await sendStreamingText({
|
|
24
|
+
client,
|
|
25
|
+
to: { id: "u1", type: "direct" },
|
|
26
|
+
sender: { sender_id: "a1", type: "direct", display_name: "Clawling" },
|
|
27
|
+
chunks: ["Hel", "lo ", "world"],
|
|
28
|
+
messageId: "m1",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const events = sent.map((e) => e.event);
|
|
32
|
+
expect(events).toEqual([
|
|
33
|
+
"message.created",
|
|
34
|
+
"message.add",
|
|
35
|
+
"message.add",
|
|
36
|
+
"message.add",
|
|
37
|
+
"message.done",
|
|
38
|
+
]);
|
|
39
|
+
expect((client.typing as ReturnType<typeof vi.fn>).mock.calls).toEqual([
|
|
40
|
+
[{ id: "u1", type: "direct" }, true],
|
|
41
|
+
[{ id: "u1", type: "direct" }, false],
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("message.created payload is minimal (just message_id); adds carry monotonic sequences", async () => {
|
|
46
|
+
const { client, sent } = mockClient();
|
|
47
|
+
await sendStreamingText({
|
|
48
|
+
client,
|
|
49
|
+
to: { id: "u1", type: "direct" },
|
|
50
|
+
sender: { sender_id: "a1", type: "direct", display_name: "Clawling" },
|
|
51
|
+
chunks: ["a", "b", "c"],
|
|
52
|
+
messageId: "m1",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// created payload is { message_id } only — no embedded message / streaming.
|
|
56
|
+
expect(sent[0]!.payload).toEqual({ message_id: "m1" });
|
|
57
|
+
expect(sent[1]!.payload.sequence).toBe(1);
|
|
58
|
+
expect(sent[2]!.payload.sequence).toBe(2);
|
|
59
|
+
expect(sent[3]!.payload.sequence).toBe(3);
|
|
60
|
+
expect((sent[4]!.payload.streaming as { sequence: number }).sequence).toBe(3);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("each message.add carries fragments: [{ text: cumulative, delta: new }]", async () => {
|
|
64
|
+
const { client, sent } = mockClient();
|
|
65
|
+
await sendStreamingText({
|
|
66
|
+
client,
|
|
67
|
+
to: { id: "u1", type: "direct" },
|
|
68
|
+
sender: { sender_id: "a1", type: "direct", display_name: "Clawling" },
|
|
69
|
+
chunks: ["Hel", "lo ", "world"],
|
|
70
|
+
messageId: "m1",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const adds = sent.filter((s) => s.event === "message.add");
|
|
74
|
+
expect(adds).toHaveLength(3);
|
|
75
|
+
expect((adds[0]!.payload as { fragments: unknown }).fragments).toEqual([
|
|
76
|
+
{ kind: "text", text: "Hel", delta: "Hel" },
|
|
77
|
+
]);
|
|
78
|
+
expect((adds[1]!.payload as { fragments: unknown }).fragments).toEqual([
|
|
79
|
+
{ kind: "text", text: "Hello ", delta: "lo " },
|
|
80
|
+
]);
|
|
81
|
+
expect((adds[2]!.payload as { fragments: unknown }).fragments).toEqual([
|
|
82
|
+
{ kind: "text", text: "Hello world", delta: "world" },
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("handles zero-chunk input by going created -> done immediately", async () => {
|
|
87
|
+
const { client, sent } = mockClient();
|
|
88
|
+
await sendStreamingText({
|
|
89
|
+
client,
|
|
90
|
+
to: { id: "u1", type: "direct" },
|
|
91
|
+
sender: { sender_id: "a1", type: "direct", display_name: "Clawling" },
|
|
92
|
+
chunks: [],
|
|
93
|
+
messageId: "m1",
|
|
94
|
+
});
|
|
95
|
+
const events = sent.map((e) => e.event);
|
|
96
|
+
expect(events).toEqual(["message.created", "message.done"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("sendStreamingFailure emits failed with reason and typing(false)", async () => {
|
|
100
|
+
const { client, sent } = mockClient();
|
|
101
|
+
const { sendStreamingFailure } = await import("./streaming.ts");
|
|
102
|
+
await sendStreamingFailure({
|
|
103
|
+
client,
|
|
104
|
+
to: { id: "u1", type: "direct" },
|
|
105
|
+
messageId: "m1",
|
|
106
|
+
currentSequence: 2,
|
|
107
|
+
reason: "boom",
|
|
108
|
+
});
|
|
109
|
+
expect(sent[0]!.event).toBe("message.failed");
|
|
110
|
+
expect(sent[0]!.payload.sequence).toBe(3);
|
|
111
|
+
expect(sent[0]!.payload.reason).toBe("boom");
|
|
112
|
+
expect((client.typing as ReturnType<typeof vi.fn>).mock.calls).toEqual([
|
|
113
|
+
[{ id: "u1", type: "direct" }, false],
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
});
|
package/src/streaming.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ClawlingChatClient } from "@newbase-clawchat/sdk";
|
|
2
|
+
import {
|
|
3
|
+
emitStreamAdd,
|
|
4
|
+
emitStreamCreated,
|
|
5
|
+
emitStreamDone,
|
|
6
|
+
emitStreamFailed,
|
|
7
|
+
type EnvelopeRouting,
|
|
8
|
+
type StreamSender,
|
|
9
|
+
} from "./client.ts";
|
|
10
|
+
|
|
11
|
+
export interface StreamingSendParams {
|
|
12
|
+
client: ClawlingChatClient;
|
|
13
|
+
routing: EnvelopeRouting;
|
|
14
|
+
sender: StreamSender;
|
|
15
|
+
/**
|
|
16
|
+
* Pre-chunked text. Use `chunkMarkdownText` from
|
|
17
|
+
* `openclaw/plugin-sdk/reply-runtime` to produce these.
|
|
18
|
+
*/
|
|
19
|
+
chunks: string[];
|
|
20
|
+
messageId: string;
|
|
21
|
+
/** Emit typing.update(true/false) around the lifecycle. Default true. */
|
|
22
|
+
emitTyping?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Emit one full streaming lifecycle for a pre-chunked reply.
|
|
27
|
+
*
|
|
28
|
+
* Sequence:
|
|
29
|
+
* typing(true)
|
|
30
|
+
* message.created (sequence 0)
|
|
31
|
+
* message.add (sequence 1..N, one per chunk)
|
|
32
|
+
* message.done (sequence N)
|
|
33
|
+
* typing(false)
|
|
34
|
+
*
|
|
35
|
+
* With zero chunks: typing(true) -> created -> done -> typing(false).
|
|
36
|
+
*/
|
|
37
|
+
export async function sendStreamingText(params: StreamingSendParams): Promise<void> {
|
|
38
|
+
const emitTyping = params.emitTyping !== false;
|
|
39
|
+
if (emitTyping) {
|
|
40
|
+
params.client.typing(params.routing.chatId, true, params.routing.chatType);
|
|
41
|
+
}
|
|
42
|
+
emitStreamCreated(params.client, {
|
|
43
|
+
messageId: params.messageId,
|
|
44
|
+
routing: params.routing,
|
|
45
|
+
});
|
|
46
|
+
let sequence = 0;
|
|
47
|
+
let fullText = "";
|
|
48
|
+
for (const chunk of params.chunks) {
|
|
49
|
+
sequence += 1;
|
|
50
|
+
fullText += chunk;
|
|
51
|
+
emitStreamAdd(params.client, {
|
|
52
|
+
messageId: params.messageId,
|
|
53
|
+
routing: params.routing,
|
|
54
|
+
sequence,
|
|
55
|
+
fullText,
|
|
56
|
+
textDelta: chunk,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
emitStreamDone(params.client, {
|
|
60
|
+
messageId: params.messageId,
|
|
61
|
+
routing: params.routing,
|
|
62
|
+
finalSequence: sequence,
|
|
63
|
+
finalText: fullText,
|
|
64
|
+
});
|
|
65
|
+
if (emitTyping) {
|
|
66
|
+
params.client.typing(params.routing.chatId, false, params.routing.chatType);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface StreamingFailureParams {
|
|
71
|
+
client: ClawlingChatClient;
|
|
72
|
+
routing: EnvelopeRouting;
|
|
73
|
+
messageId: string;
|
|
74
|
+
currentSequence: number;
|
|
75
|
+
reason?: string;
|
|
76
|
+
emitTyping?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function sendStreamingFailure(params: StreamingFailureParams): Promise<void> {
|
|
80
|
+
emitStreamFailed(params.client, {
|
|
81
|
+
messageId: params.messageId,
|
|
82
|
+
routing: params.routing,
|
|
83
|
+
sequence: params.currentSequence + 1,
|
|
84
|
+
reason: params.reason,
|
|
85
|
+
});
|
|
86
|
+
if (params.emitTyping !== false) {
|
|
87
|
+
params.client.typing(params.routing.chatId, false, params.routing.chatType);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Type, type Static } from "@sinclair/typebox";
|
|
2
|
+
|
|
3
|
+
export const ClawchatGetMyProfileSchema = Type.Object({});
|
|
4
|
+
export type ClawchatGetMyProfileParams = Static<typeof ClawchatGetMyProfileSchema>;
|
|
5
|
+
|
|
6
|
+
export const ClawchatGetUserInfoSchema = Type.Object({
|
|
7
|
+
userId: Type.String({ description: "Target user id (required)" }),
|
|
8
|
+
});
|
|
9
|
+
export type ClawchatGetUserInfoParams = Static<typeof ClawchatGetUserInfoSchema>;
|
|
10
|
+
|
|
11
|
+
export const ClawchatListFriendsSchema = Type.Object({
|
|
12
|
+
page: Type.Optional(Type.Integer({ minimum: 1, description: "1-based page number (default 1)" })),
|
|
13
|
+
pageSize: Type.Optional(
|
|
14
|
+
Type.Integer({
|
|
15
|
+
minimum: 1,
|
|
16
|
+
maximum: 100,
|
|
17
|
+
description: "Page size 1..100 (default 20)",
|
|
18
|
+
}),
|
|
19
|
+
),
|
|
20
|
+
});
|
|
21
|
+
export type ClawchatListFriendsParams = Static<typeof ClawchatListFriendsSchema>;
|
|
22
|
+
|
|
23
|
+
export const ClawchatUpdateMyProfileSchema = Type.Object({
|
|
24
|
+
nickname: Type.Optional(Type.String({ description: "New Nick Name" })),
|
|
25
|
+
avatar: Type.Optional(
|
|
26
|
+
Type.String({ description: "Avatar URL (use clawchat_upload_file first to obtain)" }),
|
|
27
|
+
),
|
|
28
|
+
});
|
|
29
|
+
export type ClawchatUpdateMyProfileParams = Static<typeof ClawchatUpdateMyProfileSchema>;
|
|
30
|
+
|
|
31
|
+
export const ClawchatUploadFileSchema = Type.Object({
|
|
32
|
+
filePath: Type.String({
|
|
33
|
+
description: "Absolute local path of the file to upload (max 20MB)",
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
export type ClawchatUploadFileParams = Static<typeof ClawchatUploadFileSchema>;
|
|
37
|
+
|
|
38
|
+
export const ClawchatActivateSchema = Type.Object({
|
|
39
|
+
code: Type.String({
|
|
40
|
+
description:
|
|
41
|
+
"The invite code (e.g. 'INV-ABC123') extracted from the user's message. " +
|
|
42
|
+
"Whitespace is trimmed automatically.",
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
export type ClawchatActivateParams = Static<typeof ClawchatActivateSchema>;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { registerOpenclawClawlingTools } from "./tools.ts";
|
|
3
|
+
|
|
4
|
+
interface RegisteredTool {
|
|
5
|
+
name: string;
|
|
6
|
+
execute: (callId: string, params: unknown) => Promise<unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function buildApi(opts: {
|
|
10
|
+
configChannel?: Record<string, unknown> | null;
|
|
11
|
+
registerTool?: (tool: { name: string }, options?: { name: string }) => void;
|
|
12
|
+
}) {
|
|
13
|
+
const registered: RegisteredTool[] = [];
|
|
14
|
+
const api = {
|
|
15
|
+
config:
|
|
16
|
+
opts.configChannel === null
|
|
17
|
+
? undefined
|
|
18
|
+
: { channels: { "openclaw-clawchat": opts.configChannel ?? {} } },
|
|
19
|
+
logger: {
|
|
20
|
+
info: vi.fn(),
|
|
21
|
+
debug: vi.fn(),
|
|
22
|
+
warn: vi.fn(),
|
|
23
|
+
error: vi.fn(),
|
|
24
|
+
},
|
|
25
|
+
registerTool: (tool: RegisteredTool, _options?: { name: string }) => {
|
|
26
|
+
registered.push(tool);
|
|
27
|
+
},
|
|
28
|
+
} as unknown as Parameters<typeof registerOpenclawClawlingTools>[0];
|
|
29
|
+
return { api, registered };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function configuredChannel(extra: Record<string, unknown> = {}) {
|
|
33
|
+
return {
|
|
34
|
+
websocketUrl: "wss://w",
|
|
35
|
+
token: "tk",
|
|
36
|
+
userId: "u",
|
|
37
|
+
...extra,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("registerOpenclawClawlingTools", () => {
|
|
42
|
+
it("registers ONLY clawchat_activate when account.configured is false (onboarding path)", () => {
|
|
43
|
+
const { api, registered } = buildApi({
|
|
44
|
+
configChannel: { websocketUrl: "wss://w" /* token / userId missing */ },
|
|
45
|
+
});
|
|
46
|
+
registerOpenclawClawlingTools(api);
|
|
47
|
+
// clawchat_activate must be available before a token exists — it's the
|
|
48
|
+
// tool the agent calls to onboard.
|
|
49
|
+
expect(registered.map((t) => t.name)).toEqual(["clawchat_activate"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("skips registration when api.config is undefined", () => {
|
|
53
|
+
const { api, registered } = buildApi({ configChannel: null });
|
|
54
|
+
registerOpenclawClawlingTools(api);
|
|
55
|
+
expect(registered).toHaveLength(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("registers all six tools when configured (regardless of baseUrl)", () => {
|
|
59
|
+
const { api, registered } = buildApi({
|
|
60
|
+
configChannel: configuredChannel(/* no baseUrl */),
|
|
61
|
+
});
|
|
62
|
+
registerOpenclawClawlingTools(api);
|
|
63
|
+
const names = registered.map((t) => t.name).sort();
|
|
64
|
+
expect(names).toEqual([
|
|
65
|
+
"clawchat_activate",
|
|
66
|
+
"clawchat_get_my_profile",
|
|
67
|
+
"clawchat_get_user_info",
|
|
68
|
+
"clawchat_list_friends",
|
|
69
|
+
"clawchat_update_my_profile",
|
|
70
|
+
"clawchat_upload_file",
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("clawchat_activate description names the `clawchat <code>` trigger", () => {
|
|
75
|
+
const { api, registered } = buildApi({
|
|
76
|
+
configChannel: configuredChannel(),
|
|
77
|
+
});
|
|
78
|
+
// Re-capture full tool objects so we can see description.
|
|
79
|
+
const fullTools: Array<{ name: string; description?: string }> = [];
|
|
80
|
+
api.registerTool = (tool: { name: string; description?: string }) => {
|
|
81
|
+
fullTools.push(tool);
|
|
82
|
+
};
|
|
83
|
+
registerOpenclawClawlingTools(api);
|
|
84
|
+
const activate = fullTools.find((t) => t.name === "clawchat_activate")!;
|
|
85
|
+
expect(activate).toBeDefined();
|
|
86
|
+
// Must tell the LLM exactly how to spot + parse the trigger.
|
|
87
|
+
expect(activate.description).toMatch(/clawchat\s*<code>/i);
|
|
88
|
+
expect(activate.description).toMatch(/INV-/);
|
|
89
|
+
// Must spell out the verbatim-extraction rule so the model doesn't
|
|
90
|
+
// re-case / prefix the code.
|
|
91
|
+
expect(activate.description).toMatch(/verbatim/i);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("clawchat_update_my_profile description names name + avatar triggers (EN + ZH)", () => {
|
|
95
|
+
const { api } = buildApi({ configChannel: configuredChannel() });
|
|
96
|
+
const fullTools: Array<{ name: string; description?: string }> = [];
|
|
97
|
+
api.registerTool = (tool: { name: string; description?: string }) => {
|
|
98
|
+
fullTools.push(tool);
|
|
99
|
+
};
|
|
100
|
+
registerOpenclawClawlingTools(api);
|
|
101
|
+
const update = fullTools.find((t) => t.name === "clawchat_update_my_profile")!;
|
|
102
|
+
expect(update).toBeDefined();
|
|
103
|
+
expect(update.description).toMatch(/change your name/i);
|
|
104
|
+
expect(update.description).toMatch(/你叫/);
|
|
105
|
+
expect(update.description).toMatch(/avatar/i);
|
|
106
|
+
expect(update.description).toMatch(/生成头像|换个头像/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
it("clawchat_upload_file rejects oversized files before upload", async () => {
|
|
111
|
+
const fs = await import("node:fs/promises");
|
|
112
|
+
const path = await import("node:path");
|
|
113
|
+
const os = await import("node:os");
|
|
114
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawchat-"));
|
|
115
|
+
const big = path.join(tmp, "big.bin");
|
|
116
|
+
// create a sparse 21MB file (we never read it; size check fires first)
|
|
117
|
+
const handle = await fs.open(big, "w");
|
|
118
|
+
await handle.truncate(21 * 1024 * 1024);
|
|
119
|
+
await handle.close();
|
|
120
|
+
try {
|
|
121
|
+
const { api, registered } = buildApi({
|
|
122
|
+
configChannel: configuredChannel({ baseUrl: "https://api.example.com" }),
|
|
123
|
+
});
|
|
124
|
+
registerOpenclawClawlingTools(api);
|
|
125
|
+
const tool = registered.find((t) => t.name === "clawchat_upload_file")!;
|
|
126
|
+
const result = await tool.execute("call-1", { filePath: big });
|
|
127
|
+
const text = (result as { content: { text: string }[] }).content[0]!.text;
|
|
128
|
+
const parsed = JSON.parse(text) as { error?: string; message?: string };
|
|
129
|
+
expect(parsed.error).toBe("validation");
|
|
130
|
+
expect(parsed.message).toMatch(/20 ?MB|too large/i);
|
|
131
|
+
} finally {
|
|
132
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
|
4
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
|
|
5
|
+
import { createOpenclawClawlingApiClient } from "./api-client.ts";
|
|
6
|
+
import { ClawlingApiError, type Profile } from "./api-types.ts";
|
|
7
|
+
import { resolveOpenclawClawlingAccount } from "./config.ts";
|
|
8
|
+
import {
|
|
9
|
+
ClawchatActivateSchema,
|
|
10
|
+
ClawchatGetMyProfileSchema,
|
|
11
|
+
ClawchatGetUserInfoSchema,
|
|
12
|
+
ClawchatListFriendsSchema,
|
|
13
|
+
ClawchatUpdateMyProfileSchema,
|
|
14
|
+
ClawchatUploadFileSchema,
|
|
15
|
+
type ClawchatActivateParams,
|
|
16
|
+
type ClawchatGetUserInfoParams,
|
|
17
|
+
type ClawchatListFriendsParams,
|
|
18
|
+
type ClawchatUpdateMyProfileParams,
|
|
19
|
+
type ClawchatUploadFileParams,
|
|
20
|
+
} from "./tools-schema.ts";
|
|
21
|
+
|
|
22
|
+
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
function jsonResponse(data: unknown): AgentToolResult<unknown> {
|
|
25
|
+
return {
|
|
26
|
+
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
|
|
27
|
+
details: data,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function configError(message: string) {
|
|
32
|
+
return jsonResponse({ error: "config", message });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function apiError(err: ClawlingApiError) {
|
|
36
|
+
return jsonResponse({
|
|
37
|
+
error: err.kind,
|
|
38
|
+
message: err.message,
|
|
39
|
+
...(err.meta ? { meta: err.meta } : {}),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function validationError(message: string) {
|
|
44
|
+
return jsonResponse({ error: "validation", message });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function genericError(err: unknown) {
|
|
48
|
+
return jsonResponse({
|
|
49
|
+
error: "unknown",
|
|
50
|
+
message: err instanceof Error ? err.message : String(err),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
55
|
+
".png": "image/png",
|
|
56
|
+
".jpg": "image/jpeg",
|
|
57
|
+
".jpeg": "image/jpeg",
|
|
58
|
+
".gif": "image/gif",
|
|
59
|
+
".webp": "image/webp",
|
|
60
|
+
".bmp": "image/bmp",
|
|
61
|
+
".svg": "image/svg+xml",
|
|
62
|
+
".pdf": "application/pdf",
|
|
63
|
+
".txt": "text/plain",
|
|
64
|
+
".md": "text/markdown",
|
|
65
|
+
".json": "application/json",
|
|
66
|
+
".csv": "text/csv",
|
|
67
|
+
".zip": "application/zip",
|
|
68
|
+
".mp3": "audio/mpeg",
|
|
69
|
+
".m4a": "audio/mp4",
|
|
70
|
+
".wav": "audio/wav",
|
|
71
|
+
".ogg": "audio/ogg",
|
|
72
|
+
".mp4": "video/mp4",
|
|
73
|
+
".mov": "video/quicktime",
|
|
74
|
+
".webm": "video/webm",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function inferMimeFromPath(filePath: string): string {
|
|
78
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
79
|
+
return MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function registerOpenclawClawlingTools(api: OpenClawPluginApi): void {
|
|
83
|
+
if (!api.config) {
|
|
84
|
+
api.logger.debug?.("openclaw-clawchat: api.config missing; skipping tool registration");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// -----------------------------------------------------------------------
|
|
89
|
+
// `clawchat_activate` is registered UNCONDITIONALLY — it is the tool the
|
|
90
|
+
// agent invokes to onboard this plugin, so it must be available even when
|
|
91
|
+
// `account.configured === false`. All other tools below require a valid
|
|
92
|
+
// token and are gated on `account.configured`.
|
|
93
|
+
// -----------------------------------------------------------------------
|
|
94
|
+
api.registerTool(
|
|
95
|
+
{
|
|
96
|
+
name: "clawchat_activate",
|
|
97
|
+
label: "Clawling: Activate (Login with Invite Code)",
|
|
98
|
+
description:
|
|
99
|
+
"Activate this agent on ClawChat by exchanging an invite code for a token. " +
|
|
100
|
+
"TRIGGER — invoke this tool whenever the user's message matches ANY of: " +
|
|
101
|
+
"(1) the pattern `clawchat <code>` or `clawchat: <code>` — e.g. 'clawchat INV-ABC123' " +
|
|
102
|
+
"means: call this tool with `code = \"INV-ABC123\"`; " +
|
|
103
|
+
"(2) phrases like 'activate clawchat', 'login to clawchat', 'use invite code XYZ', " +
|
|
104
|
+
"'connect clawchat with XYZ'; " +
|
|
105
|
+
"(3) the user pastes anything that looks like an invite code (e.g. `INV-…`, `CC-…`). " +
|
|
106
|
+
"Extract the code verbatim — do NOT normalize / lowercase / add prefixes. " +
|
|
107
|
+
"On success the tool persists the resulting token + userId to the config, so " +
|
|
108
|
+
"subsequent `clawchat_*` calls work without any other setup.",
|
|
109
|
+
parameters: ClawchatActivateSchema,
|
|
110
|
+
async execute(_callId, params) {
|
|
111
|
+
const p = params as ClawchatActivateParams;
|
|
112
|
+
const code = p.code?.trim();
|
|
113
|
+
if (!code) {
|
|
114
|
+
return validationError("openclaw-clawchat: code is required");
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const { runOpenclawClawlingLogin } = await import("./login.runtime.ts");
|
|
118
|
+
await runOpenclawClawlingLogin({
|
|
119
|
+
cfg: api.config!,
|
|
120
|
+
accountId: null,
|
|
121
|
+
runtime: { log: (message: string) => api.logger.info?.(message) },
|
|
122
|
+
readInviteCode: async () => code,
|
|
123
|
+
});
|
|
124
|
+
return jsonResponse({
|
|
125
|
+
ok: true,
|
|
126
|
+
message: "ClawChat activated successfully. Need to Restart Gateway for changes to take effect. You can now use clawchat_* tools.",
|
|
127
|
+
});
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (err instanceof ClawlingApiError) return apiError(err);
|
|
130
|
+
return genericError(err);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{ name: "clawchat_activate" },
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const account = resolveOpenclawClawlingAccount(api.config);
|
|
138
|
+
if (!account.configured) {
|
|
139
|
+
api.logger.debug?.(
|
|
140
|
+
"openclaw-clawchat: account not yet configured; only clawchat_activate is registered. " +
|
|
141
|
+
"The remaining tools will register on the next config reload after activation.",
|
|
142
|
+
);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Re-resolve at call time so config reloads pick up new tokens / baseUrl.
|
|
147
|
+
function resolveCurrent() {
|
|
148
|
+
return resolveOpenclawClawlingAccount(api.config!);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type ClientResult =
|
|
152
|
+
| { error: AgentToolResult<unknown>; client?: never }
|
|
153
|
+
| { client: ReturnType<typeof createOpenclawClawlingApiClient>; error?: never };
|
|
154
|
+
|
|
155
|
+
function buildClient(): ClientResult {
|
|
156
|
+
const acct = resolveCurrent();
|
|
157
|
+
// `baseUrl` always resolves via the built-in default in config.ts, so we
|
|
158
|
+
// only need to gate on `token` here (which is populated by `openclaw
|
|
159
|
+
// channel login`).
|
|
160
|
+
if (!acct.token) {
|
|
161
|
+
return { error: configError("openclaw-clawchat: token is required") };
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
client: createOpenclawClawlingApiClient({
|
|
165
|
+
baseUrl: acct.baseUrl,
|
|
166
|
+
token: acct.token,
|
|
167
|
+
userId: acct.userId,
|
|
168
|
+
}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function withClient<T>(
|
|
173
|
+
fn: (client: ReturnType<typeof createOpenclawClawlingApiClient>) => Promise<T>,
|
|
174
|
+
): Promise<AgentToolResult<unknown>> {
|
|
175
|
+
return (async (): Promise<AgentToolResult<unknown>> => {
|
|
176
|
+
const built = buildClient();
|
|
177
|
+
if (built.error !== undefined) return built.error;
|
|
178
|
+
try {
|
|
179
|
+
const data = await fn(built.client);
|
|
180
|
+
return jsonResponse(data);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (err instanceof ClawlingApiError) return apiError(err);
|
|
183
|
+
return genericError(err);
|
|
184
|
+
}
|
|
185
|
+
})();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
api.registerTool(
|
|
189
|
+
{
|
|
190
|
+
name: "clawchat_get_my_profile",
|
|
191
|
+
label: "Clawling: Get My Profile",
|
|
192
|
+
description: "Fetch the agent's own Clawling profile (id, display name, avatar, bio).",
|
|
193
|
+
parameters: ClawchatGetMyProfileSchema,
|
|
194
|
+
async execute(_callId, _params) {
|
|
195
|
+
return await withClient((c) => c.getMyProfile());
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
{ name: "clawchat_get_my_profile" },
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
api.registerTool(
|
|
202
|
+
{
|
|
203
|
+
name: "clawchat_get_user_info",
|
|
204
|
+
label: "Clawling: Get User Info",
|
|
205
|
+
description: "Fetch a Clawling user's public profile by userId.",
|
|
206
|
+
parameters: ClawchatGetUserInfoSchema,
|
|
207
|
+
async execute(_callId, params) {
|
|
208
|
+
const p = params as ClawchatGetUserInfoParams;
|
|
209
|
+
return await withClient((c) => c.getUserInfo(p.userId));
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
{ name: "clawchat_get_user_info" },
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
api.registerTool(
|
|
216
|
+
{
|
|
217
|
+
name: "clawchat_list_friends",
|
|
218
|
+
label: "Clawling: List Friends",
|
|
219
|
+
description: "List the agent's friends, paginated (page=1, pageSize=20 by default).",
|
|
220
|
+
parameters: ClawchatListFriendsSchema,
|
|
221
|
+
async execute(_callId, params) {
|
|
222
|
+
const p = (params ?? {}) as ClawchatListFriendsParams;
|
|
223
|
+
return await withClient((c) =>
|
|
224
|
+
c.listFriends({
|
|
225
|
+
...(p.page !== undefined ? { page: p.page } : { page: 1 }),
|
|
226
|
+
...(p.pageSize !== undefined ? { pageSize: p.pageSize } : { pageSize: 20 }),
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{ name: "clawchat_list_friends" },
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
api.registerTool(
|
|
235
|
+
{
|
|
236
|
+
name: "clawchat_update_my_profile",
|
|
237
|
+
label: "Clawling: Update My Profile",
|
|
238
|
+
description:
|
|
239
|
+
"Update this agent's own ClawChat profile (nickname and/or avatar). " +
|
|
240
|
+
"TRIGGER — invoke this tool whenever the user's message matches ANY of: " +
|
|
241
|
+
"(1) nickname/name change: 'change your name to X', 'your name is X', 'rename yourself to X', " +
|
|
242
|
+
"'I'll call you X', 'from now on you are X', '你叫 X', '改名为 X', '我叫你 X', " +
|
|
243
|
+
"'你的新名字是 X' → call with `nickname = X`; " +
|
|
244
|
+
"(2) avatar change or generation: 'change your avatar', 'update your profile picture', " +
|
|
245
|
+
"'generate a new avatar', 'use this image as your avatar', '换个头像', '生成头像', " +
|
|
246
|
+
"'把头像改为 …' → first obtain the avatar URL (generate + upload via " +
|
|
247
|
+
"`clawchat_upload_file`, OR use a provided URL directly), then call this tool " +
|
|
248
|
+
"with `avatar = <url>`. " +
|
|
249
|
+
"You can pass `nickname` and `avatar` together in one call, or just one of them. " +
|
|
250
|
+
"At least one of the two must be present.",
|
|
251
|
+
parameters: ClawchatUpdateMyProfileSchema,
|
|
252
|
+
async execute(_callId, params) {
|
|
253
|
+
const p = (params ?? {}) as ClawchatUpdateMyProfileParams;
|
|
254
|
+
const patch: { nick_name?: string; avatar?: string } = {};
|
|
255
|
+
if (typeof p.nickname === "string") patch.nick_name = p.nickname;
|
|
256
|
+
if (typeof p.avatar === "string") patch.avatar = p.avatar;
|
|
257
|
+
if (Object.keys(patch).length === 0) {
|
|
258
|
+
return validationError(
|
|
259
|
+
"openclaw-clawchat: at least one of nickname / avatar is required",
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
return await withClient((c): Promise<Profile> => c.updateMyProfile(patch));
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
{ name: "clawchat_update_my_profile" },
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
api.registerTool(
|
|
269
|
+
{
|
|
270
|
+
name: "clawchat_upload_file",
|
|
271
|
+
label: "Clawling: Upload File",
|
|
272
|
+
description:
|
|
273
|
+
"Upload a local file to Clawling media storage (max 20MB) and return the public URL.",
|
|
274
|
+
parameters: ClawchatUploadFileSchema,
|
|
275
|
+
async execute(_callId, params) {
|
|
276
|
+
const p = params as ClawchatUploadFileParams;
|
|
277
|
+
if (!p.filePath || !path.isAbsolute(p.filePath)) {
|
|
278
|
+
return validationError("openclaw-clawchat: filePath must be an absolute local path");
|
|
279
|
+
}
|
|
280
|
+
let stat: fs.Stats;
|
|
281
|
+
try {
|
|
282
|
+
stat = fs.statSync(p.filePath);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
return validationError(
|
|
285
|
+
`openclaw-clawchat: cannot stat ${p.filePath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
if (!stat.isFile()) {
|
|
289
|
+
return validationError(`openclaw-clawchat: ${p.filePath} is not a regular file`);
|
|
290
|
+
}
|
|
291
|
+
if (stat.size > MAX_UPLOAD_BYTES) {
|
|
292
|
+
return validationError(
|
|
293
|
+
`openclaw-clawchat: file too large (${stat.size} bytes; max 20MB)`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
const buffer = fs.readFileSync(p.filePath);
|
|
297
|
+
const filename = path.basename(p.filePath);
|
|
298
|
+
const mime = inferMimeFromPath(p.filePath);
|
|
299
|
+
return await withClient((c) => c.uploadMedia({ buffer, filename, mime }));
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
{ name: "clawchat_upload_file" },
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
api.logger.info?.(
|
|
306
|
+
"openclaw-clawchat: registered 6 clawchat_* tools (activate, get_my_profile, get_user_info, list_friends, update_my_profile, upload_file)",
|
|
307
|
+
);
|
|
308
|
+
}
|