diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ddcafb6fba..ebc4fb648da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index 58ccfd3323f..1f15e51fcc0 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -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 diff --git a/extensions/msteams/src/resolve-allowlist.test.ts b/extensions/msteams/src/resolve-allowlist.test.ts index b6eb0beae7a..f2353823259 100644 --- a/extensions/msteams/src/resolve-allowlist.test.ts +++ b/extensions/msteams/src/resolve-allowlist.test.ts @@ -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. diff --git a/extensions/msteams/src/resolve-allowlist.ts b/extensions/msteams/src/resolve-allowlist.ts index 5f69587c343..9df353e1535 100644 --- a/extensions/msteams/src/resolve-allowlist.ts +++ b/extensions/msteams/src/resolve-allowlist.ts @@ -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 { - const token = await resolveGraphToken(params.cfg); + let tokenPromise: Promise | undefined; + const getToken = () => { + tokenPromise ??= resolveGraphToken(params.cfg); + return tokenPromise; + }; return await mapAllowlistResolutionInputs({ inputs: params.entries, mapInput: async (input): Promise => { @@ -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);