@newbase-clawchat/openclaw-clawchat 2026.4.24 → 2026.4.29
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 +40 -13
- package/index.ts +2 -1
- package/openclaw.plugin.json +69 -1
- package/package.json +3 -11
- package/skills/clawchat-account-tools/SKILL.md +26 -0
- package/skills/clawchat-activate/SKILL.md +38 -0
- package/src/api-client.test.ts +6 -5
- package/src/api-client.ts +8 -3
- package/src/buffered-stream.test.ts +4 -4
- package/src/buffered-stream.ts +16 -8
- package/src/channel.outbound.test.ts +49 -35
- package/src/channel.test.ts +45 -10
- package/src/channel.ts +15 -14
- package/src/client.test.ts +2 -1
- package/src/client.ts +37 -11
- package/src/commands.test.ts +33 -0
- package/src/commands.ts +37 -0
- package/src/config.test.ts +37 -3
- package/src/config.ts +53 -4
- package/src/inbound.test.ts +5 -5
- package/src/inbound.ts +43 -9
- package/src/login.runtime.test.ts +106 -3
- package/src/login.runtime.ts +8 -3
- package/src/manifest.test.ts +106 -4
- package/src/outbound.test.ts +7 -5
- package/src/plugin-entry.test.ts +27 -0
- package/src/protocol.ts +5 -0
- package/src/reply-dispatcher.test.ts +4 -2
- package/src/runtime.test.ts +23 -7
- package/src/runtime.ts +12 -1
- package/src/streaming.test.ts +3 -3
- package/src/streaming.ts +19 -9
- package/src/tools-schema.ts +28 -19
- package/src/tools.test.ts +115 -37
- package/src/tools.ts +137 -116
package/src/channel.test.ts
CHANGED
|
@@ -17,20 +17,20 @@ describe("openclaw-clawchat plugin", () => {
|
|
|
17
17
|
expect(openclawClawlingPlugin.capabilities.media).toBe(true);
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
-
it("setup.validateInput requires
|
|
20
|
+
it("setup.validateInput requires an invite code", () => {
|
|
21
21
|
const validate = openclawClawlingPlugin.setup?.validateInput as
|
|
22
22
|
| ((args: { cfg: unknown; accountId: string; input: Record<string, unknown> }) => string | null)
|
|
23
23
|
| undefined;
|
|
24
24
|
expect(validate).toBeDefined();
|
|
25
25
|
expect(validate!({ cfg: {}, accountId: "default", input: {} })).toMatch(
|
|
26
|
-
|
|
26
|
+
/invite code is required/i,
|
|
27
27
|
);
|
|
28
28
|
expect(
|
|
29
29
|
validate!({ cfg: {}, accountId: "default", input: { code: " " } }),
|
|
30
|
-
).toMatch(
|
|
30
|
+
).toMatch(/invite code is required/i);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
it("setup.validateInput passes when
|
|
33
|
+
it("setup.validateInput passes when code is present", () => {
|
|
34
34
|
const validate = openclawClawlingPlugin.setup?.validateInput as (args: {
|
|
35
35
|
cfg: unknown;
|
|
36
36
|
accountId: string;
|
|
@@ -62,17 +62,52 @@ describe("openclaw-clawchat plugin", () => {
|
|
|
62
62
|
expect(section.baseUrl).toBeUndefined();
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
it("setup.applyAccountConfig allows openclaw-clawchat plugin tools without replacing policy", () => {
|
|
66
|
+
const apply = openclawClawlingPlugin.setup?.applyAccountConfig as (args: {
|
|
67
|
+
cfg: unknown;
|
|
68
|
+
accountId: string;
|
|
69
|
+
input: Record<string, unknown>;
|
|
70
|
+
}) => Record<string, unknown>;
|
|
71
|
+
const next = apply({
|
|
72
|
+
cfg: {
|
|
73
|
+
tools: {
|
|
74
|
+
profile: "coding",
|
|
75
|
+
allow: [],
|
|
76
|
+
deny: ["exec"],
|
|
77
|
+
alsoAllow: ["browser"],
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
accountId: "default",
|
|
81
|
+
input: { code: "INV-XXXX" },
|
|
82
|
+
}) as { tools: Record<string, unknown> };
|
|
83
|
+
|
|
84
|
+
expect(next.tools.profile).toBe("coding");
|
|
85
|
+
expect(next.tools.allow).toEqual([]);
|
|
86
|
+
expect(next.tools.deny).toEqual(["exec"]);
|
|
87
|
+
expect(next.tools.alsoAllow).toEqual(["browser", "openclaw-clawchat"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
65
90
|
it("publishes clawchat-specific agentPrompt hints", () => {
|
|
66
91
|
const hints = openclawClawlingPlugin.agentPrompt?.messageToolHints?.({
|
|
67
92
|
cfg: {} as never,
|
|
68
93
|
accountId: "default",
|
|
69
94
|
});
|
|
70
|
-
expect(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
95
|
+
expect(hints).toEqual([
|
|
96
|
+
"To send an image or file to the current chat, use the message tool with action='send' and set 'media' to a local file path or a remote URL.",
|
|
97
|
+
"When the user asks you to find an image from the web, find a suitable HTTPS image URL and send it using the message tool with 'media' set to that URL — do NOT download the image first.",
|
|
98
|
+
"For configured ClawChat account profile, user profile, friends, avatar, or standalone media upload/share-link workflows, use `clawchat-account-tools` for tool-selection details.",
|
|
99
|
+
"For ClawChat account avatar changes using a local image, call `clawchat_upload_avatar_image` first, then `clawchat_update_account_profile` with `avatar_url`.",
|
|
100
|
+
"- Targeting: omit `target` to reply here; for a different chat use `target=\"cc:{chat_id}\"` for direct or `target=\"cc:group:{chat_id}\"` for group.",
|
|
101
|
+
"- ClawChat supports image / file / audio / video media alongside text.",
|
|
102
|
+
]);
|
|
103
|
+
const joined = hints!.join("\n");
|
|
104
|
+
expect(joined).not.toMatch(/clawchat_get_account_profile/);
|
|
105
|
+
expect(joined).not.toMatch(/clawchat_get_user_profile/);
|
|
106
|
+
expect(joined).not.toMatch(/clawchat_list_account_friends/);
|
|
107
|
+
expect(joined).not.toMatch(/clawchat_upload_media_file/);
|
|
108
|
+
expect(joined).not.toMatch(/stream mode/i);
|
|
109
|
+
expect(joined).not.toMatch(/clawchat:/);
|
|
110
|
+
expect(joined).not.toMatch(/specify 'to'/);
|
|
76
111
|
});
|
|
77
112
|
|
|
78
113
|
it("normalizes openclaw-clawchat targets for host resolution", () => {
|
package/src/channel.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import {
|
|
15
15
|
CHANNEL_ID,
|
|
16
16
|
listOpenclawClawlingAccountIds,
|
|
17
|
+
mergeOpenclawClawchatToolAllow,
|
|
17
18
|
openclawClawlingConfigSchema,
|
|
18
19
|
resolveOpenclawClawlingAccount,
|
|
19
20
|
type ResolvedOpenclawClawlingAccount,
|
|
@@ -42,13 +43,14 @@ const configAdapter = createTopLevelChannelConfigAdapter<ResolvedOpenclawClawlin
|
|
|
42
43
|
});
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
|
-
*
|
|
46
|
+
* Invite-code setup adapter used by OpenClaw setup surfaces that already have
|
|
47
|
+
* a concrete plugin instance. This plugin does not advertise catalog-driven
|
|
48
|
+
* one-shot setup metadata because current hosts do not discover channels from
|
|
49
|
+
* `plugins.load.paths`.
|
|
46
50
|
*
|
|
47
51
|
* Setup takes exactly ONE input: `code` (an invite code). URL + token +
|
|
48
52
|
* userId come from the login flow which is triggered automatically in
|
|
49
|
-
* `afterAccountConfigWritten
|
|
50
|
-
*
|
|
51
|
-
* openclaw channels setup --channel openclaw-clawchat --code INV-XXXX
|
|
53
|
+
* `afterAccountConfigWritten`.
|
|
52
54
|
*
|
|
53
55
|
* `applyAccountConfig` itself only marks the section `enabled: true`;
|
|
54
56
|
* credentials are written by `runOpenclawClawlingLogin` which calls `writeConfigFile`
|
|
@@ -58,7 +60,7 @@ const setupAdapter = {
|
|
|
58
60
|
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
59
61
|
validateInput: ({ input }: { cfg: unknown; accountId: string; input: ChannelSetupInput }) => {
|
|
60
62
|
if (!input.code?.trim()) {
|
|
61
|
-
return "
|
|
63
|
+
return "ClawChat invite code is required.";
|
|
62
64
|
}
|
|
63
65
|
return null;
|
|
64
66
|
},
|
|
@@ -73,13 +75,13 @@ const setupAdapter = {
|
|
|
73
75
|
// `afterAccountConfigWritten` → `runOpenclawClawlingLogin`.
|
|
74
76
|
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
|
75
77
|
const current = (channels[CHANNEL_ID] ?? {}) as Record<string, unknown>;
|
|
76
|
-
return {
|
|
78
|
+
return mergeOpenclawClawchatToolAllow({
|
|
77
79
|
...cfg,
|
|
78
80
|
channels: {
|
|
79
81
|
...channels,
|
|
80
82
|
[CHANNEL_ID]: { ...current, enabled: true },
|
|
81
83
|
},
|
|
82
|
-
};
|
|
84
|
+
});
|
|
83
85
|
},
|
|
84
86
|
afterAccountConfigWritten: async ({
|
|
85
87
|
cfg,
|
|
@@ -196,13 +198,12 @@ export const openclawClawlingPlugin: ChannelPlugin<ResolvedOpenclawClawlingAccou
|
|
|
196
198
|
},
|
|
197
199
|
agentPrompt: {
|
|
198
200
|
messageToolHints: () => [
|
|
199
|
-
"To send an image or file to the current
|
|
200
|
-
"When the user asks you to find an image from the web,
|
|
201
|
-
"
|
|
202
|
-
"
|
|
203
|
-
"-
|
|
204
|
-
"- ClawChat supports
|
|
205
|
-
"- ClawChat stream mode emits `message.created` → progressive `message.add` deltas → `message.done`, followed by a consolidated `message.reply` with the merged text.",
|
|
201
|
+
"To send an image or file to the current chat, use the message tool with action='send' and set 'media' to a local file path or a remote URL.",
|
|
202
|
+
"When the user asks you to find an image from the web, find a suitable HTTPS image URL and send it using the message tool with 'media' set to that URL — do NOT download the image first.",
|
|
203
|
+
"For configured ClawChat account profile, user profile, friends, avatar, or standalone media upload/share-link workflows, use `clawchat-account-tools` for tool-selection details.",
|
|
204
|
+
"For ClawChat account avatar changes using a local image, call `clawchat_upload_avatar_image` first, then `clawchat_update_account_profile` with `avatar_url`.",
|
|
205
|
+
"- Targeting: omit `target` to reply here; for a different chat use `target=\"cc:{chat_id}\"` for direct or `target=\"cc:group:{chat_id}\"` for group.",
|
|
206
|
+
"- ClawChat supports image / file / audio / video media alongside text.",
|
|
206
207
|
],
|
|
207
208
|
},
|
|
208
209
|
messaging: {
|
package/src/client.test.ts
CHANGED
|
@@ -91,7 +91,8 @@ describe("openclaw-clawchat client", () => {
|
|
|
91
91
|
expect(transport.sent).toHaveLength(1);
|
|
92
92
|
const env = JSON.parse(transport.sent[0]!);
|
|
93
93
|
expect(env.event).toBe("message.created");
|
|
94
|
-
expect(env.
|
|
94
|
+
expect(env.chat_id).toBe("user-1");
|
|
95
|
+
expect(env.chat_type).toBe("direct");
|
|
95
96
|
// Payload is intentionally minimal: just message_id, no message body /
|
|
96
97
|
// context / sender / streaming metadata.
|
|
97
98
|
expect(env.payload).toEqual({ message_id: "msg-1" });
|
package/src/client.ts
CHANGED
|
@@ -64,6 +64,17 @@ export interface EnvelopeRouting {
|
|
|
64
64
|
chatType: ChatType;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
function normalizeRouting(params: {
|
|
68
|
+
routing?: EnvelopeRouting;
|
|
69
|
+
to?: { id?: string; type?: ChatType };
|
|
70
|
+
}): EnvelopeRouting {
|
|
71
|
+
if (params.routing) return params.routing;
|
|
72
|
+
if (params.to?.id) {
|
|
73
|
+
return { chatId: params.to.id, chatType: params.to.type ?? "direct" };
|
|
74
|
+
}
|
|
75
|
+
throw new Error("openclaw-clawchat streaming emit requires routing");
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
/**
|
|
68
79
|
* Emit a raw v2 envelope directly over the transport so we can carry
|
|
69
80
|
* `chat_id` + `chat_type` at envelope root (the new protocol). The SDK's
|
|
@@ -78,11 +89,16 @@ function emitEnvelope(
|
|
|
78
89
|
routing: EnvelopeRouting,
|
|
79
90
|
): void {
|
|
80
91
|
const inner = client as unknown as {
|
|
81
|
-
opts
|
|
92
|
+
opts?: {
|
|
82
93
|
transport: { send: (data: string) => void };
|
|
83
94
|
traceIdFactory: () => string;
|
|
84
95
|
};
|
|
96
|
+
emitRaw?: (event: string, payload: object, routing?: { to?: { id: string; type: ChatType } }) => void;
|
|
85
97
|
};
|
|
98
|
+
if (!inner.opts?.transport) {
|
|
99
|
+
inner.emitRaw?.(event, payload, { to: { id: routing.chatId, type: routing.chatType } });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
86
102
|
const env = {
|
|
87
103
|
version: "2" as const,
|
|
88
104
|
event,
|
|
@@ -107,14 +123,16 @@ export function emitStreamCreated(
|
|
|
107
123
|
client: ClawlingChatClient,
|
|
108
124
|
params: {
|
|
109
125
|
messageId: string;
|
|
110
|
-
routing
|
|
126
|
+
routing?: EnvelopeRouting;
|
|
127
|
+
to?: { id: string; type: ChatType };
|
|
111
128
|
},
|
|
112
129
|
): void {
|
|
130
|
+
const routing = normalizeRouting(params);
|
|
113
131
|
emitEnvelope(
|
|
114
132
|
client,
|
|
115
133
|
"message.created",
|
|
116
134
|
{ message_id: params.messageId },
|
|
117
|
-
|
|
135
|
+
routing,
|
|
118
136
|
);
|
|
119
137
|
}
|
|
120
138
|
|
|
@@ -130,7 +148,8 @@ export function emitStreamAdd(
|
|
|
130
148
|
client: ClawlingChatClient,
|
|
131
149
|
params: {
|
|
132
150
|
messageId: string;
|
|
133
|
-
routing
|
|
151
|
+
routing?: EnvelopeRouting;
|
|
152
|
+
to?: { id: string; type: ChatType };
|
|
134
153
|
sequence: number;
|
|
135
154
|
/** Running cumulative text after this delta is applied. */
|
|
136
155
|
fullText: string;
|
|
@@ -139,6 +158,7 @@ export function emitStreamAdd(
|
|
|
139
158
|
},
|
|
140
159
|
): void {
|
|
141
160
|
const now = Date.now();
|
|
161
|
+
const routing = normalizeRouting(params);
|
|
142
162
|
emitEnvelope(
|
|
143
163
|
client,
|
|
144
164
|
"message.add",
|
|
@@ -158,7 +178,7 @@ export function emitStreamAdd(
|
|
|
158
178
|
},
|
|
159
179
|
added_at: now,
|
|
160
180
|
},
|
|
161
|
-
|
|
181
|
+
routing,
|
|
162
182
|
);
|
|
163
183
|
}
|
|
164
184
|
|
|
@@ -171,12 +191,14 @@ export function emitStreamDone(
|
|
|
171
191
|
client: ClawlingChatClient,
|
|
172
192
|
params: {
|
|
173
193
|
messageId: string;
|
|
174
|
-
routing
|
|
194
|
+
routing?: EnvelopeRouting;
|
|
195
|
+
to?: { id: string; type: ChatType };
|
|
175
196
|
finalSequence: number;
|
|
176
197
|
finalText: string;
|
|
177
198
|
},
|
|
178
199
|
): void {
|
|
179
200
|
const now = Date.now();
|
|
201
|
+
const routing = normalizeRouting(params);
|
|
180
202
|
emitEnvelope(
|
|
181
203
|
client,
|
|
182
204
|
"message.done",
|
|
@@ -192,7 +214,7 @@ export function emitStreamDone(
|
|
|
192
214
|
},
|
|
193
215
|
completed_at: now,
|
|
194
216
|
},
|
|
195
|
-
|
|
217
|
+
routing,
|
|
196
218
|
);
|
|
197
219
|
}
|
|
198
220
|
|
|
@@ -211,7 +233,8 @@ export function emitFinalStreamReply(
|
|
|
211
233
|
params: {
|
|
212
234
|
/** The streaming message_id — must equal the id used on created/add/done. */
|
|
213
235
|
messageId: string;
|
|
214
|
-
routing
|
|
236
|
+
routing?: EnvelopeRouting;
|
|
237
|
+
to?: { id: string; type: ChatType };
|
|
215
238
|
/** The user message this stream is a reply to (usually the inbound turn). */
|
|
216
239
|
replyTo: {
|
|
217
240
|
msgId: string;
|
|
@@ -223,6 +246,7 @@ export function emitFinalStreamReply(
|
|
|
223
246
|
mentions?: string[];
|
|
224
247
|
},
|
|
225
248
|
): void {
|
|
249
|
+
const routing = normalizeRouting(params);
|
|
226
250
|
emitEnvelope(
|
|
227
251
|
client,
|
|
228
252
|
"message.reply",
|
|
@@ -244,7 +268,7 @@ export function emitFinalStreamReply(
|
|
|
244
268
|
},
|
|
245
269
|
},
|
|
246
270
|
},
|
|
247
|
-
|
|
271
|
+
routing,
|
|
248
272
|
);
|
|
249
273
|
}
|
|
250
274
|
|
|
@@ -252,12 +276,14 @@ export function emitStreamFailed(
|
|
|
252
276
|
client: ClawlingChatClient,
|
|
253
277
|
params: {
|
|
254
278
|
messageId: string;
|
|
255
|
-
routing
|
|
279
|
+
routing?: EnvelopeRouting;
|
|
280
|
+
to?: { id: string; type: ChatType };
|
|
256
281
|
sequence: number;
|
|
257
282
|
reason?: string;
|
|
258
283
|
},
|
|
259
284
|
): void {
|
|
260
285
|
const now = Date.now();
|
|
286
|
+
const routing = normalizeRouting(params);
|
|
261
287
|
emitEnvelope(
|
|
262
288
|
client,
|
|
263
289
|
"message.failed",
|
|
@@ -274,6 +300,6 @@ export function emitStreamFailed(
|
|
|
274
300
|
},
|
|
275
301
|
failed_at: now,
|
|
276
302
|
},
|
|
277
|
-
|
|
303
|
+
routing,
|
|
278
304
|
);
|
|
279
305
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { registerOpenclawClawlingCommands } from "./commands.ts";
|
|
3
|
+
|
|
4
|
+
const loginRuntime = vi.hoisted(() => ({
|
|
5
|
+
runOpenclawClawlingLogin: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock("./login.runtime.ts", () => loginRuntime);
|
|
9
|
+
|
|
10
|
+
describe("registerOpenclawClawlingCommands", () => {
|
|
11
|
+
it("registers a distinct slash login command that passes the invite code to login", async () => {
|
|
12
|
+
loginRuntime.runOpenclawClawlingLogin.mockResolvedValue(undefined);
|
|
13
|
+
const commands: Array<{ name: string; acceptsArgs?: boolean; handler: (ctx: unknown) => Promise<{ text: string }> }> = [];
|
|
14
|
+
const api = {
|
|
15
|
+
registerCommand: (command: (typeof commands)[number]) => commands.push(command),
|
|
16
|
+
} as never;
|
|
17
|
+
|
|
18
|
+
registerOpenclawClawlingCommands(api);
|
|
19
|
+
|
|
20
|
+
expect(commands.map((command) => command.name)).toEqual(["clawchat-login"]);
|
|
21
|
+
expect(commands[0]?.acceptsArgs).toBe(true);
|
|
22
|
+
|
|
23
|
+
const result = await commands[0]!.handler({
|
|
24
|
+
args: "A1B2C3",
|
|
25
|
+
config: { channels: { "openclaw-clawchat": { websocketUrl: "wss://w" } } },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(loginRuntime.runOpenclawClawlingLogin).toHaveBeenCalledTimes(1);
|
|
29
|
+
const params = loginRuntime.runOpenclawClawlingLogin.mock.calls[0]?.[0];
|
|
30
|
+
await expect(params.readInviteCode()).resolves.toBe("A1B2C3");
|
|
31
|
+
expect(result.text).toMatch(/activated successfully/i);
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
function extractInviteCode(value: unknown): string {
|
|
4
|
+
const raw = typeof value === "string" ? value.trim() : "";
|
|
5
|
+
return raw.match(/\b[A-Z0-9]{6}\b/u)?.[0] ?? "";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function errorMessage(err: unknown): string {
|
|
9
|
+
return err instanceof Error ? err.message : String(err);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function registerOpenclawClawlingCommands(api: Pick<OpenClawPluginApi, "registerCommand" | "logger">): void {
|
|
13
|
+
api.registerCommand({
|
|
14
|
+
name: "clawchat-login",
|
|
15
|
+
description: "Activate ClawChat with an invite code, e.g. /clawchat-login A1B2C3.",
|
|
16
|
+
acceptsArgs: true,
|
|
17
|
+
requireAuth: true,
|
|
18
|
+
async handler(ctx) {
|
|
19
|
+
const code = extractInviteCode(ctx.args ?? ctx.commandBody);
|
|
20
|
+
if (!code) {
|
|
21
|
+
return { text: "ClawChat invite code is required. Usage: /clawchat-login A1B2C3" };
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const { runOpenclawClawlingLogin } = await import("./login.runtime.ts");
|
|
25
|
+
await runOpenclawClawlingLogin({
|
|
26
|
+
cfg: ctx.config,
|
|
27
|
+
accountId: ctx.accountId ?? null,
|
|
28
|
+
runtime: { log: (message: string) => api.logger?.info?.(message) },
|
|
29
|
+
readInviteCode: async () => code,
|
|
30
|
+
});
|
|
31
|
+
return { text: "✅ ClawChat activated successfully." };
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return { text: `❌ ${errorMessage(err)}` };
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
package/src/config.test.ts
CHANGED
|
@@ -2,7 +2,10 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
import {
|
|
4
4
|
CHANNEL_ID,
|
|
5
|
+
DEFAULT_BASE_URL,
|
|
6
|
+
DEFAULT_WEBSOCKET_URL,
|
|
5
7
|
DEFAULT_STREAM,
|
|
8
|
+
mergeOpenclawClawchatToolAllow,
|
|
6
9
|
resolveOpenclawClawlingAccount,
|
|
7
10
|
listOpenclawClawlingAccountIds,
|
|
8
11
|
} from "./config.ts";
|
|
@@ -80,19 +83,21 @@ describe("openclaw-clawchat config", () => {
|
|
|
80
83
|
});
|
|
81
84
|
|
|
82
85
|
it("falls back to the built-in DEFAULT_BASE_URL when unset", async () => {
|
|
83
|
-
const { DEFAULT_BASE_URL } = await import("./config.ts");
|
|
84
86
|
const account = resolveOpenclawClawlingAccount({});
|
|
85
87
|
expect(account.baseUrl).toBe(DEFAULT_BASE_URL);
|
|
86
88
|
});
|
|
87
89
|
|
|
88
90
|
it("falls back to the built-in DEFAULT_WEBSOCKET_URL when unset", async () => {
|
|
89
|
-
const { DEFAULT_WEBSOCKET_URL } = await import("./config.ts");
|
|
90
91
|
const account = resolveOpenclawClawlingAccount({});
|
|
91
92
|
expect(account.websocketUrl).toBe(DEFAULT_WEBSOCKET_URL);
|
|
92
93
|
});
|
|
93
94
|
|
|
95
|
+
it("uses the production ClawChat service as the built-in fallback endpoint", () => {
|
|
96
|
+
expect(DEFAULT_BASE_URL).toBe("http://company.newbaselab.com:10086");
|
|
97
|
+
expect(DEFAULT_WEBSOCKET_URL).toBe("ws://company.newbaselab.com:10086/ws");
|
|
98
|
+
});
|
|
99
|
+
|
|
94
100
|
it("does NOT include baseUrl in the configured predicate (channel still works without it)", async () => {
|
|
95
|
-
const { DEFAULT_BASE_URL } = await import("./config.ts");
|
|
96
101
|
const cfg = {
|
|
97
102
|
channels: {
|
|
98
103
|
"openclaw-clawchat": {
|
|
@@ -107,4 +112,33 @@ describe("openclaw-clawchat config", () => {
|
|
|
107
112
|
expect(account.configured).toBe(true);
|
|
108
113
|
expect(account.baseUrl).toBe(DEFAULT_BASE_URL);
|
|
109
114
|
});
|
|
115
|
+
|
|
116
|
+
it("adds the plugin to tools.allow when an explicit allowlist is already in use", () => {
|
|
117
|
+
const cfg = mergeOpenclawClawchatToolAllow({
|
|
118
|
+
tools: {
|
|
119
|
+
allow: ["bash"],
|
|
120
|
+
deny: ["exec"],
|
|
121
|
+
},
|
|
122
|
+
} as never) as { tools: Record<string, unknown> };
|
|
123
|
+
|
|
124
|
+
expect(cfg.tools.allow).toEqual(["bash", "openclaw-clawchat"]);
|
|
125
|
+
expect(cfg.tools.deny).toEqual(["exec"]);
|
|
126
|
+
expect(cfg.tools.alsoAllow).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("adds the plugin to tools.alsoAllow when no explicit allowlist is in use", () => {
|
|
130
|
+
const cfg = mergeOpenclawClawchatToolAllow({
|
|
131
|
+
tools: {
|
|
132
|
+
profile: "coding",
|
|
133
|
+
allow: [],
|
|
134
|
+
},
|
|
135
|
+
} as never) as { tools: Record<string, unknown> };
|
|
136
|
+
|
|
137
|
+
expect(cfg.tools).toEqual({
|
|
138
|
+
profile: "coding",
|
|
139
|
+
allow: [],
|
|
140
|
+
alsoAllow: ["openclaw-clawchat"],
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
110
144
|
});
|
package/src/config.ts
CHANGED
|
@@ -8,10 +8,9 @@ export const CHANNEL_ID = "openclaw-clawchat" as const;
|
|
|
8
8
|
* login` works out of the box without requiring a prior `openclaw channel
|
|
9
9
|
* setup` call. Operators can still override either one via config.
|
|
10
10
|
*
|
|
11
|
-
* TODO: replace these placeholders with the production URLs.
|
|
12
11
|
*/
|
|
13
|
-
export const DEFAULT_BASE_URL = "
|
|
14
|
-
export const DEFAULT_WEBSOCKET_URL = "
|
|
12
|
+
export const DEFAULT_BASE_URL = "http://company.newbaselab.com:10086" as const;
|
|
13
|
+
export const DEFAULT_WEBSOCKET_URL = "ws://company.newbaselab.com:10086/ws" as const;
|
|
15
14
|
|
|
16
15
|
export type ReplyMode = "static" | "stream";
|
|
17
16
|
|
|
@@ -150,6 +149,57 @@ export const openclawClawlingConfigSchema = {
|
|
|
150
149
|
},
|
|
151
150
|
} as const;
|
|
152
151
|
|
|
152
|
+
function isOpenclawClawchatToolAllowEntry(entry: unknown): boolean {
|
|
153
|
+
return entry === CHANNEL_ID || entry === "group:plugins";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function hasOpenclawClawchatToolAllow(cfg: OpenClawConfig): boolean {
|
|
157
|
+
const currentTools = ((cfg as { tools?: Record<string, unknown> }).tools ?? {}) as Record<
|
|
158
|
+
string,
|
|
159
|
+
unknown
|
|
160
|
+
>;
|
|
161
|
+
const currentAlsoAllow = Array.isArray(currentTools.alsoAllow) ? currentTools.alsoAllow : [];
|
|
162
|
+
const currentAllow = Array.isArray(currentTools.allow) ? currentTools.allow : [];
|
|
163
|
+
return [...currentAllow, ...currentAlsoAllow].some(isOpenclawClawchatToolAllowEntry);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function mergeToolPolicyEntryAllow(
|
|
167
|
+
cfg: OpenClawConfig,
|
|
168
|
+
entry: string,
|
|
169
|
+
isAlreadyCovered: (value: unknown) => boolean,
|
|
170
|
+
): OpenClawConfig {
|
|
171
|
+
const currentTools = ((cfg as { tools?: Record<string, unknown> }).tools ?? {}) as Record<
|
|
172
|
+
string,
|
|
173
|
+
unknown
|
|
174
|
+
>;
|
|
175
|
+
const currentAlsoAllow = Array.isArray(currentTools.alsoAllow)
|
|
176
|
+
? currentTools.alsoAllow.slice()
|
|
177
|
+
: [];
|
|
178
|
+
const currentAllow = Array.isArray(currentTools.allow) ? currentTools.allow.slice() : [];
|
|
179
|
+
const alreadyAllowed = [...currentAllow, ...currentAlsoAllow].some(isAlreadyCovered);
|
|
180
|
+
if (currentAllow.length > 0) {
|
|
181
|
+
return {
|
|
182
|
+
...cfg,
|
|
183
|
+
tools: {
|
|
184
|
+
...currentTools,
|
|
185
|
+
allow: alreadyAllowed ? currentAllow : [...currentAllow, entry],
|
|
186
|
+
},
|
|
187
|
+
} as OpenClawConfig;
|
|
188
|
+
}
|
|
189
|
+
const alreadyAlsoAllowed = currentAlsoAllow.some(isAlreadyCovered);
|
|
190
|
+
return {
|
|
191
|
+
...cfg,
|
|
192
|
+
tools: {
|
|
193
|
+
...currentTools,
|
|
194
|
+
alsoAllow: alreadyAlsoAllowed ? currentAlsoAllow : [...currentAlsoAllow, entry],
|
|
195
|
+
},
|
|
196
|
+
} as OpenClawConfig;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function mergeOpenclawClawchatToolAllow(cfg: OpenClawConfig): OpenClawConfig {
|
|
200
|
+
return mergeToolPolicyEntryAllow(cfg, CHANNEL_ID, isOpenclawClawchatToolAllowEntry);
|
|
201
|
+
}
|
|
202
|
+
|
|
153
203
|
export type ResolvedOpenclawClawlingAccount = {
|
|
154
204
|
accountId: string;
|
|
155
205
|
name: string;
|
|
@@ -274,4 +324,3 @@ export function resolveOpenclawClawlingAccount(
|
|
|
274
324
|
export function listOpenclawClawlingAccountIds(): string[] {
|
|
275
325
|
return [DEFAULT_ACCOUNT_ID];
|
|
276
326
|
}
|
|
277
|
-
|
package/src/inbound.test.ts
CHANGED
|
@@ -93,7 +93,7 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
93
93
|
envelope: buildSendEnvelope({ text: "hello there" }),
|
|
94
94
|
cfg: {},
|
|
95
95
|
runtime: {} as never,
|
|
96
|
-
account: baseAccount(),
|
|
96
|
+
account: baseAccount({ groupMode: "mention" }),
|
|
97
97
|
ingest,
|
|
98
98
|
});
|
|
99
99
|
expect(ingest).toHaveBeenCalledTimes(1);
|
|
@@ -110,7 +110,7 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
110
110
|
envelope: buildSendEnvelope({ senderType: "direct" }),
|
|
111
111
|
cfg: {},
|
|
112
112
|
runtime: {} as never,
|
|
113
|
-
account: baseAccount(),
|
|
113
|
+
account: baseAccount({ groupMode: "mention" }),
|
|
114
114
|
ingest,
|
|
115
115
|
});
|
|
116
116
|
const { wasMentioned } = ingest.mock.calls[0]![0];
|
|
@@ -145,7 +145,7 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
145
145
|
envelope: buildSendEnvelope({ senderType: "group" }),
|
|
146
146
|
cfg: {},
|
|
147
147
|
runtime: {} as never,
|
|
148
|
-
account: baseAccount(),
|
|
148
|
+
account: baseAccount({ groupMode: "mention" }),
|
|
149
149
|
ingest,
|
|
150
150
|
});
|
|
151
151
|
expect(ingest).not.toHaveBeenCalled();
|
|
@@ -184,7 +184,7 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
184
184
|
expect(replyCtx).toEqual({
|
|
185
185
|
replyToMessageId: "m-orig",
|
|
186
186
|
replyPreviewSenderId: "user-2",
|
|
187
|
-
|
|
187
|
+
replyPreviewNickName: "User Two",
|
|
188
188
|
replyPreviewText: "original text",
|
|
189
189
|
});
|
|
190
190
|
});
|
|
@@ -262,7 +262,7 @@ describe("openclaw-clawchat inbound", () => {
|
|
|
262
262
|
expect(ingest).toHaveBeenCalledTimes(1);
|
|
263
263
|
const call = ingest.mock.calls[0]![0];
|
|
264
264
|
expect(call.senderId).toBe("user-1");
|
|
265
|
-
expect(call.
|
|
265
|
+
expect(call.senderNickName).toBe("User One");
|
|
266
266
|
expect(call.peer).toEqual({ kind: "direct", id: "user-1" });
|
|
267
267
|
});
|
|
268
268
|
|
package/src/inbound.ts
CHANGED
|
@@ -52,6 +52,29 @@ const DEDUP_MAX = 256;
|
|
|
52
52
|
const dedupSeen: string[] = [];
|
|
53
53
|
const dedupSet = new Set<string>();
|
|
54
54
|
|
|
55
|
+
type SenderLike = {
|
|
56
|
+
id?: unknown;
|
|
57
|
+
nick_name?: unknown;
|
|
58
|
+
sender_id?: unknown;
|
|
59
|
+
display_name?: unknown;
|
|
60
|
+
type?: unknown;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function normalizeSender(sender: unknown): { id: string; nickName: string; type?: ChatType } | null {
|
|
64
|
+
if (!sender || typeof sender !== "object") return null;
|
|
65
|
+
const s = sender as SenderLike;
|
|
66
|
+
const id = typeof s.id === "string" ? s.id : typeof s.sender_id === "string" ? s.sender_id : "";
|
|
67
|
+
if (!id) return null;
|
|
68
|
+
const type = s.type === "group" || s.type === "direct" ? s.type : undefined;
|
|
69
|
+
const nickName =
|
|
70
|
+
typeof s.nick_name === "string"
|
|
71
|
+
? s.nick_name
|
|
72
|
+
: typeof s.display_name === "string"
|
|
73
|
+
? s.display_name
|
|
74
|
+
: id;
|
|
75
|
+
return { id, nickName, ...(type ? { type } : {}) };
|
|
76
|
+
}
|
|
77
|
+
|
|
55
78
|
export function _resetDedupForTest(): void {
|
|
56
79
|
dedupSeen.length = 0;
|
|
57
80
|
dedupSet.clear();
|
|
@@ -101,20 +124,22 @@ export async function dispatchOpenclawClawlingInbound(
|
|
|
101
124
|
reply: {
|
|
102
125
|
reply_to_msg_id: string;
|
|
103
126
|
reply_preview: {
|
|
104
|
-
id
|
|
105
|
-
nick_name
|
|
127
|
+
id?: string;
|
|
128
|
+
nick_name?: string;
|
|
129
|
+
sender_id?: string;
|
|
130
|
+
display_name?: string;
|
|
106
131
|
fragments: Array<Record<string, unknown>>;
|
|
107
132
|
};
|
|
108
133
|
} | null;
|
|
109
134
|
};
|
|
110
135
|
/** Legacy fallback: older fixtures carried sender inside payload.message. */
|
|
111
|
-
sender?:
|
|
136
|
+
sender?: SenderLike;
|
|
112
137
|
};
|
|
113
138
|
|
|
114
139
|
// v2 envelopes carry sender on the envelope (RoutingSender); the legacy
|
|
115
140
|
// message.sender shape is accepted as a fallback for older fixtures.
|
|
116
|
-
const sender = envelope.sender ?? message.sender;
|
|
117
|
-
if (!sender
|
|
141
|
+
const sender = normalizeSender(envelope.sender ?? message.sender);
|
|
142
|
+
if (!sender) {
|
|
118
143
|
log?.info?.(
|
|
119
144
|
`[${account.accountId}] openclaw-clawchat skip: missing sender trace=${envelope.trace_id}`,
|
|
120
145
|
);
|
|
@@ -122,7 +147,10 @@ export async function dispatchOpenclawClawlingInbound(
|
|
|
122
147
|
}
|
|
123
148
|
// `chat_type` is on the envelope in the new protocol. Default to "direct"
|
|
124
149
|
// if the server didn't include it (defensive; shouldn't happen in practice).
|
|
125
|
-
const
|
|
150
|
+
const legacyTo = (envelope as Envelope<DownlinkMessageSendPayload> & {
|
|
151
|
+
to?: { type?: ChatType };
|
|
152
|
+
}).to;
|
|
153
|
+
const chatType: ChatType = envelope.chat_type ?? sender.type ?? legacyTo?.type ?? "direct";
|
|
126
154
|
const isGroup = chatType === "group";
|
|
127
155
|
if (payload.message_mode !== "normal") {
|
|
128
156
|
log?.info?.(
|
|
@@ -166,8 +194,14 @@ export async function dispatchOpenclawClawlingInbound(
|
|
|
166
194
|
const replyCtx = message.context.reply
|
|
167
195
|
? {
|
|
168
196
|
replyToMessageId: message.context.reply.reply_to_msg_id,
|
|
169
|
-
replyPreviewSenderId:
|
|
170
|
-
|
|
197
|
+
replyPreviewSenderId:
|
|
198
|
+
message.context.reply.reply_preview.id ??
|
|
199
|
+
message.context.reply.reply_preview.sender_id ??
|
|
200
|
+
"",
|
|
201
|
+
replyPreviewNickName:
|
|
202
|
+
message.context.reply.reply_preview.nick_name ??
|
|
203
|
+
message.context.reply.reply_preview.display_name ??
|
|
204
|
+
"",
|
|
171
205
|
replyPreviewText: fragmentsToText(message.context.reply.reply_preview.fragments as never),
|
|
172
206
|
}
|
|
173
207
|
: undefined;
|
|
@@ -187,7 +221,7 @@ export async function dispatchOpenclawClawlingInbound(
|
|
|
187
221
|
accountId: account.accountId,
|
|
188
222
|
peer: { kind: isGroup ? "group" : "direct", id: chatId },
|
|
189
223
|
senderId: sender.id,
|
|
190
|
-
senderNickName: sender.
|
|
224
|
+
senderNickName: sender.nickName,
|
|
191
225
|
rawBody,
|
|
192
226
|
messageId: payload.message_id,
|
|
193
227
|
traceId: envelope.trace_id,
|