@openclaw/msteams 2026.2.9 → 2026.2.13

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.13
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
3
9
  ## 2026.2.6-3
4
10
 
5
11
  ### Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/msteams",
3
- "version": "2026.2.9",
3
+ "version": "2026.2.13",
4
4
  "description": "OpenClaw Microsoft Teams channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -0,0 +1,189 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ type FileLockOptions = {
5
+ retries: {
6
+ retries: number;
7
+ factor: number;
8
+ minTimeout: number;
9
+ maxTimeout: number;
10
+ randomize?: boolean;
11
+ };
12
+ stale: number;
13
+ };
14
+
15
+ type LockFilePayload = {
16
+ pid: number;
17
+ createdAt: string;
18
+ };
19
+
20
+ type HeldLock = {
21
+ count: number;
22
+ handle: fs.FileHandle;
23
+ lockPath: string;
24
+ };
25
+
26
+ const HELD_LOCKS_KEY = Symbol.for("openclaw.msteamsFileLockHeldLocks");
27
+
28
+ function resolveHeldLocks(): Map<string, HeldLock> {
29
+ const proc = process as NodeJS.Process & {
30
+ [HELD_LOCKS_KEY]?: Map<string, HeldLock>;
31
+ };
32
+ if (!proc[HELD_LOCKS_KEY]) {
33
+ proc[HELD_LOCKS_KEY] = new Map<string, HeldLock>();
34
+ }
35
+ return proc[HELD_LOCKS_KEY];
36
+ }
37
+
38
+ const HELD_LOCKS = resolveHeldLocks();
39
+
40
+ function isAlive(pid: number): boolean {
41
+ if (!Number.isFinite(pid) || pid <= 0) {
42
+ return false;
43
+ }
44
+ try {
45
+ process.kill(pid, 0);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ function computeDelayMs(retries: FileLockOptions["retries"], attempt: number): number {
53
+ const base = Math.min(
54
+ retries.maxTimeout,
55
+ Math.max(retries.minTimeout, retries.minTimeout * retries.factor ** attempt),
56
+ );
57
+ const jitter = retries.randomize ? 1 + Math.random() : 1;
58
+ return Math.min(retries.maxTimeout, Math.round(base * jitter));
59
+ }
60
+
61
+ async function readLockPayload(lockPath: string): Promise<LockFilePayload | null> {
62
+ try {
63
+ const raw = await fs.readFile(lockPath, "utf8");
64
+ const parsed = JSON.parse(raw) as Partial<LockFilePayload>;
65
+ if (typeof parsed.pid !== "number" || typeof parsed.createdAt !== "string") {
66
+ return null;
67
+ }
68
+ return { pid: parsed.pid, createdAt: parsed.createdAt };
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ async function resolveNormalizedFilePath(filePath: string): Promise<string> {
75
+ const resolved = path.resolve(filePath);
76
+ const dir = path.dirname(resolved);
77
+ await fs.mkdir(dir, { recursive: true });
78
+ try {
79
+ const realDir = await fs.realpath(dir);
80
+ return path.join(realDir, path.basename(resolved));
81
+ } catch {
82
+ return resolved;
83
+ }
84
+ }
85
+
86
+ async function isStaleLock(lockPath: string, staleMs: number): Promise<boolean> {
87
+ const payload = await readLockPayload(lockPath);
88
+ if (payload?.pid && !isAlive(payload.pid)) {
89
+ return true;
90
+ }
91
+ if (payload?.createdAt) {
92
+ const createdAt = Date.parse(payload.createdAt);
93
+ if (!Number.isFinite(createdAt) || Date.now() - createdAt > staleMs) {
94
+ return true;
95
+ }
96
+ }
97
+ try {
98
+ const stat = await fs.stat(lockPath);
99
+ return Date.now() - stat.mtimeMs > staleMs;
100
+ } catch {
101
+ return true;
102
+ }
103
+ }
104
+
105
+ type FileLockHandle = {
106
+ release: () => Promise<void>;
107
+ };
108
+
109
+ async function acquireFileLock(
110
+ filePath: string,
111
+ options: FileLockOptions,
112
+ ): Promise<FileLockHandle> {
113
+ const normalizedFile = await resolveNormalizedFilePath(filePath);
114
+ const lockPath = `${normalizedFile}.lock`;
115
+ const held = HELD_LOCKS.get(normalizedFile);
116
+ if (held) {
117
+ held.count += 1;
118
+ return {
119
+ release: async () => {
120
+ const current = HELD_LOCKS.get(normalizedFile);
121
+ if (!current) {
122
+ return;
123
+ }
124
+ current.count -= 1;
125
+ if (current.count > 0) {
126
+ return;
127
+ }
128
+ HELD_LOCKS.delete(normalizedFile);
129
+ await current.handle.close().catch(() => undefined);
130
+ await fs.rm(current.lockPath, { force: true }).catch(() => undefined);
131
+ },
132
+ };
133
+ }
134
+
135
+ const attempts = Math.max(1, options.retries.retries + 1);
136
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
137
+ try {
138
+ const handle = await fs.open(lockPath, "wx");
139
+ await handle.writeFile(
140
+ JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2),
141
+ "utf8",
142
+ );
143
+ HELD_LOCKS.set(normalizedFile, { count: 1, handle, lockPath });
144
+ return {
145
+ release: async () => {
146
+ const current = HELD_LOCKS.get(normalizedFile);
147
+ if (!current) {
148
+ return;
149
+ }
150
+ current.count -= 1;
151
+ if (current.count > 0) {
152
+ return;
153
+ }
154
+ HELD_LOCKS.delete(normalizedFile);
155
+ await current.handle.close().catch(() => undefined);
156
+ await fs.rm(current.lockPath, { force: true }).catch(() => undefined);
157
+ },
158
+ };
159
+ } catch (err) {
160
+ const code = (err as { code?: string }).code;
161
+ if (code !== "EEXIST") {
162
+ throw err;
163
+ }
164
+ if (await isStaleLock(lockPath, options.stale)) {
165
+ await fs.rm(lockPath, { force: true }).catch(() => undefined);
166
+ continue;
167
+ }
168
+ if (attempt >= attempts - 1) {
169
+ break;
170
+ }
171
+ await new Promise((resolve) => setTimeout(resolve, computeDelayMs(options.retries, attempt)));
172
+ }
173
+ }
174
+
175
+ throw new Error(`file lock timeout for ${normalizedFile}`);
176
+ }
177
+
178
+ export async function withFileLock<T>(
179
+ filePath: string,
180
+ options: FileLockOptions,
181
+ fn: () => Promise<T>,
182
+ ): Promise<T> {
183
+ const lock = await acquireFileLock(filePath, options);
184
+ try {
185
+ return await fn();
186
+ } finally {
187
+ await lock.release();
188
+ }
189
+ }
@@ -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
 
@@ -454,8 +454,19 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
454
454
  });
455
455
  }
456
456
 
457
+ const inboundHistory =
458
+ isRoomish && historyKey && historyLimit > 0
459
+ ? (conversationHistories.get(historyKey) ?? []).map((entry) => ({
460
+ sender: entry.sender,
461
+ body: entry.body,
462
+ timestamp: entry.timestamp,
463
+ }))
464
+ : undefined;
465
+
457
466
  const ctxPayload = core.channel.reply.finalizeInboundContext({
458
467
  Body: combinedBody,
468
+ BodyForAgent: rawBody,
469
+ InboundHistory: inboundHistory,
459
470
  RawBody: rawBody,
460
471
  CommandBody: rawBody,
461
472
  From: teamsFrom,
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
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
  }