""" Phase 5: 多平台 IM Bot 接入(企业微信 / 飞书 / QQ / 悟空IM)—— 前台用户模块 im_bot_platform.py 为【平台注册用户】提供绑定企业级 IM 平台(需官方开放平台凭证)的能力: - 每个用户可独立配置多个平台账号(按 user_id + platform 隔离) - 配置基于各平台官方开放 API(CorpID/Secret、AppID/Secret、Bot AppID/Secret、悟空IM 地址等) - 提供统一 webhook 接收端点:平台把消息推到 /{platform}/webhook → 七虎AI 应答 → 回写平台 - 对话历史落库 im_bot_chat_log,并镜像到 AI 聊天对应会话窗(source='im_bot_') 与 im_bot_user(个人微信 iLink 扫码)的区别: - 个人微信:免 AppID 扫码,长轮询收消息 - 本模块:企业级平台,需开放平台凭证 + 回调地址(由平台主动 POST 到 webhook) """ import json from datetime import datetime, timedelta from typing import Optional import requests from fastapi import APIRouter, Depends, HTTPException, Query, Request 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() # ============================================================ # 平台注册表(含配置项 schema / 配色 / 说明) # ============================================================ PLATFORMS = { "wecom": { "name": "企业微信", "icon": "wecom", "color": "#2F7D32", "description": "基于企业微信「应用消息」回调,需 CorpID / 应用 Secret / AgentID 与可信回调地址。", "doc": "在「企业微信管理后台 → 应用管理 → 自建应用」获取 CorpID、Secret、AgentID;并将本页生成的回调地址填入「接收消息 → 设置 API 接收」。", "fields": [ {"key": "corpid", "label": "企业 ID (CorpID)", "secret": True, "required": True, "placeholder": "wwxxxxxxxxxxxxxxxx"}, {"key": "corpsecret", "label": "应用 Secret", "secret": True, "required": True, "placeholder": "应用管理中的 Secret"}, {"key": "agentid", "label": "应用 AgentID", "secret": False, "required": True, "placeholder": "1000002"}, ], }, "feishu": { "name": "飞书", "icon": "feishu", "color": "#3370FF", "description": "基于飞书「事件订阅 + 消息卡片」,需 App ID / App Secret 与请求地址。", "doc": "在「飞书开放平台 → 凭证与基础信息」获取 App ID / App Secret;在「事件订阅」中把本页生成的请求地址填入,并订阅「接收消息 v2.0」事件。", "fields": [ {"key": "app_id", "label": "App ID", "secret": True, "required": True, "placeholder": "cli_xxxxxxxxxxxxxxxx"}, {"key": "app_secret", "label": "App Secret", "secret": True, "required": True, "placeholder": "应用 App Secret"}, ], }, "qq": { "name": "QQ", "icon": "qq", "color": "#12B7F5", "description": "基于 QQ 机器人开放平台(频道/私聊消息),需 Bot AppID / Token 与回调/网关。", "doc": "在「QQ 开放平台 → 机器人管理」获取 Bot AppID / Bot Token;将本页生成的回调地址配置到「消息回调 URL」。", "fields": [ {"key": "bot_appid", "label": "Bot AppID", "secret": True, "required": True, "placeholder": "102000000"}, {"key": "bot_token", "label": "Bot Token", "secret": True, "required": True, "placeholder": "机器人 Token"}, ], }, "wukong": { "name": "悟空IM", "icon": "wukong", "color": "#FF6B35", "description": "基于悟空 IM 开源服务(REST + WebSocket),需服务地址与 App Key / Secret。", "doc": "部署悟空 IM 后,填入服务 Base URL 与 App Key / Secret;将本页生成的回调地址配置到悟空 IM 的「消息回调 Webhook」。", "fields": [ {"key": "base_url", "label": "服务地址 (Base URL)", "secret": False, "required": True, "placeholder": "http://im.yourdomain.com"}, {"key": "app_key", "label": "App Key", "secret": True, "required": True, "placeholder": "悟空 App Key"}, {"key": "app_secret", "label": "App Secret", "secret": True, "required": True, "placeholder": "悟空 App Secret"}, ], }, } PLATFORM_KEYS = list(PLATFORMS.keys()) # ============================================================ # 通用 DB 工具 # ============================================================ def _execute_raw(db, 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 def _ensure_table(db): """确保 im_bot_platform_account 表存在(首次调用时创建)。""" db.execute(text(""" CREATE TABLE IF NOT EXISTS im_bot_platform_account ( id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(64) DEFAULT NULL, platform VARCHAR(32) NOT NULL, status VARCHAR(32) DEFAULT 'disconnected', creds MEDIUMTEXT NULL, send_url VARCHAR(512) NULL, api_base VARCHAR(512) NULL, bot_token VARCHAR(512) NULL, webhook_token VARCHAR(64) NULL, last_error MEDIUMTEXT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_platform (user_id, platform) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """)) db.commit() def _account_row(db, user_id: str, platform: str): rows = _execute_raw( db, text("SELECT * FROM im_bot_platform_account WHERE user_id=:uid AND platform=:pf LIMIT 1"), {"uid": user_id, "pf": platform}, ) return rows[0] if rows else None def _mask_creds(creds: dict) -> dict: """脱敏凭据,仅在列出配置时返回(隐藏真实值,仅标记是否已填)。""" if not creds: return {} out = {} for k, v in creds.items(): if v is None or v == "": out[k] = "" else: sval = str(v) out[k] = ("***" + sval[-4:]) if len(sval) > 4 else "***" return out # ============================================================ # 七虎AI 大脑(复用 im_llm 的真实 LLM 调用) # ============================================================ def _recent_history(user_id: str, platform: str, limit: int = 6) -> str: db = None try: db = SessionLocal() rows = _execute_raw(db, text( "SELECT role, content FROM im_bot_chat_log " "WHERE user_id=:uid AND platform=:pf ORDER BY id DESC LIMIT :lim" ), {"uid": user_id, "pf": platform, "lim": limit}) pairs = [] for r in reversed(rows): dd = _row_to_dict(r) role = "用户" if dd["role"] == "user" else "七虎AI" pairs.append(f"{role}:{dd['content']}") return "\n".join(pairs) except Exception: return "" finally: if db: db.close() def _qihua_reply(text: str, user_id: str = None, platform: str = "im") -> str: prompt = text if user_id: hist = _recent_history(user_id, platform) if hist: prompt = ( "以下是你与该用户最近的对话历史,请结合上下文作答:\n" f"{hist}\n\n" f"用户当前问题:{text}" ) db = None try: from app.api.im_llm import _call_real_llm db = SessionLocal() return _call_real_llm(db, None, prompt, platform) except Exception as e: return f"(AI 回复生成失败:{e})" finally: if db: db.close() # ============================================================ # 对话历史落库 + 镜像到 AI 聊天 # ============================================================ def _log_chat(user_id, platform, from_user, context_token, role, content): db = None try: db = SessionLocal() _execute_raw(db, text( "INSERT INTO im_bot_chat_log " "(user_id, platform, from_user, context_token, role, content, created_at) " "VALUES (:uid, :platform, :fu, :ctx, :role, :content, :created)" ), { "uid": user_id, "platform": platform, "fu": from_user, "ctx": context_token, "role": role, "content": content, "created": datetime.now(), }) except Exception: pass finally: if db: db.close() _CONV_TITLE = { "wecom": "企业微信 Bot", "feishu": "飞书 Bot", "qq": "QQ Bot", "wukong": "悟空IM Bot", } def _get_or_create_conv(db: Session, user_id: str, platform: str) -> str: src = f"im_bot_{platform}" rows = _execute_raw(db, text( "SELECT id FROM conversations WHERE user_id=:uid AND source=:src " "AND status<>'archived' ORDER BY updated_at DESC LIMIT 1" ), {"uid": user_id, "src": src}) if rows: return _row_to_dict(rows[0])["id"] import uuid as _uuid cid = str(_uuid.uuid4()) now = datetime.now() _execute_raw(db, text( "INSERT INTO conversations " "(id, user_id, title, status, pinned, knowledge_graph_count, source, created_at, updated_at) " "VALUES (:id, :uid, :title, 'active', 0, 0, :src, :c, :u)" ), {"id": cid, "uid": user_id, "title": _CONV_TITLE.get(platform, "IM Bot"), "src": src, "c": now, "u": now}) return cid def _append_ai_message(db: Session, conv_id: str, role: str, content: str, created=None): import uuid as _uuid _execute_raw(db, text( "INSERT INTO messages (id, conversation_id, role, content, token_used, created_at) " "VALUES (:id, :cid, :role, :content, 0, :c)" ), { "id": str(_uuid.uuid4()), "cid": conv_id, "role": role, "content": content, "c": created or datetime.now(), }) def _mirror_to_ai_chat(user_id: str, platform: str, user_content: str, assistant_content: str): if not user_id: return db = None try: db = SessionLocal() cid = _get_or_create_conv(db, user_id, platform) now = datetime.now() _append_ai_message(db, cid, "user", user_content, created=now) _append_ai_message(db, cid, "assistant", assistant_content, created=now + timedelta(milliseconds=50)) _execute_raw(db, text("UPDATE conversations SET updated_at=:u WHERE id=:id"), {"u": datetime.now(), "id": cid}) except Exception: pass finally: if db: db.close() # ============================================================ # 平台发送适配器(把 AI 回复回写到各平台) # ============================================================ def _wecom_send(creds: dict, to_user: str, text: str) -> (bool, str): corpid = creds.get("corpid") corpsecret = creds.get("corpsecret") agentid = creds.get("agentid") if not (corpid and corpsecret and agentid): return False, "缺少 CorpID/Secret/AgentID" try: token_resp = requests.get( "https://qyapi.weixin.qq.com/cgi-bin/gettoken", params={"corpid": corpid, "corpsecret": corpsecret}, timeout=15, ) token_resp.raise_for_status() tj = token_resp.json() if tj.get("errcode", 0) != 0: return False, f"获取 access_token 失败: {tj.get('errmsg')}" access_token = tj["access_token"] send_resp = requests.post( "https://qyapi.weixin.qq.com/cgi-bin/message/send", params={"access_token": access_token}, json={"touser": to_user, "msgtype": "text", "agentid": int(agentid), "text": {"content": text}}, timeout=15, ) sj = send_resp.json() if sj.get("errcode", 0) == 0: return True, "" return False, f"发送失败: {sj.get('errmsg')}" except Exception as e: return False, f"企业微信发送异常: {e}" def _feishu_send(creds: dict, to_user: str, text: str) -> (bool, str): app_id = creds.get("app_id") app_secret = creds.get("app_secret") if not (app_id and app_secret): return False, "缺少 App ID/Secret" try: tkn_resp = requests.post( "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", json={"app_id": app_id, "app_secret": app_secret}, timeout=15, ) tkn_resp.raise_for_status() tj = tkn_resp.json() if tj.get("code", 0) != 0: return False, f"获取 tenant_access_token 失败: {tj.get('msg')}" token = tj["tenant_access_token"] send_resp = requests.post( "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", headers={"Authorization": f"Bearer {token}"}, json={"receive_id": to_user, "msg_type": "text", "content": json.dumps({"text": text})}, timeout=15, ) sj = send_resp.json() if sj.get("code", 0) == 0: return True, "" return False, f"发送失败: {sj.get('msg')}" except Exception as e: return False, f"飞书发送异常: {e}" def _generic_send(creds: dict, send_url: str, to_user: str, text: str) -> (bool, str): """QQ / 悟空IM 等:把回复 POST 到平台提供的发送地址(send_url)。""" if not send_url: return False, "未配置发送地址(send_url)" try: resp = requests.post( send_url, json={"to": to_user, "text": text, "creds": creds}, timeout=15, ) if resp.ok: return True, "" return False, f"发送失败 HTTP {resp.status_code}" except Exception as e: return False, f"发送异常: {e}" def _send_reply(platform: str, account: dict, to_user: str, text: str) -> (bool, str): creds = account.get("creds") or {} if isinstance(creds, str): try: creds = json.loads(creds) except Exception: creds = {} if platform == "wecom": return _wecom_send(creds, to_user, text) if platform == "feishu": return _feishu_send(creds, to_user, text) # QQ / 悟空IM:通用 send_url 模式 return _generic_send(creds, account.get("send_url"), to_user, text) def _extract_inbound(platform: str, body: dict): """从平台 webhook 请求体中提取 (sender_id, text, context_token)。""" if platform == "wecom": # 企业微信回调:Encrypt 报文需解密;此处约定明文回调节点 content = (body.get("Text") or {}).get("Content") or body.get("content") or "" sender = body.get("FromUserName") or body.get("userid") or "" ctx = body.get("MsgId") or "" return sender, content, ctx if platform == "feishu": # 飞书事件 v2.0:event.message + event.sender evt = body.get("event", body) msg = evt.get("message", {}) sender = evt.get("sender", {}).get("sender_id", {}).get("open_id") or msg.get("chat_id") or "" content = (msg.get("content") or "") try: content = json.loads(content).get("text", "") if content else "" except Exception: pass ctx = msg.get("message_id") or "" return sender, content, ctx if platform == "qq": d = body.get("d", body) content = d.get("content", "") sender = d.get("author", {}).get("id") or d.get("user_id") or "" ctx = d.get("id") or "" return sender, content, ctx # wukong / 通用 sender = body.get("from") or body.get("from_user") or body.get("sender") or "" content = body.get("content") or body.get("text") or body.get("message") or "" ctx = body.get("msg_id") or body.get("id") or "" return sender, content, ctx # ============================================================ # 前台用户接口(按 user_id 隔离) # ============================================================ @router.get("/platforms") async def list_supported_platforms(user: User = Depends(get_current_user)): """列出全部支持的平台及其配置 schema(供前端渲染表单)。""" result = [] for key in PLATFORM_KEYS: p = PLATFORMS[key] result.append({ "key": key, "name": p["name"], "icon": p["icon"], "color": p["color"], "description": p["description"], "doc": p["doc"], "fields": p["fields"], }) return {"success": True, "data": result} @router.get("/list") async def list_my_platforms(user: User = Depends(get_current_user)): """当前用户已配置的各平台状态。""" db = SessionLocal() try: _ensure_table(db) rows = _execute_raw( db, text("SELECT * FROM im_bot_platform_account WHERE user_id=:uid ORDER BY platform"), {"uid": user.id}, ) items = [] for r in rows: d = _row_to_dict(r) items.append({ "id": d["id"], "platform": d["platform"], "status": d["status"], "configured": True, "send_url": d.get("send_url"), "last_error": d.get("last_error"), "updated_at": d.get("updated_at"), }) # 补全未配置平台为占位 configured = {it["platform"] for it in items} for key in PLATFORM_KEYS: if key not in configured: items.append({"id": None, "platform": key, "status": "disconnected", "configured": False, "send_url": None, "last_error": None, "updated_at": None}) return {"success": True, "data": items} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: db.close() @router.get("/{platform}/config") async def get_platform_config(platform: str, user: User = Depends(get_current_user)): if platform not in PLATFORMS: raise HTTPException(status_code=404, detail="不支持的平台") db = SessionLocal() try: _ensure_table(db) row = _account_row(db, user.id, platform) if not row: return {"success": True, "data": {"configured": False, "status": "disconnected"}} d = _row_to_dict(row) creds = d.get("creds") if isinstance(creds, str): try: creds = json.loads(creds) except Exception: creds = {} return {"success": True, "data": { "configured": True, "status": d.get("status"), "creds": _mask_creds(creds or {}), "send_url": d.get("send_url"), "last_error": d.get("last_error"), "webhook_token": d.get("webhook_token"), "updated_at": d.get("updated_at"), }} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: db.close() @router.post("/{platform}/config") async def save_platform_config(platform: str, body: dict, user: User = Depends(get_current_user)): if platform not in PLATFORMS: raise HTTPException(status_code=404, detail="不支持的平台") db = SessionLocal() try: _ensure_table(db) schema = PLATFORMS[platform] required = [f["key"] for f in schema["fields"] if f.get("required")] creds = {} for f in schema["fields"]: k = f["key"] v = body.get(k) if f.get("required") and not v: raise HTTPException(status_code=400, detail=f"缺少必填项:{f['label']}") creds[k] = v send_url = body.get("send_url") api_base = body.get("api_base") import secrets as _sec webhook_token = _sec.token_hex(16) row = _account_row(db, user.id, platform) if row: _execute_raw(db, text( "UPDATE im_bot_platform_account SET creds=:creds, send_url=:su, api_base=:ab, " "status='connected', last_error=NULL, webhook_token=:wt, updated_at=:u WHERE id=:id" ), {"creds": json.dumps(creds, ensure_ascii=False), "su": send_url, "ab": api_base, "wt": webhook_token, "u": datetime.now(), "id": _row_to_dict(row)["id"]}) else: _execute_raw(db, text( "INSERT INTO im_bot_platform_account " "(user_id, platform, status, creds, send_url, api_base, webhook_token, created_at, updated_at) " "VALUES (:uid, :pf, 'connected', :creds, :su, :ab, :wt, :c, :u)" ), {"uid": user.id, "pf": platform, "creds": json.dumps(creds, ensure_ascii=False), "su": send_url, "ab": api_base, "wt": webhook_token, "c": datetime.now(), "u": datetime.now()}) return {"success": True, "message": f"{schema['name']} 配置已保存并启用"} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: db.close() @router.post("/{platform}/disconnect") async def disconnect_platform(platform: str, user: User = Depends(get_current_user)): if platform not in PLATFORMS: raise HTTPException(status_code=404, detail="不支持的平台") db = SessionLocal() try: _ensure_table(db) row = _account_row(db, user.id, platform) if row: _execute_raw(db, text( "UPDATE im_bot_platform_account SET status='disconnected', creds=NULL, " "bot_token=NULL, send_url=NULL, webhook_token=NULL, updated_at=:u WHERE id=:id" ), {"u": datetime.now(), "id": _row_to_dict(row)["id"]}) return {"success": True, "message": f"已断开 {PLATFORMS[platform]['name']} 连接"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: db.close() @router.get("/{platform}/history") async def get_platform_history( platform: str, user: User = Depends(get_current_user), limit: int = Query(200), ): if platform not in PLATFORMS: raise HTTPException(status_code=404, detail="不支持的平台") db = SessionLocal() try: rows = _execute_raw( db, text("SELECT id, role, content, platform, from_user, created_at " "FROM im_bot_chat_log WHERE user_id=:uid AND platform=:pf ORDER BY id DESC LIMIT :lim"), {"uid": user.id, "pf": platform, "lim": min(int(limit), 500)}, ) items = [_row_to_dict(r) for r in rows] items.reverse() return {"success": True, "data": {"messages": items, "total": len(items)}} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: db.close() @router.get("/{platform}/webhook-url") async def get_webhook_url(platform: str, user: User = Depends(get_current_user), request: Request = None): """返回本账号在该平台的回调/webhook 地址(配置到对应开放平台用)。""" if platform not in PLATFORMS: raise HTTPException(status_code=404, detail="不支持的平台") db = SessionLocal() try: _ensure_table(db) row = _account_row(db, user.id, platform) if not row: raise HTTPException(status_code=400, detail="请先保存配置再获取回调地址") d = _row_to_dict(row) wt = d.get("webhook_token") base = str(request.base_url).rstrip("/") if request else "http://localhost:12888" url = f"{base}/app/api/im/platform/{platform}/webhook?token={wt}" return {"success": True, "data": {"webhook_url": url}} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: db.close() # ============================================================ # 平台回调 webhook(由各 IM 平台主动 POST,无需用户登录态) # ============================================================ @router.post("/{platform}/webhook") async def platform_webhook(platform: str, request: Request, token: str = Query(None)): if platform not in PLATFORMS: raise HTTPException(status_code=404, detail="不支持的平台") try: body = await request.json() except Exception: try: form = await request.form() body = dict(form) except Exception: body = {} db = SessionLocal() try: _ensure_table(db) row = _execute_raw( db, text("SELECT * FROM im_bot_platform_account WHERE platform=:pf AND webhook_token=:wt AND status='connected' LIMIT 1"), {"pf": platform, "wt": token}, ) if not row: return {"success": False, "error": "账号未找到或未启用"} acc = _row_to_dict(row[0]) user_id = acc.get("user_id") sender, content, ctx = _extract_inbound(platform, body) if not content: return {"success": True, "echo": "no content"} reply = _qihua_reply(content, user_id, platform) ok, err = _send_reply(platform, acc, sender, reply) if ok: if user_id: _log_chat(user_id, platform, sender, ctx, "user", content) _log_chat(user_id, platform, sender, ctx, "assistant", reply) _mirror_to_ai_chat(user_id, platform, content, reply) return {"success": True, "replied": True} else: # 记录发送失败,便于排查 _execute_raw(db, text( "UPDATE im_bot_platform_account SET last_error=:e, updated_at=:u WHERE id=:id" ), {"e": err[:500], "u": datetime.now(), "id": acc["id"]}) return {"success": False, "error": err} except Exception as e: return {"success": False, "error": str(e)} finally: db.close() # ============================================================ # 应用服务(前台浏览已发布的 AI 应用) # ============================================================ @router.get("/apps") async def list_public_apps( user: User = Depends(get_current_user), category: Optional[str] = Query(None), search: Optional[str] = Query(None), page: int = Query(1, ge=1), page_size: int = Query(12, ge=1, le=48), ): """浏览平台已发布、对会员可见的 AI 应用服务。""" db = SessionLocal() try: where = "WHERE status='published' AND is_template=0 AND is_public=1" params: dict = {} if category: where += " AND category=:cat" params["cat"] = category if search: where += " AND (name LIKE :s OR description LIKE :s2)" params["s"] = f"%{search}%" params["s2"] = f"%{search}%" total = db.execute(text(f"SELECT COUNT(*) FROM ai_applications {where}"), params).scalar() or 0 offset = (page - 1) * page_size rows = db.execute(text( f"SELECT id, name, description, type, category, icon, tags, published_at " f"FROM ai_applications {where} ORDER BY published_at DESC LIMIT :lim OFFSET :off" ), {**params, "lim": page_size, "off": offset}).fetchall() type_names = { "chatbot": "对话机器人", "assistant": "智能助理", "workflow": "工作流", "api_service": "API 服务", } cat_names = { "customer_service": "客服助手", "data_analysis": "数据分析", "coding": "编程辅助", "writing": "内容创作", "education": "教育培训", "marketing": "营销推广", "hr": "人力资源", "finance": "财务法务", "custom": "自定义", } items = [] for r in rows: items.append({ "id": r[0], "name": r[1], "description": r[2], "type": r[3], "type_name": type_names.get(r[3], r[3]), "category": r[4], "category_name": cat_names.get(r[4], r[4]), "icon": r[5], "tags": r[6], "published_at": r[7].isoformat() if r[7] else None, }) return {"success": True, "data": { "items": items, "total": total, "page": page, "page_size": page_size, "total_pages": (total + page_size - 1) // page_size, }} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: db.close()