fix(msteams): preserve proactive thread replies (#78387)

Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
This commit is contained in:
Alex Knight
2026-05-06 20:01:18 +10:00
committed by GitHub
parent fa445003b5
commit 28e27ca5d1
7 changed files with 281 additions and 2 deletions

View File

@@ -574,6 +574,94 @@ describe("msteams messenger", () => {
expect(reference.conversation?.id).toBe("19:abc@thread.tacv2;messageid=legacy-activity-id");
});
it("sends no-context thread replies proactively with the channel thread root", 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,
};
const ids = await sendMSTeamsMessages({
replyStyle: "thread",
adapter,
appId: "app123",
conversationRef: channelRef,
messages: [{ text: "hello" }],
});
expect(sent).toEqual(["hello"]);
expect(ids).toEqual(["id:hello"]);
const ref = capturedReference as { conversation?: { id?: string }; activityId?: string };
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2;messageid=thread-root-msg-id");
expect(ref.activityId).toBeUndefined();
});
it("uses activityId for no-context thread replies when threadId is absent", async () => {
let capturedReference: unknown;
const sent: string[] = [];
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",
};
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: "thread",
adapter,
appId: "app123",
conversationRef: channelRef,
messages: [{ text: "hello" }],
});
const ref = capturedReference as { conversation?: { id?: string }; activityId?: string };
expect(sent).toEqual(["hello"]);
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2;messageid=legacy-activity-id");
expect(ref.activityId).toBeUndefined();
});
it("does not add thread suffix for top-level replyStyle even with threadId set", async () => {
let capturedReference: unknown;
const sent: string[] = [];

View File

@@ -572,7 +572,7 @@ export async function sendMSTeamsMessages(params: {
if (params.replyStyle === "thread") {
const ctx = params.context;
if (!ctx) {
throw new Error("Missing context for replyStyle=thread");
return await sendProactively(messages, 0, resolvedThreadId);
}
const messageIds: string[] = [];
for (const [idx, message] of messages.entries()) {

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import type { MSTeamsConfig } from "../runtime-api.js";
import type { StoredConversationReference } from "./conversation-store.js";
import { resolveMSTeamsProactiveReplyStyle } from "./send-context.js";
function channelRef(params?: Partial<StoredConversationReference>): StoredConversationReference {
return {
user: { id: "user-1" },
agent: { id: "agent-1" },
conversation: { id: "19:channel@thread.tacv2", conversationType: "channel" },
channelId: "msteams",
teamId: "team-1",
...params,
};
}
describe("resolveMSTeamsProactiveReplyStyle", () => {
it("uses thread for channel conversations with a stored thread root", () => {
expect(
resolveMSTeamsProactiveReplyStyle({
cfg: {},
conversationId: "19:channel@thread.tacv2",
ref: channelRef({ threadId: "thread-root-1" }),
conversationType: "channel",
}),
).toBe("thread");
});
it("falls back to activityId for legacy channel references", () => {
expect(
resolveMSTeamsProactiveReplyStyle({
cfg: {},
conversationId: "19:channel@thread.tacv2",
ref: channelRef({ activityId: "legacy-root-1" }),
conversationType: "channel",
}),
).toBe("thread");
});
it("keeps configured top-level channel routing", () => {
const cfg: MSTeamsConfig = {
replyStyle: "thread",
teams: {
"team-1": {
channels: {
"19:channel@thread.tacv2": { replyStyle: "top-level" },
},
},
},
};
expect(
resolveMSTeamsProactiveReplyStyle({
cfg,
conversationId: "19:channel@thread.tacv2",
ref: channelRef({ threadId: "thread-root-1" }),
conversationType: "channel",
}),
).toBe("top-level");
});
it("uses top-level when a channel has no stored thread root", () => {
expect(
resolveMSTeamsProactiveReplyStyle({
cfg: { replyStyle: "thread" },
conversationId: "19:channel@thread.tacv2",
ref: channelRef(),
conversationType: "channel",
}),
).toBe("top-level");
});
it("uses top-level for non-channel conversations", () => {
const ref = channelRef({ activityId: "activity-1" });
expect(
resolveMSTeamsProactiveReplyStyle({
cfg: { replyStyle: "thread" },
conversationId: "19:group@thread.v2",
ref,
conversationType: "groupChat",
}),
).toBe("top-level");
expect(
resolveMSTeamsProactiveReplyStyle({
cfg: { replyStyle: "thread" },
conversationId: "a:personal",
ref,
conversationType: "personal",
}),
).toBe("top-level");
});
});

View File

@@ -1,6 +1,8 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import {
resolveChannelMediaMaxBytes,
type MSTeamsConfig,
type MSTeamsReplyStyle,
type OpenClawConfig,
type PluginRuntime,
} from "../runtime-api.js";
@@ -13,6 +15,7 @@ import type {
import { formatUnknownError } from "./errors.js";
import { resolveGraphChatId } from "./graph-upload.js";
import type { MSTeamsAdapter } from "./messenger.js";
import { resolveMSTeamsReplyPolicy, resolveMSTeamsRouteConfig } from "./policy.js";
import { getMSTeamsRuntime } from "./runtime.js";
import { createMSTeamsAdapter, createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js";
@@ -27,6 +30,8 @@ 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;
/** Token provider for Graph API / OneDrive operations */
tokenProvider: MSTeamsAccessTokenProvider;
/** SharePoint site ID for file uploads in group chats/channels */
@@ -42,6 +47,32 @@ export type MSTeamsProactiveContext = {
graphChatId?: string | null;
};
export function resolveMSTeamsProactiveReplyStyle(params: {
cfg?: MSTeamsConfig;
conversationId: string;
ref: StoredConversationReference;
conversationType: MSTeamsConversationType;
}): MSTeamsReplyStyle {
const threadRootId = params.ref.threadId ?? params.ref.activityId;
if (params.conversationType !== "channel" || !threadRootId) {
return "top-level";
}
const routeConfig = resolveMSTeamsRouteConfig({
cfg: params.cfg,
teamId: params.ref.teamId,
conversationId: params.conversationId,
allowNameMatching: false,
});
const { replyStyle } = resolveMSTeamsReplyPolicy({
isDirectMessage: false,
globalConfig: params.cfg,
teamConfig: routeConfig.teamConfig,
channelConfig: routeConfig.channelConfig,
});
return replyStyle;
}
/**
* Parse the target value into a conversation reference lookup key.
* Supported formats:
@@ -167,6 +198,12 @@ export async function resolveMSTeamsSendContext(params: {
// groupChat, or unknown defaults to groupChat behavior
conversationType = "groupChat";
}
const replyStyle = resolveMSTeamsProactiveReplyStyle({
cfg: msteamsCfg,
conversationId,
ref,
conversationType,
});
// Get SharePoint site ID from config (required for file uploads in group chats/channels)
const sharePointSiteId = msteamsCfg.sharePointSiteId;
@@ -223,6 +260,7 @@ export async function resolveMSTeamsSendContext(params: {
adapter: adapter as unknown as MSTeamsAdapter,
log,
conversationType,
replyStyle,
tokenProvider,
sharePointSiteId,
mediaMaxBytes,

View File

@@ -116,6 +116,7 @@ function createSharePointSendContext(params: {
ref: {},
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
conversationType: "groupChat" as const,
replyStyle: "top-level" as const,
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
mediaMaxBytes: 8 * 1024 * 1024,
sharePointSiteId: params.siteId,
@@ -184,6 +185,7 @@ describe("sendMessageMSTeams", () => {
ref: {},
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
conversationType: "personal",
replyStyle: "top-level",
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
mediaMaxBytes: 8 * 1024,
sharePointSiteId: undefined,
@@ -266,6 +268,62 @@ describe("sendMessageMSTeams", () => {
expect(mockState.convertMarkdownTables).toHaveBeenCalledWith("hello", "off");
});
it("passes the resolved proactive replyStyle to text sends", async () => {
mockState.resolveMSTeamsSendContext.mockResolvedValue({
adapter: {},
appId: "app-id",
conversationId: "19:channel@thread.tacv2",
ref: {
threadId: "thread-root-1",
conversation: { id: "19:channel@thread.tacv2", conversationType: "channel" },
},
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
conversationType: "channel",
replyStyle: "thread",
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
mediaMaxBytes: 8 * 1024,
sharePointSiteId: undefined,
});
await sendMessageMSTeams({
cfg: {} as OpenClawConfig,
to: "conversation:19:channel@thread.tacv2",
text: "threaded reply",
});
expect(mockState.sendMSTeamsMessages).toHaveBeenCalledWith(
expect.objectContaining({ replyStyle: "thread" }),
);
});
it("keeps top-level proactive replyStyle when resolved for a channel", async () => {
mockState.resolveMSTeamsSendContext.mockResolvedValue({
adapter: {},
appId: "app-id",
conversationId: "19:channel@thread.tacv2",
ref: {
threadId: "thread-root-1",
conversation: { id: "19:channel@thread.tacv2", conversationType: "channel" },
},
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
conversationType: "channel",
replyStyle: "top-level",
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
mediaMaxBytes: 8 * 1024,
sharePointSiteId: undefined,
});
await sendMessageMSTeams({
cfg: {} as OpenClawConfig,
to: "conversation:19:channel@thread.tacv2",
text: "top-level reply",
});
expect(mockState.sendMSTeamsMessages).toHaveBeenCalledWith(
expect.objectContaining({ replyStyle: "top-level" }),
);
});
it("uses graphChatId instead of conversationId when uploading to SharePoint", async () => {
// Simulates a group chat where Bot Framework conversationId is valid but we have
// a resolved Graph chat ID cached from a prior send.

View File

@@ -391,12 +391,13 @@ async function sendTextWithMedia(
tokenProvider,
sharePointSiteId,
mediaMaxBytes,
replyStyle,
} = ctx;
let platformMessageIds: string[];
try {
platformMessageIds = await sendMSTeamsMessages({
replyStyle: "top-level",
replyStyle,
adapter,
appId,
conversationRef: ref,