@openclaw/msteams 2026.2.24 → 2026.2.25

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.2.25
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
3
9
  ## 2026.2.24
4
10
 
5
11
  ### Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/msteams",
3
- "version": "2026.2.24",
3
+ "version": "2026.2.25",
4
4
  "description": "OpenClaw Microsoft Teams channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -0,0 +1,96 @@
1
+ import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js";
4
+ import { setMSTeamsRuntime } from "../runtime.js";
5
+ import { createMSTeamsMessageHandler } from "./message-handler.js";
6
+
7
+ describe("msteams monitor handler authz", () => {
8
+ it("does not treat DM pairing-store entries as group allowlist entries", async () => {
9
+ const readAllowFromStore = vi.fn(async () => ["attacker-aad"]);
10
+ setMSTeamsRuntime({
11
+ logging: { shouldLogVerbose: () => false },
12
+ channel: {
13
+ debounce: {
14
+ resolveInboundDebounceMs: () => 0,
15
+ createInboundDebouncer: <T>(params: {
16
+ onFlush: (entries: T[]) => Promise<void>;
17
+ }): { enqueue: (entry: T) => Promise<void> } => ({
18
+ enqueue: async (entry: T) => {
19
+ await params.onFlush([entry]);
20
+ },
21
+ }),
22
+ },
23
+ pairing: {
24
+ readAllowFromStore,
25
+ upsertPairingRequest: vi.fn(async () => null),
26
+ },
27
+ text: {
28
+ hasControlCommand: () => false,
29
+ },
30
+ },
31
+ } as unknown as PluginRuntime);
32
+
33
+ const conversationStore = {
34
+ upsert: vi.fn(async () => undefined),
35
+ };
36
+
37
+ const deps: MSTeamsMessageHandlerDeps = {
38
+ cfg: {
39
+ channels: {
40
+ msteams: {
41
+ dmPolicy: "pairing",
42
+ allowFrom: [],
43
+ groupPolicy: "allowlist",
44
+ groupAllowFrom: [],
45
+ },
46
+ },
47
+ } as OpenClawConfig,
48
+ runtime: { error: vi.fn() } as unknown as RuntimeEnv,
49
+ appId: "test-app",
50
+ adapter: {} as MSTeamsMessageHandlerDeps["adapter"],
51
+ tokenProvider: {
52
+ getAccessToken: vi.fn(async () => "token"),
53
+ },
54
+ textLimit: 4000,
55
+ mediaMaxBytes: 1024 * 1024,
56
+ conversationStore:
57
+ conversationStore as unknown as MSTeamsMessageHandlerDeps["conversationStore"],
58
+ pollStore: {
59
+ recordVote: vi.fn(async () => null),
60
+ } as unknown as MSTeamsMessageHandlerDeps["pollStore"],
61
+ log: {
62
+ info: vi.fn(),
63
+ debug: vi.fn(),
64
+ error: vi.fn(),
65
+ } as unknown as MSTeamsMessageHandlerDeps["log"],
66
+ };
67
+
68
+ const handler = createMSTeamsMessageHandler(deps);
69
+ await handler({
70
+ activity: {
71
+ id: "msg-1",
72
+ type: "message",
73
+ text: "",
74
+ from: {
75
+ id: "attacker-id",
76
+ aadObjectId: "attacker-aad",
77
+ name: "Attacker",
78
+ },
79
+ recipient: {
80
+ id: "bot-id",
81
+ name: "Bot",
82
+ },
83
+ conversation: {
84
+ id: "19:group@thread.tacv2",
85
+ conversationType: "groupChat",
86
+ },
87
+ channelData: {},
88
+ attachments: [],
89
+ },
90
+ sendActivity: vi.fn(async () => undefined),
91
+ } as unknown as Parameters<typeof handler>[0]);
92
+
93
+ expect(readAllowFromStore).toHaveBeenCalledWith("msteams");
94
+ expect(conversationStore.upsert).not.toHaveBeenCalled();
95
+ });
96
+ });
@@ -135,7 +135,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
135
135
 
136
136
  // Check DM policy for direct messages.
137
137
  const dmAllowFrom = msteamsCfg?.allowFrom ?? [];
