mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 03:03:12 +00:00
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
This commit is contained in:
parent
3967ffec22
commit
928a5128f4
4 changed files with 365 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ResolvedMSTeamsAccount, ProbeMSTeamsRe
|
|||
return jsonMSTeamsOkActionResult("member-info", result);
|
||||
}
|
||||
|
||||
if (ctx.action === "channel-list") {
|
||||
const teamId = typeof ctx.params.teamId === "string" ? ctx.params.teamId.trim() : "";
|
||||
if (!teamId) {
|
||||
return actionError("channel-list requires a teamId.");
|
||||
}
|
||||
const { listChannelsMSTeams } = await loadMSTeamsChannelRuntime();
|
||||
const result = await listChannelsMSTeams({ cfg: ctx.cfg, teamId });
|
||||
return jsonMSTeamsOkActionResult("channel-list", result);
|
||||
}
|
||||
|
||||
if (ctx.action === "channel-info") {
|
||||
const teamId = typeof ctx.params.teamId === "string" ? ctx.params.teamId.trim() : "";
|
||||
const channelId =
|
||||
typeof ctx.params.channelId === "string" ? ctx.params.channelId.trim() : "";
|
||||
if (!teamId || !channelId) {
|
||||
return actionError("channel-info requires teamId and channelId.");
|
||||
}
|
||||
const { getChannelInfoMSTeams } = await loadMSTeamsChannelRuntime();
|
||||
const result = await getChannelInfoMSTeams({
|
||||
cfg: ctx.cfg,
|
||||
teamId,
|
||||
channelId,
|
||||
});
|
||||
return jsonMSTeamsOkActionResult("channel-info", {
|
||||
channelInfo: result.channel,
|
||||
});
|
||||
}
|
||||
|
||||
// Return null to fall through to default handler
|
||||
return null as never;
|
||||
},
|
||||
|
|
|
|||
215
extensions/msteams/src/graph-teams.test.ts
Normal file
215
extensions/msteams/src/graph-teams.test.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { getChannelInfoMSTeams, listChannelsMSTeams } from "./graph-teams.js";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
resolveGraphToken: vi.fn(),
|
||||
fetchGraphJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./graph.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./graph.js")>();
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
114
extensions/msteams/src/graph-teams.ts
Normal file
114
extensions/msteams/src/graph-teams.ts
Normal file
|
|
@ -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<ListChannelsMSTeamsResult> {
|
||||
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<GraphTeamsChannel> & {
|
||||
"@odata.nextLink"?: string;
|
||||
};
|
||||
const res: PagedChannelResponse = await fetchGraphJson<PagedChannelResponse>({
|
||||
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<GetChannelInfoMSTeamsResult> {
|
||||
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<GraphTeamsChannel>({ token, path });
|
||||
return {
|
||||
channel: {
|
||||
id: ch.id,
|
||||
displayName: ch.displayName,
|
||||
description: ch.description,
|
||||
membershipType: ch.membershipType,
|
||||
webUrl: ch.webUrl,
|
||||
createdDateTime: ch.createdDateTime,
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue