实现了 Teams 机器人所需的全功能组件,包括: - 基础命令解析与帮助卡片生成 - 租户验证与访问控制 - 自定义 UA 与媒体工具 - 消息分块、批注处理与会话管理 - 防抖、缓存与配置路由能力 - 投票、配对、审计与运行时状态管理 - TTS 语音合成与卡片构建工具 - 群组管理与权限控制逻辑
457 lines
16 KiB
Python
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],
|
|
}
|