msteams-mcp 0.17.0 → 0.18.0
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/dist/api/chatsvc-api.js +21 -10
- package/dist/api/files-api.d.ts +51 -0
- package/dist/api/files-api.js +123 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/substrate-api.js +6 -2
- package/dist/api/transcript-api.js +3 -2
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +7 -0
- package/dist/tools/file-tools.d.ts +33 -0
- package/dist/tools/file-tools.js +65 -0
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +1 -0
- package/dist/tools/meeting-tools.js +1 -1
- package/dist/tools/registry.js +2 -0
- package/dist/utils/auth-guards.d.ts +8 -1
- package/dist/utils/auth-guards.js +29 -8
- package/dist/utils/parsers.d.ts +33 -10
- package/dist/utils/parsers.js +55 -32
- package/dist/utils/parsers.test.js +44 -10
- package/package.json +1 -1
package/dist/api/chatsvc-api.js
CHANGED
|
@@ -9,7 +9,7 @@ import { CHATSVC_API, getMessagingHeaders, getSkypeAuthHeaders, getTeamsHeaders
|
|
|
9
9
|
import { ErrorCode, createError } from '../types/errors.js';
|
|
10
10
|
import { ok, err } from '../types/result.js';
|
|
11
11
|
import { getUserDisplayName } from '../auth/token-extractor.js';
|
|
12
|
-
import { requireMessageAuth, getRegion, getTeamsBaseUrl } from '../utils/auth-guards.js';
|
|
12
|
+
import { requireMessageAuth, getRegion, getTeamsBaseUrl, getTenantId } from '../utils/auth-guards.js';
|
|
13
13
|
import { stripHtml, extractLinks, buildMessageLink, buildOneOnOneConversationId, extractObjectId, extractActivityTimestamp, parseVirtualConversationMessage, markdownToTeamsHtml } from '../utils/parsers.js';
|
|
14
14
|
import { DEFAULT_ACTIVITY_LIMIT, SAVED_MESSAGES_ID, FOLLOWED_THREADS_ID, VIRTUAL_CONVERSATION_PREFIX, SELF_CHAT_ID, MRI_ORGID_PREFIX } from '../constants.js';
|
|
15
15
|
// Reusable date formatter for human-readable timestamps with day of week
|
|
@@ -166,18 +166,24 @@ export async function getThreadMessages(conversationId, options = {}) {
|
|
|
166
166
|
const timestamp = msg.originalarrivaltime ||
|
|
167
167
|
msg.composetime ||
|
|
168
168
|
new Date(parseInt(id, 10)).toISOString();
|
|
169
|
-
//
|
|
169
|
+
// Extract thread root ID for channel messages
|
|
170
|
+
// When rootMessageId differs from id, this message is a reply within a thread
|
|
171
|
+
const rootMessageId = msg.rootMessageId;
|
|
172
|
+
const isThreadReply = !!rootMessageId && rootMessageId !== id;
|
|
173
|
+
// Build message link with tenant context for reliable deep links
|
|
170
174
|
const messageLink = /^\d+$/.test(id)
|
|
171
|
-
? buildMessageLink(
|
|
175
|
+
? buildMessageLink({
|
|
176
|
+
conversationId,
|
|
177
|
+
messageId: id,
|
|
178
|
+
tenantId: getTenantId() ?? undefined,
|
|
179
|
+
parentMessageId: isThreadReply ? rootMessageId : undefined,
|
|
180
|
+
teamsBaseUrl: getTeamsBaseUrl(),
|
|
181
|
+
})
|
|
172
182
|
: undefined;
|
|
173
183
|
// Extract links before stripping HTML
|
|
174
184
|
const links = extractLinks(content);
|
|
175
185
|
// Format human-readable date with day of week to help LLMs
|
|
176
186
|
const when = formatHumanReadableDate(timestamp);
|
|
177
|
-
// Extract thread root ID for channel messages
|
|
178
|
-
// When rootMessageId differs from id, this message is a reply within a thread
|
|
179
|
-
const rootMessageId = msg.rootMessageId;
|
|
180
|
-
const isThreadReply = !!rootMessageId && rootMessageId !== id;
|
|
181
187
|
messages.push({
|
|
182
188
|
id,
|
|
183
189
|
content: stripHtml(content),
|
|
@@ -243,7 +249,7 @@ export async function getSavedMessages(options = {}) {
|
|
|
243
249
|
}
|
|
244
250
|
const messages = [];
|
|
245
251
|
for (const raw of rawMessages) {
|
|
246
|
-
const parsed = parseVirtualConversationMessage(raw, SAVED_MESSAGE_PATTERN);
|
|
252
|
+
const parsed = parseVirtualConversationMessage(raw, SAVED_MESSAGE_PATTERN, { tenantId: getTenantId() ?? undefined, teamsBaseUrl: getTeamsBaseUrl() });
|
|
247
253
|
if (!parsed)
|
|
248
254
|
continue;
|
|
249
255
|
messages.push({
|
|
@@ -288,7 +294,7 @@ export async function getFollowedThreads(options = {}) {
|
|
|
288
294
|
}
|
|
289
295
|
const threads = [];
|
|
290
296
|
for (const raw of rawMessages) {
|
|
291
|
-
const parsed = parseVirtualConversationMessage(raw, FOLLOWED_THREAD_PATTERN);
|
|
297
|
+
const parsed = parseVirtualConversationMessage(raw, FOLLOWED_THREAD_PATTERN, { tenantId: getTenantId() ?? undefined, teamsBaseUrl: getTeamsBaseUrl() });
|
|
292
298
|
if (!parsed)
|
|
293
299
|
continue;
|
|
294
300
|
threads.push({
|
|
@@ -998,7 +1004,12 @@ export async function getActivityFeed(options = {}) {
|
|
|
998
1004
|
// Skip virtual conversations (48:xxx) as they don't produce working deep links
|
|
999
1005
|
let activityLink;
|
|
1000
1006
|
if (conversationId && !conversationId.startsWith(VIRTUAL_CONVERSATION_PREFIX) && /^\d+$/.test(id)) {
|
|
1001
|
-
activityLink = buildMessageLink(
|
|
1007
|
+
activityLink = buildMessageLink({
|
|
1008
|
+
conversationId,
|
|
1009
|
+
messageId: id,
|
|
1010
|
+
tenantId: getTenantId() ?? undefined,
|
|
1011
|
+
teamsBaseUrl: getTeamsBaseUrl(),
|
|
1012
|
+
});
|
|
1002
1013
|
}
|
|
1003
1014
|
const activityType = detectActivityType(msg);
|
|
1004
1015
|
// Extract links before stripping HTML
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files API client for shared file operations.
|
|
3
|
+
*
|
|
4
|
+
* Uses the Substrate AllFiles API to retrieve files and links
|
|
5
|
+
* shared in a Teams conversation.
|
|
6
|
+
*/
|
|
7
|
+
import { type Result } from '../types/result.js';
|
|
8
|
+
/** A file shared in a conversation. */
|
|
9
|
+
export interface SharedFile {
|
|
10
|
+
/** Item type: "File" or "Link". */
|
|
11
|
+
itemType: 'File' | 'Link';
|
|
12
|
+
/** File name (for files). */
|
|
13
|
+
fileName?: string;
|
|
14
|
+
/** File extension (for files). */
|
|
15
|
+
fileExtension?: string;
|
|
16
|
+
/** URL to access the file or link. */
|
|
17
|
+
webUrl?: string;
|
|
18
|
+
/** File size in bytes (for files). */
|
|
19
|
+
fileSize?: number;
|
|
20
|
+
/** Display name of the person who shared the item. */
|
|
21
|
+
sharedBy?: string;
|
|
22
|
+
/** Email of the person who shared the item. */
|
|
23
|
+
sharedByEmail?: string;
|
|
24
|
+
/** When the item was shared (ISO timestamp). */
|
|
25
|
+
sharedTime?: string;
|
|
26
|
+
/** Title (for links). */
|
|
27
|
+
title?: string;
|
|
28
|
+
}
|
|
29
|
+
/** Result of getting shared files. */
|
|
30
|
+
export interface GetSharedFilesResult {
|
|
31
|
+
conversationId: string;
|
|
32
|
+
files: SharedFile[];
|
|
33
|
+
returned: number;
|
|
34
|
+
/** Continuation token for pagination (if more results available). */
|
|
35
|
+
skipToken?: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Gets files and links shared in a Teams conversation.
|
|
39
|
+
*
|
|
40
|
+
* Uses the Substrate AllFiles API which indexes files shared across
|
|
41
|
+
* Teams conversations. Supports both files (SharePoint/OneDrive) and
|
|
42
|
+
* links shared in chat.
|
|
43
|
+
*
|
|
44
|
+
* @param conversationId - The conversation ID to get files for
|
|
45
|
+
* @param options.pageSize - Number of items per page (default: 25, max: 100)
|
|
46
|
+
* @param options.skipToken - Continuation token for pagination
|
|
47
|
+
*/
|
|
48
|
+
export declare function getSharedFiles(conversationId: string, options?: {
|
|
49
|
+
pageSize?: number;
|
|
50
|
+
skipToken?: string;
|
|
51
|
+
}): Promise<Result<GetSharedFilesResult>>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files API client for shared file operations.
|
|
3
|
+
*
|
|
4
|
+
* Uses the Substrate AllFiles API to retrieve files and links
|
|
5
|
+
* shared in a Teams conversation.
|
|
6
|
+
*/
|
|
7
|
+
import { httpRequest } from '../utils/http.js';
|
|
8
|
+
import { ok, err } from '../types/result.js';
|
|
9
|
+
import { ErrorCode, createError } from '../types/errors.js';
|
|
10
|
+
import { clearTokenCache } from '../auth/token-extractor.js';
|
|
11
|
+
import { requireSubstrateTokenAsync, getTenantId } from '../utils/auth-guards.js';
|
|
12
|
+
import { extractObjectId } from '../utils/parsers.js';
|
|
13
|
+
import { requireMessageAuth } from '../utils/auth-guards.js';
|
|
14
|
+
import { DEFAULT_FILES_PAGE_SIZE } from '../constants.js';
|
|
15
|
+
import { DEFAULT_SUBSTRATE_BASE_URL } from '../utils/api-config.js';
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
// API Functions
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
/**
|
|
20
|
+
* Gets files and links shared in a Teams conversation.
|
|
21
|
+
*
|
|
22
|
+
* Uses the Substrate AllFiles API which indexes files shared across
|
|
23
|
+
* Teams conversations. Supports both files (SharePoint/OneDrive) and
|
|
24
|
+
* links shared in chat.
|
|
25
|
+
*
|
|
26
|
+
* @param conversationId - The conversation ID to get files for
|
|
27
|
+
* @param options.pageSize - Number of items per page (default: 25, max: 100)
|
|
28
|
+
* @param options.skipToken - Continuation token for pagination
|
|
29
|
+
*/
|
|
30
|
+
export async function getSharedFiles(conversationId, options = {}) {
|
|
31
|
+
// Need both Substrate token (for the API) and message auth (for user MRI)
|
|
32
|
+
const [tokenResult, authResult] = await Promise.all([
|
|
33
|
+
requireSubstrateTokenAsync(),
|
|
34
|
+
Promise.resolve(requireMessageAuth()),
|
|
35
|
+
]);
|
|
36
|
+
if (!tokenResult.ok)
|
|
37
|
+
return tokenResult;
|
|
38
|
+
if (!authResult.ok)
|
|
39
|
+
return authResult;
|
|
40
|
+
const token = tokenResult.value;
|
|
41
|
+
const auth = authResult.value;
|
|
42
|
+
// Extract user's object ID from their MRI
|
|
43
|
+
const userId = extractObjectId(auth.userMri);
|
|
44
|
+
if (!userId) {
|
|
45
|
+
return err(createError(ErrorCode.AUTH_REQUIRED, 'Could not extract user ID from session. Please try logging in again.', { suggestions: ['Call teams_login to re-authenticate'] }));
|
|
46
|
+
}
|
|
47
|
+
// Get tenant ID
|
|
48
|
+
const tenantId = getTenantId();
|
|
49
|
+
if (!tenantId) {
|
|
50
|
+
return err(createError(ErrorCode.AUTH_REQUIRED, 'Could not determine tenant ID. Please try logging in again.', { suggestions: ['Call teams_login to re-authenticate'] }));
|
|
51
|
+
}
|
|
52
|
+
const pageSize = options.pageSize ?? DEFAULT_FILES_PAGE_SIZE;
|
|
53
|
+
// Build the AllFiles API URL
|
|
54
|
+
const userPrincipal = `OID:${userId}@${tenantId}`;
|
|
55
|
+
const params = new URLSearchParams();
|
|
56
|
+
params.set('ThreadId', conversationId);
|
|
57
|
+
params.set('ItemTypes', 'File');
|
|
58
|
+
params.append('ItemTypes', 'Link');
|
|
59
|
+
params.set('PageSize', String(pageSize));
|
|
60
|
+
if (options.skipToken) {
|
|
61
|
+
params.set('$skiptoken', options.skipToken);
|
|
62
|
+
}
|
|
63
|
+
const url = `${DEFAULT_SUBSTRATE_BASE_URL}/AllFiles/api/users('${encodeURIComponent(userPrincipal)}')/AllShared?${params.toString()}`;
|
|
64
|
+
const response = await httpRequest(url, {
|
|
65
|
+
method: 'GET',
|
|
66
|
+
headers: {
|
|
67
|
+
'Authorization': `Bearer ${token}`,
|
|
68
|
+
'Accept': 'application/json',
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
if (response.error.code === ErrorCode.AUTH_EXPIRED) {
|
|
74
|
+
clearTokenCache();
|
|
75
|
+
}
|
|
76
|
+
return response;
|
|
77
|
+
}
|
|
78
|
+
const data = response.value.data;
|
|
79
|
+
const rawItems = data.Items;
|
|
80
|
+
const skipToken = data.SkipToken;
|
|
81
|
+
if (!rawItems || rawItems.length === 0) {
|
|
82
|
+
return ok({
|
|
83
|
+
conversationId,
|
|
84
|
+
files: [],
|
|
85
|
+
returned: 0,
|
|
86
|
+
skipToken: undefined,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const files = [];
|
|
90
|
+
for (const item of rawItems) {
|
|
91
|
+
const itemType = item.ItemType;
|
|
92
|
+
if (itemType === 'File') {
|
|
93
|
+
const fileData = item.FileData;
|
|
94
|
+
files.push({
|
|
95
|
+
itemType: 'File',
|
|
96
|
+
fileName: fileData?.FileName,
|
|
97
|
+
fileExtension: fileData?.FileExtension,
|
|
98
|
+
webUrl: fileData?.WebUrl,
|
|
99
|
+
fileSize: fileData?.FileSize,
|
|
100
|
+
sharedBy: item.SharedByDisplayName,
|
|
101
|
+
sharedByEmail: item.SharedBySmtp,
|
|
102
|
+
sharedTime: item.SharedTime,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
else if (itemType === 'Link') {
|
|
106
|
+
const linkData = item.WeblinkData;
|
|
107
|
+
files.push({
|
|
108
|
+
itemType: 'Link',
|
|
109
|
+
webUrl: linkData?.WebUrl,
|
|
110
|
+
title: linkData?.Title,
|
|
111
|
+
sharedBy: item.SharedByDisplayName,
|
|
112
|
+
sharedByEmail: item.SharedBySmtp,
|
|
113
|
+
sharedTime: item.SharedTime,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return ok({
|
|
118
|
+
conversationId,
|
|
119
|
+
files,
|
|
120
|
+
returned: files.length,
|
|
121
|
+
skipToken: skipToken || undefined,
|
|
122
|
+
});
|
|
123
|
+
}
|
package/dist/api/index.d.ts
CHANGED
package/dist/api/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { SUBSTRATE_API, getBearerHeaders } from '../utils/api-config.js';
|
|
|
8
8
|
import { ErrorCode } from '../types/errors.js';
|
|
9
9
|
import { ok } from '../types/result.js';
|
|
10
10
|
import { clearTokenCache } from '../auth/token-extractor.js';
|
|
11
|
-
import { requireSubstrateTokenAsync } from '../utils/auth-guards.js';
|
|
11
|
+
import { requireSubstrateTokenAsync, getTenantId, getTeamsBaseUrl } from '../utils/auth-guards.js';
|
|
12
12
|
import { parseSearchResults, parsePeopleResults, parseChannelResults, filterChannelsByName, } from '../utils/parsers.js';
|
|
13
13
|
import { getMyTeamsAndChannels } from './csa-api.js';
|
|
14
14
|
/**
|
|
@@ -72,7 +72,11 @@ export async function searchMessages(query, options = {}) {
|
|
|
72
72
|
return response;
|
|
73
73
|
}
|
|
74
74
|
const data = response.value.data;
|
|
75
|
-
const
|
|
75
|
+
const linkContext = {
|
|
76
|
+
tenantId: getTenantId() ?? undefined,
|
|
77
|
+
teamsBaseUrl: getTeamsBaseUrl(),
|
|
78
|
+
};
|
|
79
|
+
const { results, total } = parseSearchResults(data.EntitySets, linkContext);
|
|
76
80
|
const maxResults = options.maxResults ?? size;
|
|
77
81
|
const limitedResults = results.slice(0, maxResults);
|
|
78
82
|
return ok({
|
|
@@ -92,14 +92,15 @@ export async function getTranscriptContent(threadId, meetingDate) {
|
|
|
92
92
|
try {
|
|
93
93
|
transcriptData = JSON.parse(transcriptJsonStr);
|
|
94
94
|
}
|
|
95
|
-
catch {
|
|
96
|
-
return err(createError(ErrorCode.UNKNOWN,
|
|
95
|
+
catch (parseError) {
|
|
96
|
+
return err(createError(ErrorCode.UNKNOWN, `Failed to parse transcript data: ${parseError instanceof Error ? parseError.message : 'unknown error'}`));
|
|
97
97
|
}
|
|
98
98
|
const rawEntries = transcriptData.entries || [];
|
|
99
99
|
if (rawEntries.length === 0) {
|
|
100
100
|
return err(createError(ErrorCode.NOT_FOUND, 'Transcript is empty — no speech was detected during the meeting.'));
|
|
101
101
|
}
|
|
102
102
|
// Map to TranscriptEntry format
|
|
103
|
+
// Substrate returns timestamps with microsecond precision (extra 4 trailing zeros) — normalise to milliseconds
|
|
103
104
|
const entries = rawEntries.map(e => ({
|
|
104
105
|
startTime: (e.startOffset || '').replace(/0{4}$/, ''),
|
|
105
106
|
endTime: (e.endOffset || '').replace(/0{4}$/, ''),
|
package/dist/constants.d.ts
CHANGED
|
@@ -83,6 +83,10 @@ export declare const DEFAULT_MEETING_DAYS_AHEAD = 7;
|
|
|
83
83
|
export declare const DEFAULT_MEETING_LIMIT = 50;
|
|
84
84
|
/** Maximum limit for meeting results. */
|
|
85
85
|
export declare const MAX_MEETING_LIMIT = 200;
|
|
86
|
+
/** Default page size for shared files. */
|
|
87
|
+
export declare const DEFAULT_FILES_PAGE_SIZE = 25;
|
|
88
|
+
/** Maximum page size for shared files. */
|
|
89
|
+
export declare const MAX_FILES_PAGE_SIZE = 100;
|
|
86
90
|
/** Threshold for proactive token refresh (10 minutes before expiry). */
|
|
87
91
|
export declare const TOKEN_REFRESH_THRESHOLD_MS: number;
|
|
88
92
|
/** MRI type prefix for Teams/AAD users (type 8). */
|
package/dist/constants.js
CHANGED
|
@@ -111,6 +111,13 @@ export const DEFAULT_MEETING_LIMIT = 50;
|
|
|
111
111
|
/** Maximum limit for meeting results. */
|
|
112
112
|
export const MAX_MEETING_LIMIT = 200;
|
|
113
113
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
114
|
+
// Files
|
|
115
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
116
|
+
/** Default page size for shared files. */
|
|
117
|
+
export const DEFAULT_FILES_PAGE_SIZE = 25;
|
|
118
|
+
/** Maximum page size for shared files. */
|
|
119
|
+
export const MAX_FILES_PAGE_SIZE = 100;
|
|
120
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
114
121
|
// Token Refresh
|
|
115
122
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
116
123
|
/** Threshold for proactive token refresh (10 minutes before expiry). */
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-related tool handlers.
|
|
3
|
+
*/
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import type { RegisteredTool } from './index.js';
|
|
6
|
+
export declare const GetSharedFilesInputSchema: z.ZodObject<{
|
|
7
|
+
conversationId: z.ZodString;
|
|
8
|
+
pageSize: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
9
|
+
skipToken: z.ZodOptional<z.ZodString>;
|
|
10
|
+
}, "strip", z.ZodTypeAny, {
|
|
11
|
+
conversationId: string;
|
|
12
|
+
pageSize: number;
|
|
13
|
+
skipToken?: string | undefined;
|
|
14
|
+
}, {
|
|
15
|
+
conversationId: string;
|
|
16
|
+
pageSize?: number | undefined;
|
|
17
|
+
skipToken?: string | undefined;
|
|
18
|
+
}>;
|
|
19
|
+
export declare const getSharedFilesTool: RegisteredTool<typeof GetSharedFilesInputSchema>;
|
|
20
|
+
/** All file-related tools. */
|
|
21
|
+
export declare const fileTools: RegisteredTool<z.ZodObject<{
|
|
22
|
+
conversationId: z.ZodString;
|
|
23
|
+
pageSize: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
24
|
+
skipToken: z.ZodOptional<z.ZodString>;
|
|
25
|
+
}, "strip", z.ZodTypeAny, {
|
|
26
|
+
conversationId: string;
|
|
27
|
+
pageSize: number;
|
|
28
|
+
skipToken?: string | undefined;
|
|
29
|
+
}, {
|
|
30
|
+
conversationId: string;
|
|
31
|
+
pageSize?: number | undefined;
|
|
32
|
+
skipToken?: string | undefined;
|
|
33
|
+
}>>[];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-related tool handlers.
|
|
3
|
+
*/
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { handleApiResult } from './index.js';
|
|
6
|
+
import { getSharedFiles } from '../api/files-api.js';
|
|
7
|
+
import { DEFAULT_FILES_PAGE_SIZE, MAX_FILES_PAGE_SIZE, } from '../constants.js';
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
// Schemas
|
|
10
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
export const GetSharedFilesInputSchema = z.object({
|
|
12
|
+
conversationId: z.string(),
|
|
13
|
+
pageSize: z.number().min(1).max(MAX_FILES_PAGE_SIZE).optional().default(DEFAULT_FILES_PAGE_SIZE),
|
|
14
|
+
skipToken: z.string().optional(),
|
|
15
|
+
});
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
// Tool Definitions
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
const getSharedFilesToolDefinition = {
|
|
20
|
+
name: 'teams_get_shared_files',
|
|
21
|
+
description: 'Get files and links shared in a Teams conversation. Returns file names, URLs, extensions, sizes, and who shared them. Works for channels, group chats, 1:1 chats, and meeting chats. Use the conversationId from other tools (teams_get_favorites, teams_search, teams_find_channel, teams_get_chat). Supports pagination via skipToken for conversations with many files.',
|
|
22
|
+
inputSchema: {
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {
|
|
25
|
+
conversationId: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'The conversation ID to get shared files for (e.g., "19:abc@thread.tacv2" for a channel, or a chat conversation ID).',
|
|
28
|
+
},
|
|
29
|
+
pageSize: {
|
|
30
|
+
type: 'number',
|
|
31
|
+
description: `Number of items per page (default: ${DEFAULT_FILES_PAGE_SIZE}, max: ${MAX_FILES_PAGE_SIZE})`,
|
|
32
|
+
},
|
|
33
|
+
skipToken: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'Continuation token from a previous response to get the next page of results.',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
required: ['conversationId'],
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
// Handlers
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
async function handleGetSharedFiles(input, _ctx) {
|
|
45
|
+
const result = await getSharedFiles(input.conversationId, {
|
|
46
|
+
pageSize: input.pageSize,
|
|
47
|
+
skipToken: input.skipToken,
|
|
48
|
+
});
|
|
49
|
+
return handleApiResult(result, (value) => ({
|
|
50
|
+
conversationId: value.conversationId,
|
|
51
|
+
returned: value.returned,
|
|
52
|
+
files: value.files,
|
|
53
|
+
...(value.skipToken ? { skipToken: value.skipToken, hasMore: true } : { hasMore: false }),
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
// Exports
|
|
58
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
export const getSharedFilesTool = {
|
|
60
|
+
definition: getSharedFilesToolDefinition,
|
|
61
|
+
schema: GetSharedFilesInputSchema,
|
|
62
|
+
handler: handleGetSharedFiles,
|
|
63
|
+
};
|
|
64
|
+
/** All file-related tools. */
|
|
65
|
+
export const fileTools = [getSharedFilesTool];
|
package/dist/tools/index.d.ts
CHANGED
package/dist/tools/index.js
CHANGED
|
@@ -62,7 +62,7 @@ async function handleGetMeetings(input, _ctx) {
|
|
|
62
62
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
63
63
|
const getTranscriptToolDefinition = {
|
|
64
64
|
name: 'teams_get_transcript',
|
|
65
|
-
description: 'Get the transcript of a Teams meeting. Requires the meeting\'s threadId (from teams_get_meetings). Returns
|
|
65
|
+
description: 'Get the transcript of a Teams meeting. Requires the meeting\'s threadId (from teams_get_meetings). Returns formatted transcript text with timestamps and speaker names, ready for reading or summarization. The meeting must have had transcription enabled. Optionally pass meetingDate (ISO string, e.g. the startTime from teams_get_meetings) to narrow the search.',
|
|
66
66
|
inputSchema: {
|
|
67
67
|
type: 'object',
|
|
68
68
|
properties: {
|
package/dist/tools/registry.js
CHANGED
|
@@ -8,6 +8,7 @@ import { messageTools } from './message-tools.js';
|
|
|
8
8
|
import { peopleTools } from './people-tools.js';
|
|
9
9
|
import { authTools } from './auth-tools.js';
|
|
10
10
|
import { meetingTools } from './meeting-tools.js';
|
|
11
|
+
import { fileTools } from './file-tools.js';
|
|
11
12
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
13
|
// Registry
|
|
13
14
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -19,6 +20,7 @@ const allTools = [
|
|
|
19
20
|
...peopleTools,
|
|
20
21
|
...authTools,
|
|
21
22
|
...meetingTools,
|
|
23
|
+
...fileTools,
|
|
22
24
|
];
|
|
23
25
|
/** Lookup map for tools by name. */
|
|
24
26
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -61,7 +61,14 @@ export declare function getTeamsBaseUrl(): string;
|
|
|
61
61
|
*/
|
|
62
62
|
export declare function getRegionConfig(): RegionConfig | null;
|
|
63
63
|
/**
|
|
64
|
-
* Clears the cached region config.
|
|
64
|
+
* Clears the cached region config and tenant ID.
|
|
65
65
|
* Call this after login/logout to pick up new session.
|
|
66
66
|
*/
|
|
67
67
|
export declare function clearRegionCache(): void;
|
|
68
|
+
/**
|
|
69
|
+
* Gets the tenant ID from the user's session (JWT tokens).
|
|
70
|
+
*
|
|
71
|
+
* Required for building reliable Teams deep links.
|
|
72
|
+
* Returns null if no valid session is available.
|
|
73
|
+
*/
|
|
74
|
+
export declare function getTenantId(): string | null;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { ErrorCode, createError } from '../types/errors.js';
|
|
8
8
|
import { err, ok } from '../types/result.js';
|
|
9
|
-
import { getValidSubstrateToken, extractMessageAuth, extractCsaToken, extractSubstrateToken, extractSkypeSpacesToken, extractRegionConfig, } from '../auth/token-extractor.js';
|
|
9
|
+
import { getValidSubstrateToken, extractMessageAuth, extractCsaToken, extractSubstrateToken, extractSkypeSpacesToken, extractRegionConfig, getUserProfile, } from '../auth/token-extractor.js';
|
|
10
10
|
import { TOKEN_REFRESH_THRESHOLD_MS } from '../constants.js';
|
|
11
11
|
import { refreshTokensViaBrowser } from '../auth/token-refresh.js';
|
|
12
12
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -100,8 +100,8 @@ export function requireCalendarAuth() {
|
|
|
100
100
|
import { DEFAULT_TEAMS_BASE_URL } from './api-config.js';
|
|
101
101
|
/** Default region when session config is unavailable. */
|
|
102
102
|
const DEFAULT_REGION = 'amer';
|
|
103
|
-
/** Cached region config
|
|
104
|
-
let cachedRegionConfig =
|
|
103
|
+
/** Cached region config (undefined = not yet extracted, null = extraction failed). */
|
|
104
|
+
let cachedRegionConfig = undefined;
|
|
105
105
|
/**
|
|
106
106
|
* Gets the user's region from session, with caching.
|
|
107
107
|
*
|
|
@@ -109,7 +109,7 @@ let cachedRegionConfig = null;
|
|
|
109
109
|
* Falls back to 'amer' if not available (shouldn't happen with valid session).
|
|
110
110
|
*/
|
|
111
111
|
export function getRegion() {
|
|
112
|
-
if (
|
|
112
|
+
if (cachedRegionConfig === undefined) {
|
|
113
113
|
cachedRegionConfig = extractRegionConfig();
|
|
114
114
|
}
|
|
115
115
|
return cachedRegionConfig?.region ?? DEFAULT_REGION;
|
|
@@ -122,7 +122,7 @@ export function getRegion() {
|
|
|
122
122
|
* Falls back to default if config not available.
|
|
123
123
|
*/
|
|
124
124
|
export function getTeamsBaseUrl() {
|
|
125
|
-
if (
|
|
125
|
+
if (cachedRegionConfig === undefined) {
|
|
126
126
|
cachedRegionConfig = extractRegionConfig();
|
|
127
127
|
}
|
|
128
128
|
return cachedRegionConfig?.teamsBaseUrl ?? DEFAULT_TEAMS_BASE_URL;
|
|
@@ -133,15 +133,36 @@ export function getTeamsBaseUrl() {
|
|
|
133
133
|
* Returns null if no valid session - caller should handle auth error.
|
|
134
134
|
*/
|
|
135
135
|
export function getRegionConfig() {
|
|
136
|
-
if (
|
|
136
|
+
if (cachedRegionConfig === undefined) {
|
|
137
137
|
cachedRegionConfig = extractRegionConfig();
|
|
138
138
|
}
|
|
139
139
|
return cachedRegionConfig;
|
|
140
140
|
}
|
|
141
141
|
/**
|
|
142
|
-
* Clears the cached region config.
|
|
142
|
+
* Clears the cached region config and tenant ID.
|
|
143
143
|
* Call this after login/logout to pick up new session.
|
|
144
144
|
*/
|
|
145
145
|
export function clearRegionCache() {
|
|
146
|
-
cachedRegionConfig =
|
|
146
|
+
cachedRegionConfig = undefined;
|
|
147
|
+
cachedTenantId = undefined;
|
|
148
|
+
}
|
|
149
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
150
|
+
// Tenant ID
|
|
151
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
152
|
+
/** Cached tenant ID (undefined = not yet extracted, null = extraction failed). */
|
|
153
|
+
let cachedTenantId = undefined;
|
|
154
|
+
/**
|
|
155
|
+
* Gets the tenant ID from the user's session (JWT tokens).
|
|
156
|
+
*
|
|
157
|
+
* Required for building reliable Teams deep links.
|
|
158
|
+
* Returns null if no valid session is available.
|
|
159
|
+
*/
|
|
160
|
+
export function getTenantId() {
|
|
161
|
+
if (cachedTenantId !== undefined) {
|
|
162
|
+
return cachedTenantId;
|
|
163
|
+
}
|
|
164
|
+
const profile = getUserProfile();
|
|
165
|
+
const tid = profile?.tenantId ?? null;
|
|
166
|
+
cachedTenantId = tid;
|
|
167
|
+
return tid;
|
|
147
168
|
}
|
package/dist/utils/parsers.d.ts
CHANGED
|
@@ -47,17 +47,33 @@ export declare function stripHtml(html: string): string;
|
|
|
47
47
|
* - Group chats: 19:xxx@thread.v2 (non-meeting)
|
|
48
48
|
*/
|
|
49
49
|
export declare function getConversationType(conversationId: string): 'channel' | 'meeting' | 'chat';
|
|
50
|
+
/** Options for building a message deep link. */
|
|
51
|
+
export interface MessageLinkOptions {
|
|
52
|
+
/** The conversation/channel/chat ID (e.g., "19:xxx@thread.tacv2"). */
|
|
53
|
+
conversationId: string;
|
|
54
|
+
/** The message timestamp in epoch milliseconds. */
|
|
55
|
+
messageId: string | number;
|
|
56
|
+
/** Tenant ID (GUID) - required for reliable deep links. */
|
|
57
|
+
tenantId?: string;
|
|
58
|
+
/** For channel messages: the team's group ID (GUID). */
|
|
59
|
+
groupId?: string;
|
|
60
|
+
/** For channel messages: the parent/root message ID (epoch ms). If omitted for channels, messageId is used. */
|
|
61
|
+
parentMessageId?: string;
|
|
62
|
+
/** Teams base URL (for GCC/GCC-High support). */
|
|
63
|
+
teamsBaseUrl?: string;
|
|
64
|
+
}
|
|
50
65
|
/**
|
|
51
66
|
* Builds a deep link to open a message in Teams.
|
|
52
67
|
*
|
|
53
|
-
*
|
|
54
|
-
* - Channels: /l/message/{channelId}/{msgId}?parentMessageId
|
|
55
|
-
* - Chats/Meetings: /l/message/{chatId}/{msgId}?context={"contextType":"chat"}
|
|
68
|
+
* Uses Microsoft's documented deep link formats:
|
|
69
|
+
* - Channels: /l/message/{channelId}/{msgId}?tenantId=...&groupId=...&parentMessageId=...
|
|
70
|
+
* - Chats/Meetings: /l/message/{chatId}/{msgId}?tenantId=...&context={"contextType":"chat"}
|
|
56
71
|
*
|
|
57
|
-
* @
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
72
|
+
* @see https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/build-and-test/deep-link-teams
|
|
73
|
+
*/
|
|
74
|
+
export declare function buildMessageLink(opts: MessageLinkOptions): string;
|
|
75
|
+
/**
|
|
76
|
+
* @deprecated Use the options object overload instead.
|
|
61
77
|
*/
|
|
62
78
|
export declare function buildMessageLink(conversationId: string, messageTimestamp: string | number, parentMessageId?: string, teamsBaseUrl?: string): string;
|
|
63
79
|
/**
|
|
@@ -77,10 +93,17 @@ export declare function extractMessageTimestamp(source: Record<string, unknown>
|
|
|
77
93
|
* - Base64-encoded GUID: "93qkaTtFGWpUHjyRafgdhg=="
|
|
78
94
|
*/
|
|
79
95
|
export declare function parsePersonSuggestion(item: Record<string, unknown>): PersonSearchResult | null;
|
|
96
|
+
/** Context for building reliable message deep links. */
|
|
97
|
+
export interface LinkContext {
|
|
98
|
+
/** Tenant ID (GUID) from session. */
|
|
99
|
+
tenantId?: string;
|
|
100
|
+
/** Teams base URL (for GCC/GCC-High support). */
|
|
101
|
+
teamsBaseUrl?: string;
|
|
102
|
+
}
|
|
80
103
|
/**
|
|
81
104
|
* Parses a v2 query result item into a search result.
|
|
82
105
|
*/
|
|
83
|
-
export declare function parseV2Result(item: Record<string, unknown
|
|
106
|
+
export declare function parseV2Result(item: Record<string, unknown>, linkContext?: LinkContext): TeamsSearchResult | null;
|
|
84
107
|
/**
|
|
85
108
|
* Parses user profile from a JWT payload.
|
|
86
109
|
*
|
|
@@ -106,7 +129,7 @@ export declare function calculateTokenStatus(expiryMs: number, nowMs?: number):
|
|
|
106
129
|
* @param entitySets - Raw EntitySets array from API response
|
|
107
130
|
* @returns Parsed results and total count if available
|
|
108
131
|
*/
|
|
109
|
-
export declare function parseSearchResults(entitySets: unknown[] | undefined): {
|
|
132
|
+
export declare function parseSearchResults(entitySets: unknown[] | undefined, linkContext?: LinkContext): {
|
|
110
133
|
results: TeamsSearchResult[];
|
|
111
134
|
total?: number;
|
|
112
135
|
};
|
|
@@ -265,7 +288,7 @@ export interface VirtualConversationItem {
|
|
|
265
288
|
* @param referencePattern - Regex to extract source ID from secondaryReferenceId
|
|
266
289
|
* @returns Parsed virtual conversation item, or null if message should be skipped
|
|
267
290
|
*/
|
|
268
|
-
export declare function parseVirtualConversationMessage(msg: Record<string, unknown>, referencePattern: RegExp): VirtualConversationItem | null;
|
|
291
|
+
export declare function parseVirtualConversationMessage(msg: Record<string, unknown>, referencePattern: RegExp, linkContext?: LinkContext): VirtualConversationItem | null;
|
|
269
292
|
/** A single entry from a meeting transcript. */
|
|
270
293
|
export interface TranscriptEntry {
|
|
271
294
|
/** Start time (e.g., "00:00:22.287"). */
|
package/dist/utils/parsers.js
CHANGED
|
@@ -59,32 +59,44 @@ export function getConversationType(conversationId) {
|
|
|
59
59
|
}
|
|
60
60
|
/** Default Teams base URL for message links. */
|
|
61
61
|
const DEFAULT_TEAMS_LINK_BASE = 'https://teams.microsoft.com';
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
62
|
+
export function buildMessageLink(optsOrConversationId, messageTimestamp, parentMessageId, teamsBaseUrl) {
|
|
63
|
+
// Normalise arguments: support both old positional and new options-object signatures
|
|
64
|
+
let opts;
|
|
65
|
+
if (typeof optsOrConversationId === 'string') {
|
|
66
|
+
opts = {
|
|
67
|
+
conversationId: optsOrConversationId,
|
|
68
|
+
messageId: messageTimestamp,
|
|
69
|
+
parentMessageId,
|
|
70
|
+
teamsBaseUrl,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
opts = optsOrConversationId;
|
|
75
|
+
}
|
|
76
|
+
const base = opts.teamsBaseUrl ?? DEFAULT_TEAMS_LINK_BASE;
|
|
77
|
+
const msgId = typeof opts.messageId === 'string' ? opts.messageId : String(opts.messageId);
|
|
78
|
+
const convType = getConversationType(opts.conversationId);
|
|
79
|
+
// Build the base URL path — encode the conversationId for URL safety
|
|
80
|
+
const linkUrl = `${base}/l/message/${encodeURIComponent(opts.conversationId)}/${msgId}`;
|
|
81
|
+
const params = new URLSearchParams();
|
|
82
|
+
if (convType === 'channel') {
|
|
83
|
+
// Channel deep links require tenantId, groupId, and parentMessageId
|
|
84
|
+
if (opts.tenantId)
|
|
85
|
+
params.set('tenantId', opts.tenantId);
|
|
86
|
+
if (opts.groupId)
|
|
87
|
+
params.set('groupId', opts.groupId);
|
|
88
|
+
// parentMessageId is always required — for top-level posts it equals the messageId
|
|
89
|
+
params.set('parentMessageId', opts.parentMessageId ?? msgId);
|
|
90
|
+
params.set('createdTime', msgId);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
// Chat and meeting deep links require tenantId and context
|
|
94
|
+
if (opts.tenantId)
|
|
95
|
+
params.set('tenantId', opts.tenantId);
|
|
96
|
+
params.set('context', '{"contextType":"chat"}');
|
|
97
|
+
}
|
|
98
|
+
const qs = params.toString();
|
|
99
|
+
return qs ? `${linkUrl}?${qs}` : linkUrl;
|
|
88
100
|
}
|
|
89
101
|
/**
|
|
90
102
|
* Extracts a timestamp-based message ID from various sources.
|
|
@@ -170,7 +182,7 @@ export function parsePersonSuggestion(item) {
|
|
|
170
182
|
/**
|
|
171
183
|
* Parses a v2 query result item into a search result.
|
|
172
184
|
*/
|
|
173
|
-
export function parseV2Result(item) {
|
|
185
|
+
export function parseV2Result(item, linkContext) {
|
|
174
186
|
const content = item.HitHighlightedSummary ||
|
|
175
187
|
item.Summary ||
|
|
176
188
|
'';
|
|
@@ -235,7 +247,13 @@ export function parseV2Result(item) {
|
|
|
235
247
|
// Build message link if we have the required data
|
|
236
248
|
let messageLink;
|
|
237
249
|
if (conversationId && messageTimestamp) {
|
|
238
|
-
messageLink = buildMessageLink(
|
|
250
|
+
messageLink = buildMessageLink({
|
|
251
|
+
conversationId,
|
|
252
|
+
messageId: messageTimestamp,
|
|
253
|
+
tenantId: linkContext?.tenantId,
|
|
254
|
+
parentMessageId,
|
|
255
|
+
teamsBaseUrl: linkContext?.teamsBaseUrl,
|
|
256
|
+
});
|
|
239
257
|
}
|
|
240
258
|
return {
|
|
241
259
|
id,
|
|
@@ -317,7 +335,7 @@ export function calculateTokenStatus(expiryMs, nowMs = Date.now()) {
|
|
|
317
335
|
* @param entitySets - Raw EntitySets array from API response
|
|
318
336
|
* @returns Parsed results and total count if available
|
|
319
337
|
*/
|
|
320
|
-
export function parseSearchResults(entitySets) {
|
|
338
|
+
export function parseSearchResults(entitySets, linkContext) {
|
|
321
339
|
const results = [];
|
|
322
340
|
let total;
|
|
323
341
|
if (!Array.isArray(entitySets)) {
|
|
@@ -337,7 +355,7 @@ export function parseSearchResults(entitySets) {
|
|
|
337
355
|
const items = rs.Results;
|
|
338
356
|
if (Array.isArray(items)) {
|
|
339
357
|
for (const item of items) {
|
|
340
|
-
const parsed = parseV2Result(item);
|
|
358
|
+
const parsed = parseV2Result(item, linkContext);
|
|
341
359
|
if (parsed)
|
|
342
360
|
results.push(parsed);
|
|
343
361
|
}
|
|
@@ -829,7 +847,7 @@ export function hasMarkdownFormatting(text) {
|
|
|
829
847
|
* @param referencePattern - Regex to extract source ID from secondaryReferenceId
|
|
830
848
|
* @returns Parsed virtual conversation item, or null if message should be skipped
|
|
831
849
|
*/
|
|
832
|
-
export function parseVirtualConversationMessage(msg, referencePattern) {
|
|
850
|
+
export function parseVirtualConversationMessage(msg, referencePattern, linkContext) {
|
|
833
851
|
// Skip non-message types
|
|
834
852
|
const messageType = msg.messagetype || msg.type;
|
|
835
853
|
if (!messageType || messageType.startsWith('Control/')) {
|
|
@@ -859,7 +877,12 @@ export function parseVirtualConversationMessage(msg, referencePattern) {
|
|
|
859
877
|
}
|
|
860
878
|
// Build message link to original message
|
|
861
879
|
const messageLink = sourceConversationId && sourceReferenceId
|
|
862
|
-
? buildMessageLink(
|
|
880
|
+
? buildMessageLink({
|
|
881
|
+
conversationId: sourceConversationId,
|
|
882
|
+
messageId: sourceReferenceId,
|
|
883
|
+
tenantId: linkContext?.tenantId,
|
|
884
|
+
teamsBaseUrl: linkContext?.teamsBaseUrl,
|
|
885
|
+
})
|
|
863
886
|
: undefined;
|
|
864
887
|
// Extract links before stripping HTML
|
|
865
888
|
const links = extractLinks(content);
|
|
@@ -91,9 +91,11 @@ describe('getConversationType', () => {
|
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
93
|
describe('buildMessageLink', () => {
|
|
94
|
-
it('builds channel link
|
|
94
|
+
it('builds channel link with parentMessageId and createdTime', () => {
|
|
95
|
+
// Channel links always include parentMessageId (defaults to messageId for top-level posts)
|
|
95
96
|
const link = buildMessageLink('19:abc@thread.tacv2', '1705760000000');
|
|
96
|
-
expect(link).
|
|
97
|
+
expect(link).toContain('parentMessageId=1705760000000');
|
|
98
|
+
expect(link).toContain('createdTime=1705760000000');
|
|
97
99
|
expect(link).not.toContain('context');
|
|
98
100
|
});
|
|
99
101
|
it('builds chat link with context parameter', () => {
|
|
@@ -111,17 +113,47 @@ describe('buildMessageLink', () => {
|
|
|
111
113
|
it('builds channel thread reply link with parentMessageId', () => {
|
|
112
114
|
// Thread reply: message timestamp differs from parent
|
|
113
115
|
const link = buildMessageLink('19:abc@thread.tacv2', '1705770000000', '1705760000000');
|
|
114
|
-
expect(link).
|
|
116
|
+
expect(link).toContain('parentMessageId=1705760000000');
|
|
117
|
+
expect(link).toContain('createdTime=1705770000000');
|
|
115
118
|
});
|
|
116
|
-
it('
|
|
117
|
-
//
|
|
118
|
-
const link = buildMessageLink('19:abc@thread.tacv2', '1705760000000'
|
|
119
|
-
expect(link).
|
|
119
|
+
it('includes parentMessageId for top-level channel posts (defaults to messageId)', () => {
|
|
120
|
+
// Per MS docs, parentMessageId is always required for channel links
|
|
121
|
+
const link = buildMessageLink('19:abc@thread.tacv2', '1705760000000');
|
|
122
|
+
expect(link).toContain('parentMessageId=1705760000000');
|
|
120
123
|
});
|
|
121
124
|
it('encodes special characters in conversation ID', () => {
|
|
122
125
|
const link = buildMessageLink('19:special@thread.tacv2', '123');
|
|
123
126
|
expect(link).toContain('19%3Aspecial%40thread.tacv2');
|
|
124
127
|
});
|
|
128
|
+
it('builds channel link with tenantId and groupId via options object', () => {
|
|
129
|
+
const link = buildMessageLink({
|
|
130
|
+
conversationId: '19:abc@thread.tacv2',
|
|
131
|
+
messageId: '1705760000000',
|
|
132
|
+
tenantId: '0d9b645f-597b-41f0-a2a3-ef103fbd91bb',
|
|
133
|
+
groupId: '3606f714-ec2e-41b3-9ad1-6afb331bd35d',
|
|
134
|
+
});
|
|
135
|
+
expect(link).toContain('tenantId=0d9b645f-597b-41f0-a2a3-ef103fbd91bb');
|
|
136
|
+
expect(link).toContain('groupId=3606f714-ec2e-41b3-9ad1-6afb331bd35d');
|
|
137
|
+
expect(link).toContain('parentMessageId=1705760000000');
|
|
138
|
+
expect(link).toContain('createdTime=1705760000000');
|
|
139
|
+
});
|
|
140
|
+
it('builds chat link with tenantId via options object', () => {
|
|
141
|
+
const link = buildMessageLink({
|
|
142
|
+
conversationId: '19:guid1_guid2@unq.gbl.spaces',
|
|
143
|
+
messageId: '1705760000000',
|
|
144
|
+
tenantId: '0d9b645f-597b-41f0-a2a3-ef103fbd91bb',
|
|
145
|
+
});
|
|
146
|
+
expect(link).toContain('tenantId=0d9b645f-597b-41f0-a2a3-ef103fbd91bb');
|
|
147
|
+
expect(link).toContain('context=%7B%22contextType%22%3A%22chat%22%7D');
|
|
148
|
+
});
|
|
149
|
+
it('uses custom teamsBaseUrl for GCC support', () => {
|
|
150
|
+
const link = buildMessageLink({
|
|
151
|
+
conversationId: '19:guid1_guid2@unq.gbl.spaces',
|
|
152
|
+
messageId: '1705760000000',
|
|
153
|
+
teamsBaseUrl: 'https://teams.microsoft.us',
|
|
154
|
+
});
|
|
155
|
+
expect(link.startsWith('https://teams.microsoft.us/l/message/')).toBe(true);
|
|
156
|
+
});
|
|
125
157
|
});
|
|
126
158
|
describe('extractMessageTimestamp', () => {
|
|
127
159
|
it('extracts from MessageId field', () => {
|
|
@@ -252,11 +284,13 @@ describe('parseV2Result', () => {
|
|
|
252
284
|
// The message's own timestamp (from DateTimeReceived 2026-01-20T15:00:00.000Z)
|
|
253
285
|
expect(result.messageLink).toContain('/1768921200000');
|
|
254
286
|
});
|
|
255
|
-
it('generates messageLink
|
|
287
|
+
it('generates messageLink with parentMessageId for top-level channel posts', () => {
|
|
256
288
|
const result = parseV2Result(searchResultItem);
|
|
257
289
|
expect(result).not.toBeNull();
|
|
258
|
-
// Top-level post:
|
|
259
|
-
|
|
290
|
+
// Top-level post: parentMessageId equals the message's own timestamp
|
|
291
|
+
// Per MS docs, parentMessageId is always required for channel links
|
|
292
|
+
expect(result.messageLink).toContain('parentMessageId=1768919400000');
|
|
293
|
+
expect(result.messageLink).toContain('createdTime=1768919400000');
|
|
260
294
|
});
|
|
261
295
|
it('generates messageLink with context for meeting chats', () => {
|
|
262
296
|
const result = parseV2Result(searchResultWithHtml);
|