feat: move group mention gating to provider groups
This commit is contained in:
BIN
src/.DS_Store
vendored
Normal file
BIN
src/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -285,9 +285,10 @@ describe("trigger handling", () => {
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
routing: {
|
||||
groupChat: { requireMention: false },
|
||||
groupChat: {},
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
},
|
||||
|
||||
@@ -512,8 +512,48 @@ export async function getReplyFromConfig(
|
||||
sessionCtx.Body = queueCleaned;
|
||||
sessionCtx.BodyStripped = queueCleaned;
|
||||
|
||||
const resolveGroupRequireMention = () => {
|
||||
const surface =
|
||||
groupResolution?.surface ?? ctx.Surface?.trim().toLowerCase();
|
||||
const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, "");
|
||||
if (surface === "telegram") {
|
||||
if (groupId) {
|
||||
const groupConfig = cfg.telegram?.groups?.[groupId];
|
||||
if (typeof groupConfig?.requireMention === "boolean") {
|
||||
return groupConfig.requireMention;
|
||||
}
|
||||
}
|
||||
const groupDefault = cfg.telegram?.groups?.["*"]?.requireMention;
|
||||
if (typeof groupDefault === "boolean") return groupDefault;
|
||||
return true;
|
||||
}
|
||||
if (surface === "whatsapp") {
|
||||
if (groupId) {
|
||||
const groupConfig = cfg.whatsapp?.groups?.[groupId];
|
||||
if (typeof groupConfig?.requireMention === "boolean") {
|
||||
return groupConfig.requireMention;
|
||||
}
|
||||
}
|
||||
const groupDefault = cfg.whatsapp?.groups?.["*"]?.requireMention;
|
||||
if (typeof groupDefault === "boolean") return groupDefault;
|
||||
return true;
|
||||
}
|
||||
if (surface === "imessage") {
|
||||
if (groupId) {
|
||||
const groupConfig = cfg.imessage?.groups?.[groupId];
|
||||
if (typeof groupConfig?.requireMention === "boolean") {
|
||||
return groupConfig.requireMention;
|
||||
}
|
||||
}
|
||||
const groupDefault = cfg.imessage?.groups?.["*"]?.requireMention;
|
||||
if (typeof groupDefault === "boolean") return groupDefault;
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const defaultGroupActivation = () => {
|
||||
const requireMention = cfg.routing?.groupChat?.requireMention;
|
||||
const requireMention = resolveGroupRequireMention();
|
||||
return requireMention === false ? "always" : "mention";
|
||||
};
|
||||
|
||||
@@ -954,6 +994,10 @@ export async function getReplyFromConfig(
|
||||
const webLinked = await webAuthExists();
|
||||
const webAuthAgeMs = getWebAuthAgeMs();
|
||||
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
|
||||
const groupActivation = isGroup
|
||||
? normalizeGroupActivation(sessionEntry?.groupActivation) ??
|
||||
defaultGroupActivation()
|
||||
: undefined;
|
||||
const statusText = buildStatusMessage({
|
||||
agent: {
|
||||
model,
|
||||
@@ -966,6 +1010,7 @@ export async function getReplyFromConfig(
|
||||
sessionKey,
|
||||
sessionScope,
|
||||
storePath,
|
||||
groupActivation,
|
||||
resolvedThink: resolvedThinkLevel,
|
||||
resolvedVerbose: resolvedVerboseLevel,
|
||||
webLinked,
|
||||
|
||||
@@ -30,6 +30,7 @@ type StatusArgs = {
|
||||
sessionKey?: string;
|
||||
sessionScope?: SessionScope;
|
||||
storePath?: string;
|
||||
groupActivation?: "mention" | "always";
|
||||
resolvedThink?: ThinkLevel;
|
||||
resolvedVerbose?: VerboseLevel;
|
||||
now?: number;
|
||||
@@ -198,7 +199,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
Boolean(args.sessionKey?.includes(":channel:")) ||
|
||||
Boolean(args.sessionKey?.startsWith("group:"));
|
||||
const groupActivationLine = isGroupSession
|
||||
? `Group activation: ${entry?.groupActivation ?? "mention"}`
|
||||
? `Group activation: ${args.groupActivation ?? entry?.groupActivation ?? "mention"}`
|
||||
: undefined;
|
||||
|
||||
const contextLine = `Context: ${formatTokens(
|
||||
|
||||
@@ -502,6 +502,18 @@ describe("legacy config detection", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects routing.groupChat.requireMention", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
routing: { groupChat: { requireMention: false } },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("routing.groupChat.requireMention");
|
||||
}
|
||||
});
|
||||
|
||||
it("migrates routing.allowFrom to whatsapp.allowFrom", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
@@ -515,6 +527,52 @@ describe("legacy config detection", () => {
|
||||
expect(res.config?.routing?.allowFrom).toBeUndefined();
|
||||
});
|
||||
|
||||
it("migrates routing.groupChat.requireMention to whatsapp/telegram/imessage groups", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
routing: { groupChat: { requireMention: false } },
|
||||
});
|
||||
expect(res.changes).toContain(
|
||||
'Moved routing.groupChat.requireMention → whatsapp.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
'Moved routing.groupChat.requireMention → telegram.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
'Moved routing.groupChat.requireMention → imessage.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.config?.whatsapp?.groups?.["*"]?.requireMention).toBe(false);
|
||||
expect(res.config?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
||||
expect(res.config?.imessage?.groups?.["*"]?.requireMention).toBe(false);
|
||||
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects telegram.requireMention", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
telegram: { requireMention: true },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("telegram.requireMention");
|
||||
}
|
||||
});
|
||||
|
||||
it("migrates telegram.requireMention to telegram.groups.*.requireMention", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
telegram: { requireMention: false },
|
||||
});
|
||||
expect(res.changes).toContain(
|
||||
'Moved telegram.requireMention → telegram.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.config?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
||||
expect(res.config?.telegram?.requireMention).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces legacy issues in snapshot", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configPath = path.join(home, ".clawdis", "clawdis.json");
|
||||
|
||||
@@ -61,6 +61,12 @@ export type WebConfig = {
|
||||
export type WhatsAppConfig = {
|
||||
/** Optional allowlist for WhatsApp direct chats (E.164). */
|
||||
allowFrom?: string[];
|
||||
groups?: Record<
|
||||
string,
|
||||
{
|
||||
requireMention?: boolean;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export type BrowserConfig = {
|
||||
@@ -160,7 +166,12 @@ export type TelegramConfig = {
|
||||
botToken?: string;
|
||||
/** Path to file containing bot token (for secret managers like agenix) */
|
||||
tokenFile?: string;
|
||||
requireMention?: boolean;
|
||||
groups?: Record<
|
||||
string,
|
||||
{
|
||||
requireMention?: boolean;
|
||||
}
|
||||
>;
|
||||
allowFrom?: Array<string | number>;
|
||||
mediaMaxMb?: number;
|
||||
proxy?: string;
|
||||
@@ -257,6 +268,12 @@ export type IMessageConfig = {
|
||||
includeAttachments?: boolean;
|
||||
/** Max outbound media size in MB. */
|
||||
mediaMaxMb?: number;
|
||||
groups?: Record<
|
||||
string,
|
||||
{
|
||||
requireMention?: boolean;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export type QueueMode = "queue" | "interrupt";
|
||||
@@ -271,7 +288,6 @@ export type QueueModeBySurface = {
|
||||
};
|
||||
|
||||
export type GroupChatConfig = {
|
||||
requireMention?: boolean;
|
||||
mentionPatterns?: string[];
|
||||
historyLimit?: number;
|
||||
};
|
||||
@@ -628,7 +644,6 @@ const ModelsConfigSchema = z
|
||||
|
||||
const GroupChatSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
mentionPatterns: z.array(z.string()).optional(),
|
||||
historyLimit: z.number().int().positive().optional(),
|
||||
})
|
||||
@@ -930,6 +945,16 @@ const ClawdisSchema = z.object({
|
||||
whatsapp: z
|
||||
.object({
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
groups: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
telegram: z
|
||||
@@ -937,7 +962,16 @@ const ClawdisSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
botToken: z.string().optional(),
|
||||
tokenFile: z.string().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
groups: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
proxy: z.string().optional(),
|
||||
@@ -1025,6 +1059,16 @@ const ClawdisSchema = z.object({
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
includeAttachments: z.boolean().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
groups: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
bridge: z
|
||||
@@ -1183,6 +1227,16 @@ const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
||||
message:
|
||||
"routing.allowFrom was removed; use whatsapp.allowFrom instead (run `clawdis doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["routing", "groupChat", "requireMention"],
|
||||
message:
|
||||
'routing.groupChat.requireMention was removed; use whatsapp/telegram/imessage groups defaults (e.g. whatsapp.groups."*".requireMention) instead (run `clawdis doctor` to migrate).',
|
||||
},
|
||||
{
|
||||
path: ["telegram", "requireMention"],
|
||||
message:
|
||||
"telegram.requireMention was removed; use telegram.groups.\"*\".requireMention instead (run `clawdis doctor` to migrate).",
|
||||
},
|
||||
];
|
||||
|
||||
const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
|
||||
@@ -1216,6 +1270,105 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
|
||||
raw.whatsapp = whatsapp;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "routing.groupChat.requireMention->groups.*.requireMention",
|
||||
describe:
|
||||
"Move routing.groupChat.requireMention to whatsapp/telegram/imessage groups",
|
||||
apply: (raw, changes) => {
|
||||
const routing = raw.routing;
|
||||
if (!routing || typeof routing !== "object") return;
|
||||
const groupChat =
|
||||
(routing as Record<string, unknown>).groupChat &&
|
||||
typeof (routing as Record<string, unknown>).groupChat === "object"
|
||||
? ((routing as Record<string, unknown>)
|
||||
.groupChat as Record<string, unknown>)
|
||||
: null;
|
||||
if (!groupChat) return;
|
||||
const requireMention = groupChat.requireMention;
|
||||
if (requireMention === undefined) return;
|
||||
|
||||
const applyTo = (key: "whatsapp" | "telegram" | "imessage") => {
|
||||
const section =
|
||||
raw[key] && typeof raw[key] === "object"
|
||||
? (raw[key] as Record<string, unknown>)
|
||||
: {};
|
||||
const groups =
|
||||
section.groups && typeof section.groups === "object"
|
||||
? (section.groups as Record<string, unknown>)
|
||||
: {};
|
||||
const defaultKey = "*";
|
||||
const entry =
|
||||
groups[defaultKey] && typeof groups[defaultKey] === "object"
|
||||
? (groups[defaultKey] as Record<string, unknown>)
|
||||
: {};
|
||||
if (entry.requireMention === undefined) {
|
||||
entry.requireMention = requireMention;
|
||||
groups[defaultKey] = entry;
|
||||
section.groups = groups;
|
||||
raw[key] = section;
|
||||
changes.push(
|
||||
`Moved routing.groupChat.requireMention → ${key}.groups."*".requireMention.`,
|
||||
);
|
||||
} else {
|
||||
changes.push(
|
||||
`Removed routing.groupChat.requireMention (${key}.groups."*" already set).`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
applyTo("whatsapp");
|
||||
applyTo("telegram");
|
||||
applyTo("imessage");
|
||||
|
||||
delete groupChat.requireMention;
|
||||
if (Object.keys(groupChat).length === 0) {
|
||||
delete (routing as Record<string, unknown>).groupChat;
|
||||
}
|
||||
if (Object.keys(routing as Record<string, unknown>).length === 0) {
|
||||
delete raw.routing;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "telegram.requireMention->telegram.groups.*.requireMention",
|
||||
describe: "Move telegram.requireMention to telegram.groups.*.requireMention",
|
||||
apply: (raw, changes) => {
|
||||
const telegram = raw.telegram;
|
||||
if (!telegram || typeof telegram !== "object") return;
|
||||
const requireMention = (telegram as Record<string, unknown>).requireMention;
|
||||
if (requireMention === undefined) return;
|
||||
|
||||
const groups =
|
||||
(telegram as Record<string, unknown>).groups &&
|
||||
typeof (telegram as Record<string, unknown>).groups === "object"
|
||||
? ((telegram as Record<string, unknown>)
|
||||
.groups as Record<string, unknown>)
|
||||
: {};
|
||||
const defaultKey = "*";
|
||||
const entry =
|
||||
groups[defaultKey] && typeof groups[defaultKey] === "object"
|
||||
? (groups[defaultKey] as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
if (entry.requireMention === undefined) {
|
||||
entry.requireMention = requireMention;
|
||||
groups[defaultKey] = entry;
|
||||
(telegram as Record<string, unknown>).groups = groups;
|
||||
changes.push(
|
||||
'Moved telegram.requireMention → telegram.groups."*".requireMention.',
|
||||
);
|
||||
} else {
|
||||
changes.push(
|
||||
'Removed telegram.requireMention (telegram.groups."*" already set).',
|
||||
);
|
||||
}
|
||||
|
||||
delete (telegram as Record<string, unknown>).requireMention;
|
||||
if (Object.keys(telegram as Record<string, unknown>).length === 0) {
|
||||
delete raw.telegram;
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
|
||||
|
||||
@@ -2353,7 +2353,10 @@ describe("gateway server", () => {
|
||||
const prevToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
await writeConfigFile({
|
||||
telegram: { botToken: "123:abc", requireMention: false },
|
||||
telegram: {
|
||||
botToken: "123:abc",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
@@ -2370,7 +2373,7 @@ describe("gateway server", () => {
|
||||
const snap = await readConfigFileSnapshot();
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.config?.telegram?.botToken).toBeUndefined();
|
||||
expect(snap.config?.telegram?.requireMention).toBe(false);
|
||||
expect(snap.config?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
|
||||
@@ -59,10 +59,10 @@ async function waitForSubscribe() {
|
||||
|
||||
beforeEach(() => {
|
||||
config = {
|
||||
imessage: {},
|
||||
imessage: { groups: { "*": { requireMention: true } } },
|
||||
session: { mainKey: "main" },
|
||||
routing: {
|
||||
groupChat: { mentionPatterns: ["@clawd"], requireMention: true },
|
||||
groupChat: { mentionPatterns: ["@clawd"] },
|
||||
allowFrom: [],
|
||||
},
|
||||
};
|
||||
@@ -106,6 +106,35 @@ describe("monitorIMessageProvider", () => {
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows group messages when imessage groups default disables mention gating", async () => {
|
||||
config = {
|
||||
...config,
|
||||
imessage: { groups: { "*": { requireMention: false } } },
|
||||
};
|
||||
const run = monitorIMessageProvider();
|
||||
await waitForSubscribe();
|
||||
|
||||
notificationHandler?.({
|
||||
method: "message",
|
||||
params: {
|
||||
message: {
|
||||
id: 11,
|
||||
chat_id: 123,
|
||||
sender: "+15550001111",
|
||||
is_from_me: false,
|
||||
text: "hello group",
|
||||
is_group: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flush();
|
||||
closeResolve?.();
|
||||
await run;
|
||||
|
||||
expect(replyMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers group replies when mentioned", async () => {
|
||||
replyMock.mockResolvedValueOnce({ text: "yo" });
|
||||
const run = monitorIMessageProvider();
|
||||
|
||||
@@ -79,10 +79,22 @@ function resolveMentionRegexes(cfg: ReturnType<typeof loadConfig>): RegExp[] {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRequireMention(opts: MonitorIMessageOpts): boolean {
|
||||
const cfg = loadConfig();
|
||||
function resolveGroupRequireMention(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
opts: MonitorIMessageOpts,
|
||||
chatId?: number | null,
|
||||
): boolean {
|
||||
if (typeof opts.requireMention === "boolean") return opts.requireMention;
|
||||
return cfg.routing?.groupChat?.requireMention ?? true;
|
||||
const groupId = chatId != null ? String(chatId) : undefined;
|
||||
if (groupId) {
|
||||
const groupConfig = cfg.imessage?.groups?.[groupId];
|
||||
if (typeof groupConfig?.requireMention === "boolean") {
|
||||
return groupConfig.requireMention;
|
||||
}
|
||||
}
|
||||
const groupDefault = cfg.imessage?.groups?.["*"]?.requireMention;
|
||||
if (typeof groupDefault === "boolean") return groupDefault;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isMentioned(text: string, regexes: RegExp[]): boolean {
|
||||
@@ -133,7 +145,6 @@ export async function monitorIMessageProvider(
|
||||
const cfg = loadConfig();
|
||||
const allowFrom = resolveAllowFrom(opts);
|
||||
const mentionRegexes = resolveMentionRegexes(cfg);
|
||||
const requireMention = resolveRequireMention(opts);
|
||||
const includeAttachments =
|
||||
opts.includeAttachments ?? cfg.imessage?.includeAttachments ?? false;
|
||||
const mediaMaxBytes =
|
||||
@@ -170,6 +181,7 @@ export async function monitorIMessageProvider(
|
||||
|
||||
const messageText = (message.text ?? "").trim();
|
||||
const mentioned = isGroup ? isMentioned(messageText, mentionRegexes) : true;
|
||||
const requireMention = resolveGroupRequireMention(cfg, opts, chatId);
|
||||
if (isGroup && requireMention && !mentioned) {
|
||||
logVerbose(`imessage: skipping group message (no mention)`);
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as replyModule from "../auto-reply/reply.js";
|
||||
import { createTelegramBot } from "./bot.js";
|
||||
|
||||
const loadConfig = vi.fn(() => ({}));
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig,
|
||||
}));
|
||||
|
||||
const useSpy = vi.fn();
|
||||
const onSpy = vi.fn();
|
||||
const stopSpy = vi.fn();
|
||||
@@ -44,6 +49,10 @@ vi.mock("../auto-reply/reply.js", () => {
|
||||
});
|
||||
|
||||
describe("createTelegramBot", () => {
|
||||
beforeEach(() => {
|
||||
loadConfig.mockReturnValue({});
|
||||
});
|
||||
|
||||
it("installs grammY throttler", () => {
|
||||
createTelegramBot({ token: "tok" });
|
||||
expect(throttlerSpy).toHaveBeenCalledTimes(1);
|
||||
@@ -177,4 +186,122 @@ describe("createTelegramBot", () => {
|
||||
expect(call[2]?.reply_to_message_id).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips group messages without mention when requireMention is enabled", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: { groups: { "*": { requireMention: true } } },
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = onSpy.mock.calls[0][1] as (
|
||||
ctx: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 123, type: "group", title: "Dev Chat" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdis_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows per-group requireMention override", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: {
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
"123": { requireMention: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = onSpy.mock.calls[0][1] as (
|
||||
ctx: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 123, type: "group", title: "Dev Chat" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdis_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
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: {
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = onSpy.mock.calls[0][1] as (
|
||||
ctx: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 456, type: "group", title: "Ops" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdis_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not block group messages when bot username is unknown", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: { groups: { "*": { requireMention: true } } },
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = onSpy.mock.calls[0][1] as (
|
||||
ctx: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 789, type: "group", title: "No Me" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,12 +58,21 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
bot.api.config.use(apiThrottler());
|
||||
|
||||
const cfg = loadConfig();
|
||||
const requireMention =
|
||||
opts.requireMention ?? cfg.telegram?.requireMention ?? true;
|
||||
const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom;
|
||||
const mediaMaxBytes =
|
||||
(opts.mediaMaxMb ?? cfg.telegram?.mediaMaxMb ?? 5) * 1024 * 1024;
|
||||
const logger = getChildLogger({ module: "telegram-auto-reply" });
|
||||
const resolveGroupRequireMention = (chatId: string | number) => {
|
||||
const groupId = String(chatId);
|
||||
const groupConfig = cfg.telegram?.groups?.[groupId];
|
||||
if (typeof groupConfig?.requireMention === "boolean") {
|
||||
return groupConfig.requireMention;
|
||||
}
|
||||
const groupDefault = cfg.telegram?.groups?.["*"]?.requireMention;
|
||||
if (typeof groupDefault === "boolean") return groupDefault;
|
||||
if (typeof opts.requireMention === "boolean") return opts.requireMention;
|
||||
return true;
|
||||
};
|
||||
|
||||
bot.on("message", async (ctx) => {
|
||||
try {
|
||||
@@ -101,14 +110,16 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
}
|
||||
|
||||
const botUsername = ctx.me?.username?.toLowerCase();
|
||||
if (
|
||||
isGroup &&
|
||||
requireMention &&
|
||||
botUsername &&
|
||||
!hasBotMention(msg, botUsername)
|
||||
) {
|
||||
logger.info({ chatId, reason: "no-mention" }, "skipping group message");
|
||||
return;
|
||||
const wasMentioned =
|
||||
Boolean(botUsername) && hasBotMention(msg, botUsername);
|
||||
if (isGroup && resolveGroupRequireMention(chatId) && botUsername) {
|
||||
if (!wasMentioned) {
|
||||
logger.info(
|
||||
{ chatId, reason: "no-mention" },
|
||||
"skipping group message",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const media = await resolveMedia(
|
||||
@@ -150,6 +161,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
ReplyToBody: replyTarget?.body,
|
||||
ReplyToSender: replyTarget?.sender,
|
||||
Timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||
WasMentioned: isGroup && botUsername ? wasMentioned : undefined,
|
||||
MediaPath: media?.path,
|
||||
MediaType: media?.contentType,
|
||||
MediaUrl: media?.path,
|
||||
|
||||
@@ -1005,6 +1005,55 @@ describe("web auto-reply", () => {
|
||||
expect(payload.Body).toContain("[from: Bob (+222)]");
|
||||
});
|
||||
|
||||
it("allows group messages when whatsapp groups default disables mention gating", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
routing: { groupChat: { mentionPatterns: ["@clawd"] } },
|
||||
}));
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./inbound.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello group",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g-default-off",
|
||||
senderE164: "+111",
|
||||
senderName: "Alice",
|
||||
selfE164: "+999",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(1);
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
|
||||
it("supports always-on group activation with silent token and preserves history", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -1100,10 +1149,10 @@ describe("web auto-reply", () => {
|
||||
whatsapp: {
|
||||
// Self-chat heuristic: allowFrom includes selfE164.
|
||||
allowFrom: ["+999"],
|
||||
groups: { "*": { requireMention: true } },
|
||||
},
|
||||
routing: {
|
||||
groupChat: {
|
||||
requireMention: true,
|
||||
mentionPatterns: ["\\bclawd\\b"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -812,13 +812,23 @@ export async function monitorWebProvider(
|
||||
.join(", ");
|
||||
};
|
||||
|
||||
const resolveGroupRequireMentionFor = (conversationId: string) => {
|
||||
const groupConfig = cfg.whatsapp?.groups?.[conversationId];
|
||||
if (typeof groupConfig?.requireMention === "boolean") {
|
||||
return groupConfig.requireMention;
|
||||
}
|
||||
const groupDefault = cfg.whatsapp?.groups?.["*"]?.requireMention;
|
||||
if (typeof groupDefault === "boolean") return groupDefault;
|
||||
return true;
|
||||
};
|
||||
|
||||
const resolveGroupActivationFor = (conversationId: string) => {
|
||||
const key = conversationId.startsWith("group:")
|
||||
? conversationId
|
||||
: `whatsapp:group:${conversationId}`;
|
||||
const store = loadSessionStore(sessionStorePath);
|
||||
const entry = store[key];
|
||||
const requireMention = cfg.routing?.groupChat?.requireMention;
|
||||
const requireMention = resolveGroupRequireMentionFor(conversationId);
|
||||
const defaultActivation = requireMention === false ? "always" : "mention";
|
||||
return (
|
||||
normalizeGroupActivation(entry?.groupActivation) ?? defaultActivation
|
||||
|
||||
Reference in New Issue
Block a user