diff --git a/pkg/bot/slack_cloud.go b/pkg/bot/slack_cloud.go index 7917ada73..349d40cd1 100644 --- a/pkg/bot/slack_cloud.go +++ b/pkg/bot/slack_cloud.go @@ -29,6 +29,7 @@ import ( pb "github.com/kubeshop/botkube/pkg/api/cloudslack" "github.com/kubeshop/botkube/pkg/bot/interactive" "github.com/kubeshop/botkube/pkg/config" + conversationx "github.com/kubeshop/botkube/pkg/conversation" "github.com/kubeshop/botkube/pkg/execute" "github.com/kubeshop/botkube/pkg/execute/command" "github.com/kubeshop/botkube/pkg/formatx" @@ -87,7 +88,7 @@ func NewCloudSlack(log logrus.FieldLogger, return nil, err } - channels := slackChannelsConfigFrom(log, cfg.Channels) + channels := cloudSlackChannelsConfigFrom(log, cfg.Channels) if err != nil { return nil, fmt.Errorf("while producing channels configuration map by ID: %w", err) } @@ -573,7 +574,7 @@ func (b *CloudSlack) send(ctx context.Context, event slackMessage, resp interact // TODO: Currently, we don't get the channel ID once we use modal. This needs to be investigated and fixed. // // we can open modal only if we have a TriggerID (it's available when user clicks a button) - //if resp.Type == api.PopupMessage && event.TriggerID != "" { + // if resp.Type == api.PopupMessage && event.TriggerID != "" { // modalView := b.renderer.RenderModal(resp) // modalView.PrivateMetadata = event.Channel // _, err := b.client.OpenViewContext(ctx, event.TriggerID, modalView) @@ -581,7 +582,7 @@ func (b *CloudSlack) send(ctx context.Context, event slackMessage, resp interact // return fmt.Errorf("while opening modal: %w", err) // } // return nil - //} + // } options := []slack.MsgOption{ b.renderer.RenderInteractiveMessage(resp), @@ -766,3 +767,22 @@ func (b *CloudSlack) GetStatus() health.PlatformStatus { Reason: b.failureReason, } } + +func cloudSlackChannelsConfigFrom(log logrus.FieldLogger, channelsCfg config.IdentifiableMap[config.CloudSlackChannel]) map[string]channelConfigByName { + channels := make(map[string]channelConfigByName) + for channAlias, channCfg := range channelsCfg { + normalizedChannelName, changed := conversationx.NormalizeChannelIdentifier(channCfg.Name) + if changed { + log.Warnf("Channel name %q has been normalized to %q", channCfg.Name, normalizedChannelName) + } + channCfg.Name = normalizedChannelName + + channels[channCfg.Identifier()] = channelConfigByName{ + ChannelBindingsByName: channCfg.ChannelBindingsByName, + alias: channAlias, + notify: !channCfg.Notification.Disabled, + } + } + + return channels +} diff --git a/pkg/config/config.go b/pkg/config/config.go index da6e41d91..22b9e4181 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -11,6 +11,7 @@ import ( koanfyaml "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/providers/rawbytes" + "github.com/mitchellh/mapstructure" "golang.org/x/text/cases" "golang.org/x/text/language" ) @@ -97,7 +98,7 @@ const ( // DiscordCommPlatformIntegration defines Discord integration. DiscordCommPlatformIntegration CommPlatformIntegration = "discord" - //ElasticsearchCommPlatformIntegration defines Elasticsearch integration. + // ElasticsearchCommPlatformIntegration defines Elasticsearch integration. ElasticsearchCommPlatformIntegration CommPlatformIntegration = "elasticsearch" // WebhookCommPlatformIntegration defines an outgoing webhook integration. @@ -195,6 +196,18 @@ type IncomingWebhook struct { InClusterBaseURL string `yaml:"inClusterBaseURL"` } +// CloudSlackChannel contains configuration bindings per channel. +type CloudSlackChannel struct { + ChannelBindingsByName `yaml:",inline" mapstructure:",squash"` + + // ChannelID is the Slack ID of the channel. + // Currently, it is used for AI plugin as it has ability to fetch the Botkube Agent configuration. + // Later it can be used for deep linking to a given channel, see: https://api.slack.com/reference/deep-linking#app_channel + ChannelID string `yaml:"channelID"` + // Alias is an optional public alias for a private channel. + Alias *string `yaml:"alias,omitempty"` +} + // ChannelBindingsByName contains configuration bindings per channel. type ChannelBindingsByName struct { Name string `yaml:"name"` @@ -498,12 +511,12 @@ type SocketSlack struct { // CloudSlack configuration for multi-slack support type CloudSlack struct { - Enabled bool `yaml:"enabled"` - Channels IdentifiableMap[ChannelBindingsByName] `yaml:"channels" validate:"required_if=Enabled true,dive,omitempty,min=1"` - Token string `yaml:"token"` - BotID string `yaml:"botID,omitempty"` - Server GRPCServer `yaml:"server"` - ExecutionEventStreamingDisabled bool `yaml:"executionEventStreamingDisabled"` + Enabled bool `yaml:"enabled"` + Channels IdentifiableMap[CloudSlackChannel] `yaml:"channels" validate:"required_if=Enabled true,dive,omitempty,min=1"` + Token string `yaml:"token"` + BotID string `yaml:"botID,omitempty"` + Server GRPCServer `yaml:"server"` + ExecutionEventStreamingDisabled bool `yaml:"executionEventStreamingDisabled"` } // GRPCServer config for gRPC server @@ -715,7 +728,20 @@ func LoadWithDefaults(configs [][]byte) (*Config, LoadWithDefaultsDetails, error } var cfg Config - err = k.Unmarshal("", &cfg) + err = k.UnmarshalWithConf("", &cfg, koanf.UnmarshalConf{ + DecoderConfig: &mapstructure.DecoderConfig{ + Squash: true, // needed to properly unmarshal CloudSlack channel's ChannelBindingsByName + + // also use defaults from koanf.UnmarshalWithConf + DecodeHook: mapstructure.ComposeDecodeHookFunc( + mapstructure.StringToTimeDurationHookFunc(), + mapstructure.StringToSliceHookFunc(","), + mapstructure.TextUnmarshallerHookFunc()), + Metadata: nil, + Result: &cfg, + WeaklyTypedInput: true, + }, + }) if err != nil { return nil, LoadWithDefaultsDetails{}, err } diff --git a/pkg/config/redacted.go b/pkg/config/redacted.go new file mode 100644 index 000000000..b40e36a1f --- /dev/null +++ b/pkg/config/redacted.go @@ -0,0 +1,44 @@ +package config + +import ( + "fmt" +) + +const redactedSecretStr = "*** REDACTED ***" + +// HideSensitiveInfo removes sensitive information from the config. +func HideSensitiveInfo(in Config) Config { + out := in + // TODO: avoid printing sensitive data without need to resetting them manually (which is an error-prone approach) + for key, val := range out.Communications { + val.SocketSlack.AppToken = redactedSecretStr + val.SocketSlack.BotToken = redactedSecretStr + val.Elasticsearch.Password = redactedSecretStr + val.Discord.Token = redactedSecretStr + val.Mattermost.Token = redactedSecretStr + val.CloudSlack.Token = redactedSecretStr + // To keep the printed config readable, we don't print the certificate bytes. + val.CloudSlack.Server.TLS.CACertificate = nil + val.CloudTeams.Server.TLS.CACertificate = nil + + // Replace private channel names with aliases + cloudSlackChannels := make(IdentifiableMap[CloudSlackChannel]) + for _, channel := range val.CloudSlack.Channels { + if channel.Alias == nil { + cloudSlackChannels[channel.ChannelBindingsByName.Name] = channel + continue + } + + outChannel := channel + outChannel.ChannelBindingsByName.Name = fmt.Sprintf("%s (public alias)", *channel.Alias) + outChannel.Alias = nil + cloudSlackChannels[*channel.Alias] = outChannel + } + val.CloudSlack.Channels = cloudSlackChannels + + // maps are not addressable: https://stackoverflow.com/questions/42605337/cannot-assign-to-struct-field-in-a-map + out.Communications[key] = val + } + + return out +} diff --git a/pkg/execute/config.go b/pkg/execute/config.go index deb9e620f..7be7d4bc5 100644 --- a/pkg/execute/config.go +++ b/pkg/execute/config.go @@ -48,40 +48,14 @@ func (e *ConfigExecutor) Commands() map[command.Verb]CommandFn { // Show returns Config in yaml format func (e *ConfigExecutor) Show(_ context.Context, cmdCtx CommandContext) (interactive.CoreMessage, error) { - cfg, err := e.renderBotkubeConfiguration() + redactedCfg := config.HideSensitiveInfo(e.cfg) + bytes, err := yaml.Marshal(redactedCfg) if err != nil { return interactive.CoreMessage{}, fmt.Errorf("while rendering Botkube configuration: %w", err) } - return respond(cfg, cmdCtx), nil -} - -const redactedSecretStr = "*** REDACTED ***" - -func (e *ConfigExecutor) renderBotkubeConfiguration() (string, error) { - cfg := e.cfg - - // hide sensitive info - // TODO: avoid printing sensitive data without need to resetting them manually (which is an error-prone approach) - for key, val := range cfg.Communications { - val.SocketSlack.AppToken = redactedSecretStr - val.SocketSlack.BotToken = redactedSecretStr - val.Elasticsearch.Password = redactedSecretStr - val.Discord.Token = redactedSecretStr - val.Mattermost.Token = redactedSecretStr - val.CloudSlack.Token = redactedSecretStr - - // To keep the printed config readable, we don't print the certificate bytes. - val.CloudSlack.Server.TLS.CACertificate = nil - val.CloudTeams.Server.TLS.CACertificate = nil - // maps are not addressable: https://stackoverflow.com/questions/42605337/cannot-assign-to-struct-field-in-a-map - cfg.Communications[key] = val - } - - b, err := yaml.Marshal(cfg) if err != nil { - return "", err + return interactive.CoreMessage{}, fmt.Errorf("while rendering Botkube configuration: %w", err) } - - return string(b), nil + return respond(string(bytes), cmdCtx), nil }