diff --git a/.github/workflows/gradle-build-production.yml b/.github/workflows/gradle-build-production.yml index 02e3b4236..a6a3002f0 100644 --- a/.github/workflows/gradle-build-production.yml +++ b/.github/workflows/gradle-build-production.yml @@ -89,6 +89,7 @@ jobs: --set-env-vars "SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }}" \ --set-env-vars "SLACK_PULSE_SIGNING_SECRET=${{ secrets.SLACK_PULSE_SIGNING_SECRET }}" \ --set-env-vars "SLACK_PULSE_BOT_TOKEN=${{ secrets.SLACK_PULSE_BOT_TOKEN }}" \ + --set-env-vars "SLACK_KUDOS_CHANNEL_ID=${{ secrets.SLACK_KUDOS_CHANNEL_ID }}" \ --platform "managed" \ --max-instances 8 \ --allow-unauthenticated diff --git a/.github/workflows/gradle-deploy-develop.yml b/.github/workflows/gradle-deploy-develop.yml index 1994e1886..a30f87d57 100644 --- a/.github/workflows/gradle-deploy-develop.yml +++ b/.github/workflows/gradle-deploy-develop.yml @@ -113,6 +113,7 @@ jobs: --set-env-vars "SLACK_WEBHOOK_URL=${{ secrets.SLACK_WEBHOOK_URL }}" \ --set-env-vars "SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }}" \ --set-env-vars "SLACK_SIGNING_SECRET=${{ secrets.SLACK_PULSE_SIGNING_SECRET }}" \ + --set-env-vars "SLACK_KUDOS_CHANNEL_ID=${{ secrets.SLACK_KUDOS_CHANNEL_ID }}" \ --platform "managed" \ --max-instances 2 \ --allow-unauthenticated diff --git a/.github/workflows/gradle-deploy-native-develop.yml b/.github/workflows/gradle-deploy-native-develop.yml index 802d2431b..abddf7dc8 100644 --- a/.github/workflows/gradle-deploy-native-develop.yml +++ b/.github/workflows/gradle-deploy-native-develop.yml @@ -113,6 +113,7 @@ jobs: --set-env-vars "SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }}" \ --set-env-vars "SLACK_PULSE_SIGNING_SECRET=${{ secrets.SLACK_PULSE_SIGNING_SECRET }}" \ --set-env-vars "SLACK_PULSE_BOT_TOKEN=${{ secrets.SLACK_PULSE_BOT_TOKEN }}" \ + --set-env-vars "SLACK_KUDOS_CHANNEL_ID=${{ secrets.SLACK_KUDOS_CHANNEL_ID }}" \ --platform "managed" \ --max-instances 2 \ --allow-unauthenticated diff --git a/server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java b/server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java index d29dedbe8..3e8327af4 100644 --- a/server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java +++ b/server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java @@ -82,6 +82,9 @@ public static class SlackConfig { @NotBlank private String signingSecret; + + @NotBlank + private String kudosChannel; } } } diff --git a/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackSender.java b/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackSender.java new file mode 100644 index 000000000..1555638f2 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackSender.java @@ -0,0 +1,124 @@ +package com.objectcomputing.checkins.notifications.social_media; + +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; + +import com.slack.api.Slack; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.request.chat.ChatPostMessageRequest; +import com.slack.api.methods.response.chat.ChatPostMessageResponse; +import com.slack.api.methods.request.chat.ChatDeleteRequest; +import com.slack.api.methods.response.chat.ChatDeleteResponse; +import com.slack.api.methods.request.conversations.ConversationsOpenRequest; +import com.slack.api.methods.response.conversations.ConversationsOpenResponse; + +import jakarta.inject.Singleton; +import jakarta.inject.Inject; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +@Singleton +public class SlackSender { + private static final Logger LOG = LoggerFactory.getLogger(SlackSender.class); + + @Inject + private CheckInsConfiguration configuration; + + public boolean send(List userIds, String slackBlocks) { + // See if we have a token. + String token = configuration.getApplication() + .getSlack().getBotToken(); + if (token != null && !slackBlocks.isEmpty()) { + MethodsClient client = Slack.getInstance().methods(token); + + try { + ConversationsOpenResponse openResponse = + client.conversationsOpen(ConversationsOpenRequest.builder() + .users(userIds) + .returnIm(true) + .build()); + if (!openResponse.isOk()) { + LOG.error("Unable to open the conversation"); + return false; + } + + return send(openResponse.getChannel().getId(), slackBlocks); + } catch(Exception ex) { + LOG.error("SlackSender.send: " + ex.toString()); + return false; + } + } else { + LOG.error("Missing token or missing slack blocks"); + return false; + } + } + + public boolean send(String channelId, String slackBlocks) { + // See if we have a token. + String token = configuration.getApplication() + .getSlack().getBotToken(); + if (token != null && !slackBlocks.isEmpty()) { + MethodsClient client = Slack.getInstance().methods(token); + + try { + ChatPostMessageRequest request = ChatPostMessageRequest + .builder() + .channel(channelId) + .blocksAsString(slackBlocks) + .build(); + + // Send it to Slack + ChatPostMessageResponse response = client.chatPostMessage(request); + + if (!response.isOk()) { + LOG.error("Unable to send the chat message: " + + response.getError()); + } + + return response.isOk(); + } catch(Exception ex) { + LOG.error("SlackSender.send: " + ex.toString()); + return false; + } + } else { + LOG.error("Missing token or missing slack blocks"); + return false; + } + } + + public boolean delete(String channel, String ts) { + // See if we have a token. + String token = configuration.getApplication() + .getSlack().getBotToken(); + if (token != null) { + MethodsClient client = Slack.getInstance().methods(token); + + try { + ChatDeleteRequest request = ChatDeleteRequest + .builder() + .channel(channel) + .ts(ts) + .build(); + + // Send it to Slack + ChatDeleteResponse response = client.chatDelete(request); + + if (!response.isOk()) { + LOG.error("Unable to delete the chat message: " + + response.getError()); + } + + return response.isOk(); + } catch(Exception ex) { + LOG.error("SlackSender.delete: " + ex.toString()); + return false; + } + } else { + LOG.error("Missing token or missing slack blocks"); + return false; + } + } +} + diff --git a/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosConverter.java b/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosConverter.java index 3587243bb..8e4f969b9 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosConverter.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosConverter.java @@ -1,6 +1,6 @@ package com.objectcomputing.checkins.services.kudos; -import com.objectcomputing.checkins.notifications.social_media.SlackSearch; +import com.objectcomputing.checkins.services.slack.SlackSearch; import com.objectcomputing.checkins.services.kudos.kudos_recipient.KudosRecipientServices; import com.objectcomputing.checkins.services.kudos.kudos_recipient.KudosRecipient; import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; @@ -22,10 +22,6 @@ @Singleton public class KudosConverter { - private record InternalBlock( - List blocks - ) {} - private final MemberProfileServices memberProfileServices; private final KudosRecipientServices kudosRecipientServices; private final SlackSearch slackSearch; @@ -61,9 +57,8 @@ public String toSlackBlock(Kudos kudos) { .elements(content).build(); RichTextBlock richTextBlock = RichTextBlock.builder() .elements(List.of(element)).build(); - InternalBlock block = new InternalBlock(List.of(richTextBlock)); Gson mapper = GsonFactory.createSnakeCase(); - return mapper.toJson(block); + return mapper.toJson(List.of(richTextBlock)); } private RichTextSectionElement.TextStyle boldItalic() { diff --git a/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosServices.java b/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosServices.java index 9f736055c..e4695ece3 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosServices.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosServices.java @@ -13,6 +13,8 @@ public interface KudosServices { Kudos approve(Kudos kudos); + Kudos savePreapproved(KudosCreateDTO kudos); + List getRecent(); KudosResponseDTO getById(UUID id); diff --git a/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosServicesImpl.java b/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosServicesImpl.java index bf04593ec..1489a1bf9 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosServicesImpl.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosServicesImpl.java @@ -6,7 +6,9 @@ import com.objectcomputing.checkins.configuration.CheckInsConfiguration; import com.objectcomputing.checkins.notifications.email.EmailSender; import com.objectcomputing.checkins.notifications.email.MailJetFactory; -import com.objectcomputing.checkins.notifications.social_media.SlackPoster; +import com.objectcomputing.checkins.notifications.social_media.SlackSender; +import com.objectcomputing.checkins.services.slack.SlackReader; +import com.objectcomputing.checkins.services.slack.kudos.BotSentKudosLocator; import com.objectcomputing.checkins.exceptions.BadArgException; import com.objectcomputing.checkins.exceptions.NotFoundException; import com.objectcomputing.checkins.exceptions.PermissionException; @@ -23,13 +25,15 @@ import com.objectcomputing.checkins.services.team.Team; import com.objectcomputing.checkins.services.team.TeamRepository; import com.objectcomputing.checkins.util.Util; +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; + import io.micronaut.core.annotation.Nullable; import io.micronaut.transaction.annotation.Transactional; -import io.micronaut.http.HttpResponse; -import io.micronaut.http.HttpStatus; import jakarta.inject.Named; import jakarta.inject.Singleton; +import jakarta.inject.Inject; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -58,13 +62,17 @@ class KudosServicesImpl implements KudosServices { private final CheckInsConfiguration checkInsConfiguration; private final RoleServices roleServices; private final MemberProfileServices memberProfileServices; - private final SlackPoster slackPoster; + private final SlackSender slackSender; private final KudosConverter converter; + private final BotSentKudosLocator botSentKudosLocator; private enum NotificationType { creation, approval } + @Inject + private CheckInsConfiguration configuration; + KudosServicesImpl(KudosRepository kudosRepository, KudosRecipientServices kudosRecipientServices, KudosRecipientRepository kudosRecipientRepository, @@ -75,8 +83,9 @@ private enum NotificationType { MemberProfileServices memberProfileServices, @Named(MailJetFactory.HTML_FORMAT) EmailSender emailSender, CheckInsConfiguration checkInsConfiguration, - SlackPoster slackPoster, - KudosConverter converter + SlackSender slackSender, + KudosConverter converter, + BotSentKudosLocator botSentKudosLocator ) { this.kudosRepository = kudosRepository; this.kudosRecipientServices = kudosRecipientServices; @@ -88,39 +97,16 @@ private enum NotificationType { this.currentUserServices = currentUserServices; this.emailSender = emailSender; this.checkInsConfiguration = checkInsConfiguration; - this.slackPoster = slackPoster; + this.slackSender = slackSender; this.converter = converter; + this.botSentKudosLocator = botSentKudosLocator; } @Override @Transactional @RequiredPermission(Permission.CAN_CREATE_KUDOS) public Kudos save(KudosCreateDTO kudosDTO) { - UUID senderId = kudosDTO.getSenderId(); - if (memberProfileRetrievalServices.getById(senderId).isEmpty()) { - throw new BadArgException("Kudos sender %s does not exist".formatted(senderId)); - } - - if (kudosDTO.getTeamId() != null) { - UUID teamId = kudosDTO.getTeamId(); - if (teamRepository.findById(teamId).isEmpty()) { - throw new BadArgException("Team %s does not exist".formatted(teamId)); - } - } - - if (kudosDTO.getRecipientMembers() == null || kudosDTO.getRecipientMembers().isEmpty()) { - throw new BadArgException("Kudos must contain at least one recipient"); - } - - Kudos kudos = new Kudos(kudosDTO); - - Kudos savedKudos = kudosRepository.save(kudos); - - for (MemberProfile recipient : kudosDTO.getRecipientMembers()) { - KudosRecipient kudosRecipient = new KudosRecipient(savedKudos.getId(), recipient.getId()); - kudosRecipientServices.save(kudosRecipient); - } - + Kudos savedKudos = saveCommon(kudosDTO, true); sendNotification(savedKudos, NotificationType.creation); return savedKudos; } @@ -143,6 +129,13 @@ public Kudos approve(Kudos kudos) { return updated; } + @Override + public Kudos savePreapproved(KudosCreateDTO kudos) { + Kudos savedKudos = saveCommon(kudos, false); + savedKudos.setDateApproved(LocalDate.now()); + return kudosRepository.update(savedKudos); + } + @Override public Kudos update(KudosUpdateDTO kudos) { // Find the corresponding kudos and make sure we have permission. @@ -169,9 +162,9 @@ public Kudos update(KudosUpdateDTO kudos) { boolean existingPublic = existingKudos.getPubliclyVisible(); boolean proposedPublic = kudos.getPubliclyVisible(); + boolean removePublicSlack = false; if (existingPublic && !proposedPublic) { - // TODO: Search for and remove the Slack Kudos that the Check-Ins - // Integration posted. + removePublicSlack = true; existingKudos.setDateApproved(null); } else if ((!existingPublic && proposedPublic) || (proposedPublic && @@ -212,6 +205,12 @@ public Kudos update(KudosUpdateDTO kudos) { sendNotification(updated, NotificationType.creation); } + if (removePublicSlack) { + // Search for and remove the Slack Kudos that the Check-Ins + // Integration posted. + removeSlackMessage(existingKudos); + } + return updated; } @@ -262,6 +261,12 @@ public void delete(UUID id) { kudosRecipientRepository.deleteAll(recipients); kudosRepository.deleteById(id); + + if (kudos.getPubliclyVisible()) { + // Search for and remove the Slack Kudos that the Check-Ins + // Integration posted. + removeSlackMessage(kudos); + } } @Override @@ -458,17 +463,49 @@ private void sendNotification(Kudos kudos, NotificationType notificationType) { } private void slackApprovedKudos(Kudos kudos) { - HttpResponse httpResponse = - slackPoster.post(converter.toSlackBlock(kudos)); - if (httpResponse.status() != HttpStatus.OK) { - LOG.error("Unable to POST to Slack: " + httpResponse.reason()); - } + slackSender.send(configuration.getApplication() + .getSlack().getKudosChannel(), + converter.toSlackBlock(kudos)); } private boolean hasAdministerKudosPermission() { return currentUserServices.hasPermission(Permission.CAN_ADMINISTER_KUDOS); } + private Kudos saveCommon(KudosCreateDTO kudosDTO, boolean verifyAndNotify) { + UUID senderId = kudosDTO.getSenderId(); + if (memberProfileRetrievalServices.getById(senderId).isEmpty()) { + throw new BadArgException("Kudos sender %s does not exist".formatted(senderId)); + } + + if (kudosDTO.getTeamId() != null) { + UUID teamId = kudosDTO.getTeamId(); + if (teamRepository.findById(teamId).isEmpty()) { + throw new BadArgException("Team %s does not exist".formatted(teamId)); + } + } + + if (kudosDTO.getRecipientMembers() == null || kudosDTO.getRecipientMembers().isEmpty()) { + throw new BadArgException("Kudos must contain at least one recipient"); + } + + Kudos savedKudos = kudosRepository.save(new Kudos(kudosDTO)); + + for (MemberProfile recipient : kudosDTO.getRecipientMembers()) { + KudosRecipient kudosRecipient = new KudosRecipient(savedKudos.getId(), recipient.getId()); + if (verifyAndNotify) { + // Going through the service verifies the sender and recipient + // and sends email notification after saving. + kudosRecipientServices.save(kudosRecipient); + } else { + // This does none of that and just stores it in the database. + kudosRecipientRepository.save(kudosRecipient); + } + } + + return savedKudos; + } + private void updateRecipients(Kudos updated, List recipients, Set proposed) { @@ -490,4 +527,13 @@ private void updateRecipients(Kudos updated, } } } + + private void removeSlackMessage(Kudos kudos) { + String ts = botSentKudosLocator.find(kudos); + if (ts != null) { + slackSender.delete(configuration.getApplication() + .getSlack().getKudosChannel(), + ts); + } + } } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/currentuser/CurrentUserServicesImpl.java b/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/currentuser/CurrentUserServicesImpl.java index 84af6e3cb..75784e5a7 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/currentuser/CurrentUserServicesImpl.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/memberprofile/currentuser/CurrentUserServicesImpl.java @@ -57,7 +57,7 @@ public boolean hasRole(RoleType role) { @Override public boolean hasPermission(Permission permission) { - MemberProfile currentUser = getCurrentUser(); + MemberProfile currentUser = getCurrentUserImpl(); if (currentUser == null) { return false; } @@ -72,7 +72,7 @@ public boolean isAdmin() { return hasRole(RoleType.ADMIN); } - public MemberProfile getCurrentUser() { + private MemberProfile getCurrentUserImpl() { if (securityService != null) { Optional auth = securityService.getAuthentication(); if (auth.isPresent() && auth.get().getAttributes().get("email") != null) { @@ -80,8 +80,15 @@ public MemberProfile getCurrentUser() { return memberProfileRepo.findByWorkEmail(workEmail).orElse(null); } } + return null; + } - throw new NotFoundException("No active members in the system"); + public MemberProfile getCurrentUser() { + MemberProfile profile = getCurrentUserImpl(); + if (profile == null) { + throw new NotFoundException("No active members in the system"); + } + return profile; } private MemberProfile saveNewUser(String firstName, String lastName, String workEmail) { diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseController.java b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseController.java index 4fb5209de..e2ff1a1d9 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseController.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseController.java @@ -1,7 +1,7 @@ package com.objectcomputing.checkins.services.pulseresponse; import com.objectcomputing.checkins.exceptions.NotFoundException; -import com.objectcomputing.checkins.util.form.FormUrlEncodedDecoder; +import com.objectcomputing.checkins.services.slack.SlackSubmissionHandler; import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; import io.micronaut.http.MediaType; @@ -32,8 +32,6 @@ import java.time.LocalDate; import java.util.Set; import java.util.UUID; -import java.util.Map; -import java.nio.charset.StandardCharsets; @Controller("/services/pulse-responses") @ExecuteOn(TaskExecutors.BLOCKING) @@ -43,20 +41,14 @@ public class PulseResponseController { private final PulseResponseService pulseResponseServices; private final MemberProfileServices memberProfileServices; - private final SlackSignatureVerifier slackSignatureVerifier; - private final PulseSlackCommand pulseSlackCommand; - private final SlackPulseResponseConverter slackPulseResponseConverter; + private final SlackSubmissionHandler slackSubmissionHandler; public PulseResponseController(PulseResponseService pulseResponseServices, MemberProfileServices memberProfileServices, - SlackSignatureVerifier slackSignatureVerifier, - PulseSlackCommand pulseSlackCommand, - SlackPulseResponseConverter slackPulseResponseConverter) { + SlackSubmissionHandler slackSubmissionHandler) { this.pulseResponseServices = pulseResponseServices; this.memberProfileServices = memberProfileServices; - this.slackSignatureVerifier = slackSignatureVerifier; - this.pulseSlackCommand = pulseSlackCommand; - this.slackPulseResponseConverter = slackPulseResponseConverter; + this.slackSubmissionHandler = slackSubmissionHandler; } /** @@ -125,25 +117,8 @@ public HttpResponse commandPulseResponse( @Header("X-Slack-Signature") String signature, @Header("X-Slack-Request-Timestamp") String timestamp, @Body String requestBody) { - // Validate the request - if (slackSignatureVerifier.verifyRequest(signature, - timestamp, requestBody)) { - // Convert the request body to a map of values. - FormUrlEncodedDecoder formUrlEncodedDecoder = new FormUrlEncodedDecoder(); - Map body = - formUrlEncodedDecoder.decode(requestBody, - StandardCharsets.UTF_8); - - // Respond to the slack command. - String triggerId = (String)body.get("trigger_id"); - if (pulseSlackCommand.send(triggerId)) { - return HttpResponse.ok(); - } else { - return HttpResponse.status(HttpStatus.INTERNAL_SERVER_ERROR); - } - } else { - return HttpResponse.unauthorized(); - } + return slackSubmissionHandler.commandResponse(signature, + timestamp, requestBody); } @Secured(SecurityRule.IS_ANONYMOUS) @@ -153,56 +128,7 @@ public HttpResponse externalPulseResponse( @Header("X-Slack-Request-Timestamp") String timestamp, @Body String requestBody, HttpRequest request) { - // DEBUG Only - LOG.info(requestBody); - - // Validate the request - if (slackSignatureVerifier.verifyRequest(signature, - timestamp, requestBody)) { - // Convert the request body to a map of values. - FormUrlEncodedDecoder formUrlEncodedDecoder = - new FormUrlEncodedDecoder(); - Map body = - formUrlEncodedDecoder.decode(requestBody, - StandardCharsets.UTF_8); - - final String key = "payload"; - if (body.containsKey(key)) { - PulseResponseCreateDTO pulseResponseDTO = - slackPulseResponseConverter.get(memberProfileServices, - (String)body.get(key)); - - // If we receive a null DTO, that means that this is not the - // actual submission of the form. We can just return 200 so - // that Slack knows to continue without error. - if (pulseResponseDTO == null) { - return HttpResponse.ok(); - } - - // Create the pulse response - PulseResponse pulseResponse = - pulseResponseServices.unsecureSave( - new PulseResponse( - pulseResponseDTO.getInternalScore(), - pulseResponseDTO.getExternalScore(), - pulseResponseDTO.getSubmissionDate(), - pulseResponseDTO.getTeamMemberId(), - pulseResponseDTO.getInternalFeelings(), - pulseResponseDTO.getExternalFeelings() - ) - ); - - if (pulseResponse == null) { - return HttpResponse.status(HttpStatus.CONFLICT, - "Already submitted today"); - } else { - return HttpResponse.ok(); - } - } else { - return HttpResponse.unprocessableEntity(); - } - } else { - return HttpResponse.unauthorized(); - } + return slackSubmissionHandler.externalResponse(signature, timestamp, + requestBody, request); } } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImpl.java b/server/src/main/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImpl.java index baba523e3..70f76665a 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImpl.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImpl.java @@ -5,6 +5,8 @@ import com.objectcomputing.checkins.services.feedback_request.FeedbackRequestServicesImpl; import com.objectcomputing.checkins.services.reviews.ReviewPeriodServices; import com.objectcomputing.checkins.services.pulse.PulseServices; +import com.objectcomputing.checkins.services.slack.kudos.KudosChannelReader; + import jakarta.inject.Singleton; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,15 +22,18 @@ public class CheckServicesImpl implements CheckServices { private final FeedbackRequestRepository feedbackRequestRepository; private final PulseServices pulseServices; private final ReviewPeriodServices reviewPeriodServices; + private final KudosChannelReader kudosChannelReader; public CheckServicesImpl(FeedbackRequestServicesImpl feedbackRequestServices, FeedbackRequestRepository feedbackRequestRepository, PulseServices pulseServices, - ReviewPeriodServices reviewPeriodServices) { + ReviewPeriodServices reviewPeriodServices, + KudosChannelReader kudosChannelReader) { this.feedbackRequestServices = feedbackRequestServices; this.feedbackRequestRepository = feedbackRequestRepository; this.pulseServices = pulseServices; this.reviewPeriodServices = reviewPeriodServices; + this.kudosChannelReader = kudosChannelReader; } @Override @@ -43,6 +48,7 @@ public boolean sendScheduledEmails() { } pulseServices.notifyUsers(today); reviewPeriodServices.sendNotifications(today); + kudosChannelReader.readChannel(); return true; } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackReader.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackReader.java new file mode 100644 index 000000000..a880ed9af --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackReader.java @@ -0,0 +1,66 @@ +package com.objectcomputing.checkins.services.slack; + +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; + +import com.slack.api.Slack; +import com.slack.api.model.Message; +import com.slack.api.model.Conversation; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.request.conversations.ConversationsHistoryRequest; +import com.slack.api.methods.response.conversations.ConversationsHistoryResponse; + +import jakarta.inject.Singleton; +import jakarta.inject.Inject; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.ArrayList; +import java.io.IOException; +import java.time.ZoneOffset; +import java.time.LocalDateTime; +import java.time.ZoneId; + +@Singleton +public class SlackReader { + private static final Logger LOG = LoggerFactory.getLogger(SlackReader.class); + + @Inject + private CheckInsConfiguration configuration; + + public List read(String channelId, LocalDateTime last) { + String token = configuration.getApplication().getSlack().getBotToken(); + if (token != null) { + try { + long ts = last.atZone(ZoneId.systemDefault()) + .toInstant().getEpochSecond(); + String timestamp = String.valueOf(ts); + MethodsClient client = Slack.getInstance().methods(token); + ConversationsHistoryResponse response = + client.conversationsHistory( + ConversationsHistoryRequest.builder() + .channel(channelId) + .oldest(timestamp) + .inclusive(true) + .build()); + + if (response.isOk()) { + return response.getMessages(); + } else { + LOG.error("Slack Response: " + response.getError() + + " - " + response.getNeeded()); + } + } catch(IOException e) { + LOG.error("SlackReader.read: " + e.toString()); + } catch(SlackApiException e) { + LOG.error("SlackReader.read: " + e.toString()); + } + } else { + LOG.error("Slack Token not available"); + } + return new ArrayList(); + } +} + diff --git a/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackSearch.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackSearch.java similarity index 78% rename from server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackSearch.java rename to server/src/main/java/com/objectcomputing/checkins/services/slack/SlackSearch.java index e7fbb6fed..c0af370cb 100644 --- a/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackSearch.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackSearch.java @@ -1,4 +1,4 @@ -package com.objectcomputing.checkins.notifications.social_media; +package com.objectcomputing.checkins.services.slack; import com.objectcomputing.checkins.configuration.CheckInsConfiguration; import com.slack.api.model.block.LayoutBlock; @@ -58,6 +58,31 @@ public String findChannelId(String channelName) { return null; } + public String findChannelName(String channelId) { + String token = configuration.getApplication().getSlack().getBotToken(); + if (token != null) { + try { + MethodsClient client = Slack.getInstance().methods(token); + ConversationsListResponse response = client.conversationsList( + ConversationsListRequest.builder().build() + ); + + if (response.isOk()) { + for (Conversation conversation: response.getChannels()) { + if (conversation.getId().equals(channelId)) { + return conversation.getName(); + } + } + } + } catch(IOException e) { + LOG.error("SlackSearch.findChannelName: " + e.toString()); + } catch(SlackApiException e) { + LOG.error("SlackSearch.findChannelName: " + e.toString()); + } + } + return null; + } + public String findUserId(String userEmail) { String token = configuration.getApplication().getSlack().getBotToken(); if (token != null) { diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackSignatureVerifier.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackSignature.java similarity index 63% rename from server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackSignatureVerifier.java rename to server/src/main/java/com/objectcomputing/checkins/services/slack/SlackSignature.java index 2d95c33bd..22f36c202 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackSignatureVerifier.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackSignature.java @@ -1,5 +1,6 @@ -package com.objectcomputing.checkins.services.pulseresponse; +package com.objectcomputing.checkins.services.slack; +import com.objectcomputing.checkins.exceptions.BadArgException; import com.objectcomputing.checkins.configuration.CheckInsConfiguration; import javax.crypto.Mac; @@ -12,11 +13,11 @@ import jakarta.inject.Singleton; @Singleton -public class SlackSignatureVerifier { +public class SlackSignature { @Inject private CheckInsConfiguration configuration; - public SlackSignatureVerifier() {} + public SlackSignature() {} public boolean verifyRequest(String slackSignature, String timestamp, String requestBody) { try { @@ -35,7 +36,7 @@ public boolean verifyRequest(String slackSignature, String timestamp, String req // Compare the computed signature with Slack's signature return computedSignature.equals(slackSignature); - } catch (Exception e) { + } catch (Exception ex) { return false; } } @@ -64,4 +65,30 @@ private String hmacSha256(String secret, String message) throws Exception { } return hexString.toString(); } + + public String generate(String timestamp, String rawBody) { + String baseString = "v0:" + timestamp + ":" + rawBody; + String secret = configuration.getApplication() + .getSlack().getSigningSecret(); + + try { + // Generate HMAC SHA-256 signature + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKeySpec = + new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), + "HmacSHA256"); + mac.init(secretKeySpec); + byte[] hash = mac.doFinal( + baseString.getBytes(StandardCharsets.UTF_8)); + + // Convert hash to hex + StringBuilder hexString = new StringBuilder(); + for (byte b : hash) { + hexString.append(String.format("%02x", b)); + } + return "v0=" + hexString.toString(); + } catch (Exception ex) { + throw new BadArgException(ex.toString()); + } + } } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackSubmissionHandler.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackSubmissionHandler.java new file mode 100644 index 000000000..5c21ece4d --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/SlackSubmissionHandler.java @@ -0,0 +1,180 @@ +package com.objectcomputing.checkins.services.slack; + +import com.objectcomputing.checkins.services.slack.pulseresponse.PulseSlackCommand; +import com.objectcomputing.checkins.services.slack.pulseresponse.SlackPulseResponseConverter; +import com.objectcomputing.checkins.services.slack.kudos.SlackKudosResponseHandler; + +import com.objectcomputing.checkins.util.form.FormUrlEncodedDecoder; +import com.objectcomputing.checkins.services.pulseresponse.PulseResponse; +import com.objectcomputing.checkins.services.pulseresponse.PulseResponseService; +import com.objectcomputing.checkins.services.pulseresponse.PulseResponseCreateDTO; + +import io.micronaut.http.HttpStatus; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; + +import jakarta.inject.Singleton; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.core.JsonProcessingException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.nio.charset.StandardCharsets; + +@Singleton +public class SlackSubmissionHandler { + private static final Logger LOG = LoggerFactory.getLogger(SlackSubmissionHandler.class); + private static final String typeKey = "type"; + + private final PulseResponseService pulseResponseServices; + private final SlackSignature slackSignature; + private final PulseSlackCommand pulseSlackCommand; + private final SlackPulseResponseConverter slackPulseResponseConverter; + private final SlackKudosResponseHandler slackKudosResponseHandler; + + public SlackSubmissionHandler(PulseResponseService pulseResponseServices, + SlackSignature slackSignature, + PulseSlackCommand pulseSlackCommand, + SlackPulseResponseConverter slackPulseResponseConverter, + SlackKudosResponseHandler slackKudosResponseHandler) { + this.pulseResponseServices = pulseResponseServices; + this.slackSignature = slackSignature; + this.pulseSlackCommand = pulseSlackCommand; + this.slackPulseResponseConverter = slackPulseResponseConverter; + this.slackKudosResponseHandler = slackKudosResponseHandler; + } + + public HttpResponse commandResponse(String signature, + String timestamp, + String requestBody) { + // Validate the request + if (slackSignature.verifyRequest(signature, timestamp, requestBody)) { + // Convert the request body to a map of values. + FormUrlEncodedDecoder formUrlEncodedDecoder = new FormUrlEncodedDecoder(); + Map body = + formUrlEncodedDecoder.decode(requestBody, + StandardCharsets.UTF_8); + + // Respond to the slack command. + String triggerId = (String)body.get("trigger_id"); + if (pulseSlackCommand.send(triggerId)) { + return HttpResponse.ok(); + } else { + return HttpResponse.status(HttpStatus.INTERNAL_SERVER_ERROR); + } + } else { + return HttpResponse.unauthorized(); + } + } + + public HttpResponse externalResponse(String signature, + String timestamp, + String requestBody, + HttpRequest request) { + // Validate the request + if (slackSignature.verifyRequest(signature, timestamp, requestBody)) { + // Convert the request body to a map of values. + FormUrlEncodedDecoder formUrlEncodedDecoder = + new FormUrlEncodedDecoder(); + Map body = + formUrlEncodedDecoder.decode(requestBody, + StandardCharsets.UTF_8); + + final String key = "payload"; + if (body.containsKey(key)) { + try { + final ObjectMapper mapper = new ObjectMapper(); + final Map map = + mapper.readValue((String)body.get(key), + new TypeReference<>() {}); + if (isPulseSubmission(map)) { + return completePulse(map); + } else if (isKudosSubmission(map)) { + return completeKudos(map); + } + } catch(JsonProcessingException ex) { + // Fall through to the bottom... + LOG.error("externalResponse: " + ex.toString()); + } + } + } else { + return HttpResponse.unauthorized(); + } + + return HttpResponse.unprocessableEntity(); + } + + private boolean isPulseSubmission(Map map) { + if (map.containsKey(typeKey)) { + final String type = (String)map.get(typeKey); + if (type.equals("view_submission")) { + final String viewKey = "view"; + if (map.containsKey(viewKey)) { + final Map view = + (Map)map.get(viewKey); + final String callbackKey = "callback_id"; + if (view.containsKey(callbackKey)) { + return "pulseSubmission".equals(view.get(callbackKey)); + } + } + } + } + return false; + } + + private HttpResponse completePulse(Map map) { + PulseResponseCreateDTO pulseResponseDTO = + slackPulseResponseConverter.get(map); + + // If we receive a null DTO, that means that this is not the actual + // submission of the form. We can just return 200 so that Slack knows + // to continue without error. Realy, this should not happen. But, just + // in case... + if (pulseResponseDTO != null) { + // Create the pulse response + PulseResponse pulseResponse = + pulseResponseServices.unsecureSave( + new PulseResponse( + pulseResponseDTO.getInternalScore(), + pulseResponseDTO.getExternalScore(), + pulseResponseDTO.getSubmissionDate(), + pulseResponseDTO.getTeamMemberId(), + pulseResponseDTO.getInternalFeelings(), + pulseResponseDTO.getExternalFeelings() + ) + ); + + if (pulseResponse == null) { + // If pulse response is null, that means that this user has + // already submitted a response today. + return HttpResponse.status(HttpStatus.CONFLICT, + "Already submitted today"); + } + } + return HttpResponse.ok(); + } + + private boolean isKudosSubmission(Map map) { + if (map.containsKey(typeKey)) { + final String type = (String)map.get(typeKey); + if (type.equals("block_actions")) { + final String actionKey = "actions"; + return map.containsKey(actionKey); + } + } + return false; + } + + private HttpResponse completeKudos(Map map) { + if (slackKudosResponseHandler.handle(map)) { + return HttpResponse.ok(); + } else { + // Something was wrong and we were not able to handle this. + return HttpResponse.unprocessableEntity(); + } + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/AutomatedKudos.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/AutomatedKudos.java new file mode 100644 index 000000000..e08dfc941 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/AutomatedKudos.java @@ -0,0 +1,78 @@ +package com.objectcomputing.checkins.services.slack.kudos; + +import io.micronaut.core.annotation.Introspected; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.data.annotation.AutoPopulated; +import io.micronaut.data.annotation.TypeDef; +import io.micronaut.data.annotation.sql.ColumnTransformer; +import io.micronaut.data.model.DataType; +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Entity +@Getter +@Setter +@Introspected +@Table(name = "automated_kudos") +public class AutomatedKudos { + @Id + @Column(name = "id") + @AutoPopulated + @TypeDef(type = DataType.STRING) + @Schema(description = "the id of the kudos") + private UUID id; + + @Column(name = "requested") + @NotNull + @Schema(description = "Has permission been requested of the poster") + private Boolean requested; + + @NotBlank + @Column(name = "message") + @ColumnTransformer(read = "pgp_sym_decrypt(message::bytea, '${aes.key}')", write = "pgp_sym_encrypt(?, '${aes.key}')") + @Schema(description = "message describing the kudos") + private String message; + + @NotBlank + @Column(name = "externalid") + @ColumnTransformer(read = "pgp_sym_decrypt(externalid::bytea, '${aes.key}')", write = "pgp_sym_encrypt(?, '${aes.key}')") + @Schema(description = "the external id of the sender") + private String externalId; + + @NotNull + @Column(name = "senderid") + @TypeDef(type = DataType.STRING) + @Schema(description = "id of the user who gave the kudos") + private UUID senderId; + + @Column(name = "recipientids") + @TypeDef(type = DataType.STRING_ARRAY) + @Schema(description = "UUIDs of the recipients") + private List recipientIds; + + // This is necessary for Micronaut to persist instances of this class. + AutomatedKudos() {} + + AutomatedKudos(AutomatedKudosDTO automatedKudosDTO) { + this.requested = false; + this.message = automatedKudosDTO.getMessage(); + this.externalId = automatedKudosDTO.getExternalId(); + this.senderId = automatedKudosDTO.getSenderId(); + this.recipientIds = automatedKudosDTO.getRecipientIds() + .stream() + .map(UUID::toString) + .collect(Collectors.toList()); + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/AutomatedKudosDTO.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/AutomatedKudosDTO.java new file mode 100644 index 000000000..d8b80d068 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/AutomatedKudosDTO.java @@ -0,0 +1,31 @@ +package com.objectcomputing.checkins.services.slack.kudos; + +import io.micronaut.core.annotation.Introspected; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; +import java.util.UUID; + +@Getter +@Setter +@AllArgsConstructor +@Introspected +public class AutomatedKudosDTO { + + @NotBlank + private String message; + + @NotNull + private String externalId; + + @NotNull + private UUID senderId; + + @NotNull + private List recipientIds; +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/AutomatedKudosRepository.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/AutomatedKudosRepository.java new file mode 100644 index 000000000..224e0e936 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/AutomatedKudosRepository.java @@ -0,0 +1,22 @@ +package com.objectcomputing.checkins.services.slack.kudos; + +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.repository.CrudRepository; +import io.micronaut.data.annotation.Query; + +import java.util.List; +import java.util.UUID; + +@JdbcRepository(dialect = Dialect.POSTGRES) +public interface AutomatedKudosRepository extends CrudRepository { + @Query(value = """ + SELECT + id, requested, + PGP_SYM_DECRYPT(cast(message as bytea), '${aes.key}') as message, + PGP_SYM_DECRYPT(cast(externalid as bytea), '${aes.key}') as externalid, + senderid, recipientids + FROM automated_kudos + WHERE requested IS FALSE""", nativeQuery = true) + List getUnrequested(); +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/BotSentKudosLocator.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/BotSentKudosLocator.java new file mode 100644 index 000000000..0df297df9 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/BotSentKudosLocator.java @@ -0,0 +1,55 @@ +package com.objectcomputing.checkins.services.slack.kudos; + +import com.objectcomputing.checkins.services.kudos.Kudos; +import com.objectcomputing.checkins.services.slack.SlackReader; +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; + +import com.slack.api.model.Message; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.inject.Singleton; +import jakarta.inject.Inject; + +import java.util.List; +import java.time.LocalDateTime; + +@Singleton +public class BotSentKudosLocator { + private static final Logger LOG = LoggerFactory.getLogger(BotSentKudosLocator.class); + + @Inject + private CheckInsConfiguration configuration; + + @Inject + private SlackReader slackReader; + + // The identifiers needed to identify a message is the channel id and the + // time stamp. We are always looking at a specific channel. So if we find + // a message, we will return the timestamp as a string. Otherwise, we will + // return null. + public String find(Kudos kudos) { + String channelId = configuration.getApplication() + .getSlack().getKudosChannel(); + List messages = + slackReader.read(channelId, kudos.getDateCreated().atStartOfDay()); + + String kudosText = kudos.getMessage().trim(); + for (Message message : messages) { + // We only care about messages sent by our bot. + if (message.getBotId() != null) { + // The first line is the "kudos from" line and is not part of + // the kudos message. + int cut = message.getText().indexOf("\n"); + if (cut >= 0) { + String actual = message.getText().substring(cut + 1).trim(); + if (actual.equals(kudosText)) { + return message.getTs(); + } + } + } + } + return null; + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/KudosChannelReadTime.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/KudosChannelReadTime.java new file mode 100644 index 000000000..33307243b --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/KudosChannelReadTime.java @@ -0,0 +1,42 @@ +package com.objectcomputing.checkins.services.slack.kudos; + +import io.micronaut.core.annotation.Introspected; +import io.micronaut.data.annotation.TypeDef; +import io.micronaut.data.model.DataType; +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@Introspected +@Table(name = "automated_kudos_read_time") +public class KudosChannelReadTime { + static final public String key = "Singleton"; + + @Id + @Column(name = "id") + @Schema(description = "the id of the kudos channel read time") + private String id; + + @NotNull + @Column(name = "readtime") + @TypeDef(type = DataType.TIMESTAMP) + @Schema(description = "date the kudos were created") + private LocalDateTime readTime; + + public KudosChannelReadTime() { + id = key; + readTime = LocalDateTime.now(); + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/KudosChannelReadTimeStore.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/KudosChannelReadTimeStore.java new file mode 100644 index 000000000..e2adbec19 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/KudosChannelReadTimeStore.java @@ -0,0 +1,9 @@ +package com.objectcomputing.checkins.services.slack.kudos; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; + +@JdbcRepository(dialect = Dialect.POSTGRES) +public interface KudosChannelReadTimeStore extends CrudRepository { +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/KudosChannelReader.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/KudosChannelReader.java new file mode 100644 index 000000000..faa62ebfb --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/KudosChannelReader.java @@ -0,0 +1,56 @@ +package com.objectcomputing.checkins.services.slack.kudos; + +import com.objectcomputing.checkins.services.slack.SlackReader; +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; + +import com.slack.api.model.Message; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.inject.Singleton; +import jakarta.inject.Inject; + +import java.util.List; +import java.util.Optional; +import java.time.LocalDateTime; + +@Singleton +public class KudosChannelReader { + private static final Logger LOG = LoggerFactory.getLogger(KudosChannelReader.class); + + @Inject + private KudosChannelReadTimeStore kudosChannelReadTimeStore; + + @Inject + private CheckInsConfiguration configuration; + + @Inject + private SlackReader slackReader; + + @Inject + private SlackKudosCreator slackKudosCreator; + + public void readChannel() { + Optional readTime = + kudosChannelReadTimeStore.findById(KudosChannelReadTime.key); + boolean present = readTime.isPresent(); + LocalDateTime lastImport = present ? readTime.get().getReadTime() + : LocalDateTime.now(); + + String channelId = configuration.getApplication() + .getSlack().getKudosChannel(); + LOG.info("Reading messages from " + channelId + + " as of " + lastImport.toString()); + List messages = slackReader.read(channelId, lastImport); + if (present) { + kudosChannelReadTimeStore.update(new KudosChannelReadTime()); + } else { + kudosChannelReadTimeStore.save(new KudosChannelReadTime()); + } + + if (!messages.isEmpty()) { + slackKudosCreator.store(messages); + } + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/SlackKudosCreator.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/SlackKudosCreator.java new file mode 100644 index 000000000..254df1cad --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/SlackKudosCreator.java @@ -0,0 +1,170 @@ +package com.objectcomputing.checkins.services.slack.kudos; + +import com.objectcomputing.checkins.services.slack.SlackSearch; +import com.objectcomputing.checkins.notifications.social_media.SlackSender; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileUtils; +import com.objectcomputing.checkins.services.memberprofile.MemberProfile; +import com.objectcomputing.checkins.exceptions.NotFoundException; + +import org.apache.commons.lang3.StringEscapeUtils; + +import com.slack.api.model.Message; + +import io.micronaut.context.annotation.Value; +import io.micronaut.core.io.Readable; +import io.micronaut.core.io.IOUtils; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.inject.Singleton; +import jakarta.inject.Inject; + +import java.util.UUID; +import java.util.List; +import java.util.ArrayList; +import java.util.regex.Pattern; +import java.util.regex.Matcher; +import java.lang.StringBuffer; +import java.io.BufferedReader; + +@Singleton +public class SlackKudosCreator { + private static final Logger LOG = LoggerFactory.getLogger(SlackKudosCreator.class); + + @Inject + private SlackSearch slackSearch; + + @Inject + private SlackSender slackSender; + + @Inject + private AutomatedKudosRepository automatedKudosRepository; + + @Inject + private MemberProfileServices memberProfileServices; + + @Value("classpath:slack/kudos_slack_blocks.json") + private Readable kudosSlackBlocks; + + public void store(List messages) { + for (Message message : messages) { + // User messages do not have a sub-type. A bot user can send + // messages. They will not have a subtype, but they will have a bot + // id. We want to skip those too. + if (message.getSubtype() == null && message.getBotId() == null) { + try { + AutomatedKudosDTO kudosDTO = createFromMessage(message); + if (kudosDTO.getRecipientIds().size() == 0) { + LOG.warn("Unable to extract recipients from message"); + LOG.warn(message.getText()); + } else { + automatedKudosRepository.save( + new AutomatedKudos(kudosDTO)); + } + } catch (Exception ex) { + LOG.error("store: " + ex.toString()); + } + } else { + LOG.info("Skipping message: " + message.getText()); + } + } + + requestAction(); + } + + private AutomatedKudosDTO createFromMessage(Message message) { + String userId = message.getUser(); + MemberProfile sender = lookupUser(userId); + List recipients = new ArrayList<>(); + String text = processText(message.getText(), recipients); + return new AutomatedKudosDTO(text, userId, sender.getId(), recipients); + } + + private MemberProfile lookupUser(String userId) { + if (userId == null) { + throw new NotFoundException("User Id is not present"); + } + + String email = slackSearch.findUserEmail(userId); + if (email == null) { + throw new NotFoundException( + "Could not find an email address for " + userId); + } + return memberProfileServices.findByWorkEmail(email); + } + + private String processText(String text, List recipients) { + // First, process user references. + StringBuffer buffer = new StringBuffer(text.length()); + Pattern userRef = Pattern.compile("<@([^>]+)>"); + Matcher action = userRef.matcher(StringEscapeUtils.unescapeHtml4(text)); + while (action.find()) { + // Pull out the recipient user id, get the profile and add it to + // the list of recipients. + String userId = action.group(1); + MemberProfile profile = lookupUser(userId); + recipients.add(profile.getId()); + + // Replace the user reference with their full name. + action.appendReplacement(buffer, Matcher.quoteReplacement( + MemberProfileUtils.getFullName(profile))); + } + action.appendTail(buffer); + text = buffer.toString(); + + // Next, translate channel references to channel names. + Pattern channelRef = Pattern.compile("<#([^>]+)\\|>"); + buffer = new StringBuffer(text.length()); + action = channelRef.matcher(text); + while (action.find()) { + // Get the name of the channel. + String channelId = action.group(1); + String name = slackSearch.findChannelName(channelId); + if (name == null) { + name = "unknown_channel"; + } + name = "#" + name; + + // Replace the channel reference with the channel name. + action.appendReplacement(buffer, Matcher.quoteReplacement(name)); + } + action.appendTail(buffer); + return buffer.toString(); + } + + private void requestAction() { + for (AutomatedKudos kudos : automatedKudosRepository.getUnrequested()) { + try { + // Create the slack blocks, inserting the kudos UUID as the + // block id. + String blocks = getSlackBlocks(kudos.getId().toString(), + kudos.getMessage()); + + // Send the message to the sender of the kudos + List userIds = new ArrayList<>(); + userIds.add(kudos.getExternalId()); + if (slackSender.send(userIds, blocks)) { + // If the message was sent, set the requested flag and + // update the repository. + kudos.setRequested(true); + automatedKudosRepository.update(kudos); + } + } catch (Exception ex) { + LOG.error("requestAction: " + ex.toString()); + } + } + } + + private String getSlackBlocks(String kudosUUID, String contents) { + try { + return String.format(IOUtils.readText( + new BufferedReader(kudosSlackBlocks.asReader())), + kudosUUID, StringEscapeUtils.escapeJson(contents)); + } catch(Exception ex) { + LOG.error("SlackKudosCreator.getSlackBlocks: " + ex.toString()); + return ""; + } + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/SlackKudosResponseHandler.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/SlackKudosResponseHandler.java new file mode 100644 index 000000000..179a969f6 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/kudos/SlackKudosResponseHandler.java @@ -0,0 +1,84 @@ +package com.objectcomputing.checkins.services.slack.kudos; + +import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; +import com.objectcomputing.checkins.services.memberprofile.MemberProfile; +import com.objectcomputing.checkins.services.kudos.KudosCreateDTO; +import com.objectcomputing.checkins.services.kudos.KudosServices; + +import jakarta.inject.Singleton; +import jakarta.inject.Inject; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.UUID; +import java.util.List; +import java.util.ArrayList; +import java.util.Optional; + +@Singleton +public class SlackKudosResponseHandler { + private static final Logger LOG = LoggerFactory.getLogger(SlackKudosResponseHandler.class); + + @Inject + private KudosServices kudosServices; + + @Inject + private AutomatedKudosRepository automatedKudosRepository; + + @Inject + private MemberProfileServices memberProfileServices; + + public boolean handle(Map map) { + try { + // Get the blocks out of the message so that we can grab the + // automated kudos id. + Map message = + (Map)map.get("message"); + List blocks = (List)message.get("blocks"); + if (blocks.size() > 0) { + Map first = (Map)blocks.get(0); + String id = (String)first.get("block_id"); + UUID uuid = UUID.fromString(id); + + List actions = (List)map.get("actions"); + if (actions.size() > 0) { + Map entry = + (Map)actions.get(0); + String actionId = (String)entry.get("action_id"); + if (actionId.equals("yes_button")) { + store(uuid); + } else { + automatedKudosRepository.deleteById(uuid); + } + return true; + } + } + } catch (Exception ex) { + LOG.error("SlackKudosResponseHandler.handle: " + ex.toString()); + } + return false; + } + + private void store(UUID automatedKudosId) { + Optional found = + automatedKudosRepository.findById(automatedKudosId); + if (found.isPresent()) { + AutomatedKudos kudos = found.get(); + List recipients = new ArrayList<>(); + for (String recipientId : kudos.getRecipientIds()) { + recipients.add(memberProfileServices.getById( + UUID.fromString(recipientId))); + } + KudosCreateDTO dto = + new KudosCreateDTO(kudos.getMessage(), kudos.getSenderId(), + null, true, recipients); + kudosServices.savePreapproved(dto); + automatedKudosRepository.deleteById(automatedKudosId); + } else { + LOG.error("Unable to find automated kudos: " + + automatedKudosId.toString()); + } + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseSlackCommand.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/pulseresponse/PulseSlackCommand.java similarity index 90% rename from server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseSlackCommand.java rename to server/src/main/java/com/objectcomputing/checkins/services/slack/pulseresponse/PulseSlackCommand.java index 1c48eb30b..85f9d79fa 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseSlackCommand.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/pulseresponse/PulseSlackCommand.java @@ -1,4 +1,4 @@ -package com.objectcomputing.checkins.services.pulseresponse; +package com.objectcomputing.checkins.services.slack.pulseresponse; import com.objectcomputing.checkins.configuration.CheckInsConfiguration; @@ -54,7 +54,7 @@ public boolean send(String triggerId) { return response.isOk(); } catch(Exception ex) { - LOG.error(ex.toString()); + LOG.error("PulseSlackCommand.send: " + ex.toString()); return false; } } else { @@ -68,7 +68,7 @@ private String getSlackBlocks() { return IOUtils.readText( new BufferedReader(pulseSlackBlocks.asReader())); } catch(Exception ex) { - LOG.error(ex.toString()); + LOG.error("PulseSlackCommand.getSlackBlocks: " + ex.toString()); return ""; } } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackPulseResponseConverter.java b/server/src/main/java/com/objectcomputing/checkins/services/slack/pulseresponse/SlackPulseResponseConverter.java similarity index 79% rename from server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackPulseResponseConverter.java rename to server/src/main/java/com/objectcomputing/checkins/services/slack/pulseresponse/SlackPulseResponseConverter.java index 4e00287cb..3205b6c66 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackPulseResponseConverter.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/slack/pulseresponse/SlackPulseResponseConverter.java @@ -1,19 +1,16 @@ -package com.objectcomputing.checkins.services.pulseresponse; +package com.objectcomputing.checkins.services.slack.pulseresponse; import com.objectcomputing.checkins.exceptions.BadArgException; import com.objectcomputing.checkins.services.memberprofile.MemberProfile; import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; -import com.objectcomputing.checkins.notifications.social_media.SlackSearch; +import com.objectcomputing.checkins.services.slack.SlackSearch; +import com.objectcomputing.checkins.services.pulseresponse.PulseResponseCreateDTO; import jakarta.inject.Singleton; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.core.JsonProcessingException; - import java.util.Map; import java.util.UUID; import java.time.LocalDate; @@ -23,20 +20,17 @@ public class SlackPulseResponseConverter { private static final Logger LOG = LoggerFactory.getLogger(SlackPulseResponseConverter.class); private final SlackSearch slackSearch; + private final MemberProfileServices memberProfileServices; - public SlackPulseResponseConverter(SlackSearch slackSearch) { + public SlackPulseResponseConverter(SlackSearch slackSearch, + MemberProfileServices memberProfileServices) { this.slackSearch = slackSearch; + this.memberProfileServices = memberProfileServices; } - public PulseResponseCreateDTO get( - MemberProfileServices memberProfileServices, String body) { + public PulseResponseCreateDTO get(Map map) { try { - // Get the map of values from the string body - final ObjectMapper mapper = new ObjectMapper(); - final Map map = - mapper.readValue(body, new TypeReference<>() {}); final String type = (String)map.get("type"); - if (type.equals("view_submission")) { final Map view = (Map)map.get("view"); @@ -47,7 +41,7 @@ public PulseResponseCreateDTO get( // Create the pulse DTO and fill in the values. PulseResponseCreateDTO response = new PulseResponseCreateDTO(); - response.setTeamMemberId(lookupUser(memberProfileServices, map)); + response.setTeamMemberId(lookupUser(map)); response.setSubmissionDate(LocalDate.now()); // Internal Score @@ -78,11 +72,8 @@ public PulseResponseCreateDTO get( // response. return null; } - } catch(JsonProcessingException ex) { - LOG.error(ex.getMessage()); - throw new BadArgException(ex.getMessage()); } catch(NumberFormatException ex) { - LOG.error(ex.getMessage()); + LOG.error("SlackPulseResponseConverter.get: " + ex.getMessage()); throw new BadArgException("Pulse scores must be integers"); } } @@ -111,8 +102,7 @@ private String getMappedValue(Map map, String key1, } } - private UUID lookupUser(MemberProfileServices memberProfileServices, - Map map) { + private UUID lookupUser(Map map) { // Get the user's profile map. Map user = (Map)map.get("user"); diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 469d84a58..23ea93a92 100755 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -101,6 +101,7 @@ check-ins: webhook-url: ${ SLACK_WEBHOOK_URL } bot-token: ${ SLACK_BOT_TOKEN } signing-secret: ${ SLACK_SIGNING_SECRET } + kudos-channel: ${ SLACK_KUDOS_CHANNEL_ID } web-address: ${ WEB_ADDRESS } --- flyway: diff --git a/server/src/main/resources/db/common/V121__automated_kudos_table.sql b/server/src/main/resources/db/common/V121__automated_kudos_table.sql new file mode 100644 index 000000000..a29c362bb --- /dev/null +++ b/server/src/main/resources/db/common/V121__automated_kudos_table.sql @@ -0,0 +1,19 @@ +DROP TABLE IF EXISTS automated_kudos; + +CREATE TABLE automated_kudos +( + id varchar PRIMARY KEY, + requested boolean, + message varchar, + externalid varchar, + senderid varchar REFERENCES member_profile (id), + recipientids varchar[] +); + +DROP TABLE IF EXISTS automated_kudos_read_time; + +CREATE TABLE automated_kudos_read_time +( + id varchar PRIMARY KEY, + readtime timestamp +); diff --git a/server/src/main/resources/slack/kudos_slack_blocks.json b/server/src/main/resources/slack/kudos_slack_blocks.json new file mode 100644 index 000000000..19558fc32 --- /dev/null +++ b/server/src/main/resources/slack/kudos_slack_blocks.json @@ -0,0 +1,43 @@ +[ + { + "type": "section", + "block_id": "%s", + "text": { + "type": "plain_text", + "text": "Would you like to automatically add this Kudos to Check-Ins?" + } + }, + { + "type": "section", + "text": { + "type": "plain_text", + "text": "%s" + } + }, + { + "type": "divider" + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "action_id": "yes_button", + "text": { + "type": "plain_text", + "text": "Yes" + }, + "value": "yes" + }, + { + "type": "button", + "action_id": "no_button", + "text": { + "type": "plain_text", + "text": "No" + }, + "value": "no" + } + ] + } +] diff --git a/server/src/main/resources/slack/pulse_slack_blocks.json b/server/src/main/resources/slack/pulse_slack_blocks.json index ad0f7342a..6ef6b9d43 100644 --- a/server/src/main/resources/slack/pulse_slack_blocks.json +++ b/server/src/main/resources/slack/pulse_slack_blocks.json @@ -1,5 +1,6 @@ { "type": "modal", + "callback_id": "pulseSubmission", "title": { "type": "plain_text", "text": "Check-Ins Pulse", diff --git a/server/src/test/java/com/objectcomputing/checkins/services/SlackReaderReplacement.java b/server/src/test/java/com/objectcomputing/checkins/services/SlackReaderReplacement.java new file mode 100644 index 000000000..6ac2035d4 --- /dev/null +++ b/server/src/test/java/com/objectcomputing/checkins/services/SlackReaderReplacement.java @@ -0,0 +1,58 @@ +package com.objectcomputing.checkins.services; + +import com.objectcomputing.checkins.services.slack.SlackReader; +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; + +import io.micronaut.context.annotation.Replaces; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; + +import com.slack.api.model.Message; + +import jakarta.inject.Singleton; + +import java.util.List; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.time.LocalDateTime; +import java.time.ZoneId; + +@Singleton +@Replaces(SlackReader.class) +@Requires(property = "replace.slackreader", value = StringUtils.TRUE) +public class SlackReaderReplacement extends SlackReader { + public final Map> channelMessages = new HashMap<>(); + + @Override + public List read(String channelId, LocalDateTime last) { + List messages = new ArrayList<>(); + if (channelMessages.containsKey(channelId)) { + long ts = last.atZone(ZoneId.systemDefault()) + .toInstant().getEpochSecond(); + for (Message message : channelMessages.get(channelId)) { + long messageTime = Long.parseLong(message.getTs()); + if (messageTime >= ts) { + messages.add(message); + } + } + } + return messages; + } + + public void addMessage(String channelId, String userId, + String text, LocalDateTime sendTime) { + Message message = new Message(); + message.setTs(String.valueOf(sendTime.atZone(ZoneId.systemDefault()) + .toInstant().getEpochSecond())); + message.setText(text); + message.setUser(userId); + + if (!channelMessages.containsKey(channelId)) { + channelMessages.put(channelId, new ArrayList()); + } + channelMessages.get(channelId).add(message); + } +} diff --git a/server/src/test/java/com/objectcomputing/checkins/services/SlackSearchReplacement.java b/server/src/test/java/com/objectcomputing/checkins/services/SlackSearchReplacement.java index c239da699..db0d49347 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/SlackSearchReplacement.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/SlackSearchReplacement.java @@ -1,6 +1,6 @@ package com.objectcomputing.checkins.services; -import com.objectcomputing.checkins.notifications.social_media.SlackSearch; +import com.objectcomputing.checkins.services.slack.SlackSearch; import com.objectcomputing.checkins.configuration.CheckInsConfiguration; import io.micronaut.context.annotation.Replaces; @@ -25,10 +25,20 @@ public SlackSearchReplacement(CheckInsConfiguration checkInsConfiguration) { super(checkInsConfiguration); } + @Override + public String findChannelName(String channelId) { + return channels.containsKey(channelId) ? + channels.get(channelId) : null; + } + @Override public String findChannelId(String channelName) { - return channels.containsKey(channelName) ? - channels.get(channelName) : null; + for (Map.Entry entry : channels.entrySet()) { + if (entry.getValue().equals(channelName)) { + return entry.getKey(); + } + } + return null; } @Override diff --git a/server/src/test/java/com/objectcomputing/checkins/services/SlackSenderReplacement.java b/server/src/test/java/com/objectcomputing/checkins/services/SlackSenderReplacement.java new file mode 100644 index 000000000..84fba2984 --- /dev/null +++ b/server/src/test/java/com/objectcomputing/checkins/services/SlackSenderReplacement.java @@ -0,0 +1,46 @@ +package com.objectcomputing.checkins.services; + +import com.objectcomputing.checkins.notifications.social_media.SlackSender; + +import io.micronaut.context.annotation.Replaces; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.util.StringUtils; + +import jakarta.inject.Singleton; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.HashMap; + +@Singleton +@Replaces(SlackSender.class) +@Requires(property = "replace.slacksender", value = StringUtils.TRUE) +public class SlackSenderReplacement extends SlackSender { + public final Map> sent = new HashMap<>(); + + public void reset() { + sent.clear(); + } + + @Override + public boolean send(List userIds, String slackBlocks) { + for (String userId : userIds) { + if (!sent.containsKey(userId)) { + sent.put(userId, new ArrayList()); + } + sent.get(userId).add(slackBlocks); + } + return true; + } + + @Override + public boolean send(String channelId, String slackBlocks) { + if (!sent.containsKey(channelId)) { + sent.put(channelId, new ArrayList()); + } + sent.get(channelId).add(slackBlocks); + return true; + } +} + diff --git a/server/src/test/java/com/objectcomputing/checkins/services/email/EmailControllerTest.java b/server/src/test/java/com/objectcomputing/checkins/services/email/EmailControllerTest.java index eea14ecce..e1bd32cf2 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/email/EmailControllerTest.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/email/EmailControllerTest.java @@ -138,14 +138,16 @@ void testSendAndSaveTextEmail() { assertEquals(email.get("content"), firstEmailRes.getContents()); assertEquals(admin.getId(), firstEmailRes.getSentBy()); assertEquals(recipient1.getId(), firstEmailRes.getRecipient()); - assertTrue(firstEmailRes.getTransmissionDate().isAfter(firstEmailRes.getSendDate())); + assertTrue(firstEmailRes.getTransmissionDate().isAfter(firstEmailRes.getSendDate()) || + firstEmailRes.getTransmissionDate().isEqual(firstEmailRes.getSendDate())); Email secondEmailRes = response.getBody().get().get(1); assertEquals(email.get("subject"), secondEmailRes.getSubject()); assertEquals(email.get("content"), secondEmailRes.getContents()); assertEquals(admin.getId(), secondEmailRes.getSentBy()); assertEquals(recipient2.getId(), secondEmailRes.getRecipient()); - assertTrue(secondEmailRes.getTransmissionDate().isAfter(secondEmailRes.getSendDate())); + assertTrue(secondEmailRes.getTransmissionDate().isAfter(secondEmailRes.getSendDate()) || + secondEmailRes.getTransmissionDate().isEqual(secondEmailRes.getSendDate())); assertEquals(1, textEmailSender.events.size()); assertEquals( diff --git a/server/src/test/java/com/objectcomputing/checkins/services/fixture/AutomatedKudosFixture.java b/server/src/test/java/com/objectcomputing/checkins/services/fixture/AutomatedKudosFixture.java new file mode 100644 index 000000000..a8c0c2a80 --- /dev/null +++ b/server/src/test/java/com/objectcomputing/checkins/services/fixture/AutomatedKudosFixture.java @@ -0,0 +1,15 @@ +package com.objectcomputing.checkins.services.fixture; + +import com.objectcomputing.checkins.services.slack.kudos.AutomatedKudos; +import com.objectcomputing.checkins.services.slack.kudos.AutomatedKudosRepository; + +import java.util.ArrayList; +import java.util.List; + +public interface AutomatedKudosFixture extends RepositoryFixture { + default List getAutomatedKudos() { + List list = new ArrayList<>(); + getAutomatedKudosRepository().findAll().forEach(list::add); + return list; + } +} diff --git a/server/src/test/java/com/objectcomputing/checkins/services/fixture/RepositoryFixture.java b/server/src/test/java/com/objectcomputing/checkins/services/fixture/RepositoryFixture.java index eba62d2b6..648728e1d 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/fixture/RepositoryFixture.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/fixture/RepositoryFixture.java @@ -43,6 +43,7 @@ import com.objectcomputing.checkins.services.volunteering.VolunteeringRelationshipRepository; import io.micronaut.runtime.server.EmbeddedServer; import com.objectcomputing.checkins.services.employee_hours.EmployeeHoursRepository; +import com.objectcomputing.checkins.services.slack.kudos.AutomatedKudosRepository; public interface RepositoryFixture { EmbeddedServer getEmbeddedServer(); @@ -213,4 +214,8 @@ default DocumentRepository getDocumentRepository() { default RoleDocumentationRepository getRoleDocumentationRepository() { return getEmbeddedServer().getApplicationContext().getBean(RoleDocumentationRepository.class); } + + default AutomatedKudosRepository getAutomatedKudosRepository() { + return getEmbeddedServer().getApplicationContext().getBean(AutomatedKudosRepository.class); + } } diff --git a/server/src/test/java/com/objectcomputing/checkins/services/kudos/KudosControllerTest.java b/server/src/test/java/com/objectcomputing/checkins/services/kudos/KudosControllerTest.java index 839cf574f..6e42c684b 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/kudos/KudosControllerTest.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/kudos/KudosControllerTest.java @@ -3,7 +3,7 @@ import com.objectcomputing.checkins.configuration.CheckInsConfiguration; import com.objectcomputing.checkins.notifications.email.MailJetFactory; import com.objectcomputing.checkins.services.MailJetFactoryReplacement; -import com.objectcomputing.checkins.services.SlackPosterReplacement; +import com.objectcomputing.checkins.services.SlackSenderReplacement; import com.objectcomputing.checkins.services.TestContainersSuite; import com.objectcomputing.checkins.services.fixture.KudosFixture; import com.objectcomputing.checkins.services.fixture.TeamFixture; @@ -60,14 +60,14 @@ // when attempting to post public Kudos to Slack. @DisabledInNativeImage @Property(name = "replace.mailjet.factory", value = StringUtils.TRUE) -@Property(name = "replace.slackposter", value = StringUtils.TRUE) +@Property(name = "replace.slacksender", value = StringUtils.TRUE) class KudosControllerTest extends TestContainersSuite implements KudosFixture, TeamFixture, RoleFixture { @Inject @Named(MailJetFactory.HTML_FORMAT) private MailJetFactoryReplacement.MockEmailSender emailSender; @Inject - private SlackPosterReplacement slackPoster; + private SlackSenderReplacement slackSender; @Inject @Client("/services/kudos") @@ -110,7 +110,7 @@ void setUp() { message = "Kudos!"; emailSender.reset(); - slackPoster.reset(); + slackSender.reset(); } @ParameterizedTest @@ -252,15 +252,16 @@ void testApproveKudos() throws JsonProcessingException { ); // Check the posted slack block - assertEquals(1, slackPoster.posted.size()); + assertEquals(1, slackSender.sent.size()); ObjectMapper mapper = new ObjectMapper(); - JsonNode posted = mapper.readTree(slackPoster.posted.get(0)); + String channelId = checkInsConfiguration.getApplication() + .getSlack().getKudosChannel(); + List sent = slackSender.sent.get(channelId); + JsonNode posted = mapper.readTree(sent.get(0)); - assertEquals(JsonNodeType.OBJECT, posted.getNodeType()); - JsonNode blocks = posted.get("blocks"); - assertEquals(JsonNodeType.ARRAY, blocks.getNodeType()); + assertEquals(JsonNodeType.ARRAY, posted.getNodeType()); - var iter = blocks.elements(); + var iter = posted.elements(); assertTrue(iter.hasNext()); JsonNode block = iter.next(); diff --git a/server/src/test/java/com/objectcomputing/checkins/services/kudos/SlackKudosTest.java b/server/src/test/java/com/objectcomputing/checkins/services/kudos/SlackKudosTest.java new file mode 100644 index 000000000..893b9cf36 --- /dev/null +++ b/server/src/test/java/com/objectcomputing/checkins/services/kudos/SlackKudosTest.java @@ -0,0 +1,188 @@ +package com.objectcomputing.checkins.services.kudos; + +import com.objectcomputing.checkins.services.slack.SlackSignature; +import com.objectcomputing.checkins.services.slack.kudos.KudosChannelReader; +import com.objectcomputing.checkins.services.slack.kudos.AutomatedKudos; +import com.objectcomputing.checkins.services.memberprofile.MemberProfile; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileUtils; +import com.objectcomputing.checkins.services.kudos.KudosResponseDTO; +import com.objectcomputing.checkins.services.role.RoleType; + +import com.objectcomputing.checkins.services.TestContainersSuite; +import com.objectcomputing.checkins.services.SlackSenderReplacement; +import com.objectcomputing.checkins.services.SlackReaderReplacement; +import com.objectcomputing.checkins.services.SlackSearchReplacement; +import com.objectcomputing.checkins.services.fixture.MemberProfileFixture; +import com.objectcomputing.checkins.services.fixture.AutomatedKudosFixture; +import com.objectcomputing.checkins.services.fixture.RoleFixture; + +import io.micronaut.core.type.Argument; +import io.micronaut.core.util.StringUtils; +import io.micronaut.context.annotation.Property; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.core.JsonProcessingException; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.time.Instant; +import java.net.URLEncoder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Property(name = "replace.slacksearch", value = StringUtils.TRUE) +@Property(name = "replace.slackreader", value = StringUtils.TRUE) +@Property(name = "replace.slacksender", value = StringUtils.TRUE) +class SlackKudosTest extends TestContainersSuite implements MemberProfileFixture, AutomatedKudosFixture, RoleFixture { + @Inject + private SlackReaderReplacement slackReader; + + @Inject + private SlackSearchReplacement slackSearch; + + @Inject + private SlackSenderReplacement slackSender; + + @Inject + private KudosChannelReader kudosChannelReader; + + @Inject + private SlackSignature slackSignature; + + @Inject + @Client("/services") + protected HttpClient client; + + MemberProfile sender; + MemberProfile recipient; + + @BeforeEach + void setUp() { + createAndAssignRoles(); + sender = createADefaultMemberProfile(); + recipient = createASecondDefaultMemberProfile(); + } + + @Test + void testCreateKudosFromSlackMessage() throws JsonProcessingException { + String slackSenderId = "senderId"; + slackSearch.users.put(slackSenderId, sender.getWorkEmail()); + String slackRecipient = "recipientId"; + slackSearch.users.put(slackRecipient, recipient.getWorkEmail()); + + // Post to "Slack" + final String beginning = "Kudos to "; + slackReader.addMessage("SLACK_KUDOS_CHANNEL_ID", slackSenderId, + beginning + "<@" + slackRecipient + ">", + LocalDateTime.now()); + + final String messageWithName = beginning + + MemberProfileUtils.getFullName(recipient); + + // Manually tell the reader to load messages from slack. This normally + // happens on an interval. + kudosChannelReader.readChannel(); + + // Get the automated kudos from the repository and validate. + List generatedKudos = getAutomatedKudos(); + assertEquals(1, generatedKudos.size()); + AutomatedKudos kudos = generatedKudos.get(0); + assertEquals(messageWithName, kudos.getMessage()); + assertEquals(slackSenderId, kudos.getExternalId()); + assertEquals(sender.getId(), kudos.getSenderId()); + assertEquals(1, kudos.getRecipientIds().size()); + assertEquals(recipient.getId().toString(), + kudos.getRecipientIds().get(0)); + + // A slack message should have been sent to the sender as well... + assertTrue(slackSender.sent.containsKey(slackSenderId)); + List messages = slackSender.sent.get(slackSenderId); + assertEquals(1, messages.size()); + + ObjectMapper mapper = new ObjectMapper(); + JsonNode sent = mapper.readTree(messages.get(0)); + assertEquals(JsonNodeType.ARRAY, sent.getNodeType()); + + var iter = sent.elements(); + assertTrue(iter.hasNext()); + JsonNode section = iter.next(); + + assertEquals(JsonNodeType.OBJECT, section.getNodeType()); + assertTrue(section.has("block_id")); + UUID automatedKudosUUID = + UUID.fromString(section.get("block_id").asText()); + + // Post to /external to say "yes, we want it in Check-Ins". + final String rawBody = getSlackPostPayload( + automatedKudosUUID.toString()); + long currentTime = Instant.now().getEpochSecond(); + String timestamp = String.valueOf(currentTime); + + HttpRequest request = + HttpRequest.POST("/pulse-responses/external", rawBody) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("X-Slack-Signature", slackSignature.generate(timestamp, rawBody)) + .header("X-Slack-Request-Timestamp", timestamp); + + HttpResponse response = client.toBlocking().exchange(request); + assertEquals(HttpStatus.OK, response.getStatus()); + + // Get list of kudos from kudos controller and verify. + request = HttpRequest.GET("/kudos/recent") + .basicAuth(sender.getWorkEmail(), + RoleType.Constants.MEMBER_ROLE); + HttpResponse> list = + client.toBlocking() + .exchange(request, Argument.listOf(KudosResponseDTO.class)); + assertEquals(HttpStatus.OK, list.getStatus()); + assertEquals(1, list.body().size()); + KudosResponseDTO element = list.body().getFirst(); + assertEquals(messageWithName, element.getMessage()); + assertEquals(sender.getId(), element.getSenderId()); + assertTrue(element.getPubliclyVisible()); + assertEquals(element.getRecipientMembers(), List.of(recipient)); + } + + @Test + void testNoSlackMessages() { + kudosChannelReader.readChannel(); + List generatedKudos = getAutomatedKudos(); + assertEquals(0, generatedKudos.size()); + } + + String getSlackPostPayload(String kudosId) { + return "payload=" + + URLEncoder.encode(String.format(""" +{ + "type": "block_actions", + "message": { + "blocks": [ + { + "block_id": "%s" + } + ] + }, + "actions": [ + { + "action_id": "yes_button" + } + ] +} +""", kudosId)); + } +} diff --git a/server/src/test/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseControllerTest.java b/server/src/test/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseControllerTest.java index c4d94aa70..7f4985c5b 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseControllerTest.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseControllerTest.java @@ -9,6 +9,7 @@ import com.objectcomputing.checkins.util.Util; import com.objectcomputing.checkins.configuration.CheckInsConfiguration; import com.objectcomputing.checkins.services.SlackSearchReplacement; +import com.objectcomputing.checkins.services.slack.SlackSignature; import io.micronaut.core.util.StringUtils; import io.micronaut.core.type.Argument; @@ -32,11 +33,8 @@ import java.util.UUID; import java.util.stream.Collectors; import java.util.stream.Stream; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.util.Base64; import java.time.Instant; +import java.net.URLEncoder; import static com.objectcomputing.checkins.services.role.RoleType.Constants.ADMIN_ROLE; import static com.objectcomputing.checkins.services.role.RoleType.Constants.MEMBER_ROLE; @@ -57,6 +55,9 @@ class PulseResponseControllerTest extends TestContainersSuite implements MemberP @Inject private SlackSearchReplacement slackSearch; + @Inject + private SlackSignature slackSignature; + private Map hierarchy; @BeforeEach @@ -536,16 +537,17 @@ void testUpdateInvalidDatePulseResponse() { @Test void testCreateAPulseResponseFromSlack() { MemberProfile memberProfile = createADefaultMemberProfile(); - slackSearch.users.put("SLACK_ID_HI", memberProfile.getWorkEmail()); + String slackId = "SLACK_ID_HI"; + slackSearch.users.put(slackId, memberProfile.getWorkEmail()); - final String rawBody = "payload=%7B%22type%22%3A+%22view_submission%22%2C+%22user%22%3A+%7B%22id%22%3A+%22SLACK_ID_HI%22%7D%2C+%22view%22%3A+%7B%22id%22%3A+%22VNHU13V36%22%2C+%22type%22%3A+%22modal%22%2C+%22state%22%3A+%7B%22values%22%3A+%7B%22internalNumber%22%3A+%7B%22internalScore%22%3A+%7B%22selected_option%22%3A+%7B%22type%22%3A+%22radio_buttons%22%2C+%22value%22%3A+%224%22%7D%7D%7D%2C+%22internalText%22%3A+%7B%22internalFeelings%22%3A+%7B%22type%22%3A+%22plain_text_input%22%2C+%22value%22%3A+%22I+am+a+robot.%22%7D%7D%2C+%22externalNumber%22%3A+%7B%22externalScore%22%3A+%7B%22selected_option%22%3A+%7B%22type%22%3A+%22radio_buttons%22%2C+%22value%22%3A+%225%22%7D%7D%7D%2C+%22externalText%22%3A+%7B%22externalFeelings%22%3A+%7B%22type%22%3A+%22plain_text_input%22%2C+%22value%22%3A+%22You+are+a+robot.%22%7D%7D%7D%7D%7D%7D"; + final String rawBody = getSlackPulsePayload(slackId); long currentTime = Instant.now().getEpochSecond(); String timestamp = String.valueOf(currentTime); final HttpRequest request = HttpRequest.POST("/external", rawBody) .header("Content-Type", "application/x-www-form-urlencoded") - .header("X-Slack-Signature", slackSignature(timestamp, rawBody)) + .header("X-Slack-Signature", slackSignature.generate(timestamp, rawBody)) .header("X-Slack-Request-Timestamp", timestamp); final HttpResponse response = client.toBlocking().exchange(request); @@ -553,29 +555,6 @@ void testCreateAPulseResponseFromSlack() { assertEquals(HttpStatus.OK, response.getStatus()); } - private String slackSignature(String timestamp, String rawBody) { - String baseString = "v0:" + timestamp + ":" + rawBody; - String secret = configuration.getApplication() - .getSlack().getSigningSecret(); - - try { - // Generate HMAC SHA-256 signature - Mac mac = Mac.getInstance("HmacSHA256"); - SecretKeySpec secretKeySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); - mac.init(secretKeySpec); - byte[] hash = mac.doFinal(baseString.getBytes(StandardCharsets.UTF_8)); - - // Convert hash to hex - StringBuilder hexString = new StringBuilder(); - for (byte b : hash) { - hexString.append(String.format("%02x", b)); - } - return "v0=" + hexString.toString(); - } catch (Exception e) { - return null; - } - } - private static PulseResponseCreateDTO createPulseResponseCreateDTO() { return createPulseResponseCreateDTO(UUID.randomUUID()); } @@ -603,4 +582,53 @@ private MemberProfile profile(String key) { private UUID id(String key) { return profile(key).getId(); } + + private String getSlackPulsePayload(String slackId) { + return "payload=" + + URLEncoder.encode(String.format(""" +{ + "type": "view_submission", + "user": { + "id": "%s" + }, + "view": { + "id": "VNHU13V36", + "type": "modal", + "callback_id": "pulseSubmission", + "state": { + "values": { + "internalNumber": { + "internalScore": { + "selected_option": { + "type": "radio_buttons", + "value": "4" + } + } + }, + "internalText": { + "internalFeelings": { + "type": "plain_text_input", + "value": "I am a robot." + } + }, + "externalNumber": { + "externalScore": { + "selected_option": { + "type": "radio_buttons", + "value": "5" + } + } + }, + "externalText": { + "externalFeelings": { + "type": "plain_text_input", + "value": "You are a robot." + } + } + } + } + } +} +""", slackId)); + } } diff --git a/server/src/test/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImplTest.java b/server/src/test/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImplTest.java index ae589d972..6ca02c27f 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImplTest.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/request_notifications/CheckServicesImplTest.java @@ -13,6 +13,7 @@ import com.objectcomputing.checkins.services.fixture.MemberProfileFixture; import com.objectcomputing.checkins.services.memberprofile.MemberProfile; import com.objectcomputing.checkins.services.CurrentUserServicesReplacement; +import com.objectcomputing.checkins.services.SlackReaderReplacement; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -30,11 +31,15 @@ @Property(name = "replace.mailjet.factory", value = StringUtils.TRUE) @Property(name = "replace.currentuserservices", value = StringUtils.TRUE) +@Property(name = "replace.slackreader", value = StringUtils.TRUE) class CheckServicesImplTest extends TestContainersSuite implements FeedbackTemplateFixture, FeedbackRequestFixture, MemberProfileFixture, RoleFixture { @Inject CurrentUserServicesReplacement currentUserServices; + @Inject + private SlackReaderReplacement slackReader; + @Inject @Named(MailJetFactory.MJML_FORMAT) private MailJetFactoryReplacement.MockEmailSender emailSender; diff --git a/server/src/test/resources/application-test.yml b/server/src/test/resources/application-test.yml index 132a9b1a6..0eb4040fd 100644 --- a/server/src/test/resources/application-test.yml +++ b/server/src/test/resources/application-test.yml @@ -45,6 +45,7 @@ check-ins: webhook-url: https://bogus.objectcomputing.com/slack bot-token: BOGUS_TOKEN signing-secret: BOGUS_SIGNING_SECRET + kudos-channel: SLACK_KUDOS_CHANNEL_ID --- aes: key: BOGUS_TEST_KEY diff --git a/web-ui/src/components/kudos/PublicKudosCard.jsx b/web-ui/src/components/kudos/PublicKudosCard.jsx index 703a76f70..8a2787543 100644 --- a/web-ui/src/components/kudos/PublicKudosCard.jsx +++ b/web-ui/src/components/kudos/PublicKudosCard.jsx @@ -89,7 +89,7 @@ const KudosCard = ({ kudos }) => { } const firstAndLast = `${member.firstName} ${member.lastName}`; if (!members.some((k) => k.id != member.id && - firstAndLast != `${k.firstName} ${k.lastName}`)) { + firstAndLast == `${k.firstName} ${k.lastName}`)) { names.push(firstAndLast); } if (!members.some((k) => k.id != member.id && @@ -110,22 +110,32 @@ const KudosCard = ({ kudos }) => { }; const linkNames = (kudos) => { - const components = [ kudos.message ]; - for (let member of kudos.recipientMembers) { - const names = searchNames(member, kudos.recipientMembers); - for (let name of names) { - for (let i = 0; i < components.length; i++) { - const component = components[i]; - if (typeof(component) === "string") { - const built = linkMember(member, name, component); - if (built.length > 1) { - components.splice(i, 1, ...built); + const lines = []; + let index = 0; + for (let line of kudos.message.split('\n')) { + const components = [ line ]; + for (let member of kudos.recipientMembers) { + const names = searchNames(member, kudos.recipientMembers); + for (let name of names) { + for (let i = 0; i < components.length; i++) { + const component = components[i]; + if (typeof(component) === "string") { + const built = linkMember(member, name, component); + if (built.length > 1) { + components.splice(i, 1, ...built); + } } } } } + lines.push( + + {components} + + ); + index++; } - return components; + return lines; }; const multiTooltip = (num, list) => { @@ -189,9 +199,9 @@ const KudosCard = ({ kudos }) => { subheaderTypographyProps={{variant:"subtitle1"}} /> - + <> {linkNames(kudos)} - + {kudos.recipientTeam && ( multiTooltip( diff --git a/web-ui/src/components/kudos_card/KudosCard.jsx b/web-ui/src/components/kudos_card/KudosCard.jsx index b856dc976..b94fe452c 100644 --- a/web-ui/src/components/kudos_card/KudosCard.jsx +++ b/web-ui/src/components/kudos_card/KudosCard.jsx @@ -139,7 +139,7 @@ const KudosCard = ({ kudos, includeActions, includeEdit, onKudosAction }) => { type: UPDATE_TOAST, payload: { severity: "success", - toast: "Pending kudos deleted", + toast: "Kudos deleted", }, }); onKudosAction && onKudosAction(); diff --git a/web-ui/src/pages/ManageKudosPage.jsx b/web-ui/src/pages/ManageKudosPage.jsx index 15b55694f..d1df8e577 100644 --- a/web-ui/src/pages/ManageKudosPage.jsx +++ b/web-ui/src/pages/ManageKudosPage.jsx @@ -234,7 +234,12 @@ const ManageKudosPage = () => { : (
{approvedKudos.filter(filterApprovedKudos).map(k => - + )}
)