@openclaw/msteams 2026.3.2 → 2026.3.8-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,20 +2,24 @@ import {
2
2
  DEFAULT_ACCOUNT_ID,
3
3
  buildPendingHistoryContextFromMap,
4
4
  clearHistoryEntriesIfEnabled,
5
+ dispatchReplyFromConfigWithSettledDispatcher,
5
6
  DEFAULT_GROUP_HISTORY_LIMIT,
6
7
  createScopedPairingAccess,
7
8
  logInboundDrop,
9
+ evaluateSenderGroupAccessForPolicy,
10
+ resolveSenderScopedGroupPolicy,
8
11
  recordPendingHistoryEntryIfEnabled,
9
12
  resolveControlCommandGate,
10
13
  resolveDefaultGroupPolicy,
11
14
  isDangerousNameMatchingEnabled,
12
15
  readStoreAllowFromForDmPolicy,
13
16
  resolveMentionGating,
17
+ resolveInboundSessionEnvelopeContext,
14
18
  formatAllowlistMatchMeta,
15
19
  resolveEffectiveAllowFromLists,
16
20
  resolveDmGroupAccessWithLists,
17
21
  type HistoryEntry,
18
- } from "openclaw/plugin-sdk";
22
+ } from "openclaw/plugin-sdk/msteams";
19
23
  import {
20
24
  buildMSTeamsAttachmentPlaceholder,
21
25
  buildMSTeamsMediaPayload,
@@ -172,12 +176,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
172
176
  conversationId,
173
177
  channelName,
174
178
  });
