fix(zalouser): enforce group mention gating and typing

This commit is contained in:
Peter Steinberger
2026-03-02 21:53:18 +00:00
parent e5597a8dd4
commit 99a3db6ba9
10 changed files with 419 additions and 14 deletions

View File

@@ -18,6 +18,29 @@ describe("zalouser outbound chunker", () => {
});
describe("zalouser channel policies", () => {
it("resolves requireMention from group config", () => {
const resolveRequireMention = zalouserPlugin.groups?.resolveRequireMention;
expect(resolveRequireMention).toBeTypeOf("function");
if (!resolveRequireMention) {
return;
}
const requireMention = resolveRequireMention({
cfg: {
channels: {
zalouser: {
groups: {
"123": { requireMention: false },
},
},
},
},
accountId: "default",
groupId: "123",
groupChannel: "123",
});
expect(requireMention).toBe(false);
});
it("resolves group tool policy by explicit group id", () => {
const resolveToolPolicy = zalouserPlugin.groups?.resolveToolPolicy;
expect(resolveToolPolicy).toBeTypeOf("function");

View File

@@ -135,6 +135,27 @@ function resolveZalouserGroupToolPolicy(
return undefined;
}
function resolveZalouserRequireMention(params: ChannelGroupContext): boolean {
const account = resolveZalouserAccountSync({
cfg: params.cfg,
accountId: params.accountId ?? undefined,
});
const groups = account.config.groups ?? {};
const candidates = [params.groupId?.trim(), params.groupChannel?.trim()].filter(
(value): value is string => Boolean(value),
);
for (const candidate of candidates) {
const entry = groups[candidate];
if (typeof entry?.requireMention === "boolean") {
return entry.requireMention;
}
}
if (typeof groups["*"]?.requireMention === "boolean") {
return groups["*"].requireMention;
}
return true;
}
export const zalouserDock: ChannelDock = {
id: "zalouser",
capabilities: {
@@ -152,7 +173,7 @@ export const zalouserDock: ChannelDock = {
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }),
},
groups: {
resolveRequireMention: () => true,
resolveRequireMention: resolveZalouserRequireMention,
resolveToolPolicy: resolveZalouserGroupToolPolicy,
},
threading: {
@@ -235,7 +256,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
},
},
groups: {
resolveRequireMention: () => true,
resolveRequireMention: resolveZalouserRequireMention,
resolveToolPolicy: resolveZalouserGroupToolPolicy,
},
threading: {

View File

@@ -6,6 +6,7 @@ const allowFromEntry = z.union([z.string(), z.number()]);
const groupConfigSchema = z.object({
allow: z.boolean().optional(),
enabled: z.boolean().optional(),
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
});

View File

@@ -5,9 +5,11 @@ import { setZalouserRuntime } from "./runtime.js";
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
const sendMessageZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
const sendTypingZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("./send.js", () => ({
sendMessageZalouser: sendMessageZalouserMock,
sendTypingZalouser: sendTypingZalouserMock,
}));
describe("zalouser monitor pairing account scoping", () => {

View File

@@ -0,0 +1,210 @@
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { __testing } from "./monitor.js";
import { setZalouserRuntime } from "./runtime.js";
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
const sendMessageZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
const sendTypingZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("./send.js", () => ({
sendMessageZalouser: sendMessageZalouserMock,
sendTypingZalouser: sendTypingZalouserMock,
}));
function createAccount(): ResolvedZalouserAccount {
return {
accountId: "default",
enabled: true,
profile: "default",
authenticated: true,
config: {
groupPolicy: "open",
groups: {
"*": { requireMention: true },
},
},
};
}
function createConfig(): OpenClawConfig {
return {
channels: {
zalouser: {
enabled: true,
groups: {
"*": { requireMention: true },
},
},
},
};
}
function createRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: ((code: number): never => {
throw new Error(`exit ${code}`);
}) as RuntimeEnv["exit"],
};
}
function installRuntime(params: { commandAuthorized: boolean }) {
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions, ctx }) => {
await dispatcherOptions.typingCallbacks?.onReplyStart?.();
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 }, ctx };
});
setZalouserRuntime({
logging: {
shouldLogVerbose: () => false,
},
channel: {
pairing: {
readAllowFromStore: vi.fn(async () => []),
upsertPairingRequest: vi.fn(async () => ({ code: "PAIR", created: true })),
buildPairingReply: vi.fn(() => "pair"),
},
commands: {
shouldComputeCommandAuthorized: vi.fn((body: string) => body.trim().startsWith("/")),
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => params.commandAuthorized),
isControlCommandMessage: vi.fn((body: string) => body.trim().startsWith("/")),
shouldHandleTextCommands: vi.fn(() => true),
},
mentions: {
buildMentionRegexes: vi.fn(() => []),
matchesMentionWithExplicit: vi.fn(
(input) => input.explicit?.isExplicitlyMentioned === true,
),
},
groups: {
resolveRequireMention: vi.fn((input) => {
const cfg = input.cfg as OpenClawConfig;
const groupCfg = cfg.channels?.zalouser?.groups ?? {};
const groupEntry = input.groupId ? groupCfg[input.groupId] : undefined;
const defaultEntry = groupCfg["*"];
if (typeof groupEntry?.requireMention === "boolean") {
return groupEntry.requireMention;
}
if (typeof defaultEntry?.requireMention === "boolean") {
return defaultEntry.requireMention;
}
return true;
}),
},
routing: {
resolveAgentRoute: vi.fn(() => ({
agentId: "main",
sessionKey: "agent:main:zalouser:group:1",
accountId: "default",
mainSessionKey: "agent:main:main",
})),
},
session: {
resolveStorePath: vi.fn(() => "/tmp"),
readSessionUpdatedAt: vi.fn(() => undefined),
recordInboundSession: vi.fn(async () => {}),
},
reply: {
resolveEnvelopeFormatOptions: vi.fn(() => undefined),
formatAgentEnvelope: vi.fn(({ body }) => body),
finalizeInboundContext: vi.fn((ctx) => ctx),
dispatchReplyWithBufferedBlockDispatcher,
},
text: {
resolveMarkdownTableMode: vi.fn(() => "code"),
convertMarkdownTables: vi.fn((text: string) => text),
resolveChunkMode: vi.fn(() => "line"),
chunkMarkdownTextWithMode: vi.fn((text: string) => [text]),
},
},
} as unknown as PluginRuntime);
return { dispatchReplyWithBufferedBlockDispatcher };
}
function createGroupMessage(overrides: Partial<ZaloInboundMessage> = {}): ZaloInboundMessage {
return {
threadId: "g-1",
isGroup: true,
senderId: "123",
senderName: "Alice",
groupName: "Team",
content: "hello",
timestampMs: Date.now(),
msgId: "m-1",
hasAnyMention: false,
wasExplicitlyMentioned: false,
canResolveExplicitMention: true,
implicitMention: false,
raw: { source: "test" },
...overrides,
};
}
describe("zalouser monitor group mention gating", () => {
beforeEach(() => {
sendMessageZalouserMock.mockClear();
sendTypingZalouserMock.mockClear();
});
it("skips unmentioned group messages when requireMention=true", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
await __testing.processMessage({
message: createGroupMessage(),
account: createAccount(),
config: createConfig(),
runtime: createRuntimeEnv(),
});
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(sendTypingZalouserMock).not.toHaveBeenCalled();
});
it("dispatches explicitly-mentioned group messages and marks WasMentioned", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
await __testing.processMessage({
message: createGroupMessage({
hasAnyMention: true,
wasExplicitlyMentioned: true,
content: "ping @bot",
}),
account: createAccount(),
config: createConfig(),
runtime: createRuntimeEnv(),
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
expect(callArg?.ctx?.WasMentioned).toBe(true);
expect(sendTypingZalouserMock).toHaveBeenCalledWith("g-1", {
profile: "default",
isGroup: true,
});
});
it("allows authorized control commands to bypass mention gating", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: true,
});
await __testing.processMessage({
message: createGroupMessage({
content: "/status",
hasAnyMention: false,
wasExplicitlyMentioned: false,
}),
account: createAccount(),
config: createConfig(),
runtime: createRuntimeEnv(),
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
expect(callArg?.ctx?.WasMentioned).toBe(true);
});
});

