""" 频道/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"}