mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:10:44 +00:00
fix(msteams): accept conversation id allowlists
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 (mention‑gated).
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user