diff --git a/backend/python/app/api/__init__.py b/backend/python/app/api/__init__.py index 0302f31..f77f19a 100644 --- a/backend/python/app/api/__init__.py +++ b/backend/python/app/api/__init__.py @@ -1,6 +1,5 @@ from . import auth from . import chat -from . import channels from . import knowledge from . import market from . import token_usage diff --git a/backend/python/app/api/admin/im_bot_admin.py b/backend/python/app/api/admin/im_bot_admin.py index 4e0bd21..eff21bf 100644 --- a/backend/python/app/api/admin/im_bot_admin.py +++ b/backend/python/app/api/admin/im_bot_admin.py @@ -608,7 +608,7 @@ def _qihua_reply(text: str) -> str: 自行创建 DB 会话,避免上层线程无可用 session 时崩溃。""" db = None try: - from app.api.channels import _call_real_llm + from app.api.im_llm import _call_real_llm db = SessionLocal() return _call_real_llm(db, None, text, "个人微信") except Exception as e: diff --git a/backend/python/app/api/channels.py b/backend/python/app/api/channels.py deleted file mode 100644 index f4c7aa6..0000000 --- a/backend/python/app/api/channels.py +++ /dev/null @@ -1,1567 +0,0 @@ -""" -频道/IM绑定 API -参考 QwenPaw / 悟空IM 标准设计,支持飞书、微信、钉钉、WhatsApp、Telegram、Slack -""" -from fastapi import APIRouter, Body, Depends, HTTPException, Query, Header -from sqlalchemy.orm import Session -from sqlalchemy import and_, desc -from pydantic import BaseModel, Field -from typing import Optional, List -import json -import os -import secrets -import uuid -import logging -from datetime import datetime - -from ..config import APP_ACCESS_URL -from ..database import get_db -from ..models import ( - User, Channel, ChannelMember, ChannelMessage, - gen_uuid -) -from ..auth import get_current_user -from ..platform_adapter import get_platform_adapter, list_platforms, platform_cls_map, platform_registry -from ..message_session import MessageSession, SessionType - -logger = logging.getLogger("aiflow") -router = APIRouter() - -# ============================================================ -# 平台接入配置 - 按 QwenPaw 标准定义 -# 每个平台包含:凭证字段、AI行为开关、IM交互策略 -# ============================================================ - -# 公共AI行为配置字段 -COMMON_AI_SETTINGS = { - "bot_prefix": { - "key": "bot_prefix", "label": "Bot Prefix", "type": "text", - "default": "@bot", "placeholder": "@bot", - "tooltip": "唤醒机器人的前缀命令,用户消息以此为前缀时触发AI处理" - }, - "show_tool_msg": { - "key": "show_tool_msg", "label": "显示工具消息", "type": "switch", - "default": True, - "tooltip": "是否在对话中显示AI调用工具(Function Call/MCP)时的中间消息" - }, - "show_thinking": { - "key": "show_thinking", "label": "显示思考过程", "type": "switch", - "default": False, - "tooltip": "是否显示AI推理过程(如Chain-of-Thought),开启后会展示思考链" - }, - "stream_output": { - "key": "stream_output", "label": "流式输出", "type": "switch", - "default": True, - "tooltip": "AI回复是否以流式(逐字)方式推送到IM平台" - }, - "private_chat_strategy": { - "key": "private_chat_strategy", "label": "私聊策略", "type": "select", - "options": ["开放", "白名单", "关闭"], - "default": "开放", - "tooltip": "私聊场景下的AI响应策略:开放=所有私聊均可触发AI,白名单=仅白名单用户可触发,关闭=不响应私聊" - }, - "group_chat_strategy": { - "key": "group_chat_strategy", "label": "群聊策略", "type": "select", - "options": ["开放", "需要@提及", "白名单", "关闭"], - "default": "开放", - "tooltip": "群聊场景下的AI响应策略:开放=群中所有消息均触发,需要@提及=仅被@Bot的消息触发" - }, - "require_mention": { - "key": "require_mention", "label": "需要@提及", "type": "switch", - "default": True, - "tooltip": "群聊中是否需要@机器人才会触发AI响应" - }, - "whitelist_users": { - "key": "whitelist_users", "label": "白名单用户", "type": "tag_input", - "default": [], - "tooltip": "白名单策略下的用户ID列表,输入用户ID后按回车添加" - }, - "media_dir": { - "key": "media_dir", "label": "媒体文件目录", "type": "text", - "default": "/app/working/workspaces/default/media", - "placeholder": "/app/working/workspaces/default/media", - "tooltip": "IM端传输的媒体文件(图片/视频/音频)在服务端的存储目录" - }, -} - -# 飞书频道配置 -PLATFORM_FEISHU = { - "name": "飞书", - "description": "通过飞书开放平台应用接入,支持飞书国内/海外版本,可扫码自动创建飞书Bot", - "doc_url": "https://open.feishu.cn/document/home/index", - "enable_region": True, # 支持地区选择 - "regions": ["飞书国内", "飞书海外"], - "qr_config": { - "title": "扫码创建飞书机器人", - "description": "打开飞书开发者后台,使用飞书管理员账号扫码授权,系统将自动创建飞书应用并获取 App ID 和 App Secret。请将获取到的凭证填入下方表单。", - "qr_url": "https://open.feishu.cn/app", # 飞书开放平台 - "action_label": "打开飞书开发者后台", - "flow_hint": "扫码后请将获取的 App ID 和 App Secret 填入以下表单完成绑定" - }, - "credential_fields": [ - {"key": "app_id", "label": "App ID", "type": "text", "required": True, - "placeholder": "从飞书开放平台获取应用App ID", "tooltip": "飞书应用的唯一标识,在开发者后台-凭证与基础信息中获取"}, - {"key": "app_secret", "label": "App Secret", "type": "password", "required": True, - "placeholder": "从飞书开放平台获取应用App Secret", "tooltip": "飞书应用的密钥,需保密存储。在开发者后台-凭证与基础信息中获取"}, - {"key": "encrypt_key", "label": "Encrypt Key", "type": "password", "required": False, - "placeholder": "飞书事件订阅的Encrypt Key(选填)", "tooltip": "事件订阅的加密密钥,配置了事件订阅时需要填写"}, - {"key": "verification_token", "label": "Verification Token", "type": "password", "required": False, - "placeholder": "飞书事件订阅的Verification Token(选填)", "tooltip": "事件订阅的验证Token,配置了事件订阅时需要填写"}, - ], - "ai_settings": COMMON_AI_SETTINGS, -} - -# 微信开放平台配置(需要用户提供微信开放平台AppID和AppSecret) -PLATFORM_WECHAT = { - "name": "微信", - "description": "通过微信ClawBot官方API接入,扫描二维码授权后AI机器人将成为您的微信联系人,可通过微信个人号与AI对话。", - "doc_url": "https://developers.weixin.qq.com/doc/aispeech/knowledge/openapi/Clawbotrelated.html", - "qr_config": { - "title": "扫码绑定微信个人号", - "description": "调用微信ClawBot官方API生成授权二维码,使用微信扫码后确认授权,即可将AI机器人绑定到您的微信个人号。", - "qr_url": "", # 动态生成 - 调用ClawBot API获取 - "action_label": "生成绑定二维码", - "flow_hint": "点击下方按钮调用微信ClawBot API生成授权二维码,使用微信扫码后确认授权", - "config_guide": False - }, - "platform_tips": [ - {"type": "info", "content": "需要先在微信开放平台(open.weixin.qq.com)注册网站应用并获取AppID和AppSecret,点击下方按钮生成授权二维码,用微信扫码完成授权"}, - {"type": "warning", "content": "微信个人号每条消息最多回复10条内容,建议关闭「显示工具消息」和「显示思考过程」"}, - ], - "credential_fields": [ - {"key": "appid", "label": "AppID", "type": "text", "required": True, - "placeholder": "微信开放平台网站应用的AppID", "tooltip": "在微信开放平台-管理中心-网站应用详情页获取"}, - {"key": "appsecret", "label": "AppSecret", "type": "password", "required": True, - "placeholder": "微信开放平台网站应用的AppSecret", "tooltip": "在微信开放平台-管理中心-网站应用详情页获取,需保密存储"}, - {"key": "bot_token", "label": "Bot Token", "type": "password", "required": False, - "placeholder": "微信机器人Token(选填)", "tooltip": "如已获取Bot Token可填入,否则可留空"}, - ], - "extra_settings": { - "message_merge": { - "key": "message_merge", "label": "消息合并", "type": "switch", - "default": False, - "tooltip": "将多条回复合并为一条消息发送(可突破单条10条回复限制)" - }, - "merge_delay": { - "key": "merge_delay", "label": "合并延时(毫秒)", "type": "number", - "default": 500, "min": 100, "max": 5000, - "show_if": {"message_merge": True}, - "tooltip": "合并窗口等待时长" - }, - }, - "ai_settings": { - **COMMON_AI_SETTINGS, - "show_tool_msg": {**COMMON_AI_SETTINGS["show_tool_msg"], "default": False}, - "show_thinking": {**COMMON_AI_SETTINGS["show_thinking"], "default": False}, - }, -} - -# 钉钉配置 -PLATFORM_DINGTALK = { - "name": "钉钉", - "description": "钉钉机器人接入,通过钉钉开放平台创建企业内部应用或自定义机器人", - "doc_url": "https://open.dingtalk.com/document/", - "qr_config": { - "title": "扫码登录钉钉开发者后台", - "description": "使用钉钉管理员账号扫码登录钉钉开放平台,创建企业内部应用获取 AppKey 和 AppSecret。", - "qr_url": "https://open-dev.dingtalk.com/", - "action_label": "打开钉钉开放平台", - "flow_hint": "扫码后在开发者后台创建应用,将获取的凭证填入下方表单" - }, - "credential_fields": [ - {"key": "app_key", "label": "AppKey", "type": "text", "required": True, - "placeholder": "钉钉开放平台应用的AppKey", "tooltip": "钉钉应用的唯一标识,在钉钉开发者后台获取"}, - {"key": "app_secret", "label": "AppSecret", "type": "password", "required": True, - "placeholder": "钉钉开放平台应用的AppSecret", "tooltip": "钉钉应用的密钥"}, - {"key": "webhook_url", "label": "Webhook URL", "type": "text", "required": False, - "placeholder": "钉钉群机器人的Webhook地址(选填)", "tooltip": "如果使用自定义机器人方式接入,填写Webhook地址"}, - ], - "ai_settings": COMMON_AI_SETTINGS, -} - -# WhatsApp配置 -PLATFORM_WHATSAPP = { - "name": "WhatsApp", - "description": "Meta WhatsApp Business Platform API接入,需先配置Meta开发者应用并关联WhatsApp Business号码", - "doc_url": "https://developers.facebook.com/docs/whatsapp/cloud-api", - "credential_fields": [ - {"key": "phone_number_id", "label": "Phone Number ID", "type": "text", "required": True, - "placeholder": "WhatsApp Business号码ID", "tooltip": "在Meta开发者后台 WhatsApp-配置 中获取"}, - {"key": "access_token", "label": "Access Token", "type": "password", "required": True, - "placeholder": "Meta Business API的Access Token", "tooltip": "长期访问令牌,在Meta开发者后台生成"}, - {"key": "verify_token", "label": "Verify Token", "type": "text", "required": False, - "placeholder": "Webhook验证Token(选填)", "tooltip": "自定义的Webhook验证字符串"}, - ], - "ai_settings": COMMON_AI_SETTINGS, -} - -# Telegram配置 -PLATFORM_TELEGRAM = { - "name": "Telegram", - "description": "Telegram Bot API接入,通过@BotFather创建Bot获取Token", - "doc_url": "https://core.telegram.org/bots/api", - "credential_fields": [ - {"key": "bot_token", "label": "Bot Token", "type": "password", "required": True, - "placeholder": "从@BotFather获取的Bot Token", "tooltip": "通过Telegram @BotFather 创建机器人后获取的Token"}, - ], - "ai_settings": COMMON_AI_SETTINGS, -} - -# Slack配置 -PLATFORM_SLACK = { - "name": "Slack", - "description": "Slack App接入,通过Slack API创建Bot并配置OAuth权限", - "doc_url": "https://api.slack.com/docs", - "credential_fields": [ - {"key": "bot_token", "label": "Bot User OAuth Token", "type": "password", "required": True, - "placeholder": "Slack App的Bot User OAuth Token (xoxb-...)", "tooltip": "在Slack API后台 OAuth & Permissions 中获取"}, - {"key": "app_token", "label": "App-Level Token", "type": "password", "required": False, - "placeholder": "Socket Mode使用的App Token (xapp-...)(选填)", "tooltip": "如使用Socket Mode连接,需要配置App-Level Token"}, - {"key": "signing_secret", "label": "Signing Secret", "type": "password", "required": False, - "placeholder": "Slack签名密钥(选填)", "tooltip": "用于验证Slack请求签名的密钥"}, - ], - "ai_settings": COMMON_AI_SETTINGS, -} - -PLATFORM_CONFIGS = { - "feishu": PLATFORM_FEISHU, - "wechat": PLATFORM_WECHAT, - "dingtalk": PLATFORM_DINGTALK, - "whatsapp": PLATFORM_WHATSAPP, - "telegram": PLATFORM_TELEGRAM, - "slack": PLATFORM_SLACK, -} - -VALID_PLATFORMS = ("feishu", "wechat", "wecom", "dingtalk", "whatsapp", "telegram", "slack") - -SENSITIVE_FIELDS = {"app_secret", "secret", "access_token", "bot_token", "signing_secret", "token", - "api_key", "password", "encrypt_key", "verification_token", "app_token", - "verify_token", "app_key", "encoding_aes_key", "appsecret"} - - -# ============================================================ -# Pydantic Models -# ============================================================ - -class CreateChannelRequest(BaseModel): - name: str = Field(min_length=1, max_length=100) - description: Optional[str] = None - platform: str = Field(default="feishu") - credential: Optional[dict] = None - webhook_url: Optional[str] = None - settings: Optional[dict] = None - region: Optional[str] = None - - -class UpdateChannelRequest(BaseModel): - name: Optional[str] = None - description: Optional[str] = None - credential: Optional[dict] = None - webhook_url: Optional[str] = None - settings: Optional[dict] = None - is_active: Optional[bool] = None - region: Optional[str] = None - - -class WebhookMessageRequest(BaseModel): - from_user: str = Field(min_length=1, max_length=100) - content: str = Field(min_length=1, max_length=10000) - message_type: str = Field(default="text") - - -class SendChannelMessageRequest(BaseModel): - content: str = Field(min_length=1, max_length=10000) - - -class AuthorizeRequest(BaseModel): - platform: str = Field(default="feishu") - - -class InviteMemberRequest(BaseModel): - user_id: str - - -# ============================================================ -# Helper Functions -# ============================================================ - -def _check_channel_access(db: Session, channel_id: str, user_id: str): - member = db.query(ChannelMember).filter( - and_(ChannelMember.channel_id == channel_id, ChannelMember.user_id == user_id) - ).first() - if not member: - raise HTTPException(status_code=403, detail="您不是该频道成员") - return member - - -def _check_channel_owner(db: Session, channel_id: str, user_id: str): - member = db.query(ChannelMember).filter( - and_(ChannelMember.channel_id == channel_id, ChannelMember.user_id == user_id) - ).first() - if not member or member.role != "owner": - raise HTTPException(status_code=403, detail="仅频道所有者可执行此操作") - return member - - -def _mask_credential(credential: dict) -> dict: - """对凭证中的敏感字段进行脱敏处理""" - if not credential: - return {} - masked = {} - for key, value in credential.items(): - value_str = str(value) if value else "" - if key in SENSITIVE_FIELDS and len(value_str) > 8: - masked[key] = value_str[:4] + "***...***" + value_str[-4:] - elif key in SENSITIVE_FIELDS and len(value_str) > 0: - masked[key] = value_str[:2] + "****" - else: - masked[key] = value - return masked - - -def _verify_credential(platform: str, credential: dict) -> tuple: - """验证平台凭证是否完整""" - if platform not in PLATFORM_CONFIGS: - return False, f"不支持的平台类型: {platform}" - - config = PLATFORM_CONFIGS[platform] - required_fields = [f for f in config["credential_fields"] if f.get("required")] - - for field in required_fields: - if not credential or field["key"] not in credential or not credential[field["key"]]: - return False, f"缺少必填字段: {field['label']} ({field['key']})" - - return True, "凭证验证通过" - - -def _get_default_settings(platform: str) -> dict: - """获取平台默认的AI行为设置""" - if platform not in PLATFORM_CONFIGS: - return {} - ai_settings = PLATFORM_CONFIGS[platform].get("ai_settings", {}) - defaults = {} - for key, cfg in ai_settings.items(): - if cfg.get("type") == "switch": - defaults[key] = cfg.get("default", False) - elif cfg.get("type") == "select": - defaults[key] = cfg.get("default", "") - elif cfg.get("type") == "tag_input": - defaults[key] = cfg.get("default", []) - elif cfg.get("type") == "text": - defaults[key] = cfg.get("default", "") - elif cfg.get("type") == "number": - defaults[key] = cfg.get("default", 0) - return defaults - - -def _build_channel_response(channel: Channel, db: Session, user_id: str, include_credential: bool = False) -> dict: - """构建频道响应数据""" - member = db.query(ChannelMember).filter( - and_(ChannelMember.channel_id == channel.id, ChannelMember.user_id == user_id) - ).first() - - member_count = db.query(ChannelMember).filter(ChannelMember.channel_id == channel.id).count() - message_count = db.query(ChannelMessage).filter(ChannelMessage.channel_id == channel.id).count() - - data = { - "id": channel.id, - "name": channel.name, - "description": channel.description, - "platform": channel.platform, - "status": channel.status, - "is_active": channel.is_active, - "webhook_url": channel.webhook_url, - "role": member.role if member else None, - "member_count": member_count, - "message_count": message_count, - "default_agent_id": channel.default_agent_id, - "default_skill_id": channel.default_skill_id, - "knowledge_base_id": channel.knowledge_base_id, - "bound_model_id": channel.bound_model_id, - "settings": channel.settings, - "last_active": str(channel.last_active) if channel.last_active else None, - "created_at": str(channel.created_at) if channel.created_at else None, - "updated_at": str(channel.updated_at) if channel.updated_at else None, - } - - if include_credential: - data["credential"] = channel.credential - data["credential_masked"] = _mask_credential(channel.credential) if channel.credential else None - else: - data["credential_masked"] = _mask_credential(channel.credential) if channel.credential else None - - return data - - -# ============================================================ -# API Endpoints -# ============================================================ - -# ---- 平台配置 ---- - -@router.get("/platforms/config") -def get_platform_configs(): - """获取各平台完整接入配置(QwenPaw标准),含凭证字段+AI行为配置+平台提示""" - result = {} - for platform_key, config in PLATFORM_CONFIGS.items(): - result[platform_key] = { - "name": config["name"], - "description": config["description"], - "doc_url": config["doc_url"], - "credential_fields": config["credential_fields"], - "ai_settings": config.get("ai_settings", {}), - "extra_settings": config.get("extra_settings", {}), - "platform_tips": config.get("platform_tips", []), - "enable_region": config.get("enable_region", False), - "regions": config.get("regions", []), - "qr_config": config.get("qr_config"), # QR scan config for WeChat/Feishu/DingTalk - } - return {"code": 0, "data": result, "message": "ok"} - - -# ---- 扫码授权 ---- - -@router.post("/authorize/scan") -def scan_authorize( - platform: str = Query(...), - body: dict = Body({}), - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """扫码授权流程 - 返回授权二维码(微信平台统一调用ClawBot API)""" - if platform not in VALID_PLATFORMS: - raise HTTPException(status_code=400, detail="不支持的平台类型") - - platform_name = PLATFORM_CONFIGS.get(platform, {}).get("name", platform) - - # 微信平台:调用微信ClawBot API获取授权二维码 - if platform == "wechat": - # 获取AppID和Secret(优先从请求体 > 已保存频道配置 > 环境变量) - appid = "" - secret = "" - - # 1. 从请求体获取 - req_creds = body.get("credentials", {}) or {} - appid = req_creds.get("appid", "") - secret = req_creds.get("appsecret", "") or req_creds.get("secret", "") - - # 查找已保存的微信频道 - existing_channel = db.query(Channel).filter( - and_(Channel.platform == "wechat", Channel.owner_id == current_user.id) - ).first() - - # 2. 从已保存的频道配置获取 - if not appid or not secret: - if existing_channel and existing_channel.credential: - creds = existing_channel.credential - if isinstance(creds, str): - creds = json.loads(creds) - if not appid: - appid = creds.get("appid", "") - if not secret: - secret = creds.get("appsecret", "") - - # 3. 从环境变量获取兜底 - if not appid: - appid = os.environ.get("WECHAT_APPID", "") - if not secret: - secret = os.environ.get("WECHAT_SECRET", "") - - # 没有凭证时直接返回错误,不回退到本地QR - if not appid or not secret: - print(f"[WARN] WeChat credentials not found: appid={'set' if appid else 'missing'}, secret={'set' if secret else 'missing'}") - return { - "code": 1, - "data": None, - "message": "缺少微信AppID/AppSecret凭证,请在频道设置中填写或通过请求体提供", - } - - # 有凭证 - 调用微信ClawBot API - import hmac - import hashlib - import time as time_module - - clawbot_base = "https://weknora.weixin.qq.com" - request_id = secrets.token_hex(16) - timestamp = str(int(time_module.time())) - nonce = secrets.token_hex(8) - request_body = "{}" - - sign_string = f"{timestamp}\n{nonce}\n{request_id}\n{request_body}" - if isinstance(sign_string, str): - sign_string = sign_string.encode('utf-8') - signature = hmac.new( - secret.encode('utf-8'), - sign_string, - hashlib.sha256 - ).hexdigest() - - headers = { - "Content-Type": "application/json", - "X-APPID": appid, - "X-Request-ID": request_id, - "X-Timestamp": timestamp, - "X-Nonce": nonce, - "X-Signature": signature, - } - - print(f"[INFO] Calling ClawBot API: {clawbot_base}/api/v1/wechat/qrcode with appid={appid[:8]}***") - - try: - import requests as http_requests - resp = http_requests.post( - f"{clawbot_base}/api/v1/wechat/qrcode", - json={}, - headers=headers, - timeout=10, - ) - resp_body = resp.text - print(f"[INFO] ClawBot API response: HTTP {resp.status_code}, body={resp_body[:300]}") - - # 尝试解析JSON - try: - qrcode_data = resp.json() - except ValueError: - print(f"[WARN] ClawBot API returned non-JSON response") - return { - "code": 1, - "data": None, - "message": f"微信ClawBot API返回了非JSON响应: {resp_body[:200]}", - } - - # 微信API成功响应: {"success": true, "data": {"qrcode_url": "...", "qrcode": "..."}} - if resp.status_code == 200 and qrcode_data.get("success") and qrcode_data.get("data"): - qr_url = qrcode_data["data"].get("qrcode_url", "") - qrcode_token = qrcode_data["data"].get("qrcode", "") - - if existing_channel: - existing_channel.oauth_state = qrcode_token - db.commit() - else: - # 如果没有现有频道,生成一个临时token保存在响应中 - print(f"[INFO] No existing channel found, returning qrcode token directly") - - print(f"[INFO] ClawBot real QR obtained: token={qrcode_token}") - return { - "code": 0, - "data": { - "platform": platform, - "platform_name": platform_name, - "qr_url": qr_url, - "qrcode": qrcode_token, - "qrcode_token": qrcode_token, - "status": "waiting", - "is_mock": False, - "message": "请使用微信扫描二维码完成授权", - "expires_in": 300, - }, - "message": "ok", - } - - # ClawBot API返回了错误 - error_msg = "" - if qrcode_data.get("error"): - err = qrcode_data["error"] - error_msg = err.get("message", "") or err.get("detail", "") or str(err) - elif qrcode_data.get("message"): - error_msg = qrcode_data["message"] - print(f"[WARN] ClawBot API returned error (HTTP {resp.status_code}): {error_msg}") - return { - "code": 1, - "data": {"error": error_msg or f"HTTP {resp.status_code}"}, - "message": f"微信ClawBot API调用失败: {error_msg or '未知错误'}", - } - - except Exception as e: - print(f"[ERROR] ClawBot API call exception: {e}") - import traceback - traceback.print_exc() - return { - "code": 1, - "data": {"error": str(e)}, - "message": f"微信ClawBot API调用异常: {str(e)}", - } - - # 其他平台:返回模拟授权数据 - binding_token = str(uuid.uuid4()) - - # 查找已有频道并关联binding_token - existing_channel = db.query(Channel).filter( - and_(Channel.platform == platform, Channel.owner_id == current_user.id) - ).first() - if existing_channel: - existing_channel.oauth_state = binding_token - db.commit() - - return { - "code": 0, - "data": { - "platform": platform, - "platform_name": platform_name, - "qr_url": "", - "qrcode": binding_token, - "status": "waiting", - "message": f"请在{platform_name}客户端完成授权", - "expires_in": 300, - }, - "message": "ok", - } - - -@router.post("/authorize/scan/status") -def scan_authorize_status( - platform: str = Query(...), - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """查询扫码授权状态(微信平台调用ClawBot API轮询)""" - if platform not in VALID_PLATFORMS: - raise HTTPException(status_code=400, detail="不支持的平台类型") - - # 微信平台:调用ClawBot API轮询扫码状态 - if platform == "wechat": - channel = db.query(Channel).filter( - and_(Channel.platform == "wechat", Channel.owner_id == current_user.id) - ).first() - - qrcode_token = channel.oauth_state if channel else None - - if not qrcode_token: - return { - "code": 0, - "data": {"platform": platform, "status": "expired"}, - "message": "二维码已过期,请重新生成", - } - - # 获取AppID和Secret用于签名 - appid = os.environ.get("WECHAT_APPID", "") - secret = os.environ.get("WECHAT_SECRET", "") - if channel and channel.credential: - creds = channel.credential - if isinstance(creds, str): - creds = json.loads(creds) - appid = creds.get("appid", appid) - secret = creds.get("appsecret", secret) - - import hmac - import hashlib - import time as time_module - - clawbot_base = "https://weknora.weixin.qq.com" - request_id = secrets.token_hex(16) - timestamp = str(int(time_module.time())) - nonce = secrets.token_hex(8) - request_body = json.dumps({"qrcode": qrcode_token}) - - sign_string = f"{timestamp}\n{nonce}\n{request_id}\n{request_body}" - if isinstance(sign_string, str): - sign_string = sign_string.encode('utf-8') - signature = hmac.new( - secret.encode('utf-8'), - sign_string, - hashlib.sha256 - ).hexdigest() - - headers = { - "Content-Type": "application/json", - "X-APPID": appid, - "X-Request-ID": request_id, - "X-Timestamp": timestamp, - "X-Nonce": nonce, - "X-Signature": signature, - } - - try: - import requests as http_requests - resp = http_requests.post( - f"{clawbot_base}/api/v1/wechat/qrcode/status", - json={"qrcode": qrcode_token}, - headers=headers, - timeout=30, - ) - status_data = resp.json() - print(f"[INFO] ClawBot QR status: {json.dumps(status_data, ensure_ascii=False)[:300]}") - - if status_data.get("success") and status_data.get("data"): - data = status_data["data"] - status = data.get("status", "wait") - - if status == "confirmed": - # 保存授权凭据 - credentials = data.get("credentials", {}) - baseurl = data.get("baseurl", clawbot_base) - if channel: - creds_store = channel.credential or {} - if isinstance(creds_store, str): - creds_store = json.loads(creds_store) - creds_store["bot_token"] = credentials.get("bot_token", "") - creds_store["ilink_bot_id"] = credentials.get("ilink_bot_id", "") - creds_store["ilink_user_id"] = credentials.get("ilink_user_id", "") - creds_store["baseurl"] = baseurl - creds_store["authorized_at"] = datetime.now().isoformat() - channel.credential = creds_store - channel.status = "connected" - channel.oauth_state = "" - db.commit() - print(f"[INFO] WeChat channel connected: channel_id={channel.id}") - - return { - "code": 0, - "data": { - "platform": platform, - "status": "authorized", - "credentials": credentials, - "baseurl": baseurl, - }, - "message": "授权成功!微信个人号已绑定", - } - else: - return { - "code": 0, - "data": {"platform": platform, "status": status}, - "message": f"状态: {status}", - } - else: - print(f"[WARN] ClawBot QR status API returned unexpected response: {json.dumps(status_data, ensure_ascii=False)[:200]}") - return { - "code": 0, - "data": {"platform": platform, "status": "error"}, - "message": status_data.get("message", "查询状态失败"), - } - except Exception as e: - print(f"[ERROR] ClawBot status check failed: {e}") - import traceback - traceback.print_exc() - return { - "code": 1, - "data": {"platform": platform, "status": "error"}, - "message": f"微信ClawBot状态查询异常: {str(e)}", - } - - # 其他平台:返回模拟授权状态 - access_token = f"sim_{platform}_{secrets.token_hex(16)}" - return { - "code": 0, - "data": { - "platform": platform, - "status": "authorized", - "access_token": access_token, - "credential": _generate_mock_credential(platform), - }, - "message": "授权成功", - } - - -def _generate_mock_credential(platform: str) -> dict: - """根据平台生成模拟凭证""" - mock_map = { - "feishu": {"app_id": "cli_a" + secrets.token_hex(8), "app_secret": secrets.token_hex(16)}, - "wechat": {"appid": "wx" + secrets.token_hex(8), "appsecret": secrets.token_hex(16)}, - "dingtalk": {"app_key": "ding" + secrets.token_hex(8), "app_secret": secrets.token_hex(16)}, - "whatsapp": {"phone_number_id": "10" + "".join([str(secrets.randbelow(10)) for _ in range(13)]), - "access_token": "EAA" + secrets.token_hex(32)}, - "telegram": {"bot_token": f"{secrets.randbelow(10**9)}:AA" + secrets.token_hex(16)}, - "slack": {"bot_token": "xoxb-" + secrets.token_hex(20)}, - } - return mock_map.get(platform, {"token": secrets.token_hex(16)}) - - -# ---- 频道列表 ---- - -@router.get("/") -def list_channels( - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """获取用户所在的所有频道""" - memberships = db.query(ChannelMember).filter( - ChannelMember.user_id == current_user.id - ).all() - - channel_ids = [m.channel_id for m in memberships] - if not channel_ids: - return {"code": 0, "data": [], "message": "ok"} - - channels = db.query(Channel).filter(Channel.id.in_(channel_ids)).order_by(desc(Channel.updated_at)).all() - - result = [] - for ch in channels: - member = next((m for m in memberships if m.channel_id == ch.id), None) - member_count = db.query(ChannelMember).filter(ChannelMember.channel_id == ch.id).count() - message_count = db.query(ChannelMessage).filter(ChannelMessage.channel_id == ch.id).count() - result.append({ - "id": ch.id, - "name": ch.name, - "description": ch.description, - "platform": ch.platform, - "status": ch.status, - "is_active": ch.is_active, - "role": member.role if member else None, - "member_count": member_count, - "message_count": message_count, - "credential_masked": _mask_credential(ch.credential) if ch.credential else None, - "created_at": str(ch.created_at) if ch.created_at else None, - "updated_at": str(ch.updated_at) if ch.updated_at else None, - }) - - return {"code": 0, "data": result, "message": "ok"} - - -# ---- 创建频道 (静态路由必须在动态路由之前) ---- - -@router.post("/") -def create_channel( - req: CreateChannelRequest, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """创建新频道,支持QwenPaw标准配置""" - if req.platform not in VALID_PLATFORMS: - raise HTTPException(status_code=400, detail="不支持的平台类型") - - # 构建默认 settings(AI行为配置) - default_ai_settings = _get_default_settings(req.platform) - merged_settings = {**default_ai_settings, **(req.settings or {})} - - if req.region: - merged_settings["region"] = req.region - - channel = Channel( - id=gen_uuid(), - owner_id=current_user.id, - name=req.name, - description=req.description, - platform=req.platform, - webhook_url=req.webhook_url, - credential=req.credential, - settings=merged_settings, - status="pending", - is_active=True, - created_at=datetime.now(), - updated_at=datetime.now(), - ) - db.add(channel) - db.flush() - - member = ChannelMember( - id=gen_uuid(), - channel_id=channel.id, - user_id=current_user.id, - role="owner", - joined_at=datetime.now(), - ) - db.add(member) - - db.commit() - db.refresh(channel) - - return { - "code": 0, - "data": _build_channel_response(channel, db, current_user.id, include_credential=True), - "message": "频道创建成功", - } - - -# ---- 频道详情 ---- - -@router.get("/{channel_id}") -def get_channel( - channel_id: str, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """获取频道详情""" - _check_channel_access(db, channel_id, current_user.id) - channel = db.query(Channel).filter(Channel.id == channel_id).first() - if not channel: - raise HTTPException(status_code=404, detail="频道不存在") - - return { - "code": 0, - "data": _build_channel_response(channel, db, current_user.id, include_credential=True), - "message": "ok", - } - - -# ---- 更新频道配置 ---- - -@router.put("/{channel_id}") -def update_channel( - channel_id: str, - req: UpdateChannelRequest, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """更新频道配置,支持完整QwenPaw标准settings""" - _check_channel_owner(db, channel_id, current_user.id) - channel = db.query(Channel).filter(Channel.id == channel_id).first() - if not channel: - raise HTTPException(status_code=404, detail="频道不存在") - - if req.name is not None: - channel.name = req.name - if req.description is not None: - channel.description = req.description - if req.webhook_url is not None: - channel.webhook_url = req.webhook_url - if req.region is not None: - existing_settings = channel.settings or {} - existing_settings["region"] = req.region - channel.settings = existing_settings - if req.credential is not None: - is_valid, err_msg = _verify_credential(channel.platform, req.credential) - if not is_valid: - raise HTTPException(status_code=400, detail=err_msg) - channel.credential = req.credential - if req.settings is not None: - # 合并 settings(保留未提交的配置项) - existing = channel.settings or {} - channel.settings = {**existing, **req.settings} - settings = channel.settings - if "agent_id" in settings: - channel.default_agent_id = settings["agent_id"] - if "knowledge_base_id" in settings: - channel.knowledge_base_id = settings["knowledge_base_id"] - if "skill_ids" in settings: - skill_ids = settings["skill_ids"] - if isinstance(skill_ids, list) and len(skill_ids) > 0: - channel.default_skill_id = skill_ids[0] - if req.is_active is not None: - channel.is_active = req.is_active - - channel.updated_at = datetime.now() - - db.commit() - db.refresh(channel) - - return { - "code": 0, - "data": _build_channel_response(channel, db, current_user.id, include_credential=True), - "message": "更新成功", - } - - -# ---- 删除频道 ---- - -@router.delete("/{channel_id}") -def delete_channel( - channel_id: str, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """删除频道及其关联数据""" - _check_channel_owner(db, channel_id, current_user.id) - channel = db.query(Channel).filter(Channel.id == channel_id).first() - if not channel: - raise HTTPException(status_code=404, detail="频道不存在") - - db.query(ChannelMessage).filter(ChannelMessage.channel_id == channel_id).delete() - db.query(ChannelMember).filter(ChannelMember.channel_id == channel_id).delete() - db.delete(channel) - db.commit() - - return {"code": 0, "message": "频道已删除"} - - -# ---- 连接/断开频道 ---- - -@router.post("/{channel_id}/connect") -def connect_channel( - channel_id: str, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """连接频道""" - _check_channel_owner(db, channel_id, current_user.id) - channel = db.query(Channel).filter(Channel.id == channel_id).first() - if not channel: - raise HTTPException(status_code=404, detail="频道不存在") - - platform = channel.platform - if platform not in PLATFORM_CONFIGS: - raise HTTPException(status_code=400, detail=f"不支持的平台类型: {platform}") - - if not channel.credential: - raise HTTPException(status_code=400, detail="尚未配置平台凭证,请先填写接入配置") - - is_valid, err_msg = _verify_credential(platform, channel.credential) - if not is_valid: - raise HTTPException(status_code=400, detail=f"凭证配置不完整: {err_msg}") - - channel.status = "connected" - channel.is_active = True - channel.last_active = datetime.now() - channel.updated_at = datetime.now() - db.commit() - db.refresh(channel) - - platform_name = PLATFORM_CONFIGS[platform]["name"] - return { - "code": 0, - "data": { - "connected": True, - "platform": platform, - "platform_name": platform_name, - "status": "connected", - "message": f"{platform_name}平台连接成功,AI机器人已在线", - "connected_at": str(datetime.now()), - }, - "message": "连接成功", - } - - -@router.post("/{channel_id}/disconnect") -def disconnect_channel( - channel_id: str, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """断开频道连接""" - _check_channel_owner(db, channel_id, current_user.id) - channel = db.query(Channel).filter(Channel.id == channel_id).first() - if not channel: - raise HTTPException(status_code=404, detail="频道不存在") - - channel.status = "disconnected" - channel.updated_at = datetime.now() - db.commit() - db.refresh(channel) - - return { - "code": 0, - "data": {"id": channel.id, "status": channel.status, "message": "频道已断开连接"}, - "message": "已断开连接", - } - - -# ---- Webhook 模拟消息 ---- - -@router.post("/{channel_id}/webhook") -def webhook_receive_message( - channel_id: str, - req: WebhookMessageRequest, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """模拟IM平台消息接收""" - _check_channel_access(db, channel_id, current_user.id) - channel = db.query(Channel).filter(Channel.id == channel_id).first() - if not channel: - raise HTTPException(status_code=404, detail="频道不存在") - - if channel.status != "connected": - raise HTTPException(status_code=400, detail="频道未连接,无法接收消息") - - user_msg = ChannelMessage( - id=gen_uuid(), - channel_id=channel_id, - user_id=current_user.id, - role="user", - content=f"[{req.from_user}] {req.content}", - created_at=datetime.now(), - ) - db.add(user_msg) - - platform_name = PLATFORM_CONFIGS.get(channel.platform, {}).get("name", "IM平台") - ai_reply = _generate_ai_reply(req.content, channel, platform_name, current_user) - - ai_msg = ChannelMessage( - id=gen_uuid(), - channel_id=channel_id, - user_id=current_user.id, - role="assistant", - content=ai_reply, - token_used=len(ai_reply), - created_at=datetime.now(), - ) - db.add(ai_msg) - - channel.last_active = datetime.now() - channel.updated_at = datetime.now() - - db.commit() - db.refresh(user_msg) - db.refresh(ai_msg) - - return { - "code": 0, - "data": { - "received": { - "id": user_msg.id, "from_user": req.from_user, "content": req.content, - "message_type": req.message_type, "created_at": str(user_msg.created_at), - }, - "reply": { - "id": ai_msg.id, "content": ai_reply, "role": "assistant", - "created_at": str(ai_msg.created_at), - }, - }, - "message": "消息已处理", - } - - -def _generate_ai_reply(user_content: str, channel: Channel, platform_name: str, user: User) -> str: - """生成模拟AI回复消息""" - content_lower = user_content.lower().strip() - - greetings = ["你好", "hi", "hello", "嗨", "早上好", "下午好", "晚上好"] - for g in greetings: - if g in content_lower: - return f"你好!我是通过{platform_name}接入的AI助手{channel.name}。我可以帮你解答问题、查询数据、执行任务等,请随时告诉我你的需求。" - - if "天气" in content_lower: - return "根据最新气象数据,今天天气晴好,气温22-30℃,适合外出活动。如需详细天气预报,请告知具体城市。" - if "销售" in content_lower or "数据" in content_lower: - return "已查询今日数据。截至当前,GMV为12.8万元,订单量326单,环比昨日同期增长8.5%。详细报表已整理完成,可随时导出。" - if "任务" in content_lower or "待办" in content_lower: - return "你当前有3项待办任务:\n1. 审核市场推广方案(截止今日18:00)\n2. 周报提交(截止明日10:00)\n3. 团队例会准备(截止本周五)\n需要我帮你处理哪一项?" - if "帮助" in content_lower or "help" in content_lower or "能做什么" in content_lower: - return f"我是通过{platform_name}接入的AI智能助手,可以为你提供以下服务:\n- 数据查询与分析\n- 任务管理与提醒\n- 文档撰写与总结\n- 智能问答与信息检索\n- 图表生成与报表导出\n请直接告诉我你的需求。" - if "谢谢" in content_lower or "感谢" in content_lower: - return "不客气!很高兴能帮到你。如有其他需要,随时找我。" - - content_preview = user_content[:50] + ("..." if len(user_content) > 50 else "") - return f"收到你的消息:{content_preview}。\n\n我是通过{platform_name}接入的AI助手,正在为你处理这个请求。请稍候,我会尽快给出回复。如果需要更具体的帮助,请详细描述你的需求。" - - -# ---- 频道消息 ---- - -@router.get("/{channel_id}/messages") -def get_channel_messages( - channel_id: str, - limit: int = Query(default=50, ge=1, le=200), - offset: int = Query(default=0, ge=0), - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """获取频道消息列表""" - _check_channel_access(db, channel_id, current_user.id) - - messages = ( - db.query(ChannelMessage) - .filter(ChannelMessage.channel_id == channel_id) - .order_by(desc(ChannelMessage.created_at)) - .offset(offset) - .limit(limit) - .all() - ) - - result = [] - for msg in messages: - sender = db.query(User).filter(User.id == msg.user_id).first() - result.append({ - "id": msg.id, "channel_id": msg.channel_id, "user_id": msg.user_id, - "username": sender.username if sender else "unknown", - "avatar": sender.avatar if sender else None, - "role": msg.role, "content": msg.content, - "token_used": msg.token_used, - "created_at": str(msg.created_at) if msg.created_at else None, - }) - - return {"code": 0, "data": result, "message": "ok"} - - -@router.post("/{channel_id}/messages") -def send_channel_message( - channel_id: str, - req: SendChannelMessageRequest, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """用户手动发送消息""" - _check_channel_access(db, channel_id, current_user.id) - channel = db.query(Channel).filter(Channel.id == channel_id).first() - if not channel: - raise HTTPException(status_code=404, detail="频道不存在") - - msg = ChannelMessage( - id=gen_uuid(), channel_id=channel_id, user_id=current_user.id, - role="user", content=req.content, created_at=datetime.now(), - ) - db.add(msg) - - platform_name = PLATFORM_CONFIGS.get(channel.platform, {}).get("name", "IM平台") - ai_reply = _generate_ai_reply(req.content, channel, platform_name, current_user) - ai_msg = ChannelMessage( - id=gen_uuid(), channel_id=channel_id, user_id=current_user.id, - role="assistant", content=ai_reply, token_used=len(ai_reply), - created_at=datetime.now(), - ) - db.add(ai_msg) - - channel.last_active = datetime.now() - channel.updated_at = datetime.now() - - db.commit() - db.refresh(msg) - db.refresh(ai_msg) - - return { - "code": 0, - "data": { - "message": {"id": msg.id, "channel_id": msg.channel_id, "user_id": msg.user_id, - "content": msg.content, "created_at": str(msg.created_at)}, - "reply": {"id": ai_msg.id, "content": ai_reply, "role": "assistant", - "created_at": str(ai_msg.created_at)}, - "webhook_response": {"status": "delivered", "platform": channel.platform, - "message": f"消息已通过{platform_name}通道成功推送"}, - }, - "message": "发送成功", - } - - -# ---- 频道成员管理 ---- - -@router.get("/{channel_id}/members") -def get_channel_members( - channel_id: str, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """获取频道成员列表""" - _check_channel_access(db, channel_id, current_user.id) - - members = db.query(ChannelMember).filter(ChannelMember.channel_id == channel_id).all() - - result = [] - for m in members: - user = db.query(User).filter(User.id == m.user_id).first() - result.append({ - "id": m.id, "user_id": m.user_id, - "username": user.username if user else "unknown", - "avatar": user.avatar if user else None, - "role": m.role, - "joined_at": str(m.joined_at) if m.joined_at else None, - }) - - return {"code": 0, "data": result, "message": "ok"} - - -@router.post("/{channel_id}/members") -def invite_member( - channel_id: str, - req: InviteMemberRequest, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """邀请成员加入频道""" - _check_channel_owner(db, channel_id, current_user.id) - - channel = db.query(Channel).filter(Channel.id == channel_id).first() - if not channel: - raise HTTPException(status_code=404, detail="频道不存在") - - target_user = db.query(User).filter(User.id == req.user_id).first() - if not target_user: - raise HTTPException(status_code=404, detail="用户不存在") - - existing = db.query(ChannelMember).filter( - and_(ChannelMember.channel_id == channel_id, ChannelMember.user_id == req.user_id) - ).first() - if existing: - raise HTTPException(status_code=400, detail="该用户已是频道成员") - - member = ChannelMember( - id=gen_uuid(), channel_id=channel_id, user_id=req.user_id, - role="member", joined_at=datetime.now(), - ) - db.add(member) - channel.updated_at = datetime.now() - db.commit() - db.refresh(member) - - return {"code": 0, "data": { - "id": member.id, "user_id": member.user_id, "role": member.role, - "joined_at": str(member.joined_at), - }, "message": "邀请成功"} - - -@router.delete("/{channel_id}/members/{member_user_id}") -def remove_member( - channel_id: str, - member_user_id: str, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """移除频道成员""" - _check_channel_owner(db, channel_id, current_user.id) - - if member_user_id == current_user.id: - raise HTTPException(status_code=400, detail="不能移除自己,请使用删除频道功能") - - member = db.query(ChannelMember).filter( - and_(ChannelMember.channel_id == channel_id, ChannelMember.user_id == member_user_id) - ).first() - if not member: - raise HTTPException(status_code=404, detail="该用户不在频道中") - - db.delete(member) - - channel = db.query(Channel).filter(Channel.id == channel_id).first() - if channel: - channel.updated_at = datetime.now() - - db.commit() - return {"code": 0, "message": "成员已移除"} - - -# ---- 授权(兼容) ---- - -@router.post("/authorize") -def authorize_platform( - req: AuthorizeRequest, - current_user: User = Depends(get_current_user), -): - """OAuth授权流程(兼容旧接口)""" - if req.platform not in VALID_PLATFORMS: - raise HTTPException(status_code=400, detail="不支持的平台类型") - - access_token = f"sim_{req.platform}_{secrets.token_hex(16)}" - - return { - "code": 0, - "data": { - "authorized": True, - "platform": req.platform, - "access_token": access_token, - }, - "message": "ok", - } - - -# ---- OAuth2回调与频道重置 ---- - -@router.post("/authorize/scan/channel_reset") -def channel_reset( - channel_id: str = Query(...), - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """重置微信IM通道 - 对应ClawBot文档 POST /api/v1/wechat/channel_reset""" - channel = db.query(Channel).filter( - and_(Channel.id == channel_id, Channel.owner_id == current_user.id) - ).first() - if not channel: - raise HTTPException(status_code=404, detail="频道不存在") - - # 微信平台:调用ClawBot API重置通道 - if channel.platform == "wechat": - # 获取AppID和Secret用于签名 - appid = os.environ.get("WECHAT_APPID", "") - secret = os.environ.get("WECHAT_SECRET", "") - if channel.credential: - creds = channel.credential - if isinstance(creds, str): - creds = json.loads(creds) - appid = creds.get("appid", appid) - secret = creds.get("appsecret", secret) - - import hmac - import hashlib - import time as time_module - - clawbot_base = "https://weknora.weixin.qq.com" - request_id = secrets.token_hex(16) - timestamp = str(int(time_module.time())) - nonce = secrets.token_hex(8) - request_body = json.dumps({"channel_id": channel_id}) - - sign_string = f"{timestamp}\n{nonce}\n{request_id}\n{request_body}" - if isinstance(sign_string, str): - sign_string = sign_string.encode('utf-8') - signature = hmac.new( - secret.encode('utf-8'), - sign_string, - hashlib.sha256 - ).hexdigest() - - headers = { - "Content-Type": "application/json", - "X-APPID": appid, - "X-Request-ID": request_id, - "X-Timestamp": timestamp, - "X-Nonce": nonce, - "X-Signature": signature, - } - - print(f"[INFO] Calling ClawBot channel_reset: channel_id={channel_id}") - - try: - import requests as http_requests - resp = http_requests.post( - f"{clawbot_base}/api/v1/wechat/channel_reset", - json={"channel_id": channel_id}, - headers=headers, - timeout=10, - ) - print(f"[INFO] ClawBot channel_reset response: HTTP {resp.status_code}, body={resp.text[:300]}") - except Exception as e: - print(f"[WARN] ClawBot channel_reset API call failed: {e}") - import traceback - traceback.print_exc() - - # 清空本地 channel 的 oauth_state 和 credential - channel.status = "pending" - channel.oauth_state = "" - channel.credential = {} - channel.updated_at = datetime.now() - db.commit() - - return { - "code": 0, - "data": { - "message": "IM通道重置任务已提交", - "channel_id": channel_id, - }, - "message": "通道已重置", - } - - -# ============================================================ -# 统一Webhook端点 - AstrBot借鉴优化方案 P0.2 + P1 -# 所有IM平台消息通过此入口接入,使用平台适配器模式 -# ============================================================ - -@router.post("/webhook/{platform}") -def unified_webhook( - platform: str, - payload: dict = Body(...), - signature: str = Header(None, alias="X-Webhook-Signature"), - timestamp: str = Header(None, alias="X-Webhook-Timestamp"), - db: Session = Depends(get_db), -): - """统一Webhook端点:所有IM平台消息通过此入口接入(使用适配器模式) - - 流程: - 1. 根据platform参数识别平台(使用platform_cls_map注册表) - 2. 查找活跃频道获取配置 - 3. 使用平台适配器验证签名 - 4. 使用平台适配器解析消息为统一格式 - 5. 创建MessageSession和ChannelMessage记录 - 6. 返回标准Webhook响应 - """ - # 使用适配器注册表验证平台 - if platform not in platform_cls_map: - raise HTTPException( - status_code=400, - detail=f"不支持的平台: {platform}. 可用: {list(platform_cls_map.keys())}" - ) - - logger.info(f"[Webhook] 收到 {platform} 平台消息: signature={signature is not None}") - - # URL验证必须在频道存在性检查之前——平台验证URL时与频道是否存在无关 - if platform == "feishu" and payload.get("challenge"): - logger.info(f"[Webhook-FeiShu] URL验证请求") - return {"challenge": payload["challenge"]} - - if platform == "slack" and payload.get("type") == "url_verification": - logger.info(f"[Webhook-Slack] URL验证请求") - return {"challenge": payload.get("challenge", "")} - - # 查找活跃频道获取配置 - channel = db.query(Channel).filter( - Channel.platform == platform, - Channel.is_active == True, - ).first() - - if not channel: - return { - "code": 0, - "data": {"handled": False}, - "message": f"无活跃的{platform}频道", - } - - # 创建平台适配器实例 - try: - adapter = get_platform_adapter(platform, channel.credential or {}) - except Exception as e: - logger.error(f"[Webhook] 创建平台适配器失败: {e}") - raise HTTPException(status_code=500, detail=f"平台适配器初始化失败: {str(e)}") - - # 验证签名 - if signature: - if not adapter.verify_signature(payload, signature, timestamp): - logger.warning(f"[Webhook] {platform} 签名验证失败") - raise HTTPException(status_code=401, detail="Webhook签名验证失败") - - # 解析消息为统一格式 - parsed = adapter.parse_webhook(payload) - - # 创建MessageSession统一会话标识 - session = MessageSession.from_channel_event( - platform=platform, - channel_id=channel.id, - user_id=parsed.get("sender_id", ""), - group_id=parsed.get("group_id"), - ) - - # 创建消息记录 - msg_id = gen_uuid() - channel_msg = ChannelMessage( - id=msg_id, - channel_id=channel.id, - user_id=channel.owner_id, - role="user", - content=parsed.get("content", ""), - token_used=0, - created_at=datetime.now(), - ) - db.add(channel_msg) - - # 更新频道最后活跃时间 - channel.last_active = datetime.now() - channel.updated_at = datetime.now() - - db.commit() - - # 发布到事件总线(Redis队列),异步处理 - try: - from ..event_bus import MessageEvent, get_event_bus - event = MessageEvent( - platform=platform, - channel_id=channel.id, - content=parsed.get("content", ""), - sender_id=parsed.get("sender_id", ""), - sender_name=parsed.get("sender_name", ""), - message_id=str(msg_id), - group_id=parsed.get("group_id"), - content_type=parsed.get("content_type", "text"), - raw=payload, - ) - get_event_bus().publish(event) - logger.info(f"[Webhook] 事件已发布到EventBus: {platform}:{msg_id}") - except Exception as e: - logger.warning(f"[Webhook] EventBus发布失败(不影响消息接收): {e}") - - logger.info( - f"[Webhook] 消息已记录: platform={platform}, " - f"channel={channel.name}, msg_id={msg_id}, session={session}" - ) - - return { - "code": 0, - "data": { - "handled": True, - "message_id": msg_id, - "session": str(session), - "platform": platform, - }, - "message": "消息已接收", - } - - -@router.get("/webhook/platforms") -def list_supported_platforms(): - """列出所有支持的平台(从适配器注册表获取)""" - platforms = [] - for name, meta in platform_registry.items(): - platforms.append({ - "name": meta.name, - "display_name": meta.display_name, - "description": meta.description, - "icon": meta.icon, - "support_streaming": meta.support_streaming, - "support_image": meta.support_image, - "support_file": meta.support_file, - "support_voice": meta.support_voice, - }) - return {"code": 0, "data": {"platforms": platforms}, "message": "ok"} diff --git a/backend/python/app/api/im_bot_user.py b/backend/python/app/api/im_bot_user.py index 1c9a7f0..9453301 100644 --- a/backend/python/app/api/im_bot_user.py +++ b/backend/python/app/api/im_bot_user.py @@ -1,601 +1,601 @@ -""" -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, timedelta -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 _recent_history(user_id: 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 ORDER BY id DESC LIMIT :lim" - ), {"uid": user_id, "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) -> str: - """调用七虎AI 生成回复。自行创建 DB 会话,避免上层线程无可用 session。""" - prompt = text - if user_id: - hist = _recent_history(user_id) - if hist: - prompt = ( - "以下是你与该用户最近的对话历史,请结合上下文作答:\n" - f"{hist}\n\n" - f"用户当前问题:{text}" - ) - db = None - try: - from app.api.channels import _call_real_llm - db = SessionLocal() - return _call_real_llm(db, None, prompt, "个人微信") - 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): - """发送文本回复(必须带 context_token 关联到正确的对话窗口)。 - 返回 (ok, ret, body)。ret=-14=会话过期需重扫;ret=-2=token 失效。""" - import uuid as _uuid - payload = { - "msg": { - "from_user_id": "", - "to_user_id": to_user_id, - "client_id": "py-" + str(_uuid.uuid4()), - "message_type": 2, - "message_state": 2, - "context_token": context_token, - "item_list": [{"type": 1, "text_item": {"text": text}}], - }, - "base_info": {"channel_version": "1.0.2"}, - } - last_body = {} - for _ in range(2): - try: - resp = requests.post( - baseurl.rstrip("/") + "/ilink/bot/sendmessage", - headers=_wechat_headers(bot_token), - json=payload, - timeout=20, - ) - try: - body = resp.json() if resp.content else {} - except Exception: - body = {} - last_body = body - ret = body.get("ret") if isinstance(body, dict) else None - try: - with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f: - _f.write(datetime.now().isoformat() + " SEND_HTTP=%s ret=%s body=%s\n" - % (resp.status_code, ret, json.dumps(body, ensure_ascii=False)[:300])) - except Exception: - pass - if resp.ok and (ret is None or ret == 0 or not body): - return True, ret, body - if ret in (-2, -14): - return False, ret, body # 鉴权/会话失效,立即停止重试 - except Exception: - pass - return False, (last_body.get("ret") if isinstance(last_body, dict) else None), last_body - - -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"]) - if not parts: - for k in ("text", "content", "message", "msg"): - v = msg.get(k) - if isinstance(v, str) and v.strip(): - parts.append(v) - return "\n".join(parts) - - -def _wechat_sender_id(m: dict) -> str: - """从入站消息中提取发送者 id(兼容多套字段名)。""" - for k in ("from_user_id", "from_user_name", "from_username", "user_id", - "sender_id", "wxid", "from_wxid", "username"): - v = m.get(k) - if v: - return str(v) - return "" - - -def _wechat_context_token(m: dict) -> str: - """提取 context_token(回复必须回带,否则不关联到对话窗口)。""" - for k in ("context_token", "ctx_token", "conversation_id", "conv_id", - "msg_id", "context_id", "session_id"): - v = m.get(k) - if v: - return str(v) - return "" - - -def _log_chat(user_id, platform, from_user, context_token, role, content): - """持久化 IM Bot 对话历史,便于平台内查阅。""" - 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() - - -# ============================================================ -# 镜像到 AI 聊天(标准 conversations/messages) -# 个人微信对话作为一个独立会话窗(默认名 "WeXin Bot")出现在 AI 聊天中, -# 从而复用 AI 聊天模块的全部功能(滚动翻阅、重命名、置顶、删除等)。 -# ============================================================ -WECHAT_CONV_TITLE = "WeXin Bot" - - -def _get_or_create_wechat_conv(db: Session, user_id: str) -> str: - """按 user_id 找/建 source='wechat_bot' 的会话,返回会话 id。""" - rows = _execute_raw(db, text( - "SELECT id FROM conversations WHERE user_id=:uid AND source='wechat_bot' " - "AND status<>'archived' ORDER BY updated_at DESC LIMIT 1" - ), {"uid": user_id}) - 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, 'wechat_bot', :c, :u)" - ), {"id": cid, "uid": user_id, "title": WECHAT_CONV_TITLE, "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, user_content: str, assistant_content: str): - """把一轮微信问答写入用户的 WeXin Bot AI 聊天会话。""" - if not user_id: - return - db = None - try: - db = SessionLocal() - cid = _get_or_create_wechat_conv(db, user_id) - now = datetime.now() - _append_ai_message(db, cid, "user", user_content, created=now) - # assistant 比 user 晚 50ms,保证排序严格递增,避免同秒时 ORDER BY 乱序 - _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() - - -# ============================================================ -# 后台接收线程(每用户独立) -# ============================================================ -def _mark_wechat_expired(account_id: int): - """发送会话失效(ret=-14/-2):标记账号需重扫,并清除失效 token。""" - db = None - try: - db = SessionLocal() - _execute_raw(db, text( - "UPDATE im_bot_wechat_account SET status='expired', bot_token=NULL, " - "updated_at=:ts WHERE id=:aid" - ), {"aid": account_id, "ts": datetime.now()}) - except Exception: - pass - finally: - if db: - db.close() - - -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) - owner_uid = d.get("user_id") - for m in msgs: - if m.get("message_type") == 2: - continue # 仅跳过 bot 自己发出的回声,其余(含后续追问)均尝试处理 - content = _wechat_extract_text(m) - if not content: - continue - sender = _wechat_sender_id(m) - ctx = _wechat_context_token(m) - # 诊断:记录原始入站消息,定位字段名 / 第二轮不回复 - try: - with open("/tmp/wechat_inbound.log", "a", encoding="utf-8") as _f: - _f.write(datetime.now().isoformat() + " sender=" + repr(sender) - + " ctx=" + repr(ctx) + " raw=" + json.dumps(m, ensure_ascii=False) + "\n") - except Exception: - pass - # 先生成回复(此时历史尚未包含本条,避免重复) - reply = _qihua_reply(content, owner_uid) - ok, ret, _ = _wechat_send_message(baseurl, bot_token, sender, ctx, reply) - if ok: - # 仅发送成功才写对话历史(避免“发了但没收到”的误导记录) - if owner_uid: - _log_chat(owner_uid, "personal_wechat", sender, ctx, "user", content) - _log_chat(owner_uid, "personal_wechat", sender, ctx, "assistant", reply) - # 同步镜像到 AI 聊天的 WeXin Bot 会话窗 - _mirror_to_ai_chat(owner_uid, content, reply) - else: - try: - with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f: - _f.write(datetime.now().isoformat() + " SEND_FAIL ret=%s sender=%s\n" - % (ret, repr(sender))) - except Exception: - pass - if ret in (-2, -14): - # 会话/鉴权失效:标记需重扫并停止接收线程 - _mark_wechat_expired(d["id"]) - return - 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", - "status": d.get("status"), - "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 {} - qr_url = j.get("qrcode_img_content") or "" - qr_img = None - try: - import io as _io - import qrcode as _qrcode - _buf = _io.BytesIO() - _q = _qrcode.QRCode(box_size=10, border=2) - _q.add_data(qr_url) - _q.make(fit=True) - _img = _q.make_image(fill_color="black", back_color="white") - _img.save(_buf, format="PNG") - qr_img = "data:image/png;base64," + base64.b64encode(_buf.getvalue()).decode() - except Exception: - qr_img = None - return {"success": True, "data": { - "qrcode": j.get("qrcode"), - "qrcode_img_content": qr_url, - "qrcode_img": qr_img, - }} - 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() - - -# ============================================================ -# 启动恢复:为所有已连接用户账号拉起接收线程 -# ============================================================ -@router.get("/history") -async def wechat_history( - user: User = Depends(get_current_user), - limit: int = Query(200), -): - """查询当前用户通过个人微信与七虎AI 的对话历史""" - 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 ORDER BY id DESC LIMIT :lim"), - {"uid": user.id, "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() - - -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() +""" +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, timedelta +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 _recent_history(user_id: 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 ORDER BY id DESC LIMIT :lim" + ), {"uid": user_id, "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) -> str: + """调用七虎AI 生成回复。自行创建 DB 会话,避免上层线程无可用 session。""" + prompt = text + if user_id: + hist = _recent_history(user_id) + 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, "个人微信") + 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): + """发送文本回复(必须带 context_token 关联到正确的对话窗口)。 + 返回 (ok, ret, body)。ret=-14=会话过期需重扫;ret=-2=token 失效。""" + import uuid as _uuid + payload = { + "msg": { + "from_user_id": "", + "to_user_id": to_user_id, + "client_id": "py-" + str(_uuid.uuid4()), + "message_type": 2, + "message_state": 2, + "context_token": context_token, + "item_list": [{"type": 1, "text_item": {"text": text}}], + }, + "base_info": {"channel_version": "1.0.2"}, + } + last_body = {} + for _ in range(2): + try: + resp = requests.post( + baseurl.rstrip("/") + "/ilink/bot/sendmessage", + headers=_wechat_headers(bot_token), + json=payload, + timeout=20, + ) + try: + body = resp.json() if resp.content else {} + except Exception: + body = {} + last_body = body + ret = body.get("ret") if isinstance(body, dict) else None + try: + with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f: + _f.write(datetime.now().isoformat() + " SEND_HTTP=%s ret=%s body=%s\n" + % (resp.status_code, ret, json.dumps(body, ensure_ascii=False)[:300])) + except Exception: + pass + if resp.ok and (ret is None or ret == 0 or not body): + return True, ret, body + if ret in (-2, -14): + return False, ret, body # 鉴权/会话失效,立即停止重试 + except Exception: + pass + return False, (last_body.get("ret") if isinstance(last_body, dict) else None), last_body + + +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"]) + if not parts: + for k in ("text", "content", "message", "msg"): + v = msg.get(k) + if isinstance(v, str) and v.strip(): + parts.append(v) + return "\n".join(parts) + + +def _wechat_sender_id(m: dict) -> str: + """从入站消息中提取发送者 id(兼容多套字段名)。""" + for k in ("from_user_id", "from_user_name", "from_username", "user_id", + "sender_id", "wxid", "from_wxid", "username"): + v = m.get(k) + if v: + return str(v) + return "" + + +def _wechat_context_token(m: dict) -> str: + """提取 context_token(回复必须回带,否则不关联到对话窗口)。""" + for k in ("context_token", "ctx_token", "conversation_id", "conv_id", + "msg_id", "context_id", "session_id"): + v = m.get(k) + if v: + return str(v) + return "" + + +def _log_chat(user_id, platform, from_user, context_token, role, content): + """持久化 IM Bot 对话历史,便于平台内查阅。""" + 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() + + +# ============================================================ +# 镜像到 AI 聊天(标准 conversations/messages) +# 个人微信对话作为一个独立会话窗(默认名 "WeXin Bot")出现在 AI 聊天中, +# 从而复用 AI 聊天模块的全部功能(滚动翻阅、重命名、置顶、删除等)。 +# ============================================================ +WECHAT_CONV_TITLE = "WeXin Bot" + + +def _get_or_create_wechat_conv(db: Session, user_id: str) -> str: + """按 user_id 找/建 source='wechat_bot' 的会话,返回会话 id。""" + rows = _execute_raw(db, text( + "SELECT id FROM conversations WHERE user_id=:uid AND source='wechat_bot' " + "AND status<>'archived' ORDER BY updated_at DESC LIMIT 1" + ), {"uid": user_id}) + 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, 'wechat_bot', :c, :u)" + ), {"id": cid, "uid": user_id, "title": WECHAT_CONV_TITLE, "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, user_content: str, assistant_content: str): + """把一轮微信问答写入用户的 WeXin Bot AI 聊天会话。""" + if not user_id: + return + db = None + try: + db = SessionLocal() + cid = _get_or_create_wechat_conv(db, user_id) + now = datetime.now() + _append_ai_message(db, cid, "user", user_content, created=now) + # assistant 比 user 晚 50ms,保证排序严格递增,避免同秒时 ORDER BY 乱序 + _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() + + +# ============================================================ +# 后台接收线程(每用户独立) +# ============================================================ +def _mark_wechat_expired(account_id: int): + """发送会话失效(ret=-14/-2):标记账号需重扫,并清除失效 token。""" + db = None + try: + db = SessionLocal() + _execute_raw(db, text( + "UPDATE im_bot_wechat_account SET status='expired', bot_token=NULL, " + "updated_at=:ts WHERE id=:aid" + ), {"aid": account_id, "ts": datetime.now()}) + except Exception: + pass + finally: + if db: + db.close() + + +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) + owner_uid = d.get("user_id") + for m in msgs: + if m.get("message_type") == 2: + continue # 仅跳过 bot 自己发出的回声,其余(含后续追问)均尝试处理 + content = _wechat_extract_text(m) + if not content: + continue + sender = _wechat_sender_id(m) + ctx = _wechat_context_token(m) + # 诊断:记录原始入站消息,定位字段名 / 第二轮不回复 + try: + with open("/tmp/wechat_inbound.log", "a", encoding="utf-8") as _f: + _f.write(datetime.now().isoformat() + " sender=" + repr(sender) + + " ctx=" + repr(ctx) + " raw=" + json.dumps(m, ensure_ascii=False) + "\n") + except Exception: + pass + # 先生成回复(此时历史尚未包含本条,避免重复) + reply = _qihua_reply(content, owner_uid) + ok, ret, _ = _wechat_send_message(baseurl, bot_token, sender, ctx, reply) + if ok: + # 仅发送成功才写对话历史(避免“发了但没收到”的误导记录) + if owner_uid: + _log_chat(owner_uid, "personal_wechat", sender, ctx, "user", content) + _log_chat(owner_uid, "personal_wechat", sender, ctx, "assistant", reply) + # 同步镜像到 AI 聊天的 WeXin Bot 会话窗 + _mirror_to_ai_chat(owner_uid, content, reply) + else: + try: + with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f: + _f.write(datetime.now().isoformat() + " SEND_FAIL ret=%s sender=%s\n" + % (ret, repr(sender))) + except Exception: + pass + if ret in (-2, -14): + # 会话/鉴权失效:标记需重扫并停止接收线程 + _mark_wechat_expired(d["id"]) + return + 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", + "status": d.get("status"), + "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 {} + qr_url = j.get("qrcode_img_content") or "" + qr_img = None + try: + import io as _io + import qrcode as _qrcode + _buf = _io.BytesIO() + _q = _qrcode.QRCode(box_size=10, border=2) + _q.add_data(qr_url) + _q.make(fit=True) + _img = _q.make_image(fill_color="black", back_color="white") + _img.save(_buf, format="PNG") + qr_img = "data:image/png;base64," + base64.b64encode(_buf.getvalue()).decode() + except Exception: + qr_img = None + return {"success": True, "data": { + "qrcode": j.get("qrcode"), + "qrcode_img_content": qr_url, + "qrcode_img": qr_img, + }} + 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() + + +# ============================================================ +# 启动恢复:为所有已连接用户账号拉起接收线程 +# ============================================================ +@router.get("/history") +async def wechat_history( + user: User = Depends(get_current_user), + limit: int = Query(200), +): + """查询当前用户通过个人微信与七虎AI 的对话历史""" + 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 ORDER BY id DESC LIMIT :lim"), + {"uid": user.id, "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() + + +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() diff --git a/backend/python/app/api/im_llm.py b/backend/python/app/api/im_llm.py new file mode 100644 index 0000000..7cd0f9e --- /dev/null +++ b/backend/python/app/api/im_llm.py @@ -0,0 +1,45 @@ +""" +IM Bot 真实 LLM 调用。 + +原实现位于 channels.py(通用频道管理模块),该模块已移除。 +为保持 IM Bot(微信机器人等)正常生成回复,将 _call_real_llm 迁移至此独立模块。 +""" +from typing import Optional + +import httpx +from sqlalchemy.orm import Session + +from ..capability_registry import resolve_auto_model +from ..models import AIModel, ModelProvider + + +def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im") -> str: + """调用七虎AI 默认文本模型生成回复,供 IM Bot 复用。""" + try: + ai_model = resolve_auto_model("text", db) + if not ai_model: + return "(未配置默认文本模型,无法生成回复)" + provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first() + api_key = ai_model.api_key or (provider.api_key if provider else None) + api_url = ai_model.endpoint + if not api_url and provider and provider.api_base_url: + api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions" + if not (api_key and api_url): + return "(模型未配置 API Key 或地址,无法生成回复)" + messages = [ + {"role": "system", "content": "你是七虎AI 助手,负责在即时通讯中回复用户消息。"}, + {"role": "user", "content": prompt}, + ] + request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048} + with httpx.Client(timeout=ai_model.timeout or 120) as client: + resp = client.post( + api_url, + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json=request_body, + ) + if resp.status_code != 200: + return f"(AI 模型调用失败 HTTP {resp.status_code})" + data = resp.json() + return data.get("choices", [{}])[0].get("message", {}).get("content", "") + except Exception as e: + return f"(AI 回复生成失败:{e})" diff --git a/backend/python/main.py b/backend/python/main.py index 6a86aa3..e7839a7 100644 --- a/backend/python/main.py +++ b/backend/python/main.py @@ -1,245 +1,244 @@ -""" -Qihu-AIFlow FastAPI 主应用 -完整后端API,监听18080端口,所有接口以 /app 为前缀 -""" -import sys -import os -import time -import uuid -import logging -from contextlib import asynccontextmanager -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from starlette.middleware.base import BaseHTTPMiddleware -from datetime import datetime - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution -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.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 -from app.api.admin import asset_admin, achievement_admin -from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD -from app.database import SessionLocal -from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel - -# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1) -import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册 - -# 初始化文件日志(按天轮转) -access_logger = setup_file_logging() -logger = logging.getLogger("aiflow") - - -class APITrackingMiddleware(BaseHTTPMiddleware): - """API调用统计中间件 - 记录请求耗时,标记慢请求""" - - async def dispatch(self, request: Request, call_next): - request_id = str(uuid.uuid4()) - start_time = time.time() - path = request.url.path - method = request.method - - # 跳过健康检查和静态资源 - skip_paths = ["/app/api/health", "/favicon.ico"] - should_skip = any(path.startswith(p) for p in skip_paths) - - try: - response = await call_next(request) - elapsed = time.time() - start_time - status_code = response.status_code - - if not should_skip: - access_logger.info( - f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)" - ) - - # 异步记录API调用统计 - try: - db = SessionLocal() - try: - _record_api_call(db, method, path, status_code, elapsed) - finally: - db.close() - except Exception as e: - logger.warning(f"API统计记录失败: {e}") - - return response - except Exception as e: - elapsed = time.time() - start_time - if not should_skip: - access_logger.info( - f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}" - ) - raise - - async def __call__(self, scope, receive, send): - if scope["type"] != "http": - await self.app(scope, receive, send) - return - await super().__call__(scope, receive, send) - - -def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float): - """记录API调用到数据库统计表""" - # 简化路径以减少基数(去掉动态ID) - parts = path.split("/") - simplified_parts = [] - for part in parts: - # 将UUID样式的路径段替换为{id} - if len(part) == 36 and part.count("-") == 4: - simplified_parts.append("{id}") - # 将纯数字路径段替换为{id} - elif part.isdigit(): - simplified_parts.append("{id}") - else: - simplified_parts.append(part) - simplified_path = "/".join(simplified_parts) - - # 统计记录通过数据库实现,简化版记录到日志 - is_error = status_code >= 400 - is_slow = elapsed >= SLOW_REQUEST_THRESHOLD - - if is_slow: - logger.warning( - f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)" - ) - - -@asynccontextmanager -async def lifespan(app: FastAPI): - logger.info("Qihu-AIFlow v2.0.0 启动中...") - - # 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行) - try: - from app.services.evolution_service import EvolutionWatcherManager - from app.models import UserEvolutionConfig - db = SessionLocal() - try: - configs = db.query(UserEvolutionConfig).filter( - UserEvolutionConfig.watcher_status == "running" - ).all() - if configs: - watcher = EvolutionWatcherManager() - for cfg in configs: - try: - watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal()) - logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline") - except Exception as e: - logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}") - logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline") - else: - logger.info("[Evolution] 无待恢复的进化配置") - finally: - db.close() - except Exception as e: - logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}") - - # 启动Redis事件总线 - try: - from app.event_bus import get_event_bus - event_bus = get_event_bus() - event_bus.start(num_consumers=2) - logger.info("[EventBus] Redis事件总线已启动") - except Exception as e: - logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}") - - logger.info("Qihu-AIFlow v2.0.0 启动完成") - - yield - - # 停止Redis事件总线 - try: - from app.event_bus import get_event_bus - get_event_bus().stop() - logger.info("[EventBus] Redis事件总线已停止") - except Exception: - pass - - # 优雅关闭:停止所有 Evolution Pipeline - try: - from app.services.evolution_service import EvolutionWatcherManager - EvolutionWatcherManager().shutdown_all() - logger.info("[Evolution] 所有Pipeline已停止") - except Exception: - pass - - logger.info("Qihu-AIFlow 正在关闭...") - - -app = FastAPI( - title="Qihu-AIFlow - 智能自动化PaaS平台", - version="1.0.0", - lifespan=lifespan, -) - -# API调用追踪中间件 -app.add_middleware(APITrackingMiddleware) - -# CORS配置 -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# 前台 API -app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"]) -app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"]) -app.include_router(channels.router, prefix="/app/api/channels", tags=["频道"]) -app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"]) -app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"]) -app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"]) -app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"]) -app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"]) -app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"]) -app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"]) -app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"]) - -# 后台 API -app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"]) -app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"]) -app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"]) -app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"]) -app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"]) -app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"]) -app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"]) -app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"]) -app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"]) -app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"]) - -app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"]) -app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"]) -app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"]) -app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"]) -app.include_router(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"]) -app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"]) -app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"]) -app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"]) -app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"]) -app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"]) -app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"]) -app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"]) -app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"]) -app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"]) -app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"]) -app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"]) -app.include_router(im_bot_user_router, prefix="/app/api/im/wechat", tags=["im-wechat-user"]) -app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"]) -app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"]) -app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"]) -app.include_router(analytics_admin.router, prefix="/app/api/admin/analytics", tags=["analytics"]) -app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"]) -app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"]) -app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"]) - - -@app.get("/app/api/health") -async def health_check(): - return {"status": "ok", "service": "Qihu-AIFlow"} +""" +Qihu-AIFlow FastAPI 主应用 +完整后端API,监听18080端口,所有接口以 /app 为前缀 +""" +import sys +import os +import time +import uuid +import logging +from contextlib import asynccontextmanager +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.base import BaseHTTPMiddleware +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.api import auth, chat, knowledge, market, token_usage, payment, profile, notifications, assets, evolution +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.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 +from app.api.admin import asset_admin, achievement_admin +from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD +from app.database import SessionLocal +from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel + +# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1) +import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册 + +# 初始化文件日志(按天轮转) +access_logger = setup_file_logging() +logger = logging.getLogger("aiflow") + + +class APITrackingMiddleware(BaseHTTPMiddleware): + """API调用统计中间件 - 记录请求耗时,标记慢请求""" + + async def dispatch(self, request: Request, call_next): + request_id = str(uuid.uuid4()) + start_time = time.time() + path = request.url.path + method = request.method + + # 跳过健康检查和静态资源 + skip_paths = ["/app/api/health", "/favicon.ico"] + should_skip = any(path.startswith(p) for p in skip_paths) + + try: + response = await call_next(request) + elapsed = time.time() - start_time + status_code = response.status_code + + if not should_skip: + access_logger.info( + f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)" + ) + + # 异步记录API调用统计 + try: + db = SessionLocal() + try: + _record_api_call(db, method, path, status_code, elapsed) + finally: + db.close() + except Exception as e: + logger.warning(f"API统计记录失败: {e}") + + return response + except Exception as e: + elapsed = time.time() - start_time + if not should_skip: + access_logger.info( + f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}" + ) + raise + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + await super().__call__(scope, receive, send) + + +def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float): + """记录API调用到数据库统计表""" + # 简化路径以减少基数(去掉动态ID) + parts = path.split("/") + simplified_parts = [] + for part in parts: + # 将UUID样式的路径段替换为{id} + if len(part) == 36 and part.count("-") == 4: + simplified_parts.append("{id}") + # 将纯数字路径段替换为{id} + elif part.isdigit(): + simplified_parts.append("{id}") + else: + simplified_parts.append(part) + simplified_path = "/".join(simplified_parts) + + # 统计记录通过数据库实现,简化版记录到日志 + is_error = status_code >= 400 + is_slow = elapsed >= SLOW_REQUEST_THRESHOLD + + if is_slow: + logger.warning( + f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)" + ) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("Qihu-AIFlow v2.0.0 启动中...") + + # 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行) + try: + from app.services.evolution_service import EvolutionWatcherManager + from app.models import UserEvolutionConfig + db = SessionLocal() + try: + configs = db.query(UserEvolutionConfig).filter( + UserEvolutionConfig.watcher_status == "running" + ).all() + if configs: + watcher = EvolutionWatcherManager() + for cfg in configs: + try: + watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal()) + logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline") + except Exception as e: + logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}") + logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline") + else: + logger.info("[Evolution] 无待恢复的进化配置") + finally: + db.close() + except Exception as e: + logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}") + + # 启动Redis事件总线 + try: + from app.event_bus import get_event_bus + event_bus = get_event_bus() + event_bus.start(num_consumers=2) + logger.info("[EventBus] Redis事件总线已启动") + except Exception as e: + logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}") + + logger.info("Qihu-AIFlow v2.0.0 启动完成") + + yield + + # 停止Redis事件总线 + try: + from app.event_bus import get_event_bus + get_event_bus().stop() + logger.info("[EventBus] Redis事件总线已停止") + except Exception: + pass + + # 优雅关闭:停止所有 Evolution Pipeline + try: + from app.services.evolution_service import EvolutionWatcherManager + EvolutionWatcherManager().shutdown_all() + logger.info("[Evolution] 所有Pipeline已停止") + except Exception: + pass + + logger.info("Qihu-AIFlow 正在关闭...") + + +app = FastAPI( + title="Qihu-AIFlow - 智能自动化PaaS平台", + version="1.0.0", + lifespan=lifespan, +) + +# API调用追踪中间件 +app.add_middleware(APITrackingMiddleware) + +# CORS配置 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 前台 API +app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"]) +app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"]) +app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"]) +app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"]) +app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"]) +app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"]) +app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"]) +app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"]) +app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"]) +app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"]) + +# 后台 API +app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"]) +app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"]) +app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"]) +app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"]) +app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"]) +app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"]) +app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"]) +app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"]) +app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"]) +app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"]) + +app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"]) +app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"]) +app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"]) +app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"]) +app.include_router(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"]) +app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"]) +app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"]) +app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"]) +app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"]) +app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"]) +app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"]) +app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"]) +app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"]) +app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"]) +app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"]) +app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"]) +app.include_router(im_bot_user_router, prefix="/app/api/im/wechat", tags=["im-wechat-user"]) +app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"]) +app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"]) +app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"]) +app.include_router(analytics_admin.router, prefix="/app/api/admin/analytics", tags=["analytics"]) +app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"]) +app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"]) +app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"]) + + +@app.get("/app/api/health") +async def health_check(): + return {"status": "ok", "service": "Qihu-AIFlow"} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d11bd98..c3af31f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,173 +1,171 @@ -import React, { useState, useEffect, Suspense } from 'react'; -import { Routes, Route, Navigate } from 'react-router-dom'; -import type { User } from '@/types'; -import { AuthContext, AuthContextType } from '@/contexts/AuthContext'; -import { authAPI } from '@/api'; - -// Layouts(保留为即时加载,构成应用外壳) -import FrontLayout from '@/components/layout/FrontLayout'; -import AdminLayout from '@/components/layout/AdminLayout'; - -// 路由级代码分割:每个页面独立打包成 chunk,首屏只加载外壳与公共依赖, -// 访问具体页面时才按需加载对应 chunk,显著加快首屏渲染与路由切换速度。 -const LandingPage = React.lazy(() => import('@/pages/front/LandingPage')); -const LoginPage = React.lazy(() => import('@/pages/front/LoginPage')); -const ChatPage = React.lazy(() => import('@/pages/front/ChatPage')); -const ChannelPage = React.lazy(() => import('@/pages/front/ChannelPage')); -const KnowledgePage = React.lazy(() => import('@/pages/front/KnowledgePage')); -const MarketPage = React.lazy(() => import('@/pages/front/MarketPage')); -const TokenDashboardPage = React.lazy(() => import('@/pages/front/TokenDashboardPage')); -const PricingPage = React.lazy(() => import('@/pages/front/PricingPage')); -const ProfilePage = React.lazy(() => import('@/pages/front/ProfilePage')); -const NotificationPage = React.lazy(() => import('@/pages/front/NotificationPage')); -const AssetsPage = React.lazy(() => import('@/pages/front/AssetsPage')); -const NovelPreviewPage = React.lazy(() => import('@/pages/front/NovelPreviewPage')); -const EvolutionDashboard = React.lazy(() => import('@/pages/front/EvolutionDashboard')); - -const AdminDashboard = React.lazy(() => import('@/pages/admin/Dashboard')); -const AdminUsers = React.lazy(() => import('@/pages/admin/Users')); -const AdminModels = React.lazy(() => import('@/pages/admin/Models')); -const AdminSiteSettings = React.lazy(() => import('@/pages/admin/SiteSettings')); -const AdminSmsConfig = React.lazy(() => import('@/pages/admin/SmsConfig')); -const AdminPayment = React.lazy(() => import('@/pages/admin/Payment')); -const AdminServices = React.lazy(() => import('@/pages/admin/Services')); -const AdminMembership = React.lazy(() => import('@/pages/admin/Membership')); -const AdminAds = React.lazy(() => import('@/pages/admin/Ads')); -const AdminMarket = React.lazy(() => import('@/pages/admin/Market')); -const AdminLogs = React.lazy(() => import('@/pages/admin/Logs')); -const AdminSkills = React.lazy(() => import('@/pages/admin/Skills')); -const AdminAgents = React.lazy(() => import('@/pages/admin/Agents')); -const AdminCodeExec = React.lazy(() => import('@/pages/admin/CodeExec')); -const DistributionBuilder = React.lazy(() => import('@/pages/admin/DistributionBuilder')); -const ImBots = React.lazy(() => import('@/pages/admin/ImBots')); -const PrivacySettings = React.lazy(() => import('@/pages/admin/PrivacySettings')); -const Promotion = React.lazy(() => import('@/pages/admin/Promotion')); -const Achievement = React.lazy(() => import('@/pages/admin/Achievement')); -const Tenant = React.lazy(() => import('@/pages/admin/Tenant')); -const Billing = React.lazy(() => import('@/pages/admin/Billing')); -const Analytics = React.lazy(() => import('@/pages/admin/Analytics')); -const AdminKnowledge = React.lazy(() => import('@/pages/admin/Knowledge')); -const AdminWorkflow = React.lazy(() => import('@/pages/admin/Workflow')); -const AdminStorage = React.lazy(() => import('@/pages/admin/Storage')); -const AdminDeveloper = React.lazy(() => import('@/pages/admin/Developer')); -const AdminDeveloperTools = React.lazy(() => import('@/pages/admin/DeveloperTools')); -const AdminAutomation = React.lazy(() => import('@/pages/admin/Automation')); -const AdminMcp = React.lazy(() => import('@/pages/admin/Mcp')); -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 pageFallback =