fix(msteams): accept conversation id allowlists

This commit is contained in:
Peter Steinberger
2026-04-30 01:35:35 +01:00
parent c397486648
commit 08c4af0ddf
4 changed files with 88 additions and 7 deletions

View File

@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
- CLI/update: scope packaged Node compile caches by OpenClaw version and install metadata, so global installs no longer reuse stale compiled chunks after package updates. Thanks @pashpashpash.
- Plugin SDK/testing: lazy-load TypeScript from the plugin test-contract runtime and add release checks for critical SDK contract entrypoint imports and bundle size, so published packages fail preflight before shipping ESM-incompatible or oversized contract helpers. Thanks @vincentkoc.
- Channels/Microsoft Teams: treat configured `19:...@thread.tacv2` and legacy `19:...@thread.skype` team/channel IDs as already resolved during startup, avoiding false `channels unresolved` warnings while preserving Graph name lookup for display-name entries. Fixes #74683. Thanks @dseravalli.
- CLI/browser: preserve parent flags while lazy-loading browser subcommands, so `openclaw browser --json open` and `openclaw browser --json tabs` keep machine-readable output after reparsing. Fixes #74574. Thanks @devintegeritsm.
- Plugins/runtime-deps: add `openclaw plugins deps` inspection and repair with script-free package-manager defaults shared across plugin installers, so operators can repair missing bundled runtime deps without corrupting JSON output or blocking unrelated conflict-free deps. Thanks @vincentkoc.
- Agents/output: strip internal `[tool calls omitted]` replay placeholders from user-facing replies while preserving visible reply whitespace. Fixes #74573. Thanks @blaspat.

View File

