Skip to content

Commit 62ffe7c

Browse files
authored
Introduce a channel name to ID cache (#965)
1 parent eb11748 commit 62ffe7c

File tree

10 files changed

+291
-85
lines changed

10 files changed

+291
-85
lines changed

pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@
6464
<groupId>io.jenkins.plugins</groupId>
6565
<artifactId>commons-lang3-api</artifactId>
6666
</dependency>
67+
<dependency>
68+
<groupId>io.jenkins.plugins</groupId>
69+
<artifactId>caffeine-api</artifactId>
70+
</dependency>
6771
<dependency>
6872
<groupId>org.jenkins-ci.plugins</groupId>
6973
<artifactId>structs</artifactId>

src/main/java/jenkins/plugins/slack/HttpClient.java

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package jenkins.plugins.slack;
22

33
import hudson.ProxyConfiguration;
4-
import jenkins.plugins.slack.NoProxyHostCheckerRoutePlanner;
4+
import hudson.util.Secret;
55
import org.apache.http.HttpHost;
66
import org.apache.http.auth.AuthScope;
77
import org.apache.http.auth.Credentials;
@@ -20,7 +20,7 @@
2020
@Restricted(NoExternalUse.class)
2121
public class HttpClient {
2222

23-
public static CloseableHttpClient getCloseableHttpClient(ProxyConfiguration proxy) {
23+
public static HttpClientBuilder getCloseableHttpClientBuilder(ProxyConfiguration proxy) {
2424
int timeoutInSeconds = 60;
2525

2626
RequestConfig config = RequestConfig.custom()
@@ -29,9 +29,9 @@ public static CloseableHttpClient getCloseableHttpClient(ProxyConfiguration prox
2929
.setSocketTimeout(timeoutInSeconds * 1000).build();
3030

3131
final HttpClientBuilder clientBuilder = HttpClients
32-
.custom()
33-
.useSystemProperties()
34-
.setDefaultRequestConfig(config);
32+
.custom()
33+
.useSystemProperties()
34+
.setDefaultRequestConfig(config);
3535
final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
3636
clientBuilder.setDefaultCredentialsProvider(credentialsProvider);
3737

@@ -41,14 +41,20 @@ public static CloseableHttpClient getCloseableHttpClient(ProxyConfiguration prox
4141
clientBuilder.setRoutePlanner(routePlanner);
4242

4343
String username = proxy.getUserName();
44-
String password = proxy.getPassword();
44+
Secret secretPassword = proxy.getSecretPassword();
45+
String password = Secret.toString(secretPassword);
4546
// Consider it to be passed if username specified. Sufficient?
46-
if (username != null && !"".equals(username.trim())) {
47+
if (username != null && !username.trim().isEmpty()) {
4748
credentialsProvider.setCredentials(new AuthScope(proxyHost),
4849
createCredentials(username, password));
4950
}
5051
}
51-
return clientBuilder.build();
52+
return clientBuilder;
53+
54+
}
55+
56+
public static CloseableHttpClient getCloseableHttpClient(ProxyConfiguration proxy) {
57+
return getCloseableHttpClientBuilder(proxy).build();
5258
}
5359

5460
private static Credentials createCredentials(String userName, String password) {

src/main/java/jenkins/plugins/slack/SlackNotifier.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import java.util.logging.Logger;
2828
import java.util.stream.Stream;
2929
import jenkins.model.Jenkins;
30+
import jenkins.plugins.slack.cache.SlackChannelIdCache;
3031
import jenkins.plugins.slack.config.GlobalCredentialMigrator;
3132
import jenkins.plugins.slack.logging.BuildAwareLogger;
3233
import jenkins.plugins.slack.logging.BuildKey;
@@ -870,6 +871,16 @@ public String getDisplayName() {
870871
return PLUGIN_DISPLAY_NAME;
871872
}
872873

874+
@POST
875+
public FormValidation doClearCache() {
876+
Jenkins.get().checkPermission(Jenkins.ADMINISTER);
877+
878+
logger.info("Clearing channel ID cache");
879+
SlackChannelIdCache.clearCache();
880+
881+
return FormValidation.ok("Cache cleared");
882+
}
883+
873884
@POST
874885
public FormValidation doTestConnectionGlobal(
875886
@QueryParameter("baseUrl") final String baseUrl,

src/main/java/jenkins/plugins/slack/StandardSlackService.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package jenkins.plugins.slack;
22

33
import com.google.common.annotations.VisibleForTesting;
4+
import hudson.AbortException;
45
import hudson.FilePath;
56
import hudson.ProxyConfiguration;
67
import hudson.Util;
@@ -20,6 +21,7 @@
2021
import java.util.regex.Pattern;
2122
import java.util.stream.Collectors;
2223
import jenkins.model.Jenkins;
24+
import jenkins.plugins.slack.cache.SlackChannelIdCache;
2325
import jenkins.plugins.slack.pipeline.SlackFileRequest;
2426
import jenkins.plugins.slack.pipeline.SlackUploadFileRunner;
2527
import jenkins.plugins.slack.user.SlackUserIdResolver;
@@ -256,8 +258,17 @@ public boolean upload(FilePath workspace, String artifactIncludes, TaskListener
256258
boolean result = true;
257259
if(workspace!=null) {
258260
for(String roomId : roomIds) {
261+
String channelId;
262+
try {
263+
channelId = SlackChannelIdCache.getChannelId(populatedToken, roomId);
264+
} catch (ExecutionException | InterruptedException e) {
265+
throw new RuntimeException(e);
266+
} catch (AbortException e) {
267+
return false;
268+
}
269+
259270
SlackFileRequest slackFileRequest = new SlackFileRequest(
260-
workspace, populatedToken, roomId, null, artifactIncludes);
271+
workspace, populatedToken, channelId, null, artifactIncludes, null);
261272
try {
262273
workspace.getChannel().callAsync(new SlackUploadFileRunner(log, Jenkins.get().proxy, slackFileRequest)).get();
263274
} catch (IllegalStateException | InterruptedException e) {
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package jenkins.plugins.slack.cache;
2+
3+
import com.github.benmanes.caffeine.cache.Caffeine;
4+
import com.github.benmanes.caffeine.cache.LoadingCache;
5+
import hudson.AbortException;
6+
import java.io.IOException;
7+
import java.time.Duration;
8+
import java.util.HashMap;
9+
import java.util.Map;
10+
import java.util.concurrent.CompletableFuture;
11+
import java.util.concurrent.CompletionException;
12+
import java.util.concurrent.ExecutionException;
13+
import java.util.logging.Logger;
14+
import jenkins.model.Jenkins;
15+
import jenkins.plugins.slack.HttpClient;
16+
import org.apache.http.Header;
17+
import org.apache.http.HttpEntity;
18+
import org.apache.http.HttpResponse;
19+
import org.apache.http.HttpStatus;
20+
import org.apache.http.client.ResponseHandler;
21+
import org.apache.http.client.ServiceUnavailableRetryStrategy;
22+
import org.apache.http.client.methods.RequestBuilder;
23+
import org.apache.http.impl.client.CloseableHttpClient;
24+
import org.apache.http.impl.client.HttpClientBuilder;
25+
import org.apache.http.protocol.HttpContext;
26+
import org.apache.http.util.EntityUtils;
27+
import org.json.JSONArray;
28+
import org.json.JSONObject;
29+
30+
public class SlackChannelIdCache {
31+
32+
private static final String UPLOAD_FAILED_TEMPLATE = "Failed to retrieve channel names. Response: ";
33+
private static final Logger logger = Logger.getLogger(SlackChannelIdCache.class.getName());
34+
35+
// cache that includes all channel names and IDs for each workspace used
36+
private static final LoadingCache<String, Map<String, String>> CHANNEL_METADATA_CACHE = Caffeine.newBuilder()
37+
.maximumSize(100)
38+
.refreshAfterWrite(Duration.ofHours(24))
39+
.build(SlackChannelIdCache::populateCache);
40+
private static final int MAX_RETRIES = 10;
41+
42+
private static Map<String, String> populateCache(String token) {
43+
HttpClientBuilder closeableHttpClientBuilder = HttpClient.getCloseableHttpClientBuilder(Jenkins.get().getProxy())
44+
.setRetryHandler((exception, executionCount, context) -> executionCount <= MAX_RETRIES)
45+
.setServiceUnavailableRetryStrategy(new ServiceUnavailableRetryStrategy() {
46+
47+
long retryInterval;
48+
49+
@Override
50+
public boolean retryRequest(HttpResponse response, int executionCount, HttpContext context) {
51+
boolean shouldRetry = executionCount <= MAX_RETRIES &&
52+
response.getStatusLine().getStatusCode() == HttpStatus.SC_TOO_MANY_REQUESTS;
53+
if (shouldRetry) {
54+
Header firstHeader = response.getFirstHeader("Retry-After");
55+
if (firstHeader != null) {
56+
retryInterval = Long.parseLong(firstHeader.getValue()) * 1000L;
57+
logger.info(String.format("Rate limited by Slack, retrying in %dms", retryInterval));
58+
}
59+
}
60+
return shouldRetry;
61+
}
62+
63+
@Override
64+
public long getRetryInterval() {
65+
return retryInterval;
66+
}
67+
});
68+
try (CloseableHttpClient client = closeableHttpClientBuilder.build()) {
69+
return convertChannelNameToId(client, token, new HashMap<>(), null);
70+
} catch (IOException e) {
71+
throw new RuntimeException(e);
72+
}
73+
}
74+
75+
public static String getChannelId(String botUserToken, String channelName) throws ExecutionException, InterruptedException, AbortException {
76+
Map<String, String> channelNameToIdMap = CHANNEL_METADATA_CACHE.get(botUserToken);
77+
String channelId = channelNameToIdMap.get(channelName);
78+
79+
// most likely is that a new channel has been created since the last cache refresh
80+
// or a typo in the channel name, a bit risky in larger workspaces but shouldn't happen too often
81+
if (channelId == null) {
82+
try {
83+
CompletableFuture<Map<String, String>> newResult = CHANNEL_METADATA_CACHE.refresh(botUserToken);
84+
channelNameToIdMap = newResult.get();
85+
} catch (CompletionException e) {
86+
throw new AbortException("Failed uploading file to slack, channel not found: " + channelName + ", error: " + e.getMessage());
87+
}
88+
89+
channelId = channelNameToIdMap.get(channelName);
90+
}
91+
92+
return channelId;
93+
}
94+
95+
private static Map<String, String> convertChannelNameToId(CloseableHttpClient client, String token, Map<String, String> channels, String cursor) throws IOException {
96+
RequestBuilder requestBuilder = RequestBuilder.get("https://slack.com/api/conversations.list")
97+
.addHeader("Authorization", "Bearer " + token)
98+
.addParameter("exclude_archived", "true")
99+
.addParameter("types", "public_channel,private_channel");
100+
101+
if (cursor != null) {
102+
requestBuilder.addParameter("cursor", cursor);
103+
}
104+
ResponseHandler<JSONObject> standardResponseHandler = getStandardResponseHandler();
105+
JSONObject result = client.execute(requestBuilder.build(), standardResponseHandler);
106+
107+
if (!result.getBoolean("ok")) {
108+
logger.warning("Couldn't convert channel name to ID in Slack: " + result);
109+
return channels;
110+
}
111+
112+
JSONArray channelsArray = result.getJSONArray("channels");
113+
for (int i = 0; i < channelsArray.length(); i++) {
114+
JSONObject channel = channelsArray.getJSONObject(i);
115+
116+
String channelName = channel.getString("name");
117+
String channelId = channel.getString("id");
118+
119+
channels.put(channelName, channelId);
120+
}
121+
122+
cursor = result.getJSONObject("response_metadata").getString("next_cursor");
123+
if (cursor != null && !cursor.isEmpty()) {
124+
return convertChannelNameToId(client, token, channels, cursor);
125+
}
126+
127+
return channels;
128+
}
129+
130+
private static ResponseHandler<JSONObject> getStandardResponseHandler() {
131+
return response -> {
132+
int status = response.getStatusLine().getStatusCode();
133+
if (status >= 200 && status < 300) {
134+
HttpEntity entity = response.getEntity();
135+
return entity != null ? new JSONObject(EntityUtils.toString(entity)) : null;
136+
} else {
137+
String errorMessage = UPLOAD_FAILED_TEMPLATE + status + " " + EntityUtils.toString(response.getEntity());
138+
throw new HttpStatusCodeException(response.getStatusLine().getStatusCode(), errorMessage);
139+
}
140+
};
141+
}
142+
143+
public static class HttpStatusCodeException extends RuntimeException {
144+
private final int statusCode;
145+
146+
public HttpStatusCodeException(int statusCode, String message) {
147+
super(message);
148+
this.statusCode = statusCode;
149+
}
150+
151+
public int getStatusCode() {
152+
return statusCode;
153+
}
154+
}
155+
156+
public static void clearCache() {
157+
CHANNEL_METADATA_CACHE.invalidateAll();
158+
}
159+
}

src/main/java/jenkins/plugins/slack/pipeline/SlackFileRequest.java

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,27 @@
88
public class SlackFileRequest {
99
private final String fileToUploadPath;
1010
private final String token;
11-
private final String channels;
11+
private final String channelId;
12+
private final String threadTs;
1213

1314
private final String initialComment;
1415
private final FilePath filePath;
1516

16-
public SlackFileRequest(FilePath filePath, String token, String channels, String initialComment, String fileToUploadPath) {
17+
public SlackFileRequest(FilePath filePath, String token, String channelId, String initialComment, String fileToUploadPath, String threadTs) {
1718
this.token = token;
18-
this.channels = channels;
19+
this.channelId = channelId;
1920
this.initialComment = initialComment;
2021
this.filePath = filePath;
2122
this.fileToUploadPath = fileToUploadPath;
23+
this.threadTs = threadTs;
2224
}
2325

2426
public String getToken() {
2527
return token;
2628
}
2729

28-
public String getChannels() {
29-
return channels;
30+
public String getChannelId() {
31+
return channelId;
3032
}
3133

3234
public String getInitialComment() {
@@ -40,4 +42,8 @@ public FilePath getFilePath() {
4042
public String getFileToUploadPath() {
4143
return fileToUploadPath;
4244
}
45+
46+
public String getThreadTs() {
47+
return threadTs;
48+
}
4349
}

0 commit comments

Comments
 (0)