@openclaw/msteams 2026.3.13 → 2026.5.2-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.
Files changed (175) hide show
  1. package/api.ts +3 -0
  2. package/channel-config-api.ts +1 -0
  3. package/channel-plugin-api.ts +2 -0
  4. package/config-api.ts +4 -0
  5. package/contract-api.ts +4 -0
  6. package/index.ts +15 -12
  7. package/openclaw.plugin.json +553 -1
  8. package/package.json +46 -12
  9. package/runtime-api.ts +73 -0
  10. package/secret-contract-api.ts +5 -0
  11. package/setup-entry.ts +13 -0
  12. package/setup-plugin-api.ts +3 -0
  13. package/src/ai-entity.ts +7 -0
  14. package/src/approval-auth.ts +44 -0
  15. package/src/attachments/bot-framework.test.ts +461 -0
  16. package/src/attachments/bot-framework.ts +362 -0
  17. package/src/attachments/download.ts +63 -19
  18. package/src/attachments/graph.test.ts +416 -0
  19. package/src/attachments/graph.ts +163 -72
  20. package/src/attachments/html.ts +33 -1
  21. package/src/attachments/payload.ts +1 -1
  22. package/src/attachments/remote-media.test.ts +137 -0
  23. package/src/attachments/remote-media.ts +75 -8
  24. package/src/attachments/shared.test.ts +138 -1
  25. package/src/attachments/shared.ts +193 -26
  26. package/src/attachments/types.ts +10 -0
  27. package/src/attachments.graph.test.ts +342 -0
  28. package/src/attachments.helpers.test.ts +246 -0
  29. package/src/attachments.test-helpers.ts +17 -0
  30. package/src/attachments.test.ts +163 -418
  31. package/src/attachments.ts +5 -5
  32. package/src/block-streaming-config.test.ts +61 -0
  33. package/src/channel-api.ts +1 -0
  34. package/src/channel.actions.test.ts +742 -0
  35. package/src/channel.directory.test.ts +145 -4
  36. package/src/channel.runtime.ts +56 -0
  37. package/src/channel.setup.ts +77 -0
  38. package/src/channel.test.ts +128 -0
  39. package/src/channel.ts +1077 -395
  40. package/src/config-schema.ts +6 -0
  41. package/src/config-ui-hints.ts +12 -0
  42. package/src/conversation-store-fs.test.ts +4 -5
  43. package/src/conversation-store-fs.ts +35 -51
  44. package/src/conversation-store-helpers.test.ts +202 -0
  45. package/src/conversation-store-helpers.ts +105 -0
  46. package/src/conversation-store-memory.ts +27 -23
  47. package/src/conversation-store.shared.test.ts +225 -0
  48. package/src/conversation-store.ts +30 -0
  49. package/src/directory-live.test.ts +156 -0
  50. package/src/directory-live.ts +7 -4
  51. package/src/doctor.ts +27 -0
  52. package/src/errors.test.ts +64 -1
  53. package/src/errors.ts +50 -9
  54. package/src/feedback-reflection-prompt.ts +117 -0
  55. package/src/feedback-reflection-store.ts +114 -0
  56. package/src/feedback-reflection.test.ts +237 -0
  57. package/src/feedback-reflection.ts +283 -0
  58. package/src/file-consent-helpers.test.ts +83 -0
  59. package/src/file-consent-helpers.ts +64 -11
  60. package/src/file-consent-invoke.ts +150 -0
  61. package/src/file-consent.test.ts +363 -0
  62. package/src/file-consent.ts +165 -4
  63. package/src/graph-chat.ts +5 -3
  64. package/src/graph-group-management.test.ts +318 -0
  65. package/src/graph-group-management.ts +168 -0
  66. package/src/graph-members.test.ts +89 -0
  67. package/src/graph-members.ts +48 -0
  68. package/src/graph-messages.actions.test.ts +243 -0
  69. package/src/graph-messages.read.test.ts +391 -0
  70. package/src/graph-messages.search.test.ts +213 -0
  71. package/src/graph-messages.test-helpers.ts +50 -0
  72. package/src/graph-messages.ts +534 -0
  73. package/src/graph-teams.test.ts +215 -0
  74. package/src/graph-teams.ts +114 -0
  75. package/src/graph-thread.test.ts +246 -0
  76. package/src/graph-thread.ts +146 -0
  77. package/src/graph-upload.test.ts +161 -4
  78. package/src/graph-upload.ts +147 -56
  79. package/src/graph.test.ts +516 -0
  80. package/src/graph.ts +233 -21
  81. package/src/inbound.test.ts +156 -1
  82. package/src/inbound.ts +101 -1
  83. package/src/media-helpers.ts +1 -1
  84. package/src/mentions.test.ts +27 -18
  85. package/src/mentions.ts +2 -2
  86. package/src/messenger.test.ts +504 -23
  87. package/src/messenger.ts +133 -52
  88. package/src/monitor-handler/access.ts +125 -0
  89. package/src/monitor-handler/inbound-media.test.ts +289 -0
  90. package/src/monitor-handler/inbound-media.ts +57 -5
  91. package/src/monitor-handler/message-handler-mock-support.test-support.ts +28 -0
  92. package/src/monitor-handler/message-handler.authz.test.ts +588 -74
  93. package/src/monitor-handler/message-handler.dm-media.test.ts +54 -0
  94. package/src/monitor-handler/message-handler.test-support.ts +100 -0
  95. package/src/monitor-handler/message-handler.thread-parent.test.ts +223 -0
  96. package/src/monitor-handler/message-handler.thread-session.test.ts +77 -0
  97. package/src/monitor-handler/message-handler.ts +470 -164
  98. package/src/monitor-handler/reaction-handler.test.ts +267 -0
  99. package/src/monitor-handler/reaction-handler.ts +210 -0
  100. package/src/monitor-handler/thread-session.ts +17 -0
  101. package/src/monitor-handler.adaptive-card.test.ts +162 -0
  102. package/src/monitor-handler.feedback-authz.test.ts +314 -0
  103. package/src/monitor-handler.file-consent.test.ts +281 -79
  104. package/src/monitor-handler.sso.test.ts +563 -0
  105. package/src/monitor-handler.test-helpers.ts +180 -0
  106. package/src/monitor-handler.ts +459 -115
  107. package/src/monitor-handler.types.ts +27 -0
  108. package/src/monitor-types.ts +1 -0
  109. package/src/monitor.lifecycle.test.ts +74 -10
  110. package/src/monitor.test.ts +35 -1
  111. package/src/monitor.ts +143 -46
  112. package/src/oauth.flow.ts +77 -0
  113. package/src/oauth.shared.ts +37 -0
  114. package/src/oauth.test.ts +305 -0
  115. package/src/oauth.token.ts +158 -0
  116. package/src/oauth.ts +130 -0
  117. package/src/outbound.test.ts +10 -11
  118. package/src/outbound.ts +62 -44
  119. package/src/pending-uploads-fs.test.ts +246 -0
  120. package/src/pending-uploads-fs.ts +235 -0
  121. package/src/pending-uploads.test.ts +173 -0
  122. package/src/pending-uploads.ts +34 -2
  123. package/src/policy.test.ts +11 -5
  124. package/src/policy.ts +5 -5
  125. package/src/polls.test.ts +106 -5
  126. package/src/polls.ts +15 -7
  127. package/src/presentation.ts +68 -0
  128. package/src/probe.test.ts +27 -8
  129. package/src/probe.ts +43 -9
  130. package/src/reply-dispatcher.test.ts +437 -0
  131. package/src/reply-dispatcher.ts +259 -73
  132. package/src/reply-stream-controller.test.ts +235 -0
  133. package/src/reply-stream-controller.ts +147 -0
  134. package/src/resolve-allowlist.test.ts +105 -1
  135. package/src/resolve-allowlist.ts +112 -7
  136. package/src/runtime.ts +6 -3
  137. package/src/sdk-types.ts +43 -3
  138. package/src/sdk.test.ts +666 -0
  139. package/src/sdk.ts +867 -16
  140. package/src/secret-contract.ts +49 -0
  141. package/src/secret-input.ts +1 -1
  142. package/src/send-context.ts +76 -9
  143. package/src/send.test.ts +389 -5
  144. package/src/send.ts +140 -32
  145. package/src/sent-message-cache.ts +30 -18
  146. package/src/session-route.ts +40 -0
  147. package/src/setup-core.ts +160 -0
  148. package/src/setup-surface.test.ts +202 -0
  149. package/src/setup-surface.ts +320 -0
  150. package/src/sso-token-store.test.ts +72 -0
  151. package/src/sso-token-store.ts +166 -0
  152. package/src/sso.ts +300 -0
  153. package/src/storage.ts +1 -1
  154. package/src/store-fs.ts +2 -2
  155. package/src/streaming-message.test.ts +262 -0
  156. package/src/streaming-message.ts +297 -0
  157. package/src/test-runtime.ts +1 -1
  158. package/src/thread-parent-context.test.ts +224 -0
  159. package/src/thread-parent-context.ts +159 -0
  160. package/src/token.test.ts +237 -50
  161. package/src/token.ts +162 -7
  162. package/src/user-agent.test.ts +86 -0
  163. package/src/user-agent.ts +53 -0
  164. package/src/webhook-timeouts.ts +27 -0
  165. package/src/welcome-card.test.ts +81 -0
  166. package/src/welcome-card.ts +57 -0
  167. package/test-api.ts +1 -0
  168. package/tsconfig.json +16 -0
  169. package/CHANGELOG.md +0 -107
  170. package/src/file-lock.ts +0 -1
  171. package/src/graph-users.test.ts +0 -66
  172. package/src/onboarding.ts +0 -381
  173. package/src/polls-store.test.ts +0 -38
  174. package/src/revoked-context.test.ts +0 -39
  175. package/src/token-response.test.ts +0 -23
