From e12420cc489c13fd8e36fd557a19bd252c0c1c61 Mon Sep 17 00:00:00 2001 From: eric Date: Tue, 7 Apr 2026 00:31:43 -0500 Subject: [PATCH] 's' --- app/routers/chatbot.py | 27 +++++++-- app/services/chatbot_miniprogram_card.py | 70 ++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 app/services/chatbot_miniprogram_card.py diff --git a/app/routers/chatbot.py b/app/routers/chatbot.py index b493164..39571ea 100644 --- a/app/routers/chatbot.py +++ b/app/routers/chatbot.py @@ -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 文案(可含 )。""" + 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: diff --git a/app/services/chatbot_miniprogram_card.py b/app/services/chatbot_miniprogram_card.py new file mode 100644 index 0000000..f3ca53c --- /dev/null +++ b/app/services/chatbot_miniprogram_card.py @@ -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), + } + }