feat: 优化部署脚本
This commit is contained in:
329
backend/main.py
329
backend/main.py
@@ -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/SendTextMessage,body 为 SendMessageModel(MsgItem 数组)
|
||||
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/GetContactList,body 传 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):
|
||||
"""发送图片消息快捷方式,参数对应 MsgItem:ImageContent、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=0,key 走 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
|
||||
|
||||
Reference in New Issue
Block a user