chore: import clean Agent Desk snapshot (2/7)
This commit is contained in:
39
internal/handlers/third/discord_handler.go
Normal file
39
internal/handlers/third/discord_handler.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package third
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DiscordPostWebhook receives incoming Webhook events from Discord.
|
||||
func DiscordPostWebhook(ctx *gin.Context) {
|
||||
channelID := strings.TrimSpace(ctx.Param("channel_id"))
|
||||
if channelID == "" {
|
||||
channelID = strings.TrimSpace(ctx.Query("channel_id"))
|
||||
}
|
||||
|
||||
secretHeader := ctx.GetHeader("X-Discord-Secret-Token")
|
||||
if secretHeader == "" {
|
||||
secretHeader = ctx.GetHeader("X-Webhook-Secret")
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(ctx.Request.Body)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "failed to read body"})
|
||||
return
|
||||
}
|
||||
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
if err := services.DiscordInboundService.HandleWebhook(ctx.Request.Context(), channelID, secretHeader, bodyBytes); err != nil {
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
115
internal/handlers/third/discord_handler_test.go
Normal file
115
internal/handlers/third/discord_handler_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package third
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
func TestDiscordPostWebhook_Handler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupThirdHandlerTestDB(t)
|
||||
|
||||
now := time.Now()
|
||||
agent := &models.AIAgent{
|
||||
Name: "Discord Agent",
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst,
|
||||
PublishedRevisionID: 1,
|
||||
WelcomeMessage: "Hello Discord!",
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
_ = db.Create(agent)
|
||||
|
||||
discordConfig, _ := json.Marshal(dto.DiscordChannelConfig{
|
||||
GuildID: "guild_999",
|
||||
BotToken: "test_bot_token",
|
||||
WebhookSecret: "secret_discord_123",
|
||||
WelcomeMessage: "Welcome!",
|
||||
})
|
||||
|
||||
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
|
||||
channel, err := services.ChannelService.CreateChannel(request.CreateChannelRequest{
|
||||
Name: "Discord Community",
|
||||
ChannelType: enums.ChannelTypeDiscord,
|
||||
AIAgentID: agent.ID,
|
||||
AIAgentRolloutPercent: 100,
|
||||
ConfigJSON: string(discordConfig),
|
||||
Status: int(enums.StatusOk),
|
||||
}, operator)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel failed: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/third/discord/webhook/:channel_id", DiscordPostWebhook)
|
||||
router.POST("/api/third/discord/webhook", DiscordPostWebhook)
|
||||
|
||||
payload := []byte(`{
|
||||
"id": "msg_001",
|
||||
"channel_id": "ch_777",
|
||||
"guild_id": "guild_999",
|
||||
"content": "Need help with setup",
|
||||
"author": {
|
||||
"id": "user_456",
|
||||
"username": "gamer_one",
|
||||
"global_name": "Gamer One",
|
||||
"bot": false
|
||||
}
|
||||
}`)
|
||||
|
||||
// 1. Invalid secret
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/third/discord/webhook/"+channel.ChannelID, bytes.NewBuffer(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Discord-Secret-Token", "wrong_secret")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK wrapper, got: %d", rec.Code)
|
||||
}
|
||||
var resp map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp["ok"] == true {
|
||||
t.Fatalf("expected error for invalid secret token")
|
||||
}
|
||||
|
||||
// 2. Valid secret
|
||||
req2, _ := http.NewRequest(http.MethodPost, "/api/third/discord/webhook/"+channel.ChannelID, bytes.NewBuffer(payload))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
req2.Header.Set("X-Discord-Secret-Token", "secret_discord_123")
|
||||
|
||||
rec2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec2, req2)
|
||||
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d", rec2.Code)
|
||||
}
|
||||
var resp2 map[string]any
|
||||
_ = json.Unmarshal(rec2.Body.Bytes(), &resp2)
|
||||
if resp2["ok"] != true {
|
||||
t.Fatalf("expected ok: true, got: %+v", resp2)
|
||||
}
|
||||
|
||||
// Verify identity in DB
|
||||
identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd().
|
||||
Eq("external_source", enums.ExternalSourceDiscord).
|
||||
Eq("external_id", "user_456"))
|
||||
if identity == nil {
|
||||
t.Fatalf("expected customer identity for user_456")
|
||||
}
|
||||
}
|
||||
115
internal/handlers/third/email_handler.go
Normal file
115
internal/handlers/third/email_handler.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package third
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/httpx"
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// EmailPostWebhook receives incoming inbound email webhook events from Cloudflare, Brevo, SendGrid, Postmark, Mailgun, or SMTP forwarders.
|
||||
func EmailPostWebhook(ctx *gin.Context) {
|
||||
channelID := strings.TrimSpace(ctx.Param("channel_id"))
|
||||
if channelID == "" {
|
||||
channelID = strings.TrimSpace(ctx.Query("channel_id"))
|
||||
}
|
||||
|
||||
secretHeader := ctx.GetHeader("X-Webhook-Secret")
|
||||
if secretHeader == "" {
|
||||
secretHeader = ctx.GetHeader("X-Brevo-Webhook-Secret")
|
||||
}
|
||||
if secretHeader == "" {
|
||||
secretHeader = ctx.GetHeader("X-Postmark-Webhook-Secret")
|
||||
}
|
||||
if secretHeader == "" {
|
||||
secretHeader = ctx.Query("secret")
|
||||
}
|
||||
|
||||
contentType := ctx.GetHeader("Content-Type")
|
||||
|
||||
var formValues url.Values
|
||||
var bodyBytes []byte
|
||||
|
||||
if strings.Contains(strings.ToLower(contentType), "multipart/form-data") {
|
||||
if err := ctx.Request.ParseMultipartForm(32 << 20); err == nil && ctx.Request.MultipartForm != nil {
|
||||
formValues = ctx.Request.MultipartForm.Value
|
||||
}
|
||||
} else if strings.Contains(strings.ToLower(contentType), "application/x-www-form-urlencoded") {
|
||||
if err := ctx.Request.ParseForm(); err == nil {
|
||||
formValues = ctx.Request.PostForm
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.Request.Body != nil {
|
||||
bodyBytes, _ = io.ReadAll(ctx.Request.Body)
|
||||
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
}
|
||||
|
||||
if err := services.EmailInboundService.HandleWebhook(ctx.Request.Context(), channelID, secretHeader, contentType, bodyBytes, formValues); err != nil {
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "email processed"})
|
||||
}
|
||||
|
||||
// EmailPostRequest opens a support request from an external system and continues
|
||||
// the conversation over email.
|
||||
//
|
||||
// It is the API counterpart to EmailPostWebhook: same channel, same identity, but
|
||||
// the caller supplies the fields instead of a parsed email, so an integration
|
||||
// does not have to run a mail forwarder to reach support.
|
||||
//
|
||||
// Presenting X-Webhook-Secret marks the submitted contact details as verified.
|
||||
// Omitting it files the request as self-reported, which agents can see. A wrong
|
||||
// secret is rejected rather than treated as a guest submission.
|
||||
func EmailPostRequest(ctx *gin.Context) {
|
||||
channelID := strings.TrimSpace(ctx.Param("channel_id"))
|
||||
if channelID == "" {
|
||||
channelID = strings.TrimSpace(ctx.Query("channel_id"))
|
||||
}
|
||||
|
||||
req := request.EmailRequestRequest{}
|
||||
if err := params.ReadJSON(ctx, &req); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := services.EmailRequestService.HandleRequest(
|
||||
ctx.Request.Context(),
|
||||
channelID,
|
||||
webhookSecretFromHeader(ctx),
|
||||
services.EmailRequestInput{
|
||||
Email: req.Email,
|
||||
Name: req.Name,
|
||||
Subject: req.Subject,
|
||||
Message: req.Message,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, result)
|
||||
}
|
||||
|
||||
// webhookSecretFromHeader reads the channel secret. The query-string form the
|
||||
// webhook accepts is deliberately not honoured here: this endpoint is meant to be
|
||||
// called by programs, and a secret in a URL leaks into access logs and proxy
|
||||
// history for no benefit.
|
||||
func webhookSecretFromHeader(ctx *gin.Context) string {
|
||||
for _, header := range []string{"X-Webhook-Secret", "X-Breveo-Webhook-Secret", "X-Postmark-Webhook-Secret"} {
|
||||
if value := strings.TrimSpace(ctx.GetHeader(header)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
229
internal/handlers/third/email_handler_test.go
Normal file
229
internal/handlers/third/email_handler_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package third
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
func TestEmailPostWebhook_InvalidJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/webhook", EmailPostWebhook)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewBufferString("invalid-json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailPostWebhook_FullFlow(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupThirdHandlerTestDB(t)
|
||||
|
||||
now := time.Now()
|
||||
agent := &models.AIAgent{
|
||||
Name: "Email Support Agent",
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst,
|
||||
PublishedRevisionID: 1,
|
||||
WelcomeMessage: "Thanks for emailing support.",
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
_ = db.Create(agent)
|
||||
|
||||
emailConfig, _ := json.Marshal(dto.EmailChannelConfig{
|
||||
EmailAddress: "help@example.com",
|
||||
SenderName: "Support Team",
|
||||
Provider: "brevo",
|
||||
WebhookSecret: "email_secret_token_123",
|
||||
WelcomeMessage: "We have received your email.",
|
||||
})
|
||||
|
||||
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
|
||||
channel, err := services.ChannelService.CreateChannel(request.CreateChannelRequest{
|
||||
Name: "Email Support Channel",
|
||||
ChannelType: enums.ChannelTypeEmail,
|
||||
AIAgentID: agent.ID,
|
||||
AIAgentRolloutPercent: 100,
|
||||
ConfigJSON: string(emailConfig),
|
||||
Status: int(enums.StatusOk),
|
||||
}, operator)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel failed: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/third/email/webhook/:channel_id", EmailPostWebhook)
|
||||
router.POST("/api/third/email/webhook", EmailPostWebhook)
|
||||
|
||||
// 1. Test unauthorized when secret doesn't match
|
||||
genericPayload := []byte(`{
|
||||
"from": "alice@customer.com",
|
||||
"from_name": "Alice Customer",
|
||||
"to": "help@example.com",
|
||||
"subject": "Need help with Crove Desk",
|
||||
"text": "Hello, I have an issue with my login credentials.",
|
||||
"message_id": "<msg-001@mail.customer.com>"
|
||||
}`)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/third/email/webhook/"+channel.ChannelID, bytes.NewBuffer(genericPayload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Webhook-Secret", "wrong_secret")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK wrapper, got: %d", rec.Code)
|
||||
}
|
||||
var resp map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp["ok"] != false {
|
||||
t.Fatalf("expected ok: false on wrong secret token, got: %v", resp)
|
||||
}
|
||||
|
||||
// 2. Test successful processing with Generic / Cloudflare payload
|
||||
req2, _ := http.NewRequest(http.MethodPost, "/api/third/email/webhook/"+channel.ChannelID, bytes.NewBuffer(genericPayload))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
req2.Header.Set("X-Webhook-Secret", "email_secret_token_123")
|
||||
|
||||
rec2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec2, req2)
|
||||
|
||||
var resp2 map[string]any
|
||||
_ = json.Unmarshal(rec2.Body.Bytes(), &resp2)
|
||||
if resp2["ok"] != true {
|
||||
t.Fatalf("expected ok: true, got: %v", resp2)
|
||||
}
|
||||
|
||||
// Verify Customer was created with Email source
|
||||
identity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd().
|
||||
Eq("external_source", enums.ExternalSourceEmail).
|
||||
Eq("external_id", "alice@customer.com"))
|
||||
if identity == nil {
|
||||
t.Fatalf("expected customer identity for alice@customer.com to be created")
|
||||
}
|
||||
|
||||
customer := repositories.CustomerRepository.Get(sqls.DB(), identity.CustomerID)
|
||||
if customer == nil || customer.PrimaryEmail != "alice@customer.com" {
|
||||
t.Fatalf("expected customer primary_email 'alice@customer.com', got %+v", customer)
|
||||
}
|
||||
|
||||
// Verify Conversation was created
|
||||
conversations := repositories.ConversationRepository.Find(sqls.DB(), sqls.NewCnd().
|
||||
Eq("customer_id", customer.ID).
|
||||
Eq("channel_id", channel.ID))
|
||||
if len(conversations) == 0 {
|
||||
t.Fatalf("expected conversation to be created")
|
||||
}
|
||||
convID := conversations[0].ID
|
||||
|
||||
// Verify Message was saved
|
||||
messages := repositories.MessageRepository.Find(sqls.DB(), sqls.NewCnd().
|
||||
Eq("conversation_id", convID))
|
||||
if len(messages) == 0 {
|
||||
t.Fatalf("expected message to be stored")
|
||||
}
|
||||
|
||||
// 3. Test Threading: Send reply with ticket ID in subject
|
||||
threadedPayload := []byte(strings.ReplaceAll(`{
|
||||
"from": "alice@customer.com",
|
||||
"to": "help@example.com",
|
||||
"subject": "Re: [#CONV_ID] Need help with Crove Desk",
|
||||
"text": "Thanks! Here is additional information.",
|
||||
"message_id": "<msg-002@mail.customer.com>",
|
||||
"in_reply_to": "<msg-001@mail.customer.com>"
|
||||
}`, "CONV_ID", string(rune('0'+convID))))
|
||||
|
||||
reqThread, _ := http.NewRequest(http.MethodPost, "/api/third/email/webhook", bytes.NewBuffer(threadedPayload))
|
||||
reqThread.Header.Set("Content-Type", "application/json")
|
||||
reqThread.Header.Set("X-Webhook-Secret", "email_secret_token_123")
|
||||
|
||||
recThread := httptest.NewRecorder()
|
||||
router.ServeHTTP(recThread, reqThread)
|
||||
|
||||
var respThread map[string]any
|
||||
_ = json.Unmarshal(recThread.Body.Bytes(), &respThread)
|
||||
if respThread["ok"] != true {
|
||||
t.Fatalf("expected threaded ok: true, got: %v", respThread)
|
||||
}
|
||||
|
||||
// Verify message was attached to existing conversation rather than creating a duplicate
|
||||
convCount := len(repositories.ConversationRepository.Find(sqls.DB(), sqls.NewCnd().Eq("customer_id", customer.ID)))
|
||||
if convCount != 1 {
|
||||
t.Fatalf("expected conversation count to remain 1, got %d", convCount)
|
||||
}
|
||||
|
||||
// 4. Test Postmark Inbound Webhook format
|
||||
postmarkPayload := []byte(`{
|
||||
"From": "developer@company.org",
|
||||
"FromName": "Dev Team",
|
||||
"To": "help@example.com",
|
||||
"Subject": "API Inquiry",
|
||||
"TextBody": "How do I call MCP tools?",
|
||||
"MessageID": "postmark-uuid-001"
|
||||
}`)
|
||||
|
||||
reqPM, _ := http.NewRequest(http.MethodPost, "/api/third/email/webhook", bytes.NewBuffer(postmarkPayload))
|
||||
reqPM.Header.Set("Content-Type", "application/json")
|
||||
reqPM.Header.Set("X-Webhook-Secret", "email_secret_token_123")
|
||||
|
||||
recPM := httptest.NewRecorder()
|
||||
router.ServeHTTP(recPM, reqPM)
|
||||
|
||||
var respPM map[string]any
|
||||
_ = json.Unmarshal(recPM.Body.Bytes(), &respPM)
|
||||
if respPM["ok"] != true {
|
||||
t.Fatalf("expected postmark format ok: true, got: %v", respPM)
|
||||
}
|
||||
|
||||
// 5. Test Mailgun Form Data Webhook format
|
||||
mgForm := url.Values{}
|
||||
mgForm.Set("sender", "mailgunner@test.com")
|
||||
mgForm.Set("from", "Mailgun User <mailgunner@test.com>")
|
||||
mgForm.Set("recipient", "help@example.com")
|
||||
mgForm.Set("subject", "Mailgun Inbound Ticket")
|
||||
mgForm.Set("body-plain", "Testing mailgun webhook support.")
|
||||
mgForm.Set("Message-Id", "<mg-998877@mailgun.org>")
|
||||
|
||||
reqMG, _ := http.NewRequest(http.MethodPost, "/api/third/email/webhook", strings.NewReader(mgForm.Encode()))
|
||||
reqMG.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
reqMG.Header.Set("X-Webhook-Secret", "email_secret_token_123")
|
||||
|
||||
recMG := httptest.NewRecorder()
|
||||
router.ServeHTTP(recMG, reqMG)
|
||||
|
||||
var respMG map[string]any
|
||||
_ = json.Unmarshal(recMG.Body.Bytes(), &respMG)
|
||||
if respMG["ok"] != true {
|
||||
t.Fatalf("expected mailgun format ok: true, got: %v", respMG)
|
||||
}
|
||||
|
||||
mgIdentity := repositories.CustomerIdentityRepository.FindOne(sqls.DB(), sqls.NewCnd().
|
||||
Eq("external_source", enums.ExternalSourceEmail).
|
||||
Eq("external_id", "mailgunner@test.com"))
|
||||
if mgIdentity == nil {
|
||||
t.Fatalf("expected customer identity for mailgunner@test.com")
|
||||
}
|
||||
}
|
||||
43
internal/handlers/third/slack_handler.go
Normal file
43
internal/handlers/third/slack_handler.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package third
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SlackPostWebhook receives incoming Events API payloads from Slack.
|
||||
func SlackPostWebhook(ctx *gin.Context) {
|
||||
channelID := strings.TrimSpace(ctx.Param("channel_id"))
|
||||
if channelID == "" {
|
||||
channelID = strings.TrimSpace(ctx.Query("channel_id"))
|
||||
}
|
||||
|
||||
timestampHeader := ctx.GetHeader("X-Slack-Request-Timestamp")
|
||||
signatureHeader := ctx.GetHeader("X-Slack-Signature")
|
||||
|
||||
bodyBytes, err := io.ReadAll(ctx.Request.Body)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "failed to read body"})
|
||||
return
|
||||
}
|
||||
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
challenge, err := services.SlackInboundService.HandleWebhook(ctx.Request.Context(), channelID, timestampHeader, signatureHeader, bodyBytes)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if challenge != nil {
|
||||
ctx.JSON(http.StatusOK, gin.H{"challenge": *challenge})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
153
internal/handlers/third/slack_handler_test.go
Normal file
153
internal/handlers/third/slack_handler_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package third
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
const slackHandlerTestSigningSecret = "test_signing_secret"
|
||||
|
||||
// signSlackHandlerPayload builds the X-Slack-Request-Timestamp and
|
||||
// X-Slack-Signature headers Slack sends for this body right now.
|
||||
func signSlackHandlerPayload(payload []byte) (string, string) {
|
||||
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
mac := hmac.New(sha256.New, []byte(slackHandlerTestSigningSecret))
|
||||
mac.Write([]byte("v0:" + timestamp + ":" + string(payload)))
|
||||
return timestamp, "v0=" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func TestSlackWebhook_Handler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupThirdHandlerTestDB(t)
|
||||
|
||||
now := time.Now()
|
||||
agent := &models.AIAgent{
|
||||
Name: "Slack Agent",
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst,
|
||||
PublishedRevisionID: 1,
|
||||
WelcomeMessage: "Hello Slack User!",
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
if err := db.Create(agent).Error; err != nil {
|
||||
t.Fatalf("create ai agent: %v", err)
|
||||
}
|
||||
|
||||
slackConfig, err := json.Marshal(dto.SlackChannelConfig{
|
||||
BotToken: "xoxb-test-token",
|
||||
SigningSecret: slackHandlerTestSigningSecret,
|
||||
TeamID: "T_SLACK_100",
|
||||
DefaultChannel: "C_GENERAL",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal slack config: %v", err)
|
||||
}
|
||||
|
||||
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
|
||||
channel, err := services.ChannelService.CreateChannel(request.CreateChannelRequest{
|
||||
Name: "Slack Channel",
|
||||
ChannelType: enums.ChannelTypeSlack,
|
||||
AIAgentID: agent.ID,
|
||||
AIAgentRolloutPercent: 100,
|
||||
ConfigJSON: string(slackConfig),
|
||||
Status: int(enums.StatusOk),
|
||||
}, operator)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel failed: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/third/slack/webhook/:channel_id", SlackPostWebhook)
|
||||
router.POST("/api/third/slack/webhook", SlackPostWebhook)
|
||||
|
||||
post := func(path string, payload []byte, timestamp, signature string) *httptest.ResponseRecorder {
|
||||
req, _ := http.NewRequest(http.MethodPost, path, bytes.NewBuffer(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if timestamp != "" {
|
||||
req.Header.Set("X-Slack-Request-Timestamp", timestamp)
|
||||
}
|
||||
if signature != "" {
|
||||
req.Header.Set("X-Slack-Signature", signature)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
webhookPath := "/api/third/slack/webhook/" + channel.ChannelID
|
||||
|
||||
// 1. The url_verification handshake is answered without a signature, because
|
||||
// Slack sends it once while the endpoint is being configured.
|
||||
challengePayload := []byte(`{
|
||||
"token": "token123",
|
||||
"challenge": "slack_challenge_string_999",
|
||||
"type": "url_verification"
|
||||
}`)
|
||||
recChallenge := post(webhookPath, challengePayload, "", "")
|
||||
if recChallenge.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for challenge, got: %d", recChallenge.Code)
|
||||
}
|
||||
var challengeResp map[string]any
|
||||
if err := json.Unmarshal(recChallenge.Body.Bytes(), &challengeResp); err != nil {
|
||||
t.Fatalf("unmarshal challenge response: %v", err)
|
||||
}
|
||||
if challengeResp["challenge"] != "slack_challenge_string_999" {
|
||||
t.Fatalf("expected challenge in body, got: %+v", challengeResp)
|
||||
}
|
||||
|
||||
// 2. An event the channel's signing secret did not produce is rejected, and
|
||||
// must not create a customer identity.
|
||||
eventPayload := []byte(`{
|
||||
"token": "token123",
|
||||
"team_id": "T_SLACK_100",
|
||||
"type": "event_callback",
|
||||
"event": {
|
||||
"type": "message",
|
||||
"user": "U_USER_777",
|
||||
"text": "Hello support team on Slack!",
|
||||
"ts": "1725260000.000100",
|
||||
"channel": "C_GENERAL"
|
||||
}
|
||||
}`)
|
||||
recUnsigned := post(webhookPath, eventPayload, "", "")
|
||||
if recUnsigned.Code != http.StatusOK {
|
||||
t.Fatalf("expected the handler to answer 200 with an error body, got: %d", recUnsigned.Code)
|
||||
}
|
||||
if repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd().
|
||||
Eq("external_source", enums.ExternalSourceSlack).
|
||||
Eq("external_id", "U_USER_777")) != nil {
|
||||
t.Fatalf("an unsigned event created a customer identity")
|
||||
}
|
||||
|
||||
// 3. A correctly signed event is accepted and resolves the sender.
|
||||
timestamp, signature := signSlackHandlerPayload(eventPayload)
|
||||
recEvent := post(webhookPath, eventPayload, timestamp, signature)
|
||||
if recEvent.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for event, got: %d", recEvent.Code)
|
||||
}
|
||||
|
||||
identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd().
|
||||
Eq("external_source", enums.ExternalSourceSlack).
|
||||
Eq("external_id", "U_USER_777"))
|
||||
if identity == nil {
|
||||
t.Fatalf("expected customer identity for U_USER_777")
|
||||
}
|
||||
}
|
||||
36
internal/handlers/third/telegram_handler.go
Normal file
36
internal/handlers/third/telegram_handler.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package third
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TelegramPostWebhook receives incoming Webhook events from Telegram Bot API.
|
||||
func TelegramPostWebhook(ctx *gin.Context) {
|
||||
channelID := strings.TrimSpace(ctx.Param("channel_id"))
|
||||
if channelID == "" {
|
||||
channelID = strings.TrimSpace(ctx.Query("channel_id"))
|
||||
}
|
||||
|
||||
secretHeader := ctx.GetHeader("X-Telegram-Bot-Api-Secret-Token")
|
||||
|
||||
bodyBytes, err := io.ReadAll(ctx.Request.Body)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "failed to read body"})
|
||||
return
|
||||
}
|
||||
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
if err := services.TelegramInboundService.HandleWebhook(ctx.Request.Context(), channelID, secretHeader, bodyBytes); err != nil {
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
150
internal/handlers/third/telegram_handler_test.go
Normal file
150
internal/handlers/third/telegram_handler_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package third
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func setupThirdHandlerTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "t_",
|
||||
SingularTable: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite db: %v", err)
|
||||
}
|
||||
_ = db.AutoMigrate(
|
||||
&models.Channel{},
|
||||
&models.ChannelMessageOutbox{},
|
||||
&models.Customer{},
|
||||
&models.CustomerIdentity{},
|
||||
&models.CustomerContact{},
|
||||
&models.Conversation{},
|
||||
&models.ConversationParticipant{},
|
||||
&models.ConversationReadState{},
|
||||
&models.ConversationEventLog{},
|
||||
&models.ConversationInterrupt{},
|
||||
&models.Message{},
|
||||
&models.AIAgent{},
|
||||
&models.User{},
|
||||
&models.Role{},
|
||||
&models.UserRole{},
|
||||
&models.Permission{},
|
||||
&models.RolePermission{},
|
||||
&models.UserPermission{},
|
||||
)
|
||||
sqls.SetDB(db)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestTelegramPostWebhook_Handler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupThirdHandlerTestDB(t)
|
||||
|
||||
now := time.Now()
|
||||
agent := &models.AIAgent{
|
||||
Name: "Telegram Bot Agent",
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst,
|
||||
PublishedRevisionID: 1,
|
||||
WelcomeMessage: "Hello!",
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
_ = db.Create(agent)
|
||||
|
||||
tgConfig, _ := json.Marshal(dto.TelegramChannelConfig{
|
||||
BotToken: "123456:ABC-DEF",
|
||||
BotUsername: "test_bot",
|
||||
WebhookSecret: "my_secret_token_123",
|
||||
WelcomeMessage: "Welcome!",
|
||||
})
|
||||
|
||||
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
|
||||
channel, err := services.ChannelService.CreateChannel(request.CreateChannelRequest{
|
||||
Name: "Telegram Channel",
|
||||
ChannelType: enums.ChannelTypeTelegram,
|
||||
AIAgentID: agent.ID,
|
||||
AIAgentRolloutPercent: 100,
|
||||
ConfigJSON: string(tgConfig),
|
||||
Status: int(enums.StatusOk),
|
||||
}, operator)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel failed: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/third/telegram/webhook/:channel_id", TelegramPostWebhook)
|
||||
router.POST("/api/third/telegram/webhook", TelegramPostWebhook)
|
||||
|
||||
// 1. Test unauthorized when secret doesn't match
|
||||
updatePayload := []byte(`{
|
||||
"update_id": 112233,
|
||||
"message": {
|
||||
"message_id": 999,
|
||||
"from": {"id": 777888, "first_name": "Alice"},
|
||||
"chat": {"id": 777888, "type": "private"},
|
||||
"text": "Hello from Telegram!"
|
||||
}
|
||||
}`)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/third/telegram/webhook/"+channel.ChannelID, bytes.NewBuffer(updatePayload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Telegram-Bot-Api-Secret-Token", "wrong_token")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK wrapper, got: %d", rec.Code)
|
||||
}
|
||||
var resp map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp["ok"] == true {
|
||||
t.Fatalf("expected error for invalid secret token")
|
||||
}
|
||||
|
||||
// 2. Test success with valid secret
|
||||
req2, _ := http.NewRequest(http.MethodPost, "/api/third/telegram/webhook/"+channel.ChannelID, bytes.NewBuffer(updatePayload))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
req2.Header.Set("X-Telegram-Bot-Api-Secret-Token", "my_secret_token_123")
|
||||
|
||||
rec2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec2, req2)
|
||||
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d", rec2.Code)
|
||||
}
|
||||
var resp2 map[string]any
|
||||
_ = json.Unmarshal(rec2.Body.Bytes(), &resp2)
|
||||
if resp2["ok"] != true {
|
||||
t.Fatalf("expected ok: true, got: %+v", resp2)
|
||||
}
|
||||
|
||||
// Verify message in database
|
||||
identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd().
|
||||
Eq("external_source", enums.ExternalSourceTelegram).
|
||||
Eq("external_id", "777888"))
|
||||
if identity == nil {
|
||||
t.Fatalf("expected customer identity for 777888")
|
||||
}
|
||||
}
|
||||
39
internal/handlers/third/zalo_handler.go
Normal file
39
internal/handlers/third/zalo_handler.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package third
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ZaloPostWebhook receives incoming Webhook events from Zalo Official Account.
|
||||
func ZaloPostWebhook(ctx *gin.Context) {
|
||||
channelID := strings.TrimSpace(ctx.Param("channel_id"))
|
||||
if channelID == "" {
|
||||
channelID = strings.TrimSpace(ctx.Query("channel_id"))
|
||||
}
|
||||
|
||||
sigHeader := ctx.GetHeader("X-Zalo-Signature")
|
||||
if sigHeader == "" {
|
||||
sigHeader = ctx.GetHeader("X-Hub-Signature")
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(ctx.Request.Body)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": 1, "message": "failed to read body"})
|
||||
return
|
||||
}
|
||||
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
if err := services.ZaloOAInboundService.HandleWebhook(ctx.Request.Context(), channelID, sigHeader, bodyBytes); err != nil {
|
||||
ctx.JSON(http.StatusOK, gin.H{"error": 1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"error": 0, "message": "Success"})
|
||||
}
|
||||
105
internal/handlers/third/zalo_handler_test.go
Normal file
105
internal/handlers/third/zalo_handler_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package third
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
func TestZaloPostWebhook_Handler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupThirdHandlerTestDB(t)
|
||||
|
||||
now := time.Now()
|
||||
agent := &models.AIAgent{
|
||||
Name: "Zalo OA Bot Agent",
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst,
|
||||
PublishedRevisionID: 1,
|
||||
WelcomeMessage: "Xin chào từ Zalo OA!",
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
_ = db.Create(agent)
|
||||
|
||||
zaloConfig, _ := json.Marshal(dto.ZaloOAChannelConfig{
|
||||
AppID: "1234567890",
|
||||
OAID: "9876543210",
|
||||
AccessToken: "zalo_oa_live_token_abc",
|
||||
WebhookSecret: "my_zalo_secret",
|
||||
})
|
||||
|
||||
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
|
||||
channel, err := services.ChannelService.CreateChannel(request.CreateChannelRequest{
|
||||
Name: "Zalo OA Channel",
|
||||
ChannelType: enums.ChannelTypeZaloOA,
|
||||
AIAgentID: agent.ID,
|
||||
AIAgentRolloutPercent: 100,
|
||||
ConfigJSON: string(zaloConfig),
|
||||
Status: int(enums.StatusOk),
|
||||
}, operator)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel failed: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/third/zalo/webhook/:channel_id", ZaloPostWebhook)
|
||||
router.POST("/api/third/zalo/webhook", ZaloPostWebhook)
|
||||
|
||||
// Inbound message payload
|
||||
updatePayload := []byte(`{
|
||||
"event_name": "user_send_text",
|
||||
"app_id": "1234567890",
|
||||
"sender": {"id": "zalo_uid_999"},
|
||||
"recipient": {"id": "9876543210"},
|
||||
"message": {
|
||||
"msg_id": "zalo_msg_777",
|
||||
"text": "Chào bạn, tôi cần tư vấn mua license Crove Desk"
|
||||
},
|
||||
"info": {
|
||||
"display_name": "Nguyen Van C"
|
||||
},
|
||||
"timestamp": "1756201000"
|
||||
}`)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/third/zalo/webhook/"+channel.ChannelID, bytes.NewBuffer(updatePayload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Zalo-Signature", "my_zalo_secret")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d", rec.Code)
|
||||
}
|
||||
var resp map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp["error"] != float64(0) {
|
||||
t.Fatalf("expected error: 0, got: %+v", resp)
|
||||
}
|
||||
|
||||
// Verify customer identity created
|
||||
identity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd().
|
||||
Eq("external_source", enums.ExternalSourceZaloOA).
|
||||
Eq("external_id", "zalo_uid_999"))
|
||||
if identity == nil {
|
||||
t.Fatalf("expected customer identity for zalo_uid_999")
|
||||
}
|
||||
|
||||
customer := repositories.CustomerRepository.Get(db, identity.CustomerID)
|
||||
if customer == nil || customer.Name != "Nguyen Van C" {
|
||||
t.Fatalf("unexpected customer: %+v", customer)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user