diff --git a/extras/scion-chat-app/cmd/scion-chat-app/main.go b/extras/scion-chat-app/cmd/scion-chat-app/main.go index 44600db67..6b78b7c5f 100644 --- a/extras/scion-chat-app/cmd/scion-chat-app/main.go +++ b/extras/scion-chat-app/cmd/scion-chat-app/main.go @@ -39,6 +39,7 @@ import ( "github.com/GoogleCloudPlatform/scion/extras/scion-chat-app/internal/chatapp" "github.com/GoogleCloudPlatform/scion/extras/scion-chat-app/internal/googlechat" "github.com/GoogleCloudPlatform/scion/extras/scion-chat-app/internal/identity" + slackadapter "github.com/GoogleCloudPlatform/scion/extras/scion-chat-app/internal/slack" "github.com/GoogleCloudPlatform/scion/extras/scion-chat-app/internal/state" "github.com/GoogleCloudPlatform/scion/pkg/hubclient" ) @@ -231,11 +232,42 @@ func main() { ) } + // Initialize the Slack adapter if enabled. + var slackAdapter *slackadapter.Adapter + if cfg.Platforms.Slack.Enabled { + slackAdapter = slackadapter.NewAdapter(slackadapter.Config{ + BotToken: cfg.Platforms.Slack.BotToken, + AppToken: cfg.Platforms.Slack.AppToken, + SigningSecret: cfg.Platforms.Slack.SigningSecret, + ListenAddress: cfg.Platforms.Slack.ListenAddress, + SocketMode: cfg.Platforms.Slack.SocketMode, + }, cmdRouter.HandleEvent, log.With("component", "slack")) + slackAdapter.SetStore(store) + slackAdapter.SetIdentityMapper(idMapper) + + if messenger == nil { + messenger = slackAdapter + } + log.Info("slack adapter initialized") + } + // Wire the messenger into the command router now that it exists. cmdRouter.SetMessenger(messenger) + if cfg.Platforms.GoogleChat.Enabled && messenger != nil { + cmdRouter.RegisterMessenger("google_chat", messenger) + } + if slackAdapter != nil { + cmdRouter.RegisterMessenger("slack", slackAdapter) + } // Create notification relay and wire it as the broker's message handler. relay := chatapp.NewNotificationRelay(store, messenger, log.With("component", "notifications")) + if cfg.Platforms.GoogleChat.Enabled && messenger != nil { + relay.RegisterMessenger("google_chat", messenger) + } + if slackAdapter != nil { + relay.RegisterMessenger("slack", slackAdapter) + } broker.SetHandler(relay.HandleBrokerMessage) // Load existing space-grove links and request broker subscriptions. @@ -258,18 +290,41 @@ func main() { // Start platform servers. errCh := make(chan error, 1) - if cfg.Platforms.GoogleChat.Enabled && messenger != nil { - gcAdapter := messenger.(*googlechat.Adapter) - listenAddr := cfg.Platforms.GoogleChat.ListenAddress - if listenAddr == "" { - listenAddr = ":8443" + if cfg.Platforms.GoogleChat.Enabled { + if gcAdapter, ok := messenger.(*googlechat.Adapter); ok { + listenAddr := cfg.Platforms.GoogleChat.ListenAddress + if listenAddr == "" { + listenAddr = ":8443" + } + go func() { + if err := gcAdapter.Start(listenAddr); err != nil { + errCh <- fmt.Errorf("google chat server: %w", err) + } + }() + log.Info("google chat webhook server starting", "address", listenAddr) } - go func() { - if err := gcAdapter.Start(listenAddr); err != nil { - errCh <- fmt.Errorf("google chat server: %w", err) + } + + if slackAdapter != nil { + if cfg.Platforms.Slack.SocketMode { + go func() { + if err := slackAdapter.StartSocketMode(); err != nil { + errCh <- fmt.Errorf("slack socket mode: %w", err) + } + }() + log.Info("slack socket mode starting") + } else { + listenAddr := cfg.Platforms.Slack.ListenAddress + if listenAddr == "" { + listenAddr = ":8444" } - }() - log.Info("google chat webhook server starting", "address", listenAddr) + go func() { + if err := slackAdapter.Start(listenAddr); err != nil { + errCh <- fmt.Errorf("slack server: %w", err) + } + }() + log.Info("slack webhook server starting", "address", listenAddr) + } } log.Info("scion-chat-app ready") @@ -289,10 +344,17 @@ func main() { shutdownCtx, shutdownCancel := context.WithTimeout(ctx, 30*time.Second) defer shutdownCancel() - if cfg.Platforms.GoogleChat.Enabled && messenger != nil { - gcAdapter := messenger.(*googlechat.Adapter) - if err := gcAdapter.Stop(shutdownCtx); err != nil { - log.Error("failed to stop google chat adapter", "error", err) + if cfg.Platforms.GoogleChat.Enabled { + if gcAdapter, ok := messenger.(*googlechat.Adapter); ok { + if err := gcAdapter.Stop(shutdownCtx); err != nil { + log.Error("failed to stop google chat adapter", "error", err) + } + } + } + + if slackAdapter != nil { + if err := slackAdapter.Stop(shutdownCtx); err != nil { + log.Error("failed to stop slack adapter", "error", err) } } diff --git a/extras/scion-chat-app/go.mod b/extras/scion-chat-app/go.mod index 9620d8200..022cd5f36 100644 --- a/extras/scion-chat-app/go.mod +++ b/extras/scion-chat-app/go.mod @@ -8,6 +8,7 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 github.com/hashicorp/go-plugin v1.7.0 github.com/mattn/go-sqlite3 v1.14.28 + github.com/slack-go/slack v0.23.0 golang.org/x/oauth2 v0.36.0 google.golang.org/api v0.259.0 gopkg.in/yaml.v3 v3.0.1 @@ -28,6 +29,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.16.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect diff --git a/extras/scion-chat-app/go.sum b/extras/scion-chat-app/go.sum index 9271886e3..246731cc3 100644 --- a/extras/scion-chat-app/go.sum +++ b/extras/scion-chat-app/go.sum @@ -37,6 +37,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -49,6 +51,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAV github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= @@ -80,6 +84,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/slack-go/slack v0.23.0 h1:PTMIHTKJNuA+jVh0BNuE52ntdA7FAxzSqWAdXl5rGa8= +github.com/slack-go/slack v0.23.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/extras/scion-chat-app/internal/chatapp/commands.go b/extras/scion-chat-app/internal/chatapp/commands.go index de54a248e..973023125 100644 --- a/extras/scion-chat-app/internal/chatapp/commands.go +++ b/extras/scion-chat-app/internal/chatapp/commands.go @@ -29,18 +29,46 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/messages" ) -// eventUserLookup returns user info from the ChatEvent itself, using the -// Google-asserted email from the signed event payload. This avoids the need -// for a separate API call to look up the user's email. +// eventUserLookup returns user info from the ChatEvent. For platforms that +// include the email in every event (Google Chat), it returns it directly. +// For platforms where the email is absent (Slack), it falls back to the +// Messenger.GetUser() API call. Results are cached per-instance so repeated +// lookups within the same event processing avoid redundant API/cache calls. type eventUserLookup struct { - event *ChatEvent + event *ChatEvent + messenger Messenger + cached *identity.ChatUserInfo + cachedErr error + looked bool } func (el *eventUserLookup) GetUser(ctx context.Context, userID string) (*identity.ChatUserInfo, error) { - return &identity.ChatUserInfo{ - PlatformID: el.event.UserID, - Email: el.event.UserEmail, - }, nil + if el.event.UserEmail != "" { + return &identity.ChatUserInfo{ + PlatformID: el.event.UserID, + Email: el.event.UserEmail, + }, nil + } + if el.looked { + return el.cached, el.cachedErr + } + el.looked = true + if el.messenger == nil { + el.cached = &identity.ChatUserInfo{ + PlatformID: el.event.UserID, + } + return el.cached, nil + } + user, err := el.messenger.GetUser(ctx, el.event.UserID) + if err != nil { + el.cachedErr = err + return nil, err + } + el.cached = &identity.ChatUserInfo{ + PlatformID: user.PlatformID, + Email: user.Email, + } + return el.cached, nil } // pendingDeviceAuth tracks an in-progress device authorization flow. @@ -59,6 +87,7 @@ type CommandRouter struct { store *state.Store idMapper *identity.Mapper messenger Messenger + messengers map[string]Messenger // platform name → adapter broker *BrokerServer log *slog.Logger @@ -83,6 +112,7 @@ func NewCommandRouter( store: store, idMapper: idMapper, messenger: messenger, + messengers: make(map[string]Messenger), broker: broker, log: log, pendingAuth: make(map[string]*pendingDeviceAuth), @@ -98,12 +128,28 @@ func (r *CommandRouter) hubHostname() string { return r.hubURL } -// SetMessenger sets the messenger after construction, breaking the +// SetMessenger sets the default messenger after construction, breaking the // circular dependency between the command router and chat adapter. func (r *CommandRouter) SetMessenger(m Messenger) { r.messenger = m } +// RegisterMessenger registers a platform-specific messenger for multi-platform +// dispatch. When both Google Chat and Slack are enabled, each platform's +// adapter is registered under its platform name. +func (r *CommandRouter) RegisterMessenger(platform string, m Messenger) { + r.messengers[platform] = m +} + +// messengerFor returns the messenger for the given platform, falling back to +// the default messenger if no platform-specific one is registered. +func (r *CommandRouter) messengerFor(platform string) Messenger { + if m, ok := r.messengers[platform]; ok { + return m + } + return r.messenger +} + // HandleEvent processes a ChatEvent and routes it to the appropriate handler. // Returns an optional EventResponse for synchronous HTTP responses. func (r *CommandRouter) HandleEvent(ctx context.Context, event *ChatEvent) (*EventResponse, error) { @@ -204,7 +250,7 @@ func (r *CommandRouter) handleMessage(ctx context.Context, event *ChatEvent) err } // Try to resolve the user - mapping, err := r.idMapper.ResolveOrAutoRegister(ctx, &eventUserLookup{event}, event.UserID, event.Platform) + mapping, err := r.idMapper.ResolveOrAutoRegister(ctx, &eventUserLookup{event: event, messenger: r.messengerFor(event.Platform)}, event.UserID, event.Platform) if err != nil { return fmt.Errorf("resolving user: %w", err) } @@ -327,11 +373,12 @@ func (r *CommandRouter) handleAgentAction(ctx context.Context, event *ChatEvent, if err != nil { return err } + m := r.messengerFor(event.Platform) if resp != nil && resp.Message != nil { if resp.Message.Card != nil { - _, err = r.messenger.SendCard(ctx, event.SpaceID, *resp.Message.Card) + _, err = m.SendCard(ctx, event.SpaceID, *resp.Message.Card) } else { - _, err = r.messenger.SendMessage(ctx, *resp.Message) + _, err = m.SendMessage(ctx, *resp.Message) } } return err @@ -529,7 +576,7 @@ func (r *CommandRouter) cmdLink(ctx context.Context, event *ChatEvent, args []st return textResponse(event, "Usage: `/scion link `"), nil } - mapping, err := r.idMapper.ResolveOrAutoRegister(ctx, &eventUserLookup{event}, event.UserID, event.Platform) + mapping, err := r.idMapper.ResolveOrAutoRegister(ctx, &eventUserLookup{event: event, messenger: r.messengerFor(event.Platform)}, event.UserID, event.Platform) if err != nil || mapping == nil { return textResponse(event, "Authentication required. Use `/scion register` first."), nil } @@ -606,7 +653,7 @@ func (r *CommandRouter) cmdRegister(ctx context.Context, event *ChatEvent, args } // Try auto-registration by email (short-circuit) - mapping, err := r.idMapper.ResolveOrAutoRegister(ctx, &eventUserLookup{event}, event.UserID, event.Platform) + mapping, err := r.idMapper.ResolveOrAutoRegister(ctx, &eventUserLookup{event: event, messenger: r.messengerFor(event.Platform)}, event.UserID, event.Platform) if err != nil { return nil, fmt.Errorf("auto-registration: %w", err) } @@ -945,7 +992,7 @@ func (r *CommandRouter) cmdMessage(ctx context.Context, event *ChatEvent, args [ return linkResp, nil } - mapping, err := r.idMapper.ResolveOrAutoRegister(ctx, &eventUserLookup{event}, event.UserID, event.Platform) + mapping, err := r.idMapper.ResolveOrAutoRegister(ctx, &eventUserLookup{event: event, messenger: r.messengerFor(event.Platform)}, event.UserID, event.Platform) if err != nil || mapping == nil { return textResponse(event, "Authentication required. Use `/scion register` first."), nil } @@ -1005,8 +1052,16 @@ func (r *CommandRouter) cmdMessage(ctx context.Context, event *ChatEvent, args [ if displayName == "" { displayName = event.UserEmail } - replyText := fmt.Sprintf("Message from *%s* sent to *%s*:\n%s", displayName, agentSlug, messageText) - return textResponse(event, replyText), nil + visibleText := fmt.Sprintf("Message from *%s* sent to *%s*:\n%s", displayName, agentSlug, messageText) + if _, err := r.messengerFor(event.Platform).SendMessage(ctx, SendMessageRequest{ + SpaceID: event.SpaceID, + ThreadID: event.ThreadID, + Text: visibleText, + }); err != nil { + r.log.Error("failed to send visible message confirmation", "error", err) + } + + return nil, nil } func (r *CommandRouter) cmdSetDefault(ctx context.Context, event *ChatEvent, args []string) (*EventResponse, error) { @@ -1153,7 +1208,7 @@ func (r *CommandRouter) cmdHelp(ctx context.Context, event *ChatEvent) (*EventRe // reply sends a text message back to the space where the event originated. // Used by non-command handlers (actions, messages, etc.) that respond asynchronously. func (r *CommandRouter) reply(ctx context.Context, event *ChatEvent, text string) error { - _, err := r.messenger.SendMessage(ctx, SendMessageRequest{ + _, err := r.messengerFor(event.Platform).SendMessage(ctx, SendMessageRequest{ SpaceID: event.SpaceID, ThreadID: event.ThreadID, Text: text, @@ -1197,7 +1252,7 @@ func (r *CommandRouter) requireSpaceLink(ctx context.Context, event *ChatEvent) // clientForUser creates a Hub client authenticated as the event's user. func (r *CommandRouter) clientForUser(ctx context.Context, event *ChatEvent) (hubclient.Client, error) { - mapping, err := r.idMapper.ResolveOrAutoRegister(ctx, &eventUserLookup{event}, event.UserID, event.Platform) + mapping, err := r.idMapper.ResolveOrAutoRegister(ctx, &eventUserLookup{event: event, messenger: r.messengerFor(event.Platform)}, event.UserID, event.Platform) if err != nil { return nil, err } diff --git a/extras/scion-chat-app/internal/chatapp/config.go b/extras/scion-chat-app/internal/chatapp/config.go index 561f07281..3cc7cc684 100644 --- a/extras/scion-chat-app/internal/chatapp/config.go +++ b/extras/scion-chat-app/internal/chatapp/config.go @@ -55,12 +55,14 @@ type GoogleChatConfig struct { CommandIDMap map[string]string `yaml:"command_id_map"` } -// SlackConfig holds settings for the Slack adapter (future). +// SlackConfig holds settings for the Slack adapter. type SlackConfig struct { Enabled bool `yaml:"enabled"` BotToken string `yaml:"bot_token"` + AppToken string `yaml:"app_token"` SigningSecret string `yaml:"signing_secret"` ListenAddress string `yaml:"listen_address"` + SocketMode bool `yaml:"socket_mode"` } // StateConfig holds local state database settings. diff --git a/extras/scion-chat-app/internal/chatapp/notifications.go b/extras/scion-chat-app/internal/chatapp/notifications.go index 5130ac81b..ab317ff0b 100644 --- a/extras/scion-chat-app/internal/chatapp/notifications.go +++ b/extras/scion-chat-app/internal/chatapp/notifications.go @@ -26,17 +26,46 @@ import ( // NotificationRelay routes agent notifications to chat spaces as rich cards. type NotificationRelay struct { - store *state.Store - messenger Messenger - log *slog.Logger + store *state.Store + messenger Messenger + messengers map[string]Messenger // platform name → adapter + log *slog.Logger } // NewNotificationRelay creates a new notification relay. func NewNotificationRelay(store *state.Store, messenger Messenger, log *slog.Logger) *NotificationRelay { return &NotificationRelay{ - store: store, - messenger: messenger, - log: log, + store: store, + messenger: messenger, + messengers: make(map[string]Messenger), + log: log, + } +} + +// RegisterMessenger registers a platform-specific messenger. +func (n *NotificationRelay) RegisterMessenger(platform string, m Messenger) { + n.messengers[platform] = m +} + +// messengerFor returns the messenger for the given platform, falling back to +// the default. +func (n *NotificationRelay) messengerFor(platform string) Messenger { + if m, ok := n.messengers[platform]; ok { + return m + } + return n.messenger +} + +// formatMention returns a platform-specific @mention string. +// Google Chat user IDs already include the "users/" prefix, so we just +// wrap them in angle brackets. Slack user IDs are bare (e.g., "U0ABC123") +// and need the "@" prefix. +func formatMention(platform, userID string) string { + switch platform { + case "slack": + return fmt.Sprintf("<@%s>", userID) + default: + return fmt.Sprintf("<%s>", userID) } } @@ -103,7 +132,7 @@ func (n *NotificationRelay) handleAgentNotification(ctx context.Context, groveID }) } - if _, err := n.messenger.SendCard(ctx, link.SpaceID, card); err != nil { + if _, err := n.messengerFor(link.Platform).SendCard(ctx, link.SpaceID, card); err != nil { n.log.Error("failed to send notification card", "space_id", link.SpaceID, "grove_id", groveID, @@ -173,7 +202,7 @@ func (n *NotificationRelay) handleUserMessage(ctx context.Context, groveID strin // Chat API renders them as interactive user pills. mentions := n.buildMentions(mapping.PlatformUserID, agentSlug, link) - if _, err := n.messenger.SendMessage(ctx, SendMessageRequest{ + if _, err := n.messengerFor(link.Platform).SendMessage(ctx, SendMessageRequest{ SpaceID: link.SpaceID, Text: mentions, Card: &card, @@ -339,8 +368,7 @@ func (n *NotificationRelay) getSubscriberMentions(msg *messages.StructuredMessag } } - // Format platform-specific mention - mentions = append(mentions, fmt.Sprintf("", sub.PlatformUserID)) + mentions = append(mentions, formatMention(link.Platform, sub.PlatformUserID)) } if len(mentions) == 0 { @@ -355,7 +383,7 @@ func (n *NotificationRelay) getSubscriberMentions(msg *messages.StructuredMessag func (n *NotificationRelay) buildMentions(recipientPlatformID, agentSlug string, link state.SpaceLink) string { // Start with the direct recipient seen := map[string]bool{recipientPlatformID: true} - mentions := []string{fmt.Sprintf("<%s>", recipientPlatformID)} + mentions := []string{formatMention(link.Platform, recipientPlatformID)} // Add subscribers for this agent/grove, skipping the recipient to avoid duplication subs, err := n.store.ListAgentSubscriptions(agentSlug, link.GroveID) @@ -369,7 +397,7 @@ func (n *NotificationRelay) buildMentions(recipientPlatformID, agentSlug string, continue } seen[sub.PlatformUserID] = true - mentions = append(mentions, fmt.Sprintf("", sub.PlatformUserID)) + mentions = append(mentions, formatMention(link.Platform, sub.PlatformUserID)) } return strings.Join(mentions, " ") diff --git a/extras/scion-chat-app/internal/chatapp/notifications_test.go b/extras/scion-chat-app/internal/chatapp/notifications_test.go index f34fe5634..ba5d2ed97 100644 --- a/extras/scion-chat-app/internal/chatapp/notifications_test.go +++ b/extras/scion-chat-app/internal/chatapp/notifications_test.go @@ -171,3 +171,25 @@ func TestHandleUserMessage_NoSubscriptionRequired(t *testing.T) { t.Errorf("expected no card actions, got %d", len(got.Card.Actions)) } } + +func TestFormatMention(t *testing.T) { + tests := []struct { + platform string + userID string + want string + }{ + {"slack", "U0ABC123", "<@U0ABC123>"}, + {"google_chat", "users/12345", ""}, + {"googlechat", "users/12345", ""}, + {"unknown", "user123", ""}, + } + + for _, tt := range tests { + t.Run(tt.platform+"/"+tt.userID, func(t *testing.T) { + got := formatMention(tt.platform, tt.userID) + if got != tt.want { + t.Errorf("formatMention(%q, %q) = %q, want %q", tt.platform, tt.userID, got, tt.want) + } + }) + } +} diff --git a/extras/scion-chat-app/internal/slack/adapter.go b/extras/scion-chat-app/internal/slack/adapter.go new file mode 100644 index 000000000..af157a5b7 --- /dev/null +++ b/extras/scion-chat-app/internal/slack/adapter.go @@ -0,0 +1,634 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package slack + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "sync" + "time" + + slackapi "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" + + "github.com/GoogleCloudPlatform/scion/extras/scion-chat-app/internal/chatapp" + "github.com/GoogleCloudPlatform/scion/extras/scion-chat-app/internal/identity" + "github.com/GoogleCloudPlatform/scion/extras/scion-chat-app/internal/state" +) + +const PlatformName = "slack" + +// ephemeralCommands are slash command subcommands whose responses should be +// visible only to the invoker. +var ephemeralCommands = map[string]bool{ + "help": true, + "info": true, + "register": true, + "unregister": true, +} + +// EventHandler processes normalized chat events. +type EventHandler func(ctx context.Context, event *chatapp.ChatEvent) (*chatapp.EventResponse, error) + +// IconProvider generates avatar URLs for agents. +type IconProvider interface { + IconURL(agentSlug string) string +} + +// robohashProvider generates deterministic robot-themed avatars via robohash.org. +type robohashProvider struct{} + +func (r *robohashProvider) IconURL(agentSlug string) string { + return fmt.Sprintf("https://robohash.org/%s?set=set1&size=48x48", url.PathEscape(agentSlug)) +} + +// Config holds Slack adapter configuration. +type Config struct { + BotToken string + AppToken string + SigningSecret string + ListenAddress string + SocketMode bool +} + +// Adapter implements chatapp.Messenger for Slack. +type Adapter struct { + client *slackapi.Client + socketClient *socketmode.Client + botToken string + signingSecret string + appToken string + httpServer *http.Server + handler EventHandler + iconProvider IconProvider + log *slog.Logger + botUserID string + + store *state.Store + idMapper *identity.Mapper + + cacheMu sync.RWMutex + userCache map[string]*cachedUser +} + +type cachedUser struct { + user *chatapp.ChatUser + fetchedAt time.Time +} + +const userCacheTTL = 15 * time.Minute +const asyncProcessingTimeout = 30 * time.Second + +// NewAdapter creates a new Slack adapter. +func NewAdapter(cfg Config, handler EventHandler, log *slog.Logger) *Adapter { + client := slackapi.New(cfg.BotToken) + + a := &Adapter{ + client: client, + botToken: cfg.BotToken, + signingSecret: cfg.SigningSecret, + appToken: cfg.AppToken, + handler: handler, + iconProvider: &robohashProvider{}, + log: log, + userCache: make(map[string]*cachedUser), + } + + // Resolve bot user ID for mention stripping. + resp, err := client.AuthTest() + if err != nil { + log.Warn("failed to resolve bot user ID via auth.test", "error", err) + } else { + a.botUserID = resp.UserID + log.Info("slack bot user ID resolved", "bot_user_id", a.botUserID) + } + + return a +} + +// SetIconProvider overrides the default icon provider. +func (a *Adapter) SetIconProvider(p IconProvider) { + a.iconProvider = p +} + +// SetStore sets the state store for App Home rendering. +func (a *Adapter) SetStore(store *state.Store) { + a.store = store +} + +// SetIdentityMapper sets the identity mapper for App Home rendering. +func (a *Adapter) SetIdentityMapper(m *identity.Mapper) { + a.idMapper = m +} + +// --- Messenger interface implementation --- + +func (a *Adapter) SendMessage(ctx context.Context, req chatapp.SendMessageRequest) (string, error) { + opts := []slackapi.MsgOption{ + slackapi.MsgOptionText(req.Text, false), + } + + if req.AgentID != "" { + opts = append(opts, + slackapi.MsgOptionUsername(req.AgentID), + slackapi.MsgOptionIconURL(a.iconProvider.IconURL(req.AgentID)), + ) + } + + if req.ThreadID != "" { + opts = append(opts, slackapi.MsgOptionTS(req.ThreadID)) + } + + if req.Card != nil { + blocks := renderBlocks(req.Card) + opts = append(opts, slackapi.MsgOptionBlocks(blocks...)) + } + + _, ts, err := a.client.PostMessageContext(ctx, req.SpaceID, opts...) + if err != nil { + a.log.Error("failed to send message", "space", req.SpaceID, "error", err) + return "", err + } + return ts, nil +} + +func (a *Adapter) SendCard(ctx context.Context, spaceID string, card chatapp.Card) (string, error) { + return a.SendMessage(ctx, chatapp.SendMessageRequest{ + SpaceID: spaceID, + Card: &card, + }) +} + +func (a *Adapter) UpdateMessage(ctx context.Context, messageID string, req chatapp.SendMessageRequest) error { + opts := []slackapi.MsgOption{ + slackapi.MsgOptionText(req.Text, false), + } + if req.Card != nil { + blocks := renderBlocks(req.Card) + opts = append(opts, slackapi.MsgOptionBlocks(blocks...)) + } + + _, _, _, err := a.client.UpdateMessageContext(ctx, req.SpaceID, messageID, opts...) + return err +} + +func (a *Adapter) OpenDialog(ctx context.Context, triggerID string, dialog chatapp.Dialog) error { + view := renderModal(&dialog) + _, err := a.client.OpenViewContext(ctx, triggerID, view) + return err +} + +func (a *Adapter) UpdateDialog(ctx context.Context, viewID string, dialog chatapp.Dialog) error { + view := renderModal(&dialog) + _, err := a.client.UpdateViewContext(ctx, view, "", "", viewID) + return err +} + +func (a *Adapter) GetUser(ctx context.Context, userID string) (*chatapp.ChatUser, error) { + if cached := a.getCachedUser(userID); cached != nil { + return cached, nil + } + + user, err := a.client.GetUserInfoContext(ctx, userID) + if err != nil { + return nil, err + } + + chatUser := &chatapp.ChatUser{ + PlatformID: user.ID, + DisplayName: user.Profile.DisplayName, + Email: user.Profile.Email, + } + a.setCachedUser(userID, chatUser) + return chatUser, nil +} + +func (a *Adapter) SetAgentIdentity(_ context.Context, _ chatapp.AgentIdentity) error { + return nil +} + +// --- User cache --- + +func (a *Adapter) getCachedUser(userID string) *chatapp.ChatUser { + a.cacheMu.RLock() + defer a.cacheMu.RUnlock() + if cached, ok := a.userCache[userID]; ok && time.Since(cached.fetchedAt) < userCacheTTL { + return cached.user + } + return nil +} + +func (a *Adapter) setCachedUser(userID string, user *chatapp.ChatUser) { + a.cacheMu.Lock() + defer a.cacheMu.Unlock() + a.userCache[userID] = &cachedUser{user: user, fetchedAt: time.Now()} +} + +// --- HTTP server --- + +// Start begins serving the HTTP webhook endpoints for Slack events. +func (a *Adapter) Start(listenAddr string) error { + mux := http.NewServeMux() + mux.HandleFunc("POST /slack/events", a.handleEvents) + mux.HandleFunc("POST /slack/commands", a.handleCommands) + mux.HandleFunc("POST /slack/interactions", a.handleInteractions) + mux.HandleFunc("GET /slack/healthz", a.handleHealthz) + + a.httpServer = &http.Server{Addr: listenAddr, Handler: mux} + a.log.Info("slack webhook server starting", "address", listenAddr) + return a.httpServer.ListenAndServe() +} + +// StartSocketMode starts a WebSocket connection to Slack instead of an HTTP server. +func (a *Adapter) StartSocketMode() error { + appClient := slackapi.New(a.botToken, slackapi.OptionAppLevelToken(a.appToken)) + a.socketClient = socketmode.New(appClient) + + go func() { + for evt := range a.socketClient.Events { + switch evt.Type { + case socketmode.EventTypeEventsAPI: + a.handleSocketEvent(evt) + case socketmode.EventTypeSlashCommand: + a.handleSocketCommand(evt) + case socketmode.EventTypeInteractive: + a.handleSocketInteraction(evt) + } + } + }() + + a.log.Info("slack socket mode starting") + return a.socketClient.Run() +} + +// Stop gracefully shuts down the adapter. +func (a *Adapter) Stop(ctx context.Context) error { + if a.httpServer != nil { + return a.httpServer.Shutdown(ctx) + } + return nil +} + +// --- HTTP handlers --- + +func (a *Adapter) handleEvents(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + defer r.Body.Close() + + if a.signingSecret != "" { + if err := verifyRequest(r.Header, body, a.signingSecret); err != nil { + a.log.Warn("event signature verification failed", "error", err) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + } + + eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionNoVerifyToken()) + if err != nil { + a.log.Error("failed to parse event", "error", err) + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + // Handle URL verification challenge. + if eventsAPIEvent.Type == slackevents.URLVerification { + var challenge slackevents.ChallengeResponse + if err := json.Unmarshal(body, &challenge); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, challenge.Challenge) + return + } + + // Acknowledge immediately. + w.WriteHeader(http.StatusOK) + + // Process asynchronously. + go func() { + ctx, cancel := context.WithTimeout(context.Background(), asyncProcessingTimeout) + defer cancel() + a.processEventsAPIEvent(ctx, &eventsAPIEvent) + }() +} + +func (a *Adapter) handleCommands(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + defer r.Body.Close() + + if a.signingSecret != "" { + if err := verifyRequest(r.Header, body, a.signingSecret); err != nil { + a.log.Warn("command signature verification failed", "error", err) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + } + + // Restore body for SlashCommandParse. + r.Body = io.NopCloser(bytes.NewReader(body)) + cmd, err := slackapi.SlashCommandParse(r) + if err != nil { + a.log.Error("failed to parse slash command", "error", err) + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + // Acknowledge immediately with an ephemeral "Processing..." message. + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "response_type": "ephemeral", + "text": "Processing...", + }) + + // Process asynchronously. + go func() { + ctx, cancel := context.WithTimeout(context.Background(), asyncProcessingTimeout) + defer cancel() + a.processSlashCommand(ctx, cmd) + }() +} + +func (a *Adapter) handleInteractions(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + defer r.Body.Close() + + if a.signingSecret != "" { + if err := verifyRequest(r.Header, body, a.signingSecret); err != nil { + a.log.Warn("interaction signature verification failed", "error", err) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + } + + // Parse the payload from form data. + r.Body = io.NopCloser(bytes.NewReader(body)) + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + payloadJSON := r.FormValue("payload") + if payloadJSON == "" { + http.Error(w, "missing payload", http.StatusBadRequest) + return + } + + var callback slackapi.InteractionCallback + if err := json.Unmarshal([]byte(payloadJSON), &callback); err != nil { + a.log.Error("failed to parse interaction payload", "error", err) + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + // Acknowledge immediately. + w.WriteHeader(http.StatusOK) + + // Process asynchronously. + go func() { + ctx, cancel := context.WithTimeout(context.Background(), asyncProcessingTimeout) + defer cancel() + a.processInteraction(ctx, callback) + }() +} + +func (a *Adapter) handleHealthz(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") +} + +// --- Socket Mode handlers --- + +func (a *Adapter) handleSocketEvent(evt socketmode.Event) { + data, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + return + } + a.socketClient.Ack(*evt.Request) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), asyncProcessingTimeout) + defer cancel() + a.processEventsAPIEvent(ctx, &data) + }() +} + +func (a *Adapter) handleSocketCommand(evt socketmode.Event) { + cmd, ok := evt.Data.(slackapi.SlashCommand) + if !ok { + return + } + a.socketClient.Ack(*evt.Request) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), asyncProcessingTimeout) + defer cancel() + a.processSlashCommand(ctx, cmd) + }() +} + +func (a *Adapter) handleSocketInteraction(evt socketmode.Event) { + callback, ok := evt.Data.(slackapi.InteractionCallback) + if !ok { + return + } + a.socketClient.Ack(*evt.Request) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), asyncProcessingTimeout) + defer cancel() + a.processInteraction(ctx, callback) + }() +} + +// --- Event processing --- + +func (a *Adapter) processEventsAPIEvent(ctx context.Context, evt *slackevents.EventsAPIEvent) { + if evt.Type != slackevents.CallbackEvent { + return + } + + var chatEvent *chatapp.ChatEvent + + switch inner := evt.InnerEvent.Data.(type) { + case *slackevents.AppMentionEvent: + chatEvent = normalizeAppMention(inner) + + case *slackevents.MessageEvent: + if inner.BotID != "" || inner.SubType != "" { + return + } + chatEvent = normalizeMessageIM(inner) + + case *slackevents.MemberJoinedChannelEvent: + if a.botUserID != "" && inner.User != a.botUserID { + return + } + chatEvent = normalizeMemberJoined(inner) + + case *slackevents.MemberLeftChannelEvent: + if a.botUserID != "" && inner.User != a.botUserID { + return + } + chatEvent = normalizeMemberLeft(inner) + + case *slackevents.AppHomeOpenedEvent: + a.publishAppHome(ctx, inner.User) + return + + default: + a.log.Debug("unhandled inner event type", "type", evt.InnerEvent.Type) + return + } + + if chatEvent == nil { + return + } + + a.log.Info("event received", + "type", chatEvent.Type, + "space", chatEvent.SpaceID, + "user", chatEvent.UserID, + ) + + resp, err := a.handler(ctx, chatEvent) + if err != nil { + a.log.Error("event handler error", "type", chatEvent.Type, "error", err) + return + } + + a.sendResponse(ctx, chatEvent.SpaceID, chatEvent.ThreadID, "", resp) +} + +func (a *Adapter) processSlashCommand(ctx context.Context, cmd slackapi.SlashCommand) { + event := normalizeSlashCommand(cmd) + + a.log.Info("slash command received", + "text", cmd.Text, + "channel", cmd.ChannelID, + "user", cmd.UserID, + ) + + resp, err := a.handler(ctx, event) + if err != nil { + a.log.Error("command handler error", "error", err) + return + } + + if resp == nil || resp.Message == nil { + return + } + + // Determine if this should be an ephemeral response. + subcommand := strings.Fields(cmd.Text) + if len(subcommand) > 0 && ephemeralCommands[strings.ToLower(subcommand[0])] { + a.sendEphemeral(ctx, cmd.ChannelID, cmd.UserID, resp) + return + } + + a.sendResponse(ctx, cmd.ChannelID, "", "", resp) +} + +func (a *Adapter) processInteraction(ctx context.Context, callback slackapi.InteractionCallback) { + event := normalizeInteraction(callback) + if event == nil { + return + } + + a.log.Info("interaction received", + "type", callback.Type, + "action_id", event.ActionID, + "user", event.UserID, + ) + + resp, err := a.handler(ctx, event) + if err != nil { + a.log.Error("interaction handler error", "error", err) + return + } + + // For dialog opens, use the trigger_id from the interaction. + triggerID := callback.TriggerID + + // For view submissions, the channel comes from private_metadata. + channelID := callback.Channel.ID + if channelID == "" { + channelID = event.SpaceID + } + + a.sendResponse(ctx, channelID, event.ThreadID, triggerID, resp) +} + +// --- Response sending --- + +func (a *Adapter) sendResponse(ctx context.Context, channelID, threadTS, triggerID string, resp *chatapp.EventResponse) { + if resp == nil { + return + } + + if resp.Dialog != nil && triggerID != "" { + if err := a.OpenDialog(ctx, triggerID, *resp.Dialog); err != nil { + a.log.Error("failed to open dialog", "error", err) + } + } + + if resp.Message != nil { + msg := *resp.Message + if msg.SpaceID == "" { + msg.SpaceID = channelID + } + if msg.ThreadID == "" { + msg.ThreadID = threadTS + } + if _, err := a.SendMessage(ctx, msg); err != nil { + a.log.Error("failed to send response message", "error", err) + } + } +} + +func (a *Adapter) sendEphemeral(ctx context.Context, channelID, userID string, resp *chatapp.EventResponse) { + if resp == nil || resp.Message == nil { + return + } + + opts := []slackapi.MsgOption{ + slackapi.MsgOptionText(resp.Message.Text, false), + } + if resp.Message.Card != nil { + blocks := renderBlocks(resp.Message.Card) + opts = append(opts, slackapi.MsgOptionBlocks(blocks...)) + } + + if _, err := a.client.PostEphemeralContext(ctx, channelID, userID, opts...); err != nil { + a.log.Error("failed to send ephemeral message", "channel", channelID, "user", userID, "error", err) + } +} diff --git a/extras/scion-chat-app/internal/slack/adapter_test.go b/extras/scion-chat-app/internal/slack/adapter_test.go new file mode 100644 index 000000000..46b1f8d68 --- /dev/null +++ b/extras/scion-chat-app/internal/slack/adapter_test.go @@ -0,0 +1,871 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package slack + +import ( + "testing" + "time" + + slackapi "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + + "github.com/GoogleCloudPlatform/scion/extras/scion-chat-app/internal/chatapp" +) + +// --- Block Kit rendering tests --- + +func TestRenderBlocks_HeaderOnly(t *testing.T) { + card := &chatapp.Card{ + Header: chatapp.CardHeader{ + Title: "Test Card", + Subtitle: "A subtitle", + }, + } + + blocks := renderBlocks(card) + + if len(blocks) != 2 { + t.Fatalf("expected 2 blocks (header + context), got %d", len(blocks)) + } + + header, ok := blocks[0].(*slackapi.HeaderBlock) + if !ok { + t.Fatalf("block 0: expected *HeaderBlock, got %T", blocks[0]) + } + if header.Text.Text != "Test Card" { + t.Errorf("header text = %q, want %q", header.Text.Text, "Test Card") + } + + ctx, ok := blocks[1].(*slackapi.ContextBlock) + if !ok { + t.Fatalf("block 1: expected *ContextBlock, got %T", blocks[1]) + } + if len(ctx.ContextElements.Elements) == 0 { + t.Fatal("context block has no elements") + } +} + +func TestRenderBlocks_TextWidget(t *testing.T) { + card := &chatapp.Card{ + Sections: []chatapp.CardSection{ + { + Widgets: []chatapp.Widget{ + {Type: chatapp.WidgetText, Content: "Hello world"}, + }, + }, + }, + } + + blocks := renderBlocks(card) + + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + + section, ok := blocks[0].(*slackapi.SectionBlock) + if !ok { + t.Fatalf("expected *SectionBlock, got %T", blocks[0]) + } + if section.Text == nil || section.Text.Text != "Hello world" { + t.Errorf("section text = %v, want %q", section.Text, "Hello world") + } +} + +func TestRenderBlocks_KeyValueWidget(t *testing.T) { + card := &chatapp.Card{ + Sections: []chatapp.CardSection{ + { + Widgets: []chatapp.Widget{ + {Type: chatapp.WidgetKeyValue, Label: "Status", Content: "Running"}, + }, + }, + }, + } + + blocks := renderBlocks(card) + + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + + section, ok := blocks[0].(*slackapi.SectionBlock) + if !ok { + t.Fatalf("expected *SectionBlock, got %T", blocks[0]) + } + if len(section.Fields) != 2 { + t.Fatalf("expected 2 fields, got %d", len(section.Fields)) + } + if section.Fields[0].Text != "*Status*" { + t.Errorf("field 0 = %q, want %q", section.Fields[0].Text, "*Status*") + } + if section.Fields[1].Text != "Running" { + t.Errorf("field 1 = %q, want %q", section.Fields[1].Text, "Running") + } +} + +func TestRenderBlocks_ButtonWidget(t *testing.T) { + card := &chatapp.Card{ + Sections: []chatapp.CardSection{ + { + Widgets: []chatapp.Widget{ + {Type: chatapp.WidgetButton, Label: "Click Me", ActionID: "btn.1", ActionData: "value1"}, + }, + }, + }, + } + + blocks := renderBlocks(card) + + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + + action, ok := blocks[0].(*slackapi.ActionBlock) + if !ok { + t.Fatalf("expected *ActionBlock, got %T", blocks[0]) + } + if len(action.Elements.ElementSet) != 1 { + t.Fatalf("expected 1 element, got %d", len(action.Elements.ElementSet)) + } +} + +func TestRenderBlocks_DividerWidget(t *testing.T) { + card := &chatapp.Card{ + Sections: []chatapp.CardSection{ + { + Widgets: []chatapp.Widget{ + {Type: chatapp.WidgetDivider}, + }, + }, + }, + } + + blocks := renderBlocks(card) + + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + + _, ok := blocks[0].(*slackapi.DividerBlock) + if !ok { + t.Fatalf("expected *DividerBlock, got %T", blocks[0]) + } +} + +func TestRenderBlocks_ImageWidget(t *testing.T) { + card := &chatapp.Card{ + Sections: []chatapp.CardSection{ + { + Widgets: []chatapp.Widget{ + {Type: chatapp.WidgetImage, Content: "https://example.com/img.png", Label: "alt text"}, + }, + }, + }, + } + + blocks := renderBlocks(card) + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + + img, ok := blocks[0].(*slackapi.ImageBlock) + if !ok { + t.Fatalf("expected *ImageBlock, got %T", blocks[0]) + } + if img.ImageURL != "https://example.com/img.png" { + t.Errorf("image URL = %q, want %q", img.ImageURL, "https://example.com/img.png") + } +} + +func TestRenderBlocks_InputWidget(t *testing.T) { + card := &chatapp.Card{ + Sections: []chatapp.CardSection{ + { + Widgets: []chatapp.Widget{ + {Type: chatapp.WidgetInput, Label: "Your response", ActionID: "agent.respond.test"}, + }, + }, + }, + } + + blocks := renderBlocks(card) + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + + _, ok := blocks[0].(*slackapi.InputBlock) + if !ok { + t.Fatalf("expected *InputBlock, got %T", blocks[0]) + } +} + +func TestRenderBlocks_CheckboxWidget(t *testing.T) { + card := &chatapp.Card{ + Sections: []chatapp.CardSection{ + { + Widgets: []chatapp.Widget{ + { + Type: chatapp.WidgetCheckbox, + Label: "Activities", + ActionID: "filter.1", + Options: []chatapp.SelectOption{ + {Label: "Error", Value: "ERROR"}, + {Label: "Completed", Value: "COMPLETED"}, + }, + }, + }, + }, + }, + } + + blocks := renderBlocks(card) + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + + _, ok := blocks[0].(*slackapi.InputBlock) + if !ok { + t.Fatalf("expected *InputBlock, got %T", blocks[0]) + } +} + +func TestRenderBlocks_CardActions(t *testing.T) { + card := &chatapp.Card{ + Actions: []chatapp.CardAction{ + {Label: "Start", ActionID: "agent.start.test", Style: "primary"}, + {Label: "Stop", ActionID: "agent.stop.test", Style: "danger"}, + {Label: "Logs", ActionID: "agent.logs.test"}, + }, + } + + blocks := renderBlocks(card) + + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + + action, ok := blocks[0].(*slackapi.ActionBlock) + if !ok { + t.Fatalf("expected *ActionBlock, got %T", blocks[0]) + } + if len(action.Elements.ElementSet) != 3 { + t.Fatalf("expected 3 button elements, got %d", len(action.Elements.ElementSet)) + } +} + +func TestRenderBlocks_SectionHeader(t *testing.T) { + card := &chatapp.Card{ + Sections: []chatapp.CardSection{ + { + Header: "Details", + Widgets: []chatapp.Widget{ + {Type: chatapp.WidgetText, Content: "info"}, + }, + }, + }, + } + + blocks := renderBlocks(card) + + // Section header + text widget + if len(blocks) != 2 { + t.Fatalf("expected 2 blocks, got %d", len(blocks)) + } + + section, ok := blocks[0].(*slackapi.SectionBlock) + if !ok { + t.Fatalf("expected *SectionBlock for header, got %T", blocks[0]) + } + if section.Text == nil || section.Text.Text != "*Details*" { + t.Errorf("section header text = %v, want %q", section.Text, "*Details*") + } +} + +func TestRenderBlocks_FullCard(t *testing.T) { + card := &chatapp.Card{ + Header: chatapp.CardHeader{ + Title: "deploy-agent", + Subtitle: "Completed | Deployment finished", + }, + Sections: []chatapp.CardSection{ + { + Widgets: []chatapp.Widget{ + {Type: chatapp.WidgetText, Content: "All health checks passing."}, + }, + }, + }, + Actions: []chatapp.CardAction{ + {Label: "View Logs", ActionID: "agent.logs.deploy-agent"}, + }, + } + + blocks := renderBlocks(card) + + // header + context + text section + actions + if len(blocks) != 4 { + t.Fatalf("expected 4 blocks, got %d", len(blocks)) + } +} + +// --- Modal rendering tests --- + +func TestRenderModal_TextFields(t *testing.T) { + dialog := &chatapp.Dialog{ + Title: "Create Agent", + Fields: []chatapp.DialogField{ + {ID: "name", Label: "Agent Name", Type: "text", Placeholder: "my-agent", Required: true}, + {ID: "desc", Label: "Description", Type: "textarea", Placeholder: "Describe the agent", Required: false}, + }, + Submit: chatapp.CardAction{Label: "Create", ActionID: "agent.create"}, + Cancel: chatapp.CardAction{Label: "Cancel"}, + } + + modal := renderModal(dialog) + + if modal.Type != "modal" { + t.Errorf("type = %q, want %q", modal.Type, "modal") + } + if modal.Title.Text != "Create Agent" { + t.Errorf("title = %q, want %q", modal.Title.Text, "Create Agent") + } + if modal.Submit.Text != "Create" { + t.Errorf("submit = %q, want %q", modal.Submit.Text, "Create") + } + if modal.Close.Text != "Cancel" { + t.Errorf("close = %q, want %q", modal.Close.Text, "Cancel") + } + if modal.CallbackID != "agent.create" { + t.Errorf("callback_id = %q, want %q", modal.CallbackID, "agent.create") + } + if len(modal.Blocks.BlockSet) != 2 { + t.Fatalf("expected 2 blocks, got %d", len(modal.Blocks.BlockSet)) + } +} + +func TestRenderModal_SelectField(t *testing.T) { + dialog := &chatapp.Dialog{ + Title: "Pick", + Fields: []chatapp.DialogField{ + { + ID: "template", + Label: "Template", + Type: "select", + Options: []chatapp.SelectOption{ + {Label: "Standard", Value: "std"}, + {Label: "Custom", Value: "custom"}, + }, + Required: true, + }, + }, + Submit: chatapp.CardAction{Label: "OK", ActionID: "submit"}, + } + + modal := renderModal(dialog) + + if len(modal.Blocks.BlockSet) != 1 { + t.Fatalf("expected 1 block, got %d", len(modal.Blocks.BlockSet)) + } +} + +func TestRenderModal_CheckboxField(t *testing.T) { + dialog := &chatapp.Dialog{ + Title: "Filter", + Fields: []chatapp.DialogField{ + { + ID: "activities", + Label: "Activities", + Type: "checkbox", + Options: []chatapp.SelectOption{ + {Label: "Error", Value: "ERROR"}, + {Label: "Completed", Value: "COMPLETED"}, + }, + }, + }, + Submit: chatapp.CardAction{Label: "Save", ActionID: "filter.save"}, + } + + modal := renderModal(dialog) + + if len(modal.Blocks.BlockSet) != 1 { + t.Fatalf("expected 1 block, got %d", len(modal.Blocks.BlockSet)) + } + + input, ok := modal.Blocks.BlockSet[0].(*slackapi.InputBlock) + if !ok { + t.Fatalf("expected *InputBlock, got %T", modal.Blocks.BlockSet[0]) + } + if !input.Optional { + t.Error("checkbox field with Required=false should be Optional=true") + } +} + +func TestExtractModalValues_TextInput(t *testing.T) { + state := &slackapi.ViewState{ + Values: map[string]map[string]slackapi.BlockAction{ + "name": { + "name": { + Type: "plain_text_input", + Value: "my-agent", + }, + }, + }, + } + + result := extractModalValues(state) + + if result["name"] != "my-agent" { + t.Errorf("name = %q, want %q", result["name"], "my-agent") + } +} + +func TestExtractModalValues_SelectInput(t *testing.T) { + state := &slackapi.ViewState{ + Values: map[string]map[string]slackapi.BlockAction{ + "template": { + "template": { + Type: "static_select", + SelectedOption: slackapi.OptionBlockObject{ + Value: "custom", + }, + }, + }, + }, + } + + result := extractModalValues(state) + + if result["template"] != "custom" { + t.Errorf("template = %q, want %q", result["template"], "custom") + } +} + +func TestExtractModalValues_CheckboxInput(t *testing.T) { + state := &slackapi.ViewState{ + Values: map[string]map[string]slackapi.BlockAction{ + "activities": { + "activities": { + Type: "checkboxes", + SelectedOptions: []slackapi.OptionBlockObject{ + {Value: "ERROR"}, + {Value: "COMPLETED"}, + }, + }, + }, + }, + } + + result := extractModalValues(state) + + if result["activities"] != "ERROR,COMPLETED" { + t.Errorf("activities = %q, want %q", result["activities"], "ERROR,COMPLETED") + } +} + +func TestExtractModalValues_Nil(t *testing.T) { + result := extractModalValues(nil) + if result != nil { + t.Errorf("expected nil for nil state, got %v", result) + } +} + +// --- Event normalization tests --- + +func TestNormalizeSlashCommand(t *testing.T) { + cmd := slackapi.SlashCommand{ + ChannelID: "C0ABC123", + UserID: "U0DEF456", + Command: "/scion", + Text: "list", + } + + event := normalizeSlashCommand(cmd) + + if event.Type != chatapp.EventCommand { + t.Errorf("Type = %q, want %q", event.Type, chatapp.EventCommand) + } + if event.Platform != PlatformName { + t.Errorf("Platform = %q, want %q", event.Platform, PlatformName) + } + if event.SpaceID != "C0ABC123" { + t.Errorf("SpaceID = %q, want %q", event.SpaceID, "C0ABC123") + } + if event.UserID != "U0DEF456" { + t.Errorf("UserID = %q, want %q", event.UserID, "U0DEF456") + } + if event.Command != "scion" { + t.Errorf("Command = %q, want %q", event.Command, "scion") + } + if event.Args != "list" { + t.Errorf("Args = %q, want %q", event.Args, "list") + } +} + +func TestNormalizeSlashCommand_MultipleArgs(t *testing.T) { + cmd := slackapi.SlashCommand{ + ChannelID: "C123", + UserID: "U456", + Text: "status my-agent", + } + + event := normalizeSlashCommand(cmd) + + if event.Args != "status my-agent" { + t.Errorf("Args = %q, want %q", event.Args, "status my-agent") + } +} + +func TestNormalizeAppMention(t *testing.T) { + evt := &slackevents.AppMentionEvent{ + User: "U0ABC123", + Text: "<@U5678BOT> tell deploy-agent hello", + Channel: "C0CHANNEL", + TimeStamp: "1712345678.123456", + ThreadTimeStamp: "1712345000.000000", + } + + event := normalizeAppMention(evt) + + if event.Type != chatapp.EventMessage { + t.Errorf("Type = %q, want %q", event.Type, chatapp.EventMessage) + } + if event.Platform != PlatformName { + t.Errorf("Platform = %q, want %q", event.Platform, PlatformName) + } + if event.SpaceID != "C0CHANNEL" { + t.Errorf("SpaceID = %q, want %q", event.SpaceID, "C0CHANNEL") + } + if event.UserID != "U0ABC123" { + t.Errorf("UserID = %q, want %q", event.UserID, "U0ABC123") + } + if event.Text != "tell deploy-agent hello" { + t.Errorf("Text = %q, want %q", event.Text, "tell deploy-agent hello") + } + if event.ThreadID != "1712345000.000000" { + t.Errorf("ThreadID = %q, want %q", event.ThreadID, "1712345000.000000") + } +} + +func TestNormalizeAppMention_NoThread(t *testing.T) { + evt := &slackevents.AppMentionEvent{ + User: "U0ABC123", + Text: "<@U5678BOT> hello", + Channel: "C0CHANNEL", + TimeStamp: "1712345678.123456", + } + + event := normalizeAppMention(evt) + + if event.ThreadID != "1712345678.123456" { + t.Errorf("ThreadID should fall back to TimeStamp, got %q", event.ThreadID) + } +} + +func TestNormalizeMessageIM(t *testing.T) { + evt := &slackevents.MessageEvent{ + User: "U0ABC123", + Text: " hello bot ", + Channel: "D0DM123", + TimeStamp: "1712345678.123456", + ThreadTimeStamp: "", + } + + event := normalizeMessageIM(evt) + + if event.Type != chatapp.EventMessage { + t.Errorf("Type = %q, want %q", event.Type, chatapp.EventMessage) + } + if event.Text != "hello bot" { + t.Errorf("Text = %q, want %q (should be trimmed)", event.Text, "hello bot") + } + if event.ThreadID != "1712345678.123456" { + t.Errorf("ThreadID should fall back to TimeStamp, got %q", event.ThreadID) + } +} + +func TestNormalizeMemberJoined(t *testing.T) { + evt := &slackevents.MemberJoinedChannelEvent{ + User: "U0BOT", + Channel: "C0CHANNEL", + } + + event := normalizeMemberJoined(evt) + + if event.Type != chatapp.EventSpaceJoin { + t.Errorf("Type = %q, want %q", event.Type, chatapp.EventSpaceJoin) + } + if event.SpaceID != "C0CHANNEL" { + t.Errorf("SpaceID = %q, want %q", event.SpaceID, "C0CHANNEL") + } +} + +func TestNormalizeMemberLeft(t *testing.T) { + evt := &slackevents.MemberLeftChannelEvent{ + User: "U0BOT", + Channel: "C0CHANNEL", + } + + event := normalizeMemberLeft(evt) + + if event.Type != chatapp.EventSpaceRemove { + t.Errorf("Type = %q, want %q", event.Type, chatapp.EventSpaceRemove) + } +} + +func TestNormalizeInteraction_BlockActions(t *testing.T) { + callback := slackapi.InteractionCallback{ + Type: slackapi.InteractionTypeBlockActions, + Channel: slackapi.Channel{ + GroupConversation: slackapi.GroupConversation{ + Conversation: slackapi.Conversation{ID: "C0CHANNEL"}, + }, + }, + User: slackapi.User{ID: "U0USER"}, + ActionCallback: slackapi.ActionCallbacks{ + BlockActions: []*slackapi.BlockAction{ + { + ActionID: "agent.start.test-agent", + Value: "test-agent", + }, + }, + }, + Message: slackapi.Message{ + Msg: slackapi.Msg{ + Timestamp: "1712345678.123456", + }, + }, + } + + event := normalizeInteraction(callback) + + if event == nil { + t.Fatal("expected non-nil event") + } + if event.Type != chatapp.EventAction { + t.Errorf("Type = %q, want %q", event.Type, chatapp.EventAction) + } + if event.ActionID != "agent.start.test-agent" { + t.Errorf("ActionID = %q, want %q", event.ActionID, "agent.start.test-agent") + } + if event.ActionData != "test-agent" { + t.Errorf("ActionData = %q, want %q", event.ActionData, "test-agent") + } + if event.ThreadID != "1712345678.123456" { + t.Errorf("ThreadID = %q, want %q", event.ThreadID, "1712345678.123456") + } +} + +func TestNormalizeInteraction_ViewSubmission(t *testing.T) { + callback := slackapi.InteractionCallback{ + Type: slackapi.InteractionTypeViewSubmission, + User: slackapi.User{ID: "U0USER"}, + View: slackapi.View{ + CallbackID: "agent.create", + PrivateMetadata: "C0CHANNEL", + State: &slackapi.ViewState{ + Values: map[string]map[string]slackapi.BlockAction{ + "name": { + "name": { + Type: "plain_text_input", + Value: "new-agent", + }, + }, + }, + }, + }, + } + + event := normalizeInteraction(callback) + + if event == nil { + t.Fatal("expected non-nil event") + } + if event.Type != chatapp.EventDialogSubmit { + t.Errorf("Type = %q, want %q", event.Type, chatapp.EventDialogSubmit) + } + if event.ActionID != "agent.create" { + t.Errorf("ActionID = %q, want %q", event.ActionID, "agent.create") + } + if event.SpaceID != "C0CHANNEL" { + t.Errorf("SpaceID = %q, want from private_metadata %q", event.SpaceID, "C0CHANNEL") + } + if event.DialogData["name"] != "new-agent" { + t.Errorf("DialogData[name] = %q, want %q", event.DialogData["name"], "new-agent") + } +} + +func TestNormalizeInteraction_EmptyBlockActions(t *testing.T) { + callback := slackapi.InteractionCallback{ + Type: slackapi.InteractionTypeBlockActions, + ActionCallback: slackapi.ActionCallbacks{ + BlockActions: []*slackapi.BlockAction{}, + }, + } + + event := normalizeInteraction(callback) + if event != nil { + t.Errorf("expected nil event for empty block actions, got %+v", event) + } +} + +func TestNormalizeInteraction_UnknownType(t *testing.T) { + callback := slackapi.InteractionCallback{ + Type: "unknown_type", + } + + event := normalizeInteraction(callback) + if event != nil { + t.Errorf("expected nil for unknown interaction type, got %+v", event) + } +} + +// --- stripBotMention tests --- + +func TestStripBotMention(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"<@U0ABC123> hello world", "hello world"}, + {"<@UBOTID> tell deploy-agent to start", "tell deploy-agent to start"}, + {"<@U123>", ""}, + {"hello world", "hello world"}, + {"", ""}, + {"<@U0ABC123>hello", "hello"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := stripBotMention(tt.input) + if got != tt.want { + t.Errorf("stripBotMention(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +// --- IconProvider tests --- + +func TestRobohashProviderIconURL(t *testing.T) { + p := &robohashProvider{} + url := p.IconURL("deploy-agent") + want := "https://robohash.org/deploy-agent?set=set1&size=48x48" + if url != want { + t.Errorf("IconURL = %q, want %q", url, want) + } +} + +func TestRobohashProviderIconURL_SpecialChars(t *testing.T) { + p := &robohashProvider{} + url := p.IconURL("agent with spaces") + if url == "" { + t.Error("expected non-empty URL") + } + // Should be URL-encoded + if url != "https://robohash.org/agent%20with%20spaces?set=set1&size=48x48" { + t.Errorf("IconURL = %q, expected URL-encoded agent slug", url) + } +} + +// --- User cache tests --- + +func TestUserCache_HitAndMiss(t *testing.T) { + a := &Adapter{ + userCache: make(map[string]*cachedUser), + } + + // Cache miss + if got := a.getCachedUser("U123"); got != nil { + t.Error("expected cache miss, got non-nil") + } + + // Cache set + user := &chatapp.ChatUser{ + PlatformID: "U123", + DisplayName: "Alice", + Email: "alice@example.com", + } + a.setCachedUser("U123", user) + + // Cache hit + got := a.getCachedUser("U123") + if got == nil { + t.Fatal("expected cache hit, got nil") + } + if got.Email != "alice@example.com" { + t.Errorf("cached email = %q, want %q", got.Email, "alice@example.com") + } +} + +func TestUserCache_Expiry(t *testing.T) { + a := &Adapter{ + userCache: make(map[string]*cachedUser), + } + + // Set expired entry + a.userCache["U123"] = &cachedUser{ + user: &chatapp.ChatUser{PlatformID: "U123"}, + fetchedAt: time.Now().Add(-userCacheTTL - time.Minute), + } + + if got := a.getCachedUser("U123"); got != nil { + t.Error("expected cache miss for expired entry, got non-nil") + } +} + +// --- Ephemeral command detection tests --- + +func TestEphemeralCommands(t *testing.T) { + tests := []struct { + subcommand string + want bool + }{ + {"help", true}, + {"info", true}, + {"register", true}, + {"unregister", true}, + {"list", false}, + {"status", false}, + {"start", false}, + {"stop", false}, + {"message", false}, + {"link", false}, + } + + for _, tt := range tests { + t.Run(tt.subcommand, func(t *testing.T) { + if got := ephemeralCommands[tt.subcommand]; got != tt.want { + t.Errorf("ephemeralCommands[%q] = %v, want %v", tt.subcommand, got, tt.want) + } + }) + } +} + +// --- Verify tests --- + +func TestVerifyRequest_InvalidSecret(t *testing.T) { + // An empty header set should fail verification + err := verifyRequest(make(map[string][]string), []byte("body"), "secret") + if err == nil { + t.Error("expected error for missing signature headers") + } +} + +// --- Platform name constant test --- + +func TestPlatformName(t *testing.T) { + if PlatformName != "slack" { + t.Errorf("PlatformName = %q, want %q", PlatformName, "slack") + } +} diff --git a/extras/scion-chat-app/internal/slack/apphome.go b/extras/scion-chat-app/internal/slack/apphome.go new file mode 100644 index 000000000..3c50cb940 --- /dev/null +++ b/extras/scion-chat-app/internal/slack/apphome.go @@ -0,0 +1,222 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package slack + +import ( + "context" + "fmt" + + slackapi "github.com/slack-go/slack" + + "github.com/GoogleCloudPlatform/scion/extras/scion-chat-app/internal/identity" + "github.com/GoogleCloudPlatform/scion/extras/scion-chat-app/internal/state" +) + +// publishAppHome builds and publishes the App Home tab for a user. +func (a *Adapter) publishAppHome(ctx context.Context, userID string) error { + view := a.buildHomeView(ctx, userID) + _, err := a.client.PublishViewContext(ctx, slackapi.PublishViewContextRequest{ + UserID: userID, + View: view, + }) + if err != nil { + a.log.Error("failed to publish app home", "user", userID, "error", err) + } + return err +} + +// buildHomeView constructs the App Home tab view for the given user. +func (a *Adapter) buildHomeView(ctx context.Context, userID string) slackapi.HomeTabViewRequest { + var blocks []slackapi.Block + + blocks = append(blocks, + slackapi.NewHeaderBlock(slackapi.NewTextBlockObject("plain_text", "Scion", false, false)), + slackapi.NewDividerBlock(), + ) + + // User profile section + blocks = append(blocks, a.buildProfileSection(ctx, userID)...) + blocks = append(blocks, slackapi.NewDividerBlock()) + + // Linked groves section + blocks = append(blocks, a.buildLinkedGrovesSection()...) + blocks = append(blocks, slackapi.NewDividerBlock()) + + // User subscriptions section + blocks = append(blocks, a.buildSubscriptionsSection(userID)...) + blocks = append(blocks, slackapi.NewDividerBlock()) + + // Quick actions + blocks = append(blocks, slackapi.NewSectionBlock( + slackapi.NewTextBlockObject("mrkdwn", "*Quick Actions*", false, false), + nil, nil, + )) + blocks = append(blocks, slackapi.NewActionBlock("", + slackapi.NewButtonBlockElement("home.help", "help", + slackapi.NewTextBlockObject("plain_text", "Help", false, false), + ), + )) + + return slackapi.HomeTabViewRequest{ + Type: "home", + Blocks: slackapi.Blocks{ + BlockSet: blocks, + }, + } +} + +func (a *Adapter) buildProfileSection(ctx context.Context, userID string) []slackapi.Block { + var blocks []slackapi.Block + blocks = append(blocks, slackapi.NewSectionBlock( + slackapi.NewTextBlockObject("mrkdwn", "*Your Profile*", false, false), + nil, nil, + )) + + if a.idMapper == nil { + blocks = append(blocks, slackapi.NewSectionBlock( + slackapi.NewTextBlockObject("mrkdwn", "Registration: _unavailable_", false, false), + nil, nil, + )) + return blocks + } + + mapping, err := a.idMapper.Resolve(userID, PlatformName) + if err != nil || mapping == nil { + blocks = append(blocks, slackapi.NewSectionBlock(nil, + []*slackapi.TextBlockObject{ + slackapi.NewTextBlockObject("mrkdwn", "*Registration*", false, false), + slackapi.NewTextBlockObject("mrkdwn", "Not registered", false, false), + }, nil, + )) + return blocks + } + + blocks = append(blocks, slackapi.NewSectionBlock(nil, + []*slackapi.TextBlockObject{ + slackapi.NewTextBlockObject("mrkdwn", "*Registration*", false, false), + slackapi.NewTextBlockObject("mrkdwn", "Registered", false, false), + }, nil, + )) + blocks = append(blocks, slackapi.NewSectionBlock(nil, + []*slackapi.TextBlockObject{ + slackapi.NewTextBlockObject("mrkdwn", "*Hub Email*", false, false), + slackapi.NewTextBlockObject("mrkdwn", mapping.HubUserEmail, false, false), + }, nil, + )) + + return blocks +} + +func (a *Adapter) buildLinkedGrovesSection() []slackapi.Block { + var blocks []slackapi.Block + blocks = append(blocks, slackapi.NewSectionBlock( + slackapi.NewTextBlockObject("mrkdwn", "*Linked Groves*", false, false), + nil, nil, + )) + + if a.store == nil { + return blocks + } + + links, err := a.store.ListSpaceLinks() + if err != nil { + a.log.Error("failed to list space links for app home", "error", err) + return blocks + } + + var slackLinks []state.SpaceLink + for _, link := range links { + if link.Platform == PlatformName { + slackLinks = append(slackLinks, link) + } + } + + if len(slackLinks) == 0 { + blocks = append(blocks, slackapi.NewSectionBlock( + slackapi.NewTextBlockObject("mrkdwn", "_No groves linked. Use `/scion link ` in a channel._", false, false), + nil, nil, + )) + return blocks + } + + for _, link := range slackLinks { + blocks = append(blocks, slackapi.NewSectionBlock( + slackapi.NewTextBlockObject("mrkdwn", + fmt.Sprintf("<#%s> → %s", link.SpaceID, link.GroveSlug), + false, false, + ), + nil, nil, + )) + } + + return blocks +} + +func (a *Adapter) buildSubscriptionsSection(userID string) []slackapi.Block { + var blocks []slackapi.Block + blocks = append(blocks, slackapi.NewSectionBlock( + slackapi.NewTextBlockObject("mrkdwn", "*Your Subscriptions*", false, false), + nil, nil, + )) + + if a.store == nil { + return blocks + } + + subs, err := a.store.ListUserSubscriptions(userID, PlatformName) + if err != nil { + a.log.Error("failed to list subscriptions for app home", "error", err) + return blocks + } + + if len(subs) == 0 { + blocks = append(blocks, slackapi.NewSectionBlock( + slackapi.NewTextBlockObject("mrkdwn", "_No subscriptions. Use `/scion subscribe ` to subscribe._", false, false), + nil, nil, + )) + return blocks + } + + for _, sub := range subs { + activities := sub.Activities + if activities == "" { + activities = "all activities" + } + blocks = append(blocks, slackapi.NewSectionBlock( + slackapi.NewTextBlockObject("mrkdwn", + fmt.Sprintf("`%s` — %s", sub.AgentID, activities), + false, false, + ), + nil, nil, + )) + } + + return blocks +} + +// AppHomeStore provides the subset of state.Store needed by the App Home tab. +// Exposed separately so the adapter can be tested without a full store. +type AppHomeStore interface { + ListSpaceLinks() ([]state.SpaceLink, error) + ListUserSubscriptions(platformUserID, platform string) ([]state.AgentSubscription, error) +} + +// AppHomeIdentity provides the subset of identity.Mapper needed by the App Home. +type AppHomeIdentity interface { + Resolve(platformUserID, platform string) (*state.UserMapping, error) +} + +// Ensure the real types satisfy these interfaces at compile time. +var _ AppHomeIdentity = (*identity.Mapper)(nil) diff --git a/extras/scion-chat-app/internal/slack/blocks.go b/extras/scion-chat-app/internal/slack/blocks.go new file mode 100644 index 000000000..c4a474742 --- /dev/null +++ b/extras/scion-chat-app/internal/slack/blocks.go @@ -0,0 +1,145 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package slack + +import ( + slackapi "github.com/slack-go/slack" + + "github.com/GoogleCloudPlatform/scion/extras/scion-chat-app/internal/chatapp" +) + +// renderBlocks converts a platform-agnostic Card to Slack Block Kit blocks. +func renderBlocks(card *chatapp.Card) []slackapi.Block { + var blocks []slackapi.Block + + if card.Header.Title != "" { + blocks = append(blocks, slackapi.NewHeaderBlock( + slackapi.NewTextBlockObject("plain_text", card.Header.Title, false, false), + )) + if card.Header.Subtitle != "" { + blocks = append(blocks, slackapi.NewContextBlock("", + slackapi.NewTextBlockObject("mrkdwn", card.Header.Subtitle, false, false), + )) + } + } + + for _, section := range card.Sections { + if section.Header != "" { + blocks = append(blocks, slackapi.NewSectionBlock( + slackapi.NewTextBlockObject("mrkdwn", "*"+section.Header+"*", false, false), + nil, nil, + )) + } + for _, widget := range section.Widgets { + blocks = append(blocks, renderWidget(&widget)...) + } + } + + if len(card.Actions) > 0 { + var buttons []slackapi.BlockElement + for _, action := range card.Actions { + btn := slackapi.NewButtonBlockElement( + action.ActionID, + action.ActionID, + slackapi.NewTextBlockObject("plain_text", action.Label, false, false), + ) + switch action.Style { + case "primary": + btn.Style = slackapi.StylePrimary + case "danger": + btn.Style = slackapi.StyleDanger + } + buttons = append(buttons, btn) + } + blocks = append(blocks, slackapi.NewActionBlock("", buttons...)) + } + + return blocks +} + +// renderWidget converts a single Widget to one or more Block Kit blocks. +func renderWidget(w *chatapp.Widget) []slackapi.Block { + switch w.Type { + case chatapp.WidgetText: + return []slackapi.Block{ + slackapi.NewSectionBlock( + slackapi.NewTextBlockObject("mrkdwn", w.Content, false, false), + nil, nil, + ), + } + + case chatapp.WidgetKeyValue: + return []slackapi.Block{ + slackapi.NewSectionBlock(nil, + []*slackapi.TextBlockObject{ + slackapi.NewTextBlockObject("mrkdwn", "*"+w.Label+"*", false, false), + slackapi.NewTextBlockObject("mrkdwn", w.Content, false, false), + }, + nil, + ), + } + + case chatapp.WidgetButton: + btn := slackapi.NewButtonBlockElement( + w.ActionID, w.ActionData, + slackapi.NewTextBlockObject("plain_text", w.Label, false, false), + ) + return []slackapi.Block{slackapi.NewActionBlock("", btn)} + + case chatapp.WidgetDivider: + return []slackapi.Block{slackapi.NewDividerBlock()} + + case chatapp.WidgetImage: + return []slackapi.Block{ + slackapi.NewImageBlock(w.Content, w.Label, "", nil), + } + + case chatapp.WidgetInput: + input := slackapi.NewPlainTextInputBlockElement( + slackapi.NewTextBlockObject("plain_text", w.Label, false, false), + w.ActionID, + ) + return []slackapi.Block{ + slackapi.NewInputBlock( + w.ActionID, + slackapi.NewTextBlockObject("plain_text", w.Label, false, false), + nil, + input, + ), + } + + case chatapp.WidgetCheckbox: + var options []*slackapi.OptionBlockObject + for _, opt := range w.Options { + options = append(options, slackapi.NewOptionBlockObject( + opt.Value, + slackapi.NewTextBlockObject("plain_text", opt.Label, false, false), + nil, + )) + } + checkboxes := slackapi.NewCheckboxGroupsBlockElement(w.ActionID, options...) + return []slackapi.Block{ + slackapi.NewInputBlock( + w.ActionID, + slackapi.NewTextBlockObject("plain_text", w.Label, false, false), + nil, + checkboxes, + ), + } + + default: + return nil + } +} diff --git a/extras/scion-chat-app/internal/slack/events.go b/extras/scion-chat-app/internal/slack/events.go new file mode 100644 index 000000000..fadf9c761 --- /dev/null +++ b/extras/scion-chat-app/internal/slack/events.go @@ -0,0 +1,136 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package slack + +import ( + "regexp" + "strings" + + slackapi "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + + "github.com/GoogleCloudPlatform/scion/extras/scion-chat-app/internal/chatapp" +) + +// botMentionRe matches a Slack user mention at the start of a message (e.g., "<@U0ABC123> "). +var botMentionRe = regexp.MustCompile(`^<@[A-Z0-9]+>\s*`) + +// normalizeSlashCommand converts a Slack slash command into a ChatEvent. +func normalizeSlashCommand(cmd slackapi.SlashCommand) *chatapp.ChatEvent { + return &chatapp.ChatEvent{ + Type: chatapp.EventCommand, + Platform: PlatformName, + SpaceID: cmd.ChannelID, + UserID: cmd.UserID, + Command: "scion", + Args: cmd.Text, + } +} + +// normalizeAppMention converts a Slack app_mention event into a ChatEvent. +func normalizeAppMention(evt *slackevents.AppMentionEvent) *chatapp.ChatEvent { + text := stripBotMention(evt.Text) + threadTS := evt.ThreadTimeStamp + if threadTS == "" { + threadTS = evt.TimeStamp + } + return &chatapp.ChatEvent{ + Type: chatapp.EventMessage, + Platform: PlatformName, + SpaceID: evt.Channel, + ThreadID: threadTS, + UserID: evt.User, + Text: text, + } +} + +// normalizeMessageIM converts a Slack DM message event into a ChatEvent. +func normalizeMessageIM(evt *slackevents.MessageEvent) *chatapp.ChatEvent { + threadTS := evt.ThreadTimeStamp + if threadTS == "" { + threadTS = evt.TimeStamp + } + return &chatapp.ChatEvent{ + Type: chatapp.EventMessage, + Platform: PlatformName, + SpaceID: evt.Channel, + ThreadID: threadTS, + UserID: evt.User, + Text: strings.TrimSpace(evt.Text), + } +} + +// normalizeMemberJoined converts a member_joined_channel event for the bot +// into a ChatEvent indicating the bot was added to a space. +func normalizeMemberJoined(evt *slackevents.MemberJoinedChannelEvent) *chatapp.ChatEvent { + return &chatapp.ChatEvent{ + Type: chatapp.EventSpaceJoin, + Platform: PlatformName, + SpaceID: evt.Channel, + UserID: evt.User, + } +} + +// normalizeMemberLeft converts a member_left_channel event for the bot +// into a ChatEvent indicating the bot was removed from a space. +func normalizeMemberLeft(evt *slackevents.MemberLeftChannelEvent) *chatapp.ChatEvent { + return &chatapp.ChatEvent{ + Type: chatapp.EventSpaceRemove, + Platform: PlatformName, + SpaceID: evt.Channel, + UserID: evt.User, + } +} + +// normalizeInteraction converts a Slack interaction callback into a ChatEvent. +func normalizeInteraction(callback slackapi.InteractionCallback) *chatapp.ChatEvent { + event := &chatapp.ChatEvent{ + Platform: PlatformName, + SpaceID: callback.Channel.ID, + UserID: callback.User.ID, + } + + switch callback.Type { + case slackapi.InteractionTypeBlockActions: + if len(callback.ActionCallback.BlockActions) == 0 { + return nil + } + action := callback.ActionCallback.BlockActions[0] + event.Type = chatapp.EventAction + event.ActionID = action.ActionID + event.ActionData = action.Value + if callback.Message.ThreadTimestamp != "" { + event.ThreadID = callback.Message.ThreadTimestamp + } else { + event.ThreadID = callback.Message.Timestamp + } + + case slackapi.InteractionTypeViewSubmission: + event.Type = chatapp.EventDialogSubmit + event.ActionID = callback.View.CallbackID + event.DialogData = extractModalValues(callback.View.State) + event.SpaceID = callback.View.PrivateMetadata + + default: + return nil + } + + return event +} + +// stripBotMention removes the leading "<@BOTID> " from message text. +func stripBotMention(text string) string { + return strings.TrimSpace(botMentionRe.ReplaceAllString(text, "")) +} diff --git a/extras/scion-chat-app/internal/slack/modals.go b/extras/scion-chat-app/internal/slack/modals.go new file mode 100644 index 000000000..352ce2237 --- /dev/null +++ b/extras/scion-chat-app/internal/slack/modals.go @@ -0,0 +1,150 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package slack + +import ( + "strings" + + slackapi "github.com/slack-go/slack" + + "github.com/GoogleCloudPlatform/scion/extras/scion-chat-app/internal/chatapp" +) + +// renderModal converts a platform-agnostic Dialog to a Slack ModalViewRequest. +func renderModal(dialog *chatapp.Dialog) slackapi.ModalViewRequest { + var blocks slackapi.Blocks + + for _, field := range dialog.Fields { + switch field.Type { + case "text": + input := slackapi.NewPlainTextInputBlockElement( + slackapi.NewTextBlockObject("plain_text", field.Placeholder, false, false), + field.ID, + ) + block := slackapi.NewInputBlock( + field.ID, + slackapi.NewTextBlockObject("plain_text", field.Label, false, false), + nil, + input, + ) + block.Optional = !field.Required + blocks.BlockSet = append(blocks.BlockSet, block) + + case "textarea": + input := slackapi.NewPlainTextInputBlockElement( + slackapi.NewTextBlockObject("plain_text", field.Placeholder, false, false), + field.ID, + ) + input.Multiline = true + block := slackapi.NewInputBlock( + field.ID, + slackapi.NewTextBlockObject("plain_text", field.Label, false, false), + nil, + input, + ) + block.Optional = !field.Required + blocks.BlockSet = append(blocks.BlockSet, block) + + case "select": + var options []*slackapi.OptionBlockObject + for _, opt := range field.Options { + options = append(options, slackapi.NewOptionBlockObject( + opt.Value, + slackapi.NewTextBlockObject("plain_text", opt.Label, false, false), + nil, + )) + } + sel := slackapi.NewOptionsSelectBlockElement( + "static_select", nil, field.ID, options..., + ) + block := slackapi.NewInputBlock( + field.ID, + slackapi.NewTextBlockObject("plain_text", field.Label, false, false), + nil, + sel, + ) + block.Optional = !field.Required + blocks.BlockSet = append(blocks.BlockSet, block) + + case "checkbox": + var options []*slackapi.OptionBlockObject + for _, opt := range field.Options { + options = append(options, slackapi.NewOptionBlockObject( + opt.Value, + slackapi.NewTextBlockObject("plain_text", opt.Label, false, false), + nil, + )) + } + cb := slackapi.NewCheckboxGroupsBlockElement(field.ID, options...) + block := slackapi.NewInputBlock( + field.ID, + slackapi.NewTextBlockObject("plain_text", field.Label, false, false), + nil, + cb, + ) + block.Optional = !field.Required + blocks.BlockSet = append(blocks.BlockSet, block) + } + } + + mvr := slackapi.ModalViewRequest{ + Type: "modal", + Title: slackapi.NewTextBlockObject("plain_text", dialog.Title, false, false), + CallbackID: dialog.Submit.ActionID, + Blocks: blocks, + } + if dialog.Submit.Label != "" { + mvr.Submit = slackapi.NewTextBlockObject("plain_text", dialog.Submit.Label, false, false) + } + if dialog.Cancel.Label != "" { + mvr.Close = slackapi.NewTextBlockObject("plain_text", dialog.Cancel.Label, false, false) + } + return mvr +} + +// extractModalValues flattens the nested view.state.values structure from a +// Slack modal submission into a simple map keyed by action ID (and block ID +// as fallback), matching the ChatEvent.DialogData format expected by the +// CommandRouter. +func extractModalValues(state *slackapi.ViewState) map[string]string { + if state == nil { + return nil + } + result := make(map[string]string) + for blockID, blockValues := range state.Values { + for actionID, action := range blockValues { + var val string + switch action.Type { + case "plain_text_input": + val = action.Value + case "static_select": + val = action.SelectedOption.Value + case "checkboxes": + var vals []string + for _, opt := range action.SelectedOptions { + vals = append(vals, opt.Value) + } + val = strings.Join(vals, ",") + default: + val = action.Value + } + result[actionID] = val + if _, exists := result[blockID]; !exists { + result[blockID] = val + } + } + } + return result +} diff --git a/extras/scion-chat-app/internal/slack/verify.go b/extras/scion-chat-app/internal/slack/verify.go new file mode 100644 index 000000000..d9e17cc27 --- /dev/null +++ b/extras/scion-chat-app/internal/slack/verify.go @@ -0,0 +1,35 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package slack + +import ( + "net/http" + + slackapi "github.com/slack-go/slack" +) + +// verifyRequest validates that an inbound HTTP request was signed by Slack +// using the app's signing secret. The full request body must be passed in +// because the HMAC covers the raw payload. +func verifyRequest(header http.Header, body []byte, signingSecret string) error { + sv, err := slackapi.NewSecretsVerifier(header, signingSecret) + if err != nil { + return err + } + if _, err := sv.Write(body); err != nil { + return err + } + return sv.Ensure() +}