""" IMA 连接器工具 (ima_tool.py) 与腾讯会议不同,IMA 是纯 REST API(HTTP POST + JSON), 无需外部脚本,直接在进程内调用。 ┌──────────────────────────────────────────────────────┐ │ 绑定流程:侧边栏 inline UI(填写 Client ID + API Key)│ │ 调用流程:SKILL.md + Agent 对话 │ └──────────────────────────────────────────────────────┘ 凭证存储(per-user JSON): /opt/apps/mindos-next/backend/data/ima_tokens.json 格式:{ "userId": {"client_id": "...", "api_key": "..."} } IMA OpenAPI 文档:https://ima.qq.com/agent-interface """ import json import logging import os import urllib.error import urllib.request from typing import Any, Dict from tools.registry import registry, tool_error, tool_result logger = logging.getLogger(__name__) # ── 常量 ── _BASE_URL = "https://ima.qq.com/" CREDENTIALS_URL = "https://ima.qq.com/agent-interface" _SKILL_VERSION = "1.1.3" # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 凭证存取(per-user,wiki/{userId}/.config/ima.json) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ from tools._user_config import user_config_read, user_config_write, user_config_delete def get_user_credentials(user_id: str) -> Dict[str, str]: """返回 {"client_id": "...", "api_key": "..."} 或空 dict""" creds = user_config_read(user_id, "ima") if creds.get("client_id") and creds.get("api_key"): return creds # 兜底:全局 env cid = os.environ.get("IMA_OPENAPI_CLIENTID", "").strip() key = os.environ.get("IMA_OPENAPI_APIKEY", "").strip() if cid and key: return {"client_id": cid, "api_key": key} return {} def set_user_credentials(user_id: str, client_id: str, api_key: str) -> None: """保存或清除指定用户的 IMA 凭证。两者为空则删除条目。""" client_id = client_id.strip() api_key = api_key.strip() if client_id and api_key: user_config_write(user_id, "ima", {"client_id": client_id, "api_key": api_key}) else: user_config_delete(user_id, "ima") def has_user_credentials(user_id: str) -> bool: return bool(get_user_credentials(user_id)) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # HTTP 调用层(进程内,无需子进程) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ def _ima_call(api_path: str, body: dict, creds: Dict[str, str]) -> Dict[str, Any]: """ 直接发送 HTTP POST 到 IMA OpenAPI。 返回解析后的 JSON dict,出错时返回 {"error": ...}。 """ url = _BASE_URL + api_path.lstrip("/") headers = { "Content-Type": "application/json", "ima-openapi-clientid": creds["client_id"], "ima-openapi-apikey": creds["api_key"], "ima-openapi-ctx": f"skill_version={_SKILL_VERSION}", } data = json.dumps(body).encode("utf-8") req = urllib.request.Request(url, data=data, headers=headers, method="POST") try: with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read().decode("utf-8")) except urllib.error.HTTPError as e: body_text = e.read().decode("utf-8", errors="replace")[:500] return {"error": f"HTTP {e.code}", "detail": body_text} except urllib.error.URLError as e: return {"error": f"网络错误: {e}"} except Exception as e: return {"error": str(e)} # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ① ima_list_profiles — 连接状态检查 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ def _ima_list_profiles_handler(args: dict, **kwargs) -> str: user_id = str(args.get("user_id", "")).strip() if not user_id: return tool_error("user_id 参数必填") if not has_user_credentials(user_id): return tool_result( success=True, profiles=[], count=0, credentialsConfigured=False, credentialsUrl=CREDENTIALS_URL, notice="请前往上述链接获取 Client ID 和 API Key,并在侧边栏绑定。", ) creds = get_user_credentials(user_id) # 用 list_note (limit=1) 验证连通性 logger.info("[ImaTool] checking connectivity for user=%s", user_id[:8]) result = _ima_call("openapi/note/v1/list_note", {"cursor": "", "limit": 1}, creds) if "error" in result: status = "error" message = f"连接异常:{result['error']}" else: status = "connected" message = "IMA 已连接。" profiles = [{ "profileName": f"{user_id[:12]}_ima", "label": "IMA 笔记 & 知识库", "status": status, "credentialsConfigured": True, }] return tool_result( success=True, profiles=profiles, count=len(profiles), credentialsConfigured=True, message=message, ) registry.register( name="ima_list_profiles", toolset="connectors", description="检查 IMA 连接状态,返回账号信息。", emoji="📝", handler=_ima_list_profiles_handler, schema={ "name": "ima_list_profiles", "description": "检查 IMA (QQ 笔记/知识库) 是否已连接,返回连接状态。", "parameters": { "type": "object", "properties": { "user_id": {"type": "string", "description": "MINDOS_USER_ID"}, }, "required": ["user_id"], }, }, ) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ② ima_query — 笔记和知识库操作 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ _ALLOWED_API_PATHS = { # Notes "openapi/note/v1/list_notebook", "openapi/note/v1/list_note", "openapi/note/v1/search_note_book", "openapi/note/v1/get_doc_content", "openapi/note/v1/import_doc", "openapi/note/v1/append_doc", # Knowledge Base "openapi/knowledge/v1/list_knowledge_base", "openapi/knowledge/v1/search_knowledge_base", "openapi/knowledge/v1/create_knowledge_base", "openapi/knowledge/v1/delete_knowledge_base", "openapi/knowledge/v1/add_url", } def _ima_query_handler(args: dict, **kwargs) -> str: user_id = str(args.get("user_id", "")).strip() api_path = str(args.get("api_path", "")).strip() if not user_id: return tool_error("user_id 参数必填") if not api_path: return tool_error("api_path 参数必填") if api_path not in _ALLOWED_API_PATHS: return tool_error( f"不支持的 API 路径:{api_path}。" f"可选:{', '.join(sorted(_ALLOWED_API_PATHS))}" ) if not has_user_credentials(user_id): return tool_error( f"IMA 凭证未配置。请前往 {CREDENTIALS_URL} 获取 Client ID 和 API Key," "并在侧边栏「IMA」处绑定账号。" ) body = args.get("body", {}) if not isinstance(body, dict): return tool_error("body 必须是 JSON 对象") creds = get_user_credentials(user_id) logger.info("[ImaTool] call %s (user=%s)", api_path, user_id[:8]) result = _ima_call(api_path, body, creds) if "error" in result: return tool_error(f"调用失败:{result['error']}") return tool_result(success=True, data=result) registry.register( name="ima_query", toolset="connectors", description="查询或操作 IMA 笔记和知识库。", emoji="📝", handler=_ima_query_handler, schema={ "name": "ima_query", "description": ( "调用 IMA OpenAPI 操作笔记或知识库。需先在侧边栏完成凭证绑定。\n" "笔记接口示例:\n" " 搜索笔记: api_path=openapi/note/v1/search_note_book, body={\"search_type\":0,\"query_info\":{\"title\":\"xx\"},\"start\":0,\"end\":10}\n" " 读取正文: api_path=openapi/note/v1/get_doc_content, body={\"doc_id\":\"xxx\",\"target_content_format\":0}\n" " 新建笔记: api_path=openapi/note/v1/import_doc, body={\"content\":\"## 标题\\n内容\",\"content_format\":1}\n" " 列出笔记: api_path=openapi/note/v1/list_note, body={\"cursor\":\"\",\"limit\":20}\n" ), "parameters": { "type": "object", "properties": { "user_id": {"type": "string", "description": "MINDOS_USER_ID"}, "api_path": { "type": "string", "description": "IMA API 路径", "enum": sorted(_ALLOWED_API_PATHS), }, "body": { "type": "object", "description": "API 请求体(JSON 对象)", }, }, "required": ["user_id", "api_path"], }, }, )