diff --git a/api/internal/features/catchup.go b/api/internal/features/catchup.go index 33218f7..0d88f45 100644 --- a/api/internal/features/catchup.go +++ b/api/internal/features/catchup.go @@ -28,6 +28,12 @@ func NewCatchUpService(slack SlackAPI, ai *ai.Client, searcher *rts.Searcher) *C func (c *CatchUpService) HandleSlashCommand(ctx context.Context, cmd *slack.SlashCommand, user *domain.User) error { query := strings.TrimSpace(cmd.Text) if query == "" { + // Send help to DM + dmChannel, err := c.slack.OpenDMChannel(cmd.UserID) + if err != nil { + slog.Error("failed to open dm for catchup help", "error", err) + return err + } blocks := []slack.Block{ slack.NewSectionBlock( slack.NewTextBlockObject("mrkdwn", @@ -37,13 +43,27 @@ func (c *CatchUpService) HandleSlashCommand(ctx context.Context, cmd *slack.Slas nil, nil, ), } - return c.slack.PostMessage(cmd.ChannelID, blocks, "Catch-Up Help") + return c.slack.PostMessage(dmChannel, blocks, "Catch-Up Help") } - // Perform semantic search + // Perform semantic search (might take 2-3 seconds) result, err := c.searchAndSummarize(ctx, cmd.UserID, query, 7) if err != nil { - return fmt.Errorf("catchup search: %w", err) + slog.Error("catchup search failed", "error", err, "query", query) + // Send error to user's DM + dmChannel, dmErr := c.slack.OpenDMChannel(cmd.UserID) + if dmErr != nil { + return fmt.Errorf("catchup search: %w, open dm: %w", err, dmErr) + } + return c.slack.PostMessage(dmChannel, + []slack.Block{ + slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", "❌ Sorry, I couldn't search for that right now. Please try again in a moment.", false, false), + nil, nil, + ), + }, + "Catch-Up Error", + ) } // Post result to DM diff --git a/api/internal/features/controller.go b/api/internal/features/controller.go index 75db00f..622309b 100644 --- a/api/internal/features/controller.go +++ b/api/internal/features/controller.go @@ -68,10 +68,16 @@ func (c *Controller) HandleMessage(ctx context.Context, event *slackevents.Messa return nil } + // If it's a DM to Signal, show help instead of processing features + if event.ChannelType == "im" { + return c.handleDirectMessage(ctx, event, user) + } + // Ensure user exists user, err := c.ensureUser(ctx, user) if err != nil { - return err + slog.Error("failed to ensure user exists", "error", err, "slack_user_id", user.SlackUserID) + // Continue anyway with basic functionality } // Get user preferences @@ -102,19 +108,41 @@ func (c *Controller) HandleMessage(ctx context.Context, event *slackevents.Messa return nil } +// handleDirectMessage handles DMs sent to Signal +func (c *Controller) handleDirectMessage(ctx context.Context, event *slackevents.MessageEvent, user *domain.User) error { + // For now, just respond with help + dmChannel, err := c.slack.OpenDMChannel(user.SlackUserID) + if err != nil { + return fmt.Errorf("open dm: %w", err) + } + + // Send help message + return c.slack.PostMessage(dmChannel, buildHelpBlocks(), "Signal Help") +} + // HandleAppMention handles when Signal is @mentioned. func (c *Controller) HandleAppMention(ctx context.Context, event *slackevents.AppMentionEvent, user *domain.User, teamID string) error { user, err := c.ensureUser(ctx, user) if err != nil { - return err + slog.Warn("could not ensure user for app mention", "error", err) + // Continue anyway - can still respond } - dmChannel, err := c.slack.OpenDMChannel(user.SlackUserID) - if err != nil { - return fmt.Errorf("open dm: %w", err) + // Respond in the channel where we were mentioned (not DM) + blocks := []slack.Block{ + slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", + fmt.Sprintf("👋 Hi <@%s>! I'm Signal, your neurodivergent-friendly Slack assistant.\n\nUse `/signal` to see all my commands, or DM me anytime for help!", user.SlackUserID), + false, false, + ), + nil, nil, + ), + slack.NewContextBlock("mention_context", + slack.NewTextBlockObject("mrkdwn", "_💡 Tip: Try `/translate [message]` to decode ambiguous workplace language_", false, false), + ), } - return c.slack.PostMessage(dmChannel, buildHelpBlocks(), "Signal Help") + return c.slack.PostMessage(event.Channel, blocks, "Signal Help") } // HandleBlockAction routes block action events (button clicks) to features. @@ -185,15 +213,23 @@ func (c *Controller) HandleAppHomeOpened(ctx context.Context, event *slackevents } func (c *Controller) ensureUser(ctx context.Context, user *domain.User) (*domain.User, error) { + // If no SlackUserID, return the passed user as-is (shouldn't happen but defensive) + if user.SlackUserID == "" { + return user, fmt.Errorf("user has no slack_user_id") + } + existing, err := c.userRepo.GetBySlackID(ctx, user.SlackUserID, user.SlackTeamID) if err != nil { - // Create new user + // User doesn't exist, create new user newUser := &domain.User{ SlackUserID: user.SlackUserID, SlackTeamID: user.SlackTeamID, } if err := c.userRepo.Create(ctx, newUser); err != nil { - return nil, err + // If we can't create the user, log but return the basic user object + // so features can still work (just without persistence) + slog.Error("failed to create user in database", "error", err, "slack_user_id", user.SlackUserID) + return newUser, err } return newUser, nil } @@ -243,13 +279,7 @@ func buildHelpBlocks() []slack.Block { ), slack.NewSectionBlock( slack.NewTextBlockObject("mrkdwn", - "*/digest*\nSend an instant digest\n\n*@Signal help*\nShow this menu\n\n*Need help?* Visit or configure preferences from your Slack App Home.", - nil, - ), - slack.NewDividerBlock(), - slack.NewSectionBlock( - slack.NewTextBlockObject("mrkdwn", - "*Need help?* Visit or open your Slack App Home to configure preferences.", + "*/digest*\nSend an instant digest\n\n*@Signal help*\nShow this menu", false, false, ), nil, nil, diff --git a/api/internal/features/translator.go b/api/internal/features/translator.go index c36fd46..849e057 100644 --- a/api/internal/features/translator.go +++ b/api/internal/features/translator.go @@ -93,7 +93,12 @@ func (t *TranslatorService) HandleMessage(ctx context.Context, event *slackevent func (t *TranslatorService) HandleSlashCommand(ctx context.Context, cmd *slack.SlashCommand, user *domain.User) error { message := cmd.Text if strings.TrimSpace(message) == "" { - // Send help + // Send help via DM + dmChannel, err := t.slack.OpenDMChannel(cmd.UserID) + if err != nil { + slog.Error("failed to open dm for translate help", "error", err) + return err + } blocks := []slack.Block{ slack.NewSectionBlock( slack.NewTextBlockObject("mrkdwn", @@ -103,20 +108,33 @@ func (t *TranslatorService) HandleSlashCommand(ctx context.Context, cmd *slack.S nil, nil, ), } - return t.slack.PostMessage(cmd.ChannelID, blocks, "Translation Help") + return t.slack.PostMessage(dmChannel, blocks, "Translation Help") } + // Analyze tone (this might take 1-2 seconds with AI) analysis, err := t.ai.AnalyzeTone(ctx, message) if err != nil { - return fmt.Errorf("analyze tone: %w", err) + slog.Error("ai tone analysis failed for slash command", "error", err) + // Send error message to user's DM + dmChannel, dmErr := t.slack.OpenDMChannel(cmd.UserID) + if dmErr != nil { + return fmt.Errorf("analyze tone: %w, open dm: %w", err, dmErr) + } + return t.slack.PostMessage(dmChannel, []slack.Block{ + slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", "❌ Sorry, I couldn't analyze that message right now. Please try again in a moment.", false, false), + nil, nil, + ), + }, "Translation Error") } - channelID, err := t.slack.OpenDMChannel(cmd.UserID) + // Send translation to user's DM + dmChannel, err := t.slack.OpenDMChannel(cmd.UserID) if err != nil { - return err + return fmt.Errorf("open dm: %w", err) } - return t.postTranslationBlocks(channelID, message, analysis) + return t.postTranslationBlocks(dmChannel, message, analysis) } // HandleBlockAction handles translator button clicks (e.g., "Got it"). diff --git a/api/internal/slack/events.go b/api/internal/slack/events.go index af9c23a..18c02df 100644 --- a/api/internal/slack/events.go +++ b/api/internal/slack/events.go @@ -81,14 +81,19 @@ func (h *EventHandler) eventLoop(ctx context.Context) { func (h *EventHandler) handleEvent(ctx context.Context, event socketmode.Event) { switch event.Type { case socketmode.EventTypeEventsAPI: - h.handleEventsAPI(ctx, event) + if event.Request != nil { + h.handleEventsAPI(ctx, event) + } case socketmode.EventTypeInteractive: - h.handleInteractive(ctx, event) + if event.Request != nil { + h.handleInteractive(ctx, event) + } case socketmode.EventTypeSlashCommand: - h.handleSlashCommand(ctx, event) + if event.Request != nil { + h.handleSlashCommand(ctx, event) + } case socketmode.EventTypeConnected: slog.Info("slack socket mode connected") - h.client.Ack(*event.Request) default: slog.Debug("unhandled socket event type", "type", event.Type) if event.Request != nil { @@ -104,11 +109,15 @@ func (h *EventHandler) handleEventsAPI(ctx context.Context, se socketmode.Event) return } + // Ack synchronously — must be fast to keep Slack happy h.client.Ack(*se.Request) + // Process events asynchronously so the event loop stays responsive + asyncCtx := context.WithoutCancel(ctx) switch eventData.Type { case slackevents.CallbackEvent: - h.handleCallbackEvent(ctx, eventData.InnerEvent) + innerEvent := eventData.InnerEvent + go h.handleCallbackEvent(asyncCtx, innerEvent) default: slog.Debug("unhandled events API type", "type", eventData.Type) } @@ -135,7 +144,13 @@ func (h *EventHandler) handleMessageEvent(ctx context.Context, event *slackevent return } + // Skip message subtypes we don't handle (message_changed, message_deleted, etc.) + if event.SubType != "" && event.SubType != "bot_message" { + return + } + if h.featureCtrl == nil { + slog.Warn("feature controller is nil, skipping message") return } @@ -182,6 +197,7 @@ func (h *EventHandler) handleInteractive(ctx context.Context, se socketmode.Even return } + // Ack synchronously — must be fast to keep Slack happy h.client.Ack(*se.Request) if h.featureCtrl == nil { @@ -193,9 +209,14 @@ func (h *EventHandler) handleInteractive(ctx context.Context, se socketmode.Even SlackTeamID: actionEvent.Team.ID, } - if err := h.featureCtrl.HandleBlockAction(ctx, &actionEvent, user, actionEvent.Team.ID); err != nil { - slog.Error("error handling block action", "error", err) - } + // Process block action asynchronously so the event loop stays responsive + asyncCtx := context.WithoutCancel(ctx) + actionCopy := actionEvent + go func() { + if err := h.featureCtrl.HandleBlockAction(asyncCtx, &actionCopy, user, actionCopy.Team.ID); err != nil { + slog.Error("error handling block action", "error", err) + } + }() } func (h *EventHandler) handleSlashCommand(ctx context.Context, se socketmode.Event) { @@ -204,7 +225,12 @@ func (h *EventHandler) handleSlashCommand(ctx context.Context, se socketmode.Eve return } - h.client.Ack(*se.Request) + // Acknowledge immediately with an ephemeral "processing" message + // (Slack requires a response within 3 seconds or it shows "app did not respond") + h.client.Ack(*se.Request, map[string]interface{}{ + "response_type": "ephemeral", + "text": "⏳ Working on your request...", + }) if h.featureCtrl == nil { return @@ -215,9 +241,17 @@ func (h *EventHandler) handleSlashCommand(ctx context.Context, se socketmode.Eve SlackTeamID: cmd.TeamID, } - if err := h.featureCtrl.HandleCommand(ctx, &cmd, user); err != nil { - slog.Error("error handling command", "error", err, "command", cmd.Command) - } + // Process the command asynchronously — the ack already confirmed receipt + // to Slack, and the final result will be posted via chat.postMessage + // by the feature handler. Running in a goroutine keeps the event loop + // responsive so Slack doesn't drop the socket connection. + asyncCtx := context.WithoutCancel(ctx) + cmdCopy := cmd + go func() { + if err := h.featureCtrl.HandleCommand(asyncCtx, &cmdCopy, user); err != nil { + slog.Error("error handling command", "error", err, "command", cmdCopy.Command) + } + }() } // PostMessage sends a message to a Slack channel.