fix(msteams): route thread replies to correct thread via replyToId (#58030) (#62715)

This commit is contained in:
sudie-codes
2026-04-08 19:22:09 -07:00
committed by GitHub
parent 38aa1edf76
commit 9edfefedf7
4 changed files with 168 additions and 3 deletions

View File

@@ -36,6 +36,15 @@ export type StoredConversationReference = {
graphChatId?: string;
/** IANA timezone from Teams clientInfo entity (e.g. "America/New_York") */
timezone?: string;
/**
* Thread root message ID for channel thread messages.
* When a message arrives inside a Teams channel thread, the Bot Framework
* sets `conversation.id` to `19:xxx@thread.tacv2;messageid=<rootId>` and/or
* `replyToId` to the thread root activity ID. This field caches that root ID
* so outbound replies can target the correct thread instead of landing as
* top-level channel posts.
*/
threadId?: string;
};
export type MSTeamsConversationStoreEntry = {

View File

@@ -474,6 +474,147 @@ describe("msteams messenger", () => {
expect(reference.activityId).toBeUndefined();
});
it("uses threadId instead of activityId for channel revoke fallback (#58030)", async () => {
const proactiveSent: string[] = [];
let capturedReference: unknown;
const channelRef: StoredConversationReference = {
activityId: "current-message-id",
user: { id: "user123", name: "User" },
agent: { id: "bot123", name: "Bot" },
conversation: {
id: "19:abc@thread.tacv2",
conversationType: "channel",
},
channelId: "msteams",
serviceUrl: "https://service.example.com",
// threadId is the thread root, which differs from activityId (current message)
threadId: "thread-root-msg-id",
};
const ctx = createRevokedThreadContext();
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, reference, logic) => {
capturedReference = reference;
await logic({
sendActivity: createRecordedSendActivity(proactiveSent),
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
});
},
process: async () => {},
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
};
await sendMSTeamsMessages({
replyStyle: "thread",
adapter,
appId: "app123",
conversationRef: channelRef,
context: ctx,
messages: [{ text: "hello" }],
});
expect(proactiveSent).toEqual(["hello"]);
const ref = capturedReference as { conversation?: { id?: string }; activityId?: string };
// Should use threadId (thread root), NOT activityId (current message)
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2;messageid=thread-root-msg-id");
expect(ref.activityId).toBeUndefined();
});
it("falls back to activityId when threadId is not set (backward compat)", async () => {
const proactiveSent: string[] = [];
let capturedReference: unknown;
const channelRef: StoredConversationReference = {
activityId: "legacy-activity-id",
user: { id: "user123", name: "User" },
agent: { id: "bot123", name: "Bot" },
conversation: {
id: "19:abc@thread.tacv2",
conversationType: "channel",
},
channelId: "msteams",
serviceUrl: "https://service.example.com",
// No threadId — older stored references may not have it
};
const ctx = createRevokedThreadContext();
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, reference, logic) => {
capturedReference = reference;
await logic({
sendActivity: createRecordedSendActivity(proactiveSent),
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
});
},
process: async () => {},
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
};
await sendMSTeamsMessages({
replyStyle: "thread",
adapter,
appId: "app123",
conversationRef: channelRef,
context: ctx,
messages: [{ text: "hello" }],
});
expect(proactiveSent).toEqual(["hello"]);
const ref = capturedReference as { conversation?: { id?: string } };
// Falls back to activityId when threadId is missing
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2;messageid=legacy-activity-id");
});
it("does not add thread suffix for top-level replyStyle even with threadId set", async () => {
let capturedReference: unknown;
const sent: string[] = [];
const channelRef: StoredConversationReference = {
activityId: "current-msg",
user: { id: "user123", name: "User" },
agent: { id: "bot123", name: "Bot" },
conversation: {
id: "19:abc@thread.tacv2",
conversationType: "channel",
},
channelId: "msteams",
serviceUrl: "https://service.example.com",
threadId: "thread-root-msg-id",
};
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, reference, logic) => {
capturedReference = reference;
await logic({
sendActivity: createRecordedSendActivity(sent),
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
});
},
process: async () => {},
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
};
await sendMSTeamsMessages({
replyStyle: "top-level",
adapter,
appId: "app123",
conversationRef: channelRef,
messages: [{ text: "hello" }],
});
expect(sent).toEqual(["hello"]);
const ref = capturedReference as { conversation?: { id?: string } };
// Top-level sends should NOT include thread suffix
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2");
});
it("retries top-level sends on transient (5xx)", async () => {
const attempts: string[] = [];

View File

@@ -521,12 +521,16 @@ export async function sendMSTeamsMessages(params: {
return messageIds;
};
// Resolve the thread root message ID for channel thread routing.
// `threadId` is the canonical thread root (set on inbound for channel threads);
// fall back to `activityId` for backward compatibility with older stored refs.
const resolvedThreadId = params.conversationRef.threadId ?? params.conversationRef.activityId;
if (params.replyStyle === "thread") {
const ctx = params.context;
if (!ctx) {
throw new Error("Missing context for replyStyle=thread");
}
const threadActivityId = params.conversationRef.activityId;
const messageIds: string[] = [];
for (const [idx, message] of messages.entries()) {
const result = await withRevokedProxyFallback({
@@ -541,7 +545,7 @@ export async function sendMSTeamsMessages(params: {
const remaining = messages.slice(idx);
return {
ids:
remaining.length > 0 ? await sendProactively(remaining, idx, threadActivityId) : [],
remaining.length > 0 ? await sendProactively(remaining, idx, resolvedThreadId) : [],
fellBack: true,
};
},

View File

@@ -93,8 +93,10 @@ function buildStoredConversationReference(params: {
conversationId: string;
conversationType: string;
teamId?: string;
/** Thread root message ID for channel thread messages. */
threadId?: string;
}): StoredConversationReference {
const { activity, conversationId, conversationType, teamId } = params;
const { activity, conversationId, conversationType, teamId, threadId } = params;
const from = activity.from;
const conversation = activity.conversation;
const agent = activity.recipient;
@@ -116,6 +118,7 @@ function buildStoredConversationReference(params: {
serviceUrl: activity.serviceUrl,
locale: activity.locale,
...(clientInfo?.timezone ? { timezone: clientInfo.timezone } : {}),
...(threadId ? { threadId } : {}),
};
}
@@ -210,11 +213,19 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const conversationMessageId = extractMSTeamsConversationMessageId(rawConversationId);
const conversationType = conversation?.conversationType ?? "personal";
const teamId = activity.channelData?.team?.id;
// For channel thread messages, resolve the thread root message ID so outbound
// replies land in the correct thread. The root ID comes from the `messageid=`
// portion of conversation.id (preferred) or from activity.replyToId.
const threadId =
conversationType === "channel"
? (conversationMessageId ?? activity.replyToId ?? undefined)
: undefined;
const conversationRef = buildStoredConversationReference({
activity,
conversationId,
conversationType,
teamId,
threadId,
});
const {