From cf2a90b2b044196de9425b24857946e509bf77c1 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Fri, 10 Jan 2025 07:24:02 -0600 Subject: [PATCH 01/10] Initial version of Pulse Response submission through Slack. --- .../configuration/CheckInsConfiguration.java | 23 ++++++ .../PulseResponseController.java | 61 +++++++++++++++- .../PulseResponseRepository.java | 4 +- .../pulseresponse/PulseResponseService.java | 3 +- .../PulseResponseServicesImpl.java | 60 ++++++++++++---- .../SlackPulseResponseConverter.java | 71 +++++++++++++++++++ .../pulseresponse/SlackSignatureVerifier.java | 68 ++++++++++++++++++ server/src/main/resources/application.yml | 6 +- 8 files changed, 276 insertions(+), 20 deletions(-) create mode 100644 server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackPulseResponseConverter.java create mode 100644 server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackSignatureVerifier.java 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 6c58600b40..2e88f0b507 100644 --- a/server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java +++ b/server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java @@ -34,6 +34,9 @@ public static class ApplicationConfig { @NotNull private NotificationsConfig notifications; + @NotNull + private PulseResponseConfig pulseResponse; + @Getter @Setter @ConfigurationProperties("feedback") @@ -89,5 +92,25 @@ public static class SlackConfig { private String botToken; } } + + @Getter + @Setter + @ConfigurationProperties("pulse-response") + public static class PulseResponseConfig { + + @NotNull + private SlackConfig slack; + + @Getter + @Setter + @ConfigurationProperties("slack") + public static class SlackConfig { + @NotBlank + private String signingSecret; + + @NotBlank + private String webhookUrl; + } + } } } 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 5a1a96167d..a81eaa194c 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,10 +1,14 @@ package com.objectcomputing.checkins.services.pulseresponse; import com.objectcomputing.checkins.exceptions.NotFoundException; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; + import io.micronaut.core.annotation.Nullable; import io.micronaut.core.convert.format.Format; +import io.micronaut.http.HttpStatus; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Header; import io.micronaut.http.annotation.Body; import io.micronaut.http.annotation.Controller; import io.micronaut.http.annotation.Get; @@ -14,6 +18,7 @@ import io.micronaut.scheduling.annotation.ExecuteOn; import io.micronaut.security.annotation.Secured; import io.micronaut.security.rules.SecurityRule; + import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; @@ -25,14 +30,19 @@ @Controller("/services/pulse-responses") @ExecuteOn(TaskExecutors.BLOCKING) -@Secured(SecurityRule.IS_AUTHENTICATED) @Tag(name = "pulse-responses") public class PulseResponseController { private final PulseResponseService pulseResponseServices; + private final MemberProfileServices memberProfileServices; + private final SlackSignatureVerifier slackSignatureVerifier; - public PulseResponseController(PulseResponseService pulseResponseServices) { + public PulseResponseController(PulseResponseService pulseResponseServices, + MemberProfileServices memberProfileServices, + SlackSignatureVerifier slackSignatureVerifier) { this.pulseResponseServices = pulseResponseServices; + this.memberProfileServices = memberProfileServices; + this.slackSignatureVerifier = slackSignatureVerifier; } /** @@ -43,6 +53,7 @@ public PulseResponseController(PulseResponseService pulseResponseServices) { * @param dateTo * @return */ + @Secured(SecurityRule.IS_AUTHENTICATED) @Get("/{?teamMemberId,dateFrom,dateTo}") public Set findPulseResponses(@Nullable @Format("yyyy-MM-dd") LocalDate dateFrom, @Nullable @Format("yyyy-MM-dd") LocalDate dateTo, @@ -56,6 +67,7 @@ public Set findPulseResponses(@Nullable @Format("yyyy-MM-dd") Loc * @param pulseResponse, {@link PulseResponseCreateDTO} * @return {@link HttpResponse} */ + @Secured(SecurityRule.IS_AUTHENTICATED) @Post public HttpResponse createPulseResponse(@Body @Valid PulseResponseCreateDTO pulseResponse, HttpRequest request) { @@ -70,6 +82,7 @@ public HttpResponse createPulseResponse(@Body @Valid PulseRespons * @param pulseResponse, {@link PulseResponse} * @return {@link HttpResponse} */ + @Secured(SecurityRule.IS_AUTHENTICATED) @Put public HttpResponse update(@Body @Valid @NotNull PulseResponse pulseResponse, HttpRequest request) { @@ -82,6 +95,7 @@ public HttpResponse update(@Body @Valid @NotNull PulseResponse pu * @param id * @return */ + @Secured(SecurityRule.IS_AUTHENTICATED) @Get("/{id}") public PulseResponse readRole(@NotNull UUID id) { PulseResponse result = pulseResponseServices.read(id); @@ -90,4 +104,45 @@ public PulseResponse readRole(@NotNull UUID id) { } return result; } -} \ No newline at end of file + + @Secured(SecurityRule.IS_ANONYMOUS) + @Post("/external") + public HttpResponse externalPulseResponse( + @Header("X-Slack-Signature") String signature, + @Header("X-Slack-Request-Timestamp") String timestamp, + @Body String requestBody, + HttpRequest request) { + // Validate the request + if (slackSignatureVerifier.verifyRequest(signature, + timestamp, requestBody)) { + PulseResponseCreateDTO pulseResponseDTO = + SlackPulseResponseConverter.get(memberProfileServices, + requestBody); + + // 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.created(pulseResponse) + .headers(headers -> headers.location( + URI.create(String.format("%s/%s", + request.getPath(), + pulseResponse.getId())))); + } + } else { + return HttpResponse.unauthorized(); + } + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseRepository.java b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseRepository.java index a2c2ac42dd..2c237a7b95 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseRepository.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseRepository.java @@ -6,6 +6,7 @@ import jakarta.validation.constraints.NotNull; import java.time.LocalDate; +import java.util.Optional; import java.util.List; import java.util.UUID; @@ -14,4 +15,5 @@ public interface PulseResponseRepository extends CrudRepository findByTeamMemberId(@NotNull UUID teamMemberId); List findBySubmissionDateBetween(@NotNull LocalDate dateFrom, @NotNull LocalDate dateTo); -} \ No newline at end of file + Optional getByTeamMemberIdAndSubmissionDate(@NotNull UUID teamMemberId, @NotNull LocalDate submissionDate); +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseService.java b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseService.java index 8b5ec0a01c..69ac07e50b 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseService.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseService.java @@ -9,8 +9,9 @@ public interface PulseResponseService { PulseResponse read(UUID id); PulseResponse save(PulseResponse pulseResponse); + PulseResponse unsecureSave(PulseResponse pulseResponse); PulseResponse update(PulseResponse pulseResponse); Set findByFields(UUID teamMemberId, LocalDate dateFrom, LocalDate dateTo); -} \ No newline at end of file +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseServicesImpl.java b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseServicesImpl.java index 0cd2ee2177..d489aa37f6 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseServicesImpl.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseServicesImpl.java @@ -48,25 +48,57 @@ public PulseResponseServicesImpl( @Override public PulseResponse save(PulseResponse pulseResponse) { - UUID currentUserId = currentUserServices.getCurrentUser().getId(); + if (pulseResponse != null) { + final UUID memberId = pulseResponse.getTeamMemberId(); + UUID currentUserId = currentUserServices.getCurrentUser().getId(); + if (memberId != null && + !currentUserId.equals(memberId) && + !isSubordinateTo(memberId, currentUserId)) { + throw new BadArgException( + String.format("User %s does not have permission to create pulse response for user %s", + currentUserId, memberId)); + } + return saveCommon(pulseResponse); + } else { + return null; + } + } + + @Override + public PulseResponse unsecureSave(PulseResponse pulseResponse) { PulseResponse pulseResponseRet = null; if (pulseResponse != null) { + // External users could submit a pulse resonse multiple times. We + // need to check to see if this user has already submitted one + // today. + boolean submitted = false; final UUID memberId = pulseResponse.getTeamMemberId(); - LocalDate pulseSubDate = pulseResponse.getSubmissionDate(); - if (pulseResponse.getId() != null) { - throw new BadArgException(String.format("Found unexpected id for pulseresponse %s", pulseResponse.getId())); - } else if (memberId != null && - memberRepo.findById(memberId).isEmpty()) { - throw new BadArgException(String.format("Member %s doesn't exists", memberId)); - } else if (pulseSubDate.isBefore(LocalDate.EPOCH) || pulseSubDate.isAfter(LocalDate.MAX)) { - throw new BadArgException(String.format("Invalid date for pulseresponse submission date %s", memberId)); - } else if (memberId != null && - !currentUserId.equals(memberId) && - !isSubordinateTo(memberId, currentUserId)) { - throw new BadArgException(String.format("User %s does not have permission to create pulse response for user %s", currentUserId, memberId)); + if (memberId != null) { + Optional existing = + pulseResponseRepo.getByTeamMemberIdAndSubmissionDate( + memberId, pulseResponse.getSubmissionDate()); + submitted = existing.isPresent(); + } + if (!submitted) { + return saveCommon(pulseResponse); } - pulseResponseRet = pulseResponseRepo.save(pulseResponse); } + return null; + } + + private PulseResponse saveCommon(PulseResponse pulseResponse) { + final UUID memberId = pulseResponse.getTeamMemberId(); + LocalDate pulseSubDate = pulseResponse.getSubmissionDate(); + if (pulseResponse.getId() != null) { + throw new BadArgException(String.format("Found unexpected id for pulseresponse %s", pulseResponse.getId())); + } else if (memberId != null && + memberRepo.findById(memberId).isEmpty()) { + throw new BadArgException(String.format("Member %s doesn't exists", memberId)); + } else if (pulseSubDate.isBefore(LocalDate.EPOCH) || pulseSubDate.isAfter(LocalDate.MAX)) { + throw new BadArgException(String.format("Invalid date for pulseresponse submission date %s", memberId)); + } + + PulseResponse pulseResponseRet = pulseResponseRepo.save(pulseResponse); // Send low pulse survey score if appropriate sendPulseLowScoreEmail(pulseResponseRet); diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackPulseResponseConverter.java b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackPulseResponseConverter.java new file mode 100644 index 0000000000..b93c655416 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackPulseResponseConverter.java @@ -0,0 +1,71 @@ +package com.objectcomputing.checkins.services.pulseresponse; + +import com.objectcomputing.checkins.exceptions.BadArgException; +import com.objectcomputing.checkins.services.memberprofile.MemberProfile; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; + +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; + +public class SlackPulseResponseConverter { + public static PulseResponseCreateDTO get( + MemberProfileServices memberProfileServices, String body) { + final String key = "payload="; + final int start = body.indexOf(key); + if (start >= 0) { + try { + // Get the map of values from the string body + final ObjectMapper mapper = new ObjectMapper(); + final Map map = + mapper.readValue(body.substring(start + key.length()), + new TypeReference<>() {}); + final Map view = + (Map)map.get("view"); + final Map state = + (Map)view.get("state"); + final Map values = + (Map)state.get("values"); + + // Create the pulse DTO and fill in the values. + PulseResponseCreateDTO response = new PulseResponseCreateDTO(); + response.setTeamMemberId(lookupUser(memberProfileServices, map)); + response.setSubmissionDate(LocalDate.now()); + response.setInternalScore(Integer.parseInt( + getMappedValue(values, "internalScore"))); + response.setInternalFeelings( + getMappedValue(values, "internalFeelings")); + response.setExternalScore(Integer.parseInt( + getMappedValue(values, "externalScore"))); + response.setExternalFeelings( + getMappedValue(values, "externalFeelings")); + + return response; + } catch(JsonProcessingException ex) { + throw new BadArgException(ex.getMessage()); + } + } else { + throw new BadArgException("Invalid pulse response body"); + } + } + + private static String getMappedValue(Map map, String key) { + return (String)((Map)map.get(key)).get("value"); + } + + private static UUID lookupUser(MemberProfileServices memberProfileServices, + Map map) { + // Get the user's profile map. + Map user = (Map)map.get("user"); + Map profile = (Map)user.get("profile"); + + // Lookup the user based on the email address. + String email = (String)profile.get("email"); + MemberProfile member = memberProfileServices.findByWorkEmail(email); + return member.getId(); + } +} diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackSignatureVerifier.java b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackSignatureVerifier.java new file mode 100644 index 0000000000..c9be9ed274 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackSignatureVerifier.java @@ -0,0 +1,68 @@ +package com.objectcomputing.checkins.services.pulseresponse; + +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.time.Instant; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +@Singleton +public class SlackSignatureVerifier { + @Inject + private CheckInsConfiguration configuration; + + public SlackSignatureVerifier() {} + + public boolean verifyRequest(String slackSignature, String timestamp, String requestBody) { + try { + // Prevent replay attacks by checking the timestamp + if (!isRecentRequest(timestamp)) { + return false; + } + + // Create the base string + String baseString = "v0:" + timestamp + ":" + requestBody; + + // Generate HMAC-SHA256 signature + String secret = configuration.getApplication() + .getPulseResponse() + .getSlack().getSigningSecret(); + String computedSignature = "v0=" + hmacSha256(secret, baseString); + + // Compare the computed signature with Slack's signature + return computedSignature.equals(slackSignature); + } catch (Exception e) { + return false; + } + } + + private boolean isRecentRequest(String timestamp) { + long currentTime = Instant.now().getEpochSecond(); + long requestTime = Long.parseLong(timestamp); + return Math.abs(currentTime - requestTime) < 60; + } + + private String hmacSha256(String secret, String message) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKeySpec = new SecretKeySpec( + secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(secretKeySpec); + + // Convert hash to hex + StringBuilder hexString = new StringBuilder(); + byte[] hash = mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); + for (byte b : hash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } +} diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 99148dcede..88bbc3a8fd 100755 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -28,7 +28,7 @@ micronaut: enabled: true mapping: "/**" paths: - - "classpath:public" + - "classpath:public" executors: io: @@ -101,6 +101,10 @@ check-ins: slack: webhook-url: ${ SLACK_WEBHOOK_URL } bot-token: ${ SLACK_BOT_TOKEN } + pulse-response: + slack: + signing-secret: ${ SLACK_PULSE_SIGNING_SECRET } + webhook-url: ${ SLACK_PULSE_WEBHOOK_URL } web-address: ${ WEB_ADDRESS } --- flyway: From e5938e164521a8bf11b64294bc17c8006c0f5840 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Fri, 10 Jan 2025 08:01:27 -0600 Subject: [PATCH 02/10] Added a slack post containing the slack blocks that make up the Pulse form. --- .../services/pulse/PulseServices.java | 2 +- .../services/pulse/PulseServicesImpl.java | 6 +- .../services/pulse/PulseSlackPoster.java | 60 +++++++ .../CheckServicesImpl.java | 2 +- server/src/main/resources/slack/README.md | 10 ++ .../resources/slack/pulse_slack_blocks.json | 158 ++++++++++++++++++ 6 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseSlackPoster.java create mode 100644 server/src/main/resources/slack/README.md create mode 100644 server/src/main/resources/slack/pulse_slack_blocks.json diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServices.java b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServices.java index 2170d8ae2e..772a489683 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServices.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServices.java @@ -3,5 +3,5 @@ import java.time.LocalDate; public interface PulseServices { - public void sendPendingEmail(LocalDate now); + public void notifyUsers(LocalDate now); } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServicesImpl.java b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServicesImpl.java index 8f3ec0788c..2b73d0ecc9 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServicesImpl.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServicesImpl.java @@ -47,6 +47,9 @@ private class Frequency { @Inject private PulseEmail email; + @Inject + private PulseSlackPoster slackPoster; + private final DayOfWeek emailDay = DayOfWeek.MONDAY; private String setting = "bi-weekly"; @@ -69,7 +72,7 @@ public PulseServicesImpl( this.automatedEmailRepository = automatedEmailRepository; } - public void sendPendingEmail(LocalDate check) { + public void notifyUsers(LocalDate check) { if (check.getDayOfWeek() == emailDay) { LOG.info("Checking for pending Pulse email"); // Start from the first of the year and move forward to ensure that we @@ -103,6 +106,7 @@ public void sendPendingEmail(LocalDate check) { if (sent.isEmpty()) { LOG.info("Sending Pulse Email"); email.send(); + slackPoster.send(); automatedEmailRepository.save(new AutomatedEmail(key)); } else { LOG.info("The Pulse Email has already been sent today"); diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseSlackPoster.java b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseSlackPoster.java new file mode 100644 index 0000000000..c64ffb04dd --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseSlackPoster.java @@ -0,0 +1,60 @@ +package com.objectcomputing.checkins.services.pulse; + +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; + +import io.micronaut.http.HttpRequest; +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.HttpClient; +import io.micronaut.context.annotation.Value; +import io.micronaut.core.io.Readable; +import io.micronaut.core.io.IOUtils; + +import jakarta.inject.Singleton; +import jakarta.inject.Inject; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; + +@Singleton +public class PulseSlackPoster { + private static final Logger LOG = LoggerFactory.getLogger(PulseSlackPoster.class); + + @Inject + private HttpClient slackClient; + + @Inject + private CheckInsConfiguration configuration; + + @Value("classpath:slack/pulse_slack_blocks.json") + private Readable pulseSlackBlocks; + + public void send() { + String slackBlocks = getSlackBlocks(); + + // See if we can have a webhook URL. + String slackWebHook = configuration.getApplication() + .getPulseResponse() + .getSlack().getWebhookUrl(); + if (slackWebHook != null && !slackBlocks.isEmpty()) { + // POST it to Slack. + BlockingHttpClient client = slackClient.toBlocking(); + HttpRequest request = HttpRequest.POST(slackWebHook, + slackBlocks); + client.exchange(request); + } + } + + private String getSlackBlocks() { + try { + return IOUtils.readText( + new BufferedReader(pulseSlackBlocks.asReader())); + + } catch(Exception ex) { + LOG.error(ex.toString()); + return ""; + } + } +} + 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 49cb86f041..baba523e35 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 @@ -41,7 +41,7 @@ public boolean sendScheduledEmails() { req.setStatus("sent"); feedbackRequestRepository.update(req); } - pulseServices.sendPendingEmail(today); + pulseServices.notifyUsers(today); reviewPeriodServices.sendNotifications(today); return true; } diff --git a/server/src/main/resources/slack/README.md b/server/src/main/resources/slack/README.md new file mode 100644 index 0000000000..8313956e7b --- /dev/null +++ b/server/src/main/resources/slack/README.md @@ -0,0 +1,10 @@ +# Slack Blocks + +Place Slack Blocks jSON here. + +### Micronaut Usage + +```java +@Value("classpath:slack/filename.json") +private Readable slackBlocks; +``` diff --git a/server/src/main/resources/slack/pulse_slack_blocks.json b/server/src/main/resources/slack/pulse_slack_blocks.json new file mode 100644 index 0000000000..bdfd32a132 --- /dev/null +++ b/server/src/main/resources/slack/pulse_slack_blocks.json @@ -0,0 +1,158 @@ +{ + "type": "modal", + "title": { + "type": "plain_text", + "text": "Check-Ins Pulse", + "emoji": true + }, + "submit": { + "type": "plain_text", + "text": "Submit", + "emoji": true + }, + "close": { + "type": "plain_text", + "text": "Cancel", + "emoji": true + }, + "blocks": [ + { + "type": "section", + "text": { + "type": "plain_text", + "text": "How are you feeling about work today?" + }, + "accessory": { + "type": "radio_buttons", + "action_id": "internalScore", + "options": [ + { + "value": "1", + "text": { + "type": "plain_text", + "text": "😦", + "emoji": true + } + }, + { + "value": "2", + "text": { + "type": "plain_text", + "text": "🙁", + "emoji": true + } + }, + { + "value": "3", + "text": { + "type": "plain_text", + "text": "😐", + "emoji": true + } + }, + { + "value": "4", + "text": { + "type": "plain_text", + "text": "🙂", + "emoji": true + } + }, + { + "value": "5", + "text": { + "type": "plain_text", + "text": "😀", + "emoji": true + } + } + ] + } + }, + { + "type": "input", + "element": { + "type": "plain_text_input", + "action_id": "internalFeelings", + "placeholder": { + "type": "plain_text", + "text": "Comment" + } + }, + "label": { + "type": "plain_text", + "text": "Comment", + "emoji": true + } + }, + { + "type": "section", + "text": { + "type": "plain_text", + "text": "How are you feeling about life outside of work?" + }, + "accessory": { + "type": "radio_buttons", + "action_id": "externalScore", + "options": [ + { + "value": "1", + "text": { + "type": "plain_text", + "text": "😦", + "emoji": true + } + }, + { + "value": "2", + "text": { + "type": "plain_text", + "text": "🙁", + "emoji": true + } + }, + { + "value": "3", + "text": { + "type": "plain_text", + "text": "😐", + "emoji": true + } + }, + { + "value": "4", + "text": { + "type": "plain_text", + "text": "🙂", + "emoji": true + } + }, + { + "value": "5", + "text": { + "type": "plain_text", + "text": "😀", + "emoji": true + } + } + ] + } + }, + { + "type": "input", + "element": { + "type": "plain_text_input", + "action_id": "externalFeelings", + "placeholder": { + "type": "plain_text", + "text": "Comment" + } + }, + "label": { + "type": "plain_text", + "text": "Comment", + "emoji": true + } + } + ] +} From 65071c741237e161bbd28e09993b9d2c4148a8dc Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Fri, 10 Jan 2025 08:45:28 -0600 Subject: [PATCH 03/10] Fixed tests and made the Pulse POST to slack non-blocking. --- .../checkins/services/pulse/PulseSlackPoster.java | 3 +-- .../checkins/services/pulse/PulseServicesTest.java | 12 ++++++------ server/src/test/resources/application-test.yml | 6 +++++- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseSlackPoster.java b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseSlackPoster.java index c64ffb04dd..0aa353ebf6 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseSlackPoster.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseSlackPoster.java @@ -39,10 +39,9 @@ public void send() { .getSlack().getWebhookUrl(); if (slackWebHook != null && !slackBlocks.isEmpty()) { // POST it to Slack. - BlockingHttpClient client = slackClient.toBlocking(); HttpRequest request = HttpRequest.POST(slackWebHook, slackBlocks); - client.exchange(request); + slackClient.exchange(request); } } diff --git a/server/src/test/java/com/objectcomputing/checkins/services/pulse/PulseServicesTest.java b/server/src/test/java/com/objectcomputing/checkins/services/pulse/PulseServicesTest.java index f3de9f012f..540257568d 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/pulse/PulseServicesTest.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/pulse/PulseServicesTest.java @@ -97,7 +97,7 @@ void testBiWeeklySendEmail() { final Setting setting = new Setting(pulseSettingName, pulseBiWeekly); settingsServices.save(setting); - pulseServices.sendPendingEmail(biWeeklyDate); + pulseServices.notifyUsers(biWeeklyDate); assertEquals(1, emailSender.events.size()); EmailHelper.validateEmail("SEND_EMAIL", "null", "null", @@ -112,7 +112,7 @@ void testWeeklySendEmail() { final Setting setting = new Setting(pulseSettingName, pulseWeekly); settingsServices.save(setting); - pulseServices.sendPendingEmail(weeklyDate); + pulseServices.notifyUsers(weeklyDate); assertEquals(1, emailSender.events.size()); EmailHelper.validateEmail("SEND_EMAIL", "null", "null", @@ -127,7 +127,7 @@ void testMonthlySendEmail() { final Setting setting = new Setting(pulseSettingName, pulseMonthly); settingsServices.save(setting); - pulseServices.sendPendingEmail(monthlyDate); + pulseServices.notifyUsers(monthlyDate); assertEquals(1, emailSender.events.size()); EmailHelper.validateEmail("SEND_EMAIL", "null", "null", @@ -142,7 +142,7 @@ void testDuplicateSendEmail() { final Setting setting = new Setting(pulseSettingName, pulseMonthly); settingsServices.save(setting); - pulseServices.sendPendingEmail(monthlyDate); + pulseServices.notifyUsers(monthlyDate); // This should be zero because email was already sent on this date. assertEquals(0, emailSender.events.size()); } @@ -152,13 +152,13 @@ void testNoSendEmail() { final Setting setting = new Setting(pulseSettingName, pulseBiWeekly); settingsServices.save(setting); - pulseServices.sendPendingEmail(weeklyDate); + pulseServices.notifyUsers(weeklyDate); // This should be zero because, when set to bi-weekly, email is sent on // the first, third, and fifth Monday of the month. assertEquals(0, emailSender.events.size()); final LocalDate nonMonday = weeklyDate.plus(1, ChronoUnit.DAYS); - pulseServices.sendPendingEmail(nonMonday); + pulseServices.notifyUsers(nonMonday); // This should be zero because the date is not a Monday. assertEquals(0, emailSender.events.size()); } diff --git a/server/src/test/resources/application-test.yml b/server/src/test/resources/application-test.yml index c6fca9bd81..47d362d836 100644 --- a/server/src/test/resources/application-test.yml +++ b/server/src/test/resources/application-test.yml @@ -45,9 +45,13 @@ check-ins: slack: webhook-url: https://bogus.objectcomputing.com/slack bot-token: BOGUS_TOKEN + pulse-response: + slack: + signing-secret: BOGUS_SIGNING_SECRET + webhook-url: https://bogus.objectcomputing.com/slack --- aes: key: BOGUS_TEST_KEY --- github-credentials: - github_token: "test github token" \ No newline at end of file + github_token: "test github token" From a11ffaf6bb58841285fe89441fe3d7e1132f1a4e Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Fri, 10 Jan 2025 09:08:16 -0600 Subject: [PATCH 04/10] Handle required and optional values from Pulse submissions via Slack. --- .../SlackPulseResponseConverter.java | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackPulseResponseConverter.java b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackPulseResponseConverter.java index b93c655416..42dd436799 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackPulseResponseConverter.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/SlackPulseResponseConverter.java @@ -35,26 +35,46 @@ public static PulseResponseCreateDTO get( PulseResponseCreateDTO response = new PulseResponseCreateDTO(); response.setTeamMemberId(lookupUser(memberProfileServices, map)); response.setSubmissionDate(LocalDate.now()); + response.setInternalScore(Integer.parseInt( - getMappedValue(values, "internalScore"))); + getMappedValue(values, "internalScore", true))); response.setInternalFeelings( - getMappedValue(values, "internalFeelings")); - response.setExternalScore(Integer.parseInt( - getMappedValue(values, "externalScore"))); + getMappedValue(values, "internalFeelings", false)); + + String score = getMappedValue(values, "externalScore", false); + if (!score.isEmpty()) { + response.setExternalScore(Integer.parseInt(score)); + } response.setExternalFeelings( - getMappedValue(values, "externalFeelings")); + getMappedValue(values, "externalFeelings", false)); return response; } catch(JsonProcessingException ex) { throw new BadArgException(ex.getMessage()); + } catch(NumberFormatException ex) { + throw new BadArgException("Pulse scores must be integers"); } } else { throw new BadArgException("Invalid pulse response body"); } } - private static String getMappedValue(Map map, String key) { - return (String)((Map)map.get(key)).get("value"); + private static String getMappedValue(Map map, + String key, boolean required) { + final String valueKey = "value"; + if (map.containsKey(key)) { + final Map other = (Map)map.get(key); + if (other.containsKey(valueKey)) { + return (String)other.get(valueKey); + } + } + + if (required) { + throw new BadArgException( + String.format("Expected %s.%s was not found", key, valueKey)); + } else { + return ""; + } } private static UUID lookupUser(MemberProfileServices memberProfileServices, From d6c3cdd4f06d783d266e9dc1925780e1ed31607a Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Fri, 10 Jan 2025 09:29:27 -0600 Subject: [PATCH 05/10] Filter out invalid pulse data. --- web-ui/src/pages/PulseReportPage.jsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web-ui/src/pages/PulseReportPage.jsx b/web-ui/src/pages/PulseReportPage.jsx index 73355b17f0..83d4ef0b76 100644 --- a/web-ui/src/pages/PulseReportPage.jsx +++ b/web-ui/src/pages/PulseReportPage.jsx @@ -328,7 +328,13 @@ const PulseReportPage = () => { }); if (res.error) return; - const pulses = res.payload.data; + // Get the pulses and filter out invalid data. + const pulses = res.payload.data.filter((pulse) => { + return pulse.internalScore > 0 && pulse.internalScore <= 5 && + (pulse.externalScore == null || + (pulse.externalScore > 0 && pulse.externalScore <= 5)); + }); + // Sort the pulses on their submission date. pulses.sort((p1, p2) => { const [year1, month1, day1] = p1.submissionDate; From 93dd2a18df0bedfea7ee0cdb97c6bd906bd0fa4a Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Fri, 10 Jan 2025 10:13:27 -0600 Subject: [PATCH 06/10] Ensure pulse response data is verified in expected order. --- .../services/pulseresponse/PulseResponseServicesImpl.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseServicesImpl.java b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseServicesImpl.java index d489aa37f6..dbf2d0d302 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseServicesImpl.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseResponseServicesImpl.java @@ -49,6 +49,7 @@ public PulseResponseServicesImpl( @Override public PulseResponse save(PulseResponse pulseResponse) { if (pulseResponse != null) { + verifyPulseData(pulseResponse); final UUID memberId = pulseResponse.getTeamMemberId(); UUID currentUserId = currentUserServices.getCurrentUser().getId(); if (memberId != null && @@ -80,13 +81,14 @@ public PulseResponse unsecureSave(PulseResponse pulseResponse) { submitted = existing.isPresent(); } if (!submitted) { + verifyPulseData(pulseResponse); return saveCommon(pulseResponse); } } return null; } - private PulseResponse saveCommon(PulseResponse pulseResponse) { + private void verifyPulseData(PulseResponse pulseResponse) { final UUID memberId = pulseResponse.getTeamMemberId(); LocalDate pulseSubDate = pulseResponse.getSubmissionDate(); if (pulseResponse.getId() != null) { @@ -97,7 +99,9 @@ private PulseResponse saveCommon(PulseResponse pulseResponse) { } else if (pulseSubDate.isBefore(LocalDate.EPOCH) || pulseSubDate.isAfter(LocalDate.MAX)) { throw new BadArgException(String.format("Invalid date for pulseresponse submission date %s", memberId)); } + } + private PulseResponse saveCommon(PulseResponse pulseResponse) { PulseResponse pulseResponseRet = pulseResponseRepo.save(pulseResponse); // Send low pulse survey score if appropriate From c26bac2f8b8a696e647fbcdfa73f2cf8f50e6628 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Fri, 10 Jan 2025 12:30:43 -0600 Subject: [PATCH 07/10] Added the signing secret. --- .github/workflows/gradle-build-production.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/gradle-build-production.yml b/.github/workflows/gradle-build-production.yml index 64ab7c4302..fea14dc90a 100644 --- a/.github/workflows/gradle-build-production.yml +++ b/.github/workflows/gradle-build-production.yml @@ -87,6 +87,7 @@ jobs: --set-env-vars "^@^MICRONAUT_ENVIRONMENTS=cloud,google,gcp" \ --set-env-vars "SLACK_WEBHOOK_URL=${{ secrets.SLACK_WEBHOOK_URL }}" \ --set-env-vars "SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }}" \ + --set-env-vars "SLACK_PULSE_SIGNING_SECRET=${{ secrets.SLACK_PULSE_SIGNING_SECRET }}" \ --platform "managed" \ --max-instances 8 \ --allow-unauthenticated From 988eef77a091b93eecf5cc1e46ae30e3ddbc0ccb Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Mon, 13 Jan 2025 15:05:35 -0600 Subject: [PATCH 08/10] Added support for opening the pulse modal via a slash command. --- .github/workflows/gradle-build-production.yml | 1 + .../configuration/CheckInsConfiguration.java | 2 +- .../services/pulse/PulseServicesImpl.java | 4 - .../services/pulse/PulseSlackPoster.java | 59 -------------- .../PulseResponseController.java | 36 ++++++++- .../pulseresponse/PulseSlackCommand.java | 77 +++++++++++++++++++ .../util/form/FormUrlEncodedDecoder.java | 25 ++++++ server/src/main/resources/application.yml | 2 +- .../src/test/resources/application-test.yml | 2 +- 9 files changed, 141 insertions(+), 67 deletions(-) delete mode 100644 server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseSlackPoster.java create mode 100644 server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseSlackCommand.java create mode 100644 server/src/main/java/com/objectcomputing/checkins/util/form/FormUrlEncodedDecoder.java diff --git a/.github/workflows/gradle-build-production.yml b/.github/workflows/gradle-build-production.yml index fea14dc90a..02e3b4236f 100644 --- a/.github/workflows/gradle-build-production.yml +++ b/.github/workflows/gradle-build-production.yml @@ -88,6 +88,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_PULSE_SIGNING_SECRET=${{ secrets.SLACK_PULSE_SIGNING_SECRET }}" \ + --set-env-vars "SLACK_PULSE_BOT_TOKEN=${{ secrets.SLACK_PULSE_BOT_TOKEN }}" \ --platform "managed" \ --max-instances 8 \ --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 2e88f0b507..f8e8d7f92a 100644 --- a/server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java +++ b/server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java @@ -109,7 +109,7 @@ public static class SlackConfig { private String signingSecret; @NotBlank - private String webhookUrl; + private String botToken; } } } diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServicesImpl.java b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServicesImpl.java index 2b73d0ecc9..e19e4d823b 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServicesImpl.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseServicesImpl.java @@ -47,9 +47,6 @@ private class Frequency { @Inject private PulseEmail email; - @Inject - private PulseSlackPoster slackPoster; - private final DayOfWeek emailDay = DayOfWeek.MONDAY; private String setting = "bi-weekly"; @@ -106,7 +103,6 @@ public void notifyUsers(LocalDate check) { if (sent.isEmpty()) { LOG.info("Sending Pulse Email"); email.send(); - slackPoster.send(); automatedEmailRepository.save(new AutomatedEmail(key)); } else { LOG.info("The Pulse Email has already been sent today"); diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseSlackPoster.java b/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseSlackPoster.java deleted file mode 100644 index 0aa353ebf6..0000000000 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulse/PulseSlackPoster.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.objectcomputing.checkins.services.pulse; - -import com.objectcomputing.checkins.configuration.CheckInsConfiguration; - -import io.micronaut.http.HttpRequest; -import io.micronaut.http.client.BlockingHttpClient; -import io.micronaut.http.client.HttpClient; -import io.micronaut.context.annotation.Value; -import io.micronaut.core.io.Readable; -import io.micronaut.core.io.IOUtils; - -import jakarta.inject.Singleton; -import jakarta.inject.Inject; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.BufferedReader; - -@Singleton -public class PulseSlackPoster { - private static final Logger LOG = LoggerFactory.getLogger(PulseSlackPoster.class); - - @Inject - private HttpClient slackClient; - - @Inject - private CheckInsConfiguration configuration; - - @Value("classpath:slack/pulse_slack_blocks.json") - private Readable pulseSlackBlocks; - - public void send() { - String slackBlocks = getSlackBlocks(); - - // See if we can have a webhook URL. - String slackWebHook = configuration.getApplication() - .getPulseResponse() - .getSlack().getWebhookUrl(); - if (slackWebHook != null && !slackBlocks.isEmpty()) { - // POST it to Slack. - HttpRequest request = HttpRequest.POST(slackWebHook, - slackBlocks); - slackClient.exchange(request); - } - } - - private String getSlackBlocks() { - try { - return IOUtils.readText( - new BufferedReader(pulseSlackBlocks.asReader())); - - } catch(Exception ex) { - LOG.error(ex.toString()); - return ""; - } - } -} - 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 a81eaa194c..21eb967971 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,8 +1,10 @@ package com.objectcomputing.checkins.services.pulseresponse; import com.objectcomputing.checkins.exceptions.NotFoundException; +import com.objectcomputing.checkins.util.form.FormUrlEncodedDecoder; import com.objectcomputing.checkins.services.memberprofile.MemberProfileServices; +import io.micronaut.http.MediaType; import io.micronaut.core.annotation.Nullable; import io.micronaut.core.convert.format.Format; import io.micronaut.http.HttpStatus; @@ -27,6 +29,8 @@ 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) @@ -36,13 +40,16 @@ public class PulseResponseController { private final PulseResponseService pulseResponseServices; private final MemberProfileServices memberProfileServices; private final SlackSignatureVerifier slackSignatureVerifier; + private final PulseSlackCommand pulseSlackCommand; public PulseResponseController(PulseResponseService pulseResponseServices, MemberProfileServices memberProfileServices, - SlackSignatureVerifier slackSignatureVerifier) { + SlackSignatureVerifier slackSignatureVerifier, + PulseSlackCommand pulseSlackCommand) { this.pulseResponseServices = pulseResponseServices; this.memberProfileServices = memberProfileServices; this.slackSignatureVerifier = slackSignatureVerifier; + this.pulseSlackCommand = pulseSlackCommand; } /** @@ -105,6 +112,33 @@ public PulseResponse readRole(@NotNull UUID id) { return result; } + @Secured(SecurityRule.IS_ANONYMOUS) + @Post(uri = "/command", consumes = MediaType.APPLICATION_FORM_URLENCODED) + 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(); + } + } + @Secured(SecurityRule.IS_ANONYMOUS) @Post("/external") public HttpResponse externalPulseResponse( diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseSlackCommand.java b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseSlackCommand.java new file mode 100644 index 0000000000..1d77592417 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseSlackCommand.java @@ -0,0 +1,77 @@ +package com.objectcomputing.checkins.services.pulseresponse; + +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; + +import com.slack.api.Slack; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.request.views.ViewsOpenRequest; +import com.slack.api.methods.response.views.ViewsOpenResponse; + +import io.micronaut.context.annotation.Value; +import io.micronaut.core.io.Readable; +import io.micronaut.core.io.IOUtils; + +import jakarta.inject.Singleton; +import jakarta.inject.Inject; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.util.List; + +@Singleton +public class PulseSlackCommand { + private static final Logger LOG = LoggerFactory.getLogger(PulseSlackCommand.class); + + @Inject + private CheckInsConfiguration configuration; + + @Value("classpath:slack/pulse_slack_blocks.json") + private Readable pulseSlackBlocks; + + public boolean send(String triggerId) { + String slackBlocks = getSlackBlocks(); + + // See if we can have a token. + String token = configuration.getApplication() + .getPulseResponse() + .getSlack().getBotToken(); + if (token != null && !slackBlocks.isEmpty()) { + MethodsClient client = Slack.getInstance().methods(token); + + try { + ViewsOpenRequest request = ViewsOpenRequest.builder() + .triggerId(triggerId) + .viewAsString(slackBlocks) + .build(); + + // Send it to Slack + ViewsOpenResponse response = client.viewsOpen(request); + + if (!response.isOk()) { + LOG.error("Unable to open the Pulse view"); + } + + return response.isOk(); + } catch(Exception ex) { + LOG.error(ex.toString()); + return false; + } + } else { + LOG.error("Missing token or missing slack blocks"); + return false; + } + } + + private String getSlackBlocks() { + try { + return IOUtils.readText( + new BufferedReader(pulseSlackBlocks.asReader())); + } catch(Exception ex) { + LOG.error(ex.toString()); + return ""; + } + } +} + diff --git a/server/src/main/java/com/objectcomputing/checkins/util/form/FormUrlEncodedDecoder.java b/server/src/main/java/com/objectcomputing/checkins/util/form/FormUrlEncodedDecoder.java new file mode 100644 index 0000000000..c84356a8e8 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/util/form/FormUrlEncodedDecoder.java @@ -0,0 +1,25 @@ +package com.objectcomputing.checkins.util.form; + +import java.nio.charset.Charset; +import java.net.URLDecoder; +import java.util.HashMap; +import java.util.Map; + +import jakarta.inject.Singleton; + +@Singleton +public class FormUrlEncodedDecoder { + public Map decode(String formUrlEncodedString, Charset charset) { + Map queryParams = new HashMap<>(); + String[] pairs = formUrlEncodedString.split("&"); + for (String pair : pairs) { + int idx = pair.indexOf("="); + if (idx > 0) { + String key = URLDecoder.decode(pair.substring(0, idx), charset); + String value = URLDecoder.decode(pair.substring(idx + 1), charset); + queryParams.put(key, value); + } + } + return queryParams; + } +} diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 88bbc3a8fd..72d6115189 100755 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -104,7 +104,7 @@ check-ins: pulse-response: slack: signing-secret: ${ SLACK_PULSE_SIGNING_SECRET } - webhook-url: ${ SLACK_PULSE_WEBHOOK_URL } + bot-token: ${ SLACK_PULSE_BOT_TOKEN } web-address: ${ WEB_ADDRESS } --- flyway: diff --git a/server/src/test/resources/application-test.yml b/server/src/test/resources/application-test.yml index 47d362d836..c5aca006c0 100644 --- a/server/src/test/resources/application-test.yml +++ b/server/src/test/resources/application-test.yml @@ -48,7 +48,7 @@ check-ins: pulse-response: slack: signing-secret: BOGUS_SIGNING_SECRET - webhook-url: https://bogus.objectcomputing.com/slack + bot-token: BOGUS_TOKEN --- aes: key: BOGUS_TEST_KEY From 7e0a4c9fc71bfaca97e621d09d8581bce5c838a6 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Mon, 13 Jan 2025 15:57:52 -0600 Subject: [PATCH 09/10] Minor cleanup. --- .../checkins/services/pulseresponse/PulseSlackCommand.java | 2 +- .../checkins/util/form/FormUrlEncodedDecoder.java | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseSlackCommand.java b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseSlackCommand.java index 1d77592417..439de89da1 100644 --- a/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseSlackCommand.java +++ b/server/src/main/java/com/objectcomputing/checkins/services/pulseresponse/PulseSlackCommand.java @@ -33,7 +33,7 @@ public class PulseSlackCommand { public boolean send(String triggerId) { String slackBlocks = getSlackBlocks(); - // See if we can have a token. + // See if we have a token. String token = configuration.getApplication() .getPulseResponse() .getSlack().getBotToken(); diff --git a/server/src/main/java/com/objectcomputing/checkins/util/form/FormUrlEncodedDecoder.java b/server/src/main/java/com/objectcomputing/checkins/util/form/FormUrlEncodedDecoder.java index c84356a8e8..f103b3a6e4 100644 --- a/server/src/main/java/com/objectcomputing/checkins/util/form/FormUrlEncodedDecoder.java +++ b/server/src/main/java/com/objectcomputing/checkins/util/form/FormUrlEncodedDecoder.java @@ -5,9 +5,6 @@ import java.util.HashMap; import java.util.Map; -import jakarta.inject.Singleton; - -@Singleton public class FormUrlEncodedDecoder { public Map decode(String formUrlEncodedString, Charset charset) { Map queryParams = new HashMap<>(); From 931663eb4a8d1efda54b21a1b2c9f69549ecd46b Mon Sep 17 00:00:00 2001 From: Michael Kimberlin Date: Thu, 30 Jan 2025 14:08:45 -0600 Subject: [PATCH 10/10] Adjust development builds to include new secrets --- .github/workflows/gradle-deploy-develop.yml | 2 ++ .github/workflows/gradle-deploy-native-develop.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/gradle-deploy-develop.yml b/.github/workflows/gradle-deploy-develop.yml index ba2dddfc47..dde66083c0 100644 --- a/.github/workflows/gradle-deploy-develop.yml +++ b/.github/workflows/gradle-deploy-develop.yml @@ -112,6 +112,8 @@ jobs: --set-env-vars "^@^MICRONAUT_ENVIRONMENTS=dev,cloud,google,gcp" \ --set-env-vars "SLACK_WEBHOOK_URL=${{ secrets.SLACK_WEBHOOK_URL }}" \ --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 }}" \ --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 cb40d97f93..802d2431bf 100644 --- a/.github/workflows/gradle-deploy-native-develop.yml +++ b/.github/workflows/gradle-deploy-native-develop.yml @@ -111,6 +111,8 @@ jobs: --set-env-vars "^@^MICRONAUT_ENVIRONMENTS=dev,cloud,google,gcp" \ --set-env-vars "SLACK_WEBHOOK_URL=${{ secrets.SLACK_WEBHOOK_URL }}" \ --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 }}" \ --platform "managed" \ --max-instances 2 \ --allow-unauthenticated