From 81f81be816f126844d3585faaac3e88e8afa3f36 Mon Sep 17 00:00:00 2001 From: Onur Date: Thu, 8 Jan 2026 03:29:39 +0300 Subject: [PATCH] feat(msteams): add replyStyle config for thread vs top-level replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add replyStyle config at global, team, and channel levels - "thread" replies to the original message (for Posts layout channels) - "top-level" posts as a new message (for Threads layout channels) - Default based on requireMention: false → top-level, true → thread - DMs always use thread style (direct reply) --- src/msteams/monitor.ts | 97 +++++++++++++++++++++++++++--------------- 1 file changed, 62 insertions(+), 35 deletions(-) diff --git a/src/msteams/monitor.ts b/src/msteams/monitor.ts index f6d90fd4e..620be86a6 100644 --- a/src/msteams/monitor.ts +++ b/src/msteams/monitor.ts @@ -126,32 +126,52 @@ export async function monitorMSTeamsProvider( }); const adapter = new CloudAdapter(authConfig); - // Helper to deliver replies as top-level messages (not threaded) - // We use proactive messaging to avoid threading to the original message + // Helper to deliver replies with configurable reply style + // - "thread": reply to the original message (for Posts layout channels) + // - "top-level": post as a new message (for Threads layout channels) async function deliverReplies(params: { replies: ReplyPayload[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any context: any; // TurnContext from SDK - has activity.getConversationReference() adapter: InstanceType; appId: string; + replyStyle: "thread" | "top-level"; }) { const chunkLimit = Math.min(textLimit, 4000); - // Get conversation reference from SDK's activity (includes proper bot info) - // Then remove activityId to avoid threading - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const fullRef = params.context.activity.getConversationReference() as any; - const conversationRef = { - ...fullRef, - activityId: undefined, // Remove to post as top-level message, not thread - }; - // Also strip the messageid suffix from conversation.id if present - if (conversationRef.conversation?.id) { - conversationRef.conversation = { - ...conversationRef.conversation, - id: conversationRef.conversation.id.split(";")[0], - }; - } + // For "thread" style, use context.sendActivity directly (replies to original message) + // For "top-level" style, use proactive messaging without activityId + const sendMessage = + params.replyStyle === "thread" + ? async (message: string) => { + await params.context.sendActivity({ type: "message", text: message }); + } + : async (message: string) => { + // Get conversation reference from SDK's activity (includes proper bot info) + // Then remove activityId to avoid threading + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fullRef = params.context.activity.getConversationReference() as any; + const conversationRef = { + ...fullRef, + activityId: undefined, // Remove to post as top-level message + }; + // Also strip the messageid suffix from conversation.id if present + if (conversationRef.conversation?.id) { + conversationRef.conversation = { + ...conversationRef.conversation, + id: conversationRef.conversation.id.split(";")[0], + }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (params.adapter as any).continueConversation( + params.appId, + conversationRef, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async (ctx: any) => { + await ctx.sendActivity({ type: "message", text: message }); + }, + ); + }; for (const payload of params.replies) { const mediaList = @@ -159,18 +179,6 @@ export async function monitorMSTeamsProvider( const text = payload.text ?? ""; if (!text && mediaList.length === 0) continue; - const sendMessage = async (message: string) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (params.adapter as any).continueConversation( - params.appId, - conversationRef, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async (ctx: any) => { - await ctx.sendActivity({ type: "message", text: message }); - }, - ); - }; - if (mediaList.length === 0) { for (const chunk of chunkMarkdownText(text, chunkLimit)) { const trimmed = chunk.trim(); @@ -335,15 +343,15 @@ export async function monitorMSTeamsProvider( } } + // Resolve team/channel config for channels and group chats + const teamId = activity.channelData?.team?.id; + const channelId = conversationId; + const teamConfig = teamId ? msteamsCfg?.teams?.[teamId] : undefined; + const channelConfig = teamConfig?.channels?.[channelId]; + // Check requireMention for channels and group chats if (!isDirectMessage) { - const teamId = activity.channelData?.team?.id; - const channelId = conversationId; - // Resolution order: channel config > team config > global config > default (true) - const teamConfig = teamId ? msteamsCfg?.teams?.[teamId] : undefined; - const channelConfig = teamConfig?.channels?.[channelId]; - const requireMention = channelConfig?.requireMention ?? teamConfig?.requireMention ?? @@ -363,6 +371,24 @@ export async function monitorMSTeamsProvider( } } + // Resolve reply style for channels/groups + // Resolution order: channel config > team config > global config > default based on requireMention + // If requireMention is false (Threads layout), default to "top-level" + // If requireMention is true (Posts layout), default to "thread" + const explicitReplyStyle = + channelConfig?.replyStyle ?? + teamConfig?.replyStyle ?? + msteamsCfg?.replyStyle; + const effectiveRequireMention = + channelConfig?.requireMention ?? + teamConfig?.requireMention ?? + msteamsCfg?.requireMention ?? + true; + // For DMs, always use "thread" style (direct reply) + const replyStyle: "thread" | "top-level" = isDirectMessage + ? "thread" + : explicitReplyStyle ?? (effectiveRequireMention ? "thread" : "top-level"); + // Format the message body with envelope const timestamp = parseTimestamp(activity.timestamp); const body = formatAgentEnvelope({ @@ -420,6 +446,7 @@ export async function monitorMSTeamsProvider( context, adapter, appId, + replyStyle, }); }, onError: (err, info) => {