mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: recognize ClickClack handle mentions
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -196,6 +196,7 @@ export async function startClickClackGatewayAccount(
|
||||
...configuredAccount,
|
||||
workspace: workspaceId,
|
||||
botUserId: configuredAccount.botUserId ?? me.id,
|
||||
botHandle: me.handle,
|
||||
};
|
||||
const processIncomingEvent = (event: ClickClackEvent) =>
|
||||
processEvent({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ export type ResolvedClickClackAccount = {
|
||||
token: string;
|
||||
workspace: string;
|
||||
botUserId?: string;
|
||||
botHandle?: string;
|
||||
agentId?: string;
|
||||
replyMode: "agent" | "model";
|
||||
model?: string;
|
||||
|
||||
Reference in New Issue
Block a user