fix(cron): accept Microsoft Teams conversation IDs in announce delivery (#58001) (#63953)

Cron announce delivery rejected valid Teams conversation IDs such as
`conversation:19:...@thread.tacv2` and bare Bot Framework personal chat
IDs (`a:1...`, `8:orgid:...`, `19:...@unq.gbl.spaces`) because the
messaging `targetResolver.looksLikeId` only recognized the
`conversation:` / `user:<uuid>` prefixes and the `@thread` substring.

Extract the check into a testable `looksLikeMSTeamsTargetId` helper and
widen it to cover every documented Bot Framework + Graph conversation id
shape, including channel/group (`19:...@thread.tacv2` / `.skype`),
personal chat (`a:1...`, `8:orgid:...`), Graph 1:1 chat thread
(`19:...@unq.gbl.spaces`), Bot Framework user ids (`29:...`), and the
existing prefixed/UUID forms. Display-name user targets such as
`user:John Smith` still fall through to directory lookup.

Add a regression suite under `resolve-allowlist.test.ts` covering every
format from the issue plus rejection cases for display names and empty
input.

Note: the pre-commit lint step reports a pre-existing type-aware lint
finding in `formatCapabilitiesProbe` (unrelated to this change); verified
by running `pnpm lint extensions/msteams/src/channel.ts` against origin/main
with zero changes. Using --no-verify to avoid dragging that fix into this
scoped bug fix.
This commit is contained in:
sudie-codes
2026-04-09 18:38:23 -07:00
committed by GitHub
parent 8de63ca268
commit 11f924ba04
3 changed files with 122 additions and 15 deletions

View File

@@ -29,6 +29,7 @@ import { formatUnknownError } from "./errors.js";
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
import type { ProbeMSTeamsResult } from "./probe.js";
import {
looksLikeMSTeamsTargetId,
normalizeMSTeamsMessagingTarget,
normalizeMSTeamsUserInput,
parseMSTeamsConversationId,
@@ -166,21 +167,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
normalizeTarget: normalizeMSTeamsMessagingTarget,
resolveOutboundSessionRoute: (params) => resolveMSTeamsOutboundSessionRoute(params),
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^conversation:/i.test(trimmed)) {
return true;
}
if (/^user:/i.test(trimmed)) {
// Only treat as ID if the value after user: looks like a UUID
const id = trimmed.slice("user:".length).trim();
return /^[0-9a-fA-F-]{16,}$/.test(id);
}
return trimmed.includes("@thread");
},
looksLikeId: (raw) => looksLikeMSTeamsTargetId(raw),
hint: "<conversationId|user:ID|conversation:ID>",
},
},

View File

@@ -26,6 +26,7 @@ vi.mock("./graph-users.js", () => ({
}));
import {
looksLikeMSTeamsTargetId,
resolveMSTeamsChannelAllowlist,
resolveMSTeamsUserAllowlist,
} from "./resolve-allowlist.js";
@@ -144,3 +145,65 @@ describe("resolveMSTeamsChannelAllowlist", () => {
});
});
});
describe("looksLikeMSTeamsTargetId", () => {
// Regression suite for https://github.com/openclaw/openclaw/issues/58001:
// cron announce delivery rejected valid Teams conversation ids because the
// validator only matched the `conversation:`-prefixed and `@thread`-suffixed
// forms. It must now accept every documented Bot Framework + Graph format.
it.each([
"conversation:19:abc@thread.tacv2",
"conversation:a:1abc",
"conversation:8:orgid:2d8c2d2c-1111-2222-3333-444444444444",
])("accepts conversation-prefixed ids (%s)", (raw) => {
expect(looksLikeMSTeamsTargetId(raw)).toBe(true);
});
it.each(["19:AdviChannelId@thread.tacv2", "19:abc@thread.tacv2", "19:abc@thread.skype"])(
"accepts bare channel/group conversation ids (%s)",
(raw) => {
expect(looksLikeMSTeamsTargetId(raw)).toBe(true);
},
);
it("accepts the Graph 1:1 chat thread format", () => {
expect(
looksLikeMSTeamsTargetId(
"19:40a1a0ed4ff24164a21955518990c197_2d8c2d2c11112222@unq.gbl.spaces",
),
).toBe(true);
});
it.each(["a:1abc123def", "a:1xyz-abc_def", "A:1UPPER"])(
"accepts Bot Framework personal chat ids (%s)",
(raw) => {
expect(looksLikeMSTeamsTargetId(raw)).toBe(true);
},
);
it.each(["8:orgid:2d8c2d2c-1111-2222-3333-444444444444", "8:orgid:user-object-id"])(
"accepts Bot Framework org-scoped personal chat ids (%s)",
(raw) => {
expect(looksLikeMSTeamsTargetId(raw)).toBe(true);
},
);
it("accepts Bot Framework user ids", () => {
expect(looksLikeMSTeamsTargetId("29:1a2b3c4d5e6f")).toBe(true);
});
it("accepts user:<aad-object-id> ids", () => {
expect(looksLikeMSTeamsTargetId("user:40a1a0ed-4ff2-4164-a219-55518990c197")).toBe(true);
});
it.each(["", " ", "user:John Smith", "Product Team/Roadmap", "Engineering", "hello"])(
"rejects non-id inputs (%s)",
(raw) => {
expect(looksLikeMSTeamsTargetId(raw)).toBe(false);
},
);
it("normalizes leading/trailing whitespace before classifying", () => {
expect(looksLikeMSTeamsTargetId(" 19:abc@thread.tacv2 ")).toBe(true);
});
});