138
- const effectiveDmAllowFrom = [...dmAllowFrom.map((v) => String(v)), ...storedAllowFrom];
138
+ const configuredDmAllowFrom = dmAllowFrom.map((v) => String(v));
139
+ const effectiveDmAllowFrom = [...configuredDmAllowFrom, ...storedAllowFrom];
139
140
  if (isDirectMessage && msteamsCfg) {
140
141
  const allowFrom = dmAllowFrom;
141
142
 
@@ -189,9 +190,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
189
190
  (msteamsCfg.allowFrom && msteamsCfg.allowFrom.length > 0 ? msteamsCfg.allowFrom : []))
190
191
  : [];
191
192
  const effectiveGroupAllowFrom =
192
- !isDirectMessage && msteamsCfg
193
- ? [...groupAllowFrom.map((v) => String(v)), ...storedAllowFrom]
194
- : [];
193
+ !isDirectMessage && msteamsCfg ? groupAllowFrom.map((v) => String(v)) : [];
195
194
  const teamId = activity.channelData?.team?.id;
196
195
  const teamName = activity.channelData?.team?.name;
197
196
  const channelName = activity.channelData?.channel?.name;
@@ -248,9 +247,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
248
247
  }
249
248
  }
250
249
 
250
+ const commandDmAllowFrom = isDirectMessage ? effectiveDmAllowFrom : configuredDmAllowFrom;
251
251
  const ownerAllowedForCommands = isMSTeamsGroupAllowed({
252
252
  groupPolicy: "allowlist",
253
- allowFrom: effectiveDmAllowFrom,
253
+ allowFrom: commandDmAllowFrom,
254
254
  senderId,
255
255
  senderName,
256
256
  allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
@@ -266,7 +266,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
266
266
  const commandGate = resolveControlCommandGate({
267
267
  useAccessGroups,
268
268
  authorizers: [
269
- { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
269
+ { configured: commandDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
270
270
  { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
271
271
  ],
272
272
  allowTextCommands: true,
@@ -0,0 +1,220 @@
1
+ import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { MSTeamsConversationStore } from "./conversation-store.js";
4
+ import type { MSTeamsAdapter } from "./messenger.js";
5
+ import {
6
+ type MSTeamsActivityHandler,
7
+ type MSTeamsMessageHandlerDeps,
8
+ registerMSTeamsHandlers,
9
+ } from "./monitor-handler.js";
10
+ import { clearPendingUploads, getPendingUpload, storePendingUpload } from "./pending-uploads.js";
11
+ import type { MSTeamsPollStore } from "./polls.js";
12
+ import { setMSTeamsRuntime } from "./runtime.js";
13
+ import type { MSTeamsTurnContext } from "./sdk-types.js";
14
+
15
+ const fileConsentMockState = vi.hoisted(() => ({
16
+ uploadToConsentUrl: vi.fn(),
17
+ }));
18
+
19
+ vi.mock("./file-consent.js", async () => {
20
+ const actual = await vi.importActual<typeof import("./file-consent.js")>("./file-consent.js");
21
+ return {
22
+ ...actual,
23
+ uploadToConsentUrl: fileConsentMockState.uploadToConsentUrl,
24
+ };
25
+ });
26
+
27
+ const runtimeStub: PluginRuntime = {
28
+ logging: {
29
+ shouldLogVerbose: () => false,
30
+ },
31
+ channel: {
32
+ debounce: {
33
+ resolveInboundDebounceMs: () => 0,
34
+ createInboundDebouncer: () => ({
35
+ enqueue: async () => {},
36
+ }),
37
+ },
38
+ },
39
+ } as unknown as PluginRuntime;
40
+
41
+ function createDeps(): MSTeamsMessageHandlerDeps {
42
+ const adapter: MSTeamsAdapter = {
43
+ continueConversation: async () => {},
44
+ process: async () => {},
45
+ };
46
+ const conversationStore: MSTeamsConversationStore = {
47
+ upsert: async () => {},
48
+ get: async () => null,
49
+ list: async () => [],
50
+ remove: async () => false,
51
+ findByUserId: async () => null,
52
+ };
53
+ const pollStore: MSTeamsPollStore = {
54
+ createPoll: async () => {},
55
+ getPoll: async () => null,
56
+ recordVote: async () => null,
57
+ };
58
+ return {
59
+ cfg: {} as OpenClawConfig,
60
+ runtime: {
61
+ error: vi.fn(),
62
+ } as unknown as RuntimeEnv,
63
+ appId: "test-app-id",
64
+ adapter,
65
+ tokenProvider: {
66
+ getAccessToken: async () => "token",
67
+ },
68
+ textLimit: 4000,
69
+ mediaMaxBytes: 8 * 1024 * 1024,
70
+ conversationStore,
71
+ pollStore,
72
+ log: {
73
+ info: vi.fn(),
74
+ error: vi.fn(),
75
+ debug: vi.fn(),
76
+ },
77
+ };
78
+ }
79
+
80
+ function createActivityHandler(): MSTeamsActivityHandler {
81
+ let handler: MSTeamsActivityHandler;
82
+ handler = {
83
+ onMessage: () => handler,
84
+ onMembersAdded: () => handler,
85
+ run: async () => {},
86
+ };
87
+ return handler;
88
+ }
89
+
90
+ function createInvokeContext(params: {
91
+ conversationId: string;
92
+ uploadId: string;
93
+ action: "accept" | "decline";
94
+ }): { context: MSTeamsTurnContext; sendActivity: ReturnType<typeof vi.fn> } {
95
+ const sendActivity = vi.fn(async () => ({ id: "activity-id" }));
96
+ const uploadInfo =
97
+ params.action === "accept"
98
+ ? {
99
+ name: "secret.txt",
100
+ uploadUrl: "https://upload.example.com/put",
101
+ contentUrl: "https://content.example.com/file",
102
+ uniqueId: "unique-id",
103
+ fileType: "txt",
104
+ }
105
+ : undefined;
106
+ return {
107
+ context: {
108
+ activity: {
109
+ type: "invoke",
110
+ name: "fileConsent/invoke",
111
+ conversation: { id: params.conversationId },
112
+ value: {
113
+ type: "fileUpload",
114
+ action: params.action,
115
+ uploadInfo,
116
+ context: { uploadId: params.uploadId },
117
+ },
118
+ },
119
+ sendActivity,
120
+ sendActivities: async () => [],
121
+ } as unknown as MSTeamsTurnContext,
122
+ sendActivity,
123
+ };
124
+ }
125
+
126
+ describe("msteams file consent invoke authz", () => {
127
+ beforeEach(() => {
128
+ setMSTeamsRuntime(runtimeStub);
129
+ clearPendingUploads();
130
+ fileConsentMockState.uploadToConsentUrl.mockReset();
131
+ fileConsentMockState.uploadToConsentUrl.mockResolvedValue(undefined);
132
+ });
133
+
134
+ it("uploads when invoke conversation matches pending upload conversation", async () => {
135
+ const uploadId = storePendingUpload({
136
+ buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
137
+ filename: "secret.txt",
138
+ contentType: "text/plain",
139
+ conversationId: "19:victim@thread.v2",
140
+ });
141
+ const deps = createDeps();
142
+ const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
143
+ const { context, sendActivity } = createInvokeContext({
144
+ conversationId: "19:victim@thread.v2;messageid=abc123",
145
+ uploadId,
146
+ action: "accept",
147
+ });
148
+
149
+ await handler.run?.(context);
150
+
151
+ expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
152
+ expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledWith(
153
+ expect.objectContaining({
154
+ url: "https://upload.example.com/put",
155
+ }),
156
+ );
157
+ expect(getPendingUpload(uploadId)).toBeUndefined();
158
+ expect(sendActivity).toHaveBeenCalledWith(
159
+ expect.objectContaining({
160
+ type: "invokeResponse",
161
+ }),
162
+ );
163
+ });
164
+
165
+ it("rejects cross-conversation accept invoke and keeps pending upload", async () => {
166
+ const uploadId = storePendingUpload({
167
+ buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
168
+ filename: "secret.txt",
169
+ contentType: "text/plain",
170
+ conversationId: "19:victim@thread.v2",
171
+ });
172
+ const deps = createDeps();
173
+ const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
174
+ const { context, sendActivity } = createInvokeContext({
175
+ conversationId: "19:attacker@thread.v2",
176
+ uploadId,
177
+ action: "accept",
178
+ });
179
+
180
+ await handler.run?.(context);
181
+
182
+ expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
183
+ expect(getPendingUpload(uploadId)).toBeDefined();
184
+ expect(sendActivity).toHaveBeenCalledWith(
185
+ "The file upload request has expired. Please try sending the file again.",
186
+ );
187
+ expect(sendActivity).toHaveBeenCalledWith(
188
+ expect.objectContaining({
189
+ type: "invokeResponse",
190
+ }),
191
+ );
192
+ });
193
+
194
+ it("ignores cross-conversation decline invoke and keeps pending upload", async () => {
195
+ const uploadId = storePendingUpload({
196
+ buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
197
+ filename: "secret.txt",
198
+ contentType: "text/plain",
199
+ conversationId: "19:victim@thread.v2",
200
+ });
201
+ const deps = createDeps();
202
+ const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
203
+ const { context, sendActivity } = createInvokeContext({
204
+ conversationId: "19:attacker@thread.v2",
205
+ uploadId,
206
+ action: "decline",
207
+ });
208
+
209
+ await handler.run?.(context);
210
+
211
+ expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
212
+ expect(getPendingUpload(uploadId)).toBeDefined();
213
+ expect(sendActivity).toHaveBeenCalledTimes(1);
214
+ expect(sendActivity).toHaveBeenCalledWith(
215
+ expect.objectContaining({
216
+ type: "invokeResponse",
217
+ }),
218
+ );
219
+ });
220
+ });
@@ -1,6 +1,7 @@
1
1
  import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
2
2
  import type { MSTeamsConversationStore } from "./conversation-store.js";
3
3
  import { buildFileInfoCard, parseFileConsentInvoke, uploadToConsentUrl } from "./file-consent.js";
4
+ import { normalizeMSTeamsConversationId } from "./inbound.js";
4
5
  import type { MSTeamsAdapter } from "./messenger.js";
5
6
  import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
6
7
  import type { MSTeamsMonitorLogger } from "./monitor-types.js";
@@ -42,6 +43,8 @@ async function handleFileConsentInvoke(
42
43
  context: MSTeamsTurnContext,
43
44
  log: MSTeamsMonitorLogger,
44
45
  ): Promise<boolean> {
46
+ const expiredUploadMessage =
47
+ "The file upload request has expired. Please try sending the file again.";
45
48
  const activity = context.activity;
46
49
  if (activity.type !== "invoke" || activity.name !== "fileConsent/invoke") {
47
50
  return false;
@@ -57,9 +60,24 @@ async function handleFileConsentInvoke(
57
60
  typeof consentResponse.context?.uploadId === "string"
58
61
  ? consentResponse.context.uploadId
59
62
  : undefined;
63
+ const pendingFile = getPendingUpload(uploadId);
64
+ if (pendingFile) {
65
+ const pendingConversationId = normalizeMSTeamsConversationId(pendingFile.conversationId);
66
+ const invokeConversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
67
+ if (!invokeConversationId || pendingConversationId !== invokeConversationId) {
68
+ log.info("file consent conversation mismatch", {
69
+ uploadId,
70
+ expectedConversationId: pendingConversationId,
71
+ receivedConversationId: invokeConversationId || undefined,
72
+ });
73
+ if (consentResponse.action === "accept") {
74
+ await context.sendActivity(expiredUploadMessage);
75
+ }
76
+ return true;
77
+ }
78
+ }
60
79
 
61
80
  if (consentResponse.action === "accept" && consentResponse.uploadInfo) {
62
- const pendingFile = getPendingUpload(uploadId);
63
81
  if (pendingFile) {
64
82
  log.debug?.("user accepted file consent, uploading", {
65
83
  uploadId,
@@ -101,9 +119,7 @@ async function handleFileConsentInvoke(
101
119
  }
102
120
  } else {
103
121
  log.debug?.("pending file not found for consent", { uploadId });
104
- await context.sendActivity(
105
- "The file upload request has expired. Please try sending the file again.",
106
- );
122
+ await context.sendActivity(expiredUploadMessage);
107
123
  }
108
124
  } else {
109
125
  // User declined