@@ -0,0 +1,225 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
6
+ import { createMSTeamsConversationStoreMemory } from "./conversation-store-memory.js";
7
+ import type { MSTeamsConversationStore } from "./conversation-store.js";
8
+ import { setMSTeamsRuntime } from "./runtime.js";
9
+ import { msteamsRuntimeStub } from "./test-runtime.js";
10
+
11
+ type StoreFactory = {
12
+ name: string;
13
+ createStore: () => Promise<MSTeamsConversationStore>;
14
+ };
15
+
16
+ const storeFactories: StoreFactory[] = [
17
+ {
18
+ name: "fs",
19
+ createStore: async () => {
20
+ const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-store-"));
21
+ return createMSTeamsConversationStoreFs({
22
+ env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
23
+ ttlMs: 60_000,
24
+ });
25
+ },
26
+ },
27
+ {
28
+ name: "memory",
29
+ createStore: async () => createMSTeamsConversationStoreMemory(),
30
+ },
31
+ ];
32
+
33
+ describe.each(storeFactories)("msteams conversation store ($name)", ({ createStore }) => {
34
+ beforeEach(() => {
35
+ setMSTeamsRuntime(msteamsRuntimeStub);
36
+ });
37
+
38
+ it("normalizes conversation ids consistently", async () => {
39
+ const store = await createStore();
40
+
41
+ await store.upsert("conv-norm;messageid=123", {
42
+ conversation: { id: "conv-norm" },
43
+ channelId: "msteams",
44
+ serviceUrl: "https://service.example.com",
45
+ user: { id: "u1" },
46
+ });
47
+
48
+ await expect(store.get("conv-norm")).resolves.toEqual(
49
+ expect.objectContaining({
50
+ conversation: { id: "conv-norm" },
51
+ }),
52
+ );
53
+ await expect(store.remove("conv-norm")).resolves.toBe(true);
54
+ await expect(store.get("conv-norm;messageid=123")).resolves.toBeNull();
55
+ });
56
+
57
+ it("upserts, lists, removes, and resolves users by both AAD and Bot Framework ids", async () => {
58
+ const store = await createStore();
59
+
60
+ await store.upsert("conv-a", {
61
+ conversation: { id: "conv-a" },
62
+ channelId: "msteams",
63
+ serviceUrl: "https://service.example.com",
64
+ user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" },
65
+ });
66
+
67
+ await store.upsert("conv-b", {
68
+ conversation: { id: "conv-b" },
69
+ channelId: "msteams",
70
+ serviceUrl: "https://service.example.com",
71
+ user: { id: "user-b", aadObjectId: "aad-b", name: "Bob" },
72
+ });
73
+
74
+ await expect(store.get("conv-a")).resolves.toEqual({
75
+ conversation: { id: "conv-a" },
76
+ channelId: "msteams",
77
+ serviceUrl: "https://service.example.com",
78
+ user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" },
79
+ lastSeenAt: expect.any(String),
80
+ });
81
+
82
+ await expect(store.list()).resolves.toEqual([
83
+ {
84
+ conversationId: "conv-a",
85
+ reference: {
86
+ conversation: { id: "conv-a" },
87
+ channelId: "msteams",
88
+ serviceUrl: "https://service.example.com",
89
+ user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" },
90
+ lastSeenAt: expect.any(String),
91
+ },
92
+ },
93
+ {
94
+ conversationId: "conv-b",
95
+ reference: {
96
+ conversation: { id: "conv-b" },
97
+ channelId: "msteams",
98
+ serviceUrl: "https://service.example.com",
99
+ user: { id: "user-b", aadObjectId: "aad-b", name: "Bob" },
100
+ lastSeenAt: expect.any(String),
101
+ },
102
+ },
103
+ ]);
104
+
105
+ await expect(store.findPreferredDmByUserId(" aad-b ")).resolves.toEqual({
106
+ conversationId: "conv-b",
107
+ reference: {
108
+ conversation: { id: "conv-b" },
109
+ channelId: "msteams",
110
+ serviceUrl: "https://service.example.com",
111
+ user: { id: "user-b", aadObjectId: "aad-b", name: "Bob" },
112
+ lastSeenAt: expect.any(String),
113
+ },
114
+ });
115
+ await expect(store.findPreferredDmByUserId("user-a")).resolves.toEqual({
116
+ conversationId: "conv-a",
117
+ reference: {
118
+ conversation: { id: "conv-a" },
119
+ channelId: "msteams",
120
+ serviceUrl: "https://service.example.com",
121
+ user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" },
122
+ lastSeenAt: expect.any(String),
123
+ },
124
+ });
125
+ await expect(store.findByUserId("user-a")).resolves.toEqual(
126
+ await store.findPreferredDmByUserId("user-a"),
127
+ );
128
+ await expect(store.findPreferredDmByUserId(" ")).resolves.toBeNull();
129
+
130
+ await expect(store.remove("conv-a")).resolves.toBe(true);
131
+ await expect(store.get("conv-a")).resolves.toBeNull();
132
+ await expect(store.remove("missing")).resolves.toBe(false);
133
+ });
134
+
135
+ it("preserves existing timezone when upsert omits timezone", async () => {
136
+ const store = await createStore();
137
+
138
+ await store.upsert("conv-tz", {
139
+ conversation: { id: "conv-tz" },
140
+ channelId: "msteams",
141
+ serviceUrl: "https://service.example.com",
142
+ user: { id: "u1" },
143
+ timezone: "Europe/London",
144
+ });
145
+
146
+ await store.upsert("conv-tz", {
147
+ conversation: { id: "conv-tz" },
148
+ channelId: "msteams",
149
+ serviceUrl: "https://service.example.com",
150
+ user: { id: "u1" },
151
+ });
152
+
153
+ await expect(store.get("conv-tz")).resolves.toMatchObject({
154
+ timezone: "Europe/London",
155
+ });
156
+ });
157
+
158
+ it("preserves graphChatId across upserts that omit it", async () => {
159
+ const store = await createStore();
160
+
161
+ await store.upsert("conv-graph", {
162
+ conversation: { id: "conv-graph", conversationType: "personal" },
163
+ channelId: "msteams",
164
+ serviceUrl: "https://service.example.com",
165
+ user: { id: "u1" },
166
+ graphChatId: "19:resolved-chat-id@unq.gbl.spaces",
167
+ });
168
+
169
+ // Second upsert without graphChatId (normal activity-based upsert)
170
+ await store.upsert("conv-graph", {
171
+ conversation: { id: "conv-graph", conversationType: "personal" },
172
+ channelId: "msteams",
173
+ serviceUrl: "https://service.example.com",
174
+ user: { id: "u1" },
175
+ });
176
+
177
+ await expect(store.get("conv-graph")).resolves.toMatchObject({
178
+ graphChatId: "19:resolved-chat-id@unq.gbl.spaces",
179
+ });
180
+ });
181
+
182
+ it("prefers the freshest personal conversation for repeated upserts of the same user", async () => {
183
+ const store = await createStore();
184
+
185
+ vi.useFakeTimers();
186
+ try {
187
+ vi.setSystemTime(new Date("2026-03-25T20:00:00.000Z"));
188
+ await store.upsert("dm-old", {
189
+ conversation: { id: "dm-old", conversationType: "personal" },
190
+ channelId: "msteams",
191
+ serviceUrl: "https://service.example.com",
192
+ user: { id: "user-shared-old", aadObjectId: "aad-shared", name: "Old DM" },
193
+ });
194
+
195
+ vi.setSystemTime(new Date("2026-03-25T20:30:00.000Z"));
196
+ await store.upsert("group-shared", {
197
+ conversation: { id: "group-shared", conversationType: "groupChat" },
198
+ channelId: "msteams",
199
+ serviceUrl: "https://service.example.com",
200
+ user: { id: "user-shared-group", aadObjectId: "aad-shared", name: "Group" },
201
+ });
202
+
203
+ vi.setSystemTime(new Date("2026-03-25T21:00:00.000Z"));
204
+ await store.upsert("dm-new", {
205
+ conversation: { id: "dm-new", conversationType: "personal" },
206
+ channelId: "msteams",
207
+ serviceUrl: "https://service.example.com",
208
+ user: { id: "user-shared-new", aadObjectId: "aad-shared", name: "New DM" },
209
+ });
210
+
211
+ await expect(store.findPreferredDmByUserId("aad-shared")).resolves.toEqual({
212
+ conversationId: "dm-new",
213
+ reference: {
214
+ conversation: { id: "dm-new", conversationType: "personal" },
215
+ channelId: "msteams",
216
+ serviceUrl: "https://service.example.com",
217
+ user: { id: "user-shared-new", aadObjectId: "aad-shared", name: "New DM" },
218
+ lastSeenAt: "2026-03-25T21:00:00.000Z",
219
+ },
220
+ });
221
+ } finally {
222
+ vi.useRealTimers();
223
+ }
224
+ });
225
+ });
@@ -7,8 +7,12 @@
7
7
 