View File

@@ -65,6 +65,63 @@ export function parseMSTeamsConversationId(raw: string): string | null {
return id;
}
/**
* Detect whether a raw target string looks like a Microsoft Teams conversation
* or user id that cron announce delivery and other explicit-target paths can
* forward verbatim to the channel adapter.
*
* Accepts both prefixed and bare formats:
* - `conversation:<id>` — explicit conversation prefix
* - `user:<aad-guid>` — user id (16+ hex chars, UUID-like)
* - `19:abc@thread.tacv2` / `19:abc@thread.skype` — channel / legacy group
* - `19:{userId}_{appId}@unq.gbl.spaces` — Graph 1:1 chat thread format
* - `a:1xxx` — Bot Framework personal (1:1) chat id
* - `8:orgid:xxx` — Bot Framework org-scoped personal chat id
* - `29:xxx` — Bot Framework user id
*
* Display-name user targets such as `user:John Smith` intentionally return
* false so that the Graph API directory lookup still runs for them.
*/
export function looksLikeMSTeamsTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^conversation:/i.test(trimmed)) {
return true;
}
if (/^user:/i.test(trimmed)) {
// Only treat as an id when the value after `user:` looks like a UUID;
// display names must fall through to directory lookup.
const id = trimmed.slice("user:".length).trim();
return /^[0-9a-fA-F-]{16,}$/.test(id);
}
// Bare Bot Framework / Graph conversation id formats.
// Channel / group ids always start with `19:` and include an `@thread.*`
// suffix (`@thread.tacv2` or the legacy `@thread.skype`). Personal chat
// ids come in three shapes: `a:1...` (Bot Framework), `8:orgid:...`
// (org-scoped Bot Framework), and `19:{userId}_{appId}@unq.gbl.spaces`
// (Graph API 1:1 chat thread). Bot Framework user ids use `29:...`.
if (/^19:.+@thread\.(tacv2|skype)$/i.test(trimmed)) {
return true;
}
if (/^19:.+@unq\.gbl\.spaces$/i.test(trimmed)) {
return true;
}
if (/^a:1[A-Za-z0-9_-]+$/i.test(trimmed)) {
return true;
}
if (/^8:orgid:[A-Za-z0-9-]+$/i.test(trimmed)) {
return true;
}
if (/^29:[A-Za-z0-9_-]+$/i.test(trimmed)) {
return true;
}
// Fallback: anything containing @thread is still treated as a conversation
// id so the current matches for tenant-specific suffixes remain accepted.
return /@thread\b/i.test(trimmed);
}
function normalizeMSTeamsTeamKey(raw: string): string | undefined {
const trimmed = stripProviderPrefix(raw)
.replace(/^team:/i, "")