diff --git a/.env.example b/.env.example index 49566566..06b1c5af 100644 --- a/.env.example +++ b/.env.example @@ -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. + diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index e4dc1109..65fb071f 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -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) + } diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index 7c1442d5..95d121ba 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -243,6 +243,7 @@ func addRouter(app *gin.Engine) { registerThirdLineRoutes(thirdGroup.Group("/line")) registerThirdViberRoutes(thirdGroup.Group("/viber")) registerThirdThreadsRoutes(thirdGroup.Group("/threads")) + } type spaShellRewrite struct { diff --git a/internal/handlers/third/slack_handler_test.go b/internal/handlers/third/slack_handler_test.go new file mode 100644 index 00000000..72bf72a9 --- /dev/null +++ b/internal/handlers/third/slack_handler_test.go @@ -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") + } +} diff --git a/internal/handlers/third/whatsapp_slack_handler_test.go b/internal/handlers/third/whatsapp_slack_handler_test.go index 73dbb8ff..d28f20a9 100644 --- a/internal/handlers/third/whatsapp_slack_handler_test.go +++ b/internal/handlers/third/whatsapp_slack_handler_test.go @@ -8,7 +8,6 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "strconv" "testing" "time" @@ -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) @@ -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") - } -} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 0e12f602..6bad57e9 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -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 - @@ -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"` @@ -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", "") @@ -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) { @@ -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") @@ -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") @@ -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) { diff --git a/internal/pkg/i18nx/locales/en-US.yml b/internal/pkg/i18nx/locales/en-US.yml index 4a05cef4..69b27a1f 100644 --- a/internal/pkg/i18nx/locales/en-US.yml +++ b/internal/pkg/i18nx/locales/en-US.yml @@ -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." diff --git a/internal/pkg/i18nx/locales/zh-CN.yml b/internal/pkg/i18nx/locales/zh-CN.yml index e7edea11..0b5fdb09 100644 --- a/internal/pkg/i18nx/locales/zh-CN.yml +++ b/internal/pkg/i18nx/locales/zh-CN.yml @@ -369,6 +369,7 @@ error.whatsapp.oauth.noBusinesses: "该令牌无法访问任何 Meta 商务管 error.whatsapp.oauth.accountsFailed: "无法获取 %s 下的 WhatsApp 商业账号:%s" error.whatsapp.oauth.phoneNumbersFailed: "无法获取 WABA %s 的发送号码:%s" error.whatsapp.oauth.noAccounts: "该令牌无法访问任何 WhatsApp 商业账号。请为 whatsapp_business_management 申请高级权限,或手动填写 WABA ID 和 Phone Number ID。" + error.profile.nicknameRequired: "请输入昵称" error.profile.nicknameTooLong: "昵称不能超过 100 个字符" error.profile.avatarTooLong: "头像链接不能超过 255 个字符" diff --git a/internal/services/channel_message_outbox_service.go b/internal/services/channel_message_outbox_service.go index 9d50c93a..1ea4caa2 100644 --- a/internal/services/channel_message_outbox_service.go +++ b/internal/services/channel_message_outbox_service.go @@ -253,11 +253,13 @@ func (s *channelMessageOutboxService) EnqueueZaloOAMessage(conversation *models. } func (s *channelMessageOutboxService) EnqueueEmailMessage(conversation *models.Conversation, message *models.Message) error { + if conversation == nil || message == nil { return nil } channel := ChannelService.Get(conversation.ChannelID) if channel == nil || channel.ChannelType != enums.ChannelTypeEmail { + return nil } if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI { @@ -267,6 +269,7 @@ func (s *channelMessageOutboxService) EnqueueEmailMessage(conversation *models.C return nil } if existing := s.GetByMessageID(enums.ChannelTypeEmail, message.ID); existing != nil { + return nil } @@ -284,7 +287,8 @@ func (s *channelMessageOutboxService) EnqueueEmailMessage(conversation *models.C now := time.Now() err = s.Create(&models.ChannelMessageOutbox{ - ChannelType: enums.ChannelTypeEmail, + ChannelType: enums.ChannelTypeEmail, + ConversationID: conversation.ID, MessageID: message.ID, Payload: string(payload), @@ -310,6 +314,7 @@ func (s *channelMessageOutboxService) EnqueueEmailMessage(conversation *models.C } }() EmailOutboundService.DispatchPendingOutbox() + }() return nil @@ -366,6 +371,7 @@ func (s *channelMessageOutboxService) EnqueueDiscordMessage(conversation *models } // Trigger async dispatch immediately + go func() { defer func() { if r := recover(); r != nil { diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go index 0db9bac3..1d3a5d43 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -430,6 +430,7 @@ func (s *channelService) ParseEmailChannelConfig(raw string) (*dto.EmailChannelC cfg := &dto.EmailChannelConfig{ Provider: "smtp", } + if raw != "" { if err := json.Unmarshal([]byte(raw), cfg); err != nil { return nil, err @@ -452,6 +453,7 @@ func (s *channelService) ParseEmailChannelConfig(raw string) (*dto.EmailChannelC cfg.SMTPPassword = strings.TrimSpace(cfg.SMTPPassword) cfg.WebhookSecret = strings.TrimSpace(cfg.WebhookSecret) cfg.WelcomeMessage = strings.TrimSpace(cfg.WelcomeMessage) + return cfg, nil } @@ -854,6 +856,7 @@ func (s *channelService) GetEnabledChannel(ctx *gin.Context) *models.Channel { func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRequest) (*models.Channel, error) { channelType := strings.TrimSpace(req.ChannelType) if channelType != enums.ChannelTypeWeb && channelType != enums.ChannelTypeWechatMP && channelType != enums.ChannelTypeWxWorkKF && channelType != enums.ChannelTypeTelegram && channelType != enums.ChannelTypeZaloOA && channelType != enums.ChannelTypeEmail && channelType != enums.ChannelTypeDiscord && channelType != enums.ChannelTypeMessenger && channelType != enums.ChannelTypeInstagram && channelType != enums.ChannelTypeWhatsApp && channelType != enums.ChannelTypeSlack && channelType != enums.ChannelTypeX && channelType != enums.ChannelTypeTikTok && channelType != enums.ChannelTypeLine && channelType != enums.ChannelTypeViber && channelType != enums.ChannelTypeThreads { + return nil, errorsx.InvalidParamI18n("error.e0250") } name := strings.TrimSpace(req.Name) @@ -1001,6 +1004,7 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe } configJSON = string(configBytes) case enums.ChannelTypeEmail: + if channelID == "" { channelID = strs.UUID() } @@ -1018,6 +1022,7 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe if secret, err := generateUserTokenSecret(); err == nil { cfg.WebhookSecret = secret } + } configBytes, err := json.Marshal(cfg) if err != nil { @@ -1035,6 +1040,7 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe if err != nil { return nil, errorsx.InvalidParam("invalid discord configuration") } + if cfg.WebhookSecret == "" { if secret, err := generateUserTokenSecret(); err == nil { cfg.WebhookSecret = secret @@ -1223,6 +1229,7 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe return nil, err } configJSON = string(configBytes) + } return &models.Channel{ diff --git a/internal/services/cronx/cron.go b/internal/services/cronx/cron.go index 05124638..66dbe645 100644 --- a/internal/services/cronx/cron.go +++ b/internal/services/cronx/cron.go @@ -37,6 +37,7 @@ func Init() { emailCount := services.EmailOutboundService.DispatchPendingOutbox() if emailCount > 0 { slog.Info("email outbox dispatched", "count", emailCount) + } discordCount := services.DiscordOutboundService.DispatchPendingOutbox() if discordCount > 0 { @@ -78,6 +79,7 @@ func Init() { if tiktokCount > 0 { slog.Info("tiktok outbox dispatched", "count", tiktokCount) } + }) c.Start() diff --git a/internal/services/discord_inbound_service.go b/internal/services/discord_inbound_service.go index 355ccedf..0cbb75a3 100644 --- a/internal/services/discord_inbound_service.go +++ b/internal/services/discord_inbound_service.go @@ -2,8 +2,11 @@ package services import ( "context" + "crypto/subtle" "encoding/json" "fmt" + "log/slog" + "strings" "agent-desk/internal/discord" @@ -40,7 +43,11 @@ func (s *discordInboundService) HandleWebhook(ctx context.Context, channelID str return errorsx.InvalidParam("discord channel config invalid") } - if cfg.WebhookSecret != "" && strings.TrimSpace(secretHeader) != cfg.WebhookSecret { + // Compared in constant time: a byte-wise != leaks how much of the prefix + // matched through response timing. + if cfg.WebhookSecret != "" && + subtle.ConstantTimeCompare([]byte(strings.TrimSpace(secretHeader)), []byte(cfg.WebhookSecret)) != 1 { + return errorsx.UnauthorizedI18n("error.auth.invalidSignature") } @@ -85,6 +92,24 @@ func (s *discordInboundService) HandleWebhook(ctx context.Context, channelID str return nil // Ignore bot messages or invalid authors } + // Honour the channel's guild scope. A bot can be invited to several servers, + // and without these checks GuildID and ChannelScope would be stored + // configuration that silently does nothing. + if cfg.GuildID != "" && guildID != cfg.GuildID { + slog.Debug("ignoring discord message from an out-of-scope guild", + "guild_id", guildID, + "channel", channel.ID, + ) + return nil + } + if cfg.ChannelScope == "dm_only" && guildID != "" { + slog.Debug("ignoring discord guild message, channel is dm_only", + "guild_id", guildID, + "channel", channel.ID, + ) + return nil + } + if text == "" && len(attachments) > 0 { firstAtt := attachments[0] if firstAtt.Filename != "" { diff --git a/internal/services/discord_inbound_service_test.go b/internal/services/discord_inbound_service_test.go index b05fb0e5..86aa88a9 100644 --- a/internal/services/discord_inbound_service_test.go +++ b/internal/services/discord_inbound_service_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" + "testing" "time" @@ -167,3 +169,127 @@ func TestDiscordInboundAndOutbound(t *testing.T) { t.Fatalf("unexpected outbox status: %s", outbox.SendStatus) } } + +func seedDiscordScopedChannel(t *testing.T, db *gorm.DB, channelID string, cfg dto.DiscordChannelConfig) *models.Channel { + t.Helper() + now := time.Now() + agent := &models.AIAgent{ + Name: "Support AI", + Status: enums.StatusOk, + PublishedRevisionID: 1, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(agent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + cfg.BotToken = "discord_bot_token" + cfg.WebhookSecret = "scope_secret" + cfgBytes, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal discord config: %v", err) + } + channel := &models.Channel{ + ChannelType: enums.ChannelTypeDiscord, + ChannelID: channelID, + AIAgentID: agent.ID, + AIAgentRolloutPercent: 100, + Name: "Discord " + channelID, + ConfigJSON: string(cfgBytes), + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(channel).Error; err != nil { + t.Fatalf("create discord channel: %v", err) + } + return channel +} + +func discordScopedPayload(t *testing.T, guildID, messageID string) []byte { + t.Helper() + body := map[string]any{ + "id": messageID, + "channel_id": "discord_text_chan", + "content": "hello from discord", + "author": map[string]any{"id": "user_scope", "username": "scoped_user", "bot": false}, + } + if guildID != "" { + body["guild_id"] = guildID + } + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + return raw +} + +// A bot can be invited to several servers, and GuildID and ChannelScope are +// stored on the channel. Messages outside that scope must not create a +// conversation, otherwise the stored scope is configuration that does nothing. +func TestDiscordInboundHonoursGuildScope(t *testing.T) { + db := setupDiscordTestDB(t) + + guildScoped := seedDiscordScopedChannel(t, db, "discord_guild_scoped", dto.DiscordChannelConfig{ + GuildID: "guild_in_scope", + GuildName: "In Scope", + }) + dmOnly := seedDiscordScopedChannel(t, db, "discord_dm_only", dto.DiscordChannelConfig{ + ChannelScope: "dm_only", + }) + unscoped := seedDiscordScopedChannel(t, db, "discord_unscoped", dto.DiscordChannelConfig{}) + + cases := []struct { + name string + channel *models.Channel + guildID string + wantStored bool + }{ + {"matching guild is accepted", guildScoped, "guild_in_scope", true}, + {"another guild is ignored", guildScoped, "guild_elsewhere", false}, + {"a dm is ignored by a guild scoped channel", guildScoped, "", false}, + {"a dm is accepted by a dm_only channel", dmOnly, "", true}, + {"a guild message is ignored by a dm_only channel", dmOnly, "guild_anywhere", false}, + {"an unscoped channel accepts any guild", unscoped, "guild_anywhere", true}, + {"an unscoped channel accepts a dm", unscoped, "", true}, + } + + for _, tc := range cases { + messageID := "scope_" + strings.ReplaceAll(tc.name, " ", "_") + payload := discordScopedPayload(t, tc.guildID, messageID) + + if err := DiscordInboundService.HandleWebhook(context.Background(), tc.channel.ChannelID, "scope_secret", payload); err != nil { + t.Fatalf("%s: HandleWebhook failed: %v", tc.name, err) + } + + var count int64 + if err := db.Table("t_message").Where("client_msg_id LIKE ?", "%"+messageID).Count(&count).Error; err != nil { + t.Fatalf("%s: count messages: %v", tc.name, err) + } + switch { + case tc.wantStored && count == 0: + t.Errorf("%s: expected the message to be stored", tc.name) + case !tc.wantStored && count != 0: + t.Errorf("%s: expected the message to be dropped, found %d", tc.name, count) + } + } +} + +// The webhook secret is compared in constant time, so a wrong secret of the same +// length must be rejected rather than accepted by a prefix match. +func TestDiscordInboundRejectsWrongWebhookSecret(t *testing.T) { + db := setupDiscordTestDB(t) + channel := seedDiscordScopedChannel(t, db, "discord_secret", dto.DiscordChannelConfig{}) + + payload := discordScopedPayload(t, "", "secret_msg_1") + err := DiscordInboundService.HandleWebhook(context.Background(), channel.ChannelID, "wrong_secret_value", payload) + if err == nil { + t.Fatalf("expected a wrong webhook secret to be rejected") + } + + var count int64 + if err := db.Table("t_message").Where("client_msg_id LIKE ?", "%secret_msg_1").Count(&count).Error; err != nil { + t.Fatalf("count messages: %v", err) + } + if count != 0 { + t.Fatalf("a rejected delivery stored %d messages", count) + } +} diff --git a/internal/services/discord_outbound_service.go b/internal/services/discord_outbound_service.go index 9adf1f5b..20763afe 100644 --- a/internal/services/discord_outbound_service.go +++ b/internal/services/discord_outbound_service.go @@ -105,6 +105,7 @@ func (s *discordOutboundService) processOutbox(outboxID int64) error { if serverCfg := config.GetCurrent(); serverCfg != nil { botToken = strings.TrimSpace(serverCfg.Discord.BotToken) } + } if botToken == "" { botToken = strings.TrimSpace(os.Getenv("DISCORD_BOT_TOKEN")) diff --git a/internal/services/message_service.go b/internal/services/message_service.go index 85ec8d2f..cfc47eff 100644 --- a/internal/services/message_service.go +++ b/internal/services/message_service.go @@ -567,6 +567,7 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation, // Email 渠道消息入队,异步发送 if enqueueErr := ChannelMessageOutboxService.EnqueueEmailMessage(conversation, message); enqueueErr != nil { slog.Error("enqueue email outbox failed", + "conversation_id", conversation.ID, "message_id", message.ID, "error", enqueueErr, @@ -662,6 +663,7 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation, "error", enqueueErr, ) } + // 客户发送消息,触发AI回复 if senderType == enums.IMSenderTypeCustomer { if TriggerAIReplyAsyncHook != nil { diff --git a/internal/services/oidc_login_service.go b/internal/services/oidc_login_service.go index 31684fd8..1829c521 100644 --- a/internal/services/oidc_login_service.go +++ b/internal/services/oidc_login_service.go @@ -303,6 +303,7 @@ func shortSubjectHash(subject string) string { // Administrative roles are only derived from explicit DOS ID organization or // team claims (see syncOIDCUserOrganizations / syncOIDCUserTeams); a missing // or empty claim set must never escalate to admin. + func (s *oidcLoginService) ensureDefaultOIDCRole(tx *gorm.DB, user *models.User) { if user == nil || user.ID <= 0 { return diff --git a/internal/services/oidc_login_service_test.go b/internal/services/oidc_login_service_test.go index 60398fe1..77abea3d 100644 --- a/internal/services/oidc_login_service_test.go +++ b/internal/services/oidc_login_service_test.go @@ -3,9 +3,11 @@ package services import ( "strings" "testing" + "time" "agent-desk/internal/models" "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/enums" ) @@ -289,3 +291,58 @@ func TestIsSupportPortalNext(t *testing.T) { } } } + +// A first-time OIDC user is a stranger the provider vouched for, nothing +// more: they must land on the lowest staff role, never on an administrative +// one. Seeds every candidate role so the assignment cannot pass by accident +// of a missing row. +func TestOIDCLoginFirstUserGetsLowestStaffRole(t *testing.T) { + db := setupAuthServiceTestDB(t) + now := time.Now() + for _, role := range []struct{ name, code string }{ + {"Super Admin", constants.RoleCodeSuperAdmin}, + {"Admin", constants.RoleCodeAdmin}, + {"Support Agent", constants.RoleCodeCsUser}, + } { + if err := db.Create(&models.Role{ + Name: role.name, + Code: role.code, + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + }).Error; err != nil { + t.Fatalf("seed role %s: %v", role.code, err) + } + } + + if _, err := newOIDCLoginService().loginWithOIDCProfile(&oidcLoginProfile{ + Subject: "sub-777", + Email: "stranger@example.com", + PreferredUsername: "stranger", + Name: "Stranger", + RawProfile: `{"sub":"sub-777"}`, + }, config.AuthConfig{TokenTTLHours: 2}, "127.0.0.1", "go-test", false); err != nil { + t.Fatalf("loginWithOIDCProfile() error = %v", err) + } + + var user models.User + if err := db.Take(&user, "username = ?", "stranger").Error; err != nil { + t.Fatalf("expected OIDC user to be created: %v", err) + } + + var roles []models.Role + if err := db. + Joins("JOIN t_user_role ON t_user_role.role_id = t_role.id"). + Where("t_user_role.user_id = ?", user.ID). + Find(&roles).Error; err != nil { + t.Fatalf("query user roles: %v", err) + } + if len(roles) != 1 { + t.Fatalf("expected exactly one role for a first-time OIDC user, got %d", len(roles)) + } + if roles[0].Code != constants.RoleCodeCsUser { + t.Fatalf("first-time OIDC user role = %q, want %q", roles[0].Code, constants.RoleCodeCsUser) + } +} diff --git a/internal/services/slack_inbound_service_test.go b/internal/services/slack_inbound_service_test.go index 02973ecd..87784800 100644 --- a/internal/services/slack_inbound_service_test.go +++ b/internal/services/slack_inbound_service_test.go @@ -182,6 +182,175 @@ func TestSlackInboundAndOutbound(t *testing.T) { // The signature covers the timestamp and the body, but nothing in it expires, // so the five-minute drift window is the only replay protection a captured // delivery faces. Lock the window down. +func seedSlackChannel(t *testing.T, db *gorm.DB, signingSecret string) *models.Channel { + t.Helper() + now := time.Now() + aiAgent := &models.AIAgent{ + Name: "Support AI", + Status: enums.StatusOk, + PublishedRevisionID: 1, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(aiAgent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + cfgBytes, err := json.Marshal(dto.SlackChannelConfig{ + BotToken: "xoxb-test-bot-token-12345", + SigningSecret: signingSecret, + TeamID: "T0123456789", + }) + if err != nil { + t.Fatalf("marshal slack config: %v", err) + } + channel := &models.Channel{ + ChannelType: enums.ChannelTypeSlack, + ChannelID: "slack_sig_channel", + AIAgentID: aiAgent.ID, + AIAgentRolloutPercent: 100, + Name: "Slack Support", + ConfigJSON: string(cfgBytes), + Status: enums.StatusOk, + AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(channel).Error; err != nil { + t.Fatalf("create slack channel: %v", err) + } + return channel +} + +func slackEventPayload(messageID string) []byte { + return []byte(`{ + "team_id": "T0123456789", + "type": "event_callback", + "event": { + "type": "message", + "user": "U_SIG_TEST", + "text": "signature probe", + "ts": "` + messageID + `", + "channel": "C9876543210", + "channel_type": "channel" + } + }`) +} + +func countSlackMessages(t *testing.T, db *gorm.DB, messageTS string) int64 { + t.Helper() + var count int64 + if err := db.Table("t_message").Where("client_msg_id = ?", "slack_C9876543210_"+messageTS).Count(&count).Error; err != nil { + t.Fatalf("count messages: %v", err) + } + return count +} + +// A channel with a signing secret configured must reject anything Slack did not +// sign. Accepting an unsigned delivery would let anyone who learns the webhook +// URL write into a workspace conversation and trigger paid AI replies. +func TestSlackInboundRejectsUnauthenticatedDelivery(t *testing.T) { + db := setupSlackTestDB(t) + channel := seedSlackChannel(t, db, slackTestSigningSecret) + + cases := []struct { + name string + messageTS string + timestamp string + signature string + }{ + {"no signature headers at all", "1725260000.000001", "", ""}, + {"signature without a timestamp", "1725260000.000002", "", "v0=deadbeef"}, + {"timestamp without a signature", "1725260000.000003", "1725260000", ""}, + {"wrong signature", "1725260000.000004", "1725260000", "v0=deadbeef"}, + {"non-numeric timestamp", "1725260000.000005", "not-a-timestamp", "v0=deadbeef"}, + } + + for _, tc := range cases { + payload := slackEventPayload(tc.messageTS) + _, err := SlackInboundService.HandleWebhook(context.Background(), channel.ChannelID, tc.timestamp, tc.signature, payload) + if err == nil { + t.Errorf("%s: expected the delivery to be rejected", tc.name) + continue + } + if countSlackMessages(t, db, tc.messageTS) != 0 { + t.Errorf("%s: a rejected delivery stored a message", tc.name) + } + } +} + +// Slack signs the timestamp and the body but nothing in the signature expires, so +// a captured request replays forever unless the timestamp is checked. Slack's own +// guide requires rejecting anything older than five minutes. +func TestSlackInboundRejectsReplayedTimestamp(t *testing.T) { + db := setupSlackTestDB(t) + channel := seedSlackChannel(t, db, slackTestSigningSecret) + + cases := []struct { + name string + age time.Duration + }{ + {"ten minutes old", 10 * time.Minute}, + {"one hour old", time.Hour}, + {"ten minutes in the future", -10 * time.Minute}, + } + + for i, tc := range cases { + messageTS := "1725260000.0000" + strconv.Itoa(10+i) + payload := slackEventPayload(messageTS) + // Correctly signed for its own timestamp, which is exactly what a replayed + // capture looks like on the wire. + timestamp, signature := signSlackPayloadAt(t, slackTestSigningSecret, payload, time.Now().Add(-tc.age)) + + if _, err := SlackInboundService.HandleWebhook(context.Background(), channel.ChannelID, timestamp, signature, payload); err == nil { + t.Errorf("%s: expected a stale timestamp to be rejected", tc.name) + } + if countSlackMessages(t, db, messageTS) != 0 { + t.Errorf("%s: a replayed delivery stored a message", tc.name) + } + } +} + +// A correctly signed, fresh delivery is still accepted, and one signed just +// inside the tolerance window is not rejected for clock drift. +func TestSlackInboundAcceptsFreshValidSignature(t *testing.T) { + db := setupSlackTestDB(t) + channel := seedSlackChannel(t, db, slackTestSigningSecret) + + cases := []struct { + name string + age time.Duration + }{ + {"signed now", 0}, + {"signed four minutes ago", 4 * time.Minute}, + } + + for i, tc := range cases { + messageTS := "1725260000.0000" + strconv.Itoa(20+i) + payload := slackEventPayload(messageTS) + timestamp, signature := signSlackPayloadAt(t, slackTestSigningSecret, payload, time.Now().Add(-tc.age)) + + if _, err := SlackInboundService.HandleWebhook(context.Background(), channel.ChannelID, timestamp, signature, payload); err != nil { + t.Fatalf("%s: HandleWebhook failed: %v", tc.name, err) + } + if countSlackMessages(t, db, messageTS) != 1 { + t.Errorf("%s: expected the signed message to be stored", tc.name) + } + } +} + +// The url_verification handshake has to be answered before any channel lookup or +// signature check, because Slack sends it once while the endpoint is being +// configured and will not retry. +func TestSlackInboundAnswersURLVerificationChallenge(t *testing.T) { + setupSlackTestDB(t) + + payload := []byte(`{"type":"url_verification","challenge":"challenge_token_abc","token":"verification_token"}`) + challenge, err := SlackInboundService.HandleWebhook(context.Background(), "", "", "", payload) + if err != nil { + t.Fatalf("HandleWebhook failed: %v", err) + } + if challenge == nil || *challenge != "challenge_token_abc" { + t.Fatalf("challenge = %v, want challenge_token_abc", challenge) + } +} + func TestVerifySlackSignatureEnforcesReplayWindow(t *testing.T) { payload := []byte(`{"type":"event_callback","event":{"user":"U1","text":"hi"}}`) now := time.Now() @@ -208,5 +377,6 @@ func TestVerifySlackSignatureEnforcesReplayWindow(t *testing.T) { } if verifySlackSignature(slackTestSigningSecret, "", "v0=deadbeef", payload) { t.Fatal("empty timestamp must be rejected") + } } diff --git a/internal/services/slack_outbound_service.go b/internal/services/slack_outbound_service.go index eed7a921..2f31e8f6 100644 --- a/internal/services/slack_outbound_service.go +++ b/internal/services/slack_outbound_service.go @@ -8,6 +8,7 @@ import ( "agent-desk/internal/models" "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" "agent-desk/internal/repositories" "agent-desk/internal/services/storage" @@ -99,6 +100,7 @@ func (s *slackOutboundService) processOutbox(outboxID int64) error { // fallback for a single shared Slack app. botToken := config.ResolveSlack(cfg.BotToken, cfg.SigningSecret).BotToken if botToken == "" { + return s.markOutboxFailed(outbox, "slack bot token not configured") } @@ -130,6 +132,7 @@ func (s *slackOutboundService) processOutbox(outboxID int64) error { } client := slack.NewClient(botToken) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() diff --git a/internal/slack/client.go b/internal/slack/client.go index 7d5e11a8..145f8b42 100644 --- a/internal/slack/client.go +++ b/internal/slack/client.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "strings" "time" ) diff --git a/internal/slack/client_test.go b/internal/slack/client_test.go index fc99620a..c6429859 100644 --- a/internal/slack/client_test.go +++ b/internal/slack/client_test.go @@ -194,6 +194,7 @@ func TestPostMessageOmitsThreadTSForTopLevel(t *testing.T) { var body map[string]any if err := json.Unmarshal([]byte(gotRaw), &body); err != nil { t.Fatalf("parse request body: %v", err) + } if _, present := body["thread_ts"]; present { t.Errorf("request body carried thread_ts for a top-level message: %s", gotRaw) diff --git a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx index 293c50a7..3c21150e 100644 --- a/web/app/(dashboard)/dashboard/channels/_components/edit.tsx +++ b/web/app/(dashboard)/dashboard/channels/_components/edit.tsx @@ -135,6 +135,7 @@ type WhatsAppChannelConfig = { appSecret?: string } + type SlackChannelConfig = { botToken?: string signingSecret?: string @@ -185,6 +186,7 @@ type ThreadsChannelConfig = { accessToken?: string webhookVerifyToken?: string appSecret?: string + } function getDefaultWebChannelConfig(t: Translate): Required { @@ -202,6 +204,7 @@ function createSchema(t: Translate) { return z .object({ channelType: z.enum(["web", "wechat_mp", "wxwork_kf", "telegram", "zalo_oa", "email", "discord", "messenger", "instagram", "whatsapp", "slack", "x", "tiktok", "line", "viber", "threads"], t("channel.typeRequired")), + aiAgentId: z.string().trim().regex(/^\d+$/, t("channel.agentRequired")), aiAgentRolloutPercent: z.coerce.number().int().min(1).max(100), name: z.string().trim().min(1, t("channel.nameRequired")), @@ -233,6 +236,7 @@ function createSchema(t: Translate) { whatsAppWebhookVerifyToken: z.string().trim(), whatsAppAppId: z.string().trim(), whatsAppAppSecret: z.string().trim(), + slackBotToken: z.string().trim(), slackSigningSecret: z.string().trim(), slackAppId: z.string().trim(), @@ -275,6 +279,7 @@ function createSchema(t: Translate) { smtpPort: z.coerce.number().int().optional(), smtpUser: z.string().trim(), smtpPassword: z.string().trim(), + widgetTitle: z.string().trim(), widgetSubtitle: z.string().trim(), widgetThemeColor: z.string().trim(), @@ -345,6 +350,14 @@ function createSchema(t: Translate) { code: "custom", path: ["threadsUserId"], message: "Threads User ID is required", + + }) + } + if (values.channelType === "slack" && !values.slackBotToken.trim()) { + ctx.addIssue({ + code: "custom", + path: ["slackBotToken"], + message: t("channel.slackBotTokenRequired"), }) } }) @@ -352,6 +365,7 @@ function createSchema(t: Translate) { type EditForm = { channelType: "web" | "wechat_mp" | "wxwork_kf" | "telegram" | "zalo_oa" | "email" | "discord" | "messenger" | "instagram" | "whatsapp" | "slack" | "x" | "tiktok" | "line" | "viber" | "threads" + aiAgentId: string aiAgentRolloutPercent: number name: string @@ -383,6 +397,7 @@ type EditForm = { whatsAppWebhookVerifyToken: string whatsAppAppId: string whatsAppAppSecret: string + slackBotToken: string slackSigningSecret: string slackAppId: string @@ -425,6 +440,7 @@ type EditForm = { smtpPort?: number smtpUser: string smtpPassword: string + widgetTitle: string widgetSubtitle: string widgetThemeColor: string @@ -469,6 +485,7 @@ function createEmptyForm(t: Translate): EditForm { whatsAppWebhookVerifyToken: "", whatsAppAppId: "", whatsAppAppSecret: "", + slackBotToken: "", slackSigningSecret: "", slackAppId: "", @@ -511,6 +528,7 @@ function createEmptyForm(t: Translate): EditForm { smtpPort: 587, smtpUser: "", smtpPassword: "", + widgetTitle: defaultWebChannelConfig.title, widgetSubtitle: defaultWebChannelConfig.subtitle, widgetThemeColor: defaultWebChannelConfig.themeColor, @@ -571,6 +589,7 @@ function parseEmailChannelConfig(configJson: string): EmailChannelConfig { return { emailAddress: parsed.emailAddress?.trim() || "", senderName: parsed.senderName?.trim() || "", + } } catch { return {} @@ -808,6 +827,7 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { const isLine = item.channelType === "line" const isViber = item.channelType === "viber" const isThreads = item.channelType === "threads" + const webConfig = parseWebChannelConfig(item.configJson, t) const wechatConfig = isWechatMP ? parseWechatMPChannelConfig(item.configJson, t) @@ -850,6 +870,7 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { : null const threadsConfig = isThreads ? parseThreadsChannelConfig(item.configJson) + : null return { channelType: @@ -884,6 +905,7 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { : item.channelType === "wechat_mp" ? "wechat_mp" : "web", + aiAgentId: item.aiAgentId > 0 ? String(item.aiAgentId) : "", aiAgentRolloutPercent: item.aiAgentRolloutPercent || 100, name: item.name, @@ -891,6 +913,7 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { botToken: telegramConfig?.botToken || discordConfig?.botToken || "", botUsername: telegramConfig?.botUsername ?? "", webhookSecret: telegramConfig?.webhookSecret || zaloConfig?.webhookSecret || emailConfig?.webhookSecret || discordConfig?.webhookSecret || "", + zaloAppId: zaloConfig?.appId ?? "", zaloOaId: zaloConfig?.oaId ?? "", zaloAccessToken: zaloConfig?.accessToken ?? "", @@ -915,6 +938,7 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { whatsAppWebhookVerifyToken: whatsAppConfig?.webhookVerifyToken ?? "", whatsAppAppId: whatsAppConfig?.appId ?? "", whatsAppAppSecret: whatsAppConfig?.appSecret ?? "", + slackBotToken: slackConfig?.botToken ?? "", slackSigningSecret: slackConfig?.signingSecret ?? "", slackAppId: slackConfig?.appId ?? "", @@ -957,6 +981,7 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm { smtpPort: emailConfig?.smtpPort || 587, smtpUser: emailConfig?.smtpUser || "", smtpPassword: emailConfig?.smtpPassword || "", + widgetTitle: wechatConfig?.title ?? webConfig.title, widgetSubtitle: wechatConfig?.subtitle ?? webConfig.subtitle, widgetThemeColor: wechatConfig?.themeColor ?? webConfig.themeColor, @@ -1087,6 +1112,7 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin webhookVerifyToken: form.threadsWebhookVerifyToken.trim(), }) : channelType === "wechat_mp" + ? JSON.stringify(webLikeConfig) : JSON.stringify({ ...webLikeConfig, @@ -1341,6 +1367,7 @@ function ChannelFormBody({ { value: "threads", label: t("channel.typeThreads") }, { value: "telegram", label: t("channel.typeTelegram") }, { value: "zalo_oa", label: t("channel.typeZaloOa") }, + { value: "wechat_mp", label: t("channel.typeWechatMp") }, { value: "wxwork_kf", label: t("channel.typeWxworkKf") }, ] as const @@ -1816,6 +1843,148 @@ function ChannelFormBody({ ) : null} + {channelType === "slack" ? ( +
+ + {t("channel.slackBotToken")} * + + + + + + + + {t("channel.slackSigningSecret")} + + + +

+ {t("channel.slackSigningSecretHint")} +

+
+
+ +
+ + {t("channel.slackTeamId")} + + + + + + + + {t("channel.slackDefaultChannel")} + + + + + + + + {t("channel.slackAppId")} + + + + + + + + {t("channel.slackTeamName")} + + + + + +
+ +
+
{t("channel.slackSetupTitle")}
+
{t("channel.slackSetupDescription")}
+
+ {t("channel.slackRequestUrl")}: /api/third/slack/webhook +
+
+
+ ) : null} + + {channelType === "discord" ? ( +
+
+ + {t("channel.discordGuildId")} + + + + + + + + {t("channel.discordGuildName")} + + + + + +
+ + + {t("channel.discordBotToken")} + + + + + + +
+
{t("channel.discordSetupTitle")}
+
{t("channel.discordSetupDescription")}
+
+ {t("channel.inboundWebhookUrl")}: /api/third/discord/webhook +
+
+
+ ) : null} + {channelType === "telegram" ? (
diff --git a/web/app/(dashboard)/dashboard/channels/page.tsx b/web/app/(dashboard)/dashboard/channels/page.tsx index 0d42803a..cb082177 100644 --- a/web/app/(dashboard)/dashboard/channels/page.tsx +++ b/web/app/(dashboard)/dashboard/channels/page.tsx @@ -9,12 +9,15 @@ import { MailIcon, MessageCircleIcon, MessageCircleMoreIcon, + MessagesSquareIcon, MessageSquareMoreIcon, PhoneIcon, SendIcon, + SlackIcon, SmartphoneIcon, VideoIcon, + } from "lucide-react" import { @@ -82,6 +85,12 @@ function getChannelTypeLabel(channelType: string, t: (key: string) => string) { if (channelType === "zalo_oa") { return t("channel.typeZaloOa") } + if (channelType === "slack") { + return t("channel.typeSlack") + } + if (channelType === "discord") { + return t("channel.typeDiscord") + } return t("channel.typeWeb") } @@ -138,6 +147,12 @@ function ChannelIcon({ channelType }: { channelType: string }) { if (channelType === "telegram" || channelType === "zalo_oa") { return } + if (channelType === "slack") { + return + } + if (channelType === "discord") { + return + } return } @@ -165,6 +180,8 @@ export default function DashboardChannelsPage() { { value: "viber", label: t("channel.typeViber") }, { value: "threads", label: t("channel.typeThreads") }, { value: "telegram", label: t("channel.typeTelegram") }, + { value: "discord", label: t("channel.typeDiscord") }, + { value: "slack", label: t("channel.typeSlack") }, { value: "zalo_oa", label: t("channel.typeZaloOa") }, { value: "wechat_mp", label: t("channel.typeWechatMp") }, { value: "wxwork_kf", label: t("channel.typeWxworkKf") }, diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 870569a6..ec499f25 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -254,6 +254,7 @@ "oidcSignIn": "Sign in with DOS ID", "redirectingToOidc": "Redirecting to DOS ID...", "retry": "Try again", + "noAccount": "Don't have an account?", "signUp": "Sign up", "termsPrefix": "By clicking continue, you agree to our", @@ -639,6 +640,12 @@ "typeTelegram": "Telegram Bot", "typeZaloOa": "Zalo Official Account", "typeDiscord": "Discord Community", + "discordSetupDescription": "Create a bot in the Discord Developer Portal, enable the Message Content privileged intent, invite it to your server, and point an interaction or bridge endpoint at the webhook URL below. Leave the bot token empty to use the deployment-wide DISCORD_BOT_TOKEN.", + "discordSetupTitle": "Discord Bot Connection", + "slackSigningSecretHint": "Required to verify inbound events. Without it the webhook accepts unsigned requests, so anyone who learns the URL can post into a conversation.", + "slackRequestUrl": "Request URL", + "slackSetupDescription": "Create a Slack app, install it to the workspace, and copy the Bot User OAuth Token and Signing Secret from Basic Information. Subscribe to message.events, then set the Request URL below in Event Subscriptions.", + "slackSetupTitle": "Slack App Connection", "typeMessenger": "Facebook Messenger", "typeInstagram": "Instagram Direct", "typeWhatsApp": "WhatsApp Business", @@ -749,6 +756,7 @@ "slackTeamName": "Workspace Name", "slackDefaultChannel": "Default Channel ID (e.g. C0123456789)", "slackBotToken": "Bot User OAuth Token (xoxb-...)", + "slackBotTokenRequired": "Slack Bot Token is required", "slackSigningSecret": "Signing Secret", "xConnectTitle": "1-Click X (Twitter) API Connection", "xConnectDescription": "Connect your official X brand handle to Crove Desk. Direct messages will route automatically into agent workbench and AI agent.", @@ -785,6 +793,7 @@ "threadsAppSecret": "Meta App Secret", "threadsWebhookVerifyToken": "Webhook Verify Token (Auto-generated)", "threadsVerifyTokenHint": "Generated after saving - paste into your Meta app webhook settings", + "loadFailed": "Could not load channels.", "created": "Channel created: {name}", "updated": "Channel updated: {name}", diff --git a/web/messages/vi-VN.json b/web/messages/vi-VN.json index f61f8b5b..f8ac3c23 100644 --- a/web/messages/vi-VN.json +++ b/web/messages/vi-VN.json @@ -640,6 +640,12 @@ "typeTelegram": "Telegram Bot", "typeZaloOa": "Zalo Official Account", "typeDiscord": "Cộng đồng Discord", + "discordSetupDescription": "Tạo bot trong Discord Developer Portal, bật Message Content privileged intent, mời bot vào server, rồi trỏ endpoint interaction/bridge tới webhook URL bên dưới. Để trống bot token để dùng DISCORD_BOT_TOKEN toàn deployment.", + "discordSetupTitle": "Kết nối Discord Bot", + "slackSigningSecretHint": "Bắt buộc để xác minh event đến. Thiếu nó, webhook sẽ nhận request không chữ ký - ai biết URL cũng có thể nhắn vào hội thoại.", + "slackRequestUrl": "Request URL", + "slackSetupDescription": "Tạo Slack app, cài vào workspace, rồi copy Bot User OAuth Token và Signing Secret từ mục Basic Information. Đăng ký message.events, rồi đặt Request URL bên dưới vào Event Subscriptions.", + "slackSetupTitle": "Kết nối Slack App", "typeMessenger": "Facebook Messenger", "typeInstagram": "Instagram Direct", "typeWhatsApp": "WhatsApp Business", @@ -750,6 +756,7 @@ "slackTeamName": "Tên Workspace", "slackDefaultChannel": "Channel ID Mặc định (ví dụ C0123456789)", "slackBotToken": "Bot User OAuth Token (xoxb-...)", + "slackBotTokenRequired": "Bắt buộc nhập Slack Bot Token", "slackSigningSecret": "Signing Secret", "xConnectTitle": "Kết nối X (Twitter) API 1-Click", "xConnectDescription": "Kết nối tài khoản thương hiệu X của bạn với Crove Desk. Tin nhắn riêng (Direct Messages) sẽ tự động đồng bộ vào Workbench và AI Agent.", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 5caf3c1e..4a36a56e 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -254,6 +254,7 @@ "oidcSignIn": "OIDC 登录", "redirectingToOidc": "正在跳转到 DOS ID...", "retry": "重试", + "noAccount": "还没有账号?", "signUp": "注册", "termsPrefix": "点击继续即表示你同意我们的", @@ -639,6 +640,12 @@ "typeTelegram": "Telegram Bot", "typeZaloOa": "Zalo 公众号", "typeDiscord": "Discord 社区", + "discordSetupDescription": "Tạo bot trong Discord Developer Portal, bật Message Content privileged intent, mời bot vào server, rồi trỏ endpoint interaction/bridge tới webhook URL bên dưới. Để trống bot token để dùng DISCORD_BOT_TOKEN toàn deployment.", + "discordSetupTitle": "Kết nối Discord Bot", + "slackSigningSecretHint": "Bắt buộc để xác minh event đến. Thiếu nó, webhook sẽ nhận request không chữ ký - ai biết URL cũng có thể nhắn vào hội thoại.", + "slackRequestUrl": "Request URL", + "slackSetupDescription": "Tạo Slack app, cài vào workspace, rồi copy Bot User OAuth Token và Signing Secret từ mục Basic Information. Đăng ký message.events, rồi đặt Request URL bên dưới vào Event Subscriptions.", + "slackSetupTitle": "Kết nối Slack App", "typeMessenger": "Facebook Messenger", "typeInstagram": "Instagram Direct", "typeWhatsApp": "WhatsApp Business", @@ -749,6 +756,7 @@ "slackTeamName": "工作区名称", "slackDefaultChannel": "默认转发频道 ID (如 C0123456789)", "slackBotToken": "Bot User OAuth Token (xoxb-...)", + "slackBotTokenRequired": "必须填写 Slack Bot Token", "slackSigningSecret": "Signing Secret", "xConnectTitle": "X (Twitter) API 一键授权连接", "xConnectDescription": "一键连接您的 X 官方品牌账号,客户私信(Direct Messages)将自动同步至客服工作台并触发 AI 回复。", @@ -785,6 +793,7 @@ "threadsAppSecret": "Meta App Secret", "threadsWebhookVerifyToken": "Webhook 验证令牌(自动生成)", "threadsVerifyTokenHint": "保存后生成 - 粘贴到 Meta 应用的 Webhook 设置中", + "loadFailed": "加载接入渠道失败", "created": "已创建接入渠道:{name}", "updated": "已更新接入渠道:{name}",