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, "&", "&") 73 s = strings.ReplaceAll(s, "<", "<") 74 s = strings.ReplaceAll(s, ">", ">") 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, "&", "&") 83 s = strings.ReplaceAll(s, "<", "<") 84 s = strings.ReplaceAll(s, ">", ">") 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 }