mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:40:44 +00:00
fix(msteams): preserve proactive thread replies (#78387)
Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
This commit is contained in:
@@ -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[] = [];
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
93
extensions/msteams/src/send-context.test.ts
Normal file
93
extensions/msteams/src/send-context.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user