feat: 副对话系统 — Webhook 第三方平台接入 (通用/Discord格式支持)
This commit is contained in:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user