feat: 优化部署脚本

This commit is contained in:
丹尼尔
2026-03-11 09:44:17 +08:00
parent 0e8639fde1
commit 846c65b155
13 changed files with 1957 additions and 340 deletions

View File

@@ -28,6 +28,13 @@ SLIDER_VERIFY_BASE_URL = os.getenv("SLIDER_VERIFY_BASE_URL", "http://113.44.162.
SLIDER_VERIFY_KEY = os.getenv("SLIDER_VERIFY_KEY", os.getenv("KEY", "408449830"))
# 发送文本消息swagger 中为 POST /message/SendTextMessagebody 为 SendMessageModelMsgItem 数组)
SEND_MSG_PATH = (os.getenv("SEND_MSG_PATH") or "/message/SendTextMessage").strip()
# 发送图片消息:部分上游为独立接口,或与文本同 path 仅 MsgType 不同(如 3=图片)
SEND_IMAGE_PATH = (os.getenv("SEND_IMAGE_PATH") or "").strip() or SEND_MSG_PATH
# 联系人列表7006 为 POST /friend/GetContactListbody 传 CurrentChatRoomContactSeq/CurrentWxcontactSeq=0
CONTACT_LIST_PATH = (os.getenv("CONTACT_LIST_PATH") or os.getenv("FRIEND_LIST_PATH") or "/friend/GetContactList").strip()
FRIEND_LIST_PATH = (os.getenv("FRIEND_LIST_PATH") or CONTACT_LIST_PATH).strip()
# 图片消息 MsgType部分上游为 0常见为 3
IMAGE_MSG_TYPE = int(os.getenv("IMAGE_MSG_TYPE", "3"))
# 按 key 缓存取码结果与 Data62供后续步骤使用
qrcode_store: dict = {}
@@ -39,15 +46,103 @@ logging.basicConfig(
logger = logging.getLogger("wechat-backend")
def _is_self_sent(msg: dict) -> bool:
"""判断是否为当前账号自己发出的消息(则不由 AI 回复)。"""
if msg.get("direction") == "out":
return True
if msg.get("IsSelf") in (1, True, "1"):
return True
return False
def _allowed_ai_reply(key: str, from_user: str) -> bool:
"""分级处理:仅超级管理员或白名单内的联系人可获得 AI 回复,其他一律不回复。"""
if not from_user or not from_user.strip():
return False
cfg = store.get_ai_reply_config(key)
if not cfg:
return False
super_admins = set(cfg.get("super_admin_wxids") or [])
whitelist = set(cfg.get("whitelist_wxids") or [])
return from_user.strip() in super_admins or from_user.strip() in whitelist
async def _ai_takeover_reply(key: str, from_user: str, content: str) -> None:
"""收到他人消息时由 AI 接管:生成回复并发送。"""
if not from_user or not content or not content.strip():
return
try:
recent = store.list_sync_messages(key, limit=10)
# 仅取与该用户的最近几条作为上下文(简化:只取最后几条)
context = []
for m in reversed(recent):
c = (m.get("Content") or m.get("content") or "").strip()
if not c:
continue
if m.get("direction") == "out" and (m.get("ToUserName") or "").strip() == from_user:
context.append({"role": "assistant", "content": c})
elif (m.get("FromUserName") or m.get("from") or "").strip() == from_user and not _is_self_sent(m):
context.append({"role": "user", "content": c})
if len(context) >= 6:
break
if not context or context[-1].get("role") != "user":
context.append({"role": "user", "content": content})
text = await llm_chat(context)
if text and text.strip():
await _send_message_upstream(key, from_user, text.strip())
logger.info("AI takeover replied to %s: %s", from_user[:20], text.strip()[:50])
except Exception as e:
logger.exception("AI takeover reply error (from=%s): %s", from_user, e)
def _on_ws_message(key: str, data: dict) -> None:
"""GetSyncMsg 收到数据时:写入 store,便于前端拉取与自动回复逻辑使用"""
"""GetSyncMsg 收到数据时:写入 store;若为他人消息则 AI 接管对话"""
msg_list = data.get("MsgList") or data.get("List") or data.get("msgList")
if isinstance(msg_list, list) and msg_list:
store.append_sync_messages(key, msg_list)
for m in msg_list:
if _is_self_sent(m):
continue
from_user = (m.get("FromUserName") or m.get("from") or "").strip()
content = (m.get("Content") or m.get("content") or "").strip()
msg_type = m.get("MsgType") or m.get("msgType")
if from_user and content and (msg_type in (1, None) or str(msg_type) == "1"): # 仅文本触发 AI
if not _allowed_ai_reply(key, from_user):
continue
try:
asyncio.get_running_loop().create_task(_ai_takeover_reply(key, from_user, content))
except RuntimeError:
pass
elif isinstance(data, list):
store.append_sync_messages(key, data)
for m in data:
if not isinstance(m, dict) or _is_self_sent(m):
continue
from_user = (m.get("FromUserName") or m.get("from") or "").strip()
content = (m.get("Content") or m.get("content") or "").strip()
msg_type = m.get("MsgType") or m.get("msgType")
if from_user and content and (msg_type in (1, None) or str(msg_type) == "1"):
if not _allowed_ai_reply(key, from_user):
continue
try:
asyncio.get_running_loop().create_task(_ai_takeover_reply(key, from_user, content))
except RuntimeError:
pass
else:
store.append_sync_messages(key, [data])
m = data if isinstance(data, dict) else {}
if not _is_self_sent(m):
from_user = (m.get("FromUserName") or m.get("from") or "").strip()
content = (m.get("Content") or m.get("content") or "").strip()
msg_type = m.get("MsgType") or m.get("msgType")
if from_user and content and (msg_type in (1, None) or str(msg_type) == "1"):
if not _allowed_ai_reply(key, from_user):
pass
else:
try:
asyncio.get_running_loop().create_task(_ai_takeover_reply(key, from_user, content))
except RuntimeError:
pass
async def _run_greeting_scheduler() -> None:
@@ -409,6 +504,24 @@ class SendMessageBody(BaseModel):
content: str
class BatchSendItem(BaseModel):
to_user_name: str
content: str
class BatchSendBody(BaseModel):
key: str
items: List[BatchSendItem]
class SendImageBody(BaseModel):
key: str
to_user_name: str
image_content: str # 图片 base64 或 URL依上游约定
text_content: Optional[str] = ""
at_wxid_list: Optional[List[str]] = None
class QwenGenerateBody(BaseModel):
prompt: str
system: Optional[str] = None
@@ -588,6 +701,65 @@ async def _send_message_upstream(key: str, to_user_name: str, content: str) -> d
return {"ok": True, "raw": resp.text[:500]}
async def _send_batch_upstream(key: str, items: List[dict]) -> dict:
"""批量发送:一次请求多个 MsgItem快速分发。"""
url = f"{WECHAT_UPSTREAM_BASE_URL.rstrip('/')}{SEND_MSG_PATH}"
msg_items = []
for it in items:
to_user = (it.get("to_user_name") or it.get("ToUserName") or "").strip()
content = (it.get("content") or it.get("TextContent") or "").strip()
if not to_user:
continue
msg_items.append({"ToUserName": to_user, "MsgType": 1, "TextContent": content})
if not msg_items:
raise HTTPException(status_code=400, detail="items 中至少需要一条有效 to_user_name 与 content")
payload = {"MsgItem": msg_items}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(url, params={"key": key}, json=payload)
if resp.status_code >= 400:
body_preview = resp.text[:400] if resp.text else ""
logger.warning("Batch send upstream %s: %s", resp.status_code, body_preview)
raise HTTPException(
status_code=502,
detail=f"upstream_returned_{resp.status_code}: {body_preview}",
)
for it in msg_items:
store.append_sent_message(key, it["ToUserName"], it.get("TextContent", ""))
try:
return resp.json()
except Exception:
return {"ok": True, "sent": len(msg_items), "raw": resp.text[:500]}
async def _send_image_upstream(key: str, to_user_name: str, image_content: str,
text_content: Optional[str] = "",
at_wxid_list: Optional[List[str]] = None) -> dict:
"""发送图片消息MsgItem 含 ImageContent、MsgType=3或 0依上游可选 TextContent、AtWxIDList。"""
url = f"{WECHAT_UPSTREAM_BASE_URL.rstrip('/')}{SEND_IMAGE_PATH}"
item = {
"ToUserName": to_user_name,
"MsgType": IMAGE_MSG_TYPE,
"ImageContent": image_content or "",
"TextContent": text_content or "",
"AtWxIDList": at_wxid_list or [],
}
payload = {"MsgItem": [item]}
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(url, params={"key": key}, json=payload)
if resp.status_code >= 400:
body_preview = resp.text[:400] if resp.text else ""
logger.warning("Send image upstream %s: %s", resp.status_code, body_preview)
raise HTTPException(
status_code=502,
detail=f"upstream_returned_{resp.status_code}: {body_preview}",
)
store.append_sent_message(key, to_user_name, "[图片]" + ((" " + text_content) if text_content else ""))
try:
return resp.json()
except Exception:
return {"ok": True, "raw": resp.text[:500]}
@app.post("/api/send-message")
async def api_send_message(body: SendMessageBody):
try:
@@ -599,6 +771,161 @@ async def api_send_message(body: SendMessageBody):
raise HTTPException(status_code=502, detail=f"upstream_error: {exc}") from exc
@app.post("/api/send-batch")
async def api_send_batch(body: BatchSendBody):
"""快速群发:一次请求批量发送给多人,支持从好友/客户列表选择后调用。"""
items = [{"to_user_name": it.to_user_name, "content": it.content} for it in body.items]
try:
return await _send_batch_upstream(body.key, items)
except HTTPException:
raise
except Exception as exc:
logger.exception("Batch send error: %s", exc)
raise HTTPException(status_code=502, detail=f"upstream_error: {exc}") from exc
@app.post("/api/send-image")
async def api_send_image(body: SendImageBody):
"""发送图片消息快捷方式,参数对应 MsgItemImageContent、TextContent、ToUserName、AtWxIDList。"""
try:
return await _send_image_upstream(
body.key,
body.to_user_name,
body.image_content,
text_content=body.text_content or "",
at_wxid_list=body.at_wxid_list,
)
except HTTPException:
raise
except Exception as exc:
logger.exception("Send image error: %s", exc)
raise HTTPException(status_code=502, detail=f"upstream_error: {exc}") from exc
def _normalize_contact_list(raw: Any) -> List[dict]:
"""将上游 GetContactList 多种返回格式统一为 [ { wxid, remark_name, ... } ]。"""
items = []
if isinstance(raw, list):
items = raw
elif isinstance(raw, dict):
data = raw.get("Data") or raw.get("data") or raw
if isinstance(data, list):
items = data
elif isinstance(data, dict):
items = (
data.get("ContactList")
or data.get("contactList")
or data.get("WxcontactList")
or data.get("wxcontactList")
or data.get("CachedContactList")
or data.get("List")
or data.get("list")
or data.get("items")
or []
)
items = items or raw.get("items") or raw.get("list") or raw.get("List") or []
result = []
for x in items:
if not isinstance(x, dict):
continue
wxid = (
x.get("wxid")
or x.get("Wxid")
or x.get("UserName")
or x.get("userName")
or x.get("Alias")
or ""
)
remark = (
x.get("remark_name")
or x.get("RemarkName")
or x.get("NickName")
or x.get("nickName")
or x.get("DisplayName")
or wxid
)
result.append({"wxid": wxid, "remark_name": remark, **{k: v for k, v in x.items() if k not in ("wxid", "Wxid", "remark_name", "RemarkName")}})
return result
# 上游 GetContactList 请求体CurrentChatRoomContactSeq、CurrentWxcontactSeq 传 0 表示拉取全量
GET_CONTACT_LIST_BODY = {"CurrentChatRoomContactSeq": 0, "CurrentWxcontactSeq": 0}
@app.get("/api/contact-list")
async def api_contact_list(key: str = Query(..., description="账号 key")):
"""获取全部联系人POST 上游body 为 CurrentChatRoomContactSeq/CurrentWxcontactSeq=0key 走 query。"""
base = WECHAT_UPSTREAM_BASE_URL.rstrip("/")
path = CONTACT_LIST_PATH if CONTACT_LIST_PATH.startswith("/") else f"/{CONTACT_LIST_PATH}"
url = f"{base}{path}"
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
url,
params={"key": key},
json=GET_CONTACT_LIST_BODY,
)
if resp.status_code >= 400:
logger.warning("GetContactList %s: %s", resp.status_code, resp.text[:200])
return {"items": [], "error": resp.text[:200]}
raw = resp.json()
# 日志便于确认 7006 返回结构(不打印完整列表)
if isinstance(raw, dict):
data = raw.get("Data") or raw.get("data")
data_keys = list(data.keys()) if isinstance(data, dict) else getattr(data, "__name__", type(data).__name__)
logger.info("GetContactList response keys: raw=%s, Data=%s", list(raw.keys()), data_keys)
items = _normalize_contact_list(raw)
if not items and isinstance(raw, dict):
items = _normalize_contact_list(raw.get("Data") or raw.get("data") or raw)
logger.info("GetContactList normalized items count: %s", len(items))
return {"items": items}
except Exception as e:
logger.warning("GetContactList error: %s", e)
return {"items": [], "error": str(e)}
@app.get("/api/friends")
async def api_list_friends(key: str = Query(..., description="账号 key")):
"""好友列表:代理上游联系人接口,与 /api/contact-list 同源;否则返回客户档案。"""
return await api_contact_list(key)
def _friends_fallback(key: str) -> List[dict]:
"""用客户档案作为可选联系人,便于在管理页选择群发对象。"""
customers = store.list_customers(key)
return [
{"wxid": c.get("wxid"), "remark_name": c.get("remark_name") or c.get("wxid"), "id": c.get("id")}
for c in customers
if c.get("wxid")
]
# ---------- AI 接管回复配置(白名单 + 超级管理员) ----------
class AIReplyConfigUpdate(BaseModel):
key: str
super_admin_wxids: Optional[List[str]] = None
whitelist_wxids: Optional[List[str]] = None
@app.get("/api/ai-reply-config")
async def api_get_ai_reply_config(key: str = Query(..., description="账号 key")):
"""获取当前账号的 AI 回复配置:超级管理员与白名单 wxid 列表。"""
cfg = store.get_ai_reply_config(key)
if not cfg:
return {"key": key, "super_admin_wxids": [], "whitelist_wxids": []}
return cfg
@app.patch("/api/ai-reply-config")
async def api_update_ai_reply_config(body: AIReplyConfigUpdate):
"""设置 AI 回复白名单与超级管理员:仅列表内联系人会收到 AI 自动回复。"""
return store.update_ai_reply_config(
body.key,
super_admin_wxids=body.super_admin_wxids,
whitelist_wxids=body.whitelist_wxids,
)
# ---------- 模型管理多模型切换API Key 按模型配置) ----------
class ModelCreate(BaseModel):
name: str