connectonion 0.5.8__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.
- connectonion/__init__.py +78 -0
- connectonion/address.py +320 -0
- connectonion/agent.py +450 -0
- connectonion/announce.py +84 -0
- connectonion/asgi.py +287 -0
- connectonion/auto_debug_exception.py +181 -0
- connectonion/cli/__init__.py +3 -0
- connectonion/cli/browser_agent/__init__.py +5 -0
- connectonion/cli/browser_agent/browser.py +243 -0
- connectonion/cli/browser_agent/prompt.md +107 -0
- connectonion/cli/commands/__init__.py +1 -0
- connectonion/cli/commands/auth_commands.py +527 -0
- connectonion/cli/commands/browser_commands.py +27 -0
- connectonion/cli/commands/create.py +511 -0
- connectonion/cli/commands/deploy_commands.py +220 -0
- connectonion/cli/commands/doctor_commands.py +173 -0
- connectonion/cli/commands/init.py +469 -0
- connectonion/cli/commands/project_cmd_lib.py +828 -0
- connectonion/cli/commands/reset_commands.py +149 -0
- connectonion/cli/commands/status_commands.py +168 -0
- connectonion/cli/docs/co-vibecoding-principles-docs-contexts-all-in-one.md +2010 -0
- connectonion/cli/docs/connectonion.md +1256 -0
- connectonion/cli/docs.md +123 -0
- connectonion/cli/main.py +148 -0
- connectonion/cli/templates/meta-agent/README.md +287 -0
- connectonion/cli/templates/meta-agent/agent.py +196 -0
- connectonion/cli/templates/meta-agent/prompts/answer_prompt.md +9 -0
- connectonion/cli/templates/meta-agent/prompts/docs_retrieve_prompt.md +15 -0
- connectonion/cli/templates/meta-agent/prompts/metagent.md +71 -0
- connectonion/cli/templates/meta-agent/prompts/think_prompt.md +18 -0
- connectonion/cli/templates/minimal/README.md +56 -0
- connectonion/cli/templates/minimal/agent.py +40 -0
- connectonion/cli/templates/playwright/README.md +118 -0
- connectonion/cli/templates/playwright/agent.py +336 -0
- connectonion/cli/templates/playwright/prompt.md +102 -0
- connectonion/cli/templates/playwright/requirements.txt +3 -0
- connectonion/cli/templates/web-research/agent.py +122 -0
- connectonion/connect.py +128 -0
- connectonion/console.py +539 -0
- connectonion/debug_agent/__init__.py +13 -0
- connectonion/debug_agent/agent.py +45 -0
- connectonion/debug_agent/prompts/debug_assistant.md +72 -0
- connectonion/debug_agent/runtime_inspector.py +406 -0
- connectonion/debug_explainer/__init__.py +10 -0
- connectonion/debug_explainer/explain_agent.py +114 -0
- connectonion/debug_explainer/explain_context.py +263 -0
- connectonion/debug_explainer/explainer_prompt.md +29 -0
- connectonion/debug_explainer/root_cause_analysis_prompt.md +43 -0
- connectonion/debugger_ui.py +1039 -0
- connectonion/decorators.py +208 -0
- connectonion/events.py +248 -0
- connectonion/execution_analyzer/__init__.py +9 -0
- connectonion/execution_analyzer/execution_analysis.py +93 -0
- connectonion/execution_analyzer/execution_analysis_prompt.md +47 -0
- connectonion/host.py +579 -0
- connectonion/interactive_debugger.py +342 -0
- connectonion/llm.py +801 -0
- connectonion/llm_do.py +307 -0
- connectonion/logger.py +300 -0
- connectonion/prompt_files/__init__.py +1 -0
- connectonion/prompt_files/analyze_contact.md +62 -0
- connectonion/prompt_files/eval_expected.md +12 -0
- connectonion/prompt_files/react_evaluate.md +11 -0
- connectonion/prompt_files/react_plan.md +16 -0
- connectonion/prompt_files/reflect.md +22 -0
- connectonion/prompts.py +144 -0
- connectonion/relay.py +200 -0
- connectonion/static/docs.html +688 -0
- connectonion/tool_executor.py +279 -0
- connectonion/tool_factory.py +186 -0
- connectonion/tool_registry.py +105 -0
- connectonion/trust.py +166 -0
- connectonion/trust_agents.py +71 -0
- connectonion/trust_functions.py +88 -0
- connectonion/tui/__init__.py +57 -0
- connectonion/tui/divider.py +39 -0
- connectonion/tui/dropdown.py +251 -0
- connectonion/tui/footer.py +31 -0
- connectonion/tui/fuzzy.py +56 -0
- connectonion/tui/input.py +278 -0
- connectonion/tui/keys.py +35 -0
- connectonion/tui/pick.py +130 -0
- connectonion/tui/providers.py +155 -0
- connectonion/tui/status_bar.py +163 -0
- connectonion/usage.py +161 -0
- connectonion/useful_events_handlers/__init__.py +16 -0
- connectonion/useful_events_handlers/reflect.py +116 -0
- connectonion/useful_plugins/__init__.py +20 -0
- connectonion/useful_plugins/calendar_plugin.py +163 -0
- connectonion/useful_plugins/eval.py +139 -0
- connectonion/useful_plugins/gmail_plugin.py +162 -0
- connectonion/useful_plugins/image_result_formatter.py +127 -0
- connectonion/useful_plugins/re_act.py +78 -0
- connectonion/useful_plugins/shell_approval.py +159 -0
- connectonion/useful_tools/__init__.py +44 -0
- connectonion/useful_tools/diff_writer.py +192 -0
- connectonion/useful_tools/get_emails.py +183 -0
- connectonion/useful_tools/gmail.py +1596 -0
- connectonion/useful_tools/google_calendar.py +613 -0
- connectonion/useful_tools/memory.py +380 -0
- connectonion/useful_tools/microsoft_calendar.py +604 -0
- connectonion/useful_tools/outlook.py +488 -0
- connectonion/useful_tools/send_email.py +205 -0
- connectonion/useful_tools/shell.py +97 -0
- connectonion/useful_tools/slash_command.py +201 -0
- connectonion/useful_tools/terminal.py +285 -0
- connectonion/useful_tools/todo_list.py +241 -0
- connectonion/useful_tools/web_fetch.py +216 -0
- connectonion/xray.py +467 -0
- connectonion-0.5.8.dist-info/METADATA +741 -0
- connectonion-0.5.8.dist-info/RECORD +113 -0
- connectonion-0.5.8.dist-info/WHEEL +4 -0
- connectonion-0.5.8.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Google Calendar integration tool for managing events and meetings via Google API
|
|
3
|
+
LLM-Note:
|
|
4
|
+
Dependencies: imports from [os, datetime, google.oauth2.credentials, googleapiclient.discovery] | imported by [useful_tools/__init__.py] | requires OAuth tokens from 'co auth google' | tested by [tests/unit/test_google_calendar.py]
|
|
5
|
+
Data flow: Agent calls GoogleCalendar methods → _get_credentials() loads tokens from env → builds Calendar API service → API calls to Calendar REST endpoints → returns formatted results (event lists, confirmations, free slots)
|
|
6
|
+
State/Effects: reads GOOGLE_* env vars for OAuth tokens | makes HTTP calls to Google Calendar API | can create/update/delete events | no local file persistence
|
|
7
|
+
Integration: exposes GoogleCalendar class with list_events(), get_today_events(), get_event(), create_event(), update_event(), delete_event(), create_meet(), get_upcoming_meetings(), find_free_slots() | used as agent tool via Agent(tools=[GoogleCalendar()])
|
|
8
|
+
Performance: network I/O per API call | batch fetching for list operations | date parsing for queries
|
|
9
|
+
Errors: raises ValueError if OAuth not configured | Google API errors propagate | returns error strings for display
|
|
10
|
+
|
|
11
|
+
Google Calendar tool for managing calendar events and meetings.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
from connectonion import Agent, GoogleCalendar
|
|
15
|
+
|
|
16
|
+
calendar = GoogleCalendar()
|
|
17
|
+
agent = Agent("assistant", tools=[calendar])
|
|
18
|
+
|
|
19
|
+
# Agent can now use:
|
|
20
|
+
# - list_events(days_ahead, max_results)
|
|
21
|
+
# - get_today_events()
|
|
22
|
+
# - get_event(event_id)
|
|
23
|
+
# - create_event(title, start_time, end_time, description, attendees, location)
|
|
24
|
+
# - update_event(event_id, title, start_time, end_time, description, attendees, location)
|
|
25
|
+
# - delete_event(event_id)
|
|
26
|
+
# - create_meet(title, start_time, end_time, attendees, description)
|
|
27
|
+
# - get_upcoming_meetings(days_ahead)
|
|
28
|
+
# - find_free_slots(date, duration_minutes)
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
from connectonion import Agent, GoogleCalendar
|
|
32
|
+
|
|
33
|
+
calendar = GoogleCalendar()
|
|
34
|
+
agent = Agent(
|
|
35
|
+
name="calendar-assistant",
|
|
36
|
+
system_prompt="You are a calendar assistant.",
|
|
37
|
+
tools=[calendar]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
agent.input("What meetings do I have today?")
|
|
41
|
+
agent.input("Schedule a meeting with aaron@openonion.ai tomorrow at 2pm")
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
import os
|
|
45
|
+
from datetime import datetime, timedelta
|
|
46
|
+
from google.oauth2.credentials import Credentials
|
|
47
|
+
from googleapiclient.discovery import build
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GoogleCalendar:
|
|
51
|
+
"""Google Calendar tool for managing events and meetings."""
|
|
52
|
+
|
|
53
|
+
def __init__(self):
|
|
54
|
+
"""Initialize Google Calendar tool.
|
|
55
|
+
|
|
56
|
+
Validates that calendar scope is authorized.
|
|
57
|
+
Raises ValueError if scope is missing.
|
|
58
|
+
"""
|
|
59
|
+
scopes = os.getenv("GOOGLE_SCOPES", "")
|
|
60
|
+
if "calendar" not in scopes:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"Missing 'calendar' scope.\n"
|
|
63
|
+
f"Current scopes: {scopes}\n"
|
|
64
|
+
"Please authorize Google Calendar access:\n"
|
|
65
|
+
" co auth google"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
self._service = None
|
|
69
|
+
|
|
70
|
+
def _get_service(self):
|
|
71
|
+
"""Get Google Calendar API service (lazy load with auto-refresh)."""
|
|
72
|
+
access_token = os.getenv("GOOGLE_ACCESS_TOKEN")
|
|
73
|
+
refresh_token = os.getenv("GOOGLE_REFRESH_TOKEN")
|
|
74
|
+
expires_at_str = os.getenv("GOOGLE_TOKEN_EXPIRES_AT")
|
|
75
|
+
|
|
76
|
+
if not access_token or not refresh_token:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"Google OAuth credentials not found.\n"
|
|
79
|
+
"Run: co auth google"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Check if token is expired or about to expire (within 5 minutes)
|
|
83
|
+
# Always check before returning cached service
|
|
84
|
+
if expires_at_str:
|
|
85
|
+
from datetime import datetime, timedelta
|
|
86
|
+
expires_at = datetime.fromisoformat(expires_at_str.replace('Z', '+00:00'))
|
|
87
|
+
now = datetime.utcnow().replace(tzinfo=expires_at.tzinfo) if expires_at.tzinfo else datetime.utcnow()
|
|
88
|
+
|
|
89
|
+
if now >= expires_at - timedelta(minutes=5):
|
|
90
|
+
# Token expired or about to expire, refresh via backend
|
|
91
|
+
access_token = self._refresh_via_backend(refresh_token)
|
|
92
|
+
# Clear cached service to use new token
|
|
93
|
+
self._service = None
|
|
94
|
+
|
|
95
|
+
# Return cached service if available
|
|
96
|
+
if self._service:
|
|
97
|
+
return self._service
|
|
98
|
+
|
|
99
|
+
# Create credentials
|
|
100
|
+
creds = Credentials(
|
|
101
|
+
token=access_token,
|
|
102
|
+
refresh_token=refresh_token,
|
|
103
|
+
token_uri="https://oauth2.googleapis.com/token",
|
|
104
|
+
client_id=None,
|
|
105
|
+
client_secret=None,
|
|
106
|
+
scopes=["https://www.googleapis.com/auth/calendar"]
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
self._service = build('calendar', 'v3', credentials=creds)
|
|
110
|
+
return self._service
|
|
111
|
+
|
|
112
|
+
def _refresh_via_backend(self, refresh_token: str) -> str:
|
|
113
|
+
"""Refresh access token via backend API.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
refresh_token: The refresh token
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
New access token
|
|
120
|
+
"""
|
|
121
|
+
import httpx
|
|
122
|
+
|
|
123
|
+
# Get backend URL and auth
|
|
124
|
+
backend_url = os.getenv("OPENONION_API_URL", "https://oo.openonion.ai")
|
|
125
|
+
api_key = os.getenv("OPENONION_API_KEY")
|
|
126
|
+
|
|
127
|
+
if not api_key:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
"OPENONION_API_KEY not found.\n"
|
|
130
|
+
"This is needed to refresh tokens via backend."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Call backend refresh endpoint
|
|
134
|
+
response = httpx.post(
|
|
135
|
+
f"{backend_url}/api/v1/oauth/google/refresh",
|
|
136
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
137
|
+
json={"refresh_token": refresh_token}
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if response.status_code != 200:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"Failed to refresh token via backend: {response.text}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
data = response.json()
|
|
146
|
+
new_access_token = data["access_token"]
|
|
147
|
+
expires_at = data["expires_at"]
|
|
148
|
+
|
|
149
|
+
# Update environment variables for this session
|
|
150
|
+
os.environ["GOOGLE_ACCESS_TOKEN"] = new_access_token
|
|
151
|
+
os.environ["GOOGLE_TOKEN_EXPIRES_AT"] = expires_at
|
|
152
|
+
|
|
153
|
+
# Update .env file if it exists
|
|
154
|
+
env_file = os.path.join(os.getenv("AGENT_CONFIG_PATH", os.path.expanduser("~/.co")), "keys.env")
|
|
155
|
+
if os.path.exists(env_file):
|
|
156
|
+
with open(env_file, 'r') as f:
|
|
157
|
+
lines = f.readlines()
|
|
158
|
+
|
|
159
|
+
with open(env_file, 'w') as f:
|
|
160
|
+
for line in lines:
|
|
161
|
+
if line.startswith("GOOGLE_ACCESS_TOKEN="):
|
|
162
|
+
f.write(f"GOOGLE_ACCESS_TOKEN={new_access_token}\n")
|
|
163
|
+
elif line.startswith("GOOGLE_TOKEN_EXPIRES_AT="):
|
|
164
|
+
f.write(f"GOOGLE_TOKEN_EXPIRES_AT={expires_at}\n")
|
|
165
|
+
else:
|
|
166
|
+
f.write(line)
|
|
167
|
+
|
|
168
|
+
return new_access_token
|
|
169
|
+
|
|
170
|
+
def _format_datetime(self, dt_str: str) -> str:
|
|
171
|
+
"""Format datetime string to readable format."""
|
|
172
|
+
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
|
|
173
|
+
return dt.strftime('%Y-%m-%d %I:%M %p')
|
|
174
|
+
|
|
175
|
+
# === Reading Events ===
|
|
176
|
+
|
|
177
|
+
def list_events(self, days_ahead: int = 7, max_results: int = 20) -> str:
|
|
178
|
+
"""List upcoming calendar events.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
days_ahead: Number of days to look ahead (default: 7)
|
|
182
|
+
max_results: Maximum number of events to return (default: 20)
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Formatted string with event list
|
|
186
|
+
"""
|
|
187
|
+
service = self._get_service()
|
|
188
|
+
|
|
189
|
+
now = datetime.utcnow().isoformat() + 'Z'
|
|
190
|
+
end = (datetime.utcnow() + timedelta(days=days_ahead)).isoformat() + 'Z'
|
|
191
|
+
|
|
192
|
+
events_result = service.events().list(
|
|
193
|
+
calendarId='primary',
|
|
194
|
+
timeMin=now,
|
|
195
|
+
timeMax=end,
|
|
196
|
+
maxResults=max_results,
|
|
197
|
+
singleEvents=True,
|
|
198
|
+
orderBy='startTime'
|
|
199
|
+
).execute()
|
|
200
|
+
|
|
201
|
+
events = events_result.get('items', [])
|
|
202
|
+
|
|
203
|
+
if not events:
|
|
204
|
+
return f"No upcoming events in the next {days_ahead} days."
|
|
205
|
+
|
|
206
|
+
output = [f"Upcoming events (next {days_ahead} days):\n"]
|
|
207
|
+
for event in events:
|
|
208
|
+
start = event['start'].get('dateTime', event['start'].get('date'))
|
|
209
|
+
summary = event.get('summary', 'No title')
|
|
210
|
+
event_id = event['id']
|
|
211
|
+
|
|
212
|
+
# Get attendees if any
|
|
213
|
+
attendees = event.get('attendees', [])
|
|
214
|
+
attendee_str = ""
|
|
215
|
+
if attendees:
|
|
216
|
+
attendee_emails = [a.get('email', '') for a in attendees if a.get('email')]
|
|
217
|
+
if attendee_emails:
|
|
218
|
+
attendee_str = f"\n Attendees: {', '.join(attendee_emails)}"
|
|
219
|
+
|
|
220
|
+
# Get meet link if any
|
|
221
|
+
meet_link = event.get('hangoutLink', '')
|
|
222
|
+
meet_str = f"\n Meet: {meet_link}" if meet_link else ""
|
|
223
|
+
|
|
224
|
+
output.append(f"- {self._format_datetime(start)}: {summary}")
|
|
225
|
+
output.append(f" ID: {event_id}{attendee_str}{meet_str}\n")
|
|
226
|
+
|
|
227
|
+
return "\n".join(output)
|
|
228
|
+
|
|
229
|
+
def get_today_events(self) -> str:
|
|
230
|
+
"""Get today's calendar events.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Formatted string with today's events
|
|
234
|
+
"""
|
|
235
|
+
service = self._get_service()
|
|
236
|
+
|
|
237
|
+
# Get start and end of today
|
|
238
|
+
now = datetime.utcnow()
|
|
239
|
+
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat() + 'Z'
|
|
240
|
+
end_of_day = now.replace(hour=23, minute=59, second=59, microsecond=999999).isoformat() + 'Z'
|
|
241
|
+
|
|
242
|
+
events_result = service.events().list(
|
|
243
|
+
calendarId='primary',
|
|
244
|
+
timeMin=start_of_day,
|
|
245
|
+
timeMax=end_of_day,
|
|
246
|
+
singleEvents=True,
|
|
247
|
+
orderBy='startTime'
|
|
248
|
+
).execute()
|
|
249
|
+
|
|
250
|
+
events = events_result.get('items', [])
|
|
251
|
+
|
|
252
|
+
if not events:
|
|
253
|
+
return "No events scheduled for today."
|
|
254
|
+
|
|
255
|
+
output = ["Today's events:\n"]
|
|
256
|
+
for event in events:
|
|
257
|
+
start = event['start'].get('dateTime', event['start'].get('date'))
|
|
258
|
+
summary = event.get('summary', 'No title')
|
|
259
|
+
|
|
260
|
+
# Get meet link if any
|
|
261
|
+
meet_link = event.get('hangoutLink', '')
|
|
262
|
+
meet_str = f" [Meet: {meet_link}]" if meet_link else ""
|
|
263
|
+
|
|
264
|
+
output.append(f"- {self._format_datetime(start)}: {summary}{meet_str}")
|
|
265
|
+
|
|
266
|
+
return "\n".join(output)
|
|
267
|
+
|
|
268
|
+
def get_event(self, event_id: str) -> str:
|
|
269
|
+
"""Get detailed information about a specific event.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
event_id: Calendar event ID
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Formatted event details
|
|
276
|
+
"""
|
|
277
|
+
service = self._get_service()
|
|
278
|
+
|
|
279
|
+
event = service.events().get(
|
|
280
|
+
calendarId='primary',
|
|
281
|
+
eventId=event_id
|
|
282
|
+
).execute()
|
|
283
|
+
|
|
284
|
+
summary = event.get('summary', 'No title')
|
|
285
|
+
start = event['start'].get('dateTime', event['start'].get('date'))
|
|
286
|
+
end = event['end'].get('dateTime', event['end'].get('date'))
|
|
287
|
+
description = event.get('description', 'No description')
|
|
288
|
+
location = event.get('location', 'No location')
|
|
289
|
+
|
|
290
|
+
attendees = event.get('attendees', [])
|
|
291
|
+
attendee_list = []
|
|
292
|
+
for a in attendees:
|
|
293
|
+
email = a.get('email', '')
|
|
294
|
+
status = a.get('responseStatus', 'needsAction')
|
|
295
|
+
attendee_list.append(f"{email} ({status})")
|
|
296
|
+
|
|
297
|
+
meet_link = event.get('hangoutLink', 'No Meet link')
|
|
298
|
+
|
|
299
|
+
output = [
|
|
300
|
+
f"Event: {summary}",
|
|
301
|
+
f"Start: {self._format_datetime(start)}",
|
|
302
|
+
f"End: {self._format_datetime(end)}",
|
|
303
|
+
f"Description: {description}",
|
|
304
|
+
f"Location: {location}",
|
|
305
|
+
f"Meet: {meet_link}",
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
if attendee_list:
|
|
309
|
+
output.append(f"Attendees:\n " + "\n ".join(attendee_list))
|
|
310
|
+
|
|
311
|
+
return "\n".join(output)
|
|
312
|
+
|
|
313
|
+
# === Creating Events ===
|
|
314
|
+
|
|
315
|
+
def create_event(self, title: str, start_time: str, end_time: str,
|
|
316
|
+
description: str = None, attendees: str = None,
|
|
317
|
+
location: str = None) -> str:
|
|
318
|
+
"""Create a new calendar event.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
title: Event title
|
|
322
|
+
start_time: Start time (ISO format or natural like "2024-01-15 14:00")
|
|
323
|
+
end_time: End time (ISO format or natural like "2024-01-15 15:00")
|
|
324
|
+
description: Optional event description
|
|
325
|
+
attendees: Optional comma-separated email addresses
|
|
326
|
+
location: Optional location
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
Confirmation with event ID and details
|
|
330
|
+
"""
|
|
331
|
+
service = self._get_service()
|
|
332
|
+
|
|
333
|
+
# Parse times
|
|
334
|
+
start_dt = self._parse_time(start_time)
|
|
335
|
+
end_dt = self._parse_time(end_time)
|
|
336
|
+
|
|
337
|
+
event = {
|
|
338
|
+
'summary': title,
|
|
339
|
+
'start': {
|
|
340
|
+
'dateTime': start_dt.isoformat(),
|
|
341
|
+
'timeZone': 'UTC',
|
|
342
|
+
},
|
|
343
|
+
'end': {
|
|
344
|
+
'dateTime': end_dt.isoformat(),
|
|
345
|
+
'timeZone': 'UTC',
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if description:
|
|
350
|
+
event['description'] = description
|
|
351
|
+
|
|
352
|
+
if location:
|
|
353
|
+
event['location'] = location
|
|
354
|
+
|
|
355
|
+
if attendees:
|
|
356
|
+
attendee_list = [{'email': email.strip()} for email in attendees.split(',')]
|
|
357
|
+
event['attendees'] = attendee_list
|
|
358
|
+
|
|
359
|
+
created_event = service.events().insert(
|
|
360
|
+
calendarId='primary',
|
|
361
|
+
body=event
|
|
362
|
+
).execute()
|
|
363
|
+
|
|
364
|
+
return f"Event created: {title}\nStart: {self._format_datetime(start_dt.isoformat())}\nEvent ID: {created_event['id']}\nLink: {created_event.get('htmlLink', '')}"
|
|
365
|
+
|
|
366
|
+
def create_meet(self, title: str, start_time: str, end_time: str,
|
|
367
|
+
attendees: str, description: str = None) -> str:
|
|
368
|
+
"""Create a Google Meet meeting.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
title: Meeting title
|
|
372
|
+
start_time: Start time (ISO format or natural)
|
|
373
|
+
end_time: End time (ISO format or natural)
|
|
374
|
+
attendees: Comma-separated email addresses
|
|
375
|
+
description: Optional meeting description
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
Confirmation with Meet link
|
|
379
|
+
"""
|
|
380
|
+
service = self._get_service()
|
|
381
|
+
|
|
382
|
+
# Parse times
|
|
383
|
+
start_dt = self._parse_time(start_time)
|
|
384
|
+
end_dt = self._parse_time(end_time)
|
|
385
|
+
|
|
386
|
+
attendee_list = [{'email': email.strip()} for email in attendees.split(',')]
|
|
387
|
+
|
|
388
|
+
event = {
|
|
389
|
+
'summary': title,
|
|
390
|
+
'start': {
|
|
391
|
+
'dateTime': start_dt.isoformat(),
|
|
392
|
+
'timeZone': 'UTC',
|
|
393
|
+
},
|
|
394
|
+
'end': {
|
|
395
|
+
'dateTime': end_dt.isoformat(),
|
|
396
|
+
'timeZone': 'UTC',
|
|
397
|
+
},
|
|
398
|
+
'attendees': attendee_list,
|
|
399
|
+
'conferenceData': {
|
|
400
|
+
'createRequest': {
|
|
401
|
+
'requestId': f"meet-{datetime.utcnow().timestamp()}",
|
|
402
|
+
'conferenceSolutionKey': {'type': 'hangoutsMeet'}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if description:
|
|
408
|
+
event['description'] = description
|
|
409
|
+
|
|
410
|
+
created_event = service.events().insert(
|
|
411
|
+
calendarId='primary',
|
|
412
|
+
body=event,
|
|
413
|
+
conferenceDataVersion=1
|
|
414
|
+
).execute()
|
|
415
|
+
|
|
416
|
+
meet_link = created_event.get('hangoutLink', 'No Meet link generated')
|
|
417
|
+
|
|
418
|
+
return f"Meeting created: {title}\nStart: {self._format_datetime(start_dt.isoformat())}\nMeet link: {meet_link}\nEvent ID: {created_event['id']}"
|
|
419
|
+
|
|
420
|
+
def update_event(self, event_id: str, title: str = None, start_time: str = None,
|
|
421
|
+
end_time: str = None, description: str = None,
|
|
422
|
+
attendees: str = None, location: str = None) -> str:
|
|
423
|
+
"""Update an existing calendar event.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
event_id: Calendar event ID
|
|
427
|
+
title: Optional new title
|
|
428
|
+
start_time: Optional new start time
|
|
429
|
+
end_time: Optional new end time
|
|
430
|
+
description: Optional new description
|
|
431
|
+
attendees: Optional new comma-separated attendees
|
|
432
|
+
location: Optional new location
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
Confirmation message
|
|
436
|
+
"""
|
|
437
|
+
service = self._get_service()
|
|
438
|
+
|
|
439
|
+
# Get existing event
|
|
440
|
+
event = service.events().get(
|
|
441
|
+
calendarId='primary',
|
|
442
|
+
eventId=event_id
|
|
443
|
+
).execute()
|
|
444
|
+
|
|
445
|
+
# Update fields
|
|
446
|
+
if title:
|
|
447
|
+
event['summary'] = title
|
|
448
|
+
if description:
|
|
449
|
+
event['description'] = description
|
|
450
|
+
if location:
|
|
451
|
+
event['location'] = location
|
|
452
|
+
if start_time:
|
|
453
|
+
start_dt = self._parse_time(start_time)
|
|
454
|
+
event['start'] = {
|
|
455
|
+
'dateTime': start_dt.isoformat(),
|
|
456
|
+
'timeZone': 'UTC',
|
|
457
|
+
}
|
|
458
|
+
if end_time:
|
|
459
|
+
end_dt = self._parse_time(end_time)
|
|
460
|
+
event['end'] = {
|
|
461
|
+
'dateTime': end_dt.isoformat(),
|
|
462
|
+
'timeZone': 'UTC',
|
|
463
|
+
}
|
|
464
|
+
if attendees:
|
|
465
|
+
attendee_list = [{'email': email.strip()} for email in attendees.split(',')]
|
|
466
|
+
event['attendees'] = attendee_list
|
|
467
|
+
|
|
468
|
+
updated_event = service.events().update(
|
|
469
|
+
calendarId='primary',
|
|
470
|
+
eventId=event_id,
|
|
471
|
+
body=event
|
|
472
|
+
).execute()
|
|
473
|
+
|
|
474
|
+
return f"Event updated: {updated_event['summary']}\nEvent ID: {event_id}"
|
|
475
|
+
|
|
476
|
+
def delete_event(self, event_id: str) -> str:
|
|
477
|
+
"""Delete a calendar event.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
event_id: Calendar event ID
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
Confirmation message
|
|
484
|
+
"""
|
|
485
|
+
service = self._get_service()
|
|
486
|
+
|
|
487
|
+
service.events().delete(
|
|
488
|
+
calendarId='primary',
|
|
489
|
+
eventId=event_id
|
|
490
|
+
).execute()
|
|
491
|
+
|
|
492
|
+
return f"Event deleted: {event_id}"
|
|
493
|
+
|
|
494
|
+
# === Meeting Management ===
|
|
495
|
+
|
|
496
|
+
def get_upcoming_meetings(self, days_ahead: int = 7) -> str:
|
|
497
|
+
"""Get upcoming meetings (events with attendees).
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
days_ahead: Number of days to look ahead (default: 7)
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
Formatted list of upcoming meetings
|
|
504
|
+
"""
|
|
505
|
+
service = self._get_service()
|
|
506
|
+
|
|
507
|
+
now = datetime.utcnow().isoformat() + 'Z'
|
|
508
|
+
end = (datetime.utcnow() + timedelta(days=days_ahead)).isoformat() + 'Z'
|
|
509
|
+
|
|
510
|
+
events_result = service.events().list(
|
|
511
|
+
calendarId='primary',
|
|
512
|
+
timeMin=now,
|
|
513
|
+
timeMax=end,
|
|
514
|
+
singleEvents=True,
|
|
515
|
+
orderBy='startTime'
|
|
516
|
+
).execute()
|
|
517
|
+
|
|
518
|
+
events = events_result.get('items', [])
|
|
519
|
+
|
|
520
|
+
# Filter only events with attendees (meetings)
|
|
521
|
+
meetings = [e for e in events if e.get('attendees')]
|
|
522
|
+
|
|
523
|
+
if not meetings:
|
|
524
|
+
return f"No upcoming meetings in the next {days_ahead} days."
|
|
525
|
+
|
|
526
|
+
output = [f"Upcoming meetings (next {days_ahead} days):\n"]
|
|
527
|
+
for meeting in meetings:
|
|
528
|
+
start = meeting['start'].get('dateTime', meeting['start'].get('date'))
|
|
529
|
+
summary = meeting.get('summary', 'No title')
|
|
530
|
+
attendees = meeting.get('attendees', [])
|
|
531
|
+
attendee_emails = [a.get('email', '') for a in attendees if a.get('email')]
|
|
532
|
+
meet_link = meeting.get('hangoutLink', '')
|
|
533
|
+
|
|
534
|
+
output.append(f"- {self._format_datetime(start)}: {summary}")
|
|
535
|
+
output.append(f" Attendees: {', '.join(attendee_emails)}")
|
|
536
|
+
if meet_link:
|
|
537
|
+
output.append(f" Meet: {meet_link}")
|
|
538
|
+
output.append("")
|
|
539
|
+
|
|
540
|
+
return "\n".join(output)
|
|
541
|
+
|
|
542
|
+
def find_free_slots(self, date: str, duration_minutes: int = 60) -> str:
|
|
543
|
+
"""Find free time slots on a specific date.
|
|
544
|
+
|
|
545
|
+
Args:
|
|
546
|
+
date: Date to check (YYYY-MM-DD format)
|
|
547
|
+
duration_minutes: Desired meeting duration (default: 60)
|
|
548
|
+
|
|
549
|
+
Returns:
|
|
550
|
+
List of available time slots
|
|
551
|
+
"""
|
|
552
|
+
service = self._get_service()
|
|
553
|
+
|
|
554
|
+
# Parse date
|
|
555
|
+
target_date = datetime.strptime(date, '%Y-%m-%d')
|
|
556
|
+
start_of_day = target_date.replace(hour=9, minute=0, second=0).isoformat() + 'Z'
|
|
557
|
+
end_of_day = target_date.replace(hour=17, minute=0, second=0).isoformat() + 'Z'
|
|
558
|
+
|
|
559
|
+
# Get events for the day
|
|
560
|
+
events_result = service.events().list(
|
|
561
|
+
calendarId='primary',
|
|
562
|
+
timeMin=start_of_day,
|
|
563
|
+
timeMax=end_of_day,
|
|
564
|
+
singleEvents=True,
|
|
565
|
+
orderBy='startTime'
|
|
566
|
+
).execute()
|
|
567
|
+
|
|
568
|
+
events = events_result.get('items', [])
|
|
569
|
+
|
|
570
|
+
# Find gaps
|
|
571
|
+
free_slots = []
|
|
572
|
+
current_time = target_date.replace(hour=9, minute=0)
|
|
573
|
+
end_time = target_date.replace(hour=17, minute=0)
|
|
574
|
+
|
|
575
|
+
for event in events:
|
|
576
|
+
event_start = datetime.fromisoformat(event['start'].get('dateTime', '').replace('Z', '+00:00'))
|
|
577
|
+
event_end = datetime.fromisoformat(event['end'].get('dateTime', '').replace('Z', '+00:00'))
|
|
578
|
+
|
|
579
|
+
# Check if there's a gap before this event
|
|
580
|
+
if (event_start - current_time).total_seconds() >= duration_minutes * 60:
|
|
581
|
+
free_slots.append(f"{current_time.strftime('%I:%M %p')} - {event_start.strftime('%I:%M %p')}")
|
|
582
|
+
|
|
583
|
+
current_time = max(current_time, event_end)
|
|
584
|
+
|
|
585
|
+
# Check gap at end of day
|
|
586
|
+
if (end_time - current_time).total_seconds() >= duration_minutes * 60:
|
|
587
|
+
free_slots.append(f"{current_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}")
|
|
588
|
+
|
|
589
|
+
if not free_slots:
|
|
590
|
+
return f"No free slots available on {date} for {duration_minutes} minute meetings."
|
|
591
|
+
|
|
592
|
+
return f"Free slots on {date} ({duration_minutes}+ minutes):\n" + "\n".join(f" - {slot}" for slot in free_slots)
|
|
593
|
+
|
|
594
|
+
def _parse_time(self, time_str: str) -> datetime:
|
|
595
|
+
"""Parse time string to datetime object.
|
|
596
|
+
|
|
597
|
+
Supports formats:
|
|
598
|
+
- ISO: 2024-01-15T14:00:00Z, 2024-01-15T14:00:00
|
|
599
|
+
- Simple: 2024-01-15 14:00
|
|
600
|
+
|
|
601
|
+
Args:
|
|
602
|
+
time_str: Time string
|
|
603
|
+
|
|
604
|
+
Returns:
|
|
605
|
+
datetime object
|
|
606
|
+
"""
|
|
607
|
+
for fmt in ['%Y-%m-%dT%H:%M:%SZ', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M']:
|
|
608
|
+
try:
|
|
609
|
+
return datetime.strptime(time_str, fmt)
|
|
610
|
+
except ValueError:
|
|
611
|
+
continue
|
|
612
|
+
|
|
613
|
+
raise ValueError(f"Cannot parse time: {time_str}. Use format: YYYY-MM-DD HH:MM or ISO format")
|