7 Commits
test ... test2

Author SHA1 Message Date
eric
e12420cc48 's' 2026-04-07 00:31:43 -05:00
eric
a34217b182 's' 2026-04-02 05:30:18 -05:00
eric
a59592bed5 's' 2026-04-02 05:26:11 -05:00
eric
1c5b46a933 's' 2026-04-02 01:04:08 -05:00
eric
8c3483c360 's' 2026-04-02 00:26:00 -05:00
eric
a0085ba9a2 's' 2026-04-01 14:22:29 -05:00
eric
445c017a31 's' 2026-04-01 02:23:37 -05:00
7 changed files with 1030 additions and 294 deletions

View File

@@ -98,91 +98,25 @@ PAYMENT_PROVIDER=zpay
5. **查看详细错误**支付失败时payjsapi 会输出 `ZPAY create_order_api 失败` 日志,含 status/code/msg便于定位。 5. **查看详细错误**支付失败时payjsapi 会输出 `ZPAY create_order_api 失败` 日志,含 status/code/msg便于定位。
## Getting started ## 自检结果2026-04-01
To make it easy for you to get started with GitLab, here's a list of recommended next steps. 本次联动自检覆盖了 `nomadvip` 前端与本仓库 `nomadvip` 路由的支付闭环:
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! - 创建订单:`/nomadvip/payh5``/nomadvip/payh5/redirect`
- 异步通知:`/nomadvip/xorpay_notify``/nomadvip/zpay_notify`
- 支付查询:`/nomadvip/order_status`
- VIP 识别:`/nomadvip/check_vip``/nomadvip/check_vip_by_email`
## Add your files ### 发现问题
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files - 业务链路问题发生在前端 `nomadvip``/api/pay/confirm` 兜底写 `payments` 时使用默认金额,可能与实际支付金额不一致(例如测试价、活动价)。
- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
``` ### 处理状态
cd existing_repo
git remote add origin http://gitlab.yidooplanet.com/gitlab-instance-0a899031/androidh5api.git
git branch -M main
git push -uf origin main
```
## Integrate with your tools - 已在前端仓库修复confirm 兜底写库时优先使用网关返回 `pay_price`,失败才回退默认金额。
- 本仓库暂未发现新的阻断型业务 bug以静态代码审阅为准
- [ ] [Set up project integrations](http://gitlab.yidooplanet.com/gitlab-instance-0a899031/androidh5api/-/settings/integrations) ## 维护建议
## Collaborate with your team - 建议保持 `payjsapi` 为唯一支付入账源,前端 confirm 仅作为短时兜底。
- 建议为 `order_status` 返回结构固定增加字段约束(如统一返回 `pay_price` 字段),避免调用侧重复兼容。
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
## Test and Deploy
Use the built-in continuous integration in GitLab.
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
***
# Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.

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

@@ -1,26 +1,30 @@
import asyncio
import base64 import base64
import json import json
import logging import logging
import os import os
import struct import struct
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
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import HTMLResponse, PlainTextResponse from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from ..config import PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, PB_URL 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,
query_private_messages,
safe_sync_wechat_private_user_to_pb,
)
router = APIRouter(prefix="/chatbot", tags=["chatbot"]) router = APIRouter(tags=["chatbot"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_processed_callback_cache: dict[str, int] = {}
OPEN_API_PROFILES = { OPEN_API_PROFILES = {
"eric": { "eric": {
@@ -43,11 +47,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"]
PM_SYNC_AUTHTOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4" HACKROBOT_PROFILE = "xidu"
PM_SYNC_DEFAULT_WXBOT_BID = "75021053883a9090c67d5becde7f4b0a"
PB_COLLECTION = "wechat_private_users"
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)]
def pkcs5_unpadding(data: bytes) -> bytes: def pkcs5_unpadding(data: bytes) -> bytes:
pad = data[-1] pad = data[-1]
if pad < 1 or pad > 32: if pad < 1 or pad > 32:
@@ -104,8 +114,54 @@ 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\": [...]}};关键词为 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:
"""
与文档「发送客服消息」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 输出)。
命中规则:对用户 Query 首尾 trim 后,**包含**配置词作为连续子串即命中(子串匹配),
不要求用户整句与关键词全等。群卡片统一按「群」子串命中。
「小程序卡片」在 shared_resolve_reply 中优先处理,不在此函数返回。
"""
q = (query or "").strip() q = (query or "").strip()
uid = (user_id or "").strip()
if "电子书" in q: if "电子书" in q:
return { return {
"news": { "news": {
@@ -113,21 +169,22 @@ 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: group_hit = "" 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",
} }
@@ -137,6 +194,113 @@ 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 或兜底;关键词按 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, "", None)
return SharedChatbotReply(None, build_fallback_reply_plain_text(user_id), None)
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: [...] } } 或 { miniprogrampage: {...} }。
第三方回调侧将该 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.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:
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 / 控制字符前缀
@@ -215,172 +379,23 @@ 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: async def _handle_wechat_request(
user_id = str(data.get("UserId", "")).strip() request: Request,
appid = str(data.get("ChannelId", "") or data.get("appid", "")).strip() app_id: Optional[str],
channel = int(str(data.get("channel", "0") or "0")) background_tasks: BackgroundTasks,
msg = _build_keyword_h5_obj(str(data.get("Query", "")), user_id) *,
content = msg if msg else {"msg": answer_text} profile_key: str,
return {"userid": user_id, "appid": appid, "content": content, "channel": channel, "from": 1} ) -> str:
creds = _profile_creds(profile_key)
expected_app_id = creds["APP_ID"]
def _safe_filter_value(value: str) -> str: encoding_key = creds["ENCODING_AES_KEY"]
return (value or "").replace("\\", "\\\\").replace('"', '\\"') if app_id and app_id != expected_app_id:
logger.warning(
"chatbot app_id mismatch profile=%s expected=%s actual=%s",
def _build_callback_dedupe_key(data: dict) -> str: profile_key,
return "|".join( expected_app_id,
[ app_id,
str(data.get("UserId", "")).strip(),
str(data.get("MsgId", "")).strip(),
str(data.get("Timestamp", "")).strip(),
str(data.get("Query", "")).strip(),
]
)
def _is_duplicate_callback(data: dict, ttl_seconds: int = 3600) -> bool:
now_ts = int(time.time())
for k, exp in list(_processed_callback_cache.items()):
if exp <= now_ts:
_processed_callback_cache.pop(k, None)
key = _build_callback_dedupe_key(data)
if not key.strip("|"):
return False
if key in _processed_callback_cache:
return True
_processed_callback_cache[key] = now_ts + ttl_seconds
return False
async def query_private_messages(
authtoken: str,
wxbot_bid: str,
page: int = 0,
size: int = 30,
filter_value: int = 0,
request_id: str = "local-debug",
) -> dict:
url = "https://chatbot.weixin.qq.com/miniopenai/manualservice/getaccessstautuslist"
body = {"bid": wxbot_bid, "filter": filter_value, "order": {"page": page, "size": size}, "base": {"requestid": request_id}}
headers = {
"authtoken": authtoken,
"wxbot_bid": wxbot_bid,
"content-type": "application/json",
"charset": "utf-8",
}
async with httpx.AsyncClient(timeout=10, verify=False) as client:
resp = await client.post(url, json=body, headers=headers)
resp.raise_for_status()
return resp.json()
async def _pb_upsert_user_profile(record: dict) -> None:
pb_url = (PB_URL or "").rstrip("/")
if not pb_url or not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD:
return
userid = str(record.get("userid", "")).strip()
if not userid:
return
async with httpx.AsyncClient(timeout=10, verify=False) as client:
auth_resp = await client.post(
f"{pb_url}/api/admins/auth-with-password",
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
) )
if auth_resp.status_code == 404:
auth_resp = await client.post(
f"{pb_url}/api/collections/_superusers/auth-with-password",
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
)
if auth_resp.status_code != 200:
return
token = (auth_resp.json() or {}).get("token", "")
if not token:
return
headers = {"Authorization": f"Bearer {token}"}
user_expr = _safe_filter_value(userid)
# 业务要求wechat_private_users.userid 全局唯一(不按 channel_id 分桶)
filter_exp = f'userid="{user_expr}"'
list_resp = await client.get(
f"{pb_url}/api/collections/{PB_COLLECTION}/records",
params={"page": 1, "perPage": 200, "filter": filter_exp, "sort": "-updated"},
headers=headers,
)
items = ((list_resp.json() or {}).get("items")) or [] if list_resp.status_code == 200 else []
if items:
rid = items[0].get("id")
if rid:
await client.patch(f"{pb_url}/api/collections/{PB_COLLECTION}/records/{rid}", json=record, headers=headers)
# 清理历史重复数据,确保 userid 唯一
for extra in items[1:]:
extra_id = str(extra.get("id", "")).strip()
if not extra_id:
continue
await client.delete(
f"{pb_url}/api/collections/{PB_COLLECTION}/records/{extra_id}",
headers=headers,
)
return
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:
authtoken = (PM_SYNC_AUTHTOKEN or "").strip()
wxbot_bid = (PM_SYNC_DEFAULT_WXBOT_BID or "").strip()
if not authtoken or not wxbot_bid:
return
target_userid = str(data.get("UserId", "")).strip()
if not target_userid or _is_duplicate_callback(data):
return
result = await query_private_messages(
authtoken=authtoken,
wxbot_bid=wxbot_bid,
page=0,
size=100,
filter_value=0,
request_id=f"thirdapi-{target_userid}",
)
contacts = result.get("contacts") or []
target = next((c for c in contacts if str(c.get("userId", "") or c.get("userid", "")).strip() == target_userid), None)
if not target:
return
last_msg = target.get("lastMsg") or {}
await _pb_upsert_user_profile(
{
"userid": target_userid,
"channel_id": str(data.get("ChannelId", "")).strip(),
"nick": target.get("nick", ""),
"avatar": target.get("headImg") or target.get("avatar") or target.get("headimgurl") or target.get("headImgUrl") or "",
"source_query": str(data.get("Query", "")).strip(),
"source_message_id": str(data.get("MsgId", "")).strip(),
"source_message_time": str(data.get("Timestamp", "")),
"matched_last_msg": last_msg.get("content", ""),
"matched_last_msg_id": str(last_msg.get("msgId", "") or last_msg.get("id", "")),
"matched_last_msg_time": str(target.get("lastActiveTime", "")),
"raw_contact": json.dumps(target, ensure_ascii=False),
}
)
async def _safe_sync_user_profile_from_b(data: dict) -> None:
try:
await _sync_user_profile_from_b(data)
except Exception:
logger.error("chatbot后台同步失败已忽略")
async def handle_business(data: dict) -> str:
user_id = str(data.get("UserId", "")).strip()
query = str(data.get("Query", "")).strip()
msg = _build_keyword_h5_obj(query, user_id)
if msg:
return json.dumps(msg, ensure_ascii=False)
return ""
async def _handle_wechat_request(request: Request, app_id: Optional[str], background_tasks: BackgroundTasks) -> str:
if app_id and app_id != APP_ID:
logger.warning("chatbot app_id mismatch expected=%s actual=%s", 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")
@@ -408,15 +423,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],
@@ -424,7 +442,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],
@@ -432,36 +451,145 @@ 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_wechat_private_user_to_pb, 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}
@router.post("/api/private-message/query") 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:
"""SmsForwarder Webhook 调试:打印完整入参(公众号助手等通知转发)。"""
client_host = request.client.host if request.client else None
client_port = request.client.port if request.client else None
query = {k: v for k, v in request.query_params.multi_items()}
headers = {k: v for k, v in request.headers.items()}
ct = headers.get("content-type", "") or headers.get("Content-Type", "")
logger.info(
"[like] ----- incoming POST ----- path=%s full_url=%s client=%s:%s query=%r",
request.url.path,
str(request.url),
client_host,
client_port,
query,
)
logger.info("[like] content_type=%r content_length=%s body_bytes=%s", ct, headers.get("content-length"), len(body))
logger.info("[like] headers=%s", headers)
if not body:
logger.info("[like] body=(empty)")
return
text = None
try:
text = body.decode("utf-8")
except UnicodeDecodeError:
hex_preview = body[:400].hex()
suffix = "..." if len(body) > 400 else ""
logger.info("[like] body=(non-utf8, hex prefix) %s%s", hex_preview, suffix)
return
logger.info("[like] body_text=\n%s", text)
low = (ct or "").lower()
if "json" in low:
try:
parsed = json.loads(text)
logger.info("[like] body_json=%s", json.dumps(parsed, ensure_ascii=False, indent=2))
except json.JSONDecodeError as e:
logger.info("[like] body_json_parse_failed: %s", e)
def _client_ip_for_like(request: Request) -> str:
xff = request.headers.get("x-forwarded-for") or request.headers.get("X-Forwarded-For")
if xff:
return xff.split(",")[0].strip()
if request.client:
return request.client.host or ""
return ""
@router.post("/chatbot/like")
@hackrobot_router.post("/hackrobot/like")
async def like_webhook_sink(request: Request):
"""接收 SmsForwarder 等 Webhook POST详细打日志公众号助手通知写入 PocketBase。"""
body = await request.body()
_log_like_incoming(request, body)
ct = request.headers.get("content-type", "") or ""
payload: dict = {"ok": True, "received_bytes": len(body)}
try:
text = body.decode("utf-8")
except UnicodeDecodeError:
return JSONResponse(status_code=200, content=payload)
if is_mp_recommend_webhook_body(text, ct):
try:
summary = await asyncio.to_thread(try_persist_from_like_body, text, ct, _client_ip_for_like(request))
if summary:
payload["mp_recommend"] = summary
except Exception:
logger.exception("[like] PocketBase 写入 mp_recommend 失败")
payload["mp_recommend_error"] = "persist_failed"
return JSONResponse(status_code=200, content=payload)
@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()
@@ -483,7 +611,7 @@ async def chatbot_private_message_query(payload: dict):
{ {
"nick": c.get("nick"), "nick": c.get("nick"),
"userId": c.get("userId") or c.get("userid"), "userId": c.get("userId") or c.get("userid"),
"avatar": c.get("avatar") or c.get("headImg") or c.get("headimgurl") or c.get("headImgUrl"), "avatar": contact_avatar_url(c) or None,
"lastMsg": (c.get("lastMsg") or {}).get("content"), "lastMsg": (c.get("lastMsg") or {}).get("content"),
"lastActiveTime": c.get("lastActiveTime"), "lastActiveTime": c.get("lastActiveTime"),
} }
@@ -493,7 +621,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:
@@ -528,9 +657,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>
@@ -550,8 +678,16 @@ def chatbot_private_message_page():
button { border: 0; background: #2563eb; color: white; border-radius: 8px; padding: 10px 14px; cursor: pointer; } button { border: 0; background: #2563eb; color: white; border-radius: 8px; padding: 10px 14px; cursor: pointer; }
button.alt { background: #4f46e5; } button.alt { background: #4f46e5; }
#status { font-size: 13px; color: #93c5fd; } #status { font-size: 13px; color: #93c5fd; }
#token-countdown-wrap { margin-top: 12px; display: none; }
#token-countdown-wrap.active { display: block; }
.cd-row { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #94a3b8; gap: 8px; }
.cd-bar { height: 8px; background: #1e293b; border-radius: 4px; overflow: hidden; margin-top: 8px; }
.cd-bar-fill { height: 100%; width: 100%; border-radius: 4px; transition: width 0.35s linear, background 0.25s; background: #22c55e; }
#users { display:grid; grid-template-columns: repeat(auto-fill,minmax(250px,1fr)); gap: 8px; } #users { display:grid; grid-template-columns: repeat(auto-fill,minmax(250px,1fr)); gap: 8px; }
.u { border:1px solid #334155; border-radius:8px; padding:8px; background:#0b1220; } .u { border:1px solid #334155; border-radius:8px; padding:8px; background:#0b1220; display:flex; gap:10px; align-items:flex-start; }
.u .ava { width:40px; height:40px; border-radius:8px; object-fit:cover; flex-shrink:0; background:#1e293b; }
.u .ava--ph { display:flex; align-items:center; justify-content:center; font-size:10px; color:#64748b; text-align:center; line-height:1.2; padding:2px; box-sizing:border-box; }
.u .meta { min-width:0; flex:1; }
.uid { color:#93c5fd; font-size:12px; word-break:break-all; } .uid { color:#93c5fd; font-size:12px; word-break:break-all; }
pre { background:#020617; border:1px solid #334155; border-radius:8px; padding:10px; overflow:auto; max-height:420px; } pre { background:#020617; border:1px solid #334155; border-radius:8px; padding:10px; overflow:auto; max-height:420px; }
</style> </style>
@@ -561,6 +697,13 @@ def chatbot_private_message_page():
<section class="card"> <section class="card">
<h2>Chatbot 私信调试台</h2> <h2>Chatbot 私信调试台</h2>
<div id="status">准备就绪</div> <div id="status">准备就绪</div>
<div id="token-countdown-wrap">
<div class="cd-row">
<span>Token 剩余</span>
<span id="token-countdown-text">--</span>
</div>
<div class="cd-bar"><div id="token-countdown-bar" class="cd-bar-fill"></div></div>
</div>
<label>默认配置</label> <label>默认配置</label>
<select id="preset" style="width:100%;border-radius:8px;border:1px solid #475569;background:#0b1220;color:#e2e8f0;padding:10px;box-sizing:border-box;"> <select id="preset" style="width:100%;border-radius:8px;border:1px solid #475569;background:#0b1220;color:#e2e8f0;padding:10px;box-sizing:border-box;">
<option value="eric">Eric在旅行</option> <option value="eric">Eric在旅行</option>
@@ -611,18 +754,132 @@ def chatbot_private_message_page():
}; };
function setStatus(s){ $("status").textContent = s; } function setStatus(s){ $("status").textContent = s; }
function renderJson(v){ $("out").textContent = JSON.stringify(v, null, 2); } function renderJson(v){ $("out").textContent = JSON.stringify(v, null, 2); }
/** HTTPS 页内嵌 http://wx.qlogo.cn 会被浏览器拦截,改为 https 同路径即可加载 */
function normalizeAvatarUrl(av){
const s = (av || "").trim();
if(!s) return "";
const low = s.toLowerCase();
if(low.startsWith("http://") && low.indexOf("qlogo.cn") >= 0) return "https://" + s.slice(7);
return s;
}
function isAvatarImgUrl(s){
const t = (s || "").trim().toLowerCase();
return t.startsWith("https://") || t.startsWith("http://");
}
function mkAvaPlaceholder(title, hint){
const d = document.createElement("div");
d.className = "ava ava--ph";
d.title = title || "";
d.textContent = hint || "无图";
return d;
}
function renderUsers(users){ function renderUsers(users){
const box = $("users"); const box = $("users");
box.replaceChildren();
if(!Array.isArray(users) || !users.length){ if(!Array.isArray(users) || !users.length){
box.innerHTML = '<div class="u">暂无数据</div>'; const empty = document.createElement("div");
empty.className = "u";
empty.textContent = "暂无数据";
box.appendChild(empty);
return; return;
} }
box.innerHTML = users.map(u => { for(const u of users){
const nick = (u.nick || "未命名").replace(/</g,"&lt;").replace(/>/g,"&gt;"); const row = document.createElement("div");
const uid = (u.userId || "").replace(/</g,"&lt;").replace(/>/g,"&gt;"); row.className = "u";
const last = (u.lastMsg || "").replace(/</g,"&lt;").replace(/>/g,"&gt;"); const rawAv = String(u.avatar || "").trim();
return `<div class="u"><div>${nick}</div><div class="uid">${uid}</div><div style="font-size:12px;color:#94a3b8;margin-top:4px;">${last}</div></div>`; const av = normalizeAvatarUrl(rawAv);
}).join(""); if(av && isAvatarImgUrl(av)){
const img = document.createElement("img");
img.className = "ava";
img.alt = "";
img.referrerPolicy = "no-referrer";
img.loading = "lazy";
img.decoding = "async";
img.src = av;
img.onerror = function(){
const ph = mkAvaPlaceholder("头像加载失败", "失败");
this.replaceWith(ph);
};
row.appendChild(img);
}else{
row.appendChild(mkAvaPlaceholder(rawAv ? "非浏览器可加载的链接" : "接口未返回头像", rawAv ? "?" : ""));
}
const meta = document.createElement("div");
meta.className = "meta";
const nickEl = document.createElement("div");
nickEl.textContent = u.nick || "未命名";
const uidEl = document.createElement("div");
uidEl.className = "uid";
uidEl.textContent = u.userId || "";
const lastEl = document.createElement("div");
lastEl.style.cssText = "font-size:12px;color:#94a3b8;margin-top:4px;";
lastEl.textContent = u.lastMsg || "";
meta.appendChild(nickEl);
meta.appendChild(uidEl);
meta.appendChild(lastEl);
row.appendChild(meta);
box.appendChild(row);
}
}
let _tokenCdTimer = null;
let _tokenCdSpan = 0;
function parseJwtPayload(token){
try{
const parts = String(token || "").trim().split(".");
if(parts.length < 2) return null;
let p = parts[1].replace(/-/g,"+").replace(/_/g,"/");
p += "=".repeat((4 - (p.length % 4)) % 4);
return JSON.parse(atob(p));
}catch(e){ return null; }
}
function formatRemain(sec){
if(sec <= 0) return "已过期";
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if(d > 0) return d + "" + h + "小时";
if(h > 0) return h + "小时 " + m + "";
if(m > 0) return m + "" + s + "";
return s + "";
}
function stopTokenCountdown(){
if(_tokenCdTimer){ clearInterval(_tokenCdTimer); _tokenCdTimer = null; }
}
function startTokenCountdown(){
stopTokenCountdown();
const wrap = $("token-countdown-wrap");
const textEl = $("token-countdown-text");
const bar = $("token-countdown-bar");
const token = $("authtoken").value.trim();
const pl = parseJwtPayload(token);
const exp = pl && typeof pl.exp === "number" ? pl.exp : null;
const iat = pl && typeof pl.iat === "number" ? pl.iat : null;
if(exp == null){
wrap.classList.remove("active");
return;
}
wrap.classList.add("active");
const now0 = Math.floor(Date.now() / 1000);
_tokenCdSpan = Math.max(120, exp - (iat || now0));
function tick(){
const now = Math.floor(Date.now() / 1000);
const left = exp - now;
const pct = Math.max(0, Math.min(100, (left / _tokenCdSpan) * 100));
textEl.textContent = formatRemain(left);
bar.style.width = pct + "%";
if(left > _tokenCdSpan * 0.5) bar.style.background = "#22c55e";
else if(left > _tokenCdSpan * 0.2) bar.style.background = "#eab308";
else bar.style.background = "#ef4444";
if(left <= 0){
textEl.textContent = "已过期";
bar.style.width = "0%";
stopTokenCountdown();
}
}
tick();
_tokenCdTimer = setInterval(tick, 1000);
} }
function applyPreset(key){ function applyPreset(key){
@@ -630,6 +887,7 @@ def chatbot_private_message_page():
if(!p) return; if(!p) return;
$("authtoken").value = p.authtoken; $("authtoken").value = p.authtoken;
$("wxbot_bid").value = p.wxbot_bid; $("wxbot_bid").value = p.wxbot_bid;
startTokenCountdown();
} }
async function queryPm(){ async function queryPm(){
@@ -641,7 +899,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),
@@ -660,7 +918,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),
@@ -668,6 +926,7 @@ def chatbot_private_message_page():
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
setStatus(res.ok ? "分析成功" : "分析失败"); setStatus(res.ok ? "分析成功" : "分析失败");
renderJson(data); renderJson(data);
startTokenCountdown();
}catch(e){ }catch(e){
setStatus("请求异常"); setStatus("请求异常");
renderJson({ error: String(e) }); renderJson({ error: String(e) });
@@ -675,9 +934,20 @@ def chatbot_private_message_page():
} }
$("preset").addEventListener("change", (e) => applyPreset(e.target.value)); $("preset").addEventListener("change", (e) => applyPreset(e.target.value));
$("authtoken").addEventListener("input", function(){ startTokenCountdown(); });
applyPreset("eric"); applyPreset("eric");
</script> </script>
</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))

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),
}
}

View File

@@ -0,0 +1,227 @@
"""
公众号助手com.tencent.mp「收到XXX的推荐」通知入库。
仅需 PocketBase 一个集合mp_recommend_events每个 mp_uid + recommender_key 一行,累计计数)。
- username Text 用户名(展示名,如从标题解析的 Eric
- cumulative_count Number 该用户的累计触发次数(同一 mp_uid + recommender_key 每次 webhook 写入时 = 旧值 + 1
- recommender_key Text 归一化键unicode 归一化 + 清洗 + casefold与 username 对应,便于筛选,建议建索引
- mp_uid Text 用户ID用于把同名推荐人按不同账号隔离统计
字段(在 PocketBase 中新建,名称一致):
- recommender_key Text
- username Text
- cumulative_count Number
- package_name Text
- title_line Text
- summary_line Text
- mp_uid Text
- device_time_str Text
- device_model Text
- forward_timestamp_ms Number 可选
- raw_content Text
- source_from Text
- client_ip Text
"""
from __future__ import annotations
import logging
import re
import unicodedata
from typing import Any
from urllib.parse import parse_qs
from .pb import get_pb_client
logger = logging.getLogger(__name__)
COL_EVENTS = "mp_recommend_events"
PACKAGE_MP = "com.tencent.mp"
_TITLE_REC = re.compile(r"收到(.+?)的推荐")
_UID_REC = re.compile(r"UID[:]\s*(\S+)")
def _pb_escape(s: str) -> str:
return (s or "").replace("\\", "\\\\").replace('"', '\\"')
def _normalize_username(raw: str) -> str:
"""
尽量稳定地把展示名归一化到可用于统计/过滤的形式。
- 兼容 emoji/颜文字/特殊字母:保留字符本身,不做 ASCII 限制
- 统一空白:压缩为单个空格
- Unicode 归一化NFKC把全角等兼容形态归一
- 大小写:仅在 key 上 casefold
"""
s = (raw or "")
s = unicodedata.normalize("NFKC", s)
s = s.replace("\u00A0", " ")
s = re.sub(r"\s+", " ", s).strip()
return s
def _username_to_key(username: str) -> str:
u = _normalize_username(username)
return u.casefold() if u else "unknown"
def parse_form_urlencoded_like(body: str) -> dict[str, str]:
q = parse_qs(body, keep_blank_values=True, strict_parsing=False)
return {
"from": (q.get("from") or [""])[0],
"content": (q.get("content") or [""])[0],
"timestamp": (q.get("timestamp") or [""])[0],
}
def parse_mp_recommend_content(content: str) -> dict[str, Any]:
text = (content or "").replace("\r\n", "\n").replace("\r", "\n")
lines = text.split("\n")
stripped = [ln.strip() for ln in lines]
package_name = stripped[0] if stripped else ""
title_line = stripped[1] if len(stripped) > 1 else ""
summary_line = stripped[2] if len(stripped) > 2 else ""
device_time_str = stripped[4] if len(stripped) > 4 else ""
device_model = stripped[5] if len(stripped) > 5 else ""
recommender_name = ""
m = _TITLE_REC.search(title_line or "")
if m:
recommender_name = (m.group(1) or "").strip()
mp_uid = ""
um = _UID_REC.search(text)
if um:
mp_uid = (um.group(1) or "").strip()
return {
"package_name": package_name,
"recommender_name": recommender_name,
"title_line": title_line,
"summary_line": summary_line,
"mp_uid": mp_uid,
"device_time_str": device_time_str,
"device_model": device_model,
"raw_content": text,
}
def _parse_forward_ts_ms(raw: str) -> int | None:
s = (raw or "").strip()
if not s.isdigit():
return None
try:
return int(s)
except ValueError:
return None
def persist_mp_recommend(
*,
source_from: str,
content: str,
timestamp_raw: str,
client_ip: str,
) -> dict[str, Any]:
"""按 mp_uid + recommender_key 聚合更新累计次数(存在则 +1不存在则创建"""
parsed = parse_mp_recommend_content(content)
forward_ts = _parse_forward_ts_ms(timestamp_raw)
username = _normalize_username(parsed.get("recommender_name") or "") or "unknown"
key = _username_to_key(username if username != "unknown" else "")
mp_uid = (parsed.get("mp_uid") or "").strip()
pb = get_pb_client()
events = pb.collection(COL_EVENTS)
filt_parts = [f'recommender_key = "{_pb_escape(key)}"']
if mp_uid:
filt_parts.append(f'mp_uid = "{_pb_escape(mp_uid)}"')
filt = " && ".join(filt_parts)
lst = events.get_list(1, 1, {"filter": filt, "sort": "-updated"})
items = list(getattr(lst, "items", []) or [])
existing = items[0] if items else None
old_count = 0
existing_id = None
if existing is not None:
existing_id = getattr(existing, "id", None)
try:
old_count = int(getattr(existing, "cumulative_count", 0) or 0)
except (TypeError, ValueError):
old_count = 0
cumulative_count = old_count + 1
event_payload: dict[str, Any] = {
"recommender_key": key,
"username": username,
"cumulative_count": cumulative_count,
"package_name": parsed["package_name"],
"title_line": parsed["title_line"],
"summary_line": parsed["summary_line"],
"mp_uid": mp_uid,
"device_time_str": parsed["device_time_str"],
"device_model": parsed["device_model"],
"raw_content": parsed["raw_content"],
"source_from": source_from,
"client_ip": client_ip or "",
}
if forward_ts is not None:
event_payload["forward_timestamp_ms"] = forward_ts
if existing_id:
rec = events.update(existing_id, event_payload)
else:
rec = events.create(event_payload)
event_id = getattr(rec, "id", None) or str(rec)
logger.info(
"[like] mp_recommend stored event_id=%s username=%s cumulative_count=%s",
event_id,
username,
cumulative_count,
)
return {
"event_id": event_id,
"recommender_key": key,
"username": username,
"cumulative_count": cumulative_count,
}
def is_mp_recommend_webhook_body(body_text: str, content_type: str) -> bool:
"""是否为公众号助手 SmsForwarder 表单(可尝试入库)。"""
low = (content_type or "").lower()
if "application/x-www-form-urlencoded" not in low:
return False
fields = parse_form_urlencoded_like(body_text)
if (fields.get("from") or "").strip() != PACKAGE_MP:
return False
return bool((fields.get("content") or "").strip())
def try_persist_from_like_body(
body_text: str,
content_type: str,
client_ip: str,
) -> dict[str, Any] | None:
"""若为公众号助手 urlencoded 通知则入库并返回摘要;否则返回 None。"""
low = (content_type or "").lower()
if "application/x-www-form-urlencoded" not in low:
return None
fields = parse_form_urlencoded_like(body_text)
source_from = (fields.get("from") or "").strip()
if source_from != PACKAGE_MP:
return None
content = fields.get("content") or ""
if not content.strip():
return None
return persist_mp_recommend(
source_from=source_from,
content=content,
timestamp_raw=fields.get("timestamp") or "",
client_ip=client_ip,
)

View File

@@ -0,0 +1,232 @@
"""
微信 Chatbot 开放对话回调后:从 chatbot.weixin 联系人接口拉取资料,写入 PocketBase wechat_private_users。
/chatboteric与 /hackrobotxidu共用按 profile_key 选用不同 wxbot_bid。
"""
from __future__ import annotations
import json
import logging
import os
import time
from typing import Optional
import httpx
from ..config import PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, PB_URL
logger = logging.getLogger(__name__)
WECHAT_PRIVATE_USERS_COLLECTION = "wechat_private_users"
# 与线上一致:默认同一套 JWT 可拉多个 bid异度单独 bid。可用环境变量覆盖。
_DEFAULT_PM_SYNC_AUTHTOKEN = (
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaWQiOjE5MzE1MjcsIm9wZW5pZCI6Im85VS04NW1DSXZ0eDZ0a0pteTQyMkkzcUNHVWciLCJzaWduZXRpbWUiOjE3NzQ5MzM0NDQ4MTgsImlhdCI6MTc3NDkzMzQ0NCwiZXhwIjoxNzc3NTI1NDQ0fQ.iC9vdjBqa2LGlqA6F-Dg4GcfDyv3Rp8JwpBaO3j_eE4"
)
_DEFAULT_WXBOT_BID_ERIC = "75021053883a9090c67d5becde7f4b0a"
_DEFAULT_WXBOT_BID_XIDU = "4888c6ab5cb70dcc3adea1e2d2ff0201"
_PM_PROFILE_KEYS = frozenset({"eric", "xidu"})
_processed_callback_cache: dict[str, int] = {}
def normalize_pm_profile_key(profile_key: str) -> str:
k = (profile_key or "").strip().lower()
return k if k in _PM_PROFILE_KEYS else "eric"
def pm_sync_credentials_for_profile(profile_key: str) -> tuple[str, str]:
"""返回 (authtoken, wxbot_bid),供 getaccessstautuslist 使用。"""
k = normalize_pm_profile_key(profile_key)
base_token = (os.getenv("PM_SYNC_AUTHTOKEN") or _DEFAULT_PM_SYNC_AUTHTOKEN).strip()
if k == "xidu":
token = (os.getenv("PM_SYNC_AUTHTOKEN_XIDU") or base_token).strip()
bid = (
os.getenv("PM_SYNC_WXBOT_BID_XIDU")
or os.getenv("PM_SYNC_DEFAULT_WXBOT_BID_XIDU")
or _DEFAULT_WXBOT_BID_XIDU
).strip()
else:
token = base_token
bid = (
os.getenv("PM_SYNC_WXBOT_BID")
or os.getenv("PM_SYNC_DEFAULT_WXBOT_BID")
or _DEFAULT_WXBOT_BID_ERIC
).strip()
return token, bid
def safe_filter_value(value: str) -> str:
return (value or "").replace("\\", "\\\\").replace('"', '\\"')
def _build_callback_dedupe_key(data: dict) -> str:
return "|".join(
[
str(data.get("UserId", "")).strip(),
str(data.get("MsgId", "")).strip(),
str(data.get("Timestamp", "")).strip(),
str(data.get("Query", "")).strip(),
]
)
def is_duplicate_wechat_callback(data: dict, profile_key: str, ttl_seconds: int = 3600) -> bool:
now_ts = int(time.time())
for k, exp in list(_processed_callback_cache.items()):
if exp <= now_ts:
_processed_callback_cache.pop(k, None)
pk = normalize_pm_profile_key(profile_key)
key = pk + "|" + _build_callback_dedupe_key(data)
if not key.strip("|"):
return False
if key in _processed_callback_cache:
return True
_processed_callback_cache[key] = now_ts + ttl_seconds
return False
async def query_private_messages(
authtoken: str,
wxbot_bid: str,
page: int = 0,
size: int = 30,
filter_value: int = 0,
request_id: str = "local-debug",
) -> dict:
url = "https://chatbot.weixin.qq.com/miniopenai/manualservice/getaccessstautuslist"
body = {
"bid": wxbot_bid,
"filter": filter_value,
"order": {"page": page, "size": size},
"base": {"requestid": request_id},
}
headers = {
"authtoken": authtoken,
"wxbot_bid": wxbot_bid,
"content-type": "application/json",
"charset": "utf-8",
}
async with httpx.AsyncClient(timeout=10, verify=False) as client:
resp = await client.post(url, json=body, headers=headers)
resp.raise_for_status()
return resp.json()
async def pb_upsert_wechat_private_user(record: dict, *, collection: Optional[str] = None) -> None:
coll = (collection or WECHAT_PRIVATE_USERS_COLLECTION).strip() or WECHAT_PRIVATE_USERS_COLLECTION
pb_url = (PB_URL or "").rstrip("/")
if not pb_url or not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD:
return
userid = str(record.get("userid", "")).strip()
if not userid:
return
async with httpx.AsyncClient(timeout=10, verify=False) as client:
auth_resp = await client.post(
f"{pb_url}/api/admins/auth-with-password",
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
)
if auth_resp.status_code == 404:
auth_resp = await client.post(
f"{pb_url}/api/collections/_superusers/auth-with-password",
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
)
if auth_resp.status_code != 200:
return
token = (auth_resp.json() or {}).get("token", "")
if not token:
return
headers = {"Authorization": f"Bearer {token}"}
user_expr = safe_filter_value(userid)
filter_exp = f'userid="{user_expr}"'
list_resp = await client.get(
f"{pb_url}/api/collections/{coll}/records",
params={"page": 1, "perPage": 200, "filter": filter_exp, "sort": "-updated"},
headers=headers,
)
items = ((list_resp.json() or {}).get("items")) or [] if list_resp.status_code == 200 else []
if items:
rid = items[0].get("id")
if rid:
await client.patch(f"{pb_url}/api/collections/{coll}/records/{rid}", json=record, headers=headers)
for extra in items[1:]:
extra_id = str(extra.get("id", "")).strip()
if not extra_id:
continue
await client.delete(
f"{pb_url}/api/collections/{coll}/records/{extra_id}",
headers=headers,
)
return
await client.post(f"{pb_url}/api/collections/{coll}/records", json=record, headers=headers)
def normalize_wechat_avatar_url(url: str) -> str:
"""微信头像常为 http://wx.qlogo.cn在 HTTPS 站点作为 <img src> 会触发混合内容拦截,同路径可改用 https。"""
s = str(url or "").strip()
low = s.lower()
if low.startswith("http://") and "qlogo.cn" in low:
return "https://" + s[7:]
return s
def contact_avatar_url(contact: dict) -> str:
v = (
contact.get("headImg")
or contact.get("avatar")
or contact.get("headimgurl")
or contact.get("headImgUrl")
)
return normalize_wechat_avatar_url(str(v or "").strip())
async def sync_wechat_private_user_to_pb(data: dict, profile_key: str) -> None:
"""根据解密后的用户消息 data拉取 B 端联系人并 upsert 到 PocketBase。"""
authtoken, wxbot_bid = pm_sync_credentials_for_profile(profile_key)
if not authtoken or not wxbot_bid:
return
target_userid = str(data.get("UserId", "")).strip()
if not target_userid or is_duplicate_wechat_callback(data, profile_key):
return
result = await query_private_messages(
authtoken=authtoken,
wxbot_bid=wxbot_bid,
page=0,
size=100,
filter_value=0,
request_id=f"thirdapi-{target_userid}",
)
contacts = result.get("contacts") or []
target = next(
(
c
for c in contacts
if str(c.get("userId", "") or c.get("userid", "")).strip() == target_userid
),
None,
)
if not target:
return
last_msg = target.get("lastMsg") or {}
await pb_upsert_wechat_private_user(
{
"userid": target_userid,
"channel_id": str(data.get("ChannelId", "")).strip(),
"nick": target.get("nick", ""),
"avatar": contact_avatar_url(target) or "",
"source_query": str(data.get("Query", "")).strip(),
"source_message_id": str(data.get("MsgId", "")).strip(),
"source_message_time": str(data.get("Timestamp", "")),
"matched_last_msg": last_msg.get("content", ""),
"matched_last_msg_id": str(last_msg.get("msgId", "") or last_msg.get("id", "")),
"matched_last_msg_time": str(target.get("lastActiveTime", "")),
"raw_contact": json.dumps(target, ensure_ascii=False),
}
)
async def safe_sync_wechat_private_user_to_pb(data: dict, profile_key: str) -> None:
try:
await sync_wechat_private_user_to_pb(data, profile_key)
except Exception:
logger.exception("微信私信用户同步 PocketBase 失败(已忽略) profile=%s", profile_key)