Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,22 @@ BREVO_API_KEY=xkeysib-your-brevo-api-key
# MESSENGER_VERIFY_TOKEN=your-webhook-verify-token

# Slack Bot Integration (Slack Web API & Events API)
# The client id and secret are what the OAuth install flow needs. The bot token
# and signing secret are the deployment-wide fallback; a channel that carries its
# own overrides them.
# SLACK_CLIENT_ID=your-slack-client-id
# SLACK_CLIENT_SECRET=your-slack-client-secret
# SLACK_BOT_TOKEN=xoxb-your-slack-bot-token
# SLACK_SIGNING_SECRET=your-slack-signing-secret

# X (Twitter) OAuth 2.0 client identifier, used to build the authorization URL.
# Requires the dm.read, dm.write and users.read scopes, and a redirect URI
# registered on the X app.
# X_CLIENT_ID=your-x-oauth2-client-id
# TWITTER_CLIENT_ID=legacy-alias-for-X_CLIENT_ID

# TikTok Business Messaging application client key.
# TIKTOK_CLIENT_KEY=your-tiktok-client-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 @@ -293,6 +293,7 @@ func registerDashboardChannelRoutes(group *gin.RouterGroup) {
group.GET("/whatsapp_oauth_url", dashboard.ChannelGetWhatsAppOAuthURL)
group.POST("/whatsapp_oauth_callback", dashboard.ChannelPostWhatsAppOAuthCallback)
group.GET("/slack_oauth_url", dashboard.ChannelGetSlackOAuthURL)
group.POST("/slack_oauth_callback", dashboard.ChannelPostSlackOAuthCallback)
group.GET("/x_oauth_url", dashboard.ChannelGetXOAuthURL)
group.GET("/tiktok_oauth_url", dashboard.ChannelGetTikTokOAuthURL)
group.Any("/wxwork/kf/accounts", dashboard.ChannelAnyWxworkKfAccounts)
Expand Down
72 changes: 66 additions & 6 deletions internal/handlers/dashboard/channel_oauth_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,12 @@ func ChannelGetDiscordOAuthURL(ctx *gin.Context) {
redirectURI := strings.TrimSpace(ctx.Query("redirect_uri"))

if clientID == "" {
// Provide guidance or sample client id
clientID = "123456789012345678"
writeMissingOAuthCredential(ctx, "DISCORD_CLIENT_ID")
return
}
if redirectURI == "" {
httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.param.required", "redirect_uri"))
return
}

state := strings.TrimSpace(ctx.Query("state"))
Expand All @@ -69,6 +73,14 @@ func ChannelGetDiscordOAuthURL(ctx *gin.Context) {
// cannot drift onto different versions.
const metaOAuthDialogURL = "https://www.facebook.com/v21.0/dialog/oauth"

// writeMissingOAuthCredential reports an absent OAuth client identifier instead of
// inventing one. A fabricated id sends the operator to the provider's own error
// page, which reads as an application bug rather than a missing configuration
// value, and the real cause is never visible.
func writeMissingOAuthCredential(ctx *gin.Context, envName string) {
httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.channel.oauth.clientIdMissing", envName))
}

// Scopes each Meta product needs to send and receive support messages.
const (
messengerOAuthScope = "pages_show_list,pages_messaging,pages_manage_metadata"
Expand Down Expand Up @@ -206,21 +218,59 @@ func ChannelPostWhatsAppOAuthCallback(ctx *gin.Context) {
httpx.WriteJSON(ctx, result)
}

// ChannelPostSlackOAuthCallback exchanges the installation code Slack redirected
// back with for the workspace's bot credentials, and saves them when channelId
// names an existing channel.
func ChannelPostSlackOAuthCallback(ctx *gin.Context) {
req := request.SlackOAuthCallbackRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}

// Saving onto an existing channel is an update; exchanging credentials for a
// channel that does not exist yet is part of creating one.
permission := constants.PermissionChannelCreate
if req.ChannelID > 0 {
permission = constants.PermissionChannelUpdate
}
operator, err := services.AuthService.RequirePermission(ctx, permission)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}

result, err := services.SlackOAuthService.Connect(req, i18nx.Locale(ctx), operator)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, result)
}

