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

643 lines
26 KiB
Python

from __future__ import annotations
import logging
from yuxi.channel.protocols import AgentToolParam, MessageActionCapability
from .file_consent import FileConsentStore, build_file_consent_attachment
from .files import UploadRoute, download_public_file, resolve_upload_route
from .graph import MSTeamsGraphClient
from .graph_members import add_chat_members, get_chat_members, remove_chat_member
from .graph_messages import pin_message, search_messages, set_reaction, unpin_message
from .graph_teams import create_channel, get_joined_teams, get_team_channels, rename_group_chat
from .outbound import delete_message, edit_message, send_media, send_text
from .polls import PollStore, build_poll_results_text, send_poll
from .sdk import BotFrameworkAdapter
from .sso import SSOTokenStore, build_sso_attachment
from .types import StoredConversationReference
logger = logging.getLogger(__name__)
_SUPPORTED_ACTIONS = frozenset(
{
"send",
"edit",
"delete",
"react",
"pin",
"unpin",
"search_messages",
"poll_create",
"poll_results",
"upload_file",
"get_members",
"add_members",
"remove_member",
"list_teams",
"list_channels",
"create_channel",
"rename_chat",
"sso_signin",
}
)
class MSTeamsActions:
def __init__(
self,
adapter: BotFrameworkAdapter | None = None,
store_provider=None,
poll_store: PollStore | None = None,
file_consent_store: FileConsentStore | None = None,
sso_token_store: SSOTokenStore | None = None,
graph_provider=None,
):
self._adapter = adapter
self._store_provider = store_provider
self._poll_store = poll_store
self._file_consent_store = file_consent_store
self._sso_token_store = sso_token_store
self._graph_provider = graph_provider
def _get_store(self):
if self._store_provider:
return self._store_provider()
return None
def _get_graph(self) -> MSTeamsGraphClient | None:
if self._graph_provider:
return self._graph_provider()
return None
def get_message_actions(self) -> list[MessageActionCapability]:
return [
MessageActionCapability(
action="send",
description="发送文本消息到 MS Teams 对话",
parameters=[
AgentToolParam(
name="target_id", type="string", description="会话 ID (conversationId)", required=True
),
AgentToolParam(name="content", type="string", description="消息文本内容", required=True),
AgentToolParam(name="reply_to_id", type="string", description="回复的目标消息 ID", required=False),
],
scope="current_channel",
),
MessageActionCapability(
action="edit",
description="编辑已发送的消息",
parameters=[
AgentToolParam(
name="target_id", type="string", description="会话 ID (conversationId)", required=True
),
AgentToolParam(name="message_id", type="string", description="目标消息 ID", required=True),
AgentToolParam(name="content", type="string", description="更新后的文本内容", required=True),
],
scope="current_channel",
),
MessageActionCapability(
action="delete",
description="删除已发送的消息",
parameters=[
AgentToolParam(
name="target_id", type="string", description="会话 ID (conversationId)", required=True
),
AgentToolParam(name="message_id", type="string", description="目标消息 ID", required=True),
],
scope="current_channel",
),
MessageActionCapability(
action="react",
description="对消息添加反应 (Graph API ChatMessage setReaction)",
parameters=[
AgentToolParam(name="chat_id", type="string", description="聊天 ID", required=True),
AgentToolParam(name="message_id", type="string", description="目标消息 ID", required=True),
AgentToolParam(
name="reaction_type",
type="string",
description="反应类型,如 like、heart、laugh、surprised、sad、angry",
required=True,
),
],
scope="current_channel",
),
MessageActionCapability(
action="pin",
description="置顶消息 (Graph API pinnedMessage)",
parameters=[
AgentToolParam(name="chat_id", type="string", description="聊天 ID", required=True),
AgentToolParam(name="message_id", type="string", description="目标消息 ID", required=True),
],
scope="current_channel",
),
MessageActionCapability(
action="unpin",
description="取消置顶消息",
parameters=[
AgentToolParam(name="chat_id", type="string", description="聊天 ID", required=True),
AgentToolParam(
name="pinned_message_id", type="string", description="已置顶消息的 ID", required=True
),
],
scope="current_channel",
),
MessageActionCapability(
action="search_messages",
description="搜索消息 (Graph API chat messages search)",
parameters=[
AgentToolParam(name="chat_id", type="string", description="聊天 ID", required=True),
AgentToolParam(name="query", type="string", description="搜索关键词", required=True),
AgentToolParam(name="limit", type="integer", description="返回数量上限 (默认20)", required=False),
],
scope="current_channel",
),
MessageActionCapability(
action="poll_create",
description="创建投票 (支持 Adaptive Card 交互)",
parameters=[
AgentToolParam(
name="target_id", type="string", description="会话 ID (conversationId)", required=True
),
AgentToolParam(name="question", type="string", description="投票问题", required=True),
AgentToolParam(name="options", type="string", description="选项列表,逗号分隔", required=True),
AgentToolParam(name="is_multi_select", type="boolean", description="是否允许复数", required=False),
],
scope="current_channel",
),
MessageActionCapability(
action="poll_results",
description="取得投票结果",
parameters=[
AgentToolParam(name="poll_id", type="string", description="投票 ID", required=True),
],
scope="current_channel",
),
MessageActionCapability(
action="upload_file",
description="上传文件到 Teams (自动选择最佳路由: <4MB 附件URL / <50MB Connector / >=50MB OneDrive)", # noqa: E501
parameters=[
AgentToolParam(
name="target_id", type="string", description="会话 ID (conversationId)", required=True
),
AgentToolParam(name="file_url", type="string", description="文件 URL 或本地路径", required=True),
AgentToolParam(name="file_name", type="string", description="文件名", required=False),
],
scope="current_channel",
),
MessageActionCapability(
action="get_members",
description="获取聊天/频道成员列表",
parameters=[
AgentToolParam(name="chat_id", type="string", description="聊天 ID", required=True),
],
scope="current_channel",
),
MessageActionCapability(
action="add_members",
description="添加成员到群聊",
parameters=[
AgentToolParam(name="chat_id", type="string", description="聊天 ID", required=True),
AgentToolParam(name="user_ids", type="string", description="用户 ID 列表,逗号分隔", required=True),
],
scope="current_channel",
),
MessageActionCapability(
action="remove_member",
description="从群聊移除成员",
parameters=[
AgentToolParam(name="chat_id", type="string", description="聊天 ID", required=True),
AgentToolParam(name="membership_id", type="string", description="成员资格 ID", required=True),
],
scope="current_channel",
),
MessageActionCapability(
action="list_teams",
description="列出已加入的团队列表",
parameters=[],
scope="current_channel",
),
MessageActionCapability(
action="list_channels",
description="列出团队下的频道列表",
parameters=[
AgentToolParam(name="team_id", type="string", description="团队 ID", required=True),
],
scope="current_channel",
),
MessageActionCapability(
action="create_channel",
description="在团队中创建新频道",
parameters=[
AgentToolParam(name="team_id", type="string", description="团队 ID", required=True),
AgentToolParam(name="name", type="string", description="频道名称", required=True),
AgentToolParam(name="description", type="string", description="频道描述", required=False),
],
scope="current_channel",
),
MessageActionCapability(
action="rename_chat",
description="重命名群聊主题",
parameters=[
AgentToolParam(name="chat_id", type="string", description="聊天 ID", required=True),
AgentToolParam(name="new_topic", type="string", description="新主题名称", required=True),
],
scope="current_channel",
),
MessageActionCapability(
action="sso_signin",
description="发起 Microsoft 365 统一登录 (SSO)",
parameters=[
AgentToolParam(
name="target_id", type="string", description="会话 ID (conversationId)", required=True
),
AgentToolParam(name="connection_name", type="string", description="OAuth 连接名称", required=False),
],
scope="current_channel",
),
]
async def execute_message_action(self, action: str, params: dict, context: dict) -> dict:
if action not in _SUPPORTED_ACTIONS:
return {"success": False, "error": f"不支持的操作: {action}"}
try:
match action:
case "send":
return await self._do_send(params, context)
case "edit":
return await self._do_edit(params, context)
case "delete":
return await self._do_delete(params, context)
case "react":
return await self._do_react(params, context)
case "pin":
return await self._do_pin(params, context)
case "unpin":
return await self._do_unpin(params, context)
case "search_messages":
return await self._do_search(params, context)
case "poll_create":
return await self._do_poll_create(params, context)
case "poll_results":
return await self._do_poll_results(params, context)
case "upload_file":
return await self._do_upload_file(params, context)
case "get_members":
return await self._do_get_members(params, context)
case "add_members":
return await self._do_add_members(params, context)
case "remove_member":
return await self._do_remove_member(params, context)
case "list_teams":
return await self._do_list_teams(params, context)
case "list_channels":
return await self._do_list_channels(params, context)
case "create_channel":
return await self._do_create_channel(params, context)
case "rename_chat":
return await self._do_rename_chat(params, context)
case "sso_signin":
return await self._do_sso_signin(params, context)
case _:
return {"success": False, "error": f"操作 {action} 未实现"}
except Exception as e:
logger.exception("execute_message_action '%s' failed", action)
return {"success": False, "error": str(e)}
async def _resolve_ref(self, target_id: str) -> StoredConversationReference | None:
store = self._get_store()
if not store:
return None
return store.get(target_id)
async def _do_send(self, params: dict, context: dict) -> dict:
target_id = params["target_id"]
content = params["content"]
reply_to_id = params.get("reply_to_id")
ref = await self._resolve_ref(target_id)
if not ref:
return {"success": False, "error": f"未找到会话引用: {target_id}"}
if not self._adapter:
return {"success": False, "error": "适配器未初始化"}
message_ids = await send_text(self._adapter, ref, content, reply_to_id=reply_to_id)
return {"success": True, "result": {"message_ids": message_ids}}
async def _do_edit(self, params: dict, context: dict) -> dict:
target_id = params["target_id"]
message_id = params["message_id"]
content = params["content"]
ref = await self._resolve_ref(target_id)
if not ref:
return {"success": False, "error": f"未找到会话引用: {target_id}"}
if not self._adapter:
return {"success": False, "error": "适配器未初始化"}
await edit_message(self._adapter, ref, message_id, content)
return {"success": True, "result": None}
async def _do_delete(self, params: dict, context: dict) -> dict:
target_id = params["target_id"]
message_id = params["message_id"]
ref = await self._resolve_ref(target_id)
if not ref:
return {"success": False, "error": f"未找到会话引用: {target_id}"}
if not self._adapter:
return {"success": False, "error": "适配器未初始化"}
await delete_message(self._adapter, ref, message_id)
return {"success": True, "result": None}
async def _do_react(self, params: dict, context: dict) -> dict:
graph = self._get_graph()
if not graph:
return {"success": False, "error": "Graph API 未配置"}
chat_id = params["chat_id"]
message_id = params["message_id"]
reaction_type = params.get("reaction_type", "like")
ok = await set_reaction(graph, chat_id, message_id, reaction_type)
return {"success": ok, "result": None}
async def _do_pin(self, params: dict, context: dict) -> dict:
graph = self._get_graph()
if not graph:
return {"success": False, "error": "Graph API 未配置"}
ok = await pin_message(graph, params["chat_id"], params["message_id"])
return {"success": ok, "result": None}
async def _do_unpin(self, params: dict, context: dict) -> dict:
graph = self._get_graph()
if not graph:
return {"success": False, "error": "Graph API 未配置"}
ok = await unpin_message(graph, params["chat_id"], params["pinned_message_id"])
return {"success": ok, "result": None}
async def _do_search(self, params: dict, context: dict) -> dict:
graph = self._get_graph()
if not graph:
return {"success": False, "error": "Graph API 未配置"}
limit = int(params.get("limit", 20))
msgs = await search_messages(graph, params["chat_id"], params["query"], limit=limit)
return {"success": True, "result": {"messages": msgs, "count": len(msgs)}}
async def _do_poll_create(self, params: dict, context: dict) -> dict:
target_id = params["target_id"]
question = params["question"]
options_raw = params.get("options", "")
if isinstance(options_raw, list):
options = options_raw
else:
options = [opt.strip() for opt in str(options_raw).split(",") if opt.strip()]
if len(options) < 2:
return {"success": False, "error": "至少需要两个选项"}
is_multi_select = params.get("is_multi_select", False)
if isinstance(is_multi_select, str):
is_multi_select = is_multi_select.lower() in ("true", "1", "yes")
ref = await self._resolve_ref(target_id)
if not ref:
return {"success": False, "error": f"未找到会话引用: {target_id}"}
if not self._adapter:
return {"success": False, "error": "适配器未初始化"}
if not self._poll_store:
return {"success": False, "error": "投票存储未初始化"}
poll = await send_poll(
self._adapter,
ref,
question,
options,
self._poll_store,
is_multi_select=is_multi_select,
)
return {
"success": True,
"result": {
"poll_id": poll.poll_id,
"question": poll.question,
"options": poll.options,
"is_multi_select": poll.is_multi_select,
},
}
async def _do_poll_results(self, params: dict, context: dict) -> dict:
poll_id = params["poll_id"]
if not self._poll_store:
return {"success": False, "error": "投票存储未初始化"}
poll = self._poll_store.get(poll_id)
if not poll:
return {"success": False, "error": f"投票不存在或已过期: {poll_id}"}
results_text = build_poll_results_text(poll)
return {
"success": True,
"result": {
"poll_id": poll_id,
"question": poll.question,
"results": poll.results,
"display_text": results_text,
},
}
async def _do_upload_file(self, params: dict, context: dict) -> dict:
target_id = params["target_id"]
file_url = params.get("file_url", "")
file_name = params.get("file_name", "")
ref = await self._resolve_ref(target_id)
if not ref:
return {"success": False, "error": f"未找到会话引用: {target_id}"}
if not self._adapter:
return {"success": False, "error": "适配器未初始化"}
content = await download_public_file(file_url)
if not content:
return {"success": False, "error": f"无法下载文件: {file_url}"}
file_name = file_name or file_url.rsplit("/", 1)[-1] or "file"
route = resolve_upload_route(len(content))
match route:
case UploadRoute.ATTACHMENT_URL:
message_ids = await send_media(
self._adapter,
ref,
file_url,
file_name=file_name,
)
return {"success": True, "result": {"route": str(route), "message_ids": message_ids}}
case UploadRoute.CONNECTOR:
message_ids = await send_media(
self._adapter,
ref,
file_url,
file_name=file_name,
)
return {"success": True, "result": {"route": str(route), "message_ids": message_ids}}
case UploadRoute.ONEDRIVE | UploadRoute.SHAREPOINT:
if self._file_consent_store and len(content) > 50 * 1024 * 1024:
consent = self._file_consent_store.create(
file_name=file_name,
file_size=len(content),
conversation_id=ref.conversation_id,
service_url=ref.service_url,
tenant_id=ref.tenant_id,
)
attachment = build_file_consent_attachment(
consent.consent_id,
file_name,
len(content),
)
activity = {"type": "message", "attachments": [attachment]}
result = await self._adapter.send_to_conversation(
ref.service_url,
ref.conversation_id,
activity,
)
return {
"success": True,
"result": {
"route": "file_consent_required",
"consent_id": consent.consent_id,
"message_id": result.get("id", ""),
},
}
return {"success": False, "error": "大文件上传需要配置 Graph API 和 FileConsent"}
case _:
return {"success": False, "error": f"不支持的上传路由: {route}"}
async def _do_get_members(self, params: dict, context: dict) -> dict:
graph = self._get_graph()
if not graph:
return {"success": False, "error": "Graph API 未配置"}
members = await get_chat_members(graph, params["chat_id"])
return {"success": True, "result": {"members": members, "count": len(members)}}
async def _do_add_members(self, params: dict, context: dict) -> dict:
graph = self._get_graph()
if not graph:
return {"success": False, "error": "Graph API 未配置"}
user_ids_raw = params.get("user_ids", "")
if isinstance(user_ids_raw, list):
user_ids = user_ids_raw
else:
user_ids = [uid.strip() for uid in str(user_ids_raw).split(",") if uid.strip()]
ok = await add_chat_members(graph, params["chat_id"], user_ids)
return {"success": ok, "result": None}
async def _do_remove_member(self, params: dict, context: dict) -> dict:
graph = self._get_graph()
if not graph:
return {"success": False, "error": "Graph API 未配置"}
ok = await remove_chat_member(graph, params["chat_id"], params["membership_id"])
return {"success": ok, "result": None}
async def _do_list_teams(self, params: dict, context: dict) -> dict:
graph = self._get_graph()
if not graph:
return {"success": False, "error": "Graph API 未配置"}
teams = await get_joined_teams(graph)
return {"success": True, "result": {"teams": teams, "count": len(teams)}}
async def _do_list_channels(self, params: dict, context: dict) -> dict:
graph = self._get_graph()
if not graph:
return {"success": False, "error": "Graph API 未配置"}
channels = await get_team_channels(graph, params["team_id"])
return {"success": True, "result": {"channels": channels, "count": len(channels)}}
async def _do_create_channel(self, params: dict, context: dict) -> dict:
graph = self._get_graph()
if not graph:
return {"success": False, "error": "Graph API 未配置"}
channel = await create_channel(
graph,
params["team_id"],
params["name"],
description=params.get("description", ""),
)
return {"success": channel is not None, "result": channel}
async def _do_rename_chat(self, params: dict, context: dict) -> dict:
graph = self._get_graph()
if not graph:
return {"success": False, "error": "Graph API 未配置"}
ok = await rename_group_chat(graph, params["chat_id"], params["new_topic"])
return {"success": ok, "result": None}
async def _do_sso_signin(self, params: dict, context: dict) -> dict:
target_id = params.get("target_id", "")
connection_name = params.get("connection_name", "MSTeams")
ref = await self._resolve_ref(target_id) if target_id else None
if not self._sso_token_store:
return {"success": False, "error": "SSO Token Store 未配置"}
state = self._sso_token_store.create_state()
attachment = build_sso_attachment(connection_name, state)
activity: dict = {"type": "message", "attachments": [attachment]}
if ref:
if ref.tenant_id:
activity.setdefault("channelData", {})
activity["channelData"]["tenant"] = {"id": ref.tenant_id}
if self._adapter:
await self._adapter.send_to_conversation(
ref.service_url,
ref.conversation_id,
activity,
)
return {
"success": True,
"result": {
"state": state,
"connection_name": connection_name,
"message": "SSO 登录卡片已发送",
},
}
def supports_action(self, action: str) -> bool:
return action in _SUPPORTED_ACTIONS
def resolve_execution_mode(self, action: str) -> str:
return "async"
def requires_trusted_requester_sender(self, action: str, tool_context=None) -> bool:
return action in ("delete", "remove_member", "add_members")