msteams-mcp 0.2.0 → 0.3.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/README.md +5 -0
- package/dist/__fixtures__/api-responses.d.ts +6 -6
- package/dist/__fixtures__/api-responses.js +4 -4
- package/dist/api/chatsvc-api.d.ts +85 -21
- package/dist/api/chatsvc-api.js +231 -25
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.js +1 -0
- package/dist/auth/token-extractor.js +46 -60
- package/dist/auth/token-refresh.d.ts +47 -0
- package/dist/auth/token-refresh.js +121 -0
- package/dist/constants.d.ts +10 -0
- package/dist/constants.js +19 -0
- package/dist/index.js +0 -0
- package/dist/test/cli.js +124 -12
- package/dist/test/mcp-harness.js +31 -0
- package/dist/tools/message-tools.d.ts +39 -0
- package/dist/tools/message-tools.js +177 -2
- package/dist/tools/people-tools.d.ts +2 -2
- package/dist/tools/search-tools.d.ts +8 -2
- package/dist/tools/search-tools.js +49 -2
- package/dist/utils/api-config.d.ts +6 -0
- package/dist/utils/api-config.js +7 -0
- package/dist/utils/auth-guards.d.ts +7 -0
- package/dist/utils/auth-guards.js +42 -1
- package/dist/utils/parsers.d.ts +15 -0
- package/dist/utils/parsers.js +31 -0
- package/dist/utils/parsers.test.js +55 -1
- package/package.json +1 -1
|
@@ -76,6 +76,30 @@ export declare const DeleteMessageInputSchema: z.ZodObject<{
|
|
|
76
76
|
conversationId: string;
|
|
77
77
|
messageId: string;
|
|
78
78
|
}>;
|
|
79
|
+
export declare const GetUnreadInputSchema: z.ZodObject<{
|
|
80
|
+
conversationId: z.ZodOptional<z.ZodString>;
|
|
81
|
+
}, "strip", z.ZodTypeAny, {
|
|
82
|
+
conversationId?: string | undefined;
|
|
83
|
+
}, {
|
|
84
|
+
conversationId?: string | undefined;
|
|
85
|
+
}>;
|
|
86
|
+
export declare const MarkAsReadInputSchema: z.ZodObject<{
|
|
87
|
+
conversationId: z.ZodString;
|
|
88
|
+
messageId: z.ZodString;
|
|
89
|
+
}, "strip", z.ZodTypeAny, {
|
|
90
|
+
conversationId: string;
|
|
91
|
+
messageId: string;
|
|
92
|
+
}, {
|
|
93
|
+
conversationId: string;
|
|
94
|
+
messageId: string;
|
|
95
|
+
}>;
|
|
96
|
+
export declare const GetActivityInputSchema: z.ZodObject<{
|
|
97
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
98
|
+
}, "strip", z.ZodTypeAny, {
|
|
99
|
+
limit?: number | undefined;
|
|
100
|
+
}, {
|
|
101
|
+
limit?: number | undefined;
|
|
102
|
+
}>;
|
|
79
103
|
export declare const sendMessageTool: RegisteredTool<typeof SendMessageInputSchema>;
|
|
80
104
|
export declare const replyToThreadTool: RegisteredTool<typeof ReplyToThreadInputSchema>;
|
|
81
105
|
export declare const getFavoritesTool: RegisteredTool<z.ZodObject<Record<string, never>>>;
|
|
@@ -86,6 +110,9 @@ export declare const unsaveMessageTool: RegisteredTool<typeof SaveMessageInputSc
|
|
|
86
110
|
export declare const getChatTool: RegisteredTool<typeof GetChatInputSchema>;
|
|
87
111
|
export declare const editMessageTool: RegisteredTool<typeof EditMessageInputSchema>;
|
|
88
112
|
export declare const deleteMessageTool: RegisteredTool<typeof DeleteMessageInputSchema>;
|
|
113
|
+
export declare const getUnreadTool: RegisteredTool<typeof GetUnreadInputSchema>;
|
|
114
|
+
export declare const markAsReadTool: RegisteredTool<typeof MarkAsReadInputSchema>;
|
|
115
|
+
export declare const getActivityTool: RegisteredTool<typeof GetActivityInputSchema>;
|
|
89
116
|
/** All message-related tools. */
|
|
90
117
|
export declare const messageTools: (RegisteredTool<z.ZodObject<{
|
|
91
118
|
content: z.ZodString;
|
|
@@ -136,4 +163,16 @@ export declare const messageTools: (RegisteredTool<z.ZodObject<{
|
|
|
136
163
|
userId: string;
|
|
137
164
|
}, {
|
|
138
165
|
userId: string;
|
|
166
|
+
}>> | RegisteredTool<z.ZodObject<{
|
|
167
|
+
conversationId: z.ZodOptional<z.ZodString>;
|
|
168
|
+
}, "strip", z.ZodTypeAny, {
|
|
169
|
+
conversationId?: string | undefined;
|
|
170
|
+
}, {
|
|
171
|
+
conversationId?: string | undefined;
|
|
172
|
+
}>> | RegisteredTool<z.ZodObject<{
|
|
173
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
174
|
+
}, "strip", z.ZodTypeAny, {
|
|
175
|
+
limit?: number | undefined;
|
|
176
|
+
}, {
|
|
177
|
+
limit?: number | undefined;
|
|
139
178
|
}>>)[];
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Messaging-related tool handlers.
|
|
3
3
|
*/
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import { sendMessage, sendNoteToSelf, replyToThread, saveMessage, unsaveMessage, getOneOnOneChatId, editMessage, deleteMessage, } from '../api/chatsvc-api.js';
|
|
5
|
+
import { sendMessage, sendNoteToSelf, replyToThread, saveMessage, unsaveMessage, getOneOnOneChatId, editMessage, deleteMessage, getUnreadStatus, markAsRead, getActivityFeed, } from '../api/chatsvc-api.js';
|
|
6
6
|
import { getFavorites, addFavorite, removeFavorite } from '../api/csa-api.js';
|
|
7
|
-
import { SELF_CHAT_ID } from '../constants.js';
|
|
7
|
+
import { SELF_CHAT_ID, MAX_UNREAD_AGGREGATE_CHECK } from '../constants.js';
|
|
8
|
+
import { ErrorCode } from '../types/errors.js';
|
|
8
9
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
10
|
// Schemas
|
|
10
11
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -37,6 +38,16 @@ export const DeleteMessageInputSchema = z.object({
|
|
|
37
38
|
conversationId: z.string().min(1, 'Conversation ID cannot be empty'),
|
|
38
39
|
messageId: z.string().min(1, 'Message ID cannot be empty'),
|
|
39
40
|
});
|
|
41
|
+
export const GetUnreadInputSchema = z.object({
|
|
42
|
+
conversationId: z.string().optional(),
|
|
43
|
+
});
|
|
44
|
+
export const MarkAsReadInputSchema = z.object({
|
|
45
|
+
conversationId: z.string().min(1, 'Conversation ID cannot be empty'),
|
|
46
|
+
messageId: z.string().min(1, 'Message ID cannot be empty'),
|
|
47
|
+
});
|
|
48
|
+
export const GetActivityInputSchema = z.object({
|
|
49
|
+
limit: z.number().min(1).max(200).optional(),
|
|
50
|
+
});
|
|
40
51
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
41
52
|
// Tool Definitions
|
|
42
53
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -210,6 +221,50 @@ const deleteMessageToolDefinition = {
|
|
|
210
221
|
required: ['conversationId', 'messageId'],
|
|
211
222
|
},
|
|
212
223
|
};
|
|
224
|
+
const getUnreadToolDefinition = {
|
|
225
|
+
name: 'teams_get_unread',
|
|
226
|
+
description: 'Get unread message status. Without parameters, returns aggregate unread counts across all favourite/pinned conversations. With a conversationId, returns unread status for that specific conversation.',
|
|
227
|
+
inputSchema: {
|
|
228
|
+
type: 'object',
|
|
229
|
+
properties: {
|
|
230
|
+
conversationId: {
|
|
231
|
+
type: 'string',
|
|
232
|
+
description: 'Optional. A specific conversation ID to check. If omitted, checks all favourites.',
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
const markAsReadToolDefinition = {
|
|
238
|
+
name: 'teams_mark_read',
|
|
239
|
+
description: 'Mark a conversation as read up to a specific message. This updates your read position so messages up to (and including) the specified message are marked as read.',
|
|
240
|
+
inputSchema: {
|
|
241
|
+
type: 'object',
|
|
242
|
+
properties: {
|
|
243
|
+
conversationId: {
|
|
244
|
+
type: 'string',
|
|
245
|
+
description: 'The conversation ID to mark as read',
|
|
246
|
+
},
|
|
247
|
+
messageId: {
|
|
248
|
+
type: 'string',
|
|
249
|
+
description: 'The message ID to mark as read up to (all messages up to this point will be marked read)',
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
required: ['conversationId', 'messageId'],
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
const getActivityToolDefinition = {
|
|
256
|
+
name: 'teams_get_activity',
|
|
257
|
+
description: 'Get the user\'s activity feed - mentions, reactions, replies, and other notifications. Returns recent activity items with sender, content, and source conversation context.',
|
|
258
|
+
inputSchema: {
|
|
259
|
+
type: 'object',
|
|
260
|
+
properties: {
|
|
261
|
+
limit: {
|
|
262
|
+
type: 'number',
|
|
263
|
+
description: 'Maximum number of activity items to return (default: 50, max: 200)',
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
};
|
|
213
268
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
214
269
|
// Handlers
|
|
215
270
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -365,6 +420,108 @@ async function handleDeleteMessage(input, _ctx) {
|
|
|
365
420
|
},
|
|
366
421
|
};
|
|
367
422
|
}
|
|
423
|
+
async function handleGetUnread(input, _ctx) {
|
|
424
|
+
// If a specific conversation is provided, just check that one
|
|
425
|
+
if (input.conversationId) {
|
|
426
|
+
const result = await getUnreadStatus(input.conversationId);
|
|
427
|
+
if (!result.ok) {
|
|
428
|
+
return { success: false, error: result.error };
|
|
429
|
+
}
|
|
430
|
+
return {
|
|
431
|
+
success: true,
|
|
432
|
+
data: {
|
|
433
|
+
conversationId: result.value.conversationId,
|
|
434
|
+
unreadCount: result.value.unreadCount,
|
|
435
|
+
lastReadMessageId: result.value.lastReadMessageId,
|
|
436
|
+
latestMessageId: result.value.latestMessageId,
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
// Aggregate mode: check all favourites
|
|
441
|
+
const favResult = await getFavorites();
|
|
442
|
+
if (!favResult.ok) {
|
|
443
|
+
return { success: false, error: favResult.error };
|
|
444
|
+
}
|
|
445
|
+
const favorites = favResult.value.favorites;
|
|
446
|
+
const conversations = [];
|
|
447
|
+
let totalUnread = 0;
|
|
448
|
+
let checkedCount = 0;
|
|
449
|
+
let errorCount = 0;
|
|
450
|
+
// Check unread status for each favourite (limit to prevent timeout)
|
|
451
|
+
const maxToCheck = MAX_UNREAD_AGGREGATE_CHECK;
|
|
452
|
+
for (const fav of favorites.slice(0, maxToCheck)) {
|
|
453
|
+
const unreadResult = await getUnreadStatus(fav.conversationId);
|
|
454
|
+
checkedCount++;
|
|
455
|
+
if (unreadResult.ok) {
|
|
456
|
+
if (unreadResult.value.unreadCount > 0) {
|
|
457
|
+
conversations.push({
|
|
458
|
+
conversationId: fav.conversationId,
|
|
459
|
+
displayName: fav.displayName,
|
|
460
|
+
conversationType: fav.conversationType,
|
|
461
|
+
unreadCount: unreadResult.value.unreadCount,
|
|
462
|
+
});
|
|
463
|
+
totalUnread += unreadResult.value.unreadCount;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
errorCount++;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// If all checks failed, return an error rather than misleading success
|
|
471
|
+
if (checkedCount > 0 && errorCount === checkedCount) {
|
|
472
|
+
return {
|
|
473
|
+
success: false,
|
|
474
|
+
error: {
|
|
475
|
+
code: ErrorCode.API_ERROR,
|
|
476
|
+
message: `Failed to check unread status for all ${checkedCount} favourites`,
|
|
477
|
+
retryable: true,
|
|
478
|
+
suggestions: ['Check authentication status with teams_status', 'Try teams_login to refresh session'],
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
success: true,
|
|
484
|
+
data: {
|
|
485
|
+
totalUnread,
|
|
486
|
+
conversationsWithUnread: conversations.length,
|
|
487
|
+
conversations,
|
|
488
|
+
checked: checkedCount,
|
|
489
|
+
totalFavorites: favorites.length,
|
|
490
|
+
errors: errorCount > 0 ? errorCount : undefined,
|
|
491
|
+
note: favorites.length > maxToCheck
|
|
492
|
+
? `Checked first ${maxToCheck} of ${favorites.length} favourites`
|
|
493
|
+
: undefined,
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
async function handleMarkAsRead(input, _ctx) {
|
|
498
|
+
const result = await markAsRead(input.conversationId, input.messageId);
|
|
499
|
+
if (!result.ok) {
|
|
500
|
+
return { success: false, error: result.error };
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
success: true,
|
|
504
|
+
data: {
|
|
505
|
+
message: 'Conversation marked as read',
|
|
506
|
+
conversationId: result.value.conversationId,
|
|
507
|
+
markedUpTo: result.value.markedUpTo,
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
async function handleGetActivity(input, _ctx) {
|
|
512
|
+
const result = await getActivityFeed({ limit: input.limit });
|
|
513
|
+
if (!result.ok) {
|
|
514
|
+
return { success: false, error: result.error };
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
success: true,
|
|
518
|
+
data: {
|
|
519
|
+
count: result.value.activities.length,
|
|
520
|
+
activities: result.value.activities,
|
|
521
|
+
syncState: result.value.syncState,
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
}
|
|
368
525
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
369
526
|
// Exports
|
|
370
527
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -418,6 +575,21 @@ export const deleteMessageTool = {
|
|
|
418
575
|
schema: DeleteMessageInputSchema,
|
|
419
576
|
handler: handleDeleteMessage,
|
|
420
577
|
};
|
|
578
|
+
export const getUnreadTool = {
|
|
579
|
+
definition: getUnreadToolDefinition,
|
|
580
|
+
schema: GetUnreadInputSchema,
|
|
581
|
+
handler: handleGetUnread,
|
|
582
|
+
};
|
|
583
|
+
export const markAsReadTool = {
|
|
584
|
+
definition: markAsReadToolDefinition,
|
|
585
|
+
schema: MarkAsReadInputSchema,
|
|
586
|
+
handler: handleMarkAsRead,
|
|
587
|
+
};
|
|
588
|
+
export const getActivityTool = {
|
|
589
|
+
definition: getActivityToolDefinition,
|
|
590
|
+
schema: GetActivityInputSchema,
|
|
591
|
+
handler: handleGetActivity,
|
|
592
|
+
};
|
|
421
593
|
/** All message-related tools. */
|
|
422
594
|
export const messageTools = [
|
|
423
595
|
sendMessageTool,
|
|
@@ -430,4 +602,7 @@ export const messageTools = [
|
|
|
430
602
|
getChatTool,
|
|
431
603
|
editMessageTool,
|
|
432
604
|
deleteMessageTool,
|
|
605
|
+
getUnreadTool,
|
|
606
|
+
markAsReadTool,
|
|
607
|
+
getActivityTool,
|
|
433
608
|
];
|
|
@@ -7,8 +7,8 @@ export declare const SearchPeopleInputSchema: z.ZodObject<{
|
|
|
7
7
|
query: z.ZodString;
|
|
8
8
|
limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
9
9
|
}, "strip", z.ZodTypeAny, {
|
|
10
|
-
query: string;
|
|
11
10
|
limit: number;
|
|
11
|
+
query: string;
|
|
12
12
|
}, {
|
|
13
13
|
query: string;
|
|
14
14
|
limit?: number | undefined;
|
|
@@ -32,8 +32,8 @@ export declare const peopleTools: (RegisteredTool<z.ZodObject<Record<string, nev
|
|
|
32
32
|
query: z.ZodString;
|
|
33
33
|
limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
34
34
|
}, "strip", z.ZodTypeAny, {
|
|
35
|
-
query: string;
|
|
36
35
|
limit: number;
|
|
36
|
+
query: string;
|
|
37
37
|
}, {
|
|
38
38
|
query: string;
|
|
39
39
|
limit?: number | undefined;
|
|
@@ -22,19 +22,22 @@ export declare const SearchInputSchema: z.ZodObject<{
|
|
|
22
22
|
export declare const GetThreadInputSchema: z.ZodObject<{
|
|
23
23
|
conversationId: z.ZodString;
|
|
24
24
|
limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
25
|
+
markRead: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
25
26
|
}, "strip", z.ZodTypeAny, {
|
|
26
27
|
conversationId: string;
|
|
27
28
|
limit: number;
|
|
29
|
+
markRead: boolean;
|
|
28
30
|
}, {
|
|
29
31
|
conversationId: string;
|
|
30
32
|
limit?: number | undefined;
|
|
33
|
+
markRead?: boolean | undefined;
|
|
31
34
|
}>;
|
|
32
35
|
export declare const FindChannelInputSchema: z.ZodObject<{
|
|
33
36
|
query: z.ZodString;
|
|
34
37
|
limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
35
38
|
}, "strip", z.ZodTypeAny, {
|
|
36
|
-
query: string;
|
|
37
39
|
limit: number;
|
|
40
|
+
query: string;
|
|
38
41
|
}, {
|
|
39
42
|
query: string;
|
|
40
43
|
limit?: number | undefined;
|
|
@@ -61,18 +64,21 @@ export declare const searchTools: (RegisteredTool<z.ZodObject<{
|
|
|
61
64
|
}>> | RegisteredTool<z.ZodObject<{
|
|
62
65
|
conversationId: z.ZodString;
|
|
63
66
|
limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
67
|
+
markRead: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
64
68
|
}, "strip", z.ZodTypeAny, {
|
|
65
69
|
conversationId: string;
|
|
66
70
|
limit: number;
|
|
71
|
+
markRead: boolean;
|
|
67
72
|
}, {
|
|
68
73
|
conversationId: string;
|
|
69
74
|
limit?: number | undefined;
|
|
75
|
+
markRead?: boolean | undefined;
|
|
70
76
|
}>> | RegisteredTool<z.ZodObject<{
|
|
71
77
|
query: z.ZodString;
|
|
72
78
|
limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
73
79
|
}, "strip", z.ZodTypeAny, {
|
|
74
|
-
query: string;
|
|
75
80
|
limit: number;
|
|
81
|
+
query: string;
|
|
76
82
|
}, {
|
|
77
83
|
query: string;
|
|
78
84
|
limit?: number | undefined;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { searchMessages, searchChannels } from '../api/substrate-api.js';
|
|
6
|
-
import { getThreadMessages } from '../api/chatsvc-api.js';
|
|
6
|
+
import { getThreadMessages, getConsumptionHorizon, markAsRead } from '../api/chatsvc-api.js';
|
|
7
7
|
import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, DEFAULT_THREAD_LIMIT, MAX_THREAD_LIMIT, DEFAULT_CHANNEL_LIMIT, MAX_CHANNEL_LIMIT, } from '../constants.js';
|
|
8
8
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
9
|
// Schemas
|
|
@@ -17,6 +17,7 @@ export const SearchInputSchema = z.object({
|
|
|
17
17
|
export const GetThreadInputSchema = z.object({
|
|
18
18
|
conversationId: z.string().min(1, 'Conversation ID cannot be empty'),
|
|
19
19
|
limit: z.number().min(1).max(MAX_THREAD_LIMIT).optional().default(DEFAULT_THREAD_LIMIT),
|
|
20
|
+
markRead: z.boolean().optional().default(false),
|
|
20
21
|
});
|
|
21
22
|
export const FindChannelInputSchema = z.object({
|
|
22
23
|
query: z.string().min(1, 'Query cannot be empty'),
|
|
@@ -53,7 +54,7 @@ const searchToolDefinition = {
|
|
|
53
54
|
};
|
|
54
55
|
const getThreadToolDefinition = {
|
|
55
56
|
name: 'teams_get_thread',
|
|
56
|
-
description: 'Get messages from a Teams conversation/thread. Use this to see replies to a message, check thread context, or read recent messages in a chat. Requires a conversationId (available from search results).',
|
|
57
|
+
description: 'Get messages from a Teams conversation/thread. Use this to see replies to a message, check thread context, or read recent messages in a chat. Requires a conversationId (available from search results). Returns unread count and can optionally mark the conversation as read.',
|
|
57
58
|
inputSchema: {
|
|
58
59
|
type: 'object',
|
|
59
60
|
properties: {
|
|
@@ -65,6 +66,10 @@ const getThreadToolDefinition = {
|
|
|
65
66
|
type: 'number',
|
|
66
67
|
description: 'Maximum number of messages to return (default: 50, max: 200)',
|
|
67
68
|
},
|
|
69
|
+
markRead: {
|
|
70
|
+
type: 'boolean',
|
|
71
|
+
description: 'If true, marks the conversation as read up to the latest message after fetching (default: false)',
|
|
72
|
+
},
|
|
68
73
|
},
|
|
69
74
|
required: ['conversationId'],
|
|
70
75
|
},
|
|
@@ -123,11 +128,53 @@ async function handleGetThread(input, _ctx) {
|
|
|
123
128
|
if (!result.ok) {
|
|
124
129
|
return { success: false, error: result.error };
|
|
125
130
|
}
|
|
131
|
+
// Get unread status
|
|
132
|
+
let unreadCount;
|
|
133
|
+
let lastReadMessageId;
|
|
134
|
+
const horizonResult = await getConsumptionHorizon(input.conversationId);
|
|
135
|
+
if (horizonResult.ok) {
|
|
136
|
+
lastReadMessageId = horizonResult.value.lastReadMessageId;
|
|
137
|
+
// Count messages after last read
|
|
138
|
+
if (lastReadMessageId) {
|
|
139
|
+
let foundLastRead = false;
|
|
140
|
+
unreadCount = 0;
|
|
141
|
+
for (const msg of result.value.messages) {
|
|
142
|
+
if (msg.id === lastReadMessageId) {
|
|
143
|
+
foundLastRead = true;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (foundLastRead && !msg.isFromMe) {
|
|
147
|
+
unreadCount++;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// If last read message wasn't found, it's older than our window - all messages are unread
|
|
151
|
+
if (!foundLastRead) {
|
|
152
|
+
unreadCount = result.value.messages.filter(m => !m.isFromMe).length;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
// No consumption horizon means all messages are unread (new conversation)
|
|
157
|
+
unreadCount = result.value.messages.filter(m => !m.isFromMe).length;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Mark as read if requested
|
|
161
|
+
let markedAsRead = false;
|
|
162
|
+
if (input.markRead && result.value.messages.length > 0) {
|
|
163
|
+
// Find the latest message
|
|
164
|
+
const latestMessage = result.value.messages[result.value.messages.length - 1];
|
|
165
|
+
if (latestMessage) {
|
|
166
|
+
const markResult = await markAsRead(input.conversationId, latestMessage.id);
|
|
167
|
+
markedAsRead = markResult.ok;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
126
170
|
return {
|
|
127
171
|
success: true,
|
|
128
172
|
data: {
|
|
129
173
|
conversationId: result.value.conversationId,
|
|
130
174
|
messageCount: result.value.messages.length,
|
|
175
|
+
unreadCount,
|
|
176
|
+
lastReadMessageId,
|
|
177
|
+
markedAsRead: input.markRead ? markedAsRead : undefined,
|
|
131
178
|
messages: result.value.messages,
|
|
132
179
|
},
|
|
133
180
|
};
|
|
@@ -46,6 +46,12 @@ export declare const CHATSVC_API: {
|
|
|
46
46
|
readonly editMessage: (region: Region, conversationId: string, messageId: string) => string;
|
|
47
47
|
/** Delete a specific message URL (soft delete). */
|
|
48
48
|
readonly deleteMessage: (region: Region, conversationId: string, messageId: string) => string;
|
|
49
|
+
/** Get consumption horizons (read receipts) for a thread. */
|
|
50
|
+
readonly consumptionHorizons: (region: Region, threadId: string) => string;
|
|
51
|
+
/** Update consumption horizon (mark as read) for a conversation. */
|
|
52
|
+
readonly updateConsumptionHorizon: (region: Region, conversationId: string) => string;
|
|
53
|
+
/** Activity feed (notifications) messages. */
|
|
54
|
+
readonly activityFeed: (region: Region) => string;
|
|
49
55
|
};
|
|
50
56
|
/** CSA (Chat Service Aggregator) API endpoints. */
|
|
51
57
|
export declare const CSA_API: {
|
package/dist/utils/api-config.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Centralises all API URLs and common request headers.
|
|
5
5
|
*/
|
|
6
|
+
import { NOTIFICATIONS_ID } from '../constants.js';
|
|
6
7
|
/** Valid API regions. */
|
|
7
8
|
export const VALID_REGIONS = ['amer', 'emea', 'apac'];
|
|
8
9
|
/**
|
|
@@ -64,6 +65,12 @@ export const CHATSVC_API = {
|
|
|
64
65
|
editMessage: (region, conversationId, messageId) => `https://teams.microsoft.com/api/chatsvc/${region}/v1/users/ME/conversations/${encodeURIComponent(conversationId)}/messages/${messageId}`,
|
|
65
66
|
/** Delete a specific message URL (soft delete). */
|
|
66
67
|
deleteMessage: (region, conversationId, messageId) => `https://teams.microsoft.com/api/chatsvc/${region}/v1/users/ME/conversations/${encodeURIComponent(conversationId)}/messages/${messageId}?behavior=softDelete`,
|
|
68
|
+
/** Get consumption horizons (read receipts) for a thread. */
|
|
69
|
+
consumptionHorizons: (region, threadId) => `https://teams.microsoft.com/api/chatsvc/${region}/v1/threads/${encodeURIComponent(threadId)}/consumptionhorizons`,
|
|
70
|
+
/** Update consumption horizon (mark as read) for a conversation. */
|
|
71
|
+
updateConsumptionHorizon: (region, conversationId) => `https://teams.microsoft.com/api/chatsvc/${region}/v1/users/ME/conversations/${encodeURIComponent(conversationId)}/properties?name=consumptionhorizon`,
|
|
72
|
+
/** Activity feed (notifications) messages. */
|
|
73
|
+
activityFeed: (region) => `https://teams.microsoft.com/api/chatsvc/${region}/v1/users/ME/conversations/${encodeURIComponent(NOTIFICATIONS_ID)}/messages`,
|
|
67
74
|
};
|
|
68
75
|
/** CSA (Chat Service Aggregator) API endpoints. */
|
|
69
76
|
export const CSA_API = {
|
|
@@ -17,6 +17,13 @@ export interface CsaAuthInfo {
|
|
|
17
17
|
* Use for search and people APIs.
|
|
18
18
|
*/
|
|
19
19
|
export declare function requireSubstrateToken(): Result<string, McpError>;
|
|
20
|
+
/**
|
|
21
|
+
* Requires a valid Substrate token with proactive refresh.
|
|
22
|
+
*
|
|
23
|
+
* This async version attempts to refresh tokens if they're approaching
|
|
24
|
+
* expiry (within 10 minutes). Use this in tool handlers for better UX.
|
|
25
|
+
*/
|
|
26
|
+
export declare function requireSubstrateTokenAsync(): Promise<Result<string, McpError>>;
|
|
20
27
|
/**
|
|
21
28
|
* Requires valid message authentication.
|
|
22
29
|
* Use for chatsvc messaging APIs.
|
|
@@ -6,7 +6,9 @@
|
|
|
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, } from '../auth/token-extractor.js';
|
|
9
|
+
import { getValidSubstrateToken, extractMessageAuth, extractCsaToken, extractSubstrateToken, } from '../auth/token-extractor.js';
|
|
10
|
+
import { TOKEN_REFRESH_THRESHOLD_MS } from '../constants.js';
|
|
11
|
+
import { refreshTokensViaBrowser } from '../auth/token-refresh.js';
|
|
10
12
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
13
|
// Error Messages
|
|
12
14
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -29,6 +31,45 @@ export function requireSubstrateToken() {
|
|
|
29
31
|
}
|
|
30
32
|
return ok(token);
|
|
31
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Checks if the Substrate token is approaching expiry and needs refresh.
|
|
36
|
+
*
|
|
37
|
+
* @returns true if token will expire within the refresh threshold
|
|
38
|
+
*/
|
|
39
|
+
function shouldRefreshSubstrateToken() {
|
|
40
|
+
const substrate = extractSubstrateToken();
|
|
41
|
+
if (!substrate)
|
|
42
|
+
return false;
|
|
43
|
+
const timeRemaining = substrate.expiry.getTime() - Date.now();
|
|
44
|
+
return timeRemaining > 0 && timeRemaining < TOKEN_REFRESH_THRESHOLD_MS;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Requires a valid Substrate token with proactive refresh.
|
|
48
|
+
*
|
|
49
|
+
* This async version attempts to refresh tokens if they're approaching
|
|
50
|
+
* expiry (within 10 minutes). Use this in tool handlers for better UX.
|
|
51
|
+
*/
|
|
52
|
+
export async function requireSubstrateTokenAsync() {
|
|
53
|
+
// Check if we need to refresh proactively
|
|
54
|
+
if (shouldRefreshSubstrateToken()) {
|
|
55
|
+
const refreshResult = await refreshTokensViaBrowser();
|
|
56
|
+
if (refreshResult.ok) {
|
|
57
|
+
// Refresh succeeded, get the new token
|
|
58
|
+
const token = getValidSubstrateToken();
|
|
59
|
+
if (token) {
|
|
60
|
+
return ok(token);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Refresh failed but token might still be valid, continue
|
|
64
|
+
}
|
|
65
|
+
// Try to get existing token
|
|
66
|
+
const token = getValidSubstrateToken();
|
|
67
|
+
if (!token) {
|
|
68
|
+
// Token expired and refresh not available/failed
|
|
69
|
+
return err(createError(ErrorCode.AUTH_EXPIRED, 'Token expired and automatic refresh failed. Please run teams_login to re-authenticate.', { suggestions: ['Call teams_login to re-authenticate'] }));
|
|
70
|
+
}
|
|
71
|
+
return ok(token);
|
|
72
|
+
}
|
|
32
73
|
/**
|
|
33
74
|
* Requires valid message authentication.
|
|
34
75
|
* Use for chatsvc messaging APIs.
|
package/dist/utils/parsers.d.ts
CHANGED
|
@@ -185,3 +185,18 @@ export declare function extractObjectId(identifier: string): string | null;
|
|
|
185
185
|
* @returns The constructed conversation ID, or null if either ID is invalid
|
|
186
186
|
*/
|
|
187
187
|
export declare function buildOneOnOneConversationId(userId1: string, userId2: string): string | null;
|
|
188
|
+
/**
|
|
189
|
+
* Safely extracts a timestamp from an activity feed message.
|
|
190
|
+
*
|
|
191
|
+
* Tries multiple sources in order of preference:
|
|
192
|
+
* 1. originalarrivaltime - Primary timestamp field
|
|
193
|
+
* 2. composetime - When message was composed
|
|
194
|
+
* 3. id as numeric timestamp - Fallback if ID is a Unix timestamp
|
|
195
|
+
*
|
|
196
|
+
* Returns null if no valid timestamp can be determined, preventing
|
|
197
|
+
* RangeError from Date operations on invalid values.
|
|
198
|
+
*
|
|
199
|
+
* @param msg - Raw message object from activity feed API
|
|
200
|
+
* @returns ISO timestamp string, or null if no valid timestamp found
|
|
201
|
+
*/
|
|
202
|
+
export declare function extractActivityTimestamp(msg: Record<string, unknown>): string | null;
|
package/dist/utils/parsers.js
CHANGED
|
@@ -572,3 +572,34 @@ export function buildOneOnOneConversationId(userId1, userId2) {
|
|
|
572
572
|
const sorted = [id1, id2].sort();
|
|
573
573
|
return `19:${sorted[0]}_${sorted[1]}@unq.gbl.spaces`;
|
|
574
574
|
}
|
|
575
|
+
/**
|
|
576
|
+
* Safely extracts a timestamp from an activity feed message.
|
|
577
|
+
*
|
|
578
|
+
* Tries multiple sources in order of preference:
|
|
579
|
+
* 1. originalarrivaltime - Primary timestamp field
|
|
580
|
+
* 2. composetime - When message was composed
|
|
581
|
+
* 3. id as numeric timestamp - Fallback if ID is a Unix timestamp
|
|
582
|
+
*
|
|
583
|
+
* Returns null if no valid timestamp can be determined, preventing
|
|
584
|
+
* RangeError from Date operations on invalid values.
|
|
585
|
+
*
|
|
586
|
+
* @param msg - Raw message object from activity feed API
|
|
587
|
+
* @returns ISO timestamp string, or null if no valid timestamp found
|
|
588
|
+
*/
|
|
589
|
+
export function extractActivityTimestamp(msg) {
|
|
590
|
+
const arrivalTime = msg.originalarrivaltime;
|
|
591
|
+
const composeTime = msg.composetime;
|
|
592
|
+
if (arrivalTime)
|
|
593
|
+
return arrivalTime;
|
|
594
|
+
if (composeTime)
|
|
595
|
+
return composeTime;
|
|
596
|
+
// Try parsing the message ID as a numeric timestamp
|
|
597
|
+
const id = msg.id;
|
|
598
|
+
if (id) {
|
|
599
|
+
const numericId = parseInt(id, 10);
|
|
600
|
+
if (!isNaN(numericId) && numericId > 0) {
|
|
601
|
+
return new Date(numericId).toISOString();
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* produce expected outputs regardless of internal logic.
|
|
6
6
|
*/
|
|
7
7
|
import { describe, it, expect } from 'vitest';
|
|
8
|
-
import { stripHtml, buildMessageLink, extractMessageTimestamp, parsePersonSuggestion, parseV2Result, parseJwtProfile, calculateTokenStatus, parseSearchResults, parsePeopleResults, extractObjectId, buildOneOnOneConversationId, decodeBase64Guid, } from './parsers.js';
|
|
8
|
+
import { stripHtml, buildMessageLink, extractMessageTimestamp, parsePersonSuggestion, parseV2Result, parseJwtProfile, calculateTokenStatus, parseSearchResults, parsePeopleResults, extractObjectId, buildOneOnOneConversationId, decodeBase64Guid, extractActivityTimestamp, } from './parsers.js';
|
|
9
9
|
import { searchResultItem, searchResultWithHtml, searchResultMinimal, searchResultTooShort, searchEntitySetsResponse, personSuggestion, personMinimal, personWithBase64Id, peopleGroupsResponse, jwtPayloadFull, jwtPayloadMinimal, jwtPayloadCommaName, jwtPayloadSpaceName, sourceWithMessageId, sourceWithConvIdMessageId, } from '../__fixtures__/api-responses.js';
|
|
10
10
|
describe('stripHtml', () => {
|
|
11
11
|
it('removes HTML tags', () => {
|
|
@@ -358,3 +358,57 @@ describe('buildOneOnOneConversationId', () => {
|
|
|
358
358
|
expect(buildOneOnOneConversationId('', '')).toBeNull();
|
|
359
359
|
});
|
|
360
360
|
});
|
|
361
|
+
describe('extractActivityTimestamp', () => {
|
|
362
|
+
it('prefers originalarrivaltime when present', () => {
|
|
363
|
+
const msg = {
|
|
364
|
+
originalarrivaltime: '2024-01-15T10:30:00.000Z',
|
|
365
|
+
composetime: '2024-01-15T10:29:00.000Z',
|
|
366
|
+
id: '1705315800000',
|
|
367
|
+
};
|
|
368
|
+
expect(extractActivityTimestamp(msg)).toBe('2024-01-15T10:30:00.000Z');
|
|
369
|
+
});
|
|
370
|
+
it('falls back to composetime when originalarrivaltime is missing', () => {
|
|
371
|
+
const msg = {
|
|
372
|
+
composetime: '2024-01-15T10:29:00.000Z',
|
|
373
|
+
id: '1705315800000',
|
|
374
|
+
};
|
|
375
|
+
expect(extractActivityTimestamp(msg)).toBe('2024-01-15T10:29:00.000Z');
|
|
376
|
+
});
|
|
377
|
+
it('parses numeric id as timestamp when no time fields present', () => {
|
|
378
|
+
const msg = {
|
|
379
|
+
id: '1705315800000', // 2024-01-15T10:30:00.000Z
|
|
380
|
+
};
|
|
381
|
+
const result = extractActivityTimestamp(msg);
|
|
382
|
+
expect(result).toBe(new Date(1705315800000).toISOString());
|
|
383
|
+
});
|
|
384
|
+
it('returns null for non-numeric id when no time fields present', () => {
|
|
385
|
+
const msg = {
|
|
386
|
+
id: 'abc-not-a-number',
|
|
387
|
+
};
|
|
388
|
+
expect(extractActivityTimestamp(msg)).toBeNull();
|
|
389
|
+
});
|
|
390
|
+
it('returns null for empty message object', () => {
|
|
391
|
+
expect(extractActivityTimestamp({})).toBeNull();
|
|
392
|
+
});
|
|
393
|
+
it('returns null when id is undefined', () => {
|
|
394
|
+
const msg = {
|
|
395
|
+
originalarrivaltime: undefined,
|
|
396
|
+
composetime: undefined,
|
|
397
|
+
};
|
|
398
|
+
expect(extractActivityTimestamp(msg)).toBeNull();
|
|
399
|
+
});
|
|
400
|
+
it('handles zero id correctly (returns null)', () => {
|
|
401
|
+
const msg = {
|
|
402
|
+
id: '0',
|
|
403
|
+
};
|
|
404
|
+
// Zero is not a valid timestamp
|
|
405
|
+
expect(extractActivityTimestamp(msg)).toBeNull();
|
|
406
|
+
});
|
|
407
|
+
it('handles negative id correctly (returns null)', () => {
|
|
408
|
+
const msg = {
|
|
409
|
+
id: '-1705315800000',
|
|
410
|
+
};
|
|
411
|
+
// Negative timestamps are invalid
|
|
412
|
+
expect(extractActivityTimestamp(msg)).toBeNull();
|
|
413
|
+
});
|
|
414
|
+
});
|