@openclaw/zalouser 2026.3.12 → 2026.3.13
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/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/src/accounts.test-mocks.ts +10 -0
- package/src/accounts.ts +15 -14
- package/src/channel.directory.test.ts +3 -23
- package/src/channel.sendpayload.test.ts +1 -14
- package/src/channel.test.ts +29 -38
- package/src/channel.ts +2 -9
- package/src/monitor.account-scope.test.ts +2 -9
- package/src/monitor.group-gating.test.ts +182 -207
- package/src/monitor.ts +1 -10
- package/src/status-issues.test.ts +5 -5
- package/src/status-issues.ts +10 -28
- package/src/test-helpers.ts +26 -0
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
import { createDefaultResolvedZalouserAccount } from "./test-helpers.js";
|
|
3
|
+
|
|
4
|
+
vi.mock("./accounts.js", async (importOriginal) => {
|
|
5
|
+
const actual = (await importOriginal()) as Record<string, unknown>;
|
|
6
|
+
return {
|
|
7
|
+
...actual,
|
|
8
|
+
resolveZalouserAccountSync: () => createDefaultResolvedZalouserAccount(),
|
|
9
|
+
};
|
|
10
|
+
});
|
package/src/accounts.ts
CHANGED
|
@@ -43,17 +43,24 @@ function resolveProfile(config: ZalouserAccountConfig, accountId: string): strin
|
|
|
43
43
|
return "default";
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
cfg: OpenClawConfig;
|
|
48
|
-
accountId?: string | null;
|
|
49
|
-
}): Promise<ResolvedZalouserAccount> {
|
|
46
|
+
function resolveZalouserAccountBase(params: { cfg: OpenClawConfig; accountId?: string | null }) {
|
|
50
47
|
const accountId = normalizeAccountId(params.accountId);
|
|
51
48
|
const baseEnabled =
|
|
52
49
|
(params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false;
|
|
53
50
|
const merged = mergeZalouserAccountConfig(params.cfg, accountId);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
return {
|
|
52
|
+
accountId,
|
|
53
|
+
enabled: baseEnabled && merged.enabled !== false,
|
|
54
|
+
merged,
|
|
55
|
+
profile: resolveProfile(merged, accountId),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function resolveZalouserAccount(params: {
|
|
60
|
+
cfg: OpenClawConfig;
|
|
61
|
+
accountId?: string | null;
|
|
62
|
+
}): Promise<ResolvedZalouserAccount> {
|
|
63
|
+
const { accountId, enabled, merged, profile } = resolveZalouserAccountBase(params);
|
|
57
64
|
const authenticated = await checkZaloAuthenticated(profile);
|
|
58
65
|
|
|
59
66
|
return {
|
|
@@ -70,13 +77,7 @@ export function resolveZalouserAccountSync(params: {
|
|
|
70
77
|
cfg: OpenClawConfig;
|
|
71
78
|
accountId?: string | null;
|
|
72
79
|
}): ResolvedZalouserAccount {
|
|
73
|
-
const accountId =
|
|
74
|
-
const baseEnabled =
|
|
75
|
-
(params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false;
|
|
76
|
-
const merged = mergeZalouserAccountConfig(params.cfg, accountId);
|
|
77
|
-
const accountEnabled = merged.enabled !== false;
|
|
78
|
-
const enabled = baseEnabled && accountEnabled;
|
|
79
|
-
const profile = resolveProfile(merged, accountId);
|
|
80
|
+
const { accountId, enabled, merged, profile } = resolveZalouserAccountBase(params);
|
|
80
81
|
|
|
81
82
|
return {
|
|
82
83
|
accountId,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { RuntimeEnv } from "openclaw/plugin-sdk/zalouser";
|
|
2
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import "./accounts.test-mocks.js";
|
|
3
|
+
import { createZalouserRuntimeEnv } from "./test-helpers.js";
|
|
3
4
|
|
|
4
5
|
const listZaloGroupMembersMock = vi.hoisted(() => vi.fn(async () => []));
|
|
5
6
|
|
|
@@ -11,30 +12,9 @@ vi.mock("./zalo-js.js", async (importOriginal) => {
|
|
|
11
12
|
};
|
|
12
13
|
});
|
|
13
14
|
|
|
14
|
-
vi.mock("./accounts.js", async (importOriginal) => {
|
|
15
|
-
const actual = (await importOriginal()) as Record<string, unknown>;
|
|
16
|
-
return {
|
|
17
|
-
...actual,
|
|
18
|
-
resolveZalouserAccountSync: () => ({
|
|
19
|
-
accountId: "default",
|
|
20
|
-
profile: "default",
|
|
21
|
-
name: "test",
|
|
22
|
-
enabled: true,
|
|
23
|
-
authenticated: true,
|
|
24
|
-
config: {},
|
|
25
|
-
}),
|
|
26
|
-
};
|
|
27
|
-
});
|
|
28
|
-
|
|
29
15
|
import { zalouserPlugin } from "./channel.js";
|
|
30
16
|
|
|
31
|
-
const runtimeStub
|
|
32
|
-
log: vi.fn(),
|
|
33
|
-
error: vi.fn(),
|
|
34
|
-
exit: ((code: number): never => {
|
|
35
|
-
throw new Error(`exit ${code}`);
|
|
36
|
-
}) as RuntimeEnv["exit"],
|
|
37
|
-
};
|
|
17
|
+
const runtimeStub = createZalouserRuntimeEnv();
|
|
38
18
|
|
|
39
19
|
describe("zalouser directory group members", () => {
|
|
40
20
|
it("accepts prefixed group ids from directory groups list output", async () => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser";
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import "./accounts.test-mocks.js";
|
|
3
4
|
import {
|
|
4
5
|
installSendPayloadContractSuite,
|
|
5
6
|
primeSendMock,
|
|
@@ -12,20 +13,6 @@ vi.mock("./send.js", () => ({
|
|
|
12
13
|
sendReactionZalouser: vi.fn().mockResolvedValue({ ok: true }),
|
|
13
14
|
}));
|
|
14
15
|
|
|
15
|
-
vi.mock("./accounts.js", async (importOriginal) => {
|
|
16
|
-
const actual = (await importOriginal()) as Record<string, unknown>;
|
|
17
|
-
return {
|
|
18
|
-
...actual,
|
|
19
|
-
resolveZalouserAccountSync: () => ({
|
|
20
|
-
accountId: "default",
|
|
21
|
-
profile: "default",
|
|
22
|
-
name: "test",
|
|
23
|
-
enabled: true,
|
|
24
|
-
config: {},
|
|
25
|
-
}),
|
|
26
|
-
};
|
|
27
|
-
});
|
|
28
|
-
|
|
29
16
|
function baseCtx(payload: ReplyPayload) {
|
|
30
17
|
return {
|
|
31
18
|
cfg: {},
|
package/src/channel.test.ts
CHANGED
|
@@ -15,6 +15,33 @@ vi.mock("./send.js", async (importOriginal) => {
|
|
|
15
15
|
const mockSendMessage = vi.mocked(sendMessageZalouser);
|
|
16
16
|
const mockSendReaction = vi.mocked(sendReactionZalouser);
|
|
17
17
|
|
|
18
|
+
function getResolveToolPolicy() {
|
|
19
|
+
const resolveToolPolicy = zalouserPlugin.groups?.resolveToolPolicy;
|
|
20
|
+
expect(resolveToolPolicy).toBeTypeOf("function");
|
|
21
|
+
if (!resolveToolPolicy) {
|
|
22
|
+
throw new Error("resolveToolPolicy unavailable");
|
|
23
|
+
}
|
|
24
|
+
return resolveToolPolicy;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveGroupToolPolicy(
|
|
28
|
+
groups: Record<string, { tools: { allow?: string[]; deny?: string[] } }>,
|
|
29
|
+
groupId: string,
|
|
30
|
+
) {
|
|
31
|
+
return getResolveToolPolicy()({
|
|
32
|
+
cfg: {
|
|
33
|
+
channels: {
|
|
34
|
+
zalouser: {
|
|
35
|
+
groups,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
accountId: "default",
|
|
40
|
+
groupId,
|
|
41
|
+
groupChannel: groupId,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
18
45
|
describe("zalouser outbound", () => {
|
|
19
46
|
beforeEach(() => {
|
|
20
47
|
mockSendMessage.mockClear();
|
|
@@ -93,48 +120,12 @@ describe("zalouser channel policies", () => {
|
|
|
93
120
|
});
|
|
94
121
|
|
|
95
122
|
it("resolves group tool policy by explicit group id", () => {
|
|
96
|
-
const
|
|
97
|
-
expect(resolveToolPolicy).toBeTypeOf("function");
|
|
98
|
-
if (!resolveToolPolicy) {
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
const policy = resolveToolPolicy({
|
|
102
|
-
cfg: {
|
|
103
|
-
channels: {
|
|
104
|
-
zalouser: {
|
|
105
|
-
groups: {
|
|
106
|
-
"123": { tools: { allow: ["search"] } },
|
|
107
|
-
},
|
|
108
|
-
},
|
|
109
|
-
},
|
|
110
|
-
},
|
|
111
|
-
accountId: "default",
|
|
112
|
-
groupId: "123",
|
|
113
|
-
groupChannel: "123",
|
|
114
|
-
});
|
|
123
|
+
const policy = resolveGroupToolPolicy({ "123": { tools: { allow: ["search"] } } }, "123");
|
|
115
124
|
expect(policy).toEqual({ allow: ["search"] });
|
|
116
125
|
});
|
|
117
126
|
|
|
118
127
|
it("falls back to wildcard group policy", () => {
|
|
119
|
-
const
|
|
120
|
-
expect(resolveToolPolicy).toBeTypeOf("function");
|
|
121
|
-
if (!resolveToolPolicy) {
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
const policy = resolveToolPolicy({
|
|
125
|
-
cfg: {
|
|
126
|
-
channels: {
|
|
127
|
-
zalouser: {
|
|
128
|
-
groups: {
|
|
129
|
-
"*": { tools: { deny: ["system.run"] } },
|
|
130
|
-
},
|
|
131
|
-
},
|
|
132
|
-
},
|
|
133
|
-
},
|
|
134
|
-
accountId: "default",
|
|
135
|
-
groupId: "missing",
|
|
136
|
-
groupChannel: "missing",
|
|
137
|
-
});
|
|
128
|
+
const policy = resolveGroupToolPolicy({ "*": { tools: { deny: ["system.run"] } } }, "missing");
|
|
138
129
|
expect(policy).toEqual({ deny: ["system.run"] });
|
|
139
130
|
});
|
|
140
131
|
|
package/src/channel.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
sendPayloadWithChunkedTextAndMedia,
|
|
30
30
|
setAccountEnabledInConfigSection,
|
|
31
31
|
} from "openclaw/plugin-sdk/zalouser";
|
|
32
|
+
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
|
|
32
33
|
import {
|
|
33
34
|
listZalouserAccountIds,
|
|
34
35
|
resolveDefaultZalouserAccountId,
|
|
@@ -652,15 +653,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|
|
652
653
|
lastError: null,
|
|
653
654
|
},
|
|
654
655
|
collectStatusIssues: collectZalouserStatusIssues,
|
|
655
|
-
buildChannelSummary: ({ snapshot }) => (
|
|
656
|
-
configured: snapshot.configured ?? false,
|
|
657
|
-
running: snapshot.running ?? false,
|
|
658
|
-
lastStartAt: snapshot.lastStartAt ?? null,
|
|
659
|
-
lastStopAt: snapshot.lastStopAt ?? null,
|
|
660
|
-
lastError: snapshot.lastError ?? null,
|
|
661
|
-
probe: snapshot.probe,
|
|
662
|
-
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
663
|
-
}),
|
|
656
|
+
buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot),
|
|
664
657
|
probeAccount: async ({ account, timeoutMs }) => probeZalouser(account.profile, timeoutMs),
|
|
665
658
|
buildAccountSnapshot: async ({ account, runtime }) => {
|
|
666
659
|
const configured = await checkZcaAuthenticated(account.profile);
|
|
@@ -4,6 +4,7 @@ import "./monitor.send-mocks.js";
|
|
|
4
4
|
import { __testing } from "./monitor.js";
|
|
5
5
|
import { sendMessageZalouserMock } from "./monitor.send-mocks.js";
|
|
6
6
|
import { setZalouserRuntime } from "./runtime.js";
|
|
7
|
+
import { createZalouserRuntimeEnv } from "./test-helpers.js";
|
|
7
8
|
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
|
8
9
|
|
|
9
10
|
describe("zalouser monitor pairing account scoping", () => {
|
|
@@ -80,19 +81,11 @@ describe("zalouser monitor pairing account scoping", () => {
|
|
|
80
81
|
raw: { source: "test" },
|
|
81
82
|
};
|
|
82
83
|
|
|
83
|
-
const runtime: RuntimeEnv = {
|
|
84
|
-
log: vi.fn(),
|
|
85
|
-
error: vi.fn(),
|
|
86
|
-
exit: ((code: number): never => {
|
|
87
|
-
throw new Error(`exit ${code}`);
|
|
88
|
-
}) as RuntimeEnv["exit"],
|
|
89
|
-
};
|
|
90
|
-
|
|
91
84
|
await __testing.processMessage({
|
|
92
85
|
message,
|
|
93
86
|
account,
|
|
94
87
|
config,
|
|
95
|
-
runtime,
|
|
88
|
+
runtime: createZalouserRuntimeEnv(),
|
|
96
89
|
});
|
|
97
90
|
|
|
98
91
|
expect(readAllowFromStore).toHaveBeenCalledWith(
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
sendTypingZalouserMock,
|
|
10
10
|
} from "./monitor.send-mocks.js";
|
|
11
11
|
import { setZalouserRuntime } from "./runtime.js";
|
|
12
|
+
import { createZalouserRuntimeEnv } from "./test-helpers.js";
|
|
12
13
|
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
|
13
14
|
|
|
14
15
|
function createAccount(): ResolvedZalouserAccount {
|
|
@@ -39,15 +40,7 @@ function createConfig(): OpenClawConfig {
|
|
|
39
40
|
};
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
return {
|
|
44
|
-
log: vi.fn(),
|
|
45
|
-
error: vi.fn(),
|
|
46
|
-
exit: ((code: number): never => {
|
|
47
|
-
throw new Error(`exit ${code}`);
|
|
48
|
-
}) as RuntimeEnv["exit"],
|
|
49
|
-
};
|
|
50
|
-
}
|
|
43
|
+
const createRuntimeEnv = () => createZalouserRuntimeEnv();
|
|
51
44
|
|
|
52
45
|
function installRuntime(params: {
|
|
53
46
|
commandAuthorized?: boolean;
|
|
@@ -187,6 +180,31 @@ function installRuntime(params: {
|
|
|
187
180
|
};
|
|
188
181
|
}
|
|
189
182
|
|
|
183
|
+
function installGroupCommandAuthRuntime() {
|
|
184
|
+
return installRuntime({
|
|
185
|
+
resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) =>
|
|
186
|
+
useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function processGroupControlCommand(params: {
|
|
191
|
+
account: ResolvedZalouserAccount;
|
|
192
|
+
content?: string;
|
|
193
|
+
commandContent?: string;
|
|
194
|
+
}) {
|
|
195
|
+
await __testing.processMessage({
|
|
196
|
+
message: createGroupMessage({
|
|
197
|
+
content: params.content ?? "/new",
|
|
198
|
+
commandContent: params.commandContent ?? "/new",
|
|
199
|
+
hasAnyMention: true,
|
|
200
|
+
wasExplicitlyMentioned: true,
|
|
201
|
+
}),
|
|
202
|
+
account: params.account,
|
|
203
|
+
config: createConfig(),
|
|
204
|
+
runtime: createRuntimeEnv(),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
190
208
|
function createGroupMessage(overrides: Partial<ZaloInboundMessage> = {}): ZaloInboundMessage {
|
|
191
209
|
return {
|
|
192
210
|
threadId: "g-1",
|
|
@@ -229,57 +247,152 @@ describe("zalouser monitor group mention gating", () => {
|
|
|
229
247
|
sendSeenZalouserMock.mockClear();
|
|
230
248
|
});
|
|
231
249
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
250
|
+
async function processMessageWithDefaults(params: {
|
|
251
|
+
message: ZaloInboundMessage;
|
|
252
|
+
account?: ResolvedZalouserAccount;
|
|
253
|
+
historyState?: {
|
|
254
|
+
historyLimit: number;
|
|
255
|
+
groupHistories: Map<
|
|
256
|
+
string,
|
|
257
|
+
Array<{ sender: string; body: string; timestamp?: number; messageId?: string }>
|
|
258
|
+
>;
|
|
259
|
+
};
|
|
260
|
+
}) {
|
|
236
261
|
await __testing.processMessage({
|
|
237
|
-
message:
|
|
238
|
-
account: createAccount(),
|
|
262
|
+
message: params.message,
|
|
263
|
+
account: params.account ?? createAccount(),
|
|
239
264
|
config: createConfig(),
|
|
240
|
-
runtime:
|
|
265
|
+
runtime: createZalouserRuntimeEnv(),
|
|
266
|
+
historyState: params.historyState,
|
|
241
267
|
});
|
|
268
|
+
}
|
|
242
269
|
|
|
243
|
-
|
|
244
|
-
expect(sendTypingZalouserMock).not.toHaveBeenCalled();
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it("fails closed when requireMention=true but mention detection is unavailable", async () => {
|
|
270
|
+
async function expectSkippedGroupMessage(message?: Partial<ZaloInboundMessage>) {
|
|
248
271
|
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
|
249
272
|
commandAuthorized: false,
|
|
250
273
|
});
|
|
251
|
-
await
|
|
252
|
-
message: createGroupMessage(
|
|
253
|
-
canResolveExplicitMention: false,
|
|
254
|
-
hasAnyMention: false,
|
|
255
|
-
wasExplicitlyMentioned: false,
|
|
256
|
-
}),
|
|
257
|
-
account: createAccount(),
|
|
258
|
-
config: createConfig(),
|
|
259
|
-
runtime: createRuntimeEnv(),
|
|
274
|
+
await processMessageWithDefaults({
|
|
275
|
+
message: createGroupMessage(message),
|
|
260
276
|
});
|
|
261
|
-
|
|
262
277
|
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
|
263
278
|
expect(sendTypingZalouserMock).not.toHaveBeenCalled();
|
|
264
|
-
}
|
|
279
|
+
}
|
|
265
280
|
|
|
266
|
-
|
|
281
|
+
async function expectGroupCommandAuthorizers(params: {
|
|
282
|
+
accountConfig: ResolvedZalouserAccount["config"];
|
|
283
|
+
expectedAuthorizers: Array<{ configured: boolean; allowed: boolean }>;
|
|
284
|
+
}) {
|
|
285
|
+
const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } =
|
|
286
|
+
installGroupCommandAuthRuntime();
|
|
287
|
+
await processGroupControlCommand({
|
|
288
|
+
account: {
|
|
289
|
+
...createAccount(),
|
|
290
|
+
config: params.accountConfig,
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
|
294
|
+
const authCall = resolveCommandAuthorizedFromAuthorizers.mock.calls[0]?.[0];
|
|
295
|
+
expect(authCall?.authorizers).toEqual(params.expectedAuthorizers);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function processOpenDmMessage(params?: {
|
|
299
|
+
message?: Partial<ZaloInboundMessage>;
|
|
300
|
+
readSessionUpdatedAt?: (input?: {
|
|
301
|
+
storePath: string;
|
|
302
|
+
sessionKey: string;
|
|
303
|
+
}) => number | undefined;
|
|
304
|
+
}) {
|
|
305
|
+
const runtime = installRuntime({
|
|
306
|
+
commandAuthorized: false,
|
|
307
|
+
});
|
|
308
|
+
if (params?.readSessionUpdatedAt) {
|
|
309
|
+
runtime.readSessionUpdatedAt.mockImplementation(params.readSessionUpdatedAt);
|
|
310
|
+
}
|
|
311
|
+
const account = createAccount();
|
|
312
|
+
await processMessageWithDefaults({
|
|
313
|
+
message: createDmMessage(params?.message),
|
|
314
|
+
account: {
|
|
315
|
+
...account,
|
|
316
|
+
config: {
|
|
317
|
+
...account.config,
|
|
318
|
+
dmPolicy: "open",
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
return runtime;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function expectDangerousNameMatching(params: {
|
|
326
|
+
dangerouslyAllowNameMatching?: boolean;
|
|
327
|
+
expectedDispatches: number;
|
|
328
|
+
}) {
|
|
267
329
|
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
|
268
330
|
commandAuthorized: false,
|
|
269
331
|
});
|
|
270
|
-
await
|
|
332
|
+
await processMessageWithDefaults({
|
|
271
333
|
message: createGroupMessage({
|
|
334
|
+
threadId: "g-attacker-001",
|
|
335
|
+
groupName: "Trusted Team",
|
|
336
|
+
senderId: "666",
|
|
272
337
|
hasAnyMention: true,
|
|
273
338
|
wasExplicitlyMentioned: true,
|
|
274
339
|
content: "ping @bot",
|
|
275
340
|
}),
|
|
276
|
-
account:
|
|
277
|
-
|
|
278
|
-
|
|
341
|
+
account: {
|
|
342
|
+
...createAccount(),
|
|
343
|
+
config: {
|
|
344
|
+
...createAccount().config,
|
|
345
|
+
...(params.dangerouslyAllowNameMatching ? { dangerouslyAllowNameMatching: true } : {}),
|
|
346
|
+
groupPolicy: "allowlist",
|
|
347
|
+
groupAllowFrom: ["*"],
|
|
348
|
+
groups: {
|
|
349
|
+
"group:g-trusted-001": { allow: true },
|
|
350
|
+
"Trusted Team": { allow: true },
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
},
|
|
279
354
|
});
|
|
355
|
+
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(
|
|
356
|
+
params.expectedDispatches,
|
|
357
|
+
);
|
|
358
|
+
return dispatchReplyWithBufferedBlockDispatcher;
|
|
359
|
+
}
|
|
280
360
|
|
|
361
|
+
async function dispatchGroupMessage(params: {
|
|
362
|
+
commandAuthorized: boolean;
|
|
363
|
+
message: Partial<ZaloInboundMessage>;
|
|
364
|
+
}) {
|
|
365
|
+
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
|
366
|
+
commandAuthorized: params.commandAuthorized,
|
|
367
|
+
});
|
|
368
|
+
await processMessageWithDefaults({
|
|
369
|
+
message: createGroupMessage(params.message),
|
|
370
|
+
});
|
|
281
371
|
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
|
282
|
-
|
|
372
|
+
return dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
it("skips unmentioned group messages when requireMention=true", async () => {
|
|
376
|
+
await expectSkippedGroupMessage();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("fails closed when requireMention=true but mention detection is unavailable", async () => {
|
|
380
|
+
await expectSkippedGroupMessage({
|
|
381
|
+
canResolveExplicitMention: false,
|
|
382
|
+
hasAnyMention: false,
|
|
383
|
+
wasExplicitlyMentioned: false,
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("dispatches explicitly-mentioned group messages and marks WasMentioned", async () => {
|
|
388
|
+
const callArg = await dispatchGroupMessage({
|
|
389
|
+
commandAuthorized: false,
|
|
390
|
+
message: {
|
|
391
|
+
hasAnyMention: true,
|
|
392
|
+
wasExplicitlyMentioned: true,
|
|
393
|
+
content: "ping @bot",
|
|
394
|
+
},
|
|
395
|
+
});
|
|
283
396
|
expect(callArg?.ctx?.WasMentioned).toBe(true);
|
|
284
397
|
expect(callArg?.ctx?.To).toBe("zalouser:group:g-1");
|
|
285
398
|
expect(callArg?.ctx?.OriginatingTo).toBe("zalouser:group:g-1");
|
|
@@ -290,22 +403,14 @@ describe("zalouser monitor group mention gating", () => {
|
|
|
290
403
|
});
|
|
291
404
|
|
|
292
405
|
it("allows authorized control commands to bypass mention gating", async () => {
|
|
293
|
-
const
|
|
406
|
+
const callArg = await dispatchGroupMessage({
|
|
294
407
|
commandAuthorized: true,
|
|
295
|
-
|
|
296
|
-
await __testing.processMessage({
|
|
297
|
-
message: createGroupMessage({
|
|
408
|
+
message: {
|
|
298
409
|
content: "/status",
|
|
299
410
|
hasAnyMention: false,
|
|
300
411
|
wasExplicitlyMentioned: false,
|
|
301
|
-
}
|
|
302
|
-
account: createAccount(),
|
|
303
|
-
config: createConfig(),
|
|
304
|
-
runtime: createRuntimeEnv(),
|
|
412
|
+
},
|
|
305
413
|
});
|
|
306
|
-
|
|
307
|
-
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
|
308
|
-
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
|
309
414
|
expect(callArg?.ctx?.WasMentioned).toBe(true);
|
|
310
415
|
});
|
|
311
416
|
|
|
@@ -346,57 +451,30 @@ describe("zalouser monitor group mention gating", () => {
|
|
|
346
451
|
});
|
|
347
452
|
|
|
348
453
|
it("uses commandContent for mention-prefixed control commands", async () => {
|
|
349
|
-
const
|
|
454
|
+
const callArg = await dispatchGroupMessage({
|
|
350
455
|
commandAuthorized: true,
|
|
351
|
-
|
|
352
|
-
await __testing.processMessage({
|
|
353
|
-
message: createGroupMessage({
|
|
456
|
+
message: {
|
|
354
457
|
content: "@Bot /new",
|
|
355
458
|
commandContent: "/new",
|
|
356
459
|
hasAnyMention: true,
|
|
357
460
|
wasExplicitlyMentioned: true,
|
|
358
|
-
}
|
|
359
|
-
account: createAccount(),
|
|
360
|
-
config: createConfig(),
|
|
361
|
-
runtime: createRuntimeEnv(),
|
|
461
|
+
},
|
|
362
462
|
});
|
|
363
|
-
|
|
364
|
-
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
|
365
|
-
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
|
366
463
|
expect(callArg?.ctx?.CommandBody).toBe("/new");
|
|
367
464
|
expect(callArg?.ctx?.BodyForCommands).toBe("/new");
|
|
368
465
|
});
|
|
369
466
|
|
|
370
467
|
it("allows group control commands when only allowFrom is configured", async () => {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
});
|
|
376
|
-
await __testing.processMessage({
|
|
377
|
-
message: createGroupMessage({
|
|
378
|
-
content: "/new",
|
|
379
|
-
commandContent: "/new",
|
|
380
|
-
hasAnyMention: true,
|
|
381
|
-
wasExplicitlyMentioned: true,
|
|
382
|
-
}),
|
|
383
|
-
account: {
|
|
384
|
-
...createAccount(),
|
|
385
|
-
config: {
|
|
386
|
-
...createAccount().config,
|
|
387
|
-
allowFrom: ["123"],
|
|
388
|
-
},
|
|
468
|
+
await expectGroupCommandAuthorizers({
|
|
469
|
+
accountConfig: {
|
|
470
|
+
...createAccount().config,
|
|
471
|
+
allowFrom: ["123"],
|
|
389
472
|
},
|
|
390
|
-
|
|
391
|
-
|
|
473
|
+
expectedAuthorizers: [
|
|
474
|
+
{ configured: true, allowed: true },
|
|
475
|
+
{ configured: true, allowed: true },
|
|
476
|
+
],
|
|
392
477
|
});
|
|
393
|
-
|
|
394
|
-
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
|
395
|
-
const authCall = resolveCommandAuthorizedFromAuthorizers.mock.calls[0]?.[0];
|
|
396
|
-
expect(authCall?.authorizers).toEqual([
|
|
397
|
-
{ configured: true, allowed: true },
|
|
398
|
-
{ configured: true, allowed: true },
|
|
399
|
-
]);
|
|
400
478
|
});
|
|
401
479
|
|
|
402
480
|
it("blocks group messages when sender is not in groupAllowFrom/allowFrom", async () => {
|
|
@@ -425,123 +503,35 @@ describe("zalouser monitor group mention gating", () => {
|
|
|
425
503
|
});
|
|
426
504
|
|
|
427
505
|
it("does not accept a different group id by matching only the mutable group name by default", async () => {
|
|
428
|
-
|
|
429
|
-
commandAuthorized: false,
|
|
430
|
-
});
|
|
431
|
-
await __testing.processMessage({
|
|
432
|
-
message: createGroupMessage({
|
|
433
|
-
threadId: "g-attacker-001",
|
|
434
|
-
groupName: "Trusted Team",
|
|
435
|
-
senderId: "666",
|
|
436
|
-
hasAnyMention: true,
|
|
437
|
-
wasExplicitlyMentioned: true,
|
|
438
|
-
content: "ping @bot",
|
|
439
|
-
}),
|
|
440
|
-
account: {
|
|
441
|
-
...createAccount(),
|
|
442
|
-
config: {
|
|
443
|
-
...createAccount().config,
|
|
444
|
-
groupPolicy: "allowlist",
|
|
445
|
-
groupAllowFrom: ["*"],
|
|
446
|
-
groups: {
|
|
447
|
-
"group:g-trusted-001": { allow: true },
|
|
448
|
-
"Trusted Team": { allow: true },
|
|
449
|
-
},
|
|
450
|
-
},
|
|
451
|
-
},
|
|
452
|
-
config: createConfig(),
|
|
453
|
-
runtime: createRuntimeEnv(),
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
|
506
|
+
await expectDangerousNameMatching({ expectedDispatches: 0 });
|
|
457
507
|
});
|
|
458
508
|
|
|
459
509
|
it("accepts mutable group-name matches only when dangerouslyAllowNameMatching is enabled", async () => {
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
await __testing.processMessage({
|
|
464
|
-
message: createGroupMessage({
|
|
465
|
-
threadId: "g-attacker-001",
|
|
466
|
-
groupName: "Trusted Team",
|
|
467
|
-
senderId: "666",
|
|
468
|
-
hasAnyMention: true,
|
|
469
|
-
wasExplicitlyMentioned: true,
|
|
470
|
-
content: "ping @bot",
|
|
471
|
-
}),
|
|
472
|
-
account: {
|
|
473
|
-
...createAccount(),
|
|
474
|
-
config: {
|
|
475
|
-
...createAccount().config,
|
|
476
|
-
dangerouslyAllowNameMatching: true,
|
|
477
|
-
groupPolicy: "allowlist",
|
|
478
|
-
groupAllowFrom: ["*"],
|
|
479
|
-
groups: {
|
|
480
|
-
"group:g-trusted-001": { allow: true },
|
|
481
|
-
"Trusted Team": { allow: true },
|
|
482
|
-
},
|
|
483
|
-
},
|
|
484
|
-
},
|
|
485
|
-
config: createConfig(),
|
|
486
|
-
runtime: createRuntimeEnv(),
|
|
510
|
+
const dispatchReplyWithBufferedBlockDispatcher = await expectDangerousNameMatching({
|
|
511
|
+
dangerouslyAllowNameMatching: true,
|
|
512
|
+
expectedDispatches: 1,
|
|
487
513
|
});
|
|
488
|
-
|
|
489
|
-
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
|
490
514
|
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
|
491
515
|
expect(callArg?.ctx?.To).toBe("zalouser:group:g-attacker-001");
|
|
492
516
|
});
|
|
493
517
|
|
|
494
518
|
it("allows group control commands when sender is in groupAllowFrom", async () => {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
await __testing.processMessage({
|
|
501
|
-
message: createGroupMessage({
|
|
502
|
-
content: "/new",
|
|
503
|
-
commandContent: "/new",
|
|
504
|
-
hasAnyMention: true,
|
|
505
|
-
wasExplicitlyMentioned: true,
|
|
506
|
-
}),
|
|
507
|
-
account: {
|
|
508
|
-
...createAccount(),
|
|
509
|
-
config: {
|
|
510
|
-
...createAccount().config,
|
|
511
|
-
allowFrom: ["999"],
|
|
512
|
-
groupAllowFrom: ["123"],
|
|
513
|
-
},
|
|
519
|
+
await expectGroupCommandAuthorizers({
|
|
520
|
+
accountConfig: {
|
|
521
|
+
...createAccount().config,
|
|
522
|
+
allowFrom: ["999"],
|
|
523
|
+
groupAllowFrom: ["123"],
|
|
514
524
|
},
|
|
515
|
-
|
|
516
|
-
|
|
525
|
+
expectedAuthorizers: [
|
|
526
|
+
{ configured: true, allowed: false },
|
|
527
|
+
{ configured: true, allowed: true },
|
|
528
|
+
],
|
|
517
529
|
});
|
|
518
|
-
|
|
519
|
-
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
|
520
|
-
const authCall = resolveCommandAuthorizedFromAuthorizers.mock.calls[0]?.[0];
|
|
521
|
-
expect(authCall?.authorizers).toEqual([
|
|
522
|
-
{ configured: true, allowed: false },
|
|
523
|
-
{ configured: true, allowed: true },
|
|
524
|
-
]);
|
|
525
530
|
});
|
|
526
531
|
|
|
527
532
|
it("routes DM messages with direct peer kind", async () => {
|
|
528
533
|
const { dispatchReplyWithBufferedBlockDispatcher, resolveAgentRoute, buildAgentSessionKey } =
|
|
529
|
-
|
|
530
|
-
commandAuthorized: false,
|
|
531
|
-
});
|
|
532
|
-
const account = createAccount();
|
|
533
|
-
await __testing.processMessage({
|
|
534
|
-
message: createDmMessage(),
|
|
535
|
-
account: {
|
|
536
|
-
...account,
|
|
537
|
-
config: {
|
|
538
|
-
...account.config,
|
|
539
|
-
dmPolicy: "open",
|
|
540
|
-
},
|
|
541
|
-
},
|
|
542
|
-
config: createConfig(),
|
|
543
|
-
runtime: createRuntimeEnv(),
|
|
544
|
-
});
|
|
534
|
+
await processOpenDmMessage();
|
|
545
535
|
|
|
546
536
|
expect(resolveAgentRoute).toHaveBeenCalledWith(
|
|
547
537
|
expect.objectContaining({
|
|
@@ -559,24 +549,9 @@ describe("zalouser monitor group mention gating", () => {
|
|
|
559
549
|
});
|
|
560
550
|
|
|
561
551
|
it("reuses the legacy DM session key when only the old group-shaped session exists", async () => {
|
|
562
|
-
const { dispatchReplyWithBufferedBlockDispatcher
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
readSessionUpdatedAt.mockImplementation((input?: { storePath: string; sessionKey: string }) =>
|
|
566
|
-
input?.sessionKey === "agent:main:zalouser:group:321" ? 123 : undefined,
|
|
567
|
-
);
|
|
568
|
-
const account = createAccount();
|
|
569
|
-
await __testing.processMessage({
|
|
570
|
-
message: createDmMessage(),
|
|
571
|
-
account: {
|
|
572
|
-
...account,
|
|
573
|
-
config: {
|
|
574
|
-
...account.config,
|
|
575
|
-
dmPolicy: "open",
|
|
576
|
-
},
|
|
577
|
-
},
|
|
578
|
-
config: createConfig(),
|
|
579
|
-
runtime: createRuntimeEnv(),
|
|
552
|
+
const { dispatchReplyWithBufferedBlockDispatcher } = await processOpenDmMessage({
|
|
553
|
+
readSessionUpdatedAt: (input?: { storePath: string; sessionKey: string }) =>
|
|
554
|
+
input?.sessionKey === "agent:main:zalouser:group:321" ? 123 : undefined,
|
|
580
555
|
});
|
|
581
556
|
|
|
582
557
|
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
package/src/monitor.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
summarizeMapping,
|
|
32
32
|
warnMissingProviderGroupPolicyFallbackOnce,
|
|
33
33
|
} from "openclaw/plugin-sdk/zalouser";
|
|
34
|
+
import { createDeferred } from "../../shared/deferred.js";
|
|
34
35
|
import {
|
|
35
36
|
buildZalouserGroupCandidates,
|
|
36
37
|
findZalouserGroupEntry,
|
|
@@ -129,16 +130,6 @@ function resolveInboundQueueKey(message: ZaloInboundMessage): string {
|
|
|
129
130
|
return `direct:${senderId || threadId}`;
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
function createDeferred<T>() {
|
|
133
|
-
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
134
|
-
let reject!: (reason?: unknown) => void;
|
|
135
|
-
const promise = new Promise<T>((res, rej) => {
|
|
136
|
-
resolve = res;
|
|
137
|
-
reject = rej;
|
|
138
|
-
});
|
|
139
|
-
return { promise, resolve, reject };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
133
|
function resolveZalouserDmSessionScope(config: OpenClawConfig) {
|
|
143
134
|
const configured = config.session?.dmScope;
|
|
144
135
|
return configured === "main" || !configured ? "per-channel-peer" : configured;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { expectOpenDmPolicyConfigIssue } from "../../test-utils/status-issues.js";
|
|
2
3
|
import { collectZalouserStatusIssues } from "./status-issues.js";
|
|
3
4
|
|
|
4
5
|
describe("collectZalouserStatusIssues", () => {
|
|
@@ -17,16 +18,15 @@ describe("collectZalouserStatusIssues", () => {
|
|
|
17
18
|
});
|
|
18
19
|
|
|
19
20
|
it("warns when dmPolicy is open", () => {
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
expectOpenDmPolicyConfigIssue({
|
|
22
|
+
collectIssues: collectZalouserStatusIssues,
|
|
23
|
+
account: {
|
|
22
24
|
accountId: "default",
|
|
23
25
|
enabled: true,
|
|
24
26
|
configured: true,
|
|
25
27
|
dmPolicy: "open",
|
|
26
28
|
},
|
|
27
|
-
|
|
28
|
-
expect(issues).toHaveLength(1);
|
|
29
|
-
expect(issues[0]?.kind).toBe("config");
|
|
29
|
+
});
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
it("skips disabled accounts", () => {
|
package/src/status-issues.ts
CHANGED
|
@@ -1,42 +1,24 @@
|
|
|
1
1
|
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "openclaw/plugin-sdk/zalouser";
|
|
2
|
+
import { coerceStatusIssueAccountId, readStatusIssueFields } from "../../shared/status-issues.js";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
accountId
|
|
5
|
-
enabled
|
|
6
|
-
configured
|
|
7
|
-
dmPolicy
|
|
8
|
-
lastError
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
12
|
-
Boolean(value && typeof value === "object");
|
|
13
|
-
|
|
14
|
-
const asString = (value: unknown): string | undefined =>
|
|
15
|
-
typeof value === "string" ? value : typeof value === "number" ? String(value) : undefined;
|
|
16
|
-
|
|
17
|
-
function readZalouserAccountStatus(value: ChannelAccountSnapshot): ZalouserAccountStatus | null {
|
|
18
|
-
if (!isRecord(value)) {
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
return {
|
|
22
|
-
accountId: value.accountId,
|
|
23
|
-
enabled: value.enabled,
|
|
24
|
-
configured: value.configured,
|
|
25
|
-
dmPolicy: value.dmPolicy,
|
|
26
|
-
lastError: value.lastError,
|
|
27
|
-
};
|
|
28
|
-
}
|
|
4
|
+
const ZALOUSER_STATUS_FIELDS = [
|
|
5
|
+
"accountId",
|
|
6
|
+
"enabled",
|
|
7
|
+
"configured",
|
|
8
|
+
"dmPolicy",
|
|
9
|
+
"lastError",
|
|
10
|
+
] as const;
|
|
29
11
|
|
|
30
12
|
export function collectZalouserStatusIssues(
|
|
31
13
|
accounts: ChannelAccountSnapshot[],
|
|
32
14
|
): ChannelStatusIssue[] {
|
|
33
15
|
const issues: ChannelStatusIssue[] = [];
|
|
34
16
|
for (const entry of accounts) {
|
|
35
|
-
const account =
|
|
17
|
+
const account = readStatusIssueFields(entry, ZALOUSER_STATUS_FIELDS);
|
|
36
18
|
if (!account) {
|
|
37
19
|
continue;
|
|
38
20
|
}
|
|
39
|
-
const accountId =
|
|
21
|
+
const accountId = coerceStatusIssueAccountId(account.accountId) ?? "default";
|
|
40
22
|
const enabled = account.enabled !== false;
|
|
41
23
|
if (!enabled) {
|
|
42
24
|
continue;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RuntimeEnv } from "openclaw/plugin-sdk/zalouser";
|
|
2
|
+
import type { ResolvedZalouserAccount } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export function createZalouserRuntimeEnv(): RuntimeEnv {
|
|
5
|
+
return {
|
|
6
|
+
log: () => {},
|
|
7
|
+
error: () => {},
|
|
8
|
+
exit: ((code: number): never => {
|
|
9
|
+
throw new Error(`exit ${code}`);
|
|
10
|
+
}) as RuntimeEnv["exit"],
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createDefaultResolvedZalouserAccount(
|
|
15
|
+
overrides: Partial<ResolvedZalouserAccount> = {},
|
|
16
|
+
): ResolvedZalouserAccount {
|
|
17
|
+
return {
|
|
18
|
+
accountId: "default",
|
|
19
|
+
profile: "default",
|
|
20
|
+
name: "test",
|
|
21
|
+
enabled: true,
|
|
22
|
+
authenticated: true,
|
|
23
|
+
config: {},
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|