agno 1.7.7__py3-none-any.whl → 1.7.9__py3-none-any.whl

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.
@@ -1,175 +1,229 @@
1
1
  import datetime
2
2
  import json
3
- import os.path
4
3
  import uuid
5
4
  from functools import wraps
5
+ from os import getenv
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
6
8
 
7
9
  from agno.tools import Toolkit
8
- from agno.utils.log import logger
10
+ from agno.utils.log import log_debug, log_error, log_info
9
11
 
10
12
  try:
11
13
  from google.auth.transport.requests import Request
12
14
  from google.oauth2.credentials import Credentials
13
- from google_auth_oauthlib.flow import InstalledAppFlow # type: ignore
14
- from googleapiclient.discovery import build # type: ignore
15
- from googleapiclient.errors import HttpError # type: ignore
15
+ from google_auth_oauthlib.flow import InstalledAppFlow
16
+ from googleapiclient.discovery import Resource, build
17
+ from googleapiclient.errors import HttpError
18
+
16
19
  except ImportError:
17
20
  raise ImportError(
18
- "Google client library for Python not found , install it using `pip install --upgrade google-api-python-client google-auth-httplib2 google-auth-oauthlib`"
21
+ "Google client libraries not found, Please install using `pip install google-api-python-client google-auth-httplib2 google-auth-oauthlib`"
19
22
  )
20
- from typing import List, Optional
21
23
 
22
24
  SCOPES = ["https://www.googleapis.com/auth/calendar"]
23
25
 
24
26
 
25
- def authenticated(func):
27
+ def authenticate(func):
26
28
  """Decorator to ensure authentication before executing the method."""
27
29
 
28
30
  @wraps(func)
29
31
  def wrapper(self, *args, **kwargs):
30
- # Ensure credentials are valid
31
- if os.path.exists(self.token_path):
32
- self.creds = Credentials.from_authorized_user_file(self.token_path, SCOPES)
33
- if not self.creds or not self.creds.valid:
34
- if self.creds and self.creds.expired and self.creds.refresh_token:
35
- self.creds.refresh(Request())
36
- else:
37
- flow = InstalledAppFlow.from_client_secrets_file(self.creds_path, SCOPES)
38
- self.creds = flow.run_local_server(port=0)
39
- # Save the credentials for future use
40
- with open(self.token_path, "w") as token:
41
- token.write(self.creds.to_json())
42
-
43
- # Initialize the Google Calendar service
44
32
  try:
45
- self.service = build("calendar", "v3", credentials=self.creds)
46
- except HttpError as error:
47
- logger.error(f"An error occurred while creating the service: {error}")
48
- raise
49
-
50
- # Ensure the service is available
51
- if not self.service:
52
- raise ValueError("Google Calendar service could not be initialized.")
53
-
33
+ if not self.creds or not self.creds.valid:
34
+ self._auth()
35
+ if not self.service:
36
+ self.service = build("calendar", "v3", credentials=self.creds)
37
+ except Exception as e:
38
+ log_error(f"An error occurred: {e}")
54
39
  return func(self, *args, **kwargs)
55
40
 
56
41
  return wrapper
57
42
 
58
43
 
59
44
  class GoogleCalendarTools(Toolkit):
45
+ # Default scopes for Google Calendar API access
46
+ DEFAULT_SCOPES = {
47
+ "read": "https://www.googleapis.com/auth/calendar.readonly",
48
+ "write": "https://www.googleapis.com/auth/calendar",
49
+ }
50
+
51
+ service: Optional[Resource]
52
+
60
53
  def __init__(
61
54
  self,
55
+ scopes: Optional[List[str]] = None,
62
56
  credentials_path: Optional[str] = None,
63
- token_path: Optional[str] = None,
57
+ token_path: Optional[str] = "token.json",
58
+ access_token: Optional[str] = None,
59
+ calendar_id: str = "primary",
60
+ oauth_port: int = 8080,
61
+ allow_update: bool = False,
64
62
  **kwargs,
65
63
  ):
