@openclaw/msteams 2026.3.13 → 2026.5.2-beta.1
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/api.ts +3 -0
- package/channel-config-api.ts +1 -0
- package/channel-plugin-api.ts +2 -0
- package/config-api.ts +4 -0
- package/contract-api.ts +4 -0
- package/index.ts +15 -12
- package/openclaw.plugin.json +553 -1
- package/package.json +46 -12
- package/runtime-api.ts +73 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/setup-plugin-api.ts +3 -0
- package/src/ai-entity.ts +7 -0
- package/src/approval-auth.ts +44 -0
- package/src/attachments/bot-framework.test.ts +461 -0
- package/src/attachments/bot-framework.ts +362 -0
- package/src/attachments/download.ts +63 -19
- package/src/attachments/graph.test.ts +416 -0
- package/src/attachments/graph.ts +163 -72
- package/src/attachments/html.ts +33 -1
- package/src/attachments/payload.ts +1 -1
- package/src/attachments/remote-media.test.ts +137 -0
- package/src/attachments/remote-media.ts +75 -8
- package/src/attachments/shared.test.ts +138 -1
- package/src/attachments/shared.ts +193 -26
- package/src/attachments/types.ts +10 -0
- package/src/attachments.graph.test.ts +342 -0
- package/src/attachments.helpers.test.ts +246 -0
- package/src/attachments.test-helpers.ts +17 -0
- package/src/attachments.test.ts +163 -418
- package/src/attachments.ts +5 -5
- package/src/block-streaming-config.test.ts +61 -0
- package/src/channel-api.ts +1 -0
- package/src/channel.actions.test.ts +742 -0
- package/src/channel.directory.test.ts +145 -4
- package/src/channel.runtime.ts +56 -0
- package/src/channel.setup.ts +77 -0
- package/src/channel.test.ts +128 -0
- package/src/channel.ts +1077 -395
- package/src/config-schema.ts +6 -0
- package/src/config-ui-hints.ts +12 -0
- package/src/conversation-store-fs.test.ts +4 -5
- package/src/conversation-store-fs.ts +35 -51
- package/src/conversation-store-helpers.test.ts +202 -0
- package/src/conversation-store-helpers.ts +105 -0
- package/src/conversation-store-memory.ts +27 -23
- package/src/conversation-store.shared.test.ts +225 -0
- package/src/conversation-store.ts +30 -0
- package/src/directory-live.test.ts +156 -0
- package/src/directory-live.ts +7 -4
- package/src/doctor.ts +27 -0
- package/src/errors.test.ts +64 -1
- package/src/errors.ts +50 -9
- package/src/feedback-reflection-prompt.ts +117 -0
- package/src/feedback-reflection-store.ts +114 -0
- package/src/feedback-reflection.test.ts +237 -0
- package/src/feedback-reflection.ts +283 -0
- package/src/file-consent-helpers.test.ts +83 -0
- package/src/file-consent-helpers.ts +64 -11
- package/src/file-consent-invoke.ts +150 -0
- package/src/file-consent.test.ts +363 -0
- package/src/file-consent.ts +165 -4
- package/src/graph-chat.ts +5 -3
- package/src/graph-group-management.test.ts +318 -0
- package/src/graph-group-management.ts +168 -0
- package/src/graph-members.test.ts +89 -0
- package/src/graph-members.ts +48 -0
- package/src/graph-messages.actions.test.ts +243 -0
- package/src/graph-messages.read.test.ts +391 -0
- package/src/graph-messages.search.test.ts +213 -0
- package/src/graph-messages.test-helpers.ts +50 -0
- package/src/graph-messages.ts +534 -0
- package/src/graph-teams.test.ts +215 -0
- package/src/graph-teams.ts +114 -0
- package/src/graph-thread.test.ts +246 -0
- package/src/graph-thread.ts +146 -0
- package/src/graph-upload.test.ts +161 -4
- package/src/graph-upload.ts +147 -56
- package/src/graph.test.ts +516 -0
- package/src/graph.ts +233 -21
- package/src/inbound.test.ts +156 -1
- package/src/inbound.ts +101 -1
- package/src/media-helpers.ts +1 -1
- package/src/mentions.test.ts +27 -18
- package/src/mentions.ts +2 -2
- package/src/messenger.test.ts +504 -23
- package/src/messenger.ts +133 -52
- package/src/monitor-handler/access.ts +125 -0
- package/src/monitor-handler/inbound-media.test.ts +289 -0
- package/src/monitor-handler/inbound-media.ts +57 -5
- package/src/monitor-handler/message-handler-mock-support.test-support.ts +28 -0
- package/src/monitor-handler/message-handler.authz.test.ts +588 -74
- package/src/monitor-handler/message-handler.dm-media.test.ts +54 -0
- package/src/monitor-handler/message-handler.test-support.ts +100 -0
- package/src/monitor-handler/message-handler.thread-parent.test.ts +223 -0
- package/src/monitor-handler/message-handler.thread-session.test.ts +77 -0
- package/src/monitor-handler/message-handler.ts +470 -164
- package/src/monitor-handler/reaction-handler.test.ts +267 -0
- package/src/monitor-handler/reaction-handler.ts +210 -0
- package/src/monitor-handler/thread-session.ts +17 -0
- package/src/monitor-handler.adaptive-card.test.ts +162 -0
- package/src/monitor-handler.feedback-authz.test.ts +314 -0
- package/src/monitor-handler.file-consent.test.ts +281 -79
- package/src/monitor-handler.sso.test.ts +563 -0
- package/src/monitor-handler.test-helpers.ts +180 -0
- package/src/monitor-handler.ts +459 -115
- package/src/monitor-handler.types.ts +27 -0
- package/src/monitor-types.ts +1 -0
- package/src/monitor.lifecycle.test.ts +74 -10
- package/src/monitor.test.ts +35 -1
- package/src/monitor.ts +143 -46
- package/src/oauth.flow.ts +77 -0
- package/src/oauth.shared.ts +37 -0
- package/src/oauth.test.ts +305 -0
- package/src/oauth.token.ts +158 -0
- package/src/oauth.ts +130 -0
- package/src/outbound.test.ts +10 -11
- package/src/outbound.ts +62 -44
- package/src/pending-uploads-fs.test.ts +246 -0
- package/src/pending-uploads-fs.ts +235 -0
- package/src/pending-uploads.test.ts +173 -0
- package/src/pending-uploads.ts +34 -2
- package/src/policy.test.ts +11 -5
- package/src/policy.ts +5 -5
- package/src/polls.test.ts +106 -5
- package/src/polls.ts +15 -7
- package/src/presentation.ts +68 -0
- package/src/probe.test.ts +27 -8
- package/src/probe.ts +43 -9
- package/src/reply-dispatcher.test.ts +437 -0
- package/src/reply-dispatcher.ts +259 -73
- package/src/reply-stream-controller.test.ts +235 -0
- package/src/reply-stream-controller.ts +147 -0
- package/src/resolve-allowlist.test.ts +105 -1
- package/src/resolve-allowlist.ts +112 -7
- package/src/runtime.ts +6 -3
- package/src/sdk-types.ts +43 -3
- package/src/sdk.test.ts +666 -0
- package/src/sdk.ts +867 -16
- package/src/secret-contract.ts +49 -0
- package/src/secret-input.ts +1 -1
- package/src/send-context.ts +76 -9
- package/src/send.test.ts +389 -5
- package/src/send.ts +140 -32
- package/src/sent-message-cache.ts +30 -18
- package/src/session-route.ts +40 -0
- package/src/setup-core.ts +160 -0
- package/src/setup-surface.test.ts +202 -0
- package/src/setup-surface.ts +320 -0
- package/src/sso-token-store.test.ts +72 -0
- package/src/sso-token-store.ts +166 -0
- package/src/sso.ts +300 -0
- package/src/storage.ts +1 -1
- package/src/store-fs.ts +2 -2
- package/src/streaming-message.test.ts +262 -0
- package/src/streaming-message.ts +297 -0
- package/src/test-runtime.ts +1 -1
- package/src/thread-parent-context.test.ts +224 -0
- package/src/thread-parent-context.ts +159 -0
- package/src/token.test.ts +237 -50
- package/src/token.ts +162 -7
- package/src/user-agent.test.ts +86 -0
- package/src/user-agent.ts +53 -0
- package/src/webhook-timeouts.ts +27 -0
- package/src/welcome-card.test.ts +81 -0
- package/src/welcome-card.ts +57 -0
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -107
- package/src/file-lock.ts +0 -1
- package/src/graph-users.test.ts +0 -66
- package/src/onboarding.ts +0 -381
- package/src/polls-store.test.ts +0 -38
- package/src/revoked-context.test.ts +0 -39
- package/src/token-response.test.ts +0 -23
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { fetchGraphJson, type GraphResponse } from "./graph.js";
|
|
2
|
+
|
|
3
|
+
export type GraphThreadMessage = {
|
|
4
|
+
id?: string;
|
|
5
|
+
from?: {
|
|
6
|
+
user?: { displayName?: string; id?: string };
|
|
7
|
+
application?: { displayName?: string; id?: string };
|
|
8
|
+
};
|
|
9
|
+
body?: { content?: string; contentType?: string };
|
|
10
|
+
createdDateTime?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// TTL cache for team ID -> group GUID mapping.
|
|
14
|
+
const teamGroupIdCache = new Map<string, { groupId: string; expiresAt: number }>();
|
|
15
|
+
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Strip HTML tags from Teams message content, preserving @mention display names.
|
|
19
|
+
* Teams wraps mentions in <at>Name</at> tags.
|
|
20
|
+
*/
|
|
21
|
+
export function stripHtmlFromTeamsMessage(html: string): string {
|
|
22
|
+
// Preserve mention display names by replacing <at>Name</at> with @Name.
|
|
23
|
+
let text = html.replace(/<at[^>]*>(.*?)<\/at>/gi, "@$1");
|
|
24
|
+
// Strip remaining HTML tags.
|
|
25
|
+
text = text.replace(/<[^>]*>/g, " ");
|
|
26
|
+
// Decode common HTML entities.
|
|
27
|
+
text = text
|
|
28
|
+
.replace(/&/g, "&")
|
|
29
|
+
.replace(/</g, "<")
|
|
30
|
+
.replace(/>/g, ">")
|
|
31
|
+
.replace(/"/g, '"')
|
|
32
|
+
.replace(/'/g, "'")
|
|
33
|
+
.replace(/ /g, " ");
|
|
34
|
+
// Normalize whitespace.
|
|
35
|
+
return text.replace(/\s+/g, " ").trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the Azure AD group GUID for a Teams conversation team ID.
|
|
40
|
+
* Results are cached with a TTL to avoid repeated Graph API calls.
|
|
41
|
+
*/
|
|
42
|
+
export async function resolveTeamGroupId(
|
|
43
|
+
token: string,
|
|
44
|
+
conversationTeamId: string,
|
|
45
|
+
): Promise<string> {
|
|
46
|
+
const cached = teamGroupIdCache.get(conversationTeamId);
|
|
47
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
48
|
+
return cached.groupId;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The team ID in channelData is typically the group ID itself for standard teams.
|
|
52
|
+
// Validate by fetching /teams/{id} and returning the confirmed id.
|
|
53
|
+
// Requires Team.ReadBasic.All permission; fall back to raw ID if missing.
|
|
54
|
+
try {
|
|
55
|
+
const path = `/teams/${encodeURIComponent(conversationTeamId)}?$select=id`;
|
|
56
|
+
const team = await fetchGraphJson<{ id?: string }>({ token, path });
|
|
57
|
+
const groupId = team.id ?? conversationTeamId;
|
|
58
|
+
|
|
59
|
+
// Only cache when the Graph lookup succeeds — caching a fallback raw ID
|
|
60
|
+
// can cause silent failures for the entire TTL if the ID is not a valid
|
|
61
|
+
// Graph team GUID (e.g. Bot Framework conversation key).
|
|
62
|
+
teamGroupIdCache.set(conversationTeamId, {
|
|
63
|
+
groupId,
|
|
64
|
+
expiresAt: Date.now() + CACHE_TTL_MS,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return groupId;
|
|
68
|
+
} catch {
|
|
69
|
+
// Fallback to raw team ID without caching so subsequent calls retry the
|
|
70
|
+
// Graph lookup instead of using a potentially invalid cached value.
|
|
71
|
+
return conversationTeamId;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fetch a single channel message (the parent/root of a thread).
|
|
77
|
+
* Returns undefined on error so callers can degrade gracefully.
|
|
78
|
+
*/
|
|
79
|
+
export async function fetchChannelMessage(
|
|
80
|
+
token: string,
|
|
81
|
+
groupId: string,
|
|
82
|
+
channelId: string,
|
|
83
|
+
messageId: string,
|
|
84
|
+
): Promise<GraphThreadMessage | undefined> {
|
|
85
|
+
const path = `/teams/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}?$select=id,from,body,createdDateTime`;
|
|
86
|
+
try {
|
|
87
|
+
return await fetchGraphJson<GraphThreadMessage>({ token, path });
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Fetch thread replies for a channel message, ordered chronologically.
|
|
95
|
+
*
|
|
96
|
+
* **Limitation:** The Graph API replies endpoint (`/messages/{id}/replies`) does not
|
|
97
|
+
* support `$orderby`, so results are always returned in ascending (oldest-first) order.
|
|
98
|
+
* Combined with the `$top` cap of 50, this means only the **oldest 50 replies** are
|
|
99
|
+
* returned for long threads — newer replies are silently omitted. There is currently no
|
|
100
|
+
* Graph API workaround for this; pagination via `@odata.nextLink` can retrieve more
|
|
101
|
+
* replies but still in ascending order only.
|
|
102
|
+
*/
|
|
103
|
+
export async function fetchThreadReplies(
|
|
104
|
+
token: string,
|
|
105
|
+
groupId: string,
|
|
106
|
+
channelId: string,
|
|
107
|
+
messageId: string,
|
|
108
|
+
limit = 50,
|
|
109
|
+
): Promise<GraphThreadMessage[]> {
|
|
110
|
+
const top = Math.min(Math.max(limit, 1), 50);
|
|
111
|
+
// NOTE: Graph replies endpoint returns oldest-first and does not support $orderby.
|
|
112
|
+
// For threads with >50 replies, only the oldest 50 are returned. The most recent
|
|
113
|
+
// replies (often the most relevant context) may be truncated.
|
|
114
|
+
const path = `/teams/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/replies?$top=${top}&$select=id,from,body,createdDateTime`;
|
|
115
|
+
const res = await fetchGraphJson<GraphResponse<GraphThreadMessage>>({ token, path });
|
|
116
|
+
return res.value ?? [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Format thread messages into a context string for the agent.
|
|
121
|
+
* Skips the current message (by id) and blank messages.
|
|
122
|
+
*/
|
|
123
|
+
export function formatThreadContext(
|
|
124
|
+
messages: GraphThreadMessage[],
|
|
125
|
+
currentMessageId?: string,
|
|
126
|
+
): string {
|
|
127
|
+
const lines: string[] = [];
|
|
128
|
+
for (const msg of messages) {
|
|
129
|
+
if (msg.id && msg.id === currentMessageId) {
|
|
130
|
+
continue;
|
|
131
|
+
} // Skip the triggering message.
|
|
132
|
+
const sender = msg.from?.user?.displayName ?? msg.from?.application?.displayName ?? "unknown";
|
|
133
|
+
const contentType = msg.body?.contentType ?? "text";
|
|
134
|
+
const rawContent = msg.body?.content ?? "";
|
|
135
|
+
const content =
|
|
136
|
+
contentType === "html" ? stripHtmlFromTeamsMessage(rawContent) : rawContent.trim();
|
|
137
|
+
if (!content) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
lines.push(`${sender}: ${content}`);
|
|
141
|
+
}
|
|
142
|
+
return lines.join("\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Exported for testing only.
|
|
146
|
+
export { teamGroupIdCache as _teamGroupIdCacheForTest };
|
package/src/graph-upload.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
|
|
1
2
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
3
|
+
import { buildTeamsFileInfoCard } from "./graph-chat.js";
|
|
4
|
+
import { resolveGraphChatId, uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js";
|
|
3
5
|
|
|
4
6
|
describe("graph upload helpers", () => {
|
|
5
7
|
const tokenProvider = {
|
|
@@ -22,7 +24,7 @@ describe("graph upload helpers", () => {
|
|
|
22
24
|
buffer: Buffer.from("hello"),
|
|
23
25
|
filename: "a.txt",
|
|
24
26
|
tokenProvider,
|
|
25
|
-
fetchFn: fetchFn
|
|
27
|
+
fetchFn: withFetchPreconnect(fetchFn),
|
|
26
28
|
});
|
|
27
29
|
|
|
28
30
|
expect(fetchFn).toHaveBeenCalledWith(
|
|
@@ -32,6 +34,7 @@ describe("graph upload helpers", () => {
|
|
|
32
34
|
headers: expect.objectContaining({
|
|
33
35
|
Authorization: "Bearer graph-token",
|
|
34
36
|
"Content-Type": "application/octet-stream",
|
|
37
|
+
"User-Agent": expect.stringMatching(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/),
|
|
35
38
|
}),
|
|
36
39
|
}),
|
|
37
40
|
);
|
|
@@ -59,7 +62,7 @@ describe("graph upload helpers", () => {
|
|
|
59
62
|
filename: "b.txt",
|
|
60
63
|
siteId: "site-123",
|
|
61
64
|
tokenProvider,
|
|
62
|
-
fetchFn: fetchFn
|
|
65
|
+
fetchFn: withFetchPreconnect(fetchFn),
|
|
63
66
|
});
|
|
64
67
|
|
|
65
68
|
expect(fetchFn).toHaveBeenCalledWith(
|
|
@@ -69,6 +72,7 @@ describe("graph upload helpers", () => {
|
|
|
69
72
|
headers: expect.objectContaining({
|
|
70
73
|
Authorization: "Bearer graph-token",
|
|
71
74
|
"Content-Type": "application/octet-stream",
|
|
75
|
+
"User-Agent": expect.stringMatching(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/),
|
|
72
76
|
}),
|
|
73
77
|
}),
|
|
74
78
|
);
|
|
@@ -94,8 +98,161 @@ describe("graph upload helpers", () => {
|
|
|
94
98
|
filename: "bad.txt",
|
|
95
99
|
siteId: "site-123",
|
|
96
100
|
tokenProvider,
|
|
97
|
-
fetchFn: fetchFn
|
|
101
|
+
fetchFn: withFetchPreconnect(fetchFn),
|
|
98
102
|
}),
|
|
99
103
|
).rejects.toThrow("SharePoint upload response missing required fields");
|
|
100
104
|
});
|
|
101
105
|
});
|
|
106
|
+
|
|
107
|
+
describe("resolveGraphChatId", () => {
|
|
108
|
+
const tokenProvider = {
|
|
109
|
+
getAccessToken: vi.fn(async () => "graph-token"),
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
it("returns the ID directly when it already starts with 19:", async () => {
|
|
113
|
+
const fetchFn = vi.fn();
|
|
114
|
+
const result = await resolveGraphChatId({
|
|
115
|
+
botFrameworkConversationId: "19:abc123@thread.tacv2",
|
|
116
|
+
tokenProvider,
|
|
117
|
+
fetchFn: withFetchPreconnect(fetchFn),
|
|
118
|
+
});
|
|
119
|
+
// Should short-circuit without making any API call
|
|
120
|
+
expect(fetchFn).not.toHaveBeenCalled();
|
|
121
|
+
expect(result).toBe("19:abc123@thread.tacv2");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("resolves personal DM chat ID via Graph API using user AAD object ID", async () => {
|
|
125
|
+
const fetchFn = vi.fn(
|
|
126
|
+
async () =>
|
|
127
|
+
new Response(JSON.stringify({ value: [{ id: "19:dm-chat-id@unq.gbl.spaces" }] }), {
|
|
128
|
+
status: 200,
|
|
129
|
+
headers: { "content-type": "application/json" },
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const result = await resolveGraphChatId({
|
|
134
|
+
botFrameworkConversationId: "a:1abc_bot_framework_dm_id",
|
|
135
|
+
userAadObjectId: "user-aad-object-id-123",
|
|
136
|
+
tokenProvider,
|
|
137
|
+
fetchFn: withFetchPreconnect(fetchFn),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(fetchFn).toHaveBeenCalledWith(
|
|
141
|
+
expect.stringContaining("/me/chats"),
|
|
142
|
+
expect.objectContaining({
|
|
143
|
+
headers: expect.objectContaining({
|
|
144
|
+
Authorization: "Bearer graph-token",
|
|
145
|
+
"User-Agent": expect.stringMatching(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/),
|
|
146
|
+
}),
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
const firstCall = fetchFn.mock.calls[0];
|
|
150
|
+
if (!firstCall) {
|
|
151
|
+
throw new Error("expected Graph chat lookup request");
|
|
152
|
+
}
|
|
153
|
+
const [callUrlRaw] = firstCall as unknown as [string, RequestInit?];
|
|
154
|
+
const callUrl = new URL(callUrlRaw);
|
|
155
|
+
expect(callUrl.origin).toBe("https://graph.microsoft.com");
|
|
156
|
+
expect(callUrl.pathname).toBe("/v1.0/me/chats");
|
|
157
|
+
expect(callUrl.searchParams.get("$filter")).toBe(
|
|
158
|
+
"chatType eq 'oneOnOne' and members/any(m:m/microsoft.graph.aadUserConversationMember/userId eq 'user-aad-object-id-123')",
|
|
159
|
+
);
|
|
160
|
+
expect(callUrl.searchParams.get("$select")).toBe("id");
|
|
161
|
+
expect(result).toBe("19:dm-chat-id@unq.gbl.spaces");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("resolves personal DM chat ID without user AAD object ID (lists all 1:1 chats)", async () => {
|
|
165
|
+
const fetchFn = vi.fn(
|
|
166
|
+
async () =>
|
|
167
|
+
new Response(JSON.stringify({ value: [{ id: "19:fallback-chat@unq.gbl.spaces" }] }), {
|
|
168
|
+
status: 200,
|
|
169
|
+
headers: { "content-type": "application/json" },
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const result = await resolveGraphChatId({
|
|
174
|
+
botFrameworkConversationId: "8:orgid:user-object-id",
|
|
175
|
+
tokenProvider,
|
|
176
|
+
fetchFn: withFetchPreconnect(fetchFn),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(fetchFn).toHaveBeenCalledOnce();
|
|
180
|
+
expect(result).toBe("19:fallback-chat@unq.gbl.spaces");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns null when Graph API returns no chats", async () => {
|
|
184
|
+
const fetchFn = vi.fn(
|
|
185
|
+
async () =>
|
|
186
|
+
new Response(JSON.stringify({ value: [] }), {
|
|
187
|
+
status: 200,
|
|
188
|
+
headers: { "content-type": "application/json" },
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const result = await resolveGraphChatId({
|
|
193
|
+
botFrameworkConversationId: "a:1unknown_dm",
|
|
194
|
+
userAadObjectId: "some-user",
|
|
195
|
+
tokenProvider,
|
|
196
|
+
fetchFn: withFetchPreconnect(fetchFn),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(result).toBeNull();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("returns null when Graph API call fails", async () => {
|
|
203
|
+
const fetchFn = vi.fn(
|
|
204
|
+
async () =>
|
|
205
|
+
new Response("Unauthorized", {
|
|
206
|
+
status: 401,
|
|
207
|
+
headers: { "content-type": "text/plain" },
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const result = await resolveGraphChatId({
|
|
212
|
+
botFrameworkConversationId: "a:1some_dm_id",
|
|
213
|
+
userAadObjectId: "some-user",
|
|
214
|
+
tokenProvider,
|
|
215
|
+
fetchFn: withFetchPreconnect(fetchFn),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(result).toBeNull();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("buildTeamsFileInfoCard", () => {
|
|
223
|
+
it("extracts a unique id from quoted etags and lowercases file extensions", () => {
|
|
224
|
+
expect(
|
|
225
|
+
buildTeamsFileInfoCard({
|
|
226
|
+
eTag: '"{ABC-123},42"',
|
|
227
|
+
name: "Quarterly.Report.PDF",
|
|
228
|
+
webDavUrl: "https://sharepoint.example.com/file.pdf",
|
|
229
|
+
}),
|
|
230
|
+
).toEqual({
|
|
231
|
+
contentType: "application/vnd.microsoft.teams.card.file.info",
|
|
232
|
+
contentUrl: "https://sharepoint.example.com/file.pdf",
|
|
233
|
+
name: "Quarterly.Report.PDF",
|
|
234
|
+
content: {
|
|
235
|
+
uniqueId: "ABC-123",
|
|
236
|
+
fileType: "pdf",
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("keeps the raw etag when no version suffix exists and handles extensionless files", () => {
|
|
242
|
+
expect(
|
|
243
|
+
buildTeamsFileInfoCard({
|
|
244
|
+
eTag: "plain-etag",
|
|
245
|
+
name: "README",
|
|
246
|
+
webDavUrl: "https://sharepoint.example.com/readme",
|
|
247
|
+
}),
|
|
248
|
+
).toEqual({
|
|
249
|
+
contentType: "application/vnd.microsoft.teams.card.file.info",
|
|
250
|
+
contentUrl: "https://sharepoint.example.com/readme",
|
|
251
|
+
name: "README",
|
|
252
|
+
content: {
|
|
253
|
+
uniqueId: "plain-etag",
|
|
254
|
+
fileType: "",
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
package/src/graph-upload.ts
CHANGED
|
@@ -10,47 +10,39 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
|
|
13
|
+
import { buildUserAgent } from "./user-agent.js";
|
|
13
14
|
|
|
14
15
|
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
|
15
16
|
const GRAPH_BETA = "https://graph.microsoft.com/beta";
|
|
16
17
|
const GRAPH_SCOPE = "https://graph.microsoft.com";
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
interface OneDriveUploadResult {
|
|
19
20
|
id: string;
|
|
20
21
|
webUrl: string;
|
|
21
22
|
name: string;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
throw new Error(`${label} upload response missing required fields`);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return {
|
|
33
|
-
id: data.id,
|
|
34
|
-
webUrl: data.webUrl,
|
|
35
|
-
name: data.name,
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function uploadDriveItem(params: {
|
|
25
|
+
/**
|
|
26
|
+
* Upload a file to the user's OneDrive root folder.
|
|
27
|
+
* For larger files, this uses the simple upload endpoint (up to 4MB).
|
|
28
|
+
*/
|
|
29
|
+
export async function uploadToOneDrive(params: {
|
|
40
30
|
buffer: Buffer;
|
|
41
31
|
filename: string;
|
|
42
32
|
contentType?: string;
|
|
43
33
|
tokenProvider: MSTeamsAccessTokenProvider;
|
|
44
34
|
fetchFn?: typeof fetch;
|
|
45
|
-
url: string;
|
|
46
|
-
label: "OneDrive" | "SharePoint";
|
|
47
35
|
}): Promise<OneDriveUploadResult> {
|
|
48
36
|
const fetchFn = params.fetchFn ?? fetch;
|
|
49
37
|
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
|
50
38
|
|
|
51
|
-
|
|
39
|
+
// Use "OpenClawShared" folder to organize bot-uploaded files
|
|
40
|
+
const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`;
|
|
41
|
+
|
|
42
|
+
const res = await fetchFn(`${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, {
|
|
52
43
|
method: "PUT",
|
|
53
44
|
headers: {
|
|
45
|
+
"User-Agent": buildUserAgent(),
|
|
54
46
|
Authorization: `Bearer ${token}`,
|
|
55
47
|
"Content-Type": params.contentType ?? "application/octet-stream",
|
|
56
48
|
},
|
|
@@ -59,36 +51,27 @@ async function uploadDriveItem(params: {
|
|
|
59
51
|
|
|
60
52
|
if (!res.ok) {
|
|
61
53
|
const body = await res.text().catch(() => "");
|
|
62
|
-
throw new Error(
|
|
54
|
+
throw new Error(`OneDrive upload failed: ${res.status} ${res.statusText} - ${body}`);
|
|
63
55
|
}
|
|
64
56
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
57
|
+
const data = (await res.json()) as {
|
|
58
|
+
id?: string;
|
|
59
|
+
webUrl?: string;
|
|
60
|
+
name?: string;
|
|
61
|
+
};
|
|
70
62
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
fetchFn?: typeof fetch;
|
|
81
|
-
}): Promise<OneDriveUploadResult> {
|
|
82
|
-
// Use "OpenClawShared" folder to organize bot-uploaded files
|
|
83
|
-
const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`;
|
|
84
|
-
return await uploadDriveItem({
|
|
85
|
-
...params,
|
|
86
|
-
url: `${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`,
|
|
87
|
-
label: "OneDrive",
|
|
88
|
-
});
|
|
63
|
+
if (!data.id || !data.webUrl || !data.name) {
|
|
64
|
+
throw new Error("OneDrive upload response missing required fields");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
id: data.id,
|
|
69
|
+
webUrl: data.webUrl,
|
|
70
|
+
name: data.name,
|
|
71
|
+
};
|
|
89
72
|
}
|
|
90
73
|
|
|
91
|
-
|
|
74
|
+
interface OneDriveSharingLink {
|
|
92
75
|
webUrl: string;
|
|
93
76
|
}
|
|
94
77
|
|
|
@@ -96,7 +79,7 @@ export interface OneDriveSharingLink {
|
|
|
96
79
|
* Create a sharing link for a OneDrive file.
|
|
97
80
|
* The link allows organization members to view the file.
|
|
98
81
|
*/
|
|
99
|
-
|
|
82
|
+
async function createSharingLink(params: {
|
|
100
83
|
itemId: string;
|
|
101
84
|
tokenProvider: MSTeamsAccessTokenProvider;
|
|
102
85
|
/** Sharing scope: "organization" (default) or "anonymous" */
|
|
@@ -109,6 +92,7 @@ export async function createSharingLink(params: {
|
|
|
109
92
|
const res = await fetchFn(`${GRAPH_ROOT}/me/drive/items/${params.itemId}/createLink`, {
|
|
110
93
|
method: "POST",
|
|
111
94
|
headers: {
|
|
95
|
+
"User-Agent": buildUserAgent(),
|
|
112
96
|
Authorization: `Bearer ${token}`,
|
|
113
97
|
"Content-Type": "application/json",
|
|
114
98
|
},
|
|
@@ -194,16 +178,48 @@ export async function uploadToSharePoint(params: {
|
|
|
194
178
|
siteId: string;
|
|
195
179
|
fetchFn?: typeof fetch;
|
|
196
180
|
}): Promise<OneDriveUploadResult> {
|
|
181
|
+
const fetchFn = params.fetchFn ?? fetch;
|
|
182
|
+
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
|
183
|
+
|
|
197
184
|
// Use "OpenClawShared" folder to organize bot-uploaded files
|
|
198
185
|
const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
186
|
+
|
|
187
|
+
const res = await fetchFn(
|
|
188
|
+
`${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`,
|
|
189
|
+
{
|
|
190
|
+
method: "PUT",
|
|
191
|
+
headers: {
|
|
192
|
+
"User-Agent": buildUserAgent(),
|
|
193
|
+
Authorization: `Bearer ${token}`,
|
|
194
|
+
"Content-Type": params.contentType ?? "application/octet-stream",
|
|
195
|
+
},
|
|
196
|
+
body: new Uint8Array(params.buffer),
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
if (!res.ok) {
|
|
201
|
+
const body = await res.text().catch(() => "");
|
|
202
|
+
throw new Error(`SharePoint upload failed: ${res.status} ${res.statusText} - ${body}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const data = (await res.json()) as {
|
|
206
|
+
id?: string;
|
|
207
|
+
webUrl?: string;
|
|
208
|
+
name?: string;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
if (!data.id || !data.webUrl || !data.name) {
|
|
212
|
+
throw new Error("SharePoint upload response missing required fields");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
id: data.id,
|
|
217
|
+
webUrl: data.webUrl,
|
|
218
|
+
name: data.name,
|
|
219
|
+
};
|
|
204
220
|
}
|
|
205
221
|
|
|
206
|
-
|
|
222
|
+
interface ChatMember {
|
|
207
223
|
aadObjectId: string;
|
|
208
224
|
displayName?: string;
|
|
209
225
|
}
|
|
@@ -239,7 +255,7 @@ export async function getDriveItemProperties(params: {
|
|
|
239
255
|
|
|
240
256
|
const res = await fetchFn(
|
|
241
257
|
`${GRAPH_ROOT}/sites/${params.siteId}/drive/items/${params.itemId}?$select=eTag,webDavUrl,name`,
|
|
242
|
-
{ headers: { Authorization: `Bearer ${token}` } },
|
|
258
|
+
{ headers: { "User-Agent": buildUserAgent(), Authorization: `Bearer ${token}` } },
|
|
243
259
|
);
|
|
244
260
|
|
|
245
261
|
if (!res.ok) {
|
|
@@ -264,11 +280,85 @@ export async function getDriveItemProperties(params: {
|
|
|
264
280
|
};
|
|
265
281
|
}
|
|
266
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Resolve the Graph API-native chat ID from a Bot Framework conversation ID.
|
|
285
|
+
*
|
|
286
|
+
* Bot Framework personal DM conversation IDs use formats like `a:1xxx@unq.gbl.spaces`
|
|
287
|
+
* or `8:orgid:xxx` that the Graph API does not accept. Graph API requires the
|
|
288
|
+
* `19:xxx@thread.tacv2` or `19:xxx@unq.gbl.spaces` format.
|
|
289
|
+
*
|
|
290
|
+
* This function looks up the matching Graph chat by querying the bot's chats filtered
|
|
291
|
+
* by the target user's AAD object ID.
|
|
292
|
+
*/
|
|
293
|
+
export async function resolveGraphChatId(params: {
|
|
294
|
+
/** Bot Framework conversation ID (may be in non-Graph format for personal DMs) */
|
|
295
|
+
botFrameworkConversationId: string;
|
|
296
|
+
/** AAD object ID of the user in the conversation (used for filtering chats) */
|
|
297
|
+
userAadObjectId?: string;
|
|
298
|
+
tokenProvider: MSTeamsAccessTokenProvider;
|
|
299
|
+
fetchFn?: typeof fetch;
|
|
300
|
+
}): Promise<string | null> {
|
|
301
|
+
const { botFrameworkConversationId, userAadObjectId, tokenProvider } = params;
|
|
302
|
+
const fetchFn = params.fetchFn ?? fetch;
|
|
303
|
+
|
|
304
|
+
// If the conversation ID already looks like a valid Graph chat ID, return it directly.
|
|
305
|
+
// Graph chat IDs start with "19:" — Bot Framework group chat IDs already use this format.
|
|
306
|
+
if (botFrameworkConversationId.startsWith("19:")) {
|
|
307
|
+
return botFrameworkConversationId;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// For personal DMs with non-Graph conversation IDs (e.g. `a:1xxx` or `8:orgid:xxx`),
|
|
311
|
+
// query the bot's chats to find the matching one.
|
|
312
|
+
const token = await tokenProvider.getAccessToken(GRAPH_SCOPE);
|
|
313
|
+
|
|
314
|
+
// Build filter: if we have the user's AAD object ID, narrow the search to 1:1 chats
|
|
315
|
+
// with that member. Otherwise, fall back to listing all 1:1 chats.
|
|
316
|
+
let path: string;
|
|
317
|
+
if (userAadObjectId) {
|
|
318
|
+
const encoded = encodeURIComponent(
|
|
319
|
+
`chatType eq 'oneOnOne' and members/any(m:m/microsoft.graph.aadUserConversationMember/userId eq '${userAadObjectId}')`,
|
|
320
|
+
);
|
|
321
|
+
path = `/me/chats?$filter=${encoded}&$select=id`;
|
|
322
|
+
} else {
|
|
323
|
+
// Fallback: list all 1:1 chats when no user ID is available.
|
|
324
|
+
// Only safe when the bot has exactly one 1:1 chat; returns null otherwise to
|
|
325
|
+
// avoid sending to the wrong person's chat.
|
|
326
|
+
path = `/me/chats?$filter=${encodeURIComponent("chatType eq 'oneOnOne'")}&$select=id`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const res = await fetchFn(`${GRAPH_ROOT}${path}`, {
|
|
330
|
+
headers: { "User-Agent": buildUserAgent(), Authorization: `Bearer ${token}` },
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
if (!res.ok) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const data = (await res.json()) as {
|
|
338
|
+
value?: Array<{ id?: string }>;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const chats = data.value ?? [];
|
|
342
|
+
|
|
343
|
+
// When filtered by userAadObjectId, any non-empty result is the right 1:1 chat.
|
|
344
|
+
if (userAadObjectId && chats.length > 0 && chats[0]?.id) {
|
|
345
|
+
return chats[0].id;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Without a user ID we can only be certain when exactly one chat is returned;
|
|
349
|
+
// multiple results would be ambiguous and could route to the wrong person.
|
|
350
|
+
if (!userAadObjectId && chats.length === 1 && chats[0]?.id) {
|
|
351
|
+
return chats[0].id;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
|
|
267
357
|
/**
|
|
268
358
|
* Get members of a Teams chat for per-user sharing.
|
|
269
359
|
* Used to create sharing links scoped to only the chat participants.
|
|
270
360
|
*/
|
|
271
|
-
|
|
361
|
+
async function getChatMembers(params: {
|
|
272
362
|
chatId: string;
|
|
273
363
|
tokenProvider: MSTeamsAccessTokenProvider;
|
|
274
364
|
fetchFn?: typeof fetch;
|
|
@@ -277,7 +367,7 @@ export async function getChatMembers(params: {
|
|
|
277
367
|
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
|
278
368
|
|
|
279
369
|
const res = await fetchFn(`${GRAPH_ROOT}/chats/${params.chatId}/members`, {
|
|
280
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
370
|
+
headers: { "User-Agent": buildUserAgent(), Authorization: `Bearer ${token}` },
|
|
281
371
|
});
|
|
282
372
|
|
|
283
373
|
if (!res.ok) {
|
|
@@ -305,7 +395,7 @@ export async function getChatMembers(params: {
|
|
|
305
395
|
* For organization scope (default), uses v1.0 API.
|
|
306
396
|
* For per-user scope, uses beta API with recipients.
|
|
307
397
|
*/
|
|
308
|
-
|
|
398
|
+
async function createSharePointSharingLink(params: {
|
|
309
399
|
siteId: string;
|
|
310
400
|
itemId: string;
|
|
311
401
|
tokenProvider: MSTeamsAccessTokenProvider;
|
|
@@ -337,6 +427,7 @@ export async function createSharePointSharingLink(params: {
|
|
|
337
427
|
{
|
|
338
428
|
method: "POST",
|
|
339
429
|
headers: {
|
|
430
|
+
"User-Agent": buildUserAgent(),
|
|
340
431
|
Authorization: `Bearer ${token}`,
|
|
341
432
|
"Content-Type": "application/json",
|
|
342
433
|
},
|