diff --git a/CHANGELOG.md b/CHANGELOG.md index 73eb5a99673..c470767b36a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai - CI/CodeQL Swift toolchain: select Xcode 26.1 before installing Swift build tools so the CodeQL Swift job uses Swift tools 6.2 on `macos-latest`. (#41787) thanks @BunsDev. - Sandbox/subagents: pass the real configured workspace through `sessions_spawn` inheritance when a parent agent runs in a copied-workspace sandbox, so child `/agent` mounts point at the configured workspace instead of the parent sandbox copy. (#40757) Thanks @dsantoreis. - Mattermost/plugin send actions: normalize direct `replyTo` fallback handling so threaded plugin sends trim blank IDs and reuse the correct reply target again. (#41176) Thanks @hnykda. +- MS Teams/allowlist resolution: use the General channel conversation ID as the resolved team key (with Graph GUID fallback) so Bot Framework runtime `channelData.team.id` matching works for team and team/channel allowlist entries. (#41838) Thanks @BradGroux. ## 2026.3.8 diff --git a/extensions/msteams/src/resolve-allowlist.test.ts b/extensions/msteams/src/resolve-allowlist.test.ts index 03d97c15b01..1fdd706aaca 100644 --- a/extensions/msteams/src/resolve-allowlist.test.ts +++ b/extensions/msteams/src/resolve-allowlist.test.ts @@ -54,10 +54,12 @@ describe("resolveMSTeamsUserAllowlist", () => { describe("resolveMSTeamsChannelAllowlist", () => { it("resolves team/channel by team name + channel display name", async () => { - listTeamsByName.mockResolvedValueOnce([{ id: "team-1", displayName: "Product Team" }]); + // After the fix, listChannelsForTeam is called once and reused for both + // General channel resolution and channel matching. + listTeamsByName.mockResolvedValueOnce([{ id: "team-guid-1", displayName: "Product Team" }]); listChannelsForTeam.mockResolvedValueOnce([ - { id: "channel-1", displayName: "General" }, - { id: "channel-2", displayName: "Roadmap" }, + { id: "19:general-conv-id@thread.tacv2", displayName: "General" }, + { id: "19:roadmap-conv-id@thread.tacv2", displayName: "Roadmap" }, ]); const [result] = await resolveMSTeamsChannelAllowlist({ @@ -65,14 +67,80 @@ describe("resolveMSTeamsChannelAllowlist", () => { entries: ["Product Team/Roadmap"], }); + // teamId is now the General channel's conversation ID — not the Graph GUID — + // because that's what Bot Framework sends as channelData.team.id at runtime. expect(result).toEqual({ input: "Product Team/Roadmap", resolved: true, - teamId: "team-1", + teamId: "19:general-conv-id@thread.tacv2", teamName: "Product Team", - channelId: "channel-2", + channelId: "19:roadmap-conv-id@thread.tacv2", channelName: "Roadmap", note: "multiple channels; chose first", }); }); + + it("uses General channel conversation ID as team key for team-only entry", async () => { + // When no channel is specified we still resolve the General channel so the + // stored key matches what Bot Framework sends as channelData.team.id. + listTeamsByName.mockResolvedValueOnce([{ id: "guid-engineering", displayName: "Engineering" }]); + listChannelsForTeam.mockResolvedValueOnce([ + { id: "19:eng-general@thread.tacv2", displayName: "General" }, + { id: "19:eng-standups@thread.tacv2", displayName: "Standups" }, + ]); + + const [result] = await resolveMSTeamsChannelAllowlist({ + cfg: {}, + entries: ["Engineering"], + }); + + expect(result).toEqual({ + input: "Engineering", + resolved: true, + teamId: "19:eng-general@thread.tacv2", + teamName: "Engineering", + }); + }); + + it("falls back to Graph GUID when listChannelsForTeam throws", async () => { + // Edge case: API call fails (rate limit, network error). We fall back to + // the Graph GUID as the team key — the pre-fix behavior — so resolution + // still succeeds instead of propagating the error. + listTeamsByName.mockResolvedValueOnce([{ id: "guid-flaky", displayName: "Flaky Team" }]); + listChannelsForTeam.mockRejectedValueOnce(new Error("429 Too Many Requests")); + + const [result] = await resolveMSTeamsChannelAllowlist({ + cfg: {}, + entries: ["Flaky Team"], + }); + + expect(result).toEqual({ + input: "Flaky Team", + resolved: true, + teamId: "guid-flaky", + teamName: "Flaky Team", + }); + }); + + it("falls back to Graph GUID when General channel is not found", async () => { + // Edge case: General channel was renamed or deleted. We fall back to the + // Graph GUID so resolution still succeeds rather than silently breaking. + listTeamsByName.mockResolvedValueOnce([{ id: "guid-ops", displayName: "Operations" }]); + listChannelsForTeam.mockResolvedValueOnce([ + { id: "19:ops-announce@thread.tacv2", displayName: "Announcements" }, + { id: "19:ops-random@thread.tacv2", displayName: "Random" }, + ]); + + const [result] = await resolveMSTeamsChannelAllowlist({ + cfg: {}, + entries: ["Operations"], + }); + + expect(result).toEqual({ + input: "Operations", + resolved: true, + teamId: "guid-ops", + teamName: "Operations", + }); + }); }); diff --git a/extensions/msteams/src/resolve-allowlist.ts b/extensions/msteams/src/resolve-allowlist.ts index fede9c7f98b..374cae2d965 100644 --- a/extensions/msteams/src/resolve-allowlist.ts +++ b/extensions/msteams/src/resolve-allowlist.ts @@ -120,11 +120,26 @@ export async function resolveMSTeamsChannelAllowlist(params: { return { input, resolved: false, note: "team not found" }; } const teamMatch = teams[0]; - const teamId = teamMatch.id?.trim(); + const graphTeamId = teamMatch.id?.trim(); const teamName = teamMatch.displayName?.trim() || team; - if (!teamId) { + if (!graphTeamId) { return { input, resolved: false, note: "team id missing" }; } + // Bot Framework sends the General channel's conversation ID as + // channelData.team.id at runtime, NOT the Graph API group GUID. + // Fetch channels upfront so we can resolve the correct key format for + // runtime matching and reuse the list for channel lookups. + let teamChannels: Awaited> = []; + try { + teamChannels = await listChannelsForTeam(token, graphTeamId); + } catch { + // API failure (rate limit, network error) — fall back to Graph GUID as team key + } + const generalChannel = teamChannels.find((ch) => ch.displayName?.toLowerCase() === "general"); + // Use the General channel's conversation ID as the team key — this + // matches what Bot Framework sends at runtime. Fall back to the Graph + // GUID if the General channel isn't found (renamed or deleted). + const teamId = generalChannel?.id?.trim() || graphTeamId; if (!channel) { return { input, @@ -134,11 +149,11 @@ export async function resolveMSTeamsChannelAllowlist(params: { note: teams.length > 1 ? "multiple teams; chose first" : undefined, }; } - const channels = await listChannelsForTeam(token, teamId); + // Reuse teamChannels — already fetched above const channelMatch = - channels.find((item) => item.id === channel) ?? - channels.find((item) => item.displayName?.toLowerCase() === channel.toLowerCase()) ?? - channels.find((item) => + teamChannels.find((item) => item.id === channel) ?? + teamChannels.find((item) => item.displayName?.toLowerCase() === channel.toLowerCase()) ?? + teamChannels.find((item) => item.displayName?.toLowerCase().includes(channel.toLowerCase() ?? ""), ); if (!channelMatch?.id) { @@ -151,7 +166,7 @@ export async function resolveMSTeamsChannelAllowlist(params: { teamName, channelId: channelMatch.id, channelName: channelMatch.displayName ?? channel, - note: channels.length > 1 ? "multiple channels; chose first" : undefined, + note: teamChannels.length > 1 ? "multiple channels; chose first" : undefined, }; }, });