diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/slack/SlackSlashCommandController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/slack/SlackSlashCommandController.kt index 3c9ae4f12f..a56b837680 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/slack/SlackSlashCommandController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/slack/SlackSlashCommandController.kt @@ -4,6 +4,7 @@ import com.slack.api.model.block.LayoutBlock import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.component.SlackRequestValidation import io.tolgee.component.automations.processors.slackIntegration.* +import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.dtos.request.slack.SlackCommandDto import io.tolgee.dtos.response.SlackMessageDto import io.tolgee.dtos.slackintegration.SlackConfigDto @@ -42,6 +43,7 @@ class SlackSlashCommandController( private val slackErrorProvider: SlackErrorProvider, private val slackExceptionHandler: SlackExceptionHandler, private val slackHelpBlocksProvider: SlackHelpBlocksProvider, + private val tolgeeProperties: TolgeeProperties, ) : Logging { @Suppress("UastIncorrectHttpHeaderInspection") @PostMapping @@ -54,6 +56,8 @@ class SlackSlashCommandController( return slackExceptionHandler.handle { slackRequestValidation.validate(slackSignature, timestamp, body) + checkIfTokenIsPresent(payload.team_id) + val matchResult = commandRegex.matchEntire(payload.text) ?: throw SlackErrorException(slackErrorProvider.getInvalidCommandError()) @@ -87,6 +91,16 @@ class SlackSlashCommandController( } } + private fun checkIfTokenIsPresent(teamId: String) { + if (tolgeeProperties.slack.token != null) { + return + } + + organizationSlackWorkspaceService.findBySlackTeamId( + teamId, + ) ?: throw SlackErrorException(slackErrorProvider.getWorkspaceNotFoundError()) + } + private fun String?.toLongOrThrowInvalidCommand(): Long { return this?.toLongOrNull() ?: throw SlackErrorException(slackErrorProvider.getInvalidCommandError()) } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/slack/SlackLoginControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/slack/SlackLoginControllerTest.kt index 6da062d00d..f21d671126 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/slack/SlackLoginControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/slack/SlackLoginControllerTest.kt @@ -1,5 +1,6 @@ package io.tolgee.api.v2.controllers.slack +import io.tolgee.component.automations.processors.slackIntegration.SlackUserLoginUrlProvider import io.tolgee.development.testDataBuilder.data.SlackTestData import io.tolgee.fixtures.andIsOk import io.tolgee.service.slackIntegration.SlackUserConnectionService @@ -13,36 +14,22 @@ class SlackLoginControllerTest : AuthorizedControllerTest() { @Autowired lateinit var slackUserConnectionService: SlackUserConnectionService + @Autowired + lateinit var slackUserLoginUrlProvider: SlackUserLoginUrlProvider + @BeforeAll fun setUp() { tolgeeProperties.slack.token = "token" } @Test - fun `user log in`() { + fun `user logs in`() { val testData = SlackTestData() testDataService.saveTestData(testData.root) - performAuthPost( - "/v2/slack/user-login", - mapOf( - "slackId" to testData.slackUserConnection.slackUserId, - "channelId" to "TEST", - "workspaceId" to testData.slackWorkspace.id, - ), - ).andIsOk - } - @Test - fun `user does not log in and creates new connection`() { - Assertions.assertThat(slackUserConnectionService.findBySlackId("TEST1")).isNull() - performAuthPost( - "/v2/slack/user-login", - mapOf( - "slackId" to "TEST1", - "channelId" to "TEST", - "workspaceId" to 1, - ), - ).andIsOk + slackUserLoginUrlProvider.encryptData("ChannelTest", "TEST1", testData.slackWorkspace.id).let { + performAuthPost("/v2/slack/user-login?data=$it", null).andIsOk + } Assertions.assertThat(slackUserConnectionService.findBySlackId("TEST1")).isNotNull() } diff --git a/backend/app/src/test/kotlin/io/tolgee/service/slack/SavedSlackMessageServiceTest.kt b/backend/app/src/test/kotlin/io/tolgee/service/slack/SavedSlackMessageServiceTest.kt new file mode 100644 index 0000000000..7d829d3102 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/service/slack/SavedSlackMessageServiceTest.kt @@ -0,0 +1,34 @@ +package io.tolgee.service.slack + +import io.tolgee.AbstractSpringTest +import io.tolgee.development.testDataBuilder.data.SlackTestData +import io.tolgee.testing.assertions.Assertions +import io.tolgee.util.addMinutes +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test + +class SavedSlackMessageServiceTest : AbstractSpringTest() { + @AfterEach + fun after() { + currentDateProvider.forcedDate = null + } + + @Test + fun `deletes old messages`() { + val testData = SlackTestData() + testDataService.saveTestData(testData.root) + currentDateProvider.forcedDate = currentDateProvider.date.addMinutes(125) + + savedSlackMessageService.deleteOldMessage() + Assertions.assertThat(savedSlackMessageService.findAll()).isEmpty() + } + + @Test + fun `finds messages`() { + val testData = SlackTestData() + testDataService.saveTestData(testData.root) + val result = savedSlackMessageService.find(0L, testData.slackConfig.id) + + Assertions.assertThat(result).hasSize(2) + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/service/slack/SlackConfigServiceTest.kt b/backend/app/src/test/kotlin/io/tolgee/service/slack/SlackConfigServiceTest.kt new file mode 100644 index 0000000000..6723fbf209 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/service/slack/SlackConfigServiceTest.kt @@ -0,0 +1,33 @@ +package io.tolgee.service.slack + +import io.tolgee.AbstractSpringTest +import io.tolgee.development.testDataBuilder.data.SlackTestData +import io.tolgee.dtos.slackintegration.SlackConfigDto +import io.tolgee.model.slackIntegration.EventName +import io.tolgee.testing.assertions.Assertions +import org.junit.jupiter.api.Test + +class SlackConfigServiceTest : AbstractSpringTest() { + @Test + fun `deletes configs`() { + val testData = SlackTestData() + testDataService.saveTestData(testData.root) + slackConfigService.delete(testData.projectBuilder.self.id, testData.slackConfig.channelId) + Assertions.assertThat(slackConfigService.findAll()).isEmpty() + } + + @Test + fun `creates new config`() { + val testData = SlackTestData() + testDataService.saveTestData(testData.root) + val slackConfigDto = + SlackConfigDto( + project = testData.projectBuilder.self, + channelId = "testChannel2", + userAccount = testData.user, + onEvent = EventName.ALL, + ) + slackConfigService.createOrUpdate(slackConfigDto) + Assertions.assertThat(slackConfigService.findAll()).hasSize(2) + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/slack/SlackIntegrationTest.kt b/backend/app/src/test/kotlin/io/tolgee/slack/SlackIntegrationTest.kt index 3845a705de..8e13757ef9 100644 --- a/backend/app/src/test/kotlin/io/tolgee/slack/SlackIntegrationTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/slack/SlackIntegrationTest.kt @@ -1,40 +1,175 @@ package io.tolgee.slack +import com.slack.api.RequestConfigurator import com.slack.api.Slack import com.slack.api.methods.MethodsClient -import io.tolgee.AbstractSpringTest +import com.slack.api.methods.request.chat.ChatPostMessageRequest +import com.slack.api.methods.request.users.UsersLookupByEmailRequest +import com.slack.api.methods.response.chat.ChatPostMessageResponse +import com.slack.api.methods.response.users.UsersLookupByEmailResponse +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.development.testDataBuilder.data.SlackTestData +import io.tolgee.dtos.slackintegration.SlackConfigDto +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.waitForNotThrowing +import io.tolgee.model.slackIntegration.EventName +import io.tolgee.service.slackIntegration.SavedSlackMessageService import io.tolgee.testing.assert +import io.tolgee.testing.assertions.Assertions import io.tolgee.util.Logging import org.junit.jupiter.api.Test +import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.mock.mockito.MockBean +import java.util.* -class SlackIntegrationTest : AbstractSpringTest(), Logging { +class SlackIntegrationTest : ProjectAuthControllerTest(), Logging { @Autowired @MockBean lateinit var slackClient: Slack + lateinit var mockedSlackClient: MockedSlackClient + + @Autowired + lateinit var slackMessageService: SavedSlackMessageService + @Test - fun `it mocks slack client`() { - val mockedSlackClient = mockSlackClient() + fun `sends message to correct channel after translation changed`() { + val testData = SlackTestData() + testDataService.saveTestData(testData.root) + mockedSlackClient = mockSlackClient() + + val langTag = testData.projectBuilder.self.baseLanguage?.tag ?: "" + loginAsUser(testData.user.username) + + Mockito.clearInvocations(mockedSlackClient.methodsClientMock) + waitForNotThrowing(timeout = 3000) { + modifyTranslationData(testData.projectBuilder.self.id, langTag) + val request = mockedSlackClient.chatPostMessageRequests.single() + request.channel.assert.isEqualTo(testData.slackConfig.channelId) + } + Assertions.assertThat(slackMessageService.findByKey(testData.key.id, testData.slackConfig.id)).hasSize(1) + } + + @Test + fun `sends message to correct channel after key added`() { + val testData = SlackTestData() + testDataService.saveTestData(testData.root) + mockedSlackClient = mockSlackClient() + + loginAsUser(testData.user.username) + waitForNotThrowing(timeout = 3000) { + addKeyToProject(testData.projectBuilder.self.id) + mockedSlackClient.chatPostMessageRequests.assert.hasSize(1) + val request = mockedSlackClient.chatPostMessageRequests.single() + request.channel.assert.isEqualTo(testData.slackConfig.channelId) + } + } + + @Test + fun `Doesn't send a message if the subscription isn't global and modified language isn't in preferred languages`() { + val testData = SlackTestData() + testDataService.saveTestData(testData.root) + mockedSlackClient = mockSlackClient() + + val updatedConfig = + SlackConfigDto( + project = testData.projectBuilder.self, + slackId = "testSlackId", + channelId = "testChannel", + userAccount = testData.user, + languageTag = "fr", + onEvent = EventName.ALL, + slackTeamId = "", + ) + slackConfigService.delete(testData.slackConfig.project.id, "testChannel") + val config = slackConfigService.createOrUpdate(updatedConfig) - slackClient.methods("ahhs").chatPostMessage { - it.channel("channel") - it.text("text") + loginAsUser(testData.user.username) + + modifyTranslationData(testData.projectBuilder.self.id, "cs") + mockedSlackClient.chatPostMessageRequests.assert.hasSize(0) + slackMessageService.findByKey(testData.key.id, config.id).forEach { + it.langTags.assert.doesNotContain("cs") } + } + + @Test + fun `Doesn't send a message if the event isn't in subscribed by user`() { + val testData = SlackTestData() + testDataService.saveTestData(testData.root) + mockedSlackClient = mockSlackClient() - mockedSlackClient.chatPostMessageRequests.assert.hasSize(1) - val request = mockedSlackClient.chatPostMessageRequests.single() - request.channel.assert.isEqualTo("channel") - request.text.assert.isEqualTo("text") + val updatedConfig = + SlackConfigDto( + project = testData.projectBuilder.self, + slackId = "testSlackId", + channelId = "testChannel", + userAccount = testData.user, + languageTag = "en", + onEvent = EventName.TRANSLATION_CHANGED, + slackTeamId = "", + ) + slackConfigService.delete(testData.slackConfig.project.id, "testChannel") + val config = slackConfigService.createOrUpdate(updatedConfig) + + loginAsUser(testData.user.username) + + addKeyToProject(testData.projectBuilder.self.id) + mockedSlackClient.chatPostMessageRequests.assert.hasSize(0) + slackMessageService.findByKey(testData.key.id, config.id).forEach { + it.langTags.assert.doesNotContain("en") + } } fun mockSlackClient(): MockedSlackClient { val methodsClientMock = mock() whenever(slackClient.methods(any())).thenReturn(methodsClientMock) + val mockPostMessageResponse = mock() + whenever(mockPostMessageResponse.isOk).thenReturn(true) + whenever(mockPostMessageResponse.ts).thenReturn("ts") + + val mockUsersResponse = mock() + whenever(mockUsersResponse.isOk).thenReturn(true) + + whenever( + methodsClientMock.chatPostMessage( + any>(), + ), + ).thenReturn(mockPostMessageResponse) + + whenever( + methodsClientMock.usersLookupByEmail( + any>(), + ), + ).thenReturn(mockUsersResponse) + return MockedSlackClient(methodsClientMock) } + + private fun modifyTranslationData( + projectId: Long, + landTag: String, + ) { + performAuthPost( + "/v2/projects/$projectId/translations", + mapOf( + "key" to "testKey", + "translations" to mapOf(landTag to UUID.randomUUID().toString()), + ), + ).andIsOk + } + + private fun addKeyToProject(projectId: Long) { + performAuthPost( + "/v2/projects/$projectId/translations", + mapOf( + "key" to "newKey", + "translations" to mapOf("en" to "Sample Translation"), + ), + ).andIsOk + } } diff --git a/backend/app/src/test/resources/application.yaml b/backend/app/src/test/resources/application.yaml index ac9deb0c99..9c5a2693d4 100644 --- a/backend/app/src/test/resources/application.yaml +++ b/backend/app/src/test/resources/application.yaml @@ -40,6 +40,9 @@ spring: datasource: maximum-pool-size: 100 tolgee: + slack: + token: fakeToken + signingSecret: fakeSecret postgres-autostart: enabled: true container-name: tolgee_backend_tests_postgres_main diff --git a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SavedMessageDto.kt b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SavedMessageDto.kt index 2255585e11..d79f6baff0 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SavedMessageDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SavedMessageDto.kt @@ -4,8 +4,10 @@ import com.slack.api.model.Attachment import com.slack.api.model.block.LayoutBlock data class SavedMessageDto( - val blocks: List, + var blocks: List, val attachments: List, val keyId: Long, val langTag: Set, + val createdKeyBlocks: Boolean = false, + val baseChanged: Boolean = false, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SlackErrorProvider.kt b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SlackErrorProvider.kt index 5456991a81..0927d05758 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SlackErrorProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SlackErrorProvider.kt @@ -3,6 +3,7 @@ package io.tolgee.component.automations.processors.slackIntegration import com.slack.api.model.block.LayoutBlock import com.slack.api.model.kotlin_extension.block.ActionsBlockBuilder import com.slack.api.model.kotlin_extension.block.withBlocks +import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.dtos.request.slack.SlackCommandDto import io.tolgee.service.slackIntegration.OrganizationSlackWorkspaceService import io.tolgee.util.I18n @@ -13,6 +14,7 @@ class SlackErrorProvider( private val i18n: I18n, private val slackUserLoginUrlProvider: SlackUserLoginUrlProvider, private val organizationSlackWorkspaceService: OrganizationSlackWorkspaceService, + private val tolgeeProperties: TolgeeProperties, ) { fun getUserNotConnectedError(payload: SlackCommandDto): List { val workspace = organizationSlackWorkspaceService.findBySlackTeamId(payload.team_id) @@ -98,6 +100,14 @@ class SlackErrorProvider( section { markdownText(i18n.translate("slack-workspace-not-connected-to-any-organization")) } + + actions { + button { + text(i18n.translate("connect-workspace-button-text"), emoji = true) + url(tolgeeProperties.frontEndUrl + "/preferred-organization?path=apps") + style("primary") + } + } } } diff --git a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SlackExecutor.kt b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SlackExecutor.kt index af3ea7afd8..8b29b80232 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SlackExecutor.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SlackExecutor.kt @@ -44,7 +44,7 @@ class SlackExecutor( val config = slackExecutorHelper.slackConfig val counts = slackExecutorHelper.data.activityData?.counts?.get("Translation") ?: 0 if (counts >= 10) { - val messageDto = slackExecutorHelper.createMessageIfTooManyTranslations(counts) ?: return + val messageDto = slackExecutorHelper.createMessageIfTooManyTranslations(counts) sendRegularMessageWithSaving(messageDto, config) return } @@ -52,7 +52,7 @@ class SlackExecutor( val messagesDto = slackExecutorHelper.createTranslationChangeMessage() messagesDto.forEach { message -> - val savedMessage = findSavedMessageOrNull(message.keyId, message.langTag, config.id) + val savedMessage = findSavedMessageOrNull(message.keyId, config.id) if (savedMessage.isEmpty()) { sendRegularMessageWithSaving(message, config) @@ -60,11 +60,28 @@ class SlackExecutor( } savedMessage.forEach { savedMsg -> + if (savedMsg.createdKeyBlocks) { + message.blocks = emptyList() + } processSavedMessage(savedMsg, message, config, slackExecutorHelper) } } } + fun getSlackNickName(author: String?): String? { + val response = + slackClient.methods(tolgeeProperties.slack.token).usersLookupByEmail { req -> + req.email(author) + } + + return if (response.isOk) { + response.user?.name + } else { + logger.info(response.error) + null + } + } + private fun processSavedMessage( savedMsg: SavedSlackMessage, message: SavedMessageDto, @@ -80,7 +97,6 @@ class SlackExecutor( } val additionalAttachments: MutableList = mutableListOf() - languagesToAdd.forEach { lang -> val attachment = slackExecutorHelper.createAttachmentForLanguage(lang, message.keyId) attachment?.let { @@ -95,6 +111,16 @@ class SlackExecutor( updateMessage(savedMsg, config, updatedMessageDto) } + fun sortSoBaseLanguageFirst(attachments: MutableList): MutableList { + val baseLanguageAttachmentIndex = attachments.indexOfFirst { it.blocks[0].toString().contains("(base)") } + if (baseLanguageAttachmentIndex != -1) { + val baseLanguageAttachment = attachments[baseLanguageAttachmentIndex] + attachments.removeAt(baseLanguageAttachmentIndex) + attachments.add(0, baseLanguageAttachment) + } + return attachments + } + fun sendMessageOnKeyAdded( slackConfig: SlackConfig, request: SlackRequest, @@ -173,8 +199,11 @@ class SlackExecutor( request .channel(config.channelId) .ts(savedMessage.messageTs) - .blocks(messageDto.blocks) - .attachments(messageDto.attachments) + .attachments(sortSoBaseLanguageFirst(messageDto.attachments.toMutableList())) + if (messageDto.blocks.isNotEmpty()) { + request.blocks(messageDto.blocks) + } + request } if (response.isOk) { @@ -192,7 +221,7 @@ class SlackExecutor( slackClient.methods(config.organizationSlackWorkspace.getSlackToken()).chatPostMessage { request -> request.channel(config.channelId) .blocks(messageDto.blocks) - .attachments(messageDto.attachments) + .attachments(sortSoBaseLanguageFirst(messageDto.attachments.toMutableList())) } if (response.isOk) { saveMessage(messageDto, response.ts, config) @@ -213,27 +242,28 @@ class SlackExecutor( slackUserConnectionService, i18n, tolgeeProperties, + getSlackNickName(data.activityData?.author?.name ?: ""), ) } private fun findSavedMessageOrNull( keyId: Long, - langTags: Set, configId: Long, - ) = savedSlackMessageService.find(keyId, langTags, configId) + ) = savedSlackMessageService.find(keyId, configId) private fun saveMessage( messageDto: SavedMessageDto, ts: String, config: SlackConfig, ) { - savedSlackMessageService.create( + savedSlackMessageService.save( savedSlackMessage = SavedSlackMessage( messageTs = ts, slackConfig = config, keyId = messageDto.keyId, langTags = messageDto.langTag, + messageDto.createdKeyBlocks, ), ) } diff --git a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SlackExecutorHelper.kt b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SlackExecutorHelper.kt index 2a9642c264..719c903bd6 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SlackExecutorHelper.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SlackExecutorHelper.kt @@ -4,6 +4,7 @@ import com.slack.api.model.Attachment import com.slack.api.model.block.LayoutBlock import com.slack.api.model.kotlin_extension.block.ActionsBlockBuilder import com.slack.api.model.kotlin_extension.block.SectionBlockBuilder +import com.slack.api.model.kotlin_extension.block.dsl.LayoutBlockDsl import com.slack.api.model.kotlin_extension.block.withBlocks import io.tolgee.api.IModifiedEntityModel import io.tolgee.configuration.tolgee.TolgeeProperties @@ -28,6 +29,7 @@ class SlackExecutorHelper( private val slackUserConnectionService: SlackUserConnectionService, private val i18n: I18n, private val tolgeeProperties: TolgeeProperties, + private val author: String?, ) { fun createKeyAddMessage(): List { val activities = data.activityData ?: return emptyList() @@ -64,10 +66,7 @@ class SlackExecutorHelper( val key = keyService.get(keyId) blocksHeader = buildKeyInfoBlock(key, i18n.translate("new-key-text")) key.translations.forEach translations@{ translation -> - if (!shouldProcessEventNewKeyAdded( - translation.language.tag, - ) - ) { + if (!shouldProcessEventNewKeyAdded(translation.language.tag, baseLanguage.tag)) { return@translations } @@ -77,25 +76,6 @@ class SlackExecutorHelper( langTags.add(translation.language.tag) } - slackConfig.project.languages.forEach { language -> - if (!langTags.contains(language.tag)) { - if (!shouldProcessEventNewKeyAdded( - language.tag, - ) - ) { - return@forEach - } - val blocks = buildBlocksNoTranslation(baseLanguage, language) - attachments.add( - Attachment.builder() - .color("#BCC2CB") - .blocks(blocks) - .build(), - ) - langTags.add(language.tag) - } - } - if (!langTags.contains(baseLanguage.tag) && langTags.isNotEmpty()) { baseLanguage.translations?.find { it.key.id == keyId }?.let { baseTranslation -> val attachment = createAttachmentForLanguage(baseTranslation) ?: return@let @@ -116,6 +96,7 @@ class SlackExecutorHelper( attachments = attachments, keyId = keyId, langTag = langTags, + true, ) } @@ -178,16 +159,34 @@ class SlackExecutorHelper( head: String, ) = withBlocks { section { - // TODO add author - markdownText(head) + authorHeadSection(head) } - section { - markdownText("*Key:* ${key.name}") + val columnFields = mutableListOf>() + columnFields.add("Key" to key.name) + key.keyMeta?.tags?.let { tags -> + val tagNames = tags.joinToString(", ") { it.name } + if (tagNames.isNotBlank()) { + columnFields.add("Tags" to tagNames) + } } + columnFields.add("Namespace" to key.namespace?.name) + columnFields.add("Description" to key.keyMeta?.description) + field(columnFields) + } + + fun LayoutBlockDsl.field(keyValue: List>) { section { - markdownText("*Key namespace:* ${key.namespace ?: "None"}") + val filtered = keyValue.filter { it.second != null && it.second!!.isNotEmpty() } + + if (filtered.isEmpty()) return@section + fields { + filtered.forEachIndexed { index, (key, value) -> + val finalValue = value + if (index % 2 == 1 && index != filtered.size - 1) "\n\u200d" else "" + markdownText("*$key* \n$finalValue") + } + } } } @@ -221,31 +220,36 @@ class SlackExecutorHelper( data.activityData?.modifiedEntities?.forEach modifiedEntities@{ (_, modifiedEntityList) -> modifiedEntityList.forEach { modifiedEntity -> - // modifiedEntity.entityId val translationKey = modifiedEntity.entityId - result.add(processTranslationChange(translationKey) ?: return@modifiedEntities) + val translation = findTranslationByKey(translationKey) ?: return@modifiedEntities + result.add(processTranslationChange(translation) ?: return@modifiedEntities) + + val baseLanguageTag = slackConfig.project.baseLanguage?.tag ?: return@modifiedEntities + if (baseLanguageTag == translation.language.tag) { + return@modifiedEntities + } } } return result } - private fun processTranslationChange(translationKey: Long): SavedMessageDto? { - val translation = findTranslationByKey(translationKey) ?: return null + private fun processTranslationChange(translation: Translation): SavedMessageDto? { val key = translation.key val baseLanguageTag = slackConfig.project.baseLanguage?.tag ?: return null val modifiedLangTag = translation.language.tag + val isBaseChanged = modifiedLangTag == baseLanguageTag if (!shouldProcessEventTranslationChanged(modifiedLangTag, baseLanguageTag, modifiedLangTag)) return null val langName = - if (translation.language.tag == baseLanguageTag) { - "(base language)" + if (isBaseChanged) { + "base language" } else { - "(${translation.language.name})" + translation.language.name } - val headerBlock = buildKeyInfoBlock(key, i18n.translate("new-translation-text") + langName) + val headerBlock = buildKeyInfoBlock(key, i18n.translate("new-translation-text").format(langName)) val attachments = mutableListOf(createAttachmentForLanguage(translation) ?: return null) val langTags = mutableSetOf(modifiedLangTag) @@ -260,6 +264,8 @@ class SlackExecutorHelper( attachments = attachments, keyId = key.id, langTag = langTags, + false, + isBaseChanged, ) } } @@ -330,8 +336,8 @@ class SlackExecutorHelper( } private fun SectionBlockBuilder.authorHeadSection(head: String) { - // TODO add author - markdownText(head) + val authorMention = author?.let { "@$it " } ?: data.activityData?.author?.name + markdownText(authorMention + head) } private fun shouldSkipModification( @@ -391,11 +397,14 @@ class SlackExecutorHelper( return isAllEvent || isBaseLanguageChangedEvent || isTranslationChangedEvent } - private fun shouldProcessEventNewKeyAdded(modifiedLangTag: String): Boolean { + private fun shouldProcessEventNewKeyAdded( + modifiedLangTag: String, + tag: String, + ): Boolean { return if (slackConfig.isGlobalSubscription) { slackConfig.onEvent == EventName.NEW_KEY || slackConfig.onEvent == EventName.ALL } else { - val pref = slackConfig.preferences.find { it.languageTag == modifiedLangTag } ?: return false + val pref = slackConfig.preferences.find { it.languageTag == modifiedLangTag } ?: return modifiedLangTag == tag pref.onEvent == EventName.NEW_KEY || pref.onEvent == EventName.ALL } } @@ -469,7 +478,7 @@ class SlackExecutorHelper( } }, ) - .color("#EC407A") + .color("#00000000") .build() private fun ActionsBlockBuilder.redirectOnPlatformButton() { @@ -480,15 +489,17 @@ class SlackExecutorHelper( value("redirect") url(tolgeeUrl) actionId("button_redirect_to_tolgee") + style("danger") } } - fun createMessageIfTooManyTranslations(counts: Long): SavedMessageDto? { + fun createMessageIfTooManyTranslations(counts: Long): SavedMessageDto { return SavedMessageDto( blocks = buildBlocksTooManyTranslations(counts), attachments = listOf(createRedirectButton()), 0L, setOf(), + false, ) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SlackUserLoginUrlProvider.kt b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SlackUserLoginUrlProvider.kt index 354e1b45ff..71ea1706a1 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SlackUserLoginUrlProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/automations/processors/slackIntegration/SlackUserLoginUrlProvider.kt @@ -26,6 +26,15 @@ class SlackUserLoginUrlProvider( return "${frontendUrlProvider.url}/slack/connect?data=$encryptedData" } + fun encryptData( + slackChannelId: String, + slackUserId: String, + workspaceId: Long?, + ): String { + val dto = getDto(slackChannelId, slackUserId, workspaceId) + return encryptData(dto) + } + private fun encryptData(dto: SlackUserLoginDto): String { val stringData = objectMapper.writeValueAsString(dto) val encrypted = aes.encrypt(stringData.toByteArray()) diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt index 26e3d75fa0..36dc90f744 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt @@ -18,6 +18,7 @@ import io.tolgee.service.project.LanguageStatsService import io.tolgee.service.project.ProjectService import io.tolgee.service.security.* import io.tolgee.service.slackIntegration.OrganizationSlackWorkspaceService +import io.tolgee.service.slackIntegration.SavedSlackMessageService import io.tolgee.service.slackIntegration.SlackUserConnectionService import io.tolgee.service.translation.AutoTranslationService import io.tolgee.service.translation.TranslationCommentService @@ -67,6 +68,7 @@ class TestDataService( private val languageStatsListener: LanguageStatsListener, private val organizationSlackWorkspaceService: OrganizationSlackWorkspaceService, private val slackUserConnectionService: SlackUserConnectionService, + private val savedSlackMessageService: SavedSlackMessageService, ) : Logging { @Transactional fun saveTestData(ft: TestDataBuilder.() -> Unit): TestDataBuilder { @@ -209,6 +211,7 @@ class TestDataService( saveContentStorages(builder) saveContentDeliveryConfigs(builder) saveWebhookConfigs(builder) + saveSlackConfigs(builder) saveAutomations(builder) saveImportSettings(builder) } @@ -226,6 +229,17 @@ class TestDataService( } } + private fun saveSlackConfigs(builder: ProjectBuilder) { + builder.data.slackConfigs.forEach { + entityManager.persist(it.self) + } + + builder.data.slackConfigs.forEach { slackConfig -> + val messages = slackConfig.data.slackMessages.map { it.self }.toMutableList() + savedSlackMessageService.saveAll(messages) + } + } + private fun saveContentDeliveryConfigs(builder: ProjectBuilder) { builder.data.contentDeliveryConfigs.forEach { if (it.self.slug.isEmpty()) { diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt index 1c8342b3b0..05acd5b4db 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt @@ -52,7 +52,7 @@ class ProjectBuilder( var contentDeliveryConfigs = mutableListOf() var webhookConfigs = mutableListOf() var importSettings: ImportSettings? = null - var slackConfig = mutableListOf() + var slackConfigs = mutableListOf() } var data = DATA() @@ -172,7 +172,7 @@ class ProjectBuilder( fun addWebhookConfig(ft: FT) = addOperation(data.webhookConfigs, ft) - fun addSlackConfig(ft: FT) = addOperation(data.slackConfig, ft) + fun addSlackConfig(ft: FT) = addOperation(data.slackConfigs, ft) fun setImportSettings(ft: FT) { data.importSettings = ImportSettings(this.self).apply(ft) diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/slack/SavedSlackMessageBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/slack/SavedSlackMessageBuilder.kt new file mode 100644 index 0000000000..387956e402 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/slack/SavedSlackMessageBuilder.kt @@ -0,0 +1,10 @@ +package io.tolgee.development.testDataBuilder.builders.slack + +import io.tolgee.development.testDataBuilder.EntityDataBuilder +import io.tolgee.model.slackIntegration.SavedSlackMessage + +class SavedSlackMessageBuilder( + slackConfigBuilder: SlackConfigBuilder, +) : EntityDataBuilder { + override var self = SavedSlackMessage("", slackConfigBuilder.self, 0, setOf(), false) +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/slack/SlackConfigBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/slack/SlackConfigBuilder.kt index 1a39256004..b85a6cbf75 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/slack/SlackConfigBuilder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/slack/SlackConfigBuilder.kt @@ -1,13 +1,23 @@ package io.tolgee.development.testDataBuilder.builders.slack -import io.tolgee.development.testDataBuilder.EntityDataBuilder +import io.tolgee.development.testDataBuilder.FT +import io.tolgee.development.testDataBuilder.builders.BaseEntityDataBuilder import io.tolgee.development.testDataBuilder.builders.ProjectBuilder import io.tolgee.development.testDataBuilder.builders.UserAccountBuilder +import io.tolgee.model.slackIntegration.SavedSlackMessage import io.tolgee.model.slackIntegration.SlackConfig class SlackConfigBuilder( val projectBuilder: ProjectBuilder, -) : EntityDataBuilder { +) : BaseEntityDataBuilder() { + class DATA { + var slackMessages = mutableListOf() + } + + var data = DATA() + + fun addSlackMessage(ft: FT) = addOperation(data.slackMessages, ft) + val userAccountBuilder = UserAccountBuilder(projectBuilder.testDataBuilder) override var self = SlackConfig(projectBuilder.self, userAccountBuilder.self, "") } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SlackTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SlackTestData.kt index fce3791162..db77eed634 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SlackTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/SlackTestData.kt @@ -5,6 +5,10 @@ import io.tolgee.development.testDataBuilder.builders.TestDataBuilder import io.tolgee.development.testDataBuilder.builders.UserAccountBuilder import io.tolgee.model.Organization import io.tolgee.model.UserAccount +import io.tolgee.model.automations.* +import io.tolgee.model.enums.ProjectPermissionType +import io.tolgee.model.enums.Scope +import io.tolgee.model.key.Key import io.tolgee.model.slackIntegration.OrganizationSlackWorkspace import io.tolgee.model.slackIntegration.SlackConfig import io.tolgee.model.slackIntegration.SlackUserConnection @@ -15,6 +19,8 @@ class SlackTestData() { var userAccountBuilder: UserAccountBuilder var projectBuilder: ProjectBuilder var organization: Organization + var automation: Automation + var key: Key lateinit var slackWorkspace: OrganizationSlackWorkspace lateinit var slackUserConnection: SlackUserConnection @@ -36,7 +42,11 @@ class SlackTestData() { addProject { name = "projectName" organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self + }.build buildProject@{ + this@buildProject.self.baseLanguage = this@buildProject.addEnglish().self } + projectBuilder.addKey("testKey").also { key = it.self } + .addTranslation("en", "Hello") userAccountBuilder.defaultOrganizationBuilder.addSlackWorkspace { author = userAccountBuilder.self @@ -49,11 +59,62 @@ class SlackTestData() { organization = userAccountBuilder.defaultOrganizationBuilder.self user = userAccountBuilder.self + + projectBuilder.addPermission { + project = projectBuilder.self + user = user + type = ProjectPermissionType.MANAGE + scopes = arrayOf(Scope.TRANSLATIONS_EDIT) + } + + projectBuilder.addFrench() + + projectBuilder.addCzech() + slackConfig = projectBuilder.addSlackConfig { - this.channelId = "channel" + this.channelId = "testChannel" this.project = projectBuilder.self this.userAccount = userAccountBuilder.self + isGlobalSubscription = true + }.build config@{ + addSlackMessage { + slackConfig = this@config.self + this.keyId = 0L + this.langTags = mutableSetOf("en", "fr") + } + + addSlackMessage { + slackConfig = this@config.self + this.keyId = 0L + this.langTags = mutableSetOf("fr", "cz") + } + + addSlackMessage { + slackConfig = this@config.self + this.keyId = 1L + this.langTags = mutableSetOf("cz", "ru") + } + + addSlackMessage { + slackConfig = this@config.self + this.keyId = 52L + this.langTags = mutableSetOf("fr", "cz") + } + }.self + + automation = + projectBuilder.addAutomation { + this.triggers.add( + AutomationTrigger(this) + .also { it.type = AutomationTriggerType.ACTIVITY }, + ) + this.actions.add( + AutomationAction(this).also { + it.type = AutomationActionType.SLACK_SUBSCRIPTION + it.slackConfig = slackConfig + }, + ) }.self } } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/slackIntegration/SavedSlackMessage.kt b/backend/data/src/main/kotlin/io/tolgee/model/slackIntegration/SavedSlackMessage.kt index 6f5cf679ef..e2d2b51426 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/slackIntegration/SavedSlackMessage.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/slackIntegration/SavedSlackMessage.kt @@ -12,9 +12,10 @@ import org.hibernate.annotations.Type class SavedSlackMessage( val messageTs: String, @ManyToOne(fetch = FetchType.LAZY) - val slackConfig: SlackConfig, - val keyId: Long, + var slackConfig: SlackConfig, + var keyId: Long, @Column(columnDefinition = "jsonb") @Type(JsonBinaryType::class) var langTags: Set, + var createdKeyBlocks: Boolean, ) : StandardAuditModel() diff --git a/backend/data/src/main/kotlin/io/tolgee/service/slackIntegration/SavedSlackMessageService.kt b/backend/data/src/main/kotlin/io/tolgee/service/slackIntegration/SavedSlackMessageService.kt index 5f5e5acd05..ebc7d785c1 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/slackIntegration/SavedSlackMessageService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/slackIntegration/SavedSlackMessageService.kt @@ -1,5 +1,6 @@ package io.tolgee.service.slackIntegration +import com.fasterxml.jackson.databind.ObjectMapper import io.tolgee.component.CurrentDateProvider import io.tolgee.model.slackIntegration.SavedSlackMessage import io.tolgee.repository.slackIntegration.SavedSlackMessageRepository @@ -14,9 +15,10 @@ class SavedSlackMessageService( private val savedSlackMessageRepository: SavedSlackMessageRepository, private val slackConfigRepository: SlackConfigRepository, private val currentDateProvider: CurrentDateProvider, + private val objectMapper: ObjectMapper, ) { @Transactional - fun create(savedSlackMessage: SavedSlackMessage): SavedSlackMessage { + fun save(savedSlackMessage: SavedSlackMessage): SavedSlackMessage { savedSlackMessage.slackConfig.apply { this.savedSlackMessage.add(savedSlackMessage) slackConfigRepository.save(this) @@ -25,6 +27,11 @@ class SavedSlackMessageService( return savedSlackMessageRepository.save(savedSlackMessage) } + @Transactional + fun saveAll(savedSlackMessage: MutableList) { + savedSlackMessageRepository.saveAll(savedSlackMessage) + } + @Transactional fun update( id: Long, @@ -42,14 +49,12 @@ class SavedSlackMessageService( fun find( keyId: Long, - langTags: Set, configId: Long, ): List { - val savedSlackMessages = findByKey(keyId, configId) - - return savedSlackMessages.filter { savedSlackMessage -> - savedSlackMessage.langTags.any { it in langTags } - } + return savedSlackMessageRepository.findByKeyIdAndSlackConfigId( + keyId, + configId, + ) } fun findByKey( @@ -59,6 +64,10 @@ class SavedSlackMessageService( return savedSlackMessageRepository.findByKeyIdAndSlackConfigId(keyId, configId) } + fun findAll(): List { + return savedSlackMessageRepository.findAll() + } + @Scheduled(fixedDelay = 60000) fun deleteOldMessage() { val cutoff = currentDateProvider.date.addMinutes(-120) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/slackIntegration/SlackConfigService.kt b/backend/data/src/main/kotlin/io/tolgee/service/slackIntegration/SlackConfigService.kt index 911ed4057b..13e040a1d1 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/slackIntegration/SlackConfigService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/slackIntegration/SlackConfigService.kt @@ -34,6 +34,10 @@ class SlackConfigService( return slackConfigRepository.getAllByChannelId(channelId) } + fun findAll(): List { + return slackConfigRepository.findAll() + } + @Transactional fun delete( projectId: Long, diff --git a/backend/data/src/main/resources/I18n_en.properties b/backend/data/src/main/resources/I18n_en.properties index 2f3527918a..5f8b15b562 100644 --- a/backend/data/src/main/resources/I18n_en.properties +++ b/backend/data/src/main/resources/I18n_en.properties @@ -1,5 +1,6 @@ # Message displayed when the user tries to use a command that requires a connected Slack account connect-button-text = Connect Tolgee account +connect-workspace-button-text = Connect Slack workspace account slack-not-connected-message = :wave: Hello! It seems your Slack account isn't connected to our service yet. For full access to features, please connect :rocket: connect-account-instruction = *Connect account*: Click the button below to get started. @@ -9,7 +10,7 @@ unknown-error-occurred = :grey_question: Oops, we've encountered an unknown erro view-help-button-text = View Help need-help = Need help? Type `/tolgee help new-key-text = has created a new key -new-translation-text = has changed a translation +new-translation-text = has changed a %s translation imported-text = has imported too-many-translations-text = has updated already_logged_in = You are already logged in. diff --git a/backend/data/src/main/resources/db/changelog/schema.xml b/backend/data/src/main/resources/db/changelog/schema.xml index 823980f326..aace0003ab 100644 --- a/backend/data/src/main/resources/db/changelog/schema.xml +++ b/backend/data/src/main/resources/db/changelog/schema.xml @@ -3341,6 +3341,13 @@ + + + + + + + CREATE OR REPLACE FUNCTION empty_json(j jsonb) RETURNS boolean AS ' diff --git a/backend/testing/src/main/kotlin/io/tolgee/AbstractSpringTest.kt b/backend/testing/src/main/kotlin/io/tolgee/AbstractSpringTest.kt index ac446677b1..7673496bd4 100644 --- a/backend/testing/src/main/kotlin/io/tolgee/AbstractSpringTest.kt +++ b/backend/testing/src/main/kotlin/io/tolgee/AbstractSpringTest.kt @@ -1,7 +1,6 @@ package io.tolgee import com.fasterxml.jackson.databind.ObjectMapper -import com.ninjasquad.springmockk.clear import io.tolgee.activity.ActivityService import io.tolgee.component.AllCachesProvider import io.tolgee.component.CurrentDateProvider @@ -10,21 +9,11 @@ import io.tolgee.component.machineTranslation.MtServiceManager import io.tolgee.configuration.tolgee.AuthenticationProperties import io.tolgee.configuration.tolgee.InternalProperties import io.tolgee.configuration.tolgee.TolgeeProperties -import io.tolgee.configuration.tolgee.machineTranslation.AwsMachineTranslationProperties -import io.tolgee.configuration.tolgee.machineTranslation.AzureCognitiveTranslationProperties -import io.tolgee.configuration.tolgee.machineTranslation.BaiduMachineTranslationProperties -import io.tolgee.configuration.tolgee.machineTranslation.DeeplMachineTranslationProperties -import io.tolgee.configuration.tolgee.machineTranslation.GoogleMachineTranslationProperties -import io.tolgee.configuration.tolgee.machineTranslation.MachineTranslationProperties -import io.tolgee.configuration.tolgee.machineTranslation.TolgeeMachineTranslationProperties +import io.tolgee.configuration.tolgee.machineTranslation.* import io.tolgee.constants.MtServiceType import io.tolgee.development.DbPopulatorReal import io.tolgee.development.testDataBuilder.TestDataService -import io.tolgee.repository.EmailVerificationRepository -import io.tolgee.repository.KeyRepository -import io.tolgee.repository.OrganizationRepository -import io.tolgee.repository.OrganizationRoleRepository -import io.tolgee.repository.ProjectRepository +import io.tolgee.repository.* import io.tolgee.security.InitialPasswordManager import io.tolgee.service.EmailVerificationService import io.tolgee.service.ImageUploadService @@ -42,12 +31,9 @@ import io.tolgee.service.organization.OrganizationRoleService import io.tolgee.service.organization.OrganizationService import io.tolgee.service.project.LanguageStatsService import io.tolgee.service.project.ProjectService -import io.tolgee.service.security.ApiKeyService -import io.tolgee.service.security.MfaService -import io.tolgee.service.security.PatService -import io.tolgee.service.security.PermissionService -import io.tolgee.service.security.UserAccountService -import io.tolgee.service.security.UserPreferencesService +import io.tolgee.service.security.* +import io.tolgee.service.slackIntegration.SavedSlackMessageService +import io.tolgee.service.slackIntegration.SlackConfigService import io.tolgee.service.translation.TranslationCommentService import io.tolgee.service.translation.TranslationService import io.tolgee.testing.AbstractTransactionalTest @@ -224,6 +210,12 @@ abstract class AbstractSpringTest : AbstractTransactionalTest() { @Autowired lateinit var allCachesProvider: AllCachesProvider + @Autowired + lateinit var savedSlackMessageService: SavedSlackMessageService + + @Autowired + lateinit var slackConfigService: SlackConfigService + @BeforeEach fun clearCaches() { allCachesProvider.getAllCaches().forEach { cacheName ->