Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c78f63a
fix(storage): stop guest uploads from becoming same-origin script exe…
JOY Sep 10, 2026
f411969
feat(channel): add native Discord channel integration
JOY Sep 13, 2026
a1e3d7c
feat(channel): add native Slack channel integration
JOY Sep 13, 2026
60e4991
Merge pull request #40 from DOS/fix/upload-file-safety
bbbbbbbbbbbbba Sep 14, 2026
08810b6
Merge pull request #41 from DOS/feat/discord-channel
bbbbbbbbbbbbba Sep 14, 2026
cfdc563
fix(auth): stop granting admin to every first-time OIDC login
JOY Sep 22, 2026
fd82111
fix(security): make the client IP trustworthy and scope credential lo…
JOY Sep 13, 2026
c42d2de
feat(security): rate limit the public endpoints an anonymous caller c…
JOY Sep 13, 2026
7ebed45
feat(auth): auto-redirect to the SSO provider when it is the only tra…
JOY Sep 22, 2026
44fac9c
Merge pull request #48 from DOS/upstream-port-clientip
bbbbbbbbbbbbba Sep 22, 2026
811cdc2
Merge pull request #49 from DOS/upstream-port-ratelimit
bbbbbbbbbbbbba Sep 22, 2026
3ac008c
Merge pull request #47 from DOS/upstream/fix-oidc-first-login-role
bbbbbbbbbbbbba Sep 22, 2026
34a7bda
Merge remote-tracking branch 'upstream/main' into feat/slack-channel
JOY Sep 22, 2026
44c0730
Merge pull request #42 from DOS/feat/slack-channel
bbbbbbbbbbbbba Sep 22, 2026
9c866dd
Merge pull request #50 from DOS/upstream-port-login-redirect
bbbbbbbbbbbbba Sep 22, 2026
17fe1fc
Merge remote-tracking branch 'upstream/main' into dev
JOY Sep 22, 2026
2a0c4ba
fix(sync): dedupe the duplicated Slack handler test and restore upstr…
JOY Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,4 @@ BREVO_API_KEY=xkeysib-your-brevo-api-key

# LINE and Viber store all of their credentials per channel in Dashboard ->
# Channels; they read nothing from the environment.

1 change: 1 addition & 0 deletions internal/bootstrap/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -574,4 +574,5 @@ func registerThirdThreadsRoutes(group *gin.RouterGroup) {
group.GET("/webhook/:channel_id", third.ThreadsGetWebhook)
group.POST("/webhook", third.ThreadsPostWebhook)
group.POST("/webhook/:channel_id", third.ThreadsPostWebhook)

}
1 change: 1 addition & 0 deletions internal/bootstrap/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ func addRouter(app *gin.Engine) {
registerThirdLineRoutes(thirdGroup.Group("/line"))
registerThirdViberRoutes(thirdGroup.Group("/viber"))
registerThirdThreadsRoutes(thirdGroup.Group("/threads"))

}

type spaShellRewrite struct {
Expand Down
157 changes: 157 additions & 0 deletions internal/handlers/third/slack_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
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")
}

if identity == nil {
t.Fatalf("expected customer identity for U_USER_777")
}
}
139 changes: 0 additions & 139 deletions internal/handlers/third/whatsapp_slack_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"

Expand All @@ -33,19 +32,6 @@ func signWhatsAppTestPayload(payload []byte) string {
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}

// slackTestSigningSecret is the Slack signing secret the test channel is
// configured with.
const slackTestSigningSecret = "test_signing_secret"

// signSlackTestPayload builds the X-Slack-Request-Timestamp and
// X-Slack-Signature headers Slack would send for this body right now.
func signSlackTestPayload(payload []byte) (string, string) {
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
mac := hmac.New(sha256.New, []byte(slackTestSigningSecret))
mac.Write([]byte("v0:" + timestamp + ":" + string(payload)))
return timestamp, "v0=" + hex.EncodeToString(mac.Sum(nil))
}

func TestWhatsAppWebhook_Handler(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupThirdHandlerTestDB(t)
Expand Down Expand Up @@ -171,128 +157,3 @@ func TestWhatsAppWebhook_Handler(t *testing.T) {
t.Fatalf("expected customer identity for 1234567890")
}
}

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},
}
_ = db.Create(agent)

slackConfig, _ := json.Marshal(dto.SlackChannelConfig{
BotToken: "xoxb-test-token",
SigningSecret: slackTestSigningSecret,
TeamID: "T_SLACK_100",
DefaultChannel: "C_GENERAL",
})

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)

