mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 22:17:00 +00:00
fix: gate ClickClack group replies on mentions
This commit is contained in:
committed by
Shakker
parent
944cf08f94
commit
ba9e9fec13
@@ -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:`.
|
||||
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 ?? ["*"],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
133
extensions/clickclack/src/group-policy.test.ts
Normal file
133
extensions/clickclack/src/group-policy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
50
extensions/clickclack/src/group-policy.ts
Normal file
50
extensions/clickclack/src/group-policy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
160
extensions/clickclack/src/mention-facts.test.ts
Normal file
160
extensions/clickclack/src/mention-facts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
113
extensions/clickclack/src/mention-facts.ts
Normal file
113
extensions/clickclack/src/mention-facts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user