This commit is contained in:
eric
2026-04-02 00:26:00 -05:00
parent a0085ba9a2
commit 8c3483c360
3 changed files with 259 additions and 60 deletions

View File

@@ -31,6 +31,7 @@ from .routers import (
common_router,
meetup_router,
chatbot_router,
hackrobot_router,
)
from .routers.salon_meetup import router as salon_meetup_router
@@ -177,6 +178,7 @@ app.include_router(common_router)
app.include_router(meetup_router)
app.include_router(salon_meetup_router)
app.include_router(chatbot_router)
app.include_router(hackrobot_router)
@app.get("/")

View File

@@ -13,7 +13,7 @@ from .nomadlms import router as nomadlms_router
from .legacy import router as legacy_router
from .common import router as common_router
from .meetup import router as meetup_router
from .chatbot import router as chatbot_router
from .chatbot import hackrobot_router, router as chatbot_router
__all__ = [
"nomadvip_router",
@@ -24,4 +24,5 @@ __all__ = [
"common_router",
"meetup_router",
"chatbot_router",
"hackrobot_router",
]

View File

@@ -7,8 +7,8 @@ import struct
import time
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from typing import Optional
from urllib.parse import parse_qs
from typing import NamedTuple, Optional
from urllib.parse import parse_qs, quote
import httpx
from Crypto.Cipher import AES
@@ -18,7 +18,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from ..config import PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, PB_URL
from ..services.mp_recommend import is_mp_recommend_webhook_body, try_persist_from_like_body
router = APIRouter(prefix="/chatbot", tags=["chatbot"])
router = APIRouter(tags=["chatbot"])
logger = logging.getLogger(__name__)
_processed_callback_cache: dict[str, int] = {}
@@ -45,6 +45,17 @@ APP_ID = OPEN_API_PROFILES[ACTIVE_PROFILE]["APP_ID"]
TOKEN = OPEN_API_PROFILES[ACTIVE_PROFILE]["TOKEN"]
ENCODING_AES_KEY = OPEN_API_PROFILES[ACTIVE_PROFILE]["ENCODING_AES_KEY"]
HACKROBOT_PROFILE = "xidu"
def _resolve_profile_key(profile_key: str) -> str:
k = (profile_key or "").strip().lower()
return k if k in OPEN_API_PROFILES else "eric"
def _profile_creds(profile_key: str) -> dict:
return OPEN_API_PROFILES[_resolve_profile_key(profile_key)]
PM_SYNC_AUTHTOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4"
PM_SYNC_DEFAULT_WXBOT_BID = "75021053883a9090c67d5becde7f4b0a"
PB_COLLECTION = "wechat_private_users"
@@ -106,8 +117,48 @@ def aes_encrypt_base64(plaintext: str, encoding_aes_key: str, app_id: str | None
return base64.b64encode(cipher.encrypt(padded)).decode("utf-8")
def _build_keyword_h5_obj(query: str, user_id: str) -> Optional[dict]:
class SharedChatbotReply(NamedTuple):
"""全公众号配置共用的解析结果:要么 H5 图文,要么兜底纯文本(含超链)。"""
h5_message: Optional[dict]
"""与「发送客服消息」H5 一致:{\"news\": {\"articles\": [...]}},未命中关键词时为 None。"""
text_fallback: str
"""未命中关键词时的 short_answer 文案(可含 <a href>)。"""
def _normalize_h5_msg_per_wechat_doc(h5_message: dict) -> dict:
"""
与文档「发送客服消息」H5 一致msg = { news: { articles: [ { title, description, url, picurl, type } ] } }。
仅保留这五个字段,避免多余键导致客户端不渲染。
"""
raw = h5_message.get("news") if isinstance(h5_message, dict) else None
arts = raw.get("articles") if isinstance(raw, dict) else None
if not isinstance(arts, list):
arts = []
norm_articles: list[dict] = []
for a in arts:
if not isinstance(a, dict):
continue
t = str(a.get("type", "h5") or "h5").strip().lower()
if t not in ("h5", "mp"):
t = "h5"
norm_articles.append(
{
"title": str(a.get("title", "")),
"description": str(a.get("description", "")),
"url": str(a.get("url", "")),
"picurl": str(a.get("picurl", "")),
"type": t,
}
)
return {"news": {"articles": norm_articles}}
def shared_build_h5_keyword_message(query: str, user_id: str, profile_key: str) -> Optional[dict]:
"""关键词命中时返回文档约定的 H5 msg再经 _normalize 输出)。异度(/hackrobot) 群卡片仅认「群」Eric(/chatbot) 另认「加群」。"""
q = (query or "").strip()
uid = (user_id or "").strip()
prof = _resolve_profile_key(profile_key)
if "电子书" in q:
return {
"news": {
@@ -115,21 +166,26 @@ def _build_keyword_h5_obj(query: str, user_id: str) -> Optional[dict]:
{
"title": "数字游民",
"description": "地理套利与自动化杠杆",
"url": f"https://vip.hackrobot.cn/ebook?userid={user_id}",
"url": f"https://vip.hackrobot.cn/ebook?userid={uid}",
"picurl": "https://www.hackrobot.cn/static/images/images/dgnomad.png",
"type": "h5",
}
]
}
}
if "" in q:
if prof == HACKROBOT_PROFILE:
# 「加群」含「群」子串,异度仅按「群」触发时需排除「加群」整词
group_hit = ("" in q) and ("加群" not in q)
else:
group_hit = ("" in q) or ("加群" in q)
if group_hit:
return {
"news": {
"articles": [
{
"title": "异度星球",
"description": "电子书、视频教程、私密社群",
"url": f"https://vip.hackrobot.cn?userid={user_id}",
"url": f"https://vip.hackrobot.cn?userid={uid}",
"picurl": "https://www.hackrobot.cn/static/images/images/dgnomad.png",
"type": "h5",
}
@@ -139,6 +195,102 @@ def _build_keyword_h5_obj(query: str, user_id: str) -> Optional[dict]:
return None
def build_fallback_reply_plain_text(user_id: str) -> str:
"""
兜底回复(单独构建):未命中「电子书」「群」等关键词时使用。
/chatbot、/hackrobot 共用同一文案与链接逻辑,仅解密密钥因 profile 不同。
"""
uid = (user_id or "").strip()
href = f"https://vip.hackrobot.cn/?userid={quote(uid, safe='')}" if uid else "https://vip.hackrobot.cn/"
return "🤖消息已收到,稍晚看到后回复\n参与交流,👉可<a href=\"" + href + "\">加群</a>"
def shared_resolve_reply(data: dict, profile_key: str) -> SharedChatbotReply:
"""根据解密后的 data 与 profile 决定 H5 或兜底;无关键词时一律走 build_fallback_reply_plain_text。"""
user_id = str(data.get("UserId", "") or data.get("userId", "") or "").strip()
query = str(data.get("Query", "") or "").strip()
h5 = shared_build_h5_keyword_message(query, user_id, profile_key)
if h5:
return SharedChatbotReply(h5, "")
return SharedChatbotReply(None, build_fallback_reply_plain_text(user_id))
def shared_build_openapi_text_answer(short_answer: str) -> dict:
"""开放 API 文档「输出」:文本 / 含超链兜底。"""
return {"answer_type": "text", "text_info": {"short_answer": short_answer}}
def _xml_channel_h5_envelope(data: dict, h5_message: dict) -> dict:
"""XML 入站(如 Eric /chatbot命中关键词userid 信封 + content.news与线上一致。"""
user_id = str(data.get("UserId", "") or data.get("userId", "") or "").strip()
appid = str(data.get("ChannelId", "") or data.get("appid", "")).strip()
channel = int(str(data.get("channel", "0") or "0"))
return {"userid": user_id, "appid": appid, "content": h5_message, "channel": channel, "from": 1}
def _openapi_json_h5_answer_per_sendmsg_doc(msg: dict) -> dict:
"""
开放 API JSON 入站命中 H5与文档「发送客服消息」一致msg 为 { news: { articles: [...] } }。
第三方回调侧将该 msg 序列化为 JSON 字符串,放入 answer_type=text 的 text_info.short_answer
(与客服接口里 msg 对象同源结构,由网关解析为卡片)。
"""
compact = json.dumps(msg, ensure_ascii=False, separators=(",", ":"))
return {"answer_type": "text", "text_info": {"short_answer": compact}}
def _openapi_json_merge_request_echo(data: dict, body: dict) -> dict:
"""回传 RequestId便于开放平台把应答与请求关联避免未命中/空白展示)。"""
rid = data.get("RequestId")
if rid is None:
return body
s = str(rid).strip()
if not s:
return body
out = dict(body)
out["RequestId"] = rid
return out
def shared_build_plain_body_for_wechat_callback(
*,
payload_format: str,
reply: SharedChatbotReply,
data: dict,
) -> dict:
"""
回调加密前的明文 JSON 统一入口(关键词 H5 与兜底共用一套解析结果 reply
- /chatbot 多为 XML 入站H5 用 userid 信封;兜底改用 answer_type=text信封里 content.msg 对超链异常)。
- /hackrobot 多为 JSON 入站:兜底用 answer_type=textH5 用同结构的 msg JSON 串作为 short_answer见发送客服消息文档
"""
fmt = (payload_format or "").strip().lower()
is_xml = fmt == "xml"
if reply.h5_message:
msg = _normalize_h5_msg_per_wechat_doc(reply.h5_message)
if is_xml:
return _xml_channel_h5_envelope(data, msg)
body = _openapi_json_h5_answer_per_sendmsg_doc(msg)
return _openapi_json_merge_request_echo(data, body)
body = shared_build_openapi_text_answer(reply.text_fallback)
if not is_xml:
body = _openapi_json_merge_request_echo(data, body)
return body
def _ensure_user_id_from_session(data: dict) -> None:
"""开放 API JSON 可能只带 SessionId如 0_xxx|openid补全 UserId 供关键词链接与兜底文案使用。"""
uid = str(data.get("UserId", "") or data.get("userId", "") or "").strip()
if uid:
return
sid = str(data.get("SessionId", "") or "").strip()
if "|" in sid:
tail = sid.rsplit("|", 1)[-1].strip()
if tail:
data["UserId"] = tail
def _parse_decrypted_payload(decrypted_text: str) -> dict:
text = (decrypted_text or "").strip()
# 兼容 BOM / NUL / 控制字符前缀
@@ -217,15 +369,6 @@ def _parse_decrypted_payload(decrypted_text: str) -> dict:
raise ValueError("unsupported payload format")
def _build_xml_callback_reply(data: dict, answer_text: str) -> dict:
user_id = str(data.get("UserId", "")).strip()
appid = str(data.get("ChannelId", "") or data.get("appid", "")).strip()
channel = int(str(data.get("channel", "0") or "0"))
msg = _build_keyword_h5_obj(str(data.get("Query", "")), user_id)
content = msg if msg else {"msg": answer_text}
return {"userid": user_id, "appid": appid, "content": content, "channel": channel, "from": 1}
def _safe_filter_value(value: str) -> str:
return (value or "").replace("\\", "\\\\").replace('"', '\\"')
@@ -241,12 +384,12 @@ def _build_callback_dedupe_key(data: dict) -> str:
)
def _is_duplicate_callback(data: dict, ttl_seconds: int = 3600) -> bool:
def _is_duplicate_callback(data: dict, profile_key: str, ttl_seconds: int = 3600) -> bool:
now_ts = int(time.time())
for k, exp in list(_processed_callback_cache.items()):
if exp <= now_ts:
_processed_callback_cache.pop(k, None)
key = _build_callback_dedupe_key(data)
key = _resolve_profile_key(profile_key) + "|" + _build_callback_dedupe_key(data)
if not key.strip("|"):
return False
if key in _processed_callback_cache:
@@ -326,13 +469,13 @@ async def _pb_upsert_user_profile(record: dict) -> None:
await client.post(f"{pb_url}/api/collections/{PB_COLLECTION}/records", json=record, headers=headers)
async def _sync_user_profile_from_b(data: dict) -> None:
async def _sync_user_profile_from_b(data: dict, profile_key: str) -> None:
authtoken = (PM_SYNC_AUTHTOKEN or "").strip()
wxbot_bid = (PM_SYNC_DEFAULT_WXBOT_BID or "").strip()
if not authtoken or not wxbot_bid:
return
target_userid = str(data.get("UserId", "")).strip()
if not target_userid or _is_duplicate_callback(data):
if not target_userid or _is_duplicate_callback(data, profile_key):
return
result = await query_private_messages(
authtoken=authtoken,
@@ -364,25 +507,30 @@ async def _sync_user_profile_from_b(data: dict) -> None:
)
async def _safe_sync_user_profile_from_b(data: dict) -> None:
async def _safe_sync_user_profile_from_b(data: dict, profile_key: str) -> None:
try:
await _sync_user_profile_from_b(data)
await _sync_user_profile_from_b(data, profile_key)
except Exception:
logger.error("chatbot后台同步失败已忽略")
async def handle_business(data: dict) -> str:
user_id = str(data.get("UserId", "")).strip()
query = str(data.get("Query", "")).strip()
msg = _build_keyword_h5_obj(query, user_id)
if msg:
return json.dumps(msg, ensure_ascii=False)
return ""
async def _handle_wechat_request(request: Request, app_id: Optional[str], background_tasks: BackgroundTasks) -> str:
if app_id and app_id != APP_ID:
logger.warning("chatbot app_id mismatch expected=%s actual=%s", APP_ID, app_id)
async def _handle_wechat_request(
request: Request,
app_id: Optional[str],
background_tasks: BackgroundTasks,
*,
profile_key: str,
) -> str:
creds = _profile_creds(profile_key)
expected_app_id = creds["APP_ID"]
encoding_key = creds["ENCODING_AES_KEY"]
if app_id and app_id != expected_app_id:
logger.warning(
"chatbot app_id mismatch profile=%s expected=%s actual=%s",
profile_key,
expected_app_id,
app_id,
)
raw_body = (await request.body()).decode("utf-8", errors="ignore").strip()
if not raw_body:
raise HTTPException(status_code=400, detail="empty body")
@@ -410,15 +558,18 @@ async def _handle_wechat_request(request: Request, app_id: Optional[str], backgr
cipher_b64 = "".join(str(cipher_b64).split()).strip()
try:
decrypted = aes_decrypt_base64(cipher_b64, ENCODING_AES_KEY)
decrypted = aes_decrypt_base64(cipher_b64, encoding_key)
logger.info(
"chatbot decrypted preview app_id=%s preview=%s",
"chatbot decrypted preview profile=%s app_id=%s preview=%s",
profile_key,
app_id or "",
(decrypted or "")[:200],
)
data = _parse_decrypted_payload(decrypted)
_ensure_user_id_from_session(data)
logger.info(
"chatbot payload parsed app_id=%s format=%s user=%s query=%s",
"chatbot payload parsed profile=%s app_id=%s format=%s user=%s query=%s",
profile_key,
app_id or "",
data.get("_payload_format", ""),
str(data.get("UserId", "") or data.get("userId", ""))[:80],
@@ -426,7 +577,8 @@ async def _handle_wechat_request(request: Request, app_id: Optional[str], backgr
)
except Exception as error:
logger.error(
"chatbot decrypt failed app_id=%s content_type=%s body_prefix=%s err=%s",
"chatbot decrypt failed profile=%s app_id=%s content_type=%s body_prefix=%s err=%s",
profile_key,
app_id or "",
request.headers.get("content-type", ""),
raw_body[:160],
@@ -434,35 +586,67 @@ async def _handle_wechat_request(request: Request, app_id: Optional[str], backgr
)
raise HTTPException(status_code=400, detail="decrypt error")
answer_text = await handle_business(data)
background_tasks.add_task(_safe_sync_user_profile_from_b, data)
reply = shared_resolve_reply(data, profile_key)
background_tasks.add_task(_safe_sync_user_profile_from_b, data, profile_key)
if str(data.get("_payload_format", "")) == "xml":
resp_plain = json.dumps(_build_xml_callback_reply(data, answer_text), ensure_ascii=False)
response_app_id = str(data.get("ChannelId", "") or data.get("appid", "")).strip() or APP_ID
fmt = str(data.get("_payload_format", "") or "")
plain_obj = shared_build_plain_body_for_wechat_callback(payload_format=fmt, reply=reply, data=data)
resp_plain = json.dumps(plain_obj, ensure_ascii=False)
logger.info(
"chatbot reply_plain profile=%s fmt=%s shape=%s",
profile_key,
fmt,
list(plain_obj.keys()),
)
if fmt == "xml":
response_app_id = str(data.get("ChannelId", "") or data.get("appid", "")).strip() or expected_app_id
else:
resp_plain = json.dumps({"answer_type": "text", "text_info": {"short_answer": answer_text}}, ensure_ascii=False)
# 与原始 chatbot 服务保持一致thirdapiURL 带 app_id回包使用纯 AES不拼 app_id
response_app_id = None if app_id else APP_ID
response_app_id = None if app_id else expected_app_id
return aes_encrypt_base64(resp_plain, ENCODING_AES_KEY, response_app_id)
return aes_encrypt_base64(resp_plain, encoding_key, response_app_id)
@router.post("/", response_class=PlainTextResponse)
@router.post("/chatbot", response_class=PlainTextResponse)
@router.post("/chatbot/", response_class=PlainTextResponse)
async def chatbot_root(request: Request, background_tasks: BackgroundTasks, app_id: Optional[str] = None):
return await _handle_wechat_request(request, app_id, background_tasks)
return await _handle_wechat_request(request, app_id, background_tasks, profile_key=ACTIVE_PROFILE)
@router.post("/wechat/thirdapi", response_class=PlainTextResponse)
@router.post("/chatbot/wechat/thirdapi", response_class=PlainTextResponse)
@router.post("/chatbot/wechat/thirdapi/", response_class=PlainTextResponse)
async def chatbot_thirdapi(request: Request, background_tasks: BackgroundTasks, app_id: Optional[str] = None):
return await _handle_wechat_request(request, app_id, background_tasks)
return await _handle_wechat_request(request, app_id, background_tasks, profile_key=ACTIVE_PROFILE)
@router.get("/health")
@router.get("/chatbot/health")
@router.get("/chatbot/health/")
def chatbot_health():
return {"status": "ok", "module": "chatbot-clone", "profile": ACTIVE_PROFILE}
hackrobot_router = APIRouter(tags=["hackrobot"])
@hackrobot_router.post("/hackrobot", response_class=PlainTextResponse)
@hackrobot_router.post("/hackrobot/", response_class=PlainTextResponse)
async def hackrobot_root(request: Request, background_tasks: BackgroundTasks, app_id: Optional[str] = None):
return await _handle_wechat_request(request, app_id, background_tasks, profile_key=HACKROBOT_PROFILE)
@hackrobot_router.post("/hackrobot/wechat/thirdapi", response_class=PlainTextResponse)
@hackrobot_router.post("/hackrobot/wechat/thirdapi/", response_class=PlainTextResponse)
async def hackrobot_thirdapi(request: Request, background_tasks: BackgroundTasks, app_id: Optional[str] = None):
return await _handle_wechat_request(request, app_id, background_tasks, profile_key=HACKROBOT_PROFILE)
@hackrobot_router.get("/hackrobot/health")
@hackrobot_router.get("/hackrobot/health/")
def hackrobot_health():
return {"status": "ok", "module": "chatbot-clone", "profile": HACKROBOT_PROFILE}
def _log_like_incoming(request: Request, body: bytes) -> None:
"""SmsForwarder Webhook 调试:打印完整入参(公众号助手等通知转发)。"""
client_host = request.client.host if request.client else None
@@ -514,7 +698,8 @@ def _client_ip_for_like(request: Request) -> str:
return ""
@router.post("/like")
@router.post("/chatbot/like")
@hackrobot_router.post("/hackrobot/like")
async def like_webhook_sink(request: Request):
"""接收 SmsForwarder 等 Webhook POST详细打日志公众号助手通知写入 PocketBase。"""
body = await request.body()
@@ -538,7 +723,8 @@ async def like_webhook_sink(request: Request):
return JSONResponse(status_code=200, content=payload)
@router.post("/api/private-message/query")
@router.post("/chatbot/api/private-message/query")
@hackrobot_router.post("/hackrobot/api/private-message/query")
async def chatbot_private_message_query(payload: dict):
authtoken = str(payload.get("authtoken", "")).strip()
wxbot_bid = str(payload.get("wxbot_bid", "")).strip()
@@ -570,7 +756,8 @@ async def chatbot_private_message_query(payload: dict):
}
@router.post("/api/private-message/analyze")
@router.post("/chatbot/api/private-message/analyze")
@hackrobot_router.post("/hackrobot/api/private-message/analyze")
async def chatbot_private_message_analyze(payload: dict):
authtoken = str(payload.get("authtoken", "")).strip()
if not authtoken:
@@ -605,9 +792,8 @@ async def chatbot_private_message_analyze(payload: dict):
}
@router.get("/private-message", response_class=HTMLResponse)
def chatbot_private_message_page():
return """
def _private_message_debug_html(api_prefix: str) -> str:
base = """
<!doctype html>
<html lang="zh-CN">
<head>
@@ -718,7 +904,7 @@ def chatbot_private_message_page():
page: Number($("page").value || 0),
size: Number($("size").value || 30),
};
const res = await fetch("/chatbot/api/private-message/query", {
const res = await fetch("__API_PREFIX__/api/private-message/query", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -737,7 +923,7 @@ def chatbot_private_message_page():
try{
setStatus("分析中...");
const payload = { authtoken: $("authtoken").value.trim() };
const res = await fetch("/chatbot/api/private-message/analyze", {
const res = await fetch("__API_PREFIX__/api/private-message/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -757,4 +943,14 @@ def chatbot_private_message_page():
</body>
</html>
"""
return base.replace("__API_PREFIX__", (api_prefix or "").rstrip("/"))
@router.get("/chatbot/private-message")
@router.get("/chatbot/private-message/")
@hackrobot_router.get("/hackrobot/private-message")
@hackrobot_router.get("/hackrobot/private-message/")
def chatbot_private_message_page(request: Request):
api_prefix = "/hackrobot" if str(request.url.path).startswith("/hackrobot") else "/chatbot"
return HTMLResponse(_private_message_debug_html(api_prefix))