feat: 个人微信改为前台每用户独立绑定(按 user_id 隔离),后台仅保留全局 Bot

This commit is contained in:
WorkBuddy committed 2026-07-09 20:53:25 +08:00
1 parent ce0a7b4243
commit 75a708a14d
7 files changed
+1335 -574

No files matched your search

+254 -93
View File
@@ -3,13 +3,17 @@ Phase 4: IM Bot Integration - Admin API Module
im_bot_admin.py
Provides admin management for IM bot channels, routing rules,
LangBot gateway config, statistics, and message monitoring.
statistics, message monitoring, and native IM platform adapters
(e.g. 个人微信 WeKnora/Clawbot) that connect directly to each IM
platform's official open API — no third-party gateway required.
"""
import uuid
import json
import hashlib
import hmac
import time
import threading
import requests
from datetime import datetime, timedelta
from typing import Optional
@@ -167,10 +171,6 @@ async def get_im_bot_stats(user=Depends(get_current_user)):
# Total routing rules
routes_count = _execute_raw(db, text("SELECT COUNT(*) as cnt FROM im_bot_routes WHERE is_active=1"))[0]._mapping['cnt']
# LangBot status
langbot_row = _execute_raw(db, text("SELECT status FROM im_bot_langbot_config ORDER BY id LIMIT 1"))
langbot_status = _row_to_dict(langbot_row[0])['status'] if langbot_row else 'inactive'
return {
"success": True,
"data": {
@@ -182,7 +182,6 @@ async def get_im_bot_stats(user=Depends(get_current_user)):
"today_unique_users": today_stats.get('users', 0),
"today_errors": today_stats.get('errors', 0),
"active_routes": routes_count,
"langbot_status": langbot_status,
}
}
except Exception as e:
@@ -570,102 +569,264 @@ async def delete_route(route_id: str, user=Depends(get_current_user)):
# ============================================================
# LangBot Gateway
# 个人微信 (iLink 协议 / 微信 ClawBot 插件) 原生适配器
# 直接对接腾讯官方 iLink Bot HTTP API (ilinkai.weixin.qq.com)
# 扫码登录免 AppID;消息收发走 Bearer bot_token + X-WECHAT-UIN
# ============================================================
@router.get("/langbot")
async def get_langbot_config(user=Depends(get_current_user)):
"""Get LangBot gateway configuration"""
db = SessionLocal()
try:
rows = _execute_raw(db, text("SELECT * FROM im_bot_langbot_config ORDER BY id"))
return {"success": True, "data": _rows_to_dict(rows)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
ILINK_BASE = "https://ilinkai.weixin.qq.com"
_wechat_receiver_threads = {}
@router.put("/langbot")
async def update_langbot_config(
config_id: int = Query(...),
name: Optional[str] = Query(None),
gateway_url: Optional[str] = Query(None),
api_key: Optional[str] = Query(None),
webhook_secret: Optional[str] = Query(None),
bot_uuid: Optional[str] = Query(None),
callback_url: Optional[str] = Query(None),
sync_channels: Optional[int] = Query(None),
status: Optional[str] = Query(None),
user=Depends(get_current_user)
):
"""Update LangBot configuration"""
db = SessionLocal()
try:
updates = []
params = {"id": config_id}
for field, val in [("name", name), ("gateway_url", gateway_url),
("api_key", api_key), ("webhook_secret", webhook_secret),
("bot_uuid", bot_uuid), ("callback_url", callback_url),
("sync_channels", sync_channels), ("status", status)]:
if val is not None:
updates.append(f"{field} = :{field}")
params[field] = val
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
sql = f"UPDATE im_bot_langbot_config SET {', '.join(updates)} WHERE id = :id"
_execute_raw(db, text(sql), params)
return {"success": True, "message": "LangBot config updated"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
@router.post("/langbot/test")
async def test_langbot(
gateway_url: str = Query(...),
api_key: Optional[str] = Query(None),
user=Depends(get_current_user)
):
"""Test LangBot gateway connectivity"""
import urllib.request
import urllib.error
result = {
"gateway_url": gateway_url,
"tested_at": datetime.now().isoformat(),
"reachable": False,
"api_version": None,
"error": None,
def _wechat_headers(bot_token: str) -> dict:
"""iLink 协议请求头:Bearer bot_token + 随机 X-WECHAT-UIN 防重放"""
import base64, random
uin = base64.b64encode(str(random.randint(0, 2 ** 32 - 1)).encode("utf-8")).decode("utf-8")
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {bot_token}",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": uin,
}
def _wechat_row(db):
rows = _execute_raw(db, text("SELECT * FROM im_bot_wechat_account WHERE user_id IS NULL ORDER BY id LIMIT 1"))
return rows[0] if rows else None
def _wechat_mask(row) -> dict:
d = _row_to_dict(row)
if d.get("bot_token"):
bt = d["bot_token"]
d["bot_token"] = ("***" + bt[-6:]) if len(bt) > 6 else "***"
return d
def _qihua_reply(text: str) -> str:
"""调用七虎AI 生成回复(复用 channels 的真实 LLM 调用)。
自行创建 DB 会话,避免上层线程无可用 session 时崩溃。"""
db = None
try:
url = gateway_url.rstrip('/') + '/api/v1/pipelines'
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(url, headers=headers, method='GET')
with urllib.request.urlopen(req, timeout=10) as resp:
result['reachable'] = True
result['status_code'] = resp.status
body = resp.read().decode('utf-8', errors='replace')
try:
data = json.loads(body)
result['api_version'] = data.get('version', 'unknown')
except:
result['api_version'] = 'unknown'
except urllib.error.HTTPError as e:
result['reachable'] = True
result['status_code'] = e.code
result['error'] = f"HTTP {e.code}"
from app.api.channels import _call_real_llm
db = SessionLocal()
return _call_real_llm(db, None, text, "个人微信")
except Exception as e:
result['error'] = str(e)
return f"(AI 回复生成失败:{e})"
finally:
if db:
db.close()
return {"success": True, "data": result}
def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str):
"""长轮询收消息,返回 (msgs, new_cursor)。最多 hold 35s。"""
try:
resp = requests.post(
baseurl.rstrip("/") + "/ilink/bot/getupdates",
headers=_wechat_headers(bot_token),
json={"get_updates_buf": cursor, "base_info": {"channel_version": "1.0.2"}},
timeout=40,
)
if not resp.ok:
return [], cursor
j = resp.json() if resp.content else {}
return j.get("msgs", []) or [], j.get("get_updates_buf", cursor)
except Exception:
return [], cursor
def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_token: str, text: str) -> bool:
"""发送文本回复(必须带 context_token 关联到正确的对话窗口)"""
try:
resp = requests.post(
baseurl.rstrip("/") + "/ilink/bot/sendmessage",
headers=_wechat_headers(bot_token),
json={"msg": {
"to_user_id": to_user_id,
"message_type": 2,
"message_state": 2,
"context_token": context_token,
"item_list": [{"type": 1, "text_item": {"text": text}}],
}},
timeout=20,
)
return resp.ok
except Exception:
return False
def _wechat_extract_text(msg: dict) -> str:
parts = []
for it in (msg.get("item_list") or []):
if it.get("type") == 1 and it.get("text_item", {}).get("text"):
parts.append(it["text_item"]["text"])
return "\n".join(parts)
def _wechat_receiver_loop(account_id: int):
"""后台接收线程:长轮询个人微信消息 → 七虎AI 回复 → 发送。
底层 iLink 协议直连 ilinkai.weixin.qq.com,无需 AppID/SecretKey。"""
cursor = ""
while True:
db = SessionLocal()
try:
row = _wechat_row(db)
if not row:
return
d = _row_to_dict(row)
if d.get("status") != "connected" or not d.get("bot_token"):
return
bot_token = d["bot_token"]
baseurl = d.get("baseurl") or ILINK_BASE
msgs, cursor = _wechat_get_updates(baseurl, bot_token, cursor)
for m in msgs:
if m.get("message_type") not in (1, None):
continue # 只处理用户消息(2=bot 自己发出的)
content = _wechat_extract_text(m)
if not content:
continue
reply = _qihua_reply(content)
_wechat_send_message(
baseurl, bot_token,
m.get("from_user_id", ""),
m.get("context_token", ""),
reply,
)
time.sleep(1)
except Exception:
time.sleep(5)
finally:
db.close()
def _start_wechat_receiver(account_id: int):
if account_id in _wechat_receiver_threads and _wechat_receiver_threads[account_id].is_alive():
return
t = threading.Thread(target=_wechat_receiver_loop, args=(account_id,), daemon=True)
t.start()
_wechat_receiver_threads[account_id] = t
@router.get("/wechat")
async def get_wechat_config(user=Depends(get_current_user)):
"""获取个人微信(Clawbot)适配器配置(敏感字段脱敏)"""
db = SessionLocal()
try:
row = _wechat_row(db)
if not row:
return {"success": True, "data": None}
return {"success": True, "data": _wechat_mask(row)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
# (POST /wechat 凭证保存端点已移除:iLink 扫码登录无需 AppID/SecretKey)
@router.post("/wechat/qrcode")
async def wechat_qrcode(user=Depends(get_current_user)):
"""获取个人微信扫码登录二维码(iLink 协议,免 AppID)"""
try:
resp = requests.get(
ILINK_BASE + "/ilink/bot/get_bot_qrcode",
params={"bot_type": 3},
timeout=20,
)
if not resp.ok:
return {"success": False, "error": f"获取二维码失败 HTTP {resp.status_code}"}
j = resp.json() if resp.content else {}
return {"success": True, "data": {
"qrcode": j.get("qrcode"),
"qrcode_img_content": j.get("qrcode_img_content"),
}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/wechat/qrcode/status")
async def wechat_qrcode_status(qrcode: str = Query(...), user=Depends(get_current_user)):
"""轮询扫码状态;confirmed 时保存 bot_token 并启动接收线程"""
try:
resp = requests.get(
ILINK_BASE + "/ilink/bot/get_qrcode_status",
params={"qrcode": qrcode},
timeout=40,
)
if not resp.ok:
return {"success": False, "error": f"查询状态失败 HTTP {resp.status_code}"}
j = resp.json() if resp.content else {}
data = j.get("data", j) if isinstance(j, dict) else j
status = data.get("status")
if status == "confirmed":
# 兼容两种返回结构:顶层 或 credentials 嵌套
creds = data.get("credentials", {}) or {}
bot_token = data.get("bot_token") or creds.get("bot_token")
baseurl = data.get("baseurl") or creds.get("baseurl") or ILINK_BASE
ilink_bot_id = data.get("ilink_bot_id") or creds.get("ilink_bot_id")
db = SessionLocal()
try:
row = _wechat_row(db)
if not row:
_execute_raw(db, text("""INSERT INTO im_bot_wechat_account
(bot_token, ilink_bot_id, baseurl, status, connected_at, created_at, updated_at)
VALUES (:bot_token, :ilink_bot_id, :baseurl, 'connected', :connected_at, :created_at, :updated_at)"""),
{"bot_token": bot_token, "ilink_bot_id": ilink_bot_id, "baseurl": baseurl,
"connected_at": datetime.now(), "created_at": datetime.now(), "updated_at": datetime.now()})
else:
_execute_raw(db, text("""UPDATE im_bot_wechat_account SET
bot_token=:bot_token, ilink_bot_id=:ilink_bot_id, baseurl=:baseurl,
status='connected', connected_at=:connected_at, updated_at=:updated_at WHERE id=:id"""),
{"bot_token": bot_token, "ilink_bot_id": ilink_bot_id, "baseurl": baseurl,
"connected_at": datetime.now(), "updated_at": datetime.now(), "id": _row_to_dict(row)["id"]})
row = _wechat_row(db)
aid = _row_to_dict(row)["id"] if row else 1
_start_wechat_receiver(aid)
finally:
db.close()
return {"success": True, "data": {"status": "confirmed", "connected": True}}
return {"success": True, "data": {"status": status, "connected": False}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/wechat/status")
async def wechat_status(user=Depends(get_current_user)):
"""个人微信连接状态"""
db = SessionLocal()
try:
row = _wechat_row(db)
if not row:
return {"success": True, "data": {"connected": False, "configured": False}}
d = _row_to_dict(row)
return {"success": True, "data": {
"connected": d.get("status") == "connected",
"configured": True,
"baseurl": d.get("baseurl"),
"ilink_bot_id": d.get("ilink_bot_id"),
"connected_at": str(d.get("connected_at")) if d.get("connected_at") else None,
}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
@router.post("/wechat/disconnect")
async def wechat_disconnect(user=Depends(get_current_user)):
"""断开个人微信连接(清除 bot_token)"""
db = SessionLocal()
try:
row = _wechat_row(db)
if row:
_execute_raw(db, text("UPDATE im_bot_wechat_account SET bot_token=NULL, status='disconnected', updated_at=:updated_at WHERE id=:id"),
{"updated_at": datetime.now(), "id": _row_to_dict(row)["id"]})
return {"success": True, "message": "已断开个人微信连接"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
# ============================================================