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.
Files changed (66) hide show
  1. package/dist/api/calendar-api.js +4 -6
  2. package/dist/api/chatsvc-activity.d.ts +4 -0
  3. package/dist/api/chatsvc-activity.js +18 -1
  4. package/dist/api/chatsvc-messaging.d.ts +27 -0
  5. package/dist/api/chatsvc-messaging.js +24 -9
  6. package/dist/api/chatsvc-messaging.test.d.ts +7 -0
  7. package/dist/api/chatsvc-messaging.test.js +129 -0
  8. package/dist/api/chatsvc-readstatus.d.ts +26 -0
  9. package/dist/api/chatsvc-readstatus.js +85 -5
  10. package/dist/api/graph-api.d.ts +55 -0
  11. package/dist/api/graph-api.js +148 -0
  12. package/dist/api/index.d.ts +1 -0
  13. package/dist/api/index.js +1 -0
  14. package/dist/api/tags-api.d.ts +34 -0
  15. package/dist/api/tags-api.js +53 -0
  16. package/dist/browser/auth.js +2 -1
  17. package/dist/browser/chrome-cookie-import.d.ts +27 -0
  18. package/dist/browser/chrome-cookie-import.js +294 -0
  19. package/dist/browser/chrome-cookie-import.test.d.ts +1 -0
  20. package/dist/browser/chrome-cookie-import.test.js +122 -0
  21. package/dist/browser/context.js +9 -0
  22. package/dist/constants.d.ts +0 -2
  23. package/dist/constants.js +0 -5
  24. package/dist/server.js +7 -1
  25. package/dist/test/dump-tokens.d.ts +7 -0
  26. package/dist/test/dump-tokens.js +90 -0
  27. package/dist/test/mcp-harness.js +2 -1
  28. package/dist/test/test-transcript.d.ts +6 -0
  29. package/dist/test/test-transcript.js +47 -0
  30. package/dist/tools/auth-tools.js +12 -5
  31. package/dist/tools/file-tools.js +1 -1
  32. package/dist/tools/graph-tools.d.ts +42 -0
  33. package/dist/tools/graph-tools.js +99 -0
  34. package/dist/tools/index.d.ts +1 -0
  35. package/dist/tools/index.js +1 -0
  36. package/dist/tools/meeting-tools.js +1 -1
  37. package/dist/tools/message-tools.d.ts +12 -0
  38. package/dist/tools/message-tools.js +26 -58
  39. package/dist/tools/registry.js +11 -2
  40. package/dist/tools/registry.test.d.ts +4 -0
  41. package/dist/tools/registry.test.js +123 -0
  42. package/dist/tools/search-tools.d.ts +6 -0
  43. package/dist/tools/search-tools.js +19 -0
  44. package/dist/tools/search-tools.test.d.ts +9 -0
  45. package/dist/tools/search-tools.test.js +211 -0
  46. package/dist/tools/tag-tools.d.ts +25 -0
  47. package/dist/tools/tag-tools.js +73 -0
  48. package/dist/types/errors.js +3 -6
  49. package/dist/types/teams.d.ts +2 -0
  50. package/dist/utils/api-config.d.ts +16 -5
  51. package/dist/utils/api-config.js +16 -0
  52. package/dist/utils/api-config.test.d.ts +4 -0
  53. package/dist/utils/api-config.test.js +183 -0
  54. package/dist/utils/auth-guards.d.ts +5 -5
  55. package/dist/utils/auth-guards.js +5 -5
  56. package/dist/utils/http.js +3 -2
  57. package/dist/utils/http.test.d.ts +4 -0
  58. package/dist/utils/http.test.js +202 -0
  59. package/dist/utils/logger.d.ts +1 -1
  60. package/dist/utils/logger.js +1 -1
  61. package/dist/utils/logger.test.d.ts +4 -0
  62. package/dist/utils/logger.test.js +135 -0
  63. package/dist/utils/parsers-html.d.ts +6 -1
  64. package/dist/utils/parsers-html.js +23 -7
  65. package/dist/utils/parsers-search.js +1 -1
  66. package/package.json +1 -1
@@ -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 { requireCalendarAuth } from '../utils/auth-guards.js';
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 = requireCalendarAuth();
121
+ const authResult = requireSkypeSpacesAuth();
123
122
  if (!authResult.ok) {
124
123
  return authResult;
125
124
  }
126
125
  const { skypeToken, spacesToken } = authResult.value;
127
- // Get the user's region/partition from session discovery config
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 || DEFAULT_MEETING_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
- const syncState = response.value.data.syncState;
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
- // Generate unique message ID
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
- return `<readonly class="skipProofing" itemtype="http://schema.skype.com/Mention" contenteditable="false" spellcheck="false"><span itemtype="http://schema.skype.com/Mention" itemscope itemid="${itemId}">${escapeHtmlChars(displayName)}</span></readonly>`;
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,7 @@
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
+ export {};
@@ -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('&amp;');
31
+ expect(html).toContain('&lt;');
32
+ expect(html).toContain('&gt;');
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
- if (!horizonResult.ok) {
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>>;