This commit is contained in:
eric
2026-04-07 00:31:43 -05:00
parent a34217b182
commit e12420cc48
2 changed files with 91 additions and 6 deletions

View File

@@ -14,6 +14,7 @@ from Crypto.Cipher import AES
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from ..services.chatbot_miniprogram_card import build_miniprogram_card_msg, normalize_miniprogrampage_msg
from ..services.mp_recommend import is_mp_recommend_webhook_body, try_persist_from_like_body
from ..services.wechat_private_user_pb import (
contact_avatar_url,
@@ -114,12 +115,14 @@ def aes_encrypt_base64(plaintext: str, encoding_aes_key: str, app_id: str | None
class SharedChatbotReply(NamedTuple):
"""全公众号配置共用的解析结果:要么 H5 图文,要么兜底纯文本(含超链)。"""
"""全公众号配置共用的解析结果H5 图文、小程序卡片,或兜底纯文本(含超链)。"""
h5_message: Optional[dict]
"""与「发送客服消息」H5 一致:{\"news\": {\"articles\": [...]}};关键词为 Query 子串包含命中,未命中时为 None。"""
text_fallback: str
"""未命中关键词时的 short_answer 文案(可含 <a href>)。"""
miniprogram_message: Optional[dict]
"""与「发送客服消息」小程序卡片一致:{\"miniprogrampage\": {...}};未命中「小程序卡片」时为 None。"""
def _normalize_h5_msg_per_wechat_doc(h5_message: dict) -> dict:
@@ -155,6 +158,7 @@ def shared_build_h5_keyword_message(query: str, user_id: str, profile_key: str)
命中规则:对用户 Query 首尾 trim 后,**包含**配置词作为连续子串即命中(子串匹配),
不要求用户整句与关键词全等。群卡片统一按「群」子串命中。
「小程序卡片」在 shared_resolve_reply 中优先处理,不在此函数返回。
"""
q = (query or "").strip()
uid = (user_id or "").strip()
@@ -192,7 +196,7 @@ def shared_build_h5_keyword_message(query: str, user_id: str, profile_key: str)
def build_fallback_reply_plain_text(user_id: str) -> str:
"""
兜底回复(单独构建):未命中「电子书」「群」等关键词时使用。
兜底回复(单独构建):未命中「小程序卡片」「电子书」「群」等关键词时使用。
/chatbot、/hackrobot 共用同一文案与链接逻辑,仅解密密钥因 profile 不同。
"""
uid = (user_id or "").strip()
@@ -201,13 +205,16 @@ def build_fallback_reply_plain_text(user_id: str) -> str:
def shared_resolve_reply(data: dict, profile_key: str) -> SharedChatbotReply:
"""根据解密后的 data 与 profile 决定 H5 或兜底;关键词按 Query 子串包含判断,无命中时走 build_fallback_reply_plain_text。"""
"""根据解密后的 data 与 profile 决定小程序卡片、H5 或兜底;关键词按 Query 子串包含判断,无命中时走 build_fallback_reply_plain_text。"""
user_id = str(data.get("UserId", "") or data.get("userId", "") or "").strip()
query = str(data.get("Query", "") or "").strip()
if "小程序卡片" in query:
mp = build_miniprogram_card_msg(user_id=user_id)
return SharedChatbotReply(None, "", mp)
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))
return SharedChatbotReply(h5, "", None)
return SharedChatbotReply(None, build_fallback_reply_plain_text(user_id), None)
def shared_build_openapi_text_answer(short_answer: str) -> dict:
@@ -225,7 +232,8 @@ def _xml_channel_h5_envelope(data: dict, h5_message: dict) -> dict:
def _openapi_json_h5_answer_per_sendmsg_doc(msg: dict) -> dict:
"""
开放 API JSON 入站命中 H5与文档「发送客服消息」一致msg 为 { news: { articles: [...] } }
开放 API JSON 入站命中 H5 / 小程序卡片:与文档「发送客服消息」一致。
msg 为 { news: { articles: [...] } } 或 { miniprogrampage: {...} }。
第三方回调侧将该 msg 序列化为 JSON 字符串,放入 answer_type=text 的 text_info.short_answer
(与客服接口里 msg 对象同源结构,由网关解析为卡片)。
"""
@@ -261,6 +269,13 @@ def shared_build_plain_body_for_wechat_callback(
fmt = (payload_format or "").strip().lower()
is_xml = fmt == "xml"
if reply.miniprogram_message:
msg = normalize_miniprogrampage_msg(reply.miniprogram_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)
if reply.h5_message:
msg = _normalize_h5_msg_per_wechat_doc(reply.h5_message)
if is_xml:

View File

@@ -0,0 +1,70 @@
"""
微信开放平台「小程序卡片」客服消息 msg 结构(与发送客服消息文档一致)。
字段title, appid, pagepath, thumb_media_id, thumb_url。
"""
from __future__ import annotations
import os
from typing import Any, Mapping, Optional
from urllib.parse import quote
# 默认值可通过环境变量覆盖,便于不同部署环境配置素材与小程序信息
_DEFAULT_TITLE = (os.getenv("CHATBOT_MINIPROGRAM_TITLE") or "openai对话插件").strip()
_DEFAULT_APPID = (os.getenv("CHATBOT_MINIPROGRAM_APPID") or "wx8c631f7e9f2465e1").strip()
_DEFAULT_PAGEPATH = (os.getenv("CHATBOT_MINIPROGRAM_PAGEPATH") or "pages/index/index").strip()
_DEFAULT_THUMB_MEDIA_ID = (
os.getenv("CHATBOT_MINIPROGRAM_THUMB_MEDIA_ID") or "KegpipQG9t-klMo25My4e8zpBjhjg3JMrMSpgjikB4U"
).strip()
_DEFAULT_THUMB_URL = (
os.getenv("CHATBOT_MINIPROGRAM_THUMB_URL")
or "http://mmbiz.qpic.cn/mmbiz_png/W3gQtpV3j8BYhWgfHT5Hfg6auN94c2ec4BBhDOMtPQrx6vEMc1rR4iaDKxDLOfZ1jBUqIEEY4YpvEj6ktSyXT7g/0?wx_fmt=png"
).strip()
def normalize_miniprogrampage_msg(msg: Mapping[str, Any]) -> dict:
"""仅保留文档约定的 miniprogrampage 五字段,避免多余键导致客户端不渲染。"""
raw = msg.get("miniprogrampage") if isinstance(msg, dict) else None
inner = raw if isinstance(raw, dict) else {}
return {
"miniprogrampage": {
"title": str(inner.get("title", "")),
"appid": str(inner.get("appid", "")),
"pagepath": str(inner.get("pagepath", "")),
"thumb_media_id": str(inner.get("thumb_media_id", "")),
"thumb_url": str(inner.get("thumb_url", "")),
}
}
def build_miniprogram_card_msg(
*,
user_id: str = "",
title: Optional[str] = None,
appid: Optional[str] = None,
pagepath: Optional[str] = None,
thumb_media_id: Optional[str] = None,
thumb_url: Optional[str] = None,
append_userid_to_pagepath: bool = True,
) -> dict:
"""
返回完整客服 msg{\"miniprogrampage\": {...}}。
若 append_userid_to_pagepath 且 user_id 非空,在 pagepath 上追加 ?userid=(已有查询串则用 &)。
"""
uid = (user_id or "").strip()
base_path = (pagepath if pagepath is not None else _DEFAULT_PAGEPATH).strip()
if append_userid_to_pagepath and uid:
sep = "&" if "?" in base_path else "?"
base_path = f"{base_path}{sep}userid={quote(uid, safe='')}"
return {
"miniprogrampage": {
"title": str(title if title is not None else _DEFAULT_TITLE),
"appid": str(appid if appid is not None else _DEFAULT_APPID),
"pagepath": base_path,
"thumb_media_id": str(thumb_media_id if thumb_media_id is not None else _DEFAULT_THUMB_MEDIA_ID),
"thumb_url": str(thumb_url if thumb_url is not None else _DEFAULT_THUMB_URL),
}
}