chore: import clean Agent Desk snapshot (2/7)

This commit is contained in:
2026-10-16 11:57:21 +00:00
parent 79176541c7
commit 89c5231c4c
461 changed files with 60092 additions and 0 deletions

View 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})
}

View 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")
}
}

View 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 ""
}

View 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")
}
}

View 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})
}

View 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")
}
}

View 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})
}

View 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")
}
}

View 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"})
}

View 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)
}
}