Files
qihuaiflow/backend/python/app/api/im_bot_user.py
T

360 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()