fix: treat reply-to-bot as implicit mention across channels

This commit is contained in:
Peter Steinberger
2026-01-16 21:50:44 +00:00
parent 97a41a6509
commit 05d149a49b
19 changed files with 427 additions and 20 deletions

View File

@@ -11,6 +11,7 @@ import {
DEFAULT_GROUP_HISTORY_LIMIT,
type HistoryEntry,
} from "../../../../src/auto-reply/reply/history.js";
import { resolveMentionGating } from "../../../../src/channels/mention-gating.js";
import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js";
import { enqueueSystemEvent } from "../../../../src/infra/system-events.js";
import {
@@ -42,6 +43,7 @@ import {
} from "../policy.js";
import { extractMSTeamsPollVote } from "../polls.js";
import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
import type { MSTeamsTurnContext } from "../sdk-types.js";
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
@@ -74,6 +76,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
text: string;
attachments: MSTeamsAttachmentLike[];
wasMentioned: boolean;
implicitMention: boolean;
};
const handleTeamsMessageNow = async (params: MSTeamsDebounceEntry) => {
@@ -301,8 +304,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
});
if (!isDirectMessage) {
const mentioned = params.wasMentioned;
if (requireMention && !mentioned) {
const mentionGate = resolveMentionGating({
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)", {
teamId,
channelId,
@@ -379,7 +389,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
Surface: "msteams" as const,
MessageSid: activity.id,
Timestamp: timestamp?.getTime() ?? Date.now(),
WasMentioned: isDirectMessage || params.wasMentioned,
WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention,
CommandAuthorized: true,
OriginatingChannel: "msteams" as const,
OriginatingTo: teamsTo,
@@ -401,6 +411,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
context,
replyStyle,
textLimit,
onSentMessageIds: (ids) => {
for (const id of ids) {
recordMSTeamsSentMessage(conversationId, id);
}
},
});
log.info("dispatching to agent", { sessionKey: route.sessionKey });
@@ -480,12 +495,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
.filter(Boolean)
.join("\n");
const wasMentioned = entries.some((entry) => entry.wasMentioned);
const implicitMention = entries.some((entry) => entry.implicitMention);
await handleTeamsMessageNow({
context: last.context,
rawText: combinedRawText,
text: combinedText,
attachments: [],
wasMentioned,
implicitMention,
});
},
onError: (err) => {
@@ -501,7 +518,19 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
? (activity.attachments as unknown as MSTeamsAttachmentLike[])
: [];
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,
});
};
}

View File

@@ -28,6 +28,7 @@ export function createMSTeamsReplyDispatcher(params: {
context: MSTeamsTurnContext;
replyStyle: MSTeamsReplyStyle;
textLimit: number;
onSentMessageIds?: (ids: string[]) => void;
}) {
const sendTypingIndicator = async () => {
try {
@@ -46,7 +47,7 @@ export function createMSTeamsReplyDispatcher(params: {
chunkText: true,
mediaMode: "split",
});
await sendMSTeamsMessages({
const ids = await sendMSTeamsMessages({
replyStyle: params.replyStyle,
adapter: params.adapter,
appId: params.appId,
@@ -62,6 +63,7 @@ export function createMSTeamsReplyDispatcher(params: {
});
},
});
if (ids.length > 0) params.onSentMessageIds?.(ids);
},
onError: (err, info) => {
const errMsg = formatUnknownError(err);

View 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);
});
});

View 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();
}