ForcePilot/backend/package/yuxi/channel/extensions/msteams/graph.py
Kris 94444ced96 feat(channel): 添加 Microsoft Teams 渠道扩展
新增 Microsoft Teams 渠道扩展,支持在 Yuxi 平台中集成 Microsoft Teams 协作平台。

包含以下功能模块:
- sdk: Bot Framework SDK 封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- auth: JWT 认证
- jwks: JWKS 密钥管理
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- state: 状态管理
- runtime: 运行时管理
- actions: 动作处理
- adaptive_card: 自适应卡片
- task_modules: 任务模块
- message_extension: 消息扩展
- proactive: Proactive Messaging
- graph: Microsoft Graph API 集成
- graph_teams: Teams 操作
- graph_members: 成员管理
- graph_messages: 消息获取
- graph_thread: 线程管理
- graph_users: 用户管理
- graph_upload: 文件上传
- files: 文件处理
- file_consent: 文件授权
- conversations: 会话存储
- mentions: @提及处理
- threading: 线程管理
- reactions: 表情反应
- polls: 投票功能
- meetings: 会议集成
- feedback: 反馈处理
- sso: 单点登录
- deep_links: 深层链接
- incoming_webhook: 入站 Webhook
- localization: 本地化
- user_agent: 用户代理
- sent_message_cache: 消息缓存
- types: 类型定义
2026-05-21 11:28:42 +08:00

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)