""" 微信 Chatbot 桥接:与 openclaw-wechat 使用的代理 HTTP API 对齐(见 integrations/openclaw-wechat)。 需自备 apiKey 与 proxyUrl(第三方代理服务),本模块提供扫码登录、状态查询与发消息等 REST,供 Web / CLI / Agent 调用。 """ from __future__ import annotations import json from pathlib import Path from typing import Any, Optional import httpx from fastapi import Request from fastapi.responses import JSONResponse from pydantic import BaseModel, Field CONFIG_NAME = "wechat_chatbot.json" STATE_NAME = "wechat_chatbot.state.json" def _config_path(base: Path) -> Path: return base / "config" / CONFIG_NAME def _state_path(base: Path) -> Path: return base / "config" / STATE_NAME def load_wechat_chatbot_config(base_dir: Path) -> dict[str, Any]: p = _config_path(base_dir) if not p.exists(): return {} try: return json.loads(p.read_text(encoding="utf-8")) except Exception: return {} def load_wechat_state(base_dir: Path) -> dict[str, Any]: p = _state_path(base_dir) if not p.exists(): return {} try: return json.loads(p.read_text(encoding="utf-8")) except Exception: return {} def save_wechat_state(base_dir: Path, state: dict[str, Any]) -> None: p = _state_path(base_dir) p.parent.mkdir(parents=True, exist_ok=True) p.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") def mask_api_key(key: str) -> str: k = (key or "").strip() if len(k) <= 8: return "****" if k else "" return f"{k[:4]}…{k[-4:]}" class WechatProxyClient: """与 freestylefly/openclaw-wechat 中 ProxyClient 请求格式一致。""" def __init__( self, api_key: str, base_url: str, account_id: str = "default", timeout: float = 60.0, ): if not api_key or not api_key.strip(): raise ValueError("缺少 apiKey") if not base_url or not base_url.strip(): raise ValueError("缺少 proxyUrl") self.api_key = api_key.strip() self.base_url = base_url.strip().rstrip("/") self.account_id = account_id.strip() or "default" self.timeout = timeout def _request(self, endpoint: str, data: Optional[dict[str, Any]] = None) -> Any: url = f"{self.base_url}{endpoint}" headers = { "Content-Type": "application/json", "X-API-Key": self.api_key, "X-Account-ID": self.account_id, } with httpx.Client(timeout=self.timeout) as client: r = client.post(url, headers=headers, json=data) try: result = r.json() except Exception: raise RuntimeError(f"HTTP {r.status_code}: {r.text[:500]}") from None if not r.is_success: raise RuntimeError(result.get("error") or result.get("message") or f"HTTP {r.status_code}") code = result.get("code") if code in ("1000", "1001", "1002"): d = result.get("data") return d if isinstance(d, (dict, list)) else result if code and str(code) != "1000": raise RuntimeError(result.get("message") or str(result)) d = result.get("data") return d if d is not None else result def get_status(self) -> dict[str, Any]: out = self._request("/v1/account/status") if not isinstance(out, dict): return {"raw": out} return { "valid": out.get("valid", True), "wcId": out.get("wcId"), "isLoggedIn": out.get("isLoggedIn", False), "nickName": out.get("nickName"), "tier": out.get("tier"), "quota": out.get("quota"), "error": out.get("error"), } def get_qr_code(self, device_type: str = "ipad", proxy: str = "10") -> dict[str, str]: out = self._request( "/v1/iPadLogin", {"deviceType": device_type or "ipad", "proxy": proxy or "10"}, ) if not isinstance(out, dict): raise RuntimeError("代理返回格式异常") w_id = out.get("wId") or out.get("w_id") qr_url = out.get("qrCodeUrl") or out.get("qr_code_url") if not w_id or not qr_url: raise RuntimeError("代理未返回二维码信息") return {"wId": str(w_id), "qrCodeUrl": str(qr_url)} def check_login(self, w_id: str) -> dict[str, Any]: out = self._request("/v1/getIPadLoginInfo", {"wId": w_id}) if not isinstance(out, dict): return {"status": "waiting"} st = out.get("status") if st == "logged_in": return { "status": "logged_in", "wcId": out.get("wcId"), "nickName": out.get("nickName"), "headUrl": out.get("headUrl"), } if st == "need_verify": return {"status": "need_verify", "verifyUrl": out.get("verifyUrl")} return {"status": "waiting"} def send_text(self, target_id: str, content: str) -> dict[str, Any]: out = self._request("/v1/sendText", {"wcId": target_id, "content": content}) return out if isinstance(out, dict) else {"result": out} def register_webhook(self, webhook_url: str) -> None: self._request("/v1/webhook/register", {"webhookUrl": webhook_url}) def build_client(cfg: dict[str, Any]) -> WechatProxyClient: return WechatProxyClient( api_key=str(cfg.get("apiKey") or ""), base_url=str(cfg.get("proxyUrl") or ""), account_id=str(cfg.get("accountId") or "default"), ) class WechatConfigBody(BaseModel): apiKey: Optional[str] = None proxyUrl: Optional[str] = None deviceType: Optional[str] = None proxy: Optional[str] = None webhookHost: Optional[str] = None webhookPort: Optional[int] = None webhookPath: Optional[str] = None accountId: Optional[str] = None class WechatSendBody(BaseModel): targetId: str = Field(..., description="接收方 wxid 或群 id") text: str = Field(..., description="文本内容") class WechatWebhookBody(BaseModel): webhookUrl: str def register_wechat_chatbot_routes(app, base_dir: Path) -> None: """在 FastAPI app 上注册 /chatbot/wechat/* 路由。""" @app.get("/chatbot/wechat/config") async def wechat_config_get(): cfg = load_wechat_chatbot_config(base_dir) st = load_wechat_state(base_dir) key = str(cfg.get("apiKey") or "") return { "configured": bool(cfg.get("proxyUrl") and key), "proxyUrl": cfg.get("proxyUrl") or "", "apiKeyMasked": mask_api_key(key), "deviceType": cfg.get("deviceType") or "ipad", "proxy": cfg.get("proxy") or "10", "webhookHost": cfg.get("webhookHost") or "", "webhookPort": int(cfg.get("webhookPort") or 18790), "webhookPath": cfg.get("webhookPath") or "/webhook/wechat", "accountId": cfg.get("accountId") or "default", "session": { "wcId": st.get("wcId"), "nickName": st.get("nickName"), }, } @app.post("/chatbot/wechat/config") async def wechat_config_post(body: WechatConfigBody): cfg = load_wechat_chatbot_config(base_dir) d = body.model_dump(exclude_none=True) for k, v in d.items(): cfg[k] = v out_path = _config_path(base_dir) out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8") return {"ok": True} @app.get("/chatbot/wechat/proxy/status") async def wechat_proxy_status(): cfg = load_wechat_chatbot_config(base_dir) if not cfg.get("apiKey") or not cfg.get("proxyUrl"): return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400) try: c = build_client(cfg) return c.get_status() except Exception as e: return JSONResponse({"error": str(e)}, status_code=502) @app.post("/chatbot/wechat/qr/start") async def wechat_qr_start(): cfg = load_wechat_chatbot_config(base_dir) if not cfg.get("apiKey") or not cfg.get("proxyUrl"): return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400) try: c = build_client(cfg) dt = str(cfg.get("deviceType") or "ipad") px = str(cfg.get("proxy") or "10") return c.get_qr_code(device_type=dt, proxy=px) except Exception as e: return JSONResponse({"error": str(e)}, status_code=502) @app.post("/chatbot/wechat/login/check") async def wechat_login_check(request: Request): try: body = await request.json() except Exception: return JSONResponse({"error": "需要 JSON body: { \"wId\": \"...\" }"}, status_code=400) w_id = (body or {}).get("wId") or (body or {}).get("w_id") if not w_id or not isinstance(w_id, str): return JSONResponse({"error": "缺少 wId"}, status_code=400) cfg = load_wechat_chatbot_config(base_dir) if not cfg.get("apiKey") or not cfg.get("proxyUrl"): return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400) try: c = build_client(cfg) r = c.check_login(w_id.strip()) if r.get("status") == "logged_in" and r.get("wcId"): save_wechat_state( base_dir, { "wcId": r.get("wcId"), "nickName": r.get("nickName"), }, ) return r except Exception as e: return JSONResponse({"error": str(e)}, status_code=502) @app.post("/chatbot/wechat/send") async def wechat_send(body: WechatSendBody): cfg = load_wechat_chatbot_config(base_dir) if not cfg.get("apiKey") or not cfg.get("proxyUrl"): return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400) try: c = build_client(cfg) return c.send_text(body.targetId.strip(), body.text) except Exception as e: return JSONResponse({"error": str(e)}, status_code=502) @app.post("/chatbot/wechat/webhook/register") async def wechat_webhook_reg(body: WechatWebhookBody): cfg = load_wechat_chatbot_config(base_dir) if not cfg.get("apiKey") or not cfg.get("proxyUrl"): return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400) try: c = build_client(cfg) c.register_webhook(body.webhookUrl.strip()) return {"ok": True} except Exception as e: return JSONResponse({"error": str(e)}, status_code=502)