@openclaw/msteams 2026.2.19 → 2026.2.22
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 +1 -115
- package/package.json +2 -4
- package/src/attachments/download.ts +67 -68
- package/src/attachments/graph.ts +29 -28
- package/src/attachments/remote-media.ts +42 -0
- package/src/attachments/shared.test.ts +279 -0
- package/src/attachments/shared.ts +113 -0
- package/src/attachments.test.ts +126 -18
- package/src/channel.ts +9 -2
- package/src/directory-live.ts +2 -20
- package/src/graph-upload.ts +0 -1
- package/src/graph-users.test.ts +66 -0
- package/src/graph-users.ts +29 -0
- package/src/graph.ts +1 -12
- package/src/media-helpers.test.ts +4 -0
- package/src/media-helpers.ts +5 -0
- package/src/messenger.test.ts +2 -1
- package/src/messenger.ts +12 -23
- package/src/monitor-handler/message-handler.ts +7 -5
- package/src/probe.ts +1 -12
- package/src/resolve-allowlist.ts +2 -20
- package/src/sent-message-cache.ts +3 -6
- package/src/token-response.test.ts +23 -0
- package/src/token-response.ts +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,120 +1,6 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 2026.2.
|
|
4
|
-
|
|
5
|
-
### Changes
|
|
6
|
-
|
|
7
|
-
- Version alignment with core OpenClaw release numbers.
|
|
8
|
-
|
|
9
|
-
## 2026.2.16
|
|
10
|
-
|
|
11
|
-
### Changes
|
|
12
|
-
|
|
13
|
-
- Version alignment with core OpenClaw release numbers.
|
|
14
|
-
|
|
15
|
-
## 2026.2.15
|
|
16
|
-
|
|
17
|
-
### Changes
|
|
18
|
-
|
|
19
|
-
- Version alignment with core OpenClaw release numbers.
|
|
20
|
-
|
|
21
|
-
## 2026.2.14
|
|
22
|
-
|
|
23
|
-
### Changes
|
|
24
|
-
|
|
25
|
-
- Version alignment with core OpenClaw release numbers.
|
|
26
|
-
|
|
27
|
-
## 2026.2.13
|
|
28
|
-
|
|
29
|
-
### Changes
|
|
30
|
-
|
|
31
|
-
- Version alignment with core OpenClaw release numbers.
|
|
32
|
-
|
|
33
|
-
## 2026.2.6-3
|
|
34
|
-
|
|
35
|
-
### Changes
|
|
36
|
-
|
|
37
|
-
- Version alignment with core OpenClaw release numbers.
|
|
38
|
-
|
|
39
|
-
## 2026.2.6-2
|
|
40
|
-
|
|
41
|
-
### Changes
|
|
42
|
-
|
|
43
|
-
- Version alignment with core OpenClaw release numbers.
|
|
44
|
-
|
|
45
|
-
## 2026.2.6
|
|
46
|
-
|
|
47
|
-
### Changes
|
|
48
|
-
|
|
49
|
-
- Version alignment with core OpenClaw release numbers.
|
|
50
|
-
|
|
51
|
-
## 2026.2.4
|
|
52
|
-
|
|
53
|
-
### Changes
|
|
54
|
-
|
|
55
|
-
- Version alignment with core OpenClaw release numbers.
|
|
56
|
-
|
|
57
|
-
## 2026.2.2
|
|
58
|
-
|
|
59
|
-
### Changes
|
|
60
|
-
|
|
61
|
-
- Version alignment with core OpenClaw release numbers.
|
|
62
|
-
|
|
63
|
-
## 2026.1.31
|
|
64
|
-
|
|
65
|
-
### Changes
|
|
66
|
-
|
|
67
|
-
- Version alignment with core OpenClaw release numbers.
|
|
68
|
-
|
|
69
|
-
## 2026.1.30
|
|
70
|
-
|
|
71
|
-
### Changes
|
|
72
|
-
|
|
73
|
-
- Version alignment with core OpenClaw release numbers.
|
|
74
|
-
|
|
75
|
-
## 2026.1.29
|
|
76
|
-
|
|
77
|
-
### Changes
|
|
78
|
-
|
|
79
|
-
- Version alignment with core OpenClaw release numbers.
|
|
80
|
-
|
|
81
|
-
## 2026.1.23
|
|
82
|
-
|
|
83
|
-
### Changes
|
|
84
|
-
|
|
85
|
-
- Version alignment with core OpenClaw release numbers.
|
|
86
|
-
|
|
87
|
-
## 2026.1.22
|
|
88
|
-
|
|
89
|
-
### Changes
|
|
90
|
-
|
|
91
|
-
- Version alignment with core OpenClaw release numbers.
|
|
92
|
-
|
|
93
|
-
## 2026.1.21
|
|
94
|
-
|
|
95
|
-
### Changes
|
|
96
|
-
|
|
97
|
-
- Version alignment with core OpenClaw release numbers.
|
|
98
|
-
|
|
99
|
-
## 2026.1.20
|
|
100
|
-
|
|
101
|
-
### Changes
|
|
102
|
-
|
|
103
|
-
- Version alignment with core OpenClaw release numbers.
|
|
104
|
-
|
|
105
|
-
## 2026.1.17-1
|
|
106
|
-
|
|
107
|
-
### Changes
|
|
108
|
-
|
|
109
|
-
- Version alignment with core OpenClaw release numbers.
|
|
110
|
-
|
|
111
|
-
## 2026.1.17
|
|
112
|
-
|
|
113
|
-
### Changes
|
|
114
|
-
|
|
115
|
-
- Version alignment with core OpenClaw release numbers.
|
|
116
|
-
|
|
117
|
-
## 2026.1.16
|
|
3
|
+
## 2026.2.22
|
|
118
4
|
|
|
119
5
|
### Changes
|
|
120
6
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/msteams",
|
|
3
|
-
"version": "2026.2.
|
|
3
|
+
"version": "2026.2.22",
|
|
4
4
|
"description": "OpenClaw Microsoft Teams channel plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
|
-
"@microsoft/agents-hosting": "^1.
|
|
8
|
-
"@microsoft/agents-hosting-express": "^1.2.3",
|
|
9
|
-
"@microsoft/agents-hosting-extensions-teams": "^1.2.3",
|
|
7
|
+
"@microsoft/agents-hosting": "^1.3.1",
|
|
10
8
|
"express": "^5.2.1"
|
|
11
9
|
},
|
|
12
10
|
"devDependencies": {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getMSTeamsRuntime } from "../runtime.js";
|
|
2
|
+
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
|
|
2
3
|
import {
|
|
3
4
|
extractInlineImageCandidates,
|
|
4
5
|
inferPlaceholder,
|
|
@@ -6,8 +7,10 @@ import {
|
|
|
6
7
|
isRecord,
|
|
7
8
|
isUrlAllowed,
|
|
8
9
|
normalizeContentType,
|
|
10
|
+
resolveRequestUrl,
|
|
9
11
|
resolveAuthAllowedHosts,
|
|
10
12
|
resolveAllowedHosts,
|
|
13
|
+
safeFetch,
|
|
11
14
|
} from "./shared.js";
|
|
12
15
|
import type {
|
|
13
16
|
MSTeamsAccessTokenProvider,
|
|
@@ -86,11 +89,22 @@ async function fetchWithAuthFallback(params: {
|
|
|
86
89
|
url: string;
|
|
87
90
|
tokenProvider?: MSTeamsAccessTokenProvider;
|
|
88
91
|
fetchFn?: typeof fetch;
|
|
92
|
+
requestInit?: RequestInit;
|
|
89
93
|
allowHosts: string[];
|
|
90
94
|
authAllowHosts: string[];
|
|
95
|
+
resolveFn?: (hostname: string) => Promise<{ address: string }>;
|
|
91
96
|
}): Promise<Response> {
|
|
92
97
|
const fetchFn = params.fetchFn ?? fetch;
|
|
93
|
-
|
|
98
|
+
|
|
99
|
+
// Use safeFetch for the initial attempt — redirect: "manual" with
|
|
100
|
+
// allowlist + DNS/IP validation on every hop (prevents SSRF via redirect).
|
|
101
|
+
const firstAttempt = await safeFetch({
|
|
102
|
+
url: params.url,
|
|
103
|
+
allowHosts: params.allowHosts,
|
|
104
|
+
fetchFn,
|
|
105
|
+
requestInit: params.requestInit,
|
|
106
|
+
resolveFn: params.resolveFn,
|
|
107
|
+
});
|
|
94
108
|
if (firstAttempt.ok) {
|
|
95
109
|
return firstAttempt;
|
|
96
110
|
}
|
|
@@ -108,31 +122,42 @@ async function fetchWithAuthFallback(params: {
|
|
|
108
122
|
for (const scope of scopes) {
|
|
109
123
|
try {
|
|
110
124
|
const token = await params.tokenProvider.getAccessToken(scope);
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
125
|
+
const authHeaders = new Headers(params.requestInit?.headers);
|
|
126
|
+
authHeaders.set("Authorization", `Bearer ${token}`);
|
|
127
|
+
const authAttempt = await safeFetch({
|
|
128
|
+
url: params.url,
|
|
129
|
+
allowHosts: params.allowHosts,
|
|
130
|
+
fetchFn,
|
|
131
|
+
requestInit: {
|
|
132
|
+
...params.requestInit,
|
|
133
|
+
headers: authHeaders,
|
|
134
|
+
},
|
|
135
|
+
resolveFn: params.resolveFn,
|
|
114
136
|
});
|
|
115
|
-
if (
|
|
116
|
-
return
|
|
137
|
+
if (authAttempt.ok) {
|
|
138
|
+
return authAttempt;
|
|
139
|
+
}
|
|
140
|
+
if (authAttempt.status !== 401 && authAttempt.status !== 403) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const finalUrl =
|
|
145
|
+
typeof authAttempt.url === "string" && authAttempt.url ? authAttempt.url : "";
|
|
146
|
+
if (!finalUrl || finalUrl === params.url || !isUrlAllowed(finalUrl, params.authAllowHosts)) {
|
|
147
|
+
continue;
|
|
117
148
|
}
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
redirect: "manual",
|
|
131
|
-
});
|
|
132
|
-
if (redirectAuthRes.ok) {
|
|
133
|
-
return redirectAuthRes;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
149
|
+
const redirectedAuthAttempt = await safeFetch({
|
|
150
|
+
url: finalUrl,
|
|
151
|
+
allowHosts: params.allowHosts,
|
|
152
|
+
fetchFn,
|
|
153
|
+
requestInit: {
|
|
154
|
+
...params.requestInit,
|
|
155
|
+
headers: authHeaders,
|
|
156
|
+
},
|
|
157
|
+
resolveFn: params.resolveFn,
|
|
158
|
+
});
|
|
159
|
+
if (redirectedAuthAttempt.ok) {
|
|
160
|
+
return redirectedAuthAttempt;
|
|
136
161
|
}
|
|
137
162
|
} catch {
|
|
138
163
|
// Try the next scope.
|
|
@@ -142,21 +167,6 @@ async function fetchWithAuthFallback(params: {
|
|
|
142
167
|
return firstAttempt;
|
|
143
168
|
}
|
|
144
169
|
|
|
145
|
-
function readRedirectUrl(baseUrl: string, res: Response): string | null {
|
|
146
|
-
if (![301, 302, 303, 307, 308].includes(res.status)) {
|
|
147
|
-
return null;
|
|
148
|
-
}
|
|
149
|
-
const location = res.headers.get("location");
|
|
150
|
-
if (!location) {
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
-
try {
|
|
154
|
-
return new URL(location, baseUrl).toString();
|
|
155
|
-
} catch {
|
|
156
|
-
return null;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
170
|
/**
|
|
161
171
|
* Download all file attachments from a Teams message (images, documents, etc.).
|
|
162
172
|
* Renamed from downloadMSTeamsImageAttachments to support all file types.
|
|
@@ -170,6 +180,8 @@ export async function downloadMSTeamsAttachments(params: {
|
|
|
170
180
|
fetchFn?: typeof fetch;
|
|
171
181
|
/** When true, embeds original filename in stored path for later extraction. */
|
|
172
182
|
preserveFilenames?: boolean;
|
|
183
|
+
/** Override DNS resolver for testing (anti-SSRF IP validation). */
|
|
184
|
+
resolveFn?: (hostname: string) => Promise<{ address: string }>;
|
|
173
185
|
}): Promise<MSTeamsInboundMedia[]> {
|
|
174
186
|
const list = Array.isArray(params.attachments) ? params.attachments : [];
|
|
175
187
|
if (list.length === 0) {
|
|
@@ -238,38 +250,25 @@ export async function downloadMSTeamsAttachments(params: {
|
|
|
238
250
|
continue;
|
|
239
251
|
}
|
|
240
252
|
try {
|
|
241
|
-
const
|
|
253
|
+
const media = await downloadAndStoreMSTeamsRemoteMedia({
|
|
242
254
|
url: candidate.url,
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
authAllowHosts,
|
|
247
|
-
});
|
|
248
|
-
if (!res.ok) {
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
const buffer = Buffer.from(await res.arrayBuffer());
|
|
252
|
-
if (buffer.byteLength > params.maxBytes) {
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
const mime = await getMSTeamsRuntime().media.detectMime({
|
|
256
|
-
buffer,
|
|
257
|
-
headerMime: res.headers.get("content-type"),
|
|
258
|
-
filePath: candidate.fileHint ?? candidate.url,
|
|
259
|
-
});
|
|
260
|
-
const originalFilename = params.preserveFilenames ? candidate.fileHint : undefined;
|
|
261
|
-
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
|
262
|
-
buffer,
|
|
263
|
-
mime ?? candidate.contentTypeHint,
|
|
264
|
-
"inbound",
|
|
265
|
-
params.maxBytes,
|
|
266
|
-
originalFilename,
|
|
267
|
-
);
|
|
268
|
-
out.push({
|
|
269
|
-
path: saved.path,
|
|
270
|
-
contentType: saved.contentType,
|
|
255
|
+
filePathHint: candidate.fileHint ?? candidate.url,
|
|
256
|
+
maxBytes: params.maxBytes,
|
|
257
|
+
contentTypeHint: candidate.contentTypeHint,
|
|
271
258
|
placeholder: candidate.placeholder,
|
|
259
|
+
preserveFilenames: params.preserveFilenames,
|
|
260
|
+
fetchImpl: (input, init) =>
|
|
261
|
+
fetchWithAuthFallback({
|
|
262
|
+
url: resolveRequestUrl(input),
|
|
263
|
+
tokenProvider: params.tokenProvider,
|
|
264
|
+
fetchFn: params.fetchFn,
|
|
265
|
+
requestInit: init,
|
|
266
|
+
allowHosts,
|
|
267
|
+
authAllowHosts,
|
|
268
|
+
resolveFn: params.resolveFn,
|
|
269
|
+
}),
|
|
272
270
|
});
|
|
271
|
+
out.push(media);
|
|
273
272
|
} catch {
|
|
274
273
|
// Ignore download failures and continue with next candidate.
|
|
275
274
|
}
|
package/src/attachments/graph.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { getMSTeamsRuntime } from "../runtime.js";
|
|
2
2
|
import { downloadMSTeamsAttachments } from "./download.js";
|
|
3
|
+
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
|
|
3
4
|
import {
|
|
4
5
|
GRAPH_ROOT,
|
|
5
6
|
inferPlaceholder,
|
|
6
7
|
isRecord,
|
|
8
|
+
isUrlAllowed,
|
|
7
9
|
normalizeContentType,
|
|
10
|
+
resolveRequestUrl,
|
|
8
11
|
resolveAllowedHosts,
|
|
12
|
+
safeFetch,
|
|
9
13
|
} from "./shared.js";
|
|
10
14
|
import type {
|
|
11
15
|
MSTeamsAccessTokenProvider,
|
|
@@ -262,38 +266,35 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|
|
262
266
|
try {
|
|
263
267
|
// SharePoint URLs need to be accessed via Graph shares API
|
|
264
268
|
const shareUrl = att.contentUrl!;
|
|
269
|
+
if (!isUrlAllowed(shareUrl, allowHosts)) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
265
272
|
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
|
|
266
273
|
const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
|
|
267
274
|
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
275
|
+
const media = await downloadAndStoreMSTeamsRemoteMedia({
|
|
276
|
+
url: sharesUrl,
|
|
277
|
+
filePathHint: name,
|
|
278
|
+
maxBytes: params.maxBytes,
|
|
279
|
+
contentTypeHint: "application/octet-stream",
|
|
280
|
+
preserveFilenames: params.preserveFilenames,
|
|
281
|
+
fetchImpl: async (input, init) => {
|
|
282
|
+
const requestUrl = resolveRequestUrl(input);
|
|
283
|
+
const headers = new Headers(init?.headers);
|
|
284
|
+
headers.set("Authorization", `Bearer ${accessToken}`);
|
|
285
|
+
return await safeFetch({
|
|
286
|
+
url: requestUrl,
|
|
287
|
+
allowHosts,
|
|
288
|
+
fetchFn,
|
|
289
|
+
requestInit: {
|
|
290
|
+
...init,
|
|
291
|
+
headers,
|
|
292
|
+
},
|
|
280
293
|
});
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
"inbound",
|
|
286
|
-
params.maxBytes,
|
|
287
|
-
originalFilename,
|
|
288
|
-
);
|
|
289
|
-
sharePointMedia.push({
|
|
290
|
-
path: saved.path,
|
|
291
|
-
contentType: saved.contentType,
|
|
292
|
-
placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: name }),
|
|
293
|
-
});
|
|
294
|
-
downloadedReferenceUrls.add(shareUrl);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
sharePointMedia.push(media);
|
|
297
|
+
downloadedReferenceUrls.add(shareUrl);
|
|
297
298
|
} catch {
|
|
298
299
|
// Ignore SharePoint download failures.
|
|
299
300
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { getMSTeamsRuntime } from "../runtime.js";
|
|
2
|
+
import { inferPlaceholder } from "./shared.js";
|
|
3
|
+
import type { MSTeamsInboundMedia } from "./types.js";
|
|
4
|
+
|
|
5
|
+
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
6
|
+
|
|
7
|
+
export async function downloadAndStoreMSTeamsRemoteMedia(params: {
|
|
8
|
+
url: string;
|
|
9
|
+
filePathHint: string;
|
|
10
|
+
maxBytes: number;
|
|
11
|
+
fetchImpl?: FetchLike;
|
|
12
|
+
contentTypeHint?: string;
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
preserveFilenames?: boolean;
|
|
15
|
+
}): Promise<MSTeamsInboundMedia> {
|
|
16
|
+
const fetched = await getMSTeamsRuntime().channel.media.fetchRemoteMedia({
|
|
17
|
+
url: params.url,
|
|
18
|
+
fetchImpl: params.fetchImpl,
|
|
19
|
+
filePathHint: params.filePathHint,
|
|
20
|
+
maxBytes: params.maxBytes,
|
|
21
|
+
});
|
|
22
|
+
const mime = await getMSTeamsRuntime().media.detectMime({
|
|
23
|
+
buffer: fetched.buffer,
|
|
24
|
+
headerMime: fetched.contentType ?? params.contentTypeHint,
|
|
25
|
+
filePath: params.filePathHint,
|
|
26
|
+
});
|
|
27
|
+
const originalFilename = params.preserveFilenames ? params.filePathHint : undefined;
|
|
28
|
+
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
|
29
|
+
fetched.buffer,
|
|
30
|
+
mime ?? params.contentTypeHint,
|
|
31
|
+
"inbound",
|
|
32
|
+
params.maxBytes,
|
|
33
|
+
originalFilename,
|
|
34
|
+
);
|
|
35
|
+
return {
|
|
36
|
+
path: saved.path,
|
|
37
|
+
contentType: saved.contentType,
|
|
38
|
+
placeholder:
|
|
39
|
+
params.placeholder ??
|
|
40
|
+
inferPlaceholder({ contentType: saved.contentType, fileName: params.filePathHint }),
|
|
41
|
+
};
|
|
42
|
+
}
|