// 1. URL Verification
challengePayload := []byte(`{
"token": "token123",
"challenge": "slack_challenge_string_999",
"type": "url_verification"
}`)
reqChallenge, _ := http.NewRequest(http.MethodPost, "/api/third/slack/webhook/"+channel.ChannelID, bytes.NewBuffer(challengePayload))
reqChallenge.Header.Set("Content-Type", "application/json")
recChallenge := httptest.NewRecorder()
router.ServeHTTP(recChallenge, reqChallenge)

if recChallenge.Code != http.StatusOK {
t.Fatalf("expected 200 OK for challenge, got: %d", recChallenge.Code)
}
var challengeResp map[string]any
_ = json.Unmarshal(recChallenge.Body.Bytes(), &challengeResp)
if challengeResp["challenge"] != "slack_challenge_string_999" {
t.Fatalf("expected challenge in body, got: %+v", challengeResp)
}

// 2. Event Callback
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"
}
}`)
reqEvent, _ := http.NewRequest(http.MethodPost, "/api/third/slack/webhook/"+channel.ChannelID, bytes.NewBuffer(eventPayload))
reqEvent.Header.Set("Content-Type", "application/json")
slackTimestamp, slackSignature := signSlackTestPayload(eventPayload)
reqEvent.Header.Set("X-Slack-Request-Timestamp", slackTimestamp)
reqEvent.Header.Set("X-Slack-Signature", slackSignature)
recEvent := httptest.NewRecorder()
router.ServeHTTP(recEvent, reqEvent)

if recEvent.Code != http.StatusOK {
t.Fatalf("expected 200 OK for event, got: %d", recEvent.Code)
}

// Verify identity
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")
}

// 3. Unsigned delivery is rejected: once a signing secret resolves for the
// channel, a payload without Slack signature headers must not provision
// anything. The handler still answers 200 ok=false so Slack does not retry.
unsignedPayload := []byte(`{
"token": "token123",
"team_id": "T_SLACK_100",
"type": "event_callback",
"event": {
"type": "message",
"user": "U_USER_888",
"text": "Unsigned spoof attempt",
"ts": "1725260001.000100",
"channel": "C_GENERAL"
}
}`)
reqUnsigned, _ := http.NewRequest(http.MethodPost, "/api/third/slack/webhook/"+channel.ChannelID, bytes.NewBuffer(unsignedPayload))
reqUnsigned.Header.Set("Content-Type", "application/json")
recUnsigned := httptest.NewRecorder()
router.ServeHTTP(recUnsigned, reqUnsigned)

var unsignedResp map[string]any
_ = json.Unmarshal(recUnsigned.Body.Bytes(), &unsignedResp)
if unsignedResp["ok"] != false {
t.Fatalf("expected unsigned delivery to be rejected with ok=false, got: %+v", unsignedResp)
}
unsignedIdentity := repositories.CustomerIdentityRepository.FindOne(db, sqls.NewCnd().
Eq("external_source", enums.ExternalSourceSlack).
Eq("external_id", "U_USER_888"))
if unsignedIdentity != nil {
t.Fatalf("unsigned delivery must not provision an identity for U_USER_888")
}
}
9 changes: 9 additions & 0 deletions internal/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ type ServerConfig struct {
CompanyLogoURL string `yaml:"companyLogoUrl"`
CompanyFaviconURL string `yaml:"companyFaviconUrl"`
CORS CORSConfig `yaml:"cors"`

// TrustedProxies are the CIDR blocks of the reverse proxies that sit in front
// of the application. Gin's own default is 0.0.0.0/0 and ::/0, which trusts
// every peer and makes ClientIP() return the leftmost X-Forwarded-For value -
Expand Down Expand Up @@ -432,6 +433,9 @@ type EmailConfig struct {
InboundSecret string `yaml:"inboundSecret"`
}

// DiscordConfig holds deployment-wide Discord bot credentials. A channel may
// carry its own bot token, which takes precedence; these are the fallback for a
// single shared bot.
type DiscordConfig struct {
ClientID string `yaml:"clientId"`
ClientSecret string `yaml:"clientSecret"`
Expand Down Expand Up @@ -651,6 +655,7 @@ func bindConfigDefaults(v *viper.Viper) {
v.SetDefault("email.smtpPassword", "")
v.SetDefault("email.smtpUseTls", false)
v.SetDefault("email.inboundSecret", "")

v.SetDefault("discord.clientId", "")
v.SetDefault("discord.clientSecret", "")
v.SetDefault("discord.botToken", "")
Expand All @@ -668,6 +673,7 @@ func bindConfigDefaults(v *viper.Viper) {
v.SetDefault("whatsApp.appId", "")
v.SetDefault("whatsApp.appSecret", "")
v.SetDefault("whatsApp.verifyToken", "")

}

func bindEnvironmentAliases(v *viper.Viper) {
Expand All @@ -683,6 +689,7 @@ func bindEnvironmentAliases(v *viper.Viper) {
_ = v.BindEnv("server.companyName", "AGENT_DESK_SERVER_COMPANYNAME", "COMPANY_NAME", "NEXT_PUBLIC_COMPANY_NAME", "BRAND_NAME", "BRAND_COMPANY_NAME")
_ = v.BindEnv("server.companyLogoUrl", "AGENT_DESK_SERVER_COMPANYLOGOURL", "COMPANY_LOGO_URL", "NEXT_PUBLIC_COMPANY_LOGO_URL", "BRAND_LOGO_URL")
_ = v.BindEnv("server.companyFaviconUrl", "AGENT_DESK_SERVER_COMPANYFAVICONURL", "COMPANY_FAVICON_URL", "NEXT_PUBLIC_COMPANY_FAVICON_URL", "BRAND_FAVICON_URL", "FAVICON_URL")

_ = v.BindEnv("server.trustedProxies", "AGENT_DESK_SERVER_TRUSTEDPROXIES", "TRUSTED_PROXIES")
_ = v.BindEnv("server.trustedPlatform", "AGENT_DESK_SERVER_TRUSTEDPLATFORM", "TRUSTED_PLATFORM")
_ = v.BindEnv("server.rateLimit.enabled", "AGENT_DESK_SERVER_RATELIMIT_ENABLED", "RATE_LIMIT_ENABLED")
Expand Down Expand Up @@ -727,6 +734,7 @@ func bindEnvironmentAliases(v *viper.Viper) {
_ = v.BindEnv("email.smtpPassword", "AGENT_DESK_EMAIL_SMTPPASSWORD", "SMTP_PASSWORD", "SMTP_PASS", "EMAIL_SMTP_PASSWORD", "CROVE_SMTP_PASSWORD")
_ = v.BindEnv("email.smtpUseTls", "AGENT_DESK_EMAIL_SMTPUSETLS", "SMTP_USE_TLS", "SMTP_SSL")
_ = v.BindEnv("email.inboundSecret", "AGENT_DESK_EMAIL_INBOUNDSECRET", "EMAIL_INBOUND_SECRET", "EMAIL_WEBHOOK_SECRET")

_ = v.BindEnv("discord.clientId", "AGENT_DESK_DISCORD_CLIENTID", "DISCORD_CLIENT_ID")
_ = v.BindEnv("discord.clientSecret", "AGENT_DESK_DISCORD_CLIENTSECRET", "DISCORD_CLIENT_SECRET")
_ = v.BindEnv("discord.botToken", "AGENT_DESK_DISCORD_BOTTOKEN", "DISCORD_BOT_TOKEN")
Expand All @@ -748,6 +756,7 @@ func bindEnvironmentAliases(v *viper.Viper) {
_ = v.BindEnv("whatsApp.appId", "AGENT_DESK_WHATSAPP_APPID", "WHATSAPP_APP_ID")
_ = v.BindEnv("whatsApp.appSecret", "AGENT_DESK_WHATSAPP_APPSECRET", "WHATSAPP_APP_SECRET")
_ = v.BindEnv("whatsApp.verifyToken", "AGENT_DESK_WHATSAPP_VERIFYTOKEN", "WHATSAPP_VERIFY_TOKEN")

}

func normalizeLoadedConfig(cfg *Config) {
Expand Down
1 change: 1 addition & 0 deletions internal/pkg/i18nx/locales/en-US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ error.whatsapp.oauth.noBusinesses: "This token cannot see any Meta business port
error.whatsapp.oauth.accountsFailed: "Could not list WhatsApp Business Accounts for %s: %s"
error.whatsapp.oauth.phoneNumbersFailed: "Could not list phone numbers for WABA %s: %s"
error.whatsapp.oauth.noAccounts: "No WhatsApp Business Account is reachable with this token. Grant whatsapp_business_management with advanced access, or enter the WABA ID and Phone Number ID manually."

error.profile.nicknameRequired: "Enter a nickname."
error.profile.nicknameTooLong: "Nickname cannot exceed 100 characters."
error.profile.avatarTooLong: "Avatar link cannot exceed 255 characters."
Expand Down
Loading
Loading