diff --git a/src/auto-reply/reply/groups.test.ts b/src/auto-reply/reply/groups.test.ts new file mode 100644 index 000000000..a7fde10a3 --- /dev/null +++ b/src/auto-reply/reply/groups.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import type { ClawdbotConfig } from "../../config/config.js"; +import type { GroupKeyResolution } from "../../config/sessions.js"; +import type { TemplateContext } from "../templating.js"; +import { resolveGroupRequireMention } from "./groups.js"; + +describe("resolveGroupRequireMention", () => { + it("respects Discord guild/channel requireMention settings", () => { + const cfg: ClawdbotConfig = { + discord: { + guilds: { + "145": { + requireMention: false, + channels: { + general: { allow: true }, + }, + }, + }, + }, + }; + const ctx: TemplateContext = { + Surface: "discord", + From: "group:123", + GroupRoom: "#general", + GroupSpace: "145", + }; + const groupResolution: GroupKeyResolution = { + surface: "discord", + id: "123", + chatType: "group", + }; + + expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe( + false, + ); + }); + + it("respects Slack channel requireMention settings", () => { + const cfg: ClawdbotConfig = { + slack: { + channels: { + C123: { requireMention: false }, + }, + }, + }; + const ctx: TemplateContext = { + Surface: "slack", + From: "slack:channel:C123", + GroupSubject: "#general", + }; + const groupResolution: GroupKeyResolution = { + surface: "slack", + id: "C123", + chatType: "group", + }; + + expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe( + false, + ); + }); +}); diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index 8f450b224..c94f0ef73 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -6,6 +6,43 @@ import type { import { normalizeGroupActivation } from "../group-activation.js"; import type { TemplateContext } from "../templating.js"; +function normalizeDiscordSlug(value?: string | null) { + if (!value) return ""; + let text = value.trim().toLowerCase(); + if (!text) return ""; + text = text.replace(/^[@#]+/, ""); + text = text.replace(/[\s_]+/g, "-"); + text = text.replace(/[^a-z0-9-]+/g, "-"); + text = text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, ""); + return text; +} + +function normalizeSlackSlug(raw?: string | null) { + const trimmed = raw?.trim().toLowerCase() ?? ""; + if (!trimmed) return ""; + const dashed = trimmed.replace(/\s+/g, "-"); + const cleaned = dashed.replace(/[^a-z0-9#@._+-]+/g, "-"); + return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, ""); +} + +function resolveDiscordGuildEntry( + guilds: NonNullable["guilds"], + groupSpace?: string, +) { + if (!guilds || Object.keys(guilds).length === 0) return null; + const space = groupSpace?.trim(); + if (space && guilds[space]) return guilds[space]; + const normalized = normalizeDiscordSlug(space); + if (normalized && guilds[normalized]) return guilds[normalized]; + if (normalized) { + const match = Object.values(guilds).find( + (entry) => normalizeDiscordSlug(entry?.slug ?? undefined) === normalized, + ); + if (match) return match; + } + return guilds["*"] ?? null; +} + export function resolveGroupRequireMention(params: { cfg: ClawdbotConfig; ctx: TemplateContext; @@ -14,6 +51,8 @@ export function resolveGroupRequireMention(params: { const { cfg, ctx, groupResolution } = params; const surface = groupResolution?.surface ?? ctx.Surface?.trim().toLowerCase(); const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, ""); + const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim(); + const groupSpace = ctx.GroupSpace?.trim(); if (surface === "telegram") { if (groupId) { const groupConfig = cfg.telegram?.groups?.[groupId]; @@ -47,6 +86,58 @@ export function resolveGroupRequireMention(params: { if (typeof groupDefault === "boolean") return groupDefault; return true; } + if (surface === "discord") { + const guildEntry = resolveDiscordGuildEntry( + cfg.discord?.guilds, + groupSpace, + ); + const channelEntries = guildEntry?.channels; + if (channelEntries && Object.keys(channelEntries).length > 0) { + const channelSlug = normalizeDiscordSlug(groupRoom); + const entry = + (groupId ? channelEntries[groupId] : undefined) ?? + (channelSlug + ? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`]) + : undefined) ?? + (groupRoom + ? channelEntries[normalizeDiscordSlug(groupRoom)] + : undefined); + if (entry && typeof entry.requireMention === "boolean") { + return entry.requireMention; + } + } + if (typeof guildEntry?.requireMention === "boolean") { + return guildEntry.requireMention; + } + return true; + } + if (surface === "slack") { + const channels = cfg.slack?.channels ?? {}; + const keys = Object.keys(channels); + if (keys.length === 0) return true; + const channelId = groupId?.trim(); + const channelName = groupRoom?.replace(/^#/, ""); + const normalizedName = normalizeSlackSlug(channelName); + const candidates = [ + channelId ?? "", + channelName ? `#${channelName}` : "", + channelName ?? "", + normalizedName, + ].filter(Boolean); + let matched: { requireMention?: boolean } | undefined; + for (const candidate of candidates) { + if (candidate && channels[candidate]) { + matched = channels[candidate]; + break; + } + } + const fallback = channels["*"]; + const resolved = matched ?? fallback; + if (typeof resolved?.requireMention === "boolean") { + return resolved.requireMention; + } + return true; + } return true; }