msteams: fetch thread history via Graph API for channel replies (#51643)

* msteams: fetch thread history via Graph API for channel replies

* msteams: address PR #51643 review feedback

- Wrap resolveTeamGroupId Graph call in try/catch, fall back to raw
  conversationTeamId when Team.ReadBasic.All permission is missing
- Remove dead fetchChatMessages function (exported but never called)
- Add JSDoc documenting oldest-50-replies Graph API limitation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* msteams: address thread history PR review comments

* msteams: only cache team group IDs on successful Graph lookup

Avoid caching raw conversationTeamId as a Graph team GUID when the
/teams/{id} lookup fails — the raw ID may be a Bot Framework conversation
key, not a valid GUID, causing silent thread-history failures for the
entire cache TTL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
sudie-codes
2026-03-25 23:09:33 -07:00
committed by GitHub
parent 6cbd2d36f8
commit 8c852d86f7
3 changed files with 423 additions and 1 deletions

View File

@@ -0,0 +1,246 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
_teamGroupIdCacheForTest,
fetchChannelMessage,
fetchThreadReplies,
formatThreadContext,
resolveTeamGroupId,
stripHtmlFromTeamsMessage,
} from "./graph-thread.js";
import { fetchGraphJson } from "./graph.js";
vi.mock("./graph.js", () => ({
fetchGraphJson: vi.fn(),
}));
describe("stripHtmlFromTeamsMessage", () => {
it("preserves @mention display names from <at> tags", () => {
expect(stripHtmlFromTeamsMessage("<at>Alice</at> hello")).toBe("@Alice hello");
});
it("strips other HTML tags", () => {
expect(stripHtmlFromTeamsMessage("<p>Hello <b>world</b></p>")).toBe("Hello world");
});
it("decodes common HTML entities", () => {
expect(stripHtmlFromTeamsMessage("&amp; &lt;b&gt; &quot;x&quot; &#39;y&#39; &nbsp;z")).toBe(
"& <b> \"x\" 'y' z",
);
});
it("normalizes multiple whitespace to single space", () => {
expect(stripHtmlFromTeamsMessage("hello world")).toBe("hello world");
});
it("handles <at> tags with attributes", () => {
expect(stripHtmlFromTeamsMessage('<at id="123">Bob</at> please review')).toBe(
"@Bob please review",
);
});
it("returns empty string for empty input", () => {
expect(stripHtmlFromTeamsMessage("")).toBe("");
});
});
describe("resolveTeamGroupId", () => {
beforeEach(() => {
vi.mocked(fetchGraphJson).mockReset();
_teamGroupIdCacheForTest.clear();
});
it("fetches team id from Graph and caches it", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({ id: "group-guid-1" } as never);
const result = await resolveTeamGroupId("tok", "team-123");
expect(result).toBe("group-guid-1");
expect(fetchGraphJson).toHaveBeenCalledWith({
token: "tok",
path: "/teams/team-123?$select=id",
});
});
it("returns cached value without calling Graph again", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({ id: "group-guid-2" } as never);
await resolveTeamGroupId("tok", "team-456");
await resolveTeamGroupId("tok", "team-456");
expect(fetchGraphJson).toHaveBeenCalledTimes(1);
});
it("falls back to conversationTeamId when Graph returns no id", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never);
const result = await resolveTeamGroupId("tok", "team-fallback");
expect(result).toBe("team-fallback");
});
});
describe("fetchChannelMessage", () => {
beforeEach(() => {
vi.mocked(fetchGraphJson).mockReset();
});
it("fetches the parent message with correct path", async () => {
const mockMsg = { id: "msg-1", body: { content: "hello", contentType: "text" } };
vi.mocked(fetchGraphJson).mockResolvedValueOnce(mockMsg as never);
const result = await fetchChannelMessage("tok", "group-1", "channel-1", "msg-1");
expect(result).toEqual(mockMsg);
expect(fetchGraphJson).toHaveBeenCalledWith({
token: "tok",
path: "/teams/group-1/channels/channel-1/messages/msg-1?$select=id,from,body,createdDateTime",
});
});
it("returns undefined on fetch error", async () => {
vi.mocked(fetchGraphJson).mockRejectedValueOnce(new Error("forbidden") as never);
const result = await fetchChannelMessage("tok", "group-1", "channel-1", "msg-1");
expect(result).toBeUndefined();
});
it("URL-encodes group, channel, and message IDs", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never);
await fetchChannelMessage("tok", "g/1", "c/2", "m/3");
expect(fetchGraphJson).toHaveBeenCalledWith({
token: "tok",
path: "/teams/g%2F1/channels/c%2F2/messages/m%2F3?$select=id,from,body,createdDateTime",
});
});
});
describe("fetchThreadReplies", () => {
beforeEach(() => {
vi.mocked(fetchGraphJson).mockReset();
});
it("fetches replies with correct path and default limit", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({
value: [{ id: "reply-1" }, { id: "reply-2" }],
} as never);
const result = await fetchThreadReplies("tok", "group-1", "channel-1", "msg-1");
expect(result).toHaveLength(2);
expect(fetchGraphJson).toHaveBeenCalledWith({
token: "tok",
path: "/teams/group-1/channels/channel-1/messages/msg-1/replies?$top=50&$select=id,from,body,createdDateTime",
});
});
it("clamps limit to 50 maximum", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({ value: [] } as never);
await fetchThreadReplies("tok", "g", "c", "m", 200);
const path = vi.mocked(fetchGraphJson).mock.calls[0]?.[0]?.path ?? "";
expect(path).toContain("$top=50");
});
it("clamps limit to 1 minimum", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({ value: [] } as never);
await fetchThreadReplies("tok", "g", "c", "m", 0);
const path = vi.mocked(fetchGraphJson).mock.calls[0]?.[0]?.path ?? "";
expect(path).toContain("$top=1");
});
it("returns empty array when value is missing", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never);
const result = await fetchThreadReplies("tok", "g", "c", "m");
expect(result).toEqual([]);
});
});
describe("formatThreadContext", () => {
it("formats messages as sender: content lines", () => {
const messages = [
{
id: "m1",
from: { user: { displayName: "Alice" } },
body: { content: "Hello!", contentType: "text" },
},
{
id: "m2",
from: { user: { displayName: "Bob" } },
body: { content: "World!", contentType: "text" },
},
];
expect(formatThreadContext(messages)).toBe("Alice: Hello!\nBob: World!");
});
it("skips the current message by id", () => {
const messages = [
{
id: "m1",
from: { user: { displayName: "Alice" } },
body: { content: "Hello!", contentType: "text" },
},
{
id: "m2",
from: { user: { displayName: "Bob" } },
body: { content: "Current", contentType: "text" },
},
];
expect(formatThreadContext(messages, "m2")).toBe("Alice: Hello!");
});
it("strips HTML from html contentType messages", () => {
const messages = [
{
id: "m1",
from: { user: { displayName: "Carol" } },
body: { content: "<p>Hello <b>world</b></p>", contentType: "html" },
},
];
expect(formatThreadContext(messages)).toBe("Carol: Hello world");
});
it("uses application displayName when user is absent", () => {
const messages = [
{
id: "m1",
from: { application: { displayName: "BotApp" } },
body: { content: "automated msg", contentType: "text" },
},
];
expect(formatThreadContext(messages)).toBe("BotApp: automated msg");
});
it("skips messages with empty content", () => {
const messages = [
{
id: "m1",
from: { user: { displayName: "Alice" } },
body: { content: "", contentType: "text" },
},
{
id: "m2",
from: { user: { displayName: "Bob" } },
body: { content: "actual content", contentType: "text" },
},
];
expect(formatThreadContext(messages)).toBe("Bob: actual content");
});
it("falls back to 'unknown' sender when from is missing", () => {
const messages = [
{
id: "m1",
body: { content: "orphan msg", contentType: "text" },
},
];
expect(formatThreadContext(messages)).toBe("unknown: orphan msg");
});
it("returns empty string for empty messages array", () => {
expect(formatThreadContext([])).toBe("");
});
});

