新增 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: 类型定义
461 lines
16 KiB
Python
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
|