292 lines
8.3 KiB
Go
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] + "..."
|
|
}
|