View File

@@ -0,0 +1,142 @@
import { fetchGraphJson, type GraphResponse } from "./graph.js";
export type GraphThreadMessage = {
id?: string;
from?: {
user?: { displayName?: string; id?: string };
application?: { displayName?: string; id?: string };
};
body?: { content?: string; contentType?: string };
createdDateTime?: string;
};
// TTL cache for team ID -> group GUID mapping.
const teamGroupIdCache = new Map<string, { groupId: string; expiresAt: number }>();
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
/**
* Strip HTML tags from Teams message content, preserving @mention display names.
* Teams wraps mentions in <at>Name</at> tags.
*/
export function stripHtmlFromTeamsMessage(html: string): string {
// Preserve mention display names by replacing <at>Name</at> with @Name.
let text = html.replace(/<at[^>]*>(.*?)<\/at>/gi, "@$1");
// Strip remaining HTML tags.
text = text.replace(/<[^>]*>/g, " ");
// Decode common HTML entities.
text = text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ");
// Normalize whitespace.
return text.replace(/\s+/g, " ").trim();
}
/**
* Resolve the Azure AD group GUID for a Teams conversation team ID.
* Results are cached with a TTL to avoid repeated Graph API calls.
*/
export async function resolveTeamGroupId(
token: string,
conversationTeamId: string,
): Promise<string> {
const cached = teamGroupIdCache.get(conversationTeamId);
if (cached && cached.expiresAt > Date.now()) {
return cached.groupId;
}
// The team ID in channelData is typically the group ID itself for standard teams.
// Validate by fetching /teams/{id} and returning the confirmed id.
// Requires Team.ReadBasic.All permission; fall back to raw ID if missing.
try {
const path = `/teams/${encodeURIComponent(conversationTeamId)}?$select=id`;
const team = await fetchGraphJson<{ id?: string }>({ token, path });
const groupId = team.id ?? conversationTeamId;
// Only cache when the Graph lookup succeeds — caching a fallback raw ID
// can cause silent failures for the entire TTL if the ID is not a valid
// Graph team GUID (e.g. Bot Framework conversation key).
teamGroupIdCache.set(conversationTeamId, {
groupId,
expiresAt: Date.now() + CACHE_TTL_MS,
});
return groupId;
} catch {
// Fallback to raw team ID without caching so subsequent calls retry the
// Graph lookup instead of using a potentially invalid cached value.
return conversationTeamId;
}
}
/**
* Fetch a single channel message (the parent/root of a thread).
* Returns undefined on error so callers can degrade gracefully.
*/
export async function fetchChannelMessage(
token: string,
groupId: string,
channelId: string,
messageId: string,
): Promise<GraphThreadMessage | undefined> {
const path = `/teams/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}?$select=id,from,body,createdDateTime`;
try {
return await fetchGraphJson<GraphThreadMessage>({ token, path });
} catch {
return undefined;
}
}
/**
* Fetch thread replies for a channel message, ordered chronologically.
*
* **Limitation:** The Graph API replies endpoint (`/messages/{id}/replies`) does not
* support `$orderby`, so results are always returned in ascending (oldest-first) order.
* Combined with the `$top` cap of 50, this means only the **oldest 50 replies** are
* returned for long threads — newer replies are silently omitted. There is currently no
* Graph API workaround for this; pagination via `@odata.nextLink` can retrieve more
* replies but still in ascending order only.
*/
export async function fetchThreadReplies(
token: string,
groupId: string,
channelId: string,
messageId: string,
limit = 50,
): Promise<GraphThreadMessage[]> {
const top = Math.min(Math.max(limit, 1), 50);
// NOTE: Graph replies endpoint returns oldest-first and does not support $orderby.
// For threads with >50 replies, only the oldest 50 are returned. The most recent
// replies (often the most relevant context) may be truncated.
const path = `/teams/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/replies?$top=${top}&$select=id,from,body,createdDateTime`;
const res = await fetchGraphJson<GraphResponse<GraphThreadMessage>>({ token, path });
return res.value ?? [];
}
/**
* Format thread messages into a context string for the agent.
* Skips the current message (by id) and blank messages.
*/
export function formatThreadContext(
messages: GraphThreadMessage[],
currentMessageId?: string,
): string {
const lines: string[] = [];
for (const msg of messages) {
if (msg.id && msg.id === currentMessageId) continue; // Skip the triggering message.
const sender = msg.from?.user?.displayName ?? msg.from?.application?.displayName ?? "unknown";
const contentType = msg.body?.contentType ?? "text";
const rawContent = msg.body?.content ?? "";
const content =
contentType === "html" ? stripHtmlFromTeamsMessage(rawContent) : rawContent.trim();
if (!content) continue;
lines.push(`${sender}: ${content}`);
}
return lines.join("\n");
}
// Exported for testing only.
export { teamGroupIdCache as _teamGroupIdCacheForTest };

