feat: 副对话系统 — Webhook 第三方平台接入 (通用/Discord格式支持)

This commit is contained in:
2026-05-16 23:27:04 +08:00
parent 1f5c2508d6
commit 0757ad26b5
4 changed files with 439 additions and 0 deletions
+12
View File
@@ -36,10 +36,22 @@ MINIO_BUCKET=cyrene-assets
ADMIN_USERNAME=admin
ADMIN_PASSWORD=your-admin-password
# ========== 注册开关 (开发环境建议开启) ==========
REGISTRATION_ENABLED=true
# ========== JWT ==========
JWT_SECRET=your-secret-key-change-in-production
JWT_EXPIRY_HOURS=720
# ========== IoT 调试服务 ==========
IOT_DEBUG_SERVICE_URL=http://localhost:8083
# ========== 后台思考 ==========
ENABLE_BACKGROUND_THINKING=true
# ========== Webhook (第三方平台接入) ==========
WEBHOOK_API_KEY=your-webhook-api-key
# ========== 记忆系统 ==========
MEMORY_FILE_PATH=./data/memory
VECTOR_DB_URL=http://localhost:6333
@@ -45,6 +45,9 @@ type Config struct {
// WebSocket
WSMaxConnections int
// Webhook (第三方平台接入)
WebhookAPIKey string
}
// Load 从环境变量加载配置
@@ -80,6 +83,8 @@ func Load() *Config {
LLMModel: getEnv("LLM_MODEL", "gpt-4o"),
WSMaxConnections: getEnvInt("WS_MAX_CONNECTIONS", 1000),
WebhookAPIKey: getEnv("WEBHOOK_API_KEY", ""),
}
}
@@ -0,0 +1,413 @@
package handler
import (
"bufio"
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/yourname/cyrene-ai/gateway/internal/config"
"github.com/yourname/cyrene-ai/gateway/internal/ws"
)
// WebhookHandler 副对话系统处理器(第三方平台接入)
type WebhookHandler struct {
cfg *config.Config
hub *ws.Hub
}
// NewWebhookHandler 创建 Webhook 处理器
func NewWebhookHandler(cfg *config.Config, hub *ws.Hub) *WebhookHandler {
return &WebhookHandler{cfg: cfg, hub: hub}
}
// ============================================================
// 通用 Webhook 格式
// ============================================================
// GenericWebhookRequest 通用 webhook 请求格式
type GenericWebhookRequest struct {
UserID string `json:"user_id"` // 第三方平台的用户标识
SessionID string `json:"session_id"` // 可选,不提供则自动生成
Message string `json:"message"` // 用户消息
Mode string `json:"mode"` // text | voice_msg(默认 text
Platform string `json:"platform"` // 平台标识(如 discord, telegram, generic
}
// GenericWebhookResponse 通用 webhook 响应
type GenericWebhookResponse struct {
Reply string `json:"reply"`
SessionID string `json:"session_id"`
MessageID string `json:"message_id"`
FinishReason string `json:"finish_reason,omitempty"`
Error string `json:"error,omitempty"`
}
// HandleGenericWebhook 处理通用 webhook 请求
// POST /api/v1/webhook/generic
// Authorization: Bearer <webhook_api_key>
func (h *WebhookHandler) HandleGenericWebhook(c *gin.Context) {
var req GenericWebhookRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, GenericWebhookResponse{Error: "请求格式错误: " + err.Error()})
return
}
if req.Message == "" {
c.JSON(400, GenericWebhookResponse{Error: "消息不能为空"})
return
}
userID := req.UserID
if userID == "" {
// 从 webhook API key 推导用户
userID = "webhook_generic"
}
// 为第三方用户添加前缀以避免与主系统用户冲突
if !strings.HasPrefix(userID, "ext_") {
userID = "ext_" + userID
}
mode := req.Mode
if mode == "" {
mode = "text"
}
sessionID := req.SessionID
if sessionID == "" {
sessionID = "webhook_" + randomID(12)
}
platform := req.Platform
if platform == "" {
platform = "generic"
}
// 调用 AI-Core 获取回复
resp, err := h.callAICore(userID, sessionID, req.Message, mode, platform)
if err != nil {
log.Printf("[webhook] AI-Core 调用失败 (platform=%s): %v", platform, err)
c.JSON(502, GenericWebhookResponse{Error: "AI 服务暂不可用: " + err.Error()})
return
}
// 缓存对话(非 WebSocket 场景,使用 Hub 缓存)
if resp.Reply != "" {
h.hub.CacheMessage(userID, sessionID, ws.Message{
Role: "user",
Content: req.Message,
Timestamp: time.Now().UnixMilli(),
})
h.hub.CacheMessage(userID, sessionID, ws.Message{
Role: "assistant",
Content: resp.Reply,
Timestamp: time.Now().UnixMilli(),
})
}
c.JSON(200, resp)
}
// ============================================================
// Discord Webhook 格式
// ============================================================
// DiscordInteraction Discord 交互请求
type DiscordInteraction struct {
Type int `json:"type"` // 1=PING, 2=APPLICATION_COMMAND
Token string `json:"token"` // 交互 token(用于后续回复)
Member *DiscordMember `json:"member"` // 用户信息
User *DiscordUser `json:"user"` // DM 场景的用户信息
Data *DiscordCommandData `json:"data"` // 命令数据
GuildID string `json:"guild_id"`
ChannelID string `json:"channel_id"`
}
// DiscordMember Discord 成员信息
type DiscordMember struct {
User *DiscordUser `json:"user"`
}
// DiscordUser Discord 用户信息
type DiscordUser struct {
ID string `json:"id"`
Username string `json:"username"`
}
// DiscordCommandData Discord 命令数据
type DiscordCommandData struct {
Name string `json:"name"`
Options []DiscordCommandOption `json:"options,omitempty"`
}
// DiscordCommandOption 命令参数
type DiscordCommandOption struct {
Name string `json:"name"`
Value string `json:"value"`
}
// DiscordResponse Discord 响应(deferred 即时响应 + followup
type DiscordResponse struct {
Type int `json:"type"` // 4=CHANNEL_MESSAGE_WITH_SOURCE, 5=DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE
Data *DiscordResponseData `json:"data,omitempty"`
}
// DiscordResponseData Discord 响应数据
type DiscordResponseData struct {
Content string `json:"content"`
}
// HandleDiscordWebhook 处理 Discord 交互
// POST /api/v1/webhook/discord
// 注意: Discord 需要 ed25519 签名验证,这里简化为 API Key 验证
func (h *WebhookHandler) HandleDiscordWebhook(c *gin.Context) {
// 验证 Discord 签名
// signature := c.GetHeader("X-Signature-Ed25519")
// timestamp := c.GetHeader("X-Signature-Timestamp")
var interaction DiscordInteraction
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(400, gin.H{"error": "无法读取请求体"})
return
}
// 恢复 body 以供后续读取
c.Request.Body = io.NopCloser(bytes.NewReader(body))
if err := json.Unmarshal(body, &interaction); err != nil {
c.JSON(400, gin.H{"error": "无效的 Discord 交互数据"})
return
}
// PING 类型(Discord 端点验证)
if interaction.Type == 1 {
c.JSON(200, gin.H{"type": 1}) // PONG
return
}
// APPLICATION_COMMAND 类型
if interaction.Type != 2 {
c.JSON(200, DiscordResponse{Type: 4, Data: &DiscordResponseData{Content: "暂不支持的交互类型"}})
return
}
// 提取用户信息和消息
userID := "discord_unknown"
username := "unknown"
if interaction.Member != nil && interaction.Member.User != nil {
userID = "discord_" + interaction.Member.User.ID
username = interaction.Member.User.Username
} else if interaction.User != nil {
userID = "discord_" + interaction.User.ID
username = interaction.User.Username
}
prefix := "discord_" + username + "_"
sessionID := prefix + interaction.ChannelID
var message string
if interaction.Data != nil {
switch interaction.Data.Name {
case "chat":
for _, opt := range interaction.Data.Options {
if opt.Name == "message" {
message = opt.Value
}
}
default:
// 其他命令当作普通消息
message = "/" + interaction.Data.Name
for _, opt := range interaction.Data.Options {
message += " " + opt.Value
}
}
}
if message == "" {
c.JSON(200, DiscordResponse{
Type: 4,
Data: &DiscordResponseData{Content: "请提供消息内容!用法: `/chat message:你好,昔涟`"},
})
return
}
// Discord 要求 3 秒内响应,先返回 deferred,后续通过 webhook URL 发送结果
// 但这里简化处理:直接同步调用 AI-Core(如果调用超过 3 秒,Discord 会显示超时)
resp, err := h.callAICore("ext_"+userID, sessionID, message, "text", "discord")
if err != nil {
log.Printf("[webhook:discord] AI-Core 调用失败: %v", err)
c.JSON(200, DiscordResponse{
Type: 4,
Data: &DiscordResponseData{Content: "昔涟暂时无法回应喵...AI 服务异常: " + err.Error() + ""},
})
return
}
c.JSON(200, DiscordResponse{
Type: 4,
Data: &DiscordResponseData{Content: resp.Reply},
})
}
// ============================================================
// 核心:调用 AI-Core 并收集完整响应
// ============================================================
// callAICore 调用 AI-Core SSE 流式接口并收集完整响应
func (h *WebhookHandler) callAICore(userID, sessionID, message, mode, platform string) (*GenericWebhookResponse, error) {
reqBody, err := json.Marshal(map[string]string{
"user_id": userID,
"session_id": sessionID,
"message": message,
"mode": mode,
"platform": platform,
})
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
aiCoreURL := h.cfg.AICoreURL + "/api/v1/chat"
httpReq, err := http.NewRequest("POST", aiCoreURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
httpClient := &http.Client{Timeout: 120 * time.Second}
resp, err := httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("请求 AI-Core 失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("AI-Core 返回 %d: %s", resp.StatusCode, string(body))
}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var fullText string
var msgID string
var finishReason string
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
break
}
var chunk struct {
Delta string `json:"delta"`
Error string `json:"error,omitempty"`
MessageID string `json:"message_id,omitempty"`
Done bool `json:"done,omitempty"`
FinishReason string `json:"finish_reason,omitempty"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue
}
if chunk.Error != "" {
return nil, fmt.Errorf("AI-Core 流式错误: %s", chunk.Error)
}
if chunk.MessageID != "" {
msgID = chunk.MessageID
}
if chunk.FinishReason != "" {
finishReason = chunk.FinishReason
}
if chunk.Done {
break
}
if chunk.Delta != "" {
fullText += chunk.Delta
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("读取 SSE 流失败: %w", err)
}
if msgID == "" {
msgID = "msg_" + randomID(16)
}
return &GenericWebhookResponse{
Reply: fullText,
SessionID: sessionID,
MessageID: msgID,
FinishReason: finishReason,
}, nil
}
// ============================================================
// Webhook API Key 验证中间件
// ============================================================
// WebhookAuth 中间件:验证 webhook API key
func (h *WebhookHandler) WebhookAuth() gin.HandlerFunc {
return func(c *gin.Context) {
apiKey := c.GetHeader("X-Webhook-Key")
if apiKey == "" {
// 也支持 Authorization: Bearer <key>
auth := c.GetHeader("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
apiKey = strings.TrimPrefix(auth, "Bearer ")
}
}
if apiKey == "" {
c.JSON(401, gin.H{"error": "缺少 webhook API key (X-Webhook-Key 或 Authorization: Bearer <key>)"})
c.Abort()
return
}
// 验证 API key(从环境变量读取)
expectedKey := h.cfg.WebhookAPIKey
if expectedKey == "" {
// 未配置则允许任意 key
c.Next()
return
}
if !hmacEqual(apiKey, expectedKey) {
c.JSON(401, gin.H{"error": "无效的 webhook API key"})
c.Abort()
return
}
c.Next()
}
}
// hmacEqual 常量时间字符串比较(防止时序攻击)
func hmacEqual(a, b string) bool {
if len(a) != len(b) {
return false
}
mac := hmac.New(sha256.New, []byte("cyrene_webhook_salt"))
mac.Write([]byte(a))
expected := hmac.New(sha256.New, []byte("cyrene_webhook_salt"))
expected.Write([]byte(b))
return hmac.Equal(mac.Sum(nil), expected.Sum(nil))
}
@@ -22,6 +22,7 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config) {
sessionHandler := handler.NewSessionHandler(hub)
memoryHandler := handler.NewMemoryHandler(cfg.AICoreURL)
chatHandler := handler.NewChatHandler(cfg, hub)
webhookHandler := handler.NewWebhookHandler(cfg, hub)
// ========== 公开路由 ==========
api := r.Group("/api/v1")
@@ -86,6 +87,14 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config) {
wsGroup.GET("/chat", chatHandler.HandleWebSocket)
}
// ========== Webhook 路由(第三方平台接入) ==========
webhook := r.Group("/api/v1/webhook")
webhook.Use(webhookHandler.WebhookAuth())
{
webhook.POST("/generic", webhookHandler.HandleGenericWebhook)
webhook.POST("/discord", webhookHandler.HandleDiscordWebhook)
}
// ========== 静态文件服务 (生产环境) ==========
if cfg.Env == "production" {
r.Static("/assets", "./public/assets")