@kodelyth/msteams 2026.5.39 → 2026.5.42

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 (208) 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/dist/api.js +3 -0
  7. package/dist/channel-BvTXHuGs.js +1161 -0
  8. package/dist/channel-config-api.js +2 -0
  9. package/dist/channel-plugin-api.js +2 -0
  10. package/dist/channel.runtime-NssGKZm5.js +650 -0
  11. package/dist/config-schema-Btk-XCOd.js +43 -0
  12. package/dist/contract-api.js +2 -0
  13. package/dist/graph-users-D-gKCguI.js +1411 -0
  14. package/dist/index.js +22 -0
  15. package/dist/oauth-BUxlphX3.js +114 -0
  16. package/dist/oauth.token-ebId9946.js +116 -0
  17. package/dist/probe-Cj2KsAGF.js +2190 -0
  18. package/dist/runtime-api-BL4DOWXD.js +28 -0
  19. package/dist/runtime-api.js +2 -0
  20. package/dist/secret-contract-Bo7kdUrT.js +35 -0
  21. package/dist/secret-contract-api.js +2 -0
  22. package/dist/setup-entry.js +15 -0
  23. package/dist/setup-plugin-api.js +64 -0
  24. package/dist/setup-surface-COTQDcTQ.js +531 -0
  25. package/dist/src-tvpsGYPV.js +4226 -0
  26. package/dist/test-api.js +2 -0
  27. package/index.ts +20 -0
  28. package/klaw.plugin.json +2 -726
  29. package/package.json +4 -4
  30. package/runtime-api.ts +66 -0
  31. package/secret-contract-api.ts +5 -0
  32. package/setup-entry.ts +13 -0
  33. package/setup-plugin-api.ts +3 -0
  34. package/src/ai-entity.ts +7 -0
  35. package/src/approval-auth.ts +44 -0
  36. package/src/attachments/bot-framework.test.ts +506 -0
  37. package/src/attachments/bot-framework.ts +348 -0
  38. package/src/attachments/download.ts +328 -0
  39. package/src/attachments/graph.test.ts +441 -0
  40. package/src/attachments/graph.ts +489 -0
  41. package/src/attachments/html.ts +122 -0
  42. package/src/attachments/payload.ts +14 -0
  43. package/src/attachments/remote-media.test.ts +187 -0
  44. package/src/attachments/remote-media.ts +86 -0
  45. package/src/attachments/shared.test.ts +547 -0
  46. package/src/attachments/shared.ts +655 -0
  47. package/src/attachments/types.ts +47 -0
  48. package/src/attachments.graph.test.ts +414 -0
  49. package/src/attachments.helpers.test.ts +245 -0
  50. package/src/attachments.test-helpers.ts +17 -0
  51. package/src/attachments.test.ts +754 -0
  52. package/src/attachments.ts +18 -0
  53. package/src/block-streaming-config.test.ts +61 -0
  54. package/src/channel-api.ts +1 -0
  55. package/src/channel.actions.test.ts +797 -0
  56. package/src/channel.directory.test.ts +176 -0
  57. package/src/channel.message-adapter.test.ts +227 -0
  58. package/src/channel.runtime.ts +56 -0
  59. package/src/channel.setup.ts +77 -0
  60. package/src/channel.test.ts +136 -0
  61. package/src/channel.ts +1176 -0
  62. package/src/config-schema.ts +6 -0
  63. package/src/config-ui-hints.ts +40 -0
  64. package/src/conversation-store-fs.test.ts +81 -0
  65. package/src/conversation-store-fs.ts +149 -0
  66. package/src/conversation-store-helpers.test.ts +202 -0
  67. package/src/conversation-store-helpers.ts +105 -0
  68. package/src/conversation-store-memory.ts +51 -0
  69. package/src/conversation-store.shared.test.ts +260 -0
  70. package/src/conversation-store.ts +71 -0
  71. package/src/directory-live.test.ts +156 -0
  72. package/src/directory-live.ts +111 -0
  73. package/src/doctor.ts +27 -0
  74. package/src/errors.test.ts +154 -0
  75. package/src/errors.ts +270 -0
  76. package/src/feedback-reflection-prompt.ts +117 -0
  77. package/src/feedback-reflection-store.ts +113 -0
  78. package/src/feedback-reflection.test.ts +237 -0
  79. package/src/feedback-reflection.ts +268 -0
  80. package/src/file-consent-helpers.test.ts +328 -0
  81. package/src/file-consent-helpers.ts +115 -0
  82. package/src/file-consent-invoke.ts +150 -0
  83. package/src/file-consent.test.ts +378 -0
  84. package/src/file-consent.ts +223 -0
  85. package/src/graph-chat.ts +36 -0
  86. package/src/graph-group-management.test.ts +332 -0
  87. package/src/graph-group-management.ts +168 -0
  88. package/src/graph-members.test.ts +89 -0
  89. package/src/graph-members.ts +48 -0
  90. package/src/graph-messages.actions.test.ts +253 -0
  91. package/src/graph-messages.read.test.ts +391 -0
  92. package/src/graph-messages.search.test.ts +227 -0
  93. package/src/graph-messages.test-helpers.ts +50 -0
  94. package/src/graph-messages.ts +534 -0
  95. package/src/graph-teams.test.ts +222 -0
  96. package/src/graph-teams.ts +114 -0
  97. package/src/graph-thread.test.ts +252 -0
  98. package/src/graph-thread.ts +146 -0
  99. package/src/graph-upload.test.ts +253 -0
  100. package/src/graph-upload.ts +531 -0
  101. package/src/graph-users.ts +29 -0
  102. package/src/graph.test.ts +540 -0
  103. package/src/graph.ts +308 -0
  104. package/src/inbound.test.ts +221 -0
  105. package/src/inbound.ts +148 -0
  106. package/src/index.ts +4 -0
  107. package/src/media-helpers.test.ts +220 -0
  108. package/src/media-helpers.ts +105 -0
  109. package/src/mentions.test.ts +254 -0
  110. package/src/mentions.ts +114 -0
  111. package/src/messenger.test.ts +961 -0
  112. package/src/messenger.ts +608 -0
  113. package/src/monitor-handler/access.ts +136 -0
  114. package/src/monitor-handler/inbound-media.test.ts +314 -0
  115. package/src/monitor-handler/inbound-media.ts +180 -0
  116. package/src/monitor-handler/message-handler-mock-support.test-support.ts +28 -0
  117. package/src/monitor-handler/message-handler.authz.test.ts +739 -0
  118. package/src/monitor-handler/message-handler.dm-media.test.ts +54 -0
  119. package/src/monitor-handler/message-handler.test-support.ts +99 -0
  120. package/src/monitor-handler/message-handler.thread-parent.test.ts +225 -0
  121. package/src/monitor-handler/message-handler.thread-session.test.ts +132 -0
  122. package/src/monitor-handler/message-handler.ts +1003 -0
  123. package/src/monitor-handler/reaction-handler.test.ts +325 -0
  124. package/src/monitor-handler/reaction-handler.ts +122 -0
  125. package/src/monitor-handler/thread-session.ts +30 -0
  126. package/src/monitor-handler.adaptive-card.test.ts +158 -0
  127. package/src/monitor-handler.feedback-authz.test.ts +357 -0
  128. package/src/monitor-handler.file-consent.test.ts +443 -0
  129. package/src/monitor-handler.sso.test.ts +576 -0
  130. package/src/monitor-handler.test-helpers.ts +181 -0
  131. package/src/monitor-handler.ts +538 -0
  132. package/src/monitor-handler.types.ts +27 -0
  133. package/src/monitor-types.ts +6 -0
  134. package/src/monitor.lifecycle.test.ts +457 -0
  135. package/src/monitor.test.ts +119 -0
  136. package/src/monitor.ts +476 -0
  137. package/src/oauth.flow.ts +77 -0
  138. package/src/oauth.shared.ts +37 -0
  139. package/src/oauth.test.ts +350 -0
  140. package/src/oauth.token.ts +162 -0
  141. package/src/oauth.ts +130 -0
  142. package/src/outbound.test.ts +400 -0
  143. package/src/outbound.ts +198 -0
  144. package/src/pending-uploads-fs.test.ts +261 -0
  145. package/src/pending-uploads-fs.ts +235 -0
  146. package/src/pending-uploads.test.ts +186 -0
  147. package/src/pending-uploads.ts +121 -0
  148. package/src/policy.test.ts +156 -0
  149. package/src/policy.ts +245 -0
  150. package/src/polls-store-memory.ts +32 -0
  151. package/src/polls.test.ts +169 -0
  152. package/src/polls.ts +312 -0
  153. package/src/presentation.ts +93 -0
  154. package/src/probe.test.ts +79 -0
  155. package/src/probe.ts +132 -0
  156. package/src/reply-dispatcher.test.ts +543 -0
  157. package/src/reply-dispatcher.ts +523 -0
  158. package/src/reply-stream-controller.test.ts +424 -0
  159. package/src/reply-stream-controller.ts +334 -0
  160. package/src/resolve-allowlist.test.ts +253 -0
  161. package/src/resolve-allowlist.ts +309 -0
  162. package/src/revoked-context.ts +17 -0
  163. package/src/runtime.ts +12 -0
  164. package/src/sdk-types.ts +59 -0
  165. package/src/sdk.test.ts +727 -0
  166. package/src/sdk.ts +916 -0
  167. package/src/secret-contract.ts +49 -0
  168. package/src/secret-input.ts +7 -0
  169. package/src/send-context.test.ts +93 -0
  170. package/src/send-context.ts +269 -0
  171. package/src/send.test.ts +588 -0
  172. package/src/send.ts +697 -0
  173. package/src/sent-message-cache.test.ts +106 -0
  174. package/src/sent-message-cache.ts +174 -0
  175. package/src/session-route.ts +40 -0
  176. package/src/setup-core.ts +162 -0
  177. package/src/setup-surface.test.ts +175 -0
  178. package/src/setup-surface.ts +319 -0
  179. package/src/sso-token-store.test.ts +74 -0
  180. package/src/sso-token-store.ts +166 -0
  181. package/src/sso.ts +300 -0
  182. package/src/storage.ts +25 -0
  183. package/src/store-fs.ts +42 -0
  184. package/src/streaming-message.test.ts +323 -0
  185. package/src/streaming-message.ts +327 -0
  186. package/src/test-runtime.ts +16 -0
  187. package/src/thread-parent-context.test.ts +224 -0
  188. package/src/thread-parent-context.ts +159 -0
  189. package/src/token-response.ts +11 -0
  190. package/src/token.test.ts +268 -0
  191. package/src/token.ts +194 -0
  192. package/src/user-agent.test.ts +121 -0
  193. package/src/user-agent.ts +53 -0
  194. package/src/webhook-timeouts.ts +27 -0
  195. package/src/welcome-card.test.ts +104 -0
  196. package/src/welcome-card.ts +57 -0
  197. package/test-api.ts +1 -0
  198. package/tsconfig.json +16 -0
  199. package/api.js +0 -7
  200. package/channel-config-api.js +0 -7
  201. package/channel-plugin-api.js +0 -7
  202. package/contract-api.js +0 -7
  203. package/index.js +0 -7
  204. package/runtime-api.js +0 -7
  205. package/secret-contract-api.js +0 -7
  206. package/setup-entry.js +0 -7
  207. package/setup-plugin-api.js +0 -7
  208. package/test-api.js +0 -7
