- 独立 pyproject.toml(pip install -e .) - vendor_hermes.sh 已改为显式路径模式(不再依赖相对目录) - 包含 hermes vendor 快照
302 lines
11 KiB
Python
302 lines
11 KiB
Python
"""
|
||
腾讯会议连接器工具 (tencent_meeting_tool.py) — v2
|
||
|
||
重要设计:
|
||
┌─────────────────────────────────────────────────────┐
|
||
│ 绑定流程:侧边栏 inline UI(链接 + Token 粘贴) │
|
||
│ 调用流程:SKILL.md + Agent 对话 │
|
||
└─────────────────────────────────────────────────────┘
|
||
两条路径完全隔离,互不依赖。
|
||
|
||
Token 存储(per-user JSON,优先于全局 env var):
|
||
/opt/apps/mindos-next/backend/data/tencent_meeting_tokens.json
|
||
格式:{ "userId": "HpLzd..." }
|
||
|
||
注意:腾讯会议目前仅支持「个人账号」认证。
|
||
Token 获取地址:https://meeting.tencent.com/ai-skill.html
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import subprocess
|
||
from pathlib import Path
|
||
from typing import Any, Dict, Optional
|
||
|
||
from tools.registry import registry, tool_error, tool_result
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── 腾讯会议脚本路径(候选列表,取第一个存在的)──
|
||
_SCRIPT_CANDIDATES = [
|
||
Path("/root/hermess/skills/tencent-meeting/scripts/tencent_meeting.py"),
|
||
Path("/Users/lidongfang/Downloads/Coding/hermess/skills/tencent-meeting/scripts/tencent_meeting.py"),
|
||
]
|
||
|
||
# ── Per-user Token 存储(wiki/{userId}/.config/tencent_meeting.json) ──
|
||
from tools._user_config import user_config_read, user_config_write, user_config_delete
|
||
|
||
# ── 腾讯会议 Token 获取地址(仅个人账号) ──
|
||
TOKEN_OBTAIN_URL = "https://meeting.tencent.com/ai-skill.html"
|
||
|
||
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
# Token 存取(per-user)
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
def get_user_token(user_id: str) -> str:
|
||
"""
|
||
获取指定用户的 Token。
|
||
优先:per-user 存储 → 兜底:全局 env var(向后兼容)
|
||
"""
|
||
config = user_config_read(user_id, "tencent_meeting")
|
||
token = config.get("token", "").strip()
|
||
if token:
|
||
return token
|
||
# 兜底:全局 env(管理员配置场景)
|
||
return os.environ.get("TENCENT_MEETING_TOKEN", "").strip()
|
||
|
||
|
||
def set_user_token(user_id: str, token: str) -> None:
|
||
"""保存或清除指定用户的 Token。token 为空则删除条目。"""
|
||
token = token.strip()
|
||
if token:
|
||
user_config_write(user_id, "tencent_meeting", {"token": token})
|
||
else:
|
||
user_config_delete(user_id, "tencent_meeting")
|
||
|
||
|
||
def has_user_token(user_id: str) -> bool:
|
||
return bool(get_user_token(user_id))
|
||
|
||
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
# 脚本路径 & 可用性检查
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
def _get_script_path() -> Optional[Path]:
|
||
for p in _SCRIPT_CANDIDATES:
|
||
if p.exists():
|
||
return p
|
||
return None
|
||
|
||
|
||
def _check_tencent_meeting() -> bool:
|
||
"""可用性检查:脚本存在即可(Token 在 handler 中按用户检查)。"""
|
||
return bool(_get_script_path())
|
||
|
||
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
# 子进程抽象层(同步,线程安全)
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
def _run_tencent_meeting(
|
||
tool_name: str,
|
||
arguments: Dict[str, Any],
|
||
user_id: str = "",
|
||
timeout: int = 30,
|
||
) -> tuple:
|
||
"""
|
||
同步调用 tencent_meeting.py tools/call。
|
||
user_id 用于查找 per-user Token。
|
||
返回 (stdout: str, stderr: str, returncode: int)
|
||
"""
|
||
script = _get_script_path()
|
||
if not script:
|
||
return "", "找不到 tencent_meeting.py 脚本,请检查部署配置", -2
|
||
|
||
token = get_user_token(user_id) if user_id else os.environ.get("TENCENT_MEETING_TOKEN", "")
|
||
if not token:
|
||
return "", "腾讯会议 Token 未配置。请在侧边栏绑定账号。", -4
|
||
|
||
arguments.setdefault("_client_info", {
|
||
"os": "linux",
|
||
"agent": "mindos",
|
||
"model": "gemini",
|
||
})
|
||
|
||
params_json = json.dumps({"name": tool_name, "arguments": arguments}, ensure_ascii=False)
|
||
cmd = ["python3", str(script), "tools/call", params_json]
|
||
logger.info("[TencentMeetingTool] exec: tools/call %s (user=%s)", tool_name, user_id[:8] if user_id else "global")
|
||
|
||
env = os.environ.copy()
|
||
env["TENCENT_MEETING_TOKEN"] = token # 注入到子进程
|
||
|
||
try:
|
||
r = subprocess.run(
|
||
cmd,
|
||
capture_output=True,
|
||
timeout=timeout,
|
||
text=True,
|
||
env=env,
|
||
cwd=str(script.parent),
|
||
)
|
||
return r.stdout.strip(), r.stderr.strip(), r.returncode
|
||
except subprocess.TimeoutExpired:
|
||
return "", f"命令超时({timeout}s)", -1
|
||
except FileNotFoundError:
|
||
return "", "python3 未找到", -3
|
||
|
||
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
# ① tencent_meeting_list_profiles
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
def _tencent_meeting_list_profiles_handler(args: dict, **kwargs) -> str:
|
||
user_id = str(args.get("user_id", "")).strip()
|
||
if not user_id:
|
||
return tool_error("user_id 参数必填")
|
||
|
||
token_exists = has_user_token(user_id)
|
||
|
||
if not token_exists:
|
||
return tool_result(
|
||
success=True,
|
||
profiles=[],
|
||
count=0,
|
||
tokenConfigured=False,
|
||
tokenObtainUrl=TOKEN_OBTAIN_URL,
|
||
notice="腾讯会议目前仅支持个人账号认证。请前往上述链接获取 Token 并在侧边栏绑定。",
|
||
)
|
||
|
||
script = _get_script_path()
|
||
if not script:
|
||
return tool_result(
|
||
success=True,
|
||
profiles=[],
|
||
count=0,
|
||
tokenConfigured=True,
|
||
message="腾讯会议脚本未找到,请检查服务器部署。",
|
||
)
|
||
|
||
# 用 convert_timestamp 验证连通性
|
||
stdout, stderr, rc = _run_tencent_meeting("convert_timestamp", {}, user_id=user_id, timeout=15)
|
||
status = "connected" if rc == 0 else "error"
|
||
message = "腾讯会议已连接。" if rc == 0 else f"连接异常:{(stderr or stdout)[:120]}"
|
||
|
||
profiles = [{
|
||
"profileName": f"{user_id[:12]}_tencent",
|
||
"label": "腾讯会议",
|
||
"status": status,
|
||
"tokenConfigured": True,
|
||
"notice": "腾讯会议目前仅支持个人账号认证。",
|
||
}]
|
||
|
||
return tool_result(
|
||
success=True,
|
||
profiles=profiles,
|
||
count=len(profiles),
|
||
tokenConfigured=True,
|
||
message=message,
|
||
)
|
||
|
||
|
||
registry.register(
|
||
name="tencent_meeting_list_profiles",
|
||
toolset="connectors",
|
||
description="检查腾讯会议连接状态,返回账号信息。",
|
||
emoji="📋",
|
||
check_fn=_check_tencent_meeting,
|
||
handler=_tencent_meeting_list_profiles_handler,
|
||
schema={
|
||
"name": "tencent_meeting_list_profiles",
|
||
"description": "检查腾讯会议是否已连接,返回连接状态。",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"user_id": {"type": "string", "description": "MINDOS_USER_ID"},
|
||
},
|
||
"required": ["user_id"],
|
||
},
|
||
},
|
||
)
|
||
|
||
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
# ② tencent_meeting_query(白名单操作)
|
||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
_ALLOWED_TOOLS = {
|
||
"convert_timestamp",
|
||
"schedule_meeting",
|
||
"get_meeting_by_code",
|
||
"get_meeting",
|
||
"get_user_meetings",
|
||
"get_user_ended_meetings",
|
||
"get_smart_minutes",
|
||
"get_records_list",
|
||
}
|
||
|
||
|
||
def _tencent_meeting_query_handler(args: dict, **kwargs) -> str:
|
||
user_id = str(args.get("user_id", "")).strip()
|
||
tool_name = str(args.get("tool_name", "")).strip()
|
||
|
||
if not user_id:
|
||
return tool_error("user_id 参数必填")
|
||
if not tool_name:
|
||
return tool_error("tool_name 参数必填")
|
||
if tool_name not in _ALLOWED_TOOLS:
|
||
return tool_error(
|
||
f"不支持的工具:{tool_name}。可选:{', '.join(sorted(_ALLOWED_TOOLS))}"
|
||
)
|
||
if not has_user_token(user_id):
|
||
return tool_error(
|
||
f"腾讯会议 Token 未配置。请前往 {TOKEN_OBTAIN_URL} 获取 Token,"
|
||
"并在侧边栏「腾讯会议」处绑定账号。"
|
||
)
|
||
|
||
arguments = args.get("arguments", {})
|
||
if not isinstance(arguments, dict):
|
||
return tool_error("arguments 必须是 JSON 对象")
|
||
|
||
stdout, stderr, rc = _run_tencent_meeting(tool_name, arguments, user_id=user_id, timeout=30)
|
||
|
||
if rc != 0:
|
||
return tool_error(f"调用失败(exit {rc}):{(stderr or stdout)[:300]}")
|
||
if not stdout:
|
||
return tool_error(f"无输出。stderr: {stderr[:200]}")
|
||
|
||
try:
|
||
data = json.loads(stdout)
|
||
return tool_result(success=True, data=data)
|
||
except (json.JSONDecodeError, ValueError):
|
||
return tool_result(success=True, output=stdout[:3000])
|
||
|
||
|
||
registry.register(
|
||
name="tencent_meeting_query",
|
||
toolset="connectors",
|
||
description="执行腾讯会议数据查询(会议列表/纪要/录制等)。",
|
||
emoji="📹",
|
||
check_fn=_check_tencent_meeting,
|
||
handler=_tencent_meeting_query_handler,
|
||
schema={
|
||
"name": "tencent_meeting_query",
|
||
"description": (
|
||
"调用腾讯会议 MCP 工具。需先在侧边栏完成 Token 绑定。\n"
|
||
"示例:\n"
|
||
" 查当前时间: tool_name=convert_timestamp, arguments={}\n"
|
||
" 查未来会议: tool_name=get_user_meetings, arguments={\"pos\": <unix_ts>}\n"
|
||
" 查 AI 纪要: tool_name=get_smart_minutes, arguments={\"meeting_id\": \"xxx\"}\n"
|
||
" 预约会议: tool_name=schedule_meeting, arguments={\"subject\":\"xx\",\"start_time\":\"...\",\"end_time\":\"...\"}\n"
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"user_id": {"type": "string", "description": "MINDOS_USER_ID"},
|
||
"tool_name": {
|
||
"type": "string",
|
||
"description": "腾讯会议工具名",
|
||
"enum": sorted(_ALLOWED_TOOLS),
|
||
},
|
||
"arguments": {
|
||
"type": "object",
|
||
"description": "工具参数(_client_info 由工具自动注入,不需要传)",
|
||
},
|
||
},
|
||
"required": ["user_id", "tool_name"],
|
||
},
|
||
},
|
||
)
|