@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 +12 -0
- package/package.json +2 -3
- package/src/directory-live.ts +13 -92
- package/src/file-lock.ts +1 -0
- package/src/graph.ts +92 -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.ts +11 -1
- package/src/resolve-allowlist.ts +10 -92
- package/src/store-fs.ts +3 -13
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/msteams",
|
|
3
|
-
"version": "2026.2.
|
|
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:*"
|
package/src/directory-live.ts
CHANGED
|
@@ -1,95 +1,16 @@
|
|
|
1
|
-
import type { ChannelDirectoryEntry
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
type
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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;
|
package/src/file-lock.ts
ADDED
|
@@ -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);
|
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
|
|
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/resolve-allowlist.ts
CHANGED
|
@@ -1,26 +1,13 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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
|
}
|