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