@agenticmail/enterprise 0.5.83 → 0.5.85

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.
@@ -0,0 +1,468 @@
1
+ /**
2
+ * Google Meeting Tools
3
+ *
4
+ * Tools for detecting, managing, and joining meetings.
5
+ * Works with Google Calendar events + Gmail invites to automatically
6
+ * find meeting links and join via the browser.
7
+ */
8
+
9
+ import type { AnyAgentTool, ToolCreationOptions } from '../../types.js';
10
+ import { jsonResult, errorResult } from '../../common.js';
11
+ import type { GoogleToolsConfig } from './index.js';
12
+
13
+ const CALENDAR_BASE = 'https://www.googleapis.com/calendar/v3';
14
+
15
+ async function calendarApi(token: string, path: string, opts?: { method?: string; body?: any; query?: Record<string, string> }): Promise<any> {
16
+ const url = new URL(CALENDAR_BASE + path);
17
+ if (opts?.query) for (const [k, v] of Object.entries(opts.query)) { if (v) url.searchParams.set(k, v); }
18
+ const res = await fetch(url.toString(), {
19
+ method: opts?.method || 'GET',
20
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
21
+ body: opts?.body ? JSON.stringify(opts.body) : undefined,
22
+ });
23
+ if (!res.ok) throw new Error(`Calendar API ${res.status}: ${await res.text()}`);
24
+ return res.json();
25
+ }
26
+
27
+ /** Extract meeting link from various sources */
28
+ function extractMeetingLink(event: any): { platform: string; url: string } | null {
29
+ // Google Meet — direct from conferenceData
30
+ if (event.conferenceData?.entryPoints) {
31
+ for (const ep of event.conferenceData.entryPoints) {
32
+ if (ep.entryPointType === 'video' && ep.uri) {
33
+ return { platform: 'google_meet', url: ep.uri };
34
+ }
35
+ }
36
+ }
37
+
38
+ // Check hangoutLink
39
+ if (event.hangoutLink) {
40
+ return { platform: 'google_meet', url: event.hangoutLink };
41
+ }
42
+
43
+ // Parse description and location for meeting URLs
44
+ const text = [event.description || '', event.location || ''].join(' ');
45
+
46
+ // Zoom
47
+ const zoomMatch = text.match(/https:\/\/[\w.-]*zoom\.us\/[jw]\/[\d?=&\w]+/i);
48
+ if (zoomMatch) return { platform: 'zoom', url: zoomMatch[0] };
49
+
50
+ // Microsoft Teams
51
+ const teamsMatch = text.match(/https:\/\/teams\.microsoft\.com\/l\/meetup-join\/[^\s"<>]+/i);
52
+ if (teamsMatch) return { platform: 'teams', url: teamsMatch[0] };
53
+
54
+ // Webex
55
+ const webexMatch = text.match(/https:\/\/[\w.-]*webex\.com\/[\w.-]+\/j\.php[^\s"<>]*/i);
56
+ if (webexMatch) return { platform: 'webex', url: webexMatch[0] };
57
+
58
+ // Generic meet link in description
59
+ const genericMeet = text.match(/https:\/\/meet\.google\.com\/[a-z-]+/i);
60
+ if (genericMeet) return { platform: 'google_meet', url: genericMeet[0] };
61
+
62
+ return null;
63
+ }
64
+
65
+ function parseEventTime(event: any): { start: Date; end: Date } | null {
66
+ const startStr = event.start?.dateTime || event.start?.date;
67
+ const endStr = event.end?.dateTime || event.end?.date;
68
+ if (!startStr) return null;
69
+ return {
70
+ start: new Date(startStr),
71
+ end: endStr ? new Date(endStr) : new Date(new Date(startStr).getTime() + 3600000),
72
+ };
73
+ }
74
+
75
+ export function createMeetingTools(config: GoogleToolsConfig, _options?: ToolCreationOptions): AnyAgentTool[] {
76
+ const tp = config.tokenProvider;
77
+
78
+ return [
79
+ // ─── Upcoming Meetings ─────────────────────────────
80
+ {
81
+ name: 'meetings_upcoming',
82
+ description: 'List upcoming meetings with their join links, times, and attendees. Checks Google Calendar for events with video conference links (Google Meet, Zoom, Teams, Webex). Use this to know what meetings are coming up and when to join them.',
83
+ category: 'utility' as const,
84
+ parameters: {
85
+ type: 'object' as const,
86
+ properties: {
87
+ hours: { type: 'number', description: 'Look ahead this many hours (default: 24)' },
88
+ calendarId: { type: 'string', description: 'Calendar ID (default: "primary")' },
89
+ includeDeclined: { type: 'string', description: '"true" to include declined meetings' },
90
+ },
91
+ required: [],
92
+ },
93
+ async execute(_id: string, params: any) {
94
+ try {
95
+ const token = await tp.getAccessToken();
96
+ const hours = params.hours || 24;
97
+ const now = new Date();
98
+ const later = new Date(now.getTime() + hours * 3600000);
99
+ const calendarId = params.calendarId || 'primary';
100
+
101
+ const data = await calendarApi(token, `/calendars/${encodeURIComponent(calendarId)}/events`, {
102
+ query: {
103
+ timeMin: now.toISOString(),
104
+ timeMax: later.toISOString(),
105
+ singleEvents: 'true',
106
+ orderBy: 'startTime',
107
+ maxResults: '50',
108
+ },
109
+ });
110
+
111
+ const meetings = (data.items || [])
112
+ .map((event: any) => {
113
+ const times = parseEventTime(event);
114
+ const meetingLink = extractMeetingLink(event);
115
+ const myStatus = (event.attendees || []).find((a: any) => a.self)?.responseStatus;
116
+
117
+ if (params.includeDeclined !== 'true' && myStatus === 'declined') return null;
118
+
119
+ return {
120
+ id: event.id,
121
+ title: event.summary,
122
+ start: times?.start?.toISOString(),
123
+ end: times?.end?.toISOString(),
124
+ startsIn: times ? Math.round((times.start.getTime() - now.getTime()) / 60000) + ' minutes' : null,
125
+ isNow: times ? now >= times.start && now <= times.end : false,
126
+ meetingLink: meetingLink?.url || null,
127
+ platform: meetingLink?.platform || null,
128
+ organizer: event.organizer?.email,
129
+ attendees: (event.attendees || []).map((a: any) => ({
130
+ email: a.email, name: a.displayName, status: a.responseStatus, self: a.self || false,
131
+ })),
132
+ myStatus,
133
+ description: event.description?.slice(0, 500),
134
+ location: event.location,
135
+ recurring: !!event.recurringEventId,
136
+ };
137
+ })
138
+ .filter(Boolean);
139
+
140
+ const withLinks = meetings.filter((m: any) => m.meetingLink);
141
+ const happening = meetings.filter((m: any) => m.isNow);
142
+
143
+ return jsonResult({
144
+ meetings,
145
+ total: meetings.length,
146
+ withMeetingLinks: withLinks.length,
147
+ happeningNow: happening.length,
148
+ nextMeeting: meetings[0] || null,
149
+ });
150
+ } catch (e: any) { return errorResult(e.message); }
151
+ },
152
+ },
153
+
154
+ // ─── Join Meeting ──────────────────────────────────
155
+ {
156
+ name: 'meeting_join',
157
+ description: 'Join a video meeting (Google Meet, Zoom, Teams) using the browser. The agent will navigate to the meeting URL, handle permission dialogs, click join buttons, and enter the meeting. Returns the meeting status. Requires the meeting browser to be running.',
158
+ category: 'utility' as const,
159
+ parameters: {
160
+ type: 'object' as const,
161
+ properties: {
162
+ url: { type: 'string', description: 'Meeting URL to join (Google Meet, Zoom, or Teams link)' },
163
+ eventId: { type: 'string', description: 'Google Calendar event ID — will auto-extract the meeting link' },
164
+ displayName: { type: 'string', description: 'Name to display in the meeting (default: agent name)' },
165
+ muteAudio: { type: 'string', description: '"true" to join muted (default: "true")' },
166
+ muteVideo: { type: 'string', description: '"true" to join with camera off (default: "true")' },
167
+ },
168
+ required: [],
169
+ },
170
+ async execute(_id: string, params: any) {
171
+ try {
172
+ const token = await tp.getAccessToken();
173
+ let meetingUrl = params.url;
174
+ let platform = '';
175
+
176
+ // If eventId provided, fetch meeting link from calendar
177
+ if (!meetingUrl && params.eventId) {
178
+ const event = await calendarApi(token, `/calendars/primary/events/${params.eventId}`);
179
+ const link = extractMeetingLink(event);
180
+ if (!link) return errorResult('No meeting link found in calendar event: ' + (event.summary || params.eventId));
181
+ meetingUrl = link.url;
182
+ platform = link.platform;
183
+ }
184
+
185
+ if (!meetingUrl) return errorResult('No meeting URL provided. Pass url or eventId.');
186
+
187
+ // Detect platform
188
+ if (!platform) {
189
+ if (meetingUrl.includes('meet.google.com')) platform = 'google_meet';
190
+ else if (meetingUrl.includes('zoom.us')) platform = 'zoom';
191
+ else if (meetingUrl.includes('teams.microsoft.com')) platform = 'teams';
192
+ else if (meetingUrl.includes('webex.com')) platform = 'webex';
193
+ else platform = 'unknown';
194
+ }
195
+
196
+ // Return instructions for the agent to execute via browser tool
197
+ // The agent will use browser actions (navigate, snapshot, act) to join
198
+ const joinInstructions: any = {
199
+ action: 'join_meeting',
200
+ platform,
201
+ url: meetingUrl,
202
+ muteAudio: params.muteAudio !== 'false',
203
+ muteVideo: params.muteVideo !== 'false',
204
+ displayName: params.displayName,
205
+ };
206
+
207
+ if (platform === 'google_meet') {
208
+ joinInstructions.steps = [
209
+ { step: 1, action: 'navigate', description: 'Navigate to the Meet URL', url: meetingUrl },
210
+ { step: 2, action: 'wait', description: 'Wait for the pre-join screen to load (2-5 seconds)' },
211
+ { step: 3, action: 'snapshot', description: 'Take a snapshot to see the pre-join UI' },
212
+ { step: 4, action: 'mute', description: 'If muteAudio=true, click the microphone toggle button to mute' },
213
+ { step: 5, action: 'camera_off', description: 'If muteVideo=true, click the camera toggle button to turn off camera' },
214
+ { step: 6, action: 'set_name', description: 'If not logged in, enter display name in the name field' },
215
+ { step: 7, action: 'join', description: 'Click "Join now" or "Ask to join" button' },
216
+ { step: 8, action: 'verify', description: 'Take a snapshot to confirm you are in the meeting' },
217
+ ];
218
+ joinInstructions.selectors = {
219
+ joinButton: '[data-is-muted] ~ div button, [jsname="Qx7uuf"], button[data-idom-class*="join"]',
220
+ muteAudioButton: '[data-is-muted][aria-label*="microphone"], [data-tooltip*="microphone"]',
221
+ muteVideoButton: '[data-is-muted][aria-label*="camera"], [data-tooltip*="camera"]',
222
+ nameInput: 'input[aria-label*="name"], input[placeholder*="name"]',
223
+ gotItButton: 'button[jsname="LgbsSe"]',
224
+ };
225
+ } else if (platform === 'zoom') {
226
+ joinInstructions.steps = [
227
+ { step: 1, action: 'navigate', description: 'Navigate to Zoom URL', url: meetingUrl },
228
+ { step: 2, action: 'snapshot', description: 'Look for "Join from Your Browser" or "Launch Meeting" link' },
229
+ { step: 3, action: 'click_browser_join', description: 'Click "Join from Your Browser" to avoid Zoom client' },
230
+ { step: 4, action: 'enter_name', description: 'Enter display name if prompted' },
231
+ { step: 5, action: 'join', description: 'Click Join button' },
232
+ ];
233
+ } else if (platform === 'teams') {
234
+ joinInstructions.steps = [
235
+ { step: 1, action: 'navigate', description: 'Navigate to Teams URL', url: meetingUrl },
236
+ { step: 2, action: 'snapshot', description: 'Look for "Continue on this browser" option' },
237
+ { step: 3, action: 'click_browser', description: 'Click "Continue on this browser" (not the app)' },
238
+ { step: 4, action: 'configure', description: 'Toggle mic/camera as needed on pre-join screen' },
239
+ { step: 5, action: 'join', description: 'Click "Join now"' },
240
+ ];
241
+ }
242
+
243
+ return jsonResult(joinInstructions);
244
+ } catch (e: any) { return errorResult(e.message); }
245
+ },
246
+ },
247
+
248
+ // ─── Meeting Actions ───────────────────────────────
249
+ {
250
+ name: 'meeting_action',
251
+ description: 'Perform actions during an active meeting: mute/unmute, toggle camera, send chat message, raise hand, leave meeting, take notes from what is visible on screen, read chat messages.',
252
+ category: 'utility' as const,
253
+ parameters: {
254
+ type: 'object' as const,
255
+ properties: {
256
+ action: { type: 'string', description: 'Action: mute, unmute, camera_on, camera_off, chat, raise_hand, lower_hand, leave, read_chat, screenshot, participants' },
257
+ message: { type: 'string', description: 'Chat message to send (for chat action)' },
258
+ platform: { type: 'string', description: 'Meeting platform: google_meet, zoom, teams (auto-detected from current page if omitted)' },
259
+ },
260
+ required: ['action'],
261
+ },
262
+ async execute(_id: string, params: any) {
263
+ try {
264
+ const action = params.action;
265
+ const platform = params.platform || 'google_meet';
266
+
267
+ // Return browser instructions for the agent to execute
268
+ const instructions: any = { action, platform };
269
+
270
+ const platformSelectors: Record<string, Record<string, any>> = {
271
+ google_meet: {
272
+ mute: { description: 'Click microphone button to mute', selector: '[aria-label*="microphone"][data-is-muted="false"]', key: 'Ctrl+d' },
273
+ unmute: { description: 'Click microphone button to unmute', selector: '[aria-label*="microphone"][data-is-muted="true"]', key: 'Ctrl+d' },
274
+ camera_on: { description: 'Click camera button to turn on', selector: '[aria-label*="camera"][data-is-muted="true"]', key: 'Ctrl+e' },
275
+ camera_off: { description: 'Click camera button to turn off', selector: '[aria-label*="camera"][data-is-muted="false"]', key: 'Ctrl+e' },
276
+ chat: { description: 'Open chat, type message, send', steps: ['Click chat icon', 'Type in chat input', 'Press Enter'] },
277
+ raise_hand: { description: 'Click raise hand button', key: 'Ctrl+Alt+h' },
278
+ lower_hand: { description: 'Click lower hand button', key: 'Ctrl+Alt+h' },
279
+ leave: { description: 'Click the red leave/hang up button', selector: '[aria-label*="Leave"], [aria-label*="leave call"]' },
280
+ read_chat: { description: 'Take a snapshot focused on the chat panel to read messages' },
281
+ screenshot: { description: 'Take a screenshot of the current meeting view' },
282
+ participants: { description: 'Open participants panel and take a snapshot to list attendees' },
283
+ },
284
+ zoom: {
285
+ mute: { description: 'Click Mute button', selector: '.join-audio-container button, [aria-label*="mute"]' },
286
+ unmute: { description: 'Click Unmute button', selector: '.join-audio-container button, [aria-label*="unmute"]' },
287
+ leave: { description: 'Click Leave button', selector: '[aria-label*="Leave"], .leave-meeting-btn' },
288
+ chat: { description: 'Open chat panel and type message' },
289
+ },
290
+ teams: {
291
+ mute: { description: 'Click microphone button', selector: '[aria-label*="Mute"], #microphone-button' },
292
+ unmute: { description: 'Click microphone button', selector: '[aria-label*="Unmute"], #microphone-button' },
293
+ leave: { description: 'Click Leave/Hang up', selector: '[aria-label*="Leave"], [aria-label*="Hang up"], #hangup-button' },
294
+ chat: { description: 'Open chat and type message' },
295
+ },
296
+ };
297
+
298
+ const platSel = platformSelectors[platform] || platformSelectors.google_meet;
299
+ const actionInfo = platSel[action];
300
+
301
+ if (actionInfo) {
302
+ instructions.browserAction = actionInfo;
303
+ instructions.hint = 'Use the browser tool to execute this action. Take a snapshot first to confirm the meeting state, then perform the action.';
304
+ } else {
305
+ instructions.hint = 'Unsupported action for this platform. Take a screenshot to see the current meeting UI and interact manually via browser tool.';
306
+ }
307
+
308
+ return jsonResult(instructions);
309
+ } catch (e: any) { return errorResult(e.message); }
310
+ },
311
+ },
312
+
313
+ // ─── Detect Meeting Invites in Email ───────────────
314
+ {
315
+ name: 'meetings_scan_inbox',
316
+ description: 'Scan recent emails for meeting invitations and extract meeting links, times, and details. Checks for Google Meet, Zoom, Teams, and Webex links in email bodies and calendar attachments (.ics files).',
317
+ category: 'utility' as const,
318
+ parameters: {
319
+ type: 'object' as const,
320
+ properties: {
321
+ hours: { type: 'number', description: 'Scan emails from last N hours (default: 24)' },
322
+ maxResults: { type: 'number', description: 'Max emails to scan (default: 30)' },
323
+ },
324
+ required: [],
325
+ },
326
+ async execute(_id: string, params: any) {
327
+ try {
328
+ const token = await tp.getAccessToken();
329
+ const hours = params.hours || 24;
330
+ const after = new Date(Date.now() - hours * 3600000);
331
+ const dateStr = `${after.getFullYear()}/${String(after.getMonth() + 1).padStart(2, '0')}/${String(after.getDate()).padStart(2, '0')}`;
332
+
333
+ // Search for emails with meeting-related content
334
+ const GMAIL_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me';
335
+ const query = `after:${dateStr} (meet.google.com OR zoom.us OR teams.microsoft.com OR "meeting invitation" OR "calendar invitation" OR filename:ics)`;
336
+ const searchUrl = new URL(`${GMAIL_BASE}/messages`);
337
+ searchUrl.searchParams.set('q', query);
338
+ searchUrl.searchParams.set('maxResults', String(params.maxResults || 30));
339
+
340
+ const searchRes = await fetch(searchUrl.toString(), { headers: { Authorization: `Bearer ${token}` } });
341
+ if (!searchRes.ok) throw new Error(`Gmail search failed: ${searchRes.status}`);
342
+ const searchData = await searchRes.json() as any;
343
+
344
+ if (!searchData.messages?.length) {
345
+ return jsonResult({ meetings: [], count: 0, message: 'No meeting invites found in the last ' + hours + ' hours' });
346
+ }
347
+
348
+ // Fetch each message to extract meeting links
349
+ const meetings: any[] = [];
350
+ for (const msg of searchData.messages.slice(0, 20)) {
351
+ try {
352
+ const msgRes = await fetch(`${GMAIL_BASE}/messages/${msg.id}?format=full`, { headers: { Authorization: `Bearer ${token}` } });
353
+ if (!msgRes.ok) continue;
354
+ const msgData = await msgRes.json() as any;
355
+
356
+ const headers = msgData.payload?.headers || [];
357
+ const subject = headers.find((h: any) => h.name?.toLowerCase() === 'subject')?.value || '';
358
+ const from = headers.find((h: any) => h.name?.toLowerCase() === 'from')?.value || '';
359
+ const date = headers.find((h: any) => h.name?.toLowerCase() === 'date')?.value || '';
360
+
361
+ // Extract body text
362
+ let bodyText = '';
363
+ function walkParts(part: any) {
364
+ if (part.body?.data) {
365
+ bodyText += Buffer.from(part.body.data.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf-8') + ' ';
366
+ }
367
+ if (part.parts) part.parts.forEach(walkParts);
368
+ }
369
+ walkParts(msgData.payload);
370
+
371
+ // Find meeting links
372
+ const meetLinks: any[] = [];
373
+ const patterns = [
374
+ { regex: /https:\/\/meet\.google\.com\/[a-z-]+/gi, platform: 'google_meet' },
375
+ { regex: /https:\/\/[\w.-]*zoom\.us\/[jw]\/[\d?=&\w]+/gi, platform: 'zoom' },
376
+ { regex: /https:\/\/teams\.microsoft\.com\/l\/meetup-join\/[^\s"<>]+/gi, platform: 'teams' },
377
+ { regex: /https:\/\/[\w.-]*webex\.com\/[\w.-]+\/j\.php[^\s"<>]*/gi, platform: 'webex' },
378
+ ];
379
+
380
+ for (const p of patterns) {
381
+ const matches = bodyText.match(p.regex);
382
+ if (matches) {
383
+ for (const url of [...new Set(matches)]) {
384
+ meetLinks.push({ platform: p.platform, url });
385
+ }
386
+ }
387
+ }
388
+
389
+ if (meetLinks.length > 0) {
390
+ meetings.push({
391
+ messageId: msg.id,
392
+ subject,
393
+ from,
394
+ date,
395
+ meetingLinks: meetLinks,
396
+ snippet: msgData.snippet?.slice(0, 200),
397
+ });
398
+ }
399
+ } catch { /* skip individual message errors */ }
400
+ }
401
+
402
+ return jsonResult({
403
+ meetings,
404
+ count: meetings.length,
405
+ scannedEmails: searchData.messages.length,
406
+ timeRange: hours + ' hours',
407
+ });
408
+ } catch (e: any) { return errorResult(e.message); }
409
+ },
410
+ },
411
+
412
+ // ─── RSVP to Meeting ───────────────────────────────
413
+ {
414
+ name: 'meeting_rsvp',
415
+ description: 'Accept or decline a Google Calendar meeting invitation.',
416
+ category: 'utility' as const,
417
+ parameters: {
418
+ type: 'object' as const,
419
+ properties: {
420
+ eventId: { type: 'string', description: 'Calendar event ID (required)' },
421
+ response: { type: 'string', description: '"accepted", "declined", or "tentative" (required)' },
422
+ calendarId: { type: 'string', description: 'Calendar ID (default: "primary")' },
423
+ comment: { type: 'string', description: 'Optional RSVP comment' },
424
+ },
425
+ required: ['eventId', 'response'],
426
+ },
427
+ async execute(_id: string, params: any) {
428
+ try {
429
+ const token = await tp.getAccessToken();
430
+ const calendarId = params.calendarId || 'primary';
431
+ const email = tp.getEmail();
432
+
433
+ // Get the event first
434
+ const event = await calendarApi(token, `/calendars/${encodeURIComponent(calendarId)}/events/${params.eventId}`);
435
+
436
+ // Update attendee status
437
+ const attendees = (event.attendees || []).map((a: any) => {
438
+ if (a.self || a.email === email) {
439
+ return { ...a, responseStatus: params.response, comment: params.comment };
440
+ }
441
+ return a;
442
+ });
443
+
444
+ // If agent isn't in attendees list, add self
445
+ if (!attendees.find((a: any) => a.self || a.email === email)) {
446
+ attendees.push({ email, responseStatus: params.response, comment: params.comment });
447
+ }
448
+
449
+ const updated = await calendarApi(token, `/calendars/${encodeURIComponent(calendarId)}/events/${params.eventId}`, {
450
+ method: 'PATCH',
451
+ query: { sendUpdates: 'all' },
452
+ body: { attendees },
453
+ });
454
+
455
+ const link = extractMeetingLink(updated);
456
+ return jsonResult({
457
+ rsvp: params.response,
458
+ eventId: params.eventId,
459
+ title: updated.summary,
460
+ start: updated.start?.dateTime || updated.start?.date,
461
+ meetingLink: link?.url,
462
+ platform: link?.platform,
463
+ });
464
+ } catch (e: any) { return errorResult(e.message); }
465
+ },
466
+ },
467
+ ];
468
+ }