Skip to content

Commit 091f72f

Browse files
Jasmine ZhangJasmine Zhang
authored andcommitted
fix(msteams): keep file replies in channel threads
1 parent 978b522 commit 091f72f

2 files changed

Lines changed: 113 additions & 2 deletions

File tree

extensions/msteams/src/send.test.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const mockState = vi.hoisted(() => ({
2222
getDriveItemProperties: vi.fn(),
2323
buildTeamsFileInfoCard: vi.fn(),
2424
createMSTeamsTokenProvider: vi.fn(),
25+
uploadAndShareOneDrive: vi.fn(),
2526
}));
2627

2728
// `loadOutboundMediaFromUrl` is re-exported from msteams's runtime-api which
@@ -86,7 +87,7 @@ vi.mock("./runtime.js", () => ({
8687
vi.mock("./graph-upload.js", () => ({
8788
uploadAndShareSharePoint: mockState.uploadAndShareSharePoint,
8889
getDriveItemProperties: mockState.getDriveItemProperties,
89-
uploadAndShareOneDrive: vi.fn(),
90+
uploadAndShareOneDrive: mockState.uploadAndShareOneDrive,
9091
}));
9192

9293
vi.mock("./graph-chat.js", () => ({
@@ -238,6 +239,7 @@ describe("sendMessageMSTeams", () => {
238239
mockState.uploadAndShareSharePoint.mockReset();
239240
mockState.getDriveItemProperties.mockReset();
240241
mockState.buildTeamsFileInfoCard.mockReset();
242+
mockState.uploadAndShareOneDrive.mockReset();
241243

242244
mockState.extractFilename.mockResolvedValue("fallback.bin");
243245
mockState.requiresFileConsent.mockReturnValue(false);
@@ -382,6 +384,46 @@ describe("sendMessageMSTeams", () => {
382384
expect(firstObjectArg(mockState.sendMSTeamsMessages).replyStyle).toBe("top-level");
383385
});
384386

387+
it("keeps SharePoint file-card replies inside channel threads", async () => {
388+
mockState.resolveMSTeamsSendContext.mockResolvedValue({
389+
app: createMockApp(),
390+
appId: "app-id",
391+
conversationId: "19:[email protected]",
392+
graphChatId: "19:[email protected]",
393+
ref: {
394+
activityId: "current-msg-id",
395+
threadId: "thread-root-1",
396+
conversation: { id: "19:[email protected]", conversationType: "channel" },
397+
},
398+
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
399+
conversationType: "channel",
400+
replyStyle: "thread",
401+
sdkCloudOptions: { cloud: "Public" },
402+
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
403+
mediaMaxBytes: 8 * 1024 * 1024,
404+
sharePointSiteId: "site-threaded",
405+
});
406+
mockSharePointPdfUpload({
407+
bufferSize: 100,
408+
fileName: "threaded.pdf",
409+
itemId: "item-threaded",
410+
uniqueId: "{GUID-THREAD}",
411+
});
412+
413+
await sendMessageMSTeams({
414+
cfg: {} as OpenClawConfig,
415+
to: "conversation:19:[email protected]",
416+
text: "threaded file",
417+
mediaUrl: "https://example.com/threaded.pdf",
418+
});
419+
420+
expect(mockState.sendMSTeamsActivityWithReference).toHaveBeenCalledTimes(1);
421+
expect(mockState.sendMSTeamsActivityWithReference.mock.calls[0]?.[3]).toMatchObject({
422+
threadActivityId: "thread-root-1",
423+
serviceUrlBoundary: { cloud: "Public" },
424+
});
425+
});
426+
385427
it("uses graphChatId instead of conversationId when uploading to SharePoint", async () => {
386428
// Simulates a group chat where Bot Framework conversationId is valid but we have
387429
// a resolved Graph chat ID cached from a prior send.
@@ -415,6 +457,51 @@ describe("sendMessageMSTeams", () => {
415457
expect(uploadPayload.siteId).toBe("site-123");
416458
});
417459

460+
it("keeps OneDrive fallback replies inside channel threads", async () => {
461+
mockState.resolveMSTeamsSendContext.mockResolvedValue({
462+
app: createMockApp(),
463+
appId: "app-id",
464+
conversationId: "19:[email protected]",
465+
ref: {
466+
activityId: "current-msg-id",
467+
threadId: "thread-root-2",
468+
conversation: { id: "19:[email protected]", conversationType: "channel" },
469+
},
470+
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
471+
conversationType: "channel",
472+
replyStyle: "thread",
473+
sdkCloudOptions: { cloud: "Public" },
474+
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
475+
mediaMaxBytes: 8 * 1024 * 1024,
476+
sharePointSiteId: undefined,
477+
});
478+
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
479+
buffer: Buffer.from("doc"),
480+
contentType: "application/pdf",
481+
fileName: "fallback.pdf",
482+
kind: "file",
483+
});
484+
mockState.uploadAndShareOneDrive.mockResolvedValue({
485+
itemId: "onedrive-item-1",
486+
webUrl: "https://onedrive.example.com/item-1",
487+
shareUrl: "https://onedrive.example.com/share/item-1",
488+
name: "fallback.pdf",
489+
});
490+
491+
await sendMessageMSTeams({
492+
cfg: {} as OpenClawConfig,
493+
to: "conversation:19:[email protected]",
494+
text: "fallback file",
495+
mediaUrl: "https://example.com/fallback.pdf",
496+
});
497+
498+
expect(mockState.sendMSTeamsActivityWithReference).toHaveBeenCalledTimes(1);
499+
expect(mockState.sendMSTeamsActivityWithReference.mock.calls[0]?.[3]).toMatchObject({
500+
threadActivityId: "thread-root-2",
501+
serviceUrlBoundary: { cloud: "Public" },
502+
});
503+
});
504+
418505
it("falls back to conversationId when graphChatId is not available", async () => {
419506
const botFrameworkConversationId = "19:[email protected]";
420507

extensions/msteams/src/send.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ export async function sendMessageMSTeams(
162162
ref,
163163
log,
164164
conversationType,
165+
replyStyle,
165166
tokenProvider,
166167
sharePointSiteId,
167168
sdkCloudOptions,
@@ -307,6 +308,7 @@ export async function sendMessageMSTeams(
307308
app,
308309
ref,
309310
activity,
311+
threadActivityId: resolveThreadActivityId(ref, replyStyle, conversationType),
310312
serviceUrlBoundary: sdkCloudOptions,
311313
});
312314

@@ -351,6 +353,7 @@ export async function sendMessageMSTeams(
351353
app,
352354
ref,
353355
activity,
356+
threadActivityId: resolveThreadActivityId(ref, replyStyle, conversationType),
354357
serviceUrlBoundary: sdkCloudOptions,
355358
});
356359

@@ -447,18 +450,32 @@ type ProactiveActivityParams = {
447450
activity: Record<string, unknown>;
448451
errorPrefix: string;
449452
serviceUrlBoundary: MSTeamsProactiveContext["sdkCloudOptions"];
453+
threadActivityId?: string;
450454
};
451455

452456
type ProactiveActivityRawParams = Omit<ProactiveActivityParams, "errorPrefix">;
453457

458+
function resolveThreadActivityId(
459+
ref: MSTeamsProactiveContext["ref"],
460+
replyStyle: MSTeamsProactiveContext["replyStyle"],
461+
conversationType: MSTeamsProactiveContext["conversationType"],
462+
): string | undefined {
463+
if (replyStyle !== "thread" || conversationType !== "channel") {
464+
return undefined;
465+
}
466+
return ref.threadId ?? ref.activityId;
467+
}
468+
454469
async function sendProactiveActivityRaw({
455470
app,
456471
ref,
457472
activity,
473+
threadActivityId,
458474
serviceUrlBoundary,
459475
}: ProactiveActivityRawParams): Promise<string> {
460476
const baseRef = buildConversationReference(ref);
461477
const response = await sendMSTeamsActivityWithReference(app, baseRef, activity, {
478+
...(threadActivityId ? { threadActivityId } : {}),
462479
serviceUrlBoundary,
463480
});
464481
return extractMessageId(response) ?? "unknown";
@@ -470,9 +487,16 @@ async function sendProactiveActivity({
470487
activity,
471488
errorPrefix,
472489
serviceUrlBoundary,
490+
threadActivityId,
473491
}: ProactiveActivityParams): Promise<string> {
474492
try {
475-
return await sendProactiveActivityRaw({ app, ref, activity, serviceUrlBoundary });
493+
return await sendProactiveActivityRaw({
494+
app,
495+
ref,
496+
activity,
497+
threadActivityId,
498+
serviceUrlBoundary,
499+
});
476500
} catch (err) {
477501
const classification = classifyMSTeamsSendError(err);
478502
const hint = formatMSTeamsSendErrorHint(classification);

0 commit comments

Comments
 (0)