diff --git a/docs/channels/clickclack.md b/docs/channels/clickclack.md index e9c3f8faa98a..7b47d022a743 100644 --- a/docs/channels/clickclack.md +++ b/docs/channels/clickclack.md @@ -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 }, diff --git a/extensions/clickclack/src/access.ts b/extensions/clickclack/src/access.ts index 4b245bbf9769..31e467e3dcd1 100644 --- a/extensions/clickclack/src/access.ts +++ b/extensions/clickclack/src/access.ts @@ -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, }; } diff --git a/extensions/clickclack/src/gateway.test.ts b/extensions/clickclack/src/gateway.test.ts index 16039e1c4a31..0ab2685fe002 100644 --- a/extensions/clickclack/src/gateway.test.ts +++ b/extensions/clickclack/src/gateway.test.ts @@ -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"), diff --git a/extensions/clickclack/src/gateway.ts b/extensions/clickclack/src/gateway.ts index 4dad08139ed7..e2dc0826a073 100644 --- a/extensions/clickclack/src/gateway.ts +++ b/extensions/clickclack/src/gateway.ts @@ -196,6 +196,7 @@ export async function startClickClackGatewayAccount( ...configuredAccount, workspace: workspaceId, botUserId: configuredAccount.botUserId ?? me.id, + botHandle: me.handle, }; const processIncomingEvent = (event: ClickClackEvent) => processEvent({ diff --git a/extensions/clickclack/src/inbound.test.ts b/extensions/clickclack/src/inbound.test.ts index c8492be70cbb..b749992cc822 100644 --- a/extensions/clickclack/src/inbound.test.ts +++ b/extensions/clickclack/src/inbound.test.ts @@ -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(); + }); }); diff --git a/extensions/clickclack/src/mention-facts.test.ts b/extensions/clickclack/src/mention-facts.test.ts index 0e589abb5ca7..c8c79728814d 100644 --- a/extensions/clickclack/src/mention-facts.test.ts +++ b/extensions/clickclack/src/mention-facts.test.ts @@ -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); }); diff --git a/extensions/clickclack/src/mention-facts.ts b/extensions/clickclack/src/mention-facts.ts index 9366a60ed523..070a9fad9016 100644 --- a/extensions/clickclack/src/mention-facts.ts +++ b/extensions/clickclack/src/mention-facts.ts @@ -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, }; } diff --git a/extensions/clickclack/src/types.ts b/extensions/clickclack/src/types.ts index 1838f554864b..51f59565a202 100644 --- a/extensions/clickclack/src/types.ts +++ b/extensions/clickclack/src/types.ts @@ -73,6 +73,7 @@ export type ResolvedClickClackAccount = { token: string; workspace: string; botUserId?: string; + botHandle?: string; agentId?: string; replyMode: "agent" | "model"; model?: string;