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
+860 -99

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()
# ============================================================
+359
View File
@@ -0,0 +1,359 @@
"""
Phase 4: 个人微信 (iLink 协议 / 微信 ClawBot 插件) —— 前台用户模块
im_bot_user.py
为【平台注册用户】提供"各自绑定自己的个人微信"能力:
- 每个用户独立一条 im_bot_wechat_account 记录(按 user_id 隔离)
- 扫码登录免 AppID;消息收发走 Bearer bot_token + X-WECHAT-UIN
- 绑定后由七虎AI 大脑自动应答(复用 channels 的真实 LLM 调用)
与后台 im_bot_admin 的区别:
- 后台:平台级 IM Bot 接入中枢(飞书/企微/Telegram 等多平台、路由、统计),其个人微信
端点仅限 user_id IS NULL 的"全局 Bot",与用户绑定互不干扰。
- 前台:每个登录用户管理自己的个人微信,数据严格按 user_id 隔离。
"""
import base64
import json
import random
import threading
import time
from datetime import datetime
from typing import Optional
import requests
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import SessionLocal
from ..models import User
router = APIRouter()
# ============================================================
# iLink 协议常量(腾讯官方 iLink Bot HTTP API,直连免 AppID)
# ============================================================
ILINK_BASE = "https://ilinkai.weixin.qq.com"
# 每个已连接账号一个后台接收线程,key = 账号表主键 id
_wechat_receiver_threads: dict[int, threading.Thread] = {}
# ============================================================
# 通用 DB 工具
# ============================================================
def _execute_raw(db: Session, sql_text, params=None):
result = db.execute(sql_text, params or {})
if result.returns_rows:
return result.fetchall()
db.commit()
return result
def _row_to_dict(row) -> dict:
if row is None:
return None
d = {}
for col in row._mapping.keys():
val = row._mapping[col]
if isinstance(val, datetime):
val = val.isoformat()
d[col] = val
return d
# ============================================================
# iLink 协议请求工具
# ============================================================
def _wechat_headers(bot_token: str) -> dict:
"""iLink 协议请求头:Bearer bot_token + 随机 X-WECHAT-UIN 防重放"""
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_for_user(db: Session, user_id: str):
rows = _execute_raw(
db,
text("SELECT * FROM im_bot_wechat_account WHERE user_id = :uid ORDER BY id DESC LIMIT 1"),
{"uid": user_id},
)
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 "***"
d.pop("appid", None)
d.pop("secret_key", None)
return d
# ============================================================
# 七虎AI 大脑(复用 channels 的真实 LLM 调用)
# ============================================================
def _qihua_reply(text: str) -> str:
"""调用七虎AI 生成回复。自行创建 DB 会话,避免上层线程无可用 session。"""
db = None
try:
from app.api.channels import _call_real_llm
db = SessionLocal()
return _call_real_llm(db, None, text, "个人微信")
except Exception as e:
return f"(AI 回复生成失败:{e})"
finally:
if db:
db.close()
# ============================================================
# iLink 消息收发
# ============================================================
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 回复 → 发送。"""
cursor = ""
while True:
db = SessionLocal()
try:
row = _execute_raw(
db,
text("SELECT * FROM im_bot_wechat_account WHERE id = :aid"),
{"aid": account_id},
)
if not row:
return
d = _row_to_dict(row[0])
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
# ============================================================
# 前台用户接口(按 user_id 隔离)
# ============================================================
@router.get("/status")
async def wechat_status(user: User = Depends(get_current_user)):
"""当前登录用户的个人微信连接状态"""
db = SessionLocal()
try:
row = _wechat_row_for_user(db, user.id)
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"),
"username": d.get("username"),
"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("/qrcode")
async def wechat_qrcode(user: 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("/qrcode/status")
async def wechat_qrcode_status(
qrcode: str = Query(...),
user: 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":
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_for_user(db, user.id)
if not row:
_execute_raw(db, text("""INSERT INTO im_bot_wechat_account
(user_id, username, bot_token, ilink_bot_id, baseurl, status,
connected_at, created_at, updated_at)
VALUES (:user_id, :username, :bot_token, :ilink_bot_id, :baseurl,
'connected', :connected_at, :created_at, :updated_at)"""),
{
"user_id": user.id, "username": user.username,
"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
user_id=:user_id, username=:username, 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"""),
{
"user_id": user.id, "username": user.username,
"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_for_user(db, user.id)
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.post("/disconnect")
async def wechat_disconnect(user: User = Depends(get_current_user)):
"""断开当前用户的个人微信连接(清除 bot_token)"""
db = SessionLocal()
try:
row = _wechat_row_for_user(db, user.id)
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()
# ============================================================
# 启动恢复:为所有已连接用户账号拉起接收线程
# ============================================================
def start_all_wechat_receivers():
"""在应用启动时调用,恢复此前已连接用户的个人微信接收线程。"""
db = SessionLocal()
try:
rows = _execute_raw(
db,
text("SELECT id FROM im_bot_wechat_account WHERE status='connected' AND user_id IS NOT NULL"),
)
for r in rows:
_start_wechat_receiver(r._mapping["id"])
if rows:
import logging
logging.getLogger("aiflow").info(f"[IM个人微信] 已恢复 {len(rows)} 个用户微信接收线程")
except Exception as e:
import logging
logging.getLogger("aiflow").warning(f"[IM个人微信] 启动恢复失败(不影响服务): {e}")
finally:
db.close()
+11 -1
View File
@@ -37,6 +37,7 @@ from app.casdoor_auth import router as casdoor_router
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin , tenant_admin, billing_admin, analytics_admin
from app.api.im_bot_user import router as im_bot_user_router
from app.api.portal import user_portal
from app.api.admin.developer_tools_admin import router as developer_tools_router
from app.api.admin.mcp_market_admin import router as mcp_market_router
@@ -291,7 +292,15 @@ async def lifespan(app: FastAPI):
logger.info("Qihu-AIFlow v2.0.0 启动完成")
# 恢复用户个人微信(iLink)接收线程
try:
from app.api.im_bot_user import start_all_wechat_receivers
start_all_wechat_receivers()
logger.info("[IM个人微信] 用户微信接收线程已恢复")
except Exception as e:
logger.warning(f"[IM个人微信] 启动恢复异常(不影响服务): {e}")
logger.info("Qihu-AIFlow v2.0.0 启动完成")
@@ -438,6 +447,7 @@ app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", ta
app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"])
app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"])
app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"])
app.include_router(im_bot_user_router, prefix="/app/api/im/wechat", tags=["im-wechat-user"])
app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"])
app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"])
+2
View File
@@ -22,6 +22,7 @@ import NotificationPage from '@/pages/front/NotificationPage';
import AssetsPage from '@/pages/front/AssetsPage';
import NovelPreviewPage from '@/pages/front/NovelPreviewPage';
import EvolutionDashboard from '@/pages/front/EvolutionDashboard';
import WechatBot from '@/pages/front/WechatBot';
// Admin Pages
import AdminDashboard from '@/pages/admin/Dashboard';
@@ -117,6 +118,7 @@ const App: React.FC = () => {
<Route path="assets" element={<AssetsPage />} />
<Route path="assets/novel/:id" element={<NovelPreviewPage />} />
<Route path="evolution" element={<EvolutionDashboard />} />
<Route path="wechat-bot" element={<WechatBot />} />
</Route>
{/* Admin Routes */}
+18 -5
View File
@@ -110,7 +110,7 @@ export const chatAPI = {
clearContext: (convId: string) =>
post<any>(`/chat/conversations/${convId}/clear`),
convertToKnowledgeGraph: (convId: string) =>
post<any>(`/chat/conversations/${convId}/to-knowledge-graph`),
post<any>(`/chat/conversations/${convId}/to-knowledge-graph/`),
getModels: () => get<any[]>('/chat/models'),
};
@@ -155,7 +155,7 @@ export const knowledgeAPI = {
deleteBase: (id: string) => del<any>(`/knowledge/bases/${id}`),
getDocuments: (baseId: string) => get<any>(`/knowledge/bases/${baseId}/documents`),
getDocument: (baseId: string, docId: string) => get<any>(`/knowledge/bases/${baseId}/documents/${docId}`),
uploadDocument: (baseId: string, data: any) => post<any>(`/knowledge/bases/${baseId}/documents`, data),
uploadDocument: (baseId: string, data: any) => post<any>(`/knowledge/bases/${baseId}/documents/`, data),
deleteDocument: (baseId: string, docId: string) => del<any>(`/knowledge/bases/${baseId}/documents/${docId}`),
getDirectories: () => get<any[]>('/knowledge/directories'),
createDirectory: (data: any) => post<any>('/knowledge/directories', data),
@@ -501,6 +501,7 @@ export const adminAPI = {
toggleAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/toggle`),
setDefaultAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/set-default`),
testAdminStorageConfig: (data: any) => post<any>('/admin/storage/test', data),
getAdminStorageUsage: (id: string) => get<any>(`/admin/storage/${id}/usage`),
// Storage Quota APIs (Phase C5)
getStorageQuotaOverview: () => get<any>('/admin/storage/quota/overview'),
getStorageQuotaUsers: (params?: any) => {
@@ -859,9 +860,12 @@ export const evolutionAPI = {
getImBotRoutes: (params?: string) => get(`/admin/im-bot/routes${params || ''}`),
createImBotRoute: (params: string) => post(`/admin/im-bot/routes${params}`),
deleteImBotRoute: (id: string) => del(`/admin/im-bot/routes/${id}`),
getLangbotConfig: () => get('/admin/im-bot/langbot'),
updateLangbotConfig: (params: string) => post(`/admin/im-bot/langbot${params}`),
testLangbot: (params: string) => post(`/admin/im-bot/langbot/test${params}`),
// 个人微信(iLink 协议)原生适配器
getWechatConfig: () => get('/admin/im-bot/wechat'),
wechatQrcode: () => post('/admin/im-bot/wechat/qrcode'),
wechatQrcodeStatus: (params: string) => post(`/admin/im-bot/wechat/qrcode/status${params}`),
getWechatStatus: () => get('/admin/im-bot/wechat/status'),
disconnectWechat: () => post('/admin/im-bot/wechat/disconnect'),
getImBotPlatforms: () => get('/admin/im-bot/platforms'),
getImBotWebhookUrl: (platform: string) => get(`/admin/im-bot/webhook-url/${platform}`),
getImBotMessages: (params?: string) => get(`/admin/im-bot/messages${params || ''}`),
@@ -988,3 +992,12 @@ export const evolutionAPI = {
},
};
// ===== 前台用户个人微信 (iLink 协议 / 微信 ClawBot 插件) =====
export const imUserAPI = {
getStatus: () => get<any>('/im/wechat/status'),
qrcode: () => post<any>('/im/wechat/qrcode'),
qrcodeStatus: (qrcode: string) => post<any>(`/im/wechat/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`),
disconnect: () => post<any>('/im/wechat/disconnect'),
};
@@ -12,6 +12,7 @@ const navItems = [
{ path: '/pricing', icon: 'pricing', label: '付费中心' },
{ path: '/assets', icon: 'assets', label: '资产文件' },
{ path: '/evolution', icon: 'evolution', label: 'AI自进化' },
{ path: '/wechat-bot', icon: 'wechat', label: '微信机器人' },
];
const bottomItems = [
@@ -31,6 +32,7 @@ const iconPaths: Record<string, JSX.Element> = {
notify: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>,
assets: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>,
evolution: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>,
wechat: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/><circle cx="9" cy="11" r="1"/><circle cx="15" cy="11" r="1"/></svg>,
};
const FrontLayout: React.FC = () => {
+214
View File
@@ -0,0 +1,214 @@
// @ts-nocheck
import React, { useState, useEffect, useCallback } from 'react';
import { imUserAPI } from '@/api';
const WechatBot: React.FC = () => {
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<any>({ connected: false, configured: false });
const [qrcode, setQrcode] = useState<any>(null);
const [polling, setPolling] = useState(false);
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
};
// 加载当前用户的连接状态
const loadStatus = useCallback(async () => {
try {
const res = await imUserAPI.getStatus();
if ((res as any)?.success) setStatus(res.data || { connected: false, configured: false });
} catch (e) {
// 静默失败
}
}, []);
useEffect(() => {
loadStatus();
}, [loadStatus]);
// 获取扫码二维码(iLink 协议免 AppID)
const handleQrcode = async () => {
setLoading(true);
try {
const res = await imUserAPI.qrcode();
if ((res as any)?.success && (res as any).data) {
setQrcode((res as any).data);
setPolling(true);
showToast('二维码已生成,请用手机微信扫码');
} else {
showToast((res as any).error || '获取二维码失败', 'error');
}
} catch (e) {
showToast('获取二维码失败', 'error');
} finally {
setLoading(false);
}
};
// 轮询扫码状态
const handlePoll = useCallback(async () => {
if (!qrcode?.qrcode) return;
try {
const res = await imUserAPI.qrcodeStatus(qrcode.qrcode);
if ((res as any)?.success && (res as any).data) {
const d = (res as any).data;
if (d.status === 'confirmed' || d.connected) {
setPolling(false);
setStatus({ ...status, connected: true, configured: true });
showToast('个人微信已连接!');
loadStatus();
} else if (d.status === 'expired') {
setPolling(false);
showToast('二维码已过期,请重新获取', 'error');
}
}
} catch (e) {
// 忽略轮询错误
}
}, [qrcode, status, loadStatus]);
useEffect(() => {
if (polling && qrcode?.qrcode) {
const t = setInterval(() => handlePoll(), 3000);
return () => clearInterval(t);
}
}, [polling, qrcode, handlePoll]);
// 断开连接
const handleDisconnect = async () => {
try {
await imUserAPI.disconnect();
setPolling(false);
setQrcode(null);
setStatus({ connected: false, configured: false });
showToast('已断开个人微信连接');
loadStatus();
} catch (e) {
showToast('断开失败', 'error');
}
};
const formatTime = (t: string) => (t ? new Date(t).toLocaleString('zh-CN') : '-');
return (
<div style={{ minHeight: '100%', background: '#FFFFFF', color: '#1A1F36', padding: '32px' }}>
<div style={{ maxWidth: '860px', margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: '24px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: 0, color: '#1A1F36' }}>我的微信机器人</h1>
<p style={{ color: '#6B7280', fontSize: '14px', margin: '6px 0 0 0' }}>
绑定你自己的个人微信,由七虎AI 大脑自动应答。基于腾讯官方 iLink 协议,扫码授权即可,无需任何开放平台凭证。
</p>
</div>
{/* Toast */}
{toast && (
<div style={{
position: 'fixed', top: '20px', right: '20px', zIndex: 9999,
padding: '12px 24px', borderRadius: '8px',
background: toast.type === 'success' ? '#10b981' : '#ef4444',
color: '#fff', fontSize: '14px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}>{toast.msg}</div>
)}
{/* 状态条 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#F8F9FC', borderRadius: '12px', padding: '16px 24px', marginBottom: '16px' }}>
<div>
<h3 style={{ margin: 0, fontSize: '16px', color: '#1A1F36' }}>个人微信(iLink 协议)</h3>
<p style={{ margin: '4px 0 0 0', fontSize: '13px', color: '#6B7280' }}>
直接对接腾讯官方 iLink Bot HTTP API,扫码授权后由七虎AI 自动回复。
</p>
</div>
<span style={{
padding: '4px 12px', borderRadius: '4px', fontSize: '12px',
background: status.connected ? '#10b98120' : '#6b728020',
color: status.connected ? '#10b981' : '#6b7280',
}}>
{status.connected ? '● 已连接' : status.configured ? '○ 已配置' : '○ 未绑定'}
</span>
</div>
{/* 扫码绑定 */}
<div style={{ background: '#F8F9FC', borderRadius: '12px', padding: '24px', marginBottom: '16px' }}>
<h4 style={{ marginTop: 0, fontSize: '15px', color: '#1A1F36', marginBottom: '12px' }}>扫码授权绑定</h4>
{status.connected ? (
<div style={{ fontSize: '13px', color: '#10b981' }}>已绑定,你的个人微信正在自动收发消息并由七虎AI 应答。</div>
) : !qrcode ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '12px' }}>
<div style={{ fontSize: '13px', color: '#6B7280' }}>
点击「获取扫码二维码」,使用手机微信扫描二维码并确认授权,即可将你的个人微信接入七虎AI。整个过程无需申请任何开放平台凭证。
</div>
<button onClick={handleQrcode} disabled={loading} style={primaryBtnStyle}>
{loading ? '生成中…' : '获取扫码二维码'}
</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
{qrcode.qrcode_img_content ? (
<img src={qrcode.qrcode_img_content} alt="微信扫码" style={{ width: '220px', height: '220px', background: '#fff', borderRadius: '8px', border: '1px solid #E5E7EB' }} />
) : (
<div style={{ width: '220px', height: '220px', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff', borderRadius: '8px', border: '1px solid #E5E7EB', fontSize: '12px', color: '#9CA3AF', textAlign: 'center', padding: '12px' }}>
二维码数据缺失,请重新获取。
</div>
)}
<div style={{ fontSize: '13px', color: polling ? '#f59e0b' : '#6B7280' }}>
{polling ? '等待扫码中…(每 3 秒自动检测一次)' : '已停止轮询'}
</div>
<div style={{ display: 'flex', gap: '12px' }}>
{polling && (
<button onClick={() => setPolling(false)} style={secondaryBtnStyle}>取消轮询</button>
)}
<button onClick={handleDisconnect} style={{ ...secondaryBtnStyle, color: '#ef4444', borderColor: '#ef4444' }}>断开连接</button>
</div>
</div>
)}
</div>
{/* 已连接信息 */}
{status.connected && (
<div style={{ background: '#F8F9FC', borderRadius: '12px', padding: '24px', marginBottom: '16px' }}>
<h4 style={{ marginTop: 0, fontSize: '15px', color: '#1A1F36', marginBottom: '12px' }}>连接信息</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<DetailItem label="绑定账号" value={status.username || '-'} />
<DetailItem label="连接时间" value={formatTime(status.connected_at)} />
<DetailItem label="iLink Bot ID" value={status.ilink_bot_id || '-'} />
<DetailItem label="Base URL" value={status.baseurl || '-'} />
</div>
</div>
)}
{/* 说明 */}
<div style={{ background: '#F8F9FC', borderRadius: '12px', padding: '24px', marginTop: '16px' }}>
<h4 style={{ marginTop: 0, fontSize: '14px', color: '#888' }}>接入说明</h4>
<ul style={{ fontSize: '13px', color: '#6B7280', lineHeight: '1.8', paddingLeft: '20px', margin: 0 }}>
<li>每个账号独立绑定自己的个人微信,数据严格按账号隔离,互不影响</li>
<li>个人微信通过腾讯官方 iLink Bot HTTP API(微信 ClawBot 插件)接入,消息经七虎AI 大脑生成回复</li>
<li>采用官方合法协议,无需 Hook / 逆向,封号风险远低于历史方案,但仍请遵守微信平台规范</li>
<li>消息收发基于长轮询(getupdates)+ 官方 sendmessage,回复自动关联对话窗口(context_token)</li>
</ul>
</div>
</div>
</div>
);
};
const DetailItem: React.FC<{ label: string; value: any }> = ({ label, value }) => (
<div style={{ padding: '8px 12px', background: '#FFFFFF', borderRadius: '6px' }}>
<div style={{ fontSize: '12px', color: '#666' }}>{label}</div>
<div style={{ fontSize: '14px', marginTop: '2px', wordBreak: 'break-all' }}>{value || '-'}</div>
</div>
);
const primaryBtnStyle: React.CSSProperties = {
padding: '8px 20px', background: '#6C63FF', border: 'none', borderRadius: '6px',
color: '#fff', fontSize: '14px', fontWeight: 500, cursor: 'pointer',
};
const secondaryBtnStyle: React.CSSProperties = {
padding: '8px 20px', background: '#F0F2F8', border: '1px solid #F0F2F8', borderRadius: '6px',
color: '#1A1F36', fontSize: '14px', fontWeight: 500, cursor: 'pointer',
};
export default WechatBot;