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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,10 @@ func run(ctx context.Context, cancel context.CancelFunc, cfg *config.ServerConfi
// Initialize event router for multi-workspace event handling.
eventRouter := slack.NewEventRouter(slackManager)

// Initialize home view handler
homeHandler := slack.NewHomeHandler(slackManager, githubManager, configManager, stateStore)
slackManager.SetHomeViewHandler(homeHandler.HandleAppHomeOpened)

// Initialize OAuth handler for Slack app installation.
// These credentials are needed for the OAuth flow.
slackClientID := os.Getenv("SLACK_CLIENT_ID")
Expand Down
1 change: 1 addition & 0 deletions internal/bot/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type SlackClient interface {
IsBotInChannel(ctx context.Context, channelID string) bool
BotInfo(ctx context.Context) (*slack.AuthTestResponse, error)
WorkspaceInfo(ctx context.Context) (*slack.TeamInfo, error)
PublishHomeView(userID string, blocks []slack.Block) error
API() *slack.Client
}

Expand Down
147 changes: 147 additions & 0 deletions internal/slack/home_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package slack

import (
"context"
"fmt"
"log/slog"
"strings"

"github.com/codeGROOVE-dev/slacker/internal/config"
"github.com/codeGROOVE-dev/slacker/internal/github"
"github.com/codeGROOVE-dev/slacker/internal/state"
"github.com/codeGROOVE-dev/slacker/pkg/home"
)

// HomeHandler handles app_home_opened events for a workspace.
type HomeHandler struct {
slackManager *Manager
githubManager *github.Manager
configManager *config.Manager
stateStore state.Store
}

// NewHomeHandler creates a new home view handler.
func NewHomeHandler(
slackManager *Manager,
githubManager *github.Manager,
configManager *config.Manager,
stateStore state.Store,
) *HomeHandler {
return &HomeHandler{
slackManager: slackManager,
githubManager: githubManager,
configManager: configManager,
stateStore: stateStore,
}
}

// HandleAppHomeOpened updates the app home view when a user opens it.
func (h *HomeHandler) HandleAppHomeOpened(ctx context.Context, teamID, slackUserID string) error {
slog.Debug("handling app home opened",
"team_id", teamID,
"slack_user_id", slackUserID)

// Get Slack client for this workspace
slackClient, err := h.slackManager.Client(ctx, teamID)
if err != nil {
return fmt.Errorf("failed to get Slack client: %w", err)
}

// Get Slack user info to extract email
slackUser, err := slackClient.API().GetUserInfo(slackUserID)
if err != nil {
slog.Warn("failed to get Slack user info", "user_id", slackUserID, "error", err)
return h.publishPlaceholderHome(slackClient, slackUserID)
}

// Extract GitHub username from email (simple heuristic: part before @)
// Works for "username@company.com" -> "username"
email := slackUser.Profile.Email
atIndex := strings.IndexByte(email, '@')
if atIndex <= 0 {
slog.Warn("could not extract GitHub username from Slack email",
"slack_user_id", slackUserID,
"email", email)
return h.publishPlaceholderHome(slackClient, slackUserID)
}
githubUsername := email[:atIndex]

// Get all orgs for this workspace
workspaceOrgs := h.workspaceOrgs(teamID)
if len(workspaceOrgs) == 0 {
slog.Warn("no workspace orgs found", "team_id", teamID)
return h.publishPlaceholderHome(slackClient, slackUserID)
}

// Get GitHub client for first org (they all share the same app)
githubClient, ok := h.githubManager.ClientForOrg(workspaceOrgs[0])
if !ok {
return fmt.Errorf("no GitHub client for org: %s", workspaceOrgs[0])
}

// Create fetcher and fetch dashboard
fetcher := home.NewFetcher(
githubClient.Client(),
h.stateStore,
githubClient.InstallationToken(ctx),
"ready-to-review[bot]",
)

dashboard, err := fetcher.FetchDashboard(ctx, githubUsername, workspaceOrgs)
if err != nil {
slog.Error("failed to fetch dashboard",
"github_user", githubUsername,
"error", err)
return h.publishPlaceholderHome(slackClient, slackUserID)
}

// Build Block Kit UI - use first org as primary
blocks := home.BuildBlocks(dashboard, workspaceOrgs[0])

// Publish to Slack
if err := slackClient.PublishHomeView(slackUserID, blocks); err != nil {
return fmt.Errorf("failed to publish home view: %w", err)
}

slog.Info("published home view",
"slack_user_id", slackUserID,
"github_user", githubUsername,
"incoming_prs", len(dashboard.IncomingPRs),
"outgoing_prs", len(dashboard.OutgoingPRs),
"workspace_orgs", len(workspaceOrgs))

return nil
}

// workspaceOrgs returns all GitHub orgs configured for this Slack workspace.
func (h *HomeHandler) workspaceOrgs(teamID string) []string {
allOrgs := h.githubManager.AllOrgs()
var workspaceOrgs []string

for _, org := range allOrgs {
cfg, exists := h.configManager.Config(org)
if !exists {
continue
}

// Check if this org is configured for this workspace
if cfg.Global.TeamID == teamID {
workspaceOrgs = append(workspaceOrgs, org)
}
}

return workspaceOrgs
}

// publishPlaceholderHome publishes a simple placeholder home view.
func (*HomeHandler) publishPlaceholderHome(slackClient *Client, slackUserID string) error {
slog.Debug("publishing placeholder home", "user_id", slackUserID)

blocks := home.BuildBlocks(&home.Dashboard{
IncomingPRs: nil,
OutgoingPRs: nil,
WorkspaceOrgs: []string{"your-org"},
}, "your-org")

return slackClient.PublishHomeView(slackUserID, blocks)
}
29 changes: 25 additions & 4 deletions internal/slack/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ type WorkspaceMetadata struct {

// Manager manages Slack clients for multiple workspaces.
type Manager struct {
clients map[string]*Client // team_id -> client
metadata map[string]*WorkspaceMetadata
signingSecret string
mu sync.RWMutex
clients map[string]*Client // team_id -> client
metadata map[string]*WorkspaceMetadata
signingSecret string
homeViewHandler func(ctx context.Context, teamID, userID string) error // Global home view handler
mu sync.RWMutex
}

// NewManager creates a new Slack client manager.
Expand Down Expand Up @@ -81,6 +82,12 @@ func (m *Manager) Client(ctx context.Context, teamID string) (*Client, error) {

// Create client
client = New(token, m.signingSecret)
client.SetTeamID(teamID)

// Set home view handler if configured
if m.homeViewHandler != nil {
client.SetHomeViewHandler(m.homeViewHandler)
}

// Cache it
m.mu.Lock()
Expand All @@ -96,6 +103,20 @@ func (m *Manager) Client(ctx context.Context, teamID string) (*Client, error) {
return client, nil
}

// SetHomeViewHandler sets the home view handler on all current and future clients.
func (m *Manager) SetHomeViewHandler(handler func(ctx context.Context, teamID, userID string) error) {
m.mu.Lock()
defer m.mu.Unlock()

// Store for future clients
m.homeViewHandler = handler

// Set on all existing clients
for _, client := range m.clients {
client.SetHomeViewHandler(handler)
}
}

// StoreWorkspace stores a workspace's token and metadata in GSM.
func (m *Manager) StoreWorkspace(ctx context.Context, metadata *WorkspaceMetadata, token string) error {
slog.Info("storing workspace token and metadata in GSM",
Expand Down
48 changes: 41 additions & 7 deletions internal/slack/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,12 @@ type apiCache struct {

// Client wraps the Slack API client with caching.
type Client struct {
api *slack.Client
cache *apiCache
signingSecret string
api *slack.Client
cache *apiCache
signingSecret string
teamID string // Workspace team ID
homeViewHandler func(ctx context.Context, teamID, userID string) error // Callback for app_home_opened events
homeViewHandlerMu sync.RWMutex
}

// set stores a value in the cache with TTL.
Expand Down Expand Up @@ -115,6 +118,18 @@ func New(token, signingSecret string) *Client {
}
}

// SetHomeViewHandler registers a callback for app_home_opened events.
func (c *Client) SetHomeViewHandler(handler func(ctx context.Context, teamID, userID string) error) {
c.homeViewHandlerMu.Lock()
defer c.homeViewHandlerMu.Unlock()
c.homeViewHandler = handler
}

// SetTeamID sets the team ID for this client.
func (c *Client) SetTeamID(teamID string) {
c.teamID = teamID
}

// WorkspaceInfo returns information about the current workspace (cached for 1 hour).
func (c *Client) WorkspaceInfo(ctx context.Context) (*slack.TeamInfo, error) {
cacheKey := "team_info"
Expand Down Expand Up @@ -775,10 +790,29 @@ func (c *Client) EventsHandler(writer http.ResponseWriter, r *http.Request) {
// Handle app mentions if needed.
slog.Debug("received app mention", "event", evt)
case *slackevents.AppHomeOpenedEvent:
// Update app home when user opens it.
// In a full implementation, this would update the home tab.
// For now, just log.
slog.Debug("would update app home for user", "user", evt.User)
// Update app home when user opens it
slog.Debug("app home opened", "user", evt.User)

// Call registered home view handler if present
c.homeViewHandlerMu.RLock()
handler := c.homeViewHandler
c.homeViewHandlerMu.RUnlock()

if handler != nil {
go func(teamID, userID string) {
homeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

if err := handler(homeCtx, teamID, userID); err != nil {
slog.Error("home view handler failed",
"team_id", teamID,
"user", userID,
"error", err)
}
}(c.teamID, evt.User)
} else {
slog.Debug("no home view handler registered", "user", evt.User)
}
case *slackevents.MemberJoinedChannelEvent:
// Bot was added to a channel - invalidate cache
slog.Info("bot joined channel - invalidating cache",
Expand Down
27 changes: 15 additions & 12 deletions internal/state/datastore.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ const (

// Thread entity for Datastore.
type threadEntity struct {
ThreadTS string `datastore:"thread_ts"`
ChannelID string `datastore:"channel_id"`
MessageText string `datastore:"message_text,noindex"`
UpdatedAt time.Time `datastore:"updated_at"`
ThreadTS string `datastore:"thread_ts"`
ChannelID string `datastore:"channel_id"`
MessageText string `datastore:"message_text,noindex"`
UpdatedAt time.Time `datastore:"updated_at"`
LastEventTime time.Time `datastore:"last_event_time"`
}

// DM tracking entity.
Expand Down Expand Up @@ -155,10 +156,11 @@ func (s *DatastoreStore) GetThread(owner, repo string, number int, channelID str

// Found in Datastore - update JSON cache and return
result := ThreadInfo{
ThreadTS: entity.ThreadTS,
ChannelID: entity.ChannelID,
MessageText: entity.MessageText,
UpdatedAt: entity.UpdatedAt,
ThreadTS: entity.ThreadTS,
ChannelID: entity.ChannelID,
MessageText: entity.MessageText,
UpdatedAt: entity.UpdatedAt,
LastEventTime: entity.LastEventTime,
}

// Async update JSON cache (don't wait)
Expand Down Expand Up @@ -192,10 +194,11 @@ func (s *DatastoreStore) SaveThread(owner, repo string, number int, channelID st

dsKey := datastore.NameKey(kindThread, key, nil)
entity := &threadEntity{
ThreadTS: info.ThreadTS,
ChannelID: info.ChannelID,
MessageText: info.MessageText,
UpdatedAt: time.Now(),
ThreadTS: info.ThreadTS,
ChannelID: info.ChannelID,
MessageText: info.MessageText,
UpdatedAt: time.Now(),
LastEventTime: info.LastEventTime,
}

if _, err := s.ds.Put(ctx, dsKey, entity); err != nil {
Expand Down
11 changes: 6 additions & 5 deletions internal/state/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import (

// ThreadInfo stores information about a Slack thread for a PR.
type ThreadInfo struct {
UpdatedAt time.Time `json:"updated_at"`
ThreadTS string `json:"thread_ts"`
ChannelID string `json:"channel_id"`
LastState string `json:"last_state"`
MessageText string `json:"message_text"`
UpdatedAt time.Time `json:"updated_at"`
LastEventTime time.Time `json:"last_event_time"` // Last sprinkler event timestamp for turnclient cache optimization
ThreadTS string `json:"thread_ts"`
ChannelID string `json:"channel_id"`
LastState string `json:"last_state"`
MessageText string `json:"message_text"`
}

// Store provides persistent storage for bot state.
Expand Down
Loading