From 928a5128f491e1d99b45752f571495243f7d66ff Mon Sep 17 00:00:00 2001 From: sudie-codes Date: Sat, 4 Apr 2026 00:43:08 -0700 Subject: [PATCH] msteams: add channel-list and channel-info actions (#57529) * msteams: add channel-list and channel-info actions via Graph API * msteams: use action helpers, add channel-list pagination * msteams: address PR #57529 review feedback --- extensions/msteams/src/channel.runtime.ts | 6 + extensions/msteams/src/channel.ts | 30 +++ extensions/msteams/src/graph-teams.test.ts | 215 +++++++++++++++++++++ extensions/msteams/src/graph-teams.ts | 114 +++++++++++ 4 files changed, 365 insertions(+) create mode 100644 extensions/msteams/src/graph-teams.test.ts create mode 100644 extensions/msteams/src/graph-teams.ts diff --git a/extensions/msteams/src/channel.runtime.ts b/extensions/msteams/src/channel.runtime.ts index 4de6a10a046..cdfe7af56bf 100644 --- a/extensions/msteams/src/channel.runtime.ts +++ b/extensions/msteams/src/channel.runtime.ts @@ -13,6 +13,10 @@ import { unpinMessageMSTeams as unpinMessageMSTeamsImpl, unreactMessageMSTeams as unreactMessageMSTeamsImpl, } from "./graph-messages.js"; +import { + listChannelsMSTeams as listChannelsMSTeamsImpl, + getChannelInfoMSTeams as getChannelInfoMSTeamsImpl, +} from "./graph-teams.js"; import { msteamsOutbound as msteamsOutboundImpl } from "./outbound.js"; import { probeMSTeams as probeMSTeamsImpl } from "./probe.js"; import { @@ -24,8 +28,10 @@ import { export const msTeamsChannelRuntime = { deleteMessageMSTeams: deleteMessageMSTeamsImpl, editMessageMSTeams: editMessageMSTeamsImpl, + getChannelInfoMSTeams: getChannelInfoMSTeamsImpl, getMemberInfoMSTeams: getMemberInfoMSTeamsImpl, getMessageMSTeams: getMessageMSTeamsImpl, + listChannelsMSTeams: listChannelsMSTeamsImpl, listPinsMSTeams: listPinsMSTeamsImpl, listReactionsMSTeams: listReactionsMSTeamsImpl, pinMessageMSTeams: pinMessageMSTeamsImpl, diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 8e42fd309ec..38aeba67c63 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -334,6 +334,8 @@ function describeMSTeamsMessageTool({ "reactions", "search", "member-info", + "channel-list", + "channel-info", ] satisfies ChannelMessageActionName[]) : [], capabilities: enabled ? ["cards"] : [], @@ -865,6 +867,34 @@ export const msteamsPlugin: ChannelPlugin ({ + resolveGraphToken: vi.fn(), + fetchGraphJson: vi.fn(), +})); + +vi.mock("./graph.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveGraphToken: mockState.resolveGraphToken, + fetchGraphJson: mockState.fetchGraphJson, + }; +}); + +const TOKEN = "test-graph-token"; + +describe("listChannelsMSTeams", () => { + beforeEach(() => { + mockState.resolveGraphToken.mockReset().mockResolvedValue(TOKEN); + mockState.fetchGraphJson.mockReset(); + }); + + it("returns channels with all fields mapped", async () => { + mockState.fetchGraphJson.mockResolvedValue({ + value: [ + { + id: "ch-1", + displayName: "General", + description: "The default channel", + membershipType: "standard", + }, + { + id: "ch-2", + displayName: "Engineering", + description: "Engineering discussions", + membershipType: "private", + }, + ], + }); + + const result = await listChannelsMSTeams({ + cfg: {} as OpenClawConfig, + teamId: "team-abc", + }); + + expect(result.channels).toEqual([ + { + id: "ch-1", + displayName: "General", + description: "The default channel", + membershipType: "standard", + }, + { + id: "ch-2", + displayName: "Engineering", + description: "Engineering discussions", + membershipType: "private", + }, + ]); + expect(mockState.fetchGraphJson).toHaveBeenCalledWith({ + token: TOKEN, + path: `/teams/${encodeURIComponent("team-abc")}/channels?$select=id,displayName,description,membershipType`, + }); + }); + + it("returns empty array when team has no channels", async () => { + mockState.fetchGraphJson.mockResolvedValue({ value: [] }); + + const result = await listChannelsMSTeams({ + cfg: {} as OpenClawConfig, + teamId: "team-empty", + }); + + expect(result.channels).toEqual([]); + }); + + it("returns empty array when value is undefined", async () => { + mockState.fetchGraphJson.mockResolvedValue({}); + + const result = await listChannelsMSTeams({ + cfg: {} as OpenClawConfig, + teamId: "team-no-value", + }); + + expect(result.channels).toEqual([]); + }); + + it("follows @odata.nextLink across multiple pages", async () => { + mockState.fetchGraphJson + .mockResolvedValueOnce({ + value: [ + { id: "ch-1", displayName: "General", description: null, membershipType: "standard" }, + ], + "@odata.nextLink": + "https://graph.microsoft.com/v1.0/teams/team-paged/channels?$select=id,displayName,description,membershipType&$skip=1", + }) + .mockResolvedValueOnce({ + value: [ + { id: "ch-2", displayName: "Random", description: "Fun", membershipType: "standard" }, + ], + "@odata.nextLink": + "https://graph.microsoft.com/v1.0/teams/team-paged/channels?$select=id,displayName,description,membershipType&$skip=2", + }) + .mockResolvedValueOnce({ + value: [ + { id: "ch-3", displayName: "Private", description: null, membershipType: "private" }, + ], + }); + + const result = await listChannelsMSTeams({ + cfg: {} as OpenClawConfig, + teamId: "team-paged", + }); + + expect(result.channels).toHaveLength(3); + expect(result.channels.map((ch) => ch.id)).toEqual(["ch-1", "ch-2", "ch-3"]); + expect(result.truncated).toBe(false); + expect(mockState.fetchGraphJson).toHaveBeenCalledTimes(3); + + // Second call should use the relative path stripped from the nextLink + const secondCallPath = mockState.fetchGraphJson.mock.calls[1]?.[0]?.path; + expect(secondCallPath).toBe( + "/teams/team-paged/channels?$select=id,displayName,description,membershipType&$skip=1", + ); + }); + + it("stops after 10 pages to avoid runaway pagination", async () => { + for (let i = 0; i < 11; i++) { + mockState.fetchGraphJson.mockResolvedValueOnce({ + value: [ + { + id: `ch-${i}`, + displayName: `Channel ${i}`, + description: null, + membershipType: "standard", + }, + ], + "@odata.nextLink": `https://graph.microsoft.com/v1.0/teams/team-huge/channels?$skip=${i + 1}`, + }); + } + + const result = await listChannelsMSTeams({ + cfg: {} as OpenClawConfig, + teamId: "team-huge", + }); + + // Should stop at 10 pages even though more nextLinks are available + expect(result.channels).toHaveLength(10); + expect(mockState.fetchGraphJson).toHaveBeenCalledTimes(10); + expect(result.truncated).toBe(true); + }); +}); + +describe("getChannelInfoMSTeams", () => { + beforeEach(() => { + mockState.resolveGraphToken.mockReset().mockResolvedValue(TOKEN); + mockState.fetchGraphJson.mockReset(); + }); + + it("returns channel with all fields", async () => { + mockState.fetchGraphJson.mockResolvedValue({ + id: "ch-1", + displayName: "General", + description: "The default channel", + membershipType: "standard", + webUrl: "https://teams.microsoft.com/l/channel/ch-1/General", + createdDateTime: "2026-01-15T09:00:00Z", + }); + + const result = await getChannelInfoMSTeams({ + cfg: {} as OpenClawConfig, + teamId: "team-abc", + channelId: "ch-1", + }); + + expect(result.channel).toEqual({ + id: "ch-1", + displayName: "General", + description: "The default channel", + membershipType: "standard", + webUrl: "https://teams.microsoft.com/l/channel/ch-1/General", + createdDateTime: "2026-01-15T09:00:00Z", + }); + expect(mockState.fetchGraphJson).toHaveBeenCalledWith({ + token: TOKEN, + path: `/teams/${encodeURIComponent("team-abc")}/channels/${encodeURIComponent("ch-1")}?$select=id,displayName,description,membershipType,webUrl,createdDateTime`, + }); + }); + + it("handles missing optional fields gracefully", async () => { + mockState.fetchGraphJson.mockResolvedValue({ + id: "ch-2", + displayName: "Private Channel", + }); + + const result = await getChannelInfoMSTeams({ + cfg: {} as OpenClawConfig, + teamId: "team-abc", + channelId: "ch-2", + }); + + expect(result.channel).toEqual({ + id: "ch-2", + displayName: "Private Channel", + description: undefined, + membershipType: undefined, + webUrl: undefined, + createdDateTime: undefined, + }); + }); +}); diff --git a/extensions/msteams/src/graph-teams.ts b/extensions/msteams/src/graph-teams.ts new file mode 100644 index 00000000000..22d8e2ff7b8 --- /dev/null +++ b/extensions/msteams/src/graph-teams.ts @@ -0,0 +1,114 @@ +import type { OpenClawConfig } from "../runtime-api.js"; +import { type GraphResponse, fetchGraphJson, resolveGraphToken } from "./graph.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type GraphTeamsChannel = { + id?: string; + displayName?: string; + description?: string; + membershipType?: string; + webUrl?: string; + createdDateTime?: string; +}; + +export type ListChannelsMSTeamsParams = { + cfg: OpenClawConfig; + teamId: string; +}; + +export type ListChannelsMSTeamsResult = { + channels: Array<{ + id: string | undefined; + displayName: string | undefined; + description: string | undefined; + membershipType: string | undefined; + }>; + truncated?: boolean; +}; + +export type GetChannelInfoMSTeamsParams = { + cfg: OpenClawConfig; + teamId: string; + channelId: string; +}; + +export type GetChannelInfoMSTeamsResult = { + channel: { + id: string | undefined; + displayName: string | undefined; + description: string | undefined; + membershipType: string | undefined; + webUrl: string | undefined; + createdDateTime: string | undefined; + }; +}; + +// --------------------------------------------------------------------------- +// List channels for a team +// --------------------------------------------------------------------------- + +/** + * List channels in a team via Graph API. + * Returns id, displayName, description, and membershipType for each channel. + * Follows @odata.nextLink for paginated results (up to 10 pages). + */ +export async function listChannelsMSTeams( + params: ListChannelsMSTeamsParams, +): Promise { + const token = await resolveGraphToken(params.cfg); + const firstPath = `/teams/${encodeURIComponent(params.teamId)}/channels?$select=id,displayName,description,membershipType`; + const collected: GraphTeamsChannel[] = []; + let nextPath: string | undefined = firstPath; + const MAX_PAGES = 10; + let page = 0; + while (nextPath && page < MAX_PAGES) { + type PagedChannelResponse = GraphResponse & { + "@odata.nextLink"?: string; + }; + const res: PagedChannelResponse = await fetchGraphJson({ + token, + path: nextPath, + }); + collected.push(...(res.value ?? [])); + const nextLink: string | undefined = res["@odata.nextLink"]; + // Strip the Graph API root so fetchGraphJson receives a relative path + nextPath = nextLink ? nextLink.replace("https://graph.microsoft.com/v1.0", "") : undefined; + page++; + } + const channels = collected.map((ch) => ({ + id: ch.id, + displayName: ch.displayName, + description: ch.description, + membershipType: ch.membershipType, + })); + return { channels, truncated: !!nextPath }; +} + +// --------------------------------------------------------------------------- +// Get channel info +// --------------------------------------------------------------------------- + +/** + * Get detailed information about a single channel in a team via Graph API. + * Returns id, displayName, description, membershipType, webUrl, and createdDateTime. + */ +export async function getChannelInfoMSTeams( + params: GetChannelInfoMSTeamsParams, +): Promise { + const token = await resolveGraphToken(params.cfg); + const path = `/teams/${encodeURIComponent(params.teamId)}/channels/${encodeURIComponent(params.channelId)}?$select=id,displayName,description,membershipType,webUrl,createdDateTime`; + const ch = await fetchGraphJson({ token, path }); + return { + channel: { + id: ch.id, + displayName: ch.displayName, + description: ch.description, + membershipType: ch.membershipType, + webUrl: ch.webUrl, + createdDateTime: ch.createdDateTime, + }, + }; +}