最初版本
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from config import CONFIG
|
||||
|
||||
_AUTH_CACHE: dict[str, Any] = {"headers": None, "expires_at": 0.0}
|
||||
|
||||
|
||||
DEFAULT_GREETING = (
|
||||
"您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?"
|
||||
)
|
||||
|
||||
|
||||
class ChatwootGuestError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _request(
|
||||
method: str,
|
||||
path: str,
|
||||
headers: dict[str, str],
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> tuple[int, dict[str, str], Any]:
|
||||
body = None
|
||||
req_headers = dict(headers)
|
||||
if payload is not None:
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req_headers["Content-Type"] = "application/json"
|
||||
req_headers["Accept"] = "application/json"
|
||||
|
||||
api_base = (
|
||||
CONFIG.chatwoot_guest_api_base or CONFIG.chatwoot_base_url
|
||||
).rstrip("/")
|
||||
request = Request(
|
||||
f"{api_base}{path}",
|
||||
data=body,
|
||||
headers=req_headers,
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=20) as response:
|
||||
raw = response.read().decode("utf-8", "replace")
|
||||
data = json.loads(raw) if raw else {}
|
||||
return response.status, dict(response.headers.items()), data
|
||||
except HTTPError as exc:
|
||||
raw = exc.read().decode("utf-8", "replace")
|
||||
try:
|
||||
data = json.loads(raw) if raw else {}
|
||||
except json.JSONDecodeError:
|
||||
data = {"message": raw[:300]}
|
||||
raise ChatwootGuestError(
|
||||
f"Chatwoot {method} {path} 失敗:{exc.code} {data}"
|
||||
) from exc
|
||||
except URLError as exc:
|
||||
raise ChatwootGuestError(f"無法連線 Chatwoot:{exc.reason}") from exc
|
||||
|
||||
|
||||
def _login_headers(*, force: bool = False) -> dict[str, str]:
|
||||
email = CONFIG.chatwoot_guest_api_email or CONFIG.chatwoot_api_email
|
||||
password = CONFIG.chatwoot_guest_api_password or CONFIG.chatwoot_api_password
|
||||
if not email or not password:
|
||||
raise ChatwootGuestError("未設定 Chatwoot API 帳號密碼")
|
||||
|
||||
now = time.time()
|
||||
cached = _AUTH_CACHE.get("headers")
|
||||
if not force and cached and now < float(_AUTH_CACHE.get("expires_at") or 0):
|
||||
return cached
|
||||
|
||||
status, headers, _data = _request(
|
||||
"POST",
|
||||
"/auth/sign_in",
|
||||
{},
|
||||
{
|
||||
"email": email,
|
||||
"password": password,
|
||||
},
|
||||
)
|
||||
if status >= 400:
|
||||
raise ChatwootGuestError("Chatwoot 登入失敗")
|
||||
|
||||
token = headers.get("access-token") or headers.get("Access-Token") or ""
|
||||
client = headers.get("client") or headers.get("Client") or ""
|
||||
uid = headers.get("uid") or headers.get("Uid") or ""
|
||||
if not token or not client or not uid:
|
||||
raise ChatwootGuestError("Chatwoot 登入未回傳 token")
|
||||
|
||||
auth_headers = {
|
||||
"access-token": token,
|
||||
"client": client,
|
||||
"uid": uid,
|
||||
"token-type": "Bearer",
|
||||
}
|
||||
_AUTH_CACHE["headers"] = auth_headers
|
||||
_AUTH_CACHE["expires_at"] = now + 300
|
||||
return auth_headers
|
||||
|
||||
|
||||
def _dig(data: Any, *keys: str) -> Any:
|
||||
current = data
|
||||
for key in keys:
|
||||
if not isinstance(current, dict):
|
||||
return None
|
||||
current = current.get(key)
|
||||
return current
|
||||
|
||||
|
||||
def _source_id(*payloads: Any) -> str:
|
||||
for payload in payloads:
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
body = payload.get("payload") or payload
|
||||
candidates = [
|
||||
_dig(body, "contact_inbox", "source_id"),
|
||||
_dig(payload, "contact_inbox", "source_id"),
|
||||
_dig(
|
||||
payload,
|
||||
"last_non_activity_message",
|
||||
"conversation",
|
||||
"contact_inbox",
|
||||
"source_id",
|
||||
),
|
||||
]
|
||||
messages = body.get("messages") if isinstance(body, dict) else None
|
||||
if isinstance(messages, list) and messages:
|
||||
candidates.append(
|
||||
_dig(
|
||||
messages[0],
|
||||
"conversation",
|
||||
"contact_inbox",
|
||||
"source_id",
|
||||
)
|
||||
)
|
||||
for value in candidates:
|
||||
if value:
|
||||
return str(value)
|
||||
return ""
|
||||
|
||||
|
||||
def _contact_id(payload: Any) -> int | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
body = payload.get("payload") or payload
|
||||
if not isinstance(body, dict):
|
||||
return None
|
||||
contact = body.get("contact") or body
|
||||
if not isinstance(contact, dict):
|
||||
return None
|
||||
contact_id = contact.get("id")
|
||||
return int(contact_id) if contact_id is not None else None
|
||||
|
||||
|
||||
def create_guest_conversation() -> dict[str, Any]:
|
||||
"""建立新的遊客聯絡人與對話,讓 widget 直接進入聊天室。"""
|
||||
identifier = f"guest-{uuid.uuid4().hex[:16]}"
|
||||
headers = _login_headers()
|
||||
account_id = CONFIG.chatwoot_account_id
|
||||
|
||||
try:
|
||||
return _create_with_headers(identifier, headers, account_id)
|
||||
except ChatwootGuestError as exc:
|
||||
if "401" not in str(exc) and "403" not in str(exc):
|
||||
raise
|
||||
headers = _login_headers(force=True)
|
||||
return _create_with_headers(identifier, headers, account_id)
|
||||
|
||||
|
||||
def _create_with_headers(
|
||||
identifier: str,
|
||||
headers: dict[str, str],
|
||||
account_id: int,
|
||||
) -> dict[str, Any]:
|
||||
_, _, contact_body = _request(
|
||||
"POST",
|
||||
f"/api/v1/accounts/{account_id}/contacts",
|
||||
headers,
|
||||
{
|
||||
"inbox_id": CONFIG.chatwoot_guest_inbox_id or CONFIG.chatwoot_inbox_id,
|
||||
"name": "遊客",
|
||||
"identifier": identifier,
|
||||
"custom_attributes": {"visitor_type": "guest"},
|
||||
},
|
||||
)
|
||||
contact_id = _contact_id(contact_body)
|
||||
if not contact_id:
|
||||
raise ChatwootGuestError("Chatwoot 未回傳 contact id")
|
||||
|
||||
source_id = _source_id(contact_body)
|
||||
if not source_id:
|
||||
raise ChatwootGuestError("Chatwoot 未回傳 source_id")
|
||||
|
||||
# 必須帶同一個 source_id。只傳 contact_id + inbox_id 時,
|
||||
# ContactInboxBuilder 會再產生一組 WebWidget UUID,
|
||||
# 歡迎語會掛在新 session,widget JWT 對不到就變成空白對話。
|
||||
# 歡迎圖不走 Chatwoot 附件,否則 widget 會顯示檔名與「下載」。
|
||||
_, _, conversation = _request(
|
||||
"POST",
|
||||
f"/api/v1/accounts/{account_id}/conversations",
|
||||
headers,
|
||||
{
|
||||
"inbox_id": CONFIG.chatwoot_guest_inbox_id or CONFIG.chatwoot_inbox_id,
|
||||
"contact_id": contact_id,
|
||||
"source_id": source_id,
|
||||
"status": "open",
|
||||
"message": {
|
||||
"content": CONFIG.chatwoot_greeting or DEFAULT_GREETING,
|
||||
"message_type": "outgoing",
|
||||
},
|
||||
},
|
||||
)
|
||||
conversation_id = conversation.get("id") if isinstance(conversation, dict) else None
|
||||
|
||||
return {
|
||||
"identifier": identifier,
|
||||
"contact_id": contact_id,
|
||||
"conversation_id": conversation_id,
|
||||
"source_id": source_id,
|
||||
"widget_token": make_widget_token(source_id),
|
||||
"name": "遊客",
|
||||
}
|
||||
|
||||
|
||||
def make_widget_token(source_id: str) -> str:
|
||||
"""產生 Chatwoot widget 的 cw_conversation JWT。"""
|
||||
secret = CONFIG.chatwoot_widget_jwt_secret
|
||||
if not secret:
|
||||
raise ChatwootGuestError("未設定 CHATWOOT_WIDGET_JWT_SECRET")
|
||||
|
||||
now = int(time.time())
|
||||
header = _b64url(json.dumps({"alg": "HS256"}, separators=(",", ":")).encode())
|
||||
payload = _b64url(
|
||||
json.dumps(
|
||||
{
|
||||
"source_id": source_id,
|
||||
"inbox_id": CONFIG.chatwoot_guest_inbox_id or CONFIG.chatwoot_inbox_id,
|
||||
"iat": now,
|
||||
"exp": now + 60 * 60 * 24 * 180,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
)
|
||||
signing_input = f"{header}.{payload}".encode()
|
||||
signature = hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest()
|
||||
return f"{header}.{payload}.{_b64url(signature)}"
|
||||
|
||||
|
||||
def _b64url(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
Reference in New Issue
Block a user