msteams-mcp 0.22.3 → 0.23.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/dist/api/calendar-api.js +4 -6
- package/dist/api/chatsvc-activity.d.ts +4 -0
- package/dist/api/chatsvc-activity.js +18 -1
- package/dist/api/chatsvc-messaging.d.ts +27 -0
- package/dist/api/chatsvc-messaging.js +24 -9
- package/dist/api/chatsvc-messaging.test.d.ts +7 -0
- package/dist/api/chatsvc-messaging.test.js +129 -0
- package/dist/api/chatsvc-readstatus.d.ts +26 -0
- package/dist/api/chatsvc-readstatus.js +85 -5
- package/dist/api/graph-api.d.ts +55 -0
- package/dist/api/graph-api.js +148 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/tags-api.d.ts +34 -0
- package/dist/api/tags-api.js +53 -0
- package/dist/browser/auth.js +2 -1
- package/dist/browser/chrome-cookie-import.d.ts +27 -0
- package/dist/browser/chrome-cookie-import.js +294 -0
- package/dist/browser/chrome-cookie-import.test.d.ts +1 -0
- package/dist/browser/chrome-cookie-import.test.js +122 -0
- package/dist/browser/context.js +9 -0
- package/dist/constants.d.ts +0 -2
- package/dist/constants.js +0 -5
- package/dist/server.js +7 -1
- package/dist/test/dump-tokens.d.ts +7 -0
- package/dist/test/dump-tokens.js +90 -0
- package/dist/test/mcp-harness.js +2 -1
- package/dist/test/test-transcript.d.ts +6 -0
- package/dist/test/test-transcript.js +47 -0
- package/dist/tools/auth-tools.js +12 -5
- package/dist/tools/file-tools.js +1 -1
- package/dist/tools/graph-tools.d.ts +42 -0
- package/dist/tools/graph-tools.js +99 -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/message-tools.d.ts +12 -0
- package/dist/tools/message-tools.js +26 -58
- package/dist/tools/registry.js +11 -2
- package/dist/tools/registry.test.d.ts +4 -0
- package/dist/tools/registry.test.js +123 -0
- package/dist/tools/search-tools.d.ts +6 -0
- package/dist/tools/search-tools.js +19 -0
- package/dist/tools/search-tools.test.d.ts +9 -0
- package/dist/tools/search-tools.test.js +211 -0
- package/dist/tools/tag-tools.d.ts +25 -0
- package/dist/tools/tag-tools.js +73 -0
- package/dist/types/errors.js +3 -6
- package/dist/types/teams.d.ts +2 -0
- package/dist/utils/api-config.d.ts +16 -5
- package/dist/utils/api-config.js +16 -0
- package/dist/utils/api-config.test.d.ts +4 -0
- package/dist/utils/api-config.test.js +183 -0
- package/dist/utils/auth-guards.d.ts +5 -5
- package/dist/utils/auth-guards.js +5 -5
- package/dist/utils/http.js +3 -2
- package/dist/utils/http.test.d.ts +4 -0
- package/dist/utils/http.test.js +202 -0
- package/dist/utils/logger.d.ts +1 -1
- package/dist/utils/logger.js +1 -1
- package/dist/utils/logger.test.d.ts +4 -0
- package/dist/utils/logger.test.js +135 -0
- package/dist/utils/parsers-html.d.ts +6 -1
- package/dist/utils/parsers-html.js +23 -7
- package/dist/utils/parsers-search.js +1 -1
- package/package.json +1 -1
package/dist/api/calendar-api.js
CHANGED
|
@@ -7,8 +7,7 @@ import { httpRequest } from '../utils/http.js';
|
|
|
7
7
|
import { CALENDAR_API, getTeamsHeaders } from '../utils/api-config.js';
|
|
8
8
|
import { ok, err } from '../types/result.js';
|
|
9
9
|
import { ErrorCode, createError } from '../types/errors.js';
|
|
10
|
-
import {
|
|
11
|
-
import { extractRegionConfig } from '../auth/token-extractor.js';
|
|
10
|
+
import { requireSkypeSpacesAuth, getRegionConfig } from '../utils/auth-guards.js';
|
|
12
11
|
import { DEFAULT_MEETING_LIMIT, DEFAULT_MEETING_DAYS_AHEAD, } from '../constants.js';
|
|
13
12
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
14
13
|
// Helpers
|
|
@@ -119,13 +118,12 @@ function getSelectFields() {
|
|
|
119
118
|
* @returns List of meetings
|
|
120
119
|
*/
|
|
121
120
|
export async function getCalendarView(options = {}) {
|
|
122
|
-
const authResult =
|
|
121
|
+
const authResult = requireSkypeSpacesAuth();
|
|
123
122
|
if (!authResult.ok) {
|
|
124
123
|
return authResult;
|
|
125
124
|
}
|
|
126
125
|
const { skypeToken, spacesToken } = authResult.value;
|
|
127
|
-
|
|
128
|
-
const regionConfig = extractRegionConfig();
|
|
126
|
+
const regionConfig = getRegionConfig();
|
|
129
127
|
if (!regionConfig) {
|
|
130
128
|
return err(createError(ErrorCode.AUTH_REQUIRED, 'Could not determine region. Please run teams_login to authenticate.', { suggestions: ['Call teams_login to authenticate'] }));
|
|
131
129
|
}
|
|
@@ -135,7 +133,7 @@ export async function getCalendarView(options = {}) {
|
|
|
135
133
|
defaultEnd.setDate(defaultEnd.getDate() + DEFAULT_MEETING_DAYS_AHEAD);
|
|
136
134
|
const startDate = options.startDate || now.toISOString();
|
|
137
135
|
const endDate = options.endDate || defaultEnd.toISOString();
|
|
138
|
-
const limit = options.limit
|
|
136
|
+
const limit = options.limit ?? DEFAULT_MEETING_LIMIT;
|
|
139
137
|
// Build query parameters
|
|
140
138
|
const params = new URLSearchParams({
|
|
141
139
|
startDate,
|
|
@@ -43,7 +43,11 @@ export interface GetActivityResult {
|
|
|
43
43
|
/**
|
|
44
44
|
* Gets the activity feed (notifications) for the current user.
|
|
45
45
|
* Includes mentions, reactions, replies, and other notifications.
|
|
46
|
+
*
|
|
47
|
+
* @param options.limit - Maximum items to return (default: 50, max: 200)
|
|
48
|
+
* @param options.syncState - Pagination token from previous response for next page
|
|
46
49
|
*/
|
|
47
50
|
export declare function getActivityFeed(options?: {
|
|
48
51
|
limit?: number;
|
|
52
|
+
syncState?: string;
|
|
49
53
|
}): Promise<Result<GetActivityResult>>;
|
|
@@ -43,6 +43,9 @@ function detectActivityType(msg) {
|
|
|
43
43
|
/**
|
|
44
44
|
* Gets the activity feed (notifications) for the current user.
|
|
45
45
|
* Includes mentions, reactions, replies, and other notifications.
|
|
46
|
+
*
|
|
47
|
+
* @param options.limit - Maximum items to return (default: 50, max: 200)
|
|
48
|
+
* @param options.syncState - Pagination token from previous response for next page
|
|
46
49
|
*/
|
|
47
50
|
export async function getActivityFeed(options = {}) {
|
|
48
51
|
const authResult = requireMessageAuthWithConfig();
|
|
@@ -53,6 +56,9 @@ export async function getActivityFeed(options = {}) {
|
|
|
53
56
|
const limit = options.limit ?? DEFAULT_ACTIVITY_LIMIT;
|
|
54
57
|
let url = CHATSVC_API.activityFeed(region, baseUrl);
|
|
55
58
|
url += `?view=msnp24Equivalent&pageSize=${limit}`;
|
|
59
|
+
if (options.syncState) {
|
|
60
|
+
url += `&syncState=${encodeURIComponent(options.syncState)}`;
|
|
61
|
+
}
|
|
56
62
|
const response = await httpRequest(url, {
|
|
57
63
|
method: 'GET',
|
|
58
64
|
headers: getSkypeAuthHeaders(auth.skypeToken, auth.authToken, baseUrl),
|
|
@@ -61,7 +67,18 @@ export async function getActivityFeed(options = {}) {
|
|
|
61
67
|
return response;
|
|
62
68
|
}
|
|
63
69
|
const rawMessages = response.value.data.messages;
|
|
64
|
-
|
|
70
|
+
// syncState is in _metadata as a full URL — extract just the token
|
|
71
|
+
const metadata = response.value.data._metadata;
|
|
72
|
+
let syncState;
|
|
73
|
+
if (metadata?.syncState) {
|
|
74
|
+
try {
|
|
75
|
+
const metaUrl = new URL(metadata.syncState);
|
|
76
|
+
syncState = metaUrl.searchParams.get('syncState') ?? undefined;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
syncState = metadata.syncState;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
65
82
|
if (!Array.isArray(rawMessages)) {
|
|
66
83
|
return ok({
|
|
67
84
|
activities: [],
|
|
@@ -48,6 +48,13 @@ export interface DeleteMessageResult {
|
|
|
48
48
|
messageId: string;
|
|
49
49
|
conversationId: string;
|
|
50
50
|
}
|
|
51
|
+
/** A mention to include in a message. */
|
|
52
|
+
export interface Mention {
|
|
53
|
+
/** The user's MRI (e.g., '8:orgid:uuid') or tag MRI (e.g., 'tag:abc123'). */
|
|
54
|
+
mri: string;
|
|
55
|
+
/** Display name to show for the mention. */
|
|
56
|
+
displayName: string;
|
|
57
|
+
}
|
|
51
58
|
/** Options for sending a message. */
|
|
52
59
|
export interface SendMessageOptions {
|
|
53
60
|
/**
|
|
@@ -177,3 +184,23 @@ export declare function getOneOnOneChatId(otherUserIdentifier: string): Result<G
|
|
|
177
184
|
* ```
|
|
178
185
|
*/
|
|
179
186
|
export declare function createGroupChat(memberIdentifiers: string[], topic?: string): Promise<Result<CreateGroupChatResult>>;
|
|
187
|
+
/**
|
|
188
|
+
* Builds the HTML for a single mention.
|
|
189
|
+
*
|
|
190
|
+
* Tag mentions use a span-only format; person mentions include a readonly wrapper.
|
|
191
|
+
*/
|
|
192
|
+
export declare function buildMentionHtml(displayName: string, itemId: number, mri: string): string;
|
|
193
|
+
/**
|
|
194
|
+
* Builds the mentions property array for the API request.
|
|
195
|
+
*
|
|
196
|
+
* Tag mentions use mentionType 'tag' with the raw tag ID (without 'tag:' prefix).
|
|
197
|
+
*/
|
|
198
|
+
export declare function buildMentionsProperty(mentions: Mention[]): string;
|
|
199
|
+
/**
|
|
200
|
+
* Parses content for both mentions @[Name](mri) and links [text](url).
|
|
201
|
+
* Processes them in a single pass to avoid escaping conflicts.
|
|
202
|
+
*/
|
|
203
|
+
export declare function parseContentWithMentionsAndLinks(content: string): {
|
|
204
|
+
html: string;
|
|
205
|
+
mentions: Mention[];
|
|
206
|
+
};
|
|
@@ -13,6 +13,14 @@ import { requireMessageAuth, requireMessageAuthWithConfig, getTeamsBaseUrl, getT
|
|
|
13
13
|
import { stripHtml, extractLinks, buildMessageLink, buildOneOnOneConversationId, extractObjectId, markdownToTeamsHtml, escapeHtmlChars } from '../utils/parsers.js';
|
|
14
14
|
import { SELF_CHAT_ID, MRI_ORGID_PREFIX } from '../constants.js';
|
|
15
15
|
import { formatHumanReadableDate } from './chatsvc-common.js';
|
|
16
|
+
/** Returns true if the MRI refers to a channel tag (e.g., 'tag:abc123'). */
|
|
17
|
+
function isTagMention(mri) {
|
|
18
|
+
return mri.startsWith('tag:');
|
|
19
|
+
}
|
|
20
|
+
/** Returns the actual MRI to send in the API payload (strips 'tag:' prefix for tags). */
|
|
21
|
+
function getActualMri(mri) {
|
|
22
|
+
return isTagMention(mri) ? mri.substring(4) : mri;
|
|
23
|
+
}
|
|
16
24
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
25
|
// Message Sending
|
|
18
26
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -34,8 +42,7 @@ export async function sendMessage(conversationId, content, options = {}) {
|
|
|
34
42
|
const { auth, region, baseUrl } = authResult.value;
|
|
35
43
|
const { replyToMessageId } = options;
|
|
36
44
|
const displayName = getUserDisplayName() || 'User';
|
|
37
|
-
|
|
38
|
-
const clientMessageId = Date.now().toString();
|
|
45
|
+
const clientMessageId = crypto.randomUUID();
|
|
39
46
|
// Process content: handle mentions, links, and markdown formatting.
|
|
40
47
|
// Always convert through markdown→HTML pipeline (never pass user content through
|
|
41
48
|
// without sanitization, as Teams requires proper block-level wrapping like <p> tags)
|
|
@@ -618,19 +625,27 @@ function timestampFromIdOrNow(id) {
|
|
|
618
625
|
}
|
|
619
626
|
/**
|
|
620
627
|
* Builds the HTML for a single mention.
|
|
628
|
+
*
|
|
629
|
+
* Tag mentions use a span-only format; person mentions include a readonly wrapper.
|
|
621
630
|
*/
|
|
622
|
-
function buildMentionHtml(displayName, itemId) {
|
|
623
|
-
|
|
631
|
+
export function buildMentionHtml(displayName, itemId, mri) {
|
|
632
|
+
const spanHtml = `<span itemtype="http://schema.skype.com/Mention" itemscope itemid="${itemId}">${escapeHtmlChars(displayName)}</span>`;
|
|
633
|
+
if (isTagMention(mri)) {
|
|
634
|
+
return spanHtml;
|
|
635
|
+
}
|
|
636
|
+
return `<readonly class="skipProofing" itemtype="http://schema.skype.com/Mention" contenteditable="false" spellcheck="false">${spanHtml}</readonly>`;
|
|
624
637
|
}
|
|
625
638
|
/**
|
|
626
639
|
* Builds the mentions property array for the API request.
|
|
640
|
+
*
|
|
641
|
+
* Tag mentions use mentionType 'tag' with the raw tag ID (without 'tag:' prefix).
|
|
627
642
|
*/
|
|
628
|
-
function buildMentionsProperty(mentions) {
|
|
643
|
+
export function buildMentionsProperty(mentions) {
|
|
629
644
|
const mentionObjects = mentions.map((mention, index) => ({
|
|
630
645
|
'@type': 'http://schema.skype.com/Mention',
|
|
631
646
|
'itemid': String(index),
|
|
632
|
-
'mri': mention.mri,
|
|
633
|
-
'mentionType': 'person',
|
|
647
|
+
'mri': getActualMri(mention.mri),
|
|
648
|
+
'mentionType': isTagMention(mention.mri) ? 'tag' : 'person',
|
|
634
649
|
'displayName': mention.displayName,
|
|
635
650
|
}));
|
|
636
651
|
return JSON.stringify(mentionObjects);
|
|
@@ -639,7 +654,7 @@ function buildMentionsProperty(mentions) {
|
|
|
639
654
|
* Parses content for both mentions @[Name](mri) and links [text](url).
|
|
640
655
|
* Processes them in a single pass to avoid escaping conflicts.
|
|
641
656
|
*/
|
|
642
|
-
function parseContentWithMentionsAndLinks(content) {
|
|
657
|
+
export function parseContentWithMentionsAndLinks(content) {
|
|
643
658
|
// Patterns for mentions and links
|
|
644
659
|
// Note: Link pattern uses [^)\s] to reject URLs with spaces
|
|
645
660
|
const mentionPattern = /@\[([^\]]+)\]\(([^)]+)\)/g;
|
|
@@ -684,7 +699,7 @@ function parseContentWithMentionsAndLinks(content) {
|
|
|
684
699
|
let html;
|
|
685
700
|
if (m.type === 'mention') {
|
|
686
701
|
mentions.push({ mri: m.target, displayName: m.text });
|
|
687
|
-
html = buildMentionHtml(m.text, mentionId);
|
|
702
|
+
html = buildMentionHtml(m.text, mentionId, m.target);
|
|
688
703
|
mentionId++;
|
|
689
704
|
}
|
|
690
705
|
else {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for mention and content parsing in chatsvc-messaging.
|
|
3
|
+
*
|
|
4
|
+
* Tests the tag vs person mention differentiation, HTML generation,
|
|
5
|
+
* and mentions property serialisation.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import { buildMentionHtml, buildMentionsProperty, parseContentWithMentionsAndLinks, } from './chatsvc-messaging.js';
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
// buildMentionHtml
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
describe('buildMentionHtml', () => {
|
|
13
|
+
it('wraps person mentions in readonly + span', () => {
|
|
14
|
+
const html = buildMentionHtml('Alice', 0, '8:orgid:abc-123');
|
|
15
|
+
expect(html).toContain('<readonly');
|
|
16
|
+
expect(html).toContain('skipProofing');
|
|
17
|
+
expect(html).toContain('<span');
|
|
18
|
+
expect(html).toContain('itemid="0"');
|
|
19
|
+
expect(html).toContain('Alice');
|
|
20
|
+
});
|
|
21
|
+
it('uses span-only format for tag mentions', () => {
|
|
22
|
+
const html = buildMentionHtml('engineering', 0, 'tag:txk8gOnia');
|
|
23
|
+
expect(html).not.toContain('<readonly');
|
|
24
|
+
expect(html).toContain('<span');
|
|
25
|
+
expect(html).toContain('itemid="0"');
|
|
26
|
+
expect(html).toContain('engineering');
|
|
27
|
+
});
|
|
28
|
+
it('escapes HTML characters in display name', () => {
|
|
29
|
+
const html = buildMentionHtml('O\'Brien & Co <team>', 1, 'tag:abc');
|
|
30
|
+
expect(html).toContain('&');
|
|
31
|
+
expect(html).toContain('<');
|
|
32
|
+
expect(html).toContain('>');
|
|
33
|
+
expect(html).not.toContain('& ');
|
|
34
|
+
expect(html).not.toContain('<team>');
|
|
35
|
+
});
|
|
36
|
+
it('uses correct itemId for sequential mentions', () => {
|
|
37
|
+
const first = buildMentionHtml('Tag1', 0, 'tag:a');
|
|
38
|
+
const second = buildMentionHtml('Person', 1, '8:orgid:xyz');
|
|
39
|
+
expect(first).toContain('itemid="0"');
|
|
40
|
+
expect(second).toContain('itemid="1"');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
// buildMentionsProperty
|
|
45
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
describe('buildMentionsProperty', () => {
|
|
47
|
+
it('sets mentionType "person" for regular MRIs', () => {
|
|
48
|
+
const result = JSON.parse(buildMentionsProperty([
|
|
49
|
+
{ mri: '8:orgid:abc-123', displayName: 'Alice' },
|
|
50
|
+
]));
|
|
51
|
+
expect(result).toHaveLength(1);
|
|
52
|
+
expect(result[0].mentionType).toBe('person');
|
|
53
|
+
expect(result[0].mri).toBe('8:orgid:abc-123');
|
|
54
|
+
});
|
|
55
|
+
it('sets mentionType "tag" and strips prefix for tag MRIs', () => {
|
|
56
|
+
const result = JSON.parse(buildMentionsProperty([
|
|
57
|
+
{ mri: 'tag:txk8gOnia', displayName: 'engineering' },
|
|
58
|
+
]));
|
|
59
|
+
expect(result).toHaveLength(1);
|
|
60
|
+
expect(result[0].mentionType).toBe('tag');
|
|
61
|
+
expect(result[0].mri).toBe('txk8gOnia');
|
|
62
|
+
expect(result[0].displayName).toBe('engineering');
|
|
63
|
+
});
|
|
64
|
+
it('handles mixed person and tag mentions', () => {
|
|
65
|
+
const result = JSON.parse(buildMentionsProperty([
|
|
66
|
+
{ mri: '8:orgid:abc', displayName: 'Alice' },
|
|
67
|
+
{ mri: 'tag:xyz', displayName: 'my-tag' },
|
|
68
|
+
]));
|
|
69
|
+
expect(result).toHaveLength(2);
|
|
70
|
+
expect(result[0].mentionType).toBe('person');
|
|
71
|
+
expect(result[0].mri).toBe('8:orgid:abc');
|
|
72
|
+
expect(result[1].mentionType).toBe('tag');
|
|
73
|
+
expect(result[1].mri).toBe('xyz');
|
|
74
|
+
});
|
|
75
|
+
it('assigns sequential itemids', () => {
|
|
76
|
+
const result = JSON.parse(buildMentionsProperty([
|
|
77
|
+
{ mri: '8:orgid:a', displayName: 'A' },
|
|
78
|
+
{ mri: 'tag:b', displayName: 'B' },
|
|
79
|
+
{ mri: '8:orgid:c', displayName: 'C' },
|
|
80
|
+
]));
|
|
81
|
+
expect(result[0].itemid).toBe('0');
|
|
82
|
+
expect(result[1].itemid).toBe('1');
|
|
83
|
+
expect(result[2].itemid).toBe('2');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
87
|
+
// parseContentWithMentionsAndLinks - tag mentions
|
|
88
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
describe('parseContentWithMentionsAndLinks', () => {
|
|
90
|
+
it('parses a person mention', () => {
|
|
91
|
+
const { html, mentions } = parseContentWithMentionsAndLinks('Hey @[Alice](8:orgid:abc), check this');
|
|
92
|
+
expect(mentions).toHaveLength(1);
|
|
93
|
+
expect(mentions[0].mri).toBe('8:orgid:abc');
|
|
94
|
+
expect(html).toContain('<readonly');
|
|
95
|
+
expect(html).toContain('Alice');
|
|
96
|
+
});
|
|
97
|
+
it('parses a tag mention with span-only HTML', () => {
|
|
98
|
+
const { html, mentions } = parseContentWithMentionsAndLinks('Hey @[engineering](tag:txk8gOnia), please review');
|
|
99
|
+
expect(mentions).toHaveLength(1);
|
|
100
|
+
expect(mentions[0].mri).toBe('tag:txk8gOnia');
|
|
101
|
+
expect(mentions[0].displayName).toBe('engineering');
|
|
102
|
+
expect(html).not.toContain('<readonly');
|
|
103
|
+
expect(html).toContain('<span');
|
|
104
|
+
expect(html).toContain('engineering');
|
|
105
|
+
});
|
|
106
|
+
it('handles mixed person and tag mentions in order', () => {
|
|
107
|
+
const { html, mentions } = parseContentWithMentionsAndLinks('@[Alice](8:orgid:abc) and @[my-tag](tag:xyz123) - thoughts?');
|
|
108
|
+
expect(mentions).toHaveLength(2);
|
|
109
|
+
expect(mentions[0].mri).toBe('8:orgid:abc');
|
|
110
|
+
expect(mentions[1].mri).toBe('tag:xyz123');
|
|
111
|
+
// Person mention should have readonly wrapper
|
|
112
|
+
expect(html).toContain('<readonly');
|
|
113
|
+
// Both names present
|
|
114
|
+
expect(html).toContain('Alice');
|
|
115
|
+
expect(html).toContain('my-tag');
|
|
116
|
+
});
|
|
117
|
+
it('returns markdown-converted HTML when no mentions or links', () => {
|
|
118
|
+
const { html, mentions } = parseContentWithMentionsAndLinks('Just plain text');
|
|
119
|
+
expect(mentions).toHaveLength(0);
|
|
120
|
+
expect(html).toContain('Just plain text');
|
|
121
|
+
});
|
|
122
|
+
it('handles links alongside tag mentions', () => {
|
|
123
|
+
const { html, mentions } = parseContentWithMentionsAndLinks('@[my-tag](tag:abc) see [docs](https://example.com)');
|
|
124
|
+
expect(mentions).toHaveLength(1);
|
|
125
|
+
expect(mentions[0].mri).toBe('tag:abc');
|
|
126
|
+
expect(html).toContain('<a href="https://example.com">docs</a>');
|
|
127
|
+
expect(html).toContain('my-tag');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -37,6 +37,9 @@ export declare function markAsRead(conversationId: string, messageId: string): P
|
|
|
37
37
|
/**
|
|
38
38
|
* Gets unread count for a conversation by comparing consumption horizon
|
|
39
39
|
* with recent messages.
|
|
40
|
+
*
|
|
41
|
+
* If the consumption horizon endpoint fails, falls back to message-only
|
|
42
|
+
* checking (reports recent messages from others without precise read position).
|
|
40
43
|
*/
|
|
41
44
|
export declare function getUnreadStatus(conversationId: string): Promise<Result<{
|
|
42
45
|
conversationId: string;
|
|
@@ -44,3 +47,26 @@ export declare function getUnreadStatus(conversationId: string): Promise<Result<
|
|
|
44
47
|
lastReadMessageId?: string;
|
|
45
48
|
latestMessageId?: string;
|
|
46
49
|
}>>;
|
|
50
|
+
/** An unread conversation from the bulk check. */
|
|
51
|
+
export interface UnreadConversation {
|
|
52
|
+
conversationId: string;
|
|
53
|
+
displayName?: string;
|
|
54
|
+
isChannel: boolean;
|
|
55
|
+
lastMessageFrom?: string;
|
|
56
|
+
lastMessageTime: number;
|
|
57
|
+
readUpTo: number;
|
|
58
|
+
}
|
|
59
|
+
/** Result of the bulk unread conversations check. */
|
|
60
|
+
export interface UnreadConversationsResult {
|
|
61
|
+
unreadChats: UnreadConversation[];
|
|
62
|
+
unreadChannels: UnreadConversation[];
|
|
63
|
+
totalChecked: number;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Gets all unread conversations in a single API call.
|
|
67
|
+
*
|
|
68
|
+
* Uses the conversations list endpoint which returns each conversation's
|
|
69
|
+
* consumptionhorizon inline, avoiding N+1 API calls. Compares the last
|
|
70
|
+
* message timestamp against the read horizon to determine unread state.
|
|
71
|
+
*/
|
|
72
|
+
export declare function getUnreadConversations(): Promise<Result<UnreadConversationsResult>>;
|
|
@@ -86,19 +86,19 @@ export async function markAsRead(conversationId, messageId) {
|
|
|
86
86
|
/**
|
|
87
87
|
* Gets unread count for a conversation by comparing consumption horizon
|
|
88
88
|
* with recent messages.
|
|
89
|
+
*
|
|
90
|
+
* If the consumption horizon endpoint fails, falls back to message-only
|
|
91
|
+
* checking (reports recent messages from others without precise read position).
|
|
89
92
|
*/
|
|
90
93
|
export async function getUnreadStatus(conversationId) {
|
|
91
|
-
// Get consumption horizon
|
|
94
|
+
// Get consumption horizon — non-fatal if it fails
|
|
92
95
|
const horizonResult = await getConsumptionHorizon(conversationId);
|
|
93
|
-
|
|
94
|
-
return horizonResult;
|
|
95
|
-
}
|
|
96
|
+
const lastReadId = horizonResult.ok ? horizonResult.value.lastReadMessageId : undefined;
|
|
96
97
|
// Get recent messages
|
|
97
98
|
const messagesResult = await getThreadMessages(conversationId, { limit: 50 });
|
|
98
99
|
if (!messagesResult.ok) {
|
|
99
100
|
return messagesResult;
|
|
100
101
|
}
|
|
101
|
-
const lastReadId = horizonResult.value.lastReadMessageId;
|
|
102
102
|
const messages = messagesResult.value.messages;
|
|
103
103
|
// Count messages after the last read position
|
|
104
104
|
let unreadCount = 0;
|
|
@@ -125,3 +125,83 @@ export async function getUnreadStatus(conversationId) {
|
|
|
125
125
|
latestMessageId,
|
|
126
126
|
});
|
|
127
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Gets all unread conversations in a single API call.
|
|
130
|
+
*
|
|
131
|
+
* Uses the conversations list endpoint which returns each conversation's
|
|
132
|
+
* consumptionhorizon inline, avoiding N+1 API calls. Compares the last
|
|
133
|
+
* message timestamp against the read horizon to determine unread state.
|
|
134
|
+
*/
|
|
135
|
+
export async function getUnreadConversations() {
|
|
136
|
+
const authResult = requireMessageAuthWithConfig();
|
|
137
|
+
if (!authResult.ok) {
|
|
138
|
+
return authResult;
|
|
139
|
+
}
|
|
140
|
+
const { auth, region, baseUrl } = authResult.value;
|
|
141
|
+
const url = CHATSVC_API.conversations(region, baseUrl);
|
|
142
|
+
const response = await httpRequest(url, {
|
|
143
|
+
method: 'GET',
|
|
144
|
+
headers: getSkypeAuthHeaders(auth.skypeToken, auth.authToken, baseUrl),
|
|
145
|
+
});
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
return response;
|
|
148
|
+
}
|
|
149
|
+
const data = response.value.data;
|
|
150
|
+
const convs = data?.conversations || [];
|
|
151
|
+
const unreadChats = [];
|
|
152
|
+
const unreadChannels = [];
|
|
153
|
+
for (const raw of convs) {
|
|
154
|
+
const c = raw;
|
|
155
|
+
const props = (c.properties || {});
|
|
156
|
+
const tp = (c.threadProperties || {});
|
|
157
|
+
const lastMsg = c.lastMessage;
|
|
158
|
+
if (!lastMsg?.id)
|
|
159
|
+
continue;
|
|
160
|
+
const lastMsgTime = parseInt(lastMsg.id, 10);
|
|
161
|
+
const fromMe = lastMsg.from?.includes(auth.userMri);
|
|
162
|
+
const isChannel = tp.threadType === 'channel' || c.id.includes('@thread.tacv2');
|
|
163
|
+
const displayName = tp.topic || lastMsg.imdisplayname;
|
|
164
|
+
const horizon = props.consumptionhorizon;
|
|
165
|
+
if (!horizon) {
|
|
166
|
+
// Never-opened conversation — only flag DMs where last message isn't from us
|
|
167
|
+
if (fromMe || isChannel)
|
|
168
|
+
continue;
|
|
169
|
+
unreadChats.push({
|
|
170
|
+
conversationId: c.id,
|
|
171
|
+
displayName,
|
|
172
|
+
isChannel: false,
|
|
173
|
+
lastMessageFrom: lastMsg.imdisplayname,
|
|
174
|
+
lastMessageTime: lastMsgTime,
|
|
175
|
+
readUpTo: 0,
|
|
176
|
+
});
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const readUpTo = parseInt(horizon.split(';')[0], 10);
|
|
180
|
+
if (lastMsgTime <= readUpTo)
|
|
181
|
+
continue; // Already read
|
|
182
|
+
// For channels, preserve original behavior (skip if last msg is ours).
|
|
183
|
+
// For chats, only skip if read horizon is within 2s of our reply —
|
|
184
|
+
// a larger gap means unread messages exist before our reply.
|
|
185
|
+
if (fromMe && (isChannel || (lastMsgTime - readUpTo) < 2000))
|
|
186
|
+
continue;
|
|
187
|
+
const entry = {
|
|
188
|
+
conversationId: c.id,
|
|
189
|
+
displayName,
|
|
190
|
+
isChannel,
|
|
191
|
+
lastMessageFrom: lastMsg.imdisplayname,
|
|
192
|
+
lastMessageTime: lastMsgTime,
|
|
193
|
+
readUpTo,
|
|
194
|
+
};
|
|
195
|
+
if (isChannel) {
|
|
196
|
+
unreadChannels.push(entry);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
unreadChats.push(entry);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return ok({
|
|
203
|
+
unreadChats,
|
|
204
|
+
unreadChannels,
|
|
205
|
+
totalChecked: convs.length,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microsoft Graph API - Spike for sending messages.
|
|
3
|
+
*
|
|
4
|
+
* This is a spike/experiment to test whether we can use the Graph API
|
|
5
|
+
* with tokens extracted from the Teams browser session (no Azure App
|
|
6
|
+
* registration required).
|
|
7
|
+
*
|
|
8
|
+
* Graph API send message endpoint:
|
|
9
|
+
* POST https://graph.microsoft.com/v1.0/chats/{chatId}/messages
|
|
10
|
+
*
|
|
11
|
+
* Note: Graph API uses a different chat ID format than chatsvc. The
|
|
12
|
+
* conversationId from Teams (e.g., "19:xxx@thread.v2") should work
|
|
13
|
+
* directly as the Graph chatId.
|
|
14
|
+
*/
|
|
15
|
+
import { type Result } from '../types/result.js';
|
|
16
|
+
/** Result of sending a message via Graph API. */
|
|
17
|
+
export interface GraphSendMessageResult {
|
|
18
|
+
/** The Graph-assigned message ID. */
|
|
19
|
+
id: string;
|
|
20
|
+
/** When the message was created (ISO 8601). */
|
|
21
|
+
createdDateTime?: string;
|
|
22
|
+
/** The web URL for the message (if returned). */
|
|
23
|
+
webUrl?: string;
|
|
24
|
+
/** Raw Graph API response for debugging in spike. */
|
|
25
|
+
_raw?: unknown;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Sends a message to a Teams chat via the Microsoft Graph API.
|
|
29
|
+
*
|
|
30
|
+
* This is a spike to test Graph API access using tokens from the Teams
|
|
31
|
+
* browser session. The Graph API endpoint is:
|
|
32
|
+
* POST /v1.0/chats/{chatId}/messages
|
|
33
|
+
*
|
|
34
|
+
* For channel messages, the endpoint would be:
|
|
35
|
+
* POST /v1.0/teams/{teamId}/channels/{channelId}/messages
|
|
36
|
+
*
|
|
37
|
+
* @param chatId - The Teams conversation ID (e.g., "19:xxx@thread.v2")
|
|
38
|
+
* @param content - The message content (plain text or HTML)
|
|
39
|
+
* @param contentType - "text" for plain text, "html" for HTML (default: "text")
|
|
40
|
+
*/
|
|
41
|
+
export declare function graphSendMessage(chatId: string, content: string, contentType?: 'text' | 'html'): Promise<Result<GraphSendMessageResult>>;
|
|
42
|
+
/**
|
|
43
|
+
* Sends a message to a Teams channel via the Microsoft Graph API.
|
|
44
|
+
*
|
|
45
|
+
* POST /v1.0/teams/{teamId}/channels/{channelId}/messages
|
|
46
|
+
*
|
|
47
|
+
* Note: For channels, we need the teamId (group ID) and channelId separately.
|
|
48
|
+
* This is different from the chatsvc API which uses a single conversationId.
|
|
49
|
+
*
|
|
50
|
+
* @param teamId - The team's group ID (GUID)
|
|
51
|
+
* @param channelId - The channel ID (e.g., "19:xxx@thread.tacv2")
|
|
52
|
+
* @param content - The message content
|
|
53
|
+
* @param contentType - "text" for plain text, "html" for HTML (default: "text")
|
|
54
|
+
*/
|
|
55
|
+
export declare function graphSendChannelMessage(teamId: string, channelId: string, content: string, contentType?: 'text' | 'html'): Promise<Result<GraphSendMessageResult>>;
|