From 06d6bae83662efbc84d99fa3faa764d78d1b5281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=A7=80=ED=98=81?= Date: Sat, 13 Jan 2024 23:14:14 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=82=AC=ED=95=AD=EC=9D=B4?= =?UTF-8?q?=20=EB=A7=8E=EC=9D=80=20=EA=B2=BD=EC=9A=B0=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=20=EB=8F=99=EA=B8=B0=ED=99=94=20=ED=95=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B3=A0=20=EC=8A=AC=EB=9E=99=20=EC=B1=84=EB=84=90=EC=97=90=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 슬랙 커맨드로 푸시 보내기 lint --- .../data/SugangSnuLectureCompareResult.kt | 6 +- .../job/sync/service/SugangSnuSyncService.kt | 80 +++++++- build.gradle.kts | 1 + core/build.gradle.kts | 3 + core/src/main/kotlin/common/cache/CacheKey.kt | 1 + .../common/slack/SlackMessageRequest.kt | 30 +++ .../common/slack/SlackMessageService.kt | 127 +++++++++++++ core/src/main/kotlin/config/SlackConfig.kt | 173 ++++++++++++++++++ .../kotlin/users/repository/UserRepository.kt | 2 + .../testFixtures/resources/application.yaml | 5 + 10 files changed, 423 insertions(+), 5 deletions(-) create mode 100644 core/src/main/kotlin/common/slack/SlackMessageRequest.kt create mode 100644 core/src/main/kotlin/common/slack/SlackMessageService.kt create mode 100644 core/src/main/kotlin/config/SlackConfig.kt diff --git a/batch/src/main/kotlin/sugangsnu/job/sync/data/SugangSnuLectureCompareResult.kt b/batch/src/main/kotlin/sugangsnu/job/sync/data/SugangSnuLectureCompareResult.kt index a2d9f10b..d8fbb846 100644 --- a/batch/src/main/kotlin/sugangsnu/job/sync/data/SugangSnuLectureCompareResult.kt +++ b/batch/src/main/kotlin/sugangsnu/job/sync/data/SugangSnuLectureCompareResult.kt @@ -6,4 +6,8 @@ class SugangSnuLectureCompareResult( val createdLectureList: List, val deletedLectureList: List, val updatedLectureList: List, -) +) { + fun needsConfirmOnProduction(): Boolean { + return createdLectureList.size > 50 || deletedLectureList.size > 10 || updatedLectureList.size > 50 + } +} diff --git a/batch/src/main/kotlin/sugangsnu/job/sync/service/SugangSnuSyncService.kt b/batch/src/main/kotlin/sugangsnu/job/sync/service/SugangSnuSyncService.kt index 500fe8c8..b3798f5b 100644 --- a/batch/src/main/kotlin/sugangsnu/job/sync/service/SugangSnuSyncService.kt +++ b/batch/src/main/kotlin/sugangsnu/job/sync/service/SugangSnuSyncService.kt @@ -2,6 +2,15 @@ package com.wafflestudio.snu4t.sugangsnu.job.sync.service import com.wafflestudio.snu4t.bookmark.repository.BookmarkRepository import com.wafflestudio.snu4t.common.cache.Cache +import com.wafflestudio.snu4t.common.cache.CacheKey +import com.wafflestudio.snu4t.common.cache.get +import com.wafflestudio.snu4t.common.slack.CONFIRM_DONE_EMOJI +import com.wafflestudio.snu4t.common.slack.CONFIRM_ONGOING_EMOJI +import com.wafflestudio.snu4t.common.slack.SlackMessageBlock +import com.wafflestudio.snu4t.common.slack.SlackMessageRequest +import com.wafflestudio.snu4t.common.slack.SlackMessageService +import com.wafflestudio.snu4t.config.Phase +import com.wafflestudio.snu4t.config.SNUTT_MENTION import com.wafflestudio.snu4t.coursebook.data.Coursebook import com.wafflestudio.snu4t.coursebook.repository.CoursebookRepository import com.wafflestudio.snu4t.lectures.data.Lecture @@ -49,7 +58,10 @@ class SugangSnuSyncServiceImpl( private val bookmarkRepository: BookmarkRepository, private val tagListRepository: TagListRepository, private val cache: Cache, + private val phase: Phase, + private val slackMessageService: SlackMessageService ) : SugangSnuSyncService { + override suspend fun updateCoursebook(coursebook: Coursebook): List { val newLectures = sugangSnuFetchService.getSugangSnuLectures(coursebook.year, coursebook.semester) val oldLectures = @@ -140,9 +152,66 @@ class SugangSnuSyncServiceImpl( diff.newData.apply { id = diff.oldData.id } } - lectureService.upsertLectures(compareResult.createdLectureList) - lectureService.upsertLectures(updatedLectures) - lectureService.deleteLectures(compareResult.deletedLectureList) + val ongoingConfirmThread = cache.get(CacheKey.LOCK_LIVE_SUGANG_SNU_SYNC_UNTIL_CONFIRMED.build()) + val hasOngoingConfirmProcessOnProd = phase.isProd && ongoingConfirmThread != null + val currentResultRequiresConfirm = phase.isProd && compareResult.needsConfirmOnProduction() + + when { + currentResultRequiresConfirm -> { + // 이전 검토 요청 존재 여부와 별개로 새로운 검토 요청 + val newThreadTs = slackMessageService.postMessage( + message = + SlackMessageRequest( + SlackMessageBlock.Section("*$SNUTT_MENTION 수강스누 업데이트 검토가 필요합니다.*"), + SlackMessageBlock.Section("추가된 강좌 수: ${compareResult.createdLectureList.size}"), + SlackMessageBlock.Section("업데이트된 강좌 수: ${compareResult.updatedLectureList.size}"), + SlackMessageBlock.Section("삭제된 강좌 수: ${compareResult.deletedLectureList.size}"), + SlackMessageBlock.Section("개발환경에서 검토 후 아래 버튼 통해 라이브 배포해주세요."), + SlackMessageBlock.Action.SUGANG_SNU_CONFIRM + ) + ).threadTs + + // 기존 결과가 검토 대기 중이었으면 내림 + if (hasOngoingConfirmProcessOnProd) { + slackMessageService.deleteEmoji(threadTs = ongoingConfirmThread!!, emoji = CONFIRM_ONGOING_EMOJI) + slackMessageService.addEmoji(threadTs = ongoingConfirmThread, emoji = CONFIRM_DONE_EMOJI) + slackMessageService.postMessageToThread( + threadTs = ongoingConfirmThread, + message = SlackMessageRequest( + SlackMessageBlock.Header("새로운 동기화 결과가 업데이트 되어 검토 요청을 자동 종료합니다."), + SlackMessageBlock.Section("새로운 동기화 결과가 자동 배포 기준을 충족하지 않아 검토가 필요합니다."), + ) + ) + } + + slackMessageService.addEmoji(threadTs = newThreadTs, emoji = CONFIRM_ONGOING_EMOJI) + cache.set(CacheKey.LOCK_LIVE_SUGANG_SNU_SYNC_UNTIL_CONFIRMED.build(), newThreadTs) + } + + // 이전 검토 요청이 있었고, 새로운 결과가 검토 대기 중이 아니면 검토 종료, 새로운 결과 반영 + !currentResultRequiresConfirm && hasOngoingConfirmProcessOnProd -> { + slackMessageService.deleteEmoji(threadTs = ongoingConfirmThread!!, emoji = CONFIRM_ONGOING_EMOJI) + slackMessageService.addEmoji(threadTs = ongoingConfirmThread, emoji = CONFIRM_DONE_EMOJI) + slackMessageService.postMessageToThread( + threadTs = ongoingConfirmThread, + message = SlackMessageRequest( + SlackMessageBlock.Header("동기화 결과가 업데이트 되어 검토 요청을 자동 종료합니다."), + SlackMessageBlock.Section("새로운 동기화 결과가 자동 배포 기준을 충족하여 검토가 필요하지 않습니다."), + SlackMessageBlock.Section("검토 요청을 종료합니다."), + ) + ) + + lectureService.upsertLectures(compareResult.createdLectureList) + lectureService.upsertLectures(updatedLectures) + lectureService.deleteLectures(compareResult.deletedLectureList) + } + + else -> { + lectureService.upsertLectures(compareResult.createdLectureList) + lectureService.upsertLectures(updatedLectures) + lectureService.deleteLectures(compareResult.deletedLectureList) + } + } } private suspend fun syncSavedUserLectures(compareResult: SugangSnuLectureCompareResult): List = @@ -278,7 +347,10 @@ class SugangSnuSyncServiceImpl( updatedLecture.updatedField.contains(Lecture::classPlaceAndTimes) && timetable.lectures.any { it.lectureId != updatedLecture.oldData.id && - ClassTimeUtils.timesOverlap(it.classPlaceAndTimes, updatedLecture.newData.classPlaceAndTimes) + ClassTimeUtils.timesOverlap( + it.classPlaceAndTimes, + updatedLecture.newData.classPlaceAndTimes + ) } private fun deleteBookmarkLectures(deletedLecture: Lecture): Flow = diff --git a/build.gradle.kts b/build.gradle.kts index 8bfdbf04..66d228e4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -43,6 +43,7 @@ subprojects { implementation("org.springframework.boot:spring-boot-starter-webflux") implementation("io.projectreactor.kotlin:reactor-kotlin-extensions") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8") implementation("org.springframework.boot:spring-boot-starter-data-mongodb-reactive") implementation("org.springframework.boot:spring-boot-starter-data-redis") diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 440620ed..b3fdd11c 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -7,6 +7,9 @@ dependencies { implementation("software.amazon.awssdk:secretsmanager:2.17.276") implementation("software.amazon.awssdk:sts:2.17.276") implementation("com.google.firebase:firebase-admin:9.1.1") + implementation("com.slack.api:bolt-jetty:1.37.0") + implementation("com.slack.api:slack-api-model-kotlin-extension:1.21.0") + implementation("com.slack.api:slack-api-client-kotlin-extension:1.21.0") testFixturesImplementation("org.testcontainers:mongodb:1.19.0") testFixturesImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") diff --git a/core/src/main/kotlin/common/cache/CacheKey.kt b/core/src/main/kotlin/common/cache/CacheKey.kt index 64dbfb00..00ce24d0 100644 --- a/core/src/main/kotlin/common/cache/CacheKey.kt +++ b/core/src/main/kotlin/common/cache/CacheKey.kt @@ -14,6 +14,7 @@ enum class CacheKey( CLIENT_CONFIGS("client_configs:%s_%s", Duration.ofMinutes(5)), // osType, appVersion LOCK_REGISTER_LOCAL("lock:register_local:%s", Duration.ofMinutes(1)), // localId LOCK_ADD_FCM_REGISTRATION_ID("lock:add_registration_id:%s_%s", Duration.ofMinutes(1)), // userId, registrationId + LOCK_LIVE_SUGANG_SNU_SYNC_UNTIL_CONFIRMED("lock:live_sugang_snu_sync_until_confirmed", Duration.ofDays(14)), ; fun build(vararg args: Any?): BuiltCacheKey { diff --git a/core/src/main/kotlin/common/slack/SlackMessageRequest.kt b/core/src/main/kotlin/common/slack/SlackMessageRequest.kt new file mode 100644 index 00000000..cfc85f13 --- /dev/null +++ b/core/src/main/kotlin/common/slack/SlackMessageRequest.kt @@ -0,0 +1,30 @@ +package com.wafflestudio.snu4t.common.slack + +data class SlackMessageRequest( + val text: String = "", + val blocks: List, +) { + constructor(vararg blocks: SlackMessageBlock, text: String = "") : this(text, blocks.toList()) +} + +interface SlackMessageBlock { + + data class Header(val text: String) : SlackMessageBlock + + data class Section(val text: String) : SlackMessageBlock + + data class Button(val text: String, val url: String? = null) : SlackMessageBlock + + enum class Action( + val actionId: String, + val value: String, + val text: String, + ) : SlackMessageBlock { + SUGANG_SNU_CONFIRM("sugang_snu_confirm", "ok", "확인 완료"), + } +} + +data class SlackMessageResponse( + val channel: String, + val threadTs: String +) diff --git a/core/src/main/kotlin/common/slack/SlackMessageService.kt b/core/src/main/kotlin/common/slack/SlackMessageService.kt new file mode 100644 index 00000000..15bce6a7 --- /dev/null +++ b/core/src/main/kotlin/common/slack/SlackMessageService.kt @@ -0,0 +1,127 @@ +package com.wafflestudio.snu4t.common.slack + +import com.slack.api.Slack +import com.slack.api.methods.kotlin_extension.request.chat.blocks +import com.slack.api.model.block.composition.PlainTextObject +import com.slack.api.model.block.element.BlockElements.button +import com.slack.api.model.kotlin_extension.block.dsl.LayoutBlockDsl +import kotlinx.coroutines.future.await +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component + +private const val SNUTT_CHANNEL_ID = "C0PAVPS5T" +const val CONFIRM_ONGOING_EMOJI = "acongablob" +const val CONFIRM_DONE_EMOJI = "done" + +interface SlackMessageService { + + suspend fun postMessage(channel: String = SNUTT_CHANNEL_ID, message: SlackMessageRequest): SlackMessageResponse + + suspend fun postMessageToThread(channel: String = SNUTT_CHANNEL_ID, threadTs: String, message: SlackMessageRequest): SlackMessageResponse + + suspend fun addEmoji(channel: String = SNUTT_CHANNEL_ID, threadTs: String, emoji: String) + + suspend fun deleteEmoji(channel: String = SNUTT_CHANNEL_ID, threadTs: String, emoji: String) +} + +@Component +internal class Impl( + @Value("\${slack.bot.token}") secretKey: String, +) : SlackMessageService { + + private val client = Slack.getInstance() + .methodsAsync(secretKey) + + override suspend fun postMessage(channel: String, message: SlackMessageRequest): SlackMessageResponse { + return client.chatPostMessage { builder -> + builder.channel(channel) + .text(message.text) + .blocks { apply(message.blocks) } + } + .await() + .let { + if (!it.isOk) { + log.error("Failed to post message to slack: ${it.error}") + } + + SlackMessageResponse(it.channel, it.ts) + } + } + + override suspend fun postMessageToThread(channel: String, threadTs: String, message: SlackMessageRequest): SlackMessageResponse { + return client.chatPostMessage { builder -> + builder + .channel(channel) + .threadTs(threadTs) + .text(message.text) + .blocks { apply(message.blocks) } + } + .await() + .let { + if (!it.isOk) { + log.error("Failed to post message to slack: ${it.error}") + } + + SlackMessageResponse(it.channel, it.ts) + } + } + + override suspend fun addEmoji(channel: String, threadTs: String, emoji: String) { + client.reactionsAdd { builder -> + builder + .channel(channel) + .timestamp(threadTs) + .name(emoji) + } + .await() + .let { + if (!it.isOk) { + log.error("Failed to add emoji to slack: ${it.error}") + } + } + } + + override suspend fun deleteEmoji(channel: String, threadTs: String, emoji: String) { + client.reactionsRemove { builder -> + builder + .channel(channel) + .timestamp(threadTs) + .name(emoji) + } + .await() + .let { + if (!it.isOk) { + log.error("Failed to delete emoji to slack: ${it.error}") + } + } + } + + private fun LayoutBlockDsl.apply(blocks: List) { + blocks.forEach { block -> + when (block) { + is SlackMessageBlock.Header -> header { + text(block.text, emoji = false) + } + is SlackMessageBlock.Section -> section { + markdownText(block.text, verbatim = false) + } + is SlackMessageBlock.Button -> button { + it.text(PlainTextObject(block.text, false)) + block.url?.let { url -> it.url(url) } + } + is SlackMessageBlock.Action -> actions { + elements { + button { + actionId(block.actionId) + value(block.value) + text(block.text) + } + } + } + } + } + } + + private val log = LoggerFactory.getLogger(javaClass) +} diff --git a/core/src/main/kotlin/config/SlackConfig.kt b/core/src/main/kotlin/config/SlackConfig.kt new file mode 100644 index 00000000..da87a630 --- /dev/null +++ b/core/src/main/kotlin/config/SlackConfig.kt @@ -0,0 +1,173 @@ +package com.wafflestudio.snu4t.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.slack.api.app_backend.slash_commands.payload.SlashCommandPayload +import com.slack.api.bolt.App +import com.slack.api.bolt.AppConfig +import com.slack.api.bolt.request.RequestHeaders +import com.slack.api.bolt.request.builtin.BlockActionRequest +import com.slack.api.bolt.request.builtin.SlashCommandRequest +import com.wafflestudio.snu4t.common.cache.Cache +import com.wafflestudio.snu4t.common.cache.CacheKey +import com.wafflestudio.snu4t.common.cache.get +import com.wafflestudio.snu4t.common.push.dto.PushMessage +import com.wafflestudio.snu4t.common.slack.CONFIRM_DONE_EMOJI +import com.wafflestudio.snu4t.common.slack.CONFIRM_ONGOING_EMOJI +import com.wafflestudio.snu4t.common.slack.SlackMessageBlock +import com.wafflestudio.snu4t.common.slack.SlackMessageRequest +import com.wafflestudio.snu4t.common.slack.SlackMessageService +import com.wafflestudio.snu4t.notification.data.NotificationType +import com.wafflestudio.snu4t.notification.service.PushWithNotificationService +import com.wafflestudio.snu4t.users.repository.UserRepository +import kotlinx.coroutines.reactive.awaitSingle +import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ServerWebExchange + +const val SNUTT_ID = "S032EFLT1FT" +const val SNUTT_MENTION = "" + +// bolt 가 서블릿 기반이라 webflux 호환이 안되어서 직접 지정 +@RestController +@Profile("!test") +class SlackController( + private val slackApp: App, + private val objectMapper: ObjectMapper +) { + @PostMapping("/slack/interactions", consumes = ["*/*"]) + suspend fun handleInteractions( + @RequestHeader headers: Map, + serverWebExchange: ServerWebExchange + ) { + val requestHeaders = RequestHeaders(headers.mapValues { listOf(it.value) }) + val payload = serverWebExchange.formData.awaitSingle()["payload"]?.firstOrNull() ?: "" + val slashCommandRequest = BlockActionRequest(payload, payload, requestHeaders) + slackApp.run(slashCommandRequest) + } + + @PostMapping("/slack/command", consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE]) + suspend fun handle( + @RequestHeader headers: Map, + serverWebExchange: ServerWebExchange + ) { + val formData = serverWebExchange.formData.awaitSingle() + val slackCommandPayload = SlashCommandPayload().apply { + token = formData.getFirst("token") + teamId = formData.getFirst("team_id") + teamDomain = formData.getFirst("team_domain") + enterpriseId = formData.getFirst("enterprise_id") + enterpriseName = formData.getFirst("enterprise_name") + channelId = formData.getFirst("channel_id") + channelName = formData.getFirst("channel_name") + userId = formData.getFirst("user_id") + userName = formData.getFirst("user_name") + command = formData.getFirst("command") + text = formData.getFirst("text") + responseUrl = formData.getFirst("response_url") + triggerId = formData.getFirst("trigger_id") + } + val requestHeaders = RequestHeaders(headers.mapValues { listOf(it.value) }) + val slashCommandRequest = SlashCommandRequest(objectMapper.writeValueAsString(formData), slackCommandPayload, requestHeaders) + slackApp.run(slashCommandRequest) + } +} + +@Configuration +class SlackConfig( + @Value("\${slack.bot.token}") private val secretKey: String, + @Value("\${slack.user.token}") private val userSecretKey: String, + @Value("\${slack.signing-secret}") private val secret: String, + private val cache: Cache, + private val phase: Phase, + private val slackMessageService: SlackMessageService, + private val userRepository: UserRepository, + private val pushWithNotificationService: PushWithNotificationService +) { + private val log = LoggerFactory.getLogger(SlackConfig::class.java) + + @Bean + @Profile("!test") + fun app(): App { + return App( + AppConfig.builder() + .singleTeamBotToken(secretKey) + .userScope(userSecretKey) + .signingSecret(secret) + .requestVerificationEnabled(true) + .build() + ).apply { + use { req, _, chain -> + val userGroupResponse = this.client.usergroupsUsersList { + it.token(userSecretKey) + it.usergroup(SNUTT_ID) + } + + check(userGroupResponse.isOk && userGroupResponse.users.any { it == req.context.requestUserId }) { "권한이 없습니다." } + chain.next(req) + } + + blockAction(SlackMessageBlock.Action.SUGANG_SNU_CONFIRM.actionId) { request, context -> + log.info("SUGANG_SNU_CONFIRM request from ${request.payload.user.name}") + val button = request.payload.actions.find { it.type == "button" && it.actionId == SlackMessageBlock.Action.SUGANG_SNU_CONFIRM.actionId } + if (button == null) { + log.error("SUGANG_SNU_CONFIRM request from ${request.payload.user.name} has no button") + return@blockAction context.ack() + } + + runBlocking { + val threadTs = cache.get(CacheKey.LOCK_LIVE_SUGANG_SNU_SYNC_UNTIL_CONFIRMED.build()) + if (threadTs == null) { + log.error("SUGANG_SNU_CONFIRM request from ${request.payload.user.name} has no threadTs") + context.respond("이미 확인이 완료된 건입니다.") + } else { + cache.delete(CacheKey.LOCK_LIVE_SUGANG_SNU_SYNC_UNTIL_CONFIRMED.build()) + slackMessageService.deleteEmoji(emoji = CONFIRM_ONGOING_EMOJI, threadTs = threadTs) + slackMessageService.addEmoji(emoji = CONFIRM_DONE_EMOJI, threadTs = threadTs) + slackMessageService.postMessageToThread( + threadTs = threadTs, + message = SlackMessageRequest( + SlackMessageBlock.Section("<@${request.payload.user.name}> 님께서 수강신청 동기화 결과를 확인했습니다."), + SlackMessageBlock.Section("다음 일자에 수강신청 동기화 결과가 반영됩니다."), // TODO("K8S API 로 직접 잡 트리거?") + ) + ) + + context.respond("확인을 완료했습니다.") + } + } + + context.ack() + } + + command("/push") { req, ctx -> + if (phase.isProd) { + return@command ctx.ack("프로덕션에서는 사용할 수 없습니다.") // TODO 토큰 분리 + } + + runBlocking { + val text = req.payload.text + val adminUsers = userRepository.findAllByIsAdminTrue().mapNotNull { it.id } + pushWithNotificationService.sendPushesAndNotifications( + pushMessage = PushMessage( + title = "${req.payload.userName}: $text", + body = "어드민 테스트 푸시입니다.", + data = mapOf("type" to "test") + ), + notificationType = NotificationType.NORMAL, + userIds = adminUsers + ) + } + + ctx.respond("푸시를 보냈습니다.") + ctx.ack() + } + } + } +} diff --git a/core/src/main/kotlin/users/repository/UserRepository.kt b/core/src/main/kotlin/users/repository/UserRepository.kt index a09b57dd..bcbfb2bc 100644 --- a/core/src/main/kotlin/users/repository/UserRepository.kt +++ b/core/src/main/kotlin/users/repository/UserRepository.kt @@ -21,5 +21,7 @@ interface UserRepository : CoroutineCrudRepository { suspend fun existsByEmailAndIsEmailVerifiedTrueAndActiveTrue(email: String): Boolean + suspend fun findAllByIsAdminTrue(): List + fun findAllByNicknameStartingWith(nickname: String): Flow } diff --git a/core/src/testFixtures/resources/application.yaml b/core/src/testFixtures/resources/application.yaml index f83f9ccf..c02d1d42 100644 --- a/core/src/testFixtures/resources/application.yaml +++ b/core/src/testFixtures/resources/application.yaml @@ -23,3 +23,8 @@ google: http: responseTimeout: 3s + +slack: + bot.token: "" + user.token: "" + signing-secret: "" \ No newline at end of file