This commit is contained in:
2026-07-06 02:05:16 +03:00
parent 40dea9a2c2
commit 4207b38b16
14 changed files with 823 additions and 607 deletions
+148 -121
View File
@@ -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,
)