fix: unify mention gating across providers
This commit is contained in:
@@ -36,6 +36,7 @@
|
|||||||
- WhatsApp: set sender E.164 for direct chats so owner commands work in DMs.
|
- WhatsApp: set sender E.164 for direct chats so owner commands work in DMs.
|
||||||
- Slack: keep auto-replies in the original thread when responding to thread messages. Thanks @scald for PR #251.
|
- Slack: keep auto-replies in the original thread when responding to thread messages. Thanks @scald for PR #251.
|
||||||
- Control UI: avoid Slack config ReferenceError by reading slack config snapshots. Thanks @sreekaransrinath for PR #249.
|
- Control UI: avoid Slack config ReferenceError by reading slack config snapshots. Thanks @sreekaransrinath for PR #249.
|
||||||
|
- Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks @regenrek for PR #242.
|
||||||
|
|
||||||
### Maintenance
|
### Maintenance
|
||||||
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
|
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import type { MsgContext } from "../templating.js";
|
import type { MsgContext } from "../templating.js";
|
||||||
|
|
||||||
|
export function buildMentionRegexes(cfg: ClawdbotConfig | undefined): RegExp[] {
|
||||||
|
const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? [];
|
||||||
|
return patterns
|
||||||
|
.map((pattern) => {
|
||||||
|
try {
|
||||||
|
return new RegExp(pattern, "i");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((value): value is RegExp => Boolean(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMentionText(text: string): string {
|
||||||
|
return (text ?? "")
|
||||||
|
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesMentionPatterns(
|
||||||
|
text: string,
|
||||||
|
mentionRegexes: RegExp[],
|
||||||
|
): boolean {
|
||||||
|
if (mentionRegexes.length === 0) return false;
|
||||||
|
const cleaned = normalizeMentionText(text ?? "");
|
||||||
|
if (!cleaned) return false;
|
||||||
|
return mentionRegexes.some((re) => re.test(cleaned));
|
||||||
|
}
|
||||||
|
|
||||||
export function stripStructuralPrefixes(text: string): string {
|
export function stripStructuralPrefixes(text: string): string {
|
||||||
// Ignore wrapper labels, timestamps, and sender prefixes so directive-only
|
// Ignore wrapper labels, timestamps, and sender prefixes so directive-only
|
||||||
// detection still works in group batches that include history/context.
|
// detection still works in group batches that include history/context.
|
||||||
|
|||||||
@@ -147,4 +147,59 @@ describe("monitorDiscordProvider tool results", () => {
|
|||||||
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
|
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
|
||||||
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
|
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts guild messages when mentionPatterns match", async () => {
|
||||||
|
config = {
|
||||||
|
messages: { responsePrefix: "PFX" },
|
||||||
|
discord: {
|
||||||
|
dm: { enabled: true },
|
||||||
|
guilds: { "*": { requireMention: true } },
|
||||||
|
},
|
||||||
|
routing: {
|
||||||
|
allowFrom: [],
|
||||||
|
groupChat: { mentionPatterns: ["\\bclawd\\b"] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
replyMock.mockResolvedValue({ text: "hi" });
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const run = monitorDiscordProvider({
|
||||||
|
token: "token",
|
||||||
|
abortSignal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const discord = await import("discord.js");
|
||||||
|
const client = await waitForClient();
|
||||||
|
if (!client) throw new Error("Discord client not created");
|
||||||
|
|
||||||
|
client.emit(discord.Events.MessageCreate, {
|
||||||
|
id: "m2",
|
||||||
|
content: "clawd: hello",
|
||||||
|
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
|
||||||
|
member: { displayName: "Ada" },
|
||||||
|
channelId: "c1",
|
||||||
|
channel: {
|
||||||
|
type: discord.ChannelType.GuildText,
|
||||||
|
name: "general",
|
||||||
|
isSendable: () => false,
|
||||||
|
},
|
||||||
|
guild: { id: "g1", name: "Guild" },
|
||||||
|
mentions: {
|
||||||
|
has: () => false,
|
||||||
|
everyone: false,
|
||||||
|
users: { size: 0 },
|
||||||
|
roles: { size: 0 },
|
||||||
|
},
|
||||||
|
attachments: { first: () => undefined },
|
||||||
|
type: discord.MessageType.Default,
|
||||||
|
createdTimestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
controller.abort();
|
||||||
|
await run;
|
||||||
|
|
||||||
|
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||||
|
import {
|
||||||
|
buildMentionRegexes,
|
||||||
|
matchesMentionPatterns,
|
||||||
|
} from "../auto-reply/reply/mentions.js";
|
||||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
@@ -140,6 +144,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
|
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "discord");
|
const textLimit = resolveTextChunkLimit(cfg, "discord");
|
||||||
|
const mentionRegexes = buildMentionRegexes(cfg);
|
||||||
const historyLimit = Math.max(
|
const historyLimit = Math.max(
|
||||||
0,
|
0,
|
||||||
opts.historyLimit ?? cfg.discord?.historyLimit ?? 20,
|
opts.historyLimit ?? cfg.discord?.historyLimit ?? 20,
|
||||||
@@ -202,13 +207,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const botId = client.user?.id;
|
const botId = client.user?.id;
|
||||||
const wasMentioned =
|
|
||||||
!isDirectMessage && Boolean(botId && message.mentions.has(botId));
|
|
||||||
const forwardedSnapshot = resolveForwardedSnapshot(message);
|
const forwardedSnapshot = resolveForwardedSnapshot(message);
|
||||||
const forwardedText = forwardedSnapshot
|
const forwardedText = forwardedSnapshot
|
||||||
? resolveDiscordSnapshotText(forwardedSnapshot.snapshot)
|
? resolveDiscordSnapshotText(forwardedSnapshot.snapshot)
|
||||||
: "";
|
: "";
|
||||||
const baseText = resolveDiscordMessageText(message, forwardedText);
|
const baseText = resolveDiscordMessageText(message, forwardedText);
|
||||||
|
const wasMentioned =
|
||||||
|
!isDirectMessage &&
|
||||||
|
(Boolean(botId && message.mentions.has(botId)) ||
|
||||||
|
matchesMentionPatterns(baseText, mentionRegexes));
|
||||||
if (shouldLogVerbose()) {
|
if (shouldLogVerbose()) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`discord: inbound id=${message.id} guild=${message.guild?.id ?? "dm"} channel=${message.channelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${baseText ? "yes" : "no"}`,
|
`discord: inbound id=${message.id} guild=${message.guild?.id ?? "dm"} channel=${message.channelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${baseText ? "yes" : "no"}`,
|
||||||
@@ -309,8 +316,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
!hasAnyMention &&
|
!hasAnyMention &&
|
||||||
commandAuthorized &&
|
commandAuthorized &&
|
||||||
hasControlCommand(baseText);
|
hasControlCommand(baseText);
|
||||||
if (isGuildMessage && resolvedRequireMention) {
|
const canDetectMention = Boolean(botId) || mentionRegexes.length > 0;
|
||||||
if (botId && !wasMentioned && !shouldBypassMention) {
|
if (isGuildMessage && resolvedRequireMention && canDetectMention) {
|
||||||
|
if (!wasMentioned && !shouldBypassMention) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`discord: drop guild message (mention required, botId=${botId})`,
|
`discord: drop guild message (mention required, botId=${botId})`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||||
|
import {
|
||||||
|
buildMentionRegexes,
|
||||||
|
matchesMentionPatterns,
|
||||||
|
} from "../auto-reply/reply/mentions.js";
|
||||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
@@ -67,20 +71,6 @@ function resolveAllowFrom(opts: MonitorIMessageOpts): string[] {
|
|||||||
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMentionRegexes(cfg: ReturnType<typeof loadConfig>): RegExp[] {
|
|
||||||
return (
|
|
||||||
cfg.routing?.groupChat?.mentionPatterns
|
|
||||||
?.map((pattern) => {
|
|
||||||
try {
|
|
||||||
return new RegExp(pattern, "i");
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((val): val is RegExp => Boolean(val)) ?? []
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveGroupRequireMention(
|
function resolveGroupRequireMention(
|
||||||
cfg: ReturnType<typeof loadConfig>,
|
cfg: ReturnType<typeof loadConfig>,
|
||||||
opts: MonitorIMessageOpts,
|
opts: MonitorIMessageOpts,
|
||||||
@@ -99,14 +89,6 @@ function resolveGroupRequireMention(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMentioned(text: string, regexes: RegExp[]): boolean {
|
|
||||||
if (!text) return false;
|
|
||||||
const cleaned = text
|
|
||||||
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "")
|
|
||||||
.toLowerCase();
|
|
||||||
return regexes.some((re) => re.test(cleaned));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deliverReplies(params: {
|
async function deliverReplies(params: {
|
||||||
replies: ReplyPayload[];
|
replies: ReplyPayload[];
|
||||||
target: string;
|
target: string;
|
||||||
@@ -148,7 +130,7 @@ export async function monitorIMessageProvider(
|
|||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "imessage");
|
const textLimit = resolveTextChunkLimit(cfg, "imessage");
|
||||||
const allowFrom = resolveAllowFrom(opts);
|
const allowFrom = resolveAllowFrom(opts);
|
||||||
const mentionRegexes = resolveMentionRegexes(cfg);
|
const mentionRegexes = buildMentionRegexes(cfg);
|
||||||
const includeAttachments =
|
const includeAttachments =
|
||||||
opts.includeAttachments ?? cfg.imessage?.includeAttachments ?? false;
|
opts.includeAttachments ?? cfg.imessage?.includeAttachments ?? false;
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
@@ -183,15 +165,24 @@ export async function monitorIMessageProvider(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messageText = (message.text ?? "").trim();
|
const messageText = (message.text ?? "").trim();
|
||||||
const mentioned = isGroup ? isMentioned(messageText, mentionRegexes) : true;
|
const mentioned = isGroup
|
||||||
|
? matchesMentionPatterns(messageText, mentionRegexes)
|
||||||
|
: true;
|
||||||
const requireMention = resolveGroupRequireMention(cfg, opts, chatId);
|
const requireMention = resolveGroupRequireMention(cfg, opts, chatId);
|
||||||
|
const canDetectMention = mentionRegexes.length > 0;
|
||||||
const shouldBypassMention =
|
const shouldBypassMention =
|
||||||
isGroup &&
|
isGroup &&
|
||||||
requireMention &&
|
requireMention &&
|
||||||
!mentioned &&
|
!mentioned &&
|
||||||
commandAuthorized &&
|
commandAuthorized &&
|
||||||
hasControlCommand(messageText);
|
hasControlCommand(messageText);
|
||||||
if (isGroup && requireMention && !mentioned && !shouldBypassMention) {
|
if (
|
||||||
|
isGroup &&
|
||||||
|
requireMention &&
|
||||||
|
canDetectMention &&
|
||||||
|
!mentioned &&
|
||||||
|
!shouldBypassMention
|
||||||
|
) {
|
||||||
logVerbose(`imessage: skipping group message (no mention)`);
|
logVerbose(`imessage: skipping group message (no mention)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,51 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
|
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts channel messages when mentionPatterns match", async () => {
|
||||||
|
config = {
|
||||||
|
messages: { responsePrefix: "PFX" },
|
||||||
|
slack: {
|
||||||
|
dm: { enabled: true },
|
||||||
|
groupDm: { enabled: false },
|
||||||
|
channels: { C1: { allow: true, requireMention: true } },
|
||||||
|
},
|
||||||
|
routing: {
|
||||||
|
allowFrom: [],
|
||||||
|
groupChat: { mentionPatterns: ["\\bclawd\\b"] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
replyMock.mockResolvedValue({ text: "hi" });
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const run = monitorSlackProvider({
|
||||||
|
botToken: "bot-token",
|
||||||
|
appToken: "app-token",
|
||||||
|
abortSignal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForEvent("message");
|
||||||
|
const handler = getSlackHandlers()?.get("message");
|
||||||
|
if (!handler) throw new Error("Slack message handler not registered");
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
event: {
|
||||||
|
type: "message",
|
||||||
|
user: "U1",
|
||||||
|
text: "clawd: hello",
|
||||||
|
ts: "123",
|
||||||
|
channel: "C1",
|
||||||
|
channel_type: "channel",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
controller.abort();
|
||||||
|
await run;
|
||||||
|
|
||||||
|
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("threads replies when incoming message is in a thread", async () => {
|
it("threads replies when incoming message is in a thread", async () => {
|
||||||
replyMock.mockResolvedValue({ text: "thread reply" });
|
replyMock.mockResolvedValue({ text: "thread reply" });
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import bolt from "@slack/bolt";
|
|||||||
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||||
|
import {
|
||||||
|
buildMentionRegexes,
|
||||||
|
matchesMentionPatterns,
|
||||||
|
} from "../auto-reply/reply/mentions.js";
|
||||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||||
@@ -379,6 +383,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
opts.slashCommand ?? cfg.slack?.slashCommand,
|
opts.slashCommand ?? cfg.slack?.slashCommand,
|
||||||
);
|
);
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "slack");
|
const textLimit = resolveTextChunkLimit(cfg, "slack");
|
||||||
|
const mentionRegexes = buildMentionRegexes(cfg);
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
(opts.mediaMaxMb ?? cfg.slack?.mediaMaxMb ?? 20) * 1024 * 1024;
|
(opts.mediaMaxMb ?? cfg.slack?.mediaMaxMb ?? 20) * 1024 * 1024;
|
||||||
|
|
||||||
@@ -581,7 +586,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
const wasMentioned =
|
const wasMentioned =
|
||||||
opts.wasMentioned ??
|
opts.wasMentioned ??
|
||||||
(!isDirectMessage &&
|
(!isDirectMessage &&
|
||||||
Boolean(botUserId && message.text?.includes(`<@${botUserId}>`)));
|
(Boolean(botUserId && message.text?.includes(`<@${botUserId}>`)) ||
|
||||||
|
matchesMentionPatterns(message.text ?? "", mentionRegexes)));
|
||||||
const sender = await resolveUserName(message.user);
|
const sender = await resolveUserName(message.user);
|
||||||
const senderName = sender?.name ?? message.user;
|
const senderName = sender?.name ?? message.user;
|
||||||
const allowList = normalizeAllowListLower(allowFrom);
|
const allowList = normalizeAllowListLower(allowFrom);
|
||||||
@@ -600,9 +606,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
!hasAnyMention &&
|
!hasAnyMention &&
|
||||||
commandAuthorized &&
|
commandAuthorized &&
|
||||||
hasControlCommand(message.text ?? "");
|
hasControlCommand(message.text ?? "");
|
||||||
|
const canDetectMention = Boolean(botUserId) || mentionRegexes.length > 0;
|
||||||
if (
|
if (
|
||||||
isRoom &&
|
isRoom &&
|
||||||
channelConfig?.requireMention &&
|
channelConfig?.requireMention &&
|
||||||
|
canDetectMention &&
|
||||||
!wasMentioned &&
|
!wasMentioned &&
|
||||||
!shouldBypassMention
|
!shouldBypassMention
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -126,6 +126,73 @@ describe("createTelegramBot", () => {
|
|||||||
expect(sendChatActionSpy).toHaveBeenCalledWith(42, "typing");
|
expect(sendChatActionSpy).toHaveBeenCalledWith(42, "typing");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
|
||||||
|
onSpy.mockReset();
|
||||||
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
replySpy.mockReset();
|
||||||
|
|
||||||
|
loadConfig.mockReturnValue({
|
||||||
|
identity: { name: "Bert" },
|
||||||
|
routing: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
|
||||||
|
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: 7, type: "group", title: "Test Group" },
|
||||||
|
text: "bert: introduce yourself",
|
||||||
|
date: 1736380800,
|
||||||
|
message_id: 1,
|
||||||
|
from: { id: 9, first_name: "Ada" },
|
||||||
|
},
|
||||||
|
me: { username: "clawdbot_bot" },
|
||||||
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = replySpy.mock.calls[0][0];
|
||||||
|
expect(payload.WasMentioned).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips group messages when requireMention is enabled and no mention matches", async () => {
|
||||||
|
onSpy.mockReset();
|
||||||
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
replySpy.mockReset();
|
||||||
|
|
||||||
|
loadConfig.mockReturnValue({
|
||||||
|
routing: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
|
||||||
|
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: 7, type: "group", title: "Test Group" },
|
||||||
|
text: "hello everyone",
|
||||||
|
date: 1736380800,
|
||||||
|
message_id: 2,
|
||||||
|
from: { id: 9, first_name: "Ada" },
|
||||||
|
},
|
||||||
|
me: { username: "clawdbot_bot" },
|
||||||
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replySpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("includes reply-to context when a Telegram reply is received", async () => {
|
it("includes reply-to context when a Telegram reply is received", async () => {
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
sendMessageSpy.mockReset();
|
sendMessageSpy.mockReset();
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { Bot, InputFile, webhookCallback } from "grammy";
|
|||||||
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||||
|
import {
|
||||||
|
buildMentionRegexes,
|
||||||
|
matchesMentionPatterns,
|
||||||
|
} from "../auto-reply/reply/mentions.js";
|
||||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
@@ -67,6 +71,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
(opts.mediaMaxMb ?? cfg.telegram?.mediaMaxMb ?? 5) * 1024 * 1024;
|
(opts.mediaMaxMb ?? cfg.telegram?.mediaMaxMb ?? 5) * 1024 * 1024;
|
||||||
const logger = getChildLogger({ module: "telegram-auto-reply" });
|
const logger = getChildLogger({ module: "telegram-auto-reply" });
|
||||||
|
const mentionRegexes = buildMentionRegexes(cfg);
|
||||||
const resolveGroupRequireMention = (chatId: string | number) => {
|
const resolveGroupRequireMention = (chatId: string | number) => {
|
||||||
const groupId = String(chatId);
|
const groupId = String(chatId);
|
||||||
const groupConfig = cfg.telegram?.groups?.[groupId];
|
const groupConfig = cfg.telegram?.groups?.[groupId];
|
||||||
@@ -132,7 +137,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
entry.toLowerCase() === `@${senderUsername.toLowerCase()}`,
|
entry.toLowerCase() === `@${senderUsername.toLowerCase()}`,
|
||||||
));
|
));
|
||||||
const wasMentioned =
|
const wasMentioned =
|
||||||
Boolean(botUsername) && hasBotMention(msg, botUsername);
|
(Boolean(botUsername) && hasBotMention(msg, botUsername)) ||
|
||||||
|
matchesMentionPatterns(msg.text ?? msg.caption ?? "", mentionRegexes);
|
||||||
const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some(
|
const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some(
|
||||||
(ent) => ent.type === "mention",
|
(ent) => ent.type === "mention",
|
||||||
);
|
);
|
||||||
@@ -143,7 +149,9 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
!hasAnyMention &&
|
!hasAnyMention &&
|
||||||
commandAuthorized &&
|
commandAuthorized &&
|
||||||
hasControlCommand(msg.text ?? msg.caption ?? "");
|
hasControlCommand(msg.text ?? msg.caption ?? "");
|
||||||
if (isGroup && resolveGroupRequireMention(chatId) && botUsername) {
|
const canDetectMention =
|
||||||
|
Boolean(botUsername) || mentionRegexes.length > 0;
|
||||||
|
if (isGroup && resolveGroupRequireMention(chatId) && canDetectMention) {
|
||||||
if (!wasMentioned && !shouldBypassMention) {
|
if (!wasMentioned && !shouldBypassMention) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ chatId, reason: "no-mention" },
|
{ chatId, reason: "no-mention" },
|
||||||
@@ -196,7 +204,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
ReplyToBody: replyTarget?.body,
|
ReplyToBody: replyTarget?.body,
|
||||||
ReplyToSender: replyTarget?.sender,
|
ReplyToSender: replyTarget?.sender,
|
||||||
Timestamp: msg.date ? msg.date * 1000 : undefined,
|
Timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||||
WasMentioned: isGroup && botUsername ? wasMentioned : undefined,
|
WasMentioned: isGroup ? wasMentioned : undefined,
|
||||||
MediaPath: media?.path,
|
MediaPath: media?.path,
|
||||||
MediaType: media?.contentType,
|
MediaType: media?.contentType,
|
||||||
MediaUrl: media?.path,
|
MediaUrl: media?.path,
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
HEARTBEAT_PROMPT,
|
HEARTBEAT_PROMPT,
|
||||||
stripHeartbeatToken,
|
stripHeartbeatToken,
|
||||||
} from "../auto-reply/heartbeat.js";
|
} from "../auto-reply/heartbeat.js";
|
||||||
|
import {
|
||||||
|
buildMentionRegexes,
|
||||||
|
normalizeMentionText,
|
||||||
|
} from "../auto-reply/reply/mentions.js";
|
||||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||||
@@ -147,17 +151,7 @@ type MentionConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
|
function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
|
||||||
const gc = cfg.routing?.groupChat;
|
const mentionRegexes = buildMentionRegexes(cfg);
|
||||||
const mentionRegexes =
|
|
||||||
gc?.mentionPatterns
|
|
||||||
?.map((p) => {
|
|
||||||
try {
|
|
||||||
return new RegExp(p, "i");
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((r): r is RegExp => Boolean(r)) ?? [];
|
|
||||||
return { mentionRegexes, allowFrom: cfg.whatsapp?.allowFrom };
|
return { mentionRegexes, allowFrom: cfg.whatsapp?.allowFrom };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,10 +160,8 @@ function isBotMentioned(
|
|||||||
mentionCfg: MentionConfig,
|
mentionCfg: MentionConfig,
|
||||||
): boolean {
|
): boolean {
|
||||||
const clean = (text: string) =>
|
const clean = (text: string) =>
|
||||||
text
|
// Remove zero-width and directionality markers WhatsApp injects around display names
|
||||||
// Remove zero-width and directionality markers WhatsApp injects around display names
|
normalizeMentionText(text);
|
||||||
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "")
|
|
||||||
.toLowerCase();
|
|
||||||
|
|
||||||
const isSelfChat = isSelfChatMode(msg.selfE164, mentionCfg.allowFrom);
|
const isSelfChat = isSelfChatMode(msg.selfE164, mentionCfg.allowFrom);
|
||||||
|
|
||||||
@@ -212,9 +204,7 @@ function debugMention(
|
|||||||
const details = {
|
const details = {
|
||||||
from: msg.from,
|
from: msg.from,
|
||||||
body: msg.body,
|
body: msg.body,
|
||||||
bodyClean: msg.body
|
bodyClean: normalizeMentionText(msg.body),
|
||||||
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "")
|
|
||||||
.toLowerCase(),
|
|
||||||
mentionedJids: msg.mentionedJids ?? null,
|
mentionedJids: msg.mentionedJids ?? null,
|
||||||
selfJid: msg.selfJid ?? null,
|
selfJid: msg.selfJid ?? null,
|
||||||
selfE164: msg.selfE164 ?? null,
|
selfE164: msg.selfE164 ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user