diff --git a/core/util/src/main/java/com/ku_stacks/ku_ring/util/DateTimeUtil.kt b/core/util/src/main/java/com/ku_stacks/ku_ring/util/DateTimeUtil.kt
new file mode 100644
index 000000000..e0b1d616a
--- /dev/null
+++ b/core/util/src/main/java/com/ku_stacks/ku_ring/util/DateTimeUtil.kt
@@ -0,0 +1,17 @@
+package com.ku_stacks.ku_ring.util
+
+import java.time.Instant
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.YearMonth
+import java.time.ZoneOffset
+
+val LocalDateTime.yearMonth: YearMonth
+ get() = YearMonth.of(this.year, this.monthValue)
+
+fun Long.toLocalDateTime(): LocalDateTime =
+ LocalDateTime.ofInstant(Instant.ofEpochSecond(this), ZoneOffset.systemDefault())
+
+fun LocalDateTime.toEpochSecond() = atZone(ZoneOffset.systemDefault()).toEpochSecond()
+
+fun LocalDate.toEpochSecond() = atStartOfDay(ZoneOffset.systemDefault()).toEpochSecond()
\ No newline at end of file
diff --git a/data/ai/.gitignore b/data/ai/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/data/ai/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/data/ai/build.gradle.kts b/data/ai/build.gradle.kts
new file mode 100644
index 000000000..766ecd076
--- /dev/null
+++ b/data/ai/build.gradle.kts
@@ -0,0 +1,27 @@
+import com.ku_stacks.ku_ring.buildlogic.dsl.setNameSpace
+
+plugins {
+ kuring("feature")
+ kuringPrimitive("room")
+ kuringPrimitive("ktor")
+ kuringPrimitive("test")
+}
+
+android {
+ setNameSpace("ai")
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+}
+
+dependencies {
+ implementation(projects.core.util)
+ implementation(projects.data.domain)
+ implementation(projects.data.remote)
+ implementation(projects.data.local)
+
+ testImplementation(libs.kotlinx.coroutines.test)
+}
\ No newline at end of file
diff --git a/data/ai/consumer-rules.pro b/data/ai/consumer-rules.pro
new file mode 100644
index 000000000..e69de29bb
diff --git a/data/ai/src/main/AndroidManifest.xml b/data/ai/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..44008a433
--- /dev/null
+++ b/data/ai/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/di/RepositoryModule.kt b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/di/RepositoryModule.kt
new file mode 100644
index 000000000..23da66c37
--- /dev/null
+++ b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/di/RepositoryModule.kt
@@ -0,0 +1,17 @@
+package com.ku_stacks.ku_ring.ai.di
+
+import com.ku_stacks.ku_ring.ai.repository.KuringBotRepository
+import com.ku_stacks.ku_ring.ai.repository.KuringBotRepositoryImpl
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class RepositoryModule {
+ @Binds
+ @Singleton
+ abstract fun bindKuringBotMessageRepository(repositoryImpl: KuringBotRepositoryImpl): KuringBotRepository
+}
\ No newline at end of file
diff --git a/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/mapper/DomainToEntityMapper.kt b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/mapper/DomainToEntityMapper.kt
new file mode 100644
index 000000000..e0e9c99af
--- /dev/null
+++ b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/mapper/DomainToEntityMapper.kt
@@ -0,0 +1,14 @@
+package com.ku_stacks.ku_ring.ai.mapper
+
+import com.ku_stacks.ku_ring.domain.KuringBotMessage
+import com.ku_stacks.ku_ring.local.entity.KuringBotMessageEntity
+import com.ku_stacks.ku_ring.util.toEpochSecond
+
+internal fun KuringBotMessage.toEntity() = KuringBotMessageEntity(
+ id = id,
+ message = message,
+ postedEpochSeconds = postedDate.toEpochSecond(),
+ isQuery = isQuery,
+)
+
+internal fun List.toEntity() = map { it.toEntity() }
\ No newline at end of file
diff --git a/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/mapper/EntityToDomainMapper.kt b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/mapper/EntityToDomainMapper.kt
new file mode 100644
index 000000000..4e4a76d90
--- /dev/null
+++ b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/mapper/EntityToDomainMapper.kt
@@ -0,0 +1,14 @@
+package com.ku_stacks.ku_ring.ai.mapper
+
+import com.ku_stacks.ku_ring.domain.KuringBotMessage
+import com.ku_stacks.ku_ring.local.entity.KuringBotMessageEntity
+import com.ku_stacks.ku_ring.util.toLocalDateTime
+
+internal fun KuringBotMessageEntity.toDomain() = KuringBotMessage(
+ id = id,
+ message = message,
+ postedDate = postedEpochSeconds.toLocalDateTime(),
+ isQuery = isQuery,
+)
+
+internal fun List.toDomain() = map { it.toDomain() }
\ No newline at end of file
diff --git a/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/repository/KuringBotRepository.kt b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/repository/KuringBotRepository.kt
new file mode 100644
index 000000000..ca33651d4
--- /dev/null
+++ b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/repository/KuringBotRepository.kt
@@ -0,0 +1,51 @@
+package com.ku_stacks.ku_ring.ai.repository
+
+import com.ku_stacks.ku_ring.domain.KuringBotMessage
+import java.time.LocalDate
+
+interface KuringBotRepository {
+ /**
+ * 쿠링봇 메시지 세션을 열고, 서버로부터 데이터가 주어졌을 때 작업을 실행한다.
+ *
+ * @param query 질문 내용
+ * @param token 사용자 FCM 토큰
+ * @param onReceived 데이터를 수신했을 때 실행할 작업
+ */
+ suspend fun openKuringBotSession(
+ query: String,
+ token: String,
+ onReceived: (String) -> Unit,
+ )
+
+ /**
+ * 로컬 DB에 저장된 쿠링봇 메시지를 모두 반환한다.
+ *
+ * @return 로컬에 저장된 모든 쿠링봇 메시지
+ */
+ suspend fun getAllMessages(): List
+
+ /**
+ * 로컬 DB에 쿠링봇 메시지 여러 개를 저장한다.
+ * 이미 저장되어 있는 메시지는 중복으로 저장되지 않는다.
+ *
+ * @param messages 로컬에 저장할 메시지
+ */
+ suspend fun insertMessages(messages: List)
+
+ /**
+ * 로컬 DB에 쿠링봇 메시지를 저장한다.
+ * 이미 저장되어 있는 메시지라면, 아무 일도 일어나지 않는다.
+ *
+ * @param message 로컬에 저장할 메시지
+ */
+ suspend fun insertMessage(message: KuringBotMessage)
+
+ /**
+ * 지정된 기간 동안에 전송한 질문의 수를 반환한다.
+ *
+ * @param from 기간의 시작
+ * @param to 기간의 끝
+ * @return [from]부터 [to]까지 전송한 질문의 수 (inclusive)
+ */
+ suspend fun getQueryCount(from: LocalDate, to: LocalDate): Int
+}
\ No newline at end of file
diff --git a/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/repository/KuringBotRepositoryImpl.kt b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/repository/KuringBotRepositoryImpl.kt
new file mode 100644
index 000000000..d0efe6c90
--- /dev/null
+++ b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/repository/KuringBotRepositoryImpl.kt
@@ -0,0 +1,48 @@
+package com.ku_stacks.ku_ring.ai.repository
+
+import com.ku_stacks.ku_ring.ai.mapper.toDomain
+import com.ku_stacks.ku_ring.ai.mapper.toEntity
+import com.ku_stacks.ku_ring.domain.KuringBotMessage
+import com.ku_stacks.ku_ring.local.room.KuringBotMessageDao
+import com.ku_stacks.ku_ring.remote.kuringbot.KuringBotClient
+import com.ku_stacks.ku_ring.util.toEpochSecond
+import java.time.LocalDate
+import javax.inject.Inject
+
+class KuringBotRepositoryImpl @Inject constructor(
+ private val kuringBotClient: KuringBotClient,
+ private val kuringBotMessageDao: KuringBotMessageDao,
+) : KuringBotRepository {
+
+ override suspend fun openKuringBotSession(
+ query: String,
+ token: String,
+ onReceived: (String) -> Unit,
+ ) {
+ try {
+ kuringBotClient.openKuringBotConnection(query, token, onReceived)
+ } catch (e: Exception) {
+ onReceived(UNKNOWN_ERROR)
+ }
+ }
+
+ override suspend fun getAllMessages(): List {
+ return kuringBotMessageDao.getAllMessages().toDomain()
+ }
+
+ override suspend fun insertMessages(messages: List) {
+ kuringBotMessageDao.insertMessages(messages.toEntity())
+ }
+
+ override suspend fun insertMessage(message: KuringBotMessage) {
+ kuringBotMessageDao.insertMessage(message.toEntity())
+ }
+
+ override suspend fun getQueryCount(from: LocalDate, to: LocalDate): Int {
+ return kuringBotMessageDao.getQueryCount(from.toEpochSecond(), to.toEpochSecond())
+ }
+
+ companion object {
+ internal const val UNKNOWN_ERROR = "예상치 못한 오류가 발생했어요. 다음에 다시 시도해 주세요."
+ }
+}
\ No newline at end of file
diff --git a/data/ai/src/test/java/com/ku_stacks/ku_ring/ai/KuringBotRepositoryImplTest.kt b/data/ai/src/test/java/com/ku_stacks/ku_ring/ai/KuringBotRepositoryImplTest.kt
new file mode 100644
index 000000000..44ff3ee77
--- /dev/null
+++ b/data/ai/src/test/java/com/ku_stacks/ku_ring/ai/KuringBotRepositoryImplTest.kt
@@ -0,0 +1,44 @@
+package com.ku_stacks.ku_ring.ai
+
+import com.ku_stacks.ku_ring.ai.repository.KuringBotRepository
+import com.ku_stacks.ku_ring.ai.repository.KuringBotRepositoryImpl
+import com.ku_stacks.ku_ring.local.room.KuringBotMessageDao
+import com.ku_stacks.ku_ring.remote.kuringbot.KuringBotClient
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mockito
+import org.mockito.exceptions.base.MockitoException
+
+class KuringBotRepositoryImplTest {
+ private val kuringBotClient = Mockito.mock(KuringBotClient::class.java)
+ private val dao = Mockito.mock(KuringBotMessageDao::class.java)
+ private lateinit var repository: KuringBotRepository
+
+ private val query = "교내,외 장학금 및 학자금 대출 관련 전화번호들을 안내를 해줘"
+
+ @Before
+ fun setup() {
+ repository = KuringBotRepositoryImpl(kuringBotClient, dao)
+ }
+
+ @Test
+ fun `test when exception is thrown`() = runTest {
+ val tokens = mutableListOf()
+ val onReceived: (String) -> Unit = { data ->
+ tokens.add(data)
+ }
+
+ // given
+ val token = System.currentTimeMillis().toString()
+ Mockito.`when`(kuringBotClient.openKuringBotConnection(query, token, onReceived))
+ .thenThrow(MockitoException("test exception"))
+
+ // when
+ repository.openKuringBotSession(query, token, onReceived)
+
+ // then
+ assert(tokens.size == 1)
+ assert(tokens.contains(KuringBotRepositoryImpl.UNKNOWN_ERROR))
+ }
+}
\ No newline at end of file
diff --git a/data/domain/src/main/java/com/ku_stacks/ku_ring/domain/KuringBotMessage.kt b/data/domain/src/main/java/com/ku_stacks/ku_ring/domain/KuringBotMessage.kt
new file mode 100644
index 000000000..4b7b1a5f4
--- /dev/null
+++ b/data/domain/src/main/java/com/ku_stacks/ku_ring/domain/KuringBotMessage.kt
@@ -0,0 +1,10 @@
+package com.ku_stacks.ku_ring.domain
+
+import java.time.LocalDateTime
+
+data class KuringBotMessage(
+ val id: Int,
+ val message: String,
+ val postedDate: LocalDateTime,
+ val isQuery: Boolean,
+)
\ No newline at end of file
diff --git a/data/remote/src/main/java/com/ku_stacks/ku_ring/remote/kuringbot/KuringBotClient.kt b/data/remote/src/main/java/com/ku_stacks/ku_ring/remote/kuringbot/KuringBotClient.kt
index f54adf4ff..b9e63b3dd 100644
--- a/data/remote/src/main/java/com/ku_stacks/ku_ring/remote/kuringbot/KuringBotClient.kt
+++ b/data/remote/src/main/java/com/ku_stacks/ku_ring/remote/kuringbot/KuringBotClient.kt
@@ -2,14 +2,13 @@ package com.ku_stacks.ku_ring.remote.kuringbot
interface KuringBotClient {
/**
- * 쿠링봇 SSE 세션을 열고, 데이터를 문자열 형식으로 받아 처리한다.
- * SSE 데이터를 받았을 때, [onReceived]에 접두어 `data:`를 떼지 않고 그대로 제공한다.
+ * 쿠링봇 연결을 만들고, 응답을 받아 문자열 형식으로 처리한다.
*
* @param query 질문 내용
* @param token 사용자 FCM 토큰
* @param onReceived 데이터를 수신했을 때 실행할 작업.
*/
- suspend fun openKuringBotSSESession(
+ suspend fun openKuringBotConnection(
query: String,
token: String,
onReceived: (String) -> Unit,
diff --git a/data/remote/src/main/java/com/ku_stacks/ku_ring/remote/kuringbot/KuringBotSSEClient.kt b/data/remote/src/main/java/com/ku_stacks/ku_ring/remote/kuringbot/KuringBotSSEClient.kt
index 5e431248b..aebce8d82 100644
--- a/data/remote/src/main/java/com/ku_stacks/ku_ring/remote/kuringbot/KuringBotSSEClient.kt
+++ b/data/remote/src/main/java/com/ku_stacks/ku_ring/remote/kuringbot/KuringBotSSEClient.kt
@@ -12,7 +12,7 @@ class KuringBotSSEClient @Inject constructor(
private val client: HttpClient,
) : KuringBotClient {
- override suspend fun openKuringBotSSESession(
+ override suspend fun openKuringBotConnection(
query: String,
token: String,
onReceived: (String) -> Unit,
diff --git a/data/remote/src/test/java/com/ku_stacks/ku_ring/remote/KuringBotSSEClientTest.kt b/data/remote/src/test/java/com/ku_stacks/ku_ring/remote/KuringBotSSEClientTest.kt
index ba61c6ff5..de99352ff 100644
--- a/data/remote/src/test/java/com/ku_stacks/ku_ring/remote/KuringBotSSEClientTest.kt
+++ b/data/remote/src/test/java/com/ku_stacks/ku_ring/remote/KuringBotSSEClientTest.kt
@@ -28,7 +28,7 @@ class KuringBotSSEClientTest {
// when
val tokens = mutableListOf()
- kuringBotClient.openKuringBotSSESession(query, testToken) {
+ kuringBotClient.openKuringBotConnection(query, testToken) {
tokens.add(it)
}
@@ -43,7 +43,7 @@ class KuringBotSSEClientTest {
// when
val tokens = mutableListOf()
- kuringBotClient.openKuringBotSSESession(wrongQuery, testToken) {
+ kuringBotClient.openKuringBotConnection(wrongQuery, testToken) {
tokens.add(it)
}
@@ -57,11 +57,11 @@ class KuringBotSSEClientTest {
val testToken = System.currentTimeMillis().toString()
// when
- kuringBotClient.openKuringBotSSESession(query, testToken) {}
- kuringBotClient.openKuringBotSSESession(query, testToken) {}
+ kuringBotClient.openKuringBotConnection(query, testToken) {}
+ kuringBotClient.openKuringBotConnection(query, testToken) {}
val tokens = mutableListOf()
- kuringBotClient.openKuringBotSSESession(query, testToken) {
+ kuringBotClient.openKuringBotConnection(query, testToken) {
tokens.add(it)
}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index fad854b4d..147e62f19 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -27,6 +27,7 @@ include(
":data:notice",
":data:notice:test",
":core:preferences",
+ ":data:ai",
":data:push",
":data:push:test",
":data:user",