Skip to content

Commit 928a512

Browse files
authored
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
1 parent 3967ffe commit 928a512

4 files changed

Lines changed: 365 additions & 0 deletions

File tree

extensions/msteams/src/channel.runtime.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import {
1313
unpinMessageMSTeams as unpinMessageMSTeamsImpl,
1414
unreactMessageMSTeams as unreactMessageMSTeamsImpl,
1515
} from "./graph-messages.js";
16+
import {
17+
listChannelsMSTeams as listChannelsMSTeamsImpl,
18+
getChannelInfoMSTeams as getChannelInfoMSTeamsImpl,
19+
} from "./graph-teams.js";
1620
import { msteamsOutbound as msteamsOutboundImpl } from "./outbound.js";
1721
import { probeMSTeams as probeMSTeamsImpl } from "./probe.js";
1822
import {
@@ -24,8 +28,10 @@ import {
2428
export const msTeamsChannelRuntime = {
2529
deleteMessageMSTeams: deleteMessageMSTeamsImpl,
2630
editMessageMSTeams: editMessageMSTeamsImpl,
31+
getChannelInfoMSTeams: getChannelInfoMSTeamsImpl,
2732
getMemberInfoMSTeams: getMemberInfoMSTeamsImpl,
2833
getMessageMSTeams: getMessageMSTeamsImpl,
34+
listChannelsMSTeams: listChannelsMSTeamsImpl,
2935
listPinsMSTeams: listPinsMSTeamsImpl,
3036
listReactionsMSTeams: listReactionsMSTeamsImpl,
3137
pinMessageMSTeams: pinMessageMSTeamsImpl,

extensions/msteams/src/channel.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,8 @@ function describeMSTeamsMessageTool({
334334
"reactions",
335335
"search",
336336
"member-info",
337+
"channel-list",
338+
"channel-info",
337339
] satisfies ChannelMessageActionName[])
338340
: [],
339341
capabilities: enabled ? ["cards"] : [],
@@ -865,6 +867,34 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
865867
return jsonMSTeamsOkActionResult("member-info", result);
866868
}
867869

870+
if (ctx.action === "channel-list") {
871+
const teamId = typeof ctx.params.teamId === "string" ? ctx.params.teamId.trim() : "";
872+
if (!teamId) {
873+
return actionError("channel-list requires a teamId.");
874+
}
875+
const { listChannelsMSTeams } = await loadMSTeamsChannelRuntime();
876+
const result = await listChannelsMSTeams({ cfg: ctx.cfg, teamId });
877+
return jsonMSTeamsOkActionResult("channel-list", result);
878+
}
879+
880+
if (ctx.action === "channel-info") {
881+
const teamId = typeof ctx.params.teamId === "string" ? ctx.params.teamId.trim() : "";
882+
const channelId =
883+
typeof ctx.params.channelId === "string" ? ctx.params.channelId.trim() : "";
884+
if (!teamId || !channelId) {
885+
return actionError("channel-info requires teamId and channelId.");
886+
}
887+
const { getChannelInfoMSTeams } = await loadMSTeamsChannelRuntime();
888+
const result = await getChannelInfoMSTeams({
889+
cfg: ctx.cfg,
890+
teamId,
891+
channelId,
892+
});
893+
return jsonMSTeamsOkActionResult("channel-info", {
894+
channelInfo: result.channel,
895+
});
896+
}
897+
868898
// Return null to fall through to default handler
869899
return null as never;
870900
},
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { OpenClawConfig } from "../runtime-api.js";
3+
import { getChannelInfoMSTeams, listChannelsMSTeams } from "./graph-teams.js";
4+
5+
const mockState = vi.hoisted(() => ({
6+
resolveGraphToken: vi.fn(),
7+
fetchGraphJson: vi.fn(),
8+
}));
9+
10+
vi.mock("./graph.js", async (importOriginal) => {
11+
const actual = await importOriginal<typeof import("./graph.js")>();
12+
return {
13+
...actual,
14+
resolveGraphToken: mockState.resolveGraphToken,
15+
fetchGraphJson: mockState.fetchGraphJson,
16+
};
17+
});
18+
19+
const TOKEN = "test-graph-token";
20+
21+
describe("listChannelsMSTeams", () => {
22+
beforeEach(() => {
23+
mockState.resolveGraphToken.mockReset().mockResolvedValue(TOKEN);
24+
mockState.fetchGraphJson.mockReset();
25+
});
26+
27+
it("returns channels with all fields mapped", async () => {
28+
mockState.fetchGraphJson.mockResolvedValue({
29+
value: [
30+
{
31+
id: "ch-1",
32+
displayName: "General",
33+
description: "The default channel",
34+
membershipType: "standard",
35+
},
36+
{
37+
id: "ch-2",
38+
displayName: "Engineering",
39+
description: "Engineering discussions",
40+
membershipType: "private",
41+
},
42+
],
43+
});
44+
45+
const result = await listChannelsMSTeams({
46+
cfg: {} as OpenClawConfig,
47+
teamId: "team-abc",
48+
});
49+
50+
expect(result.channels).toEqual([
51+
{
52+
id: "ch-1",
53+
displayName: "General",
54+
description: "The default channel",
55+
membershipType: "standard",
56+
},
57+
{
58+
id: "ch-2",
59+
displayName: "Engineering",
60+
description: "Engineering discussions",
61+
membershipType: "private",
62+
},
63+
]);
64+
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
65+
token: TOKEN,
66+
path: `/teams/${encodeURIComponent("team-abc")}/channels?$select=id,displayName,description,membershipType`,
67+
});
68+
});
69+
70+
it("returns empty array when team has no channels", async () => {
71+
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
72+
73+
const result = await listChannelsMSTeams({
74+
cfg: {} as OpenClawConfig,
75+
teamId: "team-empty",
76+
});
77+
78+
expect(result.channels).toEqual([]);
79+
});
80+
81+
it("returns empty array when value is undefined", async () => {
82+
mockState.fetchGraphJson.mockResolvedValue({});
83+
84+
const result = await listChannelsMSTeams({
85+
cfg: {} as OpenClawConfig,
86+
teamId: "team-no-value",
87+
});
88+
89+
expect(result.channels).toEqual([]);
90+
});
91+
92+
it("follows @odata.nextLink across multiple pages", async () => {
93+
mockState.fetchGraphJson
94+
.mockResolvedValueOnce({
95+
value: [
96+
{ id: "ch-1", displayName: "General", description: null, membershipType: "standard" },
97+
],
98+
"@odata.nextLink":
99+
"https://graph.microsoft.com/v1.0/teams/team-paged/channels?$select=id,displayName,description,membershipType&$skip=1",
100+
})
101+
.mockResolvedValueOnce({
102+
value: [
103+
{ id: "ch-2", displayName: "Random", description: "Fun", membershipType: "standard" },
104+
],
105+
"@odata.nextLink":
106+
"https://graph.microsoft.com/v1.0/teams/team-paged/channels?$select=id,displayName,description,membershipType&$skip=2",
107+
})
108+
.mockResolvedValueOnce({
109+
value: [
110+
{ id: "ch-3", displayName: "Private", description: null, membershipType: "private" },
111+
],
112+
});
113+
114+
const result = await listChannelsMSTeams({
115+
cfg: {} as OpenClawConfig,
116+
teamId: "team-paged",
117+
});
118+
119+
expect(result.channels).toHaveLength(3);
120+
expect(result.channels.map((ch) => ch.id)).toEqual(["ch-1", "ch-2", "ch-3"]);
121+
expect(result.truncated).toBe(false);
122+
expect(mockState.fetchGraphJson).toHaveBeenCalledTimes(3);
123+
124+
// Second call should use the relative path stripped from the nextLink
125+
const secondCallPath = mockState.fetchGraphJson.mock.calls[1]?.[0]?.path;
126+
expect(secondCallPath).toBe(
127+
"/teams/team-paged/channels?$select=id,displayName,description,membershipType&$skip=1",
128+
);
129+
});
130+
131+
it("stops after 10 pages to avoid runaway pagination", async () => {
132+
for (let i = 0; i < 11; i++) {
133+
mockState.fetchGraphJson.mockResolvedValueOnce({
134+
value: [
135+
{
136+
id: `ch-${i}`,
137+
displayName: `Channel ${i}`,
138+
description: null,
139+
membershipType: "standard",
140+
},
141+
],
142+
"@odata.nextLink": `https://graph.microsoft.com/v1.0/teams/team-huge/channels?$skip=${i + 1}`,
143+
});
144+
}
145+
146+
const result = await listChannelsMSTeams({
147+
cfg: {} as OpenClawConfig,
148+
teamId: "team-huge",
149+
});
150+
151+
// Should stop at 10 pages even though more nextLinks are available
152+
expect(result.channels).toHaveLength(10);
153+
expect(mockState.fetchGraphJson).toHaveBeenCalledTimes(10);
154+
expect(result.truncated).toBe(true);
155+
});
156+
});
157+
158+
describe("getChannelInfoMSTeams", () => {
159+
beforeEach(() => {
160+
mockState.resolveGraphToken.mockReset().mockResolvedValue(TOKEN);
161+
mockState.fetchGraphJson.mockReset();
162+
});
163+
164+
it("returns channel with all fields", async () => {
165+
mockState.fetchGraphJson.mockResolvedValue({
166+
id: "ch-1",
167+
displayName: "General",
168+
description: "The default channel",
169+
membershipType: "standard",
170+
webUrl: "https://teams.microsoft.com/l/channel/ch-1/General",
171+
createdDateTime: "2026-01-15T09:00:00Z",
172+
});
173+
174+
const result = await getChannelInfoMSTeams({
175+
cfg: {} as OpenClawConfig,
176+
teamId: "team-abc",
177+
channelId: "ch-1",
178+
});
179+
180+
expect(result.channel).toEqual({
181+
id: "ch-1",
182+
displayName: "General",
183+
description: "The default channel",
184+
membershipType: "standard",
185+
webUrl: "https://teams.microsoft.com/l/channel/ch-1/General",
186+
createdDateTime: "2026-01-15T09:00:00Z",
187+
});
188+
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
189+
token: TOKEN,
190+
path: `/teams/${encodeURIComponent("team-abc")}/channels/${encodeURIComponent("ch-1")}?$select=id,displayName,description,membershipType,webUrl,createdDateTime`,
191+
});
192+
});
193+
194+
it("handles missing optional fields gracefully", async () => {
195+
mockState.fetchGraphJson.mockResolvedValue({
196+
id: "ch-2",
197+
displayName: "Private Channel",
198+
});
199+
200+
const result = await getChannelInfoMSTeams({
201+
cfg: {} as OpenClawConfig,
202+
teamId: "team-abc",
203+
channelId: "ch-2",
204+
});
205+
206+
expect(result.channel).toEqual({
207+
id: "ch-2",
208+
displayName: "Private Channel",
209+
description: undefined,
210+
membershipType: undefined,
211+
webUrl: undefined,
212+
createdDateTime: undefined,
213+
});
214+
});
215+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type { OpenClawConfig } from "../runtime-api.js";
2+
import { type GraphResponse, fetchGraphJson, resolveGraphToken } from "./graph.js";
3+
4+
// ---------------------------------------------------------------------------
5+
// Types
6+
// ---------------------------------------------------------------------------
7+
8+
export type GraphTeamsChannel = {
9+
id?: string;
10+
displayName?: string;
11+
description?: string;
12+
membershipType?: string;
13+
webUrl?: string;
14+
createdDateTime?: string;
15+
};
16+
17+
export type ListChannelsMSTeamsParams = {
18+
cfg: OpenClawConfig;
19+
teamId: string;
20+
};
21+
22+
export type ListChannelsMSTeamsResult = {
23+
channels: Array<{
24+
id: string | undefined;
25+
displayName: string | undefined;
26+
description: string | undefined;
27+
membershipType: string | undefined;
28+
}>;
29+
truncated?: boolean;
30+
};
31+
32+
export type GetChannelInfoMSTeamsParams = {
33+
cfg: OpenClawConfig;
34+
teamId: string;
35+
channelId: string;
36+
};
37+
38+
export type GetChannelInfoMSTeamsResult = {
39+
channel: {
40+
id: string | undefined;
41+
displayName: string | undefined;
42+
description: string | undefined;
43+
membershipType: string | undefined;
44+
webUrl: string | undefined;
45+
createdDateTime: string | undefined;
46+
};
47+
};
48+
49+
// ---------------------------------------------------------------------------
50+
// List channels for a team
51+
// ---------------------------------------------------------------------------
52+
53+
/**
54+
* List channels in a team via Graph API.
55+
* Returns id, displayName, description, and membershipType for each channel.
56+
* Follows @odata.nextLink for paginated results (up to 10 pages).
57+
*/
58+
export async function listChannelsMSTeams(
59+
params: ListChannelsMSTeamsParams,
60+
): Promise<ListChannelsMSTeamsResult> {
61+
const token = await resolveGraphToken(params.cfg);
62+
const firstPath = `/teams/${encodeURIComponent(params.teamId)}/channels?$select=id,displayName,description,membershipType`;
63+
const collected: GraphTeamsChannel[] = [];
64+
let nextPath: string | undefined = firstPath;
65+
const MAX_PAGES = 10;
66+
let page = 0;
67+
while (nextPath && page < MAX_PAGES) {
68+
type PagedChannelResponse = GraphResponse<GraphTeamsChannel> & {
69+
"@odata.nextLink"?: string;
70+
};
71+
const res: PagedChannelResponse = await fetchGraphJson<PagedChannelResponse>({
72+
token,
73+
path: nextPath,
74+
});
75+
collected.push(...(res.value ?? []));
76+
const nextLink: string | undefined = res["@odata.nextLink"];
77+
// Strip the Graph API root so fetchGraphJson receives a relative path
78+
nextPath = nextLink ? nextLink.replace("https://graph.microsoft.com/v1.0", "") : undefined;
79+
page++;
80+
}
81+
const channels = collected.map((ch) => ({
82+
id: ch.id,
83+
displayName: ch.displayName,
84+
description: ch.description,
85+
membershipType: ch.membershipType,
86+
}));
87+
return { channels, truncated: !!nextPath };
88+
}
89+
90+
// ---------------------------------------------------------------------------
91+
// Get channel info
92+
// ---------------------------------------------------------------------------
93+
94+
/**
95+
* Get detailed information about a single channel in a team via Graph API.
96+
* Returns id, displayName, description, membershipType, webUrl, and createdDateTime.
97+
*/
98+
export async function getChannelInfoMSTeams(
99+
params: GetChannelInfoMSTeamsParams,
100+
): Promise<GetChannelInfoMSTeamsResult> {
101+
const token = await resolveGraphToken(params.cfg);
102+
const path = `/teams/${encodeURIComponent(params.teamId)}/channels/${encodeURIComponent(params.channelId)}?$select=id,displayName,description,membershipType,webUrl,createdDateTime`;
103+
const ch = await fetchGraphJson<GraphTeamsChannel>({ token, path });
104+
return {
105+
channel: {
106+
id: ch.id,
107+
displayName: ch.displayName,
108+
description: ch.description,
109+
membershipType: ch.membershipType,
110+
webUrl: ch.webUrl,
111+
createdDateTime: ch.createdDateTime,
112+
},
113+
};
114+
}

0 commit comments

Comments
 (0)