Skip to content
26 changes: 23 additions & 3 deletions api/internal/features/catchup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
60 changes: 45 additions & 15 deletions api/internal/features/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 <https://github.com/LSUDOKOS/signal|GitHub> or configure preferences from your Slack App Home.",
nil,
),
slack.NewDividerBlock(),
slack.NewSectionBlock(
slack.NewTextBlockObject("mrkdwn",
"*Need help?* Visit <https://github.com/LSUDOKOS/signal|GitHub> 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,
Expand Down
30 changes: 24 additions & 6 deletions api/internal/features/translator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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").
Expand Down
58 changes: 46 additions & 12 deletions api/internal/slack/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -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.
Expand Down
Loading