From b8f352dac67fb705ca36203f5b7af7ee8f6a52d4 Mon Sep 17 00:00:00 2001 From: LSUDOKO Date: Sun, 12 Jul 2026 01:40:58 +0530 Subject: [PATCH] fix: nil pointer guards, worker slack adapter, translator logic, focus/digest wiring - AI client: add extractContent helper to guard resp.Choices[0] in all 5 methods - Worker: add workerSlackAPI adapter wrapping slack.Client to prevent nil SlackAPI panic - Translator: send translation DM to current user when ambiguous phrase lacks @-mentions - Focus mode: wire focus_open_thread button to sendSummaryToDM with AI summary - Digest: populate threads section using AI GenerateDigestContent - dead code: remove unused RateLimitInfo type and time import - Fix pre-existing compilation bug: pass slackAPI to NewController call in api/main.go --- api/cmd/api/main.go | 2 +- api/cmd/worker/main.go | 72 +++++++++++++++++++++++- api/internal/ai/client.go | 32 +++++++---- api/internal/features/digest.go | 16 ++++++ api/internal/features/focusmode.go | 33 +++++++++-- api/internal/features/translator.go | 24 ++++---- api/internal/features/translator_test.go | 15 ++++- 7 files changed, 164 insertions(+), 30 deletions(-) diff --git a/api/cmd/api/main.go b/api/cmd/api/main.go index d23590a..4b9ea69 100644 --- a/api/cmd/api/main.go +++ b/api/cmd/api/main.go @@ -112,7 +112,7 @@ func main() { featureCtrl := features.NewController( focusMode, translator, catchup, digest, deepWork, - userRepo, prefsRepo, rtsSearcher, + userRepo, prefsRepo, rtsSearcher, slackAPI, ) // Set the feature controller on the slack handler diff --git a/api/cmd/worker/main.go b/api/cmd/worker/main.go index 02006d9..6618d66 100644 --- a/api/cmd/worker/main.go +++ b/api/cmd/worker/main.go @@ -18,6 +18,7 @@ import ( "github.com/LSUDOKOS/signal/internal/store/postgres" "github.com/LSUDOKOS/signal/internal/store/redis" "github.com/hibiken/asynq" + "github.com/slack-go/slack" ) func main() { @@ -89,8 +90,9 @@ func main() { }, ) - // Initialize digest service (SlackAPI is nil for worker; uses MCP for calendar checks) - digestService := features.NewDigestService(nil, aiClient, digestRepo, userRepo, prefsRepo, cache) + // Initialize Slack API client for sending digests + slackAPI := newWorkerSlackAPI(cfg.Slack.BotToken) + digestService := features.NewDigestService(slackAPI, aiClient, digestRepo, userRepo, prefsRepo, cache) // Create mux and register handlers mux := asynq.NewServeMux() @@ -198,3 +200,69 @@ func startDigestScheduler(ctx context.Context, prefsRepo *postgres.PreferencesRe } } } + +// workerSlackAPI is a minimal SlackAPI adapter for the worker process. +// It wraps slack.Client to provide the SlackAPI interface without Socket Mode. +type workerSlackAPI struct { + api *slack.Client +} + +func newWorkerSlackAPI(botToken string) *workerSlackAPI { + return &workerSlackAPI{ + api: slack.New(botToken), + } +} + +func (w *workerSlackAPI) PostMessage(channelID string, blocks []slack.Block, text string) error { + _, _, err := w.api.PostMessage(channelID, slack.MsgOptionBlocks(blocks...), slack.MsgOptionText(text, false)) + return err +} + +func (w *workerSlackAPI) PostEphemeral(channelID, userID string, blocks []slack.Block, text string) error { + _, err := w.api.PostEphemeral(channelID, userID, slack.MsgOptionBlocks(blocks...), slack.MsgOptionText(text, false)) + return err +} + +func (w *workerSlackAPI) OpenDMChannel(userID string) (string, error) { + ch, _, _, err := w.api.OpenConversation(&slack.OpenConversationParameters{Users: []string{userID}}) + if err != nil { + return "", err + } + return ch.ID, nil +} + +func (w *workerSlackAPI) GetUser(userID string) (*slack.User, error) { + return w.api.GetUserInfo(userID) +} + +func (w *workerSlackAPI) GetChannelHistory(channelID string, limit int) ([]slack.Message, error) { + resp, err := w.api.GetConversationHistory(&slack.GetConversationHistoryParameters{ + ChannelID: channelID, + Limit: limit, + }) + if err != nil { + return nil, err + } + return resp.Messages, nil +} + +func (w *workerSlackAPI) SearchMessages(query string, params slack.SearchParameters) (*slack.SearchMessages, error) { + result, err := w.api.SearchMessages(query, params) + if err != nil { + return nil, err + } + return result, nil +} + +func (w *workerSlackAPI) SetUserStatus(userID, statusText, statusEmoji string, expiration int) error { + // worker doesn't need user status setting; no-op + return nil +} + +func (w *workerSlackAPI) PublishView(userID string, blocks []slack.Block) error { + _, err := w.api.PublishView(userID, slack.HomeTabViewRequest{ + Type: "home", + Blocks: slack.Blocks{BlockSet: blocks}, + }, "") + return err +} diff --git a/api/internal/ai/client.go b/api/internal/ai/client.go index 80a7142..68adf08 100644 --- a/api/internal/ai/client.go +++ b/api/internal/ai/client.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "strings" - "time" "github.com/LSUDOKOS/signal/internal/domain" "github.com/sashabaranov/go-openai" @@ -51,7 +50,11 @@ func (c *Client) SummarizeFocus(ctx context.Context, messages []string) (*domain return nil, fmt.Errorf("openai focus summary: %w", err) } - return parseFocusResponse(resp.Choices[0].Message.Content), nil + content, err := extractContent(resp) + if err != nil { + return nil, fmt.Errorf("focus summary: %w", err) + } + return parseFocusResponse(content), nil } // AnalyzeTone performs tone analysis on an ambiguous workplace message. @@ -75,7 +78,11 @@ func (c *Client) AnalyzeTone(ctx context.Context, message string) (*domain.ToneA return nil, fmt.Errorf("openai tone analysis: %w", err) } - return parseToneResponse(resp.Choices[0].Message.Content), nil + content, err := extractContent(resp) + if err != nil { + return nil, fmt.Errorf("tone analysis: %w", err) + } + return parseToneResponse(content), nil } // CatchUpSummary generates a "What You Missed" digest from search results. @@ -101,7 +108,7 @@ func (c *Client) CatchUpSummary(ctx context.Context, messages []string) (string, return "", fmt.Errorf("openai catchup summary: %w", err) } - return resp.Choices[0].Message.Content, nil + return extractContent(resp) } // GenerateDigestContent categorizes messages into urgent/action/FYI buckets. @@ -135,7 +142,7 @@ Format each category as a bullet list. If empty, say "None."`, strings.Join(mess return "", fmt.Errorf("openai digest: %w", err) } - return resp.Choices[0].Message.Content, nil + return extractContent(resp) } func buildFocusPrompt(messages []string) string { @@ -247,12 +254,15 @@ func (c *Client) Chat(ctx context.Context, systemPrompt, userPrompt string, maxT if err != nil { return "", fmt.Errorf("openai chat: %w", err) } - return resp.Choices[0].Message.Content, nil + return extractContent(resp) } -// RateLimitInfo holds AI rate limit state. -type RateLimitInfo struct { - RequestsThisMinute int - Limit int - ResetTime time.Time +// extractContent safely extracts the first choice's content, guarding against empty choices. +func extractContent(resp openai.ChatCompletionResponse) (string, error) { + if len(resp.Choices) == 0 { + return "", fmt.Errorf("no choices returned by AI model") + } + return resp.Choices[0].Message.Content, nil } + + diff --git a/api/internal/features/digest.go b/api/internal/features/digest.go index 4529508..38adc53 100644 --- a/api/internal/features/digest.go +++ b/api/internal/features/digest.go @@ -118,6 +118,22 @@ func (d *DigestService) SendScheduledDigest(ctx context.Context, u domain.User, } } + // Use AI to categorize messages into threads/group discussions + if len(fyi) > 0 { + var messageTexts []string + for _, item := range fyi { + messageTexts = append(messageTexts, fmt.Sprintf("#%s — %s: %s", item.Channel, item.From, item.Message)) + } + aiResult, err := d.ai.GenerateDigestContent(ctx, messageTexts) + if err == nil && aiResult != "" { + threads = append(threads, domain.DigestItem{ + From: "AI", + Message: aiResult, + Channel: "AI Summary", + }) + } + } + // Build digest blocks with real Slack data blocks := d.buildDigestBlocks(prefs.DigestHour, urgent, fyi, threads) diff --git a/api/internal/features/focusmode.go b/api/internal/features/focusmode.go index d90c6ad..9d79947 100644 --- a/api/internal/features/focusmode.go +++ b/api/internal/features/focusmode.go @@ -80,10 +80,7 @@ func (f *FocusModeService) HandleBlockAction(ctx context.Context, action *slack. case "focus_mute_30": return f.muteChannel(ctx, channelID, user.SlackUserID) case "focus_open_thread": - _ = channelID - _ = user - // Future: open thread with summary - return nil + return f.sendSummaryToDM(ctx, channelID, user.SlackUserID) } return nil @@ -212,6 +209,34 @@ func (f *FocusModeService) sendFullSummary(ctx context.Context, channelID, userI ) } +func (f *FocusModeService) sendSummaryToDM(ctx context.Context, channelID, userID string) error { + messages, err := f.slack.GetChannelHistory(channelID, 50) + if err != nil { + return fmt.Errorf("get history: %w", err) + } + + var messageTexts []string + for _, msg := range messages { + if msg.Text != "" { + messageTexts = append(messageTexts, msg.Text) + } + } + + summary, err := f.ai.SummarizeFocus(ctx, messageTexts) + if err != nil { + slog.Error("ai focus summary failed", "error", err) + return nil // Don't break the button flow + } + + dmChannel, err := f.slack.OpenDMChannel(userID) + if err != nil { + return fmt.Errorf("open dm: %w", err) + } + + blocks := buildFocusSummaryBlockKit(summary, channelID) + return f.slack.PostMessage(dmChannel, blocks, "Focus Summary") +} + func (f *FocusModeService) muteChannel(ctx context.Context, channelID, userID string) error { // Request to mute the channel for 30 minutes via DM dmChannel, err := f.slack.OpenDMChannel(userID) diff --git a/api/internal/features/translator.go b/api/internal/features/translator.go index f7e3249..c36fd46 100644 --- a/api/internal/features/translator.go +++ b/api/internal/features/translator.go @@ -62,12 +62,6 @@ func (t *TranslatorService) HandleMessage(ctx context.Context, event *slackevent } } - // Extract mentioned users - mentionedUsers := t.extractMentionedUsers(event.Text) - if len(mentionedUsers) == 0 { - return nil - } - // Analyze tone analysis, err := t.ai.AnalyzeTone(ctx, event.Text) if err != nil { @@ -75,10 +69,20 @@ func (t *TranslatorService) HandleMessage(ctx context.Context, event *slackevent return nil // Don't fail the message flow } - // Send DM to each mentioned user - for _, mentionedUser := range mentionedUsers { - if err := t.sendTranslationDM(ctx, mentionedUser, event, analysis); err != nil { - slog.Error("failed to send translation dm", "error", err, "user", mentionedUser) + // Extract mentioned users + mentionedUsers := t.extractMentionedUsers(event.Text) + + // Send translation DM: to mentioned users if any, otherwise to the current user + if len(mentionedUsers) > 0 { + for _, mentionedUser := range mentionedUsers { + if err := t.sendTranslationDM(ctx, mentionedUser, event, analysis); err != nil { + slog.Error("failed to send translation dm", "error", err, "user", mentionedUser) + } + } + } else { + // No @-mentions but ambiguous language detected; send to the current user + if err := t.sendTranslationDM(ctx, user.SlackUserID, event, analysis); err != nil { + slog.Error("failed to send translation dm to user", "error", err) } } diff --git a/api/internal/features/translator_test.go b/api/internal/features/translator_test.go index 9d35767..14ba17b 100644 --- a/api/internal/features/translator_test.go +++ b/api/internal/features/translator_test.go @@ -2,8 +2,11 @@ package features import ( "context" + "net/http" + "net/http/httptest" "testing" + "github.com/LSUDOKOS/signal/internal/ai" "github.com/LSUDOKOS/signal/internal/domain" "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" @@ -195,9 +198,17 @@ func TestHandleMessage_NoAmbiguousPhrase(t *testing.T) { } } -// TestHandleMessage_AmbiguousPhraseNoMention verifies no DM sent when no @mentions. +// TestHandleMessage_AmbiguousPhraseNoMention verifies translation sent to user when no @mentions. func TestHandleMessage_AmbiguousPhraseNoMention(t *testing.T) { - svc := &TranslatorService{} + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"choices":[{"message":{"content":"- Tone: Neutral\n- Intent: Test\n- Action: None\n- Note: Test"}}]}`)) + })) + defer ts.Close() + + svc := &TranslatorService{ + slack: &mockSlackAPI{}, + ai: ai.NewClient("test-key", "test-model", ts.URL), + } event := &slackevents.MessageEvent{ Text: "Per my last email, this needs to be done.", Channel: "C123",