diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index e122a944c..fbadf672c 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -14,7 +14,7 @@ jobs: if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name != 'DreamExposure/DisCal-Discord-Bot' }} strategy: matrix: - java: [ 16, 17 ] + java: [ 17 ] steps: - uses: actions/checkout@v2 @@ -93,8 +93,8 @@ jobs: # Have K8S pull latest images for dev pods - name: Trigger dev deploy - uses: Consensys/kubernetes-action@master + uses: davi020/kubernetes-action@master env: KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }} with: - args: delete -n discal pods -l profile=dev,app=discal + args: delete -n discal pods -l env=dev,app=discal diff --git a/README.md b/README.md index 0f8cabde6..fe41f9dcd 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # DisCal [![Discord](https://img.shields.io/discord/375357265198317579?label=DreamExposure&style=flat-square)](https://discord.gg/2TFqyuy) -![GitHub Workflow Status](https://img.shields.io/github/workflow/status/DreamExposure/DisCal-Discord-Bot/Java%20CI?label=Build&style=flat-square) +![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/DreamExposure/DisCal-Discord-Bot/gradle.yml?branch=develop&label=Build&style=flat-square) [![Website](https://img.shields.io/website?down_color=red&down_message=offline&label=Status&style=flat-square&up_message=online&url=https%3A%2F%2Fwww.discalbot.com)](https://discalbot.com) DisCal is a discord bot that connects Discord and Google Calendar as seamlessly as possible with a wide feature set for diff --git a/build.gradle.kts b/build.gradle.kts index de9a5ccf5..4f054a085 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,64 +1,73 @@ +import org.gradle.api.tasks.wrapper.Wrapper.DistributionType.ALL import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - java - //kotlin kotlin("jvm") kotlin("plugin.serialization") - kotlin("plugin.spring") apply false - id("org.jetbrains.kotlin.plugin.allopen") apply false + id("org.jetbrains.kotlin.plugin.allopen") - //Other + // Spring + kotlin("plugin.spring") id("org.springframework.boot") apply false + id("io.spring.dependency-management") + + //Tooling id("com.gorylenko.gradle-git-properties") apply false id("com.google.cloud.tools.jib") apply false } buildscript { + val kotlinPoetVersion: String by properties dependencies { - classpath("com.squareup:kotlinpoet:1.7.2") + classpath("com.squareup:kotlinpoet:$kotlinPoetVersion") } } allprojects { //Project props group = "org.dreamexposure.discal" - version = "4.2.2" + version = "4.2.3" description = "DisCal" //Plugins apply(plugin = "java") apply(plugin = "kotlin") + apply(plugin = "io.spring.dependency-management") - //Compiler nonsense - java.sourceCompatibility = JavaVersion.VERSION_16 - java.targetCompatibility = JavaVersion.VERSION_16 - - //Versions + // Versions --- found in gradle.properties val kotlinVersion: String by properties - val kotlinxSerializationVersion: String by properties - + // Tool + val kotlinxCoroutinesReactorVersion: String by properties + val reactorKotlinExtensions: String by properties + // Discord + val discord4jVersion: String by properties + val discord4jStoresVersion: String by properties + val discordWebhookVersion: String by properties + // Spring val springVersion: String by properties - - val googleCoreVersion: String by properties - val googleCalendarVersion: String by properties - - val r2MysqlVersion: String by properties - val r2PoolVersion: String by properties - - val nettyVersion: String by properties - val reactorBomVersion: String by properties - - val slfVersion: String by properties + // Database + val flywayVersion: String by properties + val mikuR2dbcMySqlVersion: String by properties + val mySqlConnectorJava: String by properties + // Serialization + val kotlinxSerializationJsonVersion: String by properties + val jacksonVersion: String by properties val jsonVersion: String by properties - val okHttpVersion: String by properties - val discordWebhookVersion: String by properties + // Google libs + val googleApiClientVersion: String by properties + val googleServicesCalendarVersion: String by properties + val googleOauthClientVersion: String by properties + // Various libs + val okhttpVersion: String by properties val copyDownVersion: String by properties + val jsoupVersion: String by properties repositories { mavenCentral() + mavenLocal() + maven("https://repo.maven.apache.org/maven2/") maven("https://kotlin.bintray.com/kotlinx") maven("https://oss.sonatype.org/content/repositories/snapshots") maven("https://repo.spring.io/milestone") @@ -66,48 +75,50 @@ allprojects { } dependencies { - //Boms - implementation(platform("io.projectreactor:reactor-bom:$reactorBomVersion")) - - //Kotlin Deps + // Tools implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion") implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationVersion") - - //Forced stuff - //slf4j-api - Need to force this for logback to work. I dunno - implementation("org.slf4j:slf4j-api:$slfVersion") - //Netty - forced due to stores-redis:lettuce-core giving 4.1.38 - implementation("io.netty:netty-all:$nettyVersion") - //Forcing reactor version - implementation("io.projectreactor:reactor-core") - - //Google apis - implementation("com.google.api-client:google-api-client:$googleCoreVersion") - implementation("com.google.apis:google-api-services-calendar:$googleCalendarVersion") - implementation("com.google.oauth-client:google-oauth-client-jetty:$googleCoreVersion") { - exclude(group = "org.mortbay.jetty", module = "servlet-api") - } - //r2dbc - implementation("dev.miku:r2dbc-mysql:$r2MysqlVersion") { - exclude("io.netty", "*") - exclude("io.projectreactor", "*") - exclude("io.projectreactor.netty", "*") - } - implementation("io.r2dbc:r2dbc-pool:$r2PoolVersion") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:$kotlinxCoroutinesReactorVersion") + implementation("io.projectreactor.kotlin:reactor-kotlin-extensions:$reactorKotlinExtensions") + // Discord + implementation("com.discord4j:discord4j-core:$discord4jVersion") + implementation("com.discord4j:stores-redis:$discord4jStoresVersion") + implementation("club.minnced:discord-webhooks:$discordWebhookVersion") - implementation("org.json:json:$jsonVersion") + // Spring + implementation("org.springframework.boot:spring-boot-starter-data-jdbc:$springVersion") + implementation("org.springframework.boot:spring-boot-starter-data-r2dbc:$springVersion") + implementation("org.springframework.boot:spring-boot-starter-data-redis:$springVersion") + implementation("org.springframework.boot:spring-boot-starter-webflux:$springVersion") + implementation("org.springframework.boot:spring-boot-starter-cache:$springVersion") - implementation("com.squareup.okhttp3:okhttp:$okHttpVersion") + // Database + implementation("dev.miku:r2dbc-mysql:$mikuR2dbcMySqlVersion") + implementation("mysql:mysql-connector-java:$mySqlConnectorJava") - implementation("club.minnced:discord-webhooks:$discordWebhookVersion") + // Serialization + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationJsonVersion") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion") + implementation("org.json:json:$jsonVersion") + + // Google libs + implementation("com.google.api-client:google-api-client:$googleApiClientVersion") + implementation("com.google.apis:google-api-services-calendar:$googleServicesCalendarVersion") + implementation("com.google.oauth-client:google-oauth-client-jetty:$googleOauthClientVersion") { + exclude(group = "org.mortbay.jetty", module = "servlet-api") + } + // Various Libs + implementation("com.squareup.okhttp3:okhttp:$okhttpVersion") implementation("io.github.furstenheim:copy_down:$copyDownVersion") + implementation("org.jsoup:jsoup:$jsoupVersion") + } - //Spring - implementation("org.springframework.boot:spring-boot-starter-webflux:$springVersion") - implementation("org.springframework.boot:spring-boot-starter-data-r2dbc:$springVersion") + java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlin { @@ -127,9 +138,16 @@ subprojects { withType { kotlinOptions { freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = targetCompatibility + jvmTarget = java.targetCompatibility.majorVersion } } } } +tasks { + wrapper { + distributionType = ALL + gradleVersion = "8.2.1" + } +} + diff --git a/cam/build.gradle.kts b/cam/build.gradle.kts index 8fdb0f878..33de04b4c 100644 --- a/cam/build.gradle.kts +++ b/cam/build.gradle.kts @@ -1,24 +1,19 @@ plugins { + // Kotlin kotlin("plugin.serialization") + id("org.jetbrains.kotlin.plugin.allopen") + + // Spring kotlin("plugin.spring") id("org.springframework.boot") - id("org.jetbrains.kotlin.plugin.allopen") + id("io.spring.dependency-management") + + // Tooling id("com.google.cloud.tools.jib") } -val springSessionVersion: String by properties -val springR2Version: String by properties -val jacksonKotlinModVersion: String by properties - dependencies { api(project(":core")) - - //Spring libs - implementation("org.springframework.session:spring-session-data-redis:$springSessionVersion") - implementation("org.springframework:spring-r2dbc:$springR2Version") - - //jackson for kotlin - implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonKotlinModVersion") } kotlin { @@ -30,14 +25,13 @@ kotlin { } jib { - var imageVersion = version.toString() - if (imageVersion.contains("SNAPSHOT")) imageVersion = "latest" + to { + image = "rg.nl-ams.scw.cloud/dreamexposure/discal-cam" + tags = mutableSetOf("latest", version.toString()) + } - to.image = "rg.nl-ams.scw.cloud/dreamexposure/discal-cam:$imageVersion" val baseImage: String by properties from.image = baseImage - - container.creationTime = "USE_CURRENT_TIMESTAMP" } tasks { diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/Cam.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/Cam.kt index c8f247658..4b4f42565 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/Cam.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/Cam.kt @@ -1,45 +1,28 @@ package org.dreamexposure.discal.cam +import jakarta.annotation.PreDestroy import org.dreamexposure.discal.Application -import org.dreamexposure.discal.cam.google.GoogleInternalAuthHandler -import org.dreamexposure.discal.core.`object`.BotSettings -import org.dreamexposure.discal.core.database.DatabaseManager +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.utils.GlobalVal import org.springframework.boot.builder.SpringApplicationBuilder import org.springframework.stereotype.Component -import java.io.FileReader -import java.util.* -import javax.annotation.PreDestroy import kotlin.system.exitProcess @Component class Cam { @PreDestroy - fun onShutdown() { - LOGGER.info(GlobalVal.STATUS, "CAM shutting down.") - DatabaseManager.disconnectFromMySQL() - } + fun onShutdown() = LOGGER.info(GlobalVal.STATUS, "CAM shutting down.") companion object { @JvmStatic fun main(args: Array) { - //Get settings - val p = Properties() - p.load(FileReader("settings.properties")) - BotSettings.init(p) - - //Handle generating new google auth credentials for discal accounts - if (args.size > 1 && args[0].equals("-forceNewGoogleAuth", true)) { - //This will automatically kill this instance once finished - GoogleInternalAuthHandler.requestCode(args[1].toInt()).subscribe() - } + Config.init() //Start up spring try { SpringApplicationBuilder(Application::class.java) - .profiles(BotSettings.PROFILE.get()) .build() .run(*args) LOGGER.info(GlobalVal.STATUS, "CAM is now online") diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/business/OauthStateService.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/business/OauthStateService.kt new file mode 100644 index 000000000..5352a697f --- /dev/null +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/business/OauthStateService.kt @@ -0,0 +1,19 @@ +package org.dreamexposure.discal.cam.business + +import org.dreamexposure.discal.OauthStateCache +import org.dreamexposure.discal.core.crypto.KeyGenerator +import org.springframework.stereotype.Component + +@Component +class OauthStateService( + private val stateCache: OauthStateCache, +) { + suspend fun generateState(): String { + val state = KeyGenerator.csRandomAlphaNumericString(64) + stateCache.put(state, state) + + return state + } + + suspend fun validateState(state: String) = stateCache.getAndRemove(state) != null +} diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/business/cronjob/HeartbeatCronJob.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/business/cronjob/HeartbeatCronJob.kt new file mode 100644 index 000000000..f8dcd002d --- /dev/null +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/business/cronjob/HeartbeatCronJob.kt @@ -0,0 +1,55 @@ +package org.dreamexposure.discal.cam.business.cronjob + +import com.fasterxml.jackson.databind.ObjectMapper +import kotlinx.coroutines.reactor.mono +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.dreamexposure.discal.core.config.Config +import org.dreamexposure.discal.core.extensions.asSeconds +import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.`object`.network.discal.InstanceData +import org.dreamexposure.discal.core.`object`.rest.HeartbeatRequest +import org.dreamexposure.discal.core.`object`.rest.HeartbeatType +import org.dreamexposure.discal.core.utils.GlobalVal +import org.dreamexposure.discal.core.utils.GlobalVal.HTTP_CLIENT +import org.dreamexposure.discal.core.utils.GlobalVal.JSON +import org.springframework.boot.ApplicationArguments +import org.springframework.boot.ApplicationRunner +import org.springframework.stereotype.Component +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers + +@Component +class HeartbeatCronJob( + private val objectMapper: ObjectMapper, +): ApplicationRunner { + private final val apiUrl = Config.URL_API.getString() + + override fun run(args: ApplicationArguments?) { + Flux.interval(Config.HEARTBEAT_INTERVAL.getLong().asSeconds()) + .flatMap { heartbeat() } + .doOnError { LOGGER.error(GlobalVal.DEFAULT, "[Heartbeat] Failed to heartbeat", it) } + .onErrorResume { Mono.empty() } + .subscribe() + } + + private fun heartbeat() = mono { + val requestBody = HeartbeatRequest(HeartbeatType.CAM, instanceData = InstanceData()) + + val request = Request.Builder() + .url("$apiUrl/v2/status/heartbeat") + .post(objectMapper.writeValueAsString(requestBody).toRequestBody(JSON)) + .header("Authorization", Config.SECRET_DISCAL_API_KEY.getString()) + .header("Content-Type", "application/json") + .build() + + Mono.fromCallable(HTTP_CLIENT.newCall(request)::execute) + .map(Response::close) + .subscribeOn(Schedulers.boundedElastic()) + .doOnError { LOGGER.error(GlobalVal.DEFAULT, "[Heartbeat] Failed to heartbeat", it) } + .onErrorResume { Mono.empty() } + .subscribe() + } +} diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/business/cronjob/SessionCronJob.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/business/cronjob/SessionCronJob.kt new file mode 100644 index 000000000..84f1905c7 --- /dev/null +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/business/cronjob/SessionCronJob.kt @@ -0,0 +1,31 @@ +package org.dreamexposure.discal.cam.business.cronjob + +import kotlinx.coroutines.reactor.mono +import org.dreamexposure.discal.core.business.SessionService +import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.utils.GlobalVal +import org.springframework.boot.ApplicationArguments +import org.springframework.boot.ApplicationRunner +import org.springframework.stereotype.Component +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.time.Duration + +@Component +class SessionCronJob( + val sessionService: SessionService, +) : ApplicationRunner { + override fun run(args: ApplicationArguments?) { + Flux.interval(Duration.ofHours(1)) + .flatMap { justDoIt() } + .doOnError { LOGGER.error(GlobalVal.DEFAULT, "Session cronjob error", it) } + .onErrorResume { Mono.empty() } + .subscribe() + } + + private fun justDoIt() = mono { + LOGGER.debug("Running expired session purge job") + + sessionService.deleteExpiredSessions() + } +} diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/discord/DiscordOauthHandler.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/discord/DiscordOauthHandler.kt index 583013344..dad3ec25e 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/discord/DiscordOauthHandler.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/discord/DiscordOauthHandler.kt @@ -1,117 +1,116 @@ package org.dreamexposure.discal.cam.discord +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import kotlinx.coroutines.reactor.awaitSingle import okhttp3.FormBody import okhttp3.Request import org.dreamexposure.discal.cam.json.discord.AccessTokenResponse import org.dreamexposure.discal.cam.json.discord.AuthorizationInfo +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.exceptions.AuthenticationException -import org.dreamexposure.discal.core.`object`.BotSettings import org.dreamexposure.discal.core.utils.GlobalVal import org.dreamexposure.discal.core.utils.GlobalVal.HTTP_CLIENT -import org.dreamexposure.discal.core.utils.GlobalVal.JSON_FORMAT +import org.springframework.stereotype.Component import reactor.core.publisher.Mono import reactor.core.scheduler.Schedulers -import java.net.URLEncoder -import java.nio.charset.Charset - -object DiscordOauthHandler { - private val redirectUrl = URLEncoder.encode(BotSettings.REDIR_URL.get(), Charset.defaultCharset()) - private const val CDN_URL = "https://cdn.discordapp.com" - - fun doTokenExchange(code: String): Mono { - return Mono.fromCallable { - val body = FormBody.Builder() - .addEncoded("client_id", BotSettings.ID.get()) - .addEncoded("client_secret", BotSettings.SECRET.get()) - .addEncoded("grant_type", "authorization_code") - .addEncoded("code", code) - .addEncoded("redirect_uri", redirectUrl) - .build() - - val tokenExchangeRequest = Request.Builder() - .url("${GlobalVal.discordApiUrl}/oauth2/token") - .post(body) - .header("Content-Type", "application/x-www-form-urlencoded") - .build() - - HTTP_CLIENT.newCall(tokenExchangeRequest).execute() - }.subscribeOn(Schedulers.boundedElastic()).flatMap { response -> - if (response.isSuccessful) { - //Transform body into our object - val responseBody = JSON_FORMAT.decodeFromString(AccessTokenResponse.serializer(), - response.body!!.string()) - // Close body to avoid mem leak - response.body?.close() - - Mono.just(responseBody) - } else { - Mono.error(AuthenticationException("Discord authorization grant error")) - } + +@Component +class DiscordOauthHandler( + private val objectMapper: ObjectMapper, +) { + private val cdnUrl = "https://cdn.discordapp.com" + private val redirectUrl = Config.URL_DISCORD_REDIRECT.getString() + private val clientId = Config.DISCORD_APP_ID.getString() + private val clientSecret = Config.SECRET_CLIENT_SECRET.getString() + + suspend fun doTokenExchange(code: String): AccessTokenResponse { + val body = FormBody.Builder() + .addEncoded("client_id", clientId) + .addEncoded("client_secret", clientSecret) + .addEncoded("grant_type", "authorization_code") + .addEncoded("code", code) + .addEncoded("redirect_uri", redirectUrl) + .build() + val tokenExchangeRequest = Request.Builder() + .url("${GlobalVal.discordApiUrl}/oauth2/token") + .post(body) + .header("Content-Type", "application/x-www-form-urlencoded") + .build() + + val response = Mono.fromCallable(HTTP_CLIENT.newCall(tokenExchangeRequest)::execute) + .subscribeOn(Schedulers.boundedElastic()) + .awaitSingle() + + if (response.isSuccessful) { + val responseBody = objectMapper.readValue(response.body!!.string()) + response.close() + + return responseBody + } else { + throw AuthenticationException("Discord authorization grant error") } } - fun doTokenRefresh(refreshToken: String): Mono { - return Mono.fromCallable { - val body = FormBody.Builder() - .addEncoded("client_id", BotSettings.ID.get()) - .addEncoded("client_secret", BotSettings.SECRET.get()) - .addEncoded("grant_type", "refresh_token") - .addEncoded("refresh_token", refreshToken) - .build() - - val tokenExchangeRequest = Request.Builder() - .url("${GlobalVal.discordApiUrl}/oauth2/token") - .post(body) - .header("Content-Type", "application/x-www-form-urlencoded") - .build() - - HTTP_CLIENT.newCall(tokenExchangeRequest).execute() - }.subscribeOn(Schedulers.boundedElastic()).flatMap { response -> - if (response.isSuccessful) { - //Transform body into our object - val responseBody = JSON_FORMAT.decodeFromString(AccessTokenResponse.serializer(), - response.body!!.string()) - // Close body to avoid mem leak - response.body?.close() - - Mono.just(responseBody) - } else { - Mono.error(AuthenticationException("Discord refresh token error")) - } + suspend fun doTokenRefresh(refreshToken: String): AccessTokenResponse { + val body = FormBody.Builder() + .addEncoded("client_id", clientId) + .addEncoded("client_secret", clientSecret) + .addEncoded("grant_type", "refresh_token") + .addEncoded("refresh_token", refreshToken) + .build() + + val tokenExchangeRequest = Request.Builder() + .url("${GlobalVal.discordApiUrl}/oauth2/token") + .post(body) + .header("Content-Type", "application/x-www-form-urlencoded") + .build() + + val response = Mono.fromCallable(HTTP_CLIENT.newCall(tokenExchangeRequest)::execute) + .subscribeOn(Schedulers.boundedElastic()) + .awaitSingle() + + if (response.isSuccessful) { + val responseBody = objectMapper.readValue(response.body!!.string()) + response.close() + + return responseBody + } else { + throw AuthenticationException("Discord refresh token error") } } - fun getOauthInfo(accessToken: String): Mono { - return Mono.fromCallable { - val request = Request.Builder() - .url("${GlobalVal.discordApiUrl}/oauth2/@me") - .get() - .header("Authorization", "Bearer $accessToken") - .build() - - HTTP_CLIENT.newCall(request).execute() - }.subscribeOn(Schedulers.boundedElastic()).flatMap { response -> - if (response.isSuccessful) { - //Transform body into our object - var responseBody = JSON_FORMAT.decodeFromString(AuthorizationInfo.serializer(), - response.body!!.string()) - - //Convert avatar hash to full URL - val avatar = if (responseBody.user!!.avatar != null) { - val userId = responseBody.user!!.id.asString() - val avatarHash = responseBody.user!!.avatar - "$CDN_URL/avatars/$userId/$avatarHash.png" - } else { - // No avatar present, get discord's default user avatar - val discrim = responseBody.user!!.discriminator - "$CDN_URL/embed/avatars/${discrim.toInt() % 5}.png" - } - responseBody = responseBody.copy(user = responseBody.user!!.copy(avatar = avatar)) - - Mono.just(responseBody) + + suspend fun getOauthInfo(accessToken: String): AuthorizationInfo { + val request = Request.Builder() + .url("${GlobalVal.discordApiUrl}/oauth2/@me") + .get() + .header("Authorization", "Bearer $accessToken") + .build() + + val response = Mono.fromCallable(HTTP_CLIENT.newCall(request)::execute) + .subscribeOn(Schedulers.boundedElastic()) + .awaitSingle() + + if (response.isSuccessful) { + var responseBody = objectMapper.readValue(response.body!!.string()) + response.close() + + //Convert avatar hash to full URL + val avatar = if (responseBody.user!!.avatar != null) { + val userId = responseBody.user!!.id.asString() + val avatarHash = responseBody.user!!.avatar + "$cdnUrl/avatars/$userId/$avatarHash.png" } else { - Mono.error(AuthenticationException("Discord auth info error")) + // No avatar present, get discord's default user avatar + val discrim = responseBody.user!!.discriminator + "$cdnUrl/embed/avatars/${discrim.toInt() % 5}.png" } + responseBody = responseBody.copy(user = responseBody.user!!.copy(avatar = avatar)) + + return responseBody + } else { + throw AuthenticationException("Discord auth info error") } } } diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/endpoints/v1/GetEndpoint.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/endpoints/v1/GetEndpoint.kt index f36298dba..a21e653cf 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/endpoints/v1/GetEndpoint.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/endpoints/v1/GetEndpoint.kt @@ -1,36 +1,23 @@ package org.dreamexposure.discal.cam.endpoints.v1 import discord4j.common.util.Snowflake -import org.dreamexposure.discal.cam.google.GoogleAuth -import org.dreamexposure.discal.core.`object`.network.discal.CredentialData +import org.dreamexposure.discal.cam.managers.CalendarAuthManager import org.dreamexposure.discal.core.annotations.Authentication -import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.enums.calendar.CalendarHost +import org.dreamexposure.discal.core.`object`.network.discal.CredentialData import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController -import reactor.core.publisher.Mono @RestController @RequestMapping("/v1/") -class GetEndpoint { - +class GetEndpoint( + private val calendarAuthManager: CalendarAuthManager, +) { @Authentication(access = Authentication.AccessLevel.ADMIN) @GetMapping("token", produces = ["application/json"]) - fun get(@RequestParam host: CalendarHost, @RequestParam id: Int, @RequestParam guild: Snowflake?): Mono { - return Mono.defer { - when (host) { - CalendarHost.GOOGLE -> { - if (guild == null) { - // Internal (owned by DisCal, should never go bad) - GoogleAuth.requestNewAccessToken(id) - } else { - // External (owned by user) - DatabaseManager.getCalendar(guild, id).flatMap(GoogleAuth::requestNewAccessToken) - } - } - } - } + suspend fun get(@RequestParam host: CalendarHost, @RequestParam id: Int, @RequestParam guild: Snowflake?): CredentialData? { + return calendarAuthManager.getCredentialData(host, id, guild) } } diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/endpoints/v1/oauth2/DiscordOauthEndpoint.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/endpoints/v1/oauth2/DiscordOauthEndpoint.kt index 53170a275..54103efa5 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/endpoints/v1/oauth2/DiscordOauthEndpoint.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/endpoints/v1/oauth2/DiscordOauthEndpoint.kt @@ -1,78 +1,34 @@ package org.dreamexposure.discal.cam.endpoints.v1.oauth2 -import org.dreamexposure.discal.cam.discord.DiscordOauthHandler import org.dreamexposure.discal.cam.json.discal.LoginResponse import org.dreamexposure.discal.cam.json.discal.TokenRequest import org.dreamexposure.discal.cam.json.discal.TokenResponse -import org.dreamexposure.discal.cam.service.StateService -import org.dreamexposure.discal.core.`object`.BotSettings.ID -import org.dreamexposure.discal.core.`object`.BotSettings.REDIR_URL -import org.dreamexposure.discal.core.`object`.WebSession +import org.dreamexposure.discal.cam.managers.DiscordOauthManager import org.dreamexposure.discal.core.annotations.Authentication -import org.dreamexposure.discal.core.crypto.KeyGenerator -import org.dreamexposure.discal.core.database.DatabaseManager -import org.dreamexposure.discal.core.utils.GlobalVal -import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.* -import org.springframework.web.server.ResponseStatusException -import reactor.core.publisher.Mono -import java.net.URLEncoder -import java.nio.charset.Charset.defaultCharset @RestController @RequestMapping("/oauth2/discord/") -class DiscordOauthEndpoint(private val stateService: StateService) { - private final val scopes = URLEncoder.encode("identify guilds", defaultCharset()) - private final val redirectUrl = URLEncoder.encode(REDIR_URL.get(), defaultCharset()) - private final val oauthLinkWithoutState = - "${GlobalVal.discordApiUrl}/oauth2/authorize" + - "?client_id=${ID.get()}" + - "&redirect_uri=$redirectUrl" + - "&response_type=code" + - "&scope=$scopes" + - "&prompt=none" // Skip consent screen if user has already authorized these scopes before +class DiscordOauthEndpoint( + private val discordOauthManager: DiscordOauthManager, +) { @GetMapping("login") @Authentication(access = Authentication.AccessLevel.PUBLIC) - fun login(): Mono { - val state = stateService.generateState() - - val link = "$oauthLinkWithoutState&state=$state" - - return Mono.just(LoginResponse(link)) + suspend fun login(): LoginResponse { + val link = discordOauthManager.getOauthLinkForLogin() + return LoginResponse(link) } @GetMapping("logout") @Authentication(access = Authentication.AccessLevel.WRITE) - fun logout(@RequestHeader("Authorization") token: String): Mono { - return DatabaseManager.deleteSession(token).then() + suspend fun logout(@RequestHeader("Authorization") token: String) { + discordOauthManager.handleLogout(token) } @PostMapping("code") @Authentication(access = Authentication.AccessLevel.PUBLIC) - fun token(@RequestBody body: TokenRequest): Mono { - // Validate state - if (!stateService.validateState(body.state)) { - // State invalid - 400 - return Mono.error(ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid state")) - } - - return DiscordOauthHandler.doTokenExchange(body.code).flatMap { dTokens -> - // request current user info - DiscordOauthHandler.getOauthInfo(dTokens.accessToken).flatMap { authInfo -> - val apiToken = KeyGenerator.csRandomAlphaNumericString(64) - - val session = WebSession( - apiToken, - authInfo.user!!.id, - accessToken = dTokens.accessToken, - refreshToken = dTokens.refreshToken - ) - - // Save session data then return response - DatabaseManager.removeAndInsertSessionData(session) - .thenReturn(TokenResponse(session.token, session.expiresAt, authInfo.user)) - } - } + suspend fun token(@RequestBody body: TokenRequest): TokenResponse { + return discordOauthManager.handleCodeExchange(body.state, body.code) } } diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleAuth.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleAuth.kt index 3c0db95d8..3b34a6505 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleAuth.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleAuth.kt @@ -1,134 +1,121 @@ package org.dreamexposure.discal.cam.google +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue import com.google.api.client.http.HttpStatusCodes.STATUS_CODE_BAD_REQUEST import com.google.api.client.http.HttpStatusCodes.STATUS_CODE_OK +import kotlinx.coroutines.reactor.awaitSingle import okhttp3.FormBody import okhttp3.Request import org.dreamexposure.discal.cam.json.google.ErrorData import org.dreamexposure.discal.cam.json.google.RefreshData -import org.dreamexposure.discal.core.`object`.BotSettings -import org.dreamexposure.discal.core.`object`.calendar.CalendarData -import org.dreamexposure.discal.core.`object`.google.ClientData -import org.dreamexposure.discal.core.`object`.network.discal.CredentialData +import org.dreamexposure.discal.core.business.CalendarService +import org.dreamexposure.discal.core.business.CredentialService +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.crypto.AESEncryption -import org.dreamexposure.discal.core.database.DatabaseManager -import org.dreamexposure.discal.core.entities.google.DisCalGoogleCredential import org.dreamexposure.discal.core.exceptions.AccessRevokedException import org.dreamexposure.discal.core.exceptions.EmptyNotAllowedException import org.dreamexposure.discal.core.exceptions.NotFoundException +import org.dreamexposure.discal.core.extensions.isExpiredTtl import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.`object`.network.discal.CredentialData +import org.dreamexposure.discal.core.`object`.new.Calendar import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT import org.dreamexposure.discal.core.utils.GlobalVal.HTTP_CLIENT -import org.dreamexposure.discal.core.utils.GlobalVal.JSON_FORMAT -import reactor.core.publisher.Flux +import org.springframework.stereotype.Component import reactor.core.publisher.Mono import reactor.core.scheduler.Schedulers import java.time.Instant -import kotlin.system.exitProcess - -@Suppress("BlockingMethodInNonBlockingContext") -object GoogleAuth { - private val clientData = ClientData(BotSettings.GOOGLE_CLIENT_ID.get(), BotSettings.GOOGLE_CLIENT_SECRET.get()) - private val CREDENTIALS: Flux - - init { - val credCount = BotSettings.CREDENTIALS_COUNT.get().toInt() - CREDENTIALS = Flux.range(0, credCount) - .flatMap(DatabaseManager::getCredentialData) - .map(::DisCalGoogleCredential) - .doOnError { exitProcess(1) } - .cache() - } - fun requestNewAccessToken(calendarData: CalendarData): Mono { - return Mono.just(AESEncryption(calendarData.privateKey)).flatMap { aes -> - if (!calendarData.expired()) { - return@flatMap aes.decrypt(calendarData.encryptedAccessToken) - .map { CredentialData(it, calendarData.expiresAt) } - } - - aes.decrypt(calendarData.encryptedRefreshToken) - .flatMap(this::doAccessTokenRequest) - .flatMap { data -> - //calendarData.encryptedAccessToken = aes.encrypt(data.accessToken) - calendarData.expiresAt = data.validUntil +@Component +class GoogleAuth( + private val credentialService: CredentialService, + private val calendarService: CalendarService, + private val objectMapper: ObjectMapper, +) { + private final val aes: AESEncryption = AESEncryption(Config.SECRET_GOOGLE_CREDENTIAL_KEY.getString()) - aes.encrypt(data.accessToken) - .doOnNext { calendarData.encryptedAccessToken = it } - .then(DatabaseManager.updateCalendar(calendarData).thenReturn(data)) - } + suspend fun requestNewAccessToken(calendar: Calendar): CredentialData? { + val aes = AESEncryption(calendar.secrets.privateKey) + if (!calendar.secrets.expiresAt.isExpiredTtl()) { + return aes.decrypt(calendar.secrets.encryptedAccessToken) + .map { CredentialData(it, calendar.secrets.expiresAt) } + .awaitSingle() } + + val refreshToken = aes.decrypt(calendar.secrets.encryptedRefreshToken).awaitSingle() + val refreshedCredential = doAccessTokenRequest(refreshToken) ?: return null + + calendar.secrets.expiresAt = refreshedCredential.validUntil.minusSeconds(60) // Add a minute of wiggle room + calendar.secrets.encryptedAccessToken = aes.encrypt(refreshedCredential.accessToken).awaitSingle() + + calendarService.updateCalendar(calendar) + + return refreshedCredential } - fun requestNewAccessToken(credentialId: Int): Mono { - return CREDENTIALS - .filter { it.credentialData.credentialNumber == credentialId } - .next() - .switchIfEmpty(Mono.error(NotFoundException())) - .flatMap { credential -> - if (!credential.expired()) { - return@flatMap credential.getAccessToken() - .map { CredentialData(it, credential.credentialData.expiresAt) } - } - - credential.getRefreshToken() - .flatMap(this::doAccessTokenRequest) - .flatMap { credential.setAccessToken(it.accessToken).thenReturn(it) } - .doOnNext { credential.credentialData.expiresAt = it.validUntil } - .flatMap { DatabaseManager.updateCredentialData(credential.credentialData).thenReturn(it) } - }.switchIfEmpty(Mono.error(EmptyNotAllowedException())) + suspend fun requestNewAccessToken(credentialId: Int): CredentialData { + val credential = credentialService.getCredential(credentialId) ?: throw NotFoundException() + if (!credential.expiresAt.isExpiredTtl()) { + val accessToken = aes.decrypt(credential.encryptedAccessToken).awaitSingle() + return CredentialData(accessToken, credential.expiresAt) + } + + val refreshToken = aes.decrypt(credential.encryptedRefreshToken).awaitSingle() + val refreshedCredentialData = doAccessTokenRequest(refreshToken) ?: throw EmptyNotAllowedException() + credential.encryptedAccessToken = aes.encrypt(refreshedCredentialData.accessToken).awaitSingle() + credential.expiresAt = refreshedCredentialData.validUntil.minusSeconds(60) // Add a minute of wiggle room + credentialService.updateCredential(credential) + return refreshedCredentialData } - private fun doAccessTokenRequest(refreshToken: String): Mono { - return Mono.fromCallable { - val body = FormBody.Builder() - .addEncoded("client_id", clientData.clientId) - .addEncoded("client_secret", clientData.clientSecret) - .addEncoded("refresh_token", refreshToken) - .addEncoded("grant_type", "refresh_token") - .build() - - val request = Request.Builder() - .url("https://www.googleapis.com/oauth2/v4/token") - .post(body) - .header("Content-Type", "application/x-www-form-urlencoded") - .build() - - HTTP_CLIENT.newCall(request).execute() - }.subscribeOn(Schedulers.boundedElastic()).flatMap { response -> - when (response.code) { - STATUS_CODE_OK -> { - val body = JSON_FORMAT.decodeFromString(RefreshData.serializer(), response.body!!.string()) - response.body?.close() - response.close() - - Mono.just(CredentialData(body.accessToken, Instant.now().plusSeconds(body.expiresIn.toLong()))) - } - STATUS_CODE_BAD_REQUEST -> { - val body = JSON_FORMAT.decodeFromString(ErrorData.serializer(), response.body!!.string()) - response.body?.close() - response.close() - - LOGGER.error("[Google] Access Token Request: $body") - - if (body.error == "invalid_grant") { - LOGGER.debug(DEFAULT, "[Google] Access to resource has been revoked") - Mono.error(AccessRevokedException()) - } else { - LOGGER.debug(DEFAULT, "[Google] Error requesting new access token | ${response.code} | ${response.message} | $body") - Mono.empty() - } - } - else -> { - // Failed to get OK. Send debug info - LOGGER.debug(DEFAULT, "[Google] Error requesting new access token | ${response.code} " + - "| ${response.message} | ${response.body?.string()}") - response.body?.close() - response.close() - Mono.empty() + private suspend fun doAccessTokenRequest(refreshToken: String): CredentialData? { + val requestFormBody = FormBody.Builder() + .addEncoded("client_id", Config.SECRET_GOOGLE_CLIENT_ID.getString()) + .addEncoded("client_secret", Config.SECRET_GOOGLE_CLIENT_SECRET.getString()) + .addEncoded("refresh_token", refreshToken) + .addEncoded("grant_type", "refresh_token") + .build() + val request = Request.Builder() + .url("https://www.googleapis.com/oauth2/v4/token") + .post(requestFormBody) + .header("Content-Type", "application/x-www-form-urlencoded") + .build() + + + val response = Mono.fromCallable(HTTP_CLIENT.newCall(request)::execute) + .subscribeOn(Schedulers.boundedElastic()) + .awaitSingle() + + return when (response.code) { + STATUS_CODE_OK -> { + val body = objectMapper.readValue(response.body!!.string()) + response.close() + + CredentialData(body.accessToken, Instant.now().plusSeconds(body.expiresIn.toLong())) + } + STATUS_CODE_BAD_REQUEST -> { + val body = objectMapper.readValue(response.body!!.string()) + response.close() + + LOGGER.error("[Google] Access Token Request: $body") + + if (body.error == "invalid_grant") { + LOGGER.debug(DEFAULT, "[Google] Access to resource has been revoked") + throw AccessRevokedException() + } else { + LOGGER.error(DEFAULT, "[Google] Error requesting new access token | ${response.code} | ${response.message} | $body") + null } } + else -> { + // Failed to get OK. Send error info + LOGGER.error(DEFAULT, "[Google] Error requesting new access token | ${response.code} ${response.message} | ${response.body?.string()}") + response.close() + + null + } } } } diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleInternalAuthHandler.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleInternalAuthHandler.kt deleted file mode 100644 index 2bbd4dde2..000000000 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleInternalAuthHandler.kt +++ /dev/null @@ -1,120 +0,0 @@ -package org.dreamexposure.discal.cam.google - -import org.dreamexposure.discal.core.`object`.BotSettings -import org.dreamexposure.discal.core.`object`.google.GoogleCredentialData -import org.dreamexposure.discal.core.`object`.google.InternalGoogleAuthPoll -import org.dreamexposure.discal.core.crypto.AESEncryption -import org.dreamexposure.discal.core.database.DatabaseManager -import org.dreamexposure.discal.core.exceptions.EmptyNotAllowedException -import org.dreamexposure.discal.core.exceptions.google.GoogleAuthCancelException -import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.utils.GlobalVal -import org.dreamexposure.discal.core.wrapper.google.GoogleAuthWrapper -import org.json.JSONObject -import reactor.core.publisher.Mono -import reactor.function.TupleUtils -import java.time.Instant - -@Suppress("BlockingMethodInNonBlockingContext") -object GoogleInternalAuthHandler { - fun requestCode(credNumber: Int): Mono { - return GoogleAuthWrapper.requestDeviceCode().flatMap { response -> - val responseBody = response.body!!.string() - response.body?.close() - response.close() - - if (response.code == GlobalVal.STATUS_SUCCESS) { - val codeResponse = JSONObject(responseBody) - - val url = codeResponse.getString("verification_url") - val code = codeResponse.getString("user_code") - LOGGER.debug(GlobalVal.DEFAULT, "[!GDC!] DisCal Google Cred Auth $credNumber", "$url | $code") - - val poll = InternalGoogleAuthPoll( - credNumber, - interval = codeResponse.getInt("interval"), - expiresIn = codeResponse.getInt("expires_in"), - remainingSeconds = codeResponse.getInt("expires_in"), - deviceCode = codeResponse.getString("device_code"), - ) { this.pollForAuth(it as InternalGoogleAuthPoll) } - - GoogleAuthWrapper.scheduleOAuthPoll(poll) - } else { - LOGGER.debug(GlobalVal.DEFAULT, "Error request access token Status code: ${response.code} | ${response.message}" + - " | $responseBody") - - Mono.empty() - } - } - } - - private fun pollForAuth(poll: InternalGoogleAuthPoll): Mono { - return GoogleAuthWrapper.requestPollResponse(poll).flatMap { response -> - val responseBody = response.body!!.string() - response.body?.close() - response.close() - - when (response.code) { - GlobalVal.STATUS_FORBIDDEN -> { - //Handle access denied - LOGGER.debug(GlobalVal.DEFAULT, "[!GDC!] Access denied for credential: ${poll.credNumber}") - - Mono.error(GoogleAuthCancelException()) - } - GlobalVal.STATUS_BAD_REQUEST, GlobalVal.STATUS_PRECONDITION_REQUIRED -> { - //See if auth is pending, if so, just reschedule. - - val aprError = JSONObject(responseBody) - when { - aprError.optString("error").equals("authorization_pending", true) -> { - //Response pending - Mono.empty() - } - aprError.optString("error").equals("expired_token", true) -> { - //Token expired, auth is cancelled - LOGGER.debug(GlobalVal.DEFAULT, "[!GDC!] token expired.") - - Mono.error(GoogleAuthCancelException()) - } - else -> { - LOGGER.debug(GlobalVal.DEFAULT, "[!GDC!] Poll Failure! Status code: ${response.code}" + - " | ${response.message} | $responseBody") - - Mono.error(GoogleAuthCancelException()) - } - } - } - GlobalVal.STATUS_RATE_LIMITED -> { - //We got rate limited... oops. Let's just poll half as often... - poll.interval = poll.interval * 2 - - Mono.empty() - } - GlobalVal.STATUS_SUCCESS -> { - //Access granted, save credentials... - val aprGrant = JSONObject(responseBody) - val aes = AESEncryption(BotSettings.CREDENTIALS_KEY.get()) - - val refreshMono = aes.encrypt(aprGrant.getString("refresh_token")) - val accessMono = aes.encrypt(aprGrant.getString("access_token")) - - Mono.zip(refreshMono, accessMono).flatMap(TupleUtils.function { refresh, access -> - val expiresAt = Instant.now().plusSeconds(aprGrant.getLong("expires_in")) - - val creds = GoogleCredentialData(poll.credNumber, refresh, access, expiresAt) - - DatabaseManager.updateCredentialData(creds) - .then(Mono.error(GoogleAuthCancelException())) - }).onErrorResume(EmptyNotAllowedException::class.java) { Mono.error(GoogleAuthCancelException()) } - } - else -> { - //Unknown network error... - LOGGER.debug(GlobalVal.DEFAULT, "[!GDC!] Network error; poll failure Status code: ${response.code} " + - "| ${response.message} | $responseBody") - - Mono.error(GoogleAuthCancelException()) - } - } - }.then() - } -} diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discal/LoginResponse.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discal/LoginResponse.kt index b8a78f104..7e48957eb 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discal/LoginResponse.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discal/LoginResponse.kt @@ -1,9 +1,5 @@ package org.dreamexposure.discal.cam.json.discal -import kotlinx.serialization.Serializable - -@Serializable data class LoginResponse( - val link: String ) diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discal/TokenRequest.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discal/TokenRequest.kt index c5737d2f9..bcb54c8e2 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discal/TokenRequest.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discal/TokenRequest.kt @@ -1,10 +1,6 @@ package org.dreamexposure.discal.cam.json.discal -import kotlinx.serialization.Serializable - -@Serializable data class TokenRequest( val state: String, - val code: String, ) diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discal/TokenResponse.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discal/TokenResponse.kt index 8c365802a..dcb2c643b 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discal/TokenResponse.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discal/TokenResponse.kt @@ -1,16 +1,10 @@ package org.dreamexposure.discal.cam.json.discal -import kotlinx.serialization.Serializable import org.dreamexposure.discal.cam.json.discord.SimpleUserData -import org.dreamexposure.discal.core.serializers.InstantAsStringSerializer import java.time.Instant -@Serializable data class TokenResponse( val token: String, - - @Serializable(with = InstantAsStringSerializer::class) val expires: Instant, - val user: SimpleUserData, ) diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discord/AccessTokenResponse.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discord/AccessTokenResponse.kt index 538182d9a..38d570d05 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discord/AccessTokenResponse.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discord/AccessTokenResponse.kt @@ -1,20 +1,18 @@ package org.dreamexposure.discal.cam.json.discord -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable +import com.fasterxml.jackson.annotation.JsonProperty -@Serializable data class AccessTokenResponse( - @SerialName("access_token") + @JsonProperty("access_token") val accessToken: String, - @SerialName("token_type") + @JsonProperty("token_type") val type: String, - @SerialName("expires_in") + @JsonProperty("expires_in") val expiresIn: Long, - @SerialName("refresh_token") + @JsonProperty("refresh_token") val refreshToken: String, val scope: String, diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discord/AuthorizationInfo.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discord/AuthorizationInfo.kt index 778614607..d2e320e45 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discord/AuthorizationInfo.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discord/AuthorizationInfo.kt @@ -1,15 +1,9 @@ package org.dreamexposure.discal.cam.json.discord -import kotlinx.serialization.Serializable -import org.dreamexposure.discal.core.serializers.InstantAsStringSerializer import java.time.Instant -@Serializable data class AuthorizationInfo( val scopes: List, - - @Serializable(with = InstantAsStringSerializer::class) val expires: Instant, - val user: SimpleUserData?, ) diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discord/SimpleUserData.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discord/SimpleUserData.kt index 69b9bf1d5..c5d3d20d2 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discord/SimpleUserData.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/discord/SimpleUserData.kt @@ -1,17 +1,10 @@ package org.dreamexposure.discal.cam.json.discord import discord4j.common.util.Snowflake -import kotlinx.serialization.Serializable -import org.dreamexposure.discal.core.serializers.SnowflakeAsStringSerializer -@Serializable data class SimpleUserData( - @Serializable(with = SnowflakeAsStringSerializer::class) val id: Snowflake, - val username: String, - val discriminator: String, - val avatar: String?, ) diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/google/ErrorData.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/google/ErrorData.kt index bef773257..73a3f2c4a 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/google/ErrorData.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/google/ErrorData.kt @@ -1,8 +1,6 @@ package org.dreamexposure.discal.cam.json.google -import kotlinx.serialization.Serializable -@Serializable data class ErrorData( - val error: String + val error: String ) diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/google/RefreshData.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/google/RefreshData.kt index 95c75f386..870da416b 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/google/RefreshData.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/json/google/RefreshData.kt @@ -1,13 +1,11 @@ package org.dreamexposure.discal.cam.json.google -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable +import com.fasterxml.jackson.annotation.JsonProperty -@Serializable data class RefreshData( - @SerialName("access_token") + @JsonProperty("access_token") val accessToken: String, - @SerialName("expires_in") + @JsonProperty("expires_in") val expiresIn: Int ) diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/managers/CalendarAuthManager.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/managers/CalendarAuthManager.kt new file mode 100644 index 000000000..b337711ae --- /dev/null +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/managers/CalendarAuthManager.kt @@ -0,0 +1,29 @@ +package org.dreamexposure.discal.cam.managers + +import discord4j.common.util.Snowflake +import org.dreamexposure.discal.cam.google.GoogleAuth +import org.dreamexposure.discal.core.business.CalendarService +import org.dreamexposure.discal.core.enums.calendar.CalendarHost +import org.dreamexposure.discal.core.`object`.network.discal.CredentialData +import org.springframework.stereotype.Component + +@Component +class CalendarAuthManager( + private val calendarService: CalendarService, + private val googleAuth: GoogleAuth, +) { + suspend fun getCredentialData(host: CalendarHost, id: Int, guild: Snowflake?): CredentialData? { + return when (host) { + CalendarHost.GOOGLE -> { + if (guild == null) { + // Internal (owned by DisCal, should never go bad) + googleAuth.requestNewAccessToken(id) + } else { + // External (owned by user) + val calendar = calendarService.getCalendar(guild, id) ?: return null + googleAuth.requestNewAccessToken(calendar) + } + } + } + } +} diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/managers/DiscordOauthManager.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/managers/DiscordOauthManager.kt new file mode 100644 index 000000000..e553c88b6 --- /dev/null +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/managers/DiscordOauthManager.kt @@ -0,0 +1,63 @@ +package org.dreamexposure.discal.cam.managers + +import org.dreamexposure.discal.cam.business.OauthStateService +import org.dreamexposure.discal.cam.discord.DiscordOauthHandler +import org.dreamexposure.discal.cam.json.discal.TokenResponse +import org.dreamexposure.discal.core.business.SessionService +import org.dreamexposure.discal.core.config.Config +import org.dreamexposure.discal.core.crypto.KeyGenerator +import org.dreamexposure.discal.core.`object`.WebSession +import org.dreamexposure.discal.core.utils.GlobalVal.discordApiUrl +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.server.ResponseStatusException +import java.net.URLEncoder +import java.nio.charset.Charset.defaultCharset + +@Component +class DiscordOauthManager( + private val sessionService: SessionService, + private val oauthStateService: OauthStateService, + private val discordOauthHandler: DiscordOauthHandler, +) { + private final val redirectUrl = Config.URL_DISCORD_REDIRECT.getString() + private final val clientId = Config.DISCORD_APP_ID.getString() + + private final val scopes = URLEncoder.encode("identify guilds", defaultCharset()) + private final val encodedRedirectUrl = URLEncoder.encode(redirectUrl, defaultCharset()) + private final val oauthLinkWithoutState = "$discordApiUrl/oauth2/authorize?client_id=$clientId&redirect_uri=$encodedRedirectUrl&response_type=code&scope=$scopes&prompt=none" + + suspend fun getOauthLinkForLogin(): String { + val state = oauthStateService.generateState() + + return "$oauthLinkWithoutState&state=$state" + } + + suspend fun handleLogout(token: String) { + sessionService.deleteSession(token) + } + + suspend fun handleCodeExchange(state: String, code: String): TokenResponse { + // Validate state + if (!oauthStateService.validateState(state)) { + // State invalid - 400 + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid state") + } + + val dTokens = discordOauthHandler.doTokenExchange(code) + val authInfo = discordOauthHandler.getOauthInfo(dTokens.accessToken) + val apiToken = KeyGenerator.csRandomAlphaNumericString(64) + val session = WebSession( + apiToken, + authInfo.user!!.id, + accessToken = dTokens.accessToken, + refreshToken = dTokens.refreshToken + ) + + sessionService.removeAndInsertSession(session) + + return TokenResponse(session.token, session.expiresAt, authInfo.user) + + TODO() + } +} diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/service/HeartbeatService.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/service/HeartbeatService.kt deleted file mode 100644 index 5ba687c06..000000000 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/service/HeartbeatService.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.dreamexposure.discal.cam.service - -import kotlinx.serialization.encodeToString -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response -import org.dreamexposure.discal.core.`object`.BotSettings -import org.dreamexposure.discal.core.`object`.network.discal.InstanceData -import org.dreamexposure.discal.core.`object`.rest.HeartbeatRequest -import org.dreamexposure.discal.core.`object`.rest.HeartbeatType -import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.utils.GlobalVal -import org.springframework.boot.ApplicationArguments -import org.springframework.boot.ApplicationRunner -import org.springframework.stereotype.Component -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono -import reactor.core.scheduler.Schedulers -import java.time.Duration - -@Component -class HeartbeatService : ApplicationRunner { - - private fun heartbeat(): Mono { - return Mono.just(InstanceData()) - .map { data -> - val requestBody = HeartbeatRequest(HeartbeatType.CAM, instanceData = data) - - val body = GlobalVal.JSON_FORMAT.encodeToString(requestBody).toRequestBody(GlobalVal.JSON) - - Request.Builder() - .url("${BotSettings.API_URL.get()}/v2/status/heartbeat") - .post(body) - .header("Authorization", BotSettings.BOT_API_TOKEN.get()) - .header("Content-Type", "application/json") - .build() - }.flatMap { - Mono.fromCallable(GlobalVal.HTTP_CLIENT.newCall(it)::execute) - .subscribeOn(Schedulers.boundedElastic()) - .map(Response::close) - }.doOnError { - LOGGER.error(GlobalVal.DEFAULT, "[Heartbeat] Failed to heartbeat", it) - }.onErrorResume { Mono.empty() } - .then() - - } - - override fun run(args: ApplicationArguments?) { - Flux.interval(Duration.ofMinutes(2)) - .flatMap { heartbeat() } - .subscribe() - } -} diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/service/SessionService.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/service/SessionService.kt deleted file mode 100644 index c4e7f9fe0..000000000 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/service/SessionService.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.dreamexposure.discal.cam.service - -import org.dreamexposure.discal.core.database.DatabaseManager -import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.utils.GlobalVal -import org.springframework.boot.ApplicationArguments -import org.springframework.boot.ApplicationRunner -import org.springframework.stereotype.Component -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono -import java.time.Duration - -@Component -class SessionService : ApplicationRunner { - override fun run(args: ApplicationArguments?) { - Flux.interval(Duration.ofHours(24)) - .flatMap { - DatabaseManager.deleteExpiredSessions() - }.doOnError { - LOGGER.error(GlobalVal.DEFAULT, "Session Service runner error", it) - }.onErrorResume { - Mono.empty() - }.subscribe() - } -} diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/service/StateService.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/service/StateService.kt deleted file mode 100644 index 80c645dd9..000000000 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/service/StateService.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.dreamexposure.discal.cam.service - -import org.dreamexposure.discal.core.crypto.KeyGenerator -import org.springframework.stereotype.Component -import reactor.core.publisher.Flux -import java.time.Duration -import java.time.Instant -import java.time.temporal.ChronoUnit -import java.util.concurrent.ConcurrentHashMap - -@Component -class StateService { - - private val states: MutableMap = ConcurrentHashMap() - - init { - // occasionally remove expired/unused states - Flux.interval(Duration.ofHours(1)) - .doOnNext { - val toRemove = mutableListOf() - - states.forEach { (state, expires) -> - if (expires.isBefore(Instant.now())) - toRemove.add(state) - } - - toRemove.forEach(states::remove) - }.subscribe() - } - - fun generateState(): String { - val state = KeyGenerator.csRandomAlphaNumericString(64) - states[state] = Instant.now().plus(5, ChronoUnit.MINUTES) - - return state - } - - fun validateState(state: String): Boolean { - val expiresAt = states[state] - states.remove(state) // Remove state immediately to prevent replay attacks - - if (expiresAt != null && expiresAt.isAfter(Instant.now())) return true - - // If state is not valid or has expired, we return false - return false - } -} diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/spring/WebFluxConfig.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/spring/WebFluxConfig.kt deleted file mode 100644 index edf646c8f..000000000 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/spring/WebFluxConfig.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.dreamexposure.discal.cam.spring - -import io.r2dbc.spi.ConnectionFactories -import io.r2dbc.spi.ConnectionFactory -import io.r2dbc.spi.ConnectionFactoryOptions -import org.dreamexposure.discal.core.`object`.BotSettings -import org.dreamexposure.discal.core.utils.GlobalVal -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.data.redis.connection.RedisPassword -import org.springframework.data.redis.connection.RedisStandaloneConfiguration -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory -import org.springframework.http.codec.ServerCodecConfigurer -import org.springframework.http.codec.json.KotlinSerializationJsonDecoder -import org.springframework.http.codec.json.KotlinSerializationJsonEncoder -import org.springframework.web.reactive.config.EnableWebFlux -import org.springframework.web.reactive.config.WebFluxConfigurer - -@Configuration -@EnableWebFlux -class WebFluxConfig: WebFluxConfigurer { - - @Bean(name = ["redisDatasource"]) - fun redisConnectionFactory(): LettuceConnectionFactory { - val rsc = RedisStandaloneConfiguration() - rsc.hostName = BotSettings.REDIS_HOSTNAME.get() - rsc.port = BotSettings.REDIS_PORT.get().toInt() - if (BotSettings.REDIS_USE_PASSWORD.get().equals("true", true)) - rsc.password = RedisPassword.of(BotSettings.REDIS_PASSWORD.get()) - - return LettuceConnectionFactory(rsc) - } - - @Bean(name = ["mysqlDatasource"]) - fun mysqlConnectionFactory(): ConnectionFactory { - return ConnectionFactories.get(ConnectionFactoryOptions.builder() - .option(ConnectionFactoryOptions.DRIVER, "pool") - .option(ConnectionFactoryOptions.PROTOCOL, "mysql") - .option(ConnectionFactoryOptions.HOST, BotSettings.SQL_HOST.get()) - .option(ConnectionFactoryOptions.PORT, BotSettings.SQL_PORT.get().toInt()) - .option(ConnectionFactoryOptions.USER, BotSettings.SQL_USER.get()) - .option(ConnectionFactoryOptions.PASSWORD, BotSettings.SQL_PASS.get()) - .option(ConnectionFactoryOptions.DATABASE, BotSettings.SQL_DB.get()) - .build()) - } - - override fun configureHttpMessageCodecs(configurer: ServerCodecConfigurer) { - val codecs = configurer.defaultCodecs() - codecs.kotlinSerializationJsonDecoder(KotlinSerializationJsonDecoder(GlobalVal.JSON_FORMAT)) - codecs.kotlinSerializationJsonEncoder(KotlinSerializationJsonEncoder(GlobalVal.JSON_FORMAT)) - } -} diff --git a/cam/src/main/resources/application.properties b/cam/src/main/resources/application.properties deleted file mode 100644 index e9a9464de..000000000 --- a/cam/src/main/resources/application.properties +++ /dev/null @@ -1,4 +0,0 @@ -spring.application.name=DisCal CAM -server.port=8080 -spring.session.store-type=redis -discal.security.enabled=true diff --git a/client/build.gradle.kts b/client/build.gradle.kts index 9d231f410..1644f5025 100644 --- a/client/build.gradle.kts +++ b/client/build.gradle.kts @@ -1,19 +1,18 @@ plugins { + // Kotlin + id("org.jetbrains.kotlin.plugin.allopen") + + // Spring kotlin("plugin.spring") id("org.springframework.boot") - id("org.jetbrains.kotlin.plugin.allopen") + id("io.spring.dependency-management") + + // Tooling id("com.google.cloud.tools.jib") } -val springVersion: String by properties -val springSessionVersion: String by properties -val springR2Version: String by properties - dependencies { api(project(":core")) - - implementation("org.springframework.session:spring-session-data-redis:$springSessionVersion") - implementation("org.springframework:spring-r2dbc:$springR2Version") } kotlin { @@ -25,14 +24,13 @@ kotlin { } jib { - var imageVersion = version.toString() - if (imageVersion.contains("SNAPSHOT")) imageVersion = "latest" + to { + image = "rg.nl-ams.scw.cloud/dreamexposure/discal-client" + tags = mutableSetOf("latest", version.toString()) + } - to.image = "rg.nl-ams.scw.cloud/dreamexposure/discal-client:$imageVersion" val baseImage: String by properties from.image = baseImage - - container.creationTime = "USE_CURRENT_TIMESTAMP" } tasks { diff --git a/client/src/main/java/org/dreamexposure/discal/client/listeners/discord/MessageCreateListener.java b/client/src/main/java/org/dreamexposure/discal/client/listeners/discord/MessageCreateListener.java deleted file mode 100644 index dfec9d424..000000000 --- a/client/src/main/java/org/dreamexposure/discal/client/listeners/discord/MessageCreateListener.java +++ /dev/null @@ -1,81 +0,0 @@ -package org.dreamexposure.discal.client.listeners.discord; - -import discord4j.core.event.domain.message.MessageCreateEvent; -import discord4j.core.object.entity.Message; -import org.dreamexposure.discal.client.module.command.CommandExecutor; -import org.dreamexposure.discal.core.database.DatabaseManager; -import org.dreamexposure.discal.core.object.BotSettings; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import reactor.core.publisher.Mono; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -import static org.dreamexposure.discal.core.utils.GlobalVal.getDEFAULT; - -@SuppressWarnings("OptionalGetWithoutIsPresent") -public class MessageCreateListener { - private final static Logger LOGGER = LoggerFactory.getLogger(BotMentionListener.class); - - public static Mono handle(final MessageCreateEvent event) { - return Mono.just(event.getMessage()) - .filter(message -> !message.getContent().isEmpty()) - .filterWhen(message -> - Mono.justOrEmpty(event.getMember() - .map(member -> !member.isBot())) - ) - .map(Message::getContent) - .flatMap(content -> - DatabaseManager.INSTANCE.getSettings(event.getGuildId().get()).flatMap(settings -> { - if (content.startsWith(settings.getPrefix())) { - final String[] cmdAndArgs = content.trim().split("\\s+"); - if (cmdAndArgs.length > 1) { - //command with args - final String cmd = cmdAndArgs[0].replace(settings.getPrefix(), ""); - final List args = Arrays.asList(cmdAndArgs).subList(1, cmdAndArgs.length); - - //issue command - return CommandExecutor.issueCommand(cmd, args, event, settings); - } else if (cmdAndArgs.length == 1) { - //Only command, no args - final String cmd = cmdAndArgs[0].replace(settings.getPrefix(), ""); - - //Issue command - return CommandExecutor.issueCommand(cmd, new ArrayList<>(), event, settings); - } - return Mono.empty(); - } else if (!event.getMessage().mentionsEveryone() - && !content.contains("@here") - && (content.startsWith("<@" + BotSettings.ID.get() + ">") - || content.startsWith("<@!" + BotSettings.ID.get() + ">"))) { - final String[] cmdAndArgs = content.split("\\s+"); - if (cmdAndArgs.length > 2) { - //DisCal mentioned with command and args - final String cmd = cmdAndArgs[1]; - final List args = Arrays.asList(cmdAndArgs).subList(2, cmdAndArgs.length); - - //issue command - return CommandExecutor.issueCommand(cmd, args, event, settings); - } else if (cmdAndArgs.length == 2) { - //DisCal mentioned with command and no args - final String cmd = cmdAndArgs[1]; - //Issue command - return CommandExecutor.issueCommand(cmd, new ArrayList<>(), event, settings); - } else if (cmdAndArgs.length == 1) { - //DisCal mentioned, nothing else - return CommandExecutor.issueCommand("DisCal", new ArrayList<>(), event, settings); - } - - return Mono.empty(); - } else { - //Bot not mentioned, and this is not a command, ignore this - return Mono.empty(); - } - })) - .doOnError(e -> LOGGER.error(getDEFAULT(), "Error handle message event | " + event.getMessage().getContent(), e)) - .onErrorResume(e -> Mono.empty()) - .then(); - } -} diff --git a/client/src/main/java/org/dreamexposure/discal/client/listeners/discord/package-info.java b/client/src/main/java/org/dreamexposure/discal/client/listeners/discord/package-info.java deleted file mode 100644 index 0d1bfb7ac..000000000 --- a/client/src/main/java/org/dreamexposure/discal/client/listeners/discord/package-info.java +++ /dev/null @@ -1 +0,0 @@ -package org.dreamexposure.discal.client.listeners.discord; \ No newline at end of file diff --git a/client/src/main/java/org/dreamexposure/discal/client/message/Messages.java b/client/src/main/java/org/dreamexposure/discal/client/message/Messages.java deleted file mode 100644 index d22d4411b..000000000 --- a/client/src/main/java/org/dreamexposure/discal/client/message/Messages.java +++ /dev/null @@ -1,151 +0,0 @@ -package org.dreamexposure.discal.client.message; - -import discord4j.core.event.domain.message.MessageCreateEvent; -import discord4j.core.object.entity.Message; -import discord4j.core.object.entity.User; -import discord4j.core.object.entity.channel.GuildMessageChannel; -import discord4j.core.spec.EmbedCreateSpec; -import discord4j.rest.http.client.ClientException; -import org.dreamexposure.discal.core.file.ReadFile; -import org.dreamexposure.discal.core.object.GuildSettings; -import org.dreamexposure.discal.core.utils.GlobalVal; -import org.json.JSONObject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import reactor.core.publisher.Mono; - -/** - * @author NovaFox161 - * Date Created: 9/8/2018 - * For Project: DisCal-Discord-Bot - * Author Website: https://www.novamaday.com - * Company Website: https://www.dreamexposure.org - * Contact: nova@dreamexposure.org - */ -public class Messages { - private static JSONObject langs; - private final static Logger LOGGER = LoggerFactory.getLogger(Messages.class); - - //Lang handling - @Deprecated - public static Mono reloadLangs() { - return ReadFile.readAllLangFiles() - .doOnNext(l -> langs = l) - .thenReturn(true) - .doOnError(e -> LOGGER.error("[LANGS] Failed to reload lang files", e)) - .onErrorReturn(false); - } - - @Deprecated - public static String getMessage(String key, GuildSettings settings) { - JSONObject messages; - - if (langs.has(settings.getLang())) - messages = langs.getJSONObject(settings.getLang()); - else - messages = langs.getJSONObject("ENGLISH"); - - if (messages.has(key)) - return messages.getString(key).replace("%lb%", GlobalVal.getLineBreak()); - else - return "***FAILSAFE MESSAGE*** MESSAGE NOT FOUND!! Message requested: " + key; - } - - @Deprecated - public static String getMessage(String key, String var, String replace, GuildSettings settings) { - JSONObject messages; - - if (langs.has(settings.getLang())) - messages = langs.getJSONObject(settings.getLang()); - else - messages = langs.getJSONObject("ENGLISH"); - - if (messages.has(key)) - return messages.getString(key).replace(var, replace).replace("%lb%", GlobalVal.getLineBreak()); - else - return "***FAILSAFE MESSAGE*** MESSAGE NOT FOUND!! Message requested: " + key; - } - - //Message sending - public static Mono sendMessage(String message, MessageCreateEvent event) { - return event.getMessage().getChannel() - .flatMap(c -> c.createMessage(message)) - .onErrorResume(ClientException.class, e -> Mono.empty()); - } - - public static Mono sendMessage(EmbedCreateSpec embed, MessageCreateEvent event) { - return event.getMessage().getChannel() - .flatMap(c -> c.createMessage(embed)) - .onErrorResume(ClientException.class, e -> Mono.empty()); - } - - public static Mono sendMessage(String message, EmbedCreateSpec embed, MessageCreateEvent event) { - return event.getMessage().getChannel() - .flatMap(c -> c.createMessage(message) - .withEmbeds(embed) - ) - .onErrorResume(ClientException.class, e -> Mono.empty()); - } - - public static Mono sendMessage(String message, GuildMessageChannel channel) { - return channel.createMessage(message) - .onErrorResume(ClientException.class, e -> Mono.empty()); - } - - public static Mono sendMessage(EmbedCreateSpec embed, GuildMessageChannel channel) { - return channel.createMessage(embed) - .onErrorResume(ClientException.class, e -> Mono.empty()); - } - - public static Mono sendMessage(String message, EmbedCreateSpec embed, GuildMessageChannel channel) { - return channel.createMessage(message).withEmbeds(embed) - .onErrorResume(ClientException.class, e -> Mono.empty()); - } - - //Direct message sending - public static Mono sendDirectMessage(String message, User user) { - return user.getPrivateChannel() - .flatMap(c -> c.createMessage(message)) - .onErrorResume(ClientException.class, e -> Mono.empty()); - } - - public static Mono sendDirectMessage(EmbedCreateSpec embed, User user) { - return user.getPrivateChannel() - .flatMap(c -> c.createMessage(embed)) - .onErrorResume(ClientException.class, e -> Mono.empty()); - } - - public static Mono sendDirectMessage(String message, EmbedCreateSpec embed, User user) { - return user.getPrivateChannel() - .flatMap(c -> c.createMessage(message).withEmbeds(embed)) - .onErrorResume(ClientException.class, e -> Mono.empty()); - } - - //Message editing - public static Mono editMessage(String message, Message original) { - return original.edit().withContentOrNull(message); - } - - public static Mono editMessage(String message, EmbedCreateSpec embed, Message original) { - return original.edit().withContentOrNull(message).withEmbeds(embed); - } - - public static Mono editMessage(String message, MessageCreateEvent event) { - return event.getMessage().edit().withContentOrNull(message); - } - - public static Mono editMessage(String message, EmbedCreateSpec embed, MessageCreateEvent event) { - return event.getMessage().edit().withContentOrNull(message).withEmbeds(embed); - } - - //Message deleting - public static Mono deleteMessage(Message message) { - return Mono.justOrEmpty(message) - .flatMap(Message::delete) - .onErrorResume(e -> Mono.empty()); - } - - public static Mono deleteMessage(MessageCreateEvent event) { - return deleteMessage(event.getMessage()); - } -} diff --git a/client/src/main/java/org/dreamexposure/discal/client/message/package-info.java b/client/src/main/java/org/dreamexposure/discal/client/message/package-info.java deleted file mode 100644 index 08eb48d52..000000000 --- a/client/src/main/java/org/dreamexposure/discal/client/message/package-info.java +++ /dev/null @@ -1 +0,0 @@ -package org.dreamexposure.discal.client.message; \ No newline at end of file diff --git a/client/src/main/java/org/dreamexposure/discal/client/module/command/AddCalendarCommand.java b/client/src/main/java/org/dreamexposure/discal/client/module/command/AddCalendarCommand.java deleted file mode 100644 index 8281238bd..000000000 --- a/client/src/main/java/org/dreamexposure/discal/client/module/command/AddCalendarCommand.java +++ /dev/null @@ -1,133 +0,0 @@ -package org.dreamexposure.discal.client.module.command; - -import discord4j.core.event.domain.message.MessageCreateEvent; -import discord4j.core.object.entity.Message; -import org.dreamexposure.discal.client.message.Messages; -import org.dreamexposure.discal.client.network.google.GoogleExternalAuthHandler; -import org.dreamexposure.discal.core.database.DatabaseManager; -import org.dreamexposure.discal.core.enums.calendar.CalendarHost; -import org.dreamexposure.discal.core.object.GuildSettings; -import org.dreamexposure.discal.core.object.calendar.CalendarData; -import org.dreamexposure.discal.core.object.command.CommandInfo; -import org.dreamexposure.discal.core.utils.PermissionChecker; -import org.dreamexposure.discal.core.wrapper.google.CalendarWrapper; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -import java.util.ArrayList; - -/** - * Created by Nova Fox on 6/29/2017. - * Website: www.cloudcraftgaming.com - * For Project: DisCal - */ -public class AddCalendarCommand implements Command { - /** - * Gets the command this Object is responsible for. - * - * @return The command this Object is responsible for. - */ - @Override - public String getCommand() { - return "addCalendar"; - } - - /** - * Gets the short aliases of the command this object is responsible for. - *
- * This will return an empty ArrayList if none are present - * - * @return The aliases of the command. - */ - @Override - public ArrayList getAliases() { - final ArrayList aliases = new ArrayList<>(); - aliases.add("addcal"); - - return aliases; - } - - /** - * Gets the info on the command (not sub command) to be used in help menus. - * - * @return The command info. - */ - @Override - public CommandInfo getCommandInfo() { - return new CommandInfo( - "addCalendar", - "Starts the process of adding an external calendar", - "!addCalendar (calendar ID)" - ); - } - - /** - * Issues the command this Object is responsible for. - * - * @param args The command arguments. - * @param event The event received. - * @return {@code true} if successful, else {@code false}. - */ - @Override - public Mono issueCommand(final String[] args, final MessageCreateEvent event, final GuildSettings settings) { - if (!(settings.getDevGuild() || settings.getPatronGuild())) { - return Messages.sendMessage(Messages.getMessage("Notification.Patron", settings), event).then(); - } - - return PermissionChecker.hasManageServerRole(event).flatMap(hasManageRole -> { - if (!hasManageRole) { - return Messages.sendMessage(Messages.getMessage("Notification.Perm.MANAGE_SERVER", settings), event); - } - - if (args.length == 0) { - return DatabaseManager.INSTANCE.getMainCalendar(settings.getGuildID()) - .hasElement() - .flatMap(hasCal -> { - if (hasCal) { - return Messages.sendMessage( - Messages.getMessage("Creator.Calendar.HasCalendar", settings), event); - } else { - return GoogleExternalAuthHandler.INSTANCE.requestCode(event, settings) - .then(Messages.sendMessage( - Messages.getMessage("AddCalendar.Start", settings), event)); - } - }); - } else if (args.length == 1) { - return DatabaseManager.INSTANCE.getMainCalendar(settings.getGuildID()) - .flatMap(data -> { - if (!"primary".equalsIgnoreCase(data.getCalendarAddress())) { - return Messages.sendMessage(Messages.getMessage("Creator.Calendar.HasCalendar", settings), event); - } else if ("N/a".equalsIgnoreCase(data.getEncryptedAccessToken()) - && "N/a".equalsIgnoreCase(data.getEncryptedRefreshToken())) { - return Messages.sendMessage(Messages.getMessage("AddCalendar.Select.NotAuth", settings), event); - } else { - return CalendarWrapper.INSTANCE.getUsersExternalCalendars(data) - .flatMapMany(Flux::fromIterable) - .any(c -> !c.isDeleted() && c.getId().equals(args[0])) - .flatMap(valid -> { - if (valid) { - final CalendarData newData = new CalendarData( - data.getGuildId(), 1, CalendarHost.GOOGLE, args[0], args[0], true, 0, - data.getPrivateKey(), data.getEncryptedAccessToken(), - data.getEncryptedRefreshToken(), data.getExpiresAt()); - - //combine db calls and message send to be executed together async - final Mono calInsert = DatabaseManager.INSTANCE.updateCalendar(newData); - final Mono sendMsg = Messages.sendMessage( - Messages.getMessage("AddCalendar.Select.Success", settings), event); - - return Mono.when(calInsert, sendMsg); - } else { - return Messages.sendMessage(Messages - .getMessage("AddCalendar.Select.Failure.Invalid", settings), event); - } - }); - } - }); - } else { - //Invalid argument count... - return Messages.sendMessage(Messages.getMessage("AddCalendar.Specify", settings), event); - } - }).then(); - } -} diff --git a/client/src/main/java/org/dreamexposure/discal/client/module/command/Command.java b/client/src/main/java/org/dreamexposure/discal/client/module/command/Command.java deleted file mode 100644 index e095c1472..000000000 --- a/client/src/main/java/org/dreamexposure/discal/client/module/command/Command.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.dreamexposure.discal.client.module.command; - -import org.dreamexposure.discal.core.object.GuildSettings; -import org.dreamexposure.discal.core.object.command.CommandInfo; - -import java.util.ArrayList; -import java.util.List; - -import discord4j.core.event.domain.message.MessageCreateEvent; -import reactor.core.publisher.Mono; - -/** - * @author NovaFox161 - * Date Created: 9/10/2018 - * For Project: DisCal-Discord-Bot - * Author Website: https://www.novamaday.com - * Company Website: https://www.dreamexposure.org - * Contact: nova@dreamexposure.org - */ -public interface Command { - default String getCommand() { - return "COMMAND_NAME"; - } - - default List getAliases() { - final List aliases = new ArrayList<>(); - - aliases.add("ALIAS"); - - return aliases; - } - - default CommandInfo getCommandInfo() { - final CommandInfo info = new CommandInfo( - "COMMAND_NAME", - "COMMAND_DESCRIPTION", - "!command (sub2)" - ); - - info.getSubCommands().put("a", "b"); - - return info; - } - - Mono issueCommand(String[] args, MessageCreateEvent event, GuildSettings settings); -} \ No newline at end of file diff --git a/client/src/main/java/org/dreamexposure/discal/client/module/command/CommandExecutor.java b/client/src/main/java/org/dreamexposure/discal/client/module/command/CommandExecutor.java deleted file mode 100644 index b470a8d4f..000000000 --- a/client/src/main/java/org/dreamexposure/discal/client/module/command/CommandExecutor.java +++ /dev/null @@ -1,158 +0,0 @@ -package org.dreamexposure.discal.client.module.command; - -import discord4j.common.util.Snowflake; -import discord4j.core.event.domain.message.MessageCreateEvent; -import discord4j.core.spec.EmbedCreateSpec; -import discord4j.rest.RestClient; -import org.dreamexposure.discal.client.message.Messages; -import org.dreamexposure.discal.core.object.BotSettings; -import org.dreamexposure.discal.core.object.GuildSettings; -import org.dreamexposure.discal.core.object.command.CommandInfo; -import org.dreamexposure.discal.core.utils.GeneralUtils; -import org.dreamexposure.discal.core.utils.GlobalVal; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -import java.util.ArrayList; -import java.util.List; - -/** - * Created by Nova Fox on 1/3/2017. - * Website: www.cloudcraftgaming.com - * For Project: DisCal - */ -public class CommandExecutor { - private static final List commands = new ArrayList<>(); - - private static final List movedCommands = List.of( - "help", "discal", "time", "linkcal", "calendarlink", "callink", "linkcallador", "events", - "rsvp", "dev", "calendar", "cal", "callador", "event", "e", - "announcement", "announcements", "announce", "alert", "alerts", "a", "ann" - ); - - private CommandExecutor() { - } - - //Functional - - /** - * Registers a command that can be executed. - * - * @param _command The command to register. - */ - public static void registerCommand(final Command _command) { - commands.add(_command); - } - - public static Mono issueCommand(final String cmd, final List argsOr, final MessageCreateEvent event, final GuildSettings settings) { - final String[] args; - if (!argsOr.isEmpty()) { - final String toParse = GeneralUtils.getContent(argsOr, 0); - args = GeneralUtils.overkillParser(toParse).split(" "); - } else { - args = new String[0]; - } - - if (movedCommands.contains(cmd)) { - return new WarningCommand().issueCommand(args, event, settings); - } else - return getCommand(cmd).flatMap(c -> c.issueCommand(args, event, settings)); - } - - /** - * Gets an ArrayList of all valid commands. - * - * @return An ArrayList of all valid commands. - */ - public static ArrayList getAllCommands() { - final ArrayList cmds = new ArrayList<>(); - for (final Command c : commands) { - if (!cmds.contains(c.getCommand())) - cmds.add(c.getCommand()); - } - return cmds; - } - - public static List getCommands() { - return commands; - } - - private static Mono getCommand(final String cmdNameOrAlias) { - return Flux.fromIterable(commands) - .filter(c -> - c.getCommand().equalsIgnoreCase(cmdNameOrAlias) - || c.getAliases().contains(cmdNameOrAlias.toLowerCase())) - .next(); - } -} - -class WarningCommand implements Command { - - @Override - public String getCommand() { - return "\u200Bdiscal-command-moved-warning"; - } - - @Override - public List getAliases() { - return new ArrayList<>(); - } - - @Override - public CommandInfo getCommandInfo() { - return new CommandInfo("", "", ""); - } - - @Override - public Mono issueCommand(String[] args, MessageCreateEvent event, GuildSettings settings) { - //Check if slash commands are enabled in this server. - RestClient restClient = event.getClient().getRestClient(); - //noinspection OptionalGetWithoutIsPresent (always present) - Snowflake guildId = event.getGuildId().get(); - - return restClient.getApplicationId().flatMapMany(appId -> - restClient.getApplicationService().getGuildApplicationCommands(appId, guildId.asLong()) - ).collectList() - .thenReturn(true) - .onErrorReturn(false) - .map(hasAppCommands -> { - var builder = EmbedCreateSpec.builder() - .author("DisCal", BotSettings.BASE_URL.get(), GlobalVal.getIconUrl()) - .color(GlobalVal.getDiscalColor()) - .title("DisCal Bot"); - - if (hasAppCommands) { - builder.description( - String.format(enabledMessage, - BotSettings.BASE_URL.get() + "/commands", - BotSettings.SUPPORT_INVITE.get() - ) - ); - } else { - builder.description( - String.format(disabledMessage, - BotSettings.INVITE_URL.get(), - BotSettings.BASE_URL.get() + "/commands", - BotSettings.SUPPORT_INVITE.get() - ) - ); - } - - return builder.build(); - }).flatMap(embed -> Messages.sendMessage(embed, event)).then(); - } - - private final String enabledMessage = """ - This command has been converted to a [Slash Command](https://discord.com/blog/slash-commands-are-here). - - - For more information on commands, check out our [Commands Page](%s). - For support, [join our server](%s)."""; - - private final String disabledMessage = """ - This command has been converted to a [Slash Command](https://discord.com/blog/slash-commands-are-here), but they aren't enabled in this guild! A guild admin can [click here to enable them](%s). - - - For more information on commands, check out our [Commands Page](%s). - For support, [join our server](%s)."""; -} diff --git a/client/src/main/java/org/dreamexposure/discal/client/module/command/package-info.java b/client/src/main/java/org/dreamexposure/discal/client/module/command/package-info.java deleted file mode 100644 index 75b5ea161..000000000 --- a/client/src/main/java/org/dreamexposure/discal/client/module/command/package-info.java +++ /dev/null @@ -1 +0,0 @@ -package org.dreamexposure.discal.client.module.command; \ No newline at end of file diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/DisCalClient.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/DisCalClient.kt index 63522671b..8c02ab7a2 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/DisCalClient.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/DisCalClient.kt @@ -1,157 +1,29 @@ package org.dreamexposure.discal.client -import discord4j.common.store.Store -import discord4j.common.store.legacy.LegacyStoreLayout -import discord4j.core.DiscordClientBuilder -import discord4j.core.GatewayDiscordClient -import discord4j.core.`object`.presence.ClientActivity -import discord4j.core.`object`.presence.ClientPresence -import discord4j.core.event.domain.interaction.ChatInputInteractionEvent -import discord4j.core.event.domain.lifecycle.ReadyEvent -import discord4j.core.event.domain.message.MessageCreateEvent -import discord4j.core.event.domain.role.RoleDeleteEvent -import discord4j.core.shard.MemberRequestFilter -import discord4j.core.shard.ShardingStrategy -import discord4j.discordjson.json.GuildData -import discord4j.discordjson.json.MessageData -import discord4j.gateway.intent.Intent -import discord4j.gateway.intent.IntentSet -import discord4j.store.api.mapping.MappingStoreService -import discord4j.store.api.service.StoreService -import discord4j.store.jdk.JdkStoreService -import discord4j.store.redis.RedisStoreService -import io.lettuce.core.RedisClient -import io.lettuce.core.RedisURI import org.dreamexposure.discal.Application -import org.dreamexposure.discal.client.listeners.discord.* -import org.dreamexposure.discal.client.message.Messages -import org.dreamexposure.discal.client.module.command.AddCalendarCommand -import org.dreamexposure.discal.client.module.command.CommandExecutor -import org.dreamexposure.discal.core.`object`.BotSettings -import org.dreamexposure.discal.core.database.DatabaseManager +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT -import org.dreamexposure.discal.core.utils.GlobalVal.STATUS import org.springframework.boot.builder.SpringApplicationBuilder import org.springframework.stereotype.Component -import reactor.core.publisher.Mono -import java.io.FileReader -import java.util.* -import javax.annotation.PreDestroy import kotlin.system.exitProcess @Component class DisCalClient { - companion object { - @JvmStatic - @Deprecated("Try to use client that is provided by d4j entities until using DI") - var client: GatewayDiscordClient? = null - private set + companion object { @JvmStatic fun main(args: Array) { - //Get settings - val p = Properties() - p.load(FileReader("settings.properties")) - BotSettings.init(p) - - //Load lang files - Messages.reloadLangs().subscribe() - - //Register commands - CommandExecutor.registerCommand(AddCalendarCommand()) + Config.init() //Start Spring - val spring = try { - SpringApplicationBuilder(Application::class.java) - .profiles(BotSettings.PROFILE.get()) - .build() - .run(*args) + try { + SpringApplicationBuilder(Application::class.java).run(*args) } catch (e: Exception) { - LOGGER.error(DEFAULT, "'Spring error' by PANIC! at the Bot", e) + LOGGER.error(DEFAULT, "Spring error!", e) exitProcess(4) } - - //Login - DiscordClientBuilder.create(BotSettings.TOKEN.get()) - .build().gateway() - .setEnabledIntents(getIntents()) - .setSharding(getStrategy()) - .setStore(Store.fromLayout(LegacyStoreLayout.of(getStores()))) - .setInitialPresence { ClientPresence.doNotDisturb(ClientActivity.playing("Booting Up!")) } - .setMemberRequestFilter(MemberRequestFilter.none()) // TODO: remove after no longer needing members intent - .withGateway { client -> - DisCalClient.client = client - - //Register listeners - val onReady = client - .on(ReadyEvent::class.java, ReadyEventListener::handle) - .then() - - val onRoleDelete = client - .on(RoleDeleteEvent::class.java, RoleDeleteListener::handle) - .then() - - val onCommand = client - .on(MessageCreateEvent::class.java, MessageCreateListener::handle) - .then() - - val onMention = client - .on(MessageCreateEvent::class.java, BotMentionListener::handle) - .then() - - val slashCommandListener = SlashCommandListener(spring) - val onSlashCommand = client - .on(ChatInputInteractionEvent::class.java, slashCommandListener::handle) - .then() - - Mono.`when`(onReady, onRoleDelete, onCommand, onSlashCommand) - }.block() } } - - - @PreDestroy - fun onShutdown() { - LOGGER.info(STATUS, "Shutting down shard") - - DatabaseManager.disconnectFromMySQL() - - client?.logout()?.subscribe() - } -} - -private fun getStrategy(): ShardingStrategy { - return ShardingStrategy.builder() - .count(Application.getShardCount()) - .indices(Application.getShardIndex().toInt()) - .build() -} - -private fun getStores(): StoreService { - return if (BotSettings.USE_REDIS_STORES.get().equals("true", ignoreCase = true)) { - val uri = RedisURI.Builder - .redis(BotSettings.REDIS_HOSTNAME.get(), BotSettings.REDIS_PORT.get().toInt()) - .withPassword(BotSettings.REDIS_PASSWORD.get().toCharArray()) - .build() - - val rss = RedisStoreService.Builder() - .redisClient(RedisClient.create(uri)) - .build() - - MappingStoreService.create() - .setMappings(rss, GuildData::class.java, MessageData::class.java) - .setFallback(JdkStoreService()) - } else JdkStoreService() } -private fun getIntents(): IntentSet { - return IntentSet.of( - Intent.GUILDS, - Intent.GUILD_MEMBERS, - Intent.GUILD_MESSAGES, - Intent.GUILD_MESSAGE_REACTIONS, - Intent.DIRECT_MESSAGES, - Intent.DIRECT_MESSAGE_REACTIONS - ) -} diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/SlashCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/SlashCommand.kt index afea62e40..c04553a1a 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/SlashCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/SlashCommand.kt @@ -1,7 +1,9 @@ package org.dreamexposure.discal.client.commands -import discord4j.core.`object`.entity.Message import discord4j.core.event.domain.interaction.ChatInputInteractionEvent +import discord4j.core.`object`.entity.Message +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.mono import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.utils.MessageSourceLoader import reactor.core.publisher.Mono @@ -11,7 +13,14 @@ interface SlashCommand { val ephemeral: Boolean - fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono + @Deprecated("Use new handleSuspend for K-coroutines") + fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + return mono { suspendHandle(event, settings) } + } + + suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message { + return handle(event, settings).awaitSingle() + } fun getMessage(key: String, settings: GuildSettings, vararg args: String): String { val src = MessageSourceLoader.getSourceByPath("command/$name/$name") diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/DevCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/dev/DevCommand.kt similarity index 97% rename from client/src/main/kotlin/org/dreamexposure/discal/client/commands/DevCommand.kt rename to client/src/main/kotlin/org/dreamexposure/discal/client/commands/dev/DevCommand.kt index 88b5e4507..5b86be54f 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/DevCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/dev/DevCommand.kt @@ -1,9 +1,10 @@ -package org.dreamexposure.discal.client.commands +package org.dreamexposure.discal.client.commands.dev import discord4j.core.`object`.command.ApplicationCommandInteractionOption import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue import discord4j.core.`object`.entity.Message import discord4j.core.event.domain.interaction.ChatInputInteractionEvent +import org.dreamexposure.discal.client.commands.SlashCommand import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.`object`.web.UserAPIAccount import org.dreamexposure.discal.core.crypto.KeyGenerator.csRandomAlphaNumericString @@ -19,6 +20,7 @@ class DevCommand : SlashCommand { override val name = "dev" override val ephemeral = true + @Deprecated("Use new handleSuspend for K-coroutines") override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { if (!GlobalVal.devUserIds.contains(event.interaction.user.id)) { return event.followupEphemeral(getMessage("error.notDeveloper", settings)) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/AnnouncementCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt similarity index 99% rename from client/src/main/kotlin/org/dreamexposure/discal/client/commands/AnnouncementCommand.kt rename to client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt index 9b6620386..375e0329c 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/AnnouncementCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt @@ -1,17 +1,15 @@ -package org.dreamexposure.discal.client.commands +package org.dreamexposure.discal.client.commands.global +import discord4j.core.event.domain.interaction.ChatInputInteractionEvent import discord4j.core.`object`.command.ApplicationCommandInteractionOption import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue import discord4j.core.`object`.entity.Member import discord4j.core.`object`.entity.Message import discord4j.core.`object`.entity.channel.MessageChannel -import discord4j.core.event.domain.interaction.ChatInputInteractionEvent import discord4j.core.spec.InteractionFollowupCreateSpec import discord4j.rest.util.AllowedMentions +import org.dreamexposure.discal.client.commands.SlashCommand import org.dreamexposure.discal.client.message.embed.AnnouncementEmbed -import org.dreamexposure.discal.core.`object`.GuildSettings -import org.dreamexposure.discal.core.`object`.Wizard -import org.dreamexposure.discal.core.`object`.announcement.Announcement import org.dreamexposure.discal.core.crypto.KeyGenerator import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.enums.announcement.AnnouncementType @@ -21,6 +19,9 @@ import org.dreamexposure.discal.core.extensions.discord4j.followupEphemeral import org.dreamexposure.discal.core.extensions.discord4j.getCalendar import org.dreamexposure.discal.core.extensions.discord4j.hasControlRole import org.dreamexposure.discal.core.extensions.messageContentSafe +import org.dreamexposure.discal.core.`object`.GuildSettings +import org.dreamexposure.discal.core.`object`.Wizard +import org.dreamexposure.discal.core.`object`.announcement.Announcement import org.dreamexposure.discal.core.utils.getCommonMsg import org.springframework.stereotype.Component import reactor.core.publisher.Flux @@ -31,6 +32,7 @@ class AnnouncementCommand(val wizard: Wizard) : SlashCommand { override val name = "announcement" override val ephemeral = true + @Deprecated("Use new handleSuspend for K-coroutines") override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { return when (event.options[0].name) { "create" -> create(event, settings) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/CalendarCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/CalendarCommand.kt similarity index 98% rename from client/src/main/kotlin/org/dreamexposure/discal/client/commands/CalendarCommand.kt rename to client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/CalendarCommand.kt index ebe6bf1c9..54c458649 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/CalendarCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/CalendarCommand.kt @@ -1,4 +1,4 @@ -package org.dreamexposure.discal.client.commands +package org.dreamexposure.discal.client.commands.global import discord4j.core.event.domain.interaction.ChatInputInteractionEvent import discord4j.core.`object`.command.ApplicationCommandInteractionOption @@ -6,6 +6,7 @@ import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue import discord4j.core.`object`.entity.Guild import discord4j.core.`object`.entity.Member import discord4j.core.`object`.entity.Message +import org.dreamexposure.discal.client.commands.SlashCommand import org.dreamexposure.discal.client.message.embed.CalendarEmbed import org.dreamexposure.discal.client.service.StaticMessageService import org.dreamexposure.discal.core.entities.response.UpdateCalendarResponse @@ -27,6 +28,7 @@ class CalendarCommand(val wizard: Wizard, val staticMessageSrv: Sta override val name = "calendar" override val ephemeral = true + @Deprecated("Use new handleSuspend for K-coroutines") override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { return when (event.options[0].name) { "create" -> create(event, settings) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/DiscalCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DiscalCommand.kt similarity index 80% rename from client/src/main/kotlin/org/dreamexposure/discal/client/commands/DiscalCommand.kt rename to client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DiscalCommand.kt index 7e8277793..e9875cf9f 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/DiscalCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DiscalCommand.kt @@ -1,7 +1,8 @@ -package org.dreamexposure.discal.client.commands +package org.dreamexposure.discal.client.commands.global import discord4j.core.`object`.entity.Message import discord4j.core.event.domain.interaction.ChatInputInteractionEvent +import org.dreamexposure.discal.client.commands.SlashCommand import org.dreamexposure.discal.client.message.embed.DiscalEmbed import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.extensions.discord4j.followup @@ -13,6 +14,7 @@ class DiscalCommand : SlashCommand { override val name = "discal" override val ephemeral = false + @Deprecated("Use new handleSuspend for K-coroutines") override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { return event.interaction.guild .flatMap(DiscalEmbed::info) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/DisplayCalendarCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DisplayCalendarCommand.kt similarity index 97% rename from client/src/main/kotlin/org/dreamexposure/discal/client/commands/DisplayCalendarCommand.kt rename to client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DisplayCalendarCommand.kt index 744b6c306..9def46dc2 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/DisplayCalendarCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DisplayCalendarCommand.kt @@ -1,4 +1,4 @@ -package org.dreamexposure.discal.client.commands +package org.dreamexposure.discal.client.commands.global import discord4j.common.util.Snowflake import discord4j.core.`object`.command.ApplicationCommandInteractionOption @@ -8,6 +8,7 @@ import discord4j.core.`object`.entity.Message import discord4j.core.event.domain.interaction.ChatInputInteractionEvent import discord4j.core.spec.MessageCreateSpec import discord4j.rest.http.client.ClientException +import org.dreamexposure.discal.client.commands.SlashCommand import org.dreamexposure.discal.client.message.embed.CalendarEmbed import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.`object`.StaticMessage @@ -28,6 +29,7 @@ class DisplayCalendarCommand : SlashCommand { override val name = "displaycal" override val ephemeral = true + @Deprecated("Use new handleSuspend for K-coroutines") override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { return when (event.options[0].name) { "new" -> new(event, settings) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/EventCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/EventCommand.kt similarity index 95% rename from client/src/main/kotlin/org/dreamexposure/discal/client/commands/EventCommand.kt rename to client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/EventCommand.kt index f9004565b..86358b931 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/EventCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/EventCommand.kt @@ -1,4 +1,4 @@ -package org.dreamexposure.discal.client.commands +package org.dreamexposure.discal.client.commands.global import discord4j.core.event.domain.interaction.ChatInputInteractionEvent import discord4j.core.`object`.command.ApplicationCommandInteractionOption @@ -6,6 +6,7 @@ import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue import discord4j.core.`object`.entity.Member import discord4j.core.`object`.entity.Message import discord4j.core.spec.MessageCreateSpec +import org.dreamexposure.discal.client.commands.SlashCommand import org.dreamexposure.discal.client.message.embed.EventEmbed import org.dreamexposure.discal.client.service.StaticMessageService import org.dreamexposure.discal.core.entities.Event @@ -33,6 +34,7 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes override val name = "event" override val ephemeral = true + @Deprecated("Use new handleSuspend for K-coroutines") override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { return when (event.options[0].name) { "create" -> create(event, settings) @@ -171,6 +173,10 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes .map(Long::toInt) .map { it.coerceAtLeast(0).coerceAtMost(59) } .orElse(0) + val keepDuration = event.options[0].getOption("keep-duration") + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asBoolean) + .orElse(settings.eventKeepDuration) return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { val pre = wizard.get(settings.guildID) @@ -196,8 +202,13 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes } } else { // Event end already set, make sure everything is in order - if (pre.end!!.isAfter(start)) { + val originalDuration = if (pre.start != null) Duration.between(pre.start, pre.end) else null + val shouldChangeDuration = keepDuration && originalDuration != null + + if (pre.end!!.isAfter(start) || shouldChangeDuration) { pre.start = start + if (shouldChangeDuration) pre.end = start.plus(originalDuration) + if (pre.start!!.isAfter(Instant.now())) { event.interaction.guild .map { EventEmbed.pre(it, settings, pre) } @@ -251,6 +262,10 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes .map(Long::toInt) .map { it.coerceAtLeast(0).coerceAtMost(59) } .orElse(0) + val keepDuration = event.options[0].getOption("keep-duration") + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asBoolean) + .orElse(settings.eventKeepDuration) return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { val pre = wizard.get(settings.guildID) @@ -276,8 +291,13 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes } } else { // Event start already set, make sure everything is in order - if (pre.start!!.isBefore(end)) { + val originalDuration = if (pre.end != null) Duration.between(pre.start, pre.end) else null + val shouldChangeDuration = keepDuration && originalDuration != null + + if (pre.start!!.isBefore(end) || shouldChangeDuration) { pre.end = end + if (shouldChangeDuration) pre.start = end.minus(originalDuration) + if (pre.end!!.isAfter(Instant.now())) { event.interaction.guild .map { EventEmbed.pre(it, settings, pre) } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/EventsCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/EventsCommand.kt similarity index 98% rename from client/src/main/kotlin/org/dreamexposure/discal/client/commands/EventsCommand.kt rename to client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/EventsCommand.kt index f8658f3bf..df7e2316c 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/EventsCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/EventsCommand.kt @@ -1,9 +1,10 @@ -package org.dreamexposure.discal.client.commands +package org.dreamexposure.discal.client.commands.global import discord4j.core.`object`.command.ApplicationCommandInteractionOption import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue import discord4j.core.`object`.entity.Message import discord4j.core.event.domain.interaction.ChatInputInteractionEvent +import org.dreamexposure.discal.client.commands.SlashCommand import org.dreamexposure.discal.client.message.embed.EventEmbed import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.extensions.discord4j.followup @@ -24,6 +25,7 @@ class EventsCommand : SlashCommand { override val name = "events" override val ephemeral = false + @Deprecated("Use new handleSuspend for K-coroutines") override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { return when (event.options[0].name) { "upcoming" -> upcomingEventsSubcommand(event, settings) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/HelpCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/HelpCommand.kt similarity index 65% rename from client/src/main/kotlin/org/dreamexposure/discal/client/commands/HelpCommand.kt rename to client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/HelpCommand.kt index a8427f1f4..7d1f398d1 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/HelpCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/HelpCommand.kt @@ -1,10 +1,11 @@ -package org.dreamexposure.discal.client.commands +package org.dreamexposure.discal.client.commands.global -import discord4j.core.`object`.entity.Message import discord4j.core.event.domain.interaction.ChatInputInteractionEvent -import org.dreamexposure.discal.core.`object`.BotSettings -import org.dreamexposure.discal.core.`object`.GuildSettings +import discord4j.core.`object`.entity.Message +import org.dreamexposure.discal.client.commands.SlashCommand +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.extensions.discord4j.followupEphemeral +import org.dreamexposure.discal.core.`object`.GuildSettings import org.springframework.stereotype.Component import reactor.core.publisher.Mono @@ -13,9 +14,10 @@ class HelpCommand : SlashCommand { override val name = "help" override val ephemeral = true + @Deprecated("Use new handleSuspend for K-coroutines") override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { return event.followupEphemeral( - getMessage("error.workInProgress", settings, "${BotSettings.BASE_URL.get()}/commands") + getMessage("error.workInProgress", settings, "${Config.URL_BASE.getString()}/commands") ) } } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/LinkCalendarCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/LinkCalendarCommand.kt similarity index 90% rename from client/src/main/kotlin/org/dreamexposure/discal/client/commands/LinkCalendarCommand.kt rename to client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/LinkCalendarCommand.kt index 72d9c126d..108df5ea1 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/LinkCalendarCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/LinkCalendarCommand.kt @@ -1,9 +1,10 @@ -package org.dreamexposure.discal.client.commands +package org.dreamexposure.discal.client.commands.global import discord4j.core.`object`.command.ApplicationCommandInteractionOption import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue import discord4j.core.`object`.entity.Message import discord4j.core.event.domain.interaction.ChatInputInteractionEvent +import org.dreamexposure.discal.client.commands.SlashCommand import org.dreamexposure.discal.client.message.embed.CalendarEmbed import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.extensions.discord4j.followup @@ -16,6 +17,7 @@ class LinkCalendarCommand : SlashCommand { override val name = "linkcal" override val ephemeral = false + @Deprecated("Use new handleSuspend for K-coroutines") override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { val showOverview = event.getOption("overview") .flatMap(ApplicationCommandInteractionOption::getValue) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/RsvpCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/RsvpCommand.kt similarity index 99% rename from client/src/main/kotlin/org/dreamexposure/discal/client/commands/RsvpCommand.kt rename to client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/RsvpCommand.kt index 0bc307556..baa8be3b4 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/RsvpCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/RsvpCommand.kt @@ -1,10 +1,11 @@ -package org.dreamexposure.discal.client.commands +package org.dreamexposure.discal.client.commands.global import discord4j.core.`object`.command.ApplicationCommandInteractionOption import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue import discord4j.core.`object`.entity.Member import discord4j.core.`object`.entity.Message import discord4j.core.event.domain.interaction.ChatInputInteractionEvent +import org.dreamexposure.discal.client.commands.SlashCommand import org.dreamexposure.discal.client.message.embed.RsvpEmbed import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.extensions.discord4j.followupEphemeral @@ -21,6 +22,7 @@ class RsvpCommand : SlashCommand { override val name = "rsvp" override val ephemeral = true + @Deprecated("Use new handleSuspend for K-coroutines") override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { return when (event.options[0].name) { "ontime" -> onTime(event, settings) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/SettingsCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/SettingsCommand.kt similarity index 86% rename from client/src/main/kotlin/org/dreamexposure/discal/client/commands/SettingsCommand.kt rename to client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/SettingsCommand.kt index 27f2e3f70..a3ddc9582 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/SettingsCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/SettingsCommand.kt @@ -1,17 +1,18 @@ -package org.dreamexposure.discal.client.commands +package org.dreamexposure.discal.client.commands.global +import discord4j.core.event.domain.interaction.ChatInputInteractionEvent import discord4j.core.`object`.command.ApplicationCommandInteractionOption import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue import discord4j.core.`object`.entity.Message -import discord4j.core.event.domain.interaction.ChatInputInteractionEvent +import org.dreamexposure.discal.client.commands.SlashCommand import org.dreamexposure.discal.client.message.embed.SettingsEmbed -import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.enums.announcement.AnnouncementStyle import org.dreamexposure.discal.core.enums.time.TimeFormat import org.dreamexposure.discal.core.extensions.discord4j.followup import org.dreamexposure.discal.core.extensions.discord4j.followupEphemeral import org.dreamexposure.discal.core.extensions.discord4j.hasElevatedPermissions +import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.utils.getCommonMsg import org.springframework.stereotype.Component import reactor.core.publisher.Mono @@ -21,6 +22,7 @@ class SettingsCommand : SlashCommand { override val name = "settings" override val ephemeral = true + @Deprecated("Use new handleSuspend for K-coroutines") override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { //Check if user has permission to use this return event.interaction.member.get().hasElevatedPermissions().flatMap { hasPerm -> @@ -31,6 +33,7 @@ class SettingsCommand : SlashCommand { "announcement-style" -> announcementStyleSubcommand(event, settings) "language" -> languageSubcommand(event, settings) "time-format" -> timeFormatSubcommand(event, settings) + "keep-event-duration" -> eventKeepDurationSubcommand(event, settings) "branding" -> brandingSubcommand(event, settings) else -> Mono.empty() //Never can reach this, makes compiler happy. } @@ -98,6 +101,18 @@ class SettingsCommand : SlashCommand { .flatMap { event.followupEphemeral(getMessage("format.success", settings, timeFormat.name)) } } + private fun eventKeepDurationSubcommand(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + val keepDuration = event.options[0].getOption("value") + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asBoolean) + .get() + + settings.eventKeepDuration = keepDuration + + return DatabaseManager.updateSettings(settings) + .flatMap { event.followupEphemeral(getMessage("eventKeepDuration.success.$keepDuration", settings)) } + } + private fun brandingSubcommand(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { return if (settings.patronGuild) { val useBranding = event.options[0].getOption("use") diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/TimeCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/TimeCommand.kt similarity index 78% rename from client/src/main/kotlin/org/dreamexposure/discal/client/commands/TimeCommand.kt rename to client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/TimeCommand.kt index 526097661..51fec260d 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/TimeCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/TimeCommand.kt @@ -1,22 +1,23 @@ -package org.dreamexposure.discal.client.commands +package org.dreamexposure.discal.client.commands.global +import discord4j.core.event.domain.interaction.ChatInputInteractionEvent import discord4j.core.`object`.command.ApplicationCommandInteractionOption import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue import discord4j.core.`object`.entity.Message -import discord4j.core.event.domain.interaction.ChatInputInteractionEvent +import kotlinx.coroutines.reactor.awaitSingle +import org.dreamexposure.discal.client.commands.SlashCommand import org.dreamexposure.discal.client.message.embed.CalendarEmbed -import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.extensions.discord4j.followupEphemeral +import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.utils.getCommonMsg import org.springframework.stereotype.Component -import reactor.core.publisher.Mono @Component class TimeCommand : SlashCommand { override val name = "time" override val ephemeral = true - override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + override suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val calendarNumber = event.getOption("calendar") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) @@ -27,6 +28,6 @@ class TimeCommand : SlashCommand { CalendarEmbed.time(guild, settings, calendarNumber).flatMap { event.followupEphemeral(it) } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings))) + }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings))).awaitSingle() } } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/AddCalCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/premium/AddCalCommand.kt similarity index 83% rename from client/src/main/kotlin/org/dreamexposure/discal/client/commands/AddCalCommand.kt rename to client/src/main/kotlin/org/dreamexposure/discal/client/commands/premium/AddCalCommand.kt index 5d0c7f600..4cbd0903a 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/AddCalCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/premium/AddCalCommand.kt @@ -1,14 +1,15 @@ -package org.dreamexposure.discal.client.commands +package org.dreamexposure.discal.client.commands.premium +import discord4j.core.event.domain.interaction.ChatInputInteractionEvent import discord4j.core.`object`.entity.Guild import discord4j.core.`object`.entity.Member import discord4j.core.`object`.entity.Message -import discord4j.core.event.domain.interaction.ChatInputInteractionEvent -import org.dreamexposure.discal.core.`object`.BotSettings -import org.dreamexposure.discal.core.`object`.GuildSettings +import org.dreamexposure.discal.client.commands.SlashCommand +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.extensions.discord4j.canAddCalendar import org.dreamexposure.discal.core.extensions.discord4j.followupEphemeral import org.dreamexposure.discal.core.extensions.discord4j.hasElevatedPermissions +import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.utils.getCommonMsg import org.springframework.stereotype.Component import reactor.core.publisher.Mono @@ -18,6 +19,7 @@ class AddCalCommand : SlashCommand { override val name = "addcal" override val ephemeral = true + @Deprecated("Use new handleSuspend for K-coroutines") override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { //TODO: Remove dev-only and switch to patron-only once this is completed return if (settings.devGuild) { @@ -33,6 +35,6 @@ class AddCalCommand : SlashCommand { } private fun getLink(settings: GuildSettings): String { - return "${BotSettings.BASE_URL.get()}/dashboard/${settings.guildID.asString()}/calendar/new?type=1&step=0" + return "${Config.URL_BASE.getString()}/dashboard/${settings.guildID.asString()}/calendar/new?type=1&step=0" } } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/conf/WebFluxConfig.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/conf/WebFluxConfig.kt deleted file mode 100644 index f6d8e495c..000000000 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/conf/WebFluxConfig.kt +++ /dev/null @@ -1,66 +0,0 @@ -package org.dreamexposure.discal.client.conf - -import io.r2dbc.spi.ConnectionFactories -import io.r2dbc.spi.ConnectionFactory -import io.r2dbc.spi.ConnectionFactoryOptions -import org.dreamexposure.discal.core.`object`.BotSettings -import org.dreamexposure.discal.core.utils.GlobalVal -import org.springframework.boot.web.server.ConfigurableWebServerFactory -import org.springframework.boot.web.server.ErrorPage -import org.springframework.boot.web.server.WebServerFactoryCustomizer -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.data.redis.connection.RedisPassword -import org.springframework.data.redis.connection.RedisStandaloneConfiguration -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory -import org.springframework.http.HttpStatus -import org.springframework.http.codec.ServerCodecConfigurer -import org.springframework.http.codec.json.KotlinSerializationJsonDecoder -import org.springframework.http.codec.json.KotlinSerializationJsonEncoder -import org.springframework.web.reactive.config.CorsRegistry -import org.springframework.web.reactive.config.EnableWebFlux -import org.springframework.web.reactive.config.WebFluxConfigurer - -@Configuration -@EnableWebFlux -class WebFluxConfig : WebFluxConfigurer, WebServerFactoryCustomizer { - - override fun customize(factory: ConfigurableWebServerFactory?) { - factory?.addErrorPages(ErrorPage(HttpStatus.NOT_FOUND, "/")) - } - - override fun addCorsMappings(registry: CorsRegistry) { - registry.addMapping("/api/**") - .allowedOrigins("*") - } - - @Bean(name = ["redisDatasource"]) - fun redisConnectionFactory(): LettuceConnectionFactory { - val rsc = RedisStandaloneConfiguration() - rsc.hostName = BotSettings.REDIS_HOSTNAME.get() - rsc.port = BotSettings.REDIS_PORT.get().toInt() - if (BotSettings.REDIS_USE_PASSWORD.get().equals("true", true)) - rsc.password = RedisPassword.of(BotSettings.REDIS_PASSWORD.get()) - - return LettuceConnectionFactory(rsc) - } - - @Bean(name = ["mysqlDatasource"]) - fun mysqlConnectionFactory(): ConnectionFactory { - return ConnectionFactories.get(ConnectionFactoryOptions.builder() - .option(ConnectionFactoryOptions.DRIVER, "pool") - .option(ConnectionFactoryOptions.PROTOCOL, "mysql") - .option(ConnectionFactoryOptions.HOST, BotSettings.SQL_HOST.get()) - .option(ConnectionFactoryOptions.PORT, BotSettings.SQL_PORT.get().toInt()) - .option(ConnectionFactoryOptions.USER, BotSettings.SQL_USER.get()) - .option(ConnectionFactoryOptions.PASSWORD, BotSettings.SQL_PASS.get()) - .option(ConnectionFactoryOptions.DATABASE, BotSettings.SQL_DB.get()) - .build()) - } - - override fun configureHttpMessageCodecs(configurer: ServerCodecConfigurer) { - val codecs = configurer.defaultCodecs() - codecs.kotlinSerializationJsonDecoder(KotlinSerializationJsonDecoder(GlobalVal.JSON_FORMAT)) - codecs.kotlinSerializationJsonEncoder(KotlinSerializationJsonEncoder(GlobalVal.JSON_FORMAT)) - } -} diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/conf/DisCalConfig.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/config/DisCalConfig.kt similarity index 92% rename from client/src/main/kotlin/org/dreamexposure/discal/client/conf/DisCalConfig.kt rename to client/src/main/kotlin/org/dreamexposure/discal/client/config/DisCalConfig.kt index ff26d457f..7d6ed92c8 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/conf/DisCalConfig.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/config/DisCalConfig.kt @@ -1,4 +1,4 @@ -package org.dreamexposure.discal.client.conf +package org.dreamexposure.discal.client.config import org.dreamexposure.discal.core.`object`.Wizard import org.dreamexposure.discal.core.`object`.announcement.Announcement diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/config/DiscordConfig.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/config/DiscordConfig.kt new file mode 100644 index 000000000..99dd21854 --- /dev/null +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/config/DiscordConfig.kt @@ -0,0 +1,114 @@ +package org.dreamexposure.discal.client.config + +import discord4j.common.store.Store +import discord4j.common.store.legacy.LegacyStoreLayout +import discord4j.core.DiscordClientBuilder +import discord4j.core.GatewayDiscordClient +import discord4j.core.event.domain.Event +import discord4j.core.`object`.presence.ClientActivity +import discord4j.core.`object`.presence.ClientPresence +import discord4j.core.shard.MemberRequestFilter +import discord4j.core.shard.ShardingStrategy +import discord4j.discordjson.json.GuildData +import discord4j.discordjson.json.MessageData +import discord4j.gateway.intent.Intent +import discord4j.gateway.intent.IntentSet +import discord4j.rest.RestClient +import discord4j.store.api.mapping.MappingStoreService +import discord4j.store.api.service.StoreService +import discord4j.store.jdk.JdkStoreService +import discord4j.store.redis.RedisClusterStoreService +import discord4j.store.redis.RedisStoreDefaults +import discord4j.store.redis.RedisStoreService +import io.lettuce.core.RedisClient +import io.lettuce.core.RedisURI +import io.lettuce.core.cluster.RedisClusterClient +import kotlinx.coroutines.reactor.mono +import org.dreamexposure.discal.Application +import org.dreamexposure.discal.client.listeners.discord.EventListener +import org.dreamexposure.discal.core.config.Config +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import reactor.kotlin.core.publisher.toFlux + +@Configuration +class DiscordConfig { + + @Bean + fun discordGatewayClient( + listeners: List>, + stores: StoreService + ): GatewayDiscordClient { + return DiscordClientBuilder.create(Config.SECRET_BOT_TOKEN.getString()) + .build().gateway() + .setEnabledIntents(getIntents()) + .setSharding(getStrategy()) + .setStore(Store.fromLayout(LegacyStoreLayout.of(stores))) + .setInitialPresence { ClientPresence.doNotDisturb(ClientActivity.playing("Booting Up!")) } + .setMemberRequestFilter(MemberRequestFilter.none()) + .withEventDispatcher { dispatcher -> + @Suppress("UNCHECKED_CAST") + (listeners as Iterable>).toFlux() + .flatMap { + dispatcher.on(it.genericType) { event -> mono { it.handle(event) } } + } + } + .login() + .block()!! + } + + @Bean + fun discordRestClient(gatewayDiscordClient: GatewayDiscordClient): RestClient { + return gatewayDiscordClient.restClient + } + + @Bean + fun discordStores(): StoreService { + val useRedis = Config.CACHE_USE_REDIS.getBoolean() + val redisHost = Config.REDIS_HOST.getString() + val redisPassword = Config.REDIS_PASSWORD.getString().toCharArray() + val redisPort = Config.REDIS_PORT.getInt() + val redisCluster = Config.CACHE_REDIS_IS_CLUSTER.getBoolean() + val prefix = Config.CACHE_PREFIX.getString() + + return if (useRedis) { + val uriBuilder = RedisURI.Builder + .redis(redisHost, redisPort) + if (redisPassword.isNotEmpty()) uriBuilder.withPassword(redisPassword) + + val rss = if (redisCluster) { + RedisClusterStoreService.Builder() + .redisClient(RedisClusterClient.create(uriBuilder.build())) + .keyPrefix("$prefix.${RedisStoreDefaults.DEFAULT_KEY_PREFIX}") + .build() + } else { + RedisStoreService.Builder() + .redisClient(RedisClient.create(uriBuilder.build())) + .keyPrefix("$prefix.${RedisStoreDefaults.DEFAULT_KEY_PREFIX}") + .build() + } + + + MappingStoreService.create() + .setMappings(rss, GuildData::class.java, MessageData::class.java) + .setFallback(JdkStoreService()) + } else JdkStoreService() + } + + private fun getStrategy(): ShardingStrategy { + return ShardingStrategy.builder() + .count(Application.getShardCount()) + .indices(Application.getShardIndex()) + .build() + } + + private fun getIntents(): IntentSet { + return IntentSet.of( + Intent.GUILDS, + Intent.GUILD_MESSAGES, + Intent.GUILD_MESSAGE_REACTIONS, + Intent.DIRECT_MESSAGES, + Intent.DIRECT_MESSAGE_REACTIONS + ) + } +} diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/config/WebFluxConfig.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/config/WebFluxConfig.kt new file mode 100644 index 000000000..ecb3cef3e --- /dev/null +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/config/WebFluxConfig.kt @@ -0,0 +1,34 @@ +package org.dreamexposure.discal.client.config + +import org.dreamexposure.discal.core.utils.GlobalVal +import org.springframework.boot.web.server.ConfigurableWebServerFactory +import org.springframework.boot.web.server.ErrorPage +import org.springframework.boot.web.server.WebServerFactoryCustomizer +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpStatus +import org.springframework.http.codec.ServerCodecConfigurer +import org.springframework.http.codec.json.KotlinSerializationJsonDecoder +import org.springframework.http.codec.json.KotlinSerializationJsonEncoder +import org.springframework.web.reactive.config.CorsRegistry +import org.springframework.web.reactive.config.EnableWebFlux +import org.springframework.web.reactive.config.WebFluxConfigurer + +@Configuration +@EnableWebFlux +class WebFluxConfig : WebFluxConfigurer, WebServerFactoryCustomizer { + + override fun customize(factory: ConfigurableWebServerFactory?) { + factory?.addErrorPages(ErrorPage(HttpStatus.NOT_FOUND, "/")) + } + + override fun addCorsMappings(registry: CorsRegistry) { + registry.addMapping("/api/**") + .allowedOrigins("*") + } + + override fun configureHttpMessageCodecs(configurer: ServerCodecConfigurer) { + val codecs = configurer.defaultCodecs() + codecs.kotlinSerializationJsonDecoder(KotlinSerializationJsonDecoder(GlobalVal.JSON_FORMAT)) + codecs.kotlinSerializationJsonEncoder(KotlinSerializationJsonEncoder(GlobalVal.JSON_FORMAT)) + } +} diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/BotMentionListener.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/BotMentionListener.kt index 3b770c63f..8ea543ae7 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/BotMentionListener.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/BotMentionListener.kt @@ -1,36 +1,31 @@ package org.dreamexposure.discal.client.listeners.discord +import discord4j.core.event.domain.message.MessageCreateEvent import discord4j.core.`object`.entity.Message import discord4j.core.`object`.entity.User -import discord4j.core.event.domain.message.MessageCreateEvent +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull import org.dreamexposure.discal.client.message.embed.DiscalEmbed -import org.dreamexposure.discal.core.`object`.BotSettings.ID -import reactor.core.publisher.Mono - -object BotMentionListener { +import org.springframework.stereotype.Component - fun handle(event: MessageCreateEvent): Mono { - if (event.guildId.isPresent //in guild - && !event.message.author.map(User::isBot).orElse(false) // not from a bot - && containsMention(event.message) // mentions the bot +@Component +class BotMentionListener: EventListener { + override suspend fun handle(event: MessageCreateEvent) { + if (event.guildId.isPresent // in guild + && !event.message.author.map(User::isBot).orElse(false) // Not from a bot + && onlyMentionsBot(event.message) ) { - return event.guild.flatMap(DiscalEmbed::info).flatMap { embed -> - event.message.channel.flatMap { channel -> - channel.createMessage(embed).then() - } - } + val embed = event.guild.flatMap(DiscalEmbed::info).awaitSingle() + val channel = event.message.channel.awaitSingle() + + channel.createMessage(embed).awaitSingleOrNull() } - //Ignore everything else - return Mono.empty() } - private fun containsMention(message: Message): Boolean { - return message.userMentionIds.size == 1 && // only 1 user mentioned - message.roleMentionIds.isEmpty() && // no roles mentioned - message.userMentionIds.contains(message.client.selfId) && // only the bot is mentioned - !message.mentionsEveryone() // no @everyone mentioned + private fun onlyMentionsBot(message: Message): Boolean { + return (message.userMentionIds.size == 1 && message.userMentionIds.contains(message.client.selfId)) // Only bot user mentioned + && message.roleMentionIds.isEmpty() // Does not mention any roles + && !message.mentionsEveryone() // Does not mention everyone } - - private fun containsMention(msg: String) = msg == "<@${ID.get()}>" || msg == "<@!${ID.get()}>" } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/EventListener.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/EventListener.kt new file mode 100644 index 000000000..9ce312446 --- /dev/null +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/EventListener.kt @@ -0,0 +1,12 @@ +package org.dreamexposure.discal.client.listeners.discord + +import discord4j.core.event.domain.Event +import org.springframework.core.GenericTypeResolver + +interface EventListener { + @Suppress("UNCHECKED_CAST") + val genericType: Class + get() = GenericTypeResolver.resolveTypeArgument(javaClass, EventListener::class.java) as Class + + suspend fun handle(event: T) +} diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/GuildCreateListener.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/GuildCreateListener.kt new file mode 100644 index 000000000..3cfbfc64a --- /dev/null +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/GuildCreateListener.kt @@ -0,0 +1,59 @@ +package org.dreamexposure.discal.client.listeners.discord + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import discord4j.core.event.domain.guild.GuildCreateEvent +import discord4j.discordjson.json.ApplicationCommandRequest +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.dreamexposure.discal.core.database.DatabaseManager +import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT +import org.springframework.core.io.support.PathMatchingResourcePatternResolver +import org.springframework.stereotype.Component + +@Component +class GuildCreateListener(objectMapper: ObjectMapper) : EventListener { + private val premiumCommands: List + private val devCommands: List + + init { + val matcher = PathMatchingResourcePatternResolver() + + // Get premium commands + val premiumCommands = mutableListOf() + for (res in matcher.getResources("commands/premium/*.json")) { + val request = objectMapper.readValue(res.inputStream) + premiumCommands.add(request) + } + this.premiumCommands = premiumCommands + + // Get dev commands + val devCommands = mutableListOf() + for (res in matcher.getResources("commands/dev/*.json")) { + val request = objectMapper.readValue(res.inputStream) + premiumCommands.add(request) + } + this.devCommands = devCommands + + } + + override suspend fun handle(event: GuildCreateEvent) { + val settings = DatabaseManager.getSettings(event.guild.id).awaitSingle() + val appService = event.client.restClient.applicationService + val guildId = settings.guildID.asLong() + val appId = event.client.selfId.asLong() + + val commands = mutableListOf() + if (settings.patronGuild) commands.addAll(premiumCommands) + if (settings.devGuild) commands.addAll(devCommands) + + if (commands.isNotEmpty()) { + appService.bulkOverwriteGuildApplicationCommand(appId, guildId, commands) + .doOnNext { LOGGER.debug("Bulk guild overwrite read: ${it.name()} | $guildId") } + .doOnError { LOGGER.error(DEFAULT, "Bulk guild overwrite failed | $guildId", it) } + .then() + .awaitSingleOrNull() + } + } +} diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/ReadyEventListener.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/ReadyEventListener.kt index c3c49f8ca..18403e6b0 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/ReadyEventListener.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/ReadyEventListener.kt @@ -2,22 +2,24 @@ package org.dreamexposure.discal.client.listeners.discord import discord4j.core.event.domain.lifecycle.ReadyEvent import discord4j.rest.util.Image -import org.dreamexposure.discal.client.message.Messages +import kotlinx.coroutines.reactor.awaitSingle import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT import org.dreamexposure.discal.core.utils.GlobalVal.STATUS import org.dreamexposure.discal.core.utils.GlobalVal.iconUrl -import reactor.core.publisher.Mono +import org.springframework.stereotype.Component -object ReadyEventListener { - fun handle(event: ReadyEvent): Mono { - return event.client.applicationInfo - .doOnNext { iconUrl = it.getIconUrl(Image.Format.PNG).get() } - .doOnNext { LOGGER.info(STATUS, "Ready event success!") } - .then(Messages.reloadLangs()) - .onErrorResume { - LOGGER.error(DEFAULT, "Failed to handle ready event") - Mono.empty() - }.then() +@Component +class ReadyEventListener : EventListener { + override suspend fun handle(event: ReadyEvent) { + try { + iconUrl = event.client.applicationInfo + .map { it.getIconUrl(Image.Format.PNG).orElse("") } + .awaitSingle() + + LOGGER.info(STATUS, "Ready event success!") + } catch (e: Exception) { + LOGGER.error(DEFAULT, "Failed to handle ready event", e) + } } } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/RoleDeleteListener.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/RoleDeleteListener.kt index 85ab9853c..1c8f2510c 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/RoleDeleteListener.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/RoleDeleteListener.kt @@ -2,20 +2,23 @@ package org.dreamexposure.discal.client.listeners.discord import discord4j.common.util.Snowflake import discord4j.core.event.domain.role.RoleDeleteEvent +import kotlinx.coroutines.reactor.awaitSingleOrNull import org.dreamexposure.discal.core.database.DatabaseManager +import org.springframework.stereotype.Component import reactor.core.publisher.Mono -object RoleDeleteListener { +@Component +class RoleDeleteListener : EventListener { - fun handle(event: RoleDeleteEvent): Mono { + override suspend fun handle(event: RoleDeleteEvent) { val updateRsvps = DatabaseManager.removeRsvpRole(event.guildId, event.roleId) val updateControlRole = DatabaseManager.getSettings(event.guildId) - .filter { !"everyone".equals(it.controlRole, true) } - .filter { event.roleId == Snowflake.of(it.controlRole) } - .doOnNext { it.controlRole = "everyone" } - .flatMap(DatabaseManager::updateSettings) + .filter { !"everyone".equals(it.controlRole, true) } + .filter { event.roleId == Snowflake.of(it.controlRole) } + .doOnNext { it.controlRole = "everyone" } + .flatMap(DatabaseManager::updateSettings) - return Mono.`when`(updateRsvps, updateControlRole) + Mono.`when`(updateRsvps, updateControlRole).awaitSingleOrNull() } } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/SlashCommandListener.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/SlashCommandListener.kt index db6f9374e..82574f4bc 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/SlashCommandListener.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/SlashCommandListener.kt @@ -1,38 +1,47 @@ package org.dreamexposure.discal.client.listeners.discord import discord4j.core.event.domain.interaction.ChatInputInteractionEvent +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull import org.dreamexposure.discal.client.commands.SlashCommand import org.dreamexposure.discal.core.database.DatabaseManager -import org.dreamexposure.discal.core.extensions.discord4j.followupEphemeral import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.utils.getCommonMsg -import org.springframework.context.ApplicationContext -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono +import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT +import org.springframework.stereotype.Component -class SlashCommandListener(applicationContext: ApplicationContext) { - private val cmds = applicationContext.getBeansOfType(SlashCommand::class.java).values +@Component +class SlashCommandListener( + private val commands: List +) : EventListener { - fun handle(event: ChatInputInteractionEvent): Mono { + override suspend fun handle(event: ChatInputInteractionEvent) { if (!event.interaction.guildId.isPresent) { - return event.reply("Commands not supported in DMs.") + event.reply("Commands not supported in DMs.").awaitSingleOrNull() + return + } + + val command = commands.firstOrNull { it.name == event.commandName } + + if (command != null) { + event.deferReply().withEphemeral(command.ephemeral).awaitSingleOrNull() + + try { + val settings = DatabaseManager.getSettings(event.interaction.guildId.get()).awaitSingle() + + command.suspendHandle(event, settings) + } catch (e: Exception) { + LOGGER.error(DEFAULT, "Error handling slash command | $event", e) + + // Attempt to provide a message if there's an unhandled exception + event.createFollowup("An unknown error has occurred") + .withEphemeral(command.ephemeral) + .awaitSingleOrNull() + } + } else { + event.createFollowup("An unknown error has occurred. Please try again and/or contact DisCal support.") + .withEphemeral(true) + .awaitSingleOrNull() } - return Flux.fromIterable(cmds) - .filter { it.name == event.commandName } - .next() - .flatMap { command -> - val mono = - if (command.ephemeral) event.deferReply().withEphemeral(true) - else event.deferReply() - - mono.then(DatabaseManager.getSettings(event.interaction.guildId.get())).flatMap { - command.handle(event, it).switchIfEmpty(event.followupEphemeral(getCommonMsg("error.unknown", it))) - } - }.doOnError { - LOGGER.error("Unhandled slash command error", it) - }.onErrorResume { - event.followupEphemeral("An unknown error has occurred. Please try again and/or contact DisCal support.") - }.then() } } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/DiscalEmbed.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/DiscalEmbed.kt index 5c9991615..5e5d9a3ed 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/DiscalEmbed.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/DiscalEmbed.kt @@ -5,7 +5,7 @@ import discord4j.core.spec.EmbedCreateSpec import org.dreamexposure.discal.Application import org.dreamexposure.discal.GitProperty.DISCAL_VERSION import org.dreamexposure.discal.GitProperty.DISCAL_VERSION_D4J -import org.dreamexposure.discal.core.`object`.BotSettings +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.extensions.discord4j.getSettings import org.dreamexposure.discal.core.extensions.getHumanReadable @@ -37,12 +37,12 @@ object DiscalEmbed : EmbedMaker { .addField(getMessage("discal", "info.field.announcements", settings), "$ann", true) .addField(getMessage("discal", "info.field.links", settings), getMessage("discal", - "info.field.links.value", - settings, - "${BotSettings.BASE_URL.get()}/commands", - BotSettings.SUPPORT_INVITE.get(), - BotSettings.INVITE_URL.get(), - "https://www.patreon.com/Novafox" + "info.field.links.value", + settings, + "${Config.URL_BASE.getString()}/commands", + Config.URL_SUPPORT.getString(), + Config.URL_INVITE.getString(), + "https://www.patreon.com/Novafox" ), false ).footer(getMessage("discal", "info.footer", settings), null) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/EmbedMaker.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/EmbedMaker.kt index 82635765a..7db29da57 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/EmbedMaker.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/EmbedMaker.kt @@ -3,7 +3,7 @@ package org.dreamexposure.discal.client.message.embed import discord4j.core.`object`.entity.Guild import discord4j.core.spec.EmbedCreateSpec import discord4j.rest.util.Image -import org.dreamexposure.discal.core.`object`.BotSettings +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.utils.GlobalVal import org.dreamexposure.discal.core.utils.getCommonMsg @@ -14,9 +14,9 @@ interface EmbedMaker { val builder = EmbedCreateSpec.builder() if (settings.branded) - builder.author(guild.name, BotSettings.BASE_URL.get(), guild.getIconUrl(Image.Format.PNG).orElse(GlobalVal.iconUrl)) + builder.author(guild.name, Config.URL_BASE.getString(), guild.getIconUrl(Image.Format.PNG).orElse(GlobalVal.iconUrl)) else - builder.author(getCommonMsg("bot.name", settings), BotSettings.BASE_URL.get(), GlobalVal.iconUrl) + builder.author(getCommonMsg("bot.name", settings), Config.URL_BASE.getString(), GlobalVal.iconUrl) return builder } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/SettingsEmbed.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/SettingsEmbed.kt index d38956bd0..70c94e109 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/SettingsEmbed.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/SettingsEmbed.kt @@ -3,8 +3,8 @@ package org.dreamexposure.discal.client.message.embed import discord4j.core.`object`.entity.Guild import discord4j.core.`object`.entity.Role import discord4j.core.spec.EmbedCreateSpec -import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.extensions.discord4j.getControlRole +import org.dreamexposure.discal.core.`object`.GuildSettings import reactor.core.publisher.Mono object SettingsEmbed : EmbedMaker { @@ -16,6 +16,7 @@ object SettingsEmbed : EmbedMaker { .addField(getMessage("settings", "view.field.role", settings), roleName, false) .addField(getMessage("settings", "view.field.style", settings), settings.announcementStyle.name, true) .addField(getMessage("settings", "view.field.format", settings), settings.timeFormat.name, true) + .addField(getMessage("settings", "view.field.eventKeepDuration", settings), "${settings.eventKeepDuration}", true) .addField(getMessage("settings", "view.field.lang", settings), settings.getLocale().displayName, false) .addField(getMessage("settings", "view.field.patron", settings), "${settings.patronGuild}", true) .addField(getMessage("settings", "view.field.dev", settings), "${settings.devGuild}", true) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/network/google/GoogleExternalAuthHandler.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/network/google/GoogleExternalAuthHandler.kt deleted file mode 100644 index 80cfc3349..000000000 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/network/google/GoogleExternalAuthHandler.kt +++ /dev/null @@ -1,196 +0,0 @@ -package org.dreamexposure.discal.client.network.google - -import discord4j.core.event.domain.message.MessageCreateEvent -import discord4j.core.spec.EmbedCreateSpec -import org.dreamexposure.discal.client.message.Messages -import org.dreamexposure.discal.core.`object`.BotSettings -import org.dreamexposure.discal.core.`object`.GuildSettings -import org.dreamexposure.discal.core.`object`.calendar.CalendarData -import org.dreamexposure.discal.core.`object`.google.ExternalGoogleAuthPoll -import org.dreamexposure.discal.core.crypto.AESEncryption -import org.dreamexposure.discal.core.database.DatabaseManager -import org.dreamexposure.discal.core.enums.calendar.CalendarHost -import org.dreamexposure.discal.core.exceptions.google.GoogleAuthCancelException -import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.utils.GlobalVal -import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT -import org.dreamexposure.discal.core.utils.GlobalVal.discalColor -import org.dreamexposure.discal.core.utils.GlobalVal.iconUrl -import org.dreamexposure.discal.core.wrapper.google.CalendarWrapper -import org.dreamexposure.discal.core.wrapper.google.GoogleAuthWrapper -import org.json.JSONObject -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono -import reactor.function.TupleUtils -import java.time.Instant -import java.util.function.Predicate - -@Suppress("BlockingMethodInNonBlockingContext") -object GoogleExternalAuthHandler { - fun requestCode(event: MessageCreateEvent, settings: GuildSettings): Mono { - return GoogleAuthWrapper.requestDeviceCode().flatMap { response -> - if (response.code == GlobalVal.STATUS_SUCCESS) { - //Got code -- Send message with code, start auth poll - val successJson = JSONObject(response.body!!.string()) - response.body?.close() - response.close() - - val embed = EmbedCreateSpec.builder() - .author("DisCal", BotSettings.BASE_URL.get(), iconUrl) - .title(Messages.getMessage("Embed.AddCalendar.Code.Title", settings)) - .addField( - Messages.getMessage("Embed.AddCalendar.Code.Code", settings), - successJson.getString("user_code"), - true) - .footer(Messages.getMessage("Embed.AddCalendar.Code.Footer", settings), null) - .url(successJson.getString("verification_url")) - .color(discalColor) - .build() - - event.message.authorAsMember.flatMap { user -> - val poll = ExternalGoogleAuthPoll( - user, - settings, - interval = successJson.getInt("interval"), - expiresIn = successJson.getInt("expires_in"), - remainingSeconds = successJson.getInt("expires_in"), - deviceCode = successJson.getString("device_code") - ) { this.pollForAuth(it as ExternalGoogleAuthPoll) } - - Messages.sendDirectMessage( - Messages.getMessage("AddCalendar.Auth.Code.Request.Success", settings), - embed, - user - ).then(GoogleAuthWrapper.scheduleOAuthPoll(poll)) - } - } else { - //Bad response -- Log, send message - val body = response.body?.string() - response.body?.close() - response.close() - LOGGER.debug(DEFAULT, "Error request access token | Status code: ${response.code} | ${ - response - .message - } | $body") - - event.message.authorAsMember.flatMap { - Messages.sendDirectMessage( - Messages.getMessage("AddCalendar.Auth.Code.Request.Failure.NotOkay", settings), it) - } - }.then() - } - } - - private fun pollForAuth(poll: ExternalGoogleAuthPoll): Mono { - return GoogleAuthWrapper.requestPollResponse(poll).flatMap { response -> - when (response.code) { - GlobalVal.STATUS_FORBIDDEN -> { - //Handle access denied -- Send message, cancel poll - Messages.sendDirectMessage(Messages.getMessage("AddCalendar.Auth.Poll.Failure.Deny", poll - .settings), poll.user) - .then(Mono.error(GoogleAuthCancelException())) - } - GlobalVal.STATUS_BAD_REQUEST, GlobalVal.STATUS_PRECONDITION_REQUIRED -> { - //See if auth is pending, if so, just reschedule... - val errorJson = JSONObject(response.body!!.string()) - response.body?.close() - response.close() - when { - "authorization_pending".equals(errorJson.getString("error"), true) -> { - //Response pending - Mono.empty() - } - "expired_token".equals(errorJson.getString("error"), true) -> { - //Token is expired -- Send message, cancel poll - Messages.sendDirectMessage(Messages.getMessage("AddCal.Auth.Poll.Failure.Expired", poll - .settings), poll.user) - .then(Mono.error(GoogleAuthCancelException())) - } - else -> { - //Unknown error -- Log, send message, cancel poll - LOGGER.debug(DEFAULT, "[E.GCA] Poll failure", "Status: ${response.code} | ${response.message} | $errorJson") - - Messages.sendDirectMessage(Messages.getMessage("Notification.Error.Network", poll.settings), - poll.user) - .then(Mono.error(GoogleAuthCancelException())) - } - } - } - GlobalVal.STATUS_RATE_LIMITED -> { - //We got rate limited. Oops. Let's just poll half as often. - poll.interval = poll.interval * 2 - - //Nothing else needs to be done for this to be handled - Mono.empty() - } - GlobalVal.STATUS_SUCCESS -> { - //Access granted -- Save creds, get calendars, list for user, cancel auth - val successJson = JSONObject(response.body!!.string()) - response.body?.close() - response.close() - - //Save creds - val calData = CalendarData.emptyExternal(poll.settings.guildID, CalendarHost.GOOGLE) - val encryption = AESEncryption(calData.privateKey) - - - val accessMono = encryption.encrypt(successJson.getString("access_token")) - val refreshMono = encryption.encrypt(successJson.getString("refresh_token")) - - Mono.zip(accessMono, refreshMono).flatMap(TupleUtils.function { access, refresh -> - calData.encryptedAccessToken = access - calData.encryptedRefreshToken = refresh - calData.expiresAt = Instant.now().plusSeconds(successJson.getLong("expires_in")) - - - DatabaseManager.updateCalendar(calData) - .then(CalendarWrapper.getUsersExternalCalendars(calData)) - .flatMapMany { Flux.fromIterable(it) } - .map { cal -> - EmbedCreateSpec.builder() - .author("DisCal", BotSettings.BASE_URL.get(), iconUrl) - .title(Messages.getMessage("Embed.AddCalendar.List.Title", poll.settings)) - .addField( - Messages.getMessage("Embed.AddCalendar.List.Name", poll.settings), - cal.summary, - false) - .addField( - Messages.getMessage("Embed.AddCalendar.List.TimeZone", poll.settings), - cal.timeZone, - false) - .addField( - Messages.getMessage("Embed.AddCalendar.List.ID", poll.settings), - cal.id, - false) - .color(discalColor) - .build() - }.flatMap { Messages.sendDirectMessage(it, poll.user) } - .switchIfEmpty { - Messages.sendDirectMessage( - Messages.getMessage("AddCalendar.Auth.Poll.Failure.ListCalendars", poll.settings), - poll.user - ) - }.then(Mono.error(GoogleAuthCancelException())) - }) - } - else -> { - //Unknown error -- Log, send message, cancel poll - LOGGER.debug(DEFAULT, "Network error | poll failure" + - " | Status code: ${response.code} | ${response.message} | ${response.body?.string()}") - response.body?.close() - response.close() - - Messages.sendDirectMessage( - Messages.getMessage("Notification.Error.Network", poll.settings), poll.user) - .then(Mono.error(GoogleAuthCancelException())) - } - } - }.onErrorResume(Predicate.not(GoogleAuthCancelException::class::isInstance)) { - //Other error -- Log, send message, cancel poll - LOGGER.error(DEFAULT, "Failed to poll for authorization to google account", it) - - Messages.sendDirectMessage(Messages.getMessage("Notification.Error.Unknown", poll.settings), poll.user) - .then(Mono.error(GoogleAuthCancelException())) - } - } -} diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/service/AnnouncementService.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/service/AnnouncementService.kt index 3196c66d5..e0307ff89 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/service/AnnouncementService.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/service/AnnouncementService.kt @@ -1,16 +1,14 @@ package org.dreamexposure.discal.client.service import discord4j.common.util.Snowflake +import discord4j.core.GatewayDiscordClient import discord4j.core.`object`.entity.Guild import discord4j.core.`object`.entity.Message import discord4j.core.`object`.entity.channel.GuildMessageChannel import discord4j.core.spec.MessageCreateSpec import discord4j.rest.http.client.ClientException import io.netty.handler.codec.http.HttpResponseStatus -import org.dreamexposure.discal.client.DisCalClient import org.dreamexposure.discal.client.message.embed.AnnouncementEmbed -import org.dreamexposure.discal.core.`object`.announcement.Announcement -import org.dreamexposure.discal.core.`object`.announcement.AnnouncementCache import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.entities.Calendar import org.dreamexposure.discal.core.entities.Event @@ -19,6 +17,8 @@ import org.dreamexposure.discal.core.enums.announcement.AnnouncementType.* import org.dreamexposure.discal.core.extensions.discord4j.getCalendar import org.dreamexposure.discal.core.extensions.messageContentSafe import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.`object`.announcement.Announcement +import org.dreamexposure.discal.core.`object`.announcement.AnnouncementCache import org.dreamexposure.discal.core.utils.GlobalVal import org.springframework.boot.ApplicationArguments import org.springframework.boot.ApplicationRunner @@ -29,7 +29,9 @@ import java.time.Duration import java.util.concurrent.ConcurrentHashMap @Component -class AnnouncementService : ApplicationRunner { +class AnnouncementService( + private val discordClient: GatewayDiscordClient +) : ApplicationRunner { private val maxDifferenceMs = Duration.ofMinutes(5).toMillis() private val cached = ConcurrentHashMap() @@ -45,10 +47,7 @@ class AnnouncementService : ApplicationRunner { // Runner private fun doAnnouncementCycle(): Mono { - //TODO: This should come in through DI once other legacy is removed/rewritten - if (DisCalClient.client == null) return Mono.empty() - - return DisCalClient.client!!.guilds.flatMap { guild -> + return discordClient.guilds.flatMap { guild -> DatabaseManager.getEnabledAnnouncements(guild.id).flatMapMany { Flux.fromIterable(it) }.flatMap { announcement -> when (announcement.modifier) { AnnouncementModifier.BEFORE -> handleBeforeModifier(guild, announcement) @@ -108,12 +107,14 @@ class AnnouncementService : ApplicationRunner { .flatMap { DatabaseManager.deleteAnnouncement(announcement.id) } .then() } + UNIVERSAL -> { return getEvents(guild, announcement) .filterWhen { isInRange(announcement, it) } .flatMap { sendAnnouncement(guild, announcement, it) } .then() } + COLOR -> { return getEvents(guild, announcement) .filter { it.color == announcement.eventColor } @@ -121,6 +122,7 @@ class AnnouncementService : ApplicationRunner { .flatMap { sendAnnouncement(guild, announcement, it) } .then() } + RECUR -> { return getEvents(guild, announcement) .filter { it.eventId.contains("_") && it.eventId.split("_")[0] == announcement.eventId } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/service/HeartbeatService.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/service/HeartbeatService.kt index 80747f571..a97b88794 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/service/HeartbeatService.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/service/HeartbeatService.kt @@ -1,15 +1,15 @@ package org.dreamexposure.discal.client.service +import discord4j.core.GatewayDiscordClient import kotlinx.serialization.encodeToString import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response -import org.dreamexposure.discal.client.DisCalClient -import org.dreamexposure.discal.core.`object`.BotSettings +import org.dreamexposure.discal.core.config.Config +import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.`object`.network.discal.BotInstanceData import org.dreamexposure.discal.core.`object`.rest.HeartbeatRequest import org.dreamexposure.discal.core.`object`.rest.HeartbeatType -import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.utils.GlobalVal import org.dreamexposure.discal.core.utils.GlobalVal.HTTP_CLIENT import org.dreamexposure.discal.core.utils.GlobalVal.JSON @@ -23,20 +23,22 @@ import reactor.core.scheduler.Schedulers import java.time.Duration @Component -class HeartbeatService : ApplicationRunner { +class HeartbeatService( + private val discordClient: GatewayDiscordClient, +) : ApplicationRunner { + private final val apiUrl = Config.URL_API.getString() private fun heartbeat(): Mono { - //TODO: Use DI for this soon - return BotInstanceData.load(DisCalClient.client) + return BotInstanceData.load(discordClient) .map { data -> val requestBody = HeartbeatRequest(HeartbeatType.BOT, botInstanceData = data) val body = JSON_FORMAT.encodeToString(requestBody).toRequestBody(JSON) Request.Builder() - .url("${BotSettings.API_URL.get()}/v2/status/heartbeat") - .post(body) - .header("Authorization", BotSettings.BOT_API_TOKEN.get()) + .url("$apiUrl/v2/status/heartbeat") + .post(body) + .header("Authorization", Config.SECRET_DISCAL_API_KEY.getString()) .header("Content-Type", "application/json") .build() }.flatMap { diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/service/ShutdownHook.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/service/ShutdownHook.kt new file mode 100644 index 000000000..d898b43da --- /dev/null +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/service/ShutdownHook.kt @@ -0,0 +1,18 @@ +package org.dreamexposure.discal.client.service + +import discord4j.core.GatewayDiscordClient +import jakarta.annotation.PreDestroy +import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.utils.GlobalVal.STATUS +import org.springframework.stereotype.Component + +// Required to be a component for shutdown hook due to lifecycle management of the discord client +@Component +class ShutdownHook(private val discordClient: GatewayDiscordClient) { + @PreDestroy + fun onShutdown() { + LOGGER.info(STATUS, "Shutting down shard") + + discordClient.logout().subscribe() + } +} diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/service/StaticMessageService.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/service/StaticMessageService.kt index e3b8272a1..c591c0c4e 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/service/StaticMessageService.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/service/StaticMessageService.kt @@ -1,20 +1,22 @@ package org.dreamexposure.discal.client.service +import discord4j.core.GatewayDiscordClient import discord4j.core.`object`.entity.Guild import discord4j.core.spec.MessageEditSpec import discord4j.rest.http.client.ClientException import org.dreamexposure.discal.Application import org.dreamexposure.discal.Application.Companion.getShardIndex -import org.dreamexposure.discal.client.DisCalClient import org.dreamexposure.discal.client.message.embed.CalendarEmbed -import org.dreamexposure.discal.core.`object`.GuildSettings -import org.dreamexposure.discal.core.`object`.StaticMessage import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.entities.Calendar import org.dreamexposure.discal.core.extensions.discord4j.getCalendar import org.dreamexposure.discal.core.extensions.discord4j.getSettings import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.`object`.GuildSettings +import org.dreamexposure.discal.core.`object`.StaticMessage import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT +import org.springframework.beans.factory.BeanFactory +import org.springframework.beans.factory.getBean import org.springframework.boot.ApplicationArguments import org.springframework.boot.ApplicationRunner import org.springframework.stereotype.Component @@ -26,8 +28,9 @@ import java.time.Instant import java.time.temporal.ChronoUnit @Component -class StaticMessageService : ApplicationRunner { - //TODO: use gateway client from DI once available +class StaticMessageService(private val beanFactory: BeanFactory) : ApplicationRunner { + private val discordClient: GatewayDiscordClient + get() = beanFactory.getBean() override fun run(args: ApplicationArguments?) { Flux.interval(Duration.ofHours(1)) @@ -39,16 +42,14 @@ class StaticMessageService : ApplicationRunner { private fun doMessageUpdateLogic(): Mono { - if (DisCalClient.client == null) return Mono.empty() - - return DatabaseManager.getStaticMessagesForShard(Application.getShardCount(), getShardIndex().toInt()) + return DatabaseManager.getStaticMessagesForShard(Application.getShardCount(), getShardIndex()) .flatMapMany { Flux.fromIterable(it) } //We have no interest in updating the message so close to its last update .filter { Duration.between(Instant.now(), it.lastUpdate).abs().toMinutes() >= 30 } // Only update messages in range .filter { Duration.between(Instant.now(), it.scheduledUpdate).toMinutes() <= 60 } .flatMap { data -> - DisCalClient.client!!.getMessageById(data.channelId, data.messageId).flatMap { message -> + discordClient.getMessageById(data.channelId, data.messageId).flatMap { message -> when (data.type) { StaticMessage.Type.CALENDAR_OVERVIEW -> { val guildMono = message.guild.cache() @@ -81,7 +82,7 @@ class StaticMessageService : ApplicationRunner { } fun updateStaticMessage(calendar: Calendar, settings: GuildSettings): Mono { - return DisCalClient.client!!.getGuildById(settings.guildID) + return discordClient.getGuildById(settings.guildID) .flatMap { updateStaticMessages(it, calendar, settings) } } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/service/StatusChanger.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/service/StatusChanger.kt index 0662b1185..cd05835e9 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/service/StatusChanger.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/service/StatusChanger.kt @@ -1,10 +1,10 @@ package org.dreamexposure.discal.client.service +import discord4j.core.GatewayDiscordClient import discord4j.core.`object`.presence.ClientActivity import discord4j.core.`object`.presence.ClientPresence import org.dreamexposure.discal.Application import org.dreamexposure.discal.GitProperty -import org.dreamexposure.discal.client.DisCalClient import org.dreamexposure.discal.core.database.DatabaseManager import org.springframework.boot.ApplicationArguments import org.springframework.boot.ApplicationRunner @@ -16,7 +16,9 @@ import java.time.Duration import java.util.concurrent.atomic.AtomicInteger @Component -class StatusChanger: ApplicationRunner { +class StatusChanger( + private val discordClient: GatewayDiscordClient, +): ApplicationRunner { private val index = AtomicInteger(0) private val statuses = listOf( @@ -33,10 +35,7 @@ class StatusChanger: ApplicationRunner { ) private fun update(): Mono { - if (DisCalClient.client == null) - return Mono.empty() - - val guCountMono = DisCalClient.client!!.guilds.count() + val guCountMono = discordClient.guilds.count() val calCountMono = DatabaseManager.getCalendarCount() val annCountMono = DatabaseManager.getAnnouncementCount() @@ -58,7 +57,7 @@ class StatusChanger: ApplicationRunner { .replace("{version}", GitProperty.DISCAL_VERSION.value) - DisCalClient.client!!.updatePresence(ClientPresence.online(ClientActivity.playing(status))) + discordClient.updatePresence(ClientPresence.online(ClientActivity.playing(status))) }) } diff --git a/client/src/main/resources/application.properties b/client/src/main/resources/application.properties deleted file mode 100644 index 36f339d9b..000000000 --- a/client/src/main/resources/application.properties +++ /dev/null @@ -1,3 +0,0 @@ -spring.application.name=DisCal Client -server.port=8080 -spring.session.store-type=redis diff --git a/core/build.gradle.kts b/core/build.gradle.kts index a72f8b477..37953ec68 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -3,29 +3,24 @@ import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.TypeSpec import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import java.util.* plugins { + // Kotlin kotlin("plugin.serialization") + id("org.jetbrains.kotlin.plugin.allopen") + + // Spring kotlin("plugin.spring") + id("io.spring.dependency-management") + // Tooling id("com.gorylenko.gradle-git-properties") - id("org.jetbrains.kotlin.plugin.allopen") } val discord4jVersion: String by properties -val discord4jStoresVersion: String by properties val kotlinSrcDir: File = buildDir.resolve("core/src/main/kotlin") -dependencies { - api("com.discord4j:discord4j-core:$discord4jVersion") { - exclude(group = "io.projectreactor.netty", module = "*") - } - api("com.discord4j:stores-redis:$discord4jStoresVersion") { - exclude(group = "io.netty", module = "*") - exclude(group = "io.projectreactor.netty", module = "*") - } -} - kotlin { sourceSets { all { @@ -43,6 +38,7 @@ gitProperties { "$version.d${System.currentTimeMillis().div(1000)}" //Seconds since epoch } + // Custom git properties for compile-time constants customProperty("discal.version", versionName) customProperty("discal.version.d4j", discord4jVersion) } @@ -52,7 +48,7 @@ tasks { doLast { @Suppress("UNCHECKED_CAST") val gitProperties = ext[gitProperties.extProperty] as Map - val enumPairs = gitProperties.mapKeys { it.key.replace('.', '_').toUpperCase() } + val enumPairs = gitProperties.mapKeys { it.key.replace('.', '_').uppercase(Locale.getDefault()) } val enumBuilder = TypeSpec.enumBuilder("GitProperty") .primaryConstructor( diff --git a/core/src/main/java/org/dreamexposure/discal/core/file/ReadFile.java b/core/src/main/java/org/dreamexposure/discal/core/file/ReadFile.java deleted file mode 100644 index fc350cbb0..000000000 --- a/core/src/main/java/org/dreamexposure/discal/core/file/ReadFile.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.dreamexposure.discal.core.file; - -import kotlin.text.Charsets; -import org.dreamexposure.discal.core.utils.GlobalVal; -import org.json.JSONObject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.core.io.Resource; -import org.springframework.core.io.support.PathMatchingResourcePatternResolver; -import org.springframework.util.FileCopyUtils; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; - -import java.io.InputStreamReader; - -/** - * Created by Nova Fox on 11/10/17. - * Website: www.cloudcraftgaming.com - * For Project: DisCal-Discord-Bot - */ -@Deprecated -public class ReadFile { - private static final Logger LOGGER = LoggerFactory.getLogger(ReadFile.class); - - public static Mono readAllLangFiles() { - return Mono.fromCallable(() -> { - final JSONObject langs = new JSONObject(); - - try { - var pathMatching = new PathMatchingResourcePatternResolver(); - - for (Resource res : pathMatching.getResources("languages/*.json")) { - var reader = new InputStreamReader(res.getInputStream(), Charsets.UTF_8); - - // Open the file - final String contents = FileCopyUtils.copyToString(reader); - - //Close reader - reader.close(); - - //Parse json - final JSONObject json = new JSONObject(contents); - - if (!json.getString("Language").equalsIgnoreCase("TEMPLATE")) - langs.put(json.getString("Language"), json); - } - } catch (final Exception e) { - LOGGER.error(GlobalVal.getDEFAULT(), "Failed to load lang files", e); - } - return langs; - }).subscribeOn(Schedulers.boundedElastic()); - } -} diff --git a/core/src/main/java/org/dreamexposure/discal/core/file/package-info.java b/core/src/main/java/org/dreamexposure/discal/core/file/package-info.java deleted file mode 100644 index 04f464209..000000000 --- a/core/src/main/java/org/dreamexposure/discal/core/file/package-info.java +++ /dev/null @@ -1 +0,0 @@ -package org.dreamexposure.discal.core.file; \ No newline at end of file diff --git a/core/src/main/java/org/dreamexposure/discal/core/utils/GeneralUtils.java b/core/src/main/java/org/dreamexposure/discal/core/utils/GeneralUtils.java deleted file mode 100644 index c9814a508..000000000 --- a/core/src/main/java/org/dreamexposure/discal/core/utils/GeneralUtils.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.dreamexposure.discal.core.utils; - -import java.util.List; -import java.util.Random; - -/** - * Created by Nova Fox on 11/10/17. - * Website: www.cloudcraftgaming.com - * For Project: DisCal-Discord-Bot - */ -@Deprecated -public class GeneralUtils { - /** - * Gets the contents of the message at a set offset. - * - * @param args The args of the command. - * @param offset The offset in the string. - * @return The contents of the message at a set offset. - */ - public static String getContent(final List args, final int offset) { - final StringBuilder content = new StringBuilder(); - for (int i = offset; i < args.size(); i++) { - content.append(args.get(i)).append(" "); - } - return content.toString().trim(); - } - - /** - * This is an overkill parser made by xaanit. You can thank him for this nightmare. - *

- * regardless, it works, and therefore we will use it because generally speaking it seems some users do not understand that "<" and ">" are not in fact required and are just symbols CLEARLY DEFINED in our documentation. - * - * @param str The string to parse. - * @return The string, but without the user errors. - */ - @SuppressWarnings("MagicNumber") - public static String overkillParser(final String str) { - final Random random = new Random(str.length() * 2L >>> 4 & 3); - final StringBuilder leftFace = new StringBuilder(); - final StringBuilder rightFace = new StringBuilder(); - final String alphabet = "abcdefghijklmnopqrstuvwxyz"; - for (int i = 0; i < 30; i++) { - leftFace.append(alphabet.charAt(random.nextInt(alphabet.length()))); - rightFace.append(alphabet.charAt(random.nextInt(alphabet.length()))); - } - return str.replace("<<", leftFace.toString()).replace(">>", rightFace.toString()).replace("<", "").replace(">", "").replace(leftFace.toString(), "<").replace(rightFace.toString(), ">"); - } -} diff --git a/core/src/main/java/org/dreamexposure/discal/core/utils/PermissionChecker.java b/core/src/main/java/org/dreamexposure/discal/core/utils/PermissionChecker.java deleted file mode 100644 index 80efdf2f9..000000000 --- a/core/src/main/java/org/dreamexposure/discal/core/utils/PermissionChecker.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.dreamexposure.discal.core.utils; - -import discord4j.core.event.domain.message.MessageCreateEvent; -import discord4j.core.object.entity.Member; -import discord4j.rest.util.Permission; -import reactor.core.publisher.Mono; - -/** - * Created by Nova Fox on 1/19/17. - * Website: www.cloudcraftgaming.com - * For Project: DisCal - */ -@Deprecated -public class PermissionChecker { - public static Mono hasManageServerRole(final MessageCreateEvent event) { - return Mono.justOrEmpty(event.getMember()) - .flatMap(Member::getBasePermissions) - .map(perms -> perms.contains(Permission.MANAGE_GUILD) - || perms.contains(Permission.ADMINISTRATOR)) - .defaultIfEmpty(false); - } -} diff --git a/core/src/main/java/org/dreamexposure/discal/core/utils/package-info.java b/core/src/main/java/org/dreamexposure/discal/core/utils/package-info.java deleted file mode 100644 index 9e219f815..000000000 --- a/core/src/main/java/org/dreamexposure/discal/core/utils/package-info.java +++ /dev/null @@ -1 +0,0 @@ -package org.dreamexposure.discal.core.utils; \ No newline at end of file diff --git a/core/src/main/kotlin/org/dreamexposure/discal/Application.kt b/core/src/main/kotlin/org/dreamexposure/discal/Application.kt index a41b920aa..be394dfe8 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/Application.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/Application.kt @@ -1,20 +1,19 @@ package org.dreamexposure.discal -import org.dreamexposure.discal.core.`object`.BotSettings +import org.dreamexposure.discal.core.config.Config import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.autoconfigure.r2dbc.R2dbcAutoConfiguration import org.springframework.boot.autoconfigure.session.SessionAutoConfiguration import java.lang.management.ManagementFactory import java.time.Duration import java.util.* import kotlin.math.roundToInt -@SpringBootApplication(exclude = [SessionAutoConfiguration::class, R2dbcAutoConfiguration::class]) +@SpringBootApplication(exclude = [SessionAutoConfiguration::class]) class Application { companion object { val instanceId: UUID = UUID.randomUUID() - fun getShardIndex(): String { + fun getShardIndex(): Int { /* This fucking sucks. So k8s doesn't expose the pod ordinal for a pod in a stateful set https://github.com/kubernetes/kubernetes/pull/68719 @@ -31,17 +30,17 @@ class Application { return if (shardPodName != null) { //In k8s, parse this shit val parts = shardPodName.split("-") - parts[parts.size -1] + parts[parts.size -1].toInt() } else { //Fall back to config value - BotSettings.SHARD_INDEX.get() + Config.SHARD_INDEX.getInt() } } fun getShardCount(): Int { val shardCount = System.getenv("SHARD_COUNT") return shardCount?.toInt() ?: //Fall back to config - BotSettings.SHARD_COUNT.get().toInt() + Config.SHARD_COUNT.getInt() } fun getUptime(): Duration { diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/ApiKeyService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/ApiKeyService.kt new file mode 100644 index 000000000..633f8d3b9 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/ApiKeyService.kt @@ -0,0 +1,23 @@ +package org.dreamexposure.discal.core.business + +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.dreamexposure.discal.core.database.ApiRepository +import org.dreamexposure.discal.core.`object`.new.ApiKey +import org.springframework.stereotype.Component + + +@Component +class DefaultApiKeyService( + private val apiRepository: ApiRepository, +): ApiKeyService { + override suspend fun getKey(key: String): ApiKey? { + return apiRepository.getByApiKey(key) + .map(::ApiKey) + .awaitSingleOrNull() + } + +} + +interface ApiKeyService { + suspend fun getKey(key: String): ApiKey? +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/CalendarService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/CalendarService.kt new file mode 100644 index 000000000..85a24b008 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/CalendarService.kt @@ -0,0 +1,66 @@ +package org.dreamexposure.discal.core.business + +import discord4j.common.util.Snowflake +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.dreamexposure.discal.CalendarCache +import org.dreamexposure.discal.core.database.CalendarRepository +import org.dreamexposure.discal.core.`object`.new.Calendar +import org.springframework.stereotype.Component + +@Component +class DefaultCalendarService( + private val calendarRepository: CalendarRepository, + private val calendarCache: CalendarCache, +) : CalendarService { + override suspend fun getAllCalendars(guildId: Snowflake): List { + var calendars = calendarCache.get(guildId.asLong())?.toList() + if (calendars != null) return calendars + + calendars = calendarRepository.findAllByGuildId(guildId.asLong()) + .map(::Calendar) + .collectList() + .awaitSingle() + + calendarCache.put(guildId.asLong(), calendars.toTypedArray()) + return calendars + } + + override suspend fun getCalendar(guildId: Snowflake, number: Int): Calendar? { + return getAllCalendars(guildId).first { it.number == number } + } + + override suspend fun updateCalendar(calendar: Calendar) { + calendarRepository.updateCalendarByGuildIdAndCalendarNumber( + guildId = calendar.guildId.asLong(), + calendarNumber = calendar.number, + host = calendar.host.name, + calendarId = calendar.id, + calendarAddress = calendar.address, + external = calendar.external, + credentialId = calendar.secrets.credentialId, + privateKey = calendar.secrets.privateKey, + accessToken = calendar.secrets.encryptedAccessToken, + refreshToken = calendar.secrets.encryptedRefreshToken, + expiresAt = calendar.secrets.expiresAt.toEpochMilli(), + ).awaitSingleOrNull() + + val cached = calendarCache.get(calendar.guildId.asLong()) + if (cached != null) { + val newList = cached.toMutableList() + newList.removeIf { it.number == calendar.number } + calendarCache.put(calendar.guildId.asLong(), (newList + calendar).toTypedArray()) + } + } + +} + +interface CalendarService { + // TODO: Need a function to invalidate cache because bot and API are using Db Manager + + suspend fun getAllCalendars(guildId: Snowflake): List + + suspend fun getCalendar(guildId: Snowflake, number: Int): Calendar? + + suspend fun updateCalendar(calendar: Calendar) +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/CredentialService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/CredentialService.kt new file mode 100644 index 000000000..a345c715c --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/CredentialService.kt @@ -0,0 +1,58 @@ +package org.dreamexposure.discal.core.business + +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.dreamexposure.discal.CredentialsCache +import org.dreamexposure.discal.core.database.CredentialData +import org.dreamexposure.discal.core.database.CredentialsRepository +import org.dreamexposure.discal.core.`object`.new.Credential +import org.springframework.stereotype.Component + + +@Component +class DefaultCredentialService( + private val credentialsRepository: CredentialsRepository, + private val credentialsCache: CredentialsCache, +) : CredentialService { + + override suspend fun createCredential(credential: Credential): Credential { + val saved = credentialsRepository.save(CredentialData( + credentialNumber = credential.credentialNumber, + accessToken = credential.encryptedAccessToken, + refreshToken = credential.encryptedRefreshToken, + expiresAt = credential.expiresAt.toEpochMilli(), + )).map(::Credential).awaitSingle() + + credentialsCache.put(saved.credentialNumber, saved) + return saved + } + + override suspend fun getCredential(number: Int): Credential? { + var credential = credentialsCache.get(number) + if (credential != null) return credential + + credential = credentialsRepository.findByCredentialNumber(number) + .map(::Credential) + .awaitSingle() + + if (credential != null) credentialsCache.put(number, credential) + return credential + } + + override suspend fun updateCredential(credential: Credential) { + credentialsRepository.updateByCredentialNumber( + credentialNumber = credential.credentialNumber, + refreshToken = credential.encryptedRefreshToken, + accessToken = credential.encryptedAccessToken, + expiresAt = credential.expiresAt.toEpochMilli(), + ).awaitSingleOrNull() + + credentialsCache.put(credential.credentialNumber, credential) + } +} + +interface CredentialService { + suspend fun createCredential(credential: Credential): Credential + suspend fun getCredential(number: Int): Credential? + suspend fun updateCredential(credential: Credential) +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/SessionService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/SessionService.kt new file mode 100644 index 000000000..2bfc9e4d5 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/SessionService.kt @@ -0,0 +1,72 @@ +package org.dreamexposure.discal.core.business + +import discord4j.common.util.Snowflake +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.dreamexposure.discal.core.database.SessionData +import org.dreamexposure.discal.core.database.SessionRepository +import org.dreamexposure.discal.core.`object`.WebSession +import org.springframework.stereotype.Component +import java.time.Instant + + +@Component +class DefaultSessionService( + private val sessionRepository: SessionRepository, +) : SessionService { + // TODO: I do want to add caching, but need to figure out how I want to do that + + override suspend fun createSession(session: WebSession): WebSession { + return sessionRepository.save(SessionData( + token = session.token, + userId = session.user.asLong(), + expiresAt = session.expiresAt, + accessToken = session.accessToken, + refreshToken = session.refreshToken, + )).map(::WebSession).awaitSingle() + } + + override suspend fun getSession(token: String): WebSession? { + return sessionRepository.findByToken(token) + .map(::WebSession) + .awaitSingleOrNull() + } + + override suspend fun getSessions(userId: Snowflake): List { + return sessionRepository.findAllByUserId(userId.asLong()) + .map(::WebSession) + .collectList() + .awaitSingle() + } + + override suspend fun deleteSession(token: String) { + sessionRepository.deleteByToken(token).awaitSingleOrNull() + } + + override suspend fun deleteAllSessions(userId: Snowflake) { + sessionRepository.deleteAllByUserId(userId.asLong()).awaitSingleOrNull() + } + + override suspend fun deleteExpiredSessions() { + sessionRepository.deleteAllByExpiresAtIsLessThan(Instant.now()).awaitSingleOrNull() + } +} + +interface SessionService { + suspend fun createSession(session: WebSession): WebSession + + suspend fun getSession(token: String): WebSession? + + suspend fun getSessions(userId: Snowflake): List + suspend fun deleteSession(token: String) + + suspend fun deleteAllSessions(userId: Snowflake) + + suspend fun deleteExpiredSessions() + + suspend fun removeAndInsertSession(session: WebSession): WebSession { + deleteAllSessions(session.user) + + return createSession(session) + } +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/cache/CacheRepository.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/cache/CacheRepository.kt new file mode 100644 index 000000000..3ad6f39c0 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/cache/CacheRepository.kt @@ -0,0 +1,17 @@ +package org.dreamexposure.discal.core.cache + +import java.time.Duration + +interface CacheRepository { + + val ttl: Duration + get() = Duration.ofMinutes(60) + + suspend fun put(key: K, value: V) + + suspend fun get(key: K): V? + + suspend fun getAndRemove(key: K): V? + + suspend fun evict(key: K) +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/cache/DiscalCache.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/cache/DiscalCache.kt index 8a1981023..b93e65ebc 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/cache/DiscalCache.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/cache/DiscalCache.kt @@ -1,13 +1,14 @@ package org.dreamexposure.discal.core.cache import discord4j.common.util.Snowflake -import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.entities.Calendar +import org.dreamexposure.discal.core.`object`.GuildSettings import reactor.core.publisher.Flux import java.time.Duration import java.util.concurrent.ConcurrentHashMap //TODO: Eventually use redis instead of in-memory so these can be shared across the whole discal network and need less time for eventual consistency. +@Deprecated("Use proper caching impl") object DiscalCache { //guild id -> settings val guildSettings: MutableMap = ConcurrentHashMap() diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/cache/JdkCacheRepository.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/cache/JdkCacheRepository.kt new file mode 100644 index 000000000..944c5dc3b --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/cache/JdkCacheRepository.kt @@ -0,0 +1,46 @@ +package org.dreamexposure.discal.core.cache + +import org.dreamexposure.discal.core.extensions.isExpiredTtl +import reactor.core.publisher.Flux +import java.time.Duration +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap + +class JdkCacheRepository(override val ttl: Duration) : CacheRepository { + private val cache = ConcurrentHashMap>() + + init { + Flux.interval(Duration.ofMinutes(5)) + .map { evictOld() } + .subscribe() + } + + override suspend fun put(key: K, value: V) { + cache[key] = Pair(Instant.now(), value) + } + + override suspend fun get(key: K): V? { + val cached = cache[key] ?: return null + if (Instant.now().isAfter(cached.first)) { + evict(key) + return null + } + return cached.second + } + + override suspend fun getAndRemove(key: K): V? { + val cached = cache[key] ?: return null + evict(key) + + return if (cached.first.isExpiredTtl()) null else cached.second + + } + + override suspend fun evict(key: K) { + cache.remove(key) + } + + private fun evictOld() { + cache.forEach { (key, pair) -> if (Duration.between(pair.first, Instant.now()) >= ttl) cache.remove(key) } + } +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/cache/RedisCacheRepository.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/cache/RedisCacheRepository.kt new file mode 100644 index 000000000..1ea1764b1 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/cache/RedisCacheRepository.kt @@ -0,0 +1,44 @@ +package org.dreamexposure.discal.core.cache + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.data.redis.cache.RedisCacheManager + +class RedisCacheRepository( + private val valueType: Class, + redisCacheManager: RedisCacheManager, + private val mapper: ObjectMapper, + cacheName: String, +) : CacheRepository { + private val cache = redisCacheManager.getCache(cacheName)!! + + override suspend fun put(key: K, value: V) { + mapper.writer() + cache.put(key, mapper.writeValueAsString(value)) + + } + + override suspend fun get(key: K): V? { + val raw = cache.get(key, String::class.java) + return if (raw != null) mapper.readValue(raw, valueType) else null + } + + override suspend fun getAndRemove(key: K): V? { + val raw = cache.get(key, String::class.java) + val parsed = if (raw != null) mapper.readValue(raw, valueType) else null + + evict(key) + return parsed + } + + override suspend fun evict(key: K) { + cache.evictIfPresent(key) + } + + companion object { + inline operator fun invoke( + redisCacheManager: RedisCacheManager, + mapper: ObjectMapper, + cacheName: String, + ) = RedisCacheRepository(V::class.java, redisCacheManager, mapper, cacheName) + } +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/config/BeanConfig.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/config/BeanConfig.kt new file mode 100644 index 000000000..be5272076 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/config/BeanConfig.kt @@ -0,0 +1,31 @@ +package org.dreamexposure.discal.core.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import discord4j.common.JacksonResources +import org.dreamexposure.discal.core.serializers.DurationMapper +import org.dreamexposure.discal.core.serializers.SnowflakeMapper +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary +import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping + +@Configuration +class BeanConfig { + @Bean + @Primary + fun objectMapper(): ObjectMapper { + // Use d4j's object mapper + return JacksonResources.create().objectMapper + .registerKotlinModule() + .registerModule(JavaTimeModule()) + .registerModule(SnowflakeMapper()) + .registerModule(DurationMapper()) + } + + @Bean + fun handlerMapping(): RequestMappingHandlerMapping { + return RequestMappingHandlerMapping() + } +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt new file mode 100644 index 000000000..66e6fdad1 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt @@ -0,0 +1,77 @@ +package org.dreamexposure.discal.core.config + +import com.fasterxml.jackson.databind.ObjectMapper +import org.dreamexposure.discal.CalendarCache +import org.dreamexposure.discal.CredentialsCache +import org.dreamexposure.discal.OauthStateCache +import org.dreamexposure.discal.core.cache.JdkCacheRepository +import org.dreamexposure.discal.core.cache.RedisCacheRepository +import org.dreamexposure.discal.core.extensions.asMinutes +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary +import org.springframework.data.redis.cache.RedisCacheConfiguration +import org.springframework.data.redis.cache.RedisCacheManager +import org.springframework.data.redis.connection.RedisConnectionFactory + +@Configuration +class CacheConfig { + // Cache name constants + private val prefix = Config.CACHE_PREFIX.getString() + private val settingsCacheName = "$prefix.settingsCache" + private val credentialsCacheName = "$prefix.credentialsCache" + private val oauthStateCacheName = "$prefix.oauthStateCache" + private val calendarCacheName = "$prefix.calendarCache" + + private val settingsTtl = Config.CACHE_TTL_SETTINGS_MINUTES.getLong().asMinutes() + private val credentialsTll = Config.CACHE_TTL_CREDENTIALS_MINUTES.getLong().asMinutes() + private val oauthStateTtl = Config.CACHE_TTL_OAUTH_STATE_MINUTES.getLong().asMinutes() + private val calendarTtl = Config.CACHE_TTL_CALENDAR_MINUTES.getLong().asMinutes() + + + // Redis caching + @Bean + @ConditionalOnProperty("bot.cache.redis", havingValue = "true") + fun redisCache(connection: RedisConnectionFactory): RedisCacheManager { + return RedisCacheManager.builder(connection) + .withCacheConfiguration(settingsCacheName, + RedisCacheConfiguration.defaultCacheConfig().entryTtl(settingsTtl) + ).withCacheConfiguration(credentialsCacheName, + RedisCacheConfiguration.defaultCacheConfig().entryTtl(credentialsTll) + ).withCacheConfiguration(oauthStateCacheName, + RedisCacheConfiguration.defaultCacheConfig().entryTtl(oauthStateTtl) + ).withCacheConfiguration(calendarCacheName, + RedisCacheConfiguration.defaultCacheConfig().entryTtl(calendarTtl) + ).build() + } + + @Bean + @Primary + @ConditionalOnProperty("bot.cache.redis", havingValue = "true") + fun credentialsRedisCache(cacheManager: RedisCacheManager, objectMapper: ObjectMapper): CredentialsCache = + RedisCacheRepository(cacheManager, objectMapper, credentialsCacheName) + + @Bean + @Primary + @ConditionalOnProperty("bot.cache.redis", havingValue = "true") + fun oauthStateRedisCache(cacheManager: RedisCacheManager, objectMapper: ObjectMapper): OauthStateCache = + RedisCacheRepository(cacheManager, objectMapper, oauthStateCacheName) + + @Bean + @Primary + @ConditionalOnProperty("bot.cache.redis", havingValue = "true") + fun calendarRedisCache(cacheManager: RedisCacheManager, objectMapper: ObjectMapper): CalendarCache = + RedisCacheRepository(cacheManager, objectMapper, calendarCacheName) + + + // In-memory fallback caching + @Bean + fun credentialsFallbackCache(): CredentialsCache = JdkCacheRepository(settingsTtl) + + @Bean + fun oauthStateFallbackCache(): OauthStateCache = JdkCacheRepository(settingsTtl) + + @Bean + fun calendarFallbackCache(): CalendarCache = JdkCacheRepository(calendarTtl) +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt new file mode 100644 index 000000000..334b64070 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt @@ -0,0 +1,87 @@ +package org.dreamexposure.discal.core.config + +import java.io.FileReader +import java.util.* + +enum class Config(private val key: String, private var value: Any? = null) { + // Basic spring settings + APP_NAME("spring.application.name"), + + // Database settings, to be removed once DatabaseManager is retired + SQL_URL("spring.r2dbc.url"), + SQL_USERNAME("spring.r2dbc.username"), + SQL_PASSWORD("spring.r2dbc.password"), + + // Redis cache settings + REDIS_HOST("spring.data.redis.host"), + REDIS_PORT("spring.data.redis.port"), + REDIS_PASSWORD("spring.data.redis.password", ""), + CACHE_REDIS_IS_CLUSTER("redis.cluster", false), + CACHE_USE_REDIS("bot.cache.redis", false), + + CACHE_PREFIX("bot.cache.prefix", "discal"), + CACHE_TTL_SETTINGS_MINUTES("bot.cache.ttl-minutes.settings", 60), + CACHE_TTL_CREDENTIALS_MINUTES("bot.cache.ttl-minutes.credentials", 120), + CACHE_TTL_ACCOUNTS_MINUTES("bot.cache.ttl-minutes.accounts", 60), + CACHE_TTL_OAUTH_STATE_MINUTES("bot.cache.ttl-minutes.oauth.state", 5), + CACHE_TTL_CALENDAR_MINUTES("bots.cache.ttl-minutes.calendar", 120), + + // Security configuration + + // Bot secrets + SECRET_DISCAL_API_KEY("bot.secret.api-token"), + SECRET_BOT_TOKEN("bot.secret.token"), + SECRET_CLIENT_SECRET("bot.secret.client-secret"), + + SECRET_GOOGLE_CLIENT_ID("bot.secret.google.client.id"), + SECRET_GOOGLE_CLIENT_SECRET("bot.secret.google.client.secret"), + SECRET_GOOGLE_CREDENTIAL_KEY("bot.secret.google.credential.key"), + SECRET_GOOGLE_CREDENTIAL_COUNT("bot.secret.google.credential.count"), + + SECRET_WEBHOOK_DEFAULT("bot.secret.default-webhook"), + SECRET_WEBHOOK_STATUS("bot.secret.status-webhook"), + + SECRET_INTEGRATION_D_BOTS_GG_TOKEN("bot.secret.token.d-bots-gg"), + SECRET_INTEGRATION_TOP_GG_TOKEN("bot.secret.token.top-gg"), + + // Various URLs + URL_BASE("bot.url.base"), + URL_API("bot.url.api"), + URL_CAM("bot.url.cam"), + URL_SUPPORT("bot.url.support", "https://discord.gg/2TFqyuy"), + URL_INVITE("bot.url.invite"), + URL_DISCORD_REDIRECT("bot.url.discord.redirect"), + + // Everything else + SHARD_COUNT("bot.sharding.count"), + SHARD_INDEX("bot.sharding.index", 0), + RESTART_SERVICE_ENABLED("bot.services.restart", false), + HEARTBEAT_INTERVAL("bot.timing.heartbeat.seconds", 120), + + DISCORD_APP_ID("bot.discord-app-id"), + + LOGGING_WEBHOOKS_USE("bot.logging.webhooks.use", false), + LOGGING_WEBHOOKS_ALL_ERRORS("bot.logging.webhooks.all-error", false), + + INTEGRATIONS_UPDATE_BOT_LIST_SITES("bot.integrations.update-bot-sites", false), + + + ; + + companion object { + fun init() { + val props = Properties() + props.load(FileReader("application.properties")) + + entries.forEach { it.value = props.getProperty(it.key, it.value?.toString()) } + } + } + + fun getString() = value.toString() + + fun getInt() = getString().toInt() + + fun getLong() = getString().toLong() + + fun getBoolean() = getString().toBoolean() +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/ApiData.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/ApiData.kt new file mode 100644 index 000000000..8fa2de651 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/ApiData.kt @@ -0,0 +1,11 @@ +package org.dreamexposure.discal.core.database + +import org.springframework.data.relational.core.mapping.Table + +@Table("api") +data class ApiData( + val userId: String, + val apiKey: String, + val blocked: Boolean, + val timeIssued: Long, +) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/ApiRepository.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/ApiRepository.kt new file mode 100644 index 000000000..41852fff1 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/ApiRepository.kt @@ -0,0 +1,8 @@ +package org.dreamexposure.discal.core.database + +import org.springframework.data.r2dbc.repository.R2dbcRepository +import reactor.core.publisher.Mono + +interface ApiRepository: R2dbcRepository { + fun getByApiKey(apiKey: String): Mono +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/CalendarData.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/CalendarData.kt new file mode 100644 index 000000000..4e895c714 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/CalendarData.kt @@ -0,0 +1,18 @@ +package org.dreamexposure.discal.core.database + +import org.springframework.data.relational.core.mapping.Table + +@Table("calendars") +data class CalendarData( + val guildId: Long, + val calendarNumber: Int, + val host: String, + val calendarId: String, + val calendarAddress: String, + val external: Boolean, + val credentialId: Int, + val privateKey: String, + val accessToken: String, + val refreshToken: String, + val expiresAt: Long, +) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/CalendarRepository.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/CalendarRepository.kt new file mode 100644 index 000000000..6274fb86d --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/CalendarRepository.kt @@ -0,0 +1,40 @@ +package org.dreamexposure.discal.core.database + +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.r2dbc.repository.R2dbcRepository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +interface CalendarRepository : R2dbcRepository { + + fun findAllByGuildId(guildId: Long): Flux + + fun findByGuildIdAndCalendarNumber(guildId: Long, calendarNumber: Int): Mono + + @Query(""" + UPDATE calendars + SET host = :host, + calendar_id = :calendarId, + calendar_address = :calendarAddress, + external = :external, + credential_id = :credentialId, + private_key = :privateKey, + access_token = :accessToken, + refresh_token = :refreshToken, + expires_at = :expiresAt + WHERE guild_id = :guildId AND calendar_number = :calendarNumber + """) + fun updateCalendarByGuildIdAndCalendarNumber( + guildId: Long, + calendarNumber: Int, + host: String, + calendarId: String, + calendarAddress: String, + external: Boolean, + credentialId: Int, + privateKey: String, + accessToken: String, + refreshToken: String, + expiresAt: Long, + ): Mono +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/CredentialData.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/CredentialData.kt new file mode 100644 index 000000000..e567b911e --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/CredentialData.kt @@ -0,0 +1,11 @@ +package org.dreamexposure.discal.core.database + +import org.springframework.data.relational.core.mapping.Table + +@Table("credentials") +data class CredentialData( + val credentialNumber: Int, + val refreshToken: String, + val accessToken: String, + val expiresAt: Long, +) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/CredentialsRepository.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/CredentialsRepository.kt new file mode 100644 index 000000000..55f4bad90 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/CredentialsRepository.kt @@ -0,0 +1,24 @@ +package org.dreamexposure.discal.core.database + +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.r2dbc.repository.R2dbcRepository +import reactor.core.publisher.Mono + +interface CredentialsRepository : R2dbcRepository { + + fun findByCredentialNumber(credentialNumber: Int): Mono + + @Query(""" + UPDATE credentials + SET refresh_token = :refreshToken, + access_token = :accessToken, + expires_at = :expiresAt + WHERE credential_number = :credentialNumber + """) + fun updateByCredentialNumber( + credentialNumber: Int, + refreshToken: String, + accessToken: String, + expiresAt: Long, + ): Mono +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt index 3ba56519f..b32bd440f 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt @@ -10,6 +10,7 @@ import io.r2dbc.spi.ConnectionFactories import io.r2dbc.spi.ConnectionFactoryOptions.* import io.r2dbc.spi.Result import org.dreamexposure.discal.core.cache.DiscalCache +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.enums.announcement.AnnouncementModifier import org.dreamexposure.discal.core.enums.announcement.AnnouncementStyle import org.dreamexposure.discal.core.enums.announcement.AnnouncementType @@ -19,15 +20,12 @@ import org.dreamexposure.discal.core.enums.time.TimeFormat import org.dreamexposure.discal.core.extensions.asStringList import org.dreamexposure.discal.core.extensions.setFromString import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.`object`.BotSettings import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.`object`.StaticMessage -import org.dreamexposure.discal.core.`object`.WebSession import org.dreamexposure.discal.core.`object`.announcement.Announcement import org.dreamexposure.discal.core.`object`.calendar.CalendarData import org.dreamexposure.discal.core.`object`.event.EventData import org.dreamexposure.discal.core.`object`.event.RsvpData -import org.dreamexposure.discal.core.`object`.google.GoogleCredentialData import org.dreamexposure.discal.core.`object`.web.UserAPIAccount import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT import org.intellij.lang.annotations.Language @@ -45,11 +43,9 @@ object DatabaseManager { builder() .option(DRIVER, "pool") .option(PROTOCOL, "mysql") - .option(HOST, BotSettings.SQL_HOST.get()) - .option(PORT, BotSettings.SQL_PORT.get().toInt()) - .option(USER, BotSettings.SQL_USER.get()) - .option(PASSWORD, BotSettings.SQL_PASS.get()) - .option(DATABASE, BotSettings.SQL_DB.get()) + .from(parse(Config.SQL_URL.getString())) + .option(USER, Config.SQL_USERNAME.getString()) + .option(PASSWORD, Config.SQL_PASSWORD.getString()) .build() ) @@ -129,7 +125,7 @@ object DatabaseManager { CONTROL_ROLE = ?, ANNOUNCEMENT_STYLE = ?, TIME_FORMAT = ?, LANG = ?, PREFIX = ?, PATRON_GUILD = ?, DEV_GUILD = ?, MAX_CALENDARS = ?, DM_ANNOUNCEMENTS = ?, - BRANDED = ? WHERE GUILD_ID = ? + BRANDED = ?, event_keep_duration = ? WHERE GUILD_ID = ? """.trimMargin() Mono.from( @@ -144,7 +140,8 @@ object DatabaseManager { .bind(7, settings.maxCalendars) .bind(8, settings.getDmAnnouncementsString()) .bind(9, settings.branded) - .bind(10, settings.guildID.asLong()) + .bind(10, settings.eventKeepDuration) + .bind(11, settings.guildID.asLong()) .execute() ).flatMap { res -> Mono.from(res.rowsUpdated) } .hasElement() @@ -152,8 +149,8 @@ object DatabaseManager { } else { val insertCommand = """INSERT INTO ${Tables.GUILD_SETTINGS} (GUILD_ID, CONTROL_ROLE, ANNOUNCEMENT_STYLE, TIME_FORMAT, LANG, PREFIX, - PATRON_GUILD, DEV_GUILD, MAX_CALENDARS, DM_ANNOUNCEMENTS, BRANDED) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + PATRON_GUILD, DEV_GUILD, MAX_CALENDARS, DM_ANNOUNCEMENTS, BRANDED, event_keep_duration) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """.trimMargin() Mono.from( @@ -169,6 +166,7 @@ object DatabaseManager { .bind(8, settings.maxCalendars) .bind(9, settings.getDmAnnouncementsString()) .bind(10, settings.branded) + .bind(11, settings.eventKeepDuration) .execute() ).flatMap { res -> Mono.from(res.rowsUpdated) } .hasElement() @@ -458,53 +456,6 @@ object DatabaseManager { } } - fun updateCredentialData(credData: GoogleCredentialData): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_CREDENTIAL_DATA) - .bind(0, credData.credentialNumber) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> row } - }.hasElements().flatMap { exists -> - if (exists) { - val updateCommand = """UPDATE ${Tables.CREDS} SET - REFRESH_TOKEN = ?, ACCESS_TOKEN = ?, EXPIRES_AT = ? - WHERE CREDENTIAL_NUMBER = ?""".trimMargin() - - Mono.from( - c.createStatement(updateCommand) - .bind(0, credData.encryptedRefreshToken) - .bind(1, credData.encryptedAccessToken) - .bind(2, credData.expiresAt.toEpochMilli()) - .bind(3, credData.credentialNumber) - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - } else { - val insertCommand = """INSERT INTO ${Tables.CREDS} - |(CREDENTIAL_NUMBER, REFRESH_TOKEN, ACCESS_TOKEN, EXPIRES_AT) - |VALUES(?, ?, ?, ?)""".trimMargin() - - Mono.from( - c.createStatement(insertCommand) - .bind(0, credData.credentialNumber) - .bind(1, credData.encryptedRefreshToken) - .bind(2, credData.encryptedAccessToken) - .bind(3, credData.expiresAt.toEpochMilli()) - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - }.doOnError { - LOGGER.error(DEFAULT, "Failed to update credential data", it) - }.onErrorResume { Mono.just(false) } - } - - } - } - fun getAPIAccount(APIKey: String): Mono { return connect { c -> Mono.from( @@ -550,10 +501,11 @@ object DatabaseManager { val maxCals = row["MAX_CALENDARS", Int::class.java]!! val dmAnnouncementsString = row["DM_ANNOUNCEMENTS", String::class.java]!! val branded = row["BRANDED", Boolean::class.java]!! + val eventKeepDuration = row["event_keep_duration", Boolean::class.java]!! val settings = GuildSettings( guildId, controlRole, announcementStyle, timeFormat, - lang, prefix, patron, dev, maxCals, branded + lang, prefix, patron, dev, maxCals, branded, eventKeepDuration, ) settings.dmAnnouncements.setFromString(dmAnnouncementsString) @@ -1096,29 +1048,6 @@ object DatabaseManager { }.defaultIfEmpty(-1) } - fun getCredentialData(credNumber: Int): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_CREDENTIAL_DATA) - .bind(0, credNumber) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val refresh = row["REFRESH_TOKEN", String::class.java]!! - val access = row["ACCESS_TOKEN", String::class.java]!! - val expires = Instant.ofEpochMilli(row["EXPIRES_AT", Long::class.java]!!) - - GoogleCredentialData(credNumber, refresh, access, expires) - } - }.next().retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get enabled announcements by type", it) - }.onErrorResume { Mono.empty() } - } - } - fun deleteAnnouncement(announcementId: String): Mono { return connect { c -> Mono.from( @@ -1511,144 +1440,6 @@ object DatabaseManager { }.collectList() } } - - /* Session Data */ - fun insertSessionData(session: WebSession): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.INSERT_SESSION_DATA) - .bind(0, session.token) - .bind(1, session.user.asLong()) - .bind(2, session.expiresAt) - .bind(3, session.accessToken) - .bind(4, session.refreshToken) - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - }.doOnError { - LOGGER.error(DEFAULT, "Failed to insert session data", it) - }.onErrorResume { Mono.just(false) } - } - - fun removeAndInsertSessionData(session: WebSession): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.REMOVE_AND_INSERT_SESSION_DATA) - // Remove all existing sessions for user bindings - .bind(0, session.user.asLong()) - // Insert new session bindings - .bind(1, session.token) - .bind(2, session.user.asLong()) - .bind(3, session.expiresAt) - .bind(4, session.accessToken) - .bind(5, session.refreshToken) - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - }.doOnError { - LOGGER.error(DEFAULT, "Failed to insert session data", it) - }.onErrorResume { Mono.just(false) } - } - - fun getSessionData(token: String): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_SESSION_TOKEN) - .bind(0, token) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val userId = Snowflake.of(row["user_id", Long::class.java]!!) - val expiresAt = row["expires_at", Instant::class.java]!! - val accessToken = row["access_token", String::class.java]!! - val refreshToken = row["refresh_token", String::class.java]!! - - WebSession(token, userId, expiresAt, accessToken, refreshToken) - } - }.next().retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get session data by token", it) - }.onErrorResume { - Mono.empty() - } - } - } - - fun getAllSessionsForUser(userId: Snowflake): Mono> { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_SESSIONS_USER) - .bind(0, userId.asLong()) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val token = row["token", String::class.java]!! - val expiresAt = row["expires_at", Instant::class.java]!! - val accessToken = row["access_token", String::class.java]!! - val refreshToken = row["refresh_token", String::class.java]!! - - WebSession(token, userId, expiresAt, accessToken, refreshToken) - } - }.retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get sessions for user", it) - }.onErrorResume { - Mono.empty() - }.collectList() - } - } - - fun deleteSession(token: String): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.DELETE_SESSION) - .bind(0, token) - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - .doOnError { - LOGGER.error(DEFAULT, "session delete failure", it) - }.onErrorReturn(false) - }.defaultIfEmpty(true) // If nothing was updated and no error was emitted, it's safe to return this worked. - } - - fun deleteAllSessionsForUser(userId: Snowflake): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.DELETE_SESSIONS_FOR_USER) - .bind(0, userId.asLong()) - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - .doOnError { - LOGGER.error(DEFAULT, "delete all sessions for user failure", it) - }.onErrorReturn(false) - }.defaultIfEmpty(true) // If nothing was updated and no error was emitted, it's safe to return this worked. - } - - fun deleteExpiredSessions(): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.DELETE_EXPIRED_SESSIONS) - .bind(0, Instant.now()) // Delete everything that expired before now - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - .doOnError { - // Technically because we have handling for expired tokens we don't need to panic if this breaks - LOGGER.error(DEFAULT, "Expired session delete failure", it) - }.onErrorReturn(false) - }.defaultIfEmpty(true) // If nothing was updated and no error was emitted, it's safe to return this worked. - } } private object Queries { @@ -1737,11 +1528,6 @@ private object Queries { @Language("MySQL") val SELECT_ALL_ANNOUNCEMENT_COUNT = """SELECT COUNT(*) FROM ${Tables.ANNOUNCEMENTS}""" - @Language("MySQL") - val SELECT_CREDENTIAL_DATA = """SELECT * FROM ${Tables.CREDS} - WHERE CREDENTIAL_NUMBER = ? - """.trimMargin() - @Language("MySQL") val DELETE_ANNOUNCEMENT = """DELETE FROM ${Tables.ANNOUNCEMENTS} WHERE ANNOUNCEMENT_ID = ? @@ -1869,41 +1655,6 @@ private object Queries { WHERE MOD(guild_id >> 22, ?) = ? """.trimMargin() - /* Session Data */ - - @Language("MySQL") - val INSERT_SESSION_DATA = """INSERT INTO ${Tables.SESSIONS} - (token, user_id, expires_at, access_token, refresh_token) - VALUES(?, ?, ?, ?, ?) - """.trimMargin() - - @Language("MySQL") - val SELECT_SESSION_TOKEN = """SELECT * FROM ${Tables.SESSIONS} - WHERE token = ? - """.trimMargin() - - @Language("MySQL") - val SELECT_SESSIONS_USER = """SELECT * FROM ${Tables.SESSIONS} - WHERE user_id = ? - """.trimMargin() - - @Language("MySQL") - val DELETE_SESSION = """DELETE FROM ${Tables.SESSIONS} - WHERE token = ? - """.trimMargin() - - @Language("MySQL") - val DELETE_SESSIONS_FOR_USER = """DELETE FROM ${Tables.SESSIONS} - WHERE user_id = ? - """.trimMargin() - - val REMOVE_AND_INSERT_SESSION_DATA = "$DELETE_SESSIONS_FOR_USER;$INSERT_SESSION_DATA" - - @Language("MySQL") - val DELETE_EXPIRED_SESSIONS = """DELETE FROM ${Tables.SESSIONS} - where expires_at < ? - """.trimMargin() - /* Delete everything */ @Language("MySQL") @@ -1938,12 +1689,6 @@ private object Tables { @Language("Kotlin") const val RSVP = "rsvp" - @Language("Kotlin") - const val CREDS = "credentials" - @Language("Kotlin") const val STATIC_MESSAGES = "static_messages" - - @Language("Kotlin") - const val SESSIONS = "sessions" } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/SessionData.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/SessionData.kt new file mode 100644 index 000000000..17983b91e --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/SessionData.kt @@ -0,0 +1,13 @@ +package org.dreamexposure.discal.core.database + +import org.springframework.data.relational.core.mapping.Table +import java.time.Instant + +@Table("sessions") +data class SessionData( + val token: String, + val userId: Long, + val expiresAt: Instant, + val accessToken: String, + val refreshToken: String, +) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/SessionRepository.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/SessionRepository.kt new file mode 100644 index 000000000..eda0794bf --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/SessionRepository.kt @@ -0,0 +1,18 @@ +package org.dreamexposure.discal.core.database + +import org.springframework.data.r2dbc.repository.R2dbcRepository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.time.Instant + +interface SessionRepository : R2dbcRepository { + fun findByToken(token: String): Mono + + fun findAllByUserId(userId: Long): Flux + + fun deleteByToken(token: String): Mono + + fun deleteAllByUserId(userId: Long): Mono + + fun deleteAllByExpiresAtIsLessThan(expiresAt: Instant): Mono +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/entities/Calendar.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/entities/Calendar.kt index ef069da50..acb90595b 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/entities/Calendar.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/entities/Calendar.kt @@ -2,12 +2,12 @@ package org.dreamexposure.discal.core.entities import discord4j.common.util.Snowflake import discord4j.core.`object`.entity.Guild +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.entities.google.GoogleCalendar import org.dreamexposure.discal.core.entities.response.UpdateCalendarResponse import org.dreamexposure.discal.core.entities.spec.create.CreateEventSpec import org.dreamexposure.discal.core.entities.spec.update.UpdateCalendarSpec import org.dreamexposure.discal.core.enums.calendar.CalendarHost -import org.dreamexposure.discal.core.`object`.BotSettings import org.dreamexposure.discal.core.`object`.calendar.CalendarData import org.dreamexposure.discal.core.`object`.web.WebCalendar import org.json.JSONObject @@ -83,7 +83,7 @@ interface Calendar { * A link to view the calendar on the official discal website */ val link: String - get() = "${BotSettings.BASE_URL.get()}/embed/${guildId.asString()}/calendar/$calendarNumber" + get() = "${Config.URL_BASE.getString()}/embed/${guildId.asString()}/calendar/$calendarNumber" /** * A link to view the calendar on the host's website (e.g. google.com) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/entities/Event.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/entities/Event.kt index 2b6bab189..fb37abc68 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/entities/Event.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/entities/Event.kt @@ -181,6 +181,8 @@ interface Event { * @return Whether the event is multi-day */ fun isMultiDay(): Boolean { + if (isAllDay()) return false // All day events should not count as multi-day events + val start = this.start.atZone(timezone).truncatedTo(ChronoUnit.DAYS) val end = this.end.atZone(timezone).truncatedTo(ChronoUnit.DAYS) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/entities/google/DisCalGoogleCredential.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/entities/google/DisCalGoogleCredential.kt deleted file mode 100644 index 1e3af401b..000000000 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/entities/google/DisCalGoogleCredential.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.dreamexposure.discal.core.entities.google - -import org.dreamexposure.discal.core.`object`.BotSettings -import org.dreamexposure.discal.core.`object`.google.GoogleCredentialData -import org.dreamexposure.discal.core.crypto.AESEncryption -import reactor.core.publisher.Mono -import java.time.Instant - -data class DisCalGoogleCredential( - val credentialData: GoogleCredentialData, -) { - private val aes: AESEncryption = AESEncryption(BotSettings.CREDENTIALS_KEY.get()) - private var access: String? = null - private var refresh: String? = null - - fun getRefreshToken(): Mono { - if (refresh != null) return Mono.justOrEmpty(refresh) - return aes.decrypt(credentialData.encryptedRefreshToken) - .doOnNext { refresh = it } - } - - fun getAccessToken(): Mono { - if (access != null) return Mono.justOrEmpty(access) - return aes.decrypt(credentialData.encryptedAccessToken) - .doOnNext { access = it } - } - - fun setRefreshToken(token: String): Mono { - refresh = token - //credentialData.encryptedRefreshToken = aes.encrypt(token) - return aes.encrypt(token) - .doOnNext { credentialData.encryptedRefreshToken = it } - .then() - } - - fun setAccessToken(token: String): Mono { - access = token - //credentialData.encryptedAccessToken = aes.encrypt(token) - return aes.encrypt(token) - .doOnNext { credentialData.encryptedAccessToken = it } - .then() - } - - fun expired() = Instant.now().isAfter(credentialData.expiresAt) -} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/InstantExtension.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/InstantExtension.kt index 3025fc355..5b730f1c0 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/InstantExtension.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/InstantExtension.kt @@ -21,3 +21,5 @@ fun Instant.humanReadableDate(timezone: ZoneId, format: TimeFormat, long: Boolea fun Instant.humanReadableTime(timezone: ZoneId, format: TimeFormat): String { return DateTimeFormatter.ofPattern(format.time).withZone(timezone).format(this) } + +fun Instant.isExpiredTtl(): Boolean = Instant.now().isAfter(this) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/Long.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/Long.kt new file mode 100644 index 000000000..0e25b2b9e --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/Long.kt @@ -0,0 +1,13 @@ +package org.dreamexposure.discal.core.extensions + +import discord4j.common.util.Snowflake +import java.time.Duration +import java.time.Instant + +fun Long.asSnowflake(): Snowflake = Snowflake.of(this) + +fun Long.asInstantMilli(): Instant = Instant.ofEpochMilli(this) + +fun Long.asSeconds(): Duration = Duration.ofSeconds(this) + +fun Long.asMinutes(): Duration = Duration.ofMinutes(this) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/MutableList.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/MutableList.kt index e077262c5..769e8ef62 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/MutableList.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/MutableList.kt @@ -9,7 +9,7 @@ import java.time.temporal.TemporalAdjusters import java.util.stream.Collectors -fun MutableList.asStringList(): String { +fun List.asStringList(): String { val builder = StringBuilder() for ((i, str) in this.withIndex()) { @@ -94,12 +94,12 @@ fun MutableList.groupByDateMulti(): Map> { val multi = mutableMapOf>() sortedDates.forEach { - val range = LongRange(it.toEpochSecond(), it.plusHours(23).plusMinutes(59).toEpochSecond()) + val range = LongRange(it.toEpochSecond(), it.plusHours(23).plusMinutes(59).plusSeconds(59).toEpochSecond()) val events = mutableListOf() this.forEach { event -> - // When we check event end, we bump it back a second in order to prevent weirdness. - if (range.contains(event.start.epochSecond) || range.contains(event.end.epochSecond - 1)) { + // When we check event end, we bump it back a couple seconds in order to prevent weirdness. + if (range.contains(event.start.epochSecond) || range.contains(event.end.epochSecond - 2)) { // Event in range, add to list events.add(event) } else if (event.start.epochSecond < range.first && event.end.epochSecond > range.last) { diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/String.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/String.kt index e34c1838d..fb9fae2a0 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/String.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/String.kt @@ -5,6 +5,7 @@ import org.dreamexposure.discal.core.utils.GlobalVal import org.dreamexposure.discal.core.utils.ImageValidator import org.jsoup.Jsoup import java.time.ZoneId +import java.util.* fun String.embedTitleSafe(): String = this.substring(0, (256).coerceAtMost(this.length)) @@ -58,3 +59,16 @@ fun String.padCenter(length: Int, padChar: Char = ' '): String { } return String(output) } + +// TODO: Do db migration so this can be removed +fun String.asLocale(): Locale { + return when (this) { + "ENGLISH" -> Locale.ENGLISH + "JAPANESE" -> Locale.JAPANESE + "PORTUGUESE" -> Locale.forLanguageTag("pt") + "SPANISH" -> Locale.forLanguageTag("es") + else -> Locale.ENGLISH + } +} + +fun String.asStringListFromDatabase(): List = this.split(",").filter(String::isNotBlank) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/logger/DiscordWebhookAppender.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/logger/DiscordWebhookAppender.kt index 11db9c7f9..9fc6d39de 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/logger/DiscordWebhookAppender.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/logger/DiscordWebhookAppender.kt @@ -1,5 +1,6 @@ package org.dreamexposure.discal.core.logger +import ch.qos.logback.classic.Level import ch.qos.logback.classic.spi.ILoggingEvent import ch.qos.logback.core.AppenderBase import club.minnced.discord.webhook.WebhookClient @@ -9,31 +10,55 @@ import org.dreamexposure.discal.Application import org.dreamexposure.discal.GitProperty import org.dreamexposure.discal.core.extensions.embedDescriptionSafe import org.dreamexposure.discal.core.extensions.embedFieldSafe -import org.dreamexposure.discal.core.`object`.BotSettings import org.dreamexposure.discal.core.utils.GlobalVal import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT import org.dreamexposure.discal.core.utils.GlobalVal.STATUS +import java.io.FileReader import java.time.Instant +import java.util.* class DiscordWebhookAppender : AppenderBase() { private val defaultHook: WebhookClient? private val statusHook: WebhookClient? + private val useWebhooks: Boolean + private val allErrorsWebhook: Boolean + private val appName: String init { - if (BotSettings.USE_WEBHOOKS.get().equals("true", true)) { - defaultHook = WebhookClient.withUrl(BotSettings.DEFAULT_WEBHOOK.get()) - statusHook = WebhookClient.withUrl(BotSettings.STATUS_WEBHOOK.get()) + val appProps = Properties() + appProps.load(FileReader("application.properties")) + + useWebhooks = appProps.getProperty("bot.logging.webhooks.use", "false").toBoolean() + appName = appProps.getProperty("spring.application.name") + + if (useWebhooks) { + defaultHook = WebhookClient.withUrl(appProps.getProperty("bot.secret.default-webhook")) + statusHook = WebhookClient.withUrl(appProps.getProperty("bot.secret.status-webhook")) + allErrorsWebhook = appProps.getProperty("bot.logging.webhooks.all-errors", "false").toBoolean() } else { defaultHook = null statusHook = null + allErrorsWebhook = false } } override fun append(eventObject: ILoggingEvent) { - if (BotSettings.USE_WEBHOOKS.get().equals("true", true)) { - when { - eventObject.marker.equals(DEFAULT) -> executeDefault(eventObject) - eventObject.marker.equals(STATUS) -> executeStatus(eventObject) + if (!useWebhooks) return + + when { + eventObject.level.equals(Level.ERROR) && allErrorsWebhook -> { + executeDefault(eventObject) + return + } + + eventObject.markerList.contains(STATUS) -> { + executeStatus(eventObject) + return + } + + eventObject.markerList.contains(DEFAULT) -> { + executeDefault(eventObject) + return } } } @@ -41,13 +66,14 @@ class DiscordWebhookAppender : AppenderBase() { private fun executeStatus(event: ILoggingEvent) { val content = WebhookEmbedBuilder() .setTitle(EmbedTitle("Status", null)) - .addField(EmbedField(true, "Shard Index", Application.getShardIndex())) + .addField(EmbedField(true, "Application", appName)) + .addField(EmbedField(true, "Index", "${Application.getShardIndex()}")) .addField(EmbedField(true, "Time", "")) .addField(EmbedField(false, "Logger", event.loggerName.embedFieldSafe())) .addField(EmbedField(true, "Level", event.level.levelStr)) .addField(EmbedField(true, "Thread", event.threadName.embedFieldSafe())) .setDescription(event.formattedMessage.embedDescriptionSafe()) - .setColor(GlobalVal.discalColor.rgb) + .setColor(getEmbedColor(event)) .setFooter(EmbedFooter("v${GitProperty.DISCAL_VERSION.value}", null)) .setTimestamp(Instant.now()) @@ -62,13 +88,14 @@ class DiscordWebhookAppender : AppenderBase() { private fun executeDefault(event: ILoggingEvent) { val content = WebhookEmbedBuilder() .setTitle(EmbedTitle(event.level.levelStr, null)) - .addField(EmbedField(true, "Shard Index", Application.getShardIndex())) + .addField(EmbedField(true, "Application", appName)) + .addField(EmbedField(true, "Index", "${Application.getShardIndex()}")) .addField(EmbedField(true, "Time", "")) .addField(EmbedField(false, "Logger", event.loggerName.embedFieldSafe())) .addField(EmbedField(true, "Level", event.level.levelStr)) .addField(EmbedField(true, "Thread", event.threadName.embedFieldSafe())) .setDescription(event.formattedMessage.embedDescriptionSafe()) - .setColor(GlobalVal.discalColor.rgb) + .setColor(getEmbedColor(event)) .setFooter(EmbedFooter("v${GitProperty.DISCAL_VERSION.value}", null)) .setTimestamp(Instant.now()) @@ -81,4 +108,9 @@ class DiscordWebhookAppender : AppenderBase() { } + private fun getEmbedColor(event: ILoggingEvent): Int { + return if (event.level.equals(Level.ERROR) || event.throwableProxy != null) GlobalVal.errorColor.rgb + else if (event.level.equals(Level.WARN)) GlobalVal.warnColor.rgb + else GlobalVal.discalColor.rgb + } } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/BotSettings.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/BotSettings.kt deleted file mode 100644 index ace6fface..000000000 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/BotSettings.kt +++ /dev/null @@ -1,72 +0,0 @@ -package org.dreamexposure.discal.core.`object` - -import java.util.* - -enum class BotSettings { - SQL_HOST, - SQL_PORT, - SQL_USER, - SQL_PASS, - SQL_DB, - SQL_PREFIX, - - REDIS_HOSTNAME, - REDIS_PORT, - REDIS_PASSWORD, - REDIS_USE_PASSWORD, - - TOKEN, - SECRET, - ID, - - GOOGLE_CLIENT_ID, - GOOGLE_CLIENT_SECRET, - CREDENTIALS_COUNT, - CREDENTIALS_KEY, - - SHARD_COUNT, - SHARD_INDEX, - - D_BOTS_GG_TOKEN, - TOP_GG_TOKEN, - - TIME_OUT, - - REDIR_URI, - REDIR_URL, - - INVITE_URL, - SUPPORT_INVITE, - - BASE_URL, - API_URL, - CAM_URL, - - DEFAULT_WEBHOOK, - STATUS_WEBHOOK, - - USE_REDIS_STORES, - USE_WEBHOOKS, - UPDATE_SITES, - USE_RESTART_SERVICE, - - BOT_API_TOKEN, - - PROFILE; - - private var value: String? = null - - companion object { - fun init(properties: Properties) { - values().forEach { - try { - it.value = properties.getProperty(it.name) - } catch (npe: NullPointerException) { - throw IllegalStateException("Settings not valid! Check console for more information", npe) - } - } - } - } - - fun get() = this.value!! -} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/GuildSettings.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/GuildSettings.kt index 1dc7c2d30..7071f57df 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/GuildSettings.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/GuildSettings.kt @@ -11,29 +11,32 @@ import java.util.* @Serializable data class GuildSettings( - @Serializable(with = SnowflakeAsStringSerializer::class) + @Serializable(with = SnowflakeAsStringSerializer::class) @SerialName("guild_id") val guildID: Snowflake, - @SerialName("control_role") + @SerialName("control_role") var controlRole: String = "everyone", - @SerialName("announcement_style") + @SerialName("announcement_style") var announcementStyle: AnnouncementStyle = AnnouncementStyle.EVENT, - @SerialName("time_format") + @SerialName("time_format") var timeFormat: TimeFormat = TimeFormat.TWENTY_FOUR_HOUR, - var lang: String = "ENGLISH", - var prefix: String = "!", + var lang: String = "ENGLISH", + var prefix: String = "!", - @SerialName("patron_guild") - var patronGuild: Boolean = false, - @SerialName("dev_guild") - var devGuild: Boolean = false, - @SerialName("max_calendars") - var maxCalendars: Int = 1, + @SerialName("patron_guild") + var patronGuild: Boolean = false, + @SerialName("dev_guild") + var devGuild: Boolean = false, + @SerialName("max_calendars") + var maxCalendars: Int = 1, - var branded: Boolean = false, + var branded: Boolean = false, + + @SerialName("event_keep_duration") + var eventKeepDuration: Boolean = false, ) { @SerialName("dm_announcements") val dmAnnouncements: MutableList = mutableListOf() diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/WebSession.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/WebSession.kt index 75adcea0d..36940e2af 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/WebSession.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/WebSession.kt @@ -1,6 +1,8 @@ package org.dreamexposure.discal.core.`object` import discord4j.common.util.Snowflake +import org.dreamexposure.discal.core.database.SessionData +import org.dreamexposure.discal.core.extensions.asSnowflake import java.time.Instant import java.time.temporal.ChronoUnit @@ -14,4 +16,12 @@ data class WebSession( val accessToken: String, val refreshToken: String, -) +) { + constructor(data: SessionData) : this( + token = data.token, + user = data.userId.asSnowflake(), + expiresAt = data.expiresAt, + accessToken = data.accessToken, + refreshToken = data.refreshToken, + ) +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/event/RsvpData.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/event/RsvpData.kt index e0eb5dcff..c007c085b 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/event/RsvpData.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/event/RsvpData.kt @@ -12,8 +12,7 @@ import discord4j.discordjson.json.MessageData import discord4j.rest.http.client.ClientException import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import org.dreamexposure.discal.core.`object`.BotSettings -import org.dreamexposure.discal.core.`object`.GuildSettings +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.entities.Event import org.dreamexposure.discal.core.enums.time.DiscordTimestampFormat.LONG_DATETIME import org.dreamexposure.discal.core.extensions.asDiscordTimestamp @@ -22,6 +21,7 @@ import org.dreamexposure.discal.core.extensions.discord4j.getSettings import org.dreamexposure.discal.core.extensions.embedFieldSafe import org.dreamexposure.discal.core.extensions.toMarkdown import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.serializers.SnowflakeAsStringSerializer import org.dreamexposure.discal.core.utils.GlobalVal import org.dreamexposure.discal.core.utils.getEmbedMessage @@ -238,7 +238,7 @@ data class RsvpData( val builder = EmbedCreateSpec.builder() // Even without branding enabled, we want the user to know what guild this is because it's in DMs - .author(guild.name(), BotSettings.BASE_URL.get(), iconUrl) + .author(guild.name(), Config.URL_BASE.getString(), iconUrl) .title(getEmbedMessage("rsvp", "waitlist.title", settings)) .description(getEmbedMessage("rsvp", "waitlist.desc", settings, userId, event.name)) .addField( @@ -270,7 +270,7 @@ data class RsvpData( val builder = EmbedCreateSpec.builder() // Even without branding enabled, we want the user to know what guild this is because it's in DMs - .author(guild.name(), BotSettings.BASE_URL.get(), iconUrl) + .author(guild.name(), Config.URL_BASE.getString(), iconUrl) .title(getEmbedMessage("rsvp", "waitlist.title", settings)) .description(getEmbedMessage("rsvp", "waitlist.desc", settings, userId, event.name)) .addField( diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/google/ClientData.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/google/ClientData.kt deleted file mode 100644 index 31f3d6017..000000000 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/google/ClientData.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.dreamexposure.discal.core.`object`.google - -import kotlinx.serialization.Serializable - -@Serializable -data class ClientData(val clientId: String, val clientSecret: String) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/network/discal/BotInstanceData.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/network/discal/BotInstanceData.kt index 0980cb1fd..ca76912f4 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/network/discal/BotInstanceData.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/network/discal/BotInstanceData.kt @@ -1,5 +1,6 @@ package org.dreamexposure.discal.core.`object`.network.discal +import com.fasterxml.jackson.annotation.JsonProperty import discord4j.core.GatewayDiscordClient import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -10,12 +11,15 @@ import reactor.core.publisher.Mono @Serializable data class BotInstanceData private constructor( @SerialName("instance") + @JsonProperty("instance") val instanceData: InstanceData, @SerialName("shard_index") + @JsonProperty("shard_index") val shardIndex: Int, @SerialName("shard_count") + @JsonProperty("shard_count") val shardCount: Int, val guilds: Int = 0, @@ -29,7 +33,7 @@ data class BotInstanceData private constructor( .map { guildCount -> BotInstanceData( instanceData = InstanceData(), - shardIndex = Application.getShardIndex().toInt(), + shardIndex = Application.getShardIndex(), shardCount = Application.getShardCount(), guilds = guildCount ) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/network/discal/InstanceData.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/network/discal/InstanceData.kt index f46102f31..0c18ae611 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/network/discal/InstanceData.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/network/discal/InstanceData.kt @@ -1,5 +1,6 @@ package org.dreamexposure.discal.core.`object`.network.discal +import com.fasterxml.jackson.annotation.JsonProperty import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.dreamexposure.discal.Application @@ -15,17 +16,20 @@ import java.time.format.DateTimeFormatter @Serializable data class InstanceData( @SerialName("instance_id") + @JsonProperty("instance_id") val instanceId: String = Application.instanceId.toString(), val version: String = GitProperty.DISCAL_VERSION.value, @SerialName("d4j_version") + @JsonProperty("d4j_version") val d4jVersion: String = GitProperty.DISCAL_VERSION_D4J.value, @Serializable(with = DurationAsStringSerializer::class) val uptime: Duration = Application.getUptime(), @SerialName("last_heartbeat") + @JsonProperty("last_heartbeat") @Serializable(with = InstantAsStringSerializer::class) val lastHeartbeat: Instant = Instant.now(), @@ -41,6 +45,7 @@ data class InstanceData( } @SerialName("human_uptime") + @JsonProperty("human_uptime") @Suppress("unused") //Used in thymeleaf status page var humanReadableUptime: String = uptime.getHumanReadable() private set diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/ApiKey.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/ApiKey.kt new file mode 100644 index 000000000..031290889 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/ApiKey.kt @@ -0,0 +1,20 @@ +package org.dreamexposure.discal.core.`object`.new + +import discord4j.common.util.Snowflake +import org.dreamexposure.discal.core.database.ApiData +import org.dreamexposure.discal.core.extensions.asInstantMilli +import java.time.Instant + +data class ApiKey( + val userId: Snowflake, + val key: String, + val blocked: Boolean, + val timeIssued: Instant, +) { + constructor(data: ApiData): this( + userId = Snowflake.of(data.apiKey), + key = data.apiKey, + blocked = data.blocked, + timeIssued = data.timeIssued.asInstantMilli(), + ) +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Calendar.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Calendar.kt new file mode 100644 index 000000000..0205cba54 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Calendar.kt @@ -0,0 +1,42 @@ +package org.dreamexposure.discal.core.`object`.new + +import discord4j.common.util.Snowflake +import org.dreamexposure.discal.core.database.CalendarData +import org.dreamexposure.discal.core.enums.calendar.CalendarHost +import org.dreamexposure.discal.core.extensions.asInstantMilli +import org.dreamexposure.discal.core.extensions.asSnowflake +import java.time.Instant + +data class Calendar( + val guildId: Snowflake, + val number: Int, + val host: CalendarHost, + val id: String, + val address: String, + val external: Boolean, + val secrets: Secrets, +) { + constructor(data: CalendarData) : this( + guildId = data.guildId.asSnowflake(), + number = data.calendarNumber, + host = CalendarHost.valueOf(data.host), + id = data.calendarId, + address = data.calendarAddress, + external = data.external, + secrets = Secrets( + credentialId = data.credentialId, + privateKey = data.privateKey, + encryptedRefreshToken = data.refreshToken, + encryptedAccessToken = data.accessToken, + expiresAt = data.expiresAt.asInstantMilli(), + ) + ) + + data class Secrets( + val credentialId: Int, + val privateKey: String, + val encryptedRefreshToken: String, + var encryptedAccessToken: String, + var expiresAt: Instant, + ) +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Credential.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Credential.kt new file mode 100644 index 000000000..272aa5244 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Credential.kt @@ -0,0 +1,19 @@ +package org.dreamexposure.discal.core.`object`.new + +import org.dreamexposure.discal.core.database.CredentialData +import org.dreamexposure.discal.core.extensions.asInstantMilli +import java.time.Instant + +data class Credential( + val credentialNumber: Int, + var encryptedRefreshToken: String, + var encryptedAccessToken: String, + var expiresAt: Instant, +) { + constructor(data: CredentialData) : this( + credentialNumber = data.credentialNumber, + encryptedRefreshToken = data.refreshToken, + encryptedAccessToken = data.accessToken, + expiresAt = data.expiresAt.asInstantMilli(), + ) +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/rest/HeartbeatRequest.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/rest/HeartbeatRequest.kt index 30a1faaf8..fa94e96ab 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/rest/HeartbeatRequest.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/rest/HeartbeatRequest.kt @@ -1,5 +1,6 @@ package org.dreamexposure.discal.core.`object`.rest +import com.fasterxml.jackson.annotation.JsonProperty import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.dreamexposure.discal.core.`object`.network.discal.BotInstanceData @@ -9,9 +10,11 @@ import org.dreamexposure.discal.core.`object`.network.discal.InstanceData data class HeartbeatRequest( val type: HeartbeatType, + @JsonProperty("instance") @SerialName("instance") val instanceData: InstanceData? = null, + @JsonProperty("bot_instance") @SerialName("bot_instance") val botInstanceData: BotInstanceData? = null ) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/web/WebGuild.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/web/WebGuild.kt index 224cb43f5..5ccf9447a 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/web/WebGuild.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/web/WebGuild.kt @@ -14,12 +14,11 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.builtins.LongAsStringSerializer import org.dreamexposure.discal.Application.Companion.getShardCount -import org.dreamexposure.discal.core.`object`.BotSettings -import org.dreamexposure.discal.core.`object`.GuildSettings -import org.dreamexposure.discal.core.`object`.announcement.Announcement import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.exceptions.BotNotInGuildException import org.dreamexposure.discal.core.extensions.discord4j.getMainCalendar +import org.dreamexposure.discal.core.`object`.GuildSettings +import org.dreamexposure.discal.core.`object`.announcement.Announcement import reactor.core.publisher.Mono import reactor.core.publisher.Mono.justOrEmpty import reactor.function.TupleUtils @@ -62,8 +61,7 @@ data class WebGuild( val name = data.name() val ico = data.icon().orElse("") - val botNick = g.member(Snowflake.of(BotSettings.ID.get())) - .data + val botNick = g.selfMember .map(MemberData::nick) .map { Possible.flatOpt(it) } .flatMap { justOrEmpty(it) } @@ -105,7 +103,7 @@ data class WebGuild( val name = g.name val icon = g.getIconUrl(Image.Format.PNG).orElse(null) - val botNick = g.getMemberById(Snowflake.of(BotSettings.ID.get())) + val botNick = g.selfMember .map(Member::getNickname) .flatMap { justOrEmpty(it) } .defaultIfEmpty("DisCal") diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/serializers/DurationAsStringSerializer.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/serializers/DurationAsStringSerializer.kt index 5140e96ad..e17fee06f 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/serializers/DurationAsStringSerializer.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/serializers/DurationAsStringSerializer.kt @@ -1,6 +1,5 @@ package org.dreamexposure.discal.core.serializers -import discord4j.common.util.Snowflake import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor @@ -10,7 +9,7 @@ import kotlinx.serialization.encoding.Encoder import java.time.Duration object DurationAsStringSerializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Duration", PrimitiveKind.STRING) + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("DurationAsString", PrimitiveKind.STRING) override fun serialize(encoder: Encoder, value: Duration) = encoder.encodeString(value.toMillis().toString()) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/serializers/DurationMapper.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/serializers/DurationMapper.kt new file mode 100644 index 000000000..1d25a957a --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/serializers/DurationMapper.kt @@ -0,0 +1,30 @@ +package org.dreamexposure.discal.core.serializers + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import java.time.Duration + +class DurationMapper: SimpleModule() { + init { + addSerializer(DurationSerializer()) + addDeserializer(Duration::class.java, DurationDeserializer()) + } + + class DurationSerializer : StdSerializer(Duration::class.java) { + override fun serialize(value: Duration?, gen: JsonGenerator?, provider: SerializerProvider?) { + gen?.writeString(value?.toMillis().toString()) + } + } + + class DurationDeserializer: StdDeserializer(Duration::class.java) { + override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): Duration { + val raw = p?.valueAsString + return if (raw != null) Duration.ofMillis(raw.toLong()) else throw IllegalStateException() + } + } +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/serializers/InstantAsStringSerializer.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/serializers/InstantAsStringSerializer.kt index a9387bb1e..f2264b21c 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/serializers/InstantAsStringSerializer.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/serializers/InstantAsStringSerializer.kt @@ -9,7 +9,7 @@ import kotlinx.serialization.encoding.Encoder import java.time.Instant object InstantAsStringSerializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Duration", PrimitiveKind.STRING) + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("InstantAsString", PrimitiveKind.STRING) override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString()) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/serializers/SnowflakeMapper.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/serializers/SnowflakeMapper.kt new file mode 100644 index 000000000..97d64e0a1 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/serializers/SnowflakeMapper.kt @@ -0,0 +1,30 @@ +package org.dreamexposure.discal.core.serializers + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import discord4j.common.util.Snowflake + +class SnowflakeMapper: SimpleModule() { + init { + addSerializer(SnowflakeSerializer()) + addDeserializer(Snowflake::class.java, SnowflakeDeserializer()) + } + + class SnowflakeSerializer : StdSerializer(Snowflake::class.java) { + override fun serialize(value: Snowflake?, gen: JsonGenerator?, provider: SerializerProvider?) { + gen?.writeString(value?.asString()) + } + } + + class SnowflakeDeserializer: StdDeserializer(Snowflake::class.java) { + override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): Snowflake { + val raw = p?.valueAsString + return if (raw != null) Snowflake.of(raw) else throw IllegalStateException() + } + } +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/spring/BeanConfig.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/spring/BeanConfig.kt deleted file mode 100644 index 7143cd059..000000000 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/spring/BeanConfig.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.dreamexposure.discal.core.spring - -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping - -@Configuration -class BeanConfig { - - @Bean - fun handlerMapping(): RequestMappingHandlerMapping { - return RequestMappingHandlerMapping() - } -} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/spring/GlobalErrorHandler.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/spring/GlobalErrorHandler.kt index b41f88f57..22de038ee 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/spring/GlobalErrorHandler.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/spring/GlobalErrorHandler.kt @@ -2,12 +2,12 @@ package org.dreamexposure.discal.core.spring import kotlinx.serialization.SerializationException import kotlinx.serialization.encodeToString -import org.dreamexposure.discal.core.`object`.rest.RestError import org.dreamexposure.discal.core.exceptions.AccessRevokedException import org.dreamexposure.discal.core.exceptions.AuthenticationException import org.dreamexposure.discal.core.exceptions.NotFoundException import org.dreamexposure.discal.core.exceptions.TeaPotException import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.`object`.rest.RestError import org.dreamexposure.discal.core.utils.GlobalVal import org.springframework.beans.TypeMismatchException import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler @@ -27,7 +27,7 @@ class GlobalErrorHandler : ErrorWebExceptionHandler { //Handle exceptions we have codes for val restError: RestError = when (throwable) { is ResponseStatusException -> { - when (throwable.status) { + when (throwable.statusCode) { HttpStatus.NOT_FOUND -> { exchange.response.statusCode = HttpStatus.NOT_FOUND LOGGER.trace("404 exchange | Path: ${exchange.request.path}") diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/spring/SecurityWebFilter.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/spring/SecurityWebFilter.kt index f7d11ee3d..e550a5a4b 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/spring/SecurityWebFilter.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/spring/SecurityWebFilter.kt @@ -1,8 +1,10 @@ package org.dreamexposure.discal.core.spring -import org.dreamexposure.discal.core.`object`.BotSettings +import kotlinx.coroutines.reactor.mono import org.dreamexposure.discal.core.annotations.Authentication -import org.dreamexposure.discal.core.database.DatabaseManager +import org.dreamexposure.discal.core.business.ApiKeyService +import org.dreamexposure.discal.core.business.SessionService +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.exceptions.AuthenticationException import org.dreamexposure.discal.core.exceptions.EmptyNotAllowedException import org.dreamexposure.discal.core.exceptions.TeaPotException @@ -22,7 +24,11 @@ import java.util.concurrent.ConcurrentMap @Component @ConditionalOnProperty(name = ["discal.security.enabled"], havingValue = "true") -class SecurityWebFilter(val handlerMapping: RequestMappingHandlerMapping) : WebFilter { +class SecurityWebFilter( + private val sessionService: SessionService, + private val handlerMapping: RequestMappingHandlerMapping, + private val apiKeyService: ApiKeyService, +) : WebFilter { private val readOnlyKeys: ConcurrentMap = ConcurrentHashMap() init { @@ -78,14 +84,16 @@ class SecurityWebFilter(val handlerMapping: RequestMappingHandlerMapping) : WebF authHeader.equals("teapot", true) -> { Mono.error(TeaPotException()) } - authHeader.equals(BotSettings.BOT_API_TOKEN.get()) -> { // This is from within discal network + + authHeader.equals(Config.SECRET_DISCAL_API_KEY.getString()) -> { // This is from within discal network Mono.just(Authentication.AccessLevel.ADMIN) } + readOnlyKeys.containsKey(authHeader) -> { // Read-only key granted for embed pages Mono.just(Authentication.AccessLevel.READ) } authHeader.startsWith("Bearer ") -> { - DatabaseManager.getSessionData(authHeader.substringAfter("Bearer ")).flatMap { session -> + mono { sessionService.getSession(authHeader.substringAfter("Bearer ")) }.flatMap { session -> if (session.expiresAt.isAfter(Instant.now())) { Mono.just(Authentication.AccessLevel.WRITE) } else { @@ -95,8 +103,8 @@ class SecurityWebFilter(val handlerMapping: RequestMappingHandlerMapping) : WebF } else -> { // Check if this is an API key - DatabaseManager.getAPIAccount(authHeader).flatMap { acc -> - if (!acc.blocked) { + mono { apiKeyService.getKey(authHeader) }.flatMap { key -> + if (!key.blocked) { Mono.just(Authentication.AccessLevel.WRITE) } else { Mono.error(AuthenticationException("API key blocked")) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/utils/GlobalVal.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/utils/GlobalVal.kt index 2031ded72..b1fe0629a 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/utils/GlobalVal.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/utils/GlobalVal.kt @@ -8,26 +8,23 @@ import io.github.furstenheim.OptionsBuilder import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient -import org.jsoup.safety.Whitelist +import org.jsoup.safety.Safelist import org.slf4j.Marker import org.slf4j.MarkerFactory object GlobalVal { - @JvmStatic var iconUrl: String = "" - @JvmStatic - @Deprecated(message = "Using linebreak char should be sufficient") - val lineBreak: String = System.getProperty("line.separator") - @JvmStatic val devUserIds = listOf( Snowflake.of(130510525770629121L), //NovaFox161 Snowflake.of(135995653095555073L), //Dannie <3 ) - @JvmStatic val discalColor: Color = Color.of(56, 138, 237) + val errorColor: Color = Color.of(248, 38, 48) + val warnColor: Color = Color.of(232, 150, 0) + const val discordApiUrl = "https://discord.com/api/v9" @@ -42,9 +39,9 @@ object GlobalVal { ignoreUnknownKeys = true } - val HTML_WHITELIST: Whitelist + val HTML_WHITELIST: Safelist get() { - return Whitelist.basic() + return Safelist.basic() .preserveRelativeLinks(false) .removeAttributes("sub", "sup", "small") } @@ -61,7 +58,6 @@ object GlobalVal { ) } - @JvmStatic val DEFAULT: Marker = MarkerFactory.getMarker("DISCAL_WEBHOOK_DEFAULT") val STATUS: Marker = MarkerFactory.getMarker("DISCAL_WEBHOOK_STATUS") diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/utils/JsonUtil.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/utils/JsonUtil.kt deleted file mode 100644 index ec2522c49..000000000 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/utils/JsonUtil.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.dreamexposure.discal.core.utils - -import kotlinx.serialization.InternalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.json.Json -import kotlinx.serialization.serializer -import org.json.JSONObject -import kotlin.jvm.internal.Reflection - -@OptIn(InternalSerializationApi::class) -@Deprecated("Phase out org.json usage") -object JsonUtil { - fun encodeToJSON(clazz: Class, data: T): JSONObject { - val kClass = Reflection.createKotlinClass(clazz) - val serializer = kClass.serializer() as KSerializer - - return JSONObject(GlobalVal.JSON_FORMAT.encodeToString(serializer, data)) - } - - fun encodeToString(clazz: Class, data: T): String = encodeToJSON(clazz, data).toString() - - fun decodeFromJSON(clazz: Class, json: JSONObject): T = decodeFromString(clazz, json.toString()) - - fun decodeFromString(clazz: Class, str: String): T { - val kClass = Reflection.createKotlinClass(clazz) - val serializer = kClass.serializer() - - return GlobalVal.JSON_FORMAT.decodeFromString(serializer, str) as T - } -} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/wrapper/google/GoogleAuthWrapper.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/wrapper/google/GoogleAuthWrapper.kt index dabb8134d..0879dfd20 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/wrapper/google/GoogleAuthWrapper.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/wrapper/google/GoogleAuthWrapper.kt @@ -11,17 +11,16 @@ import okhttp3.FormBody import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Request import okhttp3.Response -import org.dreamexposure.discal.core.`object`.BotSettings -import org.dreamexposure.discal.core.`object`.calendar.CalendarData -import org.dreamexposure.discal.core.`object`.google.ClientData -import org.dreamexposure.discal.core.`object`.google.GoogleAuthPoll -import org.dreamexposure.discal.core.`object`.network.discal.CredentialData -import org.dreamexposure.discal.core.`object`.rest.RestError +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.enums.calendar.CalendarHost import org.dreamexposure.discal.core.exceptions.EmptyNotAllowedException import org.dreamexposure.discal.core.exceptions.google.GoogleAuthCancelException import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.`object`.calendar.CalendarData +import org.dreamexposure.discal.core.`object`.google.GoogleAuthPoll +import org.dreamexposure.discal.core.`object`.network.discal.CredentialData +import org.dreamexposure.discal.core.`object`.rest.RestError import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT import org.dreamexposure.discal.core.utils.GlobalVal.HTTP_CLIENT import org.dreamexposure.discal.core.utils.GlobalVal.JSON_FORMAT @@ -34,8 +33,6 @@ import com.google.api.services.calendar.Calendar as GoogleCalendarService @Suppress("BlockingMethodInNonBlockingContext") object GoogleAuthWrapper { - private val clientData = ClientData(BotSettings.GOOGLE_CLIENT_ID.get(), BotSettings.GOOGLE_CLIENT_SECRET.get()) - private val discalTokens: MutableMap = ConcurrentHashMap() private val externalTokens: MutableMap = ConcurrentHashMap() @@ -68,15 +65,15 @@ object GoogleAuthWrapper { } return Mono.fromCallable { - val url = "${BotSettings.CAM_URL.get()}/v1/token".toHttpUrlOrNull()!!.newBuilder() - .addQueryParameter("host", CalendarHost.GOOGLE.name) - .addQueryParameter("id", credentialId.toString()) - .build() + val url = "${Config.URL_CAM.getString()}/v1/token".toHttpUrlOrNull()!!.newBuilder() + .addQueryParameter("host", CalendarHost.GOOGLE.name) + .addQueryParameter("id", credentialId.toString()) + .build() val request = Request.Builder().get() - .header("Authorization", BotSettings.BOT_API_TOKEN.get()) - .url(url) - .build() + .header("Authorization", Config.SECRET_DISCAL_API_KEY.getString()) + .url(url) + .build() HTTP_CLIENT.newCall(request).execute() }.subscribeOn(Schedulers.boundedElastic()).flatMap { response -> @@ -109,16 +106,16 @@ object GoogleAuthWrapper { } return Mono.fromCallable { - val url = "${BotSettings.CAM_URL.get()}/v1/token".toHttpUrlOrNull()!!.newBuilder() - .addQueryParameter("host", calData.host.name) - .addQueryParameter("guild", calData.guildId.asString()) - .addQueryParameter("id", calData.calendarNumber.toString()) - .build() + val url = "${Config.URL_CAM.getString()}/v1/token".toHttpUrlOrNull()!!.newBuilder() + .addQueryParameter("host", calData.host.name) + .addQueryParameter("guild", calData.guildId.asString()) + .addQueryParameter("id", calData.calendarNumber.toString()) + .build() val request = Request.Builder().get() - .header("Authorization", BotSettings.BOT_API_TOKEN.get()) - .url(url) - .build() + .header("Authorization", Config.SECRET_DISCAL_API_KEY.getString()) + .url(url) + .build() HTTP_CLIENT.newCall(request).execute() }.subscribeOn(Schedulers.boundedElastic()).flatMap { response -> @@ -168,12 +165,12 @@ object GoogleAuthWrapper { .switchIfEmpty(Mono.error(EmptyNotAllowedException())) } - fun randomCredentialId() = Random.nextInt(BotSettings.CREDENTIALS_COUNT.get().toInt()) + fun randomCredentialId() = Random.nextInt(Config.SECRET_GOOGLE_CREDENTIAL_COUNT.getInt()) fun requestDeviceCode(): Mono { return Mono.fromCallable { val body = FormBody.Builder() - .addEncoded("client_id", clientData.clientId) + .addEncoded("client_id", Config.SECRET_GOOGLE_CLIENT_ID.getString()) .addEncoded("scope", CalendarScopes.CALENDAR) .build() @@ -190,8 +187,8 @@ object GoogleAuthWrapper { fun requestPollResponse(poll: GoogleAuthPoll): Mono { return Mono.fromCallable { val body = FormBody.Builder() - .addEncoded("client_id", clientData.clientId) - .addEncoded("client_secret", clientData.clientSecret) + .addEncoded("client_id", Config.SECRET_GOOGLE_CLIENT_ID.getString()) + .addEncoded("client_secret", Config.SECRET_GOOGLE_CLIENT_SECRET.getString()) .addEncoded("code", poll.deviceCode) .addEncoded("grant_type", "http://oauth.net/grant_type/device/1.0") .build() diff --git a/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt b/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt new file mode 100644 index 000000000..d893a973c --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt @@ -0,0 +1,11 @@ +package org.dreamexposure.discal + +import org.dreamexposure.discal.core.cache.CacheRepository +import org.dreamexposure.discal.core.`object`.new.Calendar +import org.dreamexposure.discal.core.`object`.new.Credential + +// Cache +//typealias GuildSettingsCache = CacheRepository +typealias CredentialsCache = CacheRepository +typealias OauthStateCache = CacheRepository +typealias CalendarCache = CacheRepository> diff --git a/core/src/main/resources/commands/dev.json b/core/src/main/resources/commands/dev/dev.json similarity index 100% rename from core/src/main/resources/commands/dev.json rename to core/src/main/resources/commands/dev/dev.json diff --git a/core/src/main/resources/commands/announcement.json b/core/src/main/resources/commands/global/announcement.json similarity index 100% rename from core/src/main/resources/commands/announcement.json rename to core/src/main/resources/commands/global/announcement.json diff --git a/core/src/main/resources/commands/calendar.json b/core/src/main/resources/commands/global/calendar.json similarity index 100% rename from core/src/main/resources/commands/calendar.json rename to core/src/main/resources/commands/global/calendar.json diff --git a/core/src/main/resources/commands/discal.json b/core/src/main/resources/commands/global/discal.json similarity index 100% rename from core/src/main/resources/commands/discal.json rename to core/src/main/resources/commands/global/discal.json diff --git a/core/src/main/resources/commands/displayCal.json b/core/src/main/resources/commands/global/displayCal.json similarity index 100% rename from core/src/main/resources/commands/displayCal.json rename to core/src/main/resources/commands/global/displayCal.json diff --git a/core/src/main/resources/commands/event.json b/core/src/main/resources/commands/global/event.json similarity index 97% rename from core/src/main/resources/commands/event.json rename to core/src/main/resources/commands/global/event.json index 618ec133f..ca74f5c58 100644 --- a/core/src/main/resources/commands/event.json +++ b/core/src/main/resources/commands/global/event.json @@ -250,6 +250,12 @@ "required": false, "min_value": 0, "max_value": 59 + }, + { + "name": "keep-duration", + "type": 5, + "description": "Adjusts the end time to keep the same duration (overrides guild-level setting)", + "required": false } ] }, @@ -440,6 +446,12 @@ "required": false, "min_value": 0, "max_value": 59 + }, + { + "name": "keep-duration", + "type": 5, + "description": "Adjusts the start time to keep the same duration (overrides guild-level setting)", + "required": false } ] }, diff --git a/core/src/main/resources/commands/events.json b/core/src/main/resources/commands/global/events.json similarity index 100% rename from core/src/main/resources/commands/events.json rename to core/src/main/resources/commands/global/events.json diff --git a/core/src/main/resources/commands/help.json b/core/src/main/resources/commands/global/help.json similarity index 100% rename from core/src/main/resources/commands/help.json rename to core/src/main/resources/commands/global/help.json diff --git a/core/src/main/resources/commands/linkCal.json b/core/src/main/resources/commands/global/linkCal.json similarity index 100% rename from core/src/main/resources/commands/linkCal.json rename to core/src/main/resources/commands/global/linkCal.json diff --git a/core/src/main/resources/commands/rsvp.json b/core/src/main/resources/commands/global/rsvp.json similarity index 100% rename from core/src/main/resources/commands/rsvp.json rename to core/src/main/resources/commands/global/rsvp.json diff --git a/core/src/main/resources/commands/settings.json b/core/src/main/resources/commands/global/settings.json similarity index 87% rename from core/src/main/resources/commands/settings.json rename to core/src/main/resources/commands/global/settings.json index e8563da46..24427472f 100644 --- a/core/src/main/resources/commands/settings.json +++ b/core/src/main/resources/commands/global/settings.json @@ -94,6 +94,19 @@ } ] }, + { + "name": "keep-event-duration", + "type": 1, + "description": "Toggles whether to keep an event's duration when changing the start or end time (default false)", + "options": [ + { + "name": "value", + "type": 5, + "description": "Whether to keep an event's duration", + "required": true + } + ] + }, { "name": "branding", "type": 1, diff --git a/core/src/main/resources/commands/time.json b/core/src/main/resources/commands/global/time.json similarity index 100% rename from core/src/main/resources/commands/time.json rename to core/src/main/resources/commands/global/time.json diff --git a/core/src/main/resources/commands/addcal.json b/core/src/main/resources/commands/premium/addcal.json similarity index 100% rename from core/src/main/resources/commands/addcal.json rename to core/src/main/resources/commands/premium/addcal.json diff --git a/core/src/main/resources/db/migration/V26__GuildSettings_KeepDuration.sql b/core/src/main/resources/db/migration/V26__GuildSettings_KeepDuration.sql new file mode 100644 index 000000000..9455d55a5 --- /dev/null +++ b/core/src/main/resources/db/migration/V26__GuildSettings_KeepDuration.sql @@ -0,0 +1,3 @@ +ALTER TABLE guild_settings + ADD COLUMN event_keep_duration BIT NOT NULL DEFAULT 0 + AFTER announcement_style; diff --git a/core/src/main/resources/i18n/command/settings/settings.properties b/core/src/main/resources/i18n/command/settings/settings.properties index 0291c4394..4b10aec39 100644 --- a/core/src/main/resources/i18n/command/settings/settings.properties +++ b/core/src/main/resources/i18n/command/settings/settings.properties @@ -12,3 +12,7 @@ style.success=Announcement Style successfully changed to `{0}`. lang.success=Successfully changed default language! format.success=Time Format successfully changed to `{0}`. brand.success=Server branding has been set to `{0}`. +eventKeepDuration.success.true=Events will keep their duration by default when adjusting the start or end of the event.\n\ + This can be overridden when changing an event's start/end. +eventKeepDuration.success.false=Events will *not* keep their duration by default when adjusting the start or end of the event.\n\ + This can be overridden when changing an event's start/end. diff --git a/core/src/main/resources/i18n/embed/settings.properties b/core/src/main/resources/i18n/embed/settings.properties index 750c7012f..05b3cea88 100644 --- a/core/src/main/resources/i18n/embed/settings.properties +++ b/core/src/main/resources/i18n/embed/settings.properties @@ -2,6 +2,7 @@ view.title=DisCal Guild Settings view.field.role=Control Role view.field.style=Announcement Style view.field.format=Time Format +view.field.eventKeepDuration=Keep Event Duration view.field.lang=Language view.field.patron=Patron Guild view.field.dev=Dev Guild diff --git a/core/src/main/resources/logback.xml b/core/src/main/resources/logback.xml index 2c88ecf0f..965db32cb 100644 --- a/core/src/main/resources/logback.xml +++ b/core/src/main/resources/logback.xml @@ -16,6 +16,8 @@ + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..bc746853f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,78 @@ +version: "3" + +services: + mysql: + image: mysql:8.0 + # NOTE: use of "mysql_native_password" is not recommended: https://dev.mysql.com/doc/refman/8.0/en/upgrading-from-previous-series.html#upgrade-caching-sha2-password + # This is only for local debugging and development. DO NOT USE IN PRODUCTION!!!!! + command: --default-authentication-plugin=mysql_native_password + environment: + - MYSQL_ROOT_PASSWORD=password + - MYSQL_USER=discal + - MYSQL_PASSWORD=password + - MYSQL_DATABASE=discal + restart: unless-stopped + + redis: + image: redis:alpine + restart: unless-stopped + + api: + image: rg.nl-ams.scw.cloud/dreamexposure/discal-server:latest + environment: + - JAVA_TOOL_OPTIONS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 + ports: + - "8080:8080" + - "5005:5005" + volumes: + - ./.docker/api:/discal + working_dir: /discal + depends_on: + - mysql + - redis + + cam: + image: rg.nl-ams.scw.cloud/dreamexposure/discal-cam:latest + environment: + - JAVA_TOOL_OPTIONS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 + ports: + - "8081:8080" + - "5006:5005" + volumes: + - ./.docker/cam:/discal + working_dir: /discal + depends_on: + - mysql + - redis + - api + + bot: + image: rg.nl-ams.scw.cloud/dreamexposure/discal-client:latest + environment: + - JAVA_TOOL_OPTIONS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 + ports: + - "5007:5005" + volumes: + - ./.docker/bot:/discal + working_dir: /discal + depends_on: + - mysql + - redis + - api + - cam + + web: + image: rg.nl-ams.scw.cloud/dreamexposure/discal-web:latest + environment: + - JAVA_TOOL_OPTIONS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 + ports: + - "3000:8080" + - "5008:5005" + volumes: + - ./.docker/web:/discal + working_dir: /discal + depends_on: + - api + +volumes: + discal_data: {} diff --git a/gradle.properties b/gradle.properties index 41cf63f9a..5fb9c7a94 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,38 +1,50 @@ -kotlinVersion=1.6.10 -kotlinxSerializationVersion=1.3.2 +# TODO: I eventually want to get spring's dependency management (BOM) plugin working one day, but its not resolving versions somehow? -gitPropsVersion=2.3.2 -jibVersion=3.1.4 +# Language +kotlinVersion=1.9.0 -discord4jVersion=3.2.2 -discord4jStoresVersion=3.2.1 +# Plugins +springDependencyManagementVersion=1.1.3 +jibVersion=3.3.2 +gitPropertiesVersion=2.4.1 -thymeleafVersion=3.0.14.RELEASE -thymeleafSecurityVersion=3.0.4.RELEASE -thymeleafLayoutVersion=3.0.0 +# Buildscript tooling +kotlinPoetVersion=1.14.2 -springVersion=2.5.6 -springSecurityVersion=5.5.1 -springSessionVersion=2.5.3 -springR2Version=5.3.13 +# Tools +kotlinxCoroutinesReactorVersion=1.7.3 +reactorKotlinExtensions=1.2.2 -googleCoreVersion=1.32.1 -googleCalendarVersion=v3-rev20211026-1.32.1 +# Discord +discord4jVersion=3.2.5 +discord4jStoresVersion=3.2.2 +discordWebhookVersion=0.8.4 -r2MysqlVersion=0.8.2.RELEASE -r2PoolVersion=0.9.0.RELEASE +# Spring +springVersion=3.1.2 -nettyVersion=4.1.56.Final -reactorBomVersion=2020.0.16 +# Thymeleaf +thymeleafVersion=3.1.2.RELEASE -slfVersion=1.7.31 -jsonVersion=20211205 -okHttpVersion=4.9.3 -discordWebhookVersion=0.7.5 -copyDownVersion=1.0 -flywayVersion=8.0.2 -mysqlConnectorVersion=8.0.25 -hikariVersion=5.0.1 -jacksonKotlinModVersion=2.13.1 +# Database +flywayVersion=9.3.0 +mikuR2dbcMySqlVersion=0.8.2.RELEASE +mySqlConnectorJava=8.0.33 -baseImage=eclipse-temurin:16-jdk-alpine +# Serialization +kotlinxSerializationJsonVersion=1.5.1 +jacksonVersion=2.15.2 +jsonVersion=20230618 + +# Google libs +googleApiClientVersion=2.0.0 +googleServicesCalendarVersion=v3-rev20220715-2.0.0 +googleOauthClientVersion=1.34.1 + +# Various Libs +okhttpVersion=4.11.0 +copyDownVersion=1.1 +jsoupVersion=1.16.1 + +# Jib properties +baseImage=eclipse-temurin:17-jdk-alpine diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c02..033e24c4c 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ffed3a254..c747538fb 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0c8..fcb6fca14 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,98 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +118,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +129,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 107acd32c..93e3f59f1 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/server/build.gradle.kts b/server/build.gradle.kts index a598d85a2..913db524c 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -1,32 +1,26 @@ plugins { + // Kotlin + id("org.jetbrains.kotlin.plugin.allopen") + + // Spring kotlin("plugin.spring") id("org.springframework.boot") - id("org.jetbrains.kotlin.plugin.allopen") + id("io.spring.dependency-management") + + // Tooling id("com.google.cloud.tools.jib") } -val springVersion: String by properties -val springSessionVersion: String by properties -val springR2Version: String by properties +// Versions --- found in gradle.properties +// Database val flywayVersion: String by properties -val mysqlConnectorVersion: String by properties -val hikariVersion: String by properties -val jacksonKotlinModVersion: String by properties dependencies { api(project(":core")) - //Database stuff + // Database implementation("org.flywaydb:flyway-core:$flywayVersion") - implementation("mysql:mysql-connector-java:$mysqlConnectorVersion") - implementation("com.zaxxer:HikariCP:$hikariVersion") - - //Spring libs - implementation("org.springframework.session:spring-session-data-redis:$springSessionVersion") - implementation("org.springframework:spring-r2dbc:$springR2Version") - - //jackson for kotlin - implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonKotlinModVersion") + implementation("org.flywaydb:flyway-mysql:$flywayVersion") } kotlin { @@ -38,14 +32,13 @@ kotlin { } jib { - var imageVersion = version.toString() - if (imageVersion.contains("SNAPSHOT")) imageVersion = "latest" + to { + image = "rg.nl-ams.scw.cloud/dreamexposure/discal-server" + tags = mutableSetOf("latest", version.toString()) + } - to.image = "rg.nl-ams.scw.cloud/dreamexposure/discal-server:$imageVersion" val baseImage: String by properties from.image = baseImage - - container.creationTime = "USE_CURRENT_TIMESTAMP" } tasks { diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/DisCalServer.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/DisCalServer.kt index f87768574..1f794f39a 100644 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/DisCalServer.kt +++ b/server/src/main/kotlin/org/dreamexposure/discal/server/DisCalServer.kt @@ -1,16 +1,14 @@ package org.dreamexposure.discal.server +import jakarta.annotation.PreDestroy import org.dreamexposure.discal.Application -import org.dreamexposure.discal.core.`object`.BotSettings +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT import org.dreamexposure.discal.core.utils.GlobalVal.STATUS import org.springframework.boot.builder.SpringApplicationBuilder import org.springframework.stereotype.Component -import java.io.FileReader -import java.util.* -import javax.annotation.PreDestroy import kotlin.system.exitProcess @@ -26,15 +24,11 @@ class DisCalServer { companion object { @JvmStatic fun main(args: Array) { - //Get settings - val p = Properties() - p.load(FileReader("settings.properties")) - BotSettings.init(p) + Config.init() //Start up spring try { SpringApplicationBuilder(Application::class.java) - .profiles(BotSettings.PROFILE.get()) .build() .run(*args) LOGGER.info(STATUS, "API is now online") diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/conf/WebFluxConfig.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/conf/WebFluxConfig.kt deleted file mode 100644 index a831a5c10..000000000 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/conf/WebFluxConfig.kt +++ /dev/null @@ -1,70 +0,0 @@ -package org.dreamexposure.discal.server.conf - -import io.r2dbc.spi.ConnectionFactories -import io.r2dbc.spi.ConnectionFactory -import io.r2dbc.spi.ConnectionFactoryOptions -import org.dreamexposure.discal.core.`object`.BotSettings -import org.dreamexposure.discal.core.utils.GlobalVal -import org.springframework.boot.web.server.ConfigurableWebServerFactory -import org.springframework.boot.web.server.ErrorPage -import org.springframework.boot.web.server.WebServerFactoryCustomizer -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.data.redis.connection.RedisPassword -import org.springframework.data.redis.connection.RedisStandaloneConfiguration -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory -import org.springframework.http.HttpStatus -import org.springframework.http.codec.ServerCodecConfigurer -import org.springframework.http.codec.json.KotlinSerializationJsonDecoder -import org.springframework.http.codec.json.KotlinSerializationJsonEncoder -import org.springframework.web.reactive.config.CorsRegistry -import org.springframework.web.reactive.config.EnableWebFlux -import org.springframework.web.reactive.config.WebFluxConfigurer - -@Configuration -@EnableWebFlux -class WebFluxConfig : WebServerFactoryCustomizer, WebFluxConfigurer { - - override fun customize(factory: ConfigurableWebServerFactory?) { - factory?.addErrorPages(ErrorPage(HttpStatus.NOT_FOUND, "/")) - } - - override fun addCorsMappings(registry: CorsRegistry) { - registry.addMapping("/**") - .allowedOrigins("*") - .allowedMethods("GET", "POST", "OPTIONS") - .allowedHeaders("Authorization", "Content-Type") - .maxAge(600) - } - - - @Bean(name = ["redisDatasource"]) - fun redisConnectionFactory(): LettuceConnectionFactory { - val rsc = RedisStandaloneConfiguration() - rsc.hostName = BotSettings.REDIS_HOSTNAME.get() - rsc.port = BotSettings.REDIS_PORT.get().toInt() - if (BotSettings.REDIS_USE_PASSWORD.get().equals("true", true)) - rsc.password = RedisPassword.of(BotSettings.REDIS_PASSWORD.get()) - - return LettuceConnectionFactory(rsc) - } - - @Bean(name = ["mysqlDatasource"]) - fun mysqlConnectionFactory(): ConnectionFactory { - return ConnectionFactories.get(ConnectionFactoryOptions.builder() - .option(ConnectionFactoryOptions.DRIVER, "pool") - .option(ConnectionFactoryOptions.PROTOCOL, "mysql") - .option(ConnectionFactoryOptions.HOST, BotSettings.SQL_HOST.get()) - .option(ConnectionFactoryOptions.PORT, BotSettings.SQL_PORT.get().toInt()) - .option(ConnectionFactoryOptions.USER, BotSettings.SQL_USER.get()) - .option(ConnectionFactoryOptions.PASSWORD, BotSettings.SQL_PASS.get()) - .option(ConnectionFactoryOptions.DATABASE, BotSettings.SQL_DB.get()) - .build()) - } - - override fun configureHttpMessageCodecs(configurer: ServerCodecConfigurer) { - val codecs = configurer.defaultCodecs() - codecs.kotlinSerializationJsonDecoder(KotlinSerializationJsonDecoder(GlobalVal.JSON_FORMAT)) - codecs.kotlinSerializationJsonEncoder(KotlinSerializationJsonEncoder(GlobalVal.JSON_FORMAT)) - } -} diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/conf/DiscordConfiguration.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/config/DiscordConfig.kt similarity index 56% rename from server/src/main/kotlin/org/dreamexposure/discal/server/conf/DiscordConfiguration.kt rename to server/src/main/kotlin/org/dreamexposure/discal/server/config/DiscordConfig.kt index 7eb7f3cba..5415d8586 100644 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/conf/DiscordConfiguration.kt +++ b/server/src/main/kotlin/org/dreamexposure/discal/server/config/DiscordConfig.kt @@ -1,16 +1,15 @@ -package org.dreamexposure.discal.server.conf +package org.dreamexposure.discal.server.config import discord4j.core.DiscordClient import discord4j.core.DiscordClientBuilder -import org.dreamexposure.discal.core.`object`.BotSettings +import org.dreamexposure.discal.core.config.Config import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @Configuration -class DiscordConfiguration { - +class DiscordConfig { @Bean fun discordClient(): DiscordClient { - return DiscordClientBuilder.create(BotSettings.TOKEN.get()).build() + return DiscordClientBuilder.create(Config.SECRET_BOT_TOKEN.getString()).build() } } diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/config/WebFluxConfig.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/config/WebFluxConfig.kt new file mode 100644 index 000000000..d035dd5ad --- /dev/null +++ b/server/src/main/kotlin/org/dreamexposure/discal/server/config/WebFluxConfig.kt @@ -0,0 +1,37 @@ +package org.dreamexposure.discal.server.config + +import org.dreamexposure.discal.core.utils.GlobalVal +import org.springframework.boot.web.server.ConfigurableWebServerFactory +import org.springframework.boot.web.server.ErrorPage +import org.springframework.boot.web.server.WebServerFactoryCustomizer +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpStatus +import org.springframework.http.codec.ServerCodecConfigurer +import org.springframework.http.codec.json.KotlinSerializationJsonDecoder +import org.springframework.http.codec.json.KotlinSerializationJsonEncoder +import org.springframework.web.reactive.config.CorsRegistry +import org.springframework.web.reactive.config.EnableWebFlux +import org.springframework.web.reactive.config.WebFluxConfigurer + +@Configuration +@EnableWebFlux +class WebFluxConfig : WebServerFactoryCustomizer, WebFluxConfigurer { + + override fun customize(factory: ConfigurableWebServerFactory?) { + factory?.addErrorPages(ErrorPage(HttpStatus.NOT_FOUND, "/")) + } + + override fun addCorsMappings(registry: CorsRegistry) { + registry.addMapping("/**") + .allowedOrigins("*") + .allowedMethods("GET", "POST", "OPTIONS") + .allowedHeaders("Authorization", "Content-Type") + .maxAge(600) + } + + override fun configureHttpMessageCodecs(configurer: ServerCodecConfigurer) { + val codecs = configurer.defaultCodecs() + codecs.kotlinSerializationJsonDecoder(KotlinSerializationJsonDecoder(GlobalVal.JSON_FORMAT)) + codecs.kotlinSerializationJsonEncoder(KotlinSerializationJsonEncoder(GlobalVal.JSON_FORMAT)) + } +} diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/InviteEndpoint.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/InviteEndpoint.kt index ff5ea833b..6cf9cdbae 100644 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/InviteEndpoint.kt +++ b/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/InviteEndpoint.kt @@ -1,7 +1,7 @@ package org.dreamexposure.discal.server.endpoints.v3 -import org.dreamexposure.discal.core.`object`.BotSettings import org.dreamexposure.discal.core.annotations.Authentication +import org.dreamexposure.discal.core.config.Config import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -13,6 +13,6 @@ class InviteEndpoint { @Authentication(access = Authentication.AccessLevel.PUBLIC) @GetMapping("invite", produces = ["text/plain"]) fun get(): Mono { - return Mono.just(BotSettings.INVITE_URL.get()) + return Mono.just(Config.URL_INVITE.getString()) } } diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/network/dbotsgg/UpdateDBotsData.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/network/dbotsgg/UpdateDBotsData.kt index 44d50de72..4f2c0a7de 100644 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/network/dbotsgg/UpdateDBotsData.kt +++ b/server/src/main/kotlin/org/dreamexposure/discal/server/network/dbotsgg/UpdateDBotsData.kt @@ -2,34 +2,39 @@ package org.dreamexposure.discal.server.network.dbotsgg import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import org.dreamexposure.discal.core.`object`.BotSettings +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.utils.GlobalVal import org.dreamexposure.discal.server.network.discal.NetworkManager import org.json.JSONObject import org.springframework.boot.ApplicationArguments import org.springframework.boot.ApplicationRunner +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.stereotype.Component import reactor.core.publisher.Flux import reactor.core.publisher.Mono import java.time.Duration @Component -class UpdateDBotsData(private val networkManager: NetworkManager) : ApplicationRunner { +@ConditionalOnProperty("bot.integrations.update-bot-sites", havingValue = "true") +class UpdateDBotsData( + private val networkManager: NetworkManager, +) : ApplicationRunner { + private val token = Config.SECRET_INTEGRATION_D_BOTS_GG_TOKEN.getString() private fun update(): Mono { return Mono.fromCallable { val json = JSONObject() - .put("guildCount", networkManager.getStatus().totalGuilds) - .put("shardCount", networkManager.getStatus().expectedShardCount) + .put("guildCount", networkManager.getStatus().totalGuilds) + .put("shardCount", networkManager.getStatus().expectedShardCount) val body = json.toString().toRequestBody(GlobalVal.JSON) val request = Request.Builder() - .url("https://discord.bots.gg/api/v1/bots/265523588918935552/stats") - .post(body) - .header("Authorization", BotSettings.D_BOTS_GG_TOKEN.get()) - .header("Content-Type", "application/json") - .build() + .url("https://discord.bots.gg/api/v1/bots/265523588918935552/stats") + .post(body) + .header("Authorization", token) + .header("Content-Type", "application/json") + .build() GlobalVal.HTTP_CLIENT.newCall(request).execute() }.doOnNext { response -> @@ -44,10 +49,8 @@ class UpdateDBotsData(private val networkManager: NetworkManager) : ApplicationR } override fun run(args: ApplicationArguments?) { - if (BotSettings.UPDATE_SITES.get().equals("true", true)) { - Flux.interval(Duration.ofHours(1)) - .flatMap { update() } - .subscribe() - } + Flux.interval(Duration.ofHours(1)) + .flatMap { update() } + .subscribe() } } diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/DatabaseMigrationRunner.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/DatabaseMigrationRunner.kt deleted file mode 100644 index 6930cc531..000000000 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/DatabaseMigrationRunner.kt +++ /dev/null @@ -1,60 +0,0 @@ -package org.dreamexposure.discal.server.network.discal - -import com.zaxxer.hikari.HikariDataSource -import org.dreamexposure.discal.core.`object`.BotSettings.* -import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT -import org.flywaydb.core.Flyway -import org.flywaydb.core.api.exception.FlywayValidateException -import org.flywaydb.core.internal.command.DbMigrate -import org.springframework.boot.ApplicationArguments -import org.springframework.boot.ApplicationRunner -import org.springframework.stereotype.Component -import kotlin.system.exitProcess - -@Component -class DatabaseMigrationRunner : ApplicationRunner { - override fun run(args: ApplicationArguments?) { - val placeholders: Map = mapOf(Pair("prefix", SQL_PREFIX.get())) - try { - val source = HikariDataSource() - source.jdbcUrl = "jdbc:mysql://${SQL_HOST.get()}:${SQL_PORT.get()}/${SQL_DB.get()}" - source.username = SQL_USER.get() - source.password = SQL_PASS.get() - - val flyway = Flyway.configure() - .dataSource(source) - .cleanDisabled(true) - .baselineOnMigrate(true) - .table("${SQL_PREFIX.get()}schema_history") - .placeholders(placeholders) - .load() - - //Validate? - flyway.validateWithResult().invalidMigrations.forEach { result -> - LOGGER.warn("Invalid Migration: ${result.filepath}") - LOGGER.debug("Version: ${result.version}") - LOGGER.debug("Description: ${result.description}") - LOGGER.debug("Details: ${result.errorDetails.errorMessage}") - } - - var sm = 0 - if (args!!.containsOption("--repair")) - flyway.repair() - else - sm = flyway.migrate().migrationsExecuted - - source.close() - LOGGER.info(DEFAULT, "Migrations successful | $sm migrations applied!") - } catch (e: FlywayValidateException) { - LOGGER.error(DEFAULT, "Migrations failure (validate)", e) - exitProcess(3) - } catch (e: DbMigrate.FlywayMigrateException) { - LOGGER.error(DEFAULT, "Migrations failure", e) - exitProcess(3) - } catch (e: Exception) { - LOGGER.error(DEFAULT, "Migrations failures", e) - exitProcess(2) - } - } -} diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/NetworkManager.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/NetworkManager.kt index 2c04cd5d4..bdd62e1e3 100644 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/NetworkManager.kt +++ b/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/NetworkManager.kt @@ -1,9 +1,9 @@ package org.dreamexposure.discal.server.network.discal import org.dreamexposure.discal.Application +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.`object`.BotSettings import org.dreamexposure.discal.core.`object`.network.discal.BotInstanceData import org.dreamexposure.discal.core.`object`.network.discal.InstanceData import org.dreamexposure.discal.core.`object`.network.discal.NetworkData @@ -71,7 +71,7 @@ class NetworkManager : ApplicationRunner { //Gotta actually see if it needs to be restarted - if (!BotSettings.USE_RESTART_SERVICE.get().equals("true", true)) { + if (!Config.RESTART_SERVICE_ENABLED.getBoolean()) { status.botStatus.removeIf { it.shardIndex == bot.shardIndex } LOGGER.warn(STATUS, "Client disconnected from network | Index: ${bot.shardIndex} | Reason: Restart service not active!") } else { @@ -84,7 +84,7 @@ class NetworkManager : ApplicationRunner { //Gotta actually see if it needs to be restarted - if (!BotSettings.USE_RESTART_SERVICE.get().equals("true", true)) { + if (!Config.RESTART_SERVICE_ENABLED.getBoolean()) { status.camStatus.removeIf { it.instanceId == cam.instanceId } LOGGER.warn(STATUS, "Cam disconnected from network | Id: ${cam.instanceId} | Reason: Restart service not active!") } else { @@ -100,7 +100,7 @@ class NetworkManager : ApplicationRunner { val bot = Flux.from { status.botStatus } .filter { Instant.now().isAfter(it.instanceData.lastHeartbeat.plus(5, ChronoUnit.MINUTES)) } .flatMap(this::doRestartBot) - val cam = Flux.from { status.botStatus } + val cam = Flux.from { status.camStatus } .filter { Instant.now().isAfter(it.lastHeartbeat.plus(5, ChronoUnit.MINUTES)) } .flatMap(this::doRestartCam) diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/network/discord/GlobalCommandRegistrar.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/network/discord/GlobalCommandRegistrar.kt index 3b55cb40c..2ea28bcb3 100644 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/network/discord/GlobalCommandRegistrar.kt +++ b/server/src/main/kotlin/org/dreamexposure/discal/server/network/discord/GlobalCommandRegistrar.kt @@ -1,11 +1,11 @@ package org.dreamexposure.discal.server.network.discord +import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue -import discord4j.common.JacksonResources import discord4j.discordjson.json.ApplicationCommandRequest import discord4j.rest.RestClient -import org.dreamexposure.discal.core.`object`.BotSettings.DEFAULT_WEBHOOK import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT import org.springframework.boot.ApplicationArguments import org.springframework.boot.ApplicationRunner import org.springframework.core.io.support.PathMatchingResourcePatternResolver @@ -13,25 +13,24 @@ import org.springframework.stereotype.Component @Component class GlobalCommandRegistrar( - private val restClient: RestClient + private val restClient: RestClient, + private val objectMapper: ObjectMapper, ) : ApplicationRunner { override fun run(args: ApplicationArguments?) { - val d4jMapper = JacksonResources.create() - val matcher = PathMatchingResourcePatternResolver() val applicationService = restClient.applicationService val applicationId = restClient.applicationId.block()!! val commands = mutableListOf() - for (res in matcher.getResources("commands/*.json")) { - val request = d4jMapper.objectMapper.readValue(res.inputStream) + for (res in matcher.getResources("commands/global/*.json")) { + val request = objectMapper.readValue(res.inputStream) commands.add(request) } applicationService.bulkOverwriteGlobalApplicationCommand(applicationId, commands) .doOnNext { LOGGER.debug("Bulk overwrite read: ${it.name()}") } - .doOnError { LOGGER.error(DEFAULT_WEBHOOK.get(), "Bulk overwrite failed", it) } + .doOnError { LOGGER.error(DEFAULT, "Bulk overwrite failed", it) } .subscribe() } } diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/utils/Authentication.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/utils/Authentication.kt index ecad4de69..e50d56d2a 100644 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/utils/Authentication.kt +++ b/server/src/main/kotlin/org/dreamexposure/discal/server/utils/Authentication.kt @@ -1,9 +1,9 @@ package org.dreamexposure.discal.server.utils -import org.dreamexposure.discal.core.`object`.BotSettings -import org.dreamexposure.discal.core.`object`.web.AuthenticationState +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.`object`.web.AuthenticationState import org.dreamexposure.discal.core.utils.GlobalVal import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT import org.springframework.web.server.ServerWebExchange @@ -40,17 +40,18 @@ object Authentication { val authKey = swe.request.headers["Authorization"]!![0] return when { - authKey == BotSettings.BOT_API_TOKEN.get() -> { //This is from within discal network + authKey == Config.SECRET_DISCAL_API_KEY.getString() -> { //This is from within discal network Mono.just(AuthenticationState(true) - .status(GlobalVal.STATUS_SUCCESS) - .reason("Success") - .keyUsed(authKey) - .fromDisCalNetwork(true) + .status(GlobalVal.STATUS_SUCCESS) + .reason("Success") + .keyUsed(authKey) + .fromDisCalNetwork(true) ) } + tempKeys.containsKey(authKey) -> { //Temp key granted for logged in user Mono.just(AuthenticationState(true) - .status(GlobalVal.STATUS_SUCCESS) + .status(GlobalVal.STATUS_SUCCESS) .reason("Success") .keyUsed(authKey) .fromDisCalNetwork(false) diff --git a/server/src/main/resources/application.properties b/server/src/main/resources/application.properties deleted file mode 100644 index 462060068..000000000 --- a/server/src/main/resources/application.properties +++ /dev/null @@ -1,4 +0,0 @@ -spring.application.name=DisCal Server -server.port=8080 -spring.session.store-type=redis -discal.security.enabled=true diff --git a/settings.gradle.kts b/settings.gradle.kts index dd385541a..6b5325260 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,22 +1,28 @@ pluginManagement { val kotlinVersion: String by settings val springVersion: String by settings - val gitPropsVersion: String by settings + val gitPropertiesVersion: String by settings val jibVersion: String by settings + val springDependencyManagementVersion: String by settings + + repositories { + gradlePluginPortal() + } plugins { + // Kotlin kotlin("jvm") version kotlinVersion kotlin("plugin.serialization") version kotlinVersion - kotlin("plugin.spring") version kotlinVersion id("org.jetbrains.kotlin.plugin.allopen") version kotlinVersion + // Spring + kotlin("plugin.spring") version kotlinVersion + id("io.spring.dependency-management") version springDependencyManagementVersion id("org.springframework.boot") version springVersion apply false - id("com.gorylenko.gradle-git-properties") version gitPropsVersion apply false - id("com.google.cloud.tools.jib") version jibVersion apply false - } - repositories { - gradlePluginPortal() + // Tooling + id("com.gorylenko.gradle-git-properties") version gitPropertiesVersion apply false + id("com.google.cloud.tools.jib") version jibVersion apply false } } diff --git a/web/build.gradle.kts b/web/build.gradle.kts index c00d3995b..e8720c665 100644 --- a/web/build.gradle.kts +++ b/web/build.gradle.kts @@ -2,35 +2,32 @@ import org.apache.tools.ant.taskdefs.condition.Os import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { + // Kotlin + id("org.jetbrains.kotlin.plugin.allopen") + + // Spring kotlin("plugin.spring") id("org.springframework.boot") - id("org.jetbrains.kotlin.plugin.allopen") + //id("io.spring.dependency-management") + + // Tooling id("com.google.cloud.tools.jib") } +// Versions -- found in gradle.properties +// Thymeleaf val thymeleafVersion: String by properties -val thymeleafSecurityVersion: String by properties -val thymeleafLayoutVersion: String by properties - -val springVersion: String by properties -val springSecurityVersion: String by properties -val springSessionVersion: String by properties -val springR2Version: String by properties dependencies { api(project(":core")) + // Spring + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") + //Thymeleaf - implementation("org.thymeleaf:thymeleaf:$thymeleafVersion") + implementation("org.thymeleaf:thymeleaf") implementation("org.thymeleaf:thymeleaf-spring5:$thymeleafVersion") - implementation("nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:$thymeleafLayoutVersion") - implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity5:$thymeleafSecurityVersion") - - //Spring - implementation("org.springframework.boot:spring-boot-starter-thymeleaf:$springVersion") - implementation("org.springframework.session:spring-session-data-redis:$springSessionVersion") - implementation("org.springframework.security:spring-security-core:$springSecurityVersion") - implementation("org.springframework.security:spring-security-web:$springSecurityVersion") + implementation("nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect") } kotlin { @@ -50,14 +47,13 @@ sourceSets { } jib { - var imageVersion = version.toString() - if (imageVersion.contains("SNAPSHOT")) imageVersion = "latest" + to { + image = "rg.nl-ams.scw.cloud/dreamexposure/discal-web" + tags = mutableSetOf("latest", version.toString()) + } - to.image = "rg.nl-ams.scw.cloud/dreamexposure/discal-web:$imageVersion" val baseImage: String by properties from.image = baseImage - - container.creationTime = "USE_CURRENT_TIMESTAMP" } // The weird OS checks are because of windows. See this SO answer: https://stackoverflow.com/a/53428540 @@ -74,6 +70,7 @@ tasks { } create("cleanWeb") { + dependsOn("npm") var gulp = "gulp" if (Os.isFamily(Os.FAMILY_WINDOWS)) { gulp = "gulp.cmd" @@ -82,6 +79,7 @@ tasks { } create("compileCSS") { + dependsOn("npm") var gulp = "gulp" if (Os.isFamily(Os.FAMILY_WINDOWS)) { gulp = "gulp.cmd" @@ -91,6 +89,7 @@ tasks { } create("compileTypescript") { + dependsOn("npm") var webpack = "webpack" if (Os.isFamily(Os.FAMILY_WINDOWS)) { webpack = "webpack.cmd" @@ -106,7 +105,7 @@ tasks { } withType { - dependsOn("compileCSS", "compileTypescript") + dependsOn("npm", "compileCSS", "compileTypescript") } bootJar { diff --git a/web/src/main/html/templates/various/status.html b/web/src/main/html/templates/various/status.html index 4321ffcf4..b1754719d 100644 --- a/web/src/main/html/templates/various/status.html +++ b/web/src/main/html/templates/various/status.html @@ -247,7 +247,7 @@

Network Statistics

+ th:text="'Total Memory Usage: ' + ${status.totalMemoryInGb} + 'GB'">


@@ -318,79 +318,77 @@

Central Auth

Website

+ +

+

+ +

+

+ +

+

+ +

+

+ +

+

+
+ +
+ + +
+ + +
+ +

Clients

- + +
+

+

+ th:text="'Version: ' + ${client.instanceData.version}">

+ th:text="'Discord4J Version: ' + ${client.instanceData.d4jVersion}">

+ th:text="'Guilds: ' + ${client.guilds}">

+ th:text="'RAM Used: ' + ${client.instanceData.memory} + 'MB'">

-

+

+ Announcements: Not Yet Tracked

- -
- +

+

-
+

+

+
-
-

Clients

- - - -
-

- -

-

- -

-

- -

-

- -

-

- -

- Announcements: Not Yet Tracked -

- -

-

- -

-

-
- -
- -
- +
+ diff --git a/web/src/main/kotlin/org/dreamexposure/discal/web/DisCalWeb.kt b/web/src/main/kotlin/org/dreamexposure/discal/web/DisCalWeb.kt index 38ed7b8b4..b290fd2d2 100644 --- a/web/src/main/kotlin/org/dreamexposure/discal/web/DisCalWeb.kt +++ b/web/src/main/kotlin/org/dreamexposure/discal/web/DisCalWeb.kt @@ -1,15 +1,13 @@ package org.dreamexposure.discal.web +import jakarta.annotation.PreDestroy import org.dreamexposure.discal.Application -import org.dreamexposure.discal.core.`object`.BotSettings +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT import org.dreamexposure.discal.core.utils.GlobalVal.STATUS import org.springframework.boot.builder.SpringApplicationBuilder import org.springframework.stereotype.Component -import java.io.FileReader -import java.util.* -import javax.annotation.PreDestroy import kotlin.system.exitProcess @Component @@ -22,15 +20,11 @@ class DisCalWeb { companion object { @JvmStatic fun main(args: Array) { - //Get settings - val p = Properties() - p.load(FileReader("settings.properties")) - BotSettings.init(p) + Config.init() //Start up spring try { SpringApplicationBuilder(Application::class.java) - .profiles(BotSettings.PROFILE.get()) .build() .run(*args) } catch (e: Exception) { diff --git a/web/src/main/kotlin/org/dreamexposure/discal/web/config/WebFluxConfig.kt b/web/src/main/kotlin/org/dreamexposure/discal/web/config/WebFluxConfig.kt index 653d0fd8c..e489bd953 100644 --- a/web/src/main/kotlin/org/dreamexposure/discal/web/config/WebFluxConfig.kt +++ b/web/src/main/kotlin/org/dreamexposure/discal/web/config/WebFluxConfig.kt @@ -1,6 +1,5 @@ package org.dreamexposure.discal.web.config -import org.dreamexposure.discal.core.`object`.BotSettings import org.dreamexposure.discal.core.utils.GlobalVal import org.springframework.boot.web.server.ConfigurableWebServerFactory import org.springframework.boot.web.server.ErrorPage @@ -10,9 +9,6 @@ import org.springframework.context.ApplicationContextAware import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.core.io.ClassPathResource -import org.springframework.data.redis.connection.RedisPassword -import org.springframework.data.redis.connection.RedisStandaloneConfiguration -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory import org.springframework.http.HttpStatus import org.springframework.http.codec.ServerCodecConfigurer import org.springframework.http.codec.json.KotlinSerializationJsonDecoder @@ -48,17 +44,6 @@ class WebFluxConfig : WebServerFactoryCustomizer, ) } - @Bean(name = ["redisDatasource"]) - fun redisConnectionFactory(): LettuceConnectionFactory { - val rsc = RedisStandaloneConfiguration() - rsc.hostName = BotSettings.REDIS_HOSTNAME.get() - rsc.port = BotSettings.REDIS_PORT.get().toInt() - if (BotSettings.REDIS_USE_PASSWORD.get().equals("true", true)) - rsc.password = RedisPassword.of(BotSettings.REDIS_PASSWORD.get()) - - return LettuceConnectionFactory(rsc) - } - @Bean fun staticResourceRouter(): RouterFunction { return RouterFunctions.resources("/**", ClassPathResource("static/")) diff --git a/web/src/main/kotlin/org/dreamexposure/discal/web/spring/SpringController.kt b/web/src/main/kotlin/org/dreamexposure/discal/web/controllers/PageController.kt similarity index 97% rename from web/src/main/kotlin/org/dreamexposure/discal/web/spring/SpringController.kt rename to web/src/main/kotlin/org/dreamexposure/discal/web/controllers/PageController.kt index 8a2dd94f1..f80070463 100644 --- a/web/src/main/kotlin/org/dreamexposure/discal/web/spring/SpringController.kt +++ b/web/src/main/kotlin/org/dreamexposure/discal/web/controllers/PageController.kt @@ -1,4 +1,4 @@ -package org.dreamexposure.discal.web.spring +package org.dreamexposure.discal.web.controllers import org.dreamexposure.discal.web.handler.DiscordAccountHandler import org.dreamexposure.discal.web.network.discal.StatusHandler @@ -9,7 +9,10 @@ import org.springframework.web.server.ServerWebExchange import reactor.core.publisher.Mono @Controller -class SpringController(private val accountHandler: DiscordAccountHandler) { +class PageController( + private val accountHandler: DiscordAccountHandler, + private val statusHandler: StatusHandler, +) { @RequestMapping("/", "/home") fun home(model: MutableMap, swe: ServerWebExchange): Mono { return accountHandler.getAccount(swe) @@ -56,7 +59,7 @@ class SpringController(private val accountHandler: DiscordAccountHandler) { .doOnNext { model.clear() } .doOnNext(model::putAll) .doOnNext { model.remove("status") } - .then(StatusHandler.getLatestStatusInfo()) + .then(statusHandler.getLatestStatusInfo()) .doOnNext { model["status"] = it } .thenReturn("various/status") } diff --git a/web/src/main/kotlin/org/dreamexposure/discal/web/handler/DiscordAccountHandler.kt b/web/src/main/kotlin/org/dreamexposure/discal/web/handler/DiscordAccountHandler.kt index 24650f008..8b3397801 100644 --- a/web/src/main/kotlin/org/dreamexposure/discal/web/handler/DiscordAccountHandler.kt +++ b/web/src/main/kotlin/org/dreamexposure/discal/web/handler/DiscordAccountHandler.kt @@ -3,7 +3,8 @@ package org.dreamexposure.discal.web.handler import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import org.dreamexposure.discal.core.`object`.BotSettings +import org.dreamexposure.discal.core.config.Config +import org.dreamexposure.discal.core.extensions.asMinutes import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.utils.GlobalVal import org.json.JSONObject @@ -14,6 +15,8 @@ import org.springframework.web.server.ServerWebExchange import reactor.core.publisher.Flux import reactor.core.publisher.Mono import reactor.core.scheduler.Schedulers +import java.net.URLEncoder +import java.nio.charset.Charset import java.time.Duration import java.time.LocalDate import java.util.* @@ -21,7 +24,11 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentMap @Component -class DiscordAccountHandler: ApplicationRunner { +class DiscordAccountHandler : ApplicationRunner { + private final val apiUrl = Config.URL_API.getString() + private final val redirectUrl = Config.URL_DISCORD_REDIRECT.getString() + private final val clientId = Config.DISCORD_APP_ID.getString() + private val discordAccounts: ConcurrentMap> = ConcurrentHashMap() fun hasAccount(swe: ServerWebExchange): Mono { @@ -108,14 +115,14 @@ class DiscordAccountHandler: ApplicationRunner { } private fun removeTimedOutAccounts() { - val limit = BotSettings.TIME_OUT.get().toLong() + val limit = Config.CACHE_TTL_ACCOUNTS_MINUTES.getLong().asMinutes() val toRemove = mutableListOf() for (id in discordAccounts.keys) { val model = discordAccounts[id]!! val lastUse = model["last_use"] as Long - if (System.currentTimeMillis() - lastUse > limit) + if (System.currentTimeMillis() - lastUse > limit.toMillis()) toRemove.add(id) //Timed out, remove account info and require sign in } @@ -127,12 +134,12 @@ class DiscordAccountHandler: ApplicationRunner { internal fun createDefaultModel(): MutableMap { val model: MutableMap = mutableMapOf() model["logged_in"] = false - model["bot_id"] = BotSettings.ID.get() + model["bot_id"] = clientId model["year"] = LocalDate.now().year - model["redirect_uri"] = BotSettings.REDIR_URI.get() - model["bot_invite"] = BotSettings.INVITE_URL.get() - model["support_invite"] = BotSettings.SUPPORT_INVITE.get() - model["api_url"] = BotSettings.API_URL.get() + model["redirect_uri"] = URLEncoder.encode(redirectUrl, Charset.defaultCharset()) + model["bot_invite"] = Config.URL_INVITE.getString() + model["support_invite"] = Config.URL_SUPPORT.getString() + model["api_url"] = apiUrl return model } @@ -142,8 +149,8 @@ class DiscordAccountHandler: ApplicationRunner { val client = OkHttpClient() val keyGrantRequestBody = "".toRequestBody(GlobalVal.JSON) val keyGrantRequest = Request.Builder() - .url("${BotSettings.API_URL.get()}/v2/account/key/readonly/get") - .header("Authorization", BotSettings.BOT_API_TOKEN.get()) + .url("$apiUrl/v2/account/key/readonly/get") + .header("Authorization", Config.SECRET_DISCAL_API_KEY.getString()) .post(keyGrantRequestBody) .build() diff --git a/web/src/main/kotlin/org/dreamexposure/discal/web/network/discal/HeartbeatService.kt b/web/src/main/kotlin/org/dreamexposure/discal/web/network/discal/HeartbeatService.kt index 7541bec7f..01c303786 100644 --- a/web/src/main/kotlin/org/dreamexposure/discal/web/network/discal/HeartbeatService.kt +++ b/web/src/main/kotlin/org/dreamexposure/discal/web/network/discal/HeartbeatService.kt @@ -4,11 +4,11 @@ import kotlinx.serialization.encodeToString import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response -import org.dreamexposure.discal.core.`object`.BotSettings +import org.dreamexposure.discal.core.config.Config +import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.`object`.network.discal.InstanceData import org.dreamexposure.discal.core.`object`.rest.HeartbeatRequest import org.dreamexposure.discal.core.`object`.rest.HeartbeatType -import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.utils.GlobalVal import org.springframework.boot.ApplicationArguments import org.springframework.boot.ApplicationRunner @@ -20,6 +20,7 @@ import java.time.Duration @Component class HeartbeatService : ApplicationRunner { + private final val apiUrl = Config.URL_API.getString() private fun heartbeat(): Mono { return Mono.just(InstanceData()) @@ -29,9 +30,9 @@ class HeartbeatService : ApplicationRunner { val body = GlobalVal.JSON_FORMAT.encodeToString(requestBody).toRequestBody(GlobalVal.JSON) Request.Builder() - .url("${BotSettings.API_URL.get()}/v2/status/heartbeat") + .url("$apiUrl/v2/status/heartbeat") .post(body) - .header("Authorization", BotSettings.BOT_API_TOKEN.get()) + .header("Authorization", Config.SECRET_DISCAL_API_KEY.getString()) .header("Content-Type", "application/json") .build() }.flatMap { diff --git a/web/src/main/kotlin/org/dreamexposure/discal/web/network/discal/StatusHandler.kt b/web/src/main/kotlin/org/dreamexposure/discal/web/network/discal/StatusHandler.kt index b426a5eec..34fc6acfb 100644 --- a/web/src/main/kotlin/org/dreamexposure/discal/web/network/discal/StatusHandler.kt +++ b/web/src/main/kotlin/org/dreamexposure/discal/web/network/discal/StatusHandler.kt @@ -4,23 +4,27 @@ import com.google.api.client.http.HttpStatusCodes import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import org.dreamexposure.discal.core.`object`.BotSettings -import org.dreamexposure.discal.core.`object`.network.discal.NetworkData +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.`object`.network.discal.NetworkData import org.dreamexposure.discal.core.utils.GlobalVal.JSON import org.dreamexposure.discal.core.utils.GlobalVal.JSON_FORMAT +import org.springframework.stereotype.Component import reactor.core.publisher.Mono -object StatusHandler { +@Component +class StatusHandler { + private final val apiUrl = Config.URL_API.getString() + fun getLatestStatusInfo(): Mono { return Mono.fromCallable { val client = OkHttpClient() val body = "".toRequestBody(JSON) val request = Request.Builder() - .url("${BotSettings.API_URL.get()}/v2/status/get") - .post(body) - .header("Authorization", BotSettings.BOT_API_TOKEN.get()) + .url("$apiUrl/v2/status/get") + .post(body) + .header("Authorization", Config.SECRET_DISCAL_API_KEY.getString()) .header("Content-Type", "application/json") .build() diff --git a/web/src/main/kotlin/org/dreamexposure/discal/web/network/discord/DiscordLoginHandler.kt b/web/src/main/kotlin/org/dreamexposure/discal/web/network/discord/DiscordLoginHandler.kt index 4d5a7363a..d2b08b4e9 100644 --- a/web/src/main/kotlin/org/dreamexposure/discal/web/network/discord/DiscordLoginHandler.kt +++ b/web/src/main/kotlin/org/dreamexposure/discal/web/network/discord/DiscordLoginHandler.kt @@ -4,11 +4,11 @@ import okhttp3.FormBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.enums.announcement.AnnouncementType import org.dreamexposure.discal.core.enums.event.EventColor import org.dreamexposure.discal.core.enums.time.GoodTimezone import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.`object`.BotSettings import org.dreamexposure.discal.core.`object`.web.WebPartialGuild import org.dreamexposure.discal.core.utils.GlobalVal import org.dreamexposure.discal.web.handler.DiscordAccountHandler @@ -21,21 +21,30 @@ import org.springframework.web.server.ServerWebExchange import reactor.core.publisher.Mono import reactor.core.scheduler.Schedulers import reactor.function.TupleUtils +import java.net.URLEncoder +import java.nio.charset.Charset import java.util.* @Controller -class DiscordLoginHandler(private val accountHandler: DiscordAccountHandler) { +class DiscordLoginHandler( + private val accountHandler: DiscordAccountHandler, +) { + private final val apiUrl = Config.URL_API.getString() + private final val redirectUrl = Config.URL_DISCORD_REDIRECT.getString() + private final val clientId = Config.DISCORD_APP_ID.getString() + private final val clientSecret = Config.SECRET_CLIENT_SECRET.getString() + @GetMapping("/account/login") fun handleDiscordCode(swe: ServerWebExchange, @RequestParam("code") code: String): Mono { val client = OkHttpClient() return Mono.fromCallable { val body = FormBody.Builder() - .addEncoded("client_id", BotSettings.ID.get()) - .addEncoded("client_secret", BotSettings.SECRET.get()) - .addEncoded("grant_type", "authorization_code") + .addEncoded("client_id", clientId) + .addEncoded("client_secret", clientSecret) + .addEncoded("grant_type", "authorization_code") .addEncoded("code", code) - .addEncoded("redirect_uri", BotSettings.REDIR_URI.get()) + .addEncoded("redirect_uri", URLEncoder.encode(redirectUrl, Charset.defaultCharset())) .build() val tokenRequest = Request.Builder() @@ -122,8 +131,8 @@ class DiscordLoginHandler(private val accountHandler: DiscordAccountHandler) { //Do temp API key request... val keyGrantRequestBody = "".toRequestBody(GlobalVal.JSON) val keyGrantRequest = Request.Builder() - .url("${BotSettings.API_URL.get()}/v2/account/login") - .header("Authorization", BotSettings.BOT_API_TOKEN.get()) + .url("$apiUrl/v2/account/login") + .header("Authorization", Config.SECRET_DISCAL_API_KEY.getString()) .post(keyGrantRequestBody) .build() val keyGrantResponseMono = Mono @@ -177,7 +186,7 @@ class DiscordLoginHandler(private val accountHandler: DiscordAccountHandler) { val client = OkHttpClient() val logoutRequest = Request.Builder() - .url("${BotSettings.API_URL.get()}/v2/account/logout") + .url("$apiUrl/v2/account/logout") .header("Authorization", model["key"] as String) .get() .build() diff --git a/web/src/main/resources/application.properties b/web/src/main/resources/application.properties deleted file mode 100644 index bdb52268f..000000000 --- a/web/src/main/resources/application.properties +++ /dev/null @@ -1,3 +0,0 @@ -spring.application.name=DisCal Web -server.port=8080 -spring.session.store-type=redis