@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,224 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { GraphThreadMessage } from "./graph-thread.js";
3
+ import {
4
+ _resetThreadParentContextCachesForTest,
5
+ fetchParentMessageCached,
6
+ formatParentContextEvent,
7
+ markParentContextInjected,
8
+ shouldInjectParentContext,
9
+ summarizeParentMessage,
10
+ } from "./thread-parent-context.js";
11
+
12
+ describe("summarizeParentMessage", () => {
13
+ it("returns undefined for missing message", () => {
14
+ expect(summarizeParentMessage(undefined)).toBeUndefined();
15
+ });
16
+
17
+ it("returns undefined when body is blank", () => {
18
+ const msg: GraphThreadMessage = {
19
+ id: "p1",
20
+ from: { user: { displayName: "Alice" } },
21
+ body: { content: " ", contentType: "text" },
22
+ };
23
+ expect(summarizeParentMessage(msg)).toBeUndefined();
24
+ });
25
+
26
+ it("extracts sender + plain text", () => {
27
+ const msg: GraphThreadMessage = {
28
+ id: "p1",
29
+ from: { user: { displayName: "Alice" } },
30
+ body: { content: "Hello world", contentType: "text" },
31
+ };
32
+ expect(summarizeParentMessage(msg)).toEqual({ sender: "Alice", text: "Hello world" });
33
+ });
34
+
35
+ it("strips HTML for html contentType", () => {
36
+ const msg: GraphThreadMessage = {
37
+ id: "p1",
38
+ from: { user: { displayName: "Bob" } },
39
+ body: { content: "<p>Hi <b>there</b></p>", contentType: "html" },
40
+ };
41
+ expect(summarizeParentMessage(msg)).toEqual({ sender: "Bob", text: "Hi there" });
42
+ });
43
+
44
+ it("collapses whitespace in text contentType", () => {
45
+ const msg: GraphThreadMessage = {
46
+ id: "p1",
47
+ from: { user: { displayName: "Carol" } },
48
+ body: { content: "line one\n line two\t\ttrailing", contentType: "text" },
49
+ };
50
+ expect(summarizeParentMessage(msg)).toEqual({
51
+ sender: "Carol",
52
+ text: "line one line two trailing",
53
+ });
54
+ });
55
+
56
+ it("falls back to application displayName", () => {
57
+ const msg: GraphThreadMessage = {
58
+ id: "p1",
59
+ from: { application: { displayName: "BotApp" } },
60
+ body: { content: "heads up", contentType: "text" },
61
+ };
62
+ expect(summarizeParentMessage(msg)).toEqual({ sender: "BotApp", text: "heads up" });
63
+ });
64
+
65
+ it("falls back to unknown when sender is missing", () => {
66
+ const msg: GraphThreadMessage = {
67
+ id: "p1",
68
+ body: { content: "orphan", contentType: "text" },
69
+ };
70
+ expect(summarizeParentMessage(msg)).toEqual({ sender: "unknown", text: "orphan" });
71
+ });
72
+
73
+ it("truncates overly long parent text", () => {
74
+ const msg: GraphThreadMessage = {
75
+ id: "p1",
76
+ from: { user: { displayName: "Dana" } },
77
+ body: { content: "x".repeat(1000), contentType: "text" },
78
+ };
79
+ const summary = summarizeParentMessage(msg);
80
+ expect(summary?.text.length).toBeLessThanOrEqual(400);
81
+ expect(summary?.text.endsWith("…")).toBe(true);
82
+ });
83
+ });
84
+
85
+ describe("formatParentContextEvent", () => {
86
+ it("formats as Replying to @sender: body", () => {
87
+ expect(formatParentContextEvent({ sender: "Alice", text: "hello there" })).toBe(
88
+ "Replying to @Alice: hello there",
89
+ );
90
+ });
91
+ });
92
+
93
+ describe("fetchParentMessageCached", () => {
94
+ beforeEach(() => {
95
+ _resetThreadParentContextCachesForTest();
96
+ });
97
+
98
+ it("invokes the fetcher on first call", async () => {
99
+ const mockMsg: GraphThreadMessage = {
100
+ id: "p1",
101
+ body: { content: "hi", contentType: "text" },
102
+ };
103
+ const fetcher = vi.fn(async () => mockMsg);
104
+
105
+ const result = await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
106
+
107
+ expect(result).toEqual(mockMsg);
108
+ expect(fetcher).toHaveBeenCalledTimes(1);
109
+ expect(fetcher).toHaveBeenCalledWith("tok", "g1", "c1", "p1");
110
+ });
111
+
112
+ it("returns cached value on repeat fetch without invoking fetcher", async () => {
113
+ const mockMsg: GraphThreadMessage = {
114
+ id: "p1",
115
+ body: { content: "hi", contentType: "text" },
116
+ };
117
+ const fetcher = vi.fn(async () => mockMsg);
118
+
119
+ await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
120
+ await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
121
+ const third = await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
122
+
123
+ expect(fetcher).toHaveBeenCalledTimes(1);
124
+ expect(third).toEqual(mockMsg);
125
+ });
126
+
127
+ it("caches undefined (Graph error) so failures do not re-fetch on burst", async () => {
128
+ const fetcher = vi.fn(async () => undefined);
129
+
130
+ const first = await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
131
+ const second = await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
132
+
133
+ expect(first).toBeUndefined();
134
+ expect(second).toBeUndefined();
135
+ expect(fetcher).toHaveBeenCalledTimes(1);
136
+ });
137
+
138
+ it("scopes cache by groupId/channelId/parentId", async () => {
139
+ const fetcher = vi.fn(async (_tok, _g, _c, parentId) => ({
140
+ id: parentId,
141
+ body: { content: `content-${parentId}`, contentType: "text" },
142
+ }));
143
+
144
+ await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
145
+ await fetchParentMessageCached("tok", "g1", "c1", "p2", fetcher);
146
+ await fetchParentMessageCached("tok", "g2", "c1", "p1", fetcher);
147
+
148
+ expect(fetcher).toHaveBeenCalledTimes(3);
149
+ });
150
+
151
+ it("re-fetches after TTL expires", async () => {
152
+ vi.useFakeTimers();
153
+ try {
154
+ const fetcher = vi.fn(async () => ({
155
+ id: "p1",
156
+ body: { content: "hi", contentType: "text" },
157
+ }));
158
+
159
+ await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
160
+ // 5 min TTL: advance just beyond.
161
+ vi.advanceTimersByTime(5 * 60 * 1000 + 1);
162
+ await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
163
+
164
+ expect(fetcher).toHaveBeenCalledTimes(2);
165
+ } finally {
166
+ vi.useRealTimers();
167
+ }
168
+ });
169
+
170
+ it("evicts oldest entries when exceeding the 100-entry cap", async () => {
171
+ const fetcher = vi.fn(async (_tok, _g, _c, parentId) => ({
172
+ id: String(parentId),
173
+ body: { content: `v-${parentId}`, contentType: "text" },
174
+ }));
175
+
176
+ // Fill cache with 100 distinct parents.
177
+ for (let i = 0; i < 100; i += 1) {
178
+ await fetchParentMessageCached("tok", "g1", "c1", `p${i}`, fetcher);
179
+ }
180
+ expect(fetcher).toHaveBeenCalledTimes(100);
181
+
182
+ // First entry should still be cached (no evictions yet).
183
+ await fetchParentMessageCached("tok", "g1", "c1", "p0", fetcher);
184
+ expect(fetcher).toHaveBeenCalledTimes(100);
185
+
186
+ // Push one more distinct parent to trigger an eviction.
187
+ // The just-touched p0 is now the newest; the next-oldest (p1) should be evicted.
188
+ await fetchParentMessageCached("tok", "g1", "c1", "p100", fetcher);
189
+ expect(fetcher).toHaveBeenCalledTimes(101);
190
+
191
+ // Fetching p1 again should miss the cache.
192
+ await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
193
+ expect(fetcher).toHaveBeenCalledTimes(102);
194
+
195
+ // p0 is still cached because we refreshed it.
196
+ await fetchParentMessageCached("tok", "g1", "c1", "p0", fetcher);
197
+ expect(fetcher).toHaveBeenCalledTimes(102);
198
+ });
199
+ });
200
+
201
+ describe("shouldInjectParentContext / markParentContextInjected", () => {
202
+ beforeEach(() => {
203
+ _resetThreadParentContextCachesForTest();
204
+ });
205
+
206
+ it("returns true for first observation", () => {
207
+ expect(shouldInjectParentContext("session-1", "parent-1")).toBe(true);
208
+ });
209
+
210
+ it("returns false after marking the same parent", () => {
211
+ markParentContextInjected("session-1", "parent-1");
212
+ expect(shouldInjectParentContext("session-1", "parent-1")).toBe(false);
213
+ });
214
+
215
+ it("returns true again when a different parent appears in the session", () => {
216
+ markParentContextInjected("session-1", "parent-1");
217
+ expect(shouldInjectParentContext("session-1", "parent-2")).toBe(true);
218
+ });
219
+
220
+ it("dedupe is scoped per session key", () => {
221
+ markParentContextInjected("session-1", "parent-1");
222
+ expect(shouldInjectParentContext("session-2", "parent-1")).toBe(true);
223
+ });
224
+ });
@@ -0,0 +1,159 @@
1
+ // Parent-message context injection for Teams channel thread replies.
2
+ //
3
+ // When an inbound message arrives as a reply inside a Teams channel thread,
4
+ // the triggering message often makes no sense on its own (for example, a
5
+ // one-word "yes" or "go ahead"). Per-thread session isolation (PR #62713)
6
+ // gives each thread its own session, but the first message in a brand-new
7
+ // thread session still has no parent context.
8
+ //
9
+ // This module fetches the parent message via Graph and prepends a compact
10
+ // `Replying to @sender: …` system event to the next agent turn so the agent
11
+ // knows what is being responded to. Fetches are cached to avoid repeated
12
+ // Graph calls within the same active thread, and per-session dedupe ensures
13
+ // the same parent is not re-injected on every subsequent reply in the
14
+ // thread.
15
+
16
+ import { fetchChannelMessage, stripHtmlFromTeamsMessage } from "./graph-thread.js";
17
+ import type { GraphThreadMessage } from "./graph-thread.js";
18
+
19
+ // LRU cache for parent message fetches. Keyed by `teamId:channelId:parentId`.
20
+ // 5-minute TTL and 100-entry cap keep active-thread chatter fast without
21
+ // holding stale data when a thread goes quiet. Eviction uses Map insertion
22
+ // order for LRU semantics (get() re-inserts on hit).
23
+ const PARENT_CACHE_TTL_MS = 5 * 60 * 1000;
24
+ const PARENT_CACHE_MAX = 100;
25
+
26
+ type ParentCacheEntry = {
27
+ message: GraphThreadMessage | undefined;
28
+ expiresAt: number;
29
+ };
30
+
31
+ const parentCache = new Map<string, ParentCacheEntry>();
32
+
33
+ // Per-session dedupe: remembers the most recent parent id we injected for a
34
+ // given session key. When the same thread session sees another reply against
35
+ // the same parent, we skip re-enqueueing the identical system event. We keep
36
+ // a small LRU so idle sessions eventually drop out.
37
+ const INJECTED_MAX = 200;
38
+ const injectedParents = new Map<string, string>();
39
+
40
+ type ThreadParentContextFetcher = (
41
+ token: string,
42
+ groupId: string,
43
+ channelId: string,
44
+ messageId: string,
45
+ ) => Promise<GraphThreadMessage | undefined>;
46
+
47
+ function touchLru<K, V>(map: Map<K, V>, key: K, value: V, max: number): void {
48
+ if (map.has(key)) {
49
+ map.delete(key);
50
+ } else if (map.size >= max) {
51
+ // Drop the oldest (first-inserted) entry.
52
+ const firstKey = map.keys().next().value;
53
+ if (firstKey !== undefined) {
54
+ map.delete(firstKey);
55
+ }
56
+ }
57
+ map.set(key, value);
58
+ }
59
+
60
+ function buildParentCacheKey(groupId: string, channelId: string, parentId: string): string {
61
+ return `${groupId}\u0000${channelId}\u0000${parentId}`;
62
+ }
63
+
64
+ /**
65
+ * Fetch a channel parent message with an LRU+TTL cache.
66
+ *
67
+ * Uses the injected `fetchParent` (defaults to `fetchChannelMessage`) so
68
+ * tests can swap in a stub without mocking the Graph transport.
69
+ */
70
+ export async function fetchParentMessageCached(
71
+ token: string,
72
+ groupId: string,
73
+ channelId: string,
74
+ parentId: string,
75
+ fetchParent: ThreadParentContextFetcher = fetchChannelMessage,
76
+ ): Promise<GraphThreadMessage | undefined> {
77
+ const key = buildParentCacheKey(groupId, channelId, parentId);
78
+ const now = Date.now();
79
+ const cached = parentCache.get(key);
80
+ if (cached && cached.expiresAt > now) {
81
+ // Refresh LRU ordering on hit.
82
+ parentCache.delete(key);
83
+ parentCache.set(key, cached);
84
+ return cached.message;
85
+ }
86
+ const message = await fetchParent(token, groupId, channelId, parentId);
87
+ touchLru(parentCache, key, { message, expiresAt: now + PARENT_CACHE_TTL_MS }, PARENT_CACHE_MAX);
88
+ return message;
89
+ }
90
+
91
+ type ParentContextSummary = {
92
+ /** Display name of the parent message author, or "unknown". */
93
+ sender: string;
94
+ /** Stripped, single-line parent body text (or empty if unresolved). */
95
+ text: string;
96
+ };
97
+
98
+ const PARENT_TEXT_MAX_CHARS = 400;
99
+
100
+ /**
101
+ * Extract a compact summary (sender + plain-text body) from a Graph parent
102
+ * message. Returns undefined when the parent cannot be summarized (missing
103
+ * or blank body).
104
+ */
105
+ export function summarizeParentMessage(
106
+ message: GraphThreadMessage | undefined,
107
+ ): ParentContextSummary | undefined {
108
+ if (!message) {
109
+ return undefined;
110
+ }
111
+ const sender =
112
+ message.from?.user?.displayName ?? message.from?.application?.displayName ?? "unknown";
113
+ const contentType = message.body?.contentType ?? "text";
114
+ const raw = message.body?.content ?? "";
115
+ const text =
116
+ contentType === "html" ? stripHtmlFromTeamsMessage(raw) : raw.replace(/\s+/g, " ").trim();
117
+ if (!text) {
118
+ return undefined;
119
+ }
120
+ return {
121
+ sender,
122
+ text:
123
+ text.length > PARENT_TEXT_MAX_CHARS ? `${text.slice(0, PARENT_TEXT_MAX_CHARS - 1)}…` : text,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Build the single-line `Replying to @sender: body` system event text.
129
+ * Callers should pass this text to `enqueueSystemEvent` together with a
130
+ * stable contextKey derived from the parent id.
131
+ */
132
+ export function formatParentContextEvent(summary: ParentContextSummary): string {
133
+ return `Replying to @${summary.sender}: ${summary.text}`;
134
+ }
135
+
136
+ /**
137
+ * Decide whether a parent context event should be enqueued for the current
138
+ * session. Returns `false` when we already injected the same parent for this
139
+ * session recently (prevents re-prepending identical context on every reply
140
+ * in the thread).
141
+ */
142
+ export function shouldInjectParentContext(sessionKey: string, parentId: string): boolean {
143
+ const key = sessionKey;
144
+ return injectedParents.get(key) !== parentId;
145
+ }
146
+
147
+ /**
148
+ * Record that `parentId` was just injected for `sessionKey` so subsequent
149
+ * replies with the same parent can short-circuit via `shouldInjectParentContext`.
150
+ */
151
+ export function markParentContextInjected(sessionKey: string, parentId: string): void {
152
+ touchLru(injectedParents, sessionKey, parentId, INJECTED_MAX);
153
+ }
154
+
155
+ // Exported for test isolation.
156
+ export function _resetThreadParentContextCachesForTest(): void {
157
+ parentCache.clear();
158
+ injectedParents.clear();
159
+ }
package/src/token.test.ts CHANGED
@@ -1,72 +1,259 @@
1
- import { afterEach, describe, expect, it } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { readAccessToken } from "./token-response.js";
2
3
  import { hasConfiguredMSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js";
3
4
 
4
- const ORIGINAL_ENV = {
5
- appId: process.env.MSTEAMS_APP_ID,
6
- appPassword: process.env.MSTEAMS_APP_PASSWORD,
7
- tenantId: process.env.MSTEAMS_TENANT_ID,
8
- };
9
-
10
- afterEach(() => {
11
- if (ORIGINAL_ENV.appId === undefined) {
12
- delete process.env.MSTEAMS_APP_ID;
13
- } else {
14
- process.env.MSTEAMS_APP_ID = ORIGINAL_ENV.appId;
15
- }
16
- if (ORIGINAL_ENV.appPassword === undefined) {
17
- delete process.env.MSTEAMS_APP_PASSWORD;
18
- } else {
19
- process.env.MSTEAMS_APP_PASSWORD = ORIGINAL_ENV.appPassword;
5
+ vi.mock("./secret-input.js", () => ({
6
+ normalizeSecretInputString: (v: unknown) =>
7
+ typeof v === "string" && v.trim() ? v.trim() : undefined,
8
+ normalizeResolvedSecretInputString: (opts: { value: unknown; path: string }) =>
9
+ typeof opts.value === "string" && opts.value.trim() ? opts.value.trim() : undefined,
10
+ hasConfiguredSecretInput: (v: unknown) => typeof v === "string" && v.trim().length > 0,
11
+ }));
12
+
13
+ const ENV_KEYS = [
14
+ "MSTEAMS_APP_ID",
15
+ "MSTEAMS_APP_PASSWORD",
16
+ "MSTEAMS_TENANT_ID",
17
+ "MSTEAMS_AUTH_TYPE",
18
+ "MSTEAMS_CERTIFICATE_PATH",
19
+ "MSTEAMS_CERTIFICATE_THUMBPRINT",
20
+ "MSTEAMS_USE_MANAGED_IDENTITY",
21
+ "MSTEAMS_MANAGED_IDENTITY_CLIENT_ID",
22
+ ] as const;
23
+
24
+ let savedEnv: Record<string, string | undefined> = {};
25
+
26
+ function saveAndClearEnv() {
27
+ savedEnv = {};
28
+ for (const key of ENV_KEYS) {
29
+ savedEnv[key] = process.env[key];
30
+ delete process.env[key];
20
31
  }
21
- if (ORIGINAL_ENV.tenantId === undefined) {
22
- delete process.env.MSTEAMS_TENANT_ID;
23
- } else {
24
- process.env.MSTEAMS_TENANT_ID = ORIGINAL_ENV.tenantId;
32
+ }
33
+
34
+ function restoreEnv() {
35
+ for (const key of ENV_KEYS) {
36
+ if (savedEnv[key] !== undefined) {
37
+ process.env[key] = savedEnv[key];
38
+ } else {
39
+ delete process.env[key];
40
+ }
25
41
  }
42
+ }
43
+
44
+ describe("token – secret credentials", () => {
45
+ beforeEach(saveAndClearEnv);
46
+ afterEach(restoreEnv);
47
+
48
+ it("returns true when appId + appPassword + tenantId are provided in config", () => {
49
+ const cfg = { appId: "app-id", appPassword: "app-pw", tenantId: "tenant-id" } as any;
50
+ expect(hasConfiguredMSTeamsCredentials(cfg)).toBe(true);
51
+ });
52
+
53
+ it("returns false when appPassword is missing", () => {
54
+ const cfg = { appId: "app-id", tenantId: "tenant-id" } as any;
55
+ expect(hasConfiguredMSTeamsCredentials(cfg)).toBe(false);
56
+ });
57
+
58
+ it("returns false when no config is given and no env vars set", () => {
59
+ expect(hasConfiguredMSTeamsCredentials(undefined)).toBe(false);
60
+ });
61
+
62
+ it("resolves secret credentials from config", () => {
63
+ const cfg = { appId: "app-id", appPassword: "app-pw", tenantId: "tenant-id" } as any;
64
+ const result = resolveMSTeamsCredentials(cfg);
65
+ expect(result).toEqual({
66
+ type: "secret",
67
+ appId: "app-id",
68
+ appPassword: "app-pw",
69
+ tenantId: "tenant-id",
70
+ });
71
+ });
72
+
73
+ it("resolves secret credentials from env vars", () => {
74
+ process.env.MSTEAMS_APP_ID = "env-app-id";
75
+ process.env.MSTEAMS_APP_PASSWORD = "env-app-pw";
76
+ process.env.MSTEAMS_TENANT_ID = "env-tenant-id";
77
+ const result = resolveMSTeamsCredentials(undefined);
78
+ expect(result).toEqual({
79
+ type: "secret",
80
+ appId: "env-app-id",
81
+ appPassword: "env-app-pw",
82
+ tenantId: "env-tenant-id",
83
+ });
84
+ });
85
+
86
+ it("returns undefined when appPassword is missing", () => {
87
+ const cfg = { appId: "app-id", tenantId: "tenant-id" } as any;
88
+ expect(resolveMSTeamsCredentials(cfg)).toBeUndefined();
89
+ });
26
90
  });
27
91
 
28
- describe("resolveMSTeamsCredentials", () => {
29
- it("returns configured credentials for plaintext values", () => {
30
- const resolved = resolveMSTeamsCredentials({
31
- appId: " app-id ",
32
- appPassword: " app-password ",
33
- tenantId: " tenant-id ",
92
+ describe("token – federated credentials (certificate)", () => {
93
+ beforeEach(saveAndClearEnv);
94
+ afterEach(restoreEnv);
95
+
96
+ it("hasConfigured returns true when certificate path is provided", () => {
97
+ const cfg = {
98
+ appId: "app-id",
99
+ tenantId: "tenant-id",
100
+ authType: "federated",
101
+ certificatePath: "/cert.pem",
102
+ } as any;
103
+ expect(hasConfiguredMSTeamsCredentials(cfg)).toBe(true);
104
+ });
105
+
106
+ it("hasConfigured returns false when neither cert nor MI is provided", () => {
107
+ const cfg = { appId: "app-id", tenantId: "tenant-id", authType: "federated" } as any;
108
+ expect(hasConfiguredMSTeamsCredentials(cfg)).toBe(false);
109
+ });
110
+
111
+ it("resolves federated credentials with certificate from config", () => {
112
+ const cfg = {
113
+ appId: "app-id",
114
+ tenantId: "tenant-id",
115
+ authType: "federated",
116
+ certificatePath: "/cert.pem",
117
+ certificateThumbprint: "AABBCCDD",
118
+ } as any;
119
+ const result = resolveMSTeamsCredentials(cfg);
120
+ expect(result).toEqual({
121
+ type: "federated",
122
+ appId: "app-id",
123
+ tenantId: "tenant-id",
124
+ certificatePath: "/cert.pem",
125
+ certificateThumbprint: "AABBCCDD",
126
+ useManagedIdentity: undefined,
127
+ managedIdentityClientId: undefined,
128
+ });
129
+ });
130
+
131
+ it("resolves federated credentials from env vars", () => {
132
+ process.env.MSTEAMS_AUTH_TYPE = "federated";
133
+ process.env.MSTEAMS_APP_ID = "env-app-id";
134
+ process.env.MSTEAMS_TENANT_ID = "env-tenant-id";
135
+ process.env.MSTEAMS_CERTIFICATE_PATH = "/env/cert.pem";
136
+ process.env.MSTEAMS_CERTIFICATE_THUMBPRINT = "EEFF0011";
137
+ const result = resolveMSTeamsCredentials(undefined);
138
+ expect(result).toEqual({
139
+ type: "federated",
140
+ appId: "env-app-id",
141
+ tenantId: "env-tenant-id",
142
+ certificatePath: "/env/cert.pem",
143
+ certificateThumbprint: "EEFF0011",
144
+ useManagedIdentity: undefined,
145
+ managedIdentityClientId: undefined,
34
146
  });
147
+ });
148
+ });
149
+
150
+ describe("token – federated credentials (managed identity)", () => {
151
+ beforeEach(saveAndClearEnv);
152
+ afterEach(restoreEnv);
153
+
154
+ it("resolves managed identity from config", () => {
155
+ const cfg = {
156
+ appId: "app-id",
157
+ tenantId: "tenant-id",
158
+ authType: "federated",
159
+ useManagedIdentity: true,
160
+ managedIdentityClientId: "mi-client-id",
161
+ } as any;
162
+ const result = resolveMSTeamsCredentials(cfg);
163
+ expect(result).toEqual({
164
+ type: "federated",
165
+ appId: "app-id",
166
+ tenantId: "tenant-id",
167
+ certificatePath: undefined,
168
+ certificateThumbprint: undefined,
169
+ useManagedIdentity: true,
170
+ managedIdentityClientId: "mi-client-id",
171
+ });
172
+ });
35
173
 
36
- expect(resolved).toEqual({
174
+ it("resolves system-assigned managed identity (no clientId)", () => {
175
+ const cfg = {
37
176
  appId: "app-id",
38
- appPassword: "app-password", // pragma: allowlist secret
39
177
  tenantId: "tenant-id",
178
+ authType: "federated",
179
+ useManagedIdentity: true,
180
+ } as any;
181
+ const result = resolveMSTeamsCredentials(cfg);
182
+ expect(result).toEqual({
183
+ type: "federated",
184
+ appId: "app-id",
185
+ tenantId: "tenant-id",
186
+ certificatePath: undefined,
187
+ certificateThumbprint: undefined,
188
+ useManagedIdentity: true,
189
+ managedIdentityClientId: undefined,
40
190
  });
41
191
  });
42
192
 
43
- it("throws when appPassword remains an unresolved SecretRef object", () => {
44
- expect(() =>
45
- resolveMSTeamsCredentials({
46
- appId: "app-id",
47
- appPassword: {
48
- source: "env",
49
- provider: "default",
50
- id: "MSTEAMS_APP_PASSWORD",
51
- },
52
- tenantId: "tenant-id",
53
- }),
54
- ).toThrow(/channels\.msteams\.appPassword: unresolved SecretRef/i);
193
+ it("hasConfigured returns true for managed identity via env", () => {
194
+ process.env.MSTEAMS_AUTH_TYPE = "federated";
195
+ process.env.MSTEAMS_APP_ID = "env-app-id";
196
+ process.env.MSTEAMS_TENANT_ID = "env-tenant-id";
197
+ process.env.MSTEAMS_USE_MANAGED_IDENTITY = "true";
198
+ expect(hasConfiguredMSTeamsCredentials(undefined)).toBe(true);
199
+ });
200
+
201
+ it("config useManagedIdentity=false overrides env MSTEAMS_USE_MANAGED_IDENTITY=true", () => {
202
+ process.env.MSTEAMS_USE_MANAGED_IDENTITY = "true";
203
+ const cfg = {
204
+ appId: "app-id",
205
+ tenantId: "tenant-id",
206
+ authType: "federated",
207
+ certificatePath: "/cert.pem",
208
+ useManagedIdentity: false,
209
+ } as any;
210
+ const result = resolveMSTeamsCredentials(cfg);
211
+ expect(result).toBeDefined();
212
+ expect(result!.type).toBe("federated");
213
+ expect((result as any).useManagedIdentity).toBeUndefined();
214
+ expect((result as any).certificatePath).toBe("/cert.pem");
55
215
  });
56
216
  });
57
217
 
58
- describe("hasConfiguredMSTeamsCredentials", () => {
59
- it("treats SecretRef appPassword as configured", () => {
60
- const configured = hasConfiguredMSTeamsCredentials({
218
+ describe("token – backward compatibility", () => {
219
+ beforeEach(saveAndClearEnv);
220
+ afterEach(restoreEnv);
221
+
222
+ it("defaults to secret when authType is absent", () => {
223
+ const cfg = { appId: "app-id", appPassword: "pw", tenantId: "tenant-id" } as any;
224
+ const result = resolveMSTeamsCredentials(cfg);
225
+ expect(result).toBeDefined();
226
+ expect(result!.type).toBe("secret");
227
+ });
228
+
229
+ it("explicit authType=secret behaves same as absent", () => {
230
+ const cfg = {
231
+ appId: "app-id",
232
+ appPassword: "pw",
233
+ tenantId: "tenant-id",
234
+ authType: "secret",
235
+ } as any;
236
+ const result = resolveMSTeamsCredentials(cfg);
237
+ expect(result).toEqual({
238
+ type: "secret",
61
239
  appId: "app-id",
62
- appPassword: {
63
- source: "env",
64
- provider: "default",
65
- id: "MSTEAMS_APP_PASSWORD",
66
- },
240
+ appPassword: "pw",
67
241
  tenantId: "tenant-id",
68
242
  });
243
+ });
244
+ });
245
+
246
+ describe("readAccessToken", () => {
247
+ it("reads string and object token forms", () => {
248
+ expect(readAccessToken("abc")).toBe("abc");
249
+ expect(readAccessToken({ accessToken: "access-token" })).toBe("access-token");
250
+ expect(readAccessToken({ token: "fallback-token" })).toBe("fallback-token");
251
+ });
69
252
 
70
- expect(configured).toBe(true);
253
+ it("returns null for unsupported token payloads", () => {
254
+ expect(readAccessToken({ accessToken: 123 })).toBeNull();
255
+ expect(readAccessToken({ token: false })).toBeNull();
256
+ expect(readAccessToken(null)).toBeNull();
257
+ expect(readAccessToken(undefined)).toBeNull();
71
258
  });
72
259
  });