From 99a3db6ba9373f72c8b0b9d3cd1f8fabc0752ace Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 21:53:18 +0000 Subject: [PATCH] fix(zalouser): enforce group mention gating and typing --- extensions/zalouser/src/channel.test.ts | 23 ++ extensions/zalouser/src/channel.ts | 25 ++- extensions/zalouser/src/config-schema.ts | 1 + .../src/monitor.account-scope.test.ts | 2 + .../zalouser/src/monitor.group-gating.test.ts | 210 ++++++++++++++++++ extensions/zalouser/src/monitor.ts | 86 ++++++- extensions/zalouser/src/send.test.ts | 21 +- extensions/zalouser/src/send.ts | 9 +- extensions/zalouser/src/types.ts | 5 + extensions/zalouser/src/zalo-js.ts | 51 ++++- 10 files changed, 419 insertions(+), 14 deletions(-) create mode 100644 extensions/zalouser/src/monitor.group-gating.test.ts diff --git a/extensions/zalouser/src/channel.test.ts b/extensions/zalouser/src/channel.test.ts index 726577dda29..fead528730b 100644 --- a/extensions/zalouser/src/channel.test.ts +++ b/extensions/zalouser/src/channel.test.ts @@ -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"); diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index ef0c5dc97b8..f92a174d644 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -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 = { }, }, groups: { - resolveRequireMention: () => true, + resolveRequireMention: resolveZalouserRequireMention, resolveToolPolicy: resolveZalouserGroupToolPolicy, }, threading: { diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts index 2e060ff0052..795c5b6da42 100644 --- a/extensions/zalouser/src/config-schema.ts +++ b/extensions/zalouser/src/config-schema.ts @@ -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, }); diff --git a/extensions/zalouser/src/monitor.account-scope.test.ts b/extensions/zalouser/src/monitor.account-scope.test.ts index 1a075d05318..1f1ff598e74 100644 --- a/extensions/zalouser/src/monitor.account-scope.test.ts +++ b/extensions/zalouser/src/monitor.account-scope.test.ts @@ -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", () => { diff --git a/extensions/zalouser/src/monitor.group-gating.test.ts b/extensions/zalouser/src/monitor.group-gating.test.ts new file mode 100644 index 00000000000..7e7ddf80ab8 --- /dev/null +++ b/extensions/zalouser/src/monitor.group-gating.test.ts @@ -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 { + 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); + }); +}); diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index e4b4ec8bb26..7912fcc6ceb 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -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; + groups: Record; }): 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; +}): 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 }, diff --git a/extensions/zalouser/src/send.test.ts b/extensions/zalouser/src/send.test.ts index 4a379365559..7b4fbbe7240 100644 --- a/extensions/zalouser/src/send.test.ts +++ b/extensions/zalouser/src/send.test.ts @@ -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, + }); + }); }); diff --git a/extensions/zalouser/src/send.ts b/extensions/zalouser/src/send.ts index 1608c707e3f..b8a1ddf1bc4 100644 --- a/extensions/zalouser/src/send.ts +++ b/extensions/zalouser/src/send.ts @@ -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 { return await sendZaloLink(threadId, url, options); } + +export async function sendTypingZalouser( + threadId: string, + options: Pick = {}, +): Promise { + await sendZaloTypingEvent(threadId, options); +} diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts index e9f7ae71a23..d22626b27fd 100644 --- a/extensions/zalouser/src/types.ts +++ b/extensions/zalouser/src/types.ts @@ -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; }; diff --git a/extensions/zalouser/src/zalo-js.ts b/extensions/zalouser/src/zalo-js.ts index ec8d3b6e2df..feae4c346f1 100644 --- a/extensions/zalouser/src/zalo-js.ts +++ b/extensions/zalouser/src/zalo-js.ts @@ -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; 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 = {}, +): Promise { + 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; }