feat: add channel match metadata logs

Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-17 23:48:39 +00:00
parent 794bab45ff
commit 4c12c4fc04
10 changed files with 133 additions and 6 deletions

View File

@@ -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.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.

View File

@@ -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.<chatId>.topics.<threadId>` (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.

View File

@@ -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,

View File

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

View File

@@ -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";

View File

@@ -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", () => {

View File

@@ -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,

View File

@@ -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",
});
});
});

View File

@@ -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;

View File

@@ -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<typeof vi.fn>;
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<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(1);
});
it("honors groups default when no explicit group override exists", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;