Files
gitea-notification-hub/internal/notifier/slack/slack.go
T

292 lines
8.3 KiB
Go

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] + "..."
}