Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 76 additions & 14 deletions extras/scion-chat-app/cmd/scion-chat-app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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.
Expand All @@ -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")
Expand All @@ -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)
}
}

Expand Down
2 changes: 2 additions & 0 deletions extras/scion-chat-app/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions extras/scion-chat-app/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
93 changes: 74 additions & 19 deletions extras/scion-chat-app/internal/chatapp/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The messenger.GetUser() call is performed synchronously within the command handling flow. Given that this can involve network I/O, consider if this should be cached or if the latency is acceptable for the command execution path.

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.
Expand All @@ -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

Expand All @@ -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),
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -529,7 +576,7 @@ func (r *CommandRouter) cmdLink(ctx context.Context, event *ChatEvent, args []st
return textResponse(event, "Usage: `/scion link <grove-slug>`"), 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
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down
4 changes: 3 additions & 1 deletion extras/scion-chat-app/internal/chatapp/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading