mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(clickclack): allow opt-in bot-to-bot inbound dispatch
This commit is contained in:
committed by
Shakker
parent
2e4683ba7f
commit
762fa6e428
@@ -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" }
|
||||
|
||||
@@ -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: ["*"],
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user