1568 lines
60 KiB
Python
1568 lines
60 KiB
Python
"""
|
||
频道/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"}
|