Files
clawdbot/src/auto-reply/reply/groups.ts
2026-01-08 08:49:16 +01:00

238 lines
8.2 KiB
TypeScript

import type { ClawdbotConfig } from "../../config/config.js";
import { resolveProviderGroupRequireMention } from "../../config/group-policy.js";
import type {
GroupKeyResolution,
SessionEntry,
} from "../../config/sessions.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
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 parseTelegramGroupId(value?: string | null) {
const raw = value?.trim() ?? "";
if (!raw) return { chatId: undefined, topicId: undefined };
const parts = raw.split(":").filter(Boolean);
if (
parts.length >= 3 &&
parts[1] === "topic" &&
/^-?\d+$/.test(parts[0]) &&
/^\d+$/.test(parts[2])
) {
return { chatId: parts[0], topicId: parts[2] };
}
if (parts.length >= 2 && /^-?\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
return { chatId: parts[0], topicId: parts[1] };
}
return { chatId: raw, topicId: undefined };
}
function resolveTelegramRequireMention(params: {
cfg: ClawdbotConfig;
chatId?: string;
topicId?: string;
}): boolean | undefined {
const { cfg, chatId, topicId } = params;
if (!chatId) return undefined;
const groupConfig = cfg.telegram?.groups?.[chatId];
const groupDefault = cfg.telegram?.groups?.["*"];
const topicConfig =
topicId && groupConfig?.topics ? groupConfig.topics[topicId] : undefined;
const defaultTopicConfig =
topicId && groupDefault?.topics ? groupDefault.topics[topicId] : undefined;
if (typeof topicConfig?.requireMention === "boolean") {
return topicConfig.requireMention;
}
if (typeof defaultTopicConfig?.requireMention === "boolean") {
return defaultTopicConfig.requireMention;
}
if (typeof groupConfig?.requireMention === "boolean") {
return groupConfig.requireMention;
}
if (typeof groupDefault?.requireMention === "boolean") {
return groupDefault.requireMention;
}
return undefined;
}
function resolveDiscordGuildEntry(
guilds: NonNullable<ClawdbotConfig["discord"]>["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;
groupResolution?: GroupKeyResolution;
}): boolean {
const { cfg, ctx, groupResolution } = params;
const provider =
groupResolution?.provider ?? ctx.Provider?.trim().toLowerCase();
const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, "");
const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim();
const groupSpace = ctx.GroupSpace?.trim();
if (provider === "telegram") {
const { chatId, topicId } = parseTelegramGroupId(groupId);
const requireMention = resolveTelegramRequireMention({
cfg,
chatId,
topicId,
});
if (typeof requireMention === "boolean") return requireMention;
return resolveProviderGroupRequireMention({
cfg,
provider,
groupId: chatId ?? groupId,
});
}
if (provider === "whatsapp" || provider === "imessage") {
return resolveProviderGroupRequireMention({
cfg,
provider,
groupId,
});
}
if (provider === "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 (provider === "slack") {
const account = resolveSlackAccount({ cfg, accountId: ctx.AccountId });
const channels = account.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;
}
export function defaultGroupActivation(
requireMention: boolean,
): "always" | "mention" {
return requireMention === false ? "always" : "mention";
}
export function buildGroupIntro(params: {
sessionCtx: TemplateContext;
sessionEntry?: SessionEntry;
defaultActivation: "always" | "mention";
silentToken: string;
}): string {
const activation =
normalizeGroupActivation(params.sessionEntry?.groupActivation) ??
params.defaultActivation;
const subject = params.sessionCtx.GroupSubject?.trim();
const members = params.sessionCtx.GroupMembers?.trim();
const provider = params.sessionCtx.Provider?.trim().toLowerCase();
const providerLabel = (() => {
if (!provider) return "chat";
if (provider === "whatsapp") return "WhatsApp";
if (provider === "telegram") return "Telegram";
if (provider === "discord") return "Discord";
if (provider === "webchat") return "WebChat";
return `${provider.at(0)?.toUpperCase() ?? ""}${provider.slice(1)}`;
})();
const subjectLine = subject
? `You are replying inside the ${providerLabel} group "${subject}".`
: `You are replying inside a ${providerLabel} group chat.`;
const membersLine = members ? `Group members: ${members}.` : undefined;
const activationLine =
activation === "always"
? "Activation: always-on (you receive every group message)."
: "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included).";
const silenceLine =
activation === "always"
? `If no response is needed, reply with exactly "${params.silentToken}" (no other text) so Clawdbot stays silent.`
: undefined;
const cautionLine =
activation === "always"
? "Be extremely selective: reply only when directly addressed or clearly helpful. Otherwise stay silent."
: undefined;
const lurkLine =
"Be a good group participant: mostly lurk and follow the conversation; reply only when directly addressed or you can add clear value. Emoji reactions are welcome when available.";
return [
subjectLine,
membersLine,
activationLine,
silenceLine,
cautionLine,
lurkLine,
]
.filter(Boolean)
.join(" ")
.concat(" Address the specific sender noted in the message context.");
}