package slack import ( "context" "fmt" "strings" "github.com/rs/zerolog" "github.com/slack-go/slack" "github.com/vincentc-afk/gitea-notification-hub/internal/config" "github.com/vincentc-afk/gitea-notification-hub/internal/event" ) // Notifier implements notifier.Notifier and identity.SlackLookup using Slack API type Notifier struct { client *slack.Client defaultChannel string logger zerolog.Logger } // New creates a new Slack notifier func New(cfg *config.SlackConfig, logger zerolog.Logger) *Notifier { client := slack.New(cfg.BotToken) return &Notifier{ client: client, defaultChannel: cfg.DefaultChannel, logger: logger.With().Str("component", "slack-notifier").Logger(), } } // LookupSlackIDByEmail finds a Slack user ID by email address // This implements identity.SlackLookup interface func (n *Notifier) LookupSlackIDByEmail(ctx context.Context, email string) (slackID, slackName string, err error) { user, err := n.client.GetUserByEmailContext(ctx, email) if err != nil { return "", "", fmt.Errorf("looking up Slack user by email %s: %w", email, err) } return user.ID, user.RealName, nil } // SendDirect sends a direct message to a Slack user func (n *Notifier) SendDirect(ctx context.Context, userID string, msg *event.Notification) error { // Open a DM channel with the user channel, _, _, err := n.client.OpenConversationContext(ctx, &slack.OpenConversationParameters{ Users: []string{userID}, }) if err != nil { return fmt.Errorf("opening DM channel: %w", err) } // Build message blocks blocks := n.buildMessageBlocks(msg) // Send message _, _, err = n.client.PostMessageContext(ctx, channel.ID, slack.MsgOptionBlocks(blocks...), slack.MsgOptionText(msg.Message, false), // Fallback text ) if err != nil { return fmt.Errorf("sending DM: %w", err) } return nil } // SendChannel sends a message to a Slack channel func (n *Notifier) SendChannel(ctx context.Context, channel string, msg *event.Notification) error { if channel == "" { channel = n.defaultChannel } // Build message blocks blocks := n.buildMessageBlocks(msg) // Send message _, _, err := n.client.PostMessageContext(ctx, channel, slack.MsgOptionBlocks(blocks...), slack.MsgOptionText(msg.Message, false), // Fallback text ) if err != nil { return fmt.Errorf("sending channel message: %w", err) } return nil } // buildMessageBlocks creates Slack Block Kit message blocks func (n *Notifier) buildMessageBlocks(msg *event.Notification) []slack.Block { e := msg.Event // Header with emoji based on event type emoji := n.getEventEmoji(e.Type) headerText := fmt.Sprintf("%s %s", emoji, n.getEventTitle(e, msg.Reason)) headerBlock := slack.NewSectionBlock( slack.NewTextBlockObject(slack.MarkdownType, headerText, false, false), nil, nil, ) // Repository and PR/Issue info contextElements := []slack.MixedElement{ slack.NewTextBlockObject(slack.MarkdownType, fmt.Sprintf("*<%s|%s>*", e.RepoURL, e.RepoFullName), false, false), } if e.Number > 0 { contextElements = append(contextElements, slack.NewTextBlockObject(slack.MarkdownType, fmt.Sprintf("<%s|#%d %s>", e.URL, e.Number, truncate(e.Title, 50)), false, false)) } contextBlock := slack.NewContextBlock("", contextElements...) blocks := []slack.Block{headerBlock, contextBlock} // Add comment body if present if e.CommentBody != "" { commentText := truncate(e.CommentBody, 300) // Replace @mentions with Slack-style if we have them commentBlock := slack.NewSectionBlock( slack.NewTextBlockObject(slack.MarkdownType, fmt.Sprintf(">>> %s", commentText), false, false), nil, nil, ) blocks = append(blocks, commentBlock) } // Add review state if present if e.ReviewState != "" { reviewEmoji := n.getReviewStateEmoji(e.ReviewState) reviewBlock := slack.NewContextBlock("", slack.NewTextBlockObject(slack.MarkdownType, fmt.Sprintf("%s Review: *%s*", reviewEmoji, e.ReviewState), false, false)) blocks = append(blocks, reviewBlock) } // Add commits list if present (for synchronize events) if len(e.Commits) > 0 { var commitLines []string maxCommits := 5 // Show max 5 commits for i, c := range e.Commits { if i >= maxCommits { commitLines = append(commitLines, fmt.Sprintf("_... and %d more commits_", len(e.Commits)-maxCommits)) break } commitLines = append(commitLines, fmt.Sprintf("• `%s` %s", c.SHA, truncate(c.Message, 60))) } commitsText := strings.Join(commitLines, "\n") commitsBlock := slack.NewSectionBlock( slack.NewTextBlockObject(slack.MarkdownType, commitsText, false, false), nil, nil, ) blocks = append(blocks, commitsBlock) } // Add action button buttonBlock := slack.NewActionBlock("actions", slack.NewButtonBlockElement("view", e.URL, slack.NewTextBlockObject(slack.PlainTextType, "View in Gitea", false, false)). WithURL(e.URL), ) blocks = append(blocks, buttonBlock) return blocks } // getEventEmoji returns an emoji for the event type func (n *Notifier) getEventEmoji(eventType event.Type) string { switch eventType { case event.TypePROpened: return ":git-pull-request:" case event.TypePRClosed: return ":git-pull-request-closed:" case event.TypePRMerged: return ":git-merge:" case event.TypePRAssigned: return ":bust_in_silhouette:" case event.TypePRReviewRequested: return ":eyes:" case event.TypePRReviewed: return ":memo:" case event.TypePRCommented: return ":speech_balloon:" case event.TypePRSynchronized: return ":arrows_counterclockwise:" case event.TypeIssueOpened: return ":issue-opened:" case event.TypeIssueClosed: return ":issue-closed:" case event.TypeIssueCommented: return ":speech_balloon:" default: return ":bell:" } } // getEventTitle returns a human-readable title for the event func (n *Notifier) getEventTitle(e *event.Event, reason event.NotificationReason) string { actorName := e.Actor.GiteaUsername if e.Actor.FullName != "" { actorName = e.Actor.FullName } switch e.Type { case event.TypePROpened: if reason == event.ReasonAssignee { return fmt.Sprintf("*%s* assigned you to a pull request", actorName) } if reason == event.ReasonReviewer { return fmt.Sprintf("*%s* requested your review on a pull request", actorName) } return fmt.Sprintf("*%s* opened a pull request", actorName) case event.TypePRClosed: return fmt.Sprintf("*%s* closed your pull request", actorName) case event.TypePRMerged: return fmt.Sprintf("*%s* merged your pull request", actorName) case event.TypePRAssigned: return fmt.Sprintf("*%s* assigned you to a pull request", actorName) case event.TypePRReviewRequested: return fmt.Sprintf("*%s* requested your review", actorName) case event.TypePRReviewed: return fmt.Sprintf("*%s* reviewed your pull request", actorName) case event.TypePRCommented: if reason == event.ReasonMention { return fmt.Sprintf("*%s* mentioned you in a PR comment", actorName) } return fmt.Sprintf("*%s* commented on your pull request", actorName) case event.TypePRSynchronized: commitCount := len(e.Commits) if commitCount == 1 { return fmt.Sprintf("*%s* pushed 1 new commit to PR #%d", actorName, e.Number) } return fmt.Sprintf("*%s* pushed %d new commits to PR #%d", actorName, commitCount, e.Number) case event.TypeIssueOpened: if reason == event.ReasonAssignee { return fmt.Sprintf("*%s* assigned you to an issue", actorName) } return fmt.Sprintf("*%s* opened an issue", actorName) case event.TypeIssueClosed: return fmt.Sprintf("*%s* closed your issue", actorName) case event.TypeIssueCommented: if reason == event.ReasonMention { return fmt.Sprintf("*%s* mentioned you in an issue comment", actorName) } return fmt.Sprintf("*%s* commented on your issue", actorName) default: return fmt.Sprintf("New notification from *%s*", actorName) } } // getReviewStateEmoji returns an emoji for the review state func (n *Notifier) getReviewStateEmoji(state string) string { switch strings.ToUpper(state) { case "APPROVED": return ":white_check_mark:" case "CHANGES_REQUESTED": return ":x:" case "COMMENT": return ":speech_balloon:" default: return ":memo:" } } // truncate truncates a string to the specified length func truncate(s string, maxLen int) string { // Remove newlines for preview s = strings.ReplaceAll(s, "\n", " ") s = strings.ReplaceAll(s, "\r", "") if len(s) <= maxLen { return s } return s[:maxLen-3] + "..." }