mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
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:
246
extensions/msteams/src/graph-thread.test.ts
Normal file
246
extensions/msteams/src/graph-thread.test.ts
Normal 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("& <b> "x" 'y' 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("");
|
||||
});
|
||||
});
|
||||
142
extensions/msteams/src/graph-thread.ts
Normal file
142
extensions/msteams/src/graph-thread.ts
Normal 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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /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 };
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user