refactor: unify channel config matching and gating

Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-18 01:21:27 +00:00
parent 05f49d2846
commit f73dbdbaea
24 changed files with 430 additions and 120 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot
### Changes ### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs. - Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm. - Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
### Fixes ### Fixes

View File

@@ -175,7 +175,7 @@ Notes:
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages. - `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages.
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`. - Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
- If `channels` is present, any channel not listed is denied by default. - If `channels` is present, any channel not listed is denied by default.
- Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread id explicitly. - Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread channel id explicitly.
- Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered). - Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered).
- Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`. - Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.

View File

@@ -152,6 +152,7 @@ By default, the bot only responds to mentions in groups (`@botname` or patterns
``` ```
**Important:** Setting `channels.telegram.groups` creates an **allowlist** - only listed groups (or `"*"`) will be accepted. **Important:** Setting `channels.telegram.groups` creates an **allowlist** - only listed groups (or `"*"`) will be accepted.
Forum topics inherit their parent group config (allowFrom, requireMention, skills, prompts) unless you add per-topic overrides under `channels.telegram.groups.<groupId>.topics.<topicId>`.
To allow all groups with always-respond: To allow all groups with always-respond:
```json5 ```json5

View File

@@ -31,6 +31,8 @@ describe("msteams policy", () => {
expect(res.channelConfig?.requireMention).toBe(true); expect(res.channelConfig?.requireMention).toBe(true);
expect(res.allowlistConfigured).toBe(true); expect(res.allowlistConfigured).toBe(true);
expect(res.allowed).toBe(true); expect(res.allowed).toBe(true);
expect(res.channelMatchKey).toBe("chan456");
expect(res.channelMatchSource).toBe("direct");
}); });
it("returns undefined configs when teamId is missing", () => { it("returns undefined configs when teamId is missing", () => {

View File

@@ -13,6 +13,8 @@ export type MSTeamsResolvedRouteConfig = {
allowed: boolean; allowed: boolean;
teamKey?: string; teamKey?: string;
channelKey?: string; channelKey?: string;
channelMatchKey?: string;
channelMatchSource?: "direct" | "wildcard";
}; };
export function resolveMSTeamsRouteConfig(params: { export function resolveMSTeamsRouteConfig(params: {
@@ -75,6 +77,8 @@ export function resolveMSTeamsRouteConfig(params: {
allowed, allowed,
teamKey, teamKey,
channelKey, channelKey,
channelMatchKey: channelKey,
channelMatchSource: channelKey ? (channelKey === "*" ? "wildcard" : "direct") : undefined,
}; };
} }

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "./channel-config.js"; import {
buildChannelKeyCandidates,
resolveChannelEntryMatch,
resolveChannelEntryMatchWithFallback,
} from "./channel-config.js";
describe("buildChannelKeyCandidates", () => { describe("buildChannelKeyCandidates", () => {
it("dedupes and trims keys", () => { it("dedupes and trims keys", () => {
@@ -22,3 +26,44 @@ describe("resolveChannelEntryMatch", () => {
expect(match.wildcardKey).toBe("*"); expect(match.wildcardKey).toBe("*");
}); });
}); });
describe("resolveChannelEntryMatchWithFallback", () => {
it("prefers direct matches over parent and wildcard", () => {
const entries = { a: { allow: true }, parent: { allow: false }, "*": { allow: false } };
const match = resolveChannelEntryMatchWithFallback({
entries,
keys: ["a"],
parentKeys: ["parent"],
wildcardKey: "*",
});
expect(match.entry).toBe(entries.a);
expect(match.matchSource).toBe("direct");
expect(match.matchKey).toBe("a");
});
it("falls back to parent when direct misses", () => {
const entries = { parent: { allow: false }, "*": { allow: true } };
const match = resolveChannelEntryMatchWithFallback({
entries,
keys: ["missing"],
parentKeys: ["parent"],
wildcardKey: "*",
});
expect(match.entry).toBe(entries.parent);
expect(match.matchSource).toBe("parent");
expect(match.matchKey).toBe("parent");
});
it("falls back to wildcard when no direct or parent match", () => {
const entries = { "*": { allow: true } };
const match = resolveChannelEntryMatchWithFallback({
entries,
keys: ["missing"],
parentKeys: ["still-missing"],
wildcardKey: "*",
});
expect(match.entry).toBe(entries["*"]);
expect(match.matchSource).toBe("wildcard");
expect(match.matchKey).toBe("*");
});
});

View File

@@ -1,11 +1,22 @@
export type ChannelMatchSource = "direct" | "parent" | "wildcard";
export function buildChannelKeyCandidates(
...keys: Array<string | undefined | null>
): string[] {
export type ChannelEntryMatch<T> = { export type ChannelEntryMatch<T> = {
entry?: T; entry?: T;
key?: string; key?: string;
wildcardEntry?: T; wildcardEntry?: T;
wildcardKey?: string; wildcardKey?: string;
parentEntry?: T;
parentKey?: string;
matchKey?: string;
matchSource?: ChannelMatchSource;
}; };
export function buildChannelKeyCandidates(...keys: Array<string | undefined | null>): string[] { export function buildChannelKeyCandidates(
...keys: Array<string | undefined | null>
): string[] {
const seen = new Set<string>(); const seen = new Set<string>();
const candidates: string[] = []; const candidates: string[] = [];
for (const key of keys) { for (const key of keys) {
@@ -37,3 +48,48 @@ export function resolveChannelEntryMatch<T>(params: {
} }
return match; return match;
} }
export function resolveChannelEntryMatchWithFallback<T>(params: {
entries?: Record<string, T>;
keys: string[];
parentKeys?: string[];
wildcardKey?: string;
}): ChannelEntryMatch<T> {
const direct = resolveChannelEntryMatch({
entries: params.entries,
keys: params.keys,
wildcardKey: params.wildcardKey,
});
if (direct.entry && direct.key) {
return { ...direct, matchKey: direct.key, matchSource: "direct" };
}
const parentKeys = params.parentKeys ?? [];
if (parentKeys.length > 0) {
const parent = resolveChannelEntryMatch({ entries: params.entries, keys: parentKeys });
if (parent.entry && parent.key) {
return {
...direct,
entry: parent.entry,
key: parent.key,
parentEntry: parent.entry,
parentKey: parent.key,
matchKey: parent.key,
matchSource: "parent",
};
}
}
if (direct.wildcardEntry && direct.wildcardKey) {
return {
...direct,
entry: direct.wildcardEntry,
key: direct.wildcardKey,
matchKey: direct.wildcardKey,
matchSource: "wildcard",
};
}
return direct;
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveCommandAuthorizedFromAuthorizers } from "./command-gating.js"; import { resolveCommandAuthorizedFromAuthorizers, resolveControlCommandGate } from "./command-gating.js";
describe("resolveCommandAuthorizedFromAuthorizers", () => { describe("resolveCommandAuthorizedFromAuthorizers", () => {
it("denies when useAccessGroups is enabled and no authorizer is configured", () => { it("denies when useAccessGroups is enabled and no authorizer is configured", () => {
@@ -70,3 +70,26 @@ describe("resolveCommandAuthorizedFromAuthorizers", () => {
).toBe(true); ).toBe(true);
}); });
}); });
describe("resolveControlCommandGate", () => {
it("blocks control commands when unauthorized", () => {
const result = resolveControlCommandGate({
useAccessGroups: true,
authorizers: [{ configured: true, allowed: false }],
allowTextCommands: true,
hasControlCommand: true,
});
expect(result.commandAuthorized).toBe(false);
expect(result.shouldBlock).toBe(true);
});
it("does not block when control commands are disabled", () => {
const result = resolveControlCommandGate({
useAccessGroups: true,
authorizers: [{ configured: true, allowed: false }],
allowTextCommands: false,
hasControlCommand: true,
});
expect(result.shouldBlock).toBe(false);
});
});

View File

@@ -21,3 +21,19 @@ export function resolveCommandAuthorizedFromAuthorizers(params: {
} }
return authorizers.some((entry) => entry.configured && entry.allowed); return authorizers.some((entry) => entry.configured && entry.allowed);
} }
export function resolveControlCommandGate(params: {
useAccessGroups: boolean;
authorizers: CommandAuthorizer[];
allowTextCommands: boolean;
hasControlCommand: boolean;
modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff;
}): { commandAuthorized: boolean; shouldBlock: boolean } {
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: params.useAccessGroups,
authorizers: params.authorizers,
modeWhenAccessGroupsOff: params.modeWhenAccessGroupsOff,
});
const shouldBlock = params.allowTextCommands && params.hasControlCommand && !commandAuthorized;
return { commandAuthorized, shouldBlock };
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveMentionGating } from "./mention-gating.js"; import { resolveMentionGating, resolveMentionGatingWithBypass } from "./mention-gating.js";
describe("resolveMentionGating", () => { describe("resolveMentionGating", () => {
it("combines explicit, implicit, and bypass mentions", () => { it("combines explicit, implicit, and bypass mentions", () => {
@@ -36,3 +36,35 @@ describe("resolveMentionGating", () => {
expect(res.shouldSkip).toBe(false); expect(res.shouldSkip).toBe(false);
}); });
}); });
describe("resolveMentionGatingWithBypass", () => {
it("enables bypass when control commands are authorized", () => {
const res = resolveMentionGatingWithBypass({
isGroup: true,
requireMention: true,
canDetectMention: true,
wasMentioned: false,
hasAnyMention: false,
allowTextCommands: true,
hasControlCommand: true,
commandAuthorized: true,
});
expect(res.shouldBypassMention).toBe(true);
expect(res.shouldSkip).toBe(false);
});
it("does not bypass when control commands are not authorized", () => {
const res = resolveMentionGatingWithBypass({
isGroup: true,
requireMention: true,
canDetectMention: true,
wasMentioned: false,
hasAnyMention: false,
allowTextCommands: true,
hasControlCommand: true,
commandAuthorized: false,
});
expect(res.shouldBypassMention).toBe(false);
expect(res.shouldSkip).toBe(true);
});
});

View File

@@ -11,6 +11,22 @@ export type MentionGateResult = {
shouldSkip: boolean; shouldSkip: boolean;
}; };
export type MentionGateWithBypassParams = {
isGroup: boolean;
requireMention: boolean;
canDetectMention: boolean;
wasMentioned: boolean;
implicitMention?: boolean;
hasAnyMention?: boolean;
allowTextCommands: boolean;
hasControlCommand: boolean;
commandAuthorized: boolean;
};
export type MentionGateWithBypassResult = MentionGateResult & {
shouldBypassMention: boolean;
};
export function resolveMentionGating(params: MentionGateParams): MentionGateResult { export function resolveMentionGating(params: MentionGateParams): MentionGateResult {
const implicit = params.implicitMention === true; const implicit = params.implicitMention === true;
const bypass = params.shouldBypassMention === true; const bypass = params.shouldBypassMention === true;
@@ -18,3 +34,26 @@ export function resolveMentionGating(params: MentionGateParams): MentionGateResu
const shouldSkip = params.requireMention && params.canDetectMention && !effectiveWasMentioned; const shouldSkip = params.requireMention && params.canDetectMention && !effectiveWasMentioned;
return { effectiveWasMentioned, shouldSkip }; return { effectiveWasMentioned, shouldSkip };
} }
export function resolveMentionGatingWithBypass(
params: MentionGateWithBypassParams,
): MentionGateWithBypassResult {
const shouldBypassMention =
params.isGroup &&
params.requireMention &&
!params.wasMentioned &&
!(params.hasAnyMention ?? false) &&
params.allowTextCommands &&
params.commandAuthorized &&
params.hasControlCommand;
return {
...resolveMentionGating({
requireMention: params.requireMention,
canDetectMention: params.canDetectMention,
wasMentioned: params.wasMentioned,
implicitMention: params.implicitMention,
shouldBypassMention,
}),
shouldBypassMention,
};
}

View File

@@ -1,2 +1,6 @@
export type { ChannelEntryMatch } from "../channel-config.js"; export type { ChannelEntryMatch, ChannelMatchSource } from "../channel-config.js";
export { buildChannelKeyCandidates, resolveChannelEntryMatch } from "../channel-config.js"; export {
buildChannelKeyCandidates,
resolveChannelEntryMatch,
resolveChannelEntryMatchWithFallback,
} from "../channel-config.js";

View File

@@ -87,6 +87,8 @@ export {
export { export {
buildChannelKeyCandidates, buildChannelKeyCandidates,
resolveChannelEntryMatch, resolveChannelEntryMatch,
resolveChannelEntryMatchWithFallback,
type ChannelEntryMatch, type ChannelEntryMatch,
type ChannelMatchSource,
} from "./channel-config.js"; } from "./channel-config.js";
export type { ChannelId, ChannelPlugin } from "./types.js"; export type { ChannelId, ChannelPlugin } from "./types.js";

View File

@@ -1,5 +1,5 @@
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js"; import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js";
import { asString, isRecord } from "./shared.js"; import { appendMatchMetadata, asString, isRecord } from "./shared.js";
type DiscordIntentSummary = { type DiscordIntentSummary = {
messageContent?: "enabled" | "limited" | "disabled"; messageContent?: "enabled" | "limited" | "disabled";
@@ -128,15 +128,15 @@ export function collectDiscordStatusIssues(
if (channel.ok === true) continue; if (channel.ok === true) continue;
const missing = channel.missing?.length ? ` missing ${channel.missing.join(", ")}` : ""; const missing = channel.missing?.length ? ` missing ${channel.missing.join(", ")}` : "";
const error = channel.error ? `: ${channel.error}` : ""; const error = channel.error ? `: ${channel.error}` : "";
const matchMeta = const baseMessage = `Channel ${channel.channelId} permission check failed.${missing}${error}`;
channel.matchKey || channel.matchSource
? ` (matchKey=${channel.matchKey ?? "none"} matchSource=${channel.matchSource ?? "none"})`
: "";
issues.push({ issues.push({
channel: "discord", channel: "discord",
accountId, accountId,
kind: "permissions", kind: "permissions",
message: `Channel ${channel.channelId} permission check failed.${missing}${error}${matchMeta}`, message: appendMatchMetadata(baseMessage, {
matchKey: channel.matchKey,
matchSource: channel.matchSource,
}),
fix: "Ensure the bot role can view + send in this channel (and that channel overrides don't deny it).", fix: "Ensure the bot role can view + send in this channel (and that channel overrides don't deny it).",
}); });
} }

View File

@@ -5,3 +5,27 @@ export function asString(value: unknown): string | undefined {
export function isRecord(value: unknown): value is Record<string, unknown> { export function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value); return Boolean(value) && typeof value === "object" && !Array.isArray(value);
} }
export function formatMatchMetadata(params: {
matchKey?: unknown;
matchSource?: unknown;
}): string | undefined {
const matchKey =
typeof params.matchKey === "string"
? params.matchKey
: typeof params.matchKey === "number"
? String(params.matchKey)
: undefined;
const matchSource = asString(params.matchSource);
const parts = [matchKey ? `matchKey=${matchKey}` : null, matchSource ? `matchSource=${matchSource}` : null]
.filter((entry): entry is string => Boolean(entry));
return parts.length > 0 ? parts.join(" ") : undefined;
}
export function appendMatchMetadata(
message: string,
params: { matchKey?: unknown; matchSource?: unknown },
): string {
const meta = formatMatchMetadata(params);
return meta ? `${message} (${meta})` : message;
}

View File

@@ -1,5 +1,5 @@
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js"; import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js";
import { asString, isRecord } from "./shared.js"; import { appendMatchMetadata, asString, isRecord } from "./shared.js";
type TelegramAccountStatus = { type TelegramAccountStatus = {
accountId?: unknown; accountId?: unknown;
@@ -111,15 +111,15 @@ export function collectTelegramStatusIssues(
if (group.ok === true) continue; if (group.ok === true) continue;
const status = group.status ? ` status=${group.status}` : ""; const status = group.status ? ` status=${group.status}` : "";
const err = group.error ? `: ${group.error}` : ""; const err = group.error ? `: ${group.error}` : "";
const matchMeta = const baseMessage = `Group ${group.chatId} not reachable by bot.${status}${err}`;
group.matchKey || group.matchSource
? ` (matchKey=${group.matchKey ?? "none"} matchSource=${group.matchSource ?? "none"})`
: "";
issues.push({ issues.push({
channel: "telegram", channel: "telegram",
accountId, accountId,
kind: "runtime", kind: "runtime",
message: `Group ${group.chatId} not reachable by bot.${status}${err}${matchMeta}`, message: appendMatchMetadata(baseMessage, {
matchKey: group.matchKey,
matchSource: group.matchSource,
}),
fix: "Invite the bot to the group, then DM the bot once (/start) and restart the gateway.", fix: "Invite the bot to the group, then DM the bot once (/start) and restart the gateway.",
}); });
} }

View File

@@ -268,6 +268,7 @@ describe("discord mention gating", () => {
scope: "thread", scope: "thread",
}); });
expect(channelConfig?.matchSource).toBe("parent"); expect(channelConfig?.matchSource).toBe("parent");
expect(channelConfig?.matchKey).toBe("parent-1");
expect( expect(
resolveDiscordShouldRequireMention({ resolveDiscordShouldRequireMention({
isGuildMessage: true, isGuildMessage: true,

View File

@@ -2,7 +2,7 @@ import type { Guild, User } from "@buape/carbon";
import { import {
buildChannelKeyCandidates, buildChannelKeyCandidates,
resolveChannelEntryMatch, resolveChannelEntryMatchWithFallback,
} from "../../channels/channel-config.js"; } from "../../channels/channel-config.js";
import { formatDiscordUserTag } from "./format.js"; import { formatDiscordUserTag } from "./format.js";
@@ -178,40 +178,47 @@ type DiscordChannelLookup = {
slug?: string; slug?: string;
}; };
type DiscordChannelScope = "channel" | "thread"; type DiscordChannelScope = "channel" | "thread";
type DiscordChannelMatch = {
entry: DiscordChannelEntry;
key: string;
};
function resolveDiscordChannelEntry( function buildDiscordChannelKeys(
channels: NonNullable<DiscordGuildEntryResolved["channels"]>,
params: DiscordChannelLookup & { allowNameMatch?: boolean }, params: DiscordChannelLookup & { allowNameMatch?: boolean },
): DiscordChannelMatch | null { ): string[] {
const allowNameMatch = params.allowNameMatch !== false; const allowNameMatch = params.allowNameMatch !== false;
const keys = buildChannelKeyCandidates( return buildChannelKeyCandidates(
params.id, params.id,
allowNameMatch ? params.slug : undefined, allowNameMatch ? params.slug : undefined,
allowNameMatch ? params.name : undefined, allowNameMatch ? params.name : undefined,
); );
const { entry, key } = resolveChannelEntryMatch({ entries: channels, keys }); }
if (!entry || !key) return null;
return { entry, key }; function resolveDiscordChannelEntryMatch(
channels: NonNullable<DiscordGuildEntryResolved["channels"]>,
params: DiscordChannelLookup & { allowNameMatch?: boolean },
parentParams?: DiscordChannelLookup,
) {
const keys = buildDiscordChannelKeys(params);
const parentKeys = parentParams ? buildDiscordChannelKeys(parentParams) : undefined;
return resolveChannelEntryMatchWithFallback({
entries: channels,
keys,
parentKeys,
});
} }
function resolveDiscordChannelConfigEntry( function resolveDiscordChannelConfigEntry(
match: DiscordChannelMatch, entry: DiscordChannelEntry,
matchKey: string | undefined,
matchSource: "direct" | "parent", matchSource: "direct" | "parent",
): DiscordChannelConfigResolved { ): DiscordChannelConfigResolved {
const resolved: DiscordChannelConfigResolved = { const resolved: DiscordChannelConfigResolved = {
allowed: match.entry.allow !== false, allowed: entry.allow !== false,
requireMention: match.entry.requireMention, requireMention: entry.requireMention,
skills: match.entry.skills, skills: entry.skills,
enabled: match.entry.enabled, enabled: entry.enabled,
users: match.entry.users, users: entry.users,
systemPrompt: match.entry.systemPrompt, systemPrompt: entry.systemPrompt,
autoThread: match.entry.autoThread, autoThread: entry.autoThread,
}; };
if (match.key) resolved.matchKey = match.key; if (matchKey) resolved.matchKey = matchKey;
resolved.matchSource = matchSource; resolved.matchSource = matchSource;
return resolved; return resolved;
} }
@@ -225,13 +232,13 @@ export function resolveDiscordChannelConfig(params: {
const { guildInfo, channelId, channelName, channelSlug } = params; const { guildInfo, channelId, channelName, channelSlug } = params;
const channels = guildInfo?.channels; const channels = guildInfo?.channels;
if (!channels) return null; if (!channels) return null;
const entry = resolveDiscordChannelEntry(channels, { const match = resolveDiscordChannelEntryMatch(channels, {
id: channelId, id: channelId,
name: channelName, name: channelName,
slug: channelSlug, slug: channelSlug,
}); });
if (!entry) return { allowed: false }; if (!match.entry || !match.matchKey) return { allowed: false };
return resolveDiscordChannelConfigEntry(entry, "direct"); return resolveDiscordChannelConfigEntry(match.entry, match.matchKey, "direct");
} }
export function resolveDiscordChannelConfigWithFallback(params: { export function resolveDiscordChannelConfigWithFallback(params: {
@@ -256,21 +263,29 @@ export function resolveDiscordChannelConfigWithFallback(params: {
} = params; } = params;
const channels = guildInfo?.channels; const channels = guildInfo?.channels;
if (!channels) return null; if (!channels) return null;
const entry = resolveDiscordChannelEntry(channels, { const resolvedParentSlug = parentSlug ?? (parentName ? normalizeDiscordSlug(parentName) : "");
const match = resolveDiscordChannelEntryMatch(
channels,
{
id: channelId, id: channelId,
name: channelName, name: channelName,
slug: channelSlug, slug: channelSlug,
allowNameMatch: scope !== "thread", allowNameMatch: scope !== "thread",
}); },
if (entry) return resolveDiscordChannelConfigEntry(entry, "direct"); parentId || parentName || parentSlug
if (parentId || parentName || parentSlug) { ? {
const resolvedParentSlug = parentSlug ?? (parentName ? normalizeDiscordSlug(parentName) : "");
const parentEntry = resolveDiscordChannelEntry(channels, {
id: parentId ?? "", id: parentId ?? "",
name: parentName, name: parentName,
slug: resolvedParentSlug, slug: resolvedParentSlug,
}); }
if (parentEntry) return resolveDiscordChannelConfigEntry(parentEntry, "parent"); : undefined,
);
if (match.entry && match.matchKey && match.matchSource) {
return resolveDiscordChannelConfigEntry(
match.entry,
match.matchKey,
match.matchSource === "parent" ? "parent" : "direct",
);
} }
return { allowed: false }; return { allowed: false };
} }

View File

@@ -14,9 +14,9 @@ import {
upsertChannelPairingRequest, upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js"; } from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { resolveMentionGating } from "../../channels/mention-gating.js"; import { resolveMentionGatingWithBypass } from "../../channels/mention-gating.js";
import { sendMessageDiscord } from "../send.js"; import { sendMessageDiscord } from "../send.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { resolveControlCommandGate } from "../../channels/command-gating.js";
import { import {
allowListMatches, allowListMatches,
isDiscordGroupAllowedByPolicy, isDiscordGroupAllowedByPolicy,
@@ -347,6 +347,7 @@ export async function preflightDiscordMessage(
cfg: params.cfg, cfg: params.cfg,
surface: "discord", surface: "discord",
}); });
const hasControlCommandInMessage = hasControlCommand(baseText, params.cfg);
if (!isDirectMessage) { if (!isDirectMessage) {
const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, ["discord:", "user:"]); const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, ["discord:", "user:"]);
@@ -368,36 +369,35 @@ export async function preflightDiscordMessage(
}) })
: false; : false;
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ const commandGate = resolveControlCommandGate({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ configured: ownerAllowList != null, allowed: ownerOk }, { configured: ownerAllowList != null, allowed: ownerOk },
{ configured: Array.isArray(channelUsers) && channelUsers.length > 0, allowed: usersOk }, { configured: Array.isArray(channelUsers) && channelUsers.length > 0, allowed: usersOk },
], ],
modeWhenAccessGroupsOff: "configured", modeWhenAccessGroupsOff: "configured",
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
}); });
commandAuthorized = commandGate.commandAuthorized;
if (allowTextCommands && hasControlCommand(baseText, params.cfg) && !commandAuthorized) { if (commandGate.shouldBlock) {
logVerbose(`Blocked discord control command from unauthorized sender ${author.id}`); logVerbose(`Blocked discord control command from unauthorized sender ${author.id}`);
return null; return null;
} }
} }
const shouldBypassMention =
allowTextCommands &&
isGuildMessage &&
shouldRequireMention &&
!wasMentioned &&
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(baseText, params.cfg);
const canDetectMention = Boolean(botId) || mentionRegexes.length > 0; const canDetectMention = Boolean(botId) || mentionRegexes.length > 0;
const mentionGate = resolveMentionGating({ const mentionGate = resolveMentionGatingWithBypass({
isGroup: isGuildMessage,
requireMention: Boolean(shouldRequireMention), requireMention: Boolean(shouldRequireMention),
canDetectMention, canDetectMention,
wasMentioned, wasMentioned,
implicitMention, implicitMention,
shouldBypassMention, hasAnyMention,
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
}); });
const effectiveWasMentioned = mentionGate.effectiveWasMentioned; const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
if (isGuildMessage && shouldRequireMention) { if (isGuildMessage && shouldRequireMention) {
@@ -504,7 +504,7 @@ export async function preflightDiscordMessage(
shouldRequireMention, shouldRequireMention,
hasAnyMention, hasAnyMention,
allowTextCommands, allowTextCommands,
shouldBypassMention, shouldBypassMention: mentionGate.shouldBypassMention,
effectiveWasMentioned, effectiveWasMentioned,
canDetectMention, canDetectMention,
historyEntry, historyEntry,

View File

@@ -42,4 +42,16 @@ describe("resolveSlackChannelConfig", () => {
matchSource: "wildcard", matchSource: "wildcard",
}); });
}); });
it("uses direct match metadata when channel config exists", () => {
const res = resolveSlackChannelConfig({
channelId: "C1",
channels: { C1: { allow: true, requireMention: false } },
defaultRequireMention: true,
});
expect(res).toMatchObject({
matchKey: "C1",
matchSource: "direct",
});
});
}); });

View File

@@ -2,7 +2,7 @@ import type { SlackReactionNotificationMode } from "../../config/config.js";
import type { SlackMessageEvent } from "../types.js"; import type { SlackMessageEvent } from "../types.js";
import { import {
buildChannelKeyCandidates, buildChannelKeyCandidates,
resolveChannelEntryMatch, resolveChannelEntryMatchWithFallback,
} from "../../channels/channel-config.js"; } from "../../channels/channel-config.js";
import { allowListMatches, normalizeAllowListLower, normalizeSlackSlug } from "./allow-list.js"; import { allowListMatches, normalizeAllowListLower, normalizeSlackSlug } from "./allow-list.js";
@@ -91,10 +91,10 @@ export function resolveSlackChannelConfig(params: {
); );
const { const {
entry: matched, entry: matched,
key: matchedKey,
wildcardEntry: fallback, wildcardEntry: fallback,
wildcardKey, matchKey,
} = resolveChannelEntryMatch({ matchSource,
} = resolveChannelEntryMatchWithFallback({
entries, entries,
keys: candidates, keys: candidates,
wildcardKey: "*", wildcardKey: "*",
@@ -127,12 +127,9 @@ export function resolveSlackChannelConfig(params: {
skills, skills,
systemPrompt, systemPrompt,
}; };
if (matchedKey) { if (matchKey) result.matchKey = matchKey;
result.matchKey = matchedKey; if (matchSource === "direct" || matchSource === "wildcard") {
result.matchSource = "direct"; result.matchSource = matchSource;
} else if (wildcardKey && fallback) {
result.matchKey = wildcardKey;
result.matchSource = "wildcard";
} }
return result; return result;
} }

View File

@@ -18,9 +18,9 @@ import { buildPairingReply } from "../../../pairing/pairing-messages.js";
import { upsertChannelPairingRequest } from "../../../pairing/pairing-store.js"; import { upsertChannelPairingRequest } from "../../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { resolveAgentRoute } from "../../../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; import { resolveThreadSessionKeys } from "../../../routing/session-key.js";
import { resolveMentionGating } from "../../../channels/mention-gating.js"; import { resolveMentionGatingWithBypass } from "../../../channels/mention-gating.js";
import { resolveConversationLabel } from "../../../channels/conversation-label.js"; import { resolveConversationLabel } from "../../../channels/conversation-label.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../../channels/command-gating.js"; import { resolveControlCommandGate } from "../../../channels/command-gating.js";
import type { ResolvedSlackAccount } from "../../accounts.js"; import type { ResolvedSlackAccount } from "../../accounts.js";
import { reactSlackMessage } from "../../actions.js"; import { reactSlackMessage } from "../../actions.js";
@@ -229,6 +229,7 @@ export async function prepareSlackMessage(params: {
cfg, cfg,
surface: "slack", surface: "slack",
}); });
const hasControlCommandInMessage = hasControlCommand(message.text ?? "", cfg);
const ownerAuthorized = resolveSlackAllowListMatch({ const ownerAuthorized = resolveSlackAllowListMatch({
allowList: allowFromLower, allowList: allowFromLower,
@@ -245,20 +246,18 @@ export async function prepareSlackMessage(params: {
userName: senderName, userName: senderName,
}) })
: false; : false;
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ const commandGate = resolveControlCommandGate({
useAccessGroups: ctx.useAccessGroups, useAccessGroups: ctx.useAccessGroups,
authorizers: [ authorizers: [
{ configured: allowFromLower.length > 0, allowed: ownerAuthorized }, { configured: allowFromLower.length > 0, allowed: ownerAuthorized },
{ configured: channelUsersAllowlistConfigured, allowed: channelCommandAuthorized }, { configured: channelUsersAllowlistConfigured, allowed: channelCommandAuthorized },
], ],
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
}); });
const commandAuthorized = commandGate.commandAuthorized;
if ( if (isRoomish && commandGate.shouldBlock) {
allowTextCommands &&
isRoomish &&
hasControlCommand(message.text ?? "", cfg) &&
!commandAuthorized
) {
logVerbose(`Blocked slack control command from unauthorized sender ${senderId}`); logVerbose(`Blocked slack control command from unauthorized sender ${senderId}`);
return null; return null;
} }
@@ -268,22 +267,17 @@ export async function prepareSlackMessage(params: {
: false; : false;
// Allow "control commands" to bypass mention gating if sender is authorized. // Allow "control commands" to bypass mention gating if sender is authorized.
const shouldBypassMention =
allowTextCommands &&
isRoom &&
shouldRequireMention &&
!wasMentioned &&
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(message.text ?? "", cfg);
const canDetectMention = Boolean(ctx.botUserId) || mentionRegexes.length > 0; const canDetectMention = Boolean(ctx.botUserId) || mentionRegexes.length > 0;
const mentionGate = resolveMentionGating({ const mentionGate = resolveMentionGatingWithBypass({
isGroup: isRoom,
requireMention: Boolean(shouldRequireMention), requireMention: Boolean(shouldRequireMention),
canDetectMention, canDetectMention,
wasMentioned, wasMentioned,
implicitMention, implicitMention,
shouldBypassMention, hasAnyMention,
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
}); });
const effectiveWasMentioned = mentionGate.effectiveWasMentioned; const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
if (isRoom && shouldRequireMention && mentionGate.shouldSkip) { if (isRoom && shouldRequireMention && mentionGate.shouldSkip) {

View File

@@ -18,8 +18,8 @@ import type { DmPolicy, TelegramGroupConfig, TelegramTopicConfig } from "../conf
import { logVerbose, shouldLogVerbose } from "../globals.js"; import { logVerbose, shouldLogVerbose } from "../globals.js";
import { recordChannelActivity } from "../infra/channel-activity.js"; import { recordChannelActivity } from "../infra/channel-activity.js";
import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveMentionGating } from "../channels/mention-gating.js"; import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js"; import { resolveControlCommandGate } from "../channels/command-gating.js";
import { import {
buildGroupLabel, buildGroupLabel,
buildSenderLabel, buildSenderLabel,
@@ -269,10 +269,16 @@ export const buildTelegramMessageContext = async ({
senderUsername, senderUsername,
}); });
const useAccessGroups = cfg.commands?.useAccessGroups !== false; const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ const hasControlCommandInMessage = hasControlCommand(msg.text ?? msg.caption ?? "", cfg, {
botUsername,
});
const commandGate = resolveControlCommandGate({
useAccessGroups, useAccessGroups,
authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }], authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }],
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
}); });
const commandAuthorized = commandGate.commandAuthorized;
const historyKey = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : undefined; const historyKey = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : undefined;
let placeholder = ""; let placeholder = "";
@@ -300,11 +306,7 @@ export const buildTelegramMessageContext = async ({
const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some( const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some(
(ent) => ent.type === "mention", (ent) => ent.type === "mention",
); );
if ( if (isGroup && commandGate.shouldBlock) {
isGroup &&
hasControlCommand(msg.text ?? msg.caption ?? "", cfg, { botUsername }) &&
!commandAuthorized
) {
logVerbose(`telegram: drop control command from unauthorized sender ${senderId ?? "unknown"}`); logVerbose(`telegram: drop control command from unauthorized sender ${senderId ?? "unknown"}`);
return null; return null;
} }
@@ -325,20 +327,17 @@ export const buildTelegramMessageContext = async ({
const botId = primaryCtx.me?.id; const botId = primaryCtx.me?.id;
const replyFromId = msg.reply_to_message?.from?.id; const replyFromId = msg.reply_to_message?.from?.id;
const implicitMention = botId != null && replyFromId === botId; const implicitMention = botId != null && replyFromId === botId;
const shouldBypassMention =
isGroup &&
requireMention &&
!wasMentioned &&
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(msg.text ?? msg.caption ?? "", cfg, { botUsername });
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0; const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
const mentionGate = resolveMentionGating({ const mentionGate = resolveMentionGatingWithBypass({
isGroup,
requireMention: Boolean(requireMention), requireMention: Boolean(requireMention),
canDetectMention, canDetectMention,
wasMentioned, wasMentioned,
implicitMention: isGroup && Boolean(requireMention) && implicitMention, implicitMention: isGroup && Boolean(requireMention) && implicitMention,
shouldBypassMention, hasAnyMention,
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
}); });
const effectiveWasMentioned = mentionGate.effectiveWasMentioned; const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
if (isGroup && requireMention && canDetectMention) { if (isGroup && requireMention && canDetectMention) {

View File

@@ -1203,6 +1203,49 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
}); });
it("prefers topic allowFrom over group allowFrom", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "allowlist",
groups: {
"-1001234567890": {
allowFrom: ["123456789"],
topics: {
"99": { allowFrom: ["999999999"] },
},
},
},
},
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: {
id: -1001234567890,
type: "supergroup",
title: "Forum Group",
is_forum: true,
},
from: { id: 123456789, username: "testuser" },
text: "hello",
date: 1736380800,
message_thread_id: 99,
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(0);
});
it("honors groups default when no explicit group override exists", async () => { it("honors groups default when no explicit group override exists", async () => {
onSpy.mockReset(); onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>; const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;