ForcePilot/backend/package/yuxi/channels/adapters/msteams/setup_wizard.py
Kris 939f1ba82a refactor(msteams): 整理代码结构并新增多项功能
本次提交对Microsoft Teams适配器代码进行了多维度优化与新增:
1.  调整多处导入顺序,优化代码可读性
2.  新增media_tools工具模块,提供媒体相关辅助函数
3.  新增thread_history模块,实现对话历史拉取与缓存功能
4.  新增connection_modes模块,支持webhook/websocket/polling三种连接模式
5.  扩展security.py与tool_policy.py,新增通配符配置校验与三级策略解析
6.  新增feedback会话记录功能
7.  为sent_message_cache添加自动清理任务
8.  优化normalizer模块,新增引用、编辑消息解析与线程上下文注入
9.  重构file_upload的SSRF防护逻辑,复用公共校验工具
10. 修复多处导入顺序与代码排版问题
11. 为消息发送添加断路器保护与异步去重锁
2026-05-13 16:12:31 +08:00

412 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Microsoft Teams 设置向导。
5 步交互式命令行配置向导:
1. 凭据收集 (App ID + App Password + Tenant ID)
2. DM 策略配置 (open/pairing/allowlist/disabled)
3. OAuth 委托授权引导
4. 群组访问配置 (open/allowlist/disabled)
5. 功能开关 (流式/反馈/欢迎/SSO)
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from yuxi.utils.logging_config import logger
STEP_TITLES = {
1: "Bot 凭据配置",
2: "DM 策略配置",
3: "OAuth 委托授权引导",
4: "群组访问配置",
5: "功能开关配置",
}
DM_POLICY_OPTIONS = {
"1": ("open", "开放 — 任何人可私聊 Bot"),
"2": ("pairing", "配对 — 仅配对用户可私聊"),
"3": ("allowlist", "白名单 — 仅 allow_from 列表用户可私聊"),
"4": ("disabled", "禁用 — 禁止所有私聊"),
}
GROUP_POLICY_OPTIONS = {
"1": ("open", "开放 — 任何群组可添加 Bot"),
"2": ("allowlist", "白名单 — 仅 group_allow_from 列表群组可用"),
"3": ("disabled", "禁用 — 禁止群组消息"),
}
class MSTeamsSetupWizard:
"""Microsoft Teams 设置向导。
5 步交互流程生成完整配置字典。
支持命令行交互模式和非交互数据模式。
"""
def __init__(self, existing_config: dict[str, Any] | None = None):
self._config: dict[str, Any] = dict(existing_config or {})
self._steps: list[dict[str, Any]] = []
@property
def config(self) -> dict[str, Any]:
return self._config
def run_interactive(self) -> dict[str, Any]:
"""运行 5 步交互式配置向导。
在终端中逐步引导用户完成配置,返回生成的配置字典。
"""
try:
self._run_step_1_credentials()
self._run_step_2_dm_policy()
self._run_step_3_oauth()
self._run_step_4_group_policy()
self._run_step_5_features()
print("\n" + "=" * 60)
print(" Microsoft Teams 设置向导完成")
print("=" * 60)
self._print_summary()
return self._config
except KeyboardInterrupt:
print("\n\n设置向导已取消")
return self._config
def run_non_interactive(self, answers: dict[str, Any]) -> dict[str, Any]:
"""非交互模式:直接传入答案字典生成配置。
answers:
app_id, app_password, tenant_id,
dm_policy, allow_from,
oauth_enabled, oauth_connection_name,
group_policy, group_allow_from,
streaming_mode, feedback_enabled, feedback_reflection,
welcome_card, group_welcome_card, sso_enabled, sso_connection_name
"""
self._config.update(_validate_and_normalize_answers(answers))
return self._config
def save_config(self, filepath: str | None = None) -> str:
"""保存配置到 JSON 文件。"""
if filepath is None:
filepath = str(Path.home() / ".yuxi" / "msteams_config.json")
filepath = str(filepath)
Path(filepath).parent.mkdir(parents=True, exist_ok=True)
with open(filepath, "w", encoding="utf-8") as f:
json.dump(self._config, f, ensure_ascii=False, indent=2)
logger.info(f"MSTeams setup wizard: config saved to {filepath}")
return filepath
def _input(self, prompt: str, default: str = "") -> str:
if default:
prompt = f"{prompt} [{default}]: "
else:
prompt = f"{prompt}: "
try:
value = input(prompt).strip()
except (EOFError, KeyboardInterrupt):
raise KeyboardInterrupt
return value or default
def _confirm(self, prompt: str, default: bool = True) -> bool:
suffix = " (Y/n)" if default else " (y/N)"
answer = self._input(prompt + suffix).lower()
if not answer:
return default
return answer.startswith("y")
def _print_step_header(self, step: int) -> None:
title = STEP_TITLES.get(step, f"Step {step}")
print(f"\n{'' * 50}")
print(f" Step {step}/5: {title}")
print(f"{'' * 50}")
def _run_step_1_credentials(self) -> None:
self._print_step_header(1)
print("\n请提供 Bot 凭据信息。可在 Azure Portal → Bot Channels Registration 中找到。")
app_id = self._input("App ID", self._config.get("app_id", ""))
app_password = self._input("App Password", self._config.get("app_password", ""))
tenant_id = self._input("Tenant ID (可选)", self._config.get("tenant_id", ""))
if not app_id:
print("⚠ App ID 不能为空,跳过凭据配置")
return
self._config["app_id"] = app_id
self._config["app_password"] = app_password
if tenant_id:
self._config["tenant_id"] = tenant_id
self._config["service_url"] = self._config.get("service_url", "https://smba.trafficmanager.net/emea")
print(f"\n ✓ App ID: {app_id[:12]}...")
print(f" ✓ 密码: {'已配置' if app_password else '⚠ 未配置'}")
def _run_step_2_dm_policy(self) -> None:
self._print_step_header(2)
print("\n选择 DM (私聊) 策略来控制谁可以与 Bot 私聊:\n")
for key, (policy, desc) in DM_POLICY_OPTIONS.items():
print(f" {key}) {desc}")
current = self._config.get("dm_policy", "open")
current_key = next((k for k, v in DM_POLICY_OPTIONS.items() if v[0] == current), "1")
choice = self._input("\n选择策略", current_key)
choice_key = choice if choice in DM_POLICY_OPTIONS else "1"
dm_policy, _ = DM_POLICY_OPTIONS[choice_key]
self._config["dm_policy"] = dm_policy
if dm_policy in ("allowlist",):
allow_from = self._input(
"allow_from (逗号分隔的 AAD Object ID 或显示名)",
",".join(self._config.get("allow_from", [])),
)
if allow_from:
self._config["allow_from"] = [e.strip() for e in allow_from.split(",") if e.strip()]
else:
self._config["allow_from"] = []
if dm_policy in ("pairing",):
print(" 用户需通过配对请求后才能私聊 Bot")
print(f"\n ✓ DM 策略: {dm_policy}")
def _run_step_3_oauth(self) -> None:
self._print_step_header(3)
print("\nOAuth 委托授权允许 Bot 以用户身份访问 Graph API。")
print("此步骤仅引导是否需要配置,实际配置需在 Azure AD 中完成。")
use_oauth = self._confirm(
"是否启用 OAuth 委托授权",
self._config.get("delegated_auth", {}).get("enabled", False),
)
if use_oauth:
scopes = self._input(
"OAuth Scopes (空格分隔)",
" ".join(self._config.get("delegated_auth", {}).get("scopes", [])),
)
self._config["delegated_auth"] = {
"enabled": True,
"scopes": scopes.split(),
}
print("\n 请在 Azure AD 中注册以下重定向 URI")
print(" http://localhost:5353/oauth/msteams/callback")
print(" 授权流程: `make msteams-oauth` 启动本地回调服务器")
auth_url = "https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize"
if self._confirm("\n是否自动打开浏览器开始 OAuth 授权", True):
tenant_id = self._config.get("tenant_id", "organizations")
client_id = self._config.get("app_id", "")
if client_id:
scope_param = " ".join(scopes.split()) if isinstance(scopes, str) else " ".join(scopes.split())
oauth_url = (
f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize"
f"?client_id={client_id}"
f"&response_type=code"
f"&redirect_uri=http://localhost:5353/oauth/msteams/callback"
f"&scope={scope_param.replace(' ', '%20')}"
f"&response_mode=query"
)
_open_browser(oauth_url)
print(" 浏览器已打开,完成授权后将回调 localhost:5353")
else:
print(" ⚠ 未配置 App ID无法生成授权 URL")
print(f" ✓ OAuth: 已启用 (scopes={len(scopes.split())})")
else:
self._config["delegated_auth"] = {"enabled": False}
print(" ✓ OAuth: 已禁用")
def _run_step_4_group_policy(self) -> None:
self._print_step_header(4)
print("\n选择群组策略来控制 Bot 在哪些群组中可用:\n")
for key, (policy, desc) in GROUP_POLICY_OPTIONS.items():
print(f" {key}) {desc}")
current = self._config.get("group_policy", "open")
current_key = next((k for k, v in GROUP_POLICY_OPTIONS.items() if v[0] == current), "1")
choice = self._input("\n选择策略", current_key)
choice_key = choice if choice in GROUP_POLICY_OPTIONS else "1"
group_policy, _ = GROUP_POLICY_OPTIONS[choice_key]
self._config["group_policy"] = group_policy
if group_policy in ("allowlist",):
group_allow = self._input(
"group_allow_from (逗号分隔的 Team/Group ID)",
",".join(self._config.get("group_allow_from", [])),
)
if group_allow:
self._config["group_allow_from"] = [e.strip() for e in group_allow.split(",") if e.strip()]
else:
self._config["group_allow_from"] = []
print(f"\n ✓ 群组策略: {group_policy}")
def _run_step_5_features(self) -> None:
self._print_step_header(5)
print("\n配置功能开关:")
streaming = self._confirm(
"启用流式输出 (streaming)",
self._config.get("streaming_mode", "block") != "off",
)
self._config["streaming_mode"] = "block" if streaming else "off"
feedback = self._confirm(
"启用 AI 反馈按钮",
self._config.get("feedback_enabled", True),
)
self._config["feedback_enabled"] = feedback
reflection = False
if feedback:
reflection = self._confirm(
"启用反馈反思学习",
self._config.get("feedback_reflection", False),
)
self._config["feedback_reflection"] = reflection
welcome = self._confirm(
"启用私聊欢迎卡片",
self._config.get("welcome_card", True),
)
self._config["welcome_card"] = welcome
group_welcome = self._confirm(
"启用群组欢迎消息",
self._config.get("group_welcome_card", True),
)
self._config["group_welcome_card"] = group_welcome
sso_enabled = self._confirm(
"启用 SSO (Teams Single Sign-On)",
self._config.get("sso", {}).get("enabled", False),
)
sso_connection = ""
if sso_enabled:
sso_connection = self._input(
"SSO Connection Name",
self._config.get("sso", {}).get("connection_name", ""),
)
self._config["sso"] = {
"enabled": sso_enabled,
"connection_name": sso_connection,
}
print("\n 功能配置完成:")
print(f" 流式: {'on' if streaming else 'off'}")
print(f" 反馈: {'on' if feedback else 'off'}")
print(f" 反思: {'on' if reflection else 'off'}")
print(f" 欢迎: {'on' if welcome else 'off'}")
print(f" 群欢迎: {'on' if group_welcome else 'off'}")
print(f" SSO: {'on' if sso_enabled else 'off'}")
def _print_summary(self) -> None:
print("\n 配置摘要:")
print(f" App ID: {self._config.get('app_id', '')[:12]}...")
print(f" DM 策略: {self._config.get('dm_policy', '-')}")
print(f" 群组策略: {self._config.get('group_policy', '-')}")
print(f" OAuth: {'启用' if self._config.get('delegated_auth', {}).get('enabled') else '禁用'}")
print(f" 流式: {self._config.get('streaming_mode', '-')}")
print(f" 反馈: {'启用' if self._config.get('feedback_enabled') else '禁用'}")
@classmethod
def from_interactive(cls, existing_config: dict[str, Any] | None = None) -> dict[str, Any]:
wizard = cls(existing_config)
return wizard.run_interactive()
@classmethod
def from_answers(cls, answers: dict[str, Any]) -> dict[str, Any]:
wizard = cls()
return wizard.run_non_interactive(answers)
def _validate_and_normalize_answers(answers: dict[str, Any]) -> dict[str, Any]:
result: dict[str, Any] = {}
str_keys = ["app_id", "app_password", "tenant_id", "dm_policy", "group_policy", "streaming_mode", "service_url"]
for key in str_keys:
if key in answers and answers[key]:
result[key] = str(answers[key])
bool_keys = ["feedback_enabled", "feedback_reflection", "welcome_card", "group_welcome_card", "sso_enabled"]
for key in bool_keys:
if key in answers:
result[key] = bool(answers[key])
list_keys = ["allow_from", "group_allow_from"]
for key in list_keys:
if key in answers:
val = answers[key]
if isinstance(val, str):
result[key] = [e.strip() for e in val.split(",") if e.strip()]
elif isinstance(val, list):
result[key] = [str(e) for e in val]
if "sso_connection_name" in answers:
result.setdefault("sso", {})["connection_name"] = str(answers["sso_connection_name"])
result["sso"]["enabled"] = bool(answers.get("sso_enabled", False))
if "oauth_enabled" in answers:
result["delegated_auth"] = {
"enabled": bool(answers["oauth_enabled"]),
"scopes": answers.get("oauth_scopes", ["offline_access", "https://graph.microsoft.com/User.Read"]),
}
return result
def _open_browser(url: str) -> bool:
"""跨平台浏览器自动打开。
支持 Windows (start), macOS (open), Linux (xdg-open)。
"""
import subprocess
import sys
try:
if sys.platform == "win32":
subprocess.Popen(["start", url], shell=True)
elif sys.platform == "darwin":
subprocess.Popen(["open", url])
else:
subprocess.Popen(["xdg-open", url])
return True
except Exception as e:
logger.warning(f"MSTeams setup wizard: failed to open browser: {e}")
return False
def run_msteams_setup_wizard(
existing_config: dict[str, Any] | None = None,
output_file: str | None = None,
) -> dict[str, Any]:
"""快捷入口:运行 Teams 设置向导并可选保存配置。
Args:
existing_config: 已有配置,用于预填充默认值
output_file: 保存路径None 则不保存
Returns:
生成的完整配置字典
"""
wizard = MSTeamsSetupWizard(existing_config)
config = wizard.run_interactive()
if output_file:
saved = wizard.save_config(output_file)
print(f"\n配置已保存至: {saved}")
return config