Some checks failed
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled
293 lines
11 KiB
Python
293 lines
11 KiB
Python
"""
|
||
微信 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)
|