ForcePilot/backend/package/yuxi/channels/adapters/feishu/setup.py
Kris a6fa7245e5 feat(feishu): 完整实现飞书适配器核心模块
新增飞书机器人适配器全套功能,包括:
- 基础适配器入口与工具导出
- 消息格式化、卡片渲染、回复调度逻辑
- 会话ID生成、模型覆盖策略
- 消息发送缓存、顺序队列管理
- 飞书签名验证、加解密webhook请求
- 审批权限校验、机器人菜单事件处理
- 文档评论、钉消息、语音转码处理
- 静态/动态目录管理、子代理生命周期管理
- 各类工具集:聊天、云盘、文档、知识库API封装
2026-05-12 00:43:59 +08:00

256 lines
8.2 KiB
Python

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