msteams-mcp 0.2.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 +229 -0
- package/dist/__fixtures__/api-responses.d.ts +228 -0
- package/dist/__fixtures__/api-responses.js +217 -0
- package/dist/api/chatsvc-api.d.ts +171 -0
- package/dist/api/chatsvc-api.js +459 -0
- package/dist/api/csa-api.d.ts +44 -0
- package/dist/api/csa-api.js +148 -0
- package/dist/api/index.d.ts +6 -0
- package/dist/api/index.js +6 -0
- package/dist/api/substrate-api.d.ts +50 -0
- package/dist/api/substrate-api.js +305 -0
- package/dist/auth/crypto.d.ts +32 -0
- package/dist/auth/crypto.js +66 -0
- package/dist/auth/index.d.ts +6 -0
- package/dist/auth/index.js +6 -0
- package/dist/auth/session-store.d.ts +82 -0
- package/dist/auth/session-store.js +136 -0
- package/dist/auth/token-extractor.d.ts +69 -0
- package/dist/auth/token-extractor.js +330 -0
- package/dist/browser/auth.d.ts +43 -0
- package/dist/browser/auth.js +232 -0
- package/dist/browser/context.d.ts +40 -0
- package/dist/browser/context.js +121 -0
- package/dist/browser/session.d.ts +34 -0
- package/dist/browser/session.js +92 -0
- package/dist/constants.d.ts +54 -0
- package/dist/constants.js +72 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +12 -0
- package/dist/research/explore.d.ts +11 -0
- package/dist/research/explore.js +267 -0
- package/dist/research/search-research.d.ts +17 -0
- package/dist/research/search-research.js +317 -0
- package/dist/server.d.ts +64 -0
- package/dist/server.js +291 -0
- package/dist/teams/api-interceptor.d.ts +54 -0
- package/dist/teams/api-interceptor.js +391 -0
- package/dist/teams/direct-api.d.ts +321 -0
- package/dist/teams/direct-api.js +1305 -0
- package/dist/teams/messages.d.ts +14 -0
- package/dist/teams/messages.js +142 -0
- package/dist/teams/search.d.ts +40 -0
- package/dist/teams/search.js +458 -0
- package/dist/test/cli.d.ts +12 -0
- package/dist/test/cli.js +328 -0
- package/dist/test/debug-search.d.ts +10 -0
- package/dist/test/debug-search.js +147 -0
- package/dist/test/manual-test.d.ts +11 -0
- package/dist/test/manual-test.js +160 -0
- package/dist/test/mcp-harness.d.ts +17 -0
- package/dist/test/mcp-harness.js +427 -0
- package/dist/tools/auth-tools.d.ts +26 -0
- package/dist/tools/auth-tools.js +127 -0
- package/dist/tools/index.d.ts +45 -0
- package/dist/tools/index.js +12 -0
- package/dist/tools/message-tools.d.ts +139 -0
- package/dist/tools/message-tools.js +433 -0
- package/dist/tools/people-tools.d.ts +46 -0
- package/dist/tools/people-tools.js +123 -0
- package/dist/tools/registry.d.ts +23 -0
- package/dist/tools/registry.js +61 -0
- package/dist/tools/search-tools.d.ts +79 -0
- package/dist/tools/search-tools.js +168 -0
- package/dist/types/errors.d.ts +58 -0
- package/dist/types/errors.js +132 -0
- package/dist/types/result.d.ts +43 -0
- package/dist/types/result.js +51 -0
- package/dist/types/teams.d.ts +79 -0
- package/dist/types/teams.js +5 -0
- package/dist/utils/api-config.d.ts +66 -0
- package/dist/utils/api-config.js +113 -0
- package/dist/utils/auth-guards.d.ts +29 -0
- package/dist/utils/auth-guards.js +54 -0
- package/dist/utils/http.d.ts +29 -0
- package/dist/utils/http.js +111 -0
- package/dist/utils/parsers.d.ts +187 -0
- package/dist/utils/parsers.js +574 -0
- package/dist/utils/parsers.test.d.ts +7 -0
- package/dist/utils/parsers.test.js +360 -0
- package/package.json +58 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message retrieval functionality.
|
|
3
|
+
* Handles fetching and parsing message content.
|
|
4
|
+
*/
|
|
5
|
+
import type { Page } from 'playwright';
|
|
6
|
+
import type { TeamsMessage } from '../types/teams.js';
|
|
7
|
+
/**
|
|
8
|
+
* Extracts messages from the current view (chat or channel).
|
|
9
|
+
*/
|
|
10
|
+
export declare function getVisibleMessages(page: Page, maxMessages?: number): Promise<TeamsMessage[]>;
|
|
11
|
+
/**
|
|
12
|
+
* Scrolls to load more messages in the current view.
|
|
13
|
+
*/
|
|
14
|
+
export declare function loadMoreMessages(page: Page): Promise<void>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message retrieval functionality.
|
|
3
|
+
* Handles fetching and parsing message content.
|
|
4
|
+
*/
|
|
5
|
+
// Message container selectors
|
|
6
|
+
const MESSAGE_SELECTORS = [
|
|
7
|
+
'[data-tid="chat-pane-message"]',
|
|
8
|
+
'[data-tid="message-container"]',
|
|
9
|
+
'.message-list-item',
|
|
10
|
+
'[role="listitem"][data-testid*="message"]',
|
|
11
|
+
];
|
|
12
|
+
/**
|
|
13
|
+
* Extracts messages from the current view (chat or channel).
|
|
14
|
+
*/
|
|
15
|
+
export async function getVisibleMessages(page, maxMessages = 50) {
|
|
16
|
+
const messages = [];
|
|
17
|
+
for (const selector of MESSAGE_SELECTORS) {
|
|
18
|
+
const elements = await page.locator(selector).all();
|
|
19
|
+
for (const element of elements.slice(0, maxMessages)) {
|
|
20
|
+
try {
|
|
21
|
+
const message = await parseMessageElement(element);
|
|
22
|
+
if (message) {
|
|
23
|
+
messages.push(message);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Continue to next message
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (messages.length > 0)
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
return messages;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Parses a message DOM element into a TeamsMessage object.
|
|
37
|
+
*/
|
|
38
|
+
async function parseMessageElement(element) {
|
|
39
|
+
// Extract message ID
|
|
40
|
+
const id = await element.getAttribute('data-tid') ??
|
|
41
|
+
await element.getAttribute('id') ??
|
|
42
|
+
`msg-${Date.now()}`;
|
|
43
|
+
// Extract message content
|
|
44
|
+
const contentSelectors = [
|
|
45
|
+
'[data-tid="message-body"]',
|
|
46
|
+
'.message-body',
|
|
47
|
+
'[role="document"]',
|
|
48
|
+
'p',
|
|
49
|
+
];
|
|
50
|
+
let content = '';
|
|
51
|
+
for (const selector of contentSelectors) {
|
|
52
|
+
try {
|
|
53
|
+
const contentEl = element.locator(selector).first();
|
|
54
|
+
const text = await contentEl.textContent();
|
|
55
|
+
if (text) {
|
|
56
|
+
content = text.trim();
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!content) {
|
|
65
|
+
const fullText = await element.textContent();
|
|
66
|
+
content = fullText?.trim() ?? '';
|
|
67
|
+
}
|
|
68
|
+
if (!content) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
// Extract sender
|
|
72
|
+
const senderSelectors = [
|
|
73
|
+
'[data-tid="message-author"]',
|
|
74
|
+
'.message-author',
|
|
75
|
+
'[data-tid="sender-name"]',
|
|
76
|
+
];
|
|
77
|
+
let sender = 'Unknown';
|
|
78
|
+
for (const selector of senderSelectors) {
|
|
79
|
+
try {
|
|
80
|
+
const senderEl = element.locator(selector).first();
|
|
81
|
+
const text = await senderEl.textContent();
|
|
82
|
+
if (text) {
|
|
83
|
+
sender = text.trim();
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Extract timestamp
|
|
92
|
+
const timeSelectors = [
|
|
93
|
+
'[data-tid="message-timestamp"]',
|
|
94
|
+
'time',
|
|
95
|
+
'[datetime]',
|
|
96
|
+
];
|
|
97
|
+
let timestamp = new Date().toISOString();
|
|
98
|
+
for (const selector of timeSelectors) {
|
|
99
|
+
try {
|
|
100
|
+
const timeEl = element.locator(selector).first();
|
|
101
|
+
const datetime = await timeEl.getAttribute('datetime') ??
|
|
102
|
+
await timeEl.textContent();
|
|
103
|
+
if (datetime) {
|
|
104
|
+
timestamp = datetime.trim();
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
id,
|
|
114
|
+
content,
|
|
115
|
+
sender,
|
|
116
|
+
timestamp,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Scrolls to load more messages in the current view.
|
|
121
|
+
*/
|
|
122
|
+
export async function loadMoreMessages(page) {
|
|
123
|
+
// Find the scrollable container
|
|
124
|
+
const scrollContainers = [
|
|
125
|
+
'[data-tid="message-list"]',
|
|
126
|
+
'.message-list',
|
|
127
|
+
'[role="main"]',
|
|
128
|
+
];
|
|
129
|
+
for (const selector of scrollContainers) {
|
|
130
|
+
try {
|
|
131
|
+
const container = page.locator(selector).first();
|
|
132
|
+
await container.evaluate((el) => {
|
|
133
|
+
el.scrollTop = 0; // Scroll to top to load older messages
|
|
134
|
+
});
|
|
135
|
+
await page.waitForTimeout(1000);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teams search functionality.
|
|
3
|
+
* Implements both API interception (preferred) and DOM scraping (fallback).
|
|
4
|
+
*/
|
|
5
|
+
import type { Page } from 'playwright';
|
|
6
|
+
import type { TeamsSearchResult, TeamsSearchResultsWithPagination } from '../types/teams.js';
|
|
7
|
+
export interface SearchOptions {
|
|
8
|
+
/** Maximum results to return (for backward compat). Default: 25 */
|
|
9
|
+
maxResults?: number;
|
|
10
|
+
/** Timeout for waiting for results. Default: 10000 */
|
|
11
|
+
waitMs?: number;
|
|
12
|
+
/** Whether to use API interception (preferred). Default: true */
|
|
13
|
+
useApiInterception?: boolean;
|
|
14
|
+
/** Enable debug logging. Default: false */
|
|
15
|
+
debug?: boolean;
|
|
16
|
+
/** Starting offset for pagination (0-based). Default: 0 */
|
|
17
|
+
from?: number;
|
|
18
|
+
/** Page size. Default: 25 */
|
|
19
|
+
size?: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Main search function.
|
|
23
|
+
* Searches Teams for messages matching the query.
|
|
24
|
+
*
|
|
25
|
+
* Prefers API interception for structured results, falls back to DOM scraping.
|
|
26
|
+
*/
|
|
27
|
+
export declare function searchTeams(page: Page, query: string, options?: SearchOptions): Promise<TeamsSearchResult[]>;
|
|
28
|
+
/**
|
|
29
|
+
* Main search function with pagination metadata.
|
|
30
|
+
* Searches Teams for messages matching the query and returns pagination info.
|
|
31
|
+
*
|
|
32
|
+
* The Substrate v2 query API uses from/size pagination:
|
|
33
|
+
* - from: Starting offset (0, 25, 50, 75, 100...)
|
|
34
|
+
* - size: Page size (default 25)
|
|
35
|
+
*/
|
|
36
|
+
export declare function searchTeamsWithPagination(page: Page, query: string, options?: SearchOptions): Promise<TeamsSearchResultsWithPagination>;
|
|
37
|
+
/**
|
|
38
|
+
* Filters messages in the current view (channel/chat).
|
|
39
|
+
*/
|
|
40
|
+
export declare function filterCurrentMessages(page: Page, query: string): Promise<TeamsSearchResult[]>;
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teams search functionality.
|
|
3
|
+
* Implements both API interception (preferred) and DOM scraping (fallback).
|
|
4
|
+
*/
|
|
5
|
+
import { setupApiInterceptor } from './api-interceptor.js';
|
|
6
|
+
import { stripHtml } from '../utils/parsers.js';
|
|
7
|
+
// Search box selectors for Teams v2 web app
|
|
8
|
+
const SEARCH_SELECTORS = [
|
|
9
|
+
'[data-tid="searchInputField"]',
|
|
10
|
+
'[data-tid="app-search-input"]',
|
|
11
|
+
'input[data-tid*="search"]',
|
|
12
|
+
'input[placeholder*="Search" i]',
|
|
13
|
+
'input[aria-label*="Search" i]',
|
|
14
|
+
'input[type="search"]',
|
|
15
|
+
'[data-tid="search-box"]',
|
|
16
|
+
'[role="search"] input',
|
|
17
|
+
];
|
|
18
|
+
// Clickable search trigger selectors
|
|
19
|
+
const SEARCH_TRIGGER_SELECTORS = [
|
|
20
|
+
'[data-tid="search-button"]',
|
|
21
|
+
'[data-tid="app-bar-search"]',
|
|
22
|
+
'button[aria-label*="Search" i]',
|
|
23
|
+
'[aria-label*="Search" i][role="button"]',
|
|
24
|
+
];
|
|
25
|
+
// DOM selectors from the Teams bookmarklet (proven to work)
|
|
26
|
+
// These target the chat/message view structure
|
|
27
|
+
const MESSAGE_SELECTORS = {
|
|
28
|
+
item: '[data-tid="chat-pane-item"]',
|
|
29
|
+
message: '[data-tid="chat-pane-message"]',
|
|
30
|
+
controlMessage: '[data-tid="control-message-renderer"]',
|
|
31
|
+
authorName: '[data-tid="message-author-name"]',
|
|
32
|
+
timestamp: '[id^="timestamp-"]',
|
|
33
|
+
time: 'time',
|
|
34
|
+
content: '[id^="content-"]:not([id^="content-control"])',
|
|
35
|
+
edited: '[id^="edited-"]',
|
|
36
|
+
reactions: '[data-tid="diverse-reaction-pill-button"]',
|
|
37
|
+
};
|
|
38
|
+
// Search result specific selectors
|
|
39
|
+
const SEARCH_RESULT_SELECTORS = [
|
|
40
|
+
'[data-tid*="search-result"]',
|
|
41
|
+
'[data-tid*="message-result"]',
|
|
42
|
+
'[data-tid*="searchResult"]',
|
|
43
|
+
'[data-tid*="result-item"]',
|
|
44
|
+
'[role="listitem"]:has([role="img"])',
|
|
45
|
+
'[role="option"]:has([role="img"])',
|
|
46
|
+
];
|
|
47
|
+
const DEFAULT_OPTIONS = {
|
|
48
|
+
maxResults: 25,
|
|
49
|
+
waitMs: 10000,
|
|
50
|
+
useApiInterception: true,
|
|
51
|
+
debug: false,
|
|
52
|
+
from: 0,
|
|
53
|
+
size: 25,
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Finds a working search input element on the page.
|
|
57
|
+
*/
|
|
58
|
+
async function findSearchInput(page, debug = false) {
|
|
59
|
+
for (const selector of SEARCH_SELECTORS) {
|
|
60
|
+
try {
|
|
61
|
+
const locator = page.locator(selector).first();
|
|
62
|
+
const count = await locator.count();
|
|
63
|
+
if (count > 0) {
|
|
64
|
+
const isVisible = await locator.isVisible().catch(() => false);
|
|
65
|
+
if (isVisible) {
|
|
66
|
+
if (debug)
|
|
67
|
+
console.log(` [dom] Found search input: ${selector}`);
|
|
68
|
+
return locator;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Finds a clickable search trigger element.
|
|
80
|
+
*/
|
|
81
|
+
async function findSearchTrigger(page, debug = false) {
|
|
82
|
+
for (const selector of SEARCH_TRIGGER_SELECTORS) {
|
|
83
|
+
try {
|
|
84
|
+
const locator = page.locator(selector).first();
|
|
85
|
+
const count = await locator.count();
|
|
86
|
+
if (count > 0) {
|
|
87
|
+
const isVisible = await locator.isVisible().catch(() => false);
|
|
88
|
+
if (isVisible) {
|
|
89
|
+
if (debug)
|
|
90
|
+
console.log(` [dom] Found search trigger: ${selector}`);
|
|
91
|
+
return locator;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Opens the search interface using various methods.
|
|
103
|
+
*/
|
|
104
|
+
async function openSearch(page, debug = false) {
|
|
105
|
+
await page.waitForLoadState('domcontentloaded');
|
|
106
|
+
await page.waitForTimeout(2000);
|
|
107
|
+
// Check if search input is already visible
|
|
108
|
+
let searchInput = await findSearchInput(page, debug);
|
|
109
|
+
if (searchInput) {
|
|
110
|
+
return searchInput;
|
|
111
|
+
}
|
|
112
|
+
// Try keyboard shortcuts
|
|
113
|
+
const isMac = process.platform === 'darwin';
|
|
114
|
+
const shortcuts = [
|
|
115
|
+
isMac ? 'Meta+e' : 'Control+e',
|
|
116
|
+
isMac ? 'Meta+f' : 'Control+f',
|
|
117
|
+
'F3',
|
|
118
|
+
];
|
|
119
|
+
for (const shortcut of shortcuts) {
|
|
120
|
+
if (debug)
|
|
121
|
+
console.log(` [dom] Trying shortcut: ${shortcut}`);
|
|
122
|
+
await page.keyboard.press(shortcut);
|
|
123
|
+
await page.waitForTimeout(1000);
|
|
124
|
+
searchInput = await findSearchInput(page, debug);
|
|
125
|
+
if (searchInput) {
|
|
126
|
+
return searchInput;
|
|
127
|
+
}
|
|
128
|
+
await page.keyboard.press('Escape');
|
|
129
|
+
await page.waitForTimeout(300);
|
|
130
|
+
}
|
|
131
|
+
// Try clicking search trigger buttons
|
|
132
|
+
const searchTrigger = await findSearchTrigger(page, debug);
|
|
133
|
+
if (searchTrigger) {
|
|
134
|
+
if (debug)
|
|
135
|
+
console.log(' [dom] Clicking search trigger');
|
|
136
|
+
await searchTrigger.click();
|
|
137
|
+
await page.waitForTimeout(1000);
|
|
138
|
+
searchInput = await findSearchInput(page, debug);
|
|
139
|
+
if (searchInput) {
|
|
140
|
+
return searchInput;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
throw new Error('Could not find search input. Teams UI may have changed. ' +
|
|
144
|
+
'Run with debug:true to see what elements are available.');
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Types a search query and submits it.
|
|
148
|
+
*/
|
|
149
|
+
async function typeSearchQuery(page, searchInput, query, debug = false) {
|
|
150
|
+
if (debug)
|
|
151
|
+
console.log(` [dom] Typing query: "${query}"`);
|
|
152
|
+
await searchInput.scrollIntoViewIfNeeded().catch(() => { });
|
|
153
|
+
await page.waitForTimeout(300);
|
|
154
|
+
await searchInput.waitFor({ state: 'visible', timeout: 5000 }).catch(() => { });
|
|
155
|
+
// Try multiple interaction strategies
|
|
156
|
+
let typed = false;
|
|
157
|
+
// Strategy 1: Direct fill
|
|
158
|
+
try {
|
|
159
|
+
await searchInput.fill(query, { timeout: 5000 });
|
|
160
|
+
typed = true;
|
|
161
|
+
if (debug)
|
|
162
|
+
console.log(' [dom] Used fill() strategy');
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
if (debug)
|
|
166
|
+
console.log(` [dom] fill() failed: ${e instanceof Error ? e.message : e}`);
|
|
167
|
+
}
|
|
168
|
+
// Strategy 2: Click then type
|
|
169
|
+
if (!typed) {
|
|
170
|
+
try {
|
|
171
|
+
await searchInput.click({ timeout: 5000 });
|
|
172
|
+
await page.waitForTimeout(200);
|
|
173
|
+
await page.keyboard.type(query, { delay: 30 });
|
|
174
|
+
typed = true;
|
|
175
|
+
if (debug)
|
|
176
|
+
console.log(' [dom] Used click+keyboard.type() strategy');
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
if (debug)
|
|
180
|
+
console.log(` [dom] click+type failed: ${e instanceof Error ? e.message : e}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Strategy 3: Focus via JavaScript
|
|
184
|
+
if (!typed) {
|
|
185
|
+
try {
|
|
186
|
+
await searchInput.evaluate((el) => {
|
|
187
|
+
el.focus();
|
|
188
|
+
el.value = '';
|
|
189
|
+
});
|
|
190
|
+
await page.keyboard.type(query, { delay: 30 });
|
|
191
|
+
typed = true;
|
|
192
|
+
if (debug)
|
|
193
|
+
console.log(' [dom] Used JS focus+type strategy');
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
if (debug)
|
|
197
|
+
console.log(` [dom] JS focus+type failed: ${e instanceof Error ? e.message : e}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (!typed) {
|
|
201
|
+
throw new Error('Failed to type into search input using all strategies');
|
|
202
|
+
}
|
|
203
|
+
await page.waitForTimeout(500);
|
|
204
|
+
await page.keyboard.press('Enter');
|
|
205
|
+
if (debug)
|
|
206
|
+
console.log(' [dom] Query submitted');
|
|
207
|
+
}
|
|
208
|
+
// stripHtml imported from ../utils/parsers.js
|
|
209
|
+
/**
|
|
210
|
+
* Extracts search results from the DOM using bookmarklet-inspired selectors.
|
|
211
|
+
* Fallback when API interception doesn't capture results.
|
|
212
|
+
*/
|
|
213
|
+
async function extractResultsFromDom(page, maxResults, debug = false) {
|
|
214
|
+
const results = [];
|
|
215
|
+
const seenContent = new Set();
|
|
216
|
+
if (debug)
|
|
217
|
+
console.log(' [dom] Extracting results from DOM...');
|
|
218
|
+
// Strategy 1: Look for chat-pane-item elements (bookmarklet pattern)
|
|
219
|
+
const items = await page.locator(MESSAGE_SELECTORS.item).all();
|
|
220
|
+
if (debug)
|
|
221
|
+
console.log(` [dom] Found ${items.length} chat-pane-item elements`);
|
|
222
|
+
for (const item of items) {
|
|
223
|
+
if (results.length >= maxResults)
|
|
224
|
+
break;
|
|
225
|
+
try {
|
|
226
|
+
// Skip control/system messages
|
|
227
|
+
const isControl = await item.locator(MESSAGE_SELECTORS.controlMessage).count() > 0;
|
|
228
|
+
if (isControl)
|
|
229
|
+
continue;
|
|
230
|
+
// Check it's a message
|
|
231
|
+
const hasMessage = await item.locator(MESSAGE_SELECTORS.message).count() > 0;
|
|
232
|
+
if (!hasMessage)
|
|
233
|
+
continue;
|
|
234
|
+
// Extract sender
|
|
235
|
+
const sender = await item.locator(MESSAGE_SELECTORS.authorName).textContent().catch(() => null);
|
|
236
|
+
// Extract timestamp (try timestamp id first, then time element)
|
|
237
|
+
let timestamp;
|
|
238
|
+
const timestampEl = item.locator(MESSAGE_SELECTORS.timestamp).first();
|
|
239
|
+
if (await timestampEl.count() > 0) {
|
|
240
|
+
timestamp = await timestampEl.getAttribute('datetime').catch(() => null) || undefined;
|
|
241
|
+
}
|
|
242
|
+
if (!timestamp) {
|
|
243
|
+
const timeEl = item.locator(MESSAGE_SELECTORS.time).first();
|
|
244
|
+
if (await timeEl.count() > 0) {
|
|
245
|
+
timestamp = await timeEl.getAttribute('datetime').catch(() => null) || undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Extract content
|
|
249
|
+
const contentEl = item.locator(MESSAGE_SELECTORS.content).first();
|
|
250
|
+
let content = '';
|
|
251
|
+
if (await contentEl.count() > 0) {
|
|
252
|
+
const html = await contentEl.innerHTML().catch(() => null);
|
|
253
|
+
content = html ? stripHtml(html) : '';
|
|
254
|
+
}
|
|
255
|
+
// Skip empty or too short content
|
|
256
|
+
if (content.length < 5)
|
|
257
|
+
continue;
|
|
258
|
+
// Deduplicate
|
|
259
|
+
const key = content.substring(0, 60).toLowerCase();
|
|
260
|
+
if (seenContent.has(key))
|
|
261
|
+
continue;
|
|
262
|
+
seenContent.add(key);
|
|
263
|
+
results.push({
|
|
264
|
+
id: `dom-${results.length}`,
|
|
265
|
+
type: 'message',
|
|
266
|
+
content,
|
|
267
|
+
sender: sender?.trim() || undefined,
|
|
268
|
+
timestamp,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// Strategy 2: Try search result specific selectors
|
|
276
|
+
if (results.length < maxResults) {
|
|
277
|
+
for (const selector of SEARCH_RESULT_SELECTORS) {
|
|
278
|
+
const elements = await page.locator(selector).all();
|
|
279
|
+
if (debug && elements.length > 0) {
|
|
280
|
+
console.log(` [dom] Found ${elements.length} elements with: ${selector}`);
|
|
281
|
+
}
|
|
282
|
+
for (const element of elements) {
|
|
283
|
+
if (results.length >= maxResults)
|
|
284
|
+
break;
|
|
285
|
+
try {
|
|
286
|
+
const text = await element.textContent();
|
|
287
|
+
if (!text || text.length < 20)
|
|
288
|
+
continue;
|
|
289
|
+
const cleaned = stripHtml(text).replace(/\s+/g, ' ').trim();
|
|
290
|
+
if (cleaned.length < 15)
|
|
291
|
+
continue;
|
|
292
|
+
// Deduplicate
|
|
293
|
+
const key = cleaned.substring(0, 60).toLowerCase();
|
|
294
|
+
if (seenContent.has(key))
|
|
295
|
+
continue;
|
|
296
|
+
seenContent.add(key);
|
|
297
|
+
// Try to parse sender and timestamp from text
|
|
298
|
+
const parsed = parseResultText(cleaned);
|
|
299
|
+
results.push({
|
|
300
|
+
id: `result-${results.length}`,
|
|
301
|
+
type: 'message',
|
|
302
|
+
content: parsed.content,
|
|
303
|
+
sender: parsed.sender,
|
|
304
|
+
timestamp: parsed.timestamp,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (results.length >= maxResults)
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (debug)
|
|
316
|
+
console.log(` [dom] Extracted ${results.length} results from DOM`);
|
|
317
|
+
return results;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Parses a result string to extract sender, timestamp, and content.
|
|
321
|
+
*/
|
|
322
|
+
function parseResultText(text) {
|
|
323
|
+
let remaining = text;
|
|
324
|
+
// Pattern: "Lastname, Firstname" at the start
|
|
325
|
+
const senderMatch = remaining.match(/^([A-Z][a-zA-Z'-]+,\s*[A-Z][a-zA-Z'-]+)/);
|
|
326
|
+
let sender;
|
|
327
|
+
if (senderMatch) {
|
|
328
|
+
sender = senderMatch[1];
|
|
329
|
+
remaining = remaining.slice(sender.length).trim();
|
|
330
|
+
}
|
|
331
|
+
// Pattern: date/time
|
|
332
|
+
const timePatterns = [
|
|
333
|
+
/^(\d{1,2}\/\d{1,2}\/\d{4}\s+\d{1,2}:\d{2})/,
|
|
334
|
+
/^(Yesterday\s+\d{1,2}:\d{2})/i,
|
|
335
|
+
/^(Today\s+\d{1,2}:\d{2})/i,
|
|
336
|
+
/^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)\s+(\d{1,2}:\d{2})/i,
|
|
337
|
+
];
|
|
338
|
+
let timestamp;
|
|
339
|
+
for (const pattern of timePatterns) {
|
|
340
|
+
const match = remaining.match(pattern);
|
|
341
|
+
if (match) {
|
|
342
|
+
timestamp = match[0];
|
|
343
|
+
remaining = remaining.slice(timestamp.length).trim();
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return { sender, timestamp, content: remaining };
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Main search function.
|
|
351
|
+
* Searches Teams for messages matching the query.
|
|
352
|
+
*
|
|
353
|
+
* Prefers API interception for structured results, falls back to DOM scraping.
|
|
354
|
+
*/
|
|
355
|
+
export async function searchTeams(page, query, options = {}) {
|
|
356
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
357
|
+
const debug = opts.debug;
|
|
358
|
+
// Set up API interception before triggering search
|
|
359
|
+
const interceptor = opts.useApiInterception ? setupApiInterceptor(page, debug) : null;
|
|
360
|
+
try {
|
|
361
|
+
// Open search and type query
|
|
362
|
+
const searchInput = await openSearch(page, debug);
|
|
363
|
+
await typeSearchQuery(page, searchInput, query, debug);
|
|
364
|
+
// Wait for results - try API first, then DOM
|
|
365
|
+
if (interceptor) {
|
|
366
|
+
if (debug)
|
|
367
|
+
console.log(' [api] Waiting for API results...');
|
|
368
|
+
const apiResults = await interceptor.waitForSearchResults(opts.waitMs);
|
|
369
|
+
if (apiResults.length > 0) {
|
|
370
|
+
if (debug)
|
|
371
|
+
console.log(` [api] Got ${apiResults.length} results from API`);
|
|
372
|
+
return apiResults.slice(0, opts.maxResults);
|
|
373
|
+
}
|
|
374
|
+
if (debug)
|
|
375
|
+
console.log(' [api] No API results, falling back to DOM');
|
|
376
|
+
}
|
|
377
|
+
// Wait a bit for DOM to render
|
|
378
|
+
await page.waitForTimeout(2000);
|
|
379
|
+
// Fall back to DOM extraction
|
|
380
|
+
return extractResultsFromDom(page, opts.maxResults, debug);
|
|
381
|
+
}
|
|
382
|
+
finally {
|
|
383
|
+
// Clean up interceptor
|
|
384
|
+
interceptor?.stop();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Main search function with pagination metadata.
|
|
389
|
+
* Searches Teams for messages matching the query and returns pagination info.
|
|
390
|
+
*
|
|
391
|
+
* The Substrate v2 query API uses from/size pagination:
|
|
392
|
+
* - from: Starting offset (0, 25, 50, 75, 100...)
|
|
393
|
+
* - size: Page size (default 25)
|
|
394
|
+
*/
|
|
395
|
+
export async function searchTeamsWithPagination(page, query, options = {}) {
|
|
396
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
397
|
+
const debug = opts.debug;
|
|
398
|
+
const defaultPagination = {
|
|
399
|
+
returned: 0,
|
|
400
|
+
from: opts.from,
|
|
401
|
+
size: opts.size,
|
|
402
|
+
hasMore: false,
|
|
403
|
+
};
|
|
404
|
+
// Set up API interception before triggering search
|
|
405
|
+
const interceptor = opts.useApiInterception ? setupApiInterceptor(page, debug) : null;
|
|
406
|
+
try {
|
|
407
|
+
// Open search and type query
|
|
408
|
+
const searchInput = await openSearch(page, debug);
|
|
409
|
+
await typeSearchQuery(page, searchInput, query, debug);
|
|
410
|
+
// Wait for results - try API first, then DOM
|
|
411
|
+
if (interceptor) {
|
|
412
|
+
if (debug)
|
|
413
|
+
console.log(' [api] Waiting for API results with pagination...');
|
|
414
|
+
const apiResults = await interceptor.waitForSearchResultsWithPagination(opts.waitMs);
|
|
415
|
+
if (apiResults.results.length > 0) {
|
|
416
|
+
if (debug) {
|
|
417
|
+
console.log(` [api] Got ${apiResults.results.length} results from API`);
|
|
418
|
+
console.log(` [api] Pagination: from=${apiResults.pagination.from}, size=${apiResults.pagination.size}, hasMore=${apiResults.pagination.hasMore}`);
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
results: apiResults.results.slice(0, opts.maxResults),
|
|
422
|
+
pagination: {
|
|
423
|
+
returned: Math.min(apiResults.results.length, opts.maxResults),
|
|
424
|
+
from: apiResults.pagination.from,
|
|
425
|
+
size: apiResults.pagination.size,
|
|
426
|
+
total: apiResults.pagination.total,
|
|
427
|
+
hasMore: apiResults.pagination.hasMore,
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
if (debug)
|
|
432
|
+
console.log(' [api] No API results, falling back to DOM');
|
|
433
|
+
}
|
|
434
|
+
// Wait a bit for DOM to render
|
|
435
|
+
await page.waitForTimeout(2000);
|
|
436
|
+
// Fall back to DOM extraction
|
|
437
|
+
const domResults = await extractResultsFromDom(page, opts.maxResults, debug);
|
|
438
|
+
return {
|
|
439
|
+
results: domResults,
|
|
440
|
+
pagination: {
|
|
441
|
+
...defaultPagination,
|
|
442
|
+
returned: domResults.length,
|
|
443
|
+
// For DOM extraction, we don't know the total, assume more if we hit maxResults
|
|
444
|
+
hasMore: domResults.length >= opts.maxResults,
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
// Clean up interceptor
|
|
450
|
+
interceptor?.stop();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Filters messages in the current view (channel/chat).
|
|
455
|
+
*/
|
|
456
|
+
export async function filterCurrentMessages(page, query) {
|
|
457
|
+
return searchTeams(page, query);
|
|
458
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* CLI tool to interact with Teams MCP functionality directly.
|
|
4
|
+
* Useful for testing individual operations.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npm run cli -- status
|
|
8
|
+
* npm run cli -- search "your query"
|
|
9
|
+
* npm run cli -- login
|
|
10
|
+
* npm run cli -- login --force
|
|
11
|
+
*/
|
|
12
|
+
export {};
|