code.gitea.io/gitea@v1.21.7/services/webhook/slack.go (about)

     1  // Copyright 2014 The Gogs Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package webhook
     5  
     6  import (
     7  	"errors"
     8  	"fmt"
     9  	"regexp"
    10  	"strings"
    11  
    12  	webhook_model "code.gitea.io/gitea/models/webhook"
    13  	"code.gitea.io/gitea/modules/git"
    14  	"code.gitea.io/gitea/modules/json"
    15  	"code.gitea.io/gitea/modules/log"
    16  	"code.gitea.io/gitea/modules/setting"
    17  	api "code.gitea.io/gitea/modules/structs"
    18  	webhook_module "code.gitea.io/gitea/modules/webhook"
    19  )
    20  
    21  // SlackMeta contains the slack metadata
    22  type SlackMeta struct {
    23  	Channel  string `json:"channel"`
    24  	Username string `json:"username"`
    25  	IconURL  string `json:"icon_url"`
    26  	Color    string `json:"color"`
    27  }
    28  
    29  // GetSlackHook returns slack metadata
    30  func GetSlackHook(w *webhook_model.Webhook) *SlackMeta {
    31  	s := &SlackMeta{}
    32  	if err := json.Unmarshal([]byte(w.Meta), s); err != nil {
    33  		log.Error("webhook.GetSlackHook(%d): %v", w.ID, err)
    34  	}
    35  	return s
    36  }
    37  
    38  // SlackPayload contains the information about the slack channel
    39  type SlackPayload struct {
    40  	Channel     string            `json:"channel"`
    41  	Text        string            `json:"text"`
    42  	Color       string            `json:"-"`
    43  	Username    string            `json:"username"`
    44  	IconURL     string            `json:"icon_url"`
    45  	UnfurlLinks int               `json:"unfurl_links"`
    46  	LinkNames   int               `json:"link_names"`
    47  	Attachments []SlackAttachment `json:"attachments"`
    48  }
    49  
    50  // SlackAttachment contains the slack message
    51  type SlackAttachment struct {
    52  	Fallback  string `json:"fallback"`
    53  	Color     string `json:"color"`
    54  	Title     string `json:"title"`
    55  	TitleLink string `json:"title_link"`
    56  	Text      string `json:"text"`
    57  }
    58  
    59  // JSONPayload Marshals the SlackPayload to json
    60  func (s *SlackPayload) JSONPayload() ([]byte, error) {
    61  	data, err := json.MarshalIndent(s, "", "  ")
    62  	if err != nil {
    63  		return []byte{}, err
    64  	}
    65  	return data, nil
    66  }
    67  
    68  // SlackTextFormatter replaces &, <, > with HTML characters
    69  // see: https://api.slack.com/docs/formatting
    70  func SlackTextFormatter(s string) string {
    71  	// replace & < >
    72  	s = strings.ReplaceAll(s, "&", "&amp;")
    73  	s = strings.ReplaceAll(s, "<", "&lt;")
    74  	s = strings.ReplaceAll(s, ">", "&gt;")
    75  	return s
    76  }
    77  
    78  // SlackShortTextFormatter replaces &, <, > with HTML characters
    79  func SlackShortTextFormatter(s string) string {
    80  	s = strings.Split(s, "\n")[0]
    81  	// replace & < >
    82  	s = strings.ReplaceAll(s, "&", "&amp;")
    83  	s = strings.ReplaceAll(s, "<", "&lt;")
    84  	s = strings.ReplaceAll(s, ">", "&gt;")
    85  	return s
    86  }
    87  
    88  // SlackLinkFormatter creates a link compatible with slack
    89  func SlackLinkFormatter(url, text string) string {
    90  	return fmt.Sprintf("<%s|%s>", url, SlackTextFormatter(text))
    91  }
    92  
    93  // SlackLinkToRef slack-formatter link to a repo ref
    94  func SlackLinkToRef(repoURL, ref string) string {
    95  	url := git.RefURL(repoURL, ref)
    96  	refName := git.RefName(ref).ShortName()
    97  	return SlackLinkFormatter(url, refName)
    98  }
    99  
   100  var _ PayloadConvertor = &SlackPayload{}
   101  
   102  // Create implements PayloadConvertor Create method
   103  func (s *SlackPayload) Create(p *api.CreatePayload) (api.Payloader, error) {
   104  	repoLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
   105  	refLink := SlackLinkToRef(p.Repo.HTMLURL, p.Ref)
   106  	text := fmt.Sprintf("[%s:%s] %s created by %s", repoLink, refLink, p.RefType, p.Sender.UserName)
   107  
   108  	return s.createPayload(text, nil), nil
   109  }
   110  
   111  // Delete composes Slack payload for delete a branch or tag.
   112  func (s *SlackPayload) Delete(p *api.DeletePayload) (api.Payloader, error) {
   113  	refName := git.RefName(p.Ref).ShortName()
   114  	repoLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
   115  	text := fmt.Sprintf("[%s:%s] %s deleted by %s", repoLink, refName, p.RefType, p.Sender.UserName)
   116  
   117  	return s.createPayload(text, nil), nil
   118  }
   119  
   120  // Fork composes Slack payload for forked by a repository.
   121  func (s *SlackPayload) Fork(p *api.ForkPayload) (api.Payloader, error) {
   122  	baseLink := SlackLinkFormatter(p.Forkee.HTMLURL, p.Forkee.FullName)
   123  	forkLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
   124  	text := fmt.Sprintf("%s is forked to %s", baseLink, forkLink)
   125  
   126  	return s.createPayload(text, nil), nil
   127  }
   128  
   129  // Issue implements PayloadConvertor Issue method
   130  func (s *SlackPayload) Issue(p *api.IssuePayload) (api.Payloader, error) {
   131  	text, issueTitle, attachmentText, color := getIssuesPayloadInfo(p, SlackLinkFormatter, true)
   132  
   133  	var attachments []SlackAttachment
   134  	if attachmentText != "" {
   135  		attachmentText = SlackTextFormatter(attachmentText)
   136  		issueTitle = SlackTextFormatter(issueTitle)
   137  		attachments = append(attachments, SlackAttachment{
   138  			Color:     fmt.Sprintf("%x", color),
   139  			Title:     issueTitle,
   140  			TitleLink: p.Issue.HTMLURL,
   141  			Text:      attachmentText,
   142  		})
   143  	}
   144  
   145  	return s.createPayload(text, attachments), nil
   146  }
   147  
   148  // IssueComment implements PayloadConvertor IssueComment method
   149  func (s *SlackPayload) IssueComment(p *api.IssueCommentPayload) (api.Payloader, error) {
   150  	text, issueTitle, color := getIssueCommentPayloadInfo(p, SlackLinkFormatter, true)
   151  
   152  	return s.createPayload(text, []SlackAttachment{{
   153  		Color:     fmt.Sprintf("%x", color),
   154  		Title:     issueTitle,
   155  		TitleLink: p.Comment.HTMLURL,
   156  		Text:      SlackTextFormatter(p.Comment.Body),
   157  	}}), nil
   158  }
   159  
   160  // Wiki implements PayloadConvertor Wiki method
   161  func (s *SlackPayload) Wiki(p *api.WikiPayload) (api.Payloader, error) {
   162  	text, _, _ := getWikiPayloadInfo(p, SlackLinkFormatter, true)
   163  
   164  	return s.createPayload(text, nil), nil
   165  }
   166  
   167  // Release implements PayloadConvertor Release method
   168  func (s *SlackPayload) Release(p *api.ReleasePayload) (api.Payloader, error) {
   169  	text, _ := getReleasePayloadInfo(p, SlackLinkFormatter, true)
   170  
   171  	return s.createPayload(text, nil), nil
   172  }
   173  
   174  func (s *SlackPayload) Package(p *api.PackagePayload) (api.Payloader, error) {
   175  	text, _ := getPackagePayloadInfo(p, SlackLinkFormatter, true)
   176  
   177  	return s.createPayload(text, nil), nil
   178  }
   179  
   180  // Push implements PayloadConvertor Push method
   181  func (s *SlackPayload) Push(p *api.PushPayload) (api.Payloader, error) {
   182  	// n new commits
   183  	var (
   184  		commitDesc   string
   185  		commitString string
   186  	)
   187  
   188  	if p.TotalCommits == 1 {
   189  		commitDesc = "1 new commit"
   190  	} else {
   191  		commitDesc = fmt.Sprintf("%d new commits", p.TotalCommits)
   192  	}
   193  	if len(p.CompareURL) > 0 {
   194  		commitString = SlackLinkFormatter(p.CompareURL, commitDesc)
   195  	} else {
   196  		commitString = commitDesc
   197  	}
   198  
   199  	repoLink := SlackLinkFormatter(p.Repo.HTMLURL, p.Repo.FullName)
   200  	branchLink := SlackLinkToRef(p.Repo.HTMLURL, p.Ref)
   201  	text := fmt.Sprintf("[%s:%s] %s pushed by %s", repoLink, branchLink, commitString, p.Pusher.UserName)
   202  
   203  	var attachmentText string
   204  	// for each commit, generate attachment text
   205  	for i, commit := range p.Commits {
   206  		attachmentText += fmt.Sprintf("%s: %s - %s", SlackLinkFormatter(commit.URL, commit.ID[:7]), SlackShortTextFormatter(commit.Message), SlackTextFormatter(commit.Author.Name))
   207  		// add linebreak to each commit but the last
   208  		if i < len(p.Commits)-1 {
   209  			attachmentText += "\n"
   210  		}
   211  	}
   212  
   213  	return s.createPayload(text, []SlackAttachment{{
   214  		Color:     s.Color,
   215  		Title:     p.Repo.HTMLURL,
   216  		TitleLink: p.Repo.HTMLURL,
   217  		Text:      attachmentText,
   218  	}}), nil
   219  }
   220  
   221  // PullRequest implements PayloadConvertor PullRequest method
   222  func (s *SlackPayload) PullRequest(p *api.PullRequestPayload) (api.Payloader, error) {
   223  	text, issueTitle, attachmentText, color := getPullRequestPayloadInfo(p, SlackLinkFormatter, true)
   224  
   225  	var attachments []SlackAttachment
   226  	if attachmentText != "" {
   227  		attachmentText = SlackTextFormatter(p.PullRequest.Body)
   228  		issueTitle = SlackTextFormatter(issueTitle)
   229  		attachments = append(attachments, SlackAttachment{
   230  			Color:     fmt.Sprintf("%x", color),
   231  			Title:     issueTitle,
   232  			TitleLink: p.PullRequest.HTMLURL,
   233  			Text:      attachmentText,
   234  		})
   235  	}
   236  
   237  	return s.createPayload(text, attachments), nil
   238  }
   239  
   240  // Review implements PayloadConvertor Review method
   241  func (s *SlackPayload) Review(p *api.PullRequestPayload, event webhook_module.HookEventType) (api.Payloader, error) {
   242  	senderLink := SlackLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName)
   243  	title := fmt.Sprintf("#%d %s", p.Index, p.PullRequest.Title)
   244  	titleLink := fmt.Sprintf("%s/pulls/%d", p.Repository.HTMLURL, p.Index)
   245  	repoLink := SlackLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
   246  	var text string
   247  
   248  	switch p.Action {
   249  	case api.HookIssueReviewed:
   250  		action, err := parseHookPullRequestEventType(event)
   251  		if err != nil {
   252  			return nil, err
   253  		}
   254  
   255  		text = fmt.Sprintf("[%s] Pull request review %s: [%s](%s) by %s", repoLink, action, title, titleLink, senderLink)
   256  	}
   257  
   258  	return s.createPayload(text, nil), nil
   259  }
   260  
   261  // Repository implements PayloadConvertor Repository method
   262  func (s *SlackPayload) Repository(p *api.RepositoryPayload) (api.Payloader, error) {
   263  	senderLink := SlackLinkFormatter(setting.AppURL+p.Sender.UserName, p.Sender.UserName)
   264  	repoLink := SlackLinkFormatter(p.Repository.HTMLURL, p.Repository.FullName)
   265  	var text string
   266  
   267  	switch p.Action {
   268  	case api.HookRepoCreated:
   269  		text = fmt.Sprintf("[%s] Repository created by %s", repoLink, senderLink)
   270  	case api.HookRepoDeleted:
   271  		text = fmt.Sprintf("[%s] Repository deleted by %s", repoLink, senderLink)
   272  	}
   273  
   274  	return s.createPayload(text, nil), nil
   275  }
   276  
   277  func (s *SlackPayload) createPayload(text string, attachments []SlackAttachment) *SlackPayload {
   278  	return &SlackPayload{
   279  		Channel:     s.Channel,
   280  		Text:        text,
   281  		Username:    s.Username,
   282  		IconURL:     s.IconURL,
   283  		Attachments: attachments,
   284  	}
   285  }
   286  
   287  // GetSlackPayload converts a slack webhook into a SlackPayload
   288  func GetSlackPayload(p api.Payloader, event webhook_module.HookEventType, meta string) (api.Payloader, error) {
   289  	s := new(SlackPayload)
   290  
   291  	slack := &SlackMeta{}
   292  	if err := json.Unmarshal([]byte(meta), &slack); err != nil {
   293  		return s, errors.New("GetSlackPayload meta json:" + err.Error())
   294  	}
   295  
   296  	s.Channel = slack.Channel
   297  	s.Username = slack.Username
   298  	s.IconURL = slack.IconURL
   299  	s.Color = slack.Color
   300  
   301  	return convertPayloader(s, p, event)
   302  }
   303  
   304  var slackChannel = regexp.MustCompile(`^#?[a-z0-9_-]{1,80}$`)
   305  
   306  // IsValidSlackChannel validates a channel name conforms to what slack expects:
   307  // https://api.slack.com/methods/conversations.rename#naming
   308  // Conversation names can only contain lowercase letters, numbers, hyphens, and underscores, and must be 80 characters or less.
   309  // Gitea accepts if it starts with a #.
   310  func IsValidSlackChannel(name string) bool {
   311  	return slackChannel.MatchString(name)
   312  }