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