8
8
  /** Minimal ConversationReference shape for proactive messaging */
9
9
  export type StoredConversationReference = {
10
+ /** Timestamp when this reference was last seen/updated. */
11
+ lastSeenAt?: string;
10
12
  /** Activity ID from the last message */
11
13
  activityId?: string;
14
+ /** Channel thread root activity ID for threaded replies. */
15
+ threadId?: string;
12
16
  /** User who sent the message */
13
17
  user?: { id?: string; name?: string; aadObjectId?: string };
14
18
  /** Agent/bot that received the message */
@@ -17,6 +21,20 @@ export type StoredConversationReference = {
17
21
  bot?: { id?: string; name?: string };
18
22
  /** Conversation details */
19
23
  conversation?: { id?: string; conversationType?: string; tenantId?: string };
24
+ /**
25
+ * Tenant ID sourced from `activity.channelData.tenant.id` at inbound time.
26
+ * Bot Framework requires this on outbound proactive messages so the connector
27
+ * can route them to the correct Azure AD tenant; without it, the connector
28
+ * rejects the request with HTTP 403. For channel activities, `conversation.tenantId`
29
+ * is often unset, making `channelData.tenant.id` the reliable source.
30
+ */
31
+ tenantId?: string;
32
+ /**
33
+ * Azure AD object ID of the user who sent the last inbound activity,
34
+ * mirrored from `activity.from.aadObjectId` so outbound proactive sends
35
+ * can include it on the connector request (required for personal DMs).
36
+ */
37
+ aadObjectId?: string;
20
38
  /** Team ID for channel messages (when available). */
21
39
  teamId?: string;
22
40
  /** Channel ID (usually "msteams") */
@@ -25,6 +43,15 @@ export type StoredConversationReference = {
25
43
  serviceUrl?: string;
26
44
  /** Locale */
27
45
  locale?: string;
46
+ /**
47
+ * Cached Graph API chat ID (format: `19:xxx@thread.tacv2` or `19:xxx@unq.gbl.spaces`).
48
+ * Bot Framework conversation IDs for personal DMs use a different format (`a:1xxx` or
49
+ * `8:orgid:xxx`) that the Graph API does not accept. This field caches the resolved
50
+ * Graph-native chat ID so we don't need to re-query the API on every send.
51
+ */
52
+ graphChatId?: string;
53
+ /** IANA timezone from Teams clientInfo entity (e.g. "America/New_York") */
54
+ timezone?: string;
28
55
  };
29
56
 
30
57
  export type MSTeamsConversationStoreEntry = {
@@ -37,5 +64,8 @@ export type MSTeamsConversationStore = {
37
64
  get: (conversationId: string) => Promise<StoredConversationReference | null>;
38
65
  list: () => Promise<MSTeamsConversationStoreEntry[]>;
39
66
  remove: (conversationId: string) => Promise<boolean>;
67
+ /** Person-targeted proactive lookup: prefer the freshest personal DM reference. */
68
+ findPreferredDmByUserId: (id: string) => Promise<MSTeamsConversationStoreEntry | null>;
69
+ /** @deprecated Use `findPreferredDmByUserId` for proactive user-targeted sends. */
40
70
  findByUserId: (id: string) => Promise<MSTeamsConversationStoreEntry | null>;
41
71
  };
@@ -0,0 +1,156 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const {
4
+ searchGraphUsersMock,
5
+ listTeamsByNameMock,
6
+ listChannelsForTeamMock,
7
+ normalizeQueryMock,
8
+ resolveGraphTokenMock,
9
+ } = vi.hoisted(() => {
10
+ return {
11
+ searchGraphUsersMock: vi.fn(),
12
+ listTeamsByNameMock: vi.fn(),
13
+ listChannelsForTeamMock: vi.fn(),
14
+ normalizeQueryMock: vi.fn((value?: string | null) => value?.trim() ?? ""),
15
+ resolveGraphTokenMock: vi.fn(),
16
+ };
17
+ });
18
+
19
+ vi.mock("./graph-users.js", () => {
20
+ return { searchGraphUsers: searchGraphUsersMock };
21
+ });
22
+
23
+ vi.mock("./graph.js", () => {
24
+ return {
25
+ listTeamsByName: listTeamsByNameMock,
26
+ listChannelsForTeam: listChannelsForTeamMock,
27
+ normalizeQuery: normalizeQueryMock,
28
+ resolveGraphToken: resolveGraphTokenMock,
29
+ };
30
+ });
31
+
32
+ import { listMSTeamsDirectoryGroupsLive, listMSTeamsDirectoryPeersLive } from "./directory-live.js";
33
+
34
+ describe("msteams directory live", () => {
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ normalizeQueryMock.mockImplementation((value?: string | null) => value?.trim() ?? "");
38
+ });
39
+
40
+ it("returns normalized peer entries and skips users without ids", async () => {
41
+ resolveGraphTokenMock.mockResolvedValue("graph-token");
42
+ searchGraphUsersMock.mockResolvedValue([
43
+ {
44
+ id: "user-1",
45
+ displayName: "Alice",
46
+ userPrincipalName: "alice@example.com",
47
+ },
48
+ {
49
+ id: "user-2",
50
+ displayName: "Bob",
51
+ mail: "bob@example.com",
52
+ },
53
+ {
54
+ displayName: "Missing Id",
55
+ },
56
+ ]);
57
+
58
+ await expect(
59
+ listMSTeamsDirectoryPeersLive({
60
+ cfg: {},
61
+ query: " ali ",
62
+ }),
63
+ ).resolves.toEqual([
64
+ {
65
+ kind: "user",
66
+ id: "user:user-1",
67
+ name: "Alice",
68
+ handle: "@alice@example.com",
69
+ raw: {
70
+ id: "user-1",
71
+ displayName: "Alice",
72
+ userPrincipalName: "alice@example.com",
73
+ },
74
+ },
75
+ {
76
+ kind: "user",
77
+ id: "user:user-2",
78
+ name: "Bob",
79
+ handle: "@bob@example.com",
80
+ raw: {
81
+ id: "user-2",
82
+ displayName: "Bob",
83
+ mail: "bob@example.com",
84
+ },
85
+ },
86
+ ]);
87
+
88
+ expect(searchGraphUsersMock).toHaveBeenCalledWith({
89
+ token: "graph-token",
90
+ query: "ali",
91
+ top: 20,
92
+ });
93
+ });
94
+
95
+ it("returns team entries without channel queries and honors limits", async () => {
96
+ resolveGraphTokenMock.mockResolvedValue("graph-token");
97
+ listTeamsByNameMock.mockResolvedValue([
98
+ { id: "team-1", displayName: "Platform" },
99
+ { id: "team-2", displayName: "Infra" },
100
+ ]);
101
+
102
+ await expect(
103
+ listMSTeamsDirectoryGroupsLive({
104
+ cfg: {},
105
+ query: "platform",
106
+ limit: 1,
107
+ }),
108
+ ).resolves.toEqual([
109
+ {
110
+ kind: "group",
111
+ id: "team:team-1",
112
+ name: "Platform",
113
+ handle: "#Platform",
114
+ raw: { id: "team-1", displayName: "Platform" },
115
+ },
116
+ ]);
117
+ });
118
+
119
+ it("searches channels within matching teams when a team/channel query is used", async () => {
120
+ resolveGraphTokenMock.mockResolvedValue("graph-token");
121
+ listTeamsByNameMock.mockResolvedValue([
122
+ { id: "team-1", displayName: "Platform" },
123
+ { id: "team-2", displayName: "Infra" },
124
+ ]);
125
+ listChannelsForTeamMock
126
+ .mockResolvedValueOnce([
127
+ { id: "chan-1", displayName: "Deployments" },
128
+ { id: "chan-2", displayName: "General" },
129
+ ])
130
+ .mockResolvedValueOnce([{ id: "chan-3", displayName: "Deployments-West" }]);
131
+
132
+ await expect(
133
+ listMSTeamsDirectoryGroupsLive({
134
+ cfg: {},
135
+ query: "plat / deploy",
136
+ }),
137
+ ).resolves.toEqual([
138
+ {
139
+ kind: "group",
140
+ id: "conversation:chan-1",
141
+ name: "Platform/Deployments",
142
+ handle: "#Deployments",
143
+ raw: { id: "chan-1", displayName: "Deployments" },
144
+ },
145
+ {
146
+ kind: "group",
147
+ id: "conversation:chan-3",
148
+ name: "Infra/Deployments-West",
149
+ handle: "#Deployments-West",
150
+ raw: { id: "chan-3", displayName: "Deployments-West" },
151
+ },
152
+ ]);
153
+
154
+ expect(listTeamsByNameMock).toHaveBeenCalledWith("graph-token", "plat");
155
+ });
156
+ });
@@ -1,8 +1,7 @@
1
- import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/msteams";
1
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
2
+ import type { ChannelDirectoryEntry } from "../runtime-api.js";
2
3
  import { searchGraphUsers } from "./graph-users.js";
3
4
  import {
4
- type GraphChannel,
5
- type GraphGroup,
6
5
  listChannelsForTeam,
7
6
  listTeamsByName,
8
7
  normalizeQuery,
@@ -88,7 +87,11 @@ export async function listMSTeamsDirectoryGroupsLive(params: {
88
87
  if (!name) {
89
88
  continue;
90
89
  }
91
- if (!name.toLowerCase().includes(channelQuery.toLowerCase())) {
90
+ if (
91
+ !normalizeLowercaseStringOrEmpty(name).includes(
92
+ normalizeLowercaseStringOrEmpty(channelQuery),
93
+ )
94
+ ) {
92
95
  continue;
93
96
  }
94
97
  results.push({
package/src/doctor.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
2
+
3
+ function isMSTeamsMutableAllowEntry(raw: string): boolean {
4
+ const text = raw.trim();
5
+ if (!text || text === "*") {
6
+ return false;
7
+ }
8
+
9
+ const withoutPrefix = text.replace(/^(msteams|user):/i, "").trim();
10
+ return /\s/.test(withoutPrefix) || withoutPrefix.includes("@");
11
+ }
12
+
13
+ export const collectMSTeamsMutableAllowlistWarnings =
14
+ createDangerousNameMatchingMutableAllowlistWarningCollector({
15
+ channel: "msteams",
16
+ detector: isMSTeamsMutableAllowEntry,
17
+ collectLists: (scope) => [
18
+ {
19
+ pathLabel: `${scope.prefix}.allowFrom`,
20
+ list: scope.account.allowFrom,
21
+ },
22
+ {
23
+ pathLabel: `${scope.prefix}.groupAllowFrom`,
24
+ list: scope.account.groupAllowFrom,
25
+ },
26
+ ],
27
+ });
@@ -1,10 +1,11 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  classifyMSTeamsSendError,
4
4
  formatMSTeamsSendErrorHint,
5
5
  formatUnknownError,
6
6
  isRevokedProxyError,
7
7
  } from "./errors.js";
8
+ import { withRevokedProxyFallback } from "./revoked-context.js";
8
9
 
9
10
  describe("msteams errors", () => {
10
11
  it("formats unknown errors", () => {
@@ -17,6 +18,25 @@ describe("msteams errors", () => {
17
18
  expect(classifyMSTeamsSendError({ statusCode: 403 }).kind).toBe("auth");
18
19
  });
19
20
 
21
+ it("classifies ContentStreamNotAllowed as permanent instead of auth", () => {
22
+ expect(
23
+ classifyMSTeamsSendError({
24
+ statusCode: 403,
25
+ response: {
26
+ body: {
27
+ error: {
28
+ code: "ContentStreamNotAllowed",
29
+ },
30
+ },
31
+ },
32
+ }),
33
+ ).toMatchObject({
34
+ kind: "permanent",
35
+ statusCode: 403,
36
+ errorCode: "ContentStreamNotAllowed",
37
+ });
38
+ });
39
+
20
40
  it("classifies throttling errors and parses retry-after", () => {
21
41
  expect(classifyMSTeamsSendError({ statusCode: 429, retryAfter: "1.5" })).toMatchObject({
22
42
  kind: "throttled",
@@ -42,6 +62,12 @@ describe("msteams errors", () => {
42
62
  it("provides actionable hints for common cases", () => {
43
63
  expect(formatMSTeamsSendErrorHint({ kind: "auth" })).toContain("msteams");
44
64
  expect(formatMSTeamsSendErrorHint({ kind: "throttled" })).toContain("throttled");
65
+ expect(
66
+ formatMSTeamsSendErrorHint({
67
+ kind: "permanent",
68
+ errorCode: "ContentStreamNotAllowed",
69
+ }),
70
+ ).toContain("expired the content stream");
45
71
  });
46
72
 
47
73
  describe("isRevokedProxyError", () => {
@@ -67,4 +93,41 @@ describe("msteams errors", () => {
67
93
  expect(isRevokedProxyError("proxy that has been revoked")).toBe(false);
68
94
  });
69
95
  });
96
+
97
+ describe("withRevokedProxyFallback", () => {
98
+ it("returns primary result when no error occurs", async () => {
99
+ await expect(
100
+ withRevokedProxyFallback({
101
+ run: async () => "ok",
102
+ onRevoked: async () => "fallback",
103
+ }),
104
+ ).resolves.toBe("ok");
105
+ });
106
+
107
+ it("uses fallback when proxy-revoked TypeError is thrown", async () => {
108
+ const onRevokedLog = vi.fn();
109
+ await expect(
110
+ withRevokedProxyFallback({
111
+ run: async () => {
112
+ throw new TypeError("Cannot perform 'get' on a proxy that has been revoked");
113
+ },
114
+ onRevoked: async () => "fallback",
115
+ onRevokedLog,
116
+ }),
117
+ ).resolves.toBe("fallback");
118
+ expect(onRevokedLog).toHaveBeenCalledOnce();
119
+ });
120
+
121
+ it("rethrows non-revoked errors", async () => {
122
+ const err = Object.assign(new Error("boom"), { statusCode: 500 });
123
+ await expect(
124
+ withRevokedProxyFallback({
125
+ run: async () => {
126
+ throw err;
127
+ },
128
+ onRevoked: async () => "fallback",
129
+ }),
130
+ ).rejects.toBe(err);
131
+ });
132
+ });
70
133
  });