Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-teams-dm-fetchmessages.md
Original file line number Diff line number Diff line change
@@ -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
34 changes: 33 additions & 1 deletion packages/adapter-teams/src/graph-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
});
}
Expand Down Expand Up @@ -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");
});
});
123 changes: 69 additions & 54 deletions packages/adapter-teams/src/graph-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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+)/;
Expand All @@ -33,9 +33,9 @@ type GraphMessage = NonNullable<ChatMessageListResponse["value"]>[number];
export interface TeamsGraphReaderDeps {
botId: string;
formatConverter: TeamsFormatConverter;
getChannelContext: (
getGraphContext: (
baseConversationId: string
) => Promise<TeamsChannelContext | null>;
) => Promise<TeamsGraphContext | null>;
graph: GraphClient;
logger: Logger;
}
Expand All @@ -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 = {}
Expand All @@ -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 && graphContext.type !== "dm" && 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,
Expand All @@ -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,
Expand All @@ -112,7 +126,7 @@ export class TeamsGraphReader {
hasMoreMessages = graphMessages.length >= limit;
}

if (threadMessageId && !channelContext) {
if (threadMessageId && !graphContext) {
graphMessages = graphMessages.filter((msg) => {
return msg.id && msg.id >= threadMessageId;
});
Expand Down Expand Up @@ -175,23 +189,22 @@ 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,
});

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") {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
}
);
Expand Down Expand Up @@ -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"],
});
Expand Down
31 changes: 25 additions & 6 deletions packages/adapter-teams/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ import { decodeThreadId, encodeThreadId, isDM } from "./thread-id";
import type {
TeamsAdapterConfig,
TeamsChannelContext,
TeamsDmContext,
TeamsGraphContext,
TeamsThreadId,
} from "./types";

Expand Down Expand Up @@ -112,8 +114,8 @@ export class TeamsAdapter implements Adapter<TeamsThreadId, unknown> {
graph: this.app.graph,
logger: this.logger,
formatConverter: this.formatConverter,
getChannelContext: (baseConversationId) =>
this.getChannelContext(baseConversationId),
getGraphContext: (baseConversationId) =>
this.getGraphContext(baseConversationId),
});
}

Expand Down Expand Up @@ -216,14 +218,31 @@ export class TeamsAdapter implements Adapter<TeamsThreadId, unknown> {
)
.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<TeamsChannelContext | null> {
): Promise<TeamsGraphContext | null> {
if (!this.chat) {
return null;
}
Expand All @@ -233,7 +252,7 @@ export class TeamsAdapter implements Adapter<TeamsThreadId, unknown> {
.get<string>(`teams:channelContext:${baseConversationId}`);
if (cached) {
try {
return JSON.parse(cached) as TeamsChannelContext;
return JSON.parse(cached) as TeamsGraphContext;
} catch {
return null;
}
Expand Down
14 changes: 14 additions & 0 deletions packages/adapter-teams/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;