Files
2026-08-15 14:51:51 +08:00

353 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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),
}