Files
AskaEth 71f0a1abdb feat: Go模块路径迁移 + Docker生产部署适配 + ethend Docker兼容
- 所有Go模块路径从 github.com/yourname/cyrene-ai 迁移到 git.yeij.top/AskaEth/Cyrene
- 5个Go Dockerfile添加 GOPROXY=https://goproxy.cn,direct 解决国内构建问题
- ai-core go.mod 添加 pkg/plugins replace 指令
- Caddyfile 简化为 http:// 通配 + handle 保留 /api 前缀
- ethend Dockerfile 适配 (npm install + 仅 COPY package.json)
- ethend 新增 RUNNING_IN_DOCKER 环境变量,健康检查改用Docker服务名
- ethend 数据库状态检查支持Docker hostname (postgres/redis/qdrant/minio)
- process-manager 新增 CONTAINER_SVC_MAP + Docker模式自动检测
- 统一 docker-compose.dev.db.yml 卷名 (pg_data/redis_data/qdrant_data/minio_data)
- docker-compose.yml ethend服务挂载docker.sock + 端口变量化
- 清理 .env 统一后的残留文件与提示信息

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:43:22 +08:00

414 lines
11 KiB
Go
Raw Permalink 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.
package handler
import (
"bufio"
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"git.yeij.top/AskaEth/Cyrene/pkg/logger"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.yeij.top/AskaEth/Cyrene/gateway/internal/config"
"git.yeij.top/AskaEth/Cyrene/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 {
logger.Printf("[webhook] AI-Core 调用失败 (platform=%s): %v", platform, err)
c.JSON(502, GenericWebhookResponse{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 {
logger.Printf("[webhook:discord] AI-Core 调用失败: %v", err)
c.JSON(200, DiscordResponse{
Type: 4,
Data: &DiscordResponseData{Content: "昔涟暂时无法回应喵...(遇到了一些问题,请稍后再试)"},
})
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))
}