ForcePilot/backend/package/yuxi/channel/extensions/msteams/gateway.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

461 lines
16 KiB
Python

from __future__ import annotations
import asyncio
import logging
import os
from pathlib import Path
import uvicorn
from .auth import verify_botframework_jwt
from .config import MSTeamsConfigAdapter
from .conversation_store import ConversationStore
from .dedupe import MSTeamsDedupeStore
from .file_consent import FileConsentStore, FileConsentRequest
from .graph import MSTeamsGraphClient
from .monitor import activity_to_unified_message, is_echo_activity
from .polls import PollStore
from .proactive import save_conversation_reference
from .sdk import BotFrameworkAdapter
from .sent_message_cache import SentMessageCache
from .sso import SSOTokenStore, exchange_token_on_behalf_of
from .types import BotFrameworkActivity
logger = logging.getLogger(__name__)
DEFAULT_WEBHOOK_HOST = "0.0.0.0"
DEFAULT_WEBHOOK_PORT = 3978
class MSTeamsGateway:
def __init__(
self,
config_adapter: MSTeamsConfigAdapter,
account: dict,
global_config: dict,
):
self._config_adapter = config_adapter
self._account = account
self._global_config = global_config
self._account_id = account.get("account_id", "default")
self._app_id = account.get("app_id", "")
self._tenant_id = account.get("tenant_id", "")
self._running = False
self._server_task: asyncio.Task | None = None
self._stop_event = asyncio.Event()
self._ctx = None
channel_cfg = account.get("config", {}) or {}
webhook_cfg = channel_cfg.get("webhook", {}) or {}
self._webhook_host = webhook_cfg.get("host", DEFAULT_WEBHOOK_HOST)
self._webhook_port = webhook_cfg.get("port", DEFAULT_WEBHOOK_PORT)
state_dir = channel_cfg.get("stateDir") or global_config.get("stateDir") or "./data/msteams"
os.makedirs(state_dir, exist_ok=True)
store_path = Path(state_dir) / f"conversations-{self._account_id}.json"
self._conversation_store = ConversationStore(str(store_path))
self._conversation_store.load()
poll_path = Path(state_dir) / f"polls-{self._account_id}.json"
self._poll_store = PollStore(str(poll_path))
self._poll_store.load()
self._file_consent_store = FileConsentStore()
self._sso_token_store = SSOTokenStore()
self._sent_message_cache = SentMessageCache()
self._dedupe_store = MSTeamsDedupeStore()
graph_cfg = channel_cfg.get("graph", {}) or {}
self._graph_client: MSTeamsGraphClient | None = None
if graph_cfg.get("enabled") and graph_cfg.get("client_id"):
self._graph_client = MSTeamsGraphClient(
tenant_id=self._tenant_id,
client_id=graph_cfg.get("client_id", ""),
client_secret=graph_cfg.get("client_secret", ""),
)
self._adapter = BotFrameworkAdapter(
app_id=self._app_id,
app_password=account.get("app_password", ""),
tenant_id=self._tenant_id,
)
@property
def adapter(self) -> BotFrameworkAdapter:
return self._adapter
@property
def conversation_store(self) -> ConversationStore:
return self._conversation_store
@property
def poll_store(self) -> PollStore:
return self._poll_store
@property
def file_consent_store(self) -> FileConsentStore:
return self._file_consent_store
@property
def sso_token_store(self) -> SSOTokenStore:
return self._sso_token_store
@property
def sent_message_cache(self) -> SentMessageCache:
return self._sent_message_cache
@property
def dedupe_store(self) -> MSTeamsDedupeStore:
return self._dedupe_store
@property
def graph_client(self) -> MSTeamsGraphClient | None:
return self._graph_client
@property
def app_id(self) -> str:
return self._app_id
@property
def account_id(self) -> str:
return self._account_id
async def start(self, ctx=None) -> asyncio.Task:
if self._running:
raise RuntimeError("MSTeams gateway already running")
self._ctx = ctx
self._running = True
self._stop_event.clear()
self._server_task = asyncio.create_task(self._run_server())
logger.info(
"MS Teams gateway started for account '%s' on %s:%d",
self._account_id,
self._webhook_host,
self._webhook_port,
)
return self._server_task
async def stop(self) -> None:
if not self._running:
return
self._running = False
self._stop_event.set()
if self._server_task:
self._server_task.cancel()
try:
await self._server_task
except asyncio.CancelledError:
pass
self._conversation_store.save()
self._poll_store.save()
self._sent_message_cache.cleanup()
logger.info("MS Teams gateway stopped for account '%s'", self._account_id)
async def _run_server(self):
async def jwt_verifier(token: str) -> dict:
return await verify_botframework_jwt(token, self._app_id, self._tenant_id)
async def handle_activity(activity_data: dict) -> dict:
activity = BotFrameworkActivity.from_dict(activity_data)
if activity.type == "invoke":
return await self._handle_invoke(activity)
if is_echo_activity(activity, self._app_id):
return {"status": 200}
try:
await save_conversation_reference(self._conversation_store, activity, self._app_id)
except Exception:
logger.exception("Failed to save conversation reference")
unified_msg = activity_to_unified_message(activity, self._app_id, self._account_id)
if unified_msg is not None:
if self._ctx and self._ctx.queue:
try:
self._ctx.queue.put_nowait(unified_msg)
except Exception:
logger.exception("Failed to enqueue MS Teams message")
return {"status": 200}
from .webhook import create_webhook_app
app = create_webhook_app(
bot_app_id=self._app_id,
adapter=self._adapter,
conversation_store=self._conversation_store,
message_handler=handle_activity,
jwt_verifier=jwt_verifier,
)
config = uvicorn.Config(
app=app,
host=self._webhook_host,
port=self._webhook_port,
log_level="warning",
access_log=False,
)
server = uvicorn.Server(config)
try:
await server.serve()
except asyncio.CancelledError:
pass
finally:
self._conversation_store.save()
self._poll_store.save()
self._sent_message_cache.cleanup()
async def _handle_invoke(self, activity: BotFrameworkActivity) -> dict:
name = activity.name or ""
if name == "adaptiveCard/action":
return await self._handle_adaptive_card_action(activity)
if name == "task/fetch":
from .task_modules import handle_task_fetch
return await handle_task_fetch(activity, self._ctx)
if name == "task/submit":
from .task_modules import handle_task_submit
return await handle_task_submit(activity, self._ctx)
if name == "dialog/fetch":
from .task_modules import handle_dialog_fetch
return await handle_dialog_fetch(activity, self._ctx)
if name == "dialog/submit":
from .task_modules import handle_dialog_submit
return await handle_dialog_submit(activity, self._ctx)
if name.startswith("composeExtension/"):
from .message_extension import handle_compose_extension
return await handle_compose_extension(name, activity, self._ctx)
if name == "signin/tokenExchange":
return await self._handle_token_exchange(activity)
if name == "signin/verifyState":
return await self._handle_signin_verify_state(activity)
return {"status": 200}
async def _handle_meeting_event(self, activity: BotFrameworkActivity) -> None:
from .meetings import parse_meeting_event
event_data = parse_meeting_event(activity.raw)
if not event_data:
return
if self._ctx and hasattr(self._ctx, "queue") and self._ctx.queue:
try:
from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage
from yuxi.channel.routing.models import PeerKind
unified_msg = UnifiedMessage(
msg_id=f"msteams:meeting:{activity.id}",
channel_type="msteams",
account_id=getattr(self._ctx, "account_id", ""),
content="",
sender=PeerInfo(id=activity.from_id, kind=PeerKind.DIRECT),
message_type=MessageType.EVENT,
raw_payload=activity.raw,
metadata={
"event_type": "meeting_event",
"meeting_id": event_data.get("meeting_id"),
"participant_count": event_data.get("participant_count"),
},
conversation_label=f"msteams:{activity.conversation_id}",
native_channel_id=activity.conversation_id,
)
self._ctx.queue.put_nowait(unified_msg)
except Exception:
logger.exception("Failed to enqueue meeting event")
async def _handle_token_exchange(self, activity: BotFrameworkActivity) -> dict:
value = activity.value or {}
token = value.get("token", "")
if not token:
return {"status": 400, "body": {"message": "No token provided"}}
try:
result = await exchange_token_on_behalf_of(
self._tenant_id,
self._app_id,
self._account.get("app_password", ""),
token,
"https://graph.microsoft.com/.default",
)
if result:
return {"status": 200, "body": {"id": activity.id, "token": result.get("access_token")}}
return {"status": 403, "body": {"message": "Token exchange failed"}}
except Exception as e:
logger.exception("SSO token exchange failed")
return {"status": 500, "body": {"message": str(e)}}
async def _handle_signin_verify_state(self, activity: BotFrameworkActivity) -> dict:
value = activity.value or {}
state = value.get("state", "")
stored = self._sso_token_store.verify_state(state)
if stored:
return {"status": 200, "body": {"valid": True}}
return {"status": 403, "body": {"valid": False, "message": "State expired or invalid"}}
async def _handle_adaptive_card_action(self, activity: BotFrameworkActivity) -> dict:
value = activity.value or {}
action = value.get("action", "")
data = value.get("data", value)
if action == "file_consent_accept":
return await self._handle_file_consent(action="accept", data=data)
elif action == "file_consent_decline":
return await self._handle_file_consent(action="decline", data=data)
elif action == "sso_signin":
return await self._handle_sso_signin_consent(data=data, activity=activity)
elif isinstance(data, dict) and data.get("action") in ("poll_vote",):
return await self._handle_poll_vote(data=data, activity=activity)
return {"status": 200}
async def _handle_file_consent(self, action: str, data: dict) -> dict:
consent_id = data.get("consent_id", "")
if not consent_id:
return {"status": 400}
req = self._file_consent_store.get(consent_id)
if not req:
return {"status": 200, "body": {"message": "Consent request expired"}}
if action == "accept":
self._file_consent_store.accept(consent_id)
if req.file_url and self._graph_client:
asyncio.create_task(self._upload_consented_file(req))
elif action == "decline":
self._file_consent_store.decline(consent_id)
return {"status": 200}
async def _upload_consented_file(self, req: FileConsentRequest) -> None:
from .files import download_public_file
from .graph_upload import upload_to_onedrive
from .sdk import build_message_activity
try:
file_bytes = await download_public_file(req.file_url)
if not file_bytes:
raise RuntimeError("Failed to download file")
if req.upload_path:
result = await upload_to_onedrive(
self._graph_client,
file_bytes,
req.file_name,
folder=req.upload_path,
)
else:
result = await upload_to_onedrive(
self._graph_client,
file_bytes,
req.file_name,
)
if result and result.get("web_url"):
await self._adapter.send_to_conversation(
req.service_url,
req.conversation_id,
build_message_activity(
f"✅ 文件 **{req.file_name}** ({_format_size(len(file_bytes))}) 上传完成\n"
f"[查看文件]({result['web_url']})",
reply_to_id=req.reply_to_id,
tenant_id=req.tenant_id,
),
)
else:
await self._adapter.send_to_conversation(
req.service_url,
req.conversation_id,
build_message_activity(
f"✅ 文件 **{req.file_name}** ({_format_size(len(file_bytes))}) 上传完成",
reply_to_id=req.reply_to_id,
tenant_id=req.tenant_id,
),
)
except Exception as e:
logger.exception("File consent upload failed")
await self._adapter.send_to_conversation(
req.service_url,
req.conversation_id,
build_message_activity(
f"❌ 文件上传失败: {e}",
reply_to_id=req.reply_to_id,
tenant_id=req.tenant_id,
),
)
async def _handle_sso_signin_consent(self, data: dict, activity: BotFrameworkActivity) -> dict:
state = data.get("state", "")
if not state:
return {"status": 400}
stored = self._sso_token_store.verify_state(state)
if not stored:
return {"status": 200, "body": {"message": "SSO state expired or invalid"}}
return {"status": 200}
async def _handle_poll_vote(self, data: dict, activity: BotFrameworkActivity) -> dict:
poll_id = data.get("poll_id", "")
selected = data.get("selected", [])
if isinstance(selected, str):
selected = [selected]
sender_id = activity.from_id
from .polls import handle_poll_vote
result = await handle_poll_vote(
self._adapter,
self._poll_store,
{"poll_id": poll_id, "selected": selected},
sender_id=sender_id,
)
if not result.get("success"):
return {"status": 200, "body": {"message": result.get("error", "Vote failed")}}
poll = self._poll_store.get(poll_id)
if poll:
from .polls import build_poll_results_text
text = build_poll_results_text(poll)
activity_data: dict = {
"type": "message",
"text": f"✅ 投票成功!当前结果:\n\n{text}",
}
if poll.tenant_id:
activity_data.setdefault("channelData", {})
activity_data["channelData"]["tenant"] = {"id": poll.tenant_id}
await self._adapter.send_to_conversation(
poll.service_url,
poll.conversation_id,
activity_data,
)
return {"status": 200}
async def probe(self) -> bool:
try:
token_ok = await self._adapter.probe()
return token_ok
except Exception as e:
logger.warning("MS Teams probe failed: %s", e)
return False