mcpbrowser 0.3.34 → 0.3.36

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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/actions/click-element.js +8 -3
  3. package/src/actions/execute-javascript.js +8 -3
  4. package/src/actions/fetch-page.js +9 -3
  5. package/src/actions/get-current-html.js +9 -3
  6. package/src/actions/plugin-action.js +180 -0
  7. package/src/actions/plugin-info.js +170 -0
  8. package/src/core/logger.js +3 -7
  9. package/src/core/plugin-loader.js +344 -0
  10. package/src/mcp-browser.js +34 -2
  11. package/src/plugins/_example/index.js +140 -0
  12. package/src/plugins/gcal/actions/check-availability.js +185 -0
  13. package/src/plugins/gcal/actions/create-event.js +238 -0
  14. package/src/plugins/gcal/actions/delete-event.js +138 -0
  15. package/src/plugins/gcal/actions/edit-event.js +244 -0
  16. package/src/plugins/gcal/actions/list-events.js +96 -0
  17. package/src/plugins/gcal/actions/read-event.js +174 -0
  18. package/src/plugins/gcal/actions/rsvp-event.js +149 -0
  19. package/src/plugins/gcal/actions/search-events.js +121 -0
  20. package/src/plugins/gcal/helpers.js +415 -0
  21. package/src/plugins/gcal/index.js +148 -0
  22. package/src/plugins/gcal/selectors.js +54 -0
  23. package/src/plugins/gmail/actions/archive-email.js +65 -0
  24. package/src/plugins/gmail/actions/compose-email.js +116 -0
  25. package/src/plugins/gmail/actions/delete-email.js +65 -0
  26. package/src/plugins/gmail/actions/forward-email.js +95 -0
  27. package/src/plugins/gmail/actions/label-email.js +107 -0
  28. package/src/plugins/gmail/actions/list-emails.js +61 -0
  29. package/src/plugins/gmail/actions/mark-read.js +71 -0
  30. package/src/plugins/gmail/actions/mark-unread.js +71 -0
  31. package/src/plugins/gmail/actions/read-email.js +149 -0
  32. package/src/plugins/gmail/actions/reply-email.js +87 -0
  33. package/src/plugins/gmail/actions/search-emails.js +95 -0
  34. package/src/plugins/gmail/helpers.js +419 -0
  35. package/src/plugins/gmail/index.js +195 -0
  36. package/src/plugins/gmail/selectors.js +82 -0
  37. package/src/plugins.json +4 -0
