353 lines
11 KiB
Python
353 lines
11 KiB
Python
"""Chatwoot Agent Bot webhook → MaxKB(PAI)→ 回寫對話。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.error import HTTPError, URLError
|
||
from urllib.request import Request, urlopen
|
||
|
||
from config import CONFIG
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_SESSION_LOCK = threading.Lock()
|
||
_SESSIONS: dict[str, dict[str, Any]] = {}
|
||
_SESSION_FILE = Path(__file__).resolve().parent / ".maxkb_cw_sessions.json"
|
||
|
||
|
||
def _load_sessions() -> None:
|
||
if not _SESSION_FILE.exists():
|
||
return
|
||
try:
|
||
data = json.loads(_SESSION_FILE.read_text(encoding="utf-8"))
|
||
if isinstance(data, dict):
|
||
_SESSIONS.update(data)
|
||
except Exception:
|
||
logger.exception("讀取 MaxKB session 快取失敗")
|
||
|
||
|
||
def _save_sessions() -> None:
|
||
try:
|
||
_SESSION_FILE.write_text(
|
||
json.dumps(_SESSIONS, ensure_ascii=False, indent=2),
|
||
encoding="utf-8",
|
||
)
|
||
except Exception:
|
||
logger.exception("寫入 MaxKB session 快取失敗")
|
||
|
||
|
||
_load_sessions()
|
||
|
||
|
||
def _http_json(
|
||
method: str,
|
||
url: str,
|
||
*,
|
||
headers: dict[str, str] | None = None,
|
||
body: Any = None,
|
||
timeout: float = 120,
|
||
) -> tuple[int, Any]:
|
||
data = None
|
||
if body is not None:
|
||
data = body if isinstance(body, (bytes, bytearray)) else json.dumps(
|
||
body, ensure_ascii=False
|
||
).encode("utf-8")
|
||
req_headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||
if headers:
|
||
req_headers.update(headers)
|
||
req = Request(url, data=data, headers=req_headers, method=method)
|
||
try:
|
||
with urlopen(req, timeout=timeout) as resp:
|
||
raw = resp.read()
|
||
try:
|
||
return resp.status, json.loads(raw.decode("utf-8") or "null")
|
||
except Exception:
|
||
return resp.status, raw.decode("utf-8", errors="replace")
|
||
except HTTPError as exc:
|
||
raw = exc.read()
|
||
try:
|
||
return exc.code, json.loads(raw.decode("utf-8") or "null")
|
||
except Exception:
|
||
return exc.code, raw.decode("utf-8", errors="replace")
|
||
except URLError as exc:
|
||
raise RuntimeError(f"HTTP 連線失敗: {url}: {exc}") from exc
|
||
|
||
|
||
def _maxkb_auth() -> str:
|
||
status, body = _http_json(
|
||
"POST",
|
||
f"{CONFIG.maxkb_base_url}/chat/api/auth/anonymous",
|
||
body={"access_token": CONFIG.maxkb_access_token},
|
||
timeout=30,
|
||
)
|
||
if status != 200 or not isinstance(body, dict) or body.get("code") != 200:
|
||
raise RuntimeError(f"MaxKB anonymous auth 失敗: {status} {body}")
|
||
token = body.get("data")
|
||
if not token:
|
||
raise RuntimeError("MaxKB anonymous auth 未回傳 token")
|
||
return str(token)
|
||
|
||
|
||
def _maxkb_open(jwt: str) -> str:
|
||
status, body = _http_json(
|
||
"GET",
|
||
f"{CONFIG.maxkb_base_url}/chat/api/open",
|
||
headers={"Authorization": f"Bearer {jwt}"},
|
||
timeout=30,
|
||
)
|
||
if status != 200 or not isinstance(body, dict) or body.get("code") != 200:
|
||
raise RuntimeError(f"MaxKB open 失敗: {status} {body}")
|
||
chat_id = body.get("data")
|
||
if not chat_id:
|
||
raise RuntimeError("MaxKB open 未回傳 chat_id")
|
||
return str(chat_id)
|
||
|
||
|
||
def _maxkb_ask(
|
||
jwt: str,
|
||
chat_id: str,
|
||
message: str,
|
||
form_data: dict[str, Any],
|
||
) -> str:
|
||
status, body = _http_json(
|
||
"POST",
|
||
f"{CONFIG.maxkb_base_url}/chat/api/chat_message/{chat_id}",
|
||
headers={"Authorization": f"Bearer {jwt}"},
|
||
body={
|
||
"message": message,
|
||
"stream": False,
|
||
"re_chat": False,
|
||
"form_data": form_data,
|
||
},
|
||
timeout=CONFIG.maxkb_timeout_sec,
|
||
)
|
||
if status != 200 or not isinstance(body, dict) or body.get("code") != 200:
|
||
raise RuntimeError(f"MaxKB chat 失敗: {status} {body}")
|
||
data = body.get("data") or {}
|
||
content = ""
|
||
if isinstance(data, dict):
|
||
content = str(data.get("content") or "").strip()
|
||
if not content:
|
||
answers = data.get("answer_list") or []
|
||
if answers and isinstance(answers[0], dict):
|
||
content = str(answers[0].get("content") or "").strip()
|
||
if not content:
|
||
raise RuntimeError(f"MaxKB 回覆為空: {body}")
|
||
return content
|
||
|
||
|
||
def _get_or_create_session(conversation_key: str) -> dict[str, Any]:
|
||
with _SESSION_LOCK:
|
||
sess = _SESSIONS.get(conversation_key)
|
||
if sess and sess.get("jwt") and sess.get("chat_id"):
|
||
return dict(sess)
|
||
|
||
jwt = _maxkb_auth()
|
||
chat_id = _maxkb_open(jwt)
|
||
sess = {
|
||
"jwt": jwt,
|
||
"chat_id": chat_id,
|
||
"updated_at": int(time.time()),
|
||
}
|
||
with _SESSION_LOCK:
|
||
_SESSIONS[conversation_key] = sess
|
||
_save_sessions()
|
||
return dict(sess)
|
||
|
||
|
||
def _refresh_session(conversation_key: str) -> dict[str, Any]:
|
||
with _SESSION_LOCK:
|
||
_SESSIONS.pop(conversation_key, None)
|
||
_save_sessions()
|
||
return _get_or_create_session(conversation_key)
|
||
|
||
|
||
def _chatwoot_reply(
|
||
account_id: int,
|
||
conversation_id: int,
|
||
content: str,
|
||
) -> None:
|
||
base = (
|
||
CONFIG.chatwoot_guest_api_base
|
||
or CONFIG.chatwoot_guest_proxy_target
|
||
or "http://127.0.0.1:3000"
|
||
).rstrip("/")
|
||
token = CONFIG.chatwoot_agent_bot_token
|
||
if not token:
|
||
raise RuntimeError("未設定 CHATWOOT_AGENT_BOT_TOKEN")
|
||
url = (
|
||
f"{base}/api/v1/accounts/{account_id}/conversations/"
|
||
f"{conversation_id}/messages"
|
||
)
|
||
status, body = _http_json(
|
||
"POST",
|
||
url,
|
||
headers={"api_access_token": token},
|
||
body={
|
||
"content": content,
|
||
"message_type": "outgoing",
|
||
"private": False,
|
||
},
|
||
timeout=30,
|
||
)
|
||
if status >= 300:
|
||
raise RuntimeError(f"Chatwoot 回覆失敗: {status} {body}")
|
||
|
||
|
||
def _extract_incoming(payload: dict[str, Any]) -> dict[str, Any] | None:
|
||
event = str(payload.get("event") or "")
|
||
# Agent Bot 會送 message_created / message_updated;只處理新建且來自聯絡人的訊息
|
||
if event and event not in {"message_created"}:
|
||
return None
|
||
|
||
message = payload.get("message") or payload
|
||
if not isinstance(message, dict):
|
||
return None
|
||
|
||
# 0 / incoming = 聯絡人;1 / outgoing = 客服或 bot(避免自回)
|
||
message_type = message.get("message_type")
|
||
incoming_types = {0, "0", "incoming", "Incoming"}
|
||
if message_type not in incoming_types:
|
||
return None
|
||
if message.get("private"):
|
||
return None
|
||
|
||
content = str(message.get("content") or "").strip()
|
||
if not content:
|
||
return None
|
||
|
||
conversation = message.get("conversation") or payload.get("conversation") or {}
|
||
if not isinstance(conversation, dict):
|
||
conversation = {}
|
||
conversation_id = conversation.get("id") or message.get("conversation_id")
|
||
account = payload.get("account") if isinstance(payload.get("account"), dict) else {}
|
||
account_id = (
|
||
message.get("account_id")
|
||
or conversation.get("account_id")
|
||
or account.get("id")
|
||
or CONFIG.chatwoot_account_id
|
||
)
|
||
if not conversation_id:
|
||
return None
|
||
|
||
sender = message.get("sender") or {}
|
||
username = ""
|
||
if isinstance(sender, dict):
|
||
username = str(
|
||
sender.get("name")
|
||
or sender.get("identifier")
|
||
or (sender.get("additional_attributes") or {}).get("username")
|
||
or ""
|
||
)
|
||
|
||
return {
|
||
"account_id": int(account_id),
|
||
"conversation_id": int(conversation_id),
|
||
"content": content,
|
||
"username": username,
|
||
"message_id": message.get("id"),
|
||
}
|
||
|
||
|
||
def _hard_reply_for_complaint(message: str) -> str | None:
|
||
"""客诉/提款强制话术,避免意图误判。"""
|
||
text = "".join(str(message or "").split())
|
||
if "一次不给赢" in text:
|
||
return "亲,每次游戏都会有输有赢,关键是保持好心态,接受挑战,最终会有丰厚的回报!"
|
||
# YD-CUSTOMER-018
|
||
if "回归" in text and "杀" in text:
|
||
return (
|
||
"亲,游戏输输赢赢很正常的,拼的是智力、耐心、心态和运气,"
|
||
"如果您觉得手气不好,可以稍作休息,转移一下注意力,稍后再进行投注,"
|
||
"或许下一个大奖就是您的,祝您鸿运当头,盈利多多。"
|
||
)
|
||
# YD-CUSTOMER-017
|
||
low = text.lower()
|
||
if (
|
||
"pg电子" in low
|
||
or "PG电子" in text
|
||
or "电子真是假" in text
|
||
or "假的我永远" in text
|
||
or ("电子" in text and "假" in text)
|
||
):
|
||
return (
|
||
"亲,您好!电子游戏的大奖奖池是根据机率来的哦,每一轮的结果都是随机的,"
|
||
"所以不能保证每次都能中奖。希望您能理解,这也是游戏的一部分乐趣所在!"
|
||
"我们可以一起再试试,说不定下一次就能碰上好运呢"
|
||
)
|
||
# YD-WITHDRAW-016(标准为转专人,禁止追问订单号)
|
||
if "取消提款" in text or "取消提现" in text or "申请取消提款" in text:
|
||
return (
|
||
"您好,取消提款需由专员协助核实处理,请稍候为您转接真人客服。"
|
||
"<quick_question>转专人服务</quick_question>"
|
||
)
|
||
return None
|
||
|
||
|
||
def handle_chatwoot_webhook(payload: dict[str, Any]) -> dict[str, Any]:
|
||
if not CONFIG.maxkb_bridge_enabled:
|
||
return {"ok": True, "skipped": "bridge_disabled"}
|
||
|
||
incoming = _extract_incoming(payload)
|
||
if not incoming:
|
||
return {"ok": True, "skipped": "not_incoming"}
|
||
|
||
account_id = incoming["account_id"]
|
||
conversation_id = incoming["conversation_id"]
|
||
key = f"{account_id}:{conversation_id}"
|
||
form_data = {
|
||
"account_id": str(account_id),
|
||
"conversation_id": str(conversation_id),
|
||
"bot_id": str(CONFIG.chatwoot_agent_bot_id or ""),
|
||
"username": incoming["username"] or "游客",
|
||
}
|
||
|
||
hard = _hard_reply_for_complaint(incoming["content"])
|
||
if hard:
|
||
_chatwoot_reply(account_id, conversation_id, hard)
|
||
with _SESSION_LOCK:
|
||
cur = _SESSIONS.get(key) or {}
|
||
cur["updated_at"] = int(time.time())
|
||
cur["last_message_id"] = incoming.get("message_id")
|
||
cur["hard_reply"] = True
|
||
_SESSIONS[key] = cur
|
||
_save_sessions()
|
||
return {
|
||
"ok": True,
|
||
"conversation_id": conversation_id,
|
||
"hard_reply": True,
|
||
"reply_len": len(hard),
|
||
}
|
||
|
||
sess = _get_or_create_session(key)
|
||
try:
|
||
reply = _maxkb_ask(
|
||
sess["jwt"], sess["chat_id"], incoming["content"], form_data
|
||
)
|
||
except Exception:
|
||
logger.exception("MaxKB 對話失敗,嘗試重建 session")
|
||
sess = _refresh_session(key)
|
||
reply = _maxkb_ask(
|
||
sess["jwt"], sess["chat_id"], incoming["content"], form_data
|
||
)
|
||
|
||
_chatwoot_reply(account_id, conversation_id, reply)
|
||
with _SESSION_LOCK:
|
||
cur = _SESSIONS.get(key) or {}
|
||
cur["updated_at"] = int(time.time())
|
||
cur["last_message_id"] = incoming.get("message_id")
|
||
_SESSIONS[key] = cur
|
||
_save_sessions()
|
||
|
||
return {
|
||
"ok": True,
|
||
"conversation_id": conversation_id,
|
||
"chat_id": sess["chat_id"],
|
||
"reply_len": len(reply),
|
||
}
|