@openclaw/msteams 2026.2.12 → 2026.2.14

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.2.14
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.2.13
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
3
15
  ## 2026.2.6-3
4
16
 
5
17
  ### Changes
package/package.json CHANGED
@@ -1,14 +1,13 @@
1
1
  {
2
2
  "name": "@openclaw/msteams",
3
- "version": "2026.2.12",
3
+ "version": "2026.2.14",
4
4
  "description": "OpenClaw Microsoft Teams channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@microsoft/agents-hosting": "^1.2.3",
8
8
  "@microsoft/agents-hosting-express": "^1.2.3",
9
9
  "@microsoft/agents-hosting-extensions-teams": "^1.2.3",
10
- "express": "^5.2.1",
11
- "proper-lockfile": "^4.1.2"
10
+ "express": "^5.2.1"
12
11
  },
13
12
  "devDependencies": {
14
13
  "openclaw": "workspace:*"
@@ -1,95 +1,16 @@
1
- import type { ChannelDirectoryEntry, MSTeamsConfig } from "openclaw/plugin-sdk";
2
- import { GRAPH_ROOT } from "./attachments/shared.js";
3
- import { loadMSTeamsSdkWithAuth } from "./sdk.js";
4
- import { resolveMSTeamsCredentials } from "./token.js";
5
-
6
- type GraphUser = {
7
- id?: string;
8
- displayName?: string;
9
- userPrincipalName?: string;
10
- mail?: string;
11
- };
12
-
13
- type GraphGroup = {
14
- id?: string;
15
- displayName?: string;
16
- };
17
-
18
- type GraphChannel = {
19
- id?: string;
20
- displayName?: string;
21
- };
22
-
23
- type GraphResponse<T> = { value?: T[] };
24
-
25
- function readAccessToken(value: unknown): string | null {
26
- if (typeof value === "string") {
27
- return value;
28
- }
29
- if (value && typeof value === "object") {
30
- const token =
31
- (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
32
- return typeof token === "string" ? token : null;
33
- }
34
- return null;
35
- }
36
-
37
- function normalizeQuery(value?: string | null): string {
38
- return value?.trim() ?? "";
39
- }
40
-
41
- function escapeOData(value: string): string {
42
- return value.replace(/'/g, "''");
43
- }
44
-
45
- async function fetchGraphJson<T>(params: {
46
- token: string;
47
- path: string;
48
- headers?: Record<string, string>;
49
- }): Promise<T> {
50
- const res = await fetch(`${GRAPH_ROOT}${params.path}`, {
51
- headers: {
52
- Authorization: `Bearer ${params.token}`,
53
- ...params.headers,
54
- },
55
- });
56
- if (!res.ok) {
57
- const text = await res.text().catch(() => "");
58
- throw new Error(`Graph ${params.path} failed (${res.status}): ${text || "unknown error"}`);
59
- }
60
- return (await res.json()) as T;
61
- }
62
-
63
- async function resolveGraphToken(cfg: unknown): Promise<string> {
64
- const creds = resolveMSTeamsCredentials(
65
- (cfg as { channels?: { msteams?: unknown } })?.channels?.msteams as MSTeamsConfig | undefined,
66
- );
67
- if (!creds) {
68
- throw new Error("MS Teams credentials missing");
69
- }
70
- const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
71
- const tokenProvider = new sdk.MsalTokenProvider(authConfig);
72
- const token = await tokenProvider.getAccessToken("https://graph.microsoft.com");
73
- const accessToken = readAccessToken(token);
74
- if (!accessToken) {
75
- throw new Error("MS Teams graph token unavailable");
76
- }
77
- return accessToken;
78
- }
79
-
80
- async function listTeamsByName(token: string, query: string): Promise<GraphGroup[]> {
81
- const escaped = escapeOData(query);
82
- const filter = `resourceProvisioningOptions/Any(x:x eq 'Team') and startsWith(displayName,'${escaped}')`;
83
- const path = `/groups?$filter=${encodeURIComponent(filter)}&$select=id,displayName`;
84
- const res = await fetchGraphJson<GraphResponse<GraphGroup>>({ token, path });
85
- return res.value ?? [];
86
- }
87
-
88
- async function listChannelsForTeam(token: string, teamId: string): Promise<GraphChannel[]> {
89
- const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`;
90
- const res = await fetchGraphJson<GraphResponse<GraphChannel>>({ token, path });
91
- return res.value ?? [];
92
- }
1
+ import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk";
2
+ import {
3
+ escapeOData,
4
+ fetchGraphJson,
5
+ type GraphChannel,
6
+ type GraphGroup,
7
+ type GraphResponse,
8
+ type GraphUser,
9
+ listChannelsForTeam,
10
+ listTeamsByName,
11
+ normalizeQuery,
12
+ resolveGraphToken,
13
+ } from "./graph.js";
93
14
 
94
15
  export async function listMSTeamsDirectoryPeersLive(params: {
95
16
  cfg: unknown;
@@ -0,0 +1 @@
1
+ export { withFileLock } from "openclaw/plugin-sdk";
package/src/graph.ts ADDED
@@ -0,0 +1,92 @@
1
+ import type { MSTeamsConfig } from "openclaw/plugin-sdk";
2
+ import { GRAPH_ROOT } from "./attachments/shared.js";
3
+ import { loadMSTeamsSdkWithAuth } from "./sdk.js";
4
+ import { resolveMSTeamsCredentials } from "./token.js";
5
+
6
+ export type GraphUser = {
7
+ id?: string;
8
+ displayName?: string;
9
+ userPrincipalName?: string;
10
+ mail?: string;
11
+ };
12
+
13
+ export type GraphGroup = {
14
+ id?: string;
15
+ displayName?: string;
16
+ };
17
+
18
+ export type GraphChannel = {
19
+ id?: string;
20
+ displayName?: string;
21
+ };
22
+
23
+ export type GraphResponse<T> = { value?: T[] };
24
+
25
+ function readAccessToken(value: unknown): string | null {
26
+ if (typeof value === "string") {
27
+ return value;
28
+ }
29
+ if (value && typeof value === "object") {
30
+ const token =
31
+ (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
32
+ return typeof token === "string" ? token : null;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ export function normalizeQuery(value?: string | null): string {
38
+ return value?.trim() ?? "";
39
+ }
40
+
41
+ export function escapeOData(value: string): string {
42
+ return value.replace(/'/g, "''");
43
+ }
44
+
45
+ export async function fetchGraphJson<T>(params: {
46
+ token: string;
47
+ path: string;
48
+ headers?: Record<string, string>;
49
+ }): Promise<T> {
50
+ const res = await fetch(`${GRAPH_ROOT}${params.path}`, {
51
+ headers: {
52
+ Authorization: `Bearer ${params.token}`,
53
+ ...params.headers,
54
+ },
55
+ });
56
+ if (!res.ok) {
57
+ const text = await res.text().catch(() => "");
58
+ throw new Error(`Graph ${params.path} failed (${res.status}): ${text || "unknown error"}`);
59
+ }
60
+ return (await res.json()) as T;
61
+ }
62
+
63
+ export async function resolveGraphToken(cfg: unknown): Promise<string> {
64
+ const creds = resolveMSTeamsCredentials(
65
+ (cfg as { channels?: { msteams?: unknown } })?.channels?.msteams as MSTeamsConfig | undefined,
66
+ );
67
+ if (!creds) {
68
+ throw new Error("MS Teams credentials missing");
69
+ }
70
+ const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
71
+ const tokenProvider = new sdk.MsalTokenProvider(authConfig);
72
+ const token = await tokenProvider.getAccessToken("https://graph.microsoft.com");
73
+ const accessToken = readAccessToken(token);
74
+ if (!accessToken) {
75
+ throw new Error("MS Teams graph token unavailable");
76
+ }
77
+ return accessToken;
78
+ }
79
+
80
+ export async function listTeamsByName(token: string, query: string): Promise<GraphGroup[]> {
81
+ const escaped = escapeOData(query);
82
+ const filter = `resourceProvisioningOptions/Any(x:x eq 'Team') and startsWith(displayName,'${escaped}')`;
83
+ const path = `/groups?$filter=${encodeURIComponent(filter)}&$select=id,displayName`;
84
+ const res = await fetchGraphJson<GraphResponse<GraphGroup>>({ token, path });
85
+ return res.value ?? [];
86
+ }
87
+
88
+ export async function listChannelsForTeam(token: string, teamId: string): Promise<GraphChannel[]> {
89
+ const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`;
90
+ const res = await fetchGraphJson<GraphResponse<GraphChannel>>({ token, path });
91
+ return res.value ?? [];
92
+ }
@@ -145,6 +145,15 @@ describe("msteams media-helpers", () => {
145
145
  expect(isLocalPath("~/Downloads/image.png")).toBe(true);
146
146
  });
147
147
 
148
+ it("returns true for Windows absolute drive paths", () => {
149
+ expect(isLocalPath("C:\\Users\\test\\image.png")).toBe(true);
150
+ expect(isLocalPath("D:/data/photo.jpg")).toBe(true);
151
+ });
152
+
153
+ it("returns true for Windows UNC paths", () => {
154
+ expect(isLocalPath("\\\\server\\share\\image.png")).toBe(true);
155
+ });
156
+
148
157
  it("returns false for http URLs", () => {
149
158
  expect(isLocalPath("http://example.com/image.png")).toBe(false);
150
159
  expect(isLocalPath("https://example.com/image.png")).toBe(false);
@@ -65,7 +65,21 @@ export async function extractFilename(url: string): Promise<string> {
65
65
  * Check if a URL refers to a local file path.
66
66
  */
67
67
  export function isLocalPath(url: string): boolean {
68
- return url.startsWith("file://") || url.startsWith("/") || url.startsWith("~");
68
+ if (url.startsWith("file://") || url.startsWith("/") || url.startsWith("~")) {
69
+ return true;
70
+ }
71
+
72
+ // Windows drive-letter absolute path (e.g. C:\foo\bar.txt or C:/foo/bar.txt)
73
+ if (/^[a-zA-Z]:[\\/]/.test(url)) {
74
+ return true;
75
+ }
76
+
77
+ // Windows UNC path (e.g. \\server\share\file.txt)
78
+ if (url.startsWith("\\\\")) {
79
+ return true;
80
+ }
81
+
82
+ return false;
69
83
  }
70
84
 
71
85
  /**
@@ -0,0 +1,235 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildMentionEntities, formatMentionText, parseMentions } from "./mentions.js";
3
+
4
+ describe("parseMentions", () => {
5
+ it("parses single mention", () => {
6
+ const result = parseMentions("Hello @[John Doe](28:a1b2c3-d4e5f6)!");
7
+
8
+ expect(result.text).toBe("Hello <at>John Doe</at>!");
9
+ expect(result.entities).toHaveLength(1);
10
+ expect(result.entities[0]).toEqual({
11
+ type: "mention",
12
+ text: "<at>John Doe</at>",
13
+ mentioned: {
14
+ id: "28:a1b2c3-d4e5f6",
15
+ name: "John Doe",
16
+ },
17
+ });
18
+ });
19
+
20
+ it("parses multiple mentions", () => {
21
+ const result = parseMentions("Hey @[Alice](28:aaa) and @[Bob](28:bbb), can you review this?");
22
+
23
+ expect(result.text).toBe("Hey <at>Alice</at> and <at>Bob</at>, can you review this?");
24
+ expect(result.entities).toHaveLength(2);
25
+ expect(result.entities[0]).toEqual({
26
+ type: "mention",
27
+ text: "<at>Alice</at>",
28
+ mentioned: {
29
+ id: "28:aaa",
30
+ name: "Alice",
31
+ },
32
+ });
33
+ expect(result.entities[1]).toEqual({
34
+ type: "mention",
35
+ text: "<at>Bob</at>",
36
+ mentioned: {
37
+ id: "28:bbb",
38
+ name: "Bob",
39
+ },
40
+ });
41
+ });
42
+
43
+ it("handles text without mentions", () => {
44
+ const result = parseMentions("Hello world!");
45
+
46
+ expect(result.text).toBe("Hello world!");
47
+ expect(result.entities).toHaveLength(0);
48
+ });
49
+
50
+ it("handles empty text", () => {
51
+ const result = parseMentions("");
52
+
53
+ expect(result.text).toBe("");
54
+ expect(result.entities).toHaveLength(0);
55
+ });
56
+
57
+ it("handles mention with spaces in name", () => {
58
+ const result = parseMentions("@[John Peter Smith](28:a1b2c3)");
59
+
60
+ expect(result.text).toBe("<at>John Peter Smith</at>");
61
+ expect(result.entities[0]?.mentioned.name).toBe("John Peter Smith");
62
+ });
63
+
64
+ it("trims whitespace from id and name", () => {
65
+ const result = parseMentions("@[ John Doe ]( 28:a1b2c3 )");
66
+
67
+ expect(result.entities[0]).toEqual({
68
+ type: "mention",
69
+ text: "<at>John Doe</at>",
70
+ mentioned: {
71
+ id: "28:a1b2c3",
72
+ name: "John Doe",
73
+ },
74
+ });
75
+ });
76
+
77
+ it("handles Japanese characters in mention at start of message", () => {
78
+ const input = "@[タナカ タロウ](a1b2c3d4-e5f6-7890-abcd-ef1234567890) スキル化完了しました!";
79
+ const result = parseMentions(input);
80
+
81
+ expect(result.text).toBe("<at>タナカ タロウ</at> スキル化完了しました!");
82
+ expect(result.entities).toHaveLength(1);
83
+ expect(result.entities[0]).toEqual({
84
+ type: "mention",
85
+ text: "<at>タナカ タロウ</at>",
86
+ mentioned: {
87
+ id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
88
+ name: "タナカ タロウ",
89
+ },
90
+ });
91
+
92
+ // Verify entity text exactly matches what's in the formatted text
93
+ const entityText = result.entities[0]?.text;
94
+ expect(result.text).toContain(entityText);
95
+ expect(result.text.indexOf(entityText)).toBe(0);
96
+ });
97
+
98
+ it("skips mention-like patterns with non-Teams IDs (e.g. in code blocks)", () => {
99
+ // This reproduces the actual failing payload: the message contains a real mention
100
+ // plus `@[表示名](ユーザーID)` as documentation text inside backticks.
101
+ const input =
102
+ "@[タナカ タロウ](a1b2c3d4-e5f6-7890-abcd-ef1234567890) スキル化完了しました!📋\n\n" +
103
+ "**作成したスキル:** `teams-mention`\n" +
104
+ "- 機能: Teamsでのメンション形式 `@[表示名](ユーザーID)`\n\n" +
105
+ "**追加対応:**\n" +
106
+ "- ユーザーのID `a1b2c3d4-e5f6-7890-abcd-ef1234567890` を登録済み";
107
+ const result = parseMentions(input);
108
+
109
+ // Only the real mention should be parsed; the documentation example should be left as-is
110
+ expect(result.entities).toHaveLength(1);
111
+ expect(result.entities[0]?.mentioned.id).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
112
+ expect(result.entities[0]?.mentioned.name).toBe("タナカ タロウ");
113
+
114
+ // The documentation pattern must remain untouched in the text
115
+ expect(result.text).toContain("`@[表示名](ユーザーID)`");
116
+ });
117
+
118
+ it("accepts Bot Framework IDs (28:xxx)", () => {
119
+ const result = parseMentions("@[Bot](28:abc-123)");
120
+ expect(result.entities).toHaveLength(1);
121
+ expect(result.entities[0]?.mentioned.id).toBe("28:abc-123");
122
+ });
123
+
124
+ it("accepts Bot Framework IDs with non-hex payloads (29:xxx)", () => {
125
+ const result = parseMentions("@[Bot](29:08q2j2o3jc09au90eucae)");
126
+ expect(result.entities).toHaveLength(1);
127
+ expect(result.entities[0]?.mentioned.id).toBe("29:08q2j2o3jc09au90eucae");
128
+ });
129
+
130
+ it("accepts org-scoped IDs with extra segments (8:orgid:...)", () => {
131
+ const result = parseMentions("@[User](8:orgid:2d8c2d2c-1111-2222-3333-444444444444)");
132
+ expect(result.entities).toHaveLength(1);
133
+ expect(result.entities[0]?.mentioned.id).toBe("8:orgid:2d8c2d2c-1111-2222-3333-444444444444");
134
+ });
135
+
136
+ it("accepts AAD object IDs (UUIDs)", () => {
137
+ const result = parseMentions("@[User](a1b2c3d4-e5f6-7890-abcd-ef1234567890)");
138
+ expect(result.entities).toHaveLength(1);
139
+ expect(result.entities[0]?.mentioned.id).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
140
+ });
141
+
142
+ it("rejects non-ID strings as mention targets", () => {
143
+ const result = parseMentions("See @[docs](https://example.com) for details");
144
+ expect(result.entities).toHaveLength(0);
145
+ // Original text preserved
146
+ expect(result.text).toBe("See @[docs](https://example.com) for details");
147
+ });
148
+ });
149
+
150
+ describe("buildMentionEntities", () => {
151
+ it("builds entities from mention info", () => {
152
+ const mentions = [
153
+ { id: "28:aaa", name: "Alice" },
154
+ { id: "28:bbb", name: "Bob" },
155
+ ];
156
+
157
+ const entities = buildMentionEntities(mentions);
158
+
159
+ expect(entities).toHaveLength(2);
160
+ expect(entities[0]).toEqual({
161
+ type: "mention",
162
+ text: "<at>Alice</at>",
163
+ mentioned: {
164
+ id: "28:aaa",
165
+ name: "Alice",
166
+ },
167
+ });
168
+ expect(entities[1]).toEqual({
169
+ type: "mention",
170
+ text: "<at>Bob</at>",
171
+ mentioned: {
172
+ id: "28:bbb",
173
+ name: "Bob",
174
+ },
175
+ });
176
+ });
177
+
178
+ it("handles empty list", () => {
179
+ const entities = buildMentionEntities([]);
180
+ expect(entities).toHaveLength(0);
181
+ });
182
+ });
183
+
184
+ describe("formatMentionText", () => {
185
+ it("formats text with single mention", () => {
186
+ const text = "Hello @John!";
187
+ const mentions = [{ id: "28:xxx", name: "John" }];
188
+
189
+ const result = formatMentionText(text, mentions);
190
+
191
+ expect(result).toBe("Hello <at>John</at>!");
192
+ });
193
+
194
+ it("formats text with multiple mentions", () => {
195
+ const text = "Hey @Alice and @Bob";
196
+ const mentions = [
197
+ { id: "28:aaa", name: "Alice" },
198
+ { id: "28:bbb", name: "Bob" },
199
+ ];
200
+
201
+ const result = formatMentionText(text, mentions);
202
+
203
+ expect(result).toBe("Hey <at>Alice</at> and <at>Bob</at>");
204
+ });
205
+
206
+ it("handles case-insensitive matching", () => {
207
+ const text = "Hey @alice and @ALICE";
208
+ const mentions = [{ id: "28:aaa", name: "Alice" }];
209
+
210
+ const result = formatMentionText(text, mentions);
211
+
212
+ expect(result).toBe("Hey <at>Alice</at> and <at>Alice</at>");
213
+ });
214
+
215
+ it("handles text without mentions", () => {
216
+ const text = "Hello world";
217
+ const mentions = [{ id: "28:xxx", name: "John" }];
218
+
219
+ const result = formatMentionText(text, mentions);
220
+
221
+ expect(result).toBe("Hello world");
222
+ });
223
+
224
+ it("escapes regex metacharacters in names", () => {
225
+ const text = "Hey @John(Test) and @Alice.Smith";
226
+ const mentions = [
227
+ { id: "28:xxx", name: "John(Test)" },
228
+ { id: "28:yyy", name: "Alice.Smith" },
229
+ ];
230
+
231
+ const result = formatMentionText(text, mentions);
232
+
233
+ expect(result).toBe("Hey <at>John(Test)</at> and <at>Alice.Smith</at>");
234
+ });
235
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * MS Teams mention handling utilities.
3
+ *
4
+ * Mentions in Teams require:
5
+ * 1. Text containing <at>Name</at> tags
6
+ * 2. entities array with mention metadata
7
+ */
8
+
9
+ export type MentionEntity = {
10
+ type: "mention";
11
+ text: string;
12
+ mentioned: {
13
+ id: string;
14
+ name: string;
15
+ };
16
+ };
17
+
18
+ export type MentionInfo = {
19
+ /** User/bot ID (e.g., "28:xxx" or AAD object ID) */
20
+ id: string;
21
+ /** Display name */
22
+ name: string;
23
+ };
24
+
25
+ /**
26
+ * Check whether an ID looks like a valid Teams user/bot identifier.
27
+ * Accepts:
28
+ * - Bot Framework IDs: "28:xxx..." / "29:xxx..." / "8:orgid:..."
29
+ * - AAD object IDs (UUIDs): "d5318c29-33ac-4e6b-bd42-57b8b793908f"
30
+ *
31
+ * Keep this permissive enough for real Teams IDs while still rejecting
32
+ * documentation placeholders like `@[表示名](ユーザーID)`.
33
+ */
34
+ const TEAMS_BOT_ID_PATTERN = /^\d+:[a-z0-9._=-]+(?::[a-z0-9._=-]+)*$/i;
35
+ const AAD_OBJECT_ID_PATTERN = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
36
+
37
+ function isValidTeamsId(id: string): boolean {
38
+ return TEAMS_BOT_ID_PATTERN.test(id) || AAD_OBJECT_ID_PATTERN.test(id);
39
+ }
40
+
41
+ /**
42
+ * Parse mentions from text in the format @[Name](id).
43
+ * Example: "Hello @[John Doe](28:xxx-yyy-zzz)!"
44
+ *
45
+ * Only matches where the id looks like a real Teams user/bot ID are treated
46
+ * as mentions. This avoids false positives from documentation or code samples
47
+ * embedded in the message (e.g. `@[表示名](ユーザーID)` in backticks).
48
+ *
49
+ * Returns both the formatted text with <at> tags and the entities array.
50
+ */
51
+ export function parseMentions(text: string): {
52
+ text: string;
53
+ entities: MentionEntity[];
54
+ } {
55
+ const mentionPattern = /@\[([^\]]+)\]\(([^)]+)\)/g;
56
+ const entities: MentionEntity[] = [];
57
+
58
+ // Replace @[Name](id) with <at>Name</at> only for valid Teams IDs
59
+ const formattedText = text.replace(mentionPattern, (match, name, id) => {
60
+ const trimmedId = id.trim();
61
+
62
+ // Skip matches where the id doesn't look like a real Teams identifier
63
+ if (!isValidTeamsId(trimmedId)) {
64
+ return match;
65
+ }
66
+
67
+ const trimmedName = name.trim();
68
+ const mentionTag = `<at>${trimmedName}</at>`;
69
+ entities.push({
70
+ type: "mention",
71
+ text: mentionTag,
72
+ mentioned: {
73
+ id: trimmedId,
74
+ name: trimmedName,
75
+ },
76
+ });
77
+ return mentionTag;
78
+ });
79
+
80
+ return {
81
+ text: formattedText,
82
+ entities,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Build mention entities array from a list of mentions.
88
+ * Use this when you already have the mention info and formatted text.
89
+ */
90
+ export function buildMentionEntities(mentions: MentionInfo[]): MentionEntity[] {
91
+ return mentions.map((mention) => ({
92
+ type: "mention",
93
+ text: `<at>${mention.name}</at>`,
94
+ mentioned: {
95
+ id: mention.id,
96
+ name: mention.name,
97
+ },
98
+ }));
99
+ }
100
+
101
+ /**
102
+ * Format text with mentions using <at> tags.
103
+ * This is a convenience function when you want to manually format mentions.
104
+ */
105
+ export function formatMentionText(text: string, mentions: MentionInfo[]): string {
106
+ let formatted = text;
107
+ for (const mention of mentions) {
108
+ // Replace @Name or @name with <at>Name</at>
109
+ const escapedName = mention.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
110
+ const namePattern = new RegExp(`@${escapedName}`, "gi");
111
+ formatted = formatted.replace(namePattern, `<at>${mention.name}</at>`);
112
+ }
113
+ return formatted;
114
+ }
@@ -1,6 +1,21 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
1
4
  import { SILENT_REPLY_TOKEN, type PluginRuntime } from "openclaw/plugin-sdk";
2
- import { beforeEach, describe, expect, it } from "vitest";
5
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
6
  import type { StoredConversationReference } from "./conversation-store.js";
7
+ const graphUploadMockState = vi.hoisted(() => ({
8
+ uploadAndShareOneDrive: vi.fn(),
9
+ }));
10
+
11
+ vi.mock("./graph-upload.js", async () => {
12
+ const actual = await vi.importActual<typeof import("./graph-upload.js")>("./graph-upload.js");
13
+ return {
14
+ ...actual,
15
+ uploadAndShareOneDrive: graphUploadMockState.uploadAndShareOneDrive,
16
+ };
17
+ });
18
+
4
19
  import {
5
20
  type MSTeamsAdapter,
6
21
  renderReplyPayloadsToMessages,
@@ -36,6 +51,13 @@ const runtimeStub = {
36
51
  describe("msteams messenger", () => {
37
52
  beforeEach(() => {
38
53
  setMSTeamsRuntime(runtimeStub);
54
+ graphUploadMockState.uploadAndShareOneDrive.mockReset();
55
+ graphUploadMockState.uploadAndShareOneDrive.mockResolvedValue({
56
+ itemId: "item123",
57
+ webUrl: "https://onedrive.example.com/item123",
58
+ shareUrl: "https://onedrive.example.com/share/item123",
59
+ name: "upload.txt",
60
+ });
39
61
  });
40
62
 
41
63
  describe("renderReplyPayloadsToMessages", () => {
@@ -153,6 +175,64 @@ describe("msteams messenger", () => {
153
175
  expect(ref.conversation?.id).toBe("19:abc@thread.tacv2");
154
176
  });
155
177
 
178
+ it("preserves parsed mentions when appending OneDrive fallback file links", async () => {
179
+ const tmpDir = await mkdtemp(path.join(os.tmpdir(), "msteams-mention-"));
180
+ const localFile = path.join(tmpDir, "note.txt");
181
+ await writeFile(localFile, "hello");
182
+
183
+ try {
184
+ const sent: Array<{ text?: string; entities?: unknown[] }> = [];
185
+ const ctx = {
186
+ sendActivity: async (activity: unknown) => {
187
+ sent.push(activity as { text?: string; entities?: unknown[] });
188
+ return { id: "id:one" };
189
+ },
190
+ };
191
+
192
+ const adapter: MSTeamsAdapter = {
193
+ continueConversation: async () => {},
194
+ };
195
+
196
+ const ids = await sendMSTeamsMessages({
197
+ replyStyle: "thread",
198
+ adapter,
199
+ appId: "app123",
200
+ conversationRef: {
201
+ ...baseRef,
202
+ conversation: {
203
+ ...baseRef.conversation,
204
+ conversationType: "channel",
205
+ },
206
+ },
207
+ context: ctx,
208
+ messages: [{ text: "Hello @[John](29:08q2j2o3jc09au90eucae)", mediaUrl: localFile }],
209
+ tokenProvider: {
210
+ getAccessToken: async () => "token",
211
+ },
212
+ });
213
+
214
+ expect(ids).toEqual(["id:one"]);
215
+ expect(graphUploadMockState.uploadAndShareOneDrive).toHaveBeenCalledOnce();
216
+ expect(sent).toHaveLength(1);
217
+ expect(sent[0]?.text).toContain("Hello <at>John</at>");
218
+ expect(sent[0]?.text).toContain(
219
+ "📎 [upload.txt](https://onedrive.example.com/share/item123)",
220
+ );
221
+ expect(sent[0]?.entities).toEqual([
222
+ {
223
+ type: "mention",
224
+ text: "<at>John</at>",
225
+ mentioned: {
226
+ id: "29:08q2j2o3jc09au90eucae",
227
+ name: "John",
228
+ },
229
+ },
230
+ ]);
231
+ } finally {
232
+ await rm(tmpDir, { recursive: true, force: true });
233
+ }
234
+ });
235
+
156
236
  it("retries thread sends on throttling (429)", async () => {
157
237
  const attempts: string[] = [];
158
238
  const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = [];
package/src/messenger.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  uploadAndShareSharePoint,
20
20
  } from "./graph-upload.js";
21
21
  import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
22
+ import { parseMentions } from "./mentions.js";
22
23
  import { getMSTeamsRuntime } from "./runtime.js";
23
24
 
24
25
  /**
@@ -269,7 +270,14 @@ async function buildActivity(
269
270
  const activity: Record<string, unknown> = { type: "message" };
270
271
 
271
272
  if (msg.text) {
272
- activity.text = msg.text;
273
+ // Parse mentions from text (format: @[Name](id))
274
+ const { text: formattedText, entities } = parseMentions(msg.text);
275
+ activity.text = formattedText;
276
+
277
+ // Add mention entities if any mentions were found
278
+ if (entities.length > 0) {
279
+ activity.entities = entities;
280
+ }
273
281
  }
274
282
 
275
283
  if (msg.mediaUrl) {
@@ -350,7 +358,8 @@ async function buildActivity(
350
358
 
351
359
  // Bot Framework doesn't support "reference" attachment type for sending
352
360
  const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
353
- activity.text = msg.text ? `${msg.text}\n\n${fileLink}` : fileLink;
361
+ const existingText = typeof activity.text === "string" ? activity.text : undefined;
362
+ activity.text = existingText ? `${existingText}\n\n${fileLink}` : fileLink;
354
363
  return activity;
355
364
  }
356
365
 
package/src/monitor.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Request, Response } from "express";
2
2
  import {
3
+ DEFAULT_WEBHOOK_MAX_BODY_BYTES,
3
4
  mergeAllowlist,
4
5
  summarizeMapping,
5
6
  type OpenClawConfig,
@@ -32,6 +33,8 @@ export type MonitorMSTeamsResult = {
32
33
  shutdown: () => Promise<void>;
33
34
  };
34
35
 
36
+ const MSTEAMS_WEBHOOK_MAX_BODY_BYTES = DEFAULT_WEBHOOK_MAX_BODY_BYTES;
37
+
35
38
  export async function monitorMSTeamsProvider(
36
39
  opts: MonitorMSTeamsOpts,
37
40
  ): Promise<MonitorMSTeamsResult> {
@@ -239,7 +242,14 @@ export async function monitorMSTeamsProvider(
239
242
 
240
243
  // Create Express server
241
244
  const expressApp = express.default();
242
- expressApp.use(express.json());
245
+ expressApp.use(express.json({ limit: MSTEAMS_WEBHOOK_MAX_BODY_BYTES }));
246
+ expressApp.use((err: unknown, _req: Request, res: Response, next: (err?: unknown) => void) => {
247
+ if (err && typeof err === "object" && "status" in err && err.status === 413) {
248
+ res.status(413).json({ error: "Payload too large" });
249
+ return;
250
+ }
251
+ next(err);
252
+ });
243
253
  expressApp.use(authorizeJWT(authConfig));
244
254
 
245
255
  // Set up the messages endpoint - use configured path and /api/messages as fallback
@@ -1,26 +1,13 @@
1
- import type { MSTeamsConfig } from "openclaw/plugin-sdk";
2
- import { GRAPH_ROOT } from "./attachments/shared.js";
3
- import { loadMSTeamsSdkWithAuth } from "./sdk.js";
4
- import { resolveMSTeamsCredentials } from "./token.js";
5
-
6
- type GraphUser = {
7
- id?: string;
8
- displayName?: string;
9
- userPrincipalName?: string;
10
- mail?: string;
11
- };
12
-
13
- type GraphGroup = {
14
- id?: string;
15
- displayName?: string;
16
- };
17
-
18
- type GraphChannel = {
19
- id?: string;
20
- displayName?: string;
21
- };
22
-
23
- type GraphResponse<T> = { value?: T[] };
1
+ import {
2
+ escapeOData,
3
+ fetchGraphJson,
4
+ type GraphResponse,
5
+ type GraphUser,
6
+ listChannelsForTeam,
7
+ listTeamsByName,
8
+ normalizeQuery,
9
+ resolveGraphToken,
10
+ } from "./graph.js";
24
11
 
25
12
  export type MSTeamsChannelResolution = {
26
13
  input: string;
@@ -40,18 +27,6 @@ export type MSTeamsUserResolution = {
40
27
  note?: string;
41
28
  };
42
29
 
43
- function readAccessToken(value: unknown): string | null {
44
- if (typeof value === "string") {
45
- return value;
46
- }
47
- if (value && typeof value === "object") {
48
- const token =
49
- (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
50
- return typeof token === "string" ? token : null;
51
- }
52
- return null;
53
- }
54
-
55
30
  function stripProviderPrefix(raw: string): string {
56
31
  return raw.replace(/^(msteams|teams):/i, "");
57
32
  }
@@ -128,63 +103,6 @@ export function parseMSTeamsTeamEntry(
128
103
  };
129
104
  }
130
105
 
131
- function normalizeQuery(value?: string | null): string {
132
- return value?.trim() ?? "";
133
- }
134
-
135
- function escapeOData(value: string): string {
136
- return value.replace(/'/g, "''");
137
- }
138
-
139
- async function fetchGraphJson<T>(params: {
140
- token: string;
141
- path: string;
142
- headers?: Record<string, string>;
143
- }): Promise<T> {
144
- const res = await fetch(`${GRAPH_ROOT}${params.path}`, {
145
- headers: {
146
- Authorization: `Bearer ${params.token}`,
147
- ...params.headers,
148
- },
149
- });
150
- if (!res.ok) {
151
- const text = await res.text().catch(() => "");
152
- throw new Error(`Graph ${params.path} failed (${res.status}): ${text || "unknown error"}`);
153
- }
154
- return (await res.json()) as T;
155
- }
156
-
157
- async function resolveGraphToken(cfg: unknown): Promise<string> {
158
- const creds = resolveMSTeamsCredentials(
159
- (cfg as { channels?: { msteams?: unknown } })?.channels?.msteams as MSTeamsConfig | undefined,
160
- );
161
- if (!creds) {
162
- throw new Error("MS Teams credentials missing");
163
- }
164
- const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
165
- const tokenProvider = new sdk.MsalTokenProvider(authConfig);
166
- const token = await tokenProvider.getAccessToken("https://graph.microsoft.com");
167
- const accessToken = readAccessToken(token);
168
- if (!accessToken) {
169
- throw new Error("MS Teams graph token unavailable");
170
- }
171
- return accessToken;
172
- }
173
-
174
- async function listTeamsByName(token: string, query: string): Promise<GraphGroup[]> {
175
- const escaped = escapeOData(query);
176
- const filter = `resourceProvisioningOptions/Any(x:x eq 'Team') and startsWith(displayName,'${escaped}')`;
177
- const path = `/groups?$filter=${encodeURIComponent(filter)}&$select=id,displayName`;
178
- const res = await fetchGraphJson<GraphResponse<GraphGroup>>({ token, path });
179
- return res.value ?? [];
180
- }
181
-
182
- async function listChannelsForTeam(token: string, teamId: string): Promise<GraphChannel[]> {
183
- const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`;
184
- const res = await fetchGraphJson<GraphResponse<GraphChannel>>({ token, path });
185
- return res.value ?? [];
186
- }
187
-
188
106
  export async function resolveMSTeamsChannelAllowlist(params: {
189
107
  cfg: unknown;
190
108
  entries: string[];
package/src/store-fs.ts CHANGED
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { safeParseJson } from "openclaw/plugin-sdk";
5
- import lockfile from "proper-lockfile";
5
+ import { withFileLock as withPathLock } from "./file-lock.js";
6
6
 
7
7
  const STORE_LOCK_OPTIONS = {
8
8
  retries: {
@@ -60,17 +60,7 @@ export async function withFileLock<T>(
60
60
  fn: () => Promise<T>,
61
61
  ): Promise<T> {
62
62
  await ensureJsonFile(filePath, fallback);
63
- let release: (() => Promise<void>) | undefined;
64
- try {
65
- release = await lockfile.lock(filePath, STORE_LOCK_OPTIONS);
63
+ return await withPathLock(filePath, STORE_LOCK_OPTIONS, async () => {
66
64
  return await fn();
67
- } finally {
68
- if (release) {
69
- try {
70
- await release();
71
- } catch {
72
- // ignore unlock errors
73
- }
74
- }
75
- }
65
+ });
76
66
  }