@@ -0,0 +1,334 @@
1
+ import {
2
+ createLiveMessageState,
3
+ createPreviewMessageReceipt,
4
+ defineFinalizableLivePreviewAdapter,
5
+ deliverWithFinalizableLivePreviewAdapter,
6
+ markLiveMessageFinalized,
7
+ type LiveMessageState,
8
+ } from "klaw/plugin-sdk/channel-message";
9
+ import {
10
+ createChannelProgressDraftGate,
11
+ type ChannelProgressDraftLine,
12
+ formatChannelProgressDraftText,
13
+ isChannelProgressDraftWorkToolName,
14
+ mergeChannelProgressDraftLine,
15
+ normalizeChannelProgressDraftLineIdentity,
16
+ resolveChannelPreviewStreamMode,
17
+ resolveChannelProgressDraftMaxLines,
18
+ resolveChannelProgressDraftLabel,
19
+ resolveChannelStreamingPreviewToolProgress,
20
+ } from "klaw/plugin-sdk/channel-streaming";
21
+ import { normalizeOptionalLowercaseString } from "klaw/plugin-sdk/string-coerce-runtime";
22
+ import type { MSTeamsConfig, ReplyPayload } from "../runtime-api.js";
23
+ import { formatUnknownError } from "./errors.js";
24
+ import type { MSTeamsMonitorLogger } from "./monitor-types.js";
25
+ import type { MSTeamsTurnContext } from "./sdk-types.js";
26
+ import { TeamsHttpStream } from "./streaming-message.js";
27
+
28
+ // Local generic wrapper to defer union resolution. Works around a
29
+ // single-file-mode limitation in the type-aware lint where imported
30
+ // types resolved via extension runtime-api barrels are treated as
31
+ // `error` (acting as `any`) and trip `no-redundant-type-constituents`
32
+ // when combined with `undefined` in a union.
33
+ type Maybe<T> = T | undefined;
34
+
35
+ export function pickInformativeStatusText(
36
+ params: { config?: MSTeamsConfig; seed?: string; random?: () => number } | (() => number) = {},
37
+ ): string | undefined {
38
+ const options = typeof params === "function" ? { random: params } : params;
39
+ return resolveChannelProgressDraftLabel({
40
+ entry: options.config,
41
+ seed: options.seed,
42
+ random: options.random,
43
+ });
44
+ }
45
+
46
+ export function createTeamsReplyStreamController(params: {
47
+ conversationType?: string;
48
+ context: MSTeamsTurnContext;
49
+ feedbackLoopEnabled: boolean;
50
+ log: MSTeamsMonitorLogger;
51
+ msteamsConfig?: MSTeamsConfig;
52
+ progressSeed?: string;
53
+ random?: () => number;
54
+ }) {
55
+ const isPersonal = normalizeOptionalLowercaseString(params.conversationType) === "personal";
56
+ const streamMode = resolveChannelPreviewStreamMode(params.msteamsConfig, "partial");
57
+ const shouldUseNativeStream =
58
+ isPersonal && (streamMode === "partial" || streamMode === "progress");
59
+ const shouldSuppressDefaultToolProgressMessages =
60
+ shouldUseNativeStream && streamMode === "progress";
61
+ const shouldStreamPreviewToolProgress =
62
+ shouldSuppressDefaultToolProgressMessages &&
63
+ resolveChannelStreamingPreviewToolProgress(params.msteamsConfig);
64
+ const stream = shouldUseNativeStream
65
+ ? new TeamsHttpStream({
66
+ sendActivity: (activity) => params.context.sendActivity(activity),
67
+ feedbackLoopEnabled: params.feedbackLoopEnabled,
68
+ onError: (err) => {
69
+ params.log.debug?.(`stream error: ${formatUnknownError(err)}`);
70
+ },
71
+ })
72
+ : undefined;
73
+
74
+ let streamReceivedTokens = false;
75
+ let informativeUpdateSent = false;
76
+ let progressLines: Array<string | ChannelProgressDraftLine> = [];
77
+ let lastInformativeText = "";
78
+ let pendingFinalize: Promise<void> | undefined;
79
+ let liveState: LiveMessageState<ReplyPayload> = createLiveMessageState({
80
+ canFinalizeInPlace: Boolean(stream),
81
+ });
82
+
83
+ const markStreamFinalized = () => {
84
+ if (!stream || stream.isFailed) {
85
+ return;
86
+ }
87
+ const messageId = stream.messageId ?? stream.previewStreamId;
88
+ if (!messageId) {
89
+ return;
90
+ }
91
+ liveState = markLiveMessageFinalized(liveState, createPreviewMessageReceipt({ id: messageId }));
92
+ };
93
+
94
+ const renderInformativeUpdate = async () => {
95
+ if (!stream) {
96
+ return;
97
+ }
98
+ const informativeText = formatChannelProgressDraftText({
99
+ entry: params.msteamsConfig,
100
+ lines: shouldStreamPreviewToolProgress ? progressLines : [],
101
+ seed: params.progressSeed,
102
+ bullet: "-",
103
+ });
104
+ if (!informativeText || informativeText === lastInformativeText) {
105
+ return;
106
+ }
107
+ lastInformativeText = informativeText;
108
+ informativeUpdateSent = true;
109
+ await stream.sendInformativeUpdate(informativeText);
110
+ };
111
+
112
+ const progressDraftGate = createChannelProgressDraftGate({
113
+ onStart: renderInformativeUpdate,
114
+ });
115
+
116
+ const noteProgressWork = async (options?: { toolName?: string }): Promise<void> => {
117
+ if (!stream || streamMode !== "progress") {
118
+ return;
119
+ }
120
+ if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
121
+ return;
122
+ }
123
+ const hadStarted = progressDraftGate.hasStarted;
124
+ await progressDraftGate.noteWork();
125
+ if (hadStarted && progressDraftGate.hasStarted) {
126
+ await renderInformativeUpdate();
127
+ }
128
+ };
129
+
130
+ const pushProgressLine = async (
131
+ line?: string | ChannelProgressDraftLine,
132
+ options?: { toolName?: string },
133
+ ): Promise<void> => {
134
+ if (!stream || streamMode !== "progress") {
135
+ return;
136
+ }
137
+ if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
138
+ return;
139
+ }
140
+ if (shouldStreamPreviewToolProgress) {
141
+ const normalized = normalizeChannelProgressDraftLineIdentity(line);
142
+ if (normalized) {
143
+ const progressLine: string | ChannelProgressDraftLine =
144
+ typeof line === "object" && line !== undefined ? line : normalized;
145
+ progressLines = mergeChannelProgressDraftLine(progressLines, progressLine, {
146
+ maxLines: resolveChannelProgressDraftMaxLines(params.msteamsConfig),
147
+ });
148
+ }
149
+ }
150
+ await noteProgressWork();
151
+ };
152
+
153
+ const fallbackAfterStreamFailure = (
154
+ payload: ReplyPayload,
155
+ hasMedia: boolean,
156
+ ): Maybe<ReplyPayload> => {
157
+ if (!payload.text) {
158
+ return payload;
159
+ }
160
+ const streamedLength = stream?.streamedLength ?? 0;
161
+ if (streamedLength <= 0) {
162
+ return payload;
163
+ }
164
+ const remainingText = payload.text.slice(streamedLength);
165
+ if (!remainingText) {
166
+ return hasMedia ? { ...payload, text: undefined } : undefined;
167
+ }
168
+ return { ...payload, text: remainingText };
169
+ };
170
+
171
+ const finalizeProgressPayload = async (
172
+ payload: ReplyPayload,
173
+ hasMedia: boolean,
174
+ ): Promise<Maybe<ReplyPayload>> => {
175
+ if (!stream || !payload.text) {
176
+ return payload;
177
+ }
178
+ const result = await deliverWithFinalizableLivePreviewAdapter({
179
+ kind: "final",
180
+ payload,
181
+ liveState,
182
+ adapter: defineFinalizableLivePreviewAdapter<ReplyPayload, string, { text: string }>({
183
+ draft: {
184
+ flush: async () => {},
185
+ clear: async () => {},
186
+ id: () => stream.previewStreamId,
187
+ },
188
+ buildFinalEdit: (candidate) => (candidate.text ? { text: candidate.text } : undefined),
189
+ editFinal: async (_previewId, edit) => {
190
+ const finalized = await stream.replaceInformativeWithFinal(edit.text);
191
+ informativeUpdateSent = false;
192
+ if (!finalized || stream.isFailed) {
193
+ throw new Error("Teams progress stream finalization failed");
194
+ }
195
+ },
196
+ resolveFinalizedId: (previewId) => stream.messageId ?? stream.previewStreamId ?? previewId,
197
+ createPreviewReceipt: (id) => createPreviewMessageReceipt({ id }),
198
+ onPreviewFinalized: (_id, _receipt, state) => {
199
+ liveState = state;
200
+ },
201
+ logPreviewEditFailure: (err) => {
202
+ params.log.debug?.(`stream finalization failed: ${formatUnknownError(err)}`);
203
+ },
204
+ }),
205
+ deliverNormally: async () => false,
206
+ });
207
+
208
+ return result.kind === "preview-finalized"
209
+ ? hasMedia
210
+ ? { ...payload, text: undefined }
211
+ : undefined
212
+ : payload;
213
+ };
214
+
215
+ return {
216
+ async onReplyStart(): Promise<void> {
217
+ return;
218
+ },
219
+
220
+ async noteProgressWork(options?: { toolName?: string }): Promise<void> {
221
+ await noteProgressWork(options);
222
+ },
223
+
224
+ onPartialReply(payload: { text?: string }): void {
225
+ if (!stream || !payload.text) {
226
+ return;
227
+ }
228
+ if (streamMode === "progress") {
229
+ return;
230
+ }
231
+ streamReceivedTokens = true;
232
+ stream.update(payload.text);
233
+ },
234
+
235
+ async pushProgressLine(
236
+ line?: string | ChannelProgressDraftLine,
237
+ options?: { toolName?: string },
238
+ ): Promise<void> {
239
+ await pushProgressLine(line, options);
240
+ },
241
+
242
+ shouldSuppressDefaultToolProgressMessages(): boolean {
243
+ return shouldSuppressDefaultToolProgressMessages;
244
+ },
245
+
246
+ shouldStreamPreviewToolProgress(): boolean {
247
+ return shouldStreamPreviewToolProgress;
248
+ },
249
+
250
+ async preparePayload(payload: ReplyPayload): Promise<Maybe<ReplyPayload>> {
251
+ const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length);
252
+
253
+ if (stream && streamMode === "progress" && informativeUpdateSent && !stream.isFinalized) {
254
+ if (!payload.text) {
255
+ return payload;
256
+ }
257
+ return await finalizeProgressPayload(payload, hasMedia);
258
+ }
259
+
260
+ if (!stream || !streamReceivedTokens) {
261
+ return payload;
262
+ }
263
+
264
+ // Stream failed after partial delivery (e.g. > 4000 chars). Send only
265
+ // the unstreamed suffix via block delivery to avoid duplicate text.
266
+ if (stream.isFailed) {
267
+ streamReceivedTokens = false;
268
+
269
+ return fallbackAfterStreamFailure(payload, hasMedia);
270
+ }
271
+
272
+ if (!stream.hasContent || stream.isFinalized) {
273
+ return payload;
274
+ }
275
+
276
+ // Stream handled this text segment. Finalize it and reset so any
277
+ // subsequent text segments (after tool calls) use fallback delivery.
278
+ // finalize() is idempotent; the later call in markDispatchIdle is a no-op.
279
+ streamReceivedTokens = false;
280
+ pendingFinalize = stream.finalize().then(() => {
281
+ markStreamFinalized();
282
+ });
283
+
284
+ if (!hasMedia) {
285
+ return undefined;
286
+ }
287
+ return { ...payload, text: undefined };
288
+ },
289
+
290
+ async finalize(): Promise<void> {
291
+ progressDraftGate.cancel();
292
+ await pendingFinalize;
293
+ if (!pendingFinalize) {
294
+ await stream?.finalize();
295
+ markStreamFinalized();
296
+ }
297
+ },
298
+
299
+ hasStream(): boolean {
300
+ return Boolean(stream);
301
+ },
302
+
303
+ liveState(): LiveMessageState<ReplyPayload> {
304
+ return liveState;
305
+ },
306
+
307
+ /**
308
+ * Whether the Teams streaming card is currently receiving LLM tokens.
309
+ * Used to gate side-channel keepalive activity so we don't overlay plain
310
+ * "typing" indicators on top of a live streaming card.
311
+ *
312
+ * Returns true only while the stream is actively chunking text into the
313
+ * streaming card. The informative update (blue progress bar) is short
314
+ * lived so we intentionally do not count it as "active"; this way the
315
+ * typing keepalive can still fire during the informative window and
316
+ * during tool chains between text segments.
317
+ *
318
+ * Returns false when:
319
+ * - No stream exists (non-personal conversation).
320
+ * - Stream has not yet received any text tokens.
321
+ * - Stream has been finalized (e.g. after the first text segment, while
322
+ * tools run before the next segment).
323
+ */
324
+ isStreamActive(): boolean {
325
+ if (!stream) {
326
+ return false;
327
+ }
328
+ if (stream.isFinalized || stream.isFailed) {
329
+ return false;
330
+ }
331
+ return streamReceivedTokens;
332
+ },
333
+ };
334
+ }
@@ -0,0 +1,253 @@
1
+ import { beforeEach, 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
+ looksLikeMSTeamsTargetId,
30
+ resolveMSTeamsChannelAllowlist,
31
+ resolveMSTeamsUserAllowlist,
32
+ } from "./resolve-allowlist.js";
33
+
34
+ beforeEach(() => {
35
+ listTeamsByName.mockReset();
36
+ listChannelsForTeam.mockReset();
37
+ normalizeQuery.mockImplementation((value: string) => value.trim().toLowerCase());
38
+ resolveGraphToken.mockReset().mockResolvedValue("graph-token");
39
+ searchGraphUsers.mockReset();
40
+ });
41
+
42
+ describe("resolveMSTeamsUserAllowlist", () => {
43
+ it("marks empty input unresolved", async () => {
44
+ const [result] = await resolveMSTeamsUserAllowlist({ cfg: {}, entries: [" "] });
45
+ expect(result).toEqual({ input: " ", resolved: false });
46
+ });
47
+
48
+ it("resolves first Graph user match", async () => {
49
+ searchGraphUsers.mockResolvedValueOnce([
50
+ { id: "user-1", displayName: "Alice One" },
51
+ { id: "user-2", displayName: "Alice Two" },
52
+ ]);
53
+ const [result] = await resolveMSTeamsUserAllowlist({ cfg: {}, entries: ["alice"] });
54
+ expect(result).toEqual({
55
+ input: "alice",
56
+ resolved: true,
57
+ id: "user-1",
58
+ name: "Alice One",
59
+ note: "multiple matches; chose first",
60
+ });
61
+ });
62
+ });
63
+
64
+ describe("resolveMSTeamsChannelAllowlist", () => {
65
+ it("keeps configured Teams conversation IDs resolved without Graph lookup", async () => {
66
+ const [result] = await resolveMSTeamsChannelAllowlist({
67
+ cfg: {},
68
+ entries: ["19:team-general@thread.skype/19:roadmap@thread.skype"],
69
+ });
70
+
71
+ expect(result).toEqual({
72
+ input: "19:team-general@thread.skype/19:roadmap@thread.skype",
73
+ resolved: true,
74
+ teamId: "19:team-general@thread.skype",
75
+ teamName: "19:team-general@thread.skype",
76
+ channelId: "19:roadmap@thread.skype",
77
+ channelName: "19:roadmap@thread.skype",
78
+ });
79
+ expect(resolveGraphToken).not.toHaveBeenCalled();
80
+ expect(listTeamsByName).not.toHaveBeenCalled();
81
+ expect(listChannelsForTeam).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it("normalizes conversation-prefixed configured channel IDs", async () => {
85
+ const [result] = await resolveMSTeamsChannelAllowlist({
86
+ cfg: {},
87
+ entries: ["19:team-general@thread.tacv2/conversation:19:roadmap@thread.tacv2"],
88
+ });
89
+
90
+ expect(result).toEqual({
91
+ input: "19:team-general@thread.tacv2/conversation:19:roadmap@thread.tacv2",
92
+ resolved: true,
93
+ teamId: "19:team-general@thread.tacv2",
94
+ teamName: "19:team-general@thread.tacv2",
95
+ channelId: "19:roadmap@thread.tacv2",
96
+ channelName: "19:roadmap@thread.tacv2",
97
+ });
98
+ expect(resolveGraphToken).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it("resolves team/channel by team name + channel display name", async () => {
102
+ // After the fix, listChannelsForTeam is called once and reused for both
103
+ // General channel resolution and channel matching.
104
+ listTeamsByName.mockResolvedValueOnce([{ id: "team-guid-1", displayName: "Product Team" }]);
105
+ listChannelsForTeam.mockResolvedValueOnce([
106
+ { id: "19:general-conv-id@thread.tacv2", displayName: "General" },
107
+ { id: "19:roadmap-conv-id@thread.tacv2", displayName: "Roadmap" },
108
+ ]);
109
+
110
+ const [result] = await resolveMSTeamsChannelAllowlist({
111
+ cfg: {},
112
+ entries: ["Product Team/Roadmap"],
113
+ });
114
+
115
+ // teamId is now the General channel's conversation ID — not the Graph GUID —
116
+ // because that's what Bot Framework sends as channelData.team.id at runtime.
117
+ expect(result).toEqual({
118
+ input: "Product Team/Roadmap",
119
+ resolved: true,
120
+ teamId: "19:general-conv-id@thread.tacv2",
121
+ teamName: "Product Team",
122
+ channelId: "19:roadmap-conv-id@thread.tacv2",
123
+ channelName: "Roadmap",
124
+ note: "multiple channels; chose first",
125
+ });
126
+ });
127
+
128
+ it("uses General channel conversation ID as team key for team-only entry", async () => {
129
+ // When no channel is specified we still resolve the General channel so the
130
+ // stored key matches what Bot Framework sends as channelData.team.id.
131
+ listTeamsByName.mockResolvedValueOnce([{ id: "guid-engineering", displayName: "Engineering" }]);
132
+ listChannelsForTeam.mockResolvedValueOnce([
133
+ { id: "19:eng-general@thread.tacv2", displayName: "General" },
134
+ { id: "19:eng-standups@thread.tacv2", displayName: "Standups" },
135
+ ]);
136
+
137
+ const [result] = await resolveMSTeamsChannelAllowlist({
138
+ cfg: {},
139
+ entries: ["Engineering"],
140
+ });
141
+
142
+ expect(result).toEqual({
143
+ input: "Engineering",
144
+ resolved: true,
145
+ teamId: "19:eng-general@thread.tacv2",
146
+ teamName: "Engineering",
147
+ });
148
+ });
149
+
150
+ it("falls back to Graph GUID when listChannelsForTeam throws", async () => {
151
+ // Edge case: API call fails (rate limit, network error). We fall back to
152
+ // the Graph GUID as the team key — the pre-fix behavior — so resolution
153
+ // still succeeds instead of propagating the error.
154
+ listTeamsByName.mockResolvedValueOnce([{ id: "guid-flaky", displayName: "Flaky Team" }]);
155
+ listChannelsForTeam.mockRejectedValueOnce(new Error("429 Too Many Requests"));
156
+
157
+ const [result] = await resolveMSTeamsChannelAllowlist({
158
+ cfg: {},
159
+ entries: ["Flaky Team"],
160
+ });
161
+
162
+ expect(result).toEqual({
163
+ input: "Flaky Team",
164
+ resolved: true,
165
+ teamId: "guid-flaky",
166
+ teamName: "Flaky Team",
167
+ });
168
+ });
169
+
170
+ it("falls back to Graph GUID when General channel is not found", async () => {
171
+ // Edge case: General channel was renamed or deleted. We fall back to the
172
+ // Graph GUID so resolution still succeeds rather than silently breaking.
173
+ listTeamsByName.mockResolvedValueOnce([{ id: "guid-ops", displayName: "Operations" }]);
174
+ listChannelsForTeam.mockResolvedValueOnce([
175
+ { id: "19:ops-announce@thread.tacv2", displayName: "Announcements" },
176
+ { id: "19:ops-random@thread.tacv2", displayName: "Random" },
177
+ ]);
178
+
179
+ const [result] = await resolveMSTeamsChannelAllowlist({
180
+ cfg: {},
181
+ entries: ["Operations"],
182
+ });
183
+
184
+ expect(result).toEqual({
185
+ input: "Operations",
186
+ resolved: true,
187
+ teamId: "guid-ops",
188
+ teamName: "Operations",
189
+ });
190
+ });
191
+ });
192
+
193
+ describe("looksLikeMSTeamsTargetId", () => {
194
+ // Regression suite for https://github.com/klaw/klaw/issues/58001:
195
+ // cron announce delivery rejected valid Teams conversation ids because the
196
+ // validator only matched the `conversation:`-prefixed and `@thread`-suffixed
197
+ // forms. It must now accept every documented Bot Framework + Graph format.
198
+ it.each([
199
+ "conversation:19:abc@thread.tacv2",
200
+ "conversation:a:1abc",
201
+ "conversation:8:orgid:2d8c2d2c-1111-2222-3333-444444444444",
202
+ ])("accepts conversation-prefixed ids (%s)", (raw) => {
203
+ expect(looksLikeMSTeamsTargetId(raw)).toBe(true);
204
+ });
205
+
206
+ it.each(["19:AdviChannelId@thread.tacv2", "19:abc@thread.tacv2", "19:abc@thread.skype"])(
207
+ "accepts bare channel/group conversation ids (%s)",
208
+ (raw) => {
209
+ expect(looksLikeMSTeamsTargetId(raw)).toBe(true);
210
+ },
211
+ );
212
+
213
+ it("accepts the Graph 1:1 chat thread format", () => {
214
+ expect(
215
+ looksLikeMSTeamsTargetId(
216
+ "19:40a1a0ed4ff24164a21955518990c197_2d8c2d2c11112222@unq.gbl.spaces",
217
+ ),
218
+ ).toBe(true);
219
+ });
220
+
221
+ it.each(["a:1abc123def", "a:1xyz-abc_def", "A:1UPPER"])(
222
+ "accepts Bot Framework personal chat ids (%s)",
223
+ (raw) => {
224
+ expect(looksLikeMSTeamsTargetId(raw)).toBe(true);
225
+ },
226
+ );
227
+
228
+ it.each(["8:orgid:2d8c2d2c-1111-2222-3333-444444444444", "8:orgid:user-object-id"])(
229
+ "accepts Bot Framework org-scoped personal chat ids (%s)",
230
+ (raw) => {
231
+ expect(looksLikeMSTeamsTargetId(raw)).toBe(true);
232
+ },
233
+ );
234
+
235
+ it("accepts Bot Framework user ids", () => {
236
+ expect(looksLikeMSTeamsTargetId("29:1a2b3c4d5e6f")).toBe(true);
237
+ });
238
+
239
+ it("accepts user:<aad-object-id> ids", () => {
240
+ expect(looksLikeMSTeamsTargetId("user:40a1a0ed-4ff2-4164-a219-55518990c197")).toBe(true);
241
+ });
242
+
243
+ it.each(["", " ", "user:John Smith", "Product Team/Roadmap", "Engineering", "hello"])(
244
+ "rejects non-id inputs (%s)",
245
+ (raw) => {
246
+ expect(looksLikeMSTeamsTargetId(raw)).toBe(false);
247
+ },
248
+ );
249
+
250
+ it("normalizes leading/trailing whitespace before classifying", () => {
251
+ expect(looksLikeMSTeamsTargetId(" 19:abc@thread.tacv2 ")).toBe(true);
252
+ });
253
+ });