最初版本

This commit is contained in:
sa19840531
2026-08-15 14:51:51 +08:00
parent 4e01df4dce
commit ea47ea4449
176 changed files with 21009 additions and 0 deletions
+256
View File
@@ -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("=")