// ChannelGetSlackOAuthURL returns the 1-Click OAuth authorization URL for Slack Workspace Bot.
func ChannelGetSlackOAuthURL(ctx *gin.Context) {
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelView); err != nil {
httpx.WriteJSON(ctx, err)
return
}

clientID := strings.TrimSpace(os.Getenv("SLACK_CLIENT_ID"))
clientID := config.ResolveSlack("", "").ClientID
if clientID == "" {
clientID = strings.TrimSpace(os.Getenv("SLACK_CLIENT_ID"))
}
if clientID == "" {
clientID = strings.TrimSpace(ctx.Query("client_id"))
}
redirectURI := strings.TrimSpace(ctx.Query("redirect_uri"))

if clientID == "" {
clientID = "123456789012.1234567890123"
writeMissingOAuthCredential(ctx, "SLACK_CLIENT_ID")
return
}
if redirectURI == "" {
httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.param.required", "redirect_uri"))
return
}

state := strings.TrimSpace(ctx.Query("state"))
Expand Down Expand Up @@ -259,7 +309,12 @@ func ChannelGetXOAuthURL(ctx *gin.Context) {
redirectURI := strings.TrimSpace(ctx.Query("redirect_uri"))

if clientID == "" {
clientID = "x_oauth_client_id_placeholder"
writeMissingOAuthCredential(ctx, "X_CLIENT_ID")
return
}
if redirectURI == "" {
httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.param.required", "redirect_uri"))
return
}

state := strings.TrimSpace(ctx.Query("state"))
Expand Down Expand Up @@ -295,7 +350,12 @@ func ChannelGetTikTokOAuthURL(ctx *gin.Context) {
redirectURI := strings.TrimSpace(ctx.Query("redirect_uri"))

if clientKey == "" {
clientKey = "tiktok_client_key_placeholder"
writeMissingOAuthCredential(ctx, "TIKTOK_CLIENT_KEY")
return
}
if redirectURI == "" {
httpx.WriteJSON(ctx, errorsx.InvalidParamI18n("error.param.required", "redirect_uri"))
return
}

state := strings.TrimSpace(ctx.Query("state"))
Expand Down
51 changes: 50 additions & 1 deletion internal/handlers/third/whatsapp_slack_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"

Expand All @@ -32,6 +33,19 @@ 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 @@ -175,7 +189,7 @@ func TestSlackWebhook_Handler(t *testing.T) {

slackConfig, _ := json.Marshal(dto.SlackChannelConfig{
BotToken: "xoxb-test-token",
SigningSecret: "test_signing_secret",
SigningSecret: slackTestSigningSecret,
TeamID: "T_SLACK_100",
DefaultChannel: "C_GENERAL",
})
Expand Down Expand Up @@ -232,6 +246,9 @@ func TestSlackWebhook_Handler(t *testing.T) {
}`)
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)

Expand All @@ -246,4 +263,36 @@ func TestSlackWebhook_Handler(t *testing.T) {
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")
}
}
39 changes: 35 additions & 4 deletions internal/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type Config struct {
Webhook WebhookConfig `yaml:"webhook"`
Email EmailConfig `yaml:"email"`
Discord DiscordConfig `yaml:"discord"`
Slack SlackConfig `yaml:"slack"`
Messenger MessengerConfig `yaml:"messenger"`
Instagram InstagramConfig `yaml:"instagram"`
WhatsApp WhatsAppConfig `yaml:"whatsApp"`
Expand Down Expand Up @@ -438,6 +439,16 @@ type DiscordConfig struct {
PublicKey string `yaml:"publicKey"`
}

// SlackConfig holds deployment-wide Slack app credentials. A channel may carry
// its own bot token and signing secret, which take precedence; these are the
// fallback for a single shared Slack app.
type SlackConfig struct {
ClientID string `yaml:"clientId"`
ClientSecret string `yaml:"clientSecret"`
BotToken string `yaml:"botToken"`
SigningSecret string `yaml:"signingSecret"`
}

type MessengerConfig struct {
AppID string `yaml:"appId"`
AppSecret string `yaml:"appSecret"`
Expand Down Expand Up @@ -477,13 +488,13 @@ type MetaAppCredentials struct {
// sets the per-product variables and stops inheriting.
func mergeMetaApp(product MetaAppCredentials, shared MessengerConfig, channelAppID, channelAppSecret string) MetaAppCredentials {
return MetaAppCredentials{
AppID: firstNonBlankMeta(channelAppID, product.AppID, shared.AppID),
AppSecret: firstNonBlankMeta(channelAppSecret, product.AppSecret, shared.AppSecret),
VerifyToken: firstNonBlankMeta(product.VerifyToken, shared.VerifyToken),
AppID: firstNonBlank(channelAppID, product.AppID, shared.AppID),
AppSecret: firstNonBlank(channelAppSecret, product.AppSecret, shared.AppSecret),
VerifyToken: firstNonBlank(product.VerifyToken, shared.VerifyToken),
}
}

func firstNonBlankMeta(values ...string) string {
func firstNonBlank(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
Expand Down Expand Up @@ -522,6 +533,18 @@ func (c Config) WhatsAppApp(channelAppID, channelAppSecret string) MetaAppCreden
)
}

// SlackApp resolves the Slack app credentials for one channel. A channel value
// wins over the deployment-wide one, so a single deployment can serve several
// workspaces while still having a default app.
func (c Config) SlackApp(channelBotToken, channelSigningSecret string) SlackConfig {
return SlackConfig{
ClientID: strings.TrimSpace(c.Slack.ClientID),
ClientSecret: strings.TrimSpace(c.Slack.ClientSecret),
BotToken: firstNonBlank(channelBotToken, c.Slack.BotToken),
SigningSecret: firstNonBlank(channelSigningSecret, c.Slack.SigningSecret),
}
}

func Load(path string) (*Config, error) {
loadDotEnv(path)

Expand Down Expand Up @@ -632,6 +655,10 @@ func bindConfigDefaults(v *viper.Viper) {
v.SetDefault("discord.clientSecret", "")
v.SetDefault("discord.botToken", "")
v.SetDefault("discord.publicKey", "")
v.SetDefault("slack.clientId", "")
v.SetDefault("slack.clientSecret", "")
v.SetDefault("slack.botToken", "")
v.SetDefault("slack.signingSecret", "")
v.SetDefault("messenger.appId", "")
v.SetDefault("messenger.appSecret", "")
v.SetDefault("messenger.verifyToken", "")
Expand Down Expand Up @@ -704,6 +731,10 @@ func bindEnvironmentAliases(v *viper.Viper) {
_ = v.BindEnv("discord.clientSecret", "AGENT_DESK_DISCORD_CLIENTSECRET", "DISCORD_CLIENT_SECRET")
_ = v.BindEnv("discord.botToken", "AGENT_DESK_DISCORD_BOTTOKEN", "DISCORD_BOT_TOKEN")
_ = v.BindEnv("discord.publicKey", "AGENT_DESK_DISCORD_PUBLICKEY", "DISCORD_PUBLIC_KEY")
_ = v.BindEnv("slack.clientId", "AGENT_DESK_SLACK_CLIENTID", "SLACK_CLIENT_ID")
_ = v.BindEnv("slack.clientSecret", "AGENT_DESK_SLACK_CLIENTSECRET", "SLACK_CLIENT_SECRET")
_ = v.BindEnv("slack.botToken", "AGENT_DESK_SLACK_BOTTOKEN", "SLACK_BOT_TOKEN")
_ = v.BindEnv("slack.signingSecret", "AGENT_DESK_SLACK_SIGNINGSECRET", "SLACK_SIGNING_SECRET")
_ = v.BindEnv("messenger.appId", "AGENT_DESK_MESSENGER_APPID", "FACEBOOK_APP_ID", "META_APP_ID", "FB_APP_ID", "MESSENGER_APP_ID")
_ = v.BindEnv("messenger.appSecret", "AGENT_DESK_MESSENGER_APPSECRET", "FACEBOOK_APP_SECRET", "META_APP_SECRET", "FB_APP_SECRET", "MESSENGER_APP_SECRET")
_ = v.BindEnv("messenger.verifyToken", "AGENT_DESK_MESSENGER_VERIFYTOKEN", "FACEBOOK_VERIFY_TOKEN", "MESSENGER_VERIFY_TOKEN", "META_VERIFY_TOKEN", "FB_VERIFY_TOKEN")
Expand Down
12 changes: 12 additions & 0 deletions internal/pkg/config/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,15 @@ func unresolvedMetaApp(channelAppID, channelAppSecret string) MetaAppCredentials
AppSecret: strings.TrimSpace(channelAppSecret),
}
}

// ResolveSlack resolves the Slack app credentials for one channel without
// requiring the caller to nil-check the loaded configuration.
func ResolveSlack(channelBotToken, channelSigningSecret string) SlackConfig {
if current == nil {
return SlackConfig{
BotToken: strings.TrimSpace(channelBotToken),
SigningSecret: strings.TrimSpace(channelSigningSecret),
}
}
return current.SlackApp(channelBotToken, channelSigningSecret)
}
44 changes: 44 additions & 0 deletions internal/pkg/config/slack_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package config

import "testing"

func TestSlackAppChannelValuesWinOverDeployment(t *testing.T) {
cfg := Config{Slack: SlackConfig{
ClientID: "deploy-client-id",
ClientSecret: "deploy-client-secret",
BotToken: "xoxb-deploy",
SigningSecret: "deploy-signing-secret",
}}

app := cfg.SlackApp("xoxb-channel", "channel-signing-secret")
if app.ClientID != "deploy-client-id" || app.ClientSecret != "deploy-client-secret" {
t.Fatalf("the app itself is deployment-wide, got %+v", app)
}
if app.BotToken != "xoxb-channel" {
t.Fatalf("channel bot token must win over the deployment one, got %q", app.BotToken)
}
if app.SigningSecret != "channel-signing-secret" {
t.Fatalf("channel signing secret must win over the deployment one, got %q", app.SigningSecret)
}

fallback := cfg.SlackApp(" ", "")
if fallback.BotToken != "xoxb-deploy" || fallback.SigningSecret != "deploy-signing-secret" {
t.Fatalf("blank channel values must fall back to the deployment app, got %+v", fallback)
}
}

// ResolveSlack must stay usable before Load has run (tests, standalone
// commands): the channel values pass through and nothing panics.
func TestResolveSlackWithoutLoadedConfig(t *testing.T) {
saved := GetCurrent()
defer SetCurrent(saved)

SetCurrent(nil)
app := ResolveSlack("xoxb-channel", "channel-signing-secret")
if app.BotToken != "xoxb-channel" || app.SigningSecret != "channel-signing-secret" {
t.Fatalf("expected channel values to pass through, got %+v", app)
}
if app.ClientID != "" || app.ClientSecret != "" {
t.Fatalf("no deployment app exists before Load, got %+v", app)
}
}
9 changes: 9 additions & 0 deletions internal/pkg/dto/request/channel_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,12 @@ type WhatsAppOAuthCallbackRequest struct {
PhoneNumberID string `json:"phoneNumberId"`
WabaID string `json:"wabaId"`
}

// SlackOAuthCallbackRequest carries the installation code Slack redirected back
// with. ChannelID is optional and means the same thing as on the WhatsApp one.
type SlackOAuthCallbackRequest struct {
Code string `json:"code"`
State string `json:"state"`
ChannelID int64 `json:"channelId"`
RedirectURI string `json:"redirectUri"`
}
Loading
Loading