66
- """
67
- Google Calendar Tool.
68
-
69
- :param credentials_path: Path of the file credentials.json file which contains OAuth 2.0 Client ID. A client ID is used to identify a single app to Google's OAuth servers. If your app runs on multiple platforms, you must create a separate client ID for each platform. Refer doc https://developers.google.com/calendar/api/quickstart/python#authorize_credentials_for_a_desktop_application
70
- :param token_path: Path of the file token.json which stores the user's access and refresh tokens, and is created automatically when the authorization flow completes for the first time.
64
+ self.creds: Optional[Credentials] = None
65
+ self.service: Optional[Resource] = None
66
+ self.calendar_id: str = calendar_id
67
+ self.oauth_port: int = oauth_port
68
+ self.access_token = access_token
69
+ self.credentials_path = credentials_path
70
+ self.token_path = token_path
71
+ self.allow_update = allow_update
72
+ self.scopes = scopes or []
71
73
 
72
- """
74
+ super().__init__(
75
+ name="google_calendar_tools",
76
+ tools=[
77
+ self.list_events,
78
+ self.create_event,
79
+ self.update_event,
80
+ self.delete_event,
81
+ self.fetch_all_events,
82
+ self.find_available_slots,
83
+ self.list_calendars,
84
+ ],
85
+ **kwargs,
86
+ )
87
+ if not self.scopes:
88
+ # Add read permission by default
89
+ self.scopes.append(self.DEFAULT_SCOPES["read"])
90
+ # Add write permission if allow_update is True
91
+ if self.allow_update:
92
+ self.scopes.append(self.DEFAULT_SCOPES["write"])
73
93
 
