@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 +6 -0
- package/package.json +1 -1
- package/src/file-lock.ts +189 -0
- package/src/media-helpers.test.ts +9 -0
- package/src/media-helpers.ts +15 -1
- package/src/mentions.test.ts +235 -0
- package/src/mentions.ts +114 -0
- package/src/messenger.test.ts +81 -1
- package/src/messenger.ts +11 -2
- package/src/monitor-handler/message-handler.ts +11 -0
- package/src/monitor.ts +11 -1
- package/src/store-fs.ts +3 -13
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
package/src/file-lock.ts
ADDED
|
@@ -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);
|
package/src/media-helpers.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
package/src/mentions.ts
ADDED
|
@@ -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
|
+
}
|
package/src/messenger.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
68
|
-
if (release) {
|
|
69
|
-
try {
|
|
70
|
-
await release();
|
|
71
|
-
} catch {
|
|
72
|
-
// ignore unlock errors
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
65
|
+
});
|
|
76
66
|
}
|