169 lines
7.4 KiB
Python
169 lines
7.4 KiB
Python
|
|
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)
|