fix: gate ClickClack group replies on mentions

This commit is contained in:
Jacqueline Henriksen
2026-07-30 04:05:34 +01:00
committed by Shakker
parent 944cf08f94
commit ba9e9fec13
15 changed files with 750 additions and 23 deletions

View File

@@ -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:<name-or-id>` sends to a workspace channel. Bare targets default to `channel:`.

View File

@@ -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"]
},

View File

@@ -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;
},
};
}

View File

@@ -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"],

View File

@@ -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<ClickClackAccountConfig>("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<Record<string, ClickClackGroupConfig>>(
(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 ?? ["*"],

View File

@@ -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(),

View File

@@ -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;
});

View File

@@ -95,6 +95,7 @@ async function processEvent(params: {
client: ReturnType<typeof createClickClackClient>;
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 });

View File

@@ -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);
});
});

View File

@@ -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<string, { requireMention?: boolean; mentionPatterns?: string[] }>;
};
/**
* 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,
};
}

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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);
});
});

View File

@@ -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,
};
}

View File

@@ -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<string, ClickClackGroupConfig>;
};
/** 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<string, ClickClackGroupConfig>;
};
/** User object returned by the ClickClack API. */