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
- 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.
### Fixes

View File

@@ -175,7 +175,7 @@ Notes:
- `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`.
- 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).
- 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.
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:
```json5

View File

@@ -31,6 +31,8 @@ describe("msteams policy", () => {
expect(res.channelConfig?.requireMention).toBe(true);
expect(res.allowlistConfigured).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", () => {

View File

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

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "./channel-config.js";
import {
buildChannelKeyCandidates,
resolveChannelEntryMatch,
resolveChannelEntryMatchWithFallback,
} from "./channel-config.js";
describe("buildChannelKeyCandidates", () => {
it("dedupes and trims keys", () => {
@@ -22,3 +26,44 @@ describe("resolveChannelEntryMatch", () => {
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> = {
entry?: T;
key?: string;
wildcardEntry?: T;
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 candidates: string[] = [];
for (const key of keys) {
@@ -37,3 +48,48 @@ export function resolveChannelEntryMatch<T>(params: {
}
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 { resolveCommandAuthorizedFromAuthorizers } from "./command-gating.js";
import { resolveCommandAuthorizedFromAuthorizers, resolveControlCommandGate } from "./command-gating.js";
describe("resolveCommandAuthorizedFromAuthorizers", () => {
it("denies when useAccessGroups is enabled and no authorizer is configured", () => {
@@ -70,3 +70,26 @@ describe("resolveCommandAuthorizedFromAuthorizers", () => {
).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);
}
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 { resolveMentionGating } from "./mention-gating.js";
import { resolveMentionGating, resolveMentionGatingWithBypass } from "./mention-gating.js";
describe("resolveMentionGating", () => {
it("combines explicit, implicit, and bypass mentions", () => {
@@ -36,3 +36,35 @@ describe("resolveMentionGating", () => {
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;
};
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 {
const implicit = params.implicitMention === true;
const bypass = params.shouldBypassMention === true;
@@ -18,3 +34,26 @@ export function resolveMentionGating(params: MentionGateParams): MentionGateResu
const shouldSkip = params.requireMention && params.canDetectMention && !effectiveWasMentioned;
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 { buildChannelKeyCandidates, resolveChannelEntryMatch } from "../channel-config.js";
export type { ChannelEntryMatch, ChannelMatchSource } from "../channel-config.js";
export {
buildChannelKeyCandidates,
resolveChannelEntryMatch,
resolveChannelEntryMatchWithFallback,
} from "../channel-config.js";

View File

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

View File

@@ -1,5 +1,5 @@
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js";
import { asString, isRecord } from "./shared.js";
import { appendMatchMetadata, asString, isRecord } from "./shared.js";
type DiscordIntentSummary = {
messageContent?: "enabled" | "limited" | "disabled";
@@ -128,15 +128,15 @@ export function collectDiscordStatusIssues(
if (channel.ok === true) continue;
const missing = channel.missing?.length ? ` missing ${channel.missing.join(", ")}` : "";
const error = channel.error ? `: ${channel.error}` : "";
const matchMeta =
channel.matchKey || channel.matchSource
? ` (matchKey=${channel.matchKey ?? "none"} matchSource=${channel.matchSource ?? "none"})`
: "";
const baseMessage = `Channel ${channel.channelId} permission check failed.${missing}${error}`;
issues.push({
channel: "discord",
accountId,
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).",
});
}

View File

@@ -5,3 +5,27 @@ export function asString(value: unknown): string | undefined {
export function isRecord(value: unknown): value is Record<string, unknown> {
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 { asString, isRecord } from "./shared.js";
import { appendMatchMetadata, asString, isRecord } from "./shared.js";
type TelegramAccountStatus = {
accountId?: unknown;
@@ -111,15 +111,15 @@ export function collectTelegramStatusIssues(
if (group.ok === true) continue;
const status = group.status ? ` status=${group.status}` : "";
const err = group.error ? `: ${group.error}` : "";
const matchMeta =
group.matchKey || group.matchSource
? ` (matchKey=${group.matchKey ?? "none"} matchSource=${group.matchSource ?? "none"})`
: "";
const baseMessage = `Group ${group.chatId} not reachable by bot.${status}${err}`;
issues.push({
channel: "telegram",
accountId,
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.",
});
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,9 +18,9 @@ import { buildPairingReply } from "../../../pairing/pairing-messages.js";
import { upsertChannelPairingRequest } from "../../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../../routing/resolve-route.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 { resolveCommandAuthorizedFromAuthorizers } from "../../../channels/command-gating.js";
import { resolveControlCommandGate } from "../../../channels/command-gating.js";
import type { ResolvedSlackAccount } from "../../accounts.js";
import { reactSlackMessage } from "../../actions.js";
@@ -229,6 +229,7 @@ export async function prepareSlackMessage(params: {
cfg,
surface: "slack",
});
const hasControlCommandInMessage = hasControlCommand(message.text ?? "", cfg);
const ownerAuthorized = resolveSlackAllowListMatch({
allowList: allowFromLower,
@@ -245,20 +246,18 @@ export async function prepareSlackMessage(params: {
userName: senderName,
})
: false;
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
const commandGate = resolveControlCommandGate({
useAccessGroups: ctx.useAccessGroups,
authorizers: [
{ configured: allowFromLower.length > 0, allowed: ownerAuthorized },
{ configured: channelUsersAllowlistConfigured, allowed: channelCommandAuthorized },
],
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
});
const commandAuthorized = commandGate.commandAuthorized;
if (
allowTextCommands &&
isRoomish &&
hasControlCommand(message.text ?? "", cfg) &&
!commandAuthorized
) {
if (isRoomish && commandGate.shouldBlock) {
logVerbose(`Blocked slack control command from unauthorized sender ${senderId}`);
return null;
}
@@ -268,22 +267,17 @@ export async function prepareSlackMessage(params: {
: false;
// 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 mentionGate = resolveMentionGating({
const mentionGate = resolveMentionGatingWithBypass({
isGroup: isRoom,
requireMention: Boolean(shouldRequireMention),
canDetectMention,
wasMentioned,
implicitMention,
shouldBypassMention,
hasAnyMention,
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
});
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
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 { recordChannelActivity } from "../infra/channel-activity.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveMentionGating } from "../channels/mention-gating.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
import { resolveControlCommandGate } from "../channels/command-gating.js";
import {
buildGroupLabel,
buildSenderLabel,
@@ -269,10 +269,16 @@ export const buildTelegramMessageContext = async ({
senderUsername,
});
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
const hasControlCommandInMessage = hasControlCommand(msg.text ?? msg.caption ?? "", cfg, {
botUsername,
});
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }],
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
});
const commandAuthorized = commandGate.commandAuthorized;
const historyKey = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : undefined;
let placeholder = "";
@@ -300,11 +306,7 @@ export const buildTelegramMessageContext = async ({
const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some(
(ent) => ent.type === "mention",
);
if (
isGroup &&
hasControlCommand(msg.text ?? msg.caption ?? "", cfg, { botUsername }) &&
!commandAuthorized
) {
if (isGroup && commandGate.shouldBlock) {
logVerbose(`telegram: drop control command from unauthorized sender ${senderId ?? "unknown"}`);
return null;
}
@@ -325,20 +327,17 @@ export const buildTelegramMessageContext = async ({
const botId = primaryCtx.me?.id;
const replyFromId = msg.reply_to_message?.from?.id;
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 mentionGate = resolveMentionGating({
const mentionGate = resolveMentionGatingWithBypass({
isGroup,
requireMention: Boolean(requireMention),
canDetectMention,
wasMentioned,
implicitMention: isGroup && Boolean(requireMention) && implicitMention,
shouldBypassMention,
hasAnyMention,
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
});
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
if (isGroup && requireMention && canDetectMention) {

View File

@@ -1203,6 +1203,49 @@ describe("createTelegramBot", () => {
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 () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;