新增 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: 类型定义
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)
|