diff --git a/backend/python/app/api/im_bot_platform.py b/backend/python/app/api/im_bot_platform.py new file mode 100644 index 0000000..6fb6ccd --- /dev/null +++ b/backend/python/app/api/im_bot_platform.py @@ -0,0 +1,706 @@ +""" +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() diff --git a/backend/python/main.py b/backend/python/main.py index e7839a7..69aa41d 100644 --- a/backend/python/main.py +++ b/backend/python/main.py @@ -19,6 +19,7 @@ from app.api import auth, chat, knowledge, market, token_usage, payment, profile from app.casdoor_auth import router as casdoor_router 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.im_bot_platform import router as im_bot_platform_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 @@ -230,6 +231,7 @@ app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", t 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(im_bot_platform_router, prefix="/app/api/im/platform", tags=["im-platform-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"]) app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"]) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3af31f..4e3d2aa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -56,6 +56,8 @@ const AdminApps = React.lazy(() => import('@/pages/admin/Apps')); const AssetCenter = React.lazy(() => import('@/pages/admin/AssetCenter')); const UserPortal = React.lazy(() => import('@/pages/UserPortal')); const WechatBot = React.lazy(() => import('@/pages/front/WechatBot')); +const ImBotHub = React.lazy(() => import('@/pages/front/ImBotHub')); +const AppServices = React.lazy(() => import('@/pages/front/AppServices')); const pageFallback =
加载中...
; @@ -121,6 +123,8 @@ const App: React.FC = () => { } /> } /> } /> + } /> + } /> {/* Admin Routes */} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 4751320..30d60e5 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1955,5 +1955,31 @@ export const imUserAPI = { history: () => get('/im/wechat/history'), + // 多平台 IM Bot(企业微信/飞书/QQ/悟空IM) + getPlatforms: () => get('/im/platform/platforms'), + + listPlatforms: () => get('/im/platform/list'), + + platformConfig: (platform: string) => get(`/im/platform/${platform}/config`), + + savePlatformConfig: (platform: string, data: any) => post(`/im/platform/${platform}/config`, data), + + disconnectPlatform: (platform: string) => post(`/im/platform/${platform}/disconnect`), + + platformHistory: (platform: string) => get(`/im/platform/${platform}/history`), + + platformWebhookUrl: (platform: string) => get(`/im/platform/${platform}/webhook-url`), + + // 应用服务(已发布 AI 应用浏览) + appServices: (params?: { category?: string; search?: string; page?: number; page_size?: number }) => { + const qs = new URLSearchParams(); + if (params?.category) qs.set('category', params.category); + if (params?.search) qs.set('search', params.search); + if (params?.page) qs.set('page', String(params.page)); + if (params?.page_size) qs.set('page_size', String(params.page_size)); + const q = qs.toString(); + return get(`/im/platform/apps${q ? `?${q}` : ''}`); + }, + }; diff --git a/frontend/src/components/layout/FrontLayout.tsx b/frontend/src/components/layout/FrontLayout.tsx index 501a4fb..737a3a7 100644 --- a/frontend/src/components/layout/FrontLayout.tsx +++ b/frontend/src/components/layout/FrontLayout.tsx @@ -17,11 +17,16 @@ const navGroups = [ title: 'IM Bot', items: [ { path: '/wechat-bot', icon: 'wechat', label: '微信Bot' }, + { path: '/im-bots?platform=wecom', icon: 'wecom', label: '企业微信' }, + { path: '/im-bots?platform=feishu', icon: 'feishu', label: '飞书' }, + { path: '/im-bots?platform=qq', icon: 'qq', label: 'QQ' }, + { path: '/im-bots?platform=wukong', icon: 'wukong', label: '悟空IM' }, ], }, { title: '资源中心', items: [ + { path: '/app-services', icon: 'apps', label: '应用服务' }, { path: '/token', icon: 'token', label: 'Token驾驶舱' }, { path: '/pricing', icon: 'pricing', label: '会员订阅' }, { path: '/assets', icon: 'assets', label: '资产文件' }, @@ -47,6 +52,10 @@ const iconPaths: Record = { assets: , evolution: , wechat: , + wecom: , + feishu: , + qq: , + wukong: , }; const FrontLayout: React.FC = () => { diff --git a/frontend/src/pages/front/AppServices.tsx b/frontend/src/pages/front/AppServices.tsx new file mode 100644 index 0000000..294d1dd --- /dev/null +++ b/frontend/src/pages/front/AppServices.tsx @@ -0,0 +1,131 @@ +// @ts-nocheck +import React, { useState, useEffect, useCallback } from 'react'; +import { imUserAPI } from '@/api'; + +const PRIMARY = '#5B5FED'; + +const AppServices: React.FC = () => { + const [loading, setLoading] = useState(true); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [category, setCategory] = useState(''); + 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), 2500); + }; + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await imUserAPI.appServices({ category: category || undefined, search: search || undefined, page, page_size: 12 }); + if (res?.success) { + setItems(res.data.items || []); + setTotal(res.data.total || 0); + } else { + showToast('加载应用服务失败', 'error'); + } + } catch (e) { + showToast('加载应用服务失败', 'error'); + } finally { + setLoading(false); + } + }, [category, search, page]); + + useEffect(() => { load(); }, [load]); + + const categories = [ + { key: '', name: '全部' }, + { key: 'customer_service', name: '客服助手' }, + { key: 'data_analysis', name: '数据分析' }, + { key: 'coding', name: '编程辅助' }, + { key: 'writing', name: '内容创作' }, + { key: 'education', name: '教育培训' }, + { key: 'marketing', name: '营销推广' }, + { key: 'hr', name: '人力资源' }, + { key: 'finance', name: '财务法务' }, + ]; + + return ( +
+
+
+

应用服务

+

+ 浏览平台已上架的 AI 应用服务,按需接入你的工作流。由七虎AI 大脑统一驱动,覆盖客服、分析、创作等多类场景。 +

+
+ + {toast && ( +
{toast.msg}
+ )} + + {/* 搜索 + 分类筛选 */} +
+ { setSearch(e.target.value); setPage(1); }} + placeholder="搜索应用名称 / 描述…" + style={{ flex: '1 1 260px', minWidth: '200px', padding: '9px 14px', border: '1px solid #E5E7EB', borderRadius: '8px', fontSize: '14px', outline: 'none' }} + /> +
+ {categories.map((c) => ( + + ))} +
+
+ + {loading ? ( +
加载中…
+ ) : items.length === 0 ? ( +
暂无已发布的应用服务
+ ) : ( +
+ {items.map((app) => ( +
+
+
{app.icon ? : '🤖'}
+
+
{app.name}
+
+ {app.type_name} + {app.category_name} +
+
+
+

{app.description || '暂无描述'}

+
+ {app.published_at ? '上架于 ' + new Date(app.published_at).toLocaleDateString('zh-CN') : ''} + +
+
+ ))} +
+ )} + + {total > 12 && ( +
+ + 第 {page} 页 / 共 {Math.ceil(total / 12)} 页({total} 个) + +
+ )} +
+
+ ); +}; + +const pageBtn: React.CSSProperties = { padding: '8px 16px', background: '#F0F2F8', border: '1px solid #E5E7EB', borderRadius: '8px', color: '#1A1F36', fontSize: '14px', cursor: 'pointer' }; + +export default AppServices; diff --git a/frontend/src/pages/front/ImBotHub.tsx b/frontend/src/pages/front/ImBotHub.tsx new file mode 100644 index 0000000..85e85ed --- /dev/null +++ b/frontend/src/pages/front/ImBotHub.tsx @@ -0,0 +1,285 @@ +// @ts-nocheck +import React, { useState, useEffect, useCallback } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { imUserAPI } from '@/api'; + +const PRIMARY = '#5B5FED'; +const PRIMARY_ACTIVE = '#4A4ECC'; + +const ImBotHub: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const highlight = searchParams.get('platform'); + + const [loading, setLoading] = useState(true); + const [schemas, setSchemas] = useState([]); + const [statuses, setStatuses] = useState>({}); + const [activeConfig, setActiveConfig] = useState(null); + const [historyPlatform, setHistoryPlatform] = useState(null); + const [history, setHistory] = useState({ messages: [] }); + const [historyLoading, setHistoryLoading] = useState(false); + const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null); + const [saving, setSaving] = useState(false); + + const showToast = (msg: string, type: 'success' | 'error' = 'success') => { + setToast({ msg, type }); + setTimeout(() => setToast(null), 3000); + }; + + const load = useCallback(async () => { + try { + const [pRes, lRes] = await Promise.all([imUserAPI.getPlatforms(), imUserAPI.listPlatforms()]); + if (pRes?.success) setSchemas(pRes.data || []); + if (lRes?.success) { + const map: Record = {}; + (lRes.data || []).forEach((it: any) => { map[it.platform] = it; }); + setStatuses(map); + } + } catch (e) { + showToast('加载平台列表失败', 'error'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + useEffect(() => { + if (highlight && schemas.length) { + const el = document.getElementById(`imcard-${highlight}`); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, [highlight, schemas]); + + const openConfig = (key: string) => setActiveConfig(key); + const closeConfig = () => setActiveConfig(null); + + const openHistory = async (key: string) => { + setHistoryPlatform(key); + setHistoryLoading(true); + try { + const res = await imUserAPI.platformHistory(key); + if (res?.success) setHistory(res.data || { messages: [] }); + } catch (e) { + showToast('加载对话记录失败', 'error'); + } finally { + setHistoryLoading(false); + } + }; + + const handleDisconnect = async (key: string) => { + try { + const res = await imUserAPI.disconnectPlatform(key); + if (res?.success) { + showToast('已断开连接'); + await load(); + } else { + showToast(res?.error || '断开失败', 'error'); + } + } catch (e) { + showToast('断开失败', 'error'); + } + }; + + const getSchema = (key: string) => schemas.find((s) => s.key === key); + const getStatus = (key: string) => statuses[key] || { configured: false, status: 'disconnected' }; + + return ( +
+
+
+

IM 机器人

+

+ 把企业级即时通讯接入七虎AI 大脑:配置官方开放平台凭证后,消息经本平台自动应答并回写。支持企业微信、飞书、QQ、悟空IM。 +

+
+ + {toast && ( +
{toast.msg}
+ )} + + {loading ? ( +
加载中…
+ ) : ( +
+ {schemas.map((s) => { + const st = getStatus(s.key); + const connected = st.status === 'connected'; + const badge = connected + ? { bg: '#10b98120', color: '#10b981', text: '● 已连接' } + : st.configured + ? { bg: '#f59e0b20', color: '#f59e0b', text: '○ 已配置' } + : { bg: '#6b728020', color: '#6b7280', text: '○ 未连接' }; + return ( +
+
+
{s.name.slice(0, 1)}
+
+
{s.name}
+ {badge.text} +
+
+

{s.description}

+
+ + + {connected && } +
+ {st.last_error && ( +
⚠ {st.last_error}
+ )} +
+ ); + })} +
+ )} + + {activeConfig && ( + { await load(); closeConfig(); showToast('配置已保存并启用'); }} + onError={(m) => showToast(m, 'error')} + saving={saving} + setSaving={setSaving} + /> + )} + + {historyPlatform && ( + setHistoryPlatform(null)} + /> + )} +
+
+ ); +}; + +const ConfigModal: React.FC<{ schema: any; status: any; onClose: () => void; onSaved: () => void; onError: (m: string) => void; saving: boolean; setSaving: (b: boolean) => void }> = ({ schema, status, onClose, onSaved, onError, saving, setSaving }) => { + const [form, setForm] = useState({}); + const [sendUrl, setSendUrl] = useState(''); + const [webhook, setWebhook] = useState(''); + + useEffect(() => { + // 预填:若已配置,仅显示占位(机密已脱敏) + if (status?.configured) { + const c = status.creds || {}; + const f: any = {}; + (schema?.fields || []).forEach((fld: any) => { f[fld.key] = c[fld.key] && String(c[fld.key]).startsWith('***') ? '' : (c[fld.key] || ''); }); + setForm(f); + setSendUrl(''); + } + }, [schema, status]); + + const fetchWebhook = async () => { + try { + const res = await imUserAPI.platformWebhookUrl(schema.key); + if (res?.success) setWebhook(res.data.webhook_url); + } catch (e) { /* ignore */ } + }; + + const handleSave = async () => { + // 校验必填 + for (const fld of (schema?.fields || [])) { + if (fld.required && !String(form[fld.key] || '').trim()) { + onError(`请填写:${fld.label}`); + return; + } + } + setSaving(true); + try { + const payload: any = { ...form }; + if (sendUrl) payload.send_url = sendUrl; + const res = await imUserAPI.savePlatformConfig(schema.key, payload); + if (res?.success) { + await fetchWebhook(); + onSaved(); + } else { + onError(res?.error || '保存失败'); + } + } catch (e) { + onError('保存失败'); + } finally { + setSaving(false); + } + }; + + return ( +
+
e.stopPropagation()} style={{ background: '#fff', borderRadius: '14px', width: '520px', maxWidth: '92vw', maxHeight: '88vh', overflowY: 'auto', padding: '24px' }}> +
+

配置 {schema?.name}

+ +
+

{schema?.doc}

+ + {(schema?.fields || []).map((fld: any) => ( +
+ + setForm((p: any) => ({ ...p, [fld.key]: e.target.value }))} + style={{ width: '100%', boxSizing: 'border-box', padding: '9px 12px', border: '1px solid #E5E7EB', borderRadius: '8px', fontSize: '14px', outline: 'none' }} + /> +
+ ))} + + {['qq', 'wukong'].includes(schema?.key) && ( +
+ + setSendUrl(e.target.value)} style={{ width: '100%', boxSizing: 'border-box', padding: '9px 12px', border: '1px solid #E5E7EB', borderRadius: '8px', fontSize: '14px', outline: 'none' }} /> +
+ )} + + + + {webhook && ( +
+
回调地址(配置到 {schema?.name} 开放平台):
+
+ {webhook} + +
+
+ )} +
+
+ ); +}; + +const HistoryModal: React.FC<{ schema: any; data: any; loading: boolean; onClose: () => void }> = ({ schema, data, loading, onClose }) => ( +
+
e.stopPropagation()} style={{ background: '#fff', borderRadius: '14px', width: '560px', maxWidth: '92vw', maxHeight: '88vh', overflowY: 'auto', padding: '24px' }}> +
+

{schema?.name} · 对话记录

+ +
+ {loading ? ( +
加载中…
+ ) : (data?.messages || []).length === 0 ? ( +
暂无对话记录
+ ) : ( +
+ {(data.messages || []).map((m: any) => ( +
+
+ {m.content} +
{m.created_at ? new Date(m.created_at).toLocaleString('zh-CN') : ''}
+
+
+ ))} +
+ )} +
+
+); + +const btnPrimary: React.CSSProperties = { padding: '8px 16px', background: PRIMARY, border: 'none', borderRadius: '8px', color: '#fff', fontSize: '14px', fontWeight: 500, cursor: 'pointer' }; +const btnGhost: React.CSSProperties = { padding: '8px 14px', background: '#F0F2F8', border: '1px solid #E5E7EB', borderRadius: '8px', color: '#1A1F36', fontSize: '14px', fontWeight: 500, cursor: 'pointer' }; + +export default ImBotHub;