-
Notifications
You must be signed in to change notification settings - Fork 413
/
StandardSlackService.java
executable file
·490 lines (438 loc) · 19 KB
/
StandardSlackService.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
package jenkins.plugins.slack;
import com.google.common.annotations.VisibleForTesting;
import hudson.AbortException;
import hudson.FilePath;
import hudson.ProxyConfiguration;
import hudson.Util;
import hudson.model.Run;
import hudson.model.TaskListener;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import jenkins.model.Jenkins;
import jenkins.plugins.slack.cache.SlackChannelIdCache;
import jenkins.plugins.slack.pipeline.SlackFileRequest;
import jenkins.plugins.slack.pipeline.SlackUploadFileRunner;
import jenkins.plugins.slack.user.SlackUserIdResolver;
import net.sf.json.JSONArray;
import net.sf.json.JSONObject;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpEntity;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.util.EntityUtils;
import org.jenkinsci.plugins.plaincredentials.StringCredentials;
public class StandardSlackService implements SlackService {
private static final Logger logger = Logger.getLogger(StandardSlackService.class.getName());
static final Pattern JENKINS_CI_HOOK_REGEX = Pattern.compile("https://(?<teamDomain>.*)\\.slack\\.com/services/hooks/jenkins-ci.*");
private final Run run;
private String baseUrl;
private String teamDomain;
private boolean botUser;
private final List<String> roomIds;
private final boolean replyBroadcast;
private final String iconEmoji;
private final String username;
private String responseString;
private String populatedToken;
private final boolean notifyCommitters;
private final SlackUserIdResolver userIdResolver;
/**
* @deprecated use {@link #StandardSlackService(String, String, boolean, String, boolean, String)} instead}
* @param baseUrl is a base URL
* @param teamDomain is a teamDomain
* @param authTokenCredentialId
* @param botUser is a bot User
* @param roomId is a room Id
*/
@Deprecated
public StandardSlackService(String baseUrl, String teamDomain, String authTokenCredentialId, boolean botUser, String roomId) {
this(baseUrl, teamDomain, null, authTokenCredentialId, botUser, roomId, false);
}
/**
* @deprecated use {@link #StandardSlackService(String, String, boolean, String, boolean, String)} instead}
* @param baseUrl is a base URL
* @param teamDomain is a teamDomain
* @param token is a token
* @param authTokenCredentialId
* @param botUser is a bot User
* @param roomId is a room Id
*/
@Deprecated
public StandardSlackService(String baseUrl, String teamDomain, String token, String authTokenCredentialId, boolean botUser, String roomId) {
this(baseUrl, teamDomain, token, authTokenCredentialId, botUser, roomId, false);
}
/**
* @deprecated use {@link #StandardSlackService(String, String, boolean, String, boolean, String)} instead}
* @param baseUrl is a base URL
* @param teamDomain is a teamDomain
* @param token is a token
* @param authTokenCredentialId
* @param botUser is a bot User
* @param roomId is a room Id
* @param replyBroadcast is a replyBroadcast
*/
@Deprecated
public StandardSlackService(String baseUrl, String teamDomain, String token, String authTokenCredentialId, boolean botUser, String roomId, boolean replyBroadcast) {
this(baseUrl, teamDomain, botUser, roomId, replyBroadcast, authTokenCredentialId);
this.populatedToken = getTokenToUse(authTokenCredentialId, token);
if (this.populatedToken == null) {
throw new IllegalArgumentException("No slack token found, setup a secret text credential and configure it to be used");
}
}
/**
* @deprecated use {@link #StandardSlackService(String, String, boolean, String, boolean, String)} instead}
* @param baseUrl is a base URL
* @param teamDomain is a teamDomain
* @param botUser is a bot User
* @param roomId is a room Id
* @param replyBroadcast is replayBroadcast
* @param populatedToken is populated Token
*/
@Deprecated
public StandardSlackService(String baseUrl, String teamDomain, boolean botUser, String roomId, boolean replyBroadcast, String populatedToken) {
this(builder()
.withBaseUrl(baseUrl)
.withTeamDomain(teamDomain)
.withBotUser(botUser)
.withRoomId(roomId)
.withReplyBroadcast(replyBroadcast)
.withPopulatedToken(populatedToken)
);
if (populatedToken == null) {
throw new IllegalArgumentException("No slack token found, setup a secret text credential and configure it to be used");
}
this.populatedToken = populatedToken;
}
/**
* Default constructor
* @param standardSlackServiceBuilder is a StandardSlackServiceBuilder
*/
public StandardSlackService(StandardSlackServiceBuilder standardSlackServiceBuilder) {
this.run = standardSlackServiceBuilder.run;
this.baseUrl = standardSlackServiceBuilder.baseUrl;
if (this.baseUrl != null && !this.baseUrl.isEmpty() && !this.baseUrl.endsWith("/")) {
this.baseUrl += "/";
}
this.teamDomain = standardSlackServiceBuilder.teamDomain;
this.botUser = standardSlackServiceBuilder.botUser;
if (standardSlackServiceBuilder.roomId == null) {
throw new IllegalArgumentException("Project Channel or Slack User ID must be specified.");
}
this.roomIds = new ArrayList<>(Arrays.asList(standardSlackServiceBuilder.roomId.split("[,; ]+")));
this.replyBroadcast = standardSlackServiceBuilder.replyBroadcast;
this.iconEmoji = correctEmojiFormat(standardSlackServiceBuilder.iconEmoji);
this.username = standardSlackServiceBuilder.username;
this.populatedToken = standardSlackServiceBuilder.populatedToken;
this.notifyCommitters = standardSlackServiceBuilder.notifyCommitters;
this.userIdResolver = standardSlackServiceBuilder.userIdResolver;
}
public static StandardSlackServiceBuilder builder() {
return new StandardSlackServiceBuilder();
}
public String getResponseString() {
return responseString;
}
public boolean publish(String message) {
return publish(message, "warning");
}
/**
* The slack jenkins CI app documentation is incorrect, but they haven't updated it after asking
* This confused users and causes them to mis-configure the application
* We correct it to reduce the amount of support needed
*/
void correctMisconfigurationOfBaseUrl() {
Matcher matcher = JENKINS_CI_HOOK_REGEX.matcher(baseUrl);
if (StringUtils.isNotEmpty(baseUrl) && matcher.matches()) {
teamDomain = matcher.group("teamDomain");
logger.warning("Overriding base url to team domain '" + teamDomain + "' this is due to " +
"mis-configuration, you don't need to set base url unless you're using a slack compatible app like mattermost");
botUser = false;
}
}
/**
* Make an HTTP POST to the Slack API
*
* @param apiEndpoint - The API endpoint to request, e.g. `chat.postMessage`
* @param body - The payload body to be POSTed to the API
*
* @return boolean indicating whether the API request succeeded
*/
boolean postToSlack(String apiEndpoint, JSONObject body) {
boolean result = true;
if (baseUrl != null) {
correctMisconfigurationOfBaseUrl();
}
try (CloseableHttpClient client = getHttpClient()) {
HttpPost post;
String url;
if (!botUser) {
url = "https://" + teamDomain + "." + "slack.com" + "/services/hooks/jenkins-ci?token=" + populatedToken;
if (!StringUtils.isEmpty(baseUrl)) {
url = baseUrl + populatedToken;
}
post = new HttpPost(url);
} else {
url = "https://slack.com/api/" + apiEndpoint;
post = new HttpPost(url);
post.setHeader("Authorization", "Bearer " + populatedToken);
}
post.setHeader("Content-Type", "application/json; charset=utf-8");
post.setEntity(new StringEntity(body.toString(), StandardCharsets.UTF_8));
try (CloseableHttpResponse response = client.execute(post)) {
int responseCode = response.getStatusLine().getStatusCode();
HttpEntity entity = response.getEntity();
if (botUser && entity != null) {
responseString = EntityUtils.toString(entity);
try {
org.json.JSONObject slackResponse = new org.json.JSONObject(responseString);
result = slackResponse.getBoolean("ok");
} catch (org.json.JSONException ex) {
logger.log(Level.WARNING, "Slack post may have failed. Invalid JSON response: " + responseString);
result = false;
}
}
if (responseCode != HttpStatus.SC_OK || !result) {
logger.log(Level.WARNING, "Slack post may have failed. Response: " + responseString);
logger.log(Level.WARNING, "Response Code: " + responseCode);
result = false;
} else {
logger.fine("Posting succeeded");
}
} catch (Exception e) {
logger.log(Level.WARNING, "Error posting to Slack", e);
result = false;
} finally {
post.releaseConnection();
}
} catch (IOException e) {
logger.log(Level.WARNING, "Error closing HttpClient", e);
}
return result;
}
/**
* Make an HTTP POST upload to the Slack API
*
* @param workspace - job workspace
* @param artifactIncludes - includes comma-separated Ant-style globs as per {@link Util#createFileSet(File, String, String)} using {@code /} as a path separator;
* @param log - print log stream
* @return boolean indicating whether the API request succeeded
*/
public boolean upload(FilePath workspace, String artifactIncludes, TaskListener log) {
boolean result = true;
if(workspace!=null) {
for(String roomId : roomIds) {
String channelId;
try {
channelId = SlackChannelIdCache.getChannelId(populatedToken, roomId);
} catch (ExecutionException | InterruptedException e) {
throw new RuntimeException(e);
} catch (AbortException e) {
return false;
}
SlackFileRequest slackFileRequest = new SlackFileRequest(
workspace, populatedToken, channelId, null, artifactIncludes, null);
try {
workspace.getChannel().callAsync(new SlackUploadFileRunner(log, Jenkins.get().proxy, slackFileRequest)).get();
} catch (IllegalStateException | InterruptedException e) {
logger.log(Level.WARNING, "Exception", e);
result = false;
} catch (ExecutionException e) {
logger.log(Level.WARNING, "ExecutionException", e);
result = false;
} catch (IOException e) {
logger.log(Level.WARNING, "Error closing HttpClient", e);
result = false;
}
}
} else {
logger.log(Level.WARNING, "Could not get workspace for current execution");
result = false;
}
return result;
}
@Override
public boolean publish(SlackRequest slackRequest) {
boolean result = true;
try (CloseableHttpClient client = getHttpClient()) {
// include committer userIds in roomIds
if (botUser && notifyCommitters && userIdResolver != null && run != null) {
userIdResolver.setAuthToken(populatedToken);
userIdResolver.setHttpClient(client);
List<String> userIds = userIdResolver.resolveUserIdsForRun(run);
roomIds.addAll(userIds.stream()
.filter(Objects::nonNull)
.distinct()
.map(userId -> "@" + userId)
.collect(Collectors.toList())
);
}
} catch (IOException e) {
logger.log(Level.WARNING, "Error closing HttpClient", e);
}
for (String roomId : roomIds) {
String threadTs = "";
//thread_ts is passed once with roomId: Ex: roomId:threadTs
String[] splitThread = roomId.split("[:]+");
if (splitThread.length > 1) {
roomId = splitThread[0];
threadTs = splitThread[1];
}
JSONObject json = slackRequest.getBody();
json.put("channel", roomId);
if (threadTs.length() > 1) {
json.put("thread_ts", threadTs);
}
if (replyBroadcast) {
json.put("reply_broadcast", "true");
}
if (StringUtils.isEmpty(iconEmoji) && StringUtils.isEmpty(username)) {
json.put("as_user", "true");
} else {
if (StringUtils.isNotEmpty(iconEmoji)) {
json.put("icon_emoji", iconEmoji);
}
if (StringUtils.isNotEmpty(username)) {
json.put("username", username);
}
}
String apiEndpoint = "chat.postMessage";
String timestamp = slackRequest.getTimestamp();
if (StringUtils.isNotEmpty(timestamp)) {
json.put("ts", timestamp);
apiEndpoint = "chat.update";
}
logger.fine("Posting: to " + roomId + " on " + teamDomain + ": " + json.toString());
boolean individualResult = postToSlack(apiEndpoint, json);
result = result && individualResult;
}
return result;
}
@Override
public boolean publish(String message, String color) {
//prepare attachments first
JSONArray attachments = prepareAttachments(message, color);
return publish(null, attachments, color);
}
private JSONArray prepareAttachments(String message, String color) {
JSONObject field = new JSONObject();
field.put("short", false);
field.put("value", message);
JSONArray fields = new JSONArray();
fields.add(field);
JSONObject attachment = new JSONObject();
attachment.put("fallback", message);
attachment.put("color", color);
attachment.put("fields", fields);
JSONArray mrkdwn = new JSONArray();
mrkdwn.add("pretext");
mrkdwn.add("text");
mrkdwn.add("fields");
attachment.put("mrkdwn_in", mrkdwn);
JSONArray attachments = new JSONArray();
attachments.add(attachment);
return attachments;
}
@Override
public boolean publish(String message, JSONArray attachments, String color) {
return publish(
SlackRequest.builder()
.withMessage(message)
.withAttachments(attachments)
.withColor(color)
.build()
);
}
@Override
public boolean publish(String message, String color, String timestamp) {
//prepare attachments first
JSONArray attachments = prepareAttachments(message, color);
return publish(null, attachments, color, timestamp);
}
@Override
public boolean publish(String message, JSONArray attachments, String color, String timestamp) {
return publish(
SlackRequest.builder()
.withMessage(message)
.withTimestamp(timestamp)
.withAttachments(attachments)
.withColor(color)
.build()
);
}
/**
* Add an emoji reaction to a message
*
* @param channelId - Slack's internal channel id (i.e. what's returned in a `chat.postMessage` response)
* @param timestamp - Timestamp identifying the message
* @param emojiName - The name of the emoji to add in reaction to the message (no colons)
*
* @return boolean indicating whether the API request succeeded
*/
@Override
public boolean addReaction(String channelId, String timestamp, String emojiName) {
JSONObject json = SlackReactionRequest.builder()
.withChannelId(channelId)
.withTimestamp(timestamp)
.withEmojiName(emojiName)
.build()
.getBody();
logger.fine("Adding reaction: " + json.toString());
return postToSlack("reactions.add", json);
}
/**
* Remove an emoji reaction from a message.
*/
@Override
public boolean removeReaction(String channelId, String timestamp, String emojiName) {
JSONObject json = SlackReactionRequest.builder()
.withChannelId(channelId)
.withTimestamp(timestamp)
.withEmojiName(emojiName)
.build()
.getBody();
logger.fine("Removing reaction: " + json.toString());
return postToSlack("reactions.remove", json);
}
private String getTokenToUse(String authTokenCredentialId, String token) {
if (!StringUtils.isEmpty(authTokenCredentialId)) {
StringCredentials credentials = CredentialsObtainer.lookupCredentials(authTokenCredentialId);
if (credentials != null) {
logger.fine("Using Integration Token Credential ID.");
return credentials.getSecret().getPlainText();
}
}
logger.fine("Using Integration Token.");
return token;
}
private String correctEmojiFormat(String iconEmoji) {
if (StringUtils.isEmpty(iconEmoji)) {
return iconEmoji;
}
iconEmoji = StringUtils.appendIfMissing(iconEmoji, ":");
iconEmoji = StringUtils.prependIfMissing(iconEmoji, ":");
return iconEmoji;
}
protected CloseableHttpClient getHttpClient() {
Jenkins jenkins = Jenkins.getInstanceOrNull();
ProxyConfiguration proxy = jenkins != null ? jenkins.proxy : null;
return HttpClient.getCloseableHttpClient(proxy);
}
@VisibleForTesting
String getTeamDomain() {
return teamDomain;
}
}