diff --git a/extensions/msteams/src/outbound.test.ts b/extensions/msteams/src/outbound.test.ts index f7765ac69e40..5c39e9242b26 100644 --- a/extensions/msteams/src/outbound.test.ts +++ b/extensions/msteams/src/outbound.test.ts @@ -297,14 +297,15 @@ describe("msteamsOutbound cfg threading", () => { const result = await requireSendPayload()({ cfg, - to: "conversation:abc", + to: "conversation:19:channel@thread.tacv2", + threadId: "presentation-thread-root", text: "Deploy finished", payload: rendered!, }); expect(mocks.sendAdaptiveCardMSTeams).toHaveBeenCalledWith({ cfg, - to: "conversation:abc", + to: "conversation:19:channel@thread.tacv2;messageid=presentation-thread-root", card: (rendered!.channelData!.msteams as { presentationCard: unknown }).presentationCard, }); expect(result).toEqual({ @@ -574,6 +575,26 @@ describe("msteamsOutbound cfg threading", () => { expect(Number.isNaN(Date.parse(pollRecord?.createdAt))).toBe(false); }); + it("forwards resolved channel thread ids to poll sends", async () => { + await requireSendPoll()({ + cfg, + to: "conversation:19:channel@thread.tacv2", + threadId: "poll-thread-root", + poll: { + question: "Ship it?", + options: ["Yes", "No"], + }, + }); + + expect(mocks.sendPollMSTeams).toHaveBeenCalledWith({ + cfg, + to: "conversation:19:channel@thread.tacv2;messageid=poll-thread-root", + question: "Ship it?", + options: ["Yes", "No"], + maxSelections: 1, + }); + }); + it("chunks outbound text without requiring MSTeams runtime initialization", () => { const chunker = msteamsOutbound.chunker; if (!chunker) { diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts index 163226dab3a4..e23740f8adaf 100644 --- a/extensions/msteams/src/outbound.ts +++ b/extensions/msteams/src/outbound.ts @@ -218,11 +218,11 @@ export const msteamsOutbound: ChannelOutboundAdapter = { mediaReadFile, }); }, - sendPoll: async ({ cfg, to, poll }) => { + sendPoll: async ({ cfg, to, poll, threadId }) => { const maxSelections = poll.maxSelections ?? 1; const result = await sendPollMSTeams({ cfg, - to, + to: resolveMSTeamsThreadTarget(to, threadId), question: poll.question, options: poll.options, maxSelections, diff --git a/extensions/msteams/src/send-context.test.ts b/extensions/msteams/src/send-context.test.ts index 727b28efc9b8..512dc6ac74ce 100644 --- a/extensions/msteams/src/send-context.test.ts +++ b/extensions/msteams/src/send-context.test.ts @@ -50,7 +50,7 @@ function channelRef(params?: Partial): StoredConver }; } -async function resolveMSTeamsProactiveReplyStyle(params: { +async function resolveMSTeamsProactiveReplyTarget(params: { cfg?: MSTeamsConfig; conversationId: string; ref: StoredConversationReference; @@ -76,12 +76,14 @@ async function resolveMSTeamsProactiveReplyStyle(params: { }, }, } as OpenClawConfig; - return ( - await resolveMSTeamsSendContext({ - cfg, - to: `conversation:${params.conversationId}`, - }) - ).replyStyle; + const context = await resolveMSTeamsSendContext({ + cfg, + to: `conversation:${params.conversationId}`, + }); + return { + replyStyle: context.replyStyle, + threadActivityId: context.threadActivityId, + }; } beforeEach(() => { @@ -155,6 +157,7 @@ describe("resolveMSTeamsSendContext", () => { conversationId: "19:channel@thread.tacv2", ref: { threadId: "explicit-root" }, replyStyle: "thread", + threadActivityId: "explicit-root", }); expect(sendContextMockState.store.get).toHaveBeenCalledWith("19:channel@thread.tacv2"); }); @@ -186,6 +189,7 @@ describe("resolveMSTeamsSendContext", () => { conversationId: "19:channel@thread.tacv2", ref: { threadId: "graph-root" }, replyStyle: "thread", + threadActivityId: "graph-root", }); expect(sendContextMockState.store.get).toHaveBeenCalledWith("19:channel@thread.tacv2"); }); @@ -248,27 +252,27 @@ describe("resolveMSTeamsSendContext", () => { }); }); -describe("resolveMSTeamsProactiveReplyStyle", () => { +describe("resolveMSTeamsProactiveReplyTarget", () => { it("uses thread for channel conversations with a stored thread root", async () => { await expect( - resolveMSTeamsProactiveReplyStyle({ + resolveMSTeamsProactiveReplyTarget({ cfg: {}, conversationId: "19:channel@thread.tacv2", ref: channelRef({ threadId: "thread-root-1" }), conversationType: "channel", }), - ).resolves.toBe("thread"); + ).resolves.toEqual({ replyStyle: "thread", threadActivityId: "thread-root-1" }); }); it("falls back to activityId for legacy channel references", async () => { await expect( - resolveMSTeamsProactiveReplyStyle({ + resolveMSTeamsProactiveReplyTarget({ cfg: {}, conversationId: "19:channel@thread.tacv2", ref: channelRef({ activityId: "legacy-root-1" }), conversationType: "channel", }), - ).resolves.toBe("thread"); + ).resolves.toEqual({ replyStyle: "thread", threadActivityId: "legacy-root-1" }); }); it("keeps configured top-level channel routing", async () => { @@ -284,44 +288,44 @@ describe("resolveMSTeamsProactiveReplyStyle", () => { }; await expect( - resolveMSTeamsProactiveReplyStyle({ + resolveMSTeamsProactiveReplyTarget({ cfg, conversationId: "19:channel@thread.tacv2", ref: channelRef({ threadId: "thread-root-1" }), conversationType: "channel", }), - ).resolves.toBe("top-level"); + ).resolves.toEqual({ replyStyle: "top-level", threadActivityId: undefined }); }); it("uses top-level when a channel has no stored thread root", async () => { await expect( - resolveMSTeamsProactiveReplyStyle({ + resolveMSTeamsProactiveReplyTarget({ cfg: { replyStyle: "thread" }, conversationId: "19:channel@thread.tacv2", ref: channelRef(), conversationType: "channel", }), - ).resolves.toBe("top-level"); + ).resolves.toEqual({ replyStyle: "top-level", threadActivityId: undefined }); }); it("uses top-level for non-channel conversations", async () => { const ref = channelRef({ activityId: "activity-1" }); await expect( - resolveMSTeamsProactiveReplyStyle({ + resolveMSTeamsProactiveReplyTarget({ cfg: { replyStyle: "thread" }, conversationId: "19:group@thread.v2", ref, conversationType: "groupChat", }), - ).resolves.toBe("top-level"); + ).resolves.toEqual({ replyStyle: "top-level", threadActivityId: undefined }); await expect( - resolveMSTeamsProactiveReplyStyle({ + resolveMSTeamsProactiveReplyTarget({ cfg: { replyStyle: "thread" }, conversationId: "a:personal", ref, conversationType: "personal", }), - ).resolves.toBe("top-level"); + ).resolves.toEqual({ replyStyle: "top-level", threadActivityId: undefined }); }); }); diff --git a/extensions/msteams/src/send-context.ts b/extensions/msteams/src/send-context.ts index 1f60a705e402..b3b8c6c7fbff 100644 --- a/extensions/msteams/src/send-context.ts +++ b/extensions/msteams/src/send-context.ts @@ -3,7 +3,6 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coer import { resolveChannelMediaMaxBytes, type MSTeamsConfig, - type MSTeamsReplyStyle, type OpenClawConfig, type PluginRuntime, } from "../runtime-api.js"; @@ -33,6 +32,12 @@ import { resolveMSTeamsCredentials } from "./token.js"; type MSTeamsConversationType = "personal" | "groupChat" | "channel"; +// Keep reply policy and the Connector thread suffix together so every proactive +// activity kind uses the same resolved destination instead of re-deriving it. +type MSTeamsProactiveReplyTarget = + | { replyStyle: "thread"; threadActivityId: string } + | { replyStyle: "top-level"; threadActivityId?: never }; + export type MSTeamsProactiveContext = { appId: string; conversationId: string; @@ -41,8 +46,6 @@ export type MSTeamsProactiveContext = { log: ReturnType; /** The type of conversation: personal (1:1), groupChat, or channel */ conversationType: MSTeamsConversationType; - /** Reply style resolved for proactive text/media sends. */ - replyStyle: MSTeamsReplyStyle; /** Teams SDK cloud/service endpoint used to validate proactive sends. */ sdkCloudOptions: MSTeamsSdkCloudOptions; /** Token provider for Graph API / SharePoint operations */ @@ -51,17 +54,17 @@ export type MSTeamsProactiveContext = { sharePointSiteId?: string; /** Resolved media max bytes from config (default: 100MB) */ mediaMaxBytes?: number; -}; +} & MSTeamsProactiveReplyTarget; -function resolveMSTeamsProactiveReplyStyle(params: { +function resolveMSTeamsProactiveReplyTarget(params: { cfg?: MSTeamsConfig; conversationId: string; ref: StoredConversationReference; conversationType: MSTeamsConversationType; -}): MSTeamsReplyStyle { +}): MSTeamsProactiveReplyTarget { const threadRootId = params.ref.threadId ?? params.ref.activityId; if (params.conversationType !== "channel" || !threadRootId) { - return "top-level"; + return { replyStyle: "top-level" }; } const routeConfig = resolveMSTeamsRouteConfig({ @@ -76,7 +79,7 @@ function resolveMSTeamsProactiveReplyStyle(params: { teamConfig: routeConfig.teamConfig, channelConfig: routeConfig.channelConfig, }); - return replyStyle; + return replyStyle === "thread" ? { replyStyle, threadActivityId: threadRootId } : { replyStyle }; } /** @@ -250,10 +253,10 @@ export async function resolveMSTeamsSendContext(params: { // An explicit messageid is a caller-owned destination. Ambient and stored // roots still obey route policy, but explicit channel roots must not be // flattened by a top-level default. - const replyStyle = + const replyTarget: MSTeamsProactiveReplyTarget = recipient.threadId && conversationType === "channel" - ? "thread" - : resolveMSTeamsProactiveReplyStyle({ + ? { replyStyle: "thread", threadActivityId: recipient.threadId } + : resolveMSTeamsProactiveReplyTarget({ cfg: msteamsCfg, conversationId, ref: safeRef, @@ -276,7 +279,7 @@ export async function resolveMSTeamsSendContext(params: { app, log, conversationType, - replyStyle, + ...replyTarget, sdkCloudOptions, tokenProvider, sharePointSiteId, diff --git a/extensions/msteams/src/send.threaded-attachments.test.ts b/extensions/msteams/src/send.threaded-attachments.test.ts index 42e24c5d746c..7d569635e156 100644 --- a/extensions/msteams/src/send.threaded-attachments.test.ts +++ b/extensions/msteams/src/send.threaded-attachments.test.ts @@ -4,7 +4,7 @@ import type { AddressInfo } from "node:net"; import { Client as TeamsApiClient } from "@microsoft/teams.api"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../runtime-api.js"; -import { sendMessageMSTeams } from "./send.js"; +import { sendAdaptiveCardMSTeams, sendMessageMSTeams, sendPollMSTeams } from "./send.js"; const serviceUrl = "https://smba.trafficmanager.net/amer"; const conversationId = "19:channel@thread.tacv2"; @@ -173,6 +173,15 @@ type AttachmentRoutingCase = { expectedConversationId: string; }; +type StructuredRoutingCase = { + label: string; + conversationType: "channel" | "groupChat" | "personal"; + replyStyle: "thread" | "top-level"; + threadActivityId?: string; + storedThreadId?: string; + expectedConversationId: string; +}; + const attachmentRoutingCases: AttachmentRoutingCase[] = [ { label: "channel attachment in its stored thread root", @@ -205,6 +214,69 @@ const attachmentRoutingCases: AttachmentRoutingCase[] = [ }, ]; +const structuredRoutingCases: StructuredRoutingCase[] = [ + { + label: "threaded channel", + conversationType: "channel", + replyStyle: "thread", + threadActivityId: "thread-root-1", + storedThreadId: "thread-root-1", + expectedConversationId: `${conversationId};messageid=thread-root-1`, + }, + { + label: "top-level channel", + conversationType: "channel", + replyStyle: "top-level", + storedThreadId: "thread-root-1", + expectedConversationId: conversationId, + }, + { + label: "group chat", + conversationType: "groupChat", + replyStyle: "top-level", + storedThreadId: "group-activity-1", + expectedConversationId: conversationId, + }, + { + label: "personal chat", + conversationType: "personal", + replyStyle: "top-level", + storedThreadId: "personal-activity-1", + expectedConversationId: conversationId, + }, +]; + +type StructuredSender = { + label: string; + send: (cfg: OpenClawConfig) => Promise; +}; + +const structuredSenders: StructuredSender[] = [ + { + label: "presentation card", + send: async (cfg) => + await sendAdaptiveCardMSTeams({ + cfg, + to: conversationId, + card: { + type: "AdaptiveCard", + version: "1.4", + body: [{ type: "TextBlock", text: "Deploy finished" }], + }, + }), + }, + { + label: "poll", + send: async (cfg) => + await sendPollMSTeams({ + cfg, + to: conversationId, + question: "Ship it?", + options: ["Yes", "No"], + }), + }, +]; + describe("Microsoft Teams SharePoint attachment thread routing", () => { beforeEach(() => { vi.clearAllMocks(); @@ -231,6 +303,9 @@ describe("Microsoft Teams SharePoint attachment thread routing", () => { }, conversationType, replyStyle, + ...(replyStyle === "thread" && conversationType === "channel" + ? { threadActivityId: threadId ?? activityId } + : {}), sdkCloudOptions: { cloud: "Public" }, tokenProvider: { getAccessToken: vi.fn(async () => "token") }, sharePointSiteId: "sharepoint-site-1", @@ -307,3 +382,54 @@ describe("Microsoft Teams SharePoint attachment thread routing", () => { }); }); }); + +describe.each(structuredSenders)("Microsoft Teams $label thread routing", ({ send }) => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(structuredRoutingCases)( + "sends to the resolved $label identity through the real Teams SDK", + async ({ + conversationType, + replyStyle, + threadActivityId, + storedThreadId, + expectedConversationId, + }) => { + await withRealTeamsSdkHttp(async ({ api, requests }) => { + mockState.resolveMSTeamsSendContext.mockResolvedValue({ + app: { api }, + appId: "app-id", + conversationId, + ref: { + serviceUrl, + agent: { id: "28:bot", name: "OpenClaw", role: "bot" }, + user: { id: "29:user" }, + conversation: { id: conversationId, conversationType }, + activityId: "incoming-activity-1", + ...(storedThreadId ? { threadId: storedThreadId } : {}), + }, + conversationType, + replyStyle, + ...(threadActivityId ? { threadActivityId } : {}), + sdkCloudOptions: { cloud: "Public" }, + tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + await send({} as OpenClawConfig); + + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + path: `${new URL(serviceUrl).pathname}/v3/conversations/${expectedConversationId}/activities`, + body: { + type: "message", + conversation: { id: expectedConversationId, conversationType }, + attachments: [{ contentType: "application/vnd.microsoft.card.adaptive" }], + }, + }); + }); + }, + ); +}); diff --git a/extensions/msteams/src/send.ts b/extensions/msteams/src/send.ts index d65373328c79..0d3ef3896bb8 100644 --- a/extensions/msteams/src/send.ts +++ b/extensions/msteams/src/send.ts @@ -182,17 +182,7 @@ export async function sendMessageMSTeams( }); const messageText = formatMSTeamsMarkdown(text ?? "", tableMode); const ctx = await resolveMSTeamsSendContext({ cfg, to }); - const { - app, - conversationId, - ref, - log, - conversationType, - replyStyle, - tokenProvider, - sharePointSiteId, - sdkCloudOptions, - } = ctx; + const { conversationId, log, conversationType, tokenProvider, sharePointSiteId } = ctx; log.debug?.("sending proactive message", { conversationId, @@ -245,11 +235,9 @@ export async function sendMessageMSTeams( log.debug?.("sending file consent card", { uploadId, fileName, size: media.buffer.length }); const messageId = await sendProactiveActivity({ - app, - ref, + ctx, activity, errorPrefix: "msteams consent card send", - serviceUrlBoundary: sdkCloudOptions, }); // Store the activity ID so the accept handler can replace the consent @@ -326,15 +314,8 @@ export async function sendMessageMSTeams( attachments: [fileCardAttachment], }; const messageId = await sendProactiveActivityRaw({ - app, - ref, + ctx, activity, - // Only channel replies carry a thread root; top-level and group sends must stay unchanged. - threadActivityId: - replyStyle === "thread" && conversationType === "channel" - ? (ref.threadId ?? ref.activityId) - : undefined, - serviceUrlBoundary: sdkCloudOptions, }); log.info("sent native file card", { @@ -428,41 +409,32 @@ async function sendTextWithMedia( } type ProactiveActivityParams = { - app: MSTeamsProactiveContext["app"]; - ref: MSTeamsProactiveContext["ref"]; + ctx: MSTeamsProactiveContext; activity: Record; errorPrefix: string; - serviceUrlBoundary: MSTeamsProactiveContext["sdkCloudOptions"]; }; -type ProactiveActivityRawParams = Omit & { - threadActivityId?: string; -}; +type ProactiveActivityRawParams = Omit; async function sendProactiveActivityRaw({ - app, - ref, + ctx, activity, - threadActivityId, - serviceUrlBoundary, }: ProactiveActivityRawParams): Promise { - const baseRef = buildConversationReference(ref); - const response = await sendMSTeamsActivityWithReference(app, baseRef, activity, { - ...(threadActivityId ? { threadActivityId } : {}), - serviceUrlBoundary, + const baseRef = buildConversationReference(ctx.ref); + const response = await sendMSTeamsActivityWithReference(ctx.app, baseRef, activity, { + ...(ctx.threadActivityId ? { threadActivityId: ctx.threadActivityId } : {}), + serviceUrlBoundary: ctx.sdkCloudOptions, }); return extractMessageId(response) ?? "unknown"; } async function sendProactiveActivity({ - app, - ref, + ctx, activity, errorPrefix, - serviceUrlBoundary, }: ProactiveActivityParams): Promise { try { - return await sendProactiveActivityRaw({ app, ref, activity, serviceUrlBoundary }); + return await sendProactiveActivityRaw({ ctx, activity }); } catch (err) { const classification = classifyMSTeamsSendError(err); const hint = formatMSTeamsSendErrorHint(classification); @@ -481,10 +453,11 @@ export async function sendPollMSTeams( params: SendMSTeamsPollParams, ): Promise { const { cfg, to, question, options, maxSelections } = params; - const { app, conversationId, ref, log, sdkCloudOptions } = await resolveMSTeamsSendContext({ + const ctx = await resolveMSTeamsSendContext({ cfg, to, }); + const { conversationId, log } = ctx; const pollCard = buildMSTeamsPollCard({ question, @@ -510,11 +483,9 @@ export async function sendPollMSTeams( // Send poll via proactive conversation (Adaptive Cards require direct activity send) const messageId = await sendProactiveActivity({ - app, - ref, + ctx, activity, errorPrefix: "msteams poll send", - serviceUrlBoundary: sdkCloudOptions, }); log.info("sent poll", { conversationId, pollId: pollCard.pollId, messageId }); @@ -533,10 +504,11 @@ export async function sendAdaptiveCardMSTeams( params: SendMSTeamsCardParams, ): Promise { const { cfg, to, card } = params; - const { app, conversationId, ref, log, sdkCloudOptions } = await resolveMSTeamsSendContext({ + const ctx = await resolveMSTeamsSendContext({ cfg, to, }); + const { conversationId, log } = ctx; log.debug?.("sending adaptive card", { conversationId, @@ -556,11 +528,9 @@ export async function sendAdaptiveCardMSTeams( // Send card via proactive conversation const messageId = await sendProactiveActivity({ - app, - ref, + ctx, activity, errorPrefix: "msteams card send", - serviceUrlBoundary: sdkCloudOptions, }); log.info("sent adaptive card", { conversationId, messageId });