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, 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("/")

View File

@@ -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",
] ]

View File

@@ -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=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: 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 服务保持一致thirdapiURL 带 app_id回包使用纯 AES不拼 app_id # 与原始 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): 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))