from __future__ import annotations import logging import httpx logger = logging.getLogger(__name__) GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0" GRAPH_TOKEN_URL = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" GRAPH_DEFAULT_SCOPE = "https://graph.microsoft.com/.default" class MSTeamsGraphClient: def __init__(self, tenant_id: str, app_id: str, app_password: str): self._tenant_id = tenant_id self._app_id = app_id self._app_password = app_password self._token: str | None = None self._token_expires_at: float = 0.0 async def _get_token(self) -> str: import time if self._token and time.monotonic() < self._token_expires_at - 60: return self._token token_url = GRAPH_TOKEN_URL.format(tenant_id=self._tenant_id) async with httpx.AsyncClient(timeout=15.0) as client: resp = await client.post( token_url, data={ "grant_type": "client_credentials", "client_id": self._app_id, "client_secret": self._app_password, "scope": GRAPH_DEFAULT_SCOPE, }, ) resp.raise_for_status() data = resp.json() self._token = data["access_token"] self._token_expires_at = time.monotonic() + data.get("expires_in", 3600) return self._token async def fetch_json(self, path: str, *, method: str = "GET", body: dict | None = None, max_retries: int = 3) -> dict: import asyncio token = await self._get_token() url = f"{GRAPH_BASE_URL}{path}" headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", } for attempt in range(max_retries + 1): async with httpx.AsyncClient(timeout=30.0) as client: if method == "GET": resp = await client.get(url, headers=headers) elif method == "POST": resp = await client.post(url, json=body, headers=headers) elif method == "PATCH": resp = await client.patch(url, json=body, headers=headers) elif method == "DELETE": resp = await client.delete(url, headers=headers) elif method == "PUT": resp = await client.put(url, content=body or b"", headers=headers) else: raise ValueError(f"Unsupported HTTP method: {method}") if resp.status_code == 429 and attempt < max_retries: retry_after = int(resp.headers.get("Retry-After", 5)) logger.warning("Graph API 429 throttled, retrying after %ds (attempt %d/%d)", retry_after, attempt + 1, max_retries) await asyncio.sleep(retry_after) continue resp.raise_for_status() if resp.status_code == 204: return {} return resp.json() return {} async def is_accessible(self) -> bool: try: result = await self.fetch_json("/me") return bool(result) except Exception as e: logger.debug("Graph API accessibility check failed: %s", e) return False async def send_activity_notification(self, user_id: str, topic: dict, activity_type: str = "systemDefault", preview_text: str = "", template_parameters: list[dict] | None = None) -> dict: body = { "topic": topic, "activityType": activity_type, "previewText": {"content": preview_text}, "templateParameters": template_parameters or [], } return await self.fetch_json(f"/users/{user_id}/teamwork/sendActivityNotification", method="POST", body=body) async def create_chat(self, chat_type: str, members: list[str], topic: str = "") -> dict: body = { "chatType": chat_type, "members": [ {"@odata.type": "#microsoft.graph.aadUserConversationMember", "roles": ["owner"], "user@odata.bind": f"https://graph.microsoft.com/v1.0/users('{m}')"} for m in members ], } if topic: body["topic"] = topic return await self.fetch_json("/chats", method="POST", body=body) async def send_channel_message(self, team_id: str, channel_id: str, message: dict) -> dict: return await self.fetch_json(f"/teams/{team_id}/channels/{channel_id}/messages", method="POST", body=message) async def create_subscription(self, change_type: str, resource: str, notification_url: str, expiration_hours: int = 72) -> dict: import datetime import secrets expiration = (datetime.datetime.utcnow() + datetime.timedelta(hours=expiration_hours)).isoformat() + "Z" body = { "changeType": change_type, "notificationUrl": notification_url, "resource": resource, "expirationDateTime": expiration, "clientState": secrets.token_hex(16), } return await self.fetch_json("/subscriptions", method="POST", body=body) async def list_subscriptions(self) -> dict: return await self.fetch_json("/subscriptions") async def delete_subscription(self, subscription_id: str) -> None: await self.fetch_json(f"/subscriptions/{subscription_id}", method="DELETE") async def get_presence(self, user_id: str) -> dict: return await self.fetch_json(f"/users/{user_id}/presence") async def list_team_tags(self, team_id: str) -> dict: return await self.fetch_json(f"/teams/{team_id}/tags") async def create_team_tag(self, team_id: str, display_name: str, members: list[str]) -> dict: body = {"displayName": display_name, "members": [{"userId": uid} for uid in members]} return await self.fetch_json(f"/teams/{team_id}/tags", method="POST", body=body) async def archive_team(self, team_id: str, should_set_spo_site_read_only: bool = False) -> None: body = {"shouldSetSpoSiteReadOnlyForMembers": should_set_spo_site_read_only} await self.fetch_json(f"/teams/{team_id}/archive", method="POST", body=body) async def unarchive_team(self, team_id: str) -> None: await self.fetch_json(f"/teams/{team_id}/unarchive", method="POST", body={}) async def create_team(self, display_name: str, description: str = "", members: list[str] | None = None) -> dict: body = { "displayName": display_name, "description": description, "template@odata.bind": "https://graph.microsoft.com/v1.0/teamsTemplates('standard')", } if members: body["members"] = [ {"@odata.type": "#microsoft.graph.aadUserConversationMember", "roles": ["owner"], "user@odata.bind": f"https://graph.microsoft.com/v1.0/users('{m}')"} for m in members ] return await self.fetch_json("/teams", method="POST", body=body) async def delete_channel(self, team_id: str, channel_id: str) -> None: await self.fetch_json(f"/teams/{team_id}/channels/{channel_id}", method="DELETE") async def install_app_for_user(self, user_id: str, teams_app_id: str) -> dict: body = {"teamsApp@odata.bind": f"https://graph.microsoft.com/v1.0/appCatalogs/teamsApps/{teams_app_id}"} return await self.fetch_json(f"/users/{user_id}/teamwork/installedApps", method="POST", body=body)