"""Microsoft Graph API 封装。 提供 Teams Channel / Thread 管理、用户/团队查询、文件上传等操作的统一接口。 """ from __future__ import annotations import asyncio import time from collections import OrderedDict from typing import Any import aiohttp from yuxi.utils.logging_config import logger GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0" GRAPH_UPLOAD_CHUNK_SIZE = 4 * 1024 * 1024 GRAPH_MAX_RETRIES = 3 GRAPH_RETRY_BASE_DELAY_S = 1.0 GRAPH_RETRYABLE_STATUSES = {429, 500, 502, 503, 504} _PARENT_MSG_CACHE_MAX = 100 _PARENT_MSG_CACHE_TTL = 300 _PARENT_MSG_DEDUP_PER_SESSION: dict[str, set[str]] = {} class GraphClient: _parent_msg_cache: OrderedDict[str, tuple[dict[str, Any], float]] = OrderedDict() def __init__(self, token: str): self._token = token self._session: aiohttp.ClientSession | None = None @classmethod def _cache_parent_message(cls, message_id: str, msg: dict[str, Any]) -> None: now = time.monotonic() cls._parent_msg_cache[message_id] = (msg, now) cls._parent_msg_cache.move_to_end(message_id) while len(cls._parent_msg_cache) > _PARENT_MSG_CACHE_MAX: cls._parent_msg_cache.popitem(last=False) cls._cleanup_parent_cache(now) @classmethod def _get_cached_parent_message(cls, message_id: str) -> dict[str, Any] | None: now = time.monotonic() cls._cleanup_parent_cache(now) entry = cls._parent_msg_cache.get(message_id) if entry: msg, ts = entry if now - ts < _PARENT_MSG_CACHE_TTL: return msg cls._parent_msg_cache.pop(message_id, None) return None @classmethod def _cleanup_parent_cache(cls, now: float) -> None: expired = [k for k, (_, ts) in cls._parent_msg_cache.items() if now - ts > _PARENT_MSG_CACHE_TTL] for k in expired: cls._parent_msg_cache.pop(k, None) @classmethod def _is_parent_msg_injected(cls, session_key: str, message_id: str) -> bool: if session_key not in _PARENT_MSG_DEDUP_PER_SESSION: return False return message_id in _PARENT_MSG_DEDUP_PER_SESSION[session_key] @classmethod def _mark_parent_msg_injected(cls, session_key: str, message_id: str) -> None: if session_key not in _PARENT_MSG_DEDUP_PER_SESSION: _PARENT_MSG_DEDUP_PER_SESSION[session_key] = set() _PARENT_MSG_DEDUP_PER_SESSION[session_key].add(message_id) if len(_PARENT_MSG_DEDUP_PER_SESSION[session_key]) > 200: s = _PARENT_MSG_DEDUP_PER_SESSION[session_key] _PARENT_MSG_DEDUP_PER_SESSION[session_key] = set(list(s)[-100:]) @classmethod def _clear_session_dedup(cls, session_key: str) -> None: _PARENT_MSG_DEDUP_PER_SESSION.pop(session_key, None) @property def _headers(self) -> dict[str, str]: return { "Authorization": f"Bearer {self._token}", "Content-Type": "application/json", } async def _ensure_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: self._session = aiohttp.ClientSession() return self._session async def close(self) -> None: if self._session and not self._session.closed: await self._session.close() self._session = None async def _request_with_retry( self, method: str, path: str, params: dict[str, str] | None = None, json_data: dict | None = None, ) -> dict[str, Any]: url = f"{GRAPH_BASE_URL}{path}" last_error: str | None = None for attempt in range(GRAPH_MAX_RETRIES + 1): try: session = await self._ensure_session() async with session.request(method, url, headers=self._headers, params=params, json=json_data) as resp: if resp.status in (200, 201): return await resp.json() body = await resp.text() logger.warning( f"Graph {method} {path} failed (attempt {attempt + 1}): HTTP {resp.status} - {body[:200]}" ) last_error = f"HTTP {resp.status}" if resp.status not in GRAPH_RETRYABLE_STATUSES or attempt >= GRAPH_MAX_RETRIES: return {"error": f"HTTP {resp.status}", "detail": body[:500]} except Exception as e: logger.error(f"Graph {method} {path} error (attempt {attempt + 1}): {e}") last_error = str(e) if attempt >= GRAPH_MAX_RETRIES: return {"error": str(e)} delay = GRAPH_RETRY_BASE_DELAY_S * (2**attempt) await asyncio.sleep(delay) return {"error": last_error or "Max retries exceeded"} async def _get(self, path: str, params: dict[str, str] | None = None) -> dict[str, Any]: return await self._request_with_retry("GET", path, params=params) async def _post(self, path: str, data: dict) -> dict[str, Any]: return await self._request_with_retry("POST", path, json_data=data) async def _list_all(self, path: str, params: dict[str, str] | None = None) -> list[dict[str, Any]]: first_page = await self._get(path, params=params) if "error" in first_page: return [] items = list(first_page.get("value", [])) next_link = first_page.get("@odata.nextLink", "") while next_link: try: session = await self._ensure_session() async with session.get(next_link, headers=self._headers) as resp: if resp.status != 200: logger.warning(f"Graph pagination failed at {next_link}: HTTP {resp.status}") break page = await resp.json() items.extend(page.get("value", [])) next_link = page.get("@odata.nextLink", "") except Exception as e: logger.error(f"Graph pagination error at {next_link}: {e}") break return items async def get_me(self) -> dict[str, Any]: return await self._get("/me") async def get_team(self, team_id: str) -> dict[str, Any]: return await self._get(f"/teams/{team_id}") async def list_channels(self, team_id: str) -> list[dict[str, Any]]: return await self._list_all(f"/teams/{team_id}/channels") async def get_channel(self, team_id: str, channel_id: str) -> dict[str, Any]: return await self._get(f"/teams/{team_id}/channels/{channel_id}") async def list_channel_messages(self, team_id: str, channel_id: str, top: int = 50) -> list[dict[str, Any]]: return await self._list_all( f"/teams/{team_id}/channels/{channel_id}/messages", params={"$top": str(top)}, ) async def list_message_replies(self, team_id: str, channel_id: str, message_id: str) -> list[dict[str, Any]]: return await self._list_all(f"/teams/{team_id}/channels/{channel_id}/messages/{message_id}/replies") async def get_user(self, user_id: str) -> dict[str, Any]: return await self._get(f"/users/{user_id}") async def send_channel_message(self, team_id: str, channel_id: str, content: dict) -> dict[str, Any]: return await self._post( f"/teams/{team_id}/channels/{channel_id}/messages", content, ) async def upload_file( self, file_data: bytes, filename: str, folder_path: str = "ForcePilot Uploads", ) -> dict[str, Any]: path = f"/me/drive/root:/{folder_path}/{filename}:/createUploadSession" session_result = await self._post(path, {"item": {"@microsoft.graph.conflictBehavior": "rename"}}) upload_url = session_result.get("uploadUrl", "") if not upload_url: return {"error": "Failed to create upload session"} total_size = len(file_data) offset = 0 session = await self._ensure_session() while offset < total_size: end = min(offset + GRAPH_UPLOAD_CHUNK_SIZE, total_size) chunk = file_data[offset:end] content_range = f"bytes {offset}-{end - 1}/{total_size}" headers = {"Content-Range": content_range, "Content-Length": str(len(chunk))} async with session.put(upload_url, data=chunk, headers=headers) as resp: if resp.status in (200, 201): return await resp.json() if resp.status == 202: offset = end continue body = await resp.text() logger.warning(f"Graph upload chunk failed: HTTP {resp.status} - {body[:200]}") return {"error": f"HTTP {resp.status}", "detail": body[:500]} return {"error": "Upload incomplete"} async def validate(self) -> bool: result = await self.get_me() return bool(result) and "error" not in result async def fetch_thread_replies( self, team_id: str, channel_id: str, message_id: str, limit: int = 50 ) -> list[dict[str, Any]]: replies: list[dict[str, Any]] = [] path = f"/teams/{team_id}/channels/{channel_id}/messages/{message_id}/replies" params = {"$top": str(min(limit, 50))} first_page = await self._get(path, params=params) if "error" in first_page: return replies for item in first_page.get("value", []): if item.get("id") != message_id: replies.append(item) return replies[:limit] async def fetch_parent_message( self, team_id: str, channel_id: str, parent_message_id: str ) -> dict[str, Any] | None: cached = self._get_cached_parent_message(parent_message_id) if cached is not None: return cached path = f"/teams/{team_id}/channels/{channel_id}/messages/{parent_message_id}" result = await self._get(path) if "error" in result: return None self._cache_parent_message(parent_message_id, result) return result async def fetch_thread_context( self, team_id: str, channel_id: str, parent_message_id: str, session_key: str = "", limit: int = 20, ) -> str | None: if session_key and parent_message_id: if self._is_parent_msg_injected(session_key, parent_message_id): return None parent = await self.fetch_parent_message(team_id, channel_id, parent_message_id) if not parent: return None if session_key and parent_message_id: self._mark_parent_msg_injected(session_key, parent_message_id) replies = await self.fetch_thread_replies(team_id, channel_id, parent_message_id, limit) context_msgs = [parent] + replies return format_thread_context(context_msgs, limit=limit) async def search_graph_users( self, query: str, top: int = 10, ) -> list[dict[str, Any]]: path = "/users" params: dict[str, str] = { "$filter": ( f"startswith(displayName,'{query}') " f"or startswith(userPrincipalName,'{query}') " f"or startswith(mail,'{query}') " f"or startswith(givenName,'{query}') " f"or startswith(surname,'{query}')" ), "$top": str(min(top, 50)), "$select": "id,displayName,userPrincipalName,mail", } result = await self._get(path, params=params) if "error" in result: return [] users = result.get("value", []) return [ { "id": u.get("id", ""), "display_name": u.get("displayName", ""), "user_principal_name": u.get("userPrincipalName", ""), "email": u.get("mail", "") or u.get("userPrincipalName", ""), } for u in users ][:top] async def get_message( self, team_id: str, channel_id: str, message_id: str, ) -> dict[str, Any] | None: path = f"/teams/{team_id}/channels/{channel_id}/messages/{message_id}" result = await self._get(path) if "error" in result: return None return result async def list_reactions( self, team_id: str, channel_id: str, message_id: str, ) -> list[dict[str, Any]]: msg = await self.get_message(team_id, channel_id, message_id) if not msg: return [] reactions = msg.get("reactions", []) or [] return [ { "reaction_type": r.get("reactionType", ""), "created_date_time": r.get("createdDateTime", ""), "user": (r.get("user", {}) or {}).get("user", {}), } for r in reactions ] def format_thread_context( messages: list[dict[str, Any]], bot_name: str = "ForcePilot", limit: int = 20, ) -> str | None: if not messages: return None recent = messages[-limit:] lines: list[str] = [] for msg in recent: from_info = msg.get("from", {}) or {} author = (from_info.get("user") or {}).get("displayName", "") or from_info.get("name", "Unknown") body = msg.get("body", {}) or {} content = body.get("content", "") or msg.get("text", "") if not content: continue content = content.strip()[:500] lines.append(f"[{author}]: {content}") if not lines: return None header = f"[Thread history — {bot_name}]\n\n" return header + "\n".join(lines) def extract_reply_context( reply_to_body: str, reply_to_sender: str = "", ) -> dict[str, str]: result: dict[str, str] = {} if reply_to_sender: result["reply_to_sender"] = reply_to_sender if reply_to_body: result["reply_to_body"] = reply_to_body[:1000] return result async def search_messages( client: GraphClient, team_id: str, channel_id: str, query: str, limit: int = 20, ) -> list[dict[str, Any]]: path = f"/teams/{team_id}/channels/{channel_id}/messages" params = {"$search": f'"{query}"', "$top": str(min(limit, 50))} result = await client._get(path, params=params) if "error" in result: return [] return result.get("value", [])[:limit] async def list_directory_peers( client: GraphClient, team_id: str, ) -> list[dict[str, Any]]: members = await client._list_all(f"/teams/{team_id}/members") return [ { "id": m.get("userId", ""), "name": m.get("displayName", ""), "email": m.get("email", ""), "role": (m.get("roles", []) or [None])[0], } for m in members ] async def list_directory_groups( client: GraphClient, team_id: str, ) -> list[dict[str, Any]]: channels = await client._list_all(f"/teams/{team_id}/channels") return [ { "id": ch.get("id", ""), "name": ch.get("displayName", ""), "description": ch.get("description", ""), } for ch in channels ] async def get_member_info( client: GraphClient, team_id: str, user_id: str, ) -> dict[str, Any] | None: memberships = await client._list_all( f"/teams/{team_id}/members", params={"$filter": f"userId eq '{user_id}'"}, ) if not memberships: return None member = memberships[0] return { "id": member.get("userId", ""), "name": member.get("displayName", ""), "email": member.get("email", ""), "role": (member.get("roles", []) or [None])[0], }