feat(clickclack): allow opt-in bot-to-bot inbound dispatch

This commit is contained in:
Jacqueline Henriksen
2026-08-04 09:10:25 -07:00
committed by Shakker
parent 2e4683ba7f
commit 762fa6e428
17 changed files with 804 additions and 40 deletions
+31 -2
View File
@@ -120,6 +120,8 @@ id (`wsp_...`), slug, or name; the gateway resolves it to the id at startup.
| `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. |
| `allowBots` | `false` | Admit messages authored by other ClickClack bots: `true` for all allowed bot messages or `"mentions"` in groups only. |
| `botLoopProtection` | built-in defaults | Sliding-window bot-pair loop guard applied to admitted bot 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. |
@@ -452,6 +454,30 @@ ClickClack mentions are detected when:
Plain display names (e.g. `Blackbird`) are **not** treated as mentions unless they are explicitly configured as a pattern.
### Bot-to-bot messages
ClickClack ignores bot-authored messages by default. To opt in, set
`allowBots: true` on the account. Set `allowBots: "mentions"` to admit bot
messages in group channels only when they mention this bot; direct messages
remain eligible without a mention. Bot messages still pass through
`allowFrom`, but bot authors must be explicitly listed by ID; the wildcard
`allowFrom: ["*"]` default does not authorize bot-authored messages. The
wildcard remains available for human traffic. Self-authored messages are
always ignored.
Accepted bot messages also pass through OpenClaw's shared bot-pair loop guard.
Use `botLoopProtection` on the account or `channels.defaults.botLoopProtection`
to tune its window, budget, cooldown, or enabled state. Group-level `allowBots`
and `botLoopProtection` values follow the same exact-channel, wildcard, then
account-level precedence as the other group policies.
Older ClickClack responses may omit `author.kind`. Those messages intentionally
remain on the legacy `allowFrom` path: `allowFrom: ["*"]` can admit them, and
the bot-specific `allowBots` and bot-pair loop-protection checks do not apply
because the server did not classify the author. Bot-specific restrictions
therefore require a ClickClack server response that includes author
classification.
### Configuration example
```json5
@@ -463,8 +489,11 @@ Plain display names (e.g. `Blackbird`) are **not** treated as mentions unless th
workspace: "default",
requireMention: true,
mentionPatterns: ["\\bBlackbird\\b"],
allowBots: "mentions",
allowFrom: ["usr_trusted_bot"],
botLoopProtection: { maxEventsPerWindow: 12, windowSeconds: 60 },
groups: {
"*": { requireMention: true },
"*": { requireMention: true, allowBots: "mentions" },
chn_command_and_control: { requireMention: false },
},
},
@@ -520,6 +549,6 @@ OpenClaw only needs current `bot:write` for normal agent chat and command-menu s
- `ClickClack is not configured for account "<id>"`: set `baseUrl`, `token` (for example via `CLICKCLACK_BOT_TOKEN`), and `workspace` for that account.
- `ClickClack workspace not found: <value>`: set `workspace` to the workspace id, slug, or name returned by ClickClack.
- No inbound replies: confirm the token has realtime read access and note that the bot ignores its own messages and messages from other bots.
- No inbound replies: confirm the token has realtime read access. The bot always ignores its own messages; other bot messages are denied by default, and when `allowBots` is enabled the sender bot ID must also be listed explicitly in `allowFrom`.
- Channel sends fail: verify the bot is a member of the workspace and has `bot:write`.
- No command menu: confirm `commandMenu` is not `false`, the ClickClack server supports `PUT /api/bots/self/commands`, and the token has `commands:write`.
@@ -16,6 +16,19 @@
"additionalProperties": true,
"properties": {
"requireMention": { "type": "boolean" },
"allowBots": {
"anyOf": [{ "type": "boolean" }, { "type": "string", "const": "mentions" }]
},
"botLoopProtection": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"maxEventsPerWindow": { "type": "integer", "exclusiveMinimum": 0 },
"windowSeconds": { "type": "integer", "exclusiveMinimum": 0 },
"cooldownSeconds": { "type": "integer", "exclusiveMinimum": 0 }
}
},
"mentionPatterns": {
"type": "array",
"items": { "type": "string" }
@@ -27,6 +40,19 @@
"additionalProperties": true,
"properties": {
"requireMention": { "type": "boolean" },
"allowBots": {
"anyOf": [{ "type": "boolean" }, { "type": "string", "const": "mentions" }]
},
"botLoopProtection": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"maxEventsPerWindow": { "type": "integer", "exclusiveMinimum": 0 },
"windowSeconds": { "type": "integer", "exclusiveMinimum": 0 },
"cooldownSeconds": { "type": "integer", "exclusiveMinimum": 0 }
}
},
"mentionPatterns": {
"type": "array",
"items": { "type": "string" }
@@ -41,6 +67,19 @@
"additionalProperties": true,
"properties": {
"requireMention": { "type": "boolean" },
"allowBots": {
"anyOf": [{ "type": "boolean" }, { "type": "string", "const": "mentions" }]
},
"botLoopProtection": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"maxEventsPerWindow": { "type": "integer", "exclusiveMinimum": 0 },
"windowSeconds": { "type": "integer", "exclusiveMinimum": 0 },
"cooldownSeconds": { "type": "integer", "exclusiveMinimum": 0 }
}
},
"mentionPatterns": {
"type": "array",
"items": { "type": "string" }
@@ -52,6 +91,19 @@
"additionalProperties": true,
"properties": {
"requireMention": { "type": "boolean" },
"allowBots": {
"anyOf": [{ "type": "boolean" }, { "type": "string", "const": "mentions" }]
},
"botLoopProtection": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"maxEventsPerWindow": { "type": "integer", "exclusiveMinimum": 0 },
"windowSeconds": { "type": "integer", "exclusiveMinimum": 0 },
"cooldownSeconds": { "type": "integer", "exclusiveMinimum": 0 }
}
},
"mentionPatterns": {
"type": "array",
"items": { "type": "string" }
+55 -2
View File
@@ -1,3 +1,4 @@
import type { ChannelBotLoopProtectionFacts } from "openclaw/plugin-sdk/channel-inbound";
/**
* Maps ClickClack senders and conversations onto the shared channel ingress
* allowlist/command authorization contract.
@@ -13,7 +14,7 @@ import {
type RoutePeer,
} from "openclaw/plugin-sdk/routing";
import { resolveClickClackDiscussionRoute } from "./discussions/routing.js";
import { resolveClickClackGroupPolicy } from "./group-policy.js";
import { resolveClickClackBotPolicy, resolveClickClackGroupPolicy } from "./group-policy.js";
import { resolveClickClackMentionFacts } from "./mention-facts.js";
import { getClickClackRuntime } from "./runtime.js";
import { buildClickClackTarget } from "./target.js";
@@ -164,6 +165,7 @@ export type ClickClackInboundAccess = {
wasMentioned: boolean;
hasAnyMention?: boolean;
};
botLoopProtection?: ChannelBotLoopProtectionFacts;
preparedRoute: ClickClackPreparedInboundRoute;
};
@@ -198,6 +200,56 @@ export async function resolveClickClackInboundAccess(params: {
agentId: preparedRoute.route.agentId,
channelId: params.message.channel_id,
});
const effectiveBotPolicy = resolveClickClackBotPolicy({
account: params.account,
channelId: params.message.channel_id,
});
// Older ClickClack servers may omit author classification. Preserve the
// legacy ingress path for those responses and apply bot-only policy only to
// messages positively classified as bot-authored.
const isBotAuthor = params.message.author?.kind === "bot";
// The account's default allowFrom is wildcarded for human traffic. Bot
// admission is a separate opt-in boundary, so wildcard authorization must
// not implicitly trust every bot in the workspace.
const ingressAllowFrom = isBotAuthor
? params.account.allowFrom.filter((entry) => normalizeClickClackUserId(entry) !== "*")
: params.account.allowFrom;
const botMentionAllowed =
!isBotAuthor ||
effectiveBotPolicy.allowBots === true ||
(effectiveBotPolicy.allowBots === "mentions" &&
(preparedRoute.isDirect || mentionFacts.wasMentioned));
if (!botMentionAllowed) {
return {
shouldDispatch: false,
commandAuthorized: false,
requireMention: effectiveGroupPolicy.requireMention,
mentionFacts,
preparedRoute,
};
}
const botLoopProtection =
isBotAuthor && params.message.author_id !== params.account.botUserId && params.account.botUserId
? {
// Keep reciprocal ClickClack accounts in one loop-guard namespace.
// The workspace is the shared boundary; account IDs would let the
// same conversation evade the budget by alternating receivers.
scopeId: params.account.workspace,
conversationId: preparedRoute.isDirect
? (params.message.direct_conversation_id ?? params.message.author_id)
: (params.message.channel_id ??
params.message.thread_root_id ??
params.message.author_id),
senderId: params.message.author_id,
receiverId: params.account.botUserId,
...(Number.isFinite(Date.parse(params.message.created_at))
? { nowMs: Date.parse(params.message.created_at) }
: {}),
config: effectiveBotPolicy.botLoopProtection,
defaultsConfig: cfg.channels?.defaults?.botLoopProtection,
defaultEnabled: true,
}
: undefined;
const allowTextCommands =
params.account.replyMode === "agent" &&
runtime.channel.commands.shouldHandleTextCommands({
@@ -218,7 +270,7 @@ export async function resolveClickClackInboundAccess(params: {
? (params.message.direct_conversation_id ?? params.message.author_id)
: (params.message.channel_id ?? params.message.thread_root_id),
},
allowFrom: params.account.allowFrom,
allowFrom: ingressAllowFrom,
dmPolicy: "allowlist",
groupPolicy: "allowlist",
mentionFacts,
@@ -243,6 +295,7 @@ export async function resolveClickClackInboundAccess(params: {
: resolved.senderAccess.allowed,
requireMention: effectiveGroupPolicy.requireMention,
mentionFacts,
botLoopProtection,
preparedRoute,
};
}
@@ -134,10 +134,12 @@ describe("ClickClack account resolution", () => {
env: { CLICKCLACK_SERVICE_TOKEN: " test-token-placeholder " },
}),
).toEqual({
allowBots: false,
allowFrom: ["*"],
accountId: "service",
apiEndpoint: "https://app.clickclack.chat",
baseUrl: "https://app.clickclack.chat",
botLoopProtection: undefined,
config: {
allowFrom: ["*"],
baseUrl: "https://app.clickclack.chat",
@@ -241,11 +243,13 @@ describe("ClickClack account resolution", () => {
} satisfies CoreConfig;
expect(resolveClickClackAccount({ cfg, accountId: "peter" })).toEqual({
allowBots: false,
allowFrom: ["*"],
accountId: "peter",
agentId: "peter-bot",
apiEndpoint: "https://app.clickclack.chat",
baseUrl: "https://app.clickclack.chat",
botLoopProtection: undefined,
config: {
agentId: "peter-bot",
allowFrom: ["*"],
+10 -1
View File
@@ -9,6 +9,7 @@ import {
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-resolution-runtime";
import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime";
import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime";
import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth";
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime";
import {
@@ -36,7 +37,7 @@ const {
} = createAccountListHelpers<ClickClackAccountConfig>("clickclack", {
normalizeAccountId,
omitKeys: ["defaultAccount"],
nestedObjectKeys: ["discussions"],
nestedObjectKeys: ["botLoopProtection", "discussions"],
hasImplicitDefaultAccount: (cfg) => {
const channel = cfg.channels?.clickclack;
return Boolean(
@@ -61,10 +62,16 @@ function mergeClickClackGroups(
if (!key) {
continue;
}
const mergedBotLoopProtection = mergePairLoopGuardConfig(
merged.get(key)?.botLoopProtection,
value.botLoopProtection,
);
merged.set(key, {
...merged.get(key),
...(value.requireMention !== undefined ? { requireMention: value.requireMention } : {}),
...(value.mentionPatterns !== undefined ? { mentionPatterns: value.mentionPatterns } : {}),
...(value.allowBots !== undefined ? { allowBots: value.allowBots } : {}),
...(mergedBotLoopProtection ? { botLoopProtection: mergedBotLoopProtection } : {}),
});
}
}
@@ -230,6 +237,8 @@ export function resolveClickClackAccount(params: {
},
requireMention: merged.requireMention === true,
mentionPatterns: merged.mentionPatterns ?? [],
allowBots: merged.allowBots ?? false,
botLoopProtection: merged.botLoopProtection,
groups: mergeClickClackGroups(merged.groups),
config: {
...merged,
@@ -2,8 +2,10 @@
* Zod-backed config schema for ClickClack channel accounts.
*/
import {
buildChannelAllowBotsSchema,
buildChannelConfigSchema,
buildMultiAccountChannelSchema,
ChannelBotLoopProtectionSchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
import { z } from "zod";
@@ -26,6 +28,8 @@ const ClickClackAccountConfigSchema = z
toolsAllow: z.array(z.string()).optional(),
defaultTo: z.string().optional(),
allowFrom: z.array(z.string()).optional(),
allowBots: buildChannelAllowBotsSchema({ allowMentions: true }),
botLoopProtection: ChannelBotLoopProtectionSchema.optional(),
reconnectMs: z.number().int().min(100).max(60_000).optional(),
agentActivity: z.boolean().optional(),
nativeProgress: z.boolean().optional(),
@@ -39,6 +43,8 @@ const ClickClackAccountConfigSchema = z
.object({
requireMention: z.boolean().optional(),
mentionPatterns: z.array(z.string()).optional(),
allowBots: buildChannelAllowBotsSchema({ allowMentions: true }),
botLoopProtection: ChannelBotLoopProtectionSchema.optional(),
})
.strict(),
)
+33
View File
@@ -567,6 +567,39 @@ describe("ClickClack gateway", () => {
await run;
});
it("passes other bot-authored messages to ClickClack access policy", async () => {
const socket = new FakeSocket();
mocks.client.websocket.mockReturnValue(socket);
mocks.client.message.mockResolvedValueOnce({
id: "msg-1",
workspace_id: "workspace-1",
channel_id: "chan-1",
author_id: "other-bot",
thread_root_id: "msg-1",
body: "coordinate",
body_format: "markdown",
created_at: "2026-01-01T00:00:00.000Z",
author: {
id: "other-bot",
kind: "bot",
display_name: "Other bot",
handle: "other-bot",
avatar_url: "",
created_at: "2026-01-01T00:00:00.000Z",
},
});
const abort = new AbortController();
const run = startClickClackGatewayAccount(createGatewayContext(abort.signal));
await waitForGatewayState(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
emitMessageEvent(socket, 1, { author_id: "other-bot" });
await waitForGatewayState(() => expect(mocks.handleClickClackInbound).toHaveBeenCalledTimes(1));
expect(mocks.resolveClickClackInboundAccess).toHaveBeenCalledTimes(1);
abort.abort();
await run;
});
it("carries validated event correlation through the authoritative fetch and inbound turn", async () => {
const socket = new FakeSocket();
mocks.client.websocket.mockReturnValue(socket);
-3
View File
@@ -109,9 +109,6 @@ async function processEvent(params: {
if (message.author_id === params.botUserId) {
return;
}
if (message.author?.kind === "bot") {
return;
}
const access = await resolveClickClackInboundAccess({
account: params.account,
config: params.config,
+55 -1
View File
@@ -1,5 +1,48 @@
import { describe, expect, it } from "vitest";
import { resolveClickClackGroupPolicy } from "./group-policy.js";
import { resolveClickClackBotPolicy, resolveClickClackGroupPolicy } from "./group-policy.js";
describe("resolveClickClackBotPolicy", () => {
it("keeps bot-authored dispatch disabled by default", () => {
expect(resolveClickClackBotPolicy({ account: {}, channelId: "chn_unknown" })).toEqual({
allowBots: false,
botLoopProtection: undefined,
});
});
it("resolves exact, wildcard, and account bot policies independently", () => {
expect(
resolveClickClackBotPolicy({
account: {
allowBots: false,
botLoopProtection: { maxEventsPerWindow: 20, cooldownSeconds: 90 },
groups: {
"*": { allowBots: "mentions", botLoopProtection: { windowSeconds: 30 } },
chn_exact: { botLoopProtection: { maxEventsPerWindow: 5 } },
},
},
channelId: " chn_exact ",
}),
).toEqual({
allowBots: "mentions",
botLoopProtection: {
maxEventsPerWindow: 5,
windowSeconds: 30,
cooldownSeconds: 90,
},
});
});
it("does not apply group bot policy to direct messages", () => {
expect(
resolveClickClackBotPolicy({
account: {
allowBots: false,
groups: { "*": { allowBots: "mentions" } },
},
}),
).toEqual({ allowBots: false, botLoopProtection: undefined });
});
});
describe("resolveClickClackGroupPolicy", () => {
it("returns requireMention: false when no policy is configured", () => {
@@ -126,4 +169,15 @@ describe("resolveClickClackGroupPolicy", () => {
});
expect(result.requireMention).toBe(true);
});
it("does not apply group policy to direct messages", () => {
const result = resolveClickClackGroupPolicy({
account: {
requireMention: false,
mentionPatterns: ["@account"],
groups: { "*": { requireMention: true, mentionPatterns: ["@group"] } },
},
});
expect(result).toEqual({ requireMention: false, mentionPatterns: ["@account"] });
});
});
+54 -2
View File
@@ -4,6 +4,9 @@
* Pure helper no side effects, no runtime imports.
*/
import type { ChannelBotLoopProtectionConfig } from "openclaw/plugin-sdk/config-contracts";
import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime";
type ClickClackGroupPolicy = {
requireMention: boolean;
mentionPatterns: string[];
@@ -15,6 +18,51 @@ type ClickClackAccountGroupPolicyParams = {
groups?: Record<string, { requireMention?: boolean; mentionPatterns?: string[] }>;
};
type ClickClackBotPolicy = {
allowBots: boolean | "mentions";
botLoopProtection?: ChannelBotLoopProtectionConfig;
};
type ClickClackBotPolicyParams = {
allowBots?: boolean | "mentions";
botLoopProtection?: ChannelBotLoopProtectionConfig;
groups?: Record<
string,
{
allowBots?: boolean | "mentions";
botLoopProtection?: ChannelBotLoopProtectionConfig;
}
>;
};
/**
* Resolves bot-authored message policy using the same exact, wildcard, and
* account-level precedence as mention gating.
*/
export function resolveClickClackBotPolicy(params: {
account: ClickClackBotPolicyParams;
channelId?: string;
}): ClickClackBotPolicy {
const { account, channelId } = params;
const channelKey = channelId?.trim();
// Group-scoped policy must not affect direct messages, which have no
// channel ID. In particular, groups["*"] is a channel fallback, not an
// account-wide override.
const groups = channelKey ? account.groups : undefined;
const wildcard = groups?.["*"];
const exact = channelKey
? Object.entries(groups ?? {}).find(([key]) => key.trim() === channelKey)?.[1]
: undefined;
return {
allowBots: exact?.allowBots ?? wildcard?.allowBots ?? account.allowBots ?? false,
botLoopProtection: mergePairLoopGuardConfig(
account.botLoopProtection,
wildcard?.botLoopProtection,
exact?.botLoopProtection,
),
};
}
/**
* Resolves the effective group policy for a ClickClack channel.
*
@@ -33,9 +81,13 @@ export function resolveClickClackGroupPolicy(params: {
requireMention: account.requireMention === true,
mentionPatterns: account.mentionPatterns ?? [],
};
const wildcard = account.groups?.["*"];
const channelKey = channelId?.trim();
const exact = channelKey ? account.groups?.[channelKey] : undefined;
// Group-scoped policy must not affect direct messages, which have no
// channel ID. In particular, groups["*"] is a channel fallback, not an
// account-wide override.
const groups = channelKey ? account.groups : undefined;
const wildcard = groups?.["*"];
const exact = channelKey ? groups?.[channelKey] : 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.
@@ -3,13 +3,19 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { buildAgentSessionKey, resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { describe, expect, it, vi } from "vitest";
import { resolveClickClackInboundAccess } from "./access.js";
import {
getClickClackDiscussionBindingStore,
type ClickClackDiscussionBinding,
} from "./discussions/binding-store.js";
import { handleClickClackInbound } from "./inbound.js";
import { setClickClackRuntime } from "./runtime.js";
import type { ClickClackMessage, CoreConfig, ResolvedClickClackAccount } from "./types.js";
import type {
ClickClackMessage,
ClickClackUser,
CoreConfig,
ResolvedClickClackAccount,
} from "./types.js";
function configureDiscussionStore(runtime: PluginRuntime): void {
const createStore = <T>(): PluginStateSyncKeyedStore<T> => {
@@ -108,6 +114,9 @@ function createAgentAccount(
toolsAllow: [],
defaultTo: "channel:general",
allowFrom: ["*"],
botUserId: "usr_receiver",
botHandle: "blackbird",
allowBots: false,
reconnectMs: 1_500,
agentActivity: false,
commandMenu: true,
@@ -130,6 +139,18 @@ function createAgentAccount(
};
}
function createAuthor(overrides: Partial<ClickClackUser> = {}): ClickClackUser {
return {
id: "usr_owner",
kind: "human",
display_name: "Peter",
handle: "steipete",
avatar_url: "",
created_at: "2026-05-09T12:00:00.000Z",
...overrides,
};
}
function createMessage(overrides: Partial<ClickClackMessage> = {}): ClickClackMessage {
return {
id: "msg_1",
@@ -140,14 +161,7 @@ function createMessage(overrides: Partial<ClickClackMessage> = {}): ClickClackMe
body: "/fast on",
body_format: "markdown",
created_at: "2026-05-09T12:00:00.000Z",
author: {
id: "usr_owner",
kind: "human",
display_name: "Peter",
handle: "steipete",
avatar_url: "",
created_at: "2026-05-09T12:00:00.000Z",
},
author: createAuthor(),
...overrides,
};
}
@@ -219,6 +233,201 @@ describe("ClickClack inbound mention gating", () => {
);
});
it("ignores bot-authored messages by default", async () => {
const runtime = createRuntime();
setClickClackRuntime(runtime);
await handleClickClackInbound({
account: createAgentAccount({ allowFrom: ["usr_sender"] }),
config: {} satisfies CoreConfig,
message: createMessage({
author_id: "usr_sender",
author: createAuthor({ id: "usr_sender", kind: "bot", handle: "sender" }),
}),
});
expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled();
});
it("preserves legacy inbound delivery when the message omits author kind", async () => {
const runtime = createRuntime();
setClickClackRuntime(runtime);
await handleClickClackInbound({
account: createAgentAccount({ allowFrom: ["*"] }),
config: {} satisfies CoreConfig,
message: createMessage({
author: undefined,
}),
});
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1);
});
it("dispatches an allowed bot-authored message through the shared loop guard", async () => {
const runtime = createRuntime();
setClickClackRuntime(runtime);
await handleClickClackInbound({
account: createAgentAccount({ allowFrom: ["usr_sender"], allowBots: true }),
config: {
channels: { defaults: { botLoopProtection: { maxEventsPerWindow: 7 } } },
} satisfies CoreConfig,
message: createMessage({
author_id: "usr_sender",
author: createAuthor({ id: "usr_sender", kind: "bot", handle: "sender" }),
}),
});
const dispatch = vi.mocked(runtime.channel.inbound.dispatch);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch.mock.calls[0]?.[0].botLoopProtection).toMatchObject({
scopeId: "wsp_1",
conversationId: "chn_1",
senderId: "usr_sender",
receiverId: "usr_receiver",
defaultsConfig: { maxEventsPerWindow: 7 },
defaultEnabled: true,
});
});
it("does not let bot opt-in bypass the wildcard human allowFrom default", async () => {
const runtime = createRuntime();
setClickClackRuntime(runtime);
await handleClickClackInbound({
account: createAgentAccount({ allowFrom: ["*"], allowBots: true }),
config: {} satisfies CoreConfig,
message: createMessage({
author_id: "usr_sender",
author: createAuthor({ id: "usr_sender", kind: "bot", handle: "sender" }),
}),
});
expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled();
});
it("shares bot-loop scope across accounts and preserves ClickClack event time", async () => {
const runtime = createRuntime();
setClickClackRuntime(runtime);
const firstMessage = createMessage({
author_id: "usr_sender",
author: createAuthor({ id: "usr_sender", kind: "bot", handle: "sender" }),
created_at: "2026-05-09T12:00:00.000Z",
});
const accountA = await resolveClickClackInboundAccess({
account: createAgentAccount({
accountId: "account-a",
allowFrom: ["usr_sender"],
allowBots: true,
}),
config: {} satisfies CoreConfig,
message: firstMessage,
});
const accountB = await resolveClickClackInboundAccess({
account: createAgentAccount({
accountId: "account-b",
allowFrom: ["usr_sender"],
allowBots: true,
}),
config: {} satisfies CoreConfig,
message: firstMessage,
});
const delayedReplay = await resolveClickClackInboundAccess({
account: createAgentAccount({
accountId: "account-a",
allowFrom: ["usr_sender"],
allowBots: true,
}),
config: {} satisfies CoreConfig,
message: { ...firstMessage, created_at: "2026-05-09T12:02:00.000Z" },
});
expect(accountA.botLoopProtection).toMatchObject({
scopeId: "wsp_1",
nowMs: Date.parse("2026-05-09T12:00:00.000Z"),
});
expect(accountB.botLoopProtection?.scopeId).toBe(accountA.botLoopProtection?.scopeId);
expect(delayedReplay.botLoopProtection?.nowMs).toBe(Date.parse("2026-05-09T12:02:00.000Z"));
});
it("requires a mention for bot-authored group messages in mention mode", async () => {
const runtime = createRuntime();
setClickClackRuntime(runtime);
await handleClickClackInbound({
account: createAgentAccount({ allowFrom: ["usr_sender"], allowBots: "mentions" }),
config: {} satisfies CoreConfig,
message: createMessage({
author_id: "usr_sender",
body: "hello from another agent",
author: createAuthor({ id: "usr_sender", kind: "bot", handle: "sender" }),
}),
});
expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled();
});
it("allows mentioned bot-authored group messages in mention mode", async () => {
const runtime = createRuntime();
setClickClackRuntime(runtime);
await handleClickClackInbound({
account: createAgentAccount({ allowFrom: ["usr_sender"], allowBots: "mentions" }),
config: {} satisfies CoreConfig,
message: createMessage({
author_id: "usr_sender",
body: "@blackbird please coordinate",
author: createAuthor({ id: "usr_sender", kind: "bot", handle: "sender" }),
}),
});
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1);
});
it("allows bot-authored direct messages in mention mode without a mention", async () => {
const runtime = createRuntime();
setClickClackRuntime(runtime);
await handleClickClackInbound({
account: createAgentAccount({ allowFrom: ["usr_sender"], allowBots: "mentions" }),
config: {} satisfies CoreConfig,
message: createMessage({
author_id: "usr_sender",
channel_id: undefined,
direct_conversation_id: "dm_1",
body: "hello directly",
author: createAuthor({ id: "usr_sender", kind: "bot", handle: "sender" }),
}),
});
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(1);
});
it("does not let wildcard group bot policy authorize direct messages", async () => {
const runtime = createRuntime();
setClickClackRuntime(runtime);
await handleClickClackInbound({
account: createAgentAccount({
allowFrom: ["usr_sender"],
allowBots: false,
groups: { "*": { allowBots: "mentions" } },
}),
config: {} satisfies CoreConfig,
message: createMessage({
author_id: "usr_sender",
channel_id: undefined,
direct_conversation_id: "dm_1",
body: "hello directly",
author: createAuthor({ id: "usr_sender", kind: "bot", handle: "sender" }),
}),
});
expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled();
});
it("rejects an unmentioned group message when mention gating is enabled", async () => {
const runtime = createRuntime();
setClickClackRuntime(runtime);
@@ -0,0 +1,107 @@
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers";
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { describe, expect, it, vi } from "vitest";
import type { ClickClackInboundAccess } from "./access.js";
import { handleClickClackInbound } from "./inbound.js";
import { setClickClackRuntime } from "./runtime.js";
import type { CoreConfig, ResolvedClickClackAccount } from "./types.js";
const sendClickClackTextMock = vi.hoisted(() => vi.fn());
vi.mock("./outbound.js", () => ({
sendClickClackText: sendClickClackTextMock,
}));
function createRuntime(): PluginRuntime {
return createPluginRuntimeMock({
llm: {
complete: vi.fn().mockResolvedValue({
text: "service bot online",
provider: "openai",
model: "gpt-5.4-mini",
agentId: "service-bot",
usage: {},
execution: {
mode: "direct-provider",
owner: { kind: "provider", id: "openai" },
},
audit: { caller: { kind: "plugin", id: "clickclack" } },
}),
},
} as unknown as PluginRuntime);
}
function createAccount(): ResolvedClickClackAccount {
return {
accountId: "model-loop-account",
enabled: true,
configured: true,
baseUrl: "http://127.0.0.1:8080",
apiEndpoint: "http://127.0.0.1:8080",
token: "test-token-placeholder",
workspace: "wsp_model_loop",
botUserId: "usr_model_receiver",
agentId: "service-bot",
replyMode: "model",
toolsAllow: [],
defaultTo: "channel:general",
allowFrom: ["usr_model_sender"],
allowBots: true,
botLoopProtection: { maxEventsPerWindow: 1, windowSeconds: 60, cooldownSeconds: 60 },
reconnectMs: 1_500,
agentActivity: false,
commandMenu: true,
discussions: { enabled: false, workspace: "wsp_model_loop", section: "Sessions" },
config: {},
requireMention: false,
mentionPatterns: [],
groups: {},
};
}
function createAccess(): ClickClackInboundAccess {
return {
shouldDispatch: true,
commandAuthorized: false,
mentionFacts: { canDetectMention: false, wasMentioned: false },
botLoopProtection: {
scopeId: "wsp_model_loop",
conversationId: "chn_model_loop",
senderId: "usr_model_sender",
receiverId: "usr_model_receiver",
config: { maxEventsPerWindow: 1, windowSeconds: 60, cooldownSeconds: 60 },
defaultEnabled: true,
},
preparedRoute: {
isDirect: false,
target: "channel:chn_model_loop",
route: { agentId: "service-bot" } as ClickClackInboundAccess["preparedRoute"]["route"],
revoked: false,
},
};
}
describe("ClickClack direct-model bot loop protection", () => {
it("suppresses the second bot message before model completion", async () => {
const runtime = createRuntime();
setClickClackRuntime(runtime);
const account = createAccount();
const access = createAccess();
const message = {
id: "msg_01arz3ndektsv4rrffq69g5fbx",
workspace_id: "wsp_model_loop",
channel_id: "chn_model_loop",
author_id: "usr_model_sender",
thread_root_id: "msg_01arz3ndektsv4rrffq69g5fbx",
body: "hello from the other bot",
body_format: "markdown" as const,
created_at: "2026-05-09T12:00:00.000Z",
};
await handleClickClackInbound({ account, config: {} as CoreConfig, message, access });
await handleClickClackInbound({ account, config: {} as CoreConfig, message, access });
expect(runtime.llm.complete).toHaveBeenCalledTimes(1);
expect(sendClickClackTextMock).toHaveBeenCalledTimes(1);
});
});
@@ -139,6 +139,7 @@ function createAgentAccount(
toolsAllow: [],
defaultTo: "channel:general",
allowFrom: ["*"],
allowBots: false,
reconnectMs: 1_500,
agentActivity: false,
nativeProgress: false,
@@ -213,6 +214,7 @@ describe("handleClickClackInbound", () => {
toolsAllow: [],
defaultTo: "channel:general",
allowFrom: ["*"],
allowBots: false,
reconnectMs: 1_500,
agentActivity: false,
commandMenu: true,
+19 -1
View File
@@ -1,4 +1,7 @@
import { createChannelInboundEnvelopeBuilder } from "openclaw/plugin-sdk/channel-inbound";
import {
createChannelInboundEnvelopeBuilder,
recordChannelBotPairLoopAndCheckSuppression,
} from "openclaw/plugin-sdk/channel-inbound";
import { deriveDurableFinalDeliveryRequirements } from "openclaw/plugin-sdk/channel-outbound";
/**
* Converts authorized ClickClack messages into OpenClaw agent/model replies and
@@ -129,6 +132,20 @@ export async function handleClickClackInbound(params: {
})
: undefined;
if (params.account.replyMode === "model" && !discussionRoute) {
if (access.botLoopProtection) {
const loopResult = recordChannelBotPairLoopAndCheckSuppression(access.botLoopProtection);
if (loopResult.suppressed) {
runtime.logging
.getChildLogger({ plugin: "clickclack", feature: "bot-loop-protection" })
.warn(
`[${params.account.accountId}] ClickClack bot-pair loop suppressed for ${Math.max(
0,
Math.ceil((loopResult.cooldownUntilMs - Date.now()) / 1000),
)}s`,
);
return;
}
}
progress?.start();
try {
await dispatchModelReply({
@@ -257,6 +274,7 @@ export async function handleClickClackInbound(params: {
accountId: params.account.accountId,
route: { agentId: route.agentId, dmScope: route.dmScope, sessionKey: route.sessionKey },
ctxPayload,
botLoopProtection: access.botLoopProtection,
toolsAllow: params.account.toolsAllow,
replyOptions: {
...(runId ? { runId } : {}),
+12 -1
View File
@@ -1,7 +1,10 @@
/**
* Shared ClickClack config, runtime account, API object, and target types.
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type {
ChannelBotLoopProtectionConfig,
OpenClawConfig,
} from "openclaw/plugin-sdk/config-contracts";
/** Session-linked ClickClack discussion settings for one account. */
type ClickClackDiscussionsConfig = {
@@ -15,6 +18,8 @@ type ClickClackDiscussionsConfig = {
export type ClickClackGroupConfig = {
requireMention?: boolean;
mentionPatterns?: string[];
allowBots?: boolean | "mentions";
botLoopProtection?: ChannelBotLoopProtectionConfig;
};
/** User-configurable settings for one ClickClack account. */
@@ -34,6 +39,10 @@ export type ClickClackAccountConfig = {
toolsAllow?: string[];
defaultTo?: string;
allowFrom?: string[];
/** Accept messages authored by other ClickClack bots. */
allowBots?: boolean | "mentions";
/** Sliding-window bot-pair loop guard for accepted bot messages. */
botLoopProtection?: ChannelBotLoopProtectionConfig;
reconnectMs?: number;
/** Opt-in: publish durable agent activity (commentary + tool) rows. */
agentActivity?: boolean;
@@ -83,6 +92,8 @@ export type ResolvedClickClackAccount = {
toolsAllow?: string[];
defaultTo: string;
allowFrom: string[];
allowBots: boolean | "mentions";
botLoopProtection?: ChannelBotLoopProtectionConfig;
reconnectMs: number;
agentActivity: boolean;
nativeProgress?: boolean;
@@ -334,6 +334,38 @@
"requireMention": {
"type": "boolean"
},
"allowBots": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string",
"const": "mentions"
}
]
},
"botLoopProtection": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"maxEventsPerWindow": {
"type": "integer",
"exclusiveMinimum": 0
},
"windowSeconds": {
"type": "integer",
"exclusiveMinimum": 0
},
"cooldownSeconds": {
"type": "integer",
"exclusiveMinimum": 0
}
}
},
"mentionPatterns": {
"type": "array",
"items": {
@@ -349,6 +381,38 @@
"requireMention": {
"type": "boolean"
},
"allowBots": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string",
"const": "mentions"
}
]
},
"botLoopProtection": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"maxEventsPerWindow": {
"type": "integer",
"exclusiveMinimum": 0
},
"windowSeconds": {
"type": "integer",
"exclusiveMinimum": 0
},
"cooldownSeconds": {
"type": "integer",
"exclusiveMinimum": 0
}
}
},
"mentionPatterns": {
"type": "array",
"items": {
@@ -367,6 +431,38 @@
"requireMention": {
"type": "boolean"
},
"allowBots": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string",
"const": "mentions"
}
]
},
"botLoopProtection": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"maxEventsPerWindow": {
"type": "integer",
"exclusiveMinimum": 0
},
"windowSeconds": {
"type": "integer",
"exclusiveMinimum": 0
},
"cooldownSeconds": {
"type": "integer",
"exclusiveMinimum": 0
}
}
},
"mentionPatterns": {
"type": "array",
"items": {
@@ -382,6 +478,38 @@
"requireMention": {
"type": "boolean"
},
"allowBots": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string",
"const": "mentions"
}
]
},
"botLoopProtection": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"maxEventsPerWindow": {
"type": "integer",
"exclusiveMinimum": 0
},
"windowSeconds": {
"type": "integer",
"exclusiveMinimum": 0
},
"cooldownSeconds": {
"type": "integer",
"exclusiveMinimum": 0
}
}
},
"mentionPatterns": {
"type": "array",
"items": {
File diff suppressed because one or more lines are too long