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, "&", "&") 64 s = strings.ReplaceAll(s, "<", "<") 65 s = strings.ReplaceAll(s, ">", ">") 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, "&", "&") 74 s = strings.ReplaceAll(s, "<", "<") 75 s = strings.ReplaceAll(s, ">", ">") 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 }