refactor: extract shared modules chatwoot_client.py + inboxes_io.py (v1.8)

- chatwoot_client.py (364 lines): unified Chatwoot session auth + API calls
- inboxes_io.py (122 lines): unified inboxes.json read/write/validate
- provision_server.py: deleted 9 duplicate functions (-188 lines)
- ws_agent: deleted 5 duplicate auth functions, removed import requests
- All imports use sys.path.insert for cross-directory access
- Zero behavior changes, pure DRY refactor
This commit is contained in:
Chatwoot AI Agent Dev
2026-06-06 03:55:21 +00:00
parent 91104e58cf
commit 980c090873
3 changed files with 528 additions and 188 deletions
+42 -188
View File
@@ -12,19 +12,20 @@ Returns JSON for PHP to save to chathub_tenant table.
import json
import logging
import os
import secrets
import threading
import string
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import bottle
# ── Shared modules (same repo) ───────────────────────────────────
# provision_server.py is in chatwoot-ai-agent/, so sibling import works
import chatwoot_client
import inboxes_io
# ── logging ──────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
@@ -37,117 +38,19 @@ log = logging.getLogger("provision")
WORKSPACE_DIR = Path("/app/working/workspaces")
SCRIPT_DIR = WORKSPACE_DIR / "wordpress" / "skills" / "wordpress-cli"
INBOXES_PATH = SCRIPT_DIR / "inboxes.json"
AUTH_FILE = SCRIPT_DIR / "chatwoot_auth.json"
# Chatwoot base config
CW_BASE = os.environ.get("CW_BASE", "http://localhost:3000")
CW_INTERNAL = os.environ.get("CW_INTERNAL", "http://chatwoot-chatwoot-1:3000")
CW_ACCOUNT_ID = int(os.environ.get("CW_ACCOUNT_ID", "1"))
CW_PLATFORM_TOKEN = os.environ.get("CW_PLATFORM_TOKEN", "")
# Point chatwoot_client at the same auth file
chatwoot_client.CW_AUTH_FILE = SCRIPT_DIR / "chatwoot_auth.json"
# Enable CW_ADMIN_EMAIL/PASSWORD env var reading (chatwoot_client reads both CW_EMAIL and CW_ADMIN_EMAIL)
chatwoot_client.CW_BASE = os.environ.get("CW_BASE", "http://localhost:3000")
chatwoot_client.CW_INTERNAL = os.environ.get("CW_INTERNAL", "http://chatwoot-chatwoot-1:3000")
chatwoot_client.CW_ACCOUNT_ID = int(os.environ.get("CW_ACCOUNT_ID", "1"))
chatwoot_client.CW_PLATFORM_TOKEN = os.environ.get("CW_PLATFORM_TOKEN", "")
# API key for provision/suspend/activate endpoints
CHATHUB_API_KEY = os.environ.get("CHATHUB_API_KEY", "chathub-default-key-change-me")
def _gen_password(length: int = 14) -> str:
chars = string.ascii_letters + string.digits + '!@#$%^&*()_+-='
return ''.join(secrets.choice(chars) for _ in range(length))
def _relogin_chatwoot() -> dict:
email = os.environ.get("CW_ADMIN_EMAIL")
password = os.environ.get("CW_ADMIN_PASSWORD")
if not email:
raise RuntimeError("CW_ADMIN_EMAIL not set — cannot login")
if not password:
raise RuntimeError("CW_ADMIN_PASSWORD not set — cannot login")
url = f"{CW_BASE}/auth/sign_in"
payload = json.dumps({"email": email, "password": password}).encode()
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"}, method="POST")
resp = urllib.request.urlopen(req, timeout=15)
headers = {k.lower(): v for k, v in resp.headers.items()}
data = {
"access-token": headers.get("access-token", ""),
"client": headers.get("client", ""),
"expiry": headers.get("expiry", ""),
"uid": headers.get("uid", ""),
"updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
AUTH_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
log.info("Chatwoot session refreshed for %s", data["uid"])
return {
"access-token": data["access-token"],
"client": data["client"],
"expiry": data["expiry"],
"uid": data["uid"],
"Content-Type": "application/json",
}
def _get_session_headers() -> dict:
"""Load session from file, auto-renew if expired or missing."""
import time as _t
if AUTH_FILE.exists():
data = json.loads(AUTH_FILE.read_text())
expiry = int(data.get("expiry", 0))
if expiry - _t.time() < 3600:
log.info("Session < 1h (%ds left), renewing", expiry - int(_t.time()))
return _relogin_chatwoot()
if all([data.get("access-token"), data.get("client"), data.get("uid")]):
return {
"access-token": data["access-token"],
"client": data["client"],
"expiry": data["expiry"],
"uid": data["uid"],
"Content-Type": "application/json",
}
return _relogin_chatwoot()
def _call_cw(method: str, path: str, body: Optional[dict], retries: int = 3) -> dict:
"""Call Chatwoot API with retry on 401 (auto-renew session)."""
url = f"{CW_BASE}{path}"
last_err = None
for attempt in range(retries):
headers = _get_session_headers()
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 401 and attempt < retries - 1:
log.warning(f"401 on {method} {path} (attempt {attempt+1}), re-login & retry")
AUTH_FILE.unlink(missing_ok=True)
continue
body_text = e.read().decode() if e.fp else ""
raise RuntimeError(f"CW API error {e.code} on {method} {path}: {body_text}")
raise RuntimeError(f"Exhausted {retries} retries on {method} {path}: {last_err}")
def _call_internal(method: str, path: str,
body: Optional[dict],
extra_headers: Optional[dict] = None,
retries: int = 3) -> dict:
"""Call Chatwoot internal (platform) API with retry."""
url = f"{CW_INTERNAL}{path}"
for attempt in range(retries):
headers = {"Content-Type": "application/json"}
if extra_headers:
headers.update(extra_headers)
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
if attempt < retries - 1:
log.warning(f"Internal API error {e.code} on {method} {path}, retry {attempt+1}")
continue
body_text = e.read().decode() if e.fp else ""
raise RuntimeError(f"Internal API error {e.code} on {method} {path}: {body_text}")
raise RuntimeError(f"Exhausted {retries} retries on internal {method} {path}")
def _check_api_key():
"""Verify X-API-Key header matches CHATHUB_API_KEY."""
key = bottle.request.get_header("X-API-Key", "")
@@ -159,33 +62,33 @@ def _check_api_key():
def _create_agent(email: str, name: str = "") -> dict:
"""Create a Chatwoot user + agent. Returns {agent_cw_id, password}.
Rolls back (deletes user) if agent creation fails."""
password = _gen_password()
password = chatwoot_client._gen_password()
display_name = name or email.split("@")[0]
user = _call_internal(
user = chatwoot_client._call_internal(
"POST",
"/platform/api/v1/users",
{"name": display_name, "email": email, "password": password},
extra_headers={"api_access_token": CW_PLATFORM_TOKEN},
extra_headers={"api_access_token": chatwoot_client.CW_PLATFORM_TOKEN},
)
if not user.get("id"):
raise RuntimeError(f"Platform API user creation failed: {user}")
uid = user["id"]
try:
agent = _call_cw(
agent = chatwoot_client._call_cw(
"POST",
f"/api/v1/accounts/{CW_ACCOUNT_ID}/agents",
f"/api/v1/accounts/{chatwoot_client.CW_ACCOUNT_ID}/agents",
{"email": email, "name": display_name, "role": "agent"},
)
except Exception as e:
# Rollback: delete the orphaned platform user
try:
_call_internal(
chatwoot_client._call_internal(
"DELETE",
f"/platform/api/v1/users/{uid}",
None,
extra_headers={"api_access_token": CW_PLATFORM_TOKEN},
extra_headers={"api_access_token": chatwoot_client.CW_PLATFORM_TOKEN},
)
except Exception:
pass
@@ -200,9 +103,9 @@ def _create_agent(email: str, name: str = "") -> dict:
def _add_agent_to_team(agent_cw_id: int, team_id: int) -> None:
"""Assign agent to team."""
try:
_call_cw(
chatwoot_client._call_cw(
"POST",
f"/api/v1/accounts/{CW_ACCOUNT_ID}/teams/{team_id}/team_members",
f"/api/v1/accounts/{chatwoot_client.CW_ACCOUNT_ID}/teams/{team_id}/team_members",
{"user_ids": [agent_cw_id]},
)
except urllib.error.HTTPError as e:
@@ -212,56 +115,6 @@ def _add_agent_to_team(agent_cw_id: int, team_id: int) -> None:
log.warning("add_agent_to_team failed: agent=%s team=%s err=%s", agent_cw_id, team_id, e)
def _read_inboxes() -> dict:
if INBOXES_PATH.exists():
return json.loads(INBOXES_PATH.read_text(encoding="utf-8"))
return {
"_meta": {
"version": "1.1",
"updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"description": "Chatwoot WS Agent inbox routing config \u2014 hot-reloadable",
},
}
def _write_inboxes(config: dict) -> None:
INBOXES_PATH.parent.mkdir(parents=True, exist_ok=True)
INBOXES_PATH.write_text(
json.dumps(config, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def _ensure_agent_workspace(agent_id: str, name: str) -> Path:
agent_dir = WORKSPACE_DIR / agent_id
agent_dir.mkdir(parents=True, exist_ok=True)
return agent_dir
def _build_inbox_entry(name: str, domain: str, channel: str,
inbox_id: int, inbox_token: str, agent_id: str) -> dict:
return {
"name": name,
"type": channel,
"target_agent": agent_id,
"system_prompt": (
f"You are a customer service agent for {name} ({domain}). "
f"Answer questions professionally in the customer's language. "
f"If you cannot fully resolve the issue, end with [HANDOFF]."
),
"prompt_template": (
"Customer '{sender_name}' sent this message:\n\n"
"{customer_msg}\n\n"
"Write a direct reply (no preamble, no markdown). "
"Keep it concise (2-4 sentences). "
"Use the same language as the customer."
),
"note_prefix": f"\U0001f916 AI \u81ea\u52a8\u56de\u590d ({name})",
"signature": "",
"status": "active",
}
# ── Idempotency store ────────────────────────────────────────────
# Stores {key: response_dict} with 5-minute TTL.
_IDEMPOTENT_RESULTS: dict[str, dict] = {}
@@ -358,9 +211,9 @@ def provision():
agent_info = _create_agent(email, name)
# 2. Create team
team = _call_cw(
team = chatwoot_client._call_cw(
"POST",
f"/api/v1/accounts/{CW_ACCOUNT_ID}/teams",
f"/api/v1/accounts/{chatwoot_client.CW_ACCOUNT_ID}/teams",
{
"name": f"{name} 客服团队",
"description": f"{name} 的专属客服团队(限制 {max_agents} 席)",
@@ -382,9 +235,9 @@ def provision():
"welcome_tagline": f"您好!欢迎访问 {name},请问有什么可以帮您?",
},
}
inbox = _call_cw(
inbox = chatwoot_client._call_cw(
"POST",
f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes",
f"/api/v1/accounts/{chatwoot_client.CW_ACCOUNT_ID}/inboxes",
inbox_payload,
)
@@ -392,11 +245,12 @@ def provision():
inbox_token = inbox.get("access_token", "")
inbox_name = inbox.get("name", name)
website_token = inbox.get("website_token", "") or inbox.get("access_token", "")
_emb_cw_base = chatwoot_client.CW_BASE
embed_code = (
f"<script>\n"
f" (function(d,t) {{\n"
f" var g=d.createElement(t),s=d.getElementsByTagName(t)[0];\n"
f' g.src="{CW_BASE}/packs/js/sdk.js";\n'
f' g.src="{_emb_cw_base}/packs/js/sdk.js";\n'
f" g.defer=1;g.async=1;\n"
f" s.parentNode.insertBefore(g,s);\n"
f" g.onload=function(){{\n"
@@ -407,7 +261,7 @@ def provision():
f" }};\n"
f" window.chatwootSDK.run({{\n"
f' websiteToken:"{website_token}",\n'
f' baseUrl:"{CW_BASE}"\n'
f' baseUrl:"{_emb_cw_base}"\n'
f" }});\n"
f" }};\n"
f" }})(document,\"script\");\n"
@@ -417,16 +271,16 @@ def provision():
# 5. Create agent workspace
if not agent_id:
agent_id = f"chathub-{inbox_id}"
agent_dir = _ensure_agent_workspace(agent_id, name)
agent_dir = inboxes_io.ensure_agent_workspace(WORKSPACE_DIR, agent_id, name)
# 6. Update inboxes.json
config = _read_inboxes()
entry = _build_inbox_entry(name, domain, channel, inbox_id, inbox_token, agent_id)
config = inboxes_io.read_inboxes_raw(INBOXES_PATH)
entry = inboxes_io.build_inbox_entry(name, domain, channel, inbox_id, inbox_token, agent_id)
config[str(inbox_id)] = entry
config.setdefault("_meta", {})["updated_at"] = (
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
)
_write_inboxes(config)
inboxes_io.write_inboxes(INBOXES_PATH, config)
result = {
"inbox_id": inbox_id,
@@ -467,9 +321,9 @@ def health():
def _disable_inbox(inbox_id: int) -> None:
"""Disable Chatwoot inbox: rename, clear website_url, disable auto-assignment."""
_call_cw(
chatwoot_client._call_cw(
"PUT",
f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes/{inbox_id}",
f"/api/v1/accounts/{chatwoot_client.CW_ACCOUNT_ID}/inboxes/{inbox_id}",
{
"enable_auto_assignment": False,
"name": f"[Suspended] Inbox #{inbox_id}",
@@ -477,12 +331,12 @@ def _disable_inbox(inbox_id: int) -> None:
)
# Also clear channel website_url if web_widget
try:
inbox = _call_cw("GET", f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes/{inbox_id}")
inbox = chatwoot_client._call_cw("GET", f"/api/v1/accounts/{chatwoot_client.CW_ACCOUNT_ID}/inboxes/{inbox_id}")
ch = inbox.get("channel", {})
if ch.get("type") == "Channel::WebWidget":
_call_cw(
chatwoot_client._call_cw(
"PUT",
f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes/{inbox_id}",
f"/api/v1/accounts/{chatwoot_client.CW_ACCOUNT_ID}/inboxes/{inbox_id}",
{
"channel": {
"website_url": "",
@@ -497,14 +351,14 @@ def _disable_inbox(inbox_id: int) -> None:
def _update_inbox_status(inbox_id: int, status: str) -> None:
"""Update inboxes.json entry status."""
config = _read_inboxes()
config = inboxes_io.read_inboxes_raw(INBOXES_PATH)
key = str(inbox_id)
if key in config:
config[key]["status"] = status
config.setdefault("_meta", {})["updated_at"] = (
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
)
_write_inboxes(config)
inboxes_io.write_inboxes(INBOXES_PATH, config)
@bottle.post("/suspend")
@@ -562,9 +416,9 @@ def activate():
return {"error": "inbox_id is required"}
try:
_call_cw(
chatwoot_client._call_cw(
"PUT",
f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes/{inbox_id}",
f"/api/v1/accounts/{chatwoot_client.CW_ACCOUNT_ID}/inboxes/{inbox_id}",
{"enable_auto_assignment": True},
)
_update_inbox_status(int(inbox_id), "active")