"""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