SlackClient.java
package no.nav.data.team.integration.slack;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import lombok.extern.slf4j.Slf4j;
import no.nav.data.common.exceptions.NotFoundException;
import no.nav.data.common.exceptions.TechnicalException;
import no.nav.data.common.security.SecurityProperties;
import no.nav.data.common.security.azure.support.MailLog;
import no.nav.data.common.storage.StorageService;
import no.nav.data.common.utils.JsonUtils;
import no.nav.data.common.utils.MetricUtils;
import no.nav.data.common.web.TraceHeaderRequestInterceptor;
import no.nav.data.team.contact.domain.SlackChannel;
import no.nav.data.team.contact.domain.SlackUser;
import no.nav.data.team.integration.slack.dto.SlackDtos.*;
import no.nav.data.team.integration.slack.dto.SlackDtos.PostMessageRequest.Block;
import no.nav.data.team.integration.slack.dto.SlackDtos.UserResponse.User;
import no.nav.data.team.resource.NomClient;
import org.apache.commons.collections4.ListUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.springframework.boot.restclient.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static java.util.Comparator.comparing;
import static java.util.Objects.requireNonNull;
import static no.nav.data.common.utils.StartsWithComparator.startsWith;
import static no.nav.data.common.utils.StreamUtils.toMap;
@Slf4j
@Service
public class SlackClient {
private static final String LOOKUP_BY_EMAIL = "/users.lookupByEmail?email={email}";
private static final String LOOKUP_BY_ID = "/users.info?user={userId}";
private static final String OPEN_CONVERSATION = "/conversations.open";
private static final String POST_MESSAGE = "/chat.postMessage";
private static final String LIST_CONVERSATIONS = "/conversations.list";
private static final int MAX_BLOCKS_PER_MESSAGE = 50;
private static final int MAX_CHARS_PER_BLOCK = 3000;
private static final String SINGLETON = "SINGLETON";
private final NomClient nomClient;
private final RestTemplate restTemplate;
private final SecurityProperties securityProperties;
private final StorageService storage;
private final Cache<String, User> userCache;
private final LoadingCache<String, String> conversationCache;
private final LoadingCache<String, Map<String, Channel>> channelCache;
public SlackClient(NomClient nomClient, RestTemplateBuilder restTemplateBuilder, SlackProperties properties, SecurityProperties securityProperties,
StorageService storage) {
this.nomClient = nomClient;
this.securityProperties = securityProperties;
this.storage = storage;
restTemplate = restTemplateBuilder
.additionalInterceptors(TraceHeaderRequestInterceptor.correlationInterceptor())
.rootUri(properties.getBaseUrl())
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + properties.getToken())
.build();
this.userCache = MetricUtils.register("slackUserCache",
Caffeine.newBuilder().recordStats()
.expireAfterWrite(Duration.ofMinutes(60))
.maximumSize(1000).build());
this.conversationCache = MetricUtils.register("slackConversationCache",
Caffeine.newBuilder().recordStats()
.expireAfterWrite(Duration.ofMinutes(60))
.maximumSize(1000).build(this::doOpenConversation));
this.channelCache = MetricUtils.register("slackChannelCache",
Caffeine.newBuilder().recordStats()
.expireAfterWrite(Duration.ofMinutes(30))
.maximumSize(1).build(_ -> toMap(getChannels(), Channel::getId)));
}
public List<SlackChannel> searchChannel(String name) {
return getChannelCached().values().stream()
.filter(channel -> Strings.CI.contains(channel.getName(), name))
.sorted(comparing(Channel::getName, startsWith(name)))
.map(Channel::toDomain)
.collect(Collectors.toList());
}
public SlackChannel getChannel(String channelId) {
var channel = getChannelCached().get(channelId);
return channel != null ? channel.toDomain() : null;
}
public SlackUser getUserByIdent(String ident) {
var email = nomClient.getByNavIdent(ident).orElseThrow().getEmail();
return getUserByEmail(email);
}
public SlackUser getUserByEmail(String email) {
var user = userCache.get("EMAIL." + email, _ -> doGetUserByEmail(email));
return user != null ? user.toDomain() : null;
}
public SlackUser getUserBySlackId(String userId) {
var user = userCache.get("ID." + userId, _ -> doGetUserById(userId));
return user != null ? user.toDomain() : null;
}
public String openConversation(String channelId) {
return conversationCache.get(channelId);
}
private Map<String, Channel> getChannelCached() {
return requireNonNull(channelCache.get(SINGLETON));
}
private List<Channel> getChannels() {
var headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
var all = new ArrayList<Channel>();
ListChannelResponse list;
String cursor = null;
do {
// Operation does not support json requests
MultiValueMap<String, String> reqForm = new LinkedMultiValueMap<>();
if (cursor != null) {
reqForm.add("cursor", cursor);
}
reqForm.add("limit", "1000");
reqForm.add("exclude_archived", "true");
var response = restTemplate.postForEntity(LIST_CONVERSATIONS, new HttpEntity<>(reqForm, headers), ListChannelResponse.class);
list = checkResponse(response);
cursor = list.getResponseMetadata().getNextCursor();
all.addAll(list.getChannels());
} while (!StringUtils.isBlank(cursor));
return all;
}
private User doGetUserByEmail(String email) {
try {
var response = restTemplate.getForEntity(LOOKUP_BY_EMAIL, UserResponse.class, email);
UserResponse user = checkResponse(response);
return user.getUser();
} catch (Exception e) {
if (e.getMessage().contains("users_not_found")) {
log.debug("Couldn't find user for email {}", email);
return null;
}
throw new TechnicalException("Failed to get userId for " + email, e);
}
}
private User doGetUserById(String id) {
try {
var response = restTemplate.getForEntity(LOOKUP_BY_ID, UserResponse.class, id);
UserResponse user = checkResponse(response);
return user.getUser();
} catch (Exception e) {
if (e.getMessage().contains("users_not_found")) {
log.debug("Couldn't find user for id {}", id);
return null;
}
throw new TechnicalException("Failed to get user for id " + id, e);
}
}
public void sendMessageToUser(String email, String subject, List<Block> blocks) {
try {
if (getUserByEmail(email) == null) {
log.warn("Notification for email {} with subject {} could not be sent. Slack user does not exist. Message will not be sent", email, subject);
} else {
var userId = getUserByEmail(email).getId();
if (userId == null) {
throw new NotFoundException("Couldn't find slack user for email" + email);
}
sendMessageToUserId(userId, subject, blocks);
}
} catch (Exception e) {
log.error("Failed to send message to {} {}", email, JsonUtils.toJson(blocks), e);
}
}
public void sendMessageToUserId(String userId, String subject, List<Block> blocks) {
try {
var channel = openConversation(userId);
if (getUserBySlackId(userId) == null) {
log.warn("Notification for user id {} with subject {} could not be sent. Slack user does not exist. Message will not be sent", userId, subject);
} else {
var userName = getUserBySlackId(userId).getName();
List<List<Block>> partitions = ListUtils.partition(splitLongBlocks(blocks), MAX_BLOCKS_PER_MESSAGE);
partitions.forEach(partition -> doSendMessageToChannel(channel, subject, partition, no.nav.data.team.contact.domain.Channel.SLACK_USER, userName));
}
} catch (Exception e) {
log.error("Failed to send message to {} {}", userId, JsonUtils.toJson(blocks), e);
}
}
public void sendMessageToChannel(String channel, String subject, List<Block> blocks) {
try {
if (getChannel(channel) == null) {
log.warn("Notification for channel id {} with subject {} could not be sent. Channel does not exist or might be archived. Message will not be sent", channel, subject);
} else {
var channelName = getChannel(channel).getName();
List<List<Block>> partitions = ListUtils.partition(splitLongBlocks(blocks), MAX_BLOCKS_PER_MESSAGE);
partitions.forEach(partition -> doSendMessageToChannel(channel, subject, partition, no.nav.data.team.contact.domain.Channel.SLACK, channelName));
}
} catch (Exception e) {
log.error("Failed to send message to {} {}", channel, JsonUtils.toJson(blocks), e);
}
}
private void doSendMessageToChannel(String channel, String subject, List<Block> blockKit, no.nav.data.team.contact.domain.Channel channelType, String channelName) {
try {
log.info("Sending slack message to {}", channel);
if (securityProperties.isDev()) {
blockKit.addFirst(Block.header("[DEV]"));
}
var request = new PostMessageRequest(channel, blockKit);
var response = restTemplate.postForEntity(POST_MESSAGE, request, PostMessageResponse.class);
checkResponse(response);
storage.save(MailLog.builder().to(channel + " - " + channelName).subject(subject).body(JsonUtils.toJson(blockKit)).channel(channelType).build());
} catch (Exception e) {
log.error("Failed to send message to {} {}", channel, JsonUtils.toJson(blockKit), e);
}
}
private String doOpenConversation(String userId) {
try {
var response = restTemplate.postForEntity(OPEN_CONVERSATION, new CreateConversationRequest(userId), CreateConversationResponse.class);
CreateConversationResponse create = checkResponse(response);
return create.getChannel().getId();
} catch (Exception e) {
throw new TechnicalException("Failed to get channel for " + userId, e);
}
}
private <T extends Response> T checkResponse(ResponseEntity<T> response) {
Assert.notNull(response.getBody(), "empty body");
Assert.isTrue(response.getBody().isOk(), "Not ok error: " + response.getBody().getError());
return (T) response.getBody();
}
private List<Block> splitLongBlocks(List<Block> blocks) {
var newBlocks = new ArrayList<Block>();
for (Block block : blocks) {
if (block.getText() == null || block.getText().getText().length() <= MAX_CHARS_PER_BLOCK) {
newBlocks.add(block);
} else {
var text = block.getText().getText();
var lines = StringUtils.splitPreserveAllTokens(text, StringUtils.LF);
var sb = new StringBuilder(StringUtils.LF);
for (String line : lines) {
if (sb.length() + line.length() >= MAX_CHARS_PER_BLOCK) {
newBlocks.add(block.withText(sb.toString()));
sb = new StringBuilder(StringUtils.LF);
}
sb.append(line).append(StringUtils.LF);
}
newBlocks.add(block.withText(sb.toString()));
}
}
return newBlocks;
}
}