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.
- package/package.json +1 -1
- package/src/actions/click-element.js +8 -3
- package/src/actions/execute-javascript.js +8 -3
- package/src/actions/fetch-page.js +9 -3
- package/src/actions/get-current-html.js +9 -3
- package/src/actions/plugin-action.js +180 -0
- package/src/actions/plugin-info.js +170 -0
- package/src/core/logger.js +3 -7
- package/src/core/plugin-loader.js +344 -0
- package/src/mcp-browser.js +34 -2
- package/src/plugins/_example/index.js +140 -0
- package/src/plugins/gcal/actions/check-availability.js +185 -0
- package/src/plugins/gcal/actions/create-event.js +238 -0
- package/src/plugins/gcal/actions/delete-event.js +138 -0
- package/src/plugins/gcal/actions/edit-event.js +244 -0
- package/src/plugins/gcal/actions/list-events.js +96 -0
- package/src/plugins/gcal/actions/read-event.js +174 -0
- package/src/plugins/gcal/actions/rsvp-event.js +149 -0
- package/src/plugins/gcal/actions/search-events.js +121 -0
- package/src/plugins/gcal/helpers.js +415 -0
- package/src/plugins/gcal/index.js +148 -0
- package/src/plugins/gcal/selectors.js +54 -0
- package/src/plugins/gmail/actions/archive-email.js +65 -0
- package/src/plugins/gmail/actions/compose-email.js +116 -0
- package/src/plugins/gmail/actions/delete-email.js +65 -0
- package/src/plugins/gmail/actions/forward-email.js +95 -0
- package/src/plugins/gmail/actions/label-email.js +107 -0
- package/src/plugins/gmail/actions/list-emails.js +61 -0
- package/src/plugins/gmail/actions/mark-read.js +71 -0
- package/src/plugins/gmail/actions/mark-unread.js +71 -0
- package/src/plugins/gmail/actions/read-email.js +149 -0
- package/src/plugins/gmail/actions/reply-email.js +87 -0
- package/src/plugins/gmail/actions/search-emails.js +95 -0
- package/src/plugins/gmail/helpers.js +419 -0
- package/src/plugins/gmail/index.js +195 -0
- package/src/plugins/gmail/selectors.js +82 -0
- 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
|
+
}
|