- #549 平台MCP列举: 注入全量已启用MCP清单, AI不再凭空编造 - #550 微信Bot技能自动发现上限 15->300 - #551 知识库Word文档同步资产后Word栏可见 + 双向删除同步(assets/knowledge级联软删) - #552 对话反馈实际使用的技能/MCP(used_mcp字段), 含历史消息序列化 - 同步容器内其他未提交live改动(im_bot_platform/TokenDashboard/WechatBot/ChatPage)
707 lines
28 KiB
Python
707 lines
28 KiB
Python
"""
|
||
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_<platform>')
|
||
|
||
与 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, user_id=user_id)
|
||
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()
|