372 lines
14 KiB
Python
372 lines
14 KiB
Python
|
|
"""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
|