diff --git a/.github/workflows/gradle-build-production.yml b/.github/workflows/gradle-build-production.yml index 6d21be4b68..64ab7c4302 100644 --- a/.github/workflows/gradle-build-production.yml +++ b/.github/workflows/gradle-build-production.yml @@ -85,6 +85,8 @@ jobs: --set-env-vars "FROM_ADDRESS=no-reply@objectcomputing.com" \ --set-env-vars "FROM_NAME=Check-Ins" \ --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 }}" \ --platform "managed" \ --max-instances 8 \ --allow-unauthenticated diff --git a/.github/workflows/gradle-deploy-develop.yml b/.github/workflows/gradle-deploy-develop.yml index 2a17c12cbf..ba2dddfc47 100644 --- a/.github/workflows/gradle-deploy-develop.yml +++ b/.github/workflows/gradle-deploy-develop.yml @@ -110,6 +110,8 @@ jobs: --set-env-vars "FROM_ADDRESS=no-reply@objectcomputing.com" \ --set-env-vars "FROM_NAME=Check-Ins - DEVELOP" \ --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 }}" \ --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 c82cc23bf7..cb40d97f93 100644 --- a/.github/workflows/gradle-deploy-native-develop.yml +++ b/.github/workflows/gradle-deploy-native-develop.yml @@ -109,6 +109,8 @@ jobs: --set-env-vars "FROM_ADDRESS=kimberlinm@objectcomputing.com" \ --set-env-vars "FROM_NAME=Check-Ins - DEVELOP" \ --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 }}" \ --platform "managed" \ --max-instances 2 \ --allow-unauthenticated diff --git a/server/build.gradle b/server/build.gradle index 9b829a7f34..ca4887710a 100755 --- a/server/build.gradle +++ b/server/build.gradle @@ -117,6 +117,7 @@ dependencies { implementation("io.micrometer:context-propagation") implementation 'ch.digitalfondue.mjml4j:mjml4j:1.0.3' + implementation("com.slack.api:slack-api-client:1.44.1") testRuntimeOnly "org.seleniumhq.selenium:selenium-chrome-driver:$seleniumVersion" testRuntimeOnly "org.seleniumhq.selenium:selenium-firefox-driver:$seleniumVersion" 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 b1c707a559..6c58600b40 100644 --- a/server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java +++ b/server/src/main/java/com/objectcomputing/checkins/configuration/CheckInsConfiguration.java @@ -31,6 +31,9 @@ public static class ApplicationConfig { @NotNull private GoogleApiConfig googleApi; + @NotNull + private NotificationsConfig notifications; + @Getter @Setter @ConfigurationProperties("feedback") @@ -66,5 +69,25 @@ public static class ScopeConfig { private String scopeForDirectoryApi; } } + + @Getter + @Setter + @ConfigurationProperties("notifications") + public static class NotificationsConfig { + + @NotNull + private SlackConfig slack; + + @Getter + @Setter + @ConfigurationProperties("slack") + public static class SlackConfig { + @NotBlank + private String webhookUrl; + + @NotBlank + private String botToken; + } + } } } diff --git a/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackPoster.java b/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackPoster.java new file mode 100644 index 0000000000..be08f13e14 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackPoster.java @@ -0,0 +1,37 @@ +package com.objectcomputing.checkins.notifications.social_media; + +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.HttpClient; + +import jakarta.inject.Singleton; +import jakarta.inject.Inject; + +import java.util.List; + +@Singleton +public class SlackPoster { + @Inject + private HttpClient slackClient; + + @Inject + private CheckInsConfiguration configuration; + + public HttpResponse post(String slackBlock) { + // See if we can have a webhook URL. + String slackWebHook = configuration.getApplication().getNotifications().getSlack().getWebhookUrl(); + if (slackWebHook != null) { + // POST it to Slack. + BlockingHttpClient client = slackClient.toBlocking(); + HttpRequest request = HttpRequest.POST(slackWebHook, + slackBlock); + return client.exchange(request); + } + return HttpResponse.status(HttpStatus.GONE, + "Slack Webhook URL is not configured"); + } +} + diff --git a/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackSearch.java b/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackSearch.java new file mode 100644 index 0000000000..526fdf6a05 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackSearch.java @@ -0,0 +1,80 @@ +package com.objectcomputing.checkins.notifications.social_media; + +import com.objectcomputing.checkins.configuration.CheckInsConfiguration; +import com.slack.api.model.block.LayoutBlock; +import com.slack.api.Slack; +import com.slack.api.methods.MethodsClient; +import com.slack.api.model.Conversation; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.request.conversations.ConversationsListRequest; +import com.slack.api.methods.response.conversations.ConversationsListResponse; +import com.slack.api.methods.request.users.UsersLookupByEmailRequest; +import com.slack.api.methods.response.users.UsersLookupByEmailResponse; + +import jakarta.inject.Singleton; +import jakarta.inject.Inject; + +import java.util.List; +import java.io.IOException; + +import jnr.ffi.annotations.In; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Singleton +public class SlackSearch { + private static final Logger LOG = LoggerFactory.getLogger(SlackSearch.class); + + private CheckInsConfiguration configuration; + + public SlackSearch(CheckInsConfiguration checkInsConfiguration) { + this.configuration = checkInsConfiguration; + } + + public String findChannelId(String channelName) { + String token = configuration.getApplication().getNotifications().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.getName().equals(channelName)) { + return conversation.getId(); + } + } + } + } catch(IOException e) { + LOG.error("SlackSearch.findChannelId: " + e.toString()); + } catch(SlackApiException e) { + LOG.error("SlackSearch.findChannelId: " + e.toString()); + } + } + return null; + } + + public String findUserId(String userEmail) { + String token = configuration.getApplication().getNotifications().getSlack().getBotToken(); + if (token != null) { + try { + MethodsClient client = Slack.getInstance().methods(token); + UsersLookupByEmailResponse response = client.usersLookupByEmail( + UsersLookupByEmailRequest.builder().email(userEmail).build() + ); + + if (response.isOk()) { + return response.getUser().getId(); + } + } catch(IOException e) { + LOG.error("SlackSearch.findUserId: " + e.toString()); + } catch(SlackApiException e) { + LOG.error("SlackSearch.findUserId: " + e.toString()); + } + } + return null; + } +} + 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 new file mode 100644 index 0000000000..798cae7974 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosConverter.java @@ -0,0 +1,114 @@ +package com.objectcomputing.checkins.services.kudos; + +import com.objectcomputing.checkins.notifications.social_media.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; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileUtils; +import com.objectcomputing.checkins.services.memberprofile.MemberProfile; + +import com.slack.api.model.block.LayoutBlock; +import com.slack.api.model.block.RichTextBlock; +import com.slack.api.model.block.element.RichTextElement; +import com.slack.api.model.block.element.RichTextSectionElement; +import com.slack.api.util.json.GsonFactory; +import com.google.gson.Gson; + +import jakarta.inject.Singleton; + +import java.util.UUID; +import java.util.List; +import java.util.ArrayList; + +@Singleton +public class KudosConverter { + private record InternalBlock( + List blocks + ) {} + + private final MemberProfileServices memberProfileServices; + private final KudosRecipientServices kudosRecipientServices; + private final SlackSearch slackSearch; + + public KudosConverter(MemberProfileServices memberProfileServices, + KudosRecipientServices kudosRecipientServices, + SlackSearch slackSearch) { + this.memberProfileServices = memberProfileServices; + this.kudosRecipientServices = kudosRecipientServices; + this.slackSearch = slackSearch; + } + + public String toSlackBlock(Kudos kudos) { + // Build the message text out of the Kudos data. + List content = new ArrayList<>(); + content.add( + RichTextSectionElement.Text.builder() + .text("Kudos from ") + .style(boldItalic()) + .build() + ); + content.add(memberAsRichText(kudos.getSenderId())); + content.addAll(recipients(kudos)); + + content.add( + RichTextSectionElement.Text.builder() + .text("\n" + kudos.getMessage() + "\n") + .style(boldItalic()) + .build() + ); + + // Bring it all together. + RichTextSectionElement element = RichTextSectionElement.builder() + .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); + } + + private RichTextSectionElement.TextStyle boldItalic() { + return RichTextSectionElement.TextStyle.builder() + .bold(true).italic(true).build(); + } + + private RichTextSectionElement.LimitedTextStyle limitedBoldItalic() { + return RichTextSectionElement.LimitedTextStyle.builder() + .bold(true).italic(true).build(); + } + + private RichTextElement memberAsRichText(UUID memberId) { + // Look up the user id by email address on Slack + MemberProfile profile = memberProfileServices.getById(memberId); + String userId = slackSearch.findUserId(profile.getWorkEmail()); + if (userId == null) { + String name = MemberProfileUtils.getFullName(profile); + return RichTextSectionElement.Text.builder() + .text("@" + name) + .style(boldItalic()) + .build(); + } else { + return RichTextSectionElement.User.builder() + .userId(userId) + .style(limitedBoldItalic()) + .build(); + } + } + + private List recipients(Kudos kudos) { + List list = new ArrayList<>(); + List recipients = + kudosRecipientServices.getAllByKudosId(kudos.getId()); + String separator = " to "; + for (KudosRecipient recipient : recipients) { + list.add(RichTextSectionElement.Text.builder() + .text(separator) + .style(boldItalic()) + .build()); + list.add(memberAsRichText(recipient.getMemberId())); + separator = ", "; + } + return list; + } +} + 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 c672dbd439..64750cbba0 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 @@ -3,6 +3,7 @@ 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.exceptions.BadArgException; import com.objectcomputing.checkins.exceptions.NotFoundException; import com.objectcomputing.checkins.exceptions.PermissionException; @@ -21,6 +22,9 @@ import com.objectcomputing.checkins.util.Util; 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 org.slf4j.Logger; @@ -49,6 +53,8 @@ class KudosServicesImpl implements KudosServices { private final CheckInsConfiguration checkInsConfiguration; private final RoleServices roleServices; private final MemberProfileServices memberProfileServices; + private final SlackPoster slackPoster; + private final KudosConverter converter; private enum NotificationType { creation, approval @@ -63,7 +69,10 @@ private enum NotificationType { RoleServices roleServices, MemberProfileServices memberProfileServices, @Named(MailJetFactory.HTML_FORMAT) EmailSender emailSender, - CheckInsConfiguration checkInsConfiguration) { + CheckInsConfiguration checkInsConfiguration, + SlackPoster slackPoster, + KudosConverter converter + ) { this.kudosRepository = kudosRepository; this.kudosRecipientServices = kudosRecipientServices; this.kudosRecipientRepository = kudosRecipientRepository; @@ -74,6 +83,8 @@ private enum NotificationType { this.currentUserServices = currentUserServices; this.emailSender = emailSender; this.checkInsConfiguration = checkInsConfiguration; + this.slackPoster = slackPoster; + this.converter = converter; } @Override @@ -341,6 +352,7 @@ private void sendNotification(Kudos kudos, NotificationType notificationType) { recipientAddresses.add(member.getWorkEmail()); } } + slackApprovedKudos(kudos); break; case NotificationType.creation: content = getAdminEmailContent(checkInsConfiguration); @@ -366,4 +378,12 @@ private void sendNotification(Kudos kudos, NotificationType notificationType) { LOG.error("An unexpected error occurred while sending notifications: {}", ex.getLocalizedMessage(), ex); } } + + 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()); + } + } } diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index fd8c2ea7f9..99148dcede 100755 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -97,6 +97,10 @@ check-ins: feedback: max-suggestions: 6 request-subject: "Feedback request" + notifications: + slack: + webhook-url: ${ SLACK_WEBHOOK_URL } + bot-token: ${ SLACK_BOT_TOKEN } web-address: ${ WEB_ADDRESS } --- flyway: diff --git a/server/src/test/java/com/objectcomputing/checkins/services/SlackPosterReplacement.java b/server/src/test/java/com/objectcomputing/checkins/services/SlackPosterReplacement.java new file mode 100644 index 0000000000..c4283cc651 --- /dev/null +++ b/server/src/test/java/com/objectcomputing/checkins/services/SlackPosterReplacement.java @@ -0,0 +1,31 @@ +package com.objectcomputing.checkins.services; + +import com.objectcomputing.checkins.notifications.social_media.SlackPoster; + +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 jakarta.inject.Singleton; + +import java.util.List; +import java.util.ArrayList; + +@Singleton +@Replaces(SlackPoster.class) +@Requires(property = "replace.slackposter", value = StringUtils.TRUE) +public class SlackPosterReplacement extends SlackPoster { + public final List posted = new ArrayList<>(); + + public void reset() { + posted.clear(); + } + + public HttpResponse post(String slackBlock) { + posted.add(slackBlock); + return HttpResponse.status(HttpStatus.OK); + } +} + diff --git a/server/src/test/java/com/objectcomputing/checkins/services/fixture/FeedbackRequestFixture.java b/server/src/test/java/com/objectcomputing/checkins/services/fixture/FeedbackRequestFixture.java index bda6a1134b..29c427f94d 100644 --- a/server/src/test/java/com/objectcomputing/checkins/services/fixture/FeedbackRequestFixture.java +++ b/server/src/test/java/com/objectcomputing/checkins/services/fixture/FeedbackRequestFixture.java @@ -68,7 +68,7 @@ default LocalDate getRandomLocalDateTime(LocalDateTime start, LocalDateTime end) LocalDate startDate = start.toLocalDate(); long daysBetween = ChronoUnit.DAYS.between(startDate, end.toLocalDate()); Random random = new Random(); - long randomDays = random.nextLong(daysBetween); + long randomDays = daysBetween > 0 ? random.nextLong(daysBetween) : 0; return startDate.plusDays(randomDays); } 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 92975598a4..e166954846 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,12 +3,14 @@ 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.TestContainersSuite; import com.objectcomputing.checkins.services.fixture.KudosFixture; import com.objectcomputing.checkins.services.fixture.TeamFixture; import com.objectcomputing.checkins.services.fixture.RoleFixture; import com.objectcomputing.checkins.services.kudos.kudos_recipient.KudosRecipient; import com.objectcomputing.checkins.services.kudos.kudos_recipient.KudosRecipientServicesImpl; +import com.objectcomputing.checkins.services.memberprofile.MemberProfileUtils; import com.objectcomputing.checkins.services.memberprofile.MemberProfile; import com.objectcomputing.checkins.services.team.Team; import io.micronaut.core.type.Argument; @@ -28,6 +30,13 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.api.condition.DisabledInNativeImage; + +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 java.time.LocalDate; import java.util.Collections; @@ -46,12 +55,19 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +// Disabled in nativetest due to a ReflectiveOperationException from Gson +// when attempting to post public Kudos to Slack. +@DisabledInNativeImage @Property(name = "replace.mailjet.factory", value = StringUtils.TRUE) +@Property(name = "replace.slackposter", value = StringUtils.TRUE) class KudosControllerTest extends TestContainersSuite implements KudosFixture, TeamFixture, RoleFixture { @Inject @Named(MailJetFactory.HTML_FORMAT) private MailJetFactoryReplacement.MockEmailSender emailSender; + @Inject + private SlackPosterReplacement slackPoster; + @Inject @Client("/services/kudos") HttpClient httpClient; @@ -89,6 +105,7 @@ void setUp() { message = "Kudos!"; emailSender.reset(); + slackPoster.reset(); } @ParameterizedTest @@ -206,7 +223,7 @@ void testCreateKudosWithEmptyRecipientMembers() { } @Test - void testApproveKudos() { + void testApproveKudos() throws JsonProcessingException { Kudos kudos = createPublicKudos(senderId); assertNull(kudos.getDateApproved()); KudosRecipient recipient = createKudosRecipient(kudos.getId(), recipientMembers.getFirst().getId()); @@ -227,6 +244,52 @@ void testApproveKudos() { ), emailSender.events.getFirst() ); + + // Check the posted slack block + assertEquals(1, slackPoster.posted.size()); + ObjectMapper mapper = new ObjectMapper(); + JsonNode posted = mapper.readTree(slackPoster.posted.get(0)); + + assertEquals(JsonNodeType.OBJECT, posted.getNodeType()); + JsonNode blocks = posted.get("blocks"); + assertEquals(JsonNodeType.ARRAY, blocks.getNodeType()); + + var iter = blocks.elements(); + assertTrue(iter.hasNext()); + JsonNode block = iter.next(); + + assertEquals(JsonNodeType.OBJECT, block.getNodeType()); + JsonNode elements = block.get("elements"); + assertEquals(JsonNodeType.ARRAY, elements.getNodeType()); + + iter = elements.elements(); + assertTrue(iter.hasNext()); + JsonNode element = iter.next(); + + assertEquals(JsonNodeType.OBJECT, element.getNodeType()); + JsonNode innerElements = element.get("elements"); + assertEquals(JsonNodeType.ARRAY, innerElements.getNodeType()); + + iter = innerElements.elements(); + assertTrue(iter.hasNext()); + + // The real SlackPoster will look up user ids in Slack and use those in + // the posted message. Failing the lookup, it will use @. + String from = "@" + MemberProfileUtils.getFullName(sender); + String to = "@" + MemberProfileUtils.getFullName(recipientMembers.get(0)); + boolean foundFrom = false; + boolean foundTo = false; + while(iter.hasNext()) { + element = iter.next(); + assertEquals(JsonNodeType.OBJECT, element.getNodeType()); + String value = element.get("text").asText(); + if (value.equals(from)) { + foundFrom = true; + } else if (value.equals(to)) { + foundTo = true; + } + } + assertTrue(foundFrom && foundTo); } @Test diff --git a/server/src/test/resources/application-test.yml b/server/src/test/resources/application-test.yml index 5ffff78794..c6fca9bd81 100644 --- a/server/src/test/resources/application-test.yml +++ b/server/src/test/resources/application-test.yml @@ -41,6 +41,10 @@ check-ins: application: google-api: delegated-user: test@objectcomputing.com + notifications: + slack: + webhook-url: https://bogus.objectcomputing.com/slack + bot-token: BOGUS_TOKEN --- aes: key: BOGUS_TEST_KEY