mcpeasy 0.1.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.
@@ -0,0 +1,407 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "google/apis/calendar_v3"
5
+ require "googleauth"
6
+ require "signet/oauth_2/client"
7
+ require "fileutils"
8
+ require "json"
9
+ require "time"
10
+ require_relative "../_google/auth_server"
11
+ require_relative "../../mcpeasy/config"
12
+
13
+ class GmeetTool
14
+ SCOPE = "https://www.googleapis.com/auth/calendar.readonly"
15
+
16
+ def initialize(skip_auth: false)
17
+ ensure_env!
18
+ @service = Google::Apis::CalendarV3::CalendarService.new
19
+ @service.authorization = authorize unless skip_auth
20
+ end
21
+
22
+ def list_meetings(start_date: nil, end_date: nil, max_results: 20, calendar_id: "primary")
23
+ # Default to today if no start date provided
24
+ start_time = start_date ? Time.parse("#{start_date} 00:00:00") : Time.now.beginning_of_day
25
+ # Default to 7 days from start if no end date provided
26
+ end_time = end_date ? Time.parse("#{end_date} 23:59:59") : start_time + 7 * 24 * 60 * 60
27
+
28
+ results = @service.list_events(
29
+ calendar_id,
30
+ max_results: max_results,
31
+ single_events: true,
32
+ order_by: "startTime",
33
+ time_min: start_time.utc.iso8601,
34
+ time_max: end_time.utc.iso8601
35
+ )
36
+
37
+ # Filter for events that have Google Meet links
38
+ meetings = results.items.filter_map do |event|
39
+ meet_link = extract_meet_link(event)
40
+ next unless meet_link
41
+
42
+ {
43
+ id: event.id,
44
+ summary: event.summary,
45
+ description: event.description,
46
+ location: event.location,
47
+ start: format_event_time(event.start),
48
+ end: format_event_time(event.end),
49
+ attendees: format_attendees(event.attendees),
50
+ html_link: event.html_link,
51
+ meet_link: meet_link,
52
+ calendar_id: calendar_id
53
+ }
54
+ end
55
+
56
+ {meetings: meetings, count: meetings.length}
57
+ rescue Google::Apis::Error => e
58
+ raise "Google Calendar API Error: #{e.message}"
59
+ rescue => e
60
+ log_error("list_meetings", e)
61
+ raise e
62
+ end
63
+
64
+ def upcoming_meetings(max_results: 10, calendar_id: "primary")
65
+ # Get meetings starting from now
66
+ start_time = Time.now
67
+ # Look ahead 24 hours by default
68
+ end_time = start_time + 24 * 60 * 60
69
+
70
+ results = @service.list_events(
71
+ calendar_id,
72
+ max_results: max_results,
73
+ single_events: true,
74
+ order_by: "startTime",
75
+ time_min: start_time.utc.iso8601,
76
+ time_max: end_time.utc.iso8601
77
+ )
78
+
79
+ # Filter for events that have Google Meet links and are upcoming
80
+ meetings = results.items.filter_map do |event|
81
+ meet_link = extract_meet_link(event)
82
+ next unless meet_link
83
+
84
+ event_start_time = event.start.date_time&.to_time || (event.start.date ? Time.parse(event.start.date) : nil)
85
+ next unless event_start_time && event_start_time >= start_time
86
+
87
+ {
88
+ id: event.id,
89
+ summary: event.summary,
90
+ description: event.description,
91
+ location: event.location,
92
+ start: format_event_time(event.start),
93
+ end: format_event_time(event.end),
94
+ attendees: format_attendees(event.attendees),
95
+ html_link: event.html_link,
96
+ meet_link: meet_link,
97
+ calendar_id: calendar_id,
98
+ time_until_start: time_until_event(event_start_time)
99
+ }
100
+ end
101
+
102
+ {meetings: meetings, count: meetings.length}
103
+ rescue Google::Apis::Error => e
104
+ raise "Google Calendar API Error: #{e.message}"
105
+ rescue => e
106
+ log_error("upcoming_meetings", e)
107
+ raise e
108
+ end
109
+
110
+ def search_meetings(query, start_date: nil, end_date: nil, max_results: 10)
111
+ # Default to today if no start date provided
112
+ start_time = start_date ? Time.parse("#{start_date} 00:00:00") : Time.now.beginning_of_day
113
+ # Default to 30 days from start if no end date provided
114
+ end_time = end_date ? Time.parse("#{end_date} 23:59:59") : start_time + 30 * 24 * 60 * 60
115
+
116
+ results = @service.list_events(
117
+ "primary",
118
+ q: query,
119
+ max_results: max_results,
120
+ single_events: true,
121
+ order_by: "startTime",
122
+ time_min: start_time.utc.iso8601,
123
+ time_max: end_time.utc.iso8601
124
+ )
125
+
126
+ # Filter for events that have Google Meet links
127
+ meetings = results.items.filter_map do |event|
128
+ meet_link = extract_meet_link(event)
129
+ next unless meet_link
130
+
131
+ {
132
+ id: event.id,
133
+ summary: event.summary,
134
+ description: event.description,
135
+ location: event.location,
136
+ start: format_event_time(event.start),
137
+ end: format_event_time(event.end),
138
+ attendees: format_attendees(event.attendees),
139
+ html_link: event.html_link,
140
+ meet_link: meet_link,
141
+ calendar_id: "primary"
142
+ }
143
+ end
144
+
145
+ {meetings: meetings, count: meetings.length}
146
+ rescue Google::Apis::Error => e
147
+ raise "Google Calendar API Error: #{e.message}"
148
+ rescue => e
149
+ log_error("search_meetings", e)
150
+ raise e
151
+ end
152
+
153
+ def get_meeting_url(event_id, calendar_id: "primary")
154
+ event = @service.get_event(calendar_id, event_id)
155
+ meet_link = extract_meet_link(event)
156
+
157
+ unless meet_link
158
+ raise "No Google Meet link found for this event"
159
+ end
160
+
161
+ {
162
+ event_id: event_id,
163
+ summary: event.summary,
164
+ meet_link: meet_link,
165
+ start: format_event_time(event.start),
166
+ end: format_event_time(event.end)
167
+ }
168
+ rescue Google::Apis::Error => e
169
+ raise "Google Calendar API Error: #{e.message}"
170
+ rescue => e
171
+ log_error("get_meeting_url", e)
172
+ raise e
173
+ end
174
+
175
+ def test_connection
176
+ calendar = @service.get_calendar("primary")
177
+ {
178
+ ok: true,
179
+ user: calendar.summary,
180
+ email: calendar.id
181
+ }
182
+ rescue Google::Apis::Error => e
183
+ raise "Google Calendar API Error: #{e.message}"
184
+ rescue => e
185
+ log_error("test_connection", e)
186
+ raise e
187
+ end
188
+
189
+ def authenticate
190
+ perform_auth_flow
191
+ {success: true}
192
+ rescue => e
193
+ {success: false, error: e.message}
194
+ end
195
+
196
+ def perform_auth_flow
197
+ client_id = Mcpeasy::Config.google_client_id
198
+ client_secret = Mcpeasy::Config.google_client_secret
199
+
200
+ unless client_id && client_secret
201
+ raise "Google credentials not found. Please save your credentials.json file using: mcpz config set_google_credentials <path_to_credentials.json>"
202
+ end
203
+
204
+ # Create credentials using OAuth2 flow with localhost redirect
205
+ redirect_uri = "http://localhost:8080"
206
+ client = Signet::OAuth2::Client.new(
207
+ client_id: client_id,
208
+ client_secret: client_secret,
209
+ scope: SCOPE,
210
+ redirect_uri: redirect_uri,
211
+ authorization_uri: "https://accounts.google.com/o/oauth2/auth",
212
+ token_credential_uri: "https://oauth2.googleapis.com/token"
213
+ )
214
+
215
+ # Generate authorization URL
216
+ url = client.authorization_uri.to_s
217
+
218
+ puts "DEBUG: Client ID: #{client_id[0..20]}..."
219
+ puts "DEBUG: Scope: #{SCOPE}"
220
+ puts "DEBUG: Redirect URI: #{redirect_uri}"
221
+ puts
222
+
223
+ # Start callback server to capture OAuth code
224
+ puts "Starting temporary web server to capture OAuth callback..."
225
+ puts "Opening authorization URL in your default browser..."
226
+ puts url
227
+ puts
228
+
229
+ # Automatically open URL in default browser on macOS/Unix
230
+ if system("which open > /dev/null 2>&1")
231
+ system("open", url)
232
+ else
233
+ puts "Could not automatically open browser. Please copy the URL above manually."
234
+ end
235
+ puts
236
+ puts "Waiting for OAuth callback... (will timeout in 60 seconds)"
237
+
238
+ # Wait for the authorization code with timeout
239
+ code = GoogleAuthServer.capture_auth_code
240
+
241
+ unless code
242
+ raise "Failed to receive authorization code. Please try again."
243
+ end
244
+
245
+ puts "✅ Authorization code received!"
246
+ client.code = code
247
+ client.fetch_access_token!
248
+
249
+ # Save credentials to config
250
+ credentials_data = {
251
+ client_id: client.client_id,
252
+ client_secret: client.client_secret,
253
+ scope: client.scope,
254
+ refresh_token: client.refresh_token,
255
+ access_token: client.access_token,
256
+ expires_at: client.expires_at
257
+ }
258
+
259
+ Mcpeasy::Config.save_google_token(credentials_data)
260
+ puts "✅ Authentication successful! Token saved to config"
261
+
262
+ client
263
+ rescue => e
264
+ log_error("perform_auth_flow", e)
265
+ raise "Authentication flow failed: #{e.message}"
266
+ end
267
+
268
+ private
269
+
270
+ def authorize
271
+ credentials_data = Mcpeasy::Config.google_token
272
+ unless credentials_data
273
+ raise <<~ERROR
274
+ Google Calendar authentication required!
275
+ Run the auth command first:
276
+ mcpz gmeet auth
277
+ ERROR
278
+ end
279
+
280
+ client = Signet::OAuth2::Client.new(
281
+ client_id: credentials_data.client_id,
282
+ client_secret: credentials_data.client_secret,
283
+ scope: credentials_data.scope.respond_to?(:to_a) ? credentials_data.scope.to_a.join(" ") : credentials_data.scope.to_s,
284
+ refresh_token: credentials_data.refresh_token,
285
+ access_token: credentials_data.access_token,
286
+ token_credential_uri: "https://oauth2.googleapis.com/token"
287
+ )
288
+
289
+ # Check if token needs refresh
290
+ if credentials_data.expires_at
291
+ expires_at = if credentials_data.expires_at.is_a?(String)
292
+ Time.parse(credentials_data.expires_at)
293
+ else
294
+ Time.at(credentials_data.expires_at)
295
+ end
296
+
297
+ if Time.now >= expires_at
298
+ client.refresh!
299
+ # Update saved credentials with new access token
300
+ updated_data = {
301
+ client_id: credentials_data.client_id,
302
+ client_secret: credentials_data.client_secret,
303
+ scope: credentials_data.scope.respond_to?(:to_a) ? credentials_data.scope.to_a.join(" ") : credentials_data.scope.to_s,
304
+ refresh_token: credentials_data.refresh_token,
305
+ access_token: client.access_token,
306
+ expires_at: client.expires_at
307
+ }
308
+ Mcpeasy::Config.save_google_token(updated_data)
309
+ end
310
+ end
311
+
312
+ client
313
+ rescue JSON::ParserError
314
+ raise "Invalid token data. Please re-run: mcpz gmeet auth"
315
+ rescue => e
316
+ log_error("authorize", e)
317
+ raise "Authentication failed: #{e.message}"
318
+ end
319
+
320
+ def ensure_env!
321
+ unless Mcpeasy::Config.google_client_id && Mcpeasy::Config.google_client_secret
322
+ raise <<~ERROR
323
+ Google API credentials not configured!
324
+ Please save your Google credentials.json file using:
325
+ mcpz config set_google_credentials <path_to_credentials.json>
326
+ ERROR
327
+ end
328
+ end
329
+
330
+ def extract_meet_link(event)
331
+ # Check various places where Google Meet links might be stored
332
+
333
+ # 1. Check conference data (most reliable)
334
+ if event.conference_data&.conference_solution&.name == "Google Meet"
335
+ return event.conference_data.entry_points&.find { |ep| ep.entry_point_type == "video" }&.uri
336
+ end
337
+
338
+ # 2. Check hangout link (legacy)
339
+ return event.hangout_link if event.hangout_link
340
+
341
+ # 3. Check description for meet.google.com links
342
+ if event.description
343
+ meet_match = event.description.match(/https:\/\/meet\.google\.com\/[a-z-]+/)
344
+ return meet_match[0] if meet_match
345
+ end
346
+
347
+ # 4. Check location field
348
+ if event.location
349
+ meet_match = event.location.match(/https:\/\/meet\.google\.com\/[a-z-]+/)
350
+ return meet_match[0] if meet_match
351
+ end
352
+
353
+ nil
354
+ end
355
+
356
+ def format_event_time(event_time)
357
+ return nil unless event_time
358
+
359
+ if event_time.date
360
+ # All-day event
361
+ {date: event_time.date}
362
+ elsif event_time.date_time
363
+ # Specific time event
364
+ {date_time: event_time.date_time.iso8601}
365
+ else
366
+ nil
367
+ end
368
+ end
369
+
370
+ def format_attendees(attendees)
371
+ return [] unless attendees
372
+
373
+ attendees.map do |attendee|
374
+ attendee.email
375
+ end.compact
376
+ end
377
+
378
+ def time_until_event(event_time)
379
+ now = Time.now
380
+ event_time = event_time.is_a?(String) ? Time.parse(event_time) : event_time
381
+
382
+ diff_seconds = (event_time - now).to_i
383
+ return "started" if diff_seconds < 0
384
+
385
+ if diff_seconds < 60
386
+ "#{diff_seconds} seconds"
387
+ elsif diff_seconds < 3600
388
+ "#{diff_seconds / 60} minutes"
389
+ elsif diff_seconds < 86400
390
+ hours = diff_seconds / 3600
391
+ minutes = (diff_seconds % 3600) / 60
392
+ "#{hours}h #{minutes}m"
393
+ else
394
+ days = diff_seconds / 86400
395
+ "#{days} days"
396
+ end
397
+ end
398
+
399
+ def log_error(method, error)
400
+ Mcpeasy::Config.ensure_config_dirs
401
+ File.write(
402
+ Mcpeasy::Config.log_file_path("gmeet", "error"),
403
+ "#{Time.now}: #{method} error: #{error.class}: #{error.message}\n#{error.backtrace.join("\n")}\n",
404
+ mode: "a"
405
+ )
406
+ end
407
+ end