74
- if not credentials_path:
75
- logger.error(
76
- "Google Calendar Tool : Please Provide Valid Credentials Path , You can refer https://developers.google.com/calendar/api/quickstart/python#authorize_credentials_for_a_desktop_application to create your credentials"
94
+ # Validate that required scopes are present for requested operations
95
+ if self.allow_update and self.DEFAULT_SCOPES["write"] not in self.scopes:
96
+ raise ValueError(f"The scope {self.DEFAULT_SCOPES['write']} is required for write operations")
97
+ if self.DEFAULT_SCOPES["read"] not in self.scopes and self.DEFAULT_SCOPES["write"] not in self.scopes:
98
+ raise ValueError(
99
+ f"Either {self.DEFAULT_SCOPES['read']} or {self.DEFAULT_SCOPES['write']} is required for read operations"
77
100
  )
78
- raise ValueError("Credential path is required")
79
- elif not os.path.exists(credentials_path):
80
- logger.error(
81
- "Google Calendar Tool : Credential file Path is invalid , please provide the full path of the credentials json file"
82
- )
83
- raise ValueError("Credentials Path is invalid")
84
101
 
85
- if not token_path:
86
- logger.warning(
87
- f"Google Calendar Tool : Token path is not provided, using {os.getcwd()}/token.json as default path"
88
- )
89
- token_path = "token.json"
102
+ def _auth(self) -> None:
103
+ """
104
+ Authenticate with Google Calendar API
105
+ """
106
+ if self.creds and self.creds.valid:
107
+ return
90
108
 
91
- self.creds = None
92
- self.service = None
93
- self.token_path = token_path
94
- self.creds_path = credentials_path
109
+ token_file = Path(self.token_path or "token.json")
110
+ creds_file = Path(self.credentials_path or "credentials.json")
111
+
112
+ if token_file.exists():
113
+ self.creds = Credentials.from_authorized_user_file(str(token_file), self.DEFAULT_SCOPES)
95
114
 
96
- tools = []
97
- tools.append(self.list_events)
98
- tools.append(self.create_event)
115
+ if not self.creds or not self.creds.valid:
116
+ if self.creds and self.creds.expired and self.creds.refresh_token:
117
+ self.creds.refresh(Request())
118
+ else:
119
+ client_config = {
120
+ "installed": {
121
+ "client_id": getenv("GOOGLE_CLIENT_ID"),
122
+ "client_secret": getenv("GOOGLE_CLIENT_SECRET"),
123
+ "project_id": getenv("GOOGLE_PROJECT_ID"),
124
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
125
+ "token_uri": "https://oauth2.googleapis.com/token",
126
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
127
+ "redirect_uris": [getenv("GOOGLE_REDIRECT_URI", "http://localhost")],
128
+ }
129
+ }
130
+ # File based authentication
131
+ if creds_file.exists():
132
+ flow = InstalledAppFlow.from_client_secrets_file(str(creds_file), self.scopes)
133
+ else:
134
+ flow = InstalledAppFlow.from_client_config(client_config, self.scopes)
135
+ # Opens up a browser window for OAuth authentication
136
+ self.creds = flow.run_local_server(port=self.oauth_port)
99
137
 
100
- super().__init__(name="google_calendar_tools", tools=tools, **kwargs)
138
+ if self.creds:
139
+ token_file.write_text(self.creds.to_json())
140
+ log_debug("Successfully authenticated with Google Calendar API.")
141
+ log_info(f"Token file path: {token_file}")
101
142
 
102
- @authenticated
103
- def list_events(self, limit: int = 10, date_from: str = datetime.date.today().isoformat()) -> str:
143
+ @authenticate
144
+ def list_events(self, limit: int = 10, start_date: Optional[str] = None) -> str:
104
145
  """
105
- List events from the user's primary calendar.
146
+ List upcoming events from the user's Google Calendar.
106
147
 
107
148
  Args:
108
- limit (Optional[int]): Number of events to return , default value is 10
109
- date_from (str) : the start date to return events from in date isoformat. Defaults to current datetime.
149
+ limit (Optional[int]): Number of events to return, default value is 10
150
+ start_date (Optional[str]): The start date to return events from in ISO format (YYYY-MM-DDTHH:MM:SS)
110
151
 
152
+ Returns:
153
+ str: JSON string containing the Google Calendar events or error message
111
154
  """
112
- if date_from is None:
113
- date_from = datetime.datetime.now(datetime.timezone.utc).isoformat()
114
- elif isinstance(date_from, str):
115
- date_from = datetime.datetime.fromisoformat(date_from).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
155
+ if start_date is None:
156
+ start_date = datetime.datetime.now(datetime.timezone.utc).isoformat()
157
+ log_debug(f"No start date provided, using current datetime: {start_date}")
158
+ elif isinstance(start_date, str):
159
+ try:
160
+ start_date = datetime.datetime.fromisoformat(start_date).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
161
+ except ValueError:
162
+ return json.dumps(
163
+ {"error": f"Invalid date format: {start_date}. Use ISO format (YYYY-MM-DDTHH:MM:SS)."}
164
+ )
116
165
 
117
166
  try:
118
- if self.service:
119
- events_result = (
120
- self.service.events()
121
- .list(
122
- calendarId="primary",
123
- timeMin=date_from,
124
- maxResults=limit,
125
- singleEvents=True,
126
- orderBy="startTime",
127
- )
128
- .execute()
167
+ events_result = (
168
+ self.service.events() # type: ignore
169
+ .list(
170
+ calendarId=self.calendar_id,
171
+ timeMin=start_date,
172
+ maxResults=limit,
173
+ singleEvents=True,
174
+ orderBy="startTime",
129
175
  )
130
- events = events_result.get("items", [])
131
- if not events:
132
- return json.dumps({"error": "No upcoming events found."})
133
- return json.dumps(events)
134
- else:
135
- return json.dumps({"error": "authentication issue"})
176
+ .execute()
177
+ )
178
+ events = events_result.get("items", [])
179
+ if not events:
180
+ return json.dumps({"message": "No upcoming events found."})
181
+ return json.dumps(events)
136
182
  except HttpError as error:
183
+ log_error(f"An error occurred: {error}")
137
184
  return json.dumps({"error": f"An error occurred: {error}"})
138
185
 
139
- @authenticated
186
+ @authenticate
140
187
  def create_event(
141
188
  self,
142
- start_datetime: str,
143
- end_datetime: str,
189
+ start_date: str,
190
+ end_date: str,
144
191
  title: Optional[str] = None,
145
192
  description: Optional[str] = None,
146
193
  location: Optional[str] = None,
147
- timezone: Optional[str] = None,
148
- attendees: List[str] = [],
149
- send_updates: Optional[str] = "all",
194
+ timezone: Optional[str] = "UTC",
195
+ attendees: Optional[List[str]] = None,
150
196
  add_google_meet_link: Optional[bool] = False,
151
197
  ) -> str:
152
198
  """
153
- Create a new event in the user's primary calendar.
199
+ Create a new event in the Google Calendar.
154
200
 
155
201
  Args:
156
- title (Optional[str]): Title of the Event
157
- description (Optional[str]) : Detailed description of the event
158
- location (Optional[str]) : Location of the event
159
- start_datetime (Optional[str]) : start date and time of the event
160
- end_datetime (Optional[str]) : end date and time of the event
161
- attendees (Optional[List[str]]) : List of emails of the attendees
162
- send_updates (Optional[str]): Whether to send updates to attendees. Options: 'all' (default), 'externalOnly', 'none'
163
- add_google_meet_link (Optional[bool]): Whether to add a google meet link to the event
164
- """
202
+ start_date (str): Start date and time of the event in ISO format (YYYY-MM-DDTHH:MM:SS)
203
+ end_date (str): End date and time of the event in ISO format (YYYY-MM-DDTHH:MM:SS)
204
+ title (Optional[str]): Title/summary of the event
205
+ description (Optional[str]): Detailed description of the event
206
+ location (Optional[str]): Location of the event
207
+ timezone (Optional[str]): Timezone for the event (default: UTC)
208
+ attendees (Optional[List[str]]): List of email addresses of the attendees
209
+ add_google_meet_link (Optional[bool]): Whether to add a Google Meet video link to the event
165
210
 
166
- attendees_list = [{"email": attendee} for attendee in attendees] if attendees else []
211
+ Returns:
212
+ str: JSON string containing the created Google Calendar event or error message
213
+ """
214
+ try:
215
+ # Format attendees if provided
216
+ attendees_list = [{"email": attendee} for attendee in attendees] if attendees else []
167
217
 
168
- start_time = datetime.datetime.fromisoformat(start_datetime).strftime("%Y-%m-%dT%H:%M:%S")
218
+ # Convert ISO string to datetime and format as required
219
+ try:
220
+ start_time = datetime.datetime.fromisoformat(start_date).strftime("%Y-%m-%dT%H:%M:%S")
221
+ end_time = datetime.datetime.fromisoformat(end_date).strftime("%Y-%m-%dT%H:%M:%S")
222
+ except ValueError:
223
+ return json.dumps({"error": "Invalid datetime format. Use ISO format (YYYY-MM-DDTHH:MM:SS)."})
169
224
 
170
- end_time = datetime.datetime.fromisoformat(end_datetime).strftime("%Y-%m-%dT%H:%M:%S")
171
- try:
172
- event = {
225
+ # Create event dictionary
226
+ event: Dict[str, Any] = {
173
227
  "summary": title,
174
228
  "location": location,
175
229
  "description": description,
@@ -177,24 +231,416 @@ class GoogleCalendarTools(Toolkit):
177
231
  "end": {"dateTime": end_time, "timeZone": timezone},
178
232
  "attendees": attendees_list,
179
233
  }
234
+
235
+ # Add Google Meet link if requested
180
236
  if add_google_meet_link:
181
237
  event["conferenceData"] = {
182
- "createRequest": {"requestId": str(uuid.uuid4()), "conferenceSolutionKey": {"type": "hangoutsMeet"}} # type: ignore
238
+ "createRequest": {"requestId": str(uuid.uuid4()), "conferenceSolutionKey": {"type": "hangoutsMeet"}}
183
239
  }
184
- if self.service:
185
- event_result = (
186
- self.service.events()
187
- .insert(
188
- calendarId="primary",
189
- body=event,
190
- sendUpdates=send_updates,
191
- conferenceDataVersion=1 if add_google_meet_link else 0,
192
- )
193
- .execute()
240
+
241
+ # Remove None values
242
+ event = {k: v for k, v in event.items() if v is not None}
243
+
244
+ event_result = (
245
+ self.service.events() # type: ignore
246
+ .insert(
247
+ calendarId=self.calendar_id,
248
+ body=event,
249
+ conferenceDataVersion=1 if add_google_meet_link else 0,
250
+ )
251
+ .execute()
252
+ )
253
+ log_debug(f"Event created successfully in calendar {self.calendar_id}. Event ID: {event_result['id']}")
254
+ return json.dumps(event_result)
255
+ except HttpError as error:
256
+ log_error(f"An error occurred: {error}")
257
+ return json.dumps({"error": f"An error occurred: {error}"})
258
+
259
+ @authenticate
260
+ def update_event(
261
+ self,
262
+ event_id: str,
263
+ title: Optional[str] = None,
264
+ description: Optional[str] = None,
265
+ location: Optional[str] = None,
266
+ start_date: Optional[str] = None,
267
+ end_date: Optional[str] = None,
268
+ timezone: Optional[str] = None,
269
+ attendees: Optional[List[str]] = None,
270
+ ) -> str:
271
+ """
272
+ Update an existing event in the Google Calendar.
273
+
274
+ Args:
275
+ event_id (str): ID of the event to update
276
+ title (Optional[str]): New title/summary of the event
277
+ description (Optional[str]): New description of the event
278
+ location (Optional[str]): New location of the event
279
+ start_date (Optional[str]): New start date and time in ISO format (YYYY-MM-DDTHH:MM:SS)
280
+ end_date (Optional[str]): New end date and time in ISO format (YYYY-MM-DDTHH:MM:SS)
281
+ timezone (Optional[str]): New timezone for the event
282
+ attendees (Optional[List[str]]): Updated list of attendee email addresses
283
+
284
+ Returns:
285
+ str: JSON string containing the updated Google Calendar event or error message
286
+ """
287
+ try:
288
+ # First get the existing event to preserve its structure
289
+ event = self.service.events().get(calendarId=self.calendar_id, eventId=event_id).execute() # type: ignore
290
+
291
+ # Update only the fields that are provided
292
+ if title is not None:
293
+ event["summary"] = title
294
+ if description is not None:
295
+ event["description"] = description
296
+ if location is not None:
297
+ event["location"] = location
298
+ if attendees is not None:
299
+ event["attendees"] = [{"email": attendee} for attendee in attendees]
300
+
301
+ # Handle datetime updates
302
+ if start_date:
303
+ try:
304
+ start_time = datetime.datetime.fromisoformat(start_date).strftime("%Y-%m-%dT%H:%M:%S")
305
+ event["start"]["dateTime"] = start_time
306
+ if timezone:
307
+ event["start"]["timeZone"] = timezone
308
+ except ValueError:
309
+ return json.dumps({"error": f"Invalid start datetime format: {start_date}. Use ISO format."})
310
+
311
+ if end_date:
312
+ try:
313
+ end_time = datetime.datetime.fromisoformat(end_date).strftime("%Y-%m-%dT%H:%M:%S")
314
+ event["end"]["dateTime"] = end_time
315
+ if timezone:
316
+ event["end"]["timeZone"] = timezone
317
+ except ValueError:
318
+ return json.dumps({"error": f"Invalid end datetime format: {end_date}. Use ISO format."})
319
+
320
+ # Update the event
321
+ updated_event = (
322
+ self.service.events().update(calendarId=self.calendar_id, eventId=event_id, body=event).execute() # type: ignore
323
+ )
324
+
325
+ log_debug(f"Event {event_id} updated successfully.")
326
+ return json.dumps(updated_event)
327
+ except HttpError as error:
328
+ log_error(f"An error occurred while updating event: {error}")
329
+ return json.dumps({"error": f"An error occurred: {error}"})
330
+
331
+ @authenticate
332
+ def delete_event(self, event_id: str) -> str:
333
+ """
334
+ Delete an event from the Google Calendar.
335
+
336
+ Args:
337
+ event_id (str): ID of the event to delete
338
+
339
+ Returns:
340
+ str: JSON string containing success message or error message
341
+ """
342
+ try:
343
+ self.service.events().delete(calendarId=self.calendar_id, eventId=event_id).execute() # type: ignore
344
+
345
+ log_debug(f"Event {event_id} deleted successfully.")
346
+ return json.dumps({"success": True, "message": f"Event {event_id} deleted successfully."})
347
+ except HttpError as error:
348
+ log_error(f"An error occurred while deleting event: {error}")
349
+ return json.dumps({"error": f"An error occurred: {error}"})
350
+
351
+ @authenticate
352
+ def fetch_all_events(
353
+ self,
354
+ max_results: int = 10,
355
+ start_date: Optional[str] = None,
356
+ end_date: Optional[str] = None,
357
+ ) -> str:
358
+ """
359
+ Fetch all Google Calendar events in a given date range.
360
+
361
+ Args:
362
+ start_date (Optional[str]): The minimum date to include events from in ISO format (YYYY-MM-DDTHH:MM:SS).
363
+ end_date (Optional[str]): The maximum date to include events up to in ISO format (YYYY-MM-DDTHH:MM:SS).
364
+
365
+ Returns:
366
+ str: JSON string containing all Google Calendar events or error message
367
+ """
368
+ try:
369
+ params = {
370
+ "calendarId": self.calendar_id,
371
+ "maxResults": min(max_results, 100),
372
+ "singleEvents": True,
373
+ "orderBy": "startTime",
374
+ }
375
+
376
+ # Set time parameters if provided
377
+ if start_date:
378
+ # Accept both string and already formatted ISO strings
379
+ if isinstance(start_date, str):
380
+ try:
381
+ # Try to parse and reformat to ensure proper timezone format
382
+ dt = datetime.datetime.fromisoformat(start_date)
383
+ if dt.tzinfo is None:
384
+ dt = dt.replace(tzinfo=datetime.timezone.utc)
385
+ params["timeMin"] = dt.isoformat()
386
+ except ValueError:
387
+ # If it's already a valid ISO string, use it directly
388
+ params["timeMin"] = start_date
389
+ else:
390
+ params["timeMin"] = start_date
391
+
392
+ if end_date:
393
+ # Accept both string and already formatted ISO strings
394
+ if isinstance(end_date, str):
395
+ try:
396
+ # Try to parse and reformat to ensure proper timezone format
397
+ dt = datetime.datetime.fromisoformat(end_date)
398
+ if dt.tzinfo is None:
399
+ dt = dt.replace(tzinfo=datetime.timezone.utc)
400
+ params["timeMax"] = dt.isoformat()
401
+ except ValueError:
402
+ # If it's already a valid ISO string, use it directly
403
+ params["timeMax"] = end_date
404
+ else:
405
+ params["timeMax"] = end_date
406
+
407
+ # Handle pagination
408
+ all_events = []
409
+ page_token = None
410
+
411
+ while True:
412
+ if page_token:
413
+ params["pageToken"] = page_token
414
+
415
+ events_result = self.service.events().list(**params).execute() # type: ignore
416
+ all_events.extend(events_result.get("items", []))
417
+
418
+ page_token = events_result.get("nextPageToken")
419
+ if not page_token:
420
+ break
421
+
422
+ log_debug(f"Fetched {len(all_events)} events from calendar: {self.calendar_id}")
423
+
424
+ if not all_events:
425
+ return json.dumps({"message": "No events found."})
426
+ return json.dumps(all_events)
427
+ except HttpError as error:
428
+ log_error(f"An error occurred while fetching events: {error}")
429
+ return json.dumps({"error": f"An error occurred: {error}"})
430
+
431
+ @authenticate
432
+ def find_available_slots(
433
+ self,
434
+ start_date: str,
435
+ end_date: str,
436
+ duration_minutes: int = 30,
437
+ ) -> str:
438
+ """
439
+ Find available time slots within a date range.
440
+
441
+ This method fetches your actual calendar events to determine busy periods,
442
+ then finds available slots within standard working hours (9 AM - 5 PM).
443
+
444
+ Args:
445
+ start_date (str): Start date to search from in ISO format (YYYY-MM-DD)
446
+ end_date (str): End date to search to in ISO format (YYYY-MM-DD)
447
+ duration_minutes (int): Length of the desired slot in minutes (default: 30 minutes)
448
+
449
+ Returns:
450
+ str: JSON string containing available Google Calendar time slots or error message
451
+ """
452
+ try:
453
+ start_dt = datetime.datetime.fromisoformat(start_date)
454
+ end_dt = datetime.datetime.fromisoformat(end_date)
455
+ # Ensure dates are timezone-aware (use UTC if no timezone specified)
456
+ if start_dt.tzinfo is None:
457
+ start_dt = start_dt.replace(tzinfo=datetime.timezone.utc)
458
+ if end_dt.tzinfo is None:
459
+ end_dt = end_dt.replace(tzinfo=datetime.timezone.utc)
460
+
461
+ # Get working hours from user settings
462
+ working_hours_json = self._get_working_hours()
463
+ working_hours_data = json.loads(working_hours_json)
464
+
465
+ if "error" not in working_hours_data:
466
+ working_hours_start = working_hours_data["start_hour"]
467
+ working_hours_end = working_hours_data["end_hour"]
468
+ timezone = working_hours_data["timezone"]
469
+ locale = working_hours_data["locale"]
470
+ log_debug(
471
+ f"Using working hours from settings: {working_hours_start}:00-{working_hours_end}:00 ({locale})"
194
472
  )
195
- return json.dumps(event_result)
196
473
  else:
197
- return json.dumps({"error": "authentication issue"})
474
+ # Fallback defaults
475
+ working_hours_start, working_hours_end = 9, 17
476
+ timezone = "UTC"
477
+ locale = "en"
478
+ log_debug("Using default working hours: 9:00-17:00")
479
+
480
+ # Fetch actual calendar events to determine busy periods
481
+ events_json = self.fetch_all_events(start_date=start_date, end_date=end_date)
482
+ events_data = json.loads(events_json)
483
+
484
+ if "error" in events_data:
485
+ return json.dumps({"error": events_data["error"]})
486
+
487
+ events = events_data if isinstance(events_data, list) else events_data.get("items", [])
488
+
489
+ # Extract busy periods from actual calendar events
490
+ busy_periods = []
491
+ for event in events:
492
+ # Skip all-day events and transparent events
493
+ if event.get("transparency") == "transparent":
494
+ continue
495
+
496
+ start_info = event.get("start", {})
497
+ end_info = event.get("end", {})
498
+
499
+ # Only process timed events (not all-day)
500
+ if "dateTime" in start_info and "dateTime" in end_info:
501
+ try:
502
+ start_time = datetime.datetime.fromisoformat(start_info["dateTime"].replace("Z", "+00:00"))
503
+ end_time = datetime.datetime.fromisoformat(end_info["dateTime"].replace("Z", "+00:00"))
504
+ busy_periods.append((start_time, end_time))
505
+ except (ValueError, KeyError) as e:
506
+ log_debug(f"Skipping invalid event: {e}")
507
+ continue
508
+
509
+ # Generate available slots within working hours
510
+ available_slots = []
511
+ current_date = start_dt.replace(hour=working_hours_start, minute=0, second=0, microsecond=0)
512
+ end_search = end_dt.replace(hour=working_hours_end, minute=0, second=0, microsecond=0)
513
+
514
+ while current_date <= end_search:
515
+ # Skip weekends if not in working hours
516
+ if current_date.weekday() >= 5: # Saturday=5, Sunday=6
517
+ current_date = (current_date + datetime.timedelta(days=1)).replace(
518
+ hour=working_hours_start, minute=0, second=0, microsecond=0
519
+ )
520
+ continue
521
+
522
+ slot_end = current_date + datetime.timedelta(minutes=duration_minutes)
523
+
524
+ # Check if this slot conflicts with any busy period
525
+ is_available = True
526
+ for busy_start, busy_end in busy_periods:
527
+ if not (slot_end <= busy_start or current_date >= busy_end):
528
+ is_available = False
529
+ break
530
+
531
+ # Only add slots within working hours
532
+ if is_available and slot_end.hour <= working_hours_end:
533
+ available_slots.append({"start": current_date.isoformat(), "end": slot_end.isoformat()})
534
+
535
+ # Move to next slot (30-minute intervals)
536
+ current_date += datetime.timedelta(minutes=30)
537
+
538
+ # Skip to next day at working hours start if past working hours end
539
+ if current_date.hour >= working_hours_end:
540
+ current_date = (current_date + datetime.timedelta(days=1)).replace(
541
+ hour=working_hours_start, minute=0, second=0, microsecond=0
542
+ )
543
+
544
+ result = {
545
+ "available_slots": available_slots,
546
+ "duration_minutes": duration_minutes,
547
+ "working_hours": {"start": f"{working_hours_start:02d}:00", "end": f"{working_hours_end:02d}:00"},
548
+ "timezone": timezone,
549
+ "locale": locale,
550
+ "events_analyzed": len(busy_periods),
551
+ }
552
+
553
+ log_debug(f"Found {len(available_slots)} available slots")
554
+ return json.dumps(result)
555
+
556
+ except Exception as e:
557
+ log_error(f"An error occurred while finding available slots: {e}")
558
+ return json.dumps({"error": f"An error occurred: {str(e)}"})
559
+
560
+ @authenticate
561
+ def _get_working_hours(self) -> str:
562
+ """
563
+ Get working hours based on user's calendar settings and locale.
564
+
565
+ Returns:
566
+ str: JSON string containing working hours information
567
+ """
568
+ try:
569
+ # Get all user settings
570
+ settings_result = self.service.settings().list().execute() # type: ignore
571
+ settings = settings_result.get("items", [])
572
+
573
+ # Process settings into a more usable format
574
+ user_prefs = {}
575
+ for setting in settings:
576
+ user_prefs[setting["id"]] = setting["value"]
577
+
578
+ # Extract relevant settings
579
+ timezone = user_prefs.get("timezone", "UTC")
580
+ locale = user_prefs.get("locale", "en")
581
+ week_start = int(user_prefs.get("weekStart", "0")) # 0=Sunday, 1=Monday, 6=Saturday
582
+ hide_weekends = user_prefs.get("hideWeekends", "false") == "true"
583
+
584
+ # Determine working hours based on locale/culture
585
+ if locale.startswith(("es", "it", "pt")): # Spain, Italy, Portugal
586
+ start_hour, end_hour = 9, 18
587
+ elif locale.startswith(("de", "nl", "dk", "se", "no")): # Northern Europe
588
+ start_hour, end_hour = 8, 17
589
+ elif locale.startswith(("ja", "ko")): # East Asia
590
+ start_hour, end_hour = 9, 18
591
+ else: # Default US/International
592
+ start_hour, end_hour = 9, 17
593
+
594
+ working_hours = {
595
+ "start_hour": start_hour,
596
+ "end_hour": end_hour,
597
+ "start_time": f"{start_hour:02d}:00",
598
+ "end_time": f"{end_hour:02d}:00",
599
+ "timezone": timezone,
600
+ "locale": locale,
601
+ "week_start": week_start,
602
+ "hide_weekends": hide_weekends,
603
+ }
604
+
605
+ log_debug(f"Working hours for locale {locale}: {start_hour}:00-{end_hour}:00")
606
+ return json.dumps(working_hours)
607
+
608
+ except HttpError as error:
609
+ log_error(f"An error occurred while getting working hours: {error}")
610
+ return json.dumps({"error": f"An error occurred: {error}"})
611
+
612
+ @authenticate
613
+ def list_calendars(self) -> str:
614
+ """
615
+ List all available Google Calendars for the authenticated user.
616
+
617
+ Returns:
618
+ str: JSON string containing available calendars with their IDs and names
619
+ """
620
+ try:
621
+ calendar_list = self.service.calendarList().list().execute() # type: ignore
622
+ calendars = calendar_list.get("items", [])
623
+
624
+ all_calendars = []
625
+ for calendar in calendars:
626
+ calendar_info = {
627
+ "id": calendar.get("id"),
628
+ "name": calendar.get("summary", "Unnamed Calendar"),
629
+ "description": calendar.get("description", ""),
630
+ "primary": calendar.get("primary", False),
631
+ "access_role": calendar.get("accessRole", "unknown"),
632
+ "color": calendar.get("backgroundColor", "#ffffff"),
633
+ }
634
+ all_calendars.append(calendar_info)
635
+
636
+ log_debug(f"Found {len(all_calendars)} calendars for user")
637
+ return json.dumps(
638
+ {
639
+ "calendars": all_calendars,
640
+ "current_default": self.calendar_id,
641
+ }
642
+ )
643
+
198
644
  except HttpError as error:
199
- logger.error(f"An error occurred: {error}")
645
+ log_error(f"An error occurred while listing calendars: {error}")
200
646
  return json.dumps({"error": f"An error occurred: {error}"})