175
- const senderGroupPolicy =
176
- groupPolicy === "disabled"
177
- ? "disabled"
178
- : effectiveGroupAllowFrom.length > 0
179
- ? "allowlist"
180
- : "open";
179
+ const senderGroupPolicy = resolveSenderScopedGroupPolicy({
180
+ groupPolicy,
181
+ groupAllowFrom: effectiveGroupAllowFrom,
182
+ });
181
183
  const access = resolveDmGroupAccessWithLists({
182
184
  isGroup: !isDirectMessage,
183
185
  dmPolicy,
@@ -228,46 +230,54 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
228
230
  }
229
231
 
230
232
  if (!isDirectMessage && msteamsCfg) {
231
- if (groupPolicy === "disabled") {
232
- log.debug?.("dropping group message (groupPolicy: disabled)", {
233
+ if (channelGate.allowlistConfigured && !channelGate.allowed) {
234
+ log.debug?.("dropping group message (not in team/channel allowlist)", {
233
235
  conversationId,
236
+ teamKey: channelGate.teamKey ?? "none",
237
+ channelKey: channelGate.channelKey ?? "none",
238
+ channelMatchKey: channelGate.channelMatchKey ?? "none",
239
+ channelMatchSource: channelGate.channelMatchSource ?? "none",
234
240
  });
235
241
  return;
236
242
  }
237
-
238
- if (groupPolicy === "allowlist") {
239
- if (channelGate.allowlistConfigured && !channelGate.allowed) {
240
- log.debug?.("dropping group message (not in team/channel allowlist)", {
241
- conversationId,
242
- teamKey: channelGate.teamKey ?? "none",
243
- channelKey: channelGate.channelKey ?? "none",
244
- channelMatchKey: channelGate.channelMatchKey ?? "none",
245
- channelMatchSource: channelGate.channelMatchSource ?? "none",
246
- });
247
- return;
248
- }
249
- if (effectiveGroupAllowFrom.length === 0 && !channelGate.allowlistConfigured) {
250
- log.debug?.("dropping group message (groupPolicy: allowlist, no allowlist)", {
251
- conversationId,
252
- });
253
- return;
254
- }
255
- if (effectiveGroupAllowFrom.length > 0 && access.decision !== "allow") {
256
- const allowMatch = resolveMSTeamsAllowlistMatch({
257
- allowFrom: effectiveGroupAllowFrom,
243
+ const senderGroupAccess = evaluateSenderGroupAccessForPolicy({
244
+ groupPolicy,
245
+ groupAllowFrom: effectiveGroupAllowFrom,
246
+ senderId,
247
+ isSenderAllowed: (_senderId, allowFrom) =>
248
+ resolveMSTeamsAllowlistMatch({
249
+ allowFrom,
258
250
  senderId,
259
251
  senderName,
260
252
  allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
261
- });
262
- if (!allowMatch.allowed) {
263
- log.debug?.("dropping group message (not in groupAllowFrom)", {
264
- sender: senderId,
265
- label: senderName,
266
- allowlistMatch: formatAllowlistMatchMeta(allowMatch),
267
- });
268
- return;
269
- }
270
- }
253
+ }).allowed,
254
+ });
255
+
256
+ if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") {
257
+ log.debug?.("dropping group message (groupPolicy: disabled)", {
258
+ conversationId,
259
+ });
260
+ return;
261
+ }
262
+ if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") {
263
+ log.debug?.("dropping group message (groupPolicy: allowlist, no allowlist)", {
264
+ conversationId,
265
+ });
266
+ return;
267
+ }
268
+ if (!senderGroupAccess.allowed && senderGroupAccess.reason === "sender_not_allowlisted") {
269
+ const allowMatch = resolveMSTeamsAllowlistMatch({
270
+ allowFrom: effectiveGroupAllowFrom,
271
+ senderId,
272
+ senderName,
273
+ allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
274
+ });
275
+ log.debug?.("dropping group message (not in groupAllowFrom)", {
276
+ sender: senderId,
277
+ label: senderName,
278
+ allowlistMatch: formatAllowlistMatchMeta(allowMatch),
279
+ });
280
+ return;
271
281
  }
272
282
  }
273
283
 
@@ -451,12 +461,9 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
451
461
 
452
462
  const mediaPayload = buildMSTeamsMediaPayload(mediaList);
453
463
  const envelopeFrom = isDirectMessage ? senderName : conversationType;
454
- const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
464
+ const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
465
+ cfg,
455
466
  agentId: route.agentId,
456
- });
457
- const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
458
- const previousTimestamp = core.channel.session.readSessionUpdatedAt({
459
- storePath,
460
467
  sessionKey: route.sessionKey,
461
468
  });
462
469
  const body = core.channel.reply.formatAgentEnvelope({
@@ -559,18 +566,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
559
566
 
560
567
  log.info("dispatching to agent", { sessionKey: route.sessionKey });
561
568
  try {
562
- const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
569
+ const { queuedFinal, counts } = await dispatchReplyFromConfigWithSettledDispatcher({
570
+ cfg,
571
+ ctxPayload,
563
572
  dispatcher,
564
573
  onSettled: () => {
565
574
  markDispatchIdle();
566
575
  },
567
- run: () =>
568
- core.channel.reply.dispatchReplyFromConfig({
569
- ctx: ctxPayload,
570
- cfg,
571
- dispatcher,
572
- replyOptions,
573
- }),
576
+ replyOptions,
574
577
  });
575
578
 
576
579
  log.info("dispatch complete", { queuedFinal, counts });
@@ -1,4 +1,4 @@
1
- import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
1
+ import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/msteams";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import type { MSTeamsConversationStore } from "./conversation-store.js";
4
4
  import type { MSTeamsAdapter } from "./messenger.js";
@@ -1,4 +1,4 @@
1
- import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
1
+ import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/msteams";
2
2
  import type { MSTeamsConversationStore } from "./conversation-store.js";
3
3
  import { buildFileInfoCard, parseFileConsentInvoke, uploadToConsentUrl } from "./file-consent.js";
4
4
  import { normalizeMSTeamsConversationId } from "./inbound.js";
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from "node:events";
2
- import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
2
+ import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/msteams";
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
4
  import type { MSTeamsConversationStore } from "./conversation-store.js";
5
5
  import type { MSTeamsPollStore } from "./polls.js";
@@ -15,8 +15,14 @@ const expressControl = vi.hoisted(() => ({
15
15
  mode: { value: "listening" as "listening" | "error" },
16
16
  }));
17
17
 
18
- vi.mock("openclaw/plugin-sdk", () => ({
18
+ vi.mock("openclaw/plugin-sdk/msteams", () => ({
19
19
  DEFAULT_WEBHOOK_MAX_BODY_BYTES: 1024 * 1024,
20
+ normalizeSecretInputString: (value: unknown) =>
21
+ typeof value === "string" && value.trim() ? value.trim() : undefined,
22
+ hasConfiguredSecretInput: (value: unknown) =>
23
+ typeof value === "string" && value.trim().length > 0,
24
+ normalizeResolvedSecretInputString: (params: { value?: unknown }) =>
25
+ typeof params?.value === "string" && params.value.trim() ? params.value.trim() : undefined,
20
26
  keepHttpServerTaskAlive: vi.fn(
21
27
  async (params: { abortSignal?: AbortSignal; onAbort?: () => Promise<void> | void }) => {
22
28
  await new Promise<void>((resolve) => {
@@ -134,7 +140,7 @@ function createConfig(port: number): OpenClawConfig {
134
140
  msteams: {
135
141
  enabled: true,
136
142
  appId: "app-id",
137
- appPassword: "app-password",
143
+ appPassword: "app-password", // pragma: allowlist secret
138
144
  tenantId: "tenant-id",
139
145
  webhook: {
140
146
  port,
package/src/monitor.ts CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  summarizeMapping,
8
8
  type OpenClawConfig,
9
9
  type RuntimeEnv,
10
- } from "openclaw/plugin-sdk";
10
+ } from "openclaw/plugin-sdk/msteams";
11
11
  import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
12
12
  import type { MSTeamsConversationStore } from "./conversation-store.js";
13
13
  import { formatUnknownError } from "./errors.js";
package/src/onboarding.ts CHANGED
@@ -5,14 +5,17 @@ import type {
5
5
  DmPolicy,
6
6
  WizardPrompter,
7
7
  MSTeamsTeamConfig,
8
- } from "openclaw/plugin-sdk";
8
+ } from "openclaw/plugin-sdk/msteams";
9
9
  import {
10
- addWildcardAllowFrom,
11
10
  DEFAULT_ACCOUNT_ID,
12
11
  formatDocsLink,
13
12
  mergeAllowFromEntries,
14
13
  promptChannelAccessConfig,
15
- } from "openclaw/plugin-sdk";
14
+ setTopLevelChannelAllowFrom,
15
+ setTopLevelChannelDmPolicyWithAllowFrom,
16
+ setTopLevelChannelGroupPolicy,
17
+ splitOnboardingEntries,
18
+ } from "openclaw/plugin-sdk/msteams";
16
19
  import {
17
20
  parseMSTeamsTeamEntry,
18
21
  resolveMSTeamsChannelAllowlist,
@@ -24,41 +27,19 @@ import { hasConfiguredMSTeamsCredentials, resolveMSTeamsCredentials } from "./to
24
27
  const channel = "msteams" as const;
25
28
 
26
29
  function setMSTeamsDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) {
27
- const allowFrom =
28
- dmPolicy === "open"
29
- ? addWildcardAllowFrom(cfg.channels?.msteams?.allowFrom)?.map((entry) => String(entry))
30
- : undefined;
31
- return {
32
- ...cfg,
33
- channels: {
34
- ...cfg.channels,
35
- msteams: {
36
- ...cfg.channels?.msteams,
37
- dmPolicy,
38
- ...(allowFrom ? { allowFrom } : {}),
39
- },
40
- },
41
- };
30
+ return setTopLevelChannelDmPolicyWithAllowFrom({
31
+ cfg,
32
+ channel: "msteams",
33
+ dmPolicy,
34
+ });
42
35
  }
43
36
 
44
37
  function setMSTeamsAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig {
45
- return {
46
- ...cfg,
47
- channels: {
48
- ...cfg.channels,
49
- msteams: {
50
- ...cfg.channels?.msteams,
51
- allowFrom,
52
- },
53
- },
54
- };
55
- }
56
-
57
- function parseAllowFromInput(raw: string): string[] {
58
- return raw
59
- .split(/[\n,;]+/g)
60
- .map((entry) => entry.trim())
61
- .filter(Boolean);
38
+ return setTopLevelChannelAllowFrom({
39
+ cfg,
40
+ channel: "msteams",
41
+ allowFrom,
42
+ });
62
43
  }
63
44
 
64
45
  function looksLikeGuid(value: string): boolean {
@@ -115,7 +96,7 @@ async function promptMSTeamsAllowFrom(params: {
115
96
  initialValue: existing[0] ? String(existing[0]) : undefined,
116
97
  validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
117
98
  });
118
- const parts = parseAllowFromInput(String(entry));
99
+ const parts = splitOnboardingEntries(String(entry));
119
100
  if (parts.length === 0) {
120
101
  await params.prompter.note("Enter at least one user.", "MS Teams allowlist");
121
102
  continue;
@@ -171,17 +152,12 @@ function setMSTeamsGroupPolicy(
171
152
  cfg: OpenClawConfig,
172
153
  groupPolicy: "open" | "allowlist" | "disabled",
173
154
  ): OpenClawConfig {
174
- return {
175
- ...cfg,
176
- channels: {
177
- ...cfg.channels,
178
- msteams: {
179
- ...cfg.channels?.msteams,
180
- enabled: true,
181
- groupPolicy,
182
- },
183
- },
184
- };
155
+ return setTopLevelChannelGroupPolicy({
156
+ cfg,
157
+ channel: "msteams",
158
+ groupPolicy,
159
+ enabled: true,
160
+ });
185
161
  }
186
162
 
187
163
  function setMSTeamsTeamsAllowlist(
@@ -0,0 +1,131 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/msteams";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const mocks = vi.hoisted(() => ({
5
+ sendMessageMSTeams: vi.fn(),
6
+ sendPollMSTeams: vi.fn(),
7
+ createPoll: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("./send.js", () => ({
11
+ sendMessageMSTeams: mocks.sendMessageMSTeams,
12
+ sendPollMSTeams: mocks.sendPollMSTeams,
13
+ }));
14
+
15
+ vi.mock("./polls.js", () => ({
16
+ createMSTeamsPollStoreFs: () => ({
17
+ createPoll: mocks.createPoll,
18
+ }),
19
+ }));
20
+
21
+ vi.mock("./runtime.js", () => ({
22
+ getMSTeamsRuntime: () => ({
23
+ channel: {
24
+ text: {
25
+ chunkMarkdownText: (text: string) => [text],
26
+ },
27
+ },
28
+ }),
29
+ }));
30
+
31
+ import { msteamsOutbound } from "./outbound.js";
32
+
33
+ describe("msteamsOutbound cfg threading", () => {
34
+ beforeEach(() => {
35
+ mocks.sendMessageMSTeams.mockReset();
36
+ mocks.sendPollMSTeams.mockReset();
37
+ mocks.createPoll.mockReset();
38
+ mocks.sendMessageMSTeams.mockResolvedValue({
39
+ messageId: "msg-1",
40
+ conversationId: "conv-1",
41
+ });
42
+ mocks.sendPollMSTeams.mockResolvedValue({
43
+ pollId: "poll-1",
44
+ messageId: "msg-poll-1",
45
+ conversationId: "conv-1",
46
+ });
47
+ mocks.createPoll.mockResolvedValue(undefined);
48
+ });
49
+
50
+ it("passes resolved cfg to sendMessageMSTeams for text sends", async () => {
51
+ const cfg = {
52
+ channels: {
53
+ msteams: {
54
+ appId: "resolved-app-id",
55
+ },
56
+ },
57
+ } as OpenClawConfig;
58
+
59
+ await msteamsOutbound.sendText!({
60
+ cfg,
61
+ to: "conversation:abc",
62
+ text: "hello",
63
+ });
64
+
65
+ expect(mocks.sendMessageMSTeams).toHaveBeenCalledWith({
66
+ cfg,
67
+ to: "conversation:abc",
68
+ text: "hello",
69
+ });
70
+ });
71
+
72
+ it("passes resolved cfg and media roots for media sends", async () => {
73
+ const cfg = {
74
+ channels: {
75
+ msteams: {
76
+ appId: "resolved-app-id",
77
+ },
78
+ },
79
+ } as OpenClawConfig;
80
+
81
+ await msteamsOutbound.sendMedia!({
82
+ cfg,
83
+ to: "conversation:abc",
84
+ text: "photo",
85
+ mediaUrl: "file:///tmp/photo.png",
86
+ mediaLocalRoots: ["/tmp"],
87
+ });
88
+
89
+ expect(mocks.sendMessageMSTeams).toHaveBeenCalledWith({
90
+ cfg,
91
+ to: "conversation:abc",
92
+ text: "photo",
93
+ mediaUrl: "file:///tmp/photo.png",
94
+ mediaLocalRoots: ["/tmp"],
95
+ });
96
+ });
97
+
98
+ it("passes resolved cfg to sendPollMSTeams and stores poll metadata", async () => {
99
+ const cfg = {
100
+ channels: {
101
+ msteams: {
102
+ appId: "resolved-app-id",
103
+ },
104
+ },
105
+ } as OpenClawConfig;
106
+
107
+ await msteamsOutbound.sendPoll!({
108
+ cfg,
109
+ to: "conversation:abc",
110
+ poll: {
111
+ question: "Snack?",
112
+ options: ["Pizza", "Sushi"],
113
+ },
114
+ });
115
+
116
+ expect(mocks.sendPollMSTeams).toHaveBeenCalledWith({
117
+ cfg,
118
+ to: "conversation:abc",
119
+ question: "Snack?",
120
+ options: ["Pizza", "Sushi"],
121
+ maxSelections: 1,
122
+ });
123
+ expect(mocks.createPoll).toHaveBeenCalledWith(
124
+ expect.objectContaining({
125
+ id: "poll-1",
126
+ question: "Snack?",
127
+ options: ["Pizza", "Sushi"],
128
+ }),
129
+ );
130
+ });
131
+ });
package/src/outbound.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
1
+ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/msteams";
2
2
  import { createMSTeamsPollStoreFs } from "./polls.js";
3
3
  import { getMSTeamsRuntime } from "./runtime.js";
4
4
  import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
@@ -1,4 +1,4 @@
1
- import type { MSTeamsConfig } from "openclaw/plugin-sdk";
1
+ import type { MSTeamsConfig } from "openclaw/plugin-sdk/msteams";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  isMSTeamsGroupAllowed,
package/src/policy.ts CHANGED
@@ -7,15 +7,16 @@ import type {
7
7
  MSTeamsConfig,
8
8
  MSTeamsReplyStyle,
9
9
  MSTeamsTeamConfig,
10
- } from "openclaw/plugin-sdk";
10
+ } from "openclaw/plugin-sdk/msteams";
11
11
  import {
12
12
  buildChannelKeyCandidates,
13
+ evaluateSenderGroupAccessForPolicy,
13
14
  normalizeChannelSlug,
14
15
  resolveAllowlistMatchSimple,
15
16
  resolveToolsBySender,
16
17
  resolveChannelEntryMatchWithFallback,
17
18
  resolveNestedAllowlistDecision,
18
- } from "openclaw/plugin-sdk";
19
+ } from "openclaw/plugin-sdk/msteams";
19
20
 
20
21
  export type MSTeamsResolvedRouteConfig = {
21
22
  teamConfig?: MSTeamsTeamConfig;
@@ -248,12 +249,10 @@ export function isMSTeamsGroupAllowed(params: {
248
249
  senderName?: string | null;
249
250
  allowNameMatching?: boolean;
250
251
  }): boolean {
251
- const { groupPolicy } = params;
252
- if (groupPolicy === "disabled") {
253
- return false;
254
- }
255
- if (groupPolicy === "open") {
256
- return true;
257
- }
258
- return resolveMSTeamsAllowlistMatch(params).allowed;
252
+ return evaluateSenderGroupAccessForPolicy({
253
+ groupPolicy: params.groupPolicy,
254
+ groupAllowFrom: params.allowFrom.map((entry) => String(entry)),
255
+ senderId: params.senderId,
256
+ isSenderAllowed: () => resolveMSTeamsAllowlistMatch(params).allowed,
257
+ }).allowed;
259
258
  }
package/src/probe.test.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { MSTeamsConfig } from "openclaw/plugin-sdk";
1
+ import type { MSTeamsConfig } from "openclaw/plugin-sdk/msteams";
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
 
4
4
  const hostMockState = vi.hoisted(() => ({
package/src/probe.ts CHANGED
@@ -1,4 +1,8 @@
1
- import type { BaseProbeResult, MSTeamsConfig } from "openclaw/plugin-sdk";
1
+ import {
2
+ normalizeStringEntries,
3
+ type BaseProbeResult,
4
+ type MSTeamsConfig,
5
+ } from "openclaw/plugin-sdk/msteams";
2
6
  import { formatUnknownError } from "./errors.js";
3
7
  import { loadMSTeamsSdkWithAuth } from "./sdk.js";
4
8
  import { readAccessToken } from "./token-response.js";
@@ -35,7 +39,7 @@ function readStringArray(value: unknown): string[] | undefined {
35
39
  if (!Array.isArray(value)) {
36
40
  return undefined;
37
41
  }
38
- const out = value.map((entry) => String(entry).trim()).filter(Boolean);
42
+ const out = normalizeStringEntries(value);
39
43
  return out.length > 0 ? out : undefined;
40
44
  }
41
45
 
@@ -6,7 +6,7 @@ import {
6
6
  type OpenClawConfig,
7
7
  type MSTeamsReplyStyle,
8
8
  type RuntimeEnv,
9
- } from "openclaw/plugin-sdk";
9
+ } from "openclaw/plugin-sdk/msteams";
10
10
  import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
11
11
  import type { StoredConversationReference } from "./conversation-store.js";
12
12
  import {
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ const {
4
+ listTeamsByName,
5
+ listChannelsForTeam,
6
+ normalizeQuery,
7
+ resolveGraphToken,
8
+ searchGraphUsers,
9
+ } = vi.hoisted(() => ({
10
+ listTeamsByName: vi.fn(),
11
+ listChannelsForTeam: vi.fn(),
12
+ normalizeQuery: vi.fn((value: string) => value.trim().toLowerCase()),
13
+ resolveGraphToken: vi.fn(async () => "graph-token"),
14
+ searchGraphUsers: vi.fn(),
15
+ }));
16
+
17
+ vi.mock("./graph.js", () => ({
18
+ listTeamsByName,
19
+ listChannelsForTeam,
20
+ normalizeQuery,
21
+ resolveGraphToken,
22
+ }));
23
+
24
+ vi.mock("./graph-users.js", () => ({
25
+ searchGraphUsers,
26
+ }));
27
+
28
+ import {
29
+ resolveMSTeamsChannelAllowlist,
30
+ resolveMSTeamsUserAllowlist,
31
+ } from "./resolve-allowlist.js";
32
+
33
+ describe("resolveMSTeamsUserAllowlist", () => {
34
+ it("marks empty input unresolved", async () => {
35
+ const [result] = await resolveMSTeamsUserAllowlist({ cfg: {}, entries: [" "] });
36
+ expect(result).toEqual({ input: " ", resolved: false });
37
+ });
38
+
39
+ it("resolves first Graph user match", async () => {
40
+ searchGraphUsers.mockResolvedValueOnce([
41
+ { id: "user-1", displayName: "Alice One" },
42
+ { id: "user-2", displayName: "Alice Two" },
43
+ ]);
44
+ const [result] = await resolveMSTeamsUserAllowlist({ cfg: {}, entries: ["alice"] });
45
+ expect(result).toEqual({
46
+ input: "alice",
47
+ resolved: true,
48
+ id: "user-1",
49
+ name: "Alice One",
50
+ note: "multiple matches; chose first",
51
+ });
52
+ });
53
+ });
54
+
55
+ describe("resolveMSTeamsChannelAllowlist", () => {
56
+ it("resolves team/channel by team name + channel display name", async () => {
57
+ listTeamsByName.mockResolvedValueOnce([{ id: "team-1", displayName: "Product Team" }]);
58
+ listChannelsForTeam.mockResolvedValueOnce([
59
+ { id: "channel-1", displayName: "General" },
60
+ { id: "channel-2", displayName: "Roadmap" },
61
+ ]);
62
+
63
+ const [result] = await resolveMSTeamsChannelAllowlist({
64
+ cfg: {},
65
+ entries: ["Product Team/Roadmap"],
66
+ });
67
+
68
+ expect(result).toEqual({
69
+ input: "Product Team/Roadmap",
70
+ resolved: true,
71
+ teamId: "team-1",
72
+ teamName: "Product Team",
73
+ channelId: "channel-2",
74
+ channelName: "Roadmap",
75
+ note: "multiple channels; chose first",
76
+ });
77
+ });
78
+ });