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:
+42
-188
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user