""" 腾讯会议连接器工具 (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\": }\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"], }, }, )