package slacknotifications; import com.google.gson.Gson; import jetbrains.buildServer.util.StringUtil; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; import org.apache.http.NameValuePair; import org.apache.http.auth.AuthScope; import org.apache.http.auth.Credentials; import org.apache.http.client.CredentialsProvider; import org.apache.http.client.HttpClient; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.BasicCredentialsProvider; import org.apache.http.impl.client.HttpClients; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.message.BasicNameValuePair; import org.apache.http.util.EntityUtils; import org.apache.http.HttpHost; import org.springframework.util.StringUtils; import slacknotifications.teamcity.BuildState; import slacknotifications.teamcity.Loggers; import slacknotifications.teamcity.payload.content.Commit; import slacknotifications.teamcity.payload.content.PostMessageResponse; import slacknotifications.teamcity.payload.content.SlackNotificationPayloadContent; import sun.reflect.generics.reflectiveObjects.NotImplementedException; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.net.URLEncoder; import java.util.ArrayList; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Set; import java.util.concurrent.TimeUnit; public class SlackNotificationImpl implements SlackNotification { private static final String UTF8 = "UTF-8"; private String proxyHost; private Integer proxyPort = 0; private String proxyUsername; private String proxyPassword; private String channel; private String teamName; private String token; private String iconUrl; private String content; private SlackNotificationPayloadContent payload; private Integer resultCode; private HttpClient client; private String filename = ""; private Boolean enabled = false; private Boolean errored = false; private String errorReason = ""; private List<NameValuePair> params = new ArrayList<NameValuePair>(); private BuildState states; private String botName; private final static String CONTENT_TYPE = "application/x-www-form-urlencoded"; private PostMessageResponse response; private Boolean showBuildAgent; private Boolean showElapsedBuildTime; private boolean showCommits; private boolean showCommitters; private int maxCommitsToDisplay; private boolean mentionChannelEnabled; private boolean mentionSlackUserEnabled; private boolean showFailureReason; /* This is a bit mask of states that should trigger a SlackNotification. * All ones (11111111) means that all states will trigger the slacknotifications * We'll set that as the default, and then override if we get a more specific bit mask. */ //private Integer EventListBitMask = BuildState.ALL_ENABLED; //private Integer EventListBitMask = Integer.parseInt("0",2); public SlackNotificationImpl() { this.client = HttpClients.createDefault(); this.params = new ArrayList<NameValuePair>(); } public SlackNotificationImpl(String channel) { this.channel = channel; this.client = HttpClients.createDefault(); this.params = new ArrayList<NameValuePair>(); } public SlackNotificationImpl(String channel, String proxyHost, String proxyPort) { this.channel = channel; this.client = HttpClients.createDefault(); this.params = new ArrayList<NameValuePair>(); if (proxyPort.length() != 0) { try { this.proxyPort = Integer.parseInt(proxyPort); } catch (NumberFormatException ex) { ex.printStackTrace(); } } this.setProxy(proxyHost, this.proxyPort, null); } public SlackNotificationImpl(String channel, String proxyHost, Integer proxyPort) { this.channel = channel; this.client = HttpClients.createDefault(); this.params = new ArrayList<NameValuePair>(); this.setProxy(proxyHost, proxyPort, null); } public SlackNotificationImpl(String channel, SlackNotificationProxyConfig proxyConfig) { this.channel = channel; this.client = HttpClients.createDefault(); this.params = new ArrayList<NameValuePair>(); setProxy(proxyConfig); } public SlackNotificationImpl(HttpClient httpClient, String channel) { this.channel = channel; this.client = httpClient; } public void setProxy(SlackNotificationProxyConfig proxyConfig) { if ((proxyConfig != null) && (proxyConfig.getProxyHost() != null) && (proxyConfig.getProxyPort() != null)) { this.setProxy(proxyConfig.getProxyHost(), proxyConfig.getProxyPort(), proxyConfig.getCreds()); } } public void setProxy(String proxyHost, Integer proxyPort, Credentials credentials) { this.proxyHost = proxyHost; this.proxyPort = proxyPort; if (this.proxyHost.length() > 0 && !this.proxyPort.equals(0)) { HttpClientBuilder clientBuilder = HttpClients.custom() .useSystemProperties() .setProxy(new HttpHost(proxyHost, proxyPort, "http")); if (credentials != null) { CredentialsProvider credsProvider = new BasicCredentialsProvider(); credsProvider.setCredentials(new AuthScope(proxyHost, proxyPort), credentials); clientBuilder.setDefaultCredentialsProvider(credsProvider); Loggers.SERVER.debug("SlackNotification ::using proxy credentials " + credentials.getUserPrincipal().getName()); } this.client = clientBuilder.build(); } } public void post() throws IOException { if(getIsApiToken()){ postViaApi(); } else{ postViaWebHook(); } } private void postViaApi() throws IOException { if ((this.enabled) && (!this.errored)) { if (this.teamName == null) { this.teamName = ""; } String url = String.format("https://slack.com/api/chat.postMessage?token=%s&link_names=1&username=%s&icon_url=%s&channel=%s&text=%s&pretty=1", this.token, this.botName == null ? "" : URLEncoder.encode(this.botName, UTF8), this.iconUrl == null ? "" : URLEncoder.encode(this.iconUrl, UTF8), this.channel == null ? "" : URLEncoder.encode(this.channel, UTF8), this.payload == null ? "" : URLEncoder.encode(payload.getBuildDescriptionWithLinkSyntax(), UTF8), ""); HttpPost httppost = new HttpPost(url); Loggers.SERVER.info("SlackNotificationListener :: Preparing message for URL " + url + " using proxy " + this.proxyHost + ":" + this.proxyPort); if (this.filename.length() > 0) { File file = new File(this.filename); throw new NotImplementedException(); } if (this.payload != null) { List<Attachment> attachments = getAttachments(); String attachmentsParam = String.format("attachments=%s", URLEncoder.encode(convertAttachmentsToJson(attachments), UTF8)); Loggers.SERVER.info("SlackNotificationListener :: Body message will be " + attachmentsParam); httppost.setEntity(new StringEntity(attachmentsParam)); httppost.setHeader("Content-Type", CONTENT_TYPE); } try { HttpResponse response = client.execute(httppost); this.resultCode = response.getStatusLine().getStatusCode(); if (this.resultCode == HttpStatus.SC_OK) { this.response = PostMessageResponse.fromJson(EntityUtils.toString(response.getEntity())); } if (response.getEntity().getContentLength() > 0) { this.content = EntityUtils.toString(response.getEntity()); } } finally { httppost.releaseConnection(); } } } private void postViaWebHook() throws IOException { if ((this.enabled) && (!this.errored)) { if (this.teamName == null) { this.teamName = ""; } String url = ""; if(this.token != null && this.token.startsWith("http")){ url = this.token; } else { url = String.format("https://%s.slack.com/services/hooks/incoming-webhook?token=%s", this.teamName.toLowerCase(), this.token); } Loggers.SERVER.info("SlackNotificationListener :: Preparing message for URL " + url); WebHookPayload requestBody = new WebHookPayload(); requestBody.setChannel(this.getChannel()); requestBody.setUsername(this.getBotName()); requestBody.setIcon_url(this.getIconUrl()); HttpPost httppost = new HttpPost(url); if (this.payload != null) { requestBody.setText(payload.getBuildDescriptionWithLinkSyntax()); requestBody.setAttachments(getAttachments()); } String bodyParam = String.format("payload=%s", URLEncoder.encode(requestBody.toJson(), UTF8)); Loggers.SERVER.info("SlackNotificationListener :: Body message will be " + bodyParam); httppost.setEntity(new StringEntity(bodyParam)); httppost.setHeader("Content-Type", CONTENT_TYPE); try { HttpResponse response = client.execute(httppost); this.resultCode = response.getStatusLine().getStatusCode(); PostMessageResponse resp = new PostMessageResponse(); if (this.resultCode != HttpStatus.SC_OK) { String error = EntityUtils.toString(response.getEntity()); resp.setOk(error == "ok"); resp.setError(error); } else{ resp.setOk(true); this.response = resp; } this.content = EntityUtils.toString(response.getEntity()); } finally { httppost.releaseConnection(); } } } private List<Attachment> getAttachments() { List<Attachment> attachments = new ArrayList<Attachment>(); Attachment attachment = new Attachment(this.payload.getBuildName(), null, null, this.payload.getColor()); List<String> firstDetailLines = new ArrayList<String>(); if(showBuildAgent == null || showBuildAgent){ firstDetailLines.add("Agent: " + this.payload.getAgentName()); } if(this.payload.getIsComplete() && (showElapsedBuildTime == null || showElapsedBuildTime)){ firstDetailLines.add("Elapsed: " + formatTime(this.payload.getElapsedTime())); } attachment.addField(this.payload.getBuildName(), StringUtil.join(firstDetailLines, "\n"), false); if(showFailureReason && this.payload.getBuildResult() == SlackNotificationPayloadContent.BUILD_STATUS_FAILURE){ if(this.payload.getFailedBuildMessages().size() > 0) { attachment.addField("Reason", StringUtil.join(", ", payload.getFailedBuildMessages()), false); } if(this.payload.getFailedTestNames().size() > 0){ ArrayList<String> failedTestNames = payload.getFailedTestNames(); String truncated = ""; if(failedTestNames.size() > 10){ failedTestNames = new ArrayList<String>( failedTestNames.subList(0, 9)); truncated = " (+ " + Integer.toString(payload.getFailedBuildMessages().size() - 10) + " more)"; } payload.getFailedTestNames().size(); attachment.addField("Failed Tests", StringUtil.join(", ", failedTestNames) + truncated, false); } } StringBuilder sbCommits = new StringBuilder(); List<Commit> commits = this.payload.getCommits(); List<Commit> commitsToDisplay = new ArrayList<Commit>(commits); if(showCommits) { boolean truncated = false; int totalCommits = commitsToDisplay.size(); if (commitsToDisplay.size() > maxCommitsToDisplay) { commitsToDisplay = commitsToDisplay.subList(0, maxCommitsToDisplay > commitsToDisplay.size() ? commitsToDisplay.size() : 5); truncated = true; } for (Commit commit : commitsToDisplay) { String revision = commit.getRevision(); revision = revision == null ? "" : revision; sbCommits.append(String.format("%s :: %s :: %s\n", revision.substring(0, Math.min(revision.length(), 10)), commit.getUserName(), commit.getDescription())); } if (truncated) { sbCommits.append(String.format("(+ %d more)\n", totalCommits - 5)); } if (!commitsToDisplay.isEmpty()) { attachment.addField("Commits", sbCommits.toString(), false); } } List<String> slackUsers = new ArrayList<String>(); for(Commit commit : commits){ if(commit.hasSlackUsername()){ slackUsers.add("@" + commit.getSlackUserName()); } } HashSet<String> tempHash = new HashSet<String>(slackUsers); slackUsers = new ArrayList<String>(tempHash); if(showCommitters) { Set<String> committers = new HashSet<String>(); for (Commit commit : commits) { committers.add(commit.getUserName()); } String committersString = StringUtil.join(", ", committers); if (!commits.isEmpty()) { attachment.addField("Changes By", committersString, false); } } // Mention the channel and/or the Slack Username of any committers if known if(payload.getIsFirstFailedBuild() && (mentionChannelEnabled || (mentionSlackUserEnabled && !slackUsers.isEmpty()))){ String mentionContent = ":arrow_up: \"" + this.payload.getBuildName() + "\" Failed "; if(mentionChannelEnabled){ mentionContent += "<!channel> "; } if(mentionSlackUserEnabled && !slackUsers.isEmpty() && !this.payload.isMergeBranch()) { mentionContent += StringUtil.join(" ", slackUsers); } attachment.addField("", mentionContent, true); } attachments.add(attachment); return attachments; } private class WebHookPayload { private String channel; private String username; private String text; private String icon_url; private List<Attachment> attachments; public String getChannel() { return channel; } public void setChannel(String channel) { this.channel = channel; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getText() { return text; } public void setText(String text) { this.text = text; } public String getIcon_url() { return icon_url; } public void setIcon_url(String icon_url) { this.icon_url = icon_url; } public List<Attachment> getAttachments() { return attachments; } public void setAttachments(List<Attachment> attachments) { this.attachments = attachments; } public String toJson() { Gson gson = new Gson(); return gson.toJson(this); } } public static String convertAttachmentsToJson(List<Attachment> attachments) { Gson gson = new Gson(); return gson.toJson(attachments); // XStream xstream = new XStream(new JsonHierarchicalStreamDriver()); // xstream.setMode(XStream.NO_REFERENCES); // xstream.alias("build", Attachment.class); // /* For some reason, the items are coming back as "@name" and "@value" // * so strip those out with a regex. // */ // return xstream.toXML(attachments).replaceAll("\"@(fallback|text|pretext|color|fields|title|value|short)\": \"(.*)\"", "\"$1\": \"$2\""); } public Integer getStatus() { return this.resultCode; } public String getProxyHost() { return proxyHost; } public int getProxyPort() { return proxyPort; } public String getTeamName() { return teamName; } public void setTeamName(String teamName) { this.teamName = teamName; } public String getToken() { return token; } public void setToken(String token) { this.token = token; } public String getIconUrl() { return this.iconUrl; } public void setIconUrl(String iconUrl) { this.iconUrl = iconUrl; } public String getBotName() { return this.botName; } public void setBotName(String botName) { this.botName = botName; } public String getChannel() { return channel; } public void setChannel(String channel) { this.channel = channel; } public String getParameterisedUrl() { //TODO: Implement different url logic return this.channel + this.parametersAsQueryString(); } public String parametersAsQueryString() { String s = ""; for (Iterator<NameValuePair> i = this.params.iterator(); i.hasNext(); ) { NameValuePair nv = i.next(); s += "&" + nv.getName() + "=" + nv.getValue(); } if (s.length() > 0) { return "?" + s.substring(1); } return s; } public void addParam(String key, String value) { this.params.add(new BasicNameValuePair(key, value)); } public void addParams(List<NameValuePair> paramsList) { for (Iterator<NameValuePair> i = paramsList.iterator(); i.hasNext(); ) { this.params.add(i.next()); } } public String getParam(String key) { for (Iterator<NameValuePair> i = this.params.iterator(); i.hasNext(); ) { NameValuePair nv = i.next(); if (nv.getName().equals(key)) { return nv.getValue(); } } return ""; } public void setFilename(String filename) { this.filename = filename; } public String getFilename() { return filename; } public String getContent() { return content; } public Boolean isEnabled() { return enabled; } public void setEnabled(Boolean enabled) { this.enabled = enabled; } public void setEnabled(String enabled) { if ("true".equals(enabled.toLowerCase())) { this.enabled = true; } else { this.enabled = false; } } public Boolean isErrored() { return errored; } public void setErrored(Boolean errored) { this.errored = errored; } public String getErrorReason() { return errorReason; } public void setErrorReason(String errorReason) { this.errorReason = errorReason; } // public Integer getEventListBitMask() { // return EventListBitMask; // } // // public void setTriggerStateBitMask(Integer triggerStateBitMask) { // EventListBitMask = triggerStateBitMask; // } public String getProxyUsername() { return proxyUsername; } public void setProxyUsername(String proxyUsername) { this.proxyUsername = proxyUsername; } public String getProxyPassword() { return proxyPassword; } public void setProxyPassword(String proxyPassword) { this.proxyPassword = proxyPassword; } public SlackNotificationPayloadContent getPayload() { return payload; } public void setPayload(SlackNotificationPayloadContent payloadContent) { this.payload = payloadContent; } @Override public BuildState getBuildStates() { return states; } @Override public void setBuildStates(BuildState states) { this.states = states; } public PostMessageResponse getResponse() { return response; } @Override public void setShowBuildAgent(Boolean showBuildAgent) { this.showBuildAgent = showBuildAgent; } @Override public void setShowElapsedBuildTime(Boolean showElapsedBuildTime) { this.showElapsedBuildTime = showElapsedBuildTime; } @Override public void setShowCommits(boolean showCommits) { this.showCommits = showCommits; } @Override public void setShowCommitters(boolean showCommitters) { this.showCommitters = showCommitters; } @Override public void setMaxCommitsToDisplay(int maxCommitsToDisplay) { this.maxCommitsToDisplay = maxCommitsToDisplay; } @Override public void setMentionChannelEnabled(boolean mentionChannelEnabled) { this.mentionChannelEnabled = mentionChannelEnabled; } @Override public void setMentionSlackUserEnabled(boolean mentionSlackUserEnabled) { this.mentionSlackUserEnabled = mentionSlackUserEnabled; } @Override public void setShowFailureReason(boolean showFailureReason) { this.showFailureReason = showFailureReason; } public boolean getIsApiToken() { if(this.token != null && this.token.startsWith("http")){ // We now accept a webhook url. return false; } return this.token == null || this.token.split("-").length > 1; } private String formatTime(long seconds){ if(seconds < 60){ return seconds + "s"; } return String.format("%dm:%ds", TimeUnit.SECONDS.toMinutes(seconds), TimeUnit.SECONDS.toSeconds(seconds) - TimeUnit.MINUTES.toSeconds(TimeUnit.SECONDS.toMinutes(seconds)) ); } }