Skip to content

Commit 2cf6663

Browse files
authored
Merge pull request #39 from codeGROOVE-dev/reliable
Add Slacker Home page
2 parents 7d99368 + 1888c03 commit 2cf6663

File tree

10 files changed

+767
-28
lines changed

10 files changed

+767
-28
lines changed

cmd/server/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,10 @@ func run(ctx context.Context, cancel context.CancelFunc, cfg *config.ServerConfi
235235
// Initialize event router for multi-workspace event handling.
236236
eventRouter := slack.NewEventRouter(slackManager)
237237

238+
// Initialize home view handler
239+
homeHandler := slack.NewHomeHandler(slackManager, githubManager, configManager, stateStore)
240+
slackManager.SetHomeViewHandler(homeHandler.HandleAppHomeOpened)
241+
238242
// Initialize OAuth handler for Slack app installation.
239243
// These credentials are needed for the OAuth flow.
240244
slackClientID := os.Getenv("SLACK_CLIENT_ID")

internal/bot/interfaces.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type SlackClient interface {
1919
IsBotInChannel(ctx context.Context, channelID string) bool
2020
BotInfo(ctx context.Context) (*slack.AuthTestResponse, error)
2121
WorkspaceInfo(ctx context.Context) (*slack.TeamInfo, error)
22+
PublishHomeView(userID string, blocks []slack.Block) error
2223
API() *slack.Client
2324
}
2425

internal/slack/home_handler.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package slack
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"strings"
8+
9+
"github.com/codeGROOVE-dev/slacker/internal/config"
10+
"github.com/codeGROOVE-dev/slacker/internal/github"
11+
"github.com/codeGROOVE-dev/slacker/internal/state"
12+
"github.com/codeGROOVE-dev/slacker/pkg/home"
13+
)
14+
15+
// HomeHandler handles app_home_opened events for a workspace.
16+
type HomeHandler struct {
17+
slackManager *Manager
18+
githubManager *github.Manager
19+
configManager *config.Manager
20+
stateStore state.Store
21+
}
22+
23+
// NewHomeHandler creates a new home view handler.
24+
func NewHomeHandler(
25+
slackManager *Manager,
26+
githubManager *github.Manager,
27+
configManager *config.Manager,
28+
stateStore state.Store,
29+
) *HomeHandler {
30+
return &HomeHandler{
31+
slackManager: slackManager,
32+
githubManager: githubManager,
33+
configManager: configManager,
34+
stateStore: stateStore,
35+
}
36+
}
37+
38+
// HandleAppHomeOpened updates the app home view when a user opens it.
39+
func (h *HomeHandler) HandleAppHomeOpened(ctx context.Context, teamID, slackUserID string) error {
40+
slog.Debug("handling app home opened",
41+
"team_id", teamID,
42+
"slack_user_id", slackUserID)
43+
44+
// Get Slack client for this workspace
45+
slackClient, err := h.slackManager.Client(ctx, teamID)
46+
if err != nil {
47+
return fmt.Errorf("failed to get Slack client: %w", err)
48+
}
49+
50+
// Get Slack user info to extract email
51+
slackUser, err := slackClient.API().GetUserInfo(slackUserID)
52+
if err != nil {
53+
slog.Warn("failed to get Slack user info", "user_id", slackUserID, "error", err)
54+
return h.publishPlaceholderHome(slackClient, slackUserID)
55+
}
56+
57+
// Extract GitHub username from email (simple heuristic: part before @)
58+
// Works for "[email protected]" -> "username"
59+
email := slackUser.Profile.Email
60+
atIndex := strings.IndexByte(email, '@')
61+
if atIndex <= 0 {
62+
slog.Warn("could not extract GitHub username from Slack email",
63+
"slack_user_id", slackUserID,
64+
"email", email)
65+
return h.publishPlaceholderHome(slackClient, slackUserID)
66+
}
67+
githubUsername := email[:atIndex]
68+
69+
// Get all orgs for this workspace
70+
workspaceOrgs := h.workspaceOrgs(teamID)
71+
if len(workspaceOrgs) == 0 {
72+
slog.Warn("no workspace orgs found", "team_id", teamID)
73+
return h.publishPlaceholderHome(slackClient, slackUserID)
74+
}
75+
76+
// Get GitHub client for first org (they all share the same app)
77+
githubClient, ok := h.githubManager.ClientForOrg(workspaceOrgs[0])
78+
if !ok {
79+
return fmt.Errorf("no GitHub client for org: %s", workspaceOrgs[0])
80+
}
81+
82+
// Create fetcher and fetch dashboard
83+
fetcher := home.NewFetcher(
84+
githubClient.Client(),
85+
h.stateStore,
86+
githubClient.InstallationToken(ctx),
87+
"ready-to-review[bot]",
88+
)
89+
90+
dashboard, err := fetcher.FetchDashboard(ctx, githubUsername, workspaceOrgs)
91+
if err != nil {
92+
slog.Error("failed to fetch dashboard",
93+
"github_user", githubUsername,
94+
"error", err)
95+
return h.publishPlaceholderHome(slackClient, slackUserID)
96+
}
97+
98+
// Build Block Kit UI - use first org as primary
99+
blocks := home.BuildBlocks(dashboard, workspaceOrgs[0])
100+
101+
// Publish to Slack
102+
if err := slackClient.PublishHomeView(slackUserID, blocks); err != nil {
103+
return fmt.Errorf("failed to publish home view: %w", err)
104+
}
105+
106+
slog.Info("published home view",
107+
"slack_user_id", slackUserID,
108+
"github_user", githubUsername,
109+
"incoming_prs", len(dashboard.IncomingPRs),
110+
"outgoing_prs", len(dashboard.OutgoingPRs),
111+
"workspace_orgs", len(workspaceOrgs))
112+
113+
return nil
114+
}
115+
116+
// workspaceOrgs returns all GitHub orgs configured for this Slack workspace.
117+
func (h *HomeHandler) workspaceOrgs(teamID string) []string {
118+
allOrgs := h.githubManager.AllOrgs()
119+
var workspaceOrgs []string
120+
121+
for _, org := range allOrgs {
122+
cfg, exists := h.configManager.Config(org)
123+
if !exists {
124+
continue
125+
}
126+
127+
// Check if this org is configured for this workspace
128+
if cfg.Global.TeamID == teamID {
129+
workspaceOrgs = append(workspaceOrgs, org)
130+
}
131+
}
132+
133+
return workspaceOrgs
134+
}
135+
136+
// publishPlaceholderHome publishes a simple placeholder home view.
137+
func (*HomeHandler) publishPlaceholderHome(slackClient *Client, slackUserID string) error {
138+
slog.Debug("publishing placeholder home", "user_id", slackUserID)
139+
140+
blocks := home.BuildBlocks(&home.Dashboard{
141+
IncomingPRs: nil,
142+
OutgoingPRs: nil,
143+
WorkspaceOrgs: []string{"your-org"},
144+
}, "your-org")
145+
146+
return slackClient.PublishHomeView(slackUserID, blocks)
147+
}