View File

@@ -28,6 +28,12 @@ import {
} from "../attachments.js";
import type { StoredConversationReference } from "../conversation-store.js";
import { formatUnknownError } from "../errors.js";
import {
fetchChannelMessage,
fetchThreadReplies,
formatThreadContext,
resolveTeamGroupId,
} from "../graph-thread.js";
import {
extractMSTeamsConversationMessageId,
extractMSTeamsQuoteInfo,
@@ -474,6 +480,29 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
});
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
// Fetch thread history when the message is a reply inside a Teams channel thread.
// This is a best-effort enhancement; errors are logged and do not block the reply.
let threadContext: string | undefined;
if (activity.replyToId && isChannel && teamId) {
try {
const graphToken = await tokenProvider.getAccessToken("https://graph.microsoft.com");
const groupId = await resolveTeamGroupId(graphToken, teamId);
const [parentMsg, replies] = await Promise.all([
fetchChannelMessage(graphToken, groupId, conversationId, activity.replyToId),
fetchThreadReplies(graphToken, groupId, conversationId, activity.replyToId),
]);
const allMessages = parentMsg ? [parentMsg, ...replies] : replies;
const formatted = formatThreadContext(allMessages, activity.id);
if (formatted) {
threadContext = formatted;
}
} catch (err) {
log.debug?.("failed to fetch thread history", { error: String(err) });
// Graceful degradation: thread history is an optional enhancement.
}
}
const envelopeFrom = isDirectMessage ? senderName : conversationType;
const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
cfg,
@@ -518,9 +547,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
: undefined;
const commandBody = text.trim();
// Prepend thread history to the agent body so the agent has full thread context.
const bodyForAgent = threadContext
? `[Thread history]\n${threadContext}\n[/Thread history]\n\n${rawBody}`
: rawBody;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody,
BodyForAgent: rawBody,
BodyForAgent: bodyForAgent,
InboundHistory: inboundHistory,
RawBody: rawBody,
CommandBody: commandBody,