fix(msteams): preserve thread targets for structured sends (#117516)

This commit is contained in:
Peter Steinberger
2026-08-01 10:07:22 -07:00
committed by GitHub
parent 29577fb03b
commit 17625e5cd2
6 changed files with 209 additions and 85 deletions
+23 -2
View File
@@ -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) {
+2 -2
View File
@@ -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,
+24 -20
View File
@@ -50,7 +50,7 @@ function channelRef(params?: Partial<StoredConversationReference>): 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 });
});
});
+15 -12
View File
@@ -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<PluginRuntime["logging"]["getChildLogger"]>;
/** 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,
@@ -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<unknown>;
};
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" }],
},
});
});
},
);
});
+18 -48
View File
@@ -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<string, unknown>;
errorPrefix: string;
serviceUrlBoundary: MSTeamsProactiveContext["sdkCloudOptions"];
};
type ProactiveActivityRawParams = Omit<ProactiveActivityParams, "errorPrefix"> & {
threadActivityId?: string;
};
type ProactiveActivityRawParams = Omit<ProactiveActivityParams, "errorPrefix">;
async function sendProactiveActivityRaw({
app,
ref,
ctx,
activity,
threadActivityId,
serviceUrlBoundary,
}: ProactiveActivityRawParams): Promise<string> {
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<string> {
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<SendMSTeamsPollResult> {
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<SendMSTeamsCardResult> {
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 });