w
This commit is contained in:
+148
-121
@@ -5,13 +5,13 @@ using GetUserAvailability and GetItem APIs.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timedelta, date
|
||||
from collections import Counter
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from mcp.server.fastmcp import Context
|
||||
|
||||
from owa_mcp.server import mcp, AppContext
|
||||
from owa_mcp.owa_client import OWAClient
|
||||
from owa_mcp.server import AppContext, mcp
|
||||
from owa_mcp.server_lifecycle import require_client
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ def _get_client(ctx: Context) -> OWAClient:
|
||||
# Internal: resolve names to emails
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]:
|
||||
"""Resolve a name/email to (display_name, email). Returns ('','') on failure."""
|
||||
resolutions = client.resolve_names(name)
|
||||
@@ -38,6 +39,7 @@ def _resolve_to_email(client: OWAClient, name: str) -> tuple[str, str]:
|
||||
# Internal: query GetUserAvailability in chunks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_availability_events(
|
||||
client: OWAClient,
|
||||
emails: list[str],
|
||||
@@ -54,77 +56,84 @@ def _get_availability_events(
|
||||
results: dict[str, list[dict]] = {email: [] for email in emails}
|
||||
|
||||
# Batch people
|
||||
email_batches = [emails[i:i+batch_size] for i in range(0, len(emails), batch_size)]
|
||||
email_batches = [
|
||||
emails[i : i + batch_size] for i in range(0, len(emails), batch_size)
|
||||
]
|
||||
|
||||
for batch in email_batches:
|
||||
current = start
|
||||
while current < end:
|
||||
chunk_end = min(current + timedelta(days=chunk_days), end)
|
||||
|
||||
mailbox_data = [{
|
||||
'__type': 'MailboxData:#Exchange',
|
||||
'Email': {
|
||||
'__type': 'EmailAddress:#Exchange',
|
||||
'Address': email,
|
||||
},
|
||||
'AttendeeType': 'Required',
|
||||
} for email in batch]
|
||||
mailbox_data = [
|
||||
{
|
||||
"__type": "MailboxData:#Exchange",
|
||||
"Email": {
|
||||
"__type": "EmailAddress:#Exchange",
|
||||
"Address": email,
|
||||
},
|
||||
"AttendeeType": "Required",
|
||||
}
|
||||
for email in batch
|
||||
]
|
||||
|
||||
payload = {
|
||||
'__type': 'GetUserAvailabilityJsonRequest:#Exchange',
|
||||
'Header': {
|
||||
'__type': 'JsonRequestHeaders:#Exchange',
|
||||
'RequestServerVersion': 'Exchange2013',
|
||||
'TimeZoneContext': {
|
||||
'__type': 'TimeZoneContext:#Exchange',
|
||||
'TimeZoneDefinition': {
|
||||
'__type': 'TimeZoneDefinitionType:#Exchange',
|
||||
'Id': 'Russian Standard Time',
|
||||
"__type": "GetUserAvailabilityJsonRequest:#Exchange",
|
||||
"Header": {
|
||||
"__type": "JsonRequestHeaders:#Exchange",
|
||||
"RequestServerVersion": "Exchange2013",
|
||||
"TimeZoneContext": {
|
||||
"__type": "TimeZoneContext:#Exchange",
|
||||
"TimeZoneDefinition": {
|
||||
"__type": "TimeZoneDefinitionType:#Exchange",
|
||||
"Id": "Russian Standard Time",
|
||||
},
|
||||
},
|
||||
},
|
||||
'Body': {
|
||||
'__type': 'GetUserAvailabilityRequest:#Exchange',
|
||||
'MailboxDataArray': mailbox_data,
|
||||
'FreeBusyViewOptions': {
|
||||
'__type': 'FreeBusyViewOptions:#Exchange',
|
||||
'TimeWindow': {
|
||||
'__type': 'Duration:#Exchange',
|
||||
'StartTime': f'{current}T00:00:00',
|
||||
'EndTime': f'{chunk_end}T00:00:00',
|
||||
"Body": {
|
||||
"__type": "GetUserAvailabilityRequest:#Exchange",
|
||||
"MailboxDataArray": mailbox_data,
|
||||
"FreeBusyViewOptions": {
|
||||
"__type": "FreeBusyViewOptions:#Exchange",
|
||||
"TimeWindow": {
|
||||
"__type": "Duration:#Exchange",
|
||||
"StartTime": f"{current}T00:00:00",
|
||||
"EndTime": f"{chunk_end}T00:00:00",
|
||||
},
|
||||
'MergedFreeBusyIntervalInMinutes': 30,
|
||||
'RequestedView': 'DetailedMerged',
|
||||
"MergedFreeBusyIntervalInMinutes": 30,
|
||||
"RequestedView": "DetailedMerged",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
data = client.request('GetUserAvailability', payload)
|
||||
body = data.get('Body', {})
|
||||
for i, fb_resp in enumerate(body.get('FreeBusyResponseArray', [])):
|
||||
data = client.request("GetUserAvailability", payload)
|
||||
body = data.get("Body", {})
|
||||
for i, fb_resp in enumerate(body.get("FreeBusyResponseArray", [])):
|
||||
if i >= len(batch):
|
||||
break
|
||||
email = batch[i]
|
||||
fb_view = fb_resp.get('FreeBusyView', {})
|
||||
cal_events = fb_view.get('CalendarEventArray', {})
|
||||
fb_view = fb_resp.get("FreeBusyView", {})
|
||||
cal_events = fb_view.get("CalendarEventArray", {})
|
||||
if isinstance(cal_events, dict):
|
||||
items = cal_events.get('Items', [])
|
||||
items = cal_events.get("Items", [])
|
||||
elif isinstance(cal_events, list):
|
||||
items = cal_events
|
||||
else:
|
||||
items = []
|
||||
for event in items:
|
||||
s = event.get('StartTime', '')
|
||||
bt = event.get('BusyType', '')
|
||||
if s and bt != 'Free':
|
||||
details = event.get('CalendarEventDetails', {})
|
||||
subject = details.get('Subject', '') if details else ''
|
||||
results[email].append({
|
||||
'subject': subject,
|
||||
'start_date': s[:10],
|
||||
'busy_type': bt,
|
||||
})
|
||||
s = event.get("StartTime", "")
|
||||
bt = event.get("BusyType", "")
|
||||
if s and bt != "Free":
|
||||
details = event.get("CalendarEventDetails", {})
|
||||
subject = details.get("Subject", "") if details else ""
|
||||
results[email].append(
|
||||
{
|
||||
"subject": subject,
|
||||
"start_date": s[:10],
|
||||
"busy_type": bt,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -137,6 +146,7 @@ def _get_availability_events(
|
||||
# Tool: get_meeting_stats
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_meeting_stats(
|
||||
people: str,
|
||||
@@ -161,13 +171,13 @@ def get_meeting_stats(
|
||||
client = _get_client(ctx)
|
||||
|
||||
try:
|
||||
sd = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
ed = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
sd = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
ed = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": f"Invalid date format: {e}"})
|
||||
|
||||
# Resolve names
|
||||
name_list = [n.strip() for n in people.split(',') if n.strip()]
|
||||
name_list = [n.strip() for n in people.split(",") if n.strip()]
|
||||
if not name_list:
|
||||
return json.dumps({"error": "No people specified."})
|
||||
|
||||
@@ -199,43 +209,51 @@ def get_meeting_stats(
|
||||
stats = []
|
||||
for display, email in resolved:
|
||||
if not email or email not in avail:
|
||||
stats.append({
|
||||
"name": display,
|
||||
"email": email or "not_found",
|
||||
"total_meetings": 0,
|
||||
"meetings_per_workday": 0,
|
||||
"days_with_meetings": 0,
|
||||
"workdays": workdays,
|
||||
})
|
||||
stats.append(
|
||||
{
|
||||
"name": display,
|
||||
"email": email or "not_found",
|
||||
"total_meetings": 0,
|
||||
"meetings_per_workday": 0,
|
||||
"days_with_meetings": 0,
|
||||
"workdays": workdays,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
events = avail[email]
|
||||
total = len(events)
|
||||
unique_days = len(set(ev['start_date'] for ev in events))
|
||||
unique_days = len(set(ev["start_date"] for ev in events))
|
||||
avg = round(total / workdays, 1)
|
||||
|
||||
stats.append({
|
||||
"name": display,
|
||||
"email": email,
|
||||
"total_meetings": total,
|
||||
"meetings_per_workday": avg,
|
||||
"days_with_meetings": unique_days,
|
||||
"workdays": workdays,
|
||||
})
|
||||
stats.append(
|
||||
{
|
||||
"name": display,
|
||||
"email": email,
|
||||
"total_meetings": total,
|
||||
"meetings_per_workday": avg,
|
||||
"days_with_meetings": unique_days,
|
||||
"workdays": workdays,
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by total descending
|
||||
stats.sort(key=lambda x: x["total_meetings"], reverse=True)
|
||||
|
||||
return json.dumps({
|
||||
"period": {"start": start_date, "end": end_date, "workdays": workdays},
|
||||
"stats": stats,
|
||||
}, ensure_ascii=False)
|
||||
return json.dumps(
|
||||
{
|
||||
"period": {"start": start_date, "end": end_date, "workdays": workdays},
|
||||
"stats": stats,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool: get_meeting_contacts
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_meeting_contacts(
|
||||
start_date: str,
|
||||
@@ -261,8 +279,8 @@ def get_meeting_contacts(
|
||||
client = _get_client(ctx)
|
||||
|
||||
try:
|
||||
sd = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
ed = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
sd = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
ed = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": f"Invalid date format: {e}"})
|
||||
|
||||
@@ -270,7 +288,9 @@ def get_meeting_contacts(
|
||||
# to count subject occurrences (handles recurring meetings)
|
||||
own_email = client.user_email.lower() if client.user_email else ""
|
||||
if not own_email:
|
||||
return json.dumps({"error": "User email not available. Call the login tool first."})
|
||||
return json.dumps(
|
||||
{"error": "User email not available. Call the login tool first."}
|
||||
)
|
||||
|
||||
folder_id = client.get_folder_id("calendar")
|
||||
if not folder_id:
|
||||
@@ -280,7 +300,7 @@ def get_meeting_contacts(
|
||||
avail_result = _get_availability_events(client, [client.user_email], sd, ed)
|
||||
expanded_events = avail_result.get(client.user_email, [])
|
||||
|
||||
subject_counts = Counter(ev['subject'] for ev in expanded_events)
|
||||
subject_counts = Counter(ev["subject"] for ev in expanded_events)
|
||||
total_expanded = len(expanded_events)
|
||||
|
||||
# Step 2: Find master calendar items for each unique subject
|
||||
@@ -290,51 +310,51 @@ def get_meeting_contacts(
|
||||
|
||||
while len(subject_to_id) < len(subject_counts):
|
||||
payload = {
|
||||
'__type': 'FindItemJsonRequest:#Exchange',
|
||||
'Header': {
|
||||
'__type': 'JsonRequestHeaders:#Exchange',
|
||||
'RequestServerVersion': 'Exchange2013',
|
||||
"__type": "FindItemJsonRequest:#Exchange",
|
||||
"Header": {
|
||||
"__type": "JsonRequestHeaders:#Exchange",
|
||||
"RequestServerVersion": "Exchange2013",
|
||||
},
|
||||
'Body': {
|
||||
'__type': 'FindItemRequest:#Exchange',
|
||||
'ItemShape': {
|
||||
'__type': 'ItemResponseShape:#Exchange',
|
||||
'BaseShape': 'Default',
|
||||
"Body": {
|
||||
"__type": "FindItemRequest:#Exchange",
|
||||
"ItemShape": {
|
||||
"__type": "ItemResponseShape:#Exchange",
|
||||
"BaseShape": "Default",
|
||||
},
|
||||
'ParentFolderIds': [
|
||||
{'__type': 'FolderId:#Exchange', 'Id': folder_id}
|
||||
"ParentFolderIds": [{"__type": "FolderId:#Exchange", "Id": folder_id}],
|
||||
"Traversal": "Shallow",
|
||||
"Paging": {
|
||||
"__type": "IndexedPageView:#Exchange",
|
||||
"BasePoint": "Beginning",
|
||||
"Offset": offset,
|
||||
"MaxEntriesReturned": 200,
|
||||
},
|
||||
"SortOrder": [
|
||||
{
|
||||
"__type": "SortResults:#Exchange",
|
||||
"Order": "Descending",
|
||||
"Path": {
|
||||
"__type": "PropertyUri:#Exchange",
|
||||
"FieldURI": "Start",
|
||||
},
|
||||
}
|
||||
],
|
||||
'Traversal': 'Shallow',
|
||||
'Paging': {
|
||||
'__type': 'IndexedPageView:#Exchange',
|
||||
'BasePoint': 'Beginning',
|
||||
'Offset': offset,
|
||||
'MaxEntriesReturned': 200,
|
||||
},
|
||||
'SortOrder': [{
|
||||
'__type': 'SortResults:#Exchange',
|
||||
'Order': 'Descending',
|
||||
'Path': {
|
||||
'__type': 'PropertyUri:#Exchange',
|
||||
'FieldURI': 'Start',
|
||||
},
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
data = client.request('FindItem', payload)
|
||||
data = client.request("FindItem", payload)
|
||||
items = client.extract_items(data)
|
||||
if not items:
|
||||
break
|
||||
folder_items = items[0].get('RootFolder', {}).get('Items', [])
|
||||
is_last = items[0].get('RootFolder', {}).get('IncludesLastItemInRange', True)
|
||||
folder_items = items[0].get("RootFolder", {}).get("Items", [])
|
||||
is_last = items[0].get("RootFolder", {}).get("IncludesLastItemInRange", True)
|
||||
if not folder_items:
|
||||
break
|
||||
|
||||
for item in folder_items:
|
||||
subj = item.get('Subject', '')
|
||||
subj = item.get("Subject", "")
|
||||
if subj and subj in subject_counts and subj not in subject_to_id:
|
||||
subject_to_id[subj] = item.get('ItemId', {}).get('Id', '')
|
||||
subject_to_id[subj] = item.get("ItemId", {}).get("Id", "")
|
||||
|
||||
if is_last:
|
||||
break
|
||||
@@ -347,7 +367,7 @@ def get_meeting_contacts(
|
||||
id_to_attendees: dict[str, set] = {}
|
||||
|
||||
for bi in range(0, len(unique_ids), 10):
|
||||
batch = unique_ids[bi:bi+10]
|
||||
batch = unique_ids[bi : bi + 10]
|
||||
item_id_list = [{"__type": "ItemId:#Exchange", "Id": iid} for iid in batch]
|
||||
|
||||
payload = {
|
||||
@@ -375,8 +395,10 @@ def get_meeting_contacts(
|
||||
item_id = item.get("ItemId", {}).get("Id", "")
|
||||
attendees = set()
|
||||
|
||||
for att_list in [item.get("RequiredAttendees", []) or [],
|
||||
item.get("OptionalAttendees", []) or []]:
|
||||
for att_list in [
|
||||
item.get("RequiredAttendees", []) or [],
|
||||
item.get("OptionalAttendees", []) or [],
|
||||
]:
|
||||
for a in att_list:
|
||||
mailbox = a.get("Mailbox", {})
|
||||
name = mailbox.get("Name", "")
|
||||
@@ -408,15 +430,20 @@ def get_meeting_contacts(
|
||||
|
||||
result_contacts = []
|
||||
for (name, email), count in contacts.most_common(top_n):
|
||||
result_contacts.append({
|
||||
"name": name,
|
||||
"email": email,
|
||||
"meetings": count,
|
||||
})
|
||||
result_contacts.append(
|
||||
{
|
||||
"name": name,
|
||||
"email": email,
|
||||
"meetings": count,
|
||||
}
|
||||
)
|
||||
|
||||
return json.dumps({
|
||||
"period": {"start": start_date, "end": end_date},
|
||||
"total_meetings": total_expanded,
|
||||
"unique_contacts": len(contacts),
|
||||
"contacts": result_contacts,
|
||||
}, ensure_ascii=False)
|
||||
return json.dumps(
|
||||
{
|
||||
"period": {"start": start_date, "end": end_date},
|
||||
"total_meetings": total_expanded,
|
||||
"unique_contacts": len(contacts),
|
||||
"contacts": result_contacts,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user