MindOS_CLI/mindcli/_vendor/tools/feishu_tool.py
lidf 69dd868e2f init: MindOS CLI 本地执行体(从 mindOSv2/mindos-cli 独立)
- 独立 pyproject.toml(pip install -e .)
- vendor_hermes.sh 已改为显式路径模式(不再依赖相对目录)
- 包含 hermes vendor 快照
2026-04-28 13:12:54 +08:00

789 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
飞书连接器工具 (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 — 创建新 profilePTY返回授权 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 用户 IDMINDOS_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_profilePTY 模式)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
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"],
},
},
)