From b2b89fb9948e1e9171db07c1da8b1690f9d81143 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sun, 19 Apr 2026 21:19:42 -0700 Subject: [PATCH 1/2] fix(adapter-teams): resolve DM conversation IDs for Graph API fetchMessages DM conversation IDs from Bot Framework are opaque and don't work with Graph's /chats/{chat-id}/messages endpoint. Cache the user's AAD object ID from incoming activities and construct the correct Graph chat ID (19:{aadId}_{botId}@unq.gbl.spaces) via a new TeamsGraphContext union. Co-Authored-By: Claude --- .changeset/fix-teams-dm-fetchmessages.md | 5 + packages/adapter-teams/src/graph-api.test.ts | 34 ++++- packages/adapter-teams/src/graph-api.ts | 123 +++++++++++-------- packages/adapter-teams/src/index.ts | 31 ++++- packages/adapter-teams/src/types.ts | 14 +++ 5 files changed, 146 insertions(+), 61 deletions(-) create mode 100644 .changeset/fix-teams-dm-fetchmessages.md diff --git a/.changeset/fix-teams-dm-fetchmessages.md b/.changeset/fix-teams-dm-fetchmessages.md new file mode 100644 index 00000000..620dcf64 --- /dev/null +++ b/.changeset/fix-teams-dm-fetchmessages.md @@ -0,0 +1,5 @@ +--- +'@chat-adapter/teams': patch +--- + +Fix fetchMessages 404 for DM conversations by caching the user's AAD object ID and resolving the Graph API chat ID diff --git a/packages/adapter-teams/src/graph-api.test.ts b/packages/adapter-teams/src/graph-api.test.ts index 6ad70fd3..a18646b8 100644 --- a/packages/adapter-teams/src/graph-api.test.ts +++ b/packages/adapter-teams/src/graph-api.test.ts @@ -9,7 +9,7 @@ function createTestReader(): TeamsGraphReader { botId: "test-app", graph: new GraphClient(), formatConverter: new TeamsFormatConverter(), - getChannelContext: async () => null, + getGraphContext: async () => null, logger: new ConsoleLogger("error"), }); } @@ -151,3 +151,35 @@ describe("extractCardTitle", () => { expect(reader.extractCardTitle(card)).toBe("First block"); }); }); + +describe("chatIdFromContext", () => { + it("should use graphChatId from DM context", () => { + const reader = createTestReader(); + // biome-ignore lint/complexity/useLiteralKeys: testing private method + const result = (reader as never)["chatIdFromContext"]( + { type: "dm", graphChatId: "19:user-aad-id_bot-id@unq.gbl.spaces" }, + "a:opaque-conversation-id" + ); + expect(result).toBe("19:user-aad-id_bot-id@unq.gbl.spaces"); + }); + + it("should use raw conversation ID when no context", () => { + const reader = createTestReader(); + // biome-ignore lint/complexity/useLiteralKeys: testing private method + const result = (reader as never)["chatIdFromContext"]( + null, + "19:group-chat@thread.v2" + ); + expect(result).toBe("19:group-chat@thread.v2"); + }); + + it("should use raw conversation ID for channel context", () => { + const reader = createTestReader(); + // biome-ignore lint/complexity/useLiteralKeys: testing private method + const result = (reader as never)["chatIdFromContext"]( + { teamId: "team-id", channelId: "channel-id" }, + "19:channel@thread.tacv2" + ); + expect(result).toBe("19:channel@thread.tacv2"); + }); +}); diff --git a/packages/adapter-teams/src/graph-api.ts b/packages/adapter-teams/src/graph-api.ts index 217112e6..c9962b85 100644 --- a/packages/adapter-teams/src/graph-api.ts +++ b/packages/adapter-teams/src/graph-api.ts @@ -15,7 +15,7 @@ import type { import { Message, NotImplementedError } from "chat"; import type { TeamsFormatConverter } from "./markdown"; import { decodeThreadId, encodeThreadId, isDM } from "./thread-id"; -import type { TeamsChannelContext } from "./types"; +import type { TeamsChannelContext, TeamsGraphContext } from "./types"; const MESSAGEID_STRIP_PATTERN = /;messageid=\d+/; const SEMICOLON_MESSAGEID_CAPTURE_PATTERN = /;messageid=(\d+)/; @@ -33,9 +33,9 @@ type GraphMessage = NonNullable[number]; export interface TeamsGraphReaderDeps { botId: string; formatConverter: TeamsFormatConverter; - getChannelContext: ( + getGraphContext: ( baseConversationId: string - ) => Promise; + ) => Promise; graph: GraphClient; logger: Logger; } @@ -47,6 +47,21 @@ export class TeamsGraphReader { this.deps = deps; } + /** + * Resolve the Graph API chat ID for a non-channel conversation. + * Uses the DM context's graphChatId if available, otherwise falls back to + * the raw conversation ID (works for group chats). + */ + private chatIdFromContext( + context: TeamsGraphContext | null, + baseConversationId: string + ): string { + if (context?.type === "dm") { + return context.graphChatId; + } + return baseConversationId; + } + async fetchMessages( threadId: string, options: FetchOptions = {} @@ -65,35 +80,34 @@ export class TeamsGraphReader { "" ); - const channelContext = threadMessageId - ? await this.deps.getChannelContext(baseConversationId) - : null; + const graphContext = await this.deps.getGraphContext(baseConversationId); try { this.deps.logger.debug("Teams Graph API: fetching messages", { conversationId: baseConversationId, threadMessageId, - hasChannelContext: !!channelContext, + contextType: graphContext?.type ?? "none", limit, cursor, direction, }); - if (channelContext && threadMessageId) { + if (graphContext?.type !== "dm" && graphContext && threadMessageId) { return this.fetchChannelThreadMessages( - channelContext, + graphContext, threadMessageId, threadId, options ); } + const chatId = this.chatIdFromContext(graphContext, baseConversationId); let graphMessages: GraphMessage[]; let hasMoreMessages = false; if (direction === "forward") { const response = await this.deps.graph.call(chats.messages.list, { - "chat-id": baseConversationId, + "chat-id": chatId, $top: limit, $orderby: ["createdDateTime asc"], $filter: cursor ? `createdDateTime gt ${cursor}` : undefined, @@ -102,7 +116,7 @@ export class TeamsGraphReader { hasMoreMessages = graphMessages.length >= limit; } else { const response = await this.deps.graph.call(chats.messages.list, { - "chat-id": baseConversationId, + "chat-id": chatId, $top: limit, $orderby: ["createdDateTime desc"], $filter: cursor ? `createdDateTime lt ${cursor}` : undefined, @@ -112,7 +126,7 @@ export class TeamsGraphReader { hasMoreMessages = graphMessages.length >= limit; } - if (threadMessageId && !channelContext) { + if (threadMessageId && graphContext?.type !== "dm" && !graphContext) { graphMessages = graphMessages.filter((msg) => { return msg.id && msg.id >= threadMessageId; }); @@ -175,12 +189,11 @@ export class TeamsGraphReader { const direction = options.direction ?? "backward"; try { - const channelContext = - await this.deps.getChannelContext(baseConversationId); + const graphContext = await this.deps.getGraphContext(baseConversationId); this.deps.logger.debug("Teams Graph API: fetchChannelMessages", { conversationId: baseConversationId, - hasChannelContext: !!channelContext, + contextType: graphContext?.type ?? "none", limit, direction, }); @@ -188,10 +201,10 @@ export class TeamsGraphReader { let graphMessages: GraphMessage[]; let hasMoreMessages = false; - if (channelContext) { + if (graphContext && graphContext.type !== "dm") { const channelParams = { - "team-id": channelContext.teamId, - "channel-id": channelContext.channelId, + "team-id": graphContext.teamId, + "channel-id": graphContext.channelId, }; if (direction === "forward") { @@ -239,29 +252,32 @@ export class TeamsGraphReader { graphMessages.reverse(); hasMoreMessages = graphMessages.length >= limit; } - } else if (direction === "forward") { - const response = await this.deps.graph.call(chats.messages.list, { - "chat-id": baseConversationId, - $top: limit, - $orderby: ["createdDateTime asc"], - $filter: options.cursor - ? `createdDateTime gt ${options.cursor}` - : undefined, - }); - graphMessages = (response.value || []) as GraphMessage[]; - hasMoreMessages = graphMessages.length >= limit; } else { - const response = await this.deps.graph.call(chats.messages.list, { - "chat-id": baseConversationId, - $top: limit, - $orderby: ["createdDateTime desc"], - $filter: options.cursor - ? `createdDateTime lt ${options.cursor}` - : undefined, - }); - graphMessages = (response.value || []) as GraphMessage[]; - graphMessages.reverse(); - hasMoreMessages = graphMessages.length >= limit; + const chatId = this.chatIdFromContext(graphContext, baseConversationId); + if (direction === "forward") { + const response = await this.deps.graph.call(chats.messages.list, { + "chat-id": chatId, + $top: limit, + $orderby: ["createdDateTime asc"], + $filter: options.cursor + ? `createdDateTime gt ${options.cursor}` + : undefined, + }); + graphMessages = (response.value || []) as GraphMessage[]; + hasMoreMessages = graphMessages.length >= limit; + } else { + const response = await this.deps.graph.call(chats.messages.list, { + "chat-id": chatId, + $top: limit, + $orderby: ["createdDateTime desc"], + $filter: options.cursor + ? `createdDateTime lt ${options.cursor}` + : undefined, + }); + graphMessages = (response.value || []) as GraphMessage[]; + graphMessages.reverse(); + hasMoreMessages = graphMessages.length >= limit; + } } const messages = this.mapGraphMessages(graphMessages, channelId); @@ -297,19 +313,18 @@ export class TeamsGraphReader { "" ); - const channelContext = - await this.deps.getChannelContext(baseConversationId); + const graphContext = await this.deps.getGraphContext(baseConversationId); - if (channelContext) { + if (graphContext && graphContext.type !== "dm") { try { this.deps.logger.debug("Teams Graph API: GET channel info", { - teamId: channelContext.teamId, - channelId: channelContext.channelId, + teamId: graphContext.teamId, + channelId: graphContext.channelId, }); const response = await this.deps.graph.call(teams.channels.get, { - "team-id": channelContext.teamId, - "channel-id": channelContext.channelId, + "team-id": graphContext.teamId, + "channel-id": graphContext.channelId, }); return { @@ -361,23 +376,22 @@ export class TeamsGraphReader { const limit = options.limit || 50; try { - const channelContext = - await this.deps.getChannelContext(baseConversationId); + const graphContext = await this.deps.getGraphContext(baseConversationId); this.deps.logger.debug("Teams Graph API: listThreads", { conversationId: baseConversationId, - hasChannelContext: !!channelContext, + contextType: graphContext?.type ?? "none", limit, }); const threads: ThreadSummary[] = []; - if (channelContext) { + if (graphContext && graphContext.type !== "dm") { const response = await this.deps.graph.call( teams.channels.messages.list, { - "team-id": channelContext.teamId, - "channel-id": channelContext.channelId, + "team-id": graphContext.teamId, + "channel-id": graphContext.channelId, $top: limit, } ); @@ -434,8 +448,9 @@ export class TeamsGraphReader { }); } } else { + const chatId = this.chatIdFromContext(graphContext, baseConversationId); const response = await this.deps.graph.call(chats.messages.list, { - "chat-id": baseConversationId, + "chat-id": chatId, $top: limit, $orderby: ["createdDateTime desc"], }); diff --git a/packages/adapter-teams/src/index.ts b/packages/adapter-teams/src/index.ts index 89925d69..76541d06 100644 --- a/packages/adapter-teams/src/index.ts +++ b/packages/adapter-teams/src/index.ts @@ -63,6 +63,8 @@ import { decodeThreadId, encodeThreadId, isDM } from "./thread-id"; import type { TeamsAdapterConfig, TeamsChannelContext, + TeamsDmContext, + TeamsGraphContext, TeamsThreadId, } from "./types"; @@ -112,8 +114,8 @@ export class TeamsAdapter implements Adapter { graph: this.app.graph, logger: this.logger, formatConverter: this.formatConverter, - getChannelContext: (baseConversationId) => - this.getChannelContext(baseConversationId), + getGraphContext: (baseConversationId) => + this.getGraphContext(baseConversationId), }); } @@ -216,14 +218,31 @@ export class TeamsAdapter implements Adapter { ) .catch(() => {}); } + + // Cache DM context for Graph API chat ID resolution + const aadObjectId = (activity.from as { aadObjectId?: string }).aadObjectId; + if (aadObjectId && this.app.id && !baseChannelId.startsWith("19:")) { + const dmContext: TeamsDmContext = { + type: "dm", + graphChatId: `19:${aadObjectId}_${this.app.id}@unq.gbl.spaces`, + }; + this.chat + .getState() + .set( + `teams:channelContext:${baseChannelId}`, + JSON.stringify(dmContext), + ttl + ) + .catch(() => {}); + } } /** - * Look up cached channel context, resolving aadGroupId via Bot API if needed. + * Look up cached Graph context (channel or DM), resolving via Bot API if needed. */ - private async getChannelContext( + private async getGraphContext( baseConversationId: string - ): Promise { + ): Promise { if (!this.chat) { return null; } @@ -233,7 +252,7 @@ export class TeamsAdapter implements Adapter { .get(`teams:channelContext:${baseConversationId}`); if (cached) { try { - return JSON.parse(cached) as TeamsChannelContext; + return JSON.parse(cached) as TeamsGraphContext; } catch { return null; } diff --git a/packages/adapter-teams/src/types.ts b/packages/adapter-teams/src/types.ts index 22c1d167..d781cad6 100644 --- a/packages/adapter-teams/src/types.ts +++ b/packages/adapter-teams/src/types.ts @@ -49,4 +49,18 @@ export interface TeamsThreadId { export interface TeamsChannelContext { channelId: string; teamId: string; + /** Discriminator — absent for channel (backwards-compat with existing cache). */ + type?: "channel"; } + +/** DM context with the resolved Graph API chat ID */ +export interface TeamsDmContext { + graphChatId: string; + type: "dm"; +} + +/** + * Discriminated union for Graph API resolution context. + * Group chats are not included — their conversation ID works as-is with Graph. + */ +export type TeamsGraphContext = TeamsChannelContext | TeamsDmContext; From 3bfca5119e19b8574d003250592e0826aea915a8 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sun, 19 Apr 2026 21:35:11 -0700 Subject: [PATCH 2/2] fix(adapter-teams): simplify graph context branching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove redundant type checks — DMs never have threadMessageId so the channel guard doesn't need an explicit DM exclusion. Co-Authored-By: Claude --- packages/adapter-teams/src/graph-api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/adapter-teams/src/graph-api.ts b/packages/adapter-teams/src/graph-api.ts index c9962b85..3d0da180 100644 --- a/packages/adapter-teams/src/graph-api.ts +++ b/packages/adapter-teams/src/graph-api.ts @@ -92,7 +92,7 @@ export class TeamsGraphReader { direction, }); - if (graphContext?.type !== "dm" && graphContext && threadMessageId) { + if (graphContext && graphContext.type !== "dm" && threadMessageId) { return this.fetchChannelThreadMessages( graphContext, threadMessageId, @@ -126,7 +126,7 @@ export class TeamsGraphReader { hasMoreMessages = graphMessages.length >= limit; } - if (threadMessageId && graphContext?.type !== "dm" && !graphContext) { + if (threadMessageId && !graphContext) { graphMessages = graphMessages.filter((msg) => { return msg.id && msg.id >= threadMessageId; });