Files
qihuaiflow/backend/python/app/api/channels.py
T

1568 lines
60 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
频道/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"}