Files
gitlab-instance-0a899031_d2…/web2_wechat_chatbot.py
eric b77a8cadb2
Some checks failed
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled
'init'
2026-03-24 14:46:41 -05:00

293 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
微信 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)