ForcePilot/backend/package/yuxi/channels/adapters/msteams/setup_wizard.py
Kris bd60c15df0 feat(msteams): 新增完整的 Microsoft Teams 适配器模块
实现了 Teams 机器人所需的全功能组件,包括:
- 基础命令解析与帮助卡片生成
- 租户验证与访问控制
- 自定义 UA 与媒体工具
- 消息分块、批注处理与会话管理
- 防抖、缓存与配置路由能力
- 投票、配对、审计与运行时状态管理
- TTS 语音合成与卡片构建工具
- 群组管理与权限控制逻辑
2026-05-12 00:46:44 +08:00

372 lines
14 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` 启动本地回调服务器")
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 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