@@ -0,0 +1,244 @@
1
+ /**
2
+ * edit-event.js — Modify an existing event's fields.
3
+ *
4
+ * Tier usage:
5
+ * T3: selectEvent for clicking event chip, ARIA dialog for edit form,
6
+ * input targeting for field updates
7
+ * T4: SAVE_BUTTON selector for conditional save
8
+ */
9
+
10
+ import { ErrorResponse } from '../../../core/responses.js';
11
+ import logger from '../../../core/logger.js';
12
+ import {
13
+ checkPrecondition,
14
+ selectEvent,
15
+ waitForCalendar,
16
+ GCalActionResponse
17
+ } from '../helpers.js';
18
+ import { SAVE_BUTTON } from '../selectors.js';
19
+
20
+ /**
21
+ * Edit an existing event by index or event ID.
22
+ * @param {object} opts
23
+ * @param {import('puppeteer-core').Page} opts.page
24
+ * @param {object} opts.params
25
+ * @param {number} [opts.params.index] - 0-based position in current event list
26
+ * @param {string} [opts.params.id] - Google Calendar event ID
27
+ * @param {string} [opts.params.title] - New title
28
+ * @param {string} [opts.params.date] - New date (ISO format)
29
+ * @param {string} [opts.params.startTime] - New start time (HH:MM)
30
+ * @param {string} [opts.params.endTime] - New end time (HH:MM)
31
+ * @param {string} [opts.params.location] - New location
32
+ * @param {string} [opts.params.description] - New description
33
+ * @param {string[]} [opts.params.attendees] - New attendee list
34
+ * @param {boolean} [opts.params.allDay] - Toggle all-day
35
+ * @param {boolean} [opts.params.save=false] - If true, save changes (FR-015)
36
+ * @returns {Promise<GCalActionResponse|ErrorResponse>}
37
+ */
38
+ export async function editEvent({ page, params }) {
39
+ // Validate: at least one identifier required
40
+ if (params.index == null && params.id == null) {
41
+ return new ErrorResponse(
42
+ 'Either index or id is required to edit an event.',
43
+ [
44
+ 'Use list_events to see available events and their indices',
45
+ 'Use search_events to find a specific event by keyword'
46
+ ]
47
+ );
48
+ }
49
+
50
+ // Precondition: must be on Google Calendar
51
+ const pre = await checkPrecondition(page, 'on_calendar');
52
+ if (!pre.met) {
53
+ return new ErrorResponse(pre.error, [
54
+ pre.suggestion || "Use fetch_webpage({ url: 'https://calendar.google.com' }) to open Google Calendar first."
55
+ ]);
56
+ }
57
+
58
+ // T3: Select (click) the target event to open detail popup
59
+ const sel = await selectEvent(page, { index: params.index, id: params.id });
60
+ if (!sel.selected) {
61
+ return new ErrorResponse(
62
+ sel.error || 'Could not select the event to edit.',
63
+ ['Use list_events to refresh the event list and check indices']
64
+ );
65
+ }
66
+
67
+ // Wait for detail popup
68
+ try {
69
+ await waitForCalendar(page, 'div[role="dialog"]');
70
+ } catch {
71
+ return new ErrorResponse(
72
+ 'Event detail popup did not appear after clicking the event.',
73
+ ['Try list_events to refresh, then edit_event with a valid index']
74
+ );
75
+ }
76
+
77
+ // Detect recurring event dialog — if it appears, select "This event"
78
+ const recurringDialog = await page.evaluate(() => {
79
+ const buttons = Array.from(document.querySelectorAll('button, span[role="radio"]'));
80
+ return buttons.some(b => b.textContent?.includes('This event'));
81
+ });
82
+ let recurringNote = null;
83
+ if (recurringDialog) {
84
+ const thisEventBtn = await page.evaluateHandle(() => {
85
+ const elements = Array.from(document.querySelectorAll('button, span[role="radio"], label'));
86
+ return elements.find(el => el.textContent?.includes('This event'));
87
+ });
88
+ if (thisEventBtn) {
89
+ await thisEventBtn.asElement()?.click();
90
+ await new Promise(r => setTimeout(r, 300));
91
+ recurringNote = 'Editing this single occurrence of a recurring event.';
92
+ logger.debug('editEvent: selected "This event" for recurring event');
93
+ }
94
+ }
95
+
96
+ // Click \"Edit event\" pencil icon to open the edit form
97
+ const editBtn = await page.$('button[aria-label="Edit event"]') ||
98
+ await page.$('button[aria-label*="edit" i]') ||
99
+ await page.$('[data-editbtn]');
100
+ if (editBtn) {
101
+ await editBtn.click();
102
+ logger.debug('editEvent: clicked Edit button');
103
+ await new Promise(r => setTimeout(r, 500));
104
+ }
105
+
106
+ // Wait for the edit form to load
107
+ try {
108
+ await waitForCalendar(page, 'input[aria-label="Add title"], input[data-initial-value]');
109
+ } catch {
110
+ return new ErrorResponse(
111
+ 'Edit form did not appear. The event may not be editable.',
112
+ ['You may not have edit permissions for this event', 'Try reading the event first with read_event']
113
+ );
114
+ }
115
+
116
+ // T3: Update only the provided fields
117
+ const fieldsUpdated = [];
118
+
119
+ if (params.title) {
120
+ const titleInput = await page.$('input[aria-label="Add title"]') ||
121
+ await page.$('input[data-initial-value]');
122
+ if (titleInput) {
123
+ await titleInput.click({ clickCount: 3 });
124
+ await titleInput.type(params.title);
125
+ fieldsUpdated.push('title');
126
+ logger.debug(`editEvent: updated title to "${params.title}"`);
127
+ }
128
+ }
129
+
130
+ if (params.date) {
131
+ const dateInput = await page.$('input[aria-label*="date" i]') ||
132
+ await page.$('input[data-date]');
133
+ if (dateInput) {
134
+ await dateInput.click({ clickCount: 3 });
135
+ await dateInput.type(params.date);
136
+ await page.keyboard.press('Tab');
137
+ fieldsUpdated.push('date');
138
+ logger.debug(`editEvent: updated date to "${params.date}"`);
139
+ }
140
+ }
141
+
142
+ if (params.allDay !== undefined) {
143
+ const allDayCheckbox = await page.$('input[aria-label*="all day" i]') ||
144
+ await page.$('[data-allday] input[type="checkbox"]');
145
+ if (allDayCheckbox) {
146
+ await allDayCheckbox.click();
147
+ fieldsUpdated.push('allDay');
148
+ logger.debug('editEvent: toggled all-day');
149
+ }
150
+ }
151
+
152
+ if (params.startTime) {
153
+ const startInput = await page.$('input[aria-label*="start time" i]') ||
154
+ await page.$('input[aria-label*="Start time" i]');
155
+ if (startInput) {
156
+ await startInput.click({ clickCount: 3 });
157
+ await startInput.type(params.startTime);
158
+ await page.keyboard.press('Tab');
159
+ fieldsUpdated.push('startTime');
160
+ logger.debug(`editEvent: updated startTime to "${params.startTime}"`);
161
+ }
162
+ }
163
+
164
+ if (params.endTime) {
165
+ const endInput = await page.$('input[aria-label*="end time" i]') ||
166
+ await page.$('input[aria-label*="End time" i]');
167
+ if (endInput) {
168
+ await endInput.click({ clickCount: 3 });
169
+ await endInput.type(params.endTime);
170
+ await page.keyboard.press('Tab');
171
+ fieldsUpdated.push('endTime');
172
+ logger.debug(`editEvent: updated endTime to "${params.endTime}"`);
173
+ }
174
+ }
175
+
176
+ if (params.location) {
177
+ const locationInput = await page.$('input[aria-label*="location" i]') ||
178
+ await page.$('[data-locationactionpanel] input');
179
+ if (locationInput) {
180
+ await locationInput.click({ clickCount: 3 });
181
+ await locationInput.type(params.location);
182
+ await page.keyboard.press('Tab');
183
+ fieldsUpdated.push('location');
184
+ logger.debug(`editEvent: updated location to "${params.location}"`);
185
+ }
186
+ }
187
+
188
+ if (params.description) {
189
+ const descInput = await page.$('div[aria-label*="description" i][contenteditable]') ||
190
+ await page.$('textarea[aria-label*="description" i]') ||
191
+ await page.$('[data-description] [contenteditable]');
192
+ if (descInput) {
193
+ await descInput.click();
194
+ // Select all existing text and replace
195
+ await page.keyboard.down('Control');
196
+ await page.keyboard.press('a');
197
+ await page.keyboard.up('Control');
198
+ await descInput.type(params.description);
199
+ fieldsUpdated.push('description');
200
+ logger.debug('editEvent: updated description');
201
+ }
202
+ }
203
+
204
+ if (params.attendees && params.attendees.length > 0) {
205
+ const guestInput = await page.$('input[aria-label*="guest" i]') ||
206
+ await page.$('input[aria-label*="Add guests" i]');
207
+ if (guestInput) {
208
+ for (const email of params.attendees) {
209
+ await guestInput.click();
210
+ await guestInput.type(email);
211
+ await page.keyboard.press('Enter');
212
+ await new Promise(r => setTimeout(r, 200));
213
+ }
214
+ fieldsUpdated.push('attendees');
215
+ logger.debug(`editEvent: updated attendees (${params.attendees.length} added)`);
216
+ }
217
+ }
218
+
219
+ // FR-015: Conditionally save — default is false
220
+ const save = params.save === true;
221
+ if (save) {
222
+ const saveBtn = await page.$(SAVE_BUTTON) ||
223
+ await page.$('button[aria-label="Save"]');
224
+ if (saveBtn) {
225
+ await saveBtn.click();
226
+ logger.debug('editEvent: clicked Save');
227
+ await new Promise(r => setTimeout(r, 500));
228
+ }
229
+ }
230
+
231
+ return new GCalActionResponse(
232
+ {
233
+ fieldsUpdated,
234
+ saved: save,
235
+ recurringNote
236
+ },
237
+ save
238
+ ? `Event updated and saved. Fields changed: ${fieldsUpdated.join(', ') || 'none'}.`
239
+ : `Event form updated (${fieldsUpdated.join(', ') || 'none'}). Review and save in Calendar.`,
240
+ save
241
+ ? ['Use list_events to see the updated calendar']
242
+ : ['Review changes and click Save manually in Calendar', 'Use list_events to return to calendar view']
243
+ );
244
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * list-events.js — List visible events from the current Google Calendar view.
3
+ *
4
+ * Tier usage:
5
+ * T1: calendarNavigate + buildViewPath for date/view navigation
6
+ * T3: extractVisibleEvents for event chip data extraction
7
+ * T4: EVENT_CHIP selector via waitForCalendar
8
+ */
9
+
10
+ import { ErrorResponse } from '../../../core/responses.js';
11
+ import {
12
+ checkPrecondition,
13
+ calendarNavigate,
14
+ buildViewPath,
15
+ waitForCalendar,
16
+ extractVisibleEvents,
17
+ detectView,
18
+ GCalActionResponse
19
+ } from '../helpers.js';
20
+ import { EVENT_CHIP } from '../selectors.js';
21
+
22
+ /**
23
+ * List events from the current Calendar view or navigate to a specific date/view.
24
+ * @param {object} opts
25
+ * @param {import('puppeteer-core').Page} opts.page
26
+ * @param {object} opts.params
27
+ * @param {string} [opts.params.date] - ISO date to navigate to (e.g., '2026-04-10')
28
+ * @param {string} [opts.params.view] - Calendar view: day, week, month, schedule
29
+ * @param {number} [opts.params.limit=25] - Maximum events to return
30
+ * @returns {Promise<GCalActionResponse|ErrorResponse>}
31
+ */
32
+ export async function listEvents({ page, params }) {
33
+ // Precondition: must be on Google Calendar
34
+ const pre = await checkPrecondition(page, 'on_calendar');
35
+ if (!pre.met) {
36
+ return new ErrorResponse(pre.error, [
37
+ pre.suggestion || "Use fetch_webpage({ url: 'https://calendar.google.com' }) to open Google Calendar first."
38
+ ]);
39
+ }
40
+
41
+ const date = params.date || null;
42
+ const view = params.view || null;
43
+ const limit = params.limit || 25;
44
+
45
+ // Validate view param if provided
46
+ const validViews = ['day', 'week', 'month', 'schedule', 'custom'];
47
+ if (view && !validViews.includes(view)) {
48
+ return new ErrorResponse(
49
+ `Invalid view "${view}". Must be one of: ${validViews.join(', ')}.`,
50
+ ['Use list_events({ view: "week" }) or list_events({ view: "day", date: "2026-04-10" })']
51
+ );
52
+ }
53
+
54
+ // T1: Navigate to date/view if either is provided
55
+ if (date || view) {
56
+ const targetView = view || 'week';
57
+ const path = buildViewPath(targetView, date);
58
+ await calendarNavigate(page, path);
59
+ }
60
+
61
+ // Wait for event chips to appear (or timeout gracefully)
62
+ try {
63
+ await waitForCalendar(page, EVENT_CHIP);
64
+ } catch {
65
+ // No events visible — return empty list rather than error
66
+ const currentView = await detectView(page);
67
+ return new GCalActionResponse(
68
+ { events: [], view: currentView, dateRange: date || 'current', total: 0 },
69
+ 'No events found in the current view.',
70
+ [
71
+ 'Try a different date or view: list_events({ date: "2026-04-10", view: "week" })',
72
+ 'Use search_events to find events by keyword',
73
+ 'Use create_event to add a new event'
74
+ ]
75
+ );
76
+ }
77
+
78
+ // T3+T4: Extract visible event data
79
+ const events = await extractVisibleEvents(page, limit);
80
+ const currentView = await detectView(page);
81
+
82
+ return new GCalActionResponse(
83
+ {
84
+ events,
85
+ view: currentView,
86
+ dateRange: date || 'current',
87
+ total: events.length
88
+ },
89
+ `Found ${events.length} event(s) in ${currentView} view.`,
90
+ [
91
+ 'Use read_event({ index: N }) to open a specific event',
92
+ 'Use search_events to find events by keyword',
93
+ 'Use create_event to add a new event'
94
+ ]
95
+ );
96
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * read-event.js — Open an event and read its full details.
3
+ *
4
+ * Tier usage:
5
+ * T3: selectEvent for clicking event chip, ARIA dialog for detail popup
6
+ * T4: EVENT_LOCATION_IN_DETAIL, EVENT_DESCRIPTION_IN_DETAIL,
7
+ * ATTENDEE_ROW, ATTENDEE_RSVP_STATUS selectors for data extraction
8
+ */
9
+
10
+ import { ErrorResponse } from '../../../core/responses.js';
11
+ import logger from '../../../core/logger.js';
12
+ import {
13
+ checkPrecondition,
14
+ selectEvent,
15
+ waitForCalendar,
16
+ GCalActionResponse
17
+ } from '../helpers.js';
18
+ import {
19
+ EVENT_LOCATION_IN_DETAIL,
20
+ EVENT_DESCRIPTION_IN_DETAIL,
21
+ ATTENDEE_ROW,
22
+ ATTENDEE_RSVP_STATUS
23
+ } from '../selectors.js';
24
+
25
+ /**
26
+ * Open and read a specific event by index or event ID.
27
+ * @param {object} opts
28
+ * @param {import('puppeteer-core').Page} opts.page
29
+ * @param {object} opts.params
30
+ * @param {number} [opts.params.index] - 0-based position in current event list
31
+ * @param {string} [opts.params.id] - Google Calendar event ID
32
+ * @returns {Promise<GCalActionResponse|ErrorResponse>}
33
+ */
34
+ export async function readEvent({ page, params }) {
35
+ // Validate: at least one identifier required
36
+ if (params.index == null && params.id == null) {
37
+ return new ErrorResponse(
38
+ 'Either index or id is required to read an event.',
39
+ [
40
+ 'Use list_events to see available events and their indices',
41
+ 'Use search_events to find a specific event by keyword'
42
+ ]
43
+ );
44
+ }
45
+
46
+ // Precondition: must be on Google Calendar
47
+ const pre = await checkPrecondition(page, 'on_calendar');
48
+ if (!pre.met) {
49
+ return new ErrorResponse(pre.error, [
50
+ pre.suggestion || "Use fetch_webpage({ url: 'https://calendar.google.com' }) to open Google Calendar first."
51
+ ]);
52
+ }
53
+
54
+ // FR-023: Close any existing dialog before opening a new one
55
+ const existingDialog = await page.$('div[role="dialog"]');
56
+ if (existingDialog) {
57
+ logger.debug('readEvent: closing existing dialog');
58
+ await page.keyboard.press('Escape');
59
+ await new Promise(r => setTimeout(r, 300));
60
+ }
61
+
62
+ // T3: Select (click) the target event
63
+ const sel = await selectEvent(page, { index: params.index, id: params.id });
64
+ if (!sel.selected) {
65
+ return new ErrorResponse(
66
+ sel.error || 'Could not select the event.',
67
+ ['Use list_events to refresh the event list and check indices']
68
+ );
69
+ }
70
+
71
+ // Wait for detail popup/dialog to appear
72
+ try {
73
+ await waitForCalendar(page, 'div[role="dialog"]');
74
+ } catch {
75
+ return new ErrorResponse(
76
+ 'Event detail popup did not appear after clicking the event.',
77
+ [
78
+ 'The event chip may not be interactive. Try list_events to refresh.',
79
+ 'Try read_event with a different index'
80
+ ]
81
+ );
82
+ }
83
+
84
+ // T3+T4: Extract event detail fields from the dialog
85
+ const eventDetail = await page.evaluate((selectors) => {
86
+ const dialog = document.querySelector('div[role="dialog"]');
87
+ if (!dialog) return null;
88
+
89
+ // T3: Title from the heading element in the dialog
90
+ const titleEl = dialog.querySelector('span[role="heading"], [data-eventid]') ||
91
+ dialog.querySelector('span');
92
+ const title = titleEl?.textContent?.trim() || '';
93
+
94
+ // T3: Time/date info from aria-label or text content
95
+ const timeEls = dialog.querySelectorAll('div[data-datekey], span[data-datekey]');
96
+ let dateTime = '';
97
+ if (timeEls.length > 0) {
98
+ dateTime = Array.from(timeEls).map(el => el.textContent?.trim()).join(' ');
99
+ }
100
+
101
+ // T4: Location
102
+ const locationEl = dialog.querySelector(selectors.location);
103
+ const location = locationEl?.textContent?.trim() || null;
104
+
105
+ // T4: Description
106
+ const descEl = dialog.querySelector(selectors.description);
107
+ const description = descEl?.textContent?.trim() || null;
108
+
109
+ // T3: Conference link (look for meet or zoom links)
110
+ const links = dialog.querySelectorAll('a[href]');
111
+ let conferenceLink = null;
112
+ for (const link of links) {
113
+ const href = link.getAttribute('href') || '';
114
+ if (href.includes('meet.google.com') || href.includes('zoom.us')) {
115
+ conferenceLink = href;
116
+ break;
117
+ }
118
+ }
119
+
120
+ // T4: Attendees
121
+ const attendeeEls = dialog.querySelectorAll(selectors.attendeeRow);
122
+ const attendees = Array.from(attendeeEls).map(row => {
123
+ const email = row.getAttribute('data-guest-email') || '';
124
+ const name = row.textContent?.trim() || email;
125
+ const rsvpEl = row.querySelector(selectors.rsvpStatus);
126
+ const rsvpStatus = rsvpEl?.getAttribute('data-rsvp') || 'unknown';
127
+ return { email, name, rsvpStatus };
128
+ });
129
+
130
+ // T3: Organizer (first attendee or dialog context)
131
+ const organizer = attendees.length > 0 ? attendees[0].email : null;
132
+
133
+ // T3: Calendar name from color label
134
+ const calendarEl = dialog.querySelector('[data-calendar-color]');
135
+ const calendarName = calendarEl?.textContent?.trim() || null;
136
+
137
+ return {
138
+ title,
139
+ dateTime,
140
+ location,
141
+ description,
142
+ conferenceLink,
143
+ attendees,
144
+ organizer,
145
+ calendarName,
146
+ attendeeCount: attendees.length
147
+ };
148
+ }, {
149
+ location: EVENT_LOCATION_IN_DETAIL,
150
+ description: EVENT_DESCRIPTION_IN_DETAIL,
151
+ attendeeRow: ATTENDEE_ROW,
152
+ rsvpStatus: ATTENDEE_RSVP_STATUS
153
+ });
154
+
155
+ if (!eventDetail) {
156
+ return new ErrorResponse(
157
+ 'Could not extract event details from the dialog.',
158
+ ['Try closing the dialog (press Escape) and re-opening with read_event']
159
+ );
160
+ }
161
+
162
+ logger.debug(`readEvent: extracted "${eventDetail.title}"`);
163
+
164
+ return new GCalActionResponse(
165
+ eventDetail,
166
+ `Event: ${eventDetail.title}${eventDetail.dateTime ? ` — ${eventDetail.dateTime}` : ''}`,
167
+ [
168
+ 'Use edit_event to modify this event',
169
+ 'Use rsvp_event to respond to this invitation',
170
+ 'Use delete_event to remove this event',
171
+ 'Press Escape or use list_events to return to the calendar view'
172
+ ]
173
+ );
174
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * rsvp-event.js — Respond to a calendar invitation (accept, decline, tentative).
3
+ *
4
+ * Tier usage:
5
+ * T3: selectEvent for clicking event chip, ARIA dialog for detail popup
6
+ * T4: RSVP_YES_BUTTON, RSVP_NO_BUTTON, RSVP_MAYBE_BUTTON selectors
7
+ */
8
+
9
+ import { ErrorResponse } from '../../../core/responses.js';
10
+ import logger from '../../../core/logger.js';
11
+ import {
12
+ checkPrecondition,
13
+ selectEvent,
14
+ waitForCalendar,
15
+ GCalActionResponse
16
+ } from '../helpers.js';
17
+ import {
18
+ RSVP_YES_BUTTON,
19
+ RSVP_NO_BUTTON,
20
+ RSVP_MAYBE_BUTTON
21
+ } from '../selectors.js';
22
+
23
+ /** Map response names to selector keys. */
24
+ const RSVP_MAP = {
25
+ accept: RSVP_YES_BUTTON,
26
+ decline: RSVP_NO_BUTTON,
27
+ tentative: RSVP_MAYBE_BUTTON
28
+ };
29
+
30
+ /**
31
+ * RSVP to a calendar invitation.
32
+ * @param {object} opts
33
+ * @param {import('puppeteer-core').Page} opts.page
34
+ * @param {object} opts.params
35
+ * @param {number} [opts.params.index] - 0-based position in current event list
36
+ * @param {string} [opts.params.id] - Google Calendar event ID
37
+ * @param {string} opts.params.response - "accept", "decline", or "tentative" (required)
38
+ * @returns {Promise<GCalActionResponse|ErrorResponse>}
39
+ */
40
+ export async function rsvpEvent({ page, params }) {
41
+ // Validate: at least one identifier required
42
+ if (params.index == null && params.id == null) {
43
+ return new ErrorResponse(
44
+ 'Either index or id is required to RSVP to an event.',
45
+ [
46
+ 'Use list_events to see available events and their indices',
47
+ 'Use search_events to find a specific event by keyword'
48
+ ]
49
+ );
50
+ }
51
+
52
+ // Validate response value
53
+ const response = (params.response || '').toLowerCase();
54
+ if (!RSVP_MAP[response]) {
55
+ return new ErrorResponse(
56
+ `Invalid RSVP response "${params.response}". Must be one of: accept, decline, tentative.`,
57
+ ['Use rsvp_event({ index: 0, response: "accept" })']
58
+ );
59
+ }
60
+
61
+ // Precondition: must be on Google Calendar
62
+ const pre = await checkPrecondition(page, 'on_calendar');
63
+ if (!pre.met) {
64
+ return new ErrorResponse(pre.error, [
65
+ pre.suggestion || "Use fetch_webpage({ url: 'https://calendar.google.com' }) to open Google Calendar first."
66
+ ]);
67
+ }
68
+
69
+ // T3: Select (click) the target event to open detail popup
70
+ const sel = await selectEvent(page, { index: params.index, id: params.id });
71
+ if (!sel.selected) {
72
+ return new ErrorResponse(
73
+ sel.error || 'Could not select the event to RSVP.',
74
+ ['Use list_events to refresh the event list and check indices']
75
+ );
76
+ }
77
+
78
+ // Wait for detail popup
79
+ try {
80
+ await waitForCalendar(page, 'div[role="dialog"]');
81
+ } catch {
82
+ return new ErrorResponse(
83
+ 'Event detail popup did not appear after clicking the event.',
84
+ ['Try list_events to refresh, then rsvp_event with a valid index']
85
+ );
86
+ }
87
+
88
+ // Detect if the user is the organizer (organizers cannot RSVP to their own events)
89
+ const isOrganizer = await page.evaluate(() => {
90
+ const dialog = document.querySelector('div[role="dialog"]');
91
+ if (!dialog) return false;
92
+ // Organizer view typically shows "Edit event" but not RSVP buttons
93
+ const editBtn = dialog.querySelector('button[aria-label="Edit event"]');
94
+ const rsvpBtn = dialog.querySelector('[data-response]');
95
+ return editBtn && !rsvpBtn;
96
+ });
97
+
98
+ if (isOrganizer) {
99
+ return new ErrorResponse(
100
+ 'You are the organizer of this event. Organizers cannot RSVP to their own events.',
101
+ [
102
+ 'Use edit_event to modify the event instead',
103
+ 'Use delete_event to cancel the event'
104
+ ]
105
+ );
106
+ }
107
+
108
+ // T4: Click the appropriate RSVP button
109
+ const rsvpSelector = RSVP_MAP[response];
110
+ const rsvpBtn = await page.$(rsvpSelector);
111
+ if (!rsvpBtn) {
112
+ // Fallback: look for RSVP button by text content
113
+ const fallbackBtn = await page.evaluateHandle((resp) => {
114
+ const buttons = Array.from(document.querySelectorAll('button, div[role="button"]'));
115
+ const labels = { accept: 'Yes', decline: 'No', tentative: 'Maybe' };
116
+ return buttons.find(b => b.textContent?.trim() === labels[resp]);
117
+ }, response);
118
+
119
+ if (fallbackBtn && fallbackBtn.asElement()) {
120
+ await fallbackBtn.asElement().click();
121
+ logger.debug(`rsvpEvent: clicked RSVP "${response}" via text fallback`);
122
+ } else {
123
+ return new ErrorResponse(
124
+ `Could not find the RSVP "${response}" button. This event may not have RSVP options.`,
125
+ [
126
+ 'This event may not be an invitation — you can only RSVP to events you were invited to',
127
+ 'Use read_event to inspect the event details'
128
+ ]
129
+ );
130
+ }
131
+ } else {
132
+ await rsvpBtn.click();
133
+ logger.debug(`rsvpEvent: clicked RSVP "${response}" via selector`);
134
+ }
135
+
136
+ await new Promise(r => setTimeout(r, 300));
137
+
138
+ return new GCalActionResponse(
139
+ {
140
+ response,
141
+ rsvpSent: true
142
+ },
143
+ `RSVP "${response}" sent successfully.`,
144
+ [
145
+ 'Use list_events to return to the calendar view',
146
+ 'Use read_event to verify the RSVP status'
147
+ ]
148
+ );
149
+ }