diff --git a/server/build.gradle b/server/build.gradle index 8c70f18a6..64dedca68 100755 --- a/server/build.gradle +++ b/server/build.gradle @@ -116,6 +116,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/notifications/social_media/SlackPoster.java b/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackPoster.java new file mode 100644 index 000000000..0d561b2f0 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackPoster.java @@ -0,0 +1,33 @@ +package com.objectcomputing.checkins.notifications.social_media; + +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; + + public HttpResponse post(String slackBlock) { + // See if we can have a webhook URL. + String slackWebHook = System.getenv("SLACK_WEBHOOK_URL"); + 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 000000000..58ae30c75 --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/notifications/social_media/SlackSearch.java @@ -0,0 +1,73 @@ +package com.objectcomputing.checkins.notifications.social_media; + +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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Singleton +public class SlackSearch { + private static final Logger LOG = LoggerFactory.getLogger(SlackSearch.class); + private static final String env = "SLACK_BOT_TOKEN"; + + public String findChannelId(String channelName) { + String token = System.getenv(env); + 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 = System.getenv(env); + 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 000000000..ee3ff465f --- /dev/null +++ b/server/src/main/java/com/objectcomputing/checkins/services/kudos/KudosConverter.java @@ -0,0 +1,130 @@ +package com.objectcomputing.checkins.services.kudos; + +import com.objectcomputing.checkins.notifications.social_media.SlackPoster; +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 java.util.UUID; +import java.util.List; +import java.util.ArrayList; + +public class KudosConverter { + private record InternalBlock( + List blocks + ) {} + + private final MemberProfileServices memberProfileServices; + private final KudosRecipientServices kudosRecipientServices; + + public KudosConverter(MemberProfileServices memberProfileServices, + KudosRecipientServices kudosRecipientServices) { + this.memberProfileServices = memberProfileServices; + this.kudosRecipientServices = kudosRecipientServices; + } + + public String toSlackBlock(Kudos kudos) { + // Build the message text out of the Kudos data. + List content = new ArrayList<>(); + + // Look up the channel id from Slack + String channelName = "kudos"; + SlackSearch search = new SlackSearch(); + String channelId = search.findChannelId(channelName); + if (channelId == null) { + content.add( + RichTextSectionElement.Text.builder() + .text("#" + channelName) + .style(boldItalic()) + .build() + ); + } else { + content.add( + RichTextSectionElement.Channel.builder() + .channelId(channelId) + .style(limitedBoldItalic()) + .build() + ); + } + content.add( + RichTextSectionElement.Text.builder() + .text(" 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 + SlackSearch search = new SlackSearch(); + MemberProfile profile = memberProfileServices.getById(memberId); + String userId = search.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 c672dbd43..8d429b8aa 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,7 @@ class KudosServicesImpl implements KudosServices { private final CheckInsConfiguration checkInsConfiguration; private final RoleServices roleServices; private final MemberProfileServices memberProfileServices; + private final SlackPoster slackPoster; private enum NotificationType { creation, approval @@ -63,7 +68,8 @@ private enum NotificationType { RoleServices roleServices, MemberProfileServices memberProfileServices, @Named(MailJetFactory.HTML_FORMAT) EmailSender emailSender, - CheckInsConfiguration checkInsConfiguration) { + CheckInsConfiguration checkInsConfiguration, + SlackPoster slackPoster) { this.kudosRepository = kudosRepository; this.kudosRecipientServices = kudosRecipientServices; this.kudosRecipientRepository = kudosRecipientRepository; @@ -74,6 +80,7 @@ private enum NotificationType { this.currentUserServices = currentUserServices; this.emailSender = emailSender; this.checkInsConfiguration = checkInsConfiguration; + this.slackPoster = slackPoster; } @Override @@ -341,6 +348,7 @@ private void sendNotification(Kudos kudos, NotificationType notificationType) { recipientAddresses.add(member.getWorkEmail()); } } + slackApprovedKudos(kudos); break; case NotificationType.creation: content = getAdminEmailContent(checkInsConfiguration); @@ -366,4 +374,16 @@ private void sendNotification(Kudos kudos, NotificationType notificationType) { LOG.error("An unexpected error occurred while sending notifications: {}", ex.getLocalizedMessage(), ex); } } + + private void slackApprovedKudos(Kudos kudos) { + KudosConverter converter = new KudosConverter(memberProfileServices, + kudosRecipientServices); + + String slackBlock = converter.toSlackBlock(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/test/java/com/objectcomputing/checkins/services/SlackPosterReplacement.java b/server/src/test/java/com/objectcomputing/checkins/services/SlackPosterReplacement.java new file mode 100644 index 000000000..c4283cc65 --- /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 bda6a1134..29c427f94 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 92975598a..e16695484 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