From ba9e9fec130c8cff178a36cdfb6cd320c1729cd8 Mon Sep 17 00:00:00 2001 From: Jacqueline Henriksen Date: Thu, 30 Jul 2026 04:05:34 +0100 Subject: [PATCH] fix: gate ClickClack group replies on mentions --- docs/channels/clickclack.md | 87 ++++++++-- extensions/clickclack/openclaw.plugin.json | 40 +++++ extensions/clickclack/src/access.ts | 62 +++++++ extensions/clickclack/src/accounts.test.ts | 8 + extensions/clickclack/src/accounts.ts | 25 ++- extensions/clickclack/src/config-schema.ts | 13 ++ extensions/clickclack/src/gateway.test.ts | 8 + extensions/clickclack/src/gateway.ts | 10 ++ .../clickclack/src/group-policy.test.ts | 133 +++++++++++++++ extensions/clickclack/src/group-policy.ts | 50 ++++++ extensions/clickclack/src/inbound.test.ts | 44 +++++ extensions/clickclack/src/inbound.ts | 5 +- .../clickclack/src/mention-facts.test.ts | 160 ++++++++++++++++++ extensions/clickclack/src/mention-facts.ts | 113 +++++++++++++ extensions/clickclack/src/types.ts | 15 ++ 15 files changed, 750 insertions(+), 23 deletions(-) create mode 100644 extensions/clickclack/src/group-policy.test.ts create mode 100644 extensions/clickclack/src/group-policy.ts create mode 100644 extensions/clickclack/src/mention-facts.test.ts create mode 100644 extensions/clickclack/src/mention-facts.ts diff --git a/docs/channels/clickclack.md b/docs/channels/clickclack.md index 472f6c85405a..e9c3f8faa98a 100644 --- a/docs/channels/clickclack.md +++ b/docs/channels/clickclack.md @@ -110,23 +110,26 @@ id (`wsp_...`), slug, or name; the gateway resolves it to the id at startup. ### Account config keys -| Key | Default | Notes | -| ----------------------- | ------------------- | --------------------------------------------------------------------------------------- | -| `baseUrl` | none (required) | Public ClickClack URL used for browser-facing links. | -| `apiBaseUrl` | `baseUrl` | Optional server-to-server endpoint for REST and realtime WebSocket traffic. | -| `token` | none | Bot token as a plain string or secret ref (`source: "env" \| "file" \| "exec"`). | -| `tokenFile` | none | Path to a bot-token file; takes precedence over `token`. | -| `workspace` | none (required) | Workspace id, slug, or name. | -| `replyMode` | `"agent"` | `"agent"` runs the full agent pipeline; `"model"` sends short direct model completions. | -| `defaultTo` | `"channel:general"` | Target used when an outbound path gives no target. | -| `allowFrom` | `["*"]` | User-id allowlist for inbound DMs and channel messages. | -| `botUserId` | auto-detected | Resolved from the bot token identity at startup. | -| `agentId` | route default | Pin this account's inbound messages to one agent. | -| `toolsAllow` | none | Tool allowlist for agent replies from this account. | -| `model`, `systemPrompt` | none | Used by `replyMode: "model"` completions. | -| `commandMenu` | `true` | Publish native commands to ClickClack composer autocomplete. | -| `reconnectMs` | `1500` | Realtime reconnect delay (100 to 60000). | -| `discussions` | disabled | Managed per-session channel settings; see [Session discussions](#session-discussions). | +| Key | Default | Notes | +| ----------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `baseUrl` | none (required) | Public ClickClack URL used for browser-facing links. | +| `apiBaseUrl` | `baseUrl` | Optional server-to-server endpoint for REST and realtime WebSocket traffic. | +| `token` | none | Bot token as a plain string or secret ref (`source: "env" \| "file" \| "exec"`). | +| `tokenFile` | none | Path to a bot-token file; takes precedence over `token`. | +| `workspace` | none (required) | Workspace id, slug, or name. | +| `replyMode` | `"agent"` | `"agent"` runs the full agent pipeline; `"model"` sends short direct model completions. | +| `defaultTo` | `"channel:general"` | Target used when an outbound path gives no target. | +| `allowFrom` | `["*"]` | User-id allowlist for inbound DMs and channel messages. | +| `botUserId` | auto-detected | Resolved from the bot token identity at startup. | +| `agentId` | route default | Pin this account's inbound messages to one agent. | +| `toolsAllow` | none | Tool allowlist for agent replies from this account. | +| `model`, `systemPrompt` | none | Used by `replyMode: "model"` completions. | +| `commandMenu` | `true` | Publish native commands to ClickClack composer autocomplete. | +| `reconnectMs` | `1500` | Realtime reconnect delay (100 to 60000). | +| `discussions` | disabled | Managed per-session channel settings; see [Session discussions](#session-discussions). | +| `requireMention` | `false` | Require a direct mention before dispatching group messages. See [Group mention gating](#group-mention-gating). | +| `mentionPatterns` | `[]` | Mention patterns for this account in group channels. See [Group mention gating](#group-mention-gating). | +| `groups` | `{}` | Per-channel group policy overrides keyed by ClickClack channel ID. See [Group mention gating](#group-mention-gating). | ### Keep an auth-gated public hostname @@ -417,6 +420,56 @@ Requirements and behavior: - Rows are grouped per turn (`turn_id`), coalesced so one logical step is one row, and tool rows use the same progress formatting as Discord/Slack/Telegram (tool name plus command detail). - **Attribution metadata.** Agent-authored posts (activity rows and the final reply) carry `author_model` and `author_thinking` fields resolved from the actual model used for the turn (including after fallback). Servers that do not define these columns ignore the unknown JSON fields; servers that persist them can answer "which model said this line, at which thinking level" per message. +## Group mention gating + +By default, every group message in ClickClack dispatches to every enabled ClickClack account in the same workspace. This behavior is backward compatible. Add `requireMention: true` to an account to require a direct mention before the agent pipeline runs. + +The effective policy is resolved in this order: + +1. Exact channel entry in `groups` (keyed by ClickClack channel ID). +2. Wildcard `"*"` entry in `groups`. +3. Account-level `requireMention` / `mentionPatterns`. +4. Backward-compatible default (`{ requireMention: false, mentionPatterns: [] }`). + +DMs are never gated by `requireMention`. When a DM arrives, the mention gate is skipped entirely. + +### Mention detection + +ClickClack mentions are detected when: + +- The message body matches any pattern in `mentionPatterns` (each pattern is a regular expression). +- The message contains a native ClickClack mention tag (`<@bot_user_id>`) and `botUserId` is configured or auto-detected. + +Plain display names (e.g. `Blackbird`) are **not** treated as mentions unless they are explicitly configured as a pattern. + +### Configuration example + +```json5 +{ + channels: { + clickclack: { + enabled: true, + token: { source: "env", provider: "default", id: "CLICKCLACK_BOT_TOKEN" }, + workspace: "default", + requireMention: true, + mentionPatterns: ["<@usr_abc>", "@mybot", "\\bBlackbird\\b"], + groups: { + "*": { requireMention: true }, + chn_command_and_control: { requireMention: false }, + }, + }, + }, +} +``` + +Multiple accounts in the same workspace evaluate the same message independently. Accounts with `requireMention: true` reject an unmentioned message while an account with `requireMention: false` may process it. + +### Migration warning + +ClickClack channel IDs (e.g. `chn_...`) are not automatically Discord channel IDs. Configuring per-channel rules requires the actual ClickClack channel identifier. Do not reuse Discord IDs unless the ClickClack server explicitly stores them as `external_ref` and the adapter has a documented translation layer. + +Adding `requireMention: true` without also restricting `allowFrom` will not silently change existing sender allowlist behavior for group messages; the mention gate is an additional guard on top of the existing sender policy. + ## Targets - `channel:` sends to a workspace channel. Bare targets default to `channel:`. diff --git a/extensions/clickclack/openclaw.plugin.json b/extensions/clickclack/openclaw.plugin.json index 9ff5dbfbeea8..d28b63a47547 100644 --- a/extensions/clickclack/openclaw.plugin.json +++ b/extensions/clickclack/openclaw.plugin.json @@ -4,6 +4,46 @@ "onStartup": false }, "channels": ["clickclack"], + "channelConfigs": { + "clickclack": { + "label": "ClickClack", + "description": "ClickClack channel accounts and group activation policy.", + "schema": { + "type": "object", + "additionalProperties": true, + "properties": { + "accounts": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true, + "properties": { + "requireMention": { "type": "boolean" }, + "mentionPatterns": { + "type": "array", + "items": { "type": "string" } + }, + "groups": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true, + "properties": { + "requireMention": { "type": "boolean" }, + "mentionPatterns": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + } + } + } + } + } + }, "contracts": { "tools": ["discussion"] }, diff --git a/extensions/clickclack/src/access.ts b/extensions/clickclack/src/access.ts index 7c2be53a3aad..4b245bbf9769 100644 --- a/extensions/clickclack/src/access.ts +++ b/extensions/clickclack/src/access.ts @@ -7,7 +7,11 @@ import { type StableChannelIngressIdentityParams, } from "openclaw/plugin-sdk/channel-ingress-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; +import { resolveClickClackGroupPolicy } from "./group-policy.js"; +import { resolveClickClackMentionFacts } from "./mention-facts.js"; import { getClickClackRuntime } from "./runtime.js"; +import { buildClickClackTarget } from "./target.js"; import type { ClickClackMessage, CoreConfig, ResolvedClickClackAccount } from "./types.js"; const CHANNEL_ID = "clickclack" as const; @@ -37,6 +41,13 @@ const clickClackIngressIdentity = { export type ClickClackInboundAccess = { shouldDispatch: boolean; commandAuthorized: boolean; + /** Whether the resolved group policy required a direct mention. */ + requireMention?: boolean; + mentionFacts: { + canDetectMention: boolean; + wasMentioned: boolean; + hasAnyMention?: boolean; + }; }; /** @@ -51,10 +62,48 @@ export async function resolveClickClackInboundAccess(params: { const runtime = getClickClackRuntime(); const isDirect = Boolean(params.message.direct_conversation_id); const cfg = params.config as OpenClawConfig; + const target = buildClickClackTarget( + isDirect + ? { chatType: "direct", kind: "dm", id: params.message.author_id } + : { chatType: "group", kind: "channel", id: params.message.channel_id ?? "" }, + ); + const route = runtime.channel.routing.resolveAgentRoute({ + cfg, + channel: CHANNEL_ID, + accountId: params.account.accountId, + peer: { + kind: isDirect ? "direct" : "channel", + id: target, + }, + }); + const agentId = normalizeAgentId(params.account.agentId ?? route.agentId); const shouldCheckCommand = runtime.channel.commands.shouldComputeCommandAuthorized( params.message.body, cfg, ); + + // Resolve group policy and mention facts for the channel. + const effectiveGroupPolicy = resolveClickClackGroupPolicy({ + account: params.account, + channelId: params.message.channel_id, + }); + const mentionFacts = resolveClickClackMentionFacts({ + isDirect, + body: params.message.body, + mentionPatterns: effectiveGroupPolicy.mentionPatterns, + botUserId: params.account.botUserId, + cfg, + agentId, + channelId: params.message.channel_id, + }); + const allowTextCommands = + params.account.replyMode === "agent" && + runtime.channel.commands.shouldHandleTextCommands({ + cfg, + surface: CHANNEL_ID, + commandSource: "text", + }); + const resolved = await resolveStableChannelMessageIngress({ channelId: CHANNEL_ID, accountId: params.account.accountId, @@ -70,6 +119,13 @@ export async function resolveClickClackInboundAccess(params: { allowFrom: params.account.allowFrom, dmPolicy: "allowlist", groupPolicy: "allowlist", + mentionFacts, + policy: { + activation: { + requireMention: effectiveGroupPolicy.requireMention, + allowTextCommands, + }, + }, command: shouldCheckCommand ? { cfg, @@ -83,5 +139,11 @@ export async function resolveClickClackInboundAccess(params: { commandAuthorized: resolved.commandAccess.requested ? resolved.commandAccess.authorized : resolved.senderAccess.allowed, + requireMention: effectiveGroupPolicy.requireMention, + mentionFacts: mentionFacts as { + canDetectMention: boolean; + wasMentioned: boolean; + hasAnyMention?: boolean; + }, }; } diff --git a/extensions/clickclack/src/accounts.test.ts b/extensions/clickclack/src/accounts.test.ts index a647e7a0d571..91dc689bdf2a 100644 --- a/extensions/clickclack/src/accounts.test.ts +++ b/extensions/clickclack/src/accounts.test.ts @@ -107,6 +107,7 @@ describe("ClickClack account resolution", () => { baseUrl: "https://app.clickclack.chat", enabled: true, token: { source: "env", provider: "default", id: "CLICKCLACK_SERVICE_TOKEN" }, + tokenFile: undefined, workspace: "wsp_1", }, configured: true, @@ -121,10 +122,13 @@ describe("ClickClack account resolution", () => { workspace: "wsp_1", section: "Sessions", }, + groups: {}, + mentionPatterns: [], model: undefined, name: undefined, reconnectMs: 1_500, replyMode: "agent", + requireMention: false, systemPrompt: undefined, token: "test-token-placeholder", toolsAllow: undefined, @@ -213,6 +217,7 @@ describe("ClickClack account resolution", () => { model: "openai/gpt-5.4-mini", replyMode: "model", token: "token-oversized", + tokenFile: undefined, toolsAllow: ["web_search"], workspace: "wsp_1", }, @@ -227,10 +232,13 @@ describe("ClickClack account resolution", () => { workspace: "wsp_1", section: "Sessions", }, + groups: {}, + mentionPatterns: [], model: "openai/gpt-5.4-mini", name: undefined, reconnectMs: 1_500, replyMode: "model", + requireMention: false, systemPrompt: undefined, token: "token-oversized", toolsAllow: ["web_search"], diff --git a/extensions/clickclack/src/accounts.ts b/extensions/clickclack/src/accounts.ts index 8aaf25f60358..33637cd1f9c0 100644 --- a/extensions/clickclack/src/accounts.ts +++ b/extensions/clickclack/src/accounts.ts @@ -17,7 +17,12 @@ import { resolveSecretInputString, } from "openclaw/plugin-sdk/secret-input"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import type { ClickClackAccountConfig, CoreConfig, ResolvedClickClackAccount } from "./types.js"; +import type { + ClickClackAccountConfig, + ClickClackGroupConfig, + CoreConfig, + ResolvedClickClackAccount, +} from "./types.js"; const DEFAULT_RECONNECT_MS = 1_500; const MIN_RECONNECT_MS = 100; @@ -31,7 +36,7 @@ const { } = createAccountListHelpers("clickclack", { normalizeAccountId, omitKeys: ["defaultAccount"], - nestedObjectKeys: ["discussions"], + nestedObjectKeys: ["discussions", "groups"], hasImplicitDefaultAccount: (cfg) => { const channel = cfg.channels?.clickclack; return Boolean( @@ -194,6 +199,22 @@ export function resolveClickClackAccount(params: { ...(controlUrlBase ? { controlUrlBase } : {}), section: merged.discussions?.section?.trim() || DEFAULT_DISCUSSIONS_SECTION, }, + requireMention: merged.requireMention === true, + mentionPatterns: merged.mentionPatterns ?? [], + groups: Object.entries(merged.groups ?? {}).reduce>( + (acc, [key, val]) => { + const normalizedKey = key.trim(); + if (!normalizedKey) { + return acc; + } + acc[normalizedKey] = { + ...(val.requireMention !== undefined ? { requireMention: val.requireMention } : {}), + ...(val.mentionPatterns !== undefined ? { mentionPatterns: val.mentionPatterns } : {}), + }; + return acc; + }, + {}, + ), config: { ...merged, allowFrom: merged.allowFrom ?? ["*"], diff --git a/extensions/clickclack/src/config-schema.ts b/extensions/clickclack/src/config-schema.ts index 8e4c50188ccb..d19e1781e2c8 100644 --- a/extensions/clickclack/src/config-schema.ts +++ b/extensions/clickclack/src/config-schema.ts @@ -28,6 +28,19 @@ const ClickClackAccountConfigSchema = z reconnectMs: z.number().int().min(100).max(60_000).optional(), agentActivity: z.boolean().optional(), commandMenu: z.boolean().optional(), + requireMention: z.boolean().optional(), + mentionPatterns: z.array(z.string()).optional(), + groups: z + .record( + z.string(), + z + .object({ + requireMention: z.boolean().optional(), + mentionPatterns: z.array(z.string()).optional(), + }) + .strict(), + ) + .optional(), discussions: z .object({ enabled: z.boolean().optional(), diff --git a/extensions/clickclack/src/gateway.test.ts b/extensions/clickclack/src/gateway.test.ts index 10ec354b340b..16039e1c4a31 100644 --- a/extensions/clickclack/src/gateway.test.ts +++ b/extensions/clickclack/src/gateway.test.ts @@ -473,6 +473,11 @@ describe("ClickClack gateway", () => { mocks.resolveClickClackInboundAccess.mockResolvedValue({ shouldDispatch: false, commandAuthorized: false, + mentionFacts: { + canDetectMention: true, + wasMentioned: false, + hasAnyMention: false, + }, }); const abort = new AbortController(); const ctx = createGatewayContext(abort.signal); @@ -486,6 +491,9 @@ describe("ClickClack gateway", () => { expect(mocks.resolveClickClackInboundAccess).toHaveBeenCalledTimes(1), ); expect(mocks.handleClickClackInbound).not.toHaveBeenCalled(); + expect(ctx.log?.info).toHaveBeenCalledWith( + expect.stringContaining("skipped ClickClack message before agent dispatch"), + ); abort.abort(); await run; }); diff --git a/extensions/clickclack/src/gateway.ts b/extensions/clickclack/src/gateway.ts index b4ce096b7a0d..4dad08139ed7 100644 --- a/extensions/clickclack/src/gateway.ts +++ b/extensions/clickclack/src/gateway.ts @@ -95,6 +95,7 @@ async function processEvent(params: { client: ReturnType; event: ClickClackEvent; botUserId: string; + log?: { info: (message: string) => void }; }) { if (params.event.type !== "message.created" && params.event.type !== "thread.reply_created") { return; @@ -125,6 +126,14 @@ async function processEvent(params: { message, }); if (!access.shouldDispatch) { + params.log?.info( + `[${params.account.accountId}] skipped ClickClack message before agent dispatch: ` + + `kind=${message.direct_conversation_id ? "dm" : "group"} ` + + `requireMention=${access.requireMention ?? "unknown"} ` + + `wasMentioned=${access.mentionFacts.wasMentioned} ` + + `hasAnyMention=${access.mentionFacts.hasAnyMention ?? "unknown"} ` + + `commandAuthorized=${access.commandAuthorized}`, + ); return; } await handleClickClackInbound({ @@ -195,6 +204,7 @@ export async function startClickClackGatewayAccount( client, event, botUserId: account.botUserId, + log: ctx.log, }); if (account.commandMenu) { await syncClickClackCommandMenu({ cfg: ctx.cfg, client, log: ctx.log }); diff --git a/extensions/clickclack/src/group-policy.test.ts b/extensions/clickclack/src/group-policy.test.ts new file mode 100644 index 000000000000..09bd61ed7ce6 --- /dev/null +++ b/extensions/clickclack/src/group-policy.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { resolveClickClackGroupPolicy } from "./group-policy.js"; + +describe("resolveClickClackGroupPolicy", () => { + it("returns requireMention: false when no policy is configured", () => { + const result = resolveClickClackGroupPolicy({ + account: {}, + channelId: "chn_unknown", + }); + expect(result.requireMention).toBe(false); + expect(result.mentionPatterns).toEqual([]); + }); + + it("applies account-level requireMention", () => { + const result = resolveClickClackGroupPolicy({ + account: { requireMention: true }, + channelId: "chn_some", + }); + expect(result.requireMention).toBe(true); + }); + + it("groups['*'] overrides account default", () => { + const result = resolveClickClackGroupPolicy({ + account: { + requireMention: false, + groups: { "*": { requireMention: true } }, + }, + channelId: "chn_any", + }); + expect(result.requireMention).toBe(true); + }); + + it("exact channel rule overrides groups['*']", () => { + const result = resolveClickClackGroupPolicy({ + account: { + requireMention: false, + groups: { + "*": { requireMention: true }, + chn_exact: { requireMention: false }, + }, + }, + channelId: "chn_exact", + }); + expect(result.requireMention).toBe(false); + }); + + it("picks mentionPatterns from exact channel rule", () => { + const result = resolveClickClackGroupPolicy({ + account: { + mentionPatterns: ["@bot"], + groups: { + chn_exact: { mentionPatterns: ["@mybot"] }, + }, + }, + channelId: "chn_exact", + }); + expect(result.mentionPatterns).toEqual(["@mybot"]); + }); + + it("inherits unspecified fields from the account policy", () => { + const result = resolveClickClackGroupPolicy({ + account: { + requireMention: true, + mentionPatterns: ["@account"], + groups: { + chn_exact: { mentionPatterns: ["@channel"] }, + }, + }, + channelId: " chn_exact ", + }); + expect(result).toEqual({ requireMention: true, mentionPatterns: ["@channel"] }); + }); + + it("inherits unspecified exact fields from the wildcard policy", () => { + const result = resolveClickClackGroupPolicy({ + account: { + requireMention: false, + mentionPatterns: ["@account"], + groups: { + "*": { requireMention: true, mentionPatterns: ["@wildcard"] }, + chn_exact: { mentionPatterns: ["@channel"] }, + }, + }, + channelId: "chn_exact", + }); + expect(result).toEqual({ requireMention: true, mentionPatterns: ["@channel"] }); + }); + + it("picks mentionPatterns from wildcard rule", () => { + const result = resolveClickClackGroupPolicy({ + account: { + mentionPatterns: ["@bot"], + groups: { + "*": { mentionPatterns: ["@wildbot"] }, + }, + }, + channelId: "chn_other", + }); + expect(result.mentionPatterns).toEqual(["@wildbot"]); + }); + + it("falls back to account mentionPatterns when no group config", () => { + const result = resolveClickClackGroupPolicy({ + account: { mentionPatterns: ["@fallback"] }, + channelId: "chn_other", + }); + expect(result.mentionPatterns).toEqual(["@fallback"]); + }); + + it("unrelated channel does not inherit exact rule", () => { + const result = resolveClickClackGroupPolicy({ + account: { + groups: { chn_one: { requireMention: true } }, + }, + channelId: "chn_two", + }); + expect(result.requireMention).toBe(false); + }); + + it("trims channel id keys", () => { + // Edge case: leading/trailing whitespace is trimmed during resolution + // but our test passes raw channelId as is; exact match requires + // the trimmed key. The resolver does not trim; the caller must trim. + // Validating that untrimmed exact match works. + const result = resolveClickClackGroupPolicy({ + account: { + groups: { "chn_exact ": { requireMention: true } }, + }, + channelId: "chn_exact ", + }); + expect(result.requireMention).toBe(true); + }); +}); diff --git a/extensions/clickclack/src/group-policy.ts b/extensions/clickclack/src/group-policy.ts new file mode 100644 index 000000000000..b5b6eb1af323 --- /dev/null +++ b/extensions/clickclack/src/group-policy.ts @@ -0,0 +1,50 @@ +/** + * Resolved group/channel policy for ClickClack inbound gating. + * + * Pure helper – no side effects, no runtime imports. + */ + +export type ClickClackGroupPolicy = { + requireMention: boolean; + mentionPatterns: string[]; +}; + +export type ClickClackAccountGroupPolicyParams = { + requireMention?: boolean; + mentionPatterns?: string[]; + groups?: Record; +}; + +/** + * Resolves the effective group policy for a ClickClack channel. + * + * Lookup order: + * 1. Exact channel ID in `groups` + * 2. Wildcard `'*'` entry in `groups` + * 3. Account-level `requireMention` / `mentionPatterns` + * 4. Backward-compatible default: { requireMention: false, mentionPatterns: [] } + */ +export function resolveClickClackGroupPolicy(params: { + account: ClickClackAccountGroupPolicyParams; + channelId?: string; +}): ClickClackGroupPolicy { + const { account, channelId } = params; + const accountPolicy: ClickClackGroupPolicy = { + requireMention: account.requireMention === true, + mentionPatterns: account.mentionPatterns ?? [], + }; + const wildcard = account.groups?.["*"]; + const channelKey = channelId?.trim(); + const exact = channelKey + ? Object.entries(account.groups ?? {}).find(([key]) => key.trim() === channelKey)?.[1] + : undefined; + // Channel rules are partial overrides. Resolve each field independently so + // an exact channel rule can inherit unspecified fields from the wildcard + // rule before falling back to the account-level policy. + return { + requireMention: + exact?.requireMention ?? wildcard?.requireMention ?? accountPolicy.requireMention, + mentionPatterns: + exact?.mentionPatterns ?? wildcard?.mentionPatterns ?? accountPolicy.mentionPatterns, + }; +} diff --git a/extensions/clickclack/src/inbound.test.ts b/extensions/clickclack/src/inbound.test.ts index af6ae152d3ec..c8492be70cbb 100644 --- a/extensions/clickclack/src/inbound.test.ts +++ b/extensions/clickclack/src/inbound.test.ts @@ -138,6 +138,9 @@ function createAgentAccount( agentActivity: false, commandMenu: true, discussions: { enabled: false, workspace: "wsp_1", section: "Sessions" }, + requireMention: false, + mentionPatterns: [], + groups: {}, config: { allowFrom: ["*"], }, @@ -209,6 +212,9 @@ describe("handleClickClackInbound", () => { commandMenu: true, discussions: { enabled: false, workspace: "wsp_1", section: "Sessions" }, config: {}, + requireMention: false, + mentionPatterns: [], + groups: {}, } satisfies ResolvedClickClackAccount; await handleClickClackInbound({ @@ -1035,4 +1041,42 @@ describe("handleClickClackInbound", () => { expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled(); expect(runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); + + it("rejects an unmentioned group message when mention gating is enabled", async () => { + const runtime = createRuntime(); + setClickClackRuntime(runtime); + + await handleClickClackInbound({ + account: createAgentAccount({ + requireMention: true, + mentionPatterns: ["<@usr_bot>"], + botUserId: "usr_bot", + }), + config: {} satisfies CoreConfig, + message: createMessage({ body: "hello everyone" }), + }); + + expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled(); + expect(runtime.agent.runEmbeddedAgent).not.toHaveBeenCalled(); + expect(runtime.llm.complete).not.toHaveBeenCalled(); + expect(sendClickClackTextMock).not.toHaveBeenCalled(); + }); + + it("dispatches a group message when the configured bot mention matches", async () => { + const runtime = createRuntime(); + setClickClackRuntime(runtime); + + await handleClickClackInbound({ + account: createAgentAccount({ + requireMention: true, + botUserId: "usr_bot", + }), + config: {} satisfies CoreConfig, + message: createMessage({ body: "<@usr_bot> please help" }), + }); + + const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch); + expect(dispatchTurn).toHaveBeenCalledTimes(1); + expect(dispatchTurn.mock.calls[0]?.[0].ctxPayload.WasMentioned).toBe(true); + }); }); diff --git a/extensions/clickclack/src/inbound.ts b/extensions/clickclack/src/inbound.ts index e31ed384e35f..24e9d155d740 100644 --- a/extensions/clickclack/src/inbound.ts +++ b/extensions/clickclack/src/inbound.ts @@ -274,10 +274,7 @@ export async function handleClickClackInbound(params: { message: { body, bodyForAgent: message.body, rawBody: message.body, commandBody: message.body }, access: { commands: { authorized: access.commandAuthorized }, - mentions: { - canDetectMention: !isDirect, - wasMentioned: !isDirect, - }, + mentions: access.mentionFacts, }, extra: { GroupChannel: message.channel_id, diff --git a/extensions/clickclack/src/mention-facts.test.ts b/extensions/clickclack/src/mention-facts.test.ts new file mode 100644 index 000000000000..0e589abb5ca7 --- /dev/null +++ b/extensions/clickclack/src/mention-facts.test.ts @@ -0,0 +1,160 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { describe, expect, it } from "vitest"; +import { resolveClickClackMentionFacts } from "./mention-facts.js"; + +describe("resolveClickClackMentionFacts", () => { + it("direct message: canDetectMention: false, wasMentioned: false", () => { + const result = resolveClickClackMentionFacts({ + isDirect: true, + body: "hello", + mentionPatterns: ["@bot"], + }); + expect(result.canDetectMention).toBe(false); + expect(result.wasMentioned).toBe(false); + expect(result.hasAnyMention).toBeUndefined(); + }); + + it("group message with no body: canDetectMention true, wasMentioned false", () => { + const result = resolveClickClackMentionFacts({ + isDirect: false, + body: "", + mentionPatterns: [], + }); + expect(result.canDetectMention).toBe(true); + expect(result.wasMentioned).toBe(false); + expect(result.hasAnyMention).toBe(false); + }); + + it("group message without patterns: wasMentioned false", () => { + const result = resolveClickClackMentionFacts({ + isDirect: false, + body: "hello everyone", + mentionPatterns: [], + }); + expect(result.canDetectMention).toBe(true); + expect(result.wasMentioned).toBe(false); + expect(result.hasAnyMention).toBe(false); + }); + + it("matches configured pattern", () => { + const result = resolveClickClackMentionFacts({ + isDirect: false, + body: "hey @bot help me", + mentionPatterns: ["@bot"], + }); + expect(result.wasMentioned).toBe(true); + expect(result.hasAnyMention).toBe(true); + }); + + it("matches native ClickClack mention syntax when botUserId provided", () => { + const result = resolveClickClackMentionFacts({ + isDirect: false, + body: "hey <@usr_abc123> check this", + mentionPatterns: [], + botUserId: "usr_abc123", + }); + expect(result.wasMentioned).toBe(true); + }); + + it("does not match other bot user id", () => { + const result = resolveClickClackMentionFacts({ + isDirect: false, + body: "<@usr_other> hello", + mentionPatterns: [], + botUserId: "usr_abc123", + }); + expect(result.wasMentioned).toBe(false); + expect(result.hasAnyMention).toBe(true); + }); + + it("tracks another user's native mention separately from a bot mention", () => { + const result = resolveClickClackMentionFacts({ + isDirect: false, + body: "<@usr_other> /status", + mentionPatterns: [], + botUserId: "usr_abc123", + }); + expect(result.wasMentioned).toBe(false); + expect(result.hasAnyMention).toBe(true); + }); + + it("plain display name does not count unless configured as a pattern", () => { + const result = resolveClickClackMentionFacts({ + isDirect: false, + body: "Blackbird can you help?", + mentionPatterns: ["<@usr_abc>"], + botUserId: "usr_def", + }); + expect(result.wasMentioned).toBe(false); + }); + + it("rejects unsafe configured regexes without evaluating them", () => { + const result = resolveClickClackMentionFacts({ + isDirect: false, + body: `${"a".repeat(20_000)}!`, + mentionPatterns: ["(a+)+$"], + }); + expect(result.wasMentioned).toBe(false); + expect(result.hasAnyMention).toBe(false); + }); + + it("matches shared routed-agent mention patterns", () => { + const cfg = { + agents: { + entries: { + "service-bot": { + groupChat: { + mentionPatterns: ["@service"], + }, + }, + }, + }, + } as unknown as OpenClawConfig; + const result = resolveClickClackMentionFacts({ + isDirect: false, + body: "hey @service please help", + mentionPatterns: [], + cfg, + agentId: "service-bot", + channelId: "chn_123", + }); + expect(result.wasMentioned).toBe(true); + expect(result.hasAnyMention).toBe(true); + }); + + it("non-matching pattern returns wasMentioned false", () => { + const result = resolveClickClackMentionFacts({ + isDirect: false, + body: "just a message", + mentionPatterns: ["@bot", "@assistant"], + }); + expect(result.wasMentioned).toBe(false); + }); + + it("multiple patterns: matches one pattern", () => { + const result = resolveClickClackMentionFacts({ + isDirect: false, + body: "@firstbot please", + mentionPatterns: ["@firstbot", "@secondbot"], + }); + expect(result.wasMentioned).toBe(true); + }); + it("rejects unsafe patterns from shared config without evaluating them", () => { + const cfg = { + messages: { + groupChat: { + mentionPatterns: ["(a+)+$"], + }, + }, + } as unknown as OpenClawConfig; + const result = resolveClickClackMentionFacts({ + isDirect: false, + body: `${"a".repeat(20_000)}!`, + mentionPatterns: [], + cfg, + channelId: "chn_123", + }); + expect(result.wasMentioned).toBe(false); + expect(result.hasAnyMention).toBe(false); + }); +}); diff --git a/extensions/clickclack/src/mention-facts.ts b/extensions/clickclack/src/mention-facts.ts new file mode 100644 index 000000000000..9366a60ed523 --- /dev/null +++ b/extensions/clickclack/src/mention-facts.ts @@ -0,0 +1,113 @@ +/** + * Detects whether a ClickClack group message contains a direct mention of the + * current account. + * + * Pure helper – no side effects, no runtime imports. + */ + +import { + buildMentionRegexes, + normalizeMentionText, +} from "openclaw/plugin-sdk/channel-mention-gating"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; + +export type ClickClackMentionFacts = { + canDetectMention: boolean; + wasMentioned: boolean; + hasAnyMention?: boolean; +}; + +function buildLocalMentionRegexes(params: { + cfg?: OpenClawConfig; + mentionPatterns: string[]; + channelId?: string; +}): RegExp[] { + if (params.mentionPatterns.length === 0) { + return []; + } + const cfg = params.cfg; + const syntheticCfg = { + ...(cfg ?? {}), + messages: { + ...(cfg?.messages ?? {}), + groupChat: { + ...(cfg?.messages?.groupChat ?? {}), + mentionPatterns: params.mentionPatterns, + }, + }, + } as OpenClawConfig; + return buildMentionRegexes(syntheticCfg, undefined, { + provider: "clickclack", + conversationId: params.channelId, + }); +} + +function resolveNativeMentionIds(body: string): string[] { + return [...body.matchAll(/<@([^>\\s]+)>/gi)] + .map((match) => match[1]?.toLowerCase()) + .filter((id): id is string => Boolean(id)); +} + +/** + * Builds mention facts for a ClickClack message. + * + * Rules: + * - DMs always have canDetectMention: false, wasMentioned: false + * (DMs bypass mention gating). + * - Group messages: canDetectMention: true when body text is available. + * - Checks the message body against shared and account-local mention patterns. + * - If botUserId is provided and the message body contains the native + * ClickClack user mention syntax (<@user_id>), treat it as a mention. + * - Plain display names do not count unless explicitly configured as a pattern. + */ +export function resolveClickClackMentionFacts(params: { + isDirect: boolean; + body?: string; + mentionPatterns: string[]; + botUserId?: string; + cfg?: OpenClawConfig; + agentId?: string; + channelId?: string; +}): ClickClackMentionFacts { + const { isDirect, body, mentionPatterns, botUserId, cfg, agentId, channelId } = params; + + if (isDirect) { + return { + canDetectMention: false, + wasMentioned: false, + }; + } + + if (!body) { + return { + canDetectMention: true, + wasMentioned: false, + hasAnyMention: false, + }; + } + + const sharedMentionRegexes = buildMentionRegexes(cfg, agentId, { + provider: "clickclack", + conversationId: channelId, + }); + const localMentionRegexes = buildLocalMentionRegexes({ + cfg, + mentionPatterns, + channelId, + }); + const mentionRegexes = [...sharedMentionRegexes, ...localMentionRegexes]; + const bodyForRegex = normalizeMentionText(body); + const hasConfiguredMention = mentionRegexes.some((regex) => regex.test(bodyForRegex)); + + const nativeMentionIds = resolveNativeMentionIds(body); + const botId = botUserId?.toLowerCase(); + const hasNativeMention = botId ? nativeMentionIds.includes(botId) : false; + const hasAnyNativeMention = nativeMentionIds.length > 0; + const wasMentioned = hasNativeMention || hasConfiguredMention; + + return { + canDetectMention: true, + wasMentioned, + hasAnyMention: hasAnyNativeMention || hasConfiguredMention, + }; +} diff --git a/extensions/clickclack/src/types.ts b/extensions/clickclack/src/types.ts index b7ac8e84523f..1838f554864b 100644 --- a/extensions/clickclack/src/types.ts +++ b/extensions/clickclack/src/types.ts @@ -11,6 +11,12 @@ type ClickClackDiscussionsConfig = { section?: string; }; +/** Per-channel group policy for a ClickClack group/channel. */ +export type ClickClackGroupConfig = { + requireMention?: boolean; + mentionPatterns?: string[]; +}; + /** User-configurable settings for one ClickClack account. */ export type ClickClackAccountConfig = { name?: string; @@ -35,6 +41,12 @@ export type ClickClackAccountConfig = { commandMenu?: boolean; /** Create and synchronize one managed ClickClack channel per OpenClaw session. */ discussions?: ClickClackDiscussionsConfig; + /** Require a direct mention before dispatching group messages (default false). */ + requireMention?: boolean; + /** Mention patterns for this account in group channels. */ + mentionPatterns?: string[]; + /** Per-channel group policy overrides keyed by ClickClack channel ID. */ + groups?: Record; }; /** Root ClickClack channel config with optional named accounts. */ @@ -78,6 +90,9 @@ export type ResolvedClickClackAccount = { section: string; }; config: ClickClackAccountConfig; + requireMention: boolean; + mentionPatterns: string[]; + groups: Record; }; /** User object returned by the ClickClack API. */