fix: recognize ClickClack handle mentions

This commit is contained in:
Shakker
2026-07-30 04:10:02 +01:00
parent ba9e9fec13
commit 154e67b1e2
8 changed files with 79 additions and 39 deletions
+2 -2
View File
@@ -438,7 +438,7 @@ DMs are never gated by `requireMention`. When a DM arrives, the mention gate is
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.
- The message contains the bot's ClickClack `@handle`. The gateway reads the handle from the authenticated bot identity at startup.
Plain display names (e.g. `Blackbird`) are **not** treated as mentions unless they are explicitly configured as a pattern.
@@ -452,7 +452,7 @@ Plain display names (e.g. `Blackbird`) are **not** treated as mentions unless th
token: { source: "env", provider: "default", id: "CLICKCLACK_BOT_TOKEN" },
workspace: "default",
requireMention: true,
mentionPatterns: ["<@usr_abc>", "@mybot", "\\bBlackbird\\b"],
mentionPatterns: ["\\bBlackbird\\b"],
groups: {
"*": { requireMention: true },
chn_command_and_control: { requireMention: false },
+2 -6
View File
@@ -91,7 +91,7 @@ export async function resolveClickClackInboundAccess(params: {
isDirect,
body: params.message.body,
mentionPatterns: effectiveGroupPolicy.mentionPatterns,
botUserId: params.account.botUserId,
botHandle: params.account.botHandle,
cfg,
agentId,
channelId: params.message.channel_id,
@@ -140,10 +140,6 @@ export async function resolveClickClackInboundAccess(params: {
? resolved.commandAccess.authorized
: resolved.senderAccess.allowed,
requireMention: effectiveGroupPolicy.requireMention,
mentionFacts: mentionFacts as {
canDetectMention: boolean;
wasMentioned: boolean;
hasAnyMention?: boolean;
},
mentionFacts,
};
}
@@ -490,6 +490,14 @@ describe("ClickClack gateway", () => {
await waitForGatewayState(() =>
expect(mocks.resolveClickClackInboundAccess).toHaveBeenCalledTimes(1),
);
expect(mocks.resolveClickClackInboundAccess).toHaveBeenCalledWith(
expect.objectContaining({
account: expect.objectContaining({
botHandle: "bot",
botUserId: "bot-user",
}),
}),
);
expect(mocks.handleClickClackInbound).not.toHaveBeenCalled();
expect(ctx.log?.info).toHaveBeenCalledWith(
expect.stringContaining("skipped ClickClack message before agent dispatch"),
+1
View File
@@ -196,6 +196,7 @@ export async function startClickClackGatewayAccount(
...configuredAccount,
workspace: workspaceId,
botUserId: configuredAccount.botUserId ?? me.id,
botHandle: me.handle,
};
const processIncomingEvent = (event: ClickClackEvent) =>
processEvent({
+24 -5
View File
@@ -1049,8 +1049,7 @@ describe("handleClickClackInbound", () => {
await handleClickClackInbound({
account: createAgentAccount({
requireMention: true,
mentionPatterns: ["<@usr_bot>"],
botUserId: "usr_bot",
botHandle: "blackbird",
}),
config: {} satisfies CoreConfig,
message: createMessage({ body: "hello everyone" }),
@@ -1062,21 +1061,41 @@ describe("handleClickClackInbound", () => {
expect(sendClickClackTextMock).not.toHaveBeenCalled();
});
it("dispatches a group message when the configured bot mention matches", async () => {
it("dispatches a group message when its ClickClack bot handle is mentioned", async () => {
const runtime = createRuntime();
setClickClackRuntime(runtime);
await handleClickClackInbound({
account: createAgentAccount({
requireMention: true,
botUserId: "usr_bot",
botHandle: "blackbird",
}),
config: {} satisfies CoreConfig,
message: createMessage({ body: "<@usr_bot> please help" }),
message: createMessage({ body: "@blackbird please help" }),
});
const dispatchTurn = vi.mocked(runtime.channel.inbound.dispatch);
expect(dispatchTurn).toHaveBeenCalledTimes(1);
expect(dispatchTurn.mock.calls[0]?.[0].ctxPayload.WasMentioned).toBe(true);
});
it("does not bypass mention gating for a command mentioning another ClickClack user", async () => {
const runtime = createRuntime();
vi.mocked(runtime.channel.commands.shouldComputeCommandAuthorized).mockReturnValue(true);
vi.mocked(runtime.channel.commands.shouldHandleTextCommands).mockReturnValue(true);
vi.mocked(runtime.channel.text.hasControlCommand).mockReturnValue(true);
setClickClackRuntime(runtime);
await handleClickClackInbound({
account: createAgentAccount({
requireMention: true,
botHandle: "blackbird",
}),
config: {} satisfies CoreConfig,
message: createMessage({ body: "/status @alice" }),
});
expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled();
expect(runtime.agent.runEmbeddedAgent).not.toHaveBeenCalled();
});
});
+24 -12
View File
@@ -46,44 +46,56 @@ describe("resolveClickClackMentionFacts", () => {
expect(result.hasAnyMention).toBe(true);
});
it("matches native ClickClack mention syntax when botUserId provided", () => {
it("matches the ClickClack bot handle emitted by the composer", () => {
const result = resolveClickClackMentionFacts({
isDirect: false,
body: "hey <@usr_abc123> check this",
body: "hey @blackbird check this",
mentionPatterns: [],
botUserId: "usr_abc123",
botHandle: "blackbird",
});
expect(result.wasMentioned).toBe(true);
expect(result.hasAnyMention).toBe(true);
});
it("does not match other bot user id", () => {
it("matches the configured bot handle case-insensitively", () => {
const result = resolveClickClackMentionFacts({
isDirect: false,
body: "<@usr_other> hello",
body: "@BlackBird hello",
mentionPatterns: [],
botUserId: "usr_abc123",
botHandle: "@blackbird",
});
expect(result.wasMentioned).toBe(true);
expect(result.hasAnyMention).toBe(true);
});
it("tracks another user's handle separately from the bot handle", () => {
const result = resolveClickClackMentionFacts({
isDirect: false,
body: "/status @alice",
mentionPatterns: [],
botHandle: "blackbird",
});
expect(result.wasMentioned).toBe(false);
expect(result.hasAnyMention).toBe(true);
});
it("tracks another user's native mention separately from a bot mention", () => {
it("does not treat email addresses as ClickClack mentions", () => {
const result = resolveClickClackMentionFacts({
isDirect: false,
body: "<@usr_other> /status",
body: "email alice@example.com",
mentionPatterns: [],
botUserId: "usr_abc123",
botHandle: "example",
});
expect(result.wasMentioned).toBe(false);
expect(result.hasAnyMention).toBe(true);
expect(result.hasAnyMention).toBe(false);
});
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",
mentionPatterns: [],
botHandle: "blackbird",
});
expect(result.wasMentioned).toBe(false);
});
+17 -14
View File
@@ -11,12 +11,14 @@ import {
} from "openclaw/plugin-sdk/channel-mention-gating";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
export type ClickClackMentionFacts = {
type ClickClackMentionFacts = {
canDetectMention: boolean;
wasMentioned: boolean;
hasAnyMention?: boolean;
};
const CLICKCLACK_MENTION_PATTERN = /(?:^|[^a-z0-9_@-])@([a-z0-9][a-z0-9_-]{1,31})(?![a-z0-9_-])/giu;
function buildLocalMentionRegexes(params: {
cfg?: OpenClawConfig;
mentionPatterns: string[];
@@ -42,10 +44,10 @@ function buildLocalMentionRegexes(params: {
});
}
function resolveNativeMentionIds(body: string): string[] {
return [...body.matchAll(/<@([^>\\s]+)>/gi)]
function resolveMentionHandles(body: string): string[] {
return [...body.matchAll(CLICKCLACK_MENTION_PATTERN)]
.map((match) => match[1]?.toLowerCase())
.filter((id): id is string => Boolean(id));
.filter((handle): handle is string => Boolean(handle));
}
/**
@@ -56,20 +58,20 @@ function resolveNativeMentionIds(body: string): string[] {
* (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.
* - If botHandle is provided and the message body contains its ClickClack
* `@handle`, 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;
botHandle?: string;
cfg?: OpenClawConfig;
agentId?: string;
channelId?: string;
}): ClickClackMentionFacts {
const { isDirect, body, mentionPatterns, botUserId, cfg, agentId, channelId } = params;
const { isDirect, body, mentionPatterns, botHandle, cfg, agentId, channelId } = params;
if (isDirect) {
return {
@@ -99,15 +101,16 @@ export function resolveClickClackMentionFacts(params: {
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;
const mentionHandles = resolveMentionHandles(body);
const normalizedBotHandle = botHandle?.replace(/^@/u, "").trim().toLowerCase();
const hasHandleMention = normalizedBotHandle
? mentionHandles.includes(normalizedBotHandle)
: false;
const wasMentioned = hasHandleMention || hasConfiguredMention;
return {
canDetectMention: true,
wasMentioned,
hasAnyMention: hasAnyNativeMention || hasConfiguredMention,
hasAnyMention: mentionHandles.length > 0 || hasConfiguredMention,
};
}
+1
View File
@@ -73,6 +73,7 @@ export type ResolvedClickClackAccount = {
token: string;
workspace: string;
botUserId?: string;
botHandle?: string;
agentId?: string;
replyMode: "agent" | "model";
model?: string;