's'
This commit is contained in:
@@ -31,6 +31,7 @@ from .routers import (
|
|||||||
common_router,
|
common_router,
|
||||||
meetup_router,
|
meetup_router,
|
||||||
chatbot_router,
|
chatbot_router,
|
||||||
|
hackrobot_router,
|
||||||
)
|
)
|
||||||
from .routers.salon_meetup import router as salon_meetup_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(meetup_router)
|
||||||
app.include_router(salon_meetup_router)
|
app.include_router(salon_meetup_router)
|
||||||
app.include_router(chatbot_router)
|
app.include_router(chatbot_router)
|
||||||
|
app.include_router(hackrobot_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from .nomadlms import router as nomadlms_router
|
|||||||
from .legacy import router as legacy_router
|
from .legacy import router as legacy_router
|
||||||
from .common import router as common_router
|
from .common import router as common_router
|
||||||
from .meetup import router as meetup_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__ = [
|
__all__ = [
|
||||||
"nomadvip_router",
|
"nomadvip_router",
|
||||||
@@ -24,4 +24,5 @@ __all__ = [
|
|||||||
"common_router",
|
"common_router",
|
||||||
"meetup_router",
|
"meetup_router",
|
||||||
"chatbot_router",
|
"chatbot_router",
|
||||||
|
"hackrobot_router",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import struct
|
|||||||
import time
|
import time
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import NamedTuple, Optional
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs, quote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from Crypto.Cipher import AES
|
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 ..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
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
_processed_callback_cache: dict[str, int] = {}
|
_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"]
|
TOKEN = OPEN_API_PROFILES[ACTIVE_PROFILE]["TOKEN"]
|
||||||
ENCODING_AES_KEY = OPEN_API_PROFILES[ACTIVE_PROFILE]["ENCODING_AES_KEY"]
|
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_AUTHTOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4"
|
||||||
PM_SYNC_DEFAULT_WXBOT_BID = "75021053883a9090c67d5becde7f4b0a"
|
PM_SYNC_DEFAULT_WXBOT_BID = "75021053883a9090c67d5becde7f4b0a"
|
||||||
PB_COLLECTION = "wechat_private_users"
|
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")
|
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()
|
q = (query or "").strip()
|
||||||
|
uid = (user_id or "").strip()
|
||||||
|
prof = _resolve_profile_key(profile_key)
|
||||||
if "电子书" in q:
|
if "电子书" in q:
|
||||||
return {
|
return {
|
||||||
"news": {
|
"news": {
|
||||||
@@ -115,21 +166,26 @@ def _build_keyword_h5_obj(query: str, user_id: str) -> Optional[dict]:
|
|||||||
{
|
{
|
||||||
"title": "数字游民",
|
"title": "数字游民",
|
||||||
"description": "地理套利与自动化杠杆",
|
"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",
|
"picurl": "https://www.hackrobot.cn/static/images/images/dgnomad.png",
|
||||||
"type": "h5",
|
"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 {
|
return {
|
||||||
"news": {
|
"news": {
|
||||||
"articles": [
|
"articles": [
|
||||||
{
|
{
|
||||||
"title": "异度星球",
|
"title": "异度星球",
|
||||||
"description": "电子书、视频教程、私密社群",
|
"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",
|
"picurl": "https://www.hackrobot.cn/static/images/images/dgnomad.png",
|
||||||
"type": "h5",
|
"type": "h5",
|
||||||
}
|
}
|
||||||
@@ -139,6 +195,102 @@ def _build_keyword_h5_obj(query: str, user_id: str) -> Optional[dict]:
|
|||||||
return None
|
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=text;H5 用同结构的 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:
|
def _parse_decrypted_payload(decrypted_text: str) -> dict:
|
||||||
text = (decrypted_text or "").strip()
|
text = (decrypted_text or "").strip()
|
||||||
# 兼容 BOM / NUL / 控制字符前缀
|
# 兼容 BOM / NUL / 控制字符前缀
|
||||||
@@ -217,15 +369,6 @@ def _parse_decrypted_payload(decrypted_text: str) -> dict:
|
|||||||
raise ValueError("unsupported payload format")
|
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:
|
def _safe_filter_value(value: str) -> str:
|
||||||
return (value or "").replace("\\", "\\\\").replace('"', '\\"')
|
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())
|
now_ts = int(time.time())
|
||||||
for k, exp in list(_processed_callback_cache.items()):
|
for k, exp in list(_processed_callback_cache.items()):
|
||||||
if exp <= now_ts:
|
if exp <= now_ts:
|
||||||
_processed_callback_cache.pop(k, None)
|
_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("|"):
|
if not key.strip("|"):
|
||||||
return False
|
return False
|
||||||
if key in _processed_callback_cache:
|
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)
|
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()
|
authtoken = (PM_SYNC_AUTHTOKEN or "").strip()
|
||||||
wxbot_bid = (PM_SYNC_DEFAULT_WXBOT_BID or "").strip()
|
wxbot_bid = (PM_SYNC_DEFAULT_WXBOT_BID or "").strip()
|
||||||
if not authtoken or not wxbot_bid:
|
if not authtoken or not wxbot_bid:
|
||||||
return
|
return
|
||||||
target_userid = str(data.get("UserId", "")).strip()
|
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
|
return
|
||||||
result = await query_private_messages(
|
result = await query_private_messages(
|
||||||
authtoken=authtoken,
|
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:
|
try:
|
||||||
await _sync_user_profile_from_b(data)
|
await _sync_user_profile_from_b(data, profile_key)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.error("chatbot后台同步失败(已忽略)")
|
logger.error("chatbot后台同步失败(已忽略)")
|
||||||
|
|
||||||
|
|
||||||
async def handle_business(data: dict) -> str:
|
async def _handle_wechat_request(
|
||||||
user_id = str(data.get("UserId", "")).strip()
|
request: Request,
|
||||||
query = str(data.get("Query", "")).strip()
|
app_id: Optional[str],
|
||||||
msg = _build_keyword_h5_obj(query, user_id)
|
background_tasks: BackgroundTasks,
|
||||||
if msg:
|
*,
|
||||||
return json.dumps(msg, ensure_ascii=False)
|
profile_key: str,
|
||||||
return ""
|
) -> str:
|
||||||
|
creds = _profile_creds(profile_key)
|
||||||
|
expected_app_id = creds["APP_ID"]
|
||||||
async def _handle_wechat_request(request: Request, app_id: Optional[str], background_tasks: BackgroundTasks) -> str:
|
encoding_key = creds["ENCODING_AES_KEY"]
|
||||||
if app_id and app_id != APP_ID:
|
if app_id and app_id != expected_app_id:
|
||||||
logger.warning("chatbot app_id mismatch expected=%s actual=%s", APP_ID, 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()
|
raw_body = (await request.body()).decode("utf-8", errors="ignore").strip()
|
||||||
if not raw_body:
|
if not raw_body:
|
||||||
raise HTTPException(status_code=400, detail="empty 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()
|
cipher_b64 = "".join(str(cipher_b64).split()).strip()
|
||||||
try:
|
try:
|
||||||
decrypted = aes_decrypt_base64(cipher_b64, ENCODING_AES_KEY)
|
decrypted = aes_decrypt_base64(cipher_b64, encoding_key)
|
||||||
logger.info(
|
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 "",
|
app_id or "",
|
||||||
(decrypted or "")[:200],
|
(decrypted or "")[:200],
|
||||||
)
|
)
|
||||||
data = _parse_decrypted_payload(decrypted)
|
data = _parse_decrypted_payload(decrypted)
|
||||||
|
_ensure_user_id_from_session(data)
|
||||||
logger.info(
|
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 "",
|
app_id or "",
|
||||||
data.get("_payload_format", ""),
|
data.get("_payload_format", ""),
|
||||||
str(data.get("UserId", "") or data.get("userId", ""))[:80],
|
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:
|
except Exception as error:
|
||||||
logger.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 "",
|
app_id or "",
|
||||||
request.headers.get("content-type", ""),
|
request.headers.get("content-type", ""),
|
||||||
raw_body[:160],
|
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")
|
raise HTTPException(status_code=400, detail="decrypt error")
|
||||||
|
|
||||||
answer_text = await handle_business(data)
|
reply = shared_resolve_reply(data, profile_key)
|
||||||
background_tasks.add_task(_safe_sync_user_profile_from_b, data)
|
background_tasks.add_task(_safe_sync_user_profile_from_b, data, profile_key)
|
||||||
|
|
||||||
if str(data.get("_payload_format", "")) == "xml":
|
fmt = str(data.get("_payload_format", "") or "")
|
||||||
resp_plain = json.dumps(_build_xml_callback_reply(data, answer_text), ensure_ascii=False)
|
plain_obj = shared_build_plain_body_for_wechat_callback(payload_format=fmt, reply=reply, data=data)
|
||||||
response_app_id = str(data.get("ChannelId", "") or data.get("appid", "")).strip() or APP_ID
|
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:
|
else:
|
||||||
resp_plain = json.dumps({"answer_type": "text", "text_info": {"short_answer": answer_text}}, ensure_ascii=False)
|
|
||||||
# 与原始 chatbot 服务保持一致:thirdapi(URL 带 app_id)回包使用纯 AES,不拼 app_id
|
# 与原始 chatbot 服务保持一致:thirdapi(URL 带 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):
|
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):
|
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():
|
def chatbot_health():
|
||||||
return {"status": "ok", "module": "chatbot-clone", "profile": ACTIVE_PROFILE}
|
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:
|
def _log_like_incoming(request: Request, body: bytes) -> None:
|
||||||
"""SmsForwarder Webhook 调试:打印完整入参(公众号助手等通知转发)。"""
|
"""SmsForwarder Webhook 调试:打印完整入参(公众号助手等通知转发)。"""
|
||||||
client_host = request.client.host if request.client else None
|
client_host = request.client.host if request.client else None
|
||||||
@@ -514,7 +698,8 @@ def _client_ip_for_like(request: Request) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@router.post("/like")
|
@router.post("/chatbot/like")
|
||||||
|
@hackrobot_router.post("/hackrobot/like")
|
||||||
async def like_webhook_sink(request: Request):
|
async def like_webhook_sink(request: Request):
|
||||||
"""接收 SmsForwarder 等 Webhook POST,详细打日志;公众号助手通知写入 PocketBase。"""
|
"""接收 SmsForwarder 等 Webhook POST,详细打日志;公众号助手通知写入 PocketBase。"""
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
@@ -538,7 +723,8 @@ async def like_webhook_sink(request: Request):
|
|||||||
return JSONResponse(status_code=200, content=payload)
|
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):
|
async def chatbot_private_message_query(payload: dict):
|
||||||
authtoken = str(payload.get("authtoken", "")).strip()
|
authtoken = str(payload.get("authtoken", "")).strip()
|
||||||
wxbot_bid = str(payload.get("wxbot_bid", "")).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):
|
async def chatbot_private_message_analyze(payload: dict):
|
||||||
authtoken = str(payload.get("authtoken", "")).strip()
|
authtoken = str(payload.get("authtoken", "")).strip()
|
||||||
if not authtoken:
|
if not authtoken:
|
||||||
@@ -605,9 +792,8 @@ async def chatbot_private_message_analyze(payload: dict):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/private-message", response_class=HTMLResponse)
|
def _private_message_debug_html(api_prefix: str) -> str:
|
||||||
def chatbot_private_message_page():
|
base = """
|
||||||
return """
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
@@ -718,7 +904,7 @@ def chatbot_private_message_page():
|
|||||||
page: Number($("page").value || 0),
|
page: Number($("page").value || 0),
|
||||||
size: Number($("size").value || 30),
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -737,7 +923,7 @@ def chatbot_private_message_page():
|
|||||||
try{
|
try{
|
||||||
setStatus("分析中...");
|
setStatus("分析中...");
|
||||||
const payload = { authtoken: $("authtoken").value.trim() };
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -757,4 +943,14 @@ def chatbot_private_message_page():
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</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))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user