@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,173 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ clearPendingUploads,
4
+ getPendingUpload,
5
+ getPendingUploadCount,
6
+ removePendingUpload,
7
+ setPendingUploadActivityId,
8
+ storePendingUpload,
9
+ } from "./pending-uploads.js";
10
+
11
+ describe("pending-uploads", () => {
12
+ beforeEach(() => {
13
+ vi.useFakeTimers();
14
+ clearPendingUploads();
15
+ });
16
+
17
+ afterEach(() => {
18
+ clearPendingUploads();
19
+ vi.useRealTimers();
20
+ });
21
+
22
+ describe("storePendingUpload", () => {
23
+ it("stores and retrieves a pending upload", () => {
24
+ const id = storePendingUpload({
25
+ buffer: Buffer.from("data"),
26
+ filename: "file.txt",
27
+ contentType: "text/plain",
28
+ conversationId: "conv-1",
29
+ });
30
+
31
+ const upload = getPendingUpload(id);
32
+ expect(upload).toBeDefined();
33
+ expect(upload?.filename).toBe("file.txt");
34
+ expect(upload?.conversationId).toBe("conv-1");
35
+ });
36
+
37
+ it("stores consentCardActivityId when provided", () => {
38
+ const id = storePendingUpload({
39
+ buffer: Buffer.from("data"),
40
+ filename: "file.txt",
41
+ conversationId: "conv-1",
42
+ consentCardActivityId: "activity-abc",
43
+ });
44
+
45
+ const upload = getPendingUpload(id);
46
+ expect(upload?.consentCardActivityId).toBe("activity-abc");
47
+ });
48
+
49
+ it("stores without consentCardActivityId when not provided", () => {
50
+ const id = storePendingUpload({
51
+ buffer: Buffer.from("data"),
52
+ filename: "file.txt",
53
+ conversationId: "conv-1",
54
+ });
55
+
56
+ const upload = getPendingUpload(id);
57
+ expect(upload?.consentCardActivityId).toBeUndefined();
58
+ });
59
+
60
+ it("auto-removes entry after TTL expires", () => {
61
+ const id = storePendingUpload({
62
+ buffer: Buffer.from("data"),
63
+ filename: "file.txt",
64
+ conversationId: "conv-1",
65
+ });
66
+
67
+ expect(getPendingUpload(id)).toBeDefined();
68
+ vi.advanceTimersByTime(5 * 60 * 1000 + 1);
69
+ // After TTL the in-memory check also gates access
70
+ expect(getPendingUpload(id)).toBeUndefined();
71
+ });
72
+ });
73
+
74
+ describe("removePendingUpload", () => {
75
+ it("removes the entry immediately", () => {
76
+ const id = storePendingUpload({
77
+ buffer: Buffer.from("data"),
78
+ filename: "file.txt",
79
+ conversationId: "conv-1",
80
+ });
81
+
82
+ removePendingUpload(id);
83
+ expect(getPendingUpload(id)).toBeUndefined();
84
+ });
85
+
86
+ it("clears the TTL timer so it does not fire after explicit removal", () => {
87
+ const id = storePendingUpload({
88
+ buffer: Buffer.from("data"),
89
+ filename: "file.txt",
90
+ conversationId: "conv-1",
91
+ });
92
+
93
+ expect(getPendingUploadCount()).toBe(1);
94
+ removePendingUpload(id);
95
+ expect(getPendingUploadCount()).toBe(0);
96
+
97
+ // Advance past TTL — timer should have been cleared and count stays 0
98
+ vi.advanceTimersByTime(5 * 60 * 1000 + 1);
99
+ expect(getPendingUploadCount()).toBe(0);
100
+ });
101
+
102
+ it("is a no-op for undefined id", () => {
103
+ storePendingUpload({
104
+ buffer: Buffer.from("data"),
105
+ filename: "file.txt",
106
+ conversationId: "conv-1",
107
+ });
108
+
109
+ expect(() => removePendingUpload(undefined)).not.toThrow();
110
+ expect(getPendingUploadCount()).toBe(1);
111
+ });
112
+
113
+ it("is a no-op for unknown id", () => {
114
+ expect(() => removePendingUpload("non-existent-id")).not.toThrow();
115
+ });
116
+ });
117
+
118
+ describe("clearPendingUploads", () => {
119
+ it("removes all entries and cancels timers", () => {
120
+ storePendingUpload({ buffer: Buffer.from("a"), filename: "a.txt", conversationId: "c1" });
121
+ storePendingUpload({ buffer: Buffer.from("b"), filename: "b.txt", conversationId: "c2" });
122
+ expect(getPendingUploadCount()).toBe(2);
123
+
124
+ clearPendingUploads();
125
+ expect(getPendingUploadCount()).toBe(0);
126
+
127
+ // TTL timers should have been cleared — no side-effects after advance
128
+ vi.advanceTimersByTime(5 * 60 * 1000 + 1);
129
+ expect(getPendingUploadCount()).toBe(0);
130
+ });
131
+ });
132
+
133
+ describe("setPendingUploadActivityId", () => {
134
+ it("sets the consentCardActivityId on an existing upload", () => {
135
+ const id = storePendingUpload({
136
+ buffer: Buffer.from("data"),
137
+ filename: "file.txt",
138
+ conversationId: "conv-1",
139
+ });
140
+
141
+ expect(getPendingUpload(id)?.consentCardActivityId).toBeUndefined();
142
+
143
+ setPendingUploadActivityId(id, "activity-xyz");
144
+ expect(getPendingUpload(id)?.consentCardActivityId).toBe("activity-xyz");
145
+ });
146
+
147
+ it("is a no-op for unknown upload id", () => {
148
+ expect(() => setPendingUploadActivityId("non-existent", "activity-xyz")).not.toThrow();
149
+ });
150
+ });
151
+
152
+ describe("getPendingUpload", () => {
153
+ it("returns undefined for undefined id", () => {
154
+ expect(getPendingUpload(undefined)).toBeUndefined();
155
+ });
156
+
157
+ it("returns undefined for unknown id", () => {
158
+ expect(getPendingUpload("no-such-id")).toBeUndefined();
159
+ });
160
+
161
+ it("returns undefined when entry is past TTL but timer has not yet fired", () => {
162
+ const id = storePendingUpload({
163
+ buffer: Buffer.from("data"),
164
+ filename: "file.txt",
165
+ conversationId: "conv-1",
166
+ });
167
+
168
+ // Manually advance time without firing timers to simulate stale entry
169
+ vi.setSystemTime(Date.now() + 5 * 60 * 1000 + 1);
170
+ expect(getPendingUpload(id)).toBeUndefined();
171
+ });
172
+ });
173
+ });
@@ -14,10 +14,14 @@ export interface PendingUpload {
14
14
  filename: string;
15
15
  contentType?: string;
16
16
  conversationId: string;
17
+ /** Activity ID of the original FileConsentCard, used to replace it after upload */
18
+ consentCardActivityId?: string;
17
19
  createdAt: number;
18
20
  }
