'init'
Some checks failed
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled
Some checks failed
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled
This commit is contained in:
292
web2_wechat_chatbot.py
Normal file
292
web2_wechat_chatbot.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
微信 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)
|
||||
Reference in New Issue
Block a user