"""Chatwoot Agent Bot webhook → MaxKB(PAI)→ 回寫對話。""" from __future__ import annotations import json import logging import re 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" _QUICK_QUESTION_RE = re.compile( r"\s*(.*?)\s*", re.IGNORECASE | re.DOTALL, ) 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 _split_quick_questions(reply: str) -> tuple[str, list[str]]: """把 MaxKB 的 標籤拆成純文字 + 按鈕選項。""" raw = str(reply or "") items: list[str] = [] for match in _QUICK_QUESTION_RE.findall(raw): label = " ".join(str(match).split()).strip() if label and label not in items: items.append(label) clean = _QUICK_QUESTION_RE.sub("", raw) clean = re.sub(r"[ \t]+\n", "\n", clean) clean = re.sub(r"\n{3,}", "\n\n", clean).strip() return clean, items def _chatwoot_reply( account_id: int, conversation_id: int, content: str, *, content_type: str = "text", content_attributes: dict[str, Any] | None = None, ) -> 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" ) payload: dict[str, Any] = { "content": content, "message_type": "outgoing", "private": False, "content_type": content_type, } if content_attributes: payload["content_attributes"] = content_attributes status, body = _http_json( "POST", url, headers={"api_access_token": token}, body=payload, timeout=30, ) if status >= 300: raise RuntimeError(f"Chatwoot 回覆失敗: {status} {body}") def _chatwoot_reply_from_maxkb( account_id: int, conversation_id: int, reply: str, ) -> None: """回寫 Chatwoot:去掉 quick_question 原文,改成可點選按鈕。""" clean, items = _split_quick_questions(reply) if clean: _chatwoot_reply(account_id, conversation_id, clean) elif not items: # 空回覆不送 return if items: _chatwoot_reply( account_id, conversation_id, "", content_type="input_select", content_attributes={ "items": [{"title": label, "value": label} for label in items] }, ) def _has_attachments(message: dict[str, Any]) -> bool: atts = message.get("attachments") or message.get("files") or [] return isinstance(atts, list) and len(atts) > 0 def _is_incoming_message(message: dict[str, Any]) -> bool: message_type = message.get("message_type") incoming_types = {0, "0", "incoming", "Incoming"} if message_type not in incoming_types: return False if message.get("private"): return False if message.get("content_type") in {"input_email", "form", "input_select"}: return False has_text = bool(str(message.get("content") or "").strip()) return has_text or _has_attachments(message) def _pick_latest_incoming(messages: Any) -> dict[str, Any] | None: if not isinstance(messages, list): return None for msg in reversed(messages): if isinstance(msg, dict) and _is_incoming_message(msg): return msg return None def _extract_incoming(payload: dict[str, Any]) -> dict[str, Any] | None: event = str(payload.get("event") or "") message: dict[str, Any] | None = None if event == "message_created": candidate = payload.get("message") or payload message = candidate if isinstance(candidate, dict) else None elif event == "conversation_updated": # Agent Bot webhook 常送 conversation_updated,訊息在 messages 陣列 conversation = payload.get("conversation") if isinstance(payload.get("conversation"), dict) else {} messages = payload.get("messages") or conversation.get("messages") or [] message = _pick_latest_incoming(messages) elif not event: candidate = payload.get("message") or payload message = candidate if isinstance(candidate, dict) else None else: return None if not message or not _is_incoming_message(message): return None content = str(message.get("content") or "").strip() 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") or payload.get("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 ( "您好,取消提款需由专员协助核实处理,请稍候为您转接真人客服。" "转专人服务" ) 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}" message_id = incoming.get("message_id") content = str(incoming.get("content") or "").strip() now = int(time.time()) event = str(payload.get("event") or "") with _SESSION_LOCK: cur = dict(_SESSIONS.get(key) or {}) if message_id is not None and cur.get("last_message_id") == message_id: return {"ok": True, "skipped": "duplicate", "event": event} if ( content and cur.get("last_content") == content and now - int(cur.get("last_content_at") or 0) < 45 ): return {"ok": True, "skipped": "duplicate_content", "event": event} if message_id is not None: cur["last_message_id"] = message_id if content: cur["last_content"] = content cur["last_content_at"] = now cur["updated_at"] = now _SESSIONS[key] = cur _save_sessions() # 純圖片/附件沒有文字時,MaxKB 收不到內容,先前會直接略過,畫面像卡住。 if not content: ack = ( "已收到您的图片。目前客服无法辨识图片内容," "请用文字说明您要咨询的问题,例如:充值未到账、无法登录、提款问题。" ) _chatwoot_reply_from_maxkb(account_id, conversation_id, ack) return { "ok": True, "conversation_id": conversation_id, "image_ack": True, "event": event, } 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_from_maxkb(account_id, conversation_id, hard) with _SESSION_LOCK: cur = dict(_SESSIONS.get(key) or {}) cur["updated_at"] = int(time.time()) cur["hard_reply"] = True _SESSIONS[key] = cur _save_sessions() return { "ok": True, "conversation_id": conversation_id, "hard_reply": True, "reply_len": len(hard), "event": event, } 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_from_maxkb(account_id, conversation_id, reply) with _SESSION_LOCK: cur = dict(_SESSIONS.get(key) or {}) cur["updated_at"] = int(time.time()) if message_id is not None: cur["last_message_id"] = message_id _SESSIONS[key] = cur _save_sessions() return { "ok": True, "conversation_id": conversation_id, "chat_id": sess["chat_id"], "reply_len": len(reply), "event": event, }