实现了 Teams 机器人所需的全功能组件,包括: - 基础命令解析与帮助卡片生成 - 租户验证与访问控制 - 自定义 UA 与媒体工具 - 消息分块、批注处理与会话管理 - 防抖、缓存与配置路由能力 - 投票、配对、审计与运行时状态管理 - TTS 语音合成与卡片构建工具 - 群组管理与权限控制逻辑
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
|