diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 3aa01420b..53469f225 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -175,6 +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. - 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..users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 334d6fccb..be5866a22 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -216,6 +216,7 @@ Telegram forum topics include a `message_thread_id` per message. Clawdbot: - General topic (thread id `1`) is special: message sends omit `message_thread_id` (Telegram rejects it), but typing indicators still include it. - Exposes `MessageThreadId` + `IsForum` in template context for routing/templating. - Topic-specific configuration is available under `channels.telegram.groups..topics.` (skills, allowlists, auto-reply, system prompts, disable). +- Topic configs inherit group settings (requireMention, allowlists, skills, prompts, enabled) unless overridden per topic. Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps the DM session key unchanged, but still uses the thread id for replies/draft streaming when it is present. diff --git a/extensions/msteams/src/policy.test.ts b/extensions/msteams/src/policy.test.ts index f35b253b5..260a3b1ef 100644 --- a/extensions/msteams/src/policy.test.ts +++ b/extensions/msteams/src/policy.test.ts @@ -89,6 +89,18 @@ describe("msteams policy", () => { }); }); + it("inherits team mention settings when channel config is missing", () => { + const policy = resolveMSTeamsReplyPolicy({ + isDirectMessage: false, + globalConfig: { requireMention: true }, + teamConfig: { requireMention: false }, + }); + expect(policy).toEqual({ + requireMention: false, + replyStyle: "top-level", + }); + }); + it("uses explicit replyStyle even when requireMention defaults would differ", () => { const policy = resolveMSTeamsReplyPolicy({ isDirectMessage: false, diff --git a/src/channels/plugins/channel-config.ts b/src/channels/plugins/channel-config.ts new file mode 100644 index 000000000..a489f9708 --- /dev/null +++ b/src/channels/plugins/channel-config.ts @@ -0,0 +1,2 @@ +export type { ChannelEntryMatch } from "../channel-config.js"; +export { buildChannelKeyCandidates, resolveChannelEntryMatch } from "../channel-config.js"; diff --git a/src/channels/plugins/index.ts b/src/channels/plugins/index.ts index c3611056d..59f8d9b58 100644 --- a/src/channels/plugins/index.ts +++ b/src/channels/plugins/index.ts @@ -84,4 +84,9 @@ export { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, } from "./directory-config.js"; +export { + buildChannelKeyCandidates, + resolveChannelEntryMatch, + type ChannelEntryMatch, +} from "./channel-config.js"; export type { ChannelId, ChannelPlugin } from "./types.js"; diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index 2984c09ee..4dd2cbfba 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -249,6 +249,34 @@ describe("discord mention gating", () => { }), ).toBe(false); }); + + it("inherits parent channel mention rules for threads", () => { + const guildInfo: DiscordGuildEntryResolved = { + requireMention: true, + channels: { + "parent-1": { allow: true, requireMention: false }, + }, + }; + const channelConfig = resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId: "thread-1", + channelName: "topic", + channelSlug: "topic", + parentId: "parent-1", + parentName: "Parent", + parentSlug: "parent", + scope: "thread", + }); + expect(channelConfig?.matchSource).toBe("parent"); + expect( + resolveDiscordShouldRequireMention({ + isGuildMessage: true, + isThread: true, + channelConfig, + guildInfo, + }), + ).toBe(false); + }); }); describe("discord groupPolicy gating", () => { diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index 725ea797f..c4587878c 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -252,8 +252,11 @@ export async function preflightDiscordMessage( scope: threadChannel ? "thread" : "channel", }) : null; + const channelMatchMeta = `matchKey=${channelConfig?.matchKey ?? "none"} matchSource=${ + channelConfig?.matchSource ?? "none" + }`; if (isGuildMessage && channelConfig?.enabled === false) { - logVerbose(`Blocked discord channel ${message.channelId} (channel disabled)`); + logVerbose(`Blocked discord channel ${message.channelId} (channel disabled, ${channelMatchMeta})`); return null; } @@ -280,21 +283,28 @@ export async function preflightDiscordMessage( }) ) { if (params.groupPolicy === "disabled") { - logVerbose("discord: drop guild message (groupPolicy: disabled)"); + logVerbose(`discord: drop guild message (groupPolicy: disabled, ${channelMatchMeta})`); } else if (!channelAllowlistConfigured) { - logVerbose("discord: drop guild message (groupPolicy: allowlist, no channel allowlist)"); + logVerbose( + `discord: drop guild message (groupPolicy: allowlist, no channel allowlist, ${channelMatchMeta})`, + ); } else { logVerbose( - `Blocked discord channel ${message.channelId} not in guild channel allowlist (groupPolicy: allowlist)`, + `Blocked discord channel ${message.channelId} not in guild channel allowlist (groupPolicy: allowlist, ${channelMatchMeta})`, ); } return null; } if (isGuildMessage && channelConfig?.allowed === false) { - logVerbose(`Blocked discord channel ${message.channelId} not in guild channel allowlist`); + logVerbose( + `Blocked discord channel ${message.channelId} not in guild channel allowlist (${channelMatchMeta})`, + ); return null; } + if (isGuildMessage) { + logVerbose(`discord: allow channel ${message.channelId} (${channelMatchMeta})`); + } const textForHistory = resolveDiscordMessageText(message, { includeForwarded: true, diff --git a/src/slack/monitor/channel-config.test.ts b/src/slack/monitor/channel-config.test.ts index 6d71a3ab3..aa59a6971 100644 --- a/src/slack/monitor/channel-config.test.ts +++ b/src/slack/monitor/channel-config.test.ts @@ -28,4 +28,18 @@ describe("resolveSlackChannelConfig", () => { }); expect(res).toMatchObject({ requireMention: true }); }); + + it("uses wildcard entries when no direct channel config exists", () => { + const res = resolveSlackChannelConfig({ + channelId: "C1", + channels: { "*": { allow: true, requireMention: false } }, + defaultRequireMention: true, + }); + expect(res).toMatchObject({ + allowed: true, + requireMention: false, + matchKey: "*", + matchSource: "wildcard", + }); + }); }); diff --git a/src/slack/monitor/context.ts b/src/slack/monitor/context.ts index 855908a5e..caeaac9b3 100644 --- a/src/slack/monitor/context.ts +++ b/src/slack/monitor/context.ts @@ -310,6 +310,9 @@ export function createSlackMonitorContext(params: { channels: params.channelsConfig, defaultRequireMention, }); + const channelMatchMeta = `matchKey=${channelConfig?.matchKey ?? "none"} matchSource=${ + channelConfig?.matchSource ?? "none" + }`; const channelAllowed = channelConfig?.allowed !== false; const channelAllowlistConfigured = Boolean(params.channelsConfig) && Object.keys(params.channelsConfig ?? {}).length > 0; @@ -320,9 +323,16 @@ export function createSlackMonitorContext(params: { channelAllowed, }) ) { + logVerbose( + `slack: drop channel ${p.channelId} (groupPolicy=${params.groupPolicy}, ${channelMatchMeta})`, + ); return false; } - if (!channelAllowed) return false; + if (!channelAllowed) { + logVerbose(`slack: drop channel ${p.channelId} (${channelMatchMeta})`); + return false; + } + logVerbose(`slack: allow channel ${p.channelId} (${channelMatchMeta})`); } return true; diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index b80ec2fcd..f934197e6 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -1159,6 +1159,50 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); }); + it("inherits group allowlist + requireMention in topics", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + groups: { + "-1001234567890": { + requireMention: false, + allowFrom: ["123456789"], + topics: { + "99": {}, + }, + }, + }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + 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(1); + }); + it("honors groups default when no explicit group override exists", async () => { onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType;