from __future__ import annotations import os import time from typing import Any from yuxi.utils.logging_config import logger from .oauth import FeishuDeviceCodeClient def run_feishu_setup() -> dict[str, Any] | None: print("=== 飞书应用配置向导 ===\n") has_existing = _detect_existing_config() if has_existing: choice = _prompt("检测到已有飞书配置,是否使用已有配置? [编辑(e)/新建(n)/取消(c)]", default="e").strip().lower() if choice == "c": print("❌ 配置已取消") return None if choice == "n": return _run_new_app_flow() return _run_edit_flow(has_existing) return _run_new_app_flow() def _run_new_app_flow() -> dict[str, Any] | None: print("\n--- 新建应用 ---") use_scan = _prompt("是否使用扫码注册? [y/N]", default="n").strip().lower() if use_scan == "y": config = _run_scan_to_create() if config is None: return None else: config = _prompt_manual_credentials() if config is None: return None config = _prompt_policy_selection(config) config = _apply_security_recommendations(config) return _confirm_and_return_config(config) def _run_edit_flow(existing: dict[str, Any]) -> dict[str, Any] | None: print("\n--- 编辑已有应用 ---") print(f"当前 App ID: {existing.get('app_id', '未配置')}") config = dict(existing) change_app = _prompt("是否更换应用凭据? [y/N]", default="n").strip().lower() if change_app == "y": new_config = _prompt_manual_credentials() if new_config: config.update(new_config) config = _prompt_policy_selection(config) config = _apply_security_recommendations(config) return _confirm_and_return_config(config) def _prompt_manual_credentials() -> dict[str, Any] | None: app_id = _prompt("请输入 App ID").strip() if not app_id: print("❌ App ID 不能为空,配置取消") return None app_secret = _prompt("请输入 App Secret").strip() if not app_secret: print("❌ App Secret 不能为空,配置取消") return None platform = _prompt("平台类型 [feishu/lark]", default="feishu").strip() domain = _prompt("私有部署域名 (留空使用默认)", default="").strip() verify_token = _prompt("Verification Token (可选)", default="").strip() encrypt_key = _prompt("Encrypt Key (可选)", default="").strip() config: dict[str, Any] = { "app_id": app_id, "app_secret": app_secret, "platform": platform or "feishu", "verify_token": verify_token, "encrypt_key": encrypt_key, } if domain: config["domain"] = domain return config def _run_scan_to_create() -> dict[str, Any] | None: print("\n--- 扫码注册模式 ---") print("此模式将使用设备授权码流程自动获取应用凭据\n") app_id = _prompt("App ID (从飞书开发者后台获取)").strip() if not app_id: print("❌ App ID 不能为空") return None app_secret = _prompt("App Secret").strip() if not app_secret: print("❌ App Secret 不能为空") return None client = FeishuDeviceCodeClient(app_id=app_id, app_secret=app_secret) print("\n正在发起设备授权请求...") device_resp = client.init_device_flow() if device_resp is None: print("❌ 设备授权请求失败,请检查 App ID 和 App Secret") return None verification_uri = device_resp.get("verification_uri", "") user_code = device_resp.get("user_code", "") device_code = device_resp.get("device_code", "") interval = device_resp.get("interval", 5) expires_in = device_resp.get("expires_in", 600) if not verification_uri or not user_code: print("❌ 获取设备授权信息失败") return None _print_qr_code(verification_uri) print("\n请在浏览器中打开以下链接并输入验证码:") print(f" URL: {verification_uri}") print(f" 验证码: {user_code}") print(f"\n有效期: {expires_in} 秒\n") print("等待授权中...") max_attempts = expires_in // interval + 1 for attempt in range(max_attempts): time.sleep(interval) token_resp = client.poll_device_token(device_code) if token_resp is None: continue error = token_resp.get("error", "") if error == "authorization_pending": continue elif error == "slow_down": interval += 5 continue elif error: print(f"❌ 授权失败: {error}") return None access_token = token_resp.get("access_token", "") refresh_token = token_resp.get("refresh_token", "") print("\n✅ 授权成功!") return { "app_id": app_id, "app_secret": app_secret, "platform": "feishu", "access_token": access_token, "refresh_token": refresh_token, } print("❌ 授权超时") return None def _prompt_policy_selection(config: dict[str, Any]) -> dict[str, Any]: print("\n--- 安全策略配置 ---") print("请选择群聊策略:") print(" 1) allowlist - 仅允许白名单中的群聊访问 (推荐)") print(" 2) open - 所有群聊均可访问") print(" 3) disabled - 禁用群聊功能") choice = _prompt("请选择 [1/2/3]", default="1").strip() policy_map = {"1": "allowlist", "2": "open", "3": "disabled"} group_policy = policy_map.get(choice, "allowlist") config["group_policy"] = group_policy config["groupPolicy"] = group_policy if group_policy == "allowlist": print("\n请输入允许访问的群聊 ID (每行一个,空行结束):") allowlist: list[str] = [] while True: entry = _prompt(" chat_id (或直接回车结束)", default="").strip() if not entry: break allowlist.append(entry) if allowlist: config["allowlist"] = allowlist config["allowFrom"] = allowlist print("\n请选择私聊策略:") print(" 1) pairing - 用户需要先与机器人建立会话 (推荐)") print(" 2) open - 所有用户均可发送私信") print(" 3) allowlist - 仅白名单用户") dm_choice = _prompt("请选择 [1/2/3]", default="1").strip() dm_policy_map = {"1": "pairing", "2": "open", "3": "allowlist"} dm_policy = dm_policy_map.get(dm_choice, "pairing") config["dm_policy"] = dm_policy config["dmPolicy"] = dm_policy return config def _apply_security_recommendations(config: dict[str, Any]) -> dict[str, Any]: group_policy = config.get("group_policy", "allowlist") if group_policy in ("open", "allowlist"): if not config.get("allowlist") and not config.get("allowFrom"): logger.info("[FeishuSetup] Adding security notice: no allowlist configured") return config def _confirm_and_return_config(config: dict[str, Any]) -> dict[str, Any] | None: print("\n=== 配置摘要 ===") for key, value in config.items(): if key in ("access_token", "refresh_token"): continue display = "****" if "secret" in key or "encrypt" in key else str(value)[:50] print(f" {key}: {display}") confirm = _prompt("\n是否保存配置? [y/N]", default="n").strip().lower() if confirm != "y": print("❌ 配置已取消") return None print("✅ 配置已保存") return config def _detect_existing_config() -> dict[str, Any] | None: env_app_id = os.environ.get("FEISHU_APP_ID", "") env_app_secret = os.environ.get("FEISHU_APP_SECRET", "") if env_app_id and env_app_secret: return {"app_id": env_app_id, "app_secret": env_app_secret, "platform": "feishu"} return None def _print_qr_code(url: str) -> None: try: import qrcode qr = qrcode.QRCode(border=1) qr.add_data(url) qr.make(fit=True) qr.print_ascii() except ImportError: pass def _prompt(text: str, default: str = "") -> str: if default: prompt_text = f"{text} [{default}]: " else: prompt_text = f"{text}: " try: return input(prompt_text) except (EOFError, KeyboardInterrupt): return default