d0b20a0e14
新增: - provision_server.py HTTP API 服务 (Bottle, 端口 5566) - 状态持久化 (JSON, 每30秒保存, 1小时内可恢复) - 会议室模式 (开发团队 Inbox 多 AI 路由) - supervisor 托管, SIGTERM 优雅退出 - PUBSUB_TOKEN 三级 fallback 修复: - 所有硬编码凭证清除 (CW_EMAIL/CW_PASSWORD 无 fallback) - 双重 WebSocket 重连 - 内存泄漏 (无界 Set 清理) - INBOX_CONFIG 兜底 (skip+log 不崩溃) - PID 文件竞争, Metrics 热路径优化 - 幂等性正确实现 (存真实响应含 HTTP 状态码) 安全: - 完整数据脱敏 (无 URL/邮箱/密码/token 硬编码) - .env.example / chatwoot_auth.example.json / inboxes.example.json
587 lines
21 KiB
Python
587 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""ChatHub Provision HTTP Service.
|
|
|
|
Called by FastAdmin PHP to provision a tenant:
|
|
→ creates Chatwoot team + inbox + agent account
|
|
→ writes inboxes.json (WS Agent hot-reloads within 30s)
|
|
→ creates QwenPaw agent workspace
|
|
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
|
|
|
|
# ── logging ──────────────────────────────────────────────────────
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="[%(asctime)s] [%(levelname)s] %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
)
|
|
log = logging.getLogger("provision")
|
|
|
|
# ── paths (all inside QwenPaw container) ──────────────────────────
|
|
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", "")
|
|
# 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", "")
|
|
if key != CHATHUB_API_KEY:
|
|
bottle.response.status = 401
|
|
return json.dumps({"error": "Invalid or missing X-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()
|
|
display_name = name or email.split("@")[0]
|
|
|
|
user = _call_internal(
|
|
"POST",
|
|
"/platform/api/v1/users",
|
|
{"name": display_name, "email": email, "password": password},
|
|
extra_headers={"api_access_token": CW_PLATFORM_TOKEN},
|
|
)
|
|
if not user.get("id"):
|
|
raise RuntimeError(f"Platform API user creation failed: {user}")
|
|
uid = user["id"]
|
|
|
|
try:
|
|
agent = _call_cw(
|
|
"POST",
|
|
f"/api/v1/accounts/{CW_ACCOUNT_ID}/agents",
|
|
{"email": email, "name": display_name, "role": "agent"},
|
|
)
|
|
except Exception as e:
|
|
# Rollback: delete the orphaned platform user
|
|
try:
|
|
_call_internal(
|
|
"DELETE",
|
|
f"/platform/api/v1/users/{uid}",
|
|
None,
|
|
extra_headers={"api_access_token": CW_PLATFORM_TOKEN},
|
|
)
|
|
except Exception:
|
|
pass
|
|
raise RuntimeError(f"Agent creation failed, rolled back user {uid}: {e}")
|
|
|
|
if not agent.get("id"):
|
|
raise RuntimeError(f"Agent creation failed: {agent}")
|
|
|
|
return {"agent_cw_id": agent["id"], "password": password}
|
|
|
|
|
|
def _add_agent_to_team(agent_cw_id: int, team_id: int) -> None:
|
|
"""Assign agent to team."""
|
|
try:
|
|
_call_cw(
|
|
"POST",
|
|
f"/api/v1/accounts/{CW_ACCOUNT_ID}/teams/{team_id}/team_members",
|
|
{"user_ids": [agent_cw_id]},
|
|
)
|
|
except urllib.error.HTTPError as e:
|
|
body = e.read().decode() if e.fp else ""
|
|
log.warning("add_agent_to_team failed: agent=%s team=%s err=%s %s", agent_cw_id, team_id, e.code, body)
|
|
except Exception as e:
|
|
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] = {}
|
|
_IDEMPOTENT_LOCK = threading.Lock()
|
|
_IDEMPOTENT_TTL = 300
|
|
|
|
|
|
def _check_idempotency(key: str) -> Optional[dict]:
|
|
"""Return cached result if key was already processed, else None."""
|
|
if not key:
|
|
return None
|
|
with _IDEMPOTENT_LOCK:
|
|
if key in _IDEMPOTENT_RESULTS:
|
|
log.info("Idempotent request %s, returning cached result", key)
|
|
cached = _IDEMPOTENT_RESULTS[key]
|
|
# Restore HTTP status from cached entry
|
|
status = cached.pop("_http_status", 200)
|
|
bottle.response.status = status
|
|
return cached
|
|
return None
|
|
|
|
|
|
def _store_idempotency(key: str, response: dict, status: int = 200) -> None:
|
|
"""Store the response for an idempotency key (thread-safe, auto-expire).
|
|
|
|
Args:
|
|
key: Idempotency-Key header value.
|
|
response: The JSON response dict to cache.
|
|
status: HTTP status code to restore on cache hit.
|
|
"""
|
|
if not key:
|
|
return
|
|
with _IDEMPOTENT_LOCK:
|
|
if key not in _IDEMPOTENT_RESULTS:
|
|
entry = dict(response)
|
|
entry["_http_status"] = status
|
|
_IDEMPOTENT_RESULTS[key] = entry
|
|
# Schedule cleanup after TTL
|
|
threading.Timer(_IDEMPOTENT_TTL, lambda: _IDEMPOTENT_RESULTS.pop(key, None)).start()
|
|
|
|
|
|
# ── Provision endpoint ────────────────────────────────────────────
|
|
|
|
@bottle.post("/provision")
|
|
def provision():
|
|
result = None
|
|
idempotency_key = bottle.request.get_header("Idempotency-Key", "")
|
|
|
|
auth_err = _check_api_key()
|
|
if auth_err:
|
|
return auth_err
|
|
|
|
try:
|
|
data = bottle.request.json
|
|
except Exception:
|
|
bottle.response.status = 400
|
|
return {"error": "invalid JSON body"}
|
|
|
|
name = (data or {}).get("name", "").strip()
|
|
domain = (data or {}).get("domain", "").strip()
|
|
email = (data or {}).get("email", "").strip()
|
|
channel = (data or {}).get("type", "web_widget")
|
|
agent_id = (data or {}).get("agent_id", "")
|
|
max_agents = int((data or {}).get("max_agents", 3))
|
|
|
|
# Input validation (stateless — always deterministic, no need to cache)
|
|
if not name:
|
|
bottle.response.status = 400
|
|
return {"error": "name is required"}
|
|
if len(name) > 100:
|
|
bottle.response.status = 400
|
|
return {"error": "name too long (max 100 chars)"}
|
|
if not domain:
|
|
bottle.response.status = 400
|
|
return {"error": "domain is required"}
|
|
if len(domain) > 255:
|
|
bottle.response.status = 400
|
|
return {"error": "domain too long (max 255 chars)"}
|
|
if not email or "@" not in email or len(email) > 255:
|
|
bottle.response.status = 400
|
|
return {"error": "valid email is required"}
|
|
if channel not in ("web_widget", "api"):
|
|
bottle.response.status = 400
|
|
return {"error": "type must be 'web_widget' or 'api'"}
|
|
|
|
# Idempotency check (after validation, so only provisioning results get cached)
|
|
cached = _check_idempotency(idempotency_key)
|
|
if cached:
|
|
return cached
|
|
|
|
# ── Provisioning (non-idempotent, stateful operations) ──────
|
|
try:
|
|
# 1. Create Chatwoot agent (Platform API + Agents API)
|
|
agent_info = _create_agent(email, name)
|
|
|
|
# 2. Create team
|
|
team = _call_cw(
|
|
"POST",
|
|
f"/api/v1/accounts/{CW_ACCOUNT_ID}/teams",
|
|
{
|
|
"name": f"{name} 客服团队",
|
|
"description": f"{name} 的专属客服团队(限制 {max_agents} 席)",
|
|
},
|
|
)
|
|
team_id = team.get("id")
|
|
|
|
# 3. Assign agent to team
|
|
_add_agent_to_team(agent_info["agent_cw_id"], team_id)
|
|
|
|
# 4. Create inbox
|
|
inbox_payload = {
|
|
"name": name,
|
|
"channel": {
|
|
"type": channel,
|
|
"website_url": domain if channel == "web_widget" else "",
|
|
"widget_color": "#6366f1",
|
|
"welcome_title": f"欢迎来到 {name}",
|
|
"welcome_tagline": f"您好!欢迎访问 {name},请问有什么可以帮您?",
|
|
},
|
|
}
|
|
inbox = _call_cw(
|
|
"POST",
|
|
f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes",
|
|
inbox_payload,
|
|
)
|
|
|
|
inbox_id = inbox.get("id")
|
|
inbox_token = inbox.get("access_token", "")
|
|
inbox_name = inbox.get("name", name)
|
|
website_token = inbox.get("website_token", "") or inbox.get("access_token", "")
|
|
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.defer=1;g.async=1;\n"
|
|
f" s.parentNode.insertBefore(g,s);\n"
|
|
f" g.onload=function(){{\n"
|
|
f" window.chatwootSettings={{\n"
|
|
f' position:"right",\n'
|
|
f' type:"standard",\n'
|
|
f' launcherTitle:"Chat"\n'
|
|
f" }};\n"
|
|
f" window.chatwootSDK.run({{\n"
|
|
f' websiteToken:"{website_token}",\n'
|
|
f' baseUrl:"{CW_BASE}"\n'
|
|
f" }});\n"
|
|
f" }};\n"
|
|
f" }})(document,\"script\");\n"
|
|
f"</script>"
|
|
)
|
|
|
|
# 5. Create agent workspace
|
|
if not agent_id:
|
|
agent_id = f"chathub-{inbox_id}"
|
|
agent_dir = _ensure_agent_workspace(agent_id, name)
|
|
|
|
# 6. Update inboxes.json
|
|
config = _read_inboxes()
|
|
entry = _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)
|
|
|
|
result = {
|
|
"inbox_id": inbox_id,
|
|
"inbox_token": inbox_token,
|
|
"inbox_name": inbox_name,
|
|
"team_id": team_id,
|
|
"team_name": f"{name} 客服团队",
|
|
"agent_id": agent_id,
|
|
"agent_cw_id": agent_info["agent_cw_id"],
|
|
"agent_cw_password": agent_info["password"],
|
|
"agent_dir": str(agent_dir),
|
|
"embed_code": embed_code,
|
|
"ws_agent_updated": True,
|
|
}
|
|
|
|
except urllib.error.HTTPError as e:
|
|
body = e.read().decode()
|
|
bottle.response.status = 502
|
|
result = {"error": f"Chatwoot API error: {e.code}", "detail": body}
|
|
except urllib.error.URLError as e:
|
|
bottle.response.status = 502
|
|
result = {"error": f"Chatwoot network error: {e.reason}"}
|
|
except Exception as e:
|
|
bottle.response.status = 500
|
|
result = {"error": str(e)}
|
|
|
|
# Store idempotency result (both success and failure)
|
|
_store_idempotency(idempotency_key, result if result else {"error": "unknown"}, bottle.response.status_code)
|
|
return result
|
|
|
|
|
|
@bottle.get("/health")
|
|
def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
# ── Suspend endpoint ─────────────────────────────────────────────
|
|
|
|
def _disable_inbox(inbox_id: int) -> None:
|
|
"""Disable Chatwoot inbox: rename, clear website_url, disable auto-assignment."""
|
|
_call_cw(
|
|
"PUT",
|
|
f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes/{inbox_id}",
|
|
{
|
|
"enable_auto_assignment": False,
|
|
"name": f"[Suspended] Inbox #{inbox_id}",
|
|
},
|
|
)
|
|
# Also clear channel website_url if web_widget
|
|
try:
|
|
inbox = _call_cw("GET", f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes/{inbox_id}")
|
|
ch = inbox.get("channel", {})
|
|
if ch.get("type") == "Channel::WebWidget":
|
|
_call_cw(
|
|
"PUT",
|
|
f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes/{inbox_id}",
|
|
{
|
|
"channel": {
|
|
"website_url": "",
|
|
"welcome_title": "Service Suspended",
|
|
"welcome_tagline": "This chat service has been suspended.",
|
|
}
|
|
},
|
|
)
|
|
except Exception as e:
|
|
log.warning("Failed to clear inbox %d channel settings: %s", inbox_id, e)
|
|
|
|
|
|
def _update_inbox_status(inbox_id: int, status: str) -> None:
|
|
"""Update inboxes.json entry status."""
|
|
config = _read_inboxes()
|
|
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)
|
|
|
|
|
|
@bottle.post("/suspend")
|
|
def suspend():
|
|
"""Suspend a tenant: disable Chatwoot inbox + mark inboxes.json as suspended."""
|
|
auth_err = _check_api_key()
|
|
if auth_err:
|
|
return auth_err
|
|
|
|
try:
|
|
data = bottle.request.json
|
|
except Exception:
|
|
bottle.response.status = 400
|
|
return {"error": "invalid JSON body"}
|
|
|
|
inbox_id = (data or {}).get("inbox_id")
|
|
if not inbox_id:
|
|
bottle.response.status = 400
|
|
return {"error": "inbox_id is required"}
|
|
|
|
try:
|
|
# 1. Disable Chatwoot inbox
|
|
_disable_inbox(int(inbox_id))
|
|
|
|
# 2. Mark inboxes.json as suspended
|
|
_update_inbox_status(int(inbox_id), "suspended")
|
|
|
|
return {"success": True, "message": f"Inbox {inbox_id} suspended"}
|
|
|
|
except urllib.error.HTTPError as e:
|
|
body = e.read().decode()
|
|
bottle.response.status = 502
|
|
return {"error": f"Chatwoot API error: {e.code}", "detail": body}
|
|
except Exception as e:
|
|
bottle.response.status = 500
|
|
return {"error": str(e)}
|
|
|
|
|
|
@bottle.post("/activate")
|
|
def activate():
|
|
"""Re-activate a suspended tenant: re-enable inbox + mark active."""
|
|
auth_err = _check_api_key()
|
|
if auth_err:
|
|
return auth_err
|
|
|
|
try:
|
|
data = bottle.request.json
|
|
except Exception:
|
|
bottle.response.status = 400
|
|
return {"error": "invalid JSON body"}
|
|
|
|
inbox_id = (data or {}).get("inbox_id")
|
|
if not inbox_id:
|
|
bottle.response.status = 400
|
|
return {"error": "inbox_id is required"}
|
|
|
|
try:
|
|
_call_cw(
|
|
"PUT",
|
|
f"/api/v1/accounts/{CW_ACCOUNT_ID}/inboxes/{inbox_id}",
|
|
{"enable_auto_assignment": True},
|
|
)
|
|
_update_inbox_status(int(inbox_id), "active")
|
|
|
|
return {"success": True, "message": f"Inbox {inbox_id} activated"}
|
|
|
|
except urllib.error.HTTPError as e:
|
|
body = e.read().decode()
|
|
bottle.response.status = 502
|
|
return {"error": f"Chatwoot API error: {e.code}", "detail": body}
|
|
except Exception as e:
|
|
bottle.response.status = 500
|
|
return {"error": str(e)}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5566
|
|
log.info("Starting ChatHub Provision Server on port %d (threaded)", port)
|
|
bottle.run(host="0.0.0.0", port=port, debug=False, server="wsgiref", num_threads=4)
|