19
21
 
20
22
  const pendingUploads = new Map<string, PendingUpload>();
23
+ /** Timer handles keyed by upload ID, cleared on explicit removal to prevent ghost cleanup */
24
+ const pendingUploadTimers = new Map<string, ReturnType<typeof setTimeout>>();
21
25
 
22
26
  /** TTL for pending uploads: 5 minutes */
23
27
  const PENDING_UPLOAD_TTL_MS = 5 * 60 * 1000;
@@ -35,10 +39,12 @@ export function storePendingUpload(upload: Omit<PendingUpload, "id" | "createdAt
35
39
  };
36
40
  pendingUploads.set(id, entry);
37
41
 
38
- // Auto-cleanup after TTL
39
- setTimeout(() => {
42
+ // Auto-cleanup after TTL; timer ref stored so removePendingUpload can cancel it
43
+ const timer = setTimeout(() => {
40
44
  pendingUploads.delete(id);
45
+ pendingUploadTimers.delete(id);
41
46
  }, PENDING_UPLOAD_TTL_MS);
47
+ pendingUploadTimers.set(id, timer);
42
48
 
43
49
  return id;
44
50
  }
@@ -59,6 +65,11 @@ export function getPendingUpload(id?: string): PendingUpload | undefined {
59
65
  // Check if expired (in case timeout hasn't fired yet)
60
66
  if (Date.now() - entry.createdAt > PENDING_UPLOAD_TTL_MS) {
61
67
  pendingUploads.delete(id);
68
+ const timer = pendingUploadTimers.get(id);
69
+ if (timer !== undefined) {
70
+ clearTimeout(timer);
71
+ pendingUploadTimers.delete(id);
72
+ }
62
73
  return undefined;
63
74
  }
64
75
 
@@ -67,10 +78,27 @@ export function getPendingUpload(id?: string): PendingUpload | undefined {
67
78
 
68
79
  /**
69
80
  * Remove a pending upload (after successful upload or user decline).
81
+ * Also clears the TTL timer to prevent ghost Map deletions.
70
82
  */
71
83
  export function removePendingUpload(id?: string): void {
72
84
  if (id) {
73
85
  pendingUploads.delete(id);
86
+ const timer = pendingUploadTimers.get(id);
87
+ if (timer !== undefined) {
88
+ clearTimeout(timer);
89
+ pendingUploadTimers.delete(id);
90
+ }
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Set the consent card activity ID on an existing pending upload.
96
+ * Called after the FileConsentCard is sent and we know its activity ID.
97
+ */
98
+ export function setPendingUploadActivityId(uploadId: string, activityId: string): void {
99
+ const entry = pendingUploads.get(uploadId);
100
+ if (entry) {
101
+ entry.consentCardActivityId = activityId;
74
102
  }
75
103
  }
76
104
 
@@ -85,5 +113,9 @@ export function getPendingUploadCount(): number {
85
113
  * Clear all pending uploads (for testing).
86
114
  */
87
115
  export function clearPendingUploads(): void {
116
+ for (const timer of pendingUploadTimers.values()) {
117
+ clearTimeout(timer);
118
+ }
119
+ pendingUploadTimers.clear();
88
120
  pendingUploads.clear();
89
121
  }
@@ -1,5 +1,5 @@
1
- import type { MSTeamsConfig } from "openclaw/plugin-sdk/msteams";
2
1
  import { describe, expect, it } from "vitest";
2
+ import type { MSTeamsConfig } from "../runtime-api.js";
3
3
  import {
4
4
  isMSTeamsGroupAllowed,
5
5
  resolveMSTeamsReplyPolicy,
@@ -47,8 +47,11 @@ describe("msteams policy", () => {
47
47
  conversationId: "chan456",
48
48
  });
49
49
 
50
- expect(res.teamConfig?.requireMention).toBe(false);
51
- expect(res.channelConfig?.requireMention).toBe(true);
50
+ if (!res.teamConfig || !res.channelConfig) {
51
+ throw new Error("expected matched team and channel config");
52
+ }
53
+ expect(res.teamConfig.requireMention).toBe(false);
54
+ expect(res.channelConfig.requireMention).toBe(true);
52
55
  expect(res.allowlistConfigured).toBe(true);
53
56
  expect(res.allowed).toBe(true);
54
57
  expect(res.channelMatchKey).toBe("chan456");
@@ -82,8 +85,11 @@ describe("msteams policy", () => {
82
85
  it("matches team and channel by name when dangerous name matching is enabled", () => {
83
86
  const res = resolveNamedTeamRouteConfig(true);
84
87
 
85
- expect(res.teamConfig?.requireMention).toBe(true);
86
- expect(res.channelConfig?.requireMention).toBe(false);
88
+ if (!res.teamConfig || !res.channelConfig) {
89
+ throw new Error("expected matched named team and channel config");
90
+ }
91
+ expect(res.teamConfig.requireMention).toBe(true);
92
+ expect(res.channelConfig.requireMention).toBe(false);
87
93
  expect(res.allowed).toBe(true);
88
94
  });
89
95
  });
package/src/policy.ts CHANGED
@@ -7,7 +7,7 @@ import type {
7
7
  MSTeamsConfig,
8
8
  MSTeamsReplyStyle,
9
9
  MSTeamsTeamConfig,
10
- } from "openclaw/plugin-sdk/msteams";
10
+ } from "../runtime-api.js";
11
11
  import {
12
12
  buildChannelKeyCandidates,
13
13
  evaluateSenderGroupAccessForPolicy,
@@ -17,9 +17,9 @@ import {
17
17
  resolveChannelEntryMatchWithFallback,
18
18
  resolveNestedAllowlistDecision,
19
19
  isDangerousNameMatchingEnabled,
20
- } from "openclaw/plugin-sdk/msteams";
20
+ } from "../runtime-api.js";
21
21
 
22
- export type MSTeamsResolvedRouteConfig = {
22
+ type MSTeamsResolvedRouteConfig = {
23
23
  teamConfig?: MSTeamsTeamConfig;
24
24
  channelConfig?: MSTeamsChannelConfig;
25
25
  allowlistConfigured: boolean;
@@ -203,12 +203,12 @@ export function resolveMSTeamsGroupToolPolicy(
203
203
  return undefined;
204
204
  }
205
205
 
206
- export type MSTeamsReplyPolicy = {
206
+ type MSTeamsReplyPolicy = {
207
207
  requireMention: boolean;
208
208
  replyStyle: MSTeamsReplyStyle;
209
209
  };
210
210
 
211
- export type MSTeamsAllowlistMatch = AllowlistMatch<"wildcard" | "id" | "name">;
211
+ type MSTeamsAllowlistMatch = AllowlistMatch<"wildcard" | "id" | "name">;
212
212
 
213
213
  export function resolveMSTeamsAllowlistMatch(params: {
214
214
  allowFrom: Array<string | number>;
package/src/polls.test.ts CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { beforeEach, describe, expect, it } from "vitest";
5
+ import { createMSTeamsPollStoreMemory } from "./polls-store-memory.js";
5
6
  import { buildMSTeamsPollCard, createMSTeamsPollStoreFs, extractMSTeamsPollVote } from "./polls.js";
6
7
  import { setMSTeamsRuntime } from "./runtime.js";
7
8
  import { msteamsRuntimeStub } from "./test-runtime.js";
@@ -17,10 +18,10 @@ describe("msteams polls", () => {
17
18
  options: ["Pizza", "Sushi"],
18
19
  });
19
20
 
20
- expect(card.pollId).toBeTruthy();
21
- expect(card.fallbackText).toContain("Poll: Lunch?");
22
- expect(card.fallbackText).toContain("1. Pizza");
23
- expect(card.fallbackText).toContain("2. Sushi");
21
+ expect(card.pollId).toMatch(
22
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
23
+ );
24
+ expect(card.fallbackText).toBe("Poll: Lunch?\n1. Pizza\n2. Sushi");
24
25
  });
25
26
 
26
27
  it("extracts poll votes from activity values", () => {
@@ -54,6 +55,106 @@ describe("msteams polls", () => {
54
55
  selections: ["0", "1"],
55
56
  });
56
57
  const stored = await store.getPoll("poll-2");
57
- expect(stored?.votes["user-1"]).toEqual(["0"]);
58
+ if (!stored) {
59
+ throw new Error("expected stored poll after recordVote");
60
+ }
61
+ expect(stored.votes["user-1"]).toEqual(["0"]);
62
+ });
63
+ });
64
+
65
+ const createFsStore = async () => {
66
+ const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-polls-"));
67
+ return createMSTeamsPollStoreFs({ stateDir });
68
+ };
69
+
70
+ const createMemoryStore = () => createMSTeamsPollStoreMemory();
71
+
72
+ describe.each([
73
+ { name: "memory", createStore: createMemoryStore },
74
+ { name: "fs", createStore: createFsStore },
75
+ ])("$name poll store", ({ createStore }) => {
76
+ it("stores polls and records normalized votes", async () => {
77
+ const store = await createStore();
78
+ await store.createPoll({
79
+ id: "poll-1",
80
+ question: "Lunch?",
81
+ options: ["Pizza", "Sushi"],
82
+ maxSelections: 1,
83
+ createdAt: new Date().toISOString(),
84
+ votes: {},
85
+ });
86
+
87
+ const poll = await store.recordVote({
88
+ pollId: "poll-1",
89
+ voterId: "user-1",
90
+ selections: ["0", "1"],
91
+ });
92
+
93
+ if (!poll) {
94
+ throw new Error("poll store did not return the updated poll");
95
+ }
96
+ expect(poll.votes["user-1"]).toEqual(["0"]);
97
+ });
98
+ });
99
+
100
+ describe("memory poll store", () => {
101
+ it("reads seeded polls back, updates timestamps, and returns null for missing polls", async () => {
102
+ const store = createMSTeamsPollStoreMemory([
103
+ {
104
+ id: "poll-1",
105
+ question: "Pick one",
106
+ options: ["A", "B"],
107
+ maxSelections: 1,
108
+ votes: {},
109
+ createdAt: "2026-03-22T00:00:00.000Z",
110
+ updatedAt: "2026-03-22T00:00:00.000Z",
111
+ },
112
+ ]);
113
+
114
+ await expect(store.getPoll("poll-1")).resolves.toEqual(
115
+ expect.objectContaining({
116
+ id: "poll-1",
117
+ question: "Pick one",
118
+ }),
119
+ );
120
+
121
+ const originalUpdatedAt = "2026-03-22T00:00:00.000Z";
122
+ const result = await store.recordVote({
123
+ pollId: "poll-1",
124
+ voterId: "user-1",
125
+ selections: ["1", "0", "missing"],
126
+ });
127
+
128
+ expect(result?.votes["user-1"]).toEqual(["1"]);
129
+ expect(result?.updatedAt).not.toBe(originalUpdatedAt);
130
+
131
+ await store.createPoll({
132
+ id: "poll-2",
133
+ question: "Pick many",
134
+ options: ["X", "Y"],
135
+ maxSelections: 2,
136
+ votes: {},
137
+ createdAt: "2026-03-22T00:00:00.000Z",
138
+ updatedAt: "2026-03-22T00:00:00.000Z",
139
+ });
140
+
141
+ await expect(
142
+ store.recordVote({
143
+ pollId: "poll-2",
144
+ voterId: "user-2",
145
+ selections: ["1", "0", "1"],
146
+ }),
147
+ ).resolves.toEqual(
148
+ expect.objectContaining({
149
+ id: "poll-2",
150
+ votes: {
151
+ "user-2": ["1", "0"],
152
+ },
153
+ }),
154
+ );
155
+
156
+ await expect(
157
+ store.recordVote({ pollId: "missing", voterId: "nobody", selections: ["x"] }),
158
+ ).resolves.toBeNull();
58
159
  });
59
160
  });
package/src/polls.ts CHANGED
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
2
2
  import { resolveMSTeamsStorePath } from "./storage.js";
3
3
  import { readJsonFile, withFileLock, writeJsonFile } from "./store-fs.js";
4
4
 
5
- export type MSTeamsPollVote = {
5
+ type MSTeamsPollVote = {
6
6
  pollId: string;
7
7
  selections: string[];
8
8
  };
@@ -29,7 +29,7 @@ export type MSTeamsPollStore = {
29
29
  }) => Promise<MSTeamsPoll | null>;
30
30
  };
31
31
 
32
- export type MSTeamsPollCard = {
32
+ type MSTeamsPollCard = {
33
33
  pollId: string;
34
34
  question: string;
35
35
  options: string[];
@@ -46,8 +46,17 @@ type PollStoreData = {
46
46
  const STORE_FILENAME = "msteams-polls.json";
47
47
  const MAX_POLLS = 1000;
48
48
  const POLL_TTL_MS = 30 * 24 * 60 * 60 * 1000;
49
+
49
50
  function isRecord(value: unknown): value is Record<string, unknown> {
50
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
51
+ return typeof value === "object" && value !== null && !Array.isArray(value);
52
+ }
53
+
54
+ function normalizeOptionalString(value: unknown): string | undefined {
55
+ if (typeof value !== "string") {
56
+ return undefined;
57
+ }
58
+ const trimmed = value.trim();
59
+ return trimmed ? trimmed : undefined;
51
60
  }
52
61
 
53
62
  function normalizeChoiceValue(value: unknown): string | null {
@@ -90,8 +99,7 @@ function readNestedValue(value: unknown, keys: Array<string | number>): unknown
90
99
  }
91
100
 
92
101
  function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
93
- const found = readNestedValue(value, keys);
94
- return typeof found === "string" && found.trim() ? found.trim() : undefined;
102
+ return normalizeOptionalString(readNestedValue(value, keys));
95
103
  }
96
104
 
97
105
  export function extractMSTeamsPollVote(
@@ -213,7 +221,7 @@ export function buildMSTeamsPollCard(params: {
213
221
  };
214
222
  }
215
223
 
216
- export type MSTeamsPollStoreFsOptions = {
224
+ type MSTeamsPollStoreFsOptions = {
217
225
  env?: NodeJS.ProcessEnv;
218
226
  homedir?: () => string;
219
227
  stateDir?: string;
@@ -273,7 +281,7 @@ export function createMSTeamsPollStoreFs(params?: MSTeamsPollStoreFsOptions): MS
273
281
  const empty: PollStoreData = { version: 1, polls: {} };
274
282
 
275
283
  const readStore = async (): Promise<PollStoreData> => {
276
- const { value } = await readJsonFile<PollStoreData>(filePath, empty);
284
+ const { value } = await readJsonFile(filePath, empty);
277
285
  const pruned = pruneToLimit(pruneExpired(value.polls ?? {}));
278
286
  return { version: 1, polls: pruned };
279
287
  };
@@ -0,0 +1,68 @@
1
+ import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
2
+ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
3
+
4
+ export function buildMSTeamsPresentationCard(params: {
5
+ presentation: MessagePresentation;
6
+ text?: string | null;
7
+ }) {
8
+ const body: Record<string, unknown>[] = [];
9
+ const text = normalizeOptionalString(params.text);
10
+ if (text) {
11
+ body.push({
12
+ type: "TextBlock",
13
+ text,
14
+ wrap: true,
15
+ });
16
+ }
17
+ const { presentation } = params;
18
+ if (presentation.title) {
19
+ body.push({
20
+ type: "TextBlock",
21
+ text: presentation.title,
22
+ weight: "Bolder",
23
+ size: "Medium",
24
+ wrap: true,
25
+ });
26
+ }
27
+ const actions: Record<string, unknown>[] = [];
28
+ for (const block of presentation.blocks) {
29
+ if (block.type === "text" || block.type === "context") {
30
+ body.push({
31
+ type: "TextBlock",
32
+ text: block.text,
33
+ wrap: true,
34
+ ...(block.type === "context" ? { isSubtle: true, size: "Small" } : {}),
35
+ });
36
+ continue;
37
+ }
38
+ if (block.type === "divider") {
39
+ body.push({ type: "TextBlock", text: "---", wrap: true, isSubtle: true });
40
+ continue;
41
+ }
42
+ if (block.type === "buttons") {
43
+ for (const button of block.buttons) {
44
+ if (button.url) {
45
+ actions.push({
46
+ type: "Action.OpenUrl",
47
+ title: button.label,
48
+ url: button.url,
49
+ });
50
+ continue;
51
+ }
52
+ if (button.value) {
53
+ actions.push({
54
+ type: "Action.Submit",
55
+ title: button.label,
56
+ data: { value: button.value, label: button.label },
57
+ });
58
+ }
59
+ }
60
+ }
61
+ }
62
+ return {
63
+ type: "AdaptiveCard",
64
+ version: "1.4",
65
+ body,
66
+ ...(actions.length ? { actions } : {}),
67
+ };
68
+ }
package/src/probe.test.ts CHANGED
@@ -1,25 +1,45 @@
1
- import type { MSTeamsConfig } from "openclaw/plugin-sdk/msteams";
2
- import { describe, expect, it, vi } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { MSTeamsConfig } from "../runtime-api.js";
3
3
 
4
4
  const hostMockState = vi.hoisted(() => ({
5
5
  tokenError: null as Error | null,
6
6
  }));
7
7
 
8
- vi.mock("@microsoft/agents-hosting", () => ({
9
- getAuthConfigWithDefaults: (cfg: unknown) => cfg,
10
- MsalTokenProvider: class {
11
- async getAccessToken() {
8
+ vi.mock("@microsoft/teams.apps", () => ({
9
+ App: class {
10
+ protected async getBotToken() {
12
11
  if (hostMockState.tokenError) {
13
12
  throw hostMockState.tokenError;
14
13
  }
15
- return "token";
14
+ return { value: "token" };
15
+ }
16
+ protected async getAppGraphToken() {
17
+ if (hostMockState.tokenError) {
18
+ throw hostMockState.tokenError;
19
+ }
20
+ return { value: "token" };
16
21
  }
17
22
  },
18
23
  }));
19
24
 
25
+ vi.mock("@microsoft/teams.api", () => ({
26
+ Client: function Client() {},
27
+ }));
28
+
20
29
  import { probeMSTeams } from "./probe.js";
21
30
 
22
31
  describe("msteams probe", () => {
32
+ beforeEach(() => {
33
+ hostMockState.tokenError = null;
34
+ vi.stubEnv("MSTEAMS_APP_ID", "");
35
+ vi.stubEnv("MSTEAMS_APP_PASSWORD", "");
36
+ vi.stubEnv("MSTEAMS_TENANT_ID", "");
37
+ });
38
+
39
+ afterEach(() => {
40
+ vi.unstubAllEnvs();
41
+ });
42
+
23
43
  it("returns an error when credentials are missing", async () => {
24
44
  const cfg = { enabled: true } as unknown as MSTeamsConfig;
25
45
  await expect(probeMSTeams(cfg)).resolves.toMatchObject({
@@ -28,7 +48,6 @@ describe("msteams probe", () => {
28
48
  });
29
49
 
30
50
  it("validates credentials by acquiring a token", async () => {
31
- hostMockState.tokenError = null;
32
51
  const cfg = {
33
52
  enabled: true,
34
53
  appId: "app",