internal/slack/manager.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@ type WorkspaceMetadata struct {
2020

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

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

8283
// Create client
8384
client = New(token, m.signingSecret)
85+
client.SetTeamID(teamID)
86+
87+
// Set home view handler if configured
88+
if m.homeViewHandler != nil {
89+
client.SetHomeViewHandler(m.homeViewHandler)
90+
}
8491

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

106+
// SetHomeViewHandler sets the home view handler on all current and future clients.
107+
func (m *Manager) SetHomeViewHandler(handler func(ctx context.Context, teamID, userID string) error) {
108+
m.mu.Lock()
109+
defer m.mu.Unlock()
110+
111+
// Store for future clients
112+
m.homeViewHandler = handler
113+
114+
// Set on all existing clients
115+
for _, client := range m.clients {
116+
client.SetHomeViewHandler(handler)
117+
}
118+
}
119+
99120
// StoreWorkspace stores a workspace's token and metadata in GSM.
100121
func (m *Manager) StoreWorkspace(ctx context.Context, metadata *WorkspaceMetadata, token string) error {
101122
slog.Info("storing workspace token and metadata in GSM",

internal/slack/slack.go

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,12 @@ type apiCache struct {
4545

4646
// Client wraps the Slack API client with caching.
4747
type Client struct {
48-
api *slack.Client
49-
cache *apiCache
50-
signingSecret string
48+
api *slack.Client
49+
cache *apiCache
50+
signingSecret string
51+
teamID string // Workspace team ID
52+
homeViewHandler func(ctx context.Context, teamID, userID string) error // Callback for app_home_opened events
53+
homeViewHandlerMu sync.RWMutex
5154
}
5255

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

121+
// SetHomeViewHandler registers a callback for app_home_opened events.
122+
func (c *Client) SetHomeViewHandler(handler func(ctx context.Context, teamID, userID string) error) {
123+
c.homeViewHandlerMu.Lock()
124+
defer c.homeViewHandlerMu.Unlock()
125+
c.homeViewHandler = handler
126+
}
127+
128+
// SetTeamID sets the team ID for this client.
129+
func (c *Client) SetTeamID(teamID string) {
130+
c.teamID = teamID
131+
}
132+
118133
// WorkspaceInfo returns information about the current workspace (cached for 1 hour).
119134
func (c *Client) WorkspaceInfo(ctx context.Context) (*slack.TeamInfo, error) {
120135
cacheKey := "team_info"
@@ -775,10 +790,29 @@ func (c *Client) EventsHandler(writer http.ResponseWriter, r *http.Request) {
775790
// Handle app mentions if needed.
776791
slog.Debug("received app mention", "event", evt)
777792
case *slackevents.AppHomeOpenedEvent:
778-
// Update app home when user opens it.
779-
// In a full implementation, this would update the home tab.
780-
// For now, just log.
781-
slog.Debug("would update app home for user", "user", evt.User)
793+
// Update app home when user opens it
794+
slog.Debug("app home opened", "user", evt.User)
795+
796+
// Call registered home view handler if present
797+
c.homeViewHandlerMu.RLock()
798+
handler := c.homeViewHandler
799+
c.homeViewHandlerMu.RUnlock()
800+
801+
if handler != nil {
802+
go func(teamID, userID string) {
803+
homeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
804+
defer cancel()
805+
806+
if err := handler(homeCtx, teamID, userID); err != nil {
807+
slog.Error("home view handler failed",
808+
"team_id", teamID,
809+
"user", userID,
810+
"error", err)
811+
}
812+
}(c.teamID, evt.User)
813+
} else {
814+
slog.Debug("no home view handler registered", "user", evt.User)
815+
}
782816
case *slackevents.MemberJoinedChannelEvent:
783817
// Bot was added to a channel - invalidate cache
784818
slog.Info("bot joined channel - invalidating cache",

internal/state/datastore.go

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@ const (
2929

3030
// Thread entity for Datastore.
3131
type threadEntity struct {
32-
ThreadTS string `datastore:"thread_ts"`
33-
ChannelID string `datastore:"channel_id"`
34-
MessageText string `datastore:"message_text,noindex"`
35-
UpdatedAt time.Time `datastore:"updated_at"`
32+
ThreadTS string `datastore:"thread_ts"`
33+
ChannelID string `datastore:"channel_id"`
34+
MessageText string `datastore:"message_text,noindex"`
35+
UpdatedAt time.Time `datastore:"updated_at"`
36+
LastEventTime time.Time `datastore:"last_event_time"`
3637
}
3738

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

156157
// Found in Datastore - update JSON cache and return
157158
result := ThreadInfo{
158-
ThreadTS: entity.ThreadTS,
159-
ChannelID: entity.ChannelID,
160-
MessageText: entity.MessageText,
161-
UpdatedAt: entity.UpdatedAt,
159+
ThreadTS: entity.ThreadTS,
160+
ChannelID: entity.ChannelID,
161+
MessageText: entity.MessageText,
162+
UpdatedAt: entity.UpdatedAt,
163+
LastEventTime: entity.LastEventTime,
162164
}
163165

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

193195
dsKey := datastore.NameKey(kindThread, key, nil)
194196
entity := &threadEntity{
195-
ThreadTS: info.ThreadTS,
196-
ChannelID: info.ChannelID,
197-
MessageText: info.MessageText,
198-
UpdatedAt: time.Now(),
197+
ThreadTS: info.ThreadTS,
198+
ChannelID: info.ChannelID,
199+
MessageText: info.MessageText,
200+
UpdatedAt: time.Now(),
201+
LastEventTime: info.LastEventTime,
199202
}
200203

201204
if _, err := s.ds.Put(ctx, dsKey, entity); err != nil {

internal/state/store.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import (
77

88
// ThreadInfo stores information about a Slack thread for a PR.
99
type ThreadInfo struct {
10-
UpdatedAt time.Time `json:"updated_at"`
11-
ThreadTS string `json:"thread_ts"`
12-
ChannelID string `json:"channel_id"`
13-
LastState string `json:"last_state"`
14-
MessageText string `json:"message_text"`
10+
UpdatedAt time.Time `json:"updated_at"`
11+
LastEventTime time.Time `json:"last_event_time"` // Last sprinkler event timestamp for turnclient cache optimization
12+
ThreadTS string `json:"thread_ts"`
13+
ChannelID string `json:"channel_id"`
14+
LastState string `json:"last_state"`
15+
MessageText string `json:"message_text"`
1516
}
1617

1718
// Store provides persistent storage for bot state.

0 commit comments

Comments
 (0)