""" 飞书连接器工具 (feishu_tool.py) — v5:同步抽象层 核心设计: _run_cli(*args, timeout) — 唯一的子进程执行入口,subprocess.run() 同步调用。 _run_cli_pty(*args, timeout) — 需要 PTY 的命令(config init),用 script -qc 包装。 所有工具 handler 都是同步函数,在任何线程上下文(主线程 / SSE worker / executor) 都能可靠执行。不再使用 asyncio.run() / create_subprocess_exec。 后台轮询(device-code polling)使用 threading.Thread + subprocess.run。 工具清单: feishu_list_profiles — 列出当前用户飞书账号 feishu_init_profile — 创建新 profile(PTY),返回授权 URL feishu_auth_domain — Device Flow 授权域 feishu_query — 白名单数据操作 """ import json import logging import re import shutil import subprocess import threading import time from pathlib import Path from typing import Any, Dict, Optional from tools.registry import registry, tool_error, tool_result logger = logging.getLogger(__name__) _LARK_CLI = "lark-cli" _URL_FEISHU_RE = re.compile(r"https://\S+feishu\S+") def _check_lark_cli() -> bool: return shutil.which(_LARK_CLI) is not None # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 通用子进程抽象层 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ def _run_cli(*args: str, timeout: int = 30) -> tuple: """ 同步执行 lark-cli 命令。任何线程安全。 返回 (stdout: str, stderr: str, returncode: int) """ cmd = [_LARK_CLI] + list(args) logger.info("[FeishuTool] exec: %s", " ".join(cmd)) try: r = subprocess.run( cmd, capture_output=True, timeout=timeout, text=True, ) return r.stdout.strip(), r.stderr.strip(), r.returncode except subprocess.TimeoutExpired: return "", f"命令超时({timeout}s)", -1 except FileNotFoundError: return "", "lark-cli 未安装", -2 def _run_cli_pty(inner_cmd: str, timeout: int = 25) -> str: """ 通过 `script -qc` 分配 PTY 执行命令,流式读取 stdout。 关键设计:用 Popen + readline(非 subprocess.run),找到目标内容立即返回。 原因:config init 会阻塞 600s 等用户点授权链接,subprocess.run 必须等进程退出 才把 stdout 交回,永远拿不到 URL。改为逐行读,找到 URL 就返回,进程留在后台。 """ import threading cmd = ["script", "-qc", inner_cmd, "/dev/null"] logger.info("[FeishuTool] exec-pty (stream): %s", inner_cmd) lines: list = [] done = threading.Event() try: proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, bufsize=1, ) except FileNotFoundError: return "" def _read(): try: assert proc.stdout for line in proc.stdout: lines.append(line) logger.debug("[FeishuTool] pty: %r", line[:80]) if _URL_FEISHU_RE.search(line): done.set() # URL 已找到,通知主线程返回 except Exception as e: logger.debug("[FeishuTool] pty reader: %s", e) finally: done.set() # 进程结束也通知 t = threading.Thread(target=_read, daemon=True) t.start() done.wait(timeout=timeout) # 找到 URL 或超时就返回 # 不 kill 进程——让 config init 继续等用户点击并完成注册 return "".join(lines) def _parse_json(text: str) -> Optional[dict]: """从多行文本中提取第一个 JSON 对象。""" for line in text.splitlines(): line = line.strip() if not line: continue try: obj = json.loads(line) if isinstance(obj, dict): return obj except json.JSONDecodeError: continue # 尝试整体解析(可能是多行 JSON) try: obj = json.loads(text) if isinstance(obj, dict): return obj except (json.JSONDecodeError, ValueError): pass return None # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # Profile 隔离工具 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 飞书元数据存储(wiki/{userId}/.config/feishu.json) # 格式:{ "aliases": { profileName: alias }, "pending": { profileName: timestamp } } # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ from tools._user_config import user_config_read, user_config_write _PENDING_TTL_SECONDS = 10 * 60 # 10 分钟 def _uid_from_profile(profile_name: str) -> str: """从 profileName (如 '11c1cece-242_1') 反推 userId 前缀用于查找。 实际的 userId 需要通过 _find_full_uid() 匹配。""" return profile_name.rsplit("_", 1)[0] # '11c1cece-242' def _feishu_config_read(user_id: str) -> dict: """读取该用户的飞书配置(aliases + pending)""" data = user_config_read(user_id, "feishu") data.setdefault("aliases", {}) data.setdefault("pending", {}) return data def _feishu_config_write(user_id: str, data: dict) -> None: """写入该用户的飞书配置""" user_config_write(user_id, "feishu", data) def _pending_register(profile_name: str, user_id: str = "") -> None: """将新建的 profile 写入待定登记表。""" if not user_id: return data = _feishu_config_read(user_id) data["pending"][profile_name] = time.time() _feishu_config_write(user_id, data) def _pending_remove(profile_name: str, user_id: str = "") -> None: """从待定登记表中移除(授权完成或已清理)。""" if not user_id: return data = _feishu_config_read(user_id) data["pending"].pop(profile_name, None) _feishu_config_write(user_id, data) def _pending_get_ts(profile_name: str, user_id: str = "") -> float: """返回 pending 中该 profile 的创建时间戳,不存在返回 0。""" if not user_id: return 0 data = _feishu_config_read(user_id) return data["pending"].get(profile_name, 0) def _alias_get(profile_name: str, user_id: str = "") -> str: """返回 profile 的 alias,没有则返回空字符串。""" if not user_id: return "" data = _feishu_config_read(user_id) return data["aliases"].get(profile_name, "") def _alias_set(profile_name: str, alias: str, user_id: str = "") -> None: """设置或更新 profile 的 alias。alias 为空时删除条目。""" if not user_id: return data = _feishu_config_read(user_id) if alias: data["aliases"][profile_name] = alias else: data["aliases"].pop(profile_name, None) _feishu_config_write(user_id, data) def _make_profile_name(user_id: str, index: int = 1) -> str: return f"{user_id[:12]}_{index}" def _get_all_profiles() -> list: stdout, _, rc = _run_cli("profile", "list", timeout=10) if rc != 0: return [] try: parsed = json.loads(stdout) return parsed if isinstance(parsed, list) else [] except (json.JSONDecodeError, ValueError): return [] def _filter_user_profiles(profiles: list, user_id: str) -> list: prefix = user_id[:12] + "_" result = [] for p in profiles: name = p.get("name", p) if isinstance(p, dict) else str(p) if name.startswith(prefix): result.append(p) return result def _get_profile_name(p) -> str: """从 profile 条目中提取名字(兼容 str 和 dict 两种格式)。""" if isinstance(p, dict): return p.get("name", "") return str(p) def _check_profile_token(profile_name: str, user_id: str = "") -> Optional[dict]: """ 检查 profile 是否有有效 token。 返回 auth status dict(含 expiresAt 等),或 None(无 token / 超时)。 自动清理超时未授权的 pending profile。 """ stdout, _, rc = _run_cli("auth", "status", "--profile", profile_name, timeout=8) if rc != 0: # 没有 token 或命令失败 → 检查 pending 超时 created_at = _pending_get_ts(profile_name, user_id=user_id) if created_at and (time.time() - created_at) > _PENDING_TTL_SECONDS: logger.info("[FeishuTool] pending profile 超时,自动清理: %s", profile_name) _run_cli("profile", "delete", "--name", profile_name, timeout=5) _pending_remove(profile_name, user_id=user_id) return None # 解析 JSON try: data = json.loads(stdout) expires_at = data.get("expiresAt", "") if not expires_at: return None # 判断 tokenStatus token_status = data.get("tokenStatus", "") # 如果是 valid 或 needs_refresh,都认为是有效授权的 profile if token_status not in ["valid", "needs_refresh"]: return None _pending_remove(profile_name, user_id=user_id) return data except Exception: return None # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ① feishu_list_profiles # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ def _feishu_list_profiles_handler(args: dict, **kwargs) -> str: user_id = str(args.get("user_id", "")).strip() if not user_id: return tool_error("user_id 参数必填") all_profiles = _get_all_profiles() user_profiles = _filter_user_profiles(all_profiles, user_id) # 只返回有有效 token 的 profile(自动清理超时垃圾 profile) active_profiles = [] for p in user_profiles: name = _get_profile_name(p) token_info = _check_profile_token(name, user_id=user_id) if token_info: entry = { "name": name, "active": True, "user": token_info.get("identity", ""), "tokenStatus": token_info.get("tokenStatus", "valid"), "expiresAt": token_info.get("expiresAt", ""), "alias": _alias_get(name, user_id=user_id), } active_profiles.append(entry) return tool_result( success=True, profiles=active_profiles, count=len(active_profiles), message=( f"找到 {len(active_profiles)} 个已授权的飞书账号。" if active_profiles else "尚未绑定任何飞书账号。" ), ) registry.register( name="feishu_list_profiles", toolset="connectors", description="列出当前用户已绑定的所有飞书账号。", emoji="📋", check_fn=_check_lark_cli, handler=_feishu_list_profiles_handler, schema={ "name": "feishu_list_profiles", "description": "列出当前用户的所有飞书 profile。", "parameters": { "type": "object", "properties": { "user_id": { "type": "string", "description": "当前 MindOS 用户 ID(MINDOS_USER_ID)", }, }, "required": ["user_id"], }, }, ) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ① feishu_rename_profile(设置别名) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ def _feishu_rename_profile_handler(args: dict, **kwargs) -> str: user_id = str(args.get("user_id", "")).strip() profile_name = str(args.get("profile_name", "")).strip() alias = str(args.get("alias", "")).strip() if not user_id or not profile_name: return tool_error("user_id 和 profile_name 参数必填") # 安全校验:必须属于当前用户 prefix = user_id[:12] + "_" if not profile_name.startswith(prefix): return tool_error(f"profile '{profile_name}' 不属于当前用户") _alias_set(profile_name, alias, user_id=user_id) return tool_result( success=True, profile_name=profile_name, alias=alias or None, message=f"已{'设置' if alias else '清除'}别名:{profile_name} → {alias or '(无)'}", ) registry.register( name="feishu_rename_profile", toolset="connectors", description="为飞书 profile 设置(或清除)用户友好的别名。", emoji="✏️", check_fn=_check_lark_cli, handler=_feishu_rename_profile_handler, schema={ "name": "feishu_rename_profile", "description": "为指定飞书 profile 设置别名(如'企业号'、'个人号')。alias 传空字符串则清除别名。", "parameters": { "type": "object", "properties": { "user_id": {"type": "string", "description": "MindOS 用户 ID"}, "profile_name": {"type": "string", "description": "lark-cli profile 名(如 11c1cece-242_1)"}, "alias": {"type": "string", "description": "用户友好别名,如'企业号'。传空字符串清除别名"}, }, "required": ["user_id", "profile_name", "alias"], }, }, ) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ② feishu_init_profile(PTY 模式) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ def _feishu_init_profile_handler(args: dict, **kwargs) -> str: user_id = str(args.get("user_id", "")).strip() if not user_id: return tool_error("user_id 参数必填") account_name = str(args.get("account_name", "")).strip() all_profiles = _get_all_profiles() user_profiles = _filter_user_profiles(all_profiles, user_id) next_index = len(user_profiles) + 1 if account_name: profile_name = f"{user_id[:12]}_{account_name}" else: profile_name = _make_profile_name(user_id, next_index) # 检查是否已存在 all_names = [_get_profile_name(p) for p in all_profiles] if profile_name in all_names: # 如果已存在但仍在 pending(用户之前没完成授权),视为重试 token = _check_profile_token(profile_name, user_id=user_id) if token: return tool_result( success=True, profile_name=profile_name, status="already_exists", message=f"Profile '{profile_name}' 已存在且已授权。可直接进行域授权。", ) # 没有有效 token → 重新走 init(可能用户上次没完成) # 通过 PTY 执行 config init(流式读取,找到 URL 立即返回) inner_cmd = f"{_LARK_CLI} config init --new --name {profile_name}" stdout = _run_cli_pty(inner_cmd, timeout=25) # 提取 URL m = _URL_FEISHU_RE.search(stdout) if m: _pending_register(profile_name, user_id=user_id) # 写入待定登记表,启动 10 分钟超时计时 return tool_result( success=True, profile_name=profile_name, verification_url=m.group(0), message="请点击链接在飞书中创建应用。完成后告诉我。", ) return tool_error( "未获取到飞书授权链接。请检查服务器网络。" ) registry.register( name="feishu_init_profile", toolset="connectors", description="创建新飞书 Profile 并返回授权 URL。", emoji="🔗", check_fn=_check_lark_cli, handler=_feishu_init_profile_handler, schema={ "name": "feishu_init_profile", "description": ( "为当前用户创建新的飞书 profile。返回 verification_url。" "用户完成后再调 feishu_auth_domain 进行域授权。" ), "parameters": { "type": "object", "properties": { "user_id": { "type": "string", "description": "MINDOS_USER_ID", }, "account_name": { "type": "string", "description": "可选别名(如 work、personal)", }, }, "required": ["user_id"], }, }, ) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ③ feishu_auth_domain(同步 Device Flow) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ _AUTH_DOMAINS = { "approval", "attendance", "base", "calendar", "contact", "docs", "drive", "event", "im", "mail", "minutes", "sheets", "slides", "task", "vc", "wiki", "all", } def _poll_device_code_bg(profile_name: str, device_code: str, domain: str) -> None: """后台线程:同步轮询 --device-code 直到用户完成授权。""" stdout, stderr, rc = _run_cli( "auth", "login", "--device-code", device_code, "--json", "--profile", profile_name, timeout=620, ) logger.info( "[FeishuTool] poll done %s/%s (rc=%d): %s", profile_name, domain, rc, stdout[:200], ) def _feishu_auth_domain_handler(args: dict, **kwargs) -> str: user_id = str(args.get("user_id", "")).strip() if not user_id: return tool_error("user_id 参数必填") domain = str(args.get("domain", "")).strip().lower() if not domain: return tool_error("domain 参数必填") for d in domain.split(","): d = d.strip() if d and d not in _AUTH_DOMAINS: return tool_error(f"不支持的 domain:{d}") profile_name = str(args.get("profile_name", "")).strip() if not profile_name: all_profiles = _get_all_profiles() user_profiles = _filter_user_profiles(all_profiles, user_id) if not user_profiles: return tool_error("没有飞书 profile。请先调用 feishu_init_profile。") profile_name = _get_profile_name(user_profiles[0]) # 同步执行 auth login --no-wait(立即返回 JSON) stdout, stderr, rc = _run_cli( "auth", "login", "--domain", domain, "--json", "--no-wait", "--profile", profile_name, timeout=15, ) if rc != 0 and not stdout: return tool_error(f"auth 失败(exit {rc}):{stderr[:200]}") data = _parse_json(stdout) if not data: return tool_error( f"lark-cli 无 JSON 输出。" f"请确认 profile '{profile_name}' 已初始化。" f"stdout: {stdout[:200]}" ) # 显式错误 if data.get("ok") is False and "error" in data: err = data["error"] if isinstance(err, dict): if err.get("type") == "config": return tool_error( f"Profile '{profile_name}' 未初始化。" "请先调用 feishu_init_profile。" ) return tool_error( f"授权失败:{err.get('message', '')}。{err.get('hint', '')}" ) return tool_error(f"授权失败:{err}") verification_url = data.get("verification_url", "") device_code = data.get("device_code", "") if not verification_url: return tool_error( f"授权响应缺少 verification_url。" f"数据:{json.dumps(data, ensure_ascii=False)[:200]}" ) # 后台线程轮询 device_code if device_code: t = threading.Thread( target=_poll_device_code_bg, args=(profile_name, device_code, domain), daemon=True, ) t.start() logger.info("[FeishuTool] polling thread started for %s/%s", profile_name, domain) return tool_result( success=True, profile_name=profile_name, domain=domain, verification_url=verification_url, expires_in=data.get("expires_in", 600), message=f"请点击链接授权飞书 {domain} 域。", ) registry.register( name="feishu_auth_domain", toolset="connectors", description="授权飞书域(Device Flow),返回授权链接。", emoji="🔐", check_fn=_check_lark_cli, handler=_feishu_auth_domain_handler, schema={ "name": "feishu_auth_domain", "description": ( "对指定 profile 的飞书域进行 OAuth 授权。" "返回 verification_url(可点击链接),后台自动轮询等待完成。" ), "parameters": { "type": "object", "properties": { "user_id": {"type": "string", "description": "MINDOS_USER_ID"}, "domain": {"type": "string", "description": "飞书域(calendar/docs/minutes/all)"}, "profile_name": {"type": "string", "description": "profile 名(不传则用第一个)"}, }, "required": ["user_id", "domain"], }, }, ) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ④ feishu_query(白名单数据操作) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ _ALLOWED_ACTIONS: Dict[str, set] = { "calendar": {"+agenda", "+freebusy", "+create"}, "docs": {"+search", "+fetch", "+create"}, "minutes": {"+search", "+download", "minutes"}, "im": {"+send-msg"}, "base": {"+search"}, "mail": {"+search"}, "wiki": {"+search"}, "task": {"+list", "+create"}, "auth": {"status"}, } _SAFE_FLAGS = { # calendar / docs "--query", "--page-size", "--start", "--end", "--summary", "--description", "--url", "--folder-token", "--participant-ids", "--owner-ids", "--title", # docs +fetch "--doc", "--limit", "--offset", # minutes "--minute-tokens", "--minute-token", "--url-only", "--output", "--overwrite", # low-level API "--params", # common "--page-token", "--as", } def _feishu_query_handler(args: dict, **kwargs) -> str: user_id = str(args.get("user_id", "")).strip() if not user_id: return tool_error("user_id 参数必填") module = str(args.get("module", "")).strip().lower() action = str(args.get("action", "")).strip() if module not in _ALLOWED_ACTIONS: return tool_error( f"不支持的模块:{module}。" f"可选:{', '.join(sorted(_ALLOWED_ACTIONS.keys()))}" ) allowed = _ALLOWED_ACTIONS[module] if action not in allowed: return tool_error( f"模块 {module} 不允许 '{action}'。可选:{', '.join(sorted(allowed))}" ) profile_name = str(args.get("profile_name", "")).strip() if not profile_name: all_profiles = _get_all_profiles() user_profiles = _filter_user_profiles(all_profiles, user_id) if not user_profiles: return tool_error("没有飞书 profile。请先绑定飞书账号。") profile_name = _get_profile_name(user_profiles[0]) extra_args_raw = args.get("extra_args", []) if isinstance(extra_args_raw, str): extra_args_raw = extra_args_raw.split() extra_args = [str(a) for a in extra_args_raw if isinstance(a, str)] # 安全检查 for a in extra_args: if a.startswith("-") and a not in _SAFE_FLAGS: return tool_error(f"不允许的参数:{a}") # 特殊处理:minutes minutes get 需要由工具构造 --params JSON # 避免 LLM 传入时双引号丢失问题 if module == "minutes" and action == "minutes" and extra_args and extra_args[0] == "get": # 从 extra_args 中提取 minute_token token = None i = 0 cleaned_args = [] while i < len(extra_args): a = extra_args[i] if a in ("--minute-token", "--minute-tokens") and i + 1 < len(extra_args): token = extra_args[i + 1].strip() i += 2 elif a == "--params" and i + 1 < len(extra_args): # 尝试从 --params 中提取 minute_token try: import json as _json p = _json.loads(extra_args[i + 1]) token = p.get("minute_token", token) except Exception: pass i += 2 else: cleaned_args.append(a) i += 1 if not token: return tool_error( "minutes minutes get 需要 minute_token。" "请传入 extra_args=[\"get\", \"--minute-token\", \"TOKEN\"]。" "TOKEN 是飞书妙记 URL 末尾的字符串,如 obcnXXXX。" ) # 由工具内部构造正确的 --params JSON(确保双引号正确) import json as _json params_json = _json.dumps({"minute_token": token}) cli_args = [ module, action, "--profile", profile_name, "get", "--params", params_json ] + [a for a in cleaned_args if a != "get"] stdout, stderr, rc = _run_cli(*cli_args, timeout=30) if rc != 0: return tool_error(f"执行失败(exit {rc}):{stderr[:300] or stdout[:300]}") try: data = _json.loads(stdout) return tool_result(success=True, data=data) except (ValueError, _json.JSONDecodeError): return tool_result(success=True, output=stdout[:2000]) # 同步执行(lark-cli 所有命令默认 JSON 输出,不追加 --json) cli_args = [module, action, "--profile", profile_name] + extra_args stdout, stderr, rc = _run_cli(*cli_args, timeout=30) if rc != 0: return tool_error(f"执行失败(exit {rc}):{stderr[:300] or stdout[:300]}") # 尝试 JSON 解析 try: data = json.loads(stdout) return tool_result(success=True, data=data) except (json.JSONDecodeError, ValueError): if stdout: return tool_result(success=True, output=stdout[:2000]) return tool_error(f"无输出。stderr: {stderr[:200]}") registry.register( name="feishu_query", toolset="connectors", description="执行飞书数据查询(日历/文档/妙记等)。", emoji="📊", check_fn=_check_lark_cli, handler=_feishu_query_handler, schema={ "name": "feishu_query", "description": ( "执行飞书数据操作。需先完成 init + auth。\n" "示例:\n" " 查日程: module=calendar, action=+agenda\n" " 搜文档: module=docs, action=+search, extra_args=[\"--query\",\"关键词\"]\n" " 查妙记: module=minutes, action=+search\n" " 查授权: module=auth, action=status\n" ), "parameters": { "type": "object", "properties": { "user_id": {"type": "string", "description": "MINDOS_USER_ID"}, "module": { "type": "string", "description": "飞书模块", "enum": sorted(_ALLOWED_ACTIONS.keys()), }, "action": {"type": "string", "description": "操作(如 +agenda、+search)"}, "profile_name": {"type": "string", "description": "profile 名(不传则用第一个)"}, "extra_args": { "type": "array", "items": {"type": "string"}, "description": "额外参数,如 [\"--query\",\"方案\"]", }, }, "required": ["user_id", "module", "action"], }, }, )