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,604 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Microsoft Calendar integration tool for managing events via Microsoft Graph API
|
|
3
|
+
LLM-Note:
|
|
4
|
+
Dependencies: imports from [os, datetime, httpx] | imported by [useful_tools/__init__.py] | requires OAuth tokens from 'co auth microsoft' | tested by [tests/unit/test_microsoft_calendar.py]
|
|
5
|
+
Data flow: Agent calls MicrosoftCalendar methods → _get_headers() loads MICROSOFT_ACCESS_TOKEN from env → HTTP calls to Graph API (https://graph.microsoft.com/v1.0) → returns formatted results (event lists, confirmations, free slots)
|
|
6
|
+
State/Effects: reads MICROSOFT_* env vars for OAuth tokens | makes HTTP calls to Microsoft Graph API | can create/update/delete events, create Teams meetings | no local file persistence
|
|
7
|
+
Integration: exposes MicrosoftCalendar class with list_events(), get_today_events(), get_event(), create_event(), update_event(), delete_event(), create_teams_meeting(), get_upcoming_meetings(), find_free_slots(), check_availability() | used as agent tool via Agent(tools=[MicrosoftCalendar()])
|
|
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 | HTTP errors from Graph API propagate | returns error strings for display
|
|
10
|
+
|
|
11
|
+
Microsoft Calendar tool for managing calendar events via Microsoft Graph API.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
from connectonion import Agent, MicrosoftCalendar
|
|
15
|
+
|
|
16
|
+
calendar = MicrosoftCalendar()
|
|
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_teams_meeting(title, start_time, end_time, attendees, description)
|
|
27
|
+
# - get_upcoming_meetings(days_ahead)
|
|
28
|
+
# - find_free_slots(date, duration_minutes)
|
|
29
|
+
# - check_availability(datetime_str)
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
from connectonion import Agent, MicrosoftCalendar
|
|
33
|
+
|
|
34
|
+
calendar = MicrosoftCalendar()
|
|
35
|
+
agent = Agent(
|
|
36
|
+
name="calendar-assistant",
|
|
37
|
+
system_prompt="You are a calendar assistant.",
|
|
38
|
+
tools=[calendar]
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
agent.input("What meetings do I have today?")
|
|
42
|
+
agent.input("Schedule a meeting with alice@example.com tomorrow at 2pm")
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
import os
|
|
46
|
+
from datetime import datetime, timedelta
|
|
47
|
+
import httpx
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MicrosoftCalendar:
|
|
51
|
+
"""Microsoft Calendar tool for managing events via Microsoft Graph API."""
|
|
52
|
+
|
|
53
|
+
GRAPH_API_URL = "https://graph.microsoft.com/v1.0"
|
|
54
|
+
|
|
55
|
+
def __init__(self):
|
|
56
|
+
"""Initialize Microsoft Calendar tool.
|
|
57
|
+
|
|
58
|
+
Validates that Microsoft OAuth is configured with Calendar scopes.
|
|
59
|
+
Raises ValueError if credentials are missing.
|
|
60
|
+
"""
|
|
61
|
+
scopes = os.getenv("MICROSOFT_SCOPES", "")
|
|
62
|
+
if not scopes or "Calendars" not in scopes:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
"Missing Microsoft Calendar scopes.\n"
|
|
65
|
+
f"Current scopes: {scopes}\n"
|
|
66
|
+
"Please authorize Microsoft Calendar access:\n"
|
|
67
|
+
" co auth microsoft"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
self._access_token = None
|
|
71
|
+
|
|
72
|
+
def _get_access_token(self) -> str:
|
|
73
|
+
"""Get Microsoft access token (with auto-refresh)."""
|
|
74
|
+
access_token = os.getenv("MICROSOFT_ACCESS_TOKEN")
|
|
75
|
+
refresh_token = os.getenv("MICROSOFT_REFRESH_TOKEN")
|
|
76
|
+
expires_at_str = os.getenv("MICROSOFT_TOKEN_EXPIRES_AT")
|
|
77
|
+
|
|
78
|
+
if not access_token or not refresh_token:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
"Microsoft OAuth credentials not found.\n"
|
|
81
|
+
"Run: co auth microsoft"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Check if token is expired or about to expire (within 5 minutes)
|
|
85
|
+
if expires_at_str:
|
|
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
|
+
access_token = self._refresh_via_backend(refresh_token)
|
|
91
|
+
self._access_token = None
|
|
92
|
+
|
|
93
|
+
if self._access_token:
|
|
94
|
+
return self._access_token
|
|
95
|
+
|
|
96
|
+
self._access_token = access_token
|
|
97
|
+
return self._access_token
|
|
98
|
+
|
|
99
|
+
def _refresh_via_backend(self, refresh_token: str) -> str:
|
|
100
|
+
"""Refresh access token via backend API."""
|
|
101
|
+
backend_url = os.getenv("OPENONION_API_URL", "https://oo.openonion.ai")
|
|
102
|
+
api_key = os.getenv("OPENONION_API_KEY")
|
|
103
|
+
|
|
104
|
+
if not api_key:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
"OPENONION_API_KEY not found.\n"
|
|
107
|
+
"This is needed to refresh tokens via backend."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
response = httpx.post(
|
|
111
|
+
f"{backend_url}/api/v1/oauth/microsoft/refresh",
|
|
112
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
113
|
+
json={"refresh_token": refresh_token}
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if response.status_code != 200:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"Failed to refresh Microsoft token via backend: {response.text}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
data = response.json()
|
|
122
|
+
new_access_token = data["access_token"]
|
|
123
|
+
expires_at = data["expires_at"]
|
|
124
|
+
|
|
125
|
+
os.environ["MICROSOFT_ACCESS_TOKEN"] = new_access_token
|
|
126
|
+
os.environ["MICROSOFT_TOKEN_EXPIRES_AT"] = expires_at
|
|
127
|
+
|
|
128
|
+
env_file = os.path.join(os.getenv("AGENT_CONFIG_PATH", os.path.expanduser("~/.co")), "keys.env")
|
|
129
|
+
if os.path.exists(env_file):
|
|
130
|
+
with open(env_file, 'r') as f:
|
|
131
|
+
lines = f.readlines()
|
|
132
|
+
|
|
133
|
+
with open(env_file, 'w') as f:
|
|
134
|
+
for line in lines:
|
|
135
|
+
if line.startswith("MICROSOFT_ACCESS_TOKEN="):
|
|
136
|
+
f.write(f"MICROSOFT_ACCESS_TOKEN={new_access_token}\n")
|
|
137
|
+
elif line.startswith("MICROSOFT_TOKEN_EXPIRES_AT="):
|
|
138
|
+
f.write(f"MICROSOFT_TOKEN_EXPIRES_AT={expires_at}\n")
|
|
139
|
+
else:
|
|
140
|
+
f.write(line)
|
|
141
|
+
|
|
142
|
+
return new_access_token
|
|
143
|
+
|
|
144
|
+
def _request(self, method: str, endpoint: str, **kwargs) -> dict:
|
|
145
|
+
"""Make authenticated request to Microsoft Graph API."""
|
|
146
|
+
token = self._get_access_token()
|
|
147
|
+
headers = {
|
|
148
|
+
"Authorization": f"Bearer {token}",
|
|
149
|
+
"Content-Type": "application/json"
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
url = f"{self.GRAPH_API_URL}{endpoint}"
|
|
153
|
+
response = httpx.request(method, url, headers=headers, **kwargs)
|
|
154
|
+
|
|
155
|
+
if response.status_code == 401:
|
|
156
|
+
refresh_token = os.getenv("MICROSOFT_REFRESH_TOKEN")
|
|
157
|
+
if refresh_token:
|
|
158
|
+
self._access_token = None
|
|
159
|
+
token = self._refresh_via_backend(refresh_token)
|
|
160
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
161
|
+
response = httpx.request(method, url, headers=headers, **kwargs)
|
|
162
|
+
|
|
163
|
+
if response.status_code not in [200, 201, 202, 204]:
|
|
164
|
+
raise ValueError(f"Microsoft Graph API error: {response.status_code} - {response.text}")
|
|
165
|
+
|
|
166
|
+
if response.status_code == 204:
|
|
167
|
+
return {}
|
|
168
|
+
return response.json()
|
|
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
|
+
def _parse_time(self, time_str: str) -> datetime:
|
|
176
|
+
"""Parse time string to datetime object."""
|
|
177
|
+
for fmt in ['%Y-%m-%dT%H:%M:%SZ', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M']:
|
|
178
|
+
try:
|
|
179
|
+
return datetime.strptime(time_str, fmt)
|
|
180
|
+
except ValueError:
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
raise ValueError(f"Cannot parse time: {time_str}. Use format: YYYY-MM-DD HH:MM or ISO format")
|
|
184
|
+
|
|
185
|
+
# === Reading Events ===
|
|
186
|
+
|
|
187
|
+
def list_events(self, days_ahead: int = 7, max_results: int = 20) -> str:
|
|
188
|
+
"""List upcoming calendar events.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
days_ahead: Number of days to look ahead (default: 7)
|
|
192
|
+
max_results: Maximum number of events to return (default: 20)
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Formatted string with event list
|
|
196
|
+
"""
|
|
197
|
+
now = datetime.utcnow()
|
|
198
|
+
end = now + timedelta(days=days_ahead)
|
|
199
|
+
|
|
200
|
+
endpoint = "/me/calendar/calendarView"
|
|
201
|
+
params = {
|
|
202
|
+
"startDateTime": now.isoformat() + 'Z',
|
|
203
|
+
"endDateTime": end.isoformat() + 'Z',
|
|
204
|
+
"$top": max_results,
|
|
205
|
+
"$orderby": "start/dateTime",
|
|
206
|
+
"$select": "id,subject,start,end,attendees,onlineMeeting,onlineMeetingUrl"
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
result = self._request("GET", endpoint, params=params)
|
|
210
|
+
events = result.get('value', [])
|
|
211
|
+
|
|
212
|
+
if not events:
|
|
213
|
+
return f"No upcoming events in the next {days_ahead} days."
|
|
214
|
+
|
|
215
|
+
output = [f"Upcoming events (next {days_ahead} days):\n"]
|
|
216
|
+
for event in events:
|
|
217
|
+
start = event.get('start', {}).get('dateTime', '')
|
|
218
|
+
subject = event.get('subject', 'No title')
|
|
219
|
+
event_id = event['id']
|
|
220
|
+
|
|
221
|
+
attendees = event.get('attendees', [])
|
|
222
|
+
attendee_str = ""
|
|
223
|
+
if attendees:
|
|
224
|
+
attendee_emails = [a.get('emailAddress', {}).get('address', '') for a in attendees]
|
|
225
|
+
if attendee_emails:
|
|
226
|
+
attendee_str = f"\n Attendees: {', '.join(attendee_emails)}"
|
|
227
|
+
|
|
228
|
+
meeting_url = event.get('onlineMeetingUrl', '')
|
|
229
|
+
meeting_str = f"\n Meeting: {meeting_url}" if meeting_url else ""
|
|
230
|
+
|
|
231
|
+
output.append(f"- {self._format_datetime(start)}: {subject}")
|
|
232
|
+
output.append(f" ID: {event_id}{attendee_str}{meeting_str}\n")
|
|
233
|
+
|
|
234
|
+
return "\n".join(output)
|
|
235
|
+
|
|
236
|
+
def get_today_events(self) -> str:
|
|
237
|
+
"""Get today's calendar events.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Formatted string with today's events
|
|
241
|
+
"""
|
|
242
|
+
now = datetime.utcnow()
|
|
243
|
+
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
244
|
+
end_of_day = now.replace(hour=23, minute=59, second=59, microsecond=999999)
|
|
245
|
+
|
|
246
|
+
endpoint = "/me/calendar/calendarView"
|
|
247
|
+
params = {
|
|
248
|
+
"startDateTime": start_of_day.isoformat() + 'Z',
|
|
249
|
+
"endDateTime": end_of_day.isoformat() + 'Z',
|
|
250
|
+
"$orderby": "start/dateTime",
|
|
251
|
+
"$select": "id,subject,start,end,onlineMeetingUrl"
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
result = self._request("GET", endpoint, params=params)
|
|
255
|
+
events = result.get('value', [])
|
|
256
|
+
|
|
257
|
+
if not events:
|
|
258
|
+
return "No events scheduled for today."
|
|
259
|
+
|
|
260
|
+
output = ["Today's events:\n"]
|
|
261
|
+
for event in events:
|
|
262
|
+
start = event.get('start', {}).get('dateTime', '')
|
|
263
|
+
subject = event.get('subject', 'No title')
|
|
264
|
+
meeting_url = event.get('onlineMeetingUrl', '')
|
|
265
|
+
meeting_str = f" [Meeting: {meeting_url}]" if meeting_url else ""
|
|
266
|
+
|
|
267
|
+
output.append(f"- {self._format_datetime(start)}: {subject}{meeting_str}")
|
|
268
|
+
|
|
269
|
+
return "\n".join(output)
|
|
270
|
+
|
|
271
|
+
def get_event(self, event_id: str) -> str:
|
|
272
|
+
"""Get detailed information about a specific event.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
event_id: Calendar event ID
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
Formatted event details
|
|
279
|
+
"""
|
|
280
|
+
endpoint = f"/me/calendar/events/{event_id}"
|
|
281
|
+
params = {
|
|
282
|
+
"$select": "subject,start,end,body,location,attendees,onlineMeetingUrl"
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
event = self._request("GET", endpoint, params=params)
|
|
286
|
+
|
|
287
|
+
subject = event.get('subject', 'No title')
|
|
288
|
+
start = event.get('start', {}).get('dateTime', '')
|
|
289
|
+
end = event.get('end', {}).get('dateTime', '')
|
|
290
|
+
body = event.get('body', {}).get('content', 'No description')
|
|
291
|
+
location = event.get('location', {}).get('displayName', 'No location')
|
|
292
|
+
|
|
293
|
+
attendees = event.get('attendees', [])
|
|
294
|
+
attendee_list = []
|
|
295
|
+
for a in attendees:
|
|
296
|
+
email = a.get('emailAddress', {}).get('address', '')
|
|
297
|
+
status = a.get('status', {}).get('response', 'none')
|
|
298
|
+
attendee_list.append(f"{email} ({status})")
|
|
299
|
+
|
|
300
|
+
meeting_url = event.get('onlineMeetingUrl', 'No meeting link')
|
|
301
|
+
|
|
302
|
+
output = [
|
|
303
|
+
f"Event: {subject}",
|
|
304
|
+
f"Start: {self._format_datetime(start)}",
|
|
305
|
+
f"End: {self._format_datetime(end)}",
|
|
306
|
+
f"Location: {location}",
|
|
307
|
+
f"Meeting: {meeting_url}",
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
if attendee_list:
|
|
311
|
+
output.append(f"Attendees:\n " + "\n ".join(attendee_list))
|
|
312
|
+
|
|
313
|
+
return "\n".join(output)
|
|
314
|
+
|
|
315
|
+
# === Creating Events ===
|
|
316
|
+
|
|
317
|
+
def create_event(self, title: str, start_time: str, end_time: str,
|
|
318
|
+
description: str = None, attendees: str = None,
|
|
319
|
+
location: str = None) -> str:
|
|
320
|
+
"""Create a new calendar event.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
title: Event title
|
|
324
|
+
start_time: Start time (ISO format or "YYYY-MM-DD HH:MM")
|
|
325
|
+
end_time: End time (ISO format or "YYYY-MM-DD HH:MM")
|
|
326
|
+
description: Optional event description
|
|
327
|
+
attendees: Optional comma-separated email addresses
|
|
328
|
+
location: Optional location
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
Confirmation with event ID and details
|
|
332
|
+
"""
|
|
333
|
+
start_dt = self._parse_time(start_time)
|
|
334
|
+
end_dt = self._parse_time(end_time)
|
|
335
|
+
|
|
336
|
+
event = {
|
|
337
|
+
"subject": title,
|
|
338
|
+
"start": {
|
|
339
|
+
"dateTime": start_dt.isoformat(),
|
|
340
|
+
"timeZone": "UTC"
|
|
341
|
+
},
|
|
342
|
+
"end": {
|
|
343
|
+
"dateTime": end_dt.isoformat(),
|
|
344
|
+
"timeZone": "UTC"
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if description:
|
|
349
|
+
event["body"] = {
|
|
350
|
+
"contentType": "Text",
|
|
351
|
+
"content": description
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if location:
|
|
355
|
+
event["location"] = {"displayName": location}
|
|
356
|
+
|
|
357
|
+
if attendees:
|
|
358
|
+
event["attendees"] = [
|
|
359
|
+
{
|
|
360
|
+
"emailAddress": {"address": email.strip()},
|
|
361
|
+
"type": "required"
|
|
362
|
+
}
|
|
363
|
+
for email in attendees.split(',')
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
created_event = self._request("POST", "/me/calendar/events", json=event)
|
|
367
|
+
|
|
368
|
+
return f"Event created: {title}\nStart: {self._format_datetime(start_dt.isoformat())}\nEvent ID: {created_event['id']}\nLink: {created_event.get('webLink', '')}"
|
|
369
|
+
|
|
370
|
+
def create_teams_meeting(self, title: str, start_time: str, end_time: str,
|
|
371
|
+
attendees: str, description: str = None) -> str:
|
|
372
|
+
"""Create a Microsoft Teams meeting.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
title: Meeting title
|
|
376
|
+
start_time: Start time (ISO format or "YYYY-MM-DD HH:MM")
|
|
377
|
+
end_time: End time (ISO format or "YYYY-MM-DD HH:MM")
|
|
378
|
+
attendees: Comma-separated email addresses
|
|
379
|
+
description: Optional meeting description
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
Confirmation with Teams meeting link
|
|
383
|
+
"""
|
|
384
|
+
start_dt = self._parse_time(start_time)
|
|
385
|
+
end_dt = self._parse_time(end_time)
|
|
386
|
+
|
|
387
|
+
event = {
|
|
388
|
+
"subject": title,
|
|
389
|
+
"start": {
|
|
390
|
+
"dateTime": start_dt.isoformat(),
|
|
391
|
+
"timeZone": "UTC"
|
|
392
|
+
},
|
|
393
|
+
"end": {
|
|
394
|
+
"dateTime": end_dt.isoformat(),
|
|
395
|
+
"timeZone": "UTC"
|
|
396
|
+
},
|
|
397
|
+
"attendees": [
|
|
398
|
+
{
|
|
399
|
+
"emailAddress": {"address": email.strip()},
|
|
400
|
+
"type": "required"
|
|
401
|
+
}
|
|
402
|
+
for email in attendees.split(',')
|
|
403
|
+
],
|
|
404
|
+
"isOnlineMeeting": True,
|
|
405
|
+
"onlineMeetingProvider": "teamsForBusiness"
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if description:
|
|
409
|
+
event["body"] = {
|
|
410
|
+
"contentType": "Text",
|
|
411
|
+
"content": description
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
created_event = self._request("POST", "/me/calendar/events", json=event)
|
|
415
|
+
|
|
416
|
+
meeting_url = created_event.get('onlineMeeting', {}).get('joinUrl', '') or created_event.get('onlineMeetingUrl', 'No meeting link')
|
|
417
|
+
|
|
418
|
+
return f"Teams meeting created: {title}\nStart: {self._format_datetime(start_dt.isoformat())}\nTeams link: {meeting_url}\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
|
+
updates = {}
|
|
438
|
+
|
|
439
|
+
if title:
|
|
440
|
+
updates["subject"] = title
|
|
441
|
+
if description:
|
|
442
|
+
updates["body"] = {"contentType": "Text", "content": description}
|
|
443
|
+
if location:
|
|
444
|
+
updates["location"] = {"displayName": location}
|
|
445
|
+
if start_time:
|
|
446
|
+
start_dt = self._parse_time(start_time)
|
|
447
|
+
updates["start"] = {"dateTime": start_dt.isoformat(), "timeZone": "UTC"}
|
|
448
|
+
if end_time:
|
|
449
|
+
end_dt = self._parse_time(end_time)
|
|
450
|
+
updates["end"] = {"dateTime": end_dt.isoformat(), "timeZone": "UTC"}
|
|
451
|
+
if attendees:
|
|
452
|
+
updates["attendees"] = [
|
|
453
|
+
{"emailAddress": {"address": email.strip()}, "type": "required"}
|
|
454
|
+
for email in attendees.split(',')
|
|
455
|
+
]
|
|
456
|
+
|
|
457
|
+
endpoint = f"/me/calendar/events/{event_id}"
|
|
458
|
+
updated_event = self._request("PATCH", endpoint, json=updates)
|
|
459
|
+
|
|
460
|
+
return f"Event updated: {updated_event.get('subject', 'Unknown')}\nEvent ID: {event_id}"
|
|
461
|
+
|
|
462
|
+
def delete_event(self, event_id: str) -> str:
|
|
463
|
+
"""Delete a calendar event.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
event_id: Calendar event ID
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
Confirmation message
|
|
470
|
+
"""
|
|
471
|
+
endpoint = f"/me/calendar/events/{event_id}"
|
|
472
|
+
self._request("DELETE", endpoint)
|
|
473
|
+
|
|
474
|
+
return f"Event deleted: {event_id}"
|
|
475
|
+
|
|
476
|
+
# === Meeting Management ===
|
|
477
|
+
|
|
478
|
+
def get_upcoming_meetings(self, days_ahead: int = 7) -> str:
|
|
479
|
+
"""Get upcoming meetings (events with attendees).
|
|
480
|
+
|
|
481
|
+
Args:
|
|
482
|
+
days_ahead: Number of days to look ahead (default: 7)
|
|
483
|
+
|
|
484
|
+
Returns:
|
|
485
|
+
Formatted list of upcoming meetings
|
|
486
|
+
"""
|
|
487
|
+
now = datetime.utcnow()
|
|
488
|
+
end = now + timedelta(days=days_ahead)
|
|
489
|
+
|
|
490
|
+
endpoint = "/me/calendar/calendarView"
|
|
491
|
+
params = {
|
|
492
|
+
"startDateTime": now.isoformat() + 'Z',
|
|
493
|
+
"endDateTime": end.isoformat() + 'Z',
|
|
494
|
+
"$orderby": "start/dateTime",
|
|
495
|
+
"$select": "id,subject,start,attendees,onlineMeetingUrl"
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
result = self._request("GET", endpoint, params=params)
|
|
499
|
+
events = result.get('value', [])
|
|
500
|
+
|
|
501
|
+
meetings = [e for e in events if e.get('attendees')]
|
|
502
|
+
|
|
503
|
+
if not meetings:
|
|
504
|
+
return f"No upcoming meetings in the next {days_ahead} days."
|
|
505
|
+
|
|
506
|
+
output = [f"Upcoming meetings (next {days_ahead} days):\n"]
|
|
507
|
+
for meeting in meetings:
|
|
508
|
+
start = meeting.get('start', {}).get('dateTime', '')
|
|
509
|
+
subject = meeting.get('subject', 'No title')
|
|
510
|
+
attendees = meeting.get('attendees', [])
|
|
511
|
+
attendee_emails = [a.get('emailAddress', {}).get('address', '') for a in attendees]
|
|
512
|
+
meeting_url = meeting.get('onlineMeetingUrl', '')
|
|
513
|
+
|
|
514
|
+
output.append(f"- {self._format_datetime(start)}: {subject}")
|
|
515
|
+
output.append(f" Attendees: {', '.join(attendee_emails)}")
|
|
516
|
+
if meeting_url:
|
|
517
|
+
output.append(f" Meeting: {meeting_url}")
|
|
518
|
+
output.append("")
|
|
519
|
+
|
|
520
|
+
return "\n".join(output)
|
|
521
|
+
|
|
522
|
+
def find_free_slots(self, date: str, duration_minutes: int = 60) -> str:
|
|
523
|
+
"""Find free time slots on a specific date.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
date: Date to check (YYYY-MM-DD format)
|
|
527
|
+
duration_minutes: Desired meeting duration (default: 60)
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
List of available time slots
|
|
531
|
+
"""
|
|
532
|
+
target_date = datetime.strptime(date, '%Y-%m-%d')
|
|
533
|
+
start_of_day = target_date.replace(hour=9, minute=0, second=0)
|
|
534
|
+
end_of_day = target_date.replace(hour=17, minute=0, second=0)
|
|
535
|
+
|
|
536
|
+
endpoint = "/me/calendar/calendarView"
|
|
537
|
+
params = {
|
|
538
|
+
"startDateTime": start_of_day.isoformat() + 'Z',
|
|
539
|
+
"endDateTime": end_of_day.isoformat() + 'Z',
|
|
540
|
+
"$orderby": "start/dateTime",
|
|
541
|
+
"$select": "start,end"
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
result = self._request("GET", endpoint, params=params)
|
|
545
|
+
events = result.get('value', [])
|
|
546
|
+
|
|
547
|
+
free_slots = []
|
|
548
|
+
current_time = start_of_day
|
|
549
|
+
|
|
550
|
+
for event in events:
|
|
551
|
+
event_start_str = event.get('start', {}).get('dateTime', '')
|
|
552
|
+
event_end_str = event.get('end', {}).get('dateTime', '')
|
|
553
|
+
|
|
554
|
+
if not event_start_str or not event_end_str:
|
|
555
|
+
continue
|
|
556
|
+
|
|
557
|
+
event_start = datetime.fromisoformat(event_start_str.replace('Z', '+00:00')).replace(tzinfo=None)
|
|
558
|
+
event_end = datetime.fromisoformat(event_end_str.replace('Z', '+00:00')).replace(tzinfo=None)
|
|
559
|
+
|
|
560
|
+
if (event_start - current_time).total_seconds() >= duration_minutes * 60:
|
|
561
|
+
free_slots.append(f"{current_time.strftime('%I:%M %p')} - {event_start.strftime('%I:%M %p')}")
|
|
562
|
+
|
|
563
|
+
current_time = max(current_time, event_end)
|
|
564
|
+
|
|
565
|
+
if (end_of_day - current_time).total_seconds() >= duration_minutes * 60:
|
|
566
|
+
free_slots.append(f"{current_time.strftime('%I:%M %p')} - {end_of_day.strftime('%I:%M %p')}")
|
|
567
|
+
|
|
568
|
+
if not free_slots:
|
|
569
|
+
return f"No free slots available on {date} for {duration_minutes} minute meetings."
|
|
570
|
+
|
|
571
|
+
return f"Free slots on {date} ({duration_minutes}+ minutes):\n" + "\n".join(f" - {slot}" for slot in free_slots)
|
|
572
|
+
|
|
573
|
+
def check_availability(self, datetime_str: str) -> str:
|
|
574
|
+
"""Check if a specific time is free.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
datetime_str: DateTime to check (ISO format or "YYYY-MM-DD HH:MM")
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
Whether the time slot is available
|
|
581
|
+
"""
|
|
582
|
+
target_time = self._parse_time(datetime_str)
|
|
583
|
+
check_end = target_time + timedelta(hours=1)
|
|
584
|
+
|
|
585
|
+
endpoint = "/me/calendar/calendarView"
|
|
586
|
+
params = {
|
|
587
|
+
"startDateTime": target_time.isoformat() + 'Z',
|
|
588
|
+
"endDateTime": check_end.isoformat() + 'Z',
|
|
589
|
+
"$select": "subject,start,end"
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
result = self._request("GET", endpoint, params=params)
|
|
593
|
+
events = result.get('value', [])
|
|
594
|
+
|
|
595
|
+
if not events:
|
|
596
|
+
return f"Time slot {self._format_datetime(target_time.isoformat())} is FREE"
|
|
597
|
+
|
|
598
|
+
conflicts = []
|
|
599
|
+
for event in events:
|
|
600
|
+
subject = event.get('subject', 'Untitled')
|
|
601
|
+
start = event.get('start', {}).get('dateTime', '')
|
|
602
|
+
conflicts.append(f"- {subject} at {self._format_datetime(start)}")
|
|
603
|
+
|
|
604
|
+
return f"Time slot {self._format_datetime(target_time.isoformat())} is BUSY:\n" + "\n".join(conflicts)
|