@@ -174,7 +174,7 @@ Example:
**Teams + channel allowlist**
- Scope group/channel replies by listing teams and channels under `channels.msteams.teams`.
- Keys should use stable team IDs and channel conversation IDs.
- Keys should use stable Teams conversation IDs from Teams links, not mutable display names.
- When `groupPolicy="allowlist"` and a teams allowlist is present, only listed teams/channels are accepted (mentiongated).
- The configure wizard accepts `Team/Channel` entries and stores them for you.
- On startup, OpenClaw resolves team/channel and user allowlist names to IDs (when Graph permissions allow)
@@ -944,7 +944,7 @@ The `groupId` query parameter in Teams URLs is **NOT** the team ID used for conf
```
https://teams.microsoft.com/l/team/19%3ABk4j...%40thread.tacv2/conversations?groupId=...
└────────────────────────────┘
Team ID (URL-decode this)
Team conversation ID (URL-decode this)
```
**Channel URL:**
@@ -957,9 +957,9 @@ https://teams.microsoft.com/l/channel/19%3A15bc...%40thread.tacv2/ChannelName?gr
**For config:**
- Team ID = path segment after `/team/` (URL-decoded, e.g., `19:Bk4j...@thread.tacv2`)
- Channel ID = path segment after `/channel/` (URL-decoded)
- **Ignore** the `groupId` query parameter
- Team key = path segment after `/team/` (URL-decoded, e.g., `19:Bk4j...@thread.tacv2`; older tenants may show `@thread.skype`, which is also valid)
- Channel key = path segment after `/channel/` (URL-decoded)
- **Ignore** the `groupId` query parameter for OpenClaw routing. It is the Microsoft Entra group ID, not the Bot Framework conversation ID used in incoming Teams activities.
## Private channels

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
listTeamsByName,
@@ -31,6 +31,14 @@ import {
resolveMSTeamsUserAllowlist,
} from "./resolve-allowlist.js";
beforeEach(() => {
listTeamsByName.mockReset();
listChannelsForTeam.mockReset();
normalizeQuery.mockImplementation((value: string) => value.trim().toLowerCase());
resolveGraphToken.mockReset().mockResolvedValue("graph-token");
searchGraphUsers.mockReset();
});
describe("resolveMSTeamsUserAllowlist", () => {
it("marks empty input unresolved", async () => {
const [result] = await resolveMSTeamsUserAllowlist({ cfg: {}, entries: [" "] });
@@ -54,6 +62,39 @@ describe("resolveMSTeamsUserAllowlist", () => {
});
describe("resolveMSTeamsChannelAllowlist", () => {
it("keeps configured Teams conversation IDs resolved without Graph lookup", async () => {
const [result] = await resolveMSTeamsChannelAllowlist({
cfg: {},
entries: ["19:team-general@thread.skype/19:roadmap@thread.skype"],
});
expect(result).toEqual({
input: "19:team-general@thread.skype/19:roadmap@thread.skype",
resolved: true,
teamId: "19:team-general@thread.skype",
teamName: "19:team-general@thread.skype",
channelId: "19:roadmap@thread.skype",
channelName: "19:roadmap@thread.skype",
});
expect(resolveGraphToken).not.toHaveBeenCalled();
expect(listTeamsByName).not.toHaveBeenCalled();
expect(listChannelsForTeam).not.toHaveBeenCalled();
});
it("normalizes conversation-prefixed configured channel IDs", async () => {
const [result] = await resolveMSTeamsChannelAllowlist({
cfg: {},
entries: ["19:team-general@thread.tacv2/conversation:19:roadmap@thread.tacv2"],
});
expect(result).toMatchObject({
resolved: true,
teamId: "19:team-general@thread.tacv2",
channelId: "19:roadmap@thread.tacv2",
});
expect(resolveGraphToken).not.toHaveBeenCalled();
});
it("resolves team/channel by team name + channel display name", async () => {
// After the fix, listChannelsForTeam is called once and reused for both
// General channel resolution and channel matching.

View File

@@ -134,6 +134,16 @@ function normalizeMSTeamsChannelKey(raw?: string | null): string | undefined {
return trimmed || undefined;
}
function normalizeMSTeamsConversationTargetId(raw: string): string {
const trimmed = stripProviderPrefix(raw).trim();
return parseMSTeamsConversationId(trimmed) ?? trimmed;
}
function looksLikeMSTeamsThreadConversationId(raw: string): boolean {
const normalized = normalizeMSTeamsConversationTargetId(raw);
return /^19:.+@thread\./i.test(normalized);
}
export function parseMSTeamsTeamChannelInput(raw: string): { team?: string; channel?: string } {
const trimmed = stripProviderPrefix(raw).trim();
if (!trimmed) {
@@ -166,7 +176,11 @@ export async function resolveMSTeamsChannelAllowlist(params: {
cfg: unknown;
entries: string[];
}): Promise<MSTeamsChannelResolution[]> {
const token = await resolveGraphToken(params.cfg);
let tokenPromise: Promise<string> | undefined;
const getToken = () => {
tokenPromise ??= resolveGraphToken(params.cfg);
return tokenPromise;
};
return await mapAllowlistResolutionInputs({
inputs: params.entries,
mapInput: async (input): Promise<MSTeamsChannelResolution> => {
@@ -174,6 +188,31 @@ export async function resolveMSTeamsChannelAllowlist(params: {
if (!team) {
return { input, resolved: false };
}
if (looksLikeMSTeamsThreadConversationId(team)) {
const teamId = normalizeMSTeamsConversationTargetId(team);
if (!channel) {
return { input, resolved: true, teamId, teamName: teamId };
}
if (!looksLikeMSTeamsThreadConversationId(channel)) {
return {
input,
resolved: false,
teamId,
teamName: teamId,
note: "channel id required for conversation-id team",
};
}
const channelId = normalizeMSTeamsConversationTargetId(channel);
return {
input,
resolved: true,
teamId,
teamName: teamId,
channelId,
channelName: channelId,
};
}
const token = await getToken();
const teams = /^[0-9a-fA-F-]{16,}$/.test(team)
? [{ id: team, displayName: team }]
: await listTeamsByName(token, team);