ForcePilot/backend/package/yuxi/channels/adapters/msteams/graph.py
Kris bd60c15df0 feat(msteams): 新增完整的 Microsoft Teams 适配器模块
实现了 Teams 机器人所需的全功能组件,包括:
- 基础命令解析与帮助卡片生成
- 租户验证与访问控制
- 自定义 UA 与媒体工具
- 消息分块、批注处理与会话管理
- 防抖、缓存与配置路由能力
- 投票、配对、审计与运行时状态管理
- TTS 语音合成与卡片构建工具
- 群组管理与权限控制逻辑
2026-05-12 00:46:44 +08:00

457 lines
16 KiB
Python

"""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],
}