fix: treat reply-to-bot as implicit mention across channels
This commit is contained in:
@@ -90,6 +90,7 @@
|
|||||||
- Discord: truncate skill command descriptions for slash command limits. (#1018) — thanks @evalexpr.
|
- Discord: truncate skill command descriptions for slash command limits. (#1018) — thanks @evalexpr.
|
||||||
- macOS: resolve gateway token/password using config mode/remote URL, and warn when `launchctl setenv` overrides config. (#1022, #1021) — thanks @kkarimi.
|
- macOS: resolve gateway token/password using config mode/remote URL, and warn when `launchctl setenv` overrides config. (#1022, #1021) — thanks @kkarimi.
|
||||||
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
|
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
|
||||||
|
- Groups: treat replies to the bot as implicit mentions across supported channels.
|
||||||
|
|
||||||
## 2026.1.14-1
|
## 2026.1.14-1
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,8 @@ Quick mental model (evaluation order for group messages):
|
|||||||
## Mention gating (default)
|
## Mention gating (default)
|
||||||
Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`.
|
Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`.
|
||||||
|
|
||||||
|
Replying to a bot message counts as an implicit mention (when the channel supports reply metadata). This applies to Telegram, WhatsApp, Slack, Discord, and Microsoft Teams.
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||||
type HistoryEntry,
|
type HistoryEntry,
|
||||||
} from "../../../../src/auto-reply/reply/history.js";
|
} from "../../../../src/auto-reply/reply/history.js";
|
||||||
|
import { resolveMentionGating } from "../../../../src/channels/mention-gating.js";
|
||||||
import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js";
|
import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js";
|
||||||
import { enqueueSystemEvent } from "../../../../src/infra/system-events.js";
|
import { enqueueSystemEvent } from "../../../../src/infra/system-events.js";
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +43,7 @@ import {
|
|||||||
} from "../policy.js";
|
} from "../policy.js";
|
||||||
import { extractMSTeamsPollVote } from "../polls.js";
|
import { extractMSTeamsPollVote } from "../polls.js";
|
||||||
import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
|
import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
|
||||||
|
import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
|
||||||
import type { MSTeamsTurnContext } from "../sdk-types.js";
|
import type { MSTeamsTurnContext } from "../sdk-types.js";
|
||||||
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
|
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
|
||||||
|
|
||||||
@@ -74,6 +76,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
text: string;
|
text: string;
|
||||||
attachments: MSTeamsAttachmentLike[];
|
attachments: MSTeamsAttachmentLike[];
|
||||||
wasMentioned: boolean;
|
wasMentioned: boolean;
|
||||||
|
implicitMention: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTeamsMessageNow = async (params: MSTeamsDebounceEntry) => {
|
const handleTeamsMessageNow = async (params: MSTeamsDebounceEntry) => {
|
||||||
@@ -301,8 +304,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!isDirectMessage) {
|
if (!isDirectMessage) {
|
||||||
const mentioned = params.wasMentioned;
|
const mentionGate = resolveMentionGating({
|
||||||
if (requireMention && !mentioned) {
|
requireMention: Boolean(requireMention),
|
||||||
|
canDetectMention: true,
|
||||||
|
wasMentioned: params.wasMentioned,
|
||||||
|
implicitMention: params.implicitMention,
|
||||||
|
shouldBypassMention: false,
|
||||||
|
});
|
||||||
|
const mentioned = mentionGate.effectiveWasMentioned;
|
||||||
|
if (requireMention && mentionGate.shouldSkip) {
|
||||||
log.debug("skipping message (mention required)", {
|
log.debug("skipping message (mention required)", {
|
||||||
teamId,
|
teamId,
|
||||||
channelId,
|
channelId,
|
||||||
@@ -379,7 +389,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
Surface: "msteams" as const,
|
Surface: "msteams" as const,
|
||||||
MessageSid: activity.id,
|
MessageSid: activity.id,
|
||||||
Timestamp: timestamp?.getTime() ?? Date.now(),
|
Timestamp: timestamp?.getTime() ?? Date.now(),
|
||||||
WasMentioned: isDirectMessage || params.wasMentioned,
|
WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention,
|
||||||
CommandAuthorized: true,
|
CommandAuthorized: true,
|
||||||
OriginatingChannel: "msteams" as const,
|
OriginatingChannel: "msteams" as const,
|
||||||
OriginatingTo: teamsTo,
|
OriginatingTo: teamsTo,
|
||||||
@@ -401,6 +411,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
context,
|
context,
|
||||||
replyStyle,
|
replyStyle,
|
||||||
textLimit,
|
textLimit,
|
||||||
|
onSentMessageIds: (ids) => {
|
||||||
|
for (const id of ids) {
|
||||||
|
recordMSTeamsSentMessage(conversationId, id);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
||||||
@@ -480,12 +495,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
const wasMentioned = entries.some((entry) => entry.wasMentioned);
|
const wasMentioned = entries.some((entry) => entry.wasMentioned);
|
||||||
|
const implicitMention = entries.some((entry) => entry.implicitMention);
|
||||||
await handleTeamsMessageNow({
|
await handleTeamsMessageNow({
|
||||||
context: last.context,
|
context: last.context,
|
||||||
rawText: combinedRawText,
|
rawText: combinedRawText,
|
||||||
text: combinedText,
|
text: combinedText,
|
||||||
attachments: [],
|
attachments: [],
|
||||||
wasMentioned,
|
wasMentioned,
|
||||||
|
implicitMention,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
@@ -501,7 +518,19 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
? (activity.attachments as unknown as MSTeamsAttachmentLike[])
|
? (activity.attachments as unknown as MSTeamsAttachmentLike[])
|
||||||
: [];
|
: [];
|
||||||
const wasMentioned = wasMSTeamsBotMentioned(activity);
|
const wasMentioned = wasMSTeamsBotMentioned(activity);
|
||||||
|
const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
|
||||||
|
const replyToId = activity.replyToId ?? undefined;
|
||||||
|
const implicitMention = Boolean(
|
||||||
|
conversationId && replyToId && wasMSTeamsMessageSent(conversationId, replyToId),
|
||||||
|
);
|
||||||
|
|
||||||
await inboundDebouncer.enqueue({ context, rawText, text, attachments, wasMentioned });
|
await inboundDebouncer.enqueue({
|
||||||
|
context,
|
||||||
|
rawText,
|
||||||
|
text,
|
||||||
|
attachments,
|
||||||
|
wasMentioned,
|
||||||
|
implicitMention,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
|||||||
context: MSTeamsTurnContext;
|
context: MSTeamsTurnContext;
|
||||||
replyStyle: MSTeamsReplyStyle;
|
replyStyle: MSTeamsReplyStyle;
|
||||||
textLimit: number;
|
textLimit: number;
|
||||||
|
onSentMessageIds?: (ids: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const sendTypingIndicator = async () => {
|
const sendTypingIndicator = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -46,7 +47,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
|||||||
chunkText: true,
|
chunkText: true,
|
||||||
mediaMode: "split",
|
mediaMode: "split",
|
||||||
});
|
});
|
||||||
await sendMSTeamsMessages({
|
const ids = await sendMSTeamsMessages({
|
||||||
replyStyle: params.replyStyle,
|
replyStyle: params.replyStyle,
|
||||||
adapter: params.adapter,
|
adapter: params.adapter,
|
||||||
appId: params.appId,
|
appId: params.appId,
|
||||||
@@ -62,6 +63,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (ids.length > 0) params.onSentMessageIds?.(ids);
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
const errMsg = formatUnknownError(err);
|
const errMsg = formatUnknownError(err);
|
||||||
|
|||||||
16
extensions/msteams/src/sent-message-cache.test.ts
Normal file
16
extensions/msteams/src/sent-message-cache.test.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearMSTeamsSentMessageCache,
|
||||||
|
recordMSTeamsSentMessage,
|
||||||
|
wasMSTeamsMessageSent,
|
||||||
|
} from "./sent-message-cache.js";
|
||||||
|
|
||||||
|
describe("msteams sent message cache", () => {
|
||||||
|
it("records and resolves sent message ids", () => {
|
||||||
|
clearMSTeamsSentMessageCache();
|
||||||
|
recordMSTeamsSentMessage("conv-1", "msg-1");
|
||||||
|
expect(wasMSTeamsMessageSent("conv-1", "msg-1")).toBe(true);
|
||||||
|
expect(wasMSTeamsMessageSent("conv-1", "msg-2")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
41
extensions/msteams/src/sent-message-cache.ts
Normal file
41
extensions/msteams/src/sent-message-cache.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
type CacheEntry = {
|
||||||
|
messageIds: Set<string>;
|
||||||
|
timestamps: Map<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sentMessages = new Map<string, CacheEntry>();
|
||||||
|
|
||||||
|
function cleanupExpired(entry: CacheEntry): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [msgId, timestamp] of entry.timestamps) {
|
||||||
|
if (now - timestamp > TTL_MS) {
|
||||||
|
entry.messageIds.delete(msgId);
|
||||||
|
entry.timestamps.delete(msgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordMSTeamsSentMessage(conversationId: string, messageId: string): void {
|
||||||
|
if (!conversationId || !messageId) return;
|
||||||
|
let entry = sentMessages.get(conversationId);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { messageIds: new Set(), timestamps: new Map() };
|
||||||
|
sentMessages.set(conversationId, entry);
|
||||||
|
}
|
||||||
|
entry.messageIds.add(messageId);
|
||||||
|
entry.timestamps.set(messageId, Date.now());
|
||||||
|
if (entry.messageIds.size > 200) cleanupExpired(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wasMSTeamsMessageSent(conversationId: string, messageId: string): boolean {
|
||||||
|
const entry = sentMessages.get(conversationId);
|
||||||
|
if (!entry) return false;
|
||||||
|
cleanupExpired(entry);
|
||||||
|
return entry.messageIds.has(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearMSTeamsSentMessageCache(): void {
|
||||||
|
sentMessages.clear();
|
||||||
|
}
|
||||||
38
src/channels/mention-gating.test.ts
Normal file
38
src/channels/mention-gating.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { resolveMentionGating } from "./mention-gating.js";
|
||||||
|
|
||||||
|
describe("resolveMentionGating", () => {
|
||||||
|
it("combines explicit, implicit, and bypass mentions", () => {
|
||||||
|
const res = resolveMentionGating({
|
||||||
|
requireMention: true,
|
||||||
|
canDetectMention: true,
|
||||||
|
wasMentioned: false,
|
||||||
|
implicitMention: true,
|
||||||
|
shouldBypassMention: false,
|
||||||
|
});
|
||||||
|
expect(res.effectiveWasMentioned).toBe(true);
|
||||||
|
expect(res.shouldSkip).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips when mention required and none detected", () => {
|
||||||
|
const res = resolveMentionGating({
|
||||||
|
requireMention: true,
|
||||||
|
canDetectMention: true,
|
||||||
|
wasMentioned: false,
|
||||||
|
implicitMention: false,
|
||||||
|
shouldBypassMention: false,
|
||||||
|
});
|
||||||
|
expect(res.effectiveWasMentioned).toBe(false);
|
||||||
|
expect(res.shouldSkip).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not skip when mention detection is unavailable", () => {
|
||||||
|
const res = resolveMentionGating({
|
||||||
|
requireMention: true,
|
||||||
|
canDetectMention: false,
|
||||||
|
wasMentioned: false,
|
||||||
|
});
|
||||||
|
expect(res.shouldSkip).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
21
src/channels/mention-gating.ts
Normal file
21
src/channels/mention-gating.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export type MentionGateParams = {
|
||||||
|
requireMention: boolean;
|
||||||
|
canDetectMention: boolean;
|
||||||
|
wasMentioned: boolean;
|
||||||
|
implicitMention?: boolean;
|
||||||
|
shouldBypassMention?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MentionGateResult = {
|
||||||
|
effectiveWasMentioned: boolean;
|
||||||
|
shouldSkip: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveMentionGating(params: MentionGateParams): MentionGateResult {
|
||||||
|
const implicit = params.implicitMention === true;
|
||||||
|
const bypass = params.shouldBypassMention === true;
|
||||||
|
const effectiveWasMentioned = params.wasMentioned || implicit || bypass;
|
||||||
|
const shouldSkip =
|
||||||
|
params.requireMention && params.canDetectMention && !effectiveWasMentioned;
|
||||||
|
return { effectiveWasMentioned, shouldSkip };
|
||||||
|
}
|
||||||
@@ -126,6 +126,106 @@ describe("discord tool result dispatch", () => {
|
|||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
|
it("accepts guild reply-to-bot messages as implicit mentions", async () => {
|
||||||
|
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||||
|
const cfg = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: "anthropic/claude-opus-4-5",
|
||||||
|
workspace: "/tmp/clawd",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: { store: "/tmp/clawdbot-sessions.json" },
|
||||||
|
channels: {
|
||||||
|
discord: {
|
||||||
|
dm: { enabled: true, policy: "open" },
|
||||||
|
groupPolicy: "open",
|
||||||
|
guilds: { "*": { requireMention: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
||||||
|
|
||||||
|
const handler = createDiscordMessageHandler({
|
||||||
|
cfg,
|
||||||
|
discordConfig: cfg.channels.discord,
|
||||||
|
accountId: "default",
|
||||||
|
token: "token",
|
||||||
|
runtime: {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: (code: number): never => {
|
||||||
|
throw new Error(`exit ${code}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
botUserId: "bot-id",
|
||||||
|
guildHistories: new Map(),
|
||||||
|
historyLimit: 0,
|
||||||
|
mediaMaxBytes: 10_000,
|
||||||
|
textLimit: 2000,
|
||||||
|
replyToMode: "off",
|
||||||
|
dmEnabled: true,
|
||||||
|
groupDmEnabled: false,
|
||||||
|
guildEntries: { "*": { requireMention: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
fetchChannel: vi.fn().mockResolvedValue({
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
name: "general",
|
||||||
|
}),
|
||||||
|
} as unknown as Client;
|
||||||
|
|
||||||
|
await handler(
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
id: "m3",
|
||||||
|
content: "following up",
|
||||||
|
channelId: "c1",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
type: MessageType.Default,
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
mentionedEveryone: false,
|
||||||
|
mentionedUsers: [],
|
||||||
|
mentionedRoles: [],
|
||||||
|
author: { id: "u1", bot: false, username: "Ada" },
|
||||||
|
referencedMessage: {
|
||||||
|
id: "m2",
|
||||||
|
channelId: "c1",
|
||||||
|
content: "bot reply",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
type: MessageType.Default,
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
mentionedEveryone: false,
|
||||||
|
mentionedUsers: [],
|
||||||
|
mentionedRoles: [],
|
||||||
|
author: { id: "bot-id", bot: true, username: "Clawdbot" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
author: { id: "u1", bot: false, username: "Ada" },
|
||||||
|
member: { nickname: "Ada" },
|
||||||
|
guild: { id: "g1", name: "Guild" },
|
||||||
|
guild_id: "g1",
|
||||||
|
channel: { id: "c1", type: ChannelType.GuildText },
|
||||||
|
client,
|
||||||
|
data: {
|
||||||
|
id: "m3",
|
||||||
|
content: "following up",
|
||||||
|
channel_id: "c1",
|
||||||
|
guild_id: "g1",
|
||||||
|
type: MessageType.Default,
|
||||||
|
mentions: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
client,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = dispatchMock.mock.calls[0]?.[0]?.ctx as Record<string, unknown>;
|
||||||
|
expect(payload.WasMentioned).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("forks thread sessions and injects starter context", async () => {
|
it("forks thread sessions and injects starter context", async () => {
|
||||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||||
let capturedCtx:
|
let capturedCtx:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
upsertChannelPairingRequest,
|
upsertChannelPairingRequest,
|
||||||
} from "../../pairing/pairing-store.js";
|
} from "../../pairing/pairing-store.js";
|
||||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||||
|
import { resolveMentionGating } from "../../channels/mention-gating.js";
|
||||||
import { sendMessageDiscord } from "../send.js";
|
import { sendMessageDiscord } from "../send.js";
|
||||||
import {
|
import {
|
||||||
allowListMatches,
|
allowListMatches,
|
||||||
@@ -164,6 +165,12 @@ export async function preflightDiscordMessage(
|
|||||||
!isDirectMessage &&
|
!isDirectMessage &&
|
||||||
(Boolean(botId && message.mentionedUsers?.some((user: User) => user.id === botId)) ||
|
(Boolean(botId && message.mentionedUsers?.some((user: User) => user.id === botId)) ||
|
||||||
matchesMentionPatterns(baseText, mentionRegexes));
|
matchesMentionPatterns(baseText, mentionRegexes));
|
||||||
|
const implicitMention = Boolean(
|
||||||
|
!isDirectMessage &&
|
||||||
|
botId &&
|
||||||
|
message.referencedMessage?.author?.id &&
|
||||||
|
message.referencedMessage.author.id === botId,
|
||||||
|
);
|
||||||
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=${messageText ? "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=${messageText ? "yes" : "no"}`,
|
||||||
@@ -327,10 +334,17 @@ export async function preflightDiscordMessage(
|
|||||||
!hasAnyMention &&
|
!hasAnyMention &&
|
||||||
commandAuthorized &&
|
commandAuthorized &&
|
||||||
hasControlCommand(baseText, params.cfg);
|
hasControlCommand(baseText, params.cfg);
|
||||||
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
|
|
||||||
const canDetectMention = Boolean(botId) || mentionRegexes.length > 0;
|
const canDetectMention = Boolean(botId) || mentionRegexes.length > 0;
|
||||||
|
const mentionGate = resolveMentionGating({
|
||||||
|
requireMention: Boolean(shouldRequireMention),
|
||||||
|
canDetectMention,
|
||||||
|
wasMentioned,
|
||||||
|
implicitMention,
|
||||||
|
shouldBypassMention,
|
||||||
|
});
|
||||||
|
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
|
||||||
if (isGuildMessage && shouldRequireMention) {
|
if (isGuildMessage && shouldRequireMention) {
|
||||||
if (botId && !wasMentioned && !shouldBypassMention) {
|
if (botId && mentionGate.shouldSkip) {
|
||||||
logVerbose(`discord: drop guild message (mention required, botId=${botId})`);
|
logVerbose(`discord: drop guild message (mention required, botId=${botId})`);
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -56,10 +56,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
isGroupDm,
|
isGroupDm,
|
||||||
baseText,
|
baseText,
|
||||||
messageText,
|
messageText,
|
||||||
wasMentioned,
|
|
||||||
shouldRequireMention,
|
shouldRequireMention,
|
||||||
canDetectMention,
|
canDetectMention,
|
||||||
shouldBypassMention,
|
|
||||||
effectiveWasMentioned,
|
effectiveWasMentioned,
|
||||||
historyEntry,
|
historyEntry,
|
||||||
threadChannel,
|
threadChannel,
|
||||||
@@ -94,7 +92,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
if (!isGuildMessage) return false;
|
if (!isGuildMessage) return false;
|
||||||
if (!shouldRequireMention) return false;
|
if (!shouldRequireMention) return false;
|
||||||
if (!canDetectMention) return false;
|
if (!canDetectMention) return false;
|
||||||
return wasMentioned || shouldBypassMention;
|
return effectiveWasMentioned;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -498,6 +498,49 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
|
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats replies to bot threads as implicit mentions", async () => {
|
||||||
|
config = {
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||||
|
channels: { C1: { allow: true, requireMention: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
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: "following up",
|
||||||
|
ts: "124",
|
||||||
|
thread_ts: "123",
|
||||||
|
parent_user_id: "bot-user",
|
||||||
|
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("accepts channel messages without mention when channels.slack.requireMention is false", async () => {
|
it("accepts channel messages without mention when channels.slack.requireMention is false", async () => {
|
||||||
config = {
|
config = {
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { buildPairingReply } from "../../../pairing/pairing-messages.js";
|
|||||||
import { upsertChannelPairingRequest } from "../../../pairing/pairing-store.js";
|
import { upsertChannelPairingRequest } from "../../../pairing/pairing-store.js";
|
||||||
import { resolveAgentRoute } from "../../../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../../../routing/resolve-route.js";
|
||||||
import { resolveThreadSessionKeys } from "../../../routing/session-key.js";
|
import { resolveThreadSessionKeys } from "../../../routing/session-key.js";
|
||||||
|
import { resolveMentionGating } from "../../../channels/mention-gating.js";
|
||||||
|
|
||||||
import type { ResolvedSlackAccount } from "../../accounts.js";
|
import type { ResolvedSlackAccount } from "../../accounts.js";
|
||||||
import { reactSlackMessage } from "../../actions.js";
|
import { reactSlackMessage } from "../../actions.js";
|
||||||
@@ -172,6 +173,12 @@ export async function prepareSlackMessage(params: {
|
|||||||
(!isDirectMessage &&
|
(!isDirectMessage &&
|
||||||
(Boolean(ctx.botUserId && message.text?.includes(`<@${ctx.botUserId}>`)) ||
|
(Boolean(ctx.botUserId && message.text?.includes(`<@${ctx.botUserId}>`)) ||
|
||||||
matchesMentionPatterns(message.text ?? "", mentionRegexes)));
|
matchesMentionPatterns(message.text ?? "", mentionRegexes)));
|
||||||
|
const implicitMention = Boolean(
|
||||||
|
!isDirectMessage &&
|
||||||
|
ctx.botUserId &&
|
||||||
|
message.thread_ts &&
|
||||||
|
message.parent_user_id === ctx.botUserId,
|
||||||
|
);
|
||||||
|
|
||||||
const sender = message.user ? await ctx.resolveUserName(message.user) : null;
|
const sender = message.user ? await ctx.resolveUserName(message.user) : null;
|
||||||
const senderName =
|
const senderName =
|
||||||
@@ -215,9 +222,16 @@ export async function prepareSlackMessage(params: {
|
|||||||
commandAuthorized &&
|
commandAuthorized &&
|
||||||
hasControlCommand(message.text ?? "", cfg);
|
hasControlCommand(message.text ?? "", cfg);
|
||||||
|
|
||||||
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
|
|
||||||
const canDetectMention = Boolean(ctx.botUserId) || mentionRegexes.length > 0;
|
const canDetectMention = Boolean(ctx.botUserId) || mentionRegexes.length > 0;
|
||||||
if (isRoom && shouldRequireMention && canDetectMention && !wasMentioned && !shouldBypassMention) {
|
const mentionGate = resolveMentionGating({
|
||||||
|
requireMention: Boolean(shouldRequireMention),
|
||||||
|
canDetectMention,
|
||||||
|
wasMentioned,
|
||||||
|
implicitMention,
|
||||||
|
shouldBypassMention,
|
||||||
|
});
|
||||||
|
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
|
||||||
|
if (isRoom && shouldRequireMention && mentionGate.shouldSkip) {
|
||||||
ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping room message");
|
ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping room message");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -242,7 +256,7 @@ export async function prepareSlackMessage(params: {
|
|||||||
if (!isRoom) return false;
|
if (!isRoom) return false;
|
||||||
if (!shouldRequireMention) return false;
|
if (!shouldRequireMention) return false;
|
||||||
if (!canDetectMention) return false;
|
if (!canDetectMention) return false;
|
||||||
return wasMentioned || shouldBypassMention;
|
return effectiveWasMentioned;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
|||||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
|
import { resolveMentionGating } from "../channels/mention-gating.js";
|
||||||
import {
|
import {
|
||||||
buildGroupFromLabel,
|
buildGroupFromLabel,
|
||||||
buildGroupLabel,
|
buildGroupLabel,
|
||||||
@@ -222,7 +223,7 @@ export const buildTelegramMessageContext = async ({
|
|||||||
// Reply-chain detection: replying to a bot message acts like an implicit mention.
|
// Reply-chain detection: replying to a bot message acts like an implicit mention.
|
||||||
const botId = primaryCtx.me?.id;
|
const botId = primaryCtx.me?.id;
|
||||||
const replyFromId = msg.reply_to_message?.from?.id;
|
const replyFromId = msg.reply_to_message?.from?.id;
|
||||||
const isReplyToBot = botId != null && replyFromId === botId;
|
const implicitMention = botId != null && replyFromId === botId;
|
||||||
const shouldBypassMention =
|
const shouldBypassMention =
|
||||||
isGroup &&
|
isGroup &&
|
||||||
requireMention &&
|
requireMention &&
|
||||||
@@ -230,11 +231,17 @@ export const buildTelegramMessageContext = async ({
|
|||||||
!hasAnyMention &&
|
!hasAnyMention &&
|
||||||
commandAuthorized &&
|
commandAuthorized &&
|
||||||
hasControlCommand(msg.text ?? msg.caption ?? "", cfg, { botUsername });
|
hasControlCommand(msg.text ?? msg.caption ?? "", cfg, { botUsername });
|
||||||
const shouldBypassForReplyChain = isGroup && requireMention && isReplyToBot;
|
|
||||||
const effectiveWasMentioned = wasMentioned || shouldBypassMention || shouldBypassForReplyChain;
|
|
||||||
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
|
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
|
||||||
|
const mentionGate = resolveMentionGating({
|
||||||
|
requireMention: Boolean(requireMention),
|
||||||
|
canDetectMention,
|
||||||
|
wasMentioned,
|
||||||
|
implicitMention: isGroup && Boolean(requireMention) && implicitMention,
|
||||||
|
shouldBypassMention,
|
||||||
|
});
|
||||||
|
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
|
||||||
if (isGroup && requireMention && canDetectMention) {
|
if (isGroup && requireMention && canDetectMention) {
|
||||||
if (!wasMentioned && !shouldBypassMention && !shouldBypassForReplyChain) {
|
if (mentionGate.shouldSkip) {
|
||||||
logger.info({ chatId, reason: "no-mention" }, "skipping group message");
|
logger.info({ chatId, reason: "no-mention" }, "skipping group message");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -252,7 +259,7 @@ export const buildTelegramMessageContext = async ({
|
|||||||
if (!isGroup) return false;
|
if (!isGroup) return false;
|
||||||
if (!requireMention) return false;
|
if (!requireMention) return false;
|
||||||
if (!canDetectMention) return false;
|
if (!canDetectMention) return false;
|
||||||
return wasMentioned || shouldBypassMention || shouldBypassForReplyChain;
|
return effectiveWasMentioned;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|||||||
55
src/web/auto-reply/monitor/group-gating.test.ts
Normal file
55
src/web/auto-reply/monitor/group-gating.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { applyGroupGating } from "./group-gating.js";
|
||||||
|
|
||||||
|
const baseConfig = {
|
||||||
|
channels: {
|
||||||
|
whatsapp: {
|
||||||
|
groupPolicy: "open",
|
||||||
|
groups: { "*": { requireMention: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: { store: "/tmp/clawdbot-sessions.json" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
describe("applyGroupGating", () => {
|
||||||
|
it("treats reply-to-bot as implicit mention", () => {
|
||||||
|
const groupHistories = new Map();
|
||||||
|
const result = applyGroupGating({
|
||||||
|
cfg: baseConfig as unknown as ReturnType<typeof import("../../../config/config.js").loadConfig>,
|
||||||
|
msg: {
|
||||||
|
id: "m1",
|
||||||
|
from: "123@g.us",
|
||||||
|
conversationId: "123@g.us",
|
||||||
|
to: "+15550000",
|
||||||
|
accountId: "default",
|
||||||
|
body: "following up",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
chatType: "group",
|
||||||
|
chatId: "123@g.us",
|
||||||
|
selfJid: "15551234567@s.whatsapp.net",
|
||||||
|
selfE164: "+15551234567",
|
||||||
|
replyToId: "m0",
|
||||||
|
replyToBody: "bot said hi",
|
||||||
|
replyToSender: "+15551234567",
|
||||||
|
replyToSenderJid: "15551234567@s.whatsapp.net",
|
||||||
|
replyToSenderE164: "+15551234567",
|
||||||
|
sendComposing: async () => {},
|
||||||
|
reply: async () => {},
|
||||||
|
sendMedia: async () => {},
|
||||||
|
},
|
||||||
|
conversationId: "123@g.us",
|
||||||
|
groupHistoryKey: "group:123@g.us",
|
||||||
|
agentId: "main",
|
||||||
|
sessionKey: "agent:main:whatsapp:group:123@g.us",
|
||||||
|
baseMentionConfig: { mentionRegexes: [] },
|
||||||
|
groupHistories,
|
||||||
|
groupHistoryLimit: 10,
|
||||||
|
groupMemberNames: new Map(),
|
||||||
|
logVerbose: () => {},
|
||||||
|
replyLogger: { debug: () => {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.shouldProcess).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { hasControlCommand } from "../../../auto-reply/command-detection.js";
|
|||||||
import { parseActivationCommand } from "../../../auto-reply/group-activation.js";
|
import { parseActivationCommand } from "../../../auto-reply/group-activation.js";
|
||||||
import type { loadConfig } from "../../../config/config.js";
|
import type { loadConfig } from "../../../config/config.js";
|
||||||
import { normalizeE164 } from "../../../utils.js";
|
import { normalizeE164 } from "../../../utils.js";
|
||||||
|
import { resolveMentionGating } from "../../../channels/mention-gating.js";
|
||||||
import type { MentionConfig } from "../mentions.js";
|
import type { MentionConfig } from "../mentions.js";
|
||||||
import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js";
|
import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js";
|
||||||
import type { WebInboundMsg } from "../types.js";
|
import type { WebInboundMsg } from "../types.js";
|
||||||
@@ -94,7 +95,6 @@ export function applyGroupGating(params: {
|
|||||||
"group mention debug",
|
"group mention debug",
|
||||||
);
|
);
|
||||||
const wasMentioned = mentionDebug.wasMentioned;
|
const wasMentioned = mentionDebug.wasMentioned;
|
||||||
params.msg.wasMentioned = wasMentioned;
|
|
||||||
const activation = resolveGroupActivationFor({
|
const activation = resolveGroupActivationFor({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
@@ -102,7 +102,25 @@ export function applyGroupGating(params: {
|
|||||||
conversationId: params.conversationId,
|
conversationId: params.conversationId,
|
||||||
});
|
});
|
||||||
const requireMention = activation !== "always";
|
const requireMention = activation !== "always";
|
||||||
if (!shouldBypassMention && requireMention && !wasMentioned) {
|
const selfJid = params.msg.selfJid?.replace(/:\\d+/, "");
|
||||||
|
const replySenderJid = params.msg.replyToSenderJid?.replace(/:\\d+/, "");
|
||||||
|
const selfE164 = params.msg.selfE164 ? normalizeE164(params.msg.selfE164) : null;
|
||||||
|
const replySenderE164 = params.msg.replyToSenderE164
|
||||||
|
? normalizeE164(params.msg.replyToSenderE164)
|
||||||
|
: null;
|
||||||
|
const implicitMention = Boolean(
|
||||||
|
(selfJid && replySenderJid && selfJid === replySenderJid) ||
|
||||||
|
(selfE164 && replySenderE164 && selfE164 === replySenderE164),
|
||||||
|
);
|
||||||
|
const mentionGate = resolveMentionGating({
|
||||||
|
requireMention,
|
||||||
|
canDetectMention: true,
|
||||||
|
wasMentioned,
|
||||||
|
implicitMention,
|
||||||
|
shouldBypassMention,
|
||||||
|
});
|
||||||
|
params.msg.wasMentioned = mentionGate.effectiveWasMentioned;
|
||||||
|
if (!shouldBypassMention && requireMention && mentionGate.shouldSkip) {
|
||||||
params.logVerbose(
|
params.logVerbose(
|
||||||
`Group message stored for context (no mention detected) in ${params.conversationId}: ${params.msg.body}`,
|
`Group message stored for context (no mention detected) in ${params.conversationId}: ${params.msg.body}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -238,6 +238,8 @@ export function describeReplyContext(rawMessage: proto.IMessage | undefined): {
|
|||||||
id?: string;
|
id?: string;
|
||||||
body: string;
|
body: string;
|
||||||
sender: string;
|
sender: string;
|
||||||
|
senderJid?: string;
|
||||||
|
senderE164?: string;
|
||||||
} | null {
|
} | null {
|
||||||
const message = unwrapMessage(rawMessage);
|
const message = unwrapMessage(rawMessage);
|
||||||
if (!message) return null;
|
if (!message) return null;
|
||||||
@@ -265,5 +267,7 @@ export function describeReplyContext(rawMessage: proto.IMessage | undefined): {
|
|||||||
id: contextInfo?.stanzaId ? String(contextInfo.stanzaId) : undefined,
|
id: contextInfo?.stanzaId ? String(contextInfo.stanzaId) : undefined,
|
||||||
body,
|
body,
|
||||||
sender,
|
sender,
|
||||||
|
senderJid,
|
||||||
|
senderE164,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,6 +284,8 @@ export async function monitorWebInbox(options: {
|
|||||||
replyToId: replyContext?.id,
|
replyToId: replyContext?.id,
|
||||||
replyToBody: replyContext?.body,
|
replyToBody: replyContext?.body,
|
||||||
replyToSender: replyContext?.sender,
|
replyToSender: replyContext?.sender,
|
||||||
|
replyToSenderJid: replyContext?.senderJid,
|
||||||
|
replyToSenderE164: replyContext?.senderE164,
|
||||||
groupSubject,
|
groupSubject,
|
||||||
groupParticipants,
|
groupParticipants,
|
||||||
mentionedJids: mentionedJids ?? undefined,
|
mentionedJids: mentionedJids ?? undefined,
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export type WebInboundMessage = {
|
|||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
replyToBody?: string;
|
replyToBody?: string;
|
||||||
replyToSender?: string;
|
replyToSender?: string;
|
||||||
|
replyToSenderJid?: string;
|
||||||
|
replyToSenderE164?: string;
|
||||||
groupSubject?: string;
|
groupSubject?: string;
|
||||||
groupParticipants?: string[];
|
groupParticipants?: string[];
|
||||||
mentionedJids?: string[];
|
mentionedJids?: string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user