init: MindOS CLI 本地执行体(从 mindOSv2/mindos-cli 独立)
- 独立 pyproject.toml(pip install -e .) - vendor_hermes.sh 已改为显式路径模式(不再依赖相对目录) - 包含 hermes vendor 快照
This commit is contained in:
@@ -0,0 +1,788 @@
|
||||
"""
|
||||
飞书连接器工具 (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"],
|
||||
},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user