@newbase-clawchat/openclaw-clawchat 2026.4.23 → 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 +23 -11
- 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/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,
|
|
@@ -95,7 +95,7 @@ describe("runOpenclawClawlingLogin", () => {
|
|
|
95
95
|
});
|
|
96
96
|
|
|
97
97
|
expect(agentsConnect).toHaveBeenCalledWith({
|
|
98
|
-
|
|
98
|
+
code: "INV-ABC",
|
|
99
99
|
platform: "openclaw",
|
|
100
100
|
type: "clawbot",
|
|
101
101
|
});
|
|
@@ -112,6 +112,109 @@ describe("runOpenclawClawlingLogin", () => {
|
|
|
112
112
|
expect(log).toHaveBeenCalledWith(expect.stringContaining("login succeeded"));
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
+
it("preserves other configured channels when persisting ClawChat credentials", async () => {
|
|
116
|
+
const cfg = {
|
|
117
|
+
channels: {
|
|
118
|
+
telegram: {
|
|
119
|
+
enabled: true,
|
|
120
|
+
token: "telegram-token",
|
|
121
|
+
},
|
|
122
|
+
[CHANNEL_ID]: {
|
|
123
|
+
baseUrl: "https://api.example.com",
|
|
124
|
+
websocketUrl: "wss://ws.example.com/v2/client",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
} as unknown as OpenClawConfig;
|
|
128
|
+
const agentsConnect = vi.fn().mockResolvedValue({
|
|
129
|
+
agent: { user_id: "agent-123" },
|
|
130
|
+
access_token: "access-tok",
|
|
131
|
+
refresh_token: "refresh-tok",
|
|
132
|
+
});
|
|
133
|
+
const persistConfig = vi.fn();
|
|
134
|
+
|
|
135
|
+
await runOpenclawClawlingLogin({
|
|
136
|
+
cfg,
|
|
137
|
+
runtime: { log: vi.fn() },
|
|
138
|
+
readInviteCode: async () => "INV-ABC",
|
|
139
|
+
apiClientFactory: () => makeApiClient({ agentsConnect }),
|
|
140
|
+
persistConfig,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const savedCfg = persistConfig.mock.calls[0]![0] as OpenClawConfig;
|
|
144
|
+
expect(savedCfg.channels?.telegram).toEqual({
|
|
145
|
+
enabled: true,
|
|
146
|
+
token: "telegram-token",
|
|
147
|
+
});
|
|
148
|
+
expect(savedCfg.channels?.[CHANNEL_ID]).toMatchObject({
|
|
149
|
+
baseUrl: "https://api.example.com",
|
|
150
|
+
websocketUrl: "wss://ws.example.com/v2/client",
|
|
151
|
+
token: "access-tok",
|
|
152
|
+
userId: "agent-123",
|
|
153
|
+
refreshToken: "refresh-tok",
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("enables the channel when login fills a pre-activation disabled skeleton", async () => {
|
|
158
|
+
const cfg = buildCfg({
|
|
159
|
+
enabled: false,
|
|
160
|
+
baseUrl: "https://api.example.com",
|
|
161
|
+
});
|
|
162
|
+
const agentsConnect = vi.fn().mockResolvedValue({
|
|
163
|
+
agent: { user_id: "agent-123" },
|
|
164
|
+
access_token: "access-tok",
|
|
165
|
+
refresh_token: "refresh-tok",
|
|
166
|
+
});
|
|
167
|
+
const persistConfig = vi.fn();
|
|
168
|
+
|
|
169
|
+
await runOpenclawClawlingLogin({
|
|
170
|
+
cfg,
|
|
171
|
+
runtime: { log: vi.fn() },
|
|
172
|
+
readInviteCode: async () => "INV-ABC",
|
|
173
|
+
apiClientFactory: () => makeApiClient({ agentsConnect }),
|
|
174
|
+
persistConfig,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const savedCfg = persistConfig.mock.calls[0]![0] as OpenClawConfig;
|
|
178
|
+
const section = (savedCfg.channels as Record<string, Record<string, unknown>>)[CHANNEL_ID]!;
|
|
179
|
+
expect(section.enabled).toBe(true);
|
|
180
|
+
expect(section.token).toBe("access-tok");
|
|
181
|
+
expect(section.userId).toBe("agent-123");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("allows openclaw-clawchat plugin tools after successful login without replacing policy", async () => {
|
|
185
|
+
const cfg = {
|
|
186
|
+
...buildCfg({ baseUrl: "https://api.example.com" }),
|
|
187
|
+
tools: {
|
|
188
|
+
profile: "coding",
|
|
189
|
+
allow: [],
|
|
190
|
+
deny: ["exec"],
|
|
191
|
+
alsoAllow: ["browser"],
|
|
192
|
+
},
|
|
193
|
+
} as unknown as OpenClawConfig;
|
|
194
|
+
const agentsConnect = vi.fn().mockResolvedValue({
|
|
195
|
+
agent: { user_id: "agent-123" },
|
|
196
|
+
access_token: "access-tok",
|
|
197
|
+
refresh_token: "refresh-tok",
|
|
198
|
+
});
|
|
199
|
+
const persistConfig = vi.fn();
|
|
200
|
+
|
|
201
|
+
await runOpenclawClawlingLogin({
|
|
202
|
+
cfg,
|
|
203
|
+
runtime: { log: vi.fn() },
|
|
204
|
+
readInviteCode: async () => "INV-ABC",
|
|
205
|
+
apiClientFactory: () => makeApiClient({ agentsConnect }),
|
|
206
|
+
persistConfig,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const savedCfg = persistConfig.mock.calls[0]![0] as OpenClawConfig;
|
|
210
|
+
expect(savedCfg.tools).toMatchObject({
|
|
211
|
+
profile: "coding",
|
|
212
|
+
allow: [],
|
|
213
|
+
deny: ["exec"],
|
|
214
|
+
alsoAllow: ["browser", "openclaw-clawchat"],
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
115
218
|
it("surfaces agents/connect API errors with the kind and message", async () => {
|
|
116
219
|
const cfg = buildCfg({ baseUrl: "https://api.example.com" });
|
|
117
220
|
const agentsConnect = vi.fn().mockRejectedValue(
|
|
@@ -219,7 +322,7 @@ describe("runOpenclawClawlingLogin", () => {
|
|
|
219
322
|
persistConfig: vi.fn(),
|
|
220
323
|
});
|
|
221
324
|
expect(agentsConnect).toHaveBeenCalledWith({
|
|
222
|
-
|
|
325
|
+
code: "INV-TRIM",
|
|
223
326
|
platform: "openclaw",
|
|
224
327
|
type: "clawbot",
|
|
225
328
|
});
|
|
@@ -243,7 +346,7 @@ describe("runOpenclawClawlingLogin (non-interactive via readInviteCode)", () =>
|
|
|
243
346
|
persistConfig,
|
|
244
347
|
});
|
|
245
348
|
expect(agentsConnect).toHaveBeenCalledWith({
|
|
246
|
-
|
|
349
|
+
code: "INV-PROGRAMMATIC",
|
|
247
350
|
platform: "openclaw",
|
|
248
351
|
type: "clawbot",
|
|
249
352
|
});
|
package/src/login.runtime.ts
CHANGED
|
@@ -3,7 +3,11 @@ import { writeConfigFile } from "openclaw/plugin-sdk/config-runtime";
|
|
|
3
3
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
4
4
|
import { createOpenclawClawlingApiClient } from "./api-client.ts";
|
|
5
5
|
import { ClawlingApiError } from "./api-types.ts";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
CHANNEL_ID,
|
|
8
|
+
mergeOpenclawClawchatToolAllow,
|
|
9
|
+
resolveOpenclawClawlingAccount,
|
|
10
|
+
} from "./config.ts";
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
13
|
* Platform tag sent to `/v1/agents/connect`. Identifies the host of this
|
|
@@ -120,16 +124,17 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
|
|
|
120
124
|
const existing = (channels[CHANNEL_ID] ?? {}) as Record<string, unknown>;
|
|
121
125
|
const nextSection: Record<string, unknown> = {
|
|
122
126
|
...existing,
|
|
127
|
+
enabled: true,
|
|
123
128
|
token: result.access_token,
|
|
124
129
|
userId: result.agent.user_id,
|
|
125
130
|
};
|
|
126
131
|
if (result.refresh_token) {
|
|
127
132
|
nextSection.refreshToken = result.refresh_token;
|
|
128
133
|
}
|
|
129
|
-
const nextCfg: OpenClawConfig = {
|
|
134
|
+
const nextCfg: OpenClawConfig = mergeOpenclawClawchatToolAllow({
|
|
130
135
|
...cfg,
|
|
131
136
|
channels: { ...channels, [CHANNEL_ID]: nextSection },
|
|
132
|
-
};
|
|
137
|
+
});
|
|
133
138
|
|
|
134
139
|
const tokenPreview = redactToken(result.access_token);
|
|
135
140
|
runtime.log(
|
package/src/manifest.test.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import { describe, expect, it } from "vitest";
|
|
2
3
|
import pluginManifest from "../openclaw.plugin.json" with { type: "json" };
|
|
3
4
|
import packageJson from "../package.json" with { type: "json" };
|
|
4
5
|
|
|
5
6
|
interface PackageJsonWithOpenclaw {
|
|
6
7
|
name: string;
|
|
8
|
+
files: string[];
|
|
7
9
|
openclaw: {
|
|
8
|
-
|
|
10
|
+
extensions: string[];
|
|
11
|
+
setupEntry?: string;
|
|
12
|
+
channel?: { id: string; aliases?: string[]; cliAddOptions?: Array<{ flags: string }> };
|
|
9
13
|
install: { npmSpec: string };
|
|
10
14
|
};
|
|
11
15
|
}
|
|
@@ -14,9 +18,107 @@ describe("openclaw-clawchat manifest", () => {
|
|
|
14
18
|
it("keeps plugin id / channel id / package name aligned", () => {
|
|
15
19
|
expect(pluginManifest.id).toBe("openclaw-clawchat");
|
|
16
20
|
expect(pluginManifest.channels).toContain("openclaw-clawchat");
|
|
17
|
-
expect(
|
|
21
|
+
expect(pluginManifest.skills).toContain("./skills");
|
|
22
|
+
expect(pluginManifest.channelConfigs?.["openclaw-clawchat"]?.label).toBe(
|
|
23
|
+
"Clawling Chat",
|
|
24
|
+
);
|
|
25
|
+
expect(pluginManifest.channelConfigs?.["openclaw-clawchat"]?.schema?.properties).toHaveProperty(
|
|
26
|
+
"token",
|
|
27
|
+
);
|
|
28
|
+
expect(packageJson.name).toBe("@newbase-clawchat/openclaw-clawchat");
|
|
18
29
|
const pkg = packageJson as PackageJsonWithOpenclaw;
|
|
19
|
-
expect(pkg.openclaw.
|
|
20
|
-
expect(pkg.openclaw.install.npmSpec).toBe("openclaw-clawchat");
|
|
30
|
+
expect(pkg.openclaw.extensions).toContain("./index.ts");
|
|
31
|
+
expect(pkg.openclaw.install.npmSpec).toBe("@newbase-clawchat/openclaw-clawchat");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("declares supported channel/command activation hints for plugin loading", () => {
|
|
35
|
+
expect(pluginManifest.activation).toEqual({
|
|
36
|
+
onChannels: ["openclaw-clawchat"],
|
|
37
|
+
onCommands: ["clawchat-login"],
|
|
38
|
+
});
|
|
39
|
+
expect(pluginManifest.activation).not.toHaveProperty("onStartup");
|
|
40
|
+
expect(pluginManifest.commandAliases).toEqual([
|
|
41
|
+
{ name: "clawchat-login", kind: "runtime-slash" },
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("does not publish setup migration or setup-runtime channel metadata", () => {
|
|
46
|
+
const pkg = packageJson as PackageJsonWithOpenclaw;
|
|
47
|
+
expect(pkg.files).not.toContain("setup-api.ts");
|
|
48
|
+
expect(pkg.files).not.toContain("setup-entry.ts");
|
|
49
|
+
expect(pkg.openclaw.setupEntry).toBeUndefined();
|
|
50
|
+
expect(pkg.openclaw.channel).toBeUndefined();
|
|
51
|
+
expect(fs.existsSync(new URL("../setup-api.ts", import.meta.url))).toBe(false);
|
|
52
|
+
expect(fs.existsSync(new URL("../setup-entry.ts", import.meta.url))).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("does not document channels add as an activation path", () => {
|
|
56
|
+
const readme = fs.readFileSync(new URL("../README.md", import.meta.url), "utf8");
|
|
57
|
+
const docs = fs.readFileSync(new URL("../docs/openclaw-clawchat.md", import.meta.url), "utf8");
|
|
58
|
+
expect(readme).not.toMatch(/channels add --channel openclaw-clawchat/i);
|
|
59
|
+
expect(docs).not.toMatch(/channels add --channel openclaw-clawchat/i);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("publishes a ClawChat account tools skill for non-activation workflows", () => {
|
|
63
|
+
const skill = fs.readFileSync(new URL("../skills/clawchat-account-tools/SKILL.md", import.meta.url), "utf8");
|
|
64
|
+
expect(skill).toMatch(/^---\nname: clawchat-account-tools\n/m);
|
|
65
|
+
expect(skill).toMatch(/description: .*Use when/i);
|
|
66
|
+
expect(skill).toMatch(/clawchat_get_account_profile/);
|
|
67
|
+
expect(skill).toMatch(/clawchat_get_user_profile/);
|
|
68
|
+
expect(skill).toMatch(/clawchat_list_account_friends/);
|
|
69
|
+
expect(skill).toMatch(/clawchat_update_account_profile/);
|
|
70
|
+
expect(skill).toMatch(/clawchat_upload_avatar_image/);
|
|
71
|
+
expect(skill).toMatch(/clawchat_upload_media_file/);
|
|
72
|
+
expect(skill).toMatch(/configured ClawChat account/i);
|
|
73
|
+
expect(skill).not.toMatch(/clawchat_activate/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("keeps the activation skill on channel login without tool or slash-command dispatch", () => {
|
|
77
|
+
const skill = fs.readFileSync(new URL("../skills/clawchat-activate/SKILL.md", import.meta.url), "utf8");
|
|
78
|
+
expect(skill).toMatch(/name:\s*clawchat-activate/);
|
|
79
|
+
expect(skill).not.toMatch(/clawchat_activate/);
|
|
80
|
+
expect(skill).not.toMatch(/command-dispatch:\s*tool/);
|
|
81
|
+
expect(skill).not.toMatch(/command-tool:/);
|
|
82
|
+
expect(skill).not.toMatch(/command-dispatch:/);
|
|
83
|
+
expect(skill).not.toMatch(/command-command:/);
|
|
84
|
+
expect(skill).not.toMatch(/command-arg-mode:/);
|
|
85
|
+
expect(skill).not.toMatch(/user-invocable:/);
|
|
86
|
+
expect(skill).not.toMatch(/`clawchat\s+A1B2C3`/i);
|
|
87
|
+
expect(skill).not.toMatch(/`clawchat\s*<code>`/i);
|
|
88
|
+
expect(skill).not.toMatch(/\/clawchat_activate A1B2C3/);
|
|
89
|
+
expect(skill).not.toMatch(/\/clawchat-activate A1B2C3/);
|
|
90
|
+
expect(skill).not.toMatch(/\/clawchat-login A1B2C3/);
|
|
91
|
+
expect(skill).toMatch(/openclaw channels login --channel openclaw-clawchat/);
|
|
92
|
+
expect(skill).toMatch(/do not append/i);
|
|
93
|
+
expect(skill).toMatch(/prompt[^\n]+invite code[^\n]+provide/i);
|
|
94
|
+
expect(skill).toMatch(/channel login/i);
|
|
95
|
+
expect(skill).toMatch(/openclaw gateway restart/);
|
|
96
|
+
expect(skill).not.toMatch(/ask the user to send/i);
|
|
97
|
+
expect(skill).not.toMatch(/give the exact/i);
|
|
98
|
+
expect(skill).toMatch(/execute[^\n]+openclaw channels login --channel openclaw-clawchat/i);
|
|
99
|
+
expect(skill).toMatch(/execute[^\n]+openclaw gateway restart/i);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("documents channel login as the natural-language activation path", () => {
|
|
103
|
+
const readme = fs.readFileSync(new URL("../README.md", import.meta.url), "utf8");
|
|
104
|
+
const docs = fs.readFileSync(new URL("../docs/openclaw-clawchat.md", import.meta.url), "utf8");
|
|
105
|
+
expect(readme).toMatch(/openclaw channels login --channel openclaw-clawchat/i);
|
|
106
|
+
expect(docs).toMatch(/openclaw channels login --channel openclaw-clawchat/i);
|
|
107
|
+
expect(readme).toMatch(/openclaw gateway restart/i);
|
|
108
|
+
expect(docs).toMatch(/openclaw gateway restart/i);
|
|
109
|
+
expect(readme).not.toMatch(/activation skill[^.]+\/clawchat-login/i);
|
|
110
|
+
expect(docs).not.toMatch(/natural-language activation requests[^.]+\/clawchat-login/i);
|
|
111
|
+
expect(readme).not.toMatch(/\/clawchat-activate\s+A1B2C3/i);
|
|
112
|
+
expect(docs).not.toMatch(/\/clawchat-activate\s+A1B2C3/i);
|
|
113
|
+
expect(readme).not.toMatch(/clawchat_activate/);
|
|
114
|
+
expect(docs).not.toMatch(/clawchat_activate/);
|
|
115
|
+
expect(readme).not.toMatch(/\/clawchat_activate\s+A1B2C3/i);
|
|
116
|
+
expect(docs).not.toMatch(/\/clawchat_activate\s+A1B2C3/i);
|
|
117
|
+
expect(readme).not.toMatch(/direct users to/i);
|
|
118
|
+
expect(docs).not.toMatch(/direct the\s+user/i);
|
|
119
|
+
expect(docs).not.toMatch(/call the tool with the extracted code/i);
|
|
120
|
+
expect(docs).not.toMatch(/AGT->>PLG: clawchat_activate/);
|
|
121
|
+
expect(readme).toMatch(/activation skill[^.]+execute/i);
|
|
122
|
+
expect(docs).toMatch(/natural-language activation requests[^.]+execute/i);
|
|
21
123
|
});
|
|
22
124
|
});
|
package/src/outbound.test.ts
CHANGED
|
@@ -64,7 +64,8 @@ describe("openclaw-clawchat outbound", () => {
|
|
|
64
64
|
text: "hello",
|
|
65
65
|
});
|
|
66
66
|
expect((client.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).toMatchObject({
|
|
67
|
-
|
|
67
|
+
chat_id: "user-1",
|
|
68
|
+
chat_type: "direct",
|
|
68
69
|
body: { fragments: [{ kind: "text", text: "hello" }] },
|
|
69
70
|
});
|
|
70
71
|
expect(client.replyMessage).not.toHaveBeenCalled();
|
|
@@ -82,16 +83,17 @@ describe("openclaw-clawchat outbound", () => {
|
|
|
82
83
|
replyCtx: {
|
|
83
84
|
replyToMessageId: "m-orig",
|
|
84
85
|
replyPreviewSenderId: "user-2",
|
|
85
|
-
|
|
86
|
+
replyPreviewNickName: "Sender",
|
|
86
87
|
replyPreviewText: "original",
|
|
87
88
|
},
|
|
88
89
|
});
|
|
89
90
|
expect((client.replyMessage as ReturnType<typeof vi.fn>).mock.calls[0][0]).toMatchObject({
|
|
90
|
-
|
|
91
|
+
chat_id: "user-1",
|
|
92
|
+
chat_type: "direct",
|
|
91
93
|
replyTo: {
|
|
92
94
|
msgId: "m-orig",
|
|
93
95
|
senderId: "user-2",
|
|
94
|
-
|
|
96
|
+
nickName: "Sender",
|
|
95
97
|
fragments: [{ kind: "text", text: "original" }],
|
|
96
98
|
},
|
|
97
99
|
body: { fragments: [{ kind: "text", text: "reply" }] },
|
|
@@ -168,7 +170,7 @@ describe("openclaw-clawchat outbound", () => {
|
|
|
168
170
|
replyCtx: {
|
|
169
171
|
replyToMessageId: "m-orig",
|
|
170
172
|
replyPreviewSenderId: "user-2",
|
|
171
|
-
|
|
173
|
+
replyPreviewNickName: "Sender",
|
|
172
174
|
replyPreviewText: "original",
|
|
173
175
|
},
|
|
174
176
|
mediaFragments: [{ kind: "image", url: "https://cdn/x.png" }],
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import pluginEntry from "../index.ts";
|
|
3
|
+
|
|
4
|
+
describe("openclaw-clawchat plugin entry", () => {
|
|
5
|
+
it("registers the channel/tools and native activation command without bootstrap migration", () => {
|
|
6
|
+
const api = {
|
|
7
|
+
config: {},
|
|
8
|
+
runtime: {},
|
|
9
|
+
logger: { debug: vi.fn(), info: vi.fn(), error: vi.fn() },
|
|
10
|
+
registerChannel: vi.fn(),
|
|
11
|
+
registerCommand: vi.fn(),
|
|
12
|
+
registerConfigMigration: vi.fn(),
|
|
13
|
+
registerTool: vi.fn(),
|
|
14
|
+
} as never;
|
|
15
|
+
|
|
16
|
+
pluginEntry.register(api);
|
|
17
|
+
|
|
18
|
+
expect(api.registerChannel).toHaveBeenCalledTimes(1);
|
|
19
|
+
expect(api.registerConfigMigration).not.toHaveBeenCalled();
|
|
20
|
+
expect(api.registerCommand).toHaveBeenCalledTimes(1);
|
|
21
|
+
expect(api.registerCommand).toHaveBeenCalledWith(
|
|
22
|
+
expect.objectContaining({
|
|
23
|
+
name: "clawchat-login",
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
});
|
package/src/protocol.ts
CHANGED
|
@@ -40,3 +40,8 @@ export function hasRenderableText(message: {
|
|
|
40
40
|
(f as { url: string }).url.trim().length > 0)),
|
|
41
41
|
);
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
export function isGroupSender(sender: unknown): boolean {
|
|
45
|
+
if (!sender || typeof sender !== "object") return false;
|
|
46
|
+
return (sender as { type?: unknown }).type === "group";
|
|
47
|
+
}
|
|
@@ -185,7 +185,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
185
185
|
|
|
186
186
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
187
187
|
expect.objectContaining({
|
|
188
|
-
|
|
188
|
+
chat_id: "chat-1",
|
|
189
|
+
chat_type: "direct",
|
|
189
190
|
body: { fragments: [{ kind: "text", text: "Error: boom" }] },
|
|
190
191
|
}),
|
|
191
192
|
);
|
|
@@ -244,7 +245,8 @@ describe("openclaw-clawchat reply-dispatcher", () => {
|
|
|
244
245
|
|
|
245
246
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
246
247
|
expect.objectContaining({
|
|
247
|
-
|
|
248
|
+
chat_id: "chat-1",
|
|
249
|
+
chat_type: "direct",
|
|
248
250
|
body: { fragments: [{ kind: "text", text: "Error: boom" }] },
|
|
249
251
|
}),
|
|
250
252
|
);
|
package/src/runtime.test.ts
CHANGED
|
@@ -56,15 +56,19 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
56
56
|
capturedCtx = ctx;
|
|
57
57
|
return ctx;
|
|
58
58
|
});
|
|
59
|
+
const resolveAgentRoute = vi.fn(() => ({
|
|
60
|
+
agentId: "u",
|
|
61
|
+
accountId: "default",
|
|
62
|
+
sessionKey: "s",
|
|
63
|
+
}));
|
|
64
|
+
const cfg = {
|
|
65
|
+
session: { store: "/tmp/sessions.json", dmScope: "main" },
|
|
66
|
+
} as unknown as OpenClawConfig;
|
|
59
67
|
|
|
60
68
|
const runtime = {
|
|
61
69
|
channel: {
|
|
62
70
|
routing: {
|
|
63
|
-
resolveAgentRoute
|
|
64
|
-
agentId: "u",
|
|
65
|
-
accountId: "default",
|
|
66
|
-
sessionKey: "s",
|
|
67
|
-
})),
|
|
71
|
+
resolveAgentRoute,
|
|
68
72
|
},
|
|
69
73
|
session: {
|
|
70
74
|
resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
|
|
@@ -108,7 +112,7 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
108
112
|
const abortController = new AbortController();
|
|
109
113
|
|
|
110
114
|
const startPromise = startOpenclawClawlingGateway({
|
|
111
|
-
cfg
|
|
115
|
+
cfg,
|
|
112
116
|
account: {
|
|
113
117
|
accountId: "default",
|
|
114
118
|
name: "openclaw-clawchat",
|
|
@@ -203,6 +207,18 @@ describe("openclaw-clawchat runtime media ingest", () => {
|
|
|
203
207
|
expect(capturedCtx?.From).toBe("openclaw-clawchat:chat-1");
|
|
204
208
|
expect(capturedCtx?.ConversationLabel).toBe("chat-1");
|
|
205
209
|
expect(capturedCtx?.SenderId).toBe("user-1");
|
|
210
|
+
expect(resolveAgentRoute).toHaveBeenCalledWith(
|
|
211
|
+
expect.objectContaining({
|
|
212
|
+
cfg: expect.objectContaining({
|
|
213
|
+
session: expect.objectContaining({
|
|
214
|
+
dmScope: "per-account-channel-peer",
|
|
215
|
+
store: "/tmp/sessions.json",
|
|
216
|
+
}),
|
|
217
|
+
}),
|
|
218
|
+
peer: { kind: "direct", id: "chat-1" },
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
expect(cfg.session?.dmScope).toBe("main");
|
|
206
222
|
});
|
|
207
223
|
|
|
208
224
|
it("uses group chat_id as the canonical conversation identity", async () => {
|
|
@@ -494,7 +510,7 @@ describe("openclaw-clawchat runtime reply dispatch lifecycle", () => {
|
|
|
494
510
|
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
495
511
|
expect(markDispatchIdle).toHaveBeenCalledTimes(1);
|
|
496
512
|
expect(logError).toHaveBeenCalledWith(
|
|
497
|
-
expect.stringContaining("openclaw-clawchat
|
|
513
|
+
expect.stringContaining("openclaw-clawchat dispatch failed msg=m-fail"),
|
|
498
514
|
);
|
|
499
515
|
});
|
|
500
516
|
});
|