- 独立 pyproject.toml(pip install -e .) - vendor_hermes.sh 已改为显式路径模式(不再依赖相对目录) - 包含 hermes vendor 快照
789 lines
28 KiB
Python
789 lines
28 KiB
Python
"""
|
||
飞书连接器工具 (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"],
|
||
},
|
||
},
|
||
)
|