Skip to content

Commit 568b0a2

Browse files
BradGrouxonutc
andauthored
fix(msteams): use General channel conversation ID as team key for Bot Framework compatibility (#41838)
* fix(msteams): use General channel conversation ID as team key for Bot Framework compatibility Bot Framework sends `activity.channelData.team.id` as the General channel's conversation ID (e.g. `19:[email protected]`), not the Graph API group GUID (e.g. `fa101332-cf00-431b-b0ea-f701a85fde81`). The startup resolver was storing the Graph GUID as the team config key, so runtime matching always failed and every channel message was silently dropped. Fix: always call `listChannelsForTeam` during resolution to find the General channel, then use its conversation ID as the stored `teamId`. When a specific channel is also configured, reuse the same channel list rather than issuing a second API call. Falls back to the Graph GUID if the General channel cannot be found (renamed/deleted edge case). Fixes #41390 * fix(msteams): handle listChannelsForTeam failure gracefully * fix(msteams): trim General channel ID and guard against empty string * fix: document MS Teams allowlist team-key fix (#41838) (thanks @BradGroux) --------- Co-authored-by: bradgroux <[email protected]> Co-authored-by: Onur <[email protected]>
1 parent 450d49e commit 568b0a2

File tree

3 files changed

+96
-12
lines changed

3 files changed

+96
-12
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
5555
- 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.
5656
- 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.
5757
- 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.
58+
- 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.
5859

5960
## 2026.3.8
6061

extensions/msteams/src/resolve-allowlist.test.ts

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,25 +54,93 @@ describe("resolveMSTeamsUserAllowlist", () => {
5454

5555
describe("resolveMSTeamsChannelAllowlist", () => {
5656
it("resolves team/channel by team name + channel display name", async () => {
57-
listTeamsByName.mockResolvedValueOnce([{ id: "team-1", displayName: "Product Team" }]);
57+
// After the fix, listChannelsForTeam is called once and reused for both
58+
// General channel resolution and channel matching.
59+
listTeamsByName.mockResolvedValueOnce([{ id: "team-guid-1", displayName: "Product Team" }]);
5860
listChannelsForTeam.mockResolvedValueOnce([
59-
{ id: "channel-1", displayName: "General" },
60-
{ id: "channel-2", displayName: "Roadmap" },
61+
{ id: "19:[email protected]", displayName: "General" },
62+
{ id: "19:[email protected]", displayName: "Roadmap" },
6163
]);
6264

6365
const [result] = await resolveMSTeamsChannelAllowlist({
6466
cfg: {},
6567
entries: ["Product Team/Roadmap"],
6668
});
6769

70+
// teamId is now the General channel's conversation ID — not the Graph GUID —
71+
// because that's what Bot Framework sends as channelData.team.id at runtime.
6872
expect(result).toEqual({
6973
input: "Product Team/Roadmap",
7074
resolved: true,
71-
teamId: "team-1",
75+
teamId: "19:[email protected]",
7276
teamName: "Product Team",
73-
channelId: "channel-2",
77+
channelId: "19:[email protected]",
7478
channelName: "Roadmap",
7579
note: "multiple channels; chose first",
7680
});
7781
});
82+
83+
it("uses General channel conversation ID as team key for team-only entry", async () => {
84+
// When no channel is specified we still resolve the General channel so the
85+
// stored key matches what Bot Framework sends as channelData.team.id.
86+
listTeamsByName.mockResolvedValueOnce([{ id: "guid-engineering", displayName: "Engineering" }]);
87+
listChannelsForTeam.mockResolvedValueOnce([
88+
{ id: "19:[email protected]", displayName: "General" },
89+
{ id: "19:[email protected]", displayName: "Standups" },
90+
]);
91+
92+
const [result] = await resolveMSTeamsChannelAllowlist({
93+
cfg: {},
94+
entries: ["Engineering"],
95+
});
96+
97+
expect(result).toEqual({
98+
input: "Engineering",
99+
resolved: true,
100+
teamId: "19:[email protected]",
101+
teamName: "Engineering",
102+
});
103+
});
104+
105+
it("falls back to Graph GUID when listChannelsForTeam throws", async () => {
106+
// Edge case: API call fails (rate limit, network error). We fall back to
107+
// the Graph GUID as the team key — the pre-fix behavior — so resolution
108+
// still succeeds instead of propagating the error.
109+
listTeamsByName.mockResolvedValueOnce([{ id: "guid-flaky", displayName: "Flaky Team" }]);
110+
listChannelsForTeam.mockRejectedValueOnce(new Error("429 Too Many Requests"));
111+
112+
const [result] = await resolveMSTeamsChannelAllowlist({
113+
cfg: {},
114+
entries: ["Flaky Team"],
115+
});
116+
117+
expect(result).toEqual({
118+
input: "Flaky Team",
119+
resolved: true,
120+
teamId: "guid-flaky",
121+
teamName: "Flaky Team",
122+
});
123+
});
124+
125+
it("falls back to Graph GUID when General channel is not found", async () => {
126+
// Edge case: General channel was renamed or deleted. We fall back to the
127+
// Graph GUID so resolution still succeeds rather than silently breaking.
128+
listTeamsByName.mockResolvedValueOnce([{ id: "guid-ops", displayName: "Operations" }]);
129+
listChannelsForTeam.mockResolvedValueOnce([
130+
{ id: "19:[email protected]", displayName: "Announcements" },
131+
{ id: "19:[email protected]", displayName: "Random" },
132+
]);
133+
134+
const [result] = await resolveMSTeamsChannelAllowlist({
135+
cfg: {},
136+
entries: ["Operations"],
137+
});
138+
139+
expect(result).toEqual({
140+
input: "Operations",
141+
resolved: true,
142+
teamId: "guid-ops",
143+
teamName: "Operations",
144+
});
145+
});
78146
});

extensions/msteams/src/resolve-allowlist.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,26 @@ export async function resolveMSTeamsChannelAllowlist(params: {
120120
return { input, resolved: false, note: "team not found" };
121121
}
122122
const teamMatch = teams[0];
123-
const teamId = teamMatch.id?.trim();
123+
const graphTeamId = teamMatch.id?.trim();
124124
const teamName = teamMatch.displayName?.trim() || team;
125-
if (!teamId) {
125+
if (!graphTeamId) {
126126
return { input, resolved: false, note: "team id missing" };
127127
}
128+
// Bot Framework sends the General channel's conversation ID as
129+
// channelData.team.id at runtime, NOT the Graph API group GUID.
130+
// Fetch channels upfront so we can resolve the correct key format for
131+
// runtime matching and reuse the list for channel lookups.
132+
let teamChannels: Awaited<ReturnType<typeof listChannelsForTeam>> = [];
133+
try {
134+
teamChannels = await listChannelsForTeam(token, graphTeamId);
135+
} catch {
136+
// API failure (rate limit, network error) — fall back to Graph GUID as team key
137+
}
138+
const generalChannel = teamChannels.find((ch) => ch.displayName?.toLowerCase() === "general");
139+
// Use the General channel's conversation ID as the team key — this
140+
// matches what Bot Framework sends at runtime. Fall back to the Graph
141+
// GUID if the General channel isn't found (renamed or deleted).
142+
const teamId = generalChannel?.id?.trim() || graphTeamId;
128143
if (!channel) {
129144
return {
130145
input,
@@ -134,11 +149,11 @@ export async function resolveMSTeamsChannelAllowlist(params: {
134149
note: teams.length > 1 ? "multiple teams; chose first" : undefined,
135150
};
136151
}
137-
const channels = await listChannelsForTeam(token, teamId);
152+
// Reuse teamChannels — already fetched above
138153
const channelMatch =
139-
channels.find((item) => item.id === channel) ??
140-
channels.find((item) => item.displayName?.toLowerCase() === channel.toLowerCase()) ??
141-
channels.find((item) =>
154+
teamChannels.find((item) => item.id === channel) ??
155+
teamChannels.find((item) => item.displayName?.toLowerCase() === channel.toLowerCase()) ??
156+
teamChannels.find((item) =>
142157
item.displayName?.toLowerCase().includes(channel.toLowerCase() ?? ""),
143158
);
144159
if (!channelMatch?.id) {
@@ -151,7 +166,7 @@ export async function resolveMSTeamsChannelAllowlist(params: {
151166
teamName,
152167
channelId: channelMatch.id,
153168
channelName: channelMatch.displayName ?? channel,
154-
note: channels.length > 1 ? "multiple channels; chose first" : undefined,
169+
note: teamChannels.length > 1 ? "multiple channels; chose first" : undefined,
155170
};
156171
},
157172
});

0 commit comments

Comments
 (0)