View File

@@ -5,10 +5,12 @@ import type {
RuntimeEnv,
} from "openclaw/plugin-sdk";
import {
createTypingCallbacks,
createScopedPairingAccess,
createReplyPrefixOptions,
resolveOutboundMediaUrls,
mergeAllowlist,
resolveMentionGatingWithBypass,
resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveSenderCommandAuthorization,
@@ -17,7 +19,7 @@ import {
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk";
import { getZalouserRuntime } from "./runtime.js";
import { sendMessageZalouser } from "./send.js";
import { sendMessageZalouser, sendTypingZalouser } from "./send.js";
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
import { listZaloFriends, listZaloGroups, startZaloListener } from "./zalo-js.js";
@@ -89,7 +91,7 @@ function normalizeGroupSlug(raw?: string | null): string {
function isGroupAllowed(params: {
groupId: string;
groupName?: string | null;
groups: Record<string, { allow?: boolean; enabled?: boolean }>;
groups: Record<string, { allow?: boolean; enabled?: boolean; requireMention?: boolean }>;
}): boolean {
const groups = params.groups ?? {};
const keys = Object.keys(groups);
@@ -116,6 +118,30 @@ function isGroupAllowed(params: {
return false;
}
function resolveGroupRequireMention(params: {
groupId: string;
groupName?: string | null;
groups: Record<string, { allow?: boolean; enabled?: boolean; requireMention?: boolean }>;
}): boolean {
const groups = params.groups ?? {};
const candidates = [
params.groupId,
`group:${params.groupId}`,
params.groupName ?? "",
normalizeGroupSlug(params.groupName ?? ""),
].filter(Boolean);
for (const candidate of candidates) {
const entry = groups[candidate];
if (typeof entry?.requireMention === "boolean") {
return entry.requireMention;
}
}
if (typeof groups["*"]?.requireMention === "boolean") {
return groups["*"].requireMention;
}
return true;
}
async function processMessage(
message: ZaloInboundMessage,
account: ResolvedZalouserAccount,
@@ -238,11 +264,8 @@ async function processMessage(
}
}
if (
isGroup &&
core.channel.commands.isControlCommandMessage(rawBody, config) &&
commandAuthorized !== true
) {
const hasControlCommand = core.channel.commands.isControlCommandMessage(rawBody, config);
if (isGroup && hasControlCommand && commandAuthorized !== true) {
logVerbose(
core,
runtime,
@@ -266,6 +289,42 @@ async function processMessage(
},
});
const requireMention = isGroup
? resolveGroupRequireMention({
groupId: chatId,
groupName,
groups,
})
: false;
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
const explicitMention = {
hasAnyMention: message.hasAnyMention === true,
isExplicitlyMentioned: message.wasExplicitlyMentioned === true,
canResolveExplicit: message.canResolveExplicitMention === true,
};
const wasMentioned = isGroup
? core.channel.mentions.matchesMentionWithExplicit({
text: rawBody,
mentionRegexes,
explicit: explicitMention,
})
: true;
const mentionGate = resolveMentionGatingWithBypass({
isGroup,
requireMention,
canDetectMention: mentionRegexes.length > 0 || explicitMention.canResolveExplicit,
wasMentioned,
implicitMention: message.implicitMention === true,
hasAnyMention: explicitMention.hasAnyMention,
allowTextCommands: core.channel.commands.shouldHandleTextCommands(config),
hasControlCommand,
commandAuthorized: commandAuthorized === true,
});
if (isGroup && mentionGate.shouldSkip) {
logVerbose(core, runtime, `zalouser: skip group ${chatId} (mention required, not mentioned)`);
return;
}
const fromLabel = isGroup ? groupName || `group:${chatId}` : senderName || `user:${senderId}`;
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
@@ -297,6 +356,7 @@ async function processMessage(
ConversationLabel: fromLabel,
SenderName: senderName || undefined,
SenderId: senderId,
WasMentioned: isGroup ? mentionGate.effectiveWasMentioned : undefined,
CommandAuthorized: commandAuthorized,
Provider: "zalouser",
Surface: "zalouser",
@@ -320,12 +380,24 @@ async function processMessage(
channel: "zalouser",
accountId: account.accountId,
});
const typingCallbacks = createTypingCallbacks({
start: async () => {
await sendTypingZalouser(chatId, {
profile: account.profile,
isGroup,
});
},
onStartError: (err) => {
logVerbose(core, runtime, `zalouser typing failed for ${chatId}: ${String(err)}`);
},
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
...prefixOptions,
typingCallbacks,
deliver: async (payload) => {
await deliverZalouserReply({
payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string },

View File

@@ -1,19 +1,27 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { sendImageZalouser, sendLinkZalouser, sendMessageZalouser } from "./send.js";
import { sendZaloLink, sendZaloTextMessage } from "./zalo-js.js";
import {
sendImageZalouser,
sendLinkZalouser,
sendMessageZalouser,
sendTypingZalouser,
} from "./send.js";
import { sendZaloLink, sendZaloTextMessage, sendZaloTypingEvent } from "./zalo-js.js";
vi.mock("./zalo-js.js", () => ({
sendZaloTextMessage: vi.fn(),
sendZaloLink: vi.fn(),
sendZaloTypingEvent: vi.fn(),
}));
const mockSendText = vi.mocked(sendZaloTextMessage);
const mockSendLink = vi.mocked(sendZaloLink);
const mockSendTyping = vi.mocked(sendZaloTypingEvent);
describe("zalouser send helpers", () => {
beforeEach(() => {
mockSendText.mockReset();
mockSendLink.mockReset();
mockSendTyping.mockReset();
});
it("delegates text send to JS transport", async () => {
@@ -62,4 +70,13 @@ describe("zalouser send helpers", () => {
});
expect(result).toEqual({ ok: false, error: "boom" });
});
it("delegates typing helper to JS transport", async () => {
await sendTypingZalouser("thread-4", { profile: "p4", isGroup: true });
expect(mockSendTyping).toHaveBeenCalledWith("thread-4", {
profile: "p4",
isGroup: true,
});
});
});

View File

@@ -1,5 +1,5 @@
import type { ZaloSendOptions, ZaloSendResult } from "./types.js";
import { sendZaloLink, sendZaloTextMessage } from "./zalo-js.js";
import { sendZaloLink, sendZaloTextMessage, sendZaloTypingEvent } from "./zalo-js.js";
export type ZalouserSendOptions = ZaloSendOptions;
export type ZalouserSendResult = ZaloSendResult;
@@ -30,3 +30,10 @@ export async function sendLinkZalouser(
): Promise<ZalouserSendResult> {
return await sendZaloLink(threadId, url, options);
}
export async function sendTypingZalouser(
threadId: string,
options: Pick<ZalouserSendOptions, "profile" | "isGroup"> = {},
): Promise<void> {
await sendZaloTypingEvent(threadId, options);
}

View File

@@ -26,6 +26,10 @@ export type ZaloInboundMessage = {
timestampMs: number;
msgId?: string;
cliMsgId?: string;
hasAnyMention?: boolean;
wasExplicitlyMentioned?: boolean;
canResolveExplicitMention?: boolean;
implicitMention?: boolean;
raw: unknown;
};
@@ -59,6 +63,7 @@ type ZalouserToolConfig = { allow?: string[]; deny?: string[] };
type ZalouserGroupConfig = {
allow?: boolean;
enabled?: boolean;
requireMention?: boolean;
tools?: ZalouserToolConfig;
};

View File

@@ -165,6 +165,20 @@ function resolveInboundTimestamp(rawTs: unknown): number {
return parsed > 1_000_000_000_000 ? parsed : parsed * 1000;
}
function extractMentionIds(raw: unknown): string[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => {
if (!entry || typeof entry !== "object") {
return "";
}
return toNumberId((entry as { uid?: unknown }).uid);
})
.filter(Boolean);
}
function extractSendMessageId(result: unknown): string | undefined {
if (!result || typeof result !== "object") {
return undefined;
@@ -422,7 +436,7 @@ async function fetchGroupsByIds(api: API, ids: string[]): Promise<Map<string, Gr
return result;
}
function toInboundMessage(message: Message): ZaloInboundMessage | null {
function toInboundMessage(message: Message, ownUserId?: string): ZaloInboundMessage | null {
const data = message.data as Record<string, unknown>;
const isGroup = message.type === ThreadType.Group;
const senderId = toNumberId(data.uidFrom);
@@ -433,6 +447,20 @@ function toInboundMessage(message: Message): ZaloInboundMessage | null {
return null;
}
const content = normalizeMessageContent(data.content);
const normalizedOwnUserId = toNumberId(ownUserId);
const mentionIds = extractMentionIds(data.mentions);
const quoteOwnerId =
data.quote && typeof data.quote === "object"
? toNumberId((data.quote as { ownerId?: unknown }).ownerId)
: "";
const hasAnyMention = mentionIds.length > 0;
const canResolveExplicitMention = Boolean(normalizedOwnUserId);
const wasExplicitlyMentioned = Boolean(
normalizedOwnUserId && mentionIds.some((id) => id === normalizedOwnUserId),
);
const implicitMention = Boolean(
normalizedOwnUserId && quoteOwnerId && quoteOwnerId === normalizedOwnUserId,
);
return {
threadId,
isGroup,
@@ -442,6 +470,10 @@ function toInboundMessage(message: Message): ZaloInboundMessage | null {
timestampMs: resolveInboundTimestamp(data.ts),
msgId: typeof data.msgId === "string" ? data.msgId : undefined,
cliMsgId: typeof data.cliMsgId === "string" ? data.cliMsgId : undefined,
hasAnyMention,
canResolveExplicitMention,
wasExplicitlyMentioned,
implicitMention,
raw: message,
};
}
@@ -670,6 +702,20 @@ export async function sendZaloTextMessage(
}
}
export async function sendZaloTypingEvent(
threadId: string,
options: Pick<ZaloSendOptions, "profile" | "isGroup"> = {},
): Promise<void> {
const profile = normalizeProfile(options.profile);
const trimmedThreadId = threadId.trim();
if (!trimmedThreadId) {
throw new Error("No threadId provided");
}
const api = await ensureApi(profile);
const type = options.isGroup ? ThreadType.Group : ThreadType.User;
await api.sendTypingEvent(trimmedThreadId, type);
}
export async function sendZaloLink(
threadId: string,
url: string,
@@ -956,6 +1002,7 @@ export async function startZaloListener(params: {
}
const api = await ensureApi(profile);
const ownUserId = toNumberId(api.getOwnId());
let stopped = false;
const cleanup = () => {
@@ -982,7 +1029,7 @@ export async function startZaloListener(params: {
if (incoming.isSelf) {
return;
}
const normalized = toInboundMessage(incoming);
const normalized = toInboundMessage(incoming, ownUserId);
if (!normalized) {
return;
}