v4.2.7 RC

v4.2.7 RC
This commit is contained in:
Nova Maday
2024-12-30 22:57:54 -06:00
committed by GitHub
197 changed files with 5148 additions and 6544 deletions

View File

@@ -1,4 +1,5 @@
import org.gradle.api.tasks.wrapper.Wrapper.DistributionType.ALL
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
@@ -27,7 +28,7 @@ buildscript {
allprojects {
//Project props
group = "org.dreamexposure.discal"
version = "4.2.6"
version = "4.2.7"
description = "DisCal"
//Plugins
@@ -50,6 +51,7 @@ allprojects {
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
@@ -88,7 +90,7 @@ allprojects {
implementation("org.springframework.boot:spring-boot-starter-actuator")
// Database
implementation("io.asyncer:r2dbc-mysql")
implementation("io.asyncer:r2dbc-mysql:1.3.0") // TODO: Remove hard coded version once spring includes this in bom as it is a breaking change
implementation("com.mysql:mysql-connector-j")
// Serialization
@@ -101,6 +103,7 @@ allprojects {
implementation("ch.qos.logback.contrib:logback-json-classic:$logbackContribVersion")
implementation("ch.qos.logback.contrib:logback-jackson:$logbackContribVersion")
implementation("io.micrometer:micrometer-registry-prometheus")
implementation("io.projectreactor:reactor-core-micrometer")
// Google libs
implementation("com.google.api-client:google-api-client:$googleApiClientVersion")
@@ -111,6 +114,7 @@ allprojects {
// Various Libs
implementation("com.squareup.okhttp3:okhttp")
implementation("com.squareup.okhttp3:okhttp-coroutines:$okhttpVersion")
implementation("io.github.furstenheim:copy_down:$copyDownVersion")
implementation("org.jsoup:jsoup:$jsoupVersion")
}
@@ -135,9 +139,9 @@ allprojects {
subprojects {
tasks {
withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = java.targetCompatibility.majorVersion
compilerOptions {
freeCompilerArgs.set(listOf("-Xjsr305=strict"))
jvmTarget.set(JvmTarget.fromTarget(java.targetCompatibility.majorVersion))
}
}
}
@@ -146,7 +150,7 @@ subprojects {
tasks {
wrapper {
distributionType = ALL
gradleVersion = "8.2.1"
gradleVersion = "8.10.2"
}
bootJar {

View File

@@ -27,7 +27,7 @@ class Cam {
.run(*args)
LOGGER.info(GlobalVal.STATUS, "CAM is now online")
} catch (e: Exception) {
LOGGER.error(GlobalVal.DEFAULT, "'Spring error' by PANIC! at the CAM")
LOGGER.error(GlobalVal.DEFAULT, "'Spring error' by PANIC! at the CAM", e)
exitProcess(4)
}
}

View File

@@ -12,7 +12,7 @@ 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.DEFAULT
import org.dreamexposure.discal.core.utils.GlobalVal.JSON
import org.springframework.boot.ApplicationArguments
import org.springframework.boot.ApplicationRunner
@@ -31,26 +31,30 @@ class HeartbeatCronJob(
override fun run(args: ApplicationArguments?) {
Flux.interval(Config.HEARTBEAT_INTERVAL.getLong().asSeconds())
.flatMap { heartbeat() }
.doOnError { LOGGER.error(GlobalVal.DEFAULT, "[Heartbeat] Failed to heartbeat", it) }
.doOnError { LOGGER.error(DEFAULT, "[Heartbeat] Failed to heartbeat", it) }
.onErrorResume { Mono.empty() }
.subscribe()
}
private fun heartbeat() = mono {
val requestBody = HeartbeatRequest(HeartbeatType.CAM, instanceData = InstanceData())
try {
val requestBody = HeartbeatRequest(HeartbeatType.CAM, instanceData = InstanceData())
val request = Request.Builder()
.url("$apiUrl/v3/status/heartbeat")
.post(objectMapper.writeValueAsString(requestBody).toRequestBody(JSON))
.header("Authorization", "Int ${Config.SECRET_DISCAL_API_KEY.getString()}")
.header("Content-Type", "application/json")
.build()
val request = Request.Builder()
.url("$apiUrl/v3/status/heartbeat")
.post(objectMapper.writeValueAsString(requestBody).toRequestBody(JSON))
.header("Authorization", "Int ${Config.SECRET_DISCAL_API_KEY.getString()}")
.header("Content-Type", "application/json")
.build()
Mono.fromCallable(httpClient.newCall(request)::execute)
.map(Response::close)
.subscribeOn(Schedulers.boundedElastic())
.doOnError { LOGGER.error(GlobalVal.DEFAULT, "[Heartbeat] Failed to heartbeat", it) }
.onErrorResume { Mono.empty() }
.subscribe()
Mono.fromCallable(httpClient.newCall(request)::execute)
.map(Response::close)
.subscribeOn(Schedulers.boundedElastic())
.doOnError { LOGGER.error(DEFAULT, "[Heartbeat] Failed to heartbeat", it) }
.onErrorResume { Mono.empty() }
.subscribe()
} catch (ex: Exception) {
LOGGER.error(DEFAULT, "[Heartbeat] Failed to heartbeat", ex)
}
}
}

View File

@@ -0,0 +1,52 @@
package org.dreamexposure.discal.cam.business.google
import org.dreamexposure.discal.core.business.CalendarService
import org.dreamexposure.discal.core.business.CredentialService
import org.dreamexposure.discal.core.business.google.GoogleAuthApiWrapper
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`.new.CalendarMetadata
import org.dreamexposure.discal.core.`object`.new.model.discal.cam.TokenV1Model
import org.springframework.stereotype.Component
import java.time.Duration
import java.time.Instant
@Component
class GoogleAuthService(
private val credentialService: CredentialService,
private val calendarService: CalendarService,
private val googleAuthApiWrapper: GoogleAuthApiWrapper,
) {
suspend fun requestNewAccessToken(calendar: CalendarMetadata): TokenV1Model? {
if (!calendar.secrets.expiresAt.isExpiredTtl()) return TokenV1Model(calendar.secrets.accessToken, calendar.secrets.expiresAt)
LOGGER.debug("Refreshing access token | guildId:{} | calendar:{}", calendar.guildId, calendar.number)
val refreshed = googleAuthApiWrapper.refreshAccessToken(calendar.secrets.refreshToken).entity ?: return null
calendar.secrets.accessToken = refreshed.accessToken
calendar.secrets.expiresAt = Instant.now().plusSeconds(refreshed.expiresIn.toLong()).minus(Duration.ofMinutes(5)) // Add some wiggle room
calendarService.updateCalendarMetadata(calendar)
LOGGER.debug("Refreshed access token | guildId:{} | calendar:{}, validUntil:{}", calendar.guildId, calendar.number, calendar.external)
return TokenV1Model(calendar.secrets.accessToken, calendar.secrets.expiresAt)
}
suspend fun requestNewAccessToken(credentialId: Int): TokenV1Model {
val credential = credentialService.getCredential(credentialId) ?: throw NotFoundException()
if (!credential.expiresAt.isExpiredTtl()) return TokenV1Model(credential.accessToken, credential.expiresAt)
LOGGER.debug("Refreshing access token | credentialId:$credentialId")
val refreshed = googleAuthApiWrapper.refreshAccessToken(credential.refreshToken).entity ?: throw EmptyNotAllowedException()
credential.accessToken = refreshed.accessToken
credential.expiresAt = Instant.now().plusSeconds(refreshed.expiresIn.toLong()).minus(Duration.ofMinutes(5)) // Add some wiggle room
credentialService.updateCredential(credential)
LOGGER.debug("Refreshed access token | credentialId:{} | validUntil:{}", credentialId, credential.expiresAt)
return TokenV1Model(credential.accessToken, credential.expiresAt)
}
}

View File

@@ -2,10 +2,10 @@ package org.dreamexposure.discal.cam.controllers.v1
import org.dreamexposure.discal.cam.business.SecurityService
import org.dreamexposure.discal.core.annotations.SecurityRequirement
import org.dreamexposure.discal.core.`object`.new.model.discal.cam.SecurityValidateV1Request
import org.dreamexposure.discal.core.`object`.new.model.discal.cam.SecurityValidateV1Response
import org.dreamexposure.discal.core.`object`.new.security.Scope.INTERNAL_CAM_VALIDATE_TOKEN
import org.dreamexposure.discal.core.`object`.new.security.TokenType.INTERNAL
import org.dreamexposure.discal.core.`object`.rest.v1.security.ValidateRequest
import org.dreamexposure.discal.core.`object`.rest.v1.security.ValidateResponse
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
@@ -19,13 +19,13 @@ class SecurityController(
) {
@SecurityRequirement(schemas = [INTERNAL], scopes = [INTERNAL_CAM_VALIDATE_TOKEN])
@PostMapping("/validate", produces = ["application/json"])
suspend fun validate(@RequestBody request: ValidateRequest): ValidateResponse {
suspend fun validate(@RequestBody request: SecurityValidateV1Request): SecurityValidateV1Response {
val result = securityService.authenticateAndAuthorizeToken(
request.token,
request.schemas,
request.scopes,
)
return ValidateResponse(result.first == HttpStatus.OK, result.first, result.second)
return SecurityValidateV1Response(result.first == HttpStatus.OK, result.first, result.second)
}
}

View File

@@ -4,7 +4,7 @@ import discord4j.common.util.Snowflake
import org.dreamexposure.discal.cam.managers.CalendarAuthManager
import org.dreamexposure.discal.core.annotations.SecurityRequirement
import org.dreamexposure.discal.core.enums.calendar.CalendarHost
import org.dreamexposure.discal.core.`object`.network.discal.CredentialData
import org.dreamexposure.discal.core.`object`.new.model.discal.cam.TokenV1Model
import org.dreamexposure.discal.core.`object`.new.security.Scope.CALENDAR_TOKEN_READ
import org.dreamexposure.discal.core.`object`.new.security.TokenType.INTERNAL
import org.springframework.web.bind.annotation.GetMapping
@@ -19,7 +19,7 @@ class TokenController(
) {
@SecurityRequirement(schemas = [INTERNAL], scopes = [CALENDAR_TOKEN_READ])
@GetMapping(produces = ["application/json"])
suspend fun getToken(@RequestParam host: CalendarHost, @RequestParam id: Int, @RequestParam guild: Snowflake?): CredentialData? {
suspend fun getToken(@RequestParam host: CalendarHost, @RequestParam id: Int, @RequestParam guild: Snowflake?): TokenV1Model? {
return calendarAuthManager.getCredentialData(host, id, guild)
}
}

View File

@@ -1,118 +0,0 @@
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.OkHttpClient
import okhttp3.Request
import org.dreamexposure.discal.cam.json.google.ErrorData
import org.dreamexposure.discal.cam.json.google.RefreshData
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.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.springframework.stereotype.Component
import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
import java.time.Duration
import java.time.Instant
@Component
class GoogleAuth(
private val credentialService: CredentialService,
private val calendarService: CalendarService,
private val objectMapper: ObjectMapper,
private val httpClient: OkHttpClient,
) {
suspend fun requestNewAccessToken(calendar: Calendar): CredentialData? {
if (!calendar.secrets.expiresAt.isExpiredTtl()) return CredentialData(calendar.secrets.accessToken, calendar.secrets.expiresAt)
LOGGER.debug("Refreshing access token | guildId:{} | calendar:{}", calendar.guildId, calendar.number)
val refreshedCredential = doAccessTokenRequest(calendar.secrets.refreshToken) ?: return null
calendar.secrets.accessToken = refreshedCredential.accessToken
calendar.secrets.expiresAt = refreshedCredential.validUntil.minus(Duration.ofMinutes(5)) // Add some wiggle room
calendarService.updateCalendar(calendar)
LOGGER.debug("Refreshing access token | guildId:{} | calendar:{}", calendar.guildId, calendar.number)
return refreshedCredential
}
suspend fun requestNewAccessToken(credentialId: Int): CredentialData {
val credential = credentialService.getCredential(credentialId) ?: throw NotFoundException()
if (!credential.expiresAt.isExpiredTtl()) return CredentialData(credential.accessToken, credential.expiresAt)
LOGGER.debug("Refreshing access token | credentialId:$credentialId")
val refreshedCredentialData = doAccessTokenRequest(credential.refreshToken) ?: throw EmptyNotAllowedException()
credential.accessToken = refreshedCredentialData.accessToken
credential.expiresAt = refreshedCredentialData.validUntil.minus(Duration.ofMinutes(5)) // Add some wiggle room
credentialService.updateCredential(credential)
LOGGER.debug("Refreshed access token | credentialId:{} | validUntil{}", credentialId, credential.expiresAt)
return refreshedCredentialData
}
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(httpClient.newCall(request)::execute)
.subscribeOn(Schedulers.boundedElastic())
.awaitSingle()
return when (response.code) {
STATUS_CODE_OK -> {
val body = objectMapper.readValue<RefreshData>(response.body!!.string())
response.close()
CredentialData(body.accessToken, Instant.now().plusSeconds(body.expiresIn.toLong()))
}
STATUS_CODE_BAD_REQUEST -> {
val bodyRaw = response.body!!.string()
LOGGER.error("[Google] Access Token Request: $bodyRaw")
val body = objectMapper.readValue<ErrorData>(bodyRaw)
response.close()
if (body.error == "invalid_grant") {
LOGGER.debug(DEFAULT, "[Google] Access to resource has been revoked")
throw AccessRevokedException() // TODO: How should I handle this for external calendars? Right now we just delete everything
} 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
}
}
}
}

View File

@@ -1,6 +0,0 @@
package org.dreamexposure.discal.cam.json.google
data class ErrorData(
val error: String
)

View File

@@ -1,11 +0,0 @@
package org.dreamexposure.discal.cam.json.google
import com.fasterxml.jackson.annotation.JsonProperty
data class RefreshData(
@JsonProperty("access_token")
val accessToken: String,
@JsonProperty("expires_in")
val expiresIn: Int
)

View File

@@ -1,29 +1,29 @@
package org.dreamexposure.discal.cam.managers
import discord4j.common.util.Snowflake
import org.dreamexposure.discal.cam.google.GoogleAuth
import org.dreamexposure.discal.cam.business.google.GoogleAuthService
import org.dreamexposure.discal.core.business.CalendarService
import org.dreamexposure.discal.core.enums.calendar.CalendarHost
import org.dreamexposure.discal.core.logger.LOGGER
import org.dreamexposure.discal.core.`object`.network.discal.CredentialData
import org.dreamexposure.discal.core.`object`.new.model.discal.cam.TokenV1Model
import org.springframework.stereotype.Component
@Component
class CalendarAuthManager(
private val calendarService: CalendarService,
private val googleAuth: GoogleAuth,
private val googleAuthService: GoogleAuthService,
) {
suspend fun getCredentialData(host: CalendarHost, id: Int, guild: Snowflake?): CredentialData? {
suspend fun getCredentialData(host: CalendarHost, id: Int, guild: Snowflake?): TokenV1Model? {
return try {
when (host) {
CalendarHost.GOOGLE -> {
if (guild == null) {
// Internal (owned by DisCal, should never go bad)
googleAuth.requestNewAccessToken(id)
googleAuthService.requestNewAccessToken(id)
} else {
// External (owned by user)
val calendar = calendarService.getCalendar(guild, id) ?: return null
googleAuth.requestNewAccessToken(calendar)
val calendar = calendarService.getCalendarMetadata(guild, id) ?: return null
googleAuthService.requestNewAccessToken(calendar)
}
}
}

View File

@@ -35,19 +35,23 @@ class AnnouncementCronJob(
}
private fun doAction() = mono {
val taskTimer = StopWatch()
taskTimer.start()
try {
val taskTimer = StopWatch()
taskTimer.start()
val guilds = discordClient.guilds.collectList().awaitSingle()
val guilds = discordClient.guilds.collectList().awaitSingle()
guilds.forEach { guild ->
try {
announcementService.processAnnouncementsForGuild(guild.id, maxDifference)
} catch (ex: Exception) {
LOGGER.error("Failed to process announcements for guild | guildId:${guild.id.asLong()}", ex)
guilds.forEach { guild ->
try {
announcementService.processAnnouncementsForGuild(guild.id, maxDifference)
} catch (ex: Exception) {
LOGGER.error("Failed to process announcements for guild | guildId:${guild.id.asLong()}", ex)
}
}
taskTimer.stop()
metricService.recordAnnouncementTaskDuration("cronjob", taskTimer.totalTimeMillis)
} catch (ex: Exception) {
LOGGER.error(DEFAULT, "Announcement task failed for all guilds", ex)
}
taskTimer.stop()
metricService.recordAnnouncementTaskDuration("cronjob", taskTimer.totalTimeMillis)
}
}

View File

@@ -15,6 +15,7 @@ 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.utils.GlobalVal
import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT
import org.springframework.boot.ApplicationArguments
import org.springframework.boot.ApplicationRunner
import org.springframework.stereotype.Component
@@ -33,27 +34,31 @@ class HeartbeatCronJob(
override fun run(args: ApplicationArguments?) {
Flux.interval(Config.HEARTBEAT_INTERVAL.getLong().asSeconds())
.flatMap { heartbeat() }
.doOnError { LOGGER.error(GlobalVal.DEFAULT, "[Heartbeat] Failed to heartbeat", it) }
.doOnError { LOGGER.error(DEFAULT, "[Heartbeat] Failed to heartbeat", it) }
.onErrorResume { Mono.empty() }
.subscribe()
}
private fun heartbeat() = mono {
val data = BotInstanceData.load(discordClient).awaitSingle()
try {
val data = BotInstanceData.load(discordClient).awaitSingle()
val requestBody = HeartbeatRequest(HeartbeatType.BOT, botInstanceData = data)
val request = Request.Builder()
.url("$apiUrl/v3/status/heartbeat")
.post(objectMapper.writeValueAsString(requestBody).toRequestBody(GlobalVal.JSON))
.header("Authorization", "Int ${Config.SECRET_DISCAL_API_KEY.getString()}")
.header("Content-Type", "application/json")
.build()
val requestBody = HeartbeatRequest(HeartbeatType.BOT, botInstanceData = data)
val request = Request.Builder()
.url("$apiUrl/v3/status/heartbeat")
.post(objectMapper.writeValueAsString(requestBody).toRequestBody(GlobalVal.JSON))
.header("Authorization", "Int ${Config.SECRET_DISCAL_API_KEY.getString()}")
.header("Content-Type", "application/json")
.build()
Mono.fromCallable(httpClient.newCall(request)::execute)
.map(Response::close)
.subscribeOn(Schedulers.boundedElastic())
.doOnError { LOGGER.error(GlobalVal.DEFAULT, "[Heartbeat] Failed to heartbeat", it) }
.onErrorResume { Mono.empty() }
.subscribe()
Mono.fromCallable(httpClient.newCall(request)::execute)
.map(Response::close)
.subscribeOn(Schedulers.boundedElastic())
.doOnError { LOGGER.error(DEFAULT, "[Heartbeat] Failed to heartbeat", it) }
.onErrorResume { Mono.empty() }
.subscribe()
} catch (ex: Exception) {
LOGGER.error(DEFAULT, "[Heartbeat] Failed to heartbeat", ex)
}
}
}

View File

@@ -37,16 +37,16 @@ class StatusUpdateCronJob(
"Version {version}",
"{calendar_count} calendars managed!",
"Now has interactions!",
"Delay, Deny, Defend",
"Proudly written in Kotlin using Discord4J",
"Free Palestine!",
"https://discalbot.com",
"I swear DisCal isn't abandoned",
"Powered by Discord4J v{d4j_version}",
"{shards} total shards!",
"Slava Ukraini!",
"Support DisCal on Patreon",
"{announcement_count} announcements running!",
"Finally fixing the annoying stuff"
"Now the real improvements begin"
)
override fun run(args: ApplicationArguments?) {

View File

@@ -2,29 +2,22 @@ package org.dreamexposure.discal.client.commands
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.`object`.new.GuildSettings
import org.dreamexposure.discal.core.utils.MessageSourceLoader
import reactor.core.publisher.Mono
interface SlashCommand {
val name: String
val hasSubcommands: Boolean
val ephemeral: Boolean
@Deprecated("Use new handleSuspend for K-coroutines")
fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
return mono { suspendHandle(event, settings) }
}
fun shouldDefer(event: ChatInputInteractionEvent): Boolean = true
suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
return handle(event, settings).awaitSingle()
}
suspend fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Message
fun getMessage(key: String, settings: GuildSettings, vararg args: String): String {
val src = MessageSourceLoader.getSourceByPath("command/$name/$name")
return src.getMessage(key, args, settings.getLocale())
return src.getMessage(key, args, settings.locale)
}
}

View File

@@ -1,126 +1,105 @@
package org.dreamexposure.discal.client.commands.dev
import discord4j.common.util.Snowflake
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 kotlinx.coroutines.reactor.awaitSingle
import org.dreamexposure.discal.client.commands.SlashCommand
import org.dreamexposure.discal.core.crypto.KeyGenerator.csRandomAlphaNumericString
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.`object`.GuildSettings
import org.dreamexposure.discal.core.`object`.web.UserAPIAccount
import org.dreamexposure.discal.core.business.EmbedService
import org.dreamexposure.discal.core.business.GuildSettingsService
import org.dreamexposure.discal.core.`object`.new.GuildSettings
import org.dreamexposure.discal.core.utils.GlobalVal
import org.springframework.stereotype.Component
import reactor.core.publisher.Mono
@Component
class DevCommand : SlashCommand {
class DevCommand(
private val settingsService: GuildSettingsService,
private val embedService: EmbedService,
) : SlashCommand {
override val name = "dev"
override val hasSubcommands = true
override val ephemeral = true
@Deprecated("Use new handleSuspend for K-coroutines")
override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
if (!GlobalVal.devUserIds.contains(event.interaction.user.id)) {
return event.followupEphemeral(getMessage("error.notDeveloper", settings))
}
override suspend fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
// Validate this user is actually a dev
if (!GlobalVal.devUserIds.contains(event.interaction.user.id))
return event.createFollowup(getMessage("error.notDeveloper", settings))
.withEphemeral(ephemeral)
.awaitSingle()
return when (event.options[0].name) {
"patron" -> patronSubcommand(event, settings)
"dev" -> devSubcommand(event, settings)
"maxcal" -> maxCalSubcommand(event, settings)
"api-register" -> apiRegisterSubcommand(event, settings)
"api-block" -> apiBlockSubcommand(event, settings)
else -> Mono.empty() //Never can reach this, makes compiler happy.
"patron" -> patron(event, settings)
"dev" -> dev(event, settings)
"maxcal" -> maxCalendars(event, settings)
"settings" -> settings(event)
else -> throw IllegalStateException("Invalid subcommand specified")
}
}
private fun patronSubcommand(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
private suspend fun patron(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val guildId = event.options[0].getOption("guild")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asSnowflake)
.get()
return DatabaseManager.getSettings(guildId)
.doOnNext { settings.patronGuild = !settings.patronGuild }
.flatMap {
DatabaseManager.updateSettings(it).then(
event.followupEphemeral(getMessage("patron.success", settings, settings.patronGuild.toString()))
)
}.doOnError { LOGGER.error("[cmd] patron failure", it) }
.onErrorResume { event.followupEphemeral(getMessage("patron.failure.badId", settings)) }
}
private fun devSubcommand(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
val guildId = event.options[0].getOption("guild")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asSnowflake)
.get()
return DatabaseManager.getSettings(guildId)
.doOnNext { settings.devGuild = !settings.devGuild }
.flatMap {
DatabaseManager.updateSettings(it).then(
event.followupEphemeral(getMessage("dev.success", settings, settings.devGuild.toString()))
)
}.doOnError { LOGGER.error("[cmd] dev failure", it) }
.onErrorResume { event.followupEphemeral(getMessage("dev.failure.badId", settings)) }
}
private fun maxCalSubcommand(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
val guildId = event.options[0].getOption("guild")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asSnowflake)
.get()
return DatabaseManager.getSettings(guildId)
.doOnNext {
val amount = event.options[0].getOption("amount").get().value.get().asLong().toInt()
it.maxCalendars = amount
}.flatMap {
DatabaseManager.updateSettings(it).then(
event.followupEphemeral(getMessage("maxcal.success", settings, settings.maxCalendars.toString()))
)
}
.onErrorResume {
event.followupEphemeral(getMessage("maxcal.failure.badInput", settings))
}
}
private fun apiRegisterSubcommand(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
return Mono.justOrEmpty(event.options[0].getOption("user").flatMap { it.value })
.flatMap(ApplicationCommandInteractionOptionValue::asUser)
.flatMap { user ->
val acc = UserAPIAccount(
user.id.asString(),
csRandomAlphaNumericString(64),
false,
System.currentTimeMillis()
)
DatabaseManager.updateAPIAccount(acc).flatMap { success ->
if (success) {
event.followupEphemeral(getMessage("apiRegister.success", settings, acc.APIKey))
} else {
event.followupEphemeral(getMessage("apiRegister.failure.unable", settings))
}
}
}.switchIfEmpty(event.followupEphemeral(getMessage("apiRegister.failure.empty", settings)))
}
private fun apiBlockSubcommand(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
return Mono.justOrEmpty(event.options[0].getOption("key").flatMap { it.value })
.map(ApplicationCommandInteractionOptionValue::asString)
.flatMap(DatabaseManager::getAPIAccount)
.map {
it.copy(blocked = true)
}.flatMap(DatabaseManager::updateAPIAccount)
.flatMap { event.followupEphemeral(getMessage("apiBlock.success", settings)) }
.switchIfEmpty(event.followupEphemeral(getMessage("apiBlock.failure.notFound", settings)))
.onErrorResume {
event.followupEphemeral(getMessage("apiBlock.failure.other", settings))
}
.map(Snowflake::of)
.get()
val oldTargetSettings = settingsService.getSettings(guildId)
val newTargetSettings = settingsService.upsertSettings(oldTargetSettings.copy(patronGuild = !oldTargetSettings.patronGuild))
return event.createFollowup(getMessage("patron.success", settings, "${newTargetSettings.patronGuild}"))
.withEphemeral(ephemeral)
.awaitSingle()
}
private suspend fun dev(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val guildId = event.options[0].getOption("guild")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asString)
.map(Snowflake::of)
.get()
val oldTargetSettings = settingsService.getSettings(guildId)
val newTargetSettings = settingsService.upsertSettings(oldTargetSettings.copy(devGuild = !oldTargetSettings.devGuild))
return event.createFollowup(getMessage("dev.success", settings, newTargetSettings.devGuild.toString()))
.withEphemeral(ephemeral)
.awaitSingle()
}
private suspend fun maxCalendars(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val guildId = event.options[0].getOption("guild")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asString)
.map(Snowflake::of)
.get()
val amount = event.options[0].getOption("amount")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asLong)
.map(Long::toInt)
.get()
val oldTargetSettings = settingsService.getSettings(guildId)
settingsService.upsertSettings(oldTargetSettings.copy(maxCalendars = amount))
return event.createFollowup(getMessage("maxcal.success", settings, "$amount"))
.withEphemeral(ephemeral)
.awaitSingle()
}
private suspend fun settings(event: ChatInputInteractionEvent): Message {
val guildId = event.options[0].getOption("guild")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asString)
.map(Snowflake::of)
.get()
val targetSettings = settingsService.getSettings(guildId)
return event.createFollowup()
.withEmbeds(embedService.settingsEmbeds(targetSettings))
.withEphemeral(ephemeral)
.awaitSingle()
}
}

View File

@@ -6,19 +6,16 @@ import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue
import discord4j.core.`object`.entity.Message
import discord4j.rest.util.AllowedMentions
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.dreamexposure.discal.client.commands.SlashCommand
import org.dreamexposure.discal.core.business.AnnouncementService
import org.dreamexposure.discal.core.business.CalendarService
import org.dreamexposure.discal.core.business.EmbedService
import org.dreamexposure.discal.core.business.PermissionService
import org.dreamexposure.discal.core.crypto.KeyGenerator
import org.dreamexposure.discal.core.enums.event.EventColor
import org.dreamexposure.discal.core.extensions.discord4j.followup
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.`object`.GuildSettings
import org.dreamexposure.discal.core.`object`.new.Announcement
import org.dreamexposure.discal.core.`object`.new.AnnouncementWizardState
import org.dreamexposure.discal.core.`object`.new.GuildSettings
import org.dreamexposure.discal.core.utils.getCommonMsg
import org.springframework.stereotype.Component
import kotlin.jvm.optionals.getOrNull
@@ -26,13 +23,15 @@ import kotlin.jvm.optionals.getOrNull
@Component
class AnnouncementCommand(
private val announcementService: AnnouncementService,
private val permissionService: PermissionService,
private val embedService: EmbedService,
private val calendarService: CalendarService,
) : SlashCommand {
override val name = "announcement"
override val hasSubcommands = true
override val ephemeral = true
override suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
override suspend fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
return when (event.options[0].name) {
"create" -> create(event, settings)
"type" -> type(event, settings)
@@ -86,11 +85,13 @@ class AnnouncementCommand(
.orElse(1)
// Validate permissions
val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle()
if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle()
val hasControlRole = permissionService.hasControlRole(settings.guildId, event.interaction.user.id)
if (!hasControlRole) return event.createFollowup(getCommonMsg("error.perms.privileged", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Check if wizard already started
val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id)
val existingWizard = announcementService.getWizard(settings.guildId, event.interaction.user.id)
if (existingWizard != null) {
return event.createFollowup(getMessage("error.wizard.started", settings))
.withEphemeral(ephemeral)
@@ -99,11 +100,11 @@ class AnnouncementCommand(
}
val newWizard = AnnouncementWizardState(
guildId = settings.guildID,
guildId = settings.guildId,
userId = event.interaction.user.id,
editing = false,
entity = Announcement(
guildId = settings.guildID,
guildId = settings.guildId,
calendarNumber = calendar,
type = type,
channelId = channelId,
@@ -127,15 +128,18 @@ class AnnouncementCommand(
.get()
// Validate permissions
val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle()
if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle()
val hasControlRole = permissionService.hasControlRole(settings.guildId, event.interaction.user.id)
if (!hasControlRole) return event.createFollowup(getCommonMsg("error.perms.privileged", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Check if wizard not started
val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id)
val existingWizard = announcementService.getWizard(settings.guildId, event.interaction.user.id)
?: return event.createFollowup(getMessage("error.wizard.notStarted", settings))
.withEphemeral(ephemeral)
.awaitSingle()
val altered = existingWizard.copy(
entity = existingWizard.entity.copy(
type = type,
@@ -158,11 +162,13 @@ class AnnouncementCommand(
.get()
// Validate permissions
val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle()
if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle()
val hasControlRole = permissionService.hasControlRole(settings.guildId, event.interaction.user.id)
if (!hasControlRole) return event.createFollowup(getCommonMsg("error.perms.privileged", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Check if wizard not started
val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id)
val existingWizard = announcementService.getWizard(settings.guildId, event.interaction.user.id)
?: return event.createFollowup(getMessage("error.wizard.notStarted", settings))
.withEphemeral(ephemeral)
.awaitSingle()
@@ -177,19 +183,16 @@ class AnnouncementCommand(
}
// Validate event actually exists
val calendarEvent = event.interaction.guild
.flatMap { it.getCalendar(announcement.calendarNumber) }
.flatMap { it.getEvent(eventId) }
.awaitSingleOrNull()
val calendarEvent = calendarService.getEvent(announcement.guildId, announcement.calendarNumber, eventId)
if (calendarEvent == null) {
return event.createFollowup(getCommonMsg("error.notFound.event", settings))
return event.createFollowup(getCommonMsg("error.notFound.event", settings.locale))
.withEphemeral(ephemeral)
.withEmbeds(embedService.announcementWizardEmbed(existingWizard, settings))
.awaitSingle()
}
// Handle what format the ID is actually saved in
val idToSet = if (announcement.type == Announcement.Type.RECUR) calendarEvent.eventId.split("_")[0]
val idToSet = if (announcement.type == Announcement.Type.RECUR) calendarEvent.id.split("_")[0]
else eventId
val alteredWizard = existingWizard.copy(entity = announcement.copy(eventId = idToSet))
@@ -210,11 +213,13 @@ class AnnouncementCommand(
.get()
// Validate permissions
val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle()
if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle()
val hasControlRole = permissionService.hasControlRole(settings.guildId, event.interaction.user.id)
if (!hasControlRole) return event.createFollowup(getCommonMsg("error.perms.privileged", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Check if wizard not started
val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id)
val existingWizard = announcementService.getWizard(settings.guildId, event.interaction.user.id)
?: return event.createFollowup(getMessage("error.wizard.notStarted", settings))
.withEphemeral(ephemeral)
.awaitSingle()
@@ -243,11 +248,13 @@ class AnnouncementCommand(
.orElse(event.interaction.channelId)
// Validate permissions
val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle()
if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle()
val hasControlRole = permissionService.hasControlRole(settings.guildId, event.interaction.user.id)
if (!hasControlRole) return event.createFollowup(getCommonMsg("error.perms.privileged", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Check if wizard not started
val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id)
val existingWizard = announcementService.getWizard(settings.guildId, event.interaction.user.id)
?: return event.createFollowup(getMessage("error.wizard.notStarted", settings))
.withEphemeral(ephemeral)
.awaitSingle()
@@ -270,11 +277,13 @@ class AnnouncementCommand(
.get()
// Validate permissions
val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle()
if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle()
val hasControlRole = permissionService.hasControlRole(settings.guildId, event.interaction.user.id)
if (!hasControlRole) return event.createFollowup(getCommonMsg("error.perms.privileged", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Check if wizard not started
val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id)
val existingWizard = announcementService.getWizard(settings.guildId, event.interaction.user.id)
?: return event.createFollowup(getMessage("error.wizard.notStarted", settings))
.withEphemeral(ephemeral)
.awaitSingle()
@@ -297,11 +306,13 @@ class AnnouncementCommand(
.orElse(0)
// Validate permissions
val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle()
if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle()
val hasControlRole = permissionService.hasControlRole(settings.guildId, event.interaction.user.id)
if (!hasControlRole) return event.createFollowup(getCommonMsg("error.perms.privileged", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Check if wizard not started
val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id)
val existingWizard = announcementService.getWizard(settings.guildId, event.interaction.user.id)
?: return event.createFollowup(getMessage("error.wizard.notStarted", settings))
.withEphemeral(ephemeral)
.awaitSingle()
@@ -324,11 +335,13 @@ class AnnouncementCommand(
.getOrNull()
// Validate permissions
val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle()
if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle()
val hasControlRole = permissionService.hasControlRole(settings.guildId, event.interaction.user.id)
if (!hasControlRole) return event.createFollowup(getCommonMsg("error.perms.privileged", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Check if wizard not started
val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id)
val existingWizard = announcementService.getWizard(settings.guildId, event.interaction.user.id)
?: return event.createFollowup(getMessage("error.wizard.notStarted", settings))
.withEphemeral(ephemeral)
.awaitSingle()
@@ -351,11 +364,13 @@ class AnnouncementCommand(
.get()
// Validate permissions
val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle()
if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle()
val hasControlRole = permissionService.hasControlRole(settings.guildId, event.interaction.user.id)
if (!hasControlRole) return event.createFollowup(getCommonMsg("error.perms.privileged", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Check if wizard not started
val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id)
val existingWizard = announcementService.getWizard(settings.guildId, event.interaction.user.id)
?: return event.createFollowup(getMessage("error.wizard.notStarted", settings))
.withEphemeral(ephemeral)
.awaitSingle()
@@ -377,11 +392,13 @@ class AnnouncementCommand(
.get()
// Validate permissions
val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle()
if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle()
val hasControlRole = permissionService.hasControlRole(settings.guildId, event.interaction.user.id)
if (!hasControlRole) return event.createFollowup(getCommonMsg("error.perms.privileged", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Check if wizard not started
val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id)
val existingWizard = announcementService.getWizard(settings.guildId, event.interaction.user.id)
?: return event.createFollowup(getMessage("error.wizard.notStarted", settings))
.withEphemeral(ephemeral)
.awaitSingle()
@@ -389,7 +406,7 @@ class AnnouncementCommand(
// Confirm guild has access to feature
if (!settings.patronGuild) {
return event.createFollowup(getCommonMsg("error.patronOnly", settings))
return event.createFollowup(getCommonMsg("error.patronOnly", settings.locale))
.withEphemeral(ephemeral)
.withEmbeds(embedService.announcementWizardEmbed(existingWizard, settings))
.awaitSingle()
@@ -406,11 +423,13 @@ class AnnouncementCommand(
private suspend fun review(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
// Validate permissions
val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle()
if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle()
val hasControlRole = permissionService.hasControlRole(settings.guildId, event.interaction.user.id)
if (!hasControlRole) return event.createFollowup(getCommonMsg("error.perms.privileged", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Check if wizard not started
val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id)
val existingWizard = announcementService.getWizard(settings.guildId, event.interaction.user.id)
?: return event.createFollowup(getMessage("error.wizard.notStarted", settings))
.withEphemeral(ephemeral)
.awaitSingle()
@@ -423,11 +442,13 @@ class AnnouncementCommand(
private suspend fun confirm(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
// Validate permissions
val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle()
if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle()
val hasControlRole = permissionService.hasControlRole(settings.guildId, event.interaction.user.id)
if (!hasControlRole) return event.createFollowup(getCommonMsg("error.perms.privileged", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Check if wizard not started
val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id)
val existingWizard = announcementService.getWizard(settings.guildId, event.interaction.user.id)
?: return event.createFollowup(getMessage("error.wizard.notStarted", settings))
.withEphemeral(ephemeral)
.awaitSingle()
@@ -451,7 +472,7 @@ class AnnouncementCommand(
if (existingWizard.editing) announcementService.updateAnnouncement(announcement)
else announcementService.createAnnouncement(announcement)
announcementService.cancelWizard(settings.guildID, event.interaction.user.id)
announcementService.cancelWizard(settings.guildId, event.interaction.user.id)
val message = if (existingWizard.editing) getMessage("confirm.success.edit", settings)
else getMessage("confirm.success.create", settings)
@@ -464,10 +485,12 @@ class AnnouncementCommand(
private suspend fun cancel(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
// Validate permissions
val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle()
if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle()
val hasControlRole = permissionService.hasControlRole(settings.guildId, event.interaction.user.id)
if (!hasControlRole) return event.createFollowup(getCommonMsg("error.perms.privileged", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
announcementService.cancelWizard(settings.guildID, event.interaction.user.id)
announcementService.cancelWizard(settings.guildId, event.interaction.user.id)
return event.createFollowup(getMessage("cancel.success", settings))
.withEphemeral(ephemeral)
@@ -481,11 +504,13 @@ class AnnouncementCommand(
.get()
// Validate permissions
val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle()
if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle()
val hasControlRole = permissionService.hasControlRole(settings.guildId, event.interaction.user.id)
if (!hasControlRole) return event.createFollowup(getCommonMsg("error.perms.privileged", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Check if wizard already started
val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id)
val existingWizard = announcementService.getWizard(settings.guildId, event.interaction.user.id)
if (existingWizard != null) {
return event.createFollowup(getMessage("error.wizard.started", settings))
.withEphemeral(ephemeral)
@@ -493,11 +518,13 @@ class AnnouncementCommand(
.awaitSingle()
}
val announcement = announcementService.getAnnouncement(settings.guildID, announcementId)
?: return event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings)).awaitSingle()
val announcement = announcementService.getAnnouncement(settings.guildId, announcementId)
?: return event.createFollowup(getCommonMsg("error.notFound.announcement", settings.locale))
.withEphemeral(true)
.awaitSingle()
val newWizard = AnnouncementWizardState(
guildId = settings.guildID,
guildId = settings.guildId,
userId = event.interaction.user.id,
editing = true,
entity = announcement
@@ -517,11 +544,13 @@ class AnnouncementCommand(
.get()
// Validate permissions
val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle()
if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle()
val hasControlRole = permissionService.hasControlRole(settings.guildId, event.interaction.user.id)
if (!hasControlRole) return event.createFollowup(getCommonMsg("error.perms.privileged", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Check if wizard already started
val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id)
val existingWizard = announcementService.getWizard(settings.guildId, event.interaction.user.id)
if (existingWizard != null) {
return event.createFollowup(getMessage("error.wizard.started", settings))
.withEphemeral(ephemeral)
@@ -529,11 +558,13 @@ class AnnouncementCommand(
.awaitSingle()
}
val announcement = announcementService.getAnnouncement(settings.guildID, announcementId)
?: return event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings)).awaitSingle()
val announcement = announcementService.getAnnouncement(settings.guildId, announcementId)
?: return event.createFollowup(getCommonMsg("error.notFound.announcement", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
val newWizard = AnnouncementWizardState(
guildId = settings.guildID,
guildId = settings.guildId,
userId = event.interaction.user.id,
editing = false,
entity = announcement.copy(id = KeyGenerator.generateAnnouncementId())
@@ -553,12 +584,12 @@ class AnnouncementCommand(
.get()
// Validate permissions
val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle()
if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle()
val hasControlRole = permissionService.hasControlRole(settings.guildId, event.interaction.user.id)
if (!hasControlRole) return event.createFollowup(getCommonMsg("error.perms.privileged", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// If announcement is being edited, cancel the editor
announcementService.cancelWizard(settings.guildID, announcementId)
announcementService.deleteAnnouncement(settings.guildID, announcementId)
announcementService.deleteAnnouncement(settings.guildId, announcementId)
return event.createFollowup(getMessage("delete.success", settings))
.withEphemeral(ephemeral)
@@ -576,11 +607,15 @@ class AnnouncementCommand(
.get()
// Validate permissions
val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle()
if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle()
val hasControlRole = permissionService.hasControlRole(settings.guildId, event.interaction.user.id)
if (!hasControlRole) return event.createFollowup(getCommonMsg("error.perms.privileged", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
val announcement = announcementService.getAnnouncement(settings.guildID, announcementId)
?: return event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings)).awaitSingle()
val announcement = announcementService.getAnnouncement(settings.guildId, announcementId)
?: return event.createFollowup(getCommonMsg("error.notFound.announcement", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
val new = announcement.copy(enabled = enabled)
announcementService.updateAnnouncement(new)
@@ -600,8 +635,10 @@ class AnnouncementCommand(
.map(ApplicationCommandInteractionOptionValue::asString)
.get()
val announcement = announcementService.getAnnouncement(settings.guildID, announcementId)
?: return event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings)).awaitSingle()
val announcement = announcementService.getAnnouncement(settings.guildId, announcementId)
?: return event.createFollowup(getCommonMsg("error.notFound.announcement", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
return event.createFollowup()
.withEphemeral(ephemeral)
@@ -633,11 +670,13 @@ class AnnouncementCommand(
.getOrNull()
// Get filtered announcements
val announcements = announcementService.getAllAnnouncements(settings.guildID, type, showDisabled)
val announcements = announcementService.getAllAnnouncements(settings.guildId, type, showDisabled)
.filter { it.calendarNumber == calendar }
return if (announcements.isEmpty()) {
event.followupEphemeral(getMessage("list.success.none", settings)).awaitSingle()
event.createFollowup(getMessage("list.success.none", settings))
.withEphemeral(ephemeral)
.awaitSingle()
} else if (announcements.size == 1) {
event.createFollowup()
.withEphemeral(ephemeral)
@@ -648,10 +687,15 @@ class AnnouncementCommand(
} else {
val limit = if (amount > 0) amount.coerceAtMost(announcements.size) else announcements.size
val message = event.followupEphemeral(getMessage("list.success.many", settings, "$limit")).awaitSingle()
val message = event.createFollowup(getMessage("list.success.many", settings, "$limit"))
.withEphemeral(ephemeral)
.awaitSingle()
announcements.subList(0, limit).forEach { announcement ->
event.followupEphemeral(embedService.condensedAnnouncementEmbed(announcement, settings)).awaitSingle()
event.createFollowup()
.withEmbeds(embedService.condensedAnnouncementEmbed(announcement, settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
message
@@ -674,8 +718,10 @@ class AnnouncementCommand(
.map(ApplicationCommandInteractionOptionValue::asSnowflake)
.getOrNull()
val announcement = announcementService.getAnnouncement(settings.guildID, announcementId)
?: return event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings)).awaitSingle()
val announcement = announcementService.getAnnouncement(settings.guildId, announcementId)
?: return event.createFollowup(getCommonMsg("error.notFound.announcement", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
var newSubs = announcement.subscribers
if (userId != null) newSubs = newSubs.copy(users = newSubs.users + userId)
@@ -707,8 +753,10 @@ class AnnouncementCommand(
.map(ApplicationCommandInteractionOptionValue::asSnowflake)
.getOrNull()
val announcement = announcementService.getAnnouncement(settings.guildID, announcementId)
?: return event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings)).awaitSingle()
val announcement = announcementService.getAnnouncement(settings.guildId, announcementId)
?: return event.createFollowup(getCommonMsg("error.notFound.announcement", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
var newSubs = announcement.subscribers
if (userId != null) newSubs = newSubs.copy(users = newSubs.users - userId)

View File

@@ -3,39 +3,39 @@ 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.Guild
import discord4j.core.`object`.entity.Member
import discord4j.core.`object`.entity.Message
import kotlinx.coroutines.reactor.mono
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.business.StaticMessageService
import org.dreamexposure.discal.core.entities.response.UpdateCalendarResponse
import org.dreamexposure.discal.core.enums.calendar.CalendarHost
import org.dreamexposure.discal.core.extensions.discord4j.*
import org.dreamexposure.discal.core.extensions.isValidTimezone
import org.dreamexposure.discal.core.business.CalendarService
import org.dreamexposure.discal.core.business.EmbedService
import org.dreamexposure.discal.core.business.PermissionService
import org.dreamexposure.discal.core.config.Config
import org.dreamexposure.discal.core.extensions.toZoneId
import org.dreamexposure.discal.core.logger.LOGGER
import org.dreamexposure.discal.core.`object`.GuildSettings
import org.dreamexposure.discal.core.`object`.Wizard
import org.dreamexposure.discal.core.`object`.calendar.PreCalendar
import org.dreamexposure.discal.core.`object`.new.Calendar
import org.dreamexposure.discal.core.`object`.new.CalendarMetadata
import org.dreamexposure.discal.core.`object`.new.CalendarWizardState
import org.dreamexposure.discal.core.`object`.new.GuildSettings
import org.dreamexposure.discal.core.utils.getCommonMsg
import org.springframework.stereotype.Component
import reactor.core.publisher.Mono
import java.time.Instant
import java.time.ZoneId
@Component
class CalendarCommand(
private val wizard: Wizard<PreCalendar>,
private val staticMessageService: StaticMessageService
private val calendarService: CalendarService,
private val permissionService: PermissionService,
private val embedService: EmbedService,
) : SlashCommand {
override val name = "calendar"
override val hasSubcommands = true
override val ephemeral = true
private val OVERVIEW_EVENT_COUNT = Config.CALENDAR_OVERVIEW_DEFAULT_EVENT_COUNT.getInt()
@Deprecated("Use new handleSuspend for K-coroutines")
override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
override suspend fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
return when (event.options[0].name) {
"view" -> view(event, settings)
"list" -> list(event, settings)
"create" -> create(event, settings)
"name" -> name(event, settings)
"description" -> description(event, settings)
@@ -45,232 +45,373 @@ class CalendarCommand(
"cancel" -> cancel(event, settings)
"delete" -> delete(event, settings)
"edit" -> edit(event, settings)
else -> Mono.empty() //Never can reach this, makes compiler happy.
else -> throw IllegalStateException("Invalid subcommand specified")
}
}
private fun create(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
private suspend fun view(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val showOverview = event.options[0].getOption("overview")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asBoolean)
.orElse(true)
val calendarNumber = event.options[0].getOption("calendar")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asLong)
.map(Long::toInt)
.orElse(1)
val calendar = calendarService.getCalendar(settings.guildId, calendarNumber)
if (calendar == null) {
return event.createFollowup(getCommonMsg("error.notFound.calendar", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
}
val events = if (showOverview)
calendarService.getUpcomingEvents(settings.guildId, calendarNumber, OVERVIEW_EVENT_COUNT)
else null
return event.createFollowup()
.withEmbeds(embedService.linkCalendarEmbed(calendar, events))
.withEphemeral(ephemeral)
.awaitSingle()
}
private suspend fun list(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val calendars = calendarService.getAllCalendars(settings.guildId)
if (calendars.isEmpty()) {
return event.createFollowup(getMessage("list.success.none", settings))
.withEphemeral(ephemeral)
.awaitSingle()
} else if (calendars.size == 1) {
return event.createFollowup(getMessage("list.success.one", settings))
.withEphemeral(ephemeral)
.withEmbeds(embedService.linkCalendarEmbed(calendars[0]))
.awaitSingle()
} else {
val response = event.createFollowup(getMessage("list.success.many", settings, "${calendars.size}"))
.withEphemeral(ephemeral)
.awaitSingle()
calendars.forEach {
event.createFollowup()
.withEmbeds(embedService.linkCalendarEmbed(it))
.withEphemeral(ephemeral)
.awaitSingle()
}
return response
}
}
private suspend fun create(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val name = event.options[0].getOption("name")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asString)
.get()
val description = event.options[0].getOption("description")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asString)
.orElse("")
val timezone = event.options[0].getOption("timezone")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asString)
.orElse("")
.orElse("UTC")
.toZoneId() ?: ZoneId.of("UTC")
val host = event.options[0].getOption("host")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asString)
.map(CalendarHost::valueOf)
.orElse(CalendarHost.GOOGLE)
.map(CalendarMetadata.Host::valueOf)
.orElse(CalendarMetadata.Host.GOOGLE)
return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasElevatedPermissions).flatMap {
if (wizard.get(settings.guildID) == null) {
//Start calendar wizard
val pre = PreCalendar.new(settings.guildID, host, name)
pre.description = description
pre.timezone = timezone.toZoneId() // Extension method auto-checks if the timezone is valid
// Validate permissions
val hasElevatedPerms = permissionService.hasElevatedPermissions(settings.guildId, event.interaction.user.id)
if (!hasElevatedPerms) return event.createFollowup(getCommonMsg("error.perms.elevated", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Message content for wizard start so that if a bad optional timezone is given, we can provide better feedback.
val msg = if (timezone.isNotEmpty() && !timezone.isValidTimezone()) {
// Invalid timezone specified
getMessage("create.success.badTimezone", settings)
} else {
getMessage("create.success", settings)
}
// Check if wizard already started
val existingWizard = calendarService.getCalendarWizard(settings.guildId, event.interaction.user.id)
if (existingWizard != null) return event.createFollowup(getMessage("error.wizard.started", settings))
.withEphemeral(ephemeral)
.withEmbeds(embedService.calendarWizardEmbed(existingWizard, settings))
.awaitSingle()
event.interaction.guild
.filterWhen(Guild::canAddCalendar)
.doOnNext { wizard.start(pre) } //only start wizard if another calendar can be added
.map { CalendarEmbed.pre(it, settings, pre) }
.flatMap { event.followupEphemeral(msg, it) }
.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.calendar.max", settings)))
} else {
event.interaction.guild
.map { CalendarEmbed.pre(it, settings, wizard.get(settings.guildID)!!) }
.flatMap { event.followupEphemeral(getMessage("error.wizard.started", settings), it) }
}
}.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.elevated", settings)))
// Check if new calendar can be added
if (!calendarService.canAddNewCalendar(settings.guildId))
return event.createFollowup(getCommonMsg("error.calendar.max", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
val newWizard = CalendarWizardState(
guildId = settings.guildId,
userId = event.interaction.user.id,
editing = false,
entity = Calendar(
metadata = CalendarMetadata(
guildId = settings.guildId,
number = calendarService.getNextCalendarNumber(settings.guildId),
host = host,
id = "NOT_YET_GENERATED",
address = "NOT_YET_GENERATED",
external = false,
secrets = CalendarMetadata.Secrets(
credentialId = 0,
privateKey = "NOT_YET_GENERATED",
expiresAt = Instant.now(),
refreshToken = "NOT_YET_GENERATED",
accessToken = "NOT_YET_GENERATED",
),
),
name = name,
description = description,
timezone = timezone,
hostLink = "NOT_YET_GENERATED",
)
)
calendarService.putCalendarWizard(newWizard)
return event.createFollowup(getMessage("create.success", settings))
.withEphemeral(ephemeral)
.withEmbeds(embedService.calendarWizardEmbed(newWizard, settings))
.awaitSingle()
}
private fun name(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
private suspend fun name(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val name = event.options[0].getOption("name")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asString)
.get()
return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasElevatedPermissions).flatMap {
val pre = wizard.get(settings.guildID)
if (pre != null) {
pre.name = name
event.interaction.guild
.map { CalendarEmbed.pre(it, settings, pre) }
.flatMap { event.followupEphemeral(getMessage("name.success", settings), it) }
} else {
event.followupEphemeral(getMessage("error.wizard.notStarted", settings))
}
}.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.elevated", settings)))
// Validate permissions
val hasElevatedPerms = permissionService.hasElevatedPermissions(settings.guildId, event.interaction.user.id)
if (!hasElevatedPerms) return event.createFollowup(getCommonMsg("error.perms.elevated", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Check if wizard not started
val existingWizard = calendarService.getCalendarWizard(settings.guildId, event.interaction.user.id)
if (existingWizard == null) return event.createFollowup(getMessage("error.wizard.notStarted", settings))
.withEphemeral(ephemeral)
.awaitSingle()
val alteredWizard = existingWizard.copy(entity = existingWizard.entity.copy(name = name))
calendarService.putCalendarWizard(alteredWizard)
return event.createFollowup(getMessage("name.success", settings))
.withEmbeds(embedService.calendarWizardEmbed(alteredWizard, settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
private fun description(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
private suspend fun description(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val desc = event.options[0].getOption("description")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asString)
.get()
return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasElevatedPermissions).flatMap {
val pre = wizard.get(settings.guildID)
if (pre != null) {
pre.description = desc
event.interaction.guild
.map { CalendarEmbed.pre(it, settings, pre) }
.flatMap { event.followupEphemeral(getMessage("description.success", settings), it) }
} else {
event.followupEphemeral(getMessage("error.wizard.notStarted", settings))
}
}.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.elevated", settings)))
// Validate permissions
val hasElevatedPerms = permissionService.hasElevatedPermissions(settings.guildId, event.interaction.user.id)
if (!hasElevatedPerms) return event.createFollowup(getCommonMsg("error.perms.elevated", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Check if wizard not started
val existingWizard = calendarService.getCalendarWizard(settings.guildId, event.interaction.user.id)
if (existingWizard == null) return event.createFollowup(getMessage("error.wizard.notStarted", settings))
.withEphemeral(ephemeral)
.awaitSingle()
val alteredWizard = existingWizard.copy(entity = existingWizard.entity.copy(description = desc))
calendarService.putCalendarWizard(alteredWizard)
return event.createFollowup(getMessage("description.success", settings))
.withEmbeds(embedService.calendarWizardEmbed(alteredWizard, settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
private fun timezone(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
private suspend fun timezone(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val timezone = event.options[0].getOption("timezone")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asString)
.get()
.get().toZoneId()
return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasElevatedPermissions).flatMap {
val pre = wizard.get(settings.guildID)
if (pre != null) {
if (timezone.isValidTimezone()) {
pre.timezone = ZoneId.of(timezone)
// Validate permissions
val hasElevatedPerms = permissionService.hasElevatedPermissions(settings.guildId, event.interaction.user.id)
if (!hasElevatedPerms) return event.createFollowup(getCommonMsg("error.perms.elevated", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
event.interaction.guild
.map { CalendarEmbed.pre(it, settings, pre) }
.flatMap { event.followupEphemeral(getMessage("timezone.success", settings), it) }
} else {
event.followupEphemeral(getMessage("timezone.failure.invalid", settings))
}
} else {
event.followupEphemeral(getMessage("error.wizard.notStarted", settings))
}
}.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.elevated", settings)))
// Check if wizard not started
val existingWizard = calendarService.getCalendarWizard(settings.guildId, event.interaction.user.id)
if (existingWizard == null) return event.createFollowup(getMessage("error.wizard.notStarted", settings))
.withEphemeral(ephemeral)
.awaitSingle()
// Timezone will be null if not a valid tz
if (timezone == null) return event.createFollowup(getMessage("timezone.failure.invalid", settings))
.withEphemeral(ephemeral)
.withEmbeds(embedService.calendarWizardEmbed(existingWizard, settings))
.awaitSingle()
val alteredWizard = existingWizard.copy(entity = existingWizard.entity.copy(timezone = timezone))
calendarService.putCalendarWizard(alteredWizard)
return event.createFollowup(getMessage("timezone.success", settings))
.withEphemeral(ephemeral)
.withEmbeds(embedService.calendarWizardEmbed(alteredWizard, settings))
.awaitSingle()
}
private fun review(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasElevatedPermissions).flatMap {
val pre = wizard.get(settings.guildID)
if (pre != null) {
event.interaction.guild.flatMap {
event.followupEphemeral(CalendarEmbed.pre(it, settings, pre))
}
} else {
event.followupEphemeral(getMessage("error.wizard.notStarted", settings))
}
}.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.elevated", settings)))
private suspend fun review(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
// Validate permissions
val hasElevatedPerms = permissionService.hasElevatedPermissions(settings.guildId, event.interaction.user.id)
if (!hasElevatedPerms) return event.createFollowup(getCommonMsg("error.perms.elevated", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Check if wizard not started
val existingWizard = calendarService.getCalendarWizard(settings.guildId, event.interaction.user.id)
if (existingWizard == null) return event.createFollowup(getMessage("error.wizard.notStarted", settings))
.withEphemeral(ephemeral)
.awaitSingle()
return event.createFollowup()
.withEmbeds(embedService.calendarWizardEmbed(existingWizard, settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
private fun confirm(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasElevatedPermissions).flatMap {
val pre = wizard.get(settings.guildID)
if (pre != null) {
if (!pre.hasRequiredValues()) {
return@flatMap event.followupEphemeral(getMessage("confirm.failure.missing", settings))
}
private suspend fun confirm(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
// Validate permissions
val hasElevatedPerms = permissionService.hasElevatedPermissions(settings.guildId, event.interaction.user.id)
if (!hasElevatedPerms) return event.createFollowup(getCommonMsg("error.perms.elevated", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
event.interaction.guild.flatMap { guild ->
if (!pre.editing) {
// New calendar
pre.createSpec(guild)
.flatMap(guild::createCalendar)
.doOnNext { wizard.remove(settings.guildID) }
.flatMap {
event.followupEphemeral(
getMessage("confirm.success.create", settings),
CalendarEmbed.link(guild, settings, it)
)
}.doOnError {
LOGGER.error("Create calendar with command failure", it)
}.onErrorResume {
event.followupEphemeral(getMessage("confirm.failure.create", settings))
}
} else {
// Editing
pre.calendar!!.update(pre.updateSpec())
.filter(UpdateCalendarResponse::success)
.doOnNext { wizard.remove(settings.guildID) }
.flatMap { ucr ->
val updateMessages = mono {
staticMessageService.updateStaticMessages(settings.guildID, ucr.new!!.calendarNumber)
}
event.followupEphemeral(
getMessage("confirm.success.edit", settings),
CalendarEmbed.link(guild, settings, ucr.new!!)
).flatMap { updateMessages.thenReturn(it) }
}.switchIfEmpty(event.followupEphemeral(getMessage("confirm.failure.edit", settings)))
}
}
} else {
event.followupEphemeral(getMessage("error.wizard.notStarted", settings))
}
}.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.elevated", settings)))
// Check if wizard not started
val existingWizard = calendarService.getCalendarWizard(settings.guildId, event.interaction.user.id)
if (existingWizard == null) return event.createFollowup(getMessage("error.wizard.notStarted", settings))
.withEphemeral(ephemeral)
.awaitSingle()
// Would add checks for required values here, but I think I've basically hand-waved that away now
try {
val calendar = if (existingWizard.editing) calendarService.updateCalendar(
settings.guildId,
existingWizard.entity.metadata.number,
Calendar.UpdateSpec(
name = existingWizard.entity.name,
description = existingWizard.entity.description,
timezone = existingWizard.entity.timezone,
)
) else calendarService.createCalendar(
settings.guildId,
Calendar.CreateSpec(
host = existingWizard.entity.metadata.host,
number = existingWizard.entity.metadata.number,
name = existingWizard.entity.name,
description = existingWizard.entity.description,
timezone = existingWizard.entity.timezone,
)
)
calendarService.cancelCalendarWizard(settings.guildId, calendar.metadata.number)
val message = if (existingWizard.editing) getMessage("confirm.success.edit", settings)
else getMessage("confirm.success.create", settings)
return event.createFollowup(message)
.withEmbeds(embedService.linkCalendarEmbed(calendar))
.withEphemeral(ephemeral)
.awaitSingle()
} catch (ex: Exception) {
LOGGER.error("Failed to create calendar via command interaction", ex)
val message = if (existingWizard.editing) getMessage("confirm.failure.edit", settings)
else getMessage("confirm.failure.create", settings)
return event.createFollowup(message)
.withEmbeds(embedService.calendarWizardEmbed(existingWizard, settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
}
private fun cancel(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasElevatedPermissions).flatMap {
wizard.remove(settings.guildID)
private suspend fun cancel(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
// Validate permissions
val hasElevatedPerms = permissionService.hasElevatedPermissions(settings.guildId, event.interaction.user.id)
if (!hasElevatedPerms) return event.createFollowup(getCommonMsg("error.perms.elevated", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
event.followupEphemeral(getMessage("cancel.success", settings))
}.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.elevated", settings)))
calendarService.cancelCalendarWizard(settings.guildId, event.interaction.user.id)
return event.createFollowup(getMessage("cancel.success", settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
private fun delete(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
private suspend fun delete(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val calendarNumber = event.options[0].getOption("calendar")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asLong)
.map(Long::toInt)
.orElse(1)
return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasElevatedPermissions).flatMap {
// Before we delete the calendar, if the wizard is editing that calendar we need to cancel the wizard
val pre = wizard.get(settings.guildID)
if (pre != null && pre.calendar?.calendarNumber == calendarNumber) wizard.remove(settings.guildID)
// Validate permissions
val hasElevatedPerms = permissionService.hasElevatedPermissions(settings.guildId, event.interaction.user.id)
if (!hasElevatedPerms) return event.createFollowup(getCommonMsg("error.perms.elevated", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
event.interaction.guild
.flatMap { it.getCalendar(calendarNumber) }
.flatMap { it.delete() }
.flatMap { event.followupEphemeral(getMessage("delete.success", settings)) }
.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)))
}.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.elevated", settings)))
calendarService.deleteCalendar(settings.guildId, calendarNumber)
return event.createFollowup(getMessage("delete.success", settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
private fun edit(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
private suspend fun edit(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val calendarNumber = event.options[0].getOption("calendar")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asLong)
.map(Long::toInt)
.orElse(1)
return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasElevatedPermissions).flatMap {
if (wizard.get(settings.guildID) == null) {
event.interaction.guild.flatMap { guild ->
guild.getCalendar(calendarNumber)
.map { PreCalendar.edit(it) }
.doOnNext { wizard.start(it) }
.map { CalendarEmbed.pre(guild, settings, it) }
.flatMap { event.followupEphemeral(getMessage("edit.success", settings), it) }
.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)))
}
} else {
event.interaction.guild
.map { CalendarEmbed.pre(it, settings, wizard.get(settings.guildID)!!) }
.flatMap { event.followupEphemeral(getMessage("error.wizard.started", settings), it) }
}
}.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.elevated", settings)))
// Validate permissions
val hasElevatedPerms = permissionService.hasElevatedPermissions(settings.guildId, event.interaction.user.id)
if (!hasElevatedPerms) return event.createFollowup(getCommonMsg("error.perms.elevated", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Check if wizard already started
val existingWizard = calendarService.getCalendarWizard(settings.guildId, event.interaction.user.id)
if (existingWizard != null) return event.createFollowup(getMessage("error.wizard.started", settings))
.withEphemeral(ephemeral)
.withEmbeds(embedService.calendarWizardEmbed(existingWizard, settings))
.awaitSingle()
val calendar = calendarService.getCalendar(settings.guildId, calendarNumber)
if (calendar == null) return event.createFollowup(getCommonMsg("error.notFound.calendar", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
val newWizard = CalendarWizardState(
guildId = settings.guildId,
userId = event.interaction.user.id,
editing = true,
entity = calendar
)
calendarService.putCalendarWizard(newWizard)
return event.createFollowup(getMessage("edit.success", settings))
.withEphemeral(ephemeral)
.withEmbeds(embedService.calendarWizardEmbed(newWizard, settings))
.awaitSingle()
}
}

View File

@@ -7,8 +7,7 @@ import org.dreamexposure.discal.client.commands.SlashCommand
import org.dreamexposure.discal.core.business.AnnouncementService
import org.dreamexposure.discal.core.business.CalendarService
import org.dreamexposure.discal.core.business.EmbedService
import org.dreamexposure.discal.core.extensions.discord4j.followup
import org.dreamexposure.discal.core.`object`.GuildSettings
import org.dreamexposure.discal.core.`object`.new.GuildSettings
import org.springframework.stereotype.Component
@Component
@@ -21,12 +20,16 @@ class DiscalCommand(
override val hasSubcommands = false
override val ephemeral = false
override suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
override suspend fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val announcementCount = announcementService.getAnnouncementCount()
val calendarCount = calendarService.getCalendarCount()
val guildCount = event.client.guilds.count().awaitSingle()
val embed = embedService.discalInfoEmbed(settings, calendarCount, announcementCount)
val embed = embedService.discalInfoEmbed(settings, guildCount, calendarCount, announcementCount)
return event.followup(embed).awaitSingle()
return event.createFollowup()
.withEmbeds(embed)
.withEphemeral(ephemeral)
.awaitSingle()
}
}

View File

@@ -5,26 +5,26 @@ import discord4j.core.`object`.command.ApplicationCommandInteractionOption
import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue
import discord4j.core.`object`.entity.Message
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.dreamexposure.discal.client.commands.SlashCommand
import org.dreamexposure.discal.core.business.CalendarService
import org.dreamexposure.discal.core.business.PermissionService
import org.dreamexposure.discal.core.business.StaticMessageService
import org.dreamexposure.discal.core.extensions.discord4j.followupEphemeral
import org.dreamexposure.discal.core.extensions.discord4j.getCalendar
import org.dreamexposure.discal.core.extensions.discord4j.hasElevatedPermissions
import org.dreamexposure.discal.core.`object`.GuildSettings
import org.dreamexposure.discal.core.`object`.new.GuildSettings
import org.dreamexposure.discal.core.utils.getCommonMsg
import org.springframework.stereotype.Component
@Component
class DisplayCalendarCommand(
private val staticMessageService: StaticMessageService,
private val calendarService: CalendarService,
private val permissionService: PermissionService,
) : SlashCommand {
override val name = "displaycal"
override val hasSubcommands = true
override val ephemeral = true
override suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
override suspend fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
return when (event.options[0].name) {
"new" -> new(event, settings)
else -> throw IllegalStateException("Invalid subcommand specified")
@@ -43,18 +43,24 @@ class DisplayCalendarCommand(
.orElse(1)
// Validate control role
val hasElevatedPerms = event.interaction.member.get().hasElevatedPermissions().awaitSingle()
val hasElevatedPerms = permissionService.hasElevatedPermissions(settings.guildId, event.interaction.user.id)
if (!hasElevatedPerms)
return event.followupEphemeral(getCommonMsg("error.perms.elevated", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.perms.elevated", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Validate calendar exists
val calendar = event.interaction.guild.flatMap { it.getCalendar(calendarNumber) }.awaitSingleOrNull()
val calendar = calendarService.getCalendar(settings.guildId, calendarNumber)
if (calendar == null)
return event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.calendar", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Create and respond
staticMessageService.createStaticMessage(settings.guildID, event.interaction.channelId, calendarNumber, hour)
staticMessageService.createStaticMessage(settings.guildId, event.interaction.channelId, calendarNumber, hour)
return event.followupEphemeral(getCommonMsg("success.generic", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("success.generic", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
}
}

View File

@@ -1,43 +1,42 @@
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.reactive.awaitSingle
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
import org.dreamexposure.discal.core.extensions.discord4j.getCalendar
import org.dreamexposure.discal.core.business.CalendarService
import org.dreamexposure.discal.core.business.EmbedService
import org.dreamexposure.discal.core.`object`.new.GuildSettings
import org.dreamexposure.discal.core.utils.getCommonMsg
import org.springframework.stereotype.Component
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import reactor.function.TupleUtils
import java.time.Instant
import java.time.LocalDate
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
@Component
class EventsCommand : SlashCommand {
class EventsCommand(
private val calendarService: CalendarService,
private val embedService: EmbedService,
) : SlashCommand {
override val name = "events"
override val hasSubcommands = true
override val ephemeral = false
@Deprecated("Use new handleSuspend for K-coroutines")
override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
override suspend fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
return when (event.options[0].name) {
"upcoming" -> upcomingEventsSubcommand(event, settings)
"ongoing" -> ongoingEventsSubcommand(event, settings)
"today" -> eventsTodaySubcommand(event, settings)
"range" -> eventsRangeSubcommand(event, settings)
else -> Mono.empty() //Never can reach this, makes compiler happy.
"upcoming" -> upcomingEvents(event, settings)
"ongoing" -> ongoingEvents(event, settings)
"today" -> eventsToday(event, settings)
"range" -> eventsRange(event, settings)
else -> throw IllegalStateException("Invalid subcommand specified")
}
}
private fun upcomingEventsSubcommand(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
private suspend fun upcomingEvents(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val calendarNumber = event.options[0].getOption("calendar")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asLong)
@@ -49,156 +48,165 @@ class EventsCommand : SlashCommand {
.map(ApplicationCommandInteractionOptionValue::asLong)
.map(Long::toInt)
.orElse(1)
.coerceIn(1, 15)
val events = calendarService.getUpcomingEvents(settings.guildId, calendarNumber, amount)
if (amount < 1 || amount > 15) {
return event.followup(getMessage("upcoming.failure.outOfRange", settings))
}
return if (events.isEmpty()) {
event.createFollowup(getMessage("upcoming.success.none", settings))
.withEphemeral(ephemeral)
.awaitSingle()
} else if (events.size == 1) {
event.createFollowup(getMessage("upcoming.success.one", settings))
.withEmbeds(embedService.fullEventEmbed(events[0], settings))
.withEphemeral(ephemeral)
.awaitSingle()
} else {
val response = event.createFollowup(getMessage("upcoming.success.many", settings, "${events.size}"))
.withEphemeral(ephemeral)
.awaitSingle()
return event.interaction.guild.flatMap { guild ->
guild.getCalendar(calendarNumber).flatMap { cal ->
cal.getUpcomingEvents(amount).collectList().flatMap { events ->
if (events.isEmpty()) {
event.followup(getMessage("upcoming.success.none", settings))
} else if (events.size == 1) {
event.followup(
getMessage("upcoming.success.one", settings),
EventEmbed.getFull(guild, settings, events[0])
)
} else {
event.followup(
getMessage("upcoming.success.many", settings, "${events.size}")
).flatMapMany {
Flux.fromIterable(events)
}.concatMap {
event.followup(EventEmbed.getCondensed(guild, settings, it))
}.last()
}
}
}.switchIfEmpty(event.followup(getCommonMsg("error.notFound.calendar", settings)))
}
}
private fun ongoingEventsSubcommand(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
val calendarNumber = event.options[0].getOption("calendar")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asLong)
.map(Long::toInt)
.orElse(1)
return event.interaction.guild.flatMap { guild ->
guild.getCalendar(calendarNumber).flatMap { cal ->
cal.getOngoingEvents().collectList().flatMap { events ->
if (events.isEmpty()) {
event.followup(getMessage("ongoing.success.none", settings))
} else if (events.size == 1) {
event.followup(
getMessage("ongoing.success.one", settings),
EventEmbed.getFull(guild, settings, events[0])
)
} else {
event.followup(
getMessage("ongoing.success.many", settings, "${events.size}")
).flatMapMany {
Flux.fromIterable(events)
}.concatMap {
event.followup(EventEmbed.getCondensed(guild, settings, it))
}.last()
}
}
}.switchIfEmpty(event.followup(getCommonMsg("error.notFound.calendar", settings)))
}
}
private fun eventsTodaySubcommand(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
val calendarNumber = event.options[0].getOption("calendar")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asLong)
.map(Long::toInt)
.orElse(1)
return event.interaction.guild.flatMap { guild ->
guild.getCalendar(calendarNumber).flatMap { cal ->
cal.getEventsInNext24HourPeriod(Instant.now()).collectList().flatMap { events ->
if (events.isEmpty()) {
event.followup(getMessage("today.success.none", settings))
} else if (events.size == 1) {
event.followup(
getMessage("today.success.one", settings),
EventEmbed.getFull(guild, settings, events[0])
)
} else {
event.followup(
getMessage("today.success.many", settings, "${events.size}")
).flatMapMany {
Flux.fromIterable(events)
}.concatMap {
event.followup(EventEmbed.getCondensed(guild, settings, it))
}.last()
}
}
}.switchIfEmpty(event.followup(getCommonMsg("error.notFound.calendar", settings)))
}
}
private fun eventsRangeSubcommand(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
val gMono = event.interaction.guild.cache()
val calMono = Mono.justOrEmpty(event.options[0].getOption("calendar").flatMap { it.value })
.map { it.asLong().toInt() }
.defaultIfEmpty(1)
.flatMap { num ->
gMono.flatMap {
it.getCalendar(num)
}
}.cache()
val sMono = Mono.justOrEmpty(event.options[0].getOption("start").flatMap { it.value })
.map { it.asString() }
.flatMap { value ->
calMono.map {
val formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd")
LocalDate.parse(value, formatter).atStartOfDay(it.timezone)
}
}.map(ZonedDateTime::toInstant)
val eMono = Mono.justOrEmpty(event.options[0].getOption("end").flatMap { it.value })
.map { it.asString() }
.flatMap { value ->
calMono.map {
val formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd")
//At end of day
LocalDate.parse(value, formatter).plusDays(1).atStartOfDay(it.timezone)
}
}.map(ZonedDateTime::toInstant)
return Mono.zip(gMono, calMono, sMono, eMono).flatMap(
TupleUtils.function { guild, cal, start, end ->
cal.getEventsInTimeRange(start, end).collectList().flatMap { events ->
if (events.isEmpty()) {
event.followup(getMessage("range.success.none", settings))
} else if (events.size == 1) {
event.followup(
getMessage("range.success.one", settings),
EventEmbed.getFull(guild, settings, events[0])
)
} else if (events.size > 15) {
event.followup(getMessage("range.success.tooMany", settings, "${events.size}", cal.link))
} else {
event.followup(
getMessage("range.success.many", settings, "${events.size}")
).flatMapMany {
Flux.fromIterable(events)
}.concatMap {
event.followup(EventEmbed.getCondensed(guild, settings, it))
}.last()
}
}
}).switchIfEmpty(event.followup(getCommonMsg("error.notFound.calendar", settings)))
.onErrorResume(DateTimeParseException::class.java) {
event.followup(getCommonMsg("error.format.date", settings))
events.forEach {
event.createFollowup()
.withEmbeds(embedService.condensedEventEmbed(it, settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
response
}
}
private suspend fun ongoingEvents(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val calendarNumber = event.options[0].getOption("calendar")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asLong)
.map(Long::toInt)
.orElse(1)
val events = calendarService.getOngoingEvents(settings.guildId, calendarNumber)
return if (events.isEmpty()) {
event.createFollowup(getMessage("ongoing.success.none", settings))
.withEphemeral(ephemeral)
.awaitSingle()
} else if (events.size == 1) {
event.createFollowup(getMessage("ongoing.success.one", settings))
.withEmbeds(embedService.fullEventEmbed(events[0], settings))
.withEphemeral(ephemeral)
.awaitSingle()
} else {
val response = event.createFollowup(getMessage("ongoing.success.many", settings, "${events.size}"))
.withEphemeral(ephemeral)
.awaitSingle()
events.forEach {
event.createFollowup()
.withEmbeds(embedService.condensedEventEmbed(it, settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
response
}
}
private suspend fun eventsToday(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val calendarNumber = event.options[0].getOption("calendar")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asLong)
.map(Long::toInt)
.orElse(1)
val events = calendarService.getEventsInNext24HourPeriod(settings.guildId, calendarNumber, Instant.now())
return if (events.isEmpty()) {
event.createFollowup(getMessage("today.success.none", settings))
.withEphemeral(ephemeral)
.awaitSingle()
} else if (events.size == 1) {
event.createFollowup(getMessage("today.success.one", settings))
.withEmbeds(embedService.fullEventEmbed(events[0], settings))
.withEphemeral(ephemeral)
.awaitSingle()
} else {
val response = event.createFollowup(getMessage("today.success.many", settings, "${events.size}"))
.withEphemeral(ephemeral)
.awaitSingle()
events.forEach {
event.createFollowup()
.withEmbeds(embedService.condensedEventEmbed(it, settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
response
}
}
private suspend fun eventsRange(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val calendarNumber = event.options[0].getOption("calendar")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asLong)
.map(Long::toInt)
.orElse(1)
val startInput = event.options[0].getOption("start")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asString)
.get()
val endInput = event.options[0].getOption("end")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asString)
.get()
val formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd")
// In order to parse the inputs with timezone, we need to fetch the calendar
val calendar = calendarService.getCalendar(settings.guildId, calendarNumber)
if (calendar == null) {
return event.createFollowup(getCommonMsg("error.notFound.calendar", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
}
try {
val start = LocalDate.parse(startInput, formatter).atStartOfDay(calendar.timezone).toInstant()
val end = LocalDate.parse(endInput, formatter).plusDays(1).atStartOfDay(calendar.timezone).toInstant()
val events = calendarService.getEventsInTimeRange(settings.guildId, calendarNumber, start, end)
return if (events.isEmpty()) {
event.createFollowup(getMessage("range.success.none", settings))
.withEphemeral(ephemeral)
.awaitSingle()
} else if (events.size == 1) {
event.createFollowup(getMessage("range.success.one", settings))
.withEmbeds(embedService.fullEventEmbed(events[0], settings))
.withEphemeral(ephemeral)
.awaitSingle()
} else if (events.size > 15) {
event.createFollowup(getMessage("range.success.tooMany", settings, "${events.size}", calendar.link))
.withEphemeral(ephemeral)
.awaitSingle()
} else {
val response = event.createFollowup(getMessage("range.success.many", settings, "${events.size}"))
.withEphemeral(ephemeral)
.awaitSingle()
events.forEach {
event.createFollowup()
.withEmbeds(embedService.condensedEventEmbed(it, settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
response
}
} catch (_: DateTimeParseException) {
return event.createFollowup(getCommonMsg("error.format.date", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
}
}
}

View File

@@ -5,8 +5,7 @@ import discord4j.core.`object`.entity.Message
import kotlinx.coroutines.reactor.awaitSingle
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.dreamexposure.discal.core.`object`.new.GuildSettings
import org.springframework.stereotype.Component
@Component
@@ -15,9 +14,9 @@ class HelpCommand : SlashCommand {
override val hasSubcommands = false
override val ephemeral = true
override suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
return event.followupEphemeral(
override suspend fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
return event.createFollowup(
getMessage("error.workInProgress", settings, "${Config.URL_BASE.getString()}/commands")
).awaitSingle()
).withEphemeral(ephemeral).awaitSingle()
}
}

View File

@@ -5,25 +5,27 @@ import discord4j.core.`object`.command.ApplicationCommandInteractionOption
import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue
import discord4j.core.`object`.entity.Message
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.dreamexposure.discal.client.commands.SlashCommand
import org.dreamexposure.discal.core.business.CalendarService
import org.dreamexposure.discal.core.business.EmbedService
import org.dreamexposure.discal.core.extensions.discord4j.followup
import org.dreamexposure.discal.core.extensions.discord4j.getCalendar
import org.dreamexposure.discal.core.`object`.GuildSettings
import org.dreamexposure.discal.core.config.Config
import org.dreamexposure.discal.core.`object`.new.GuildSettings
import org.dreamexposure.discal.core.utils.getCommonMsg
import org.springframework.stereotype.Component
@Component
class LinkCalendarCommand(
private val embedService: EmbedService,
private val calendarService: CalendarService,
) : SlashCommand {
override val name = "linkcal"
override val hasSubcommands = false
override val ephemeral = false
private val OVERVIEW_EVENT_COUNT = Config.CALENDAR_OVERVIEW_DEFAULT_EVENT_COUNT.getInt()
override suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
override suspend fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val showOverview = event.getOption("overview")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asBoolean)
@@ -34,13 +36,20 @@ class LinkCalendarCommand(
.map(Long::toInt)
.orElse(1)
val calendar = event.interaction.guild.flatMap {
it.getCalendar(calendarNumber)
}.awaitSingleOrNull()
val calendar = calendarService.getCalendar(settings.guildId, calendarNumber)
if (calendar == null) {
return event.followup(getCommonMsg("error.notFound.calendar", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.calendar", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
}
return event.followup(embedService.linkCalendarEmbed(calendarNumber, settings, showOverview)).awaitSingle()
val events = if (showOverview)
calendarService.getUpcomingEvents(settings.guildId, calendarNumber, OVERVIEW_EVENT_COUNT)
else null
return event.createFollowup()
.withEmbeds(embedService.linkCalendarEmbed(calendar, events))
.withEphemeral(ephemeral)
.awaitSingle()
}
}

View File

@@ -5,15 +5,12 @@ import discord4j.core.`object`.command.ApplicationCommandInteractionOption
import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue
import discord4j.core.`object`.entity.Message
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.dreamexposure.discal.client.commands.SlashCommand
import org.dreamexposure.discal.core.business.CalendarService
import org.dreamexposure.discal.core.business.EmbedService
import org.dreamexposure.discal.core.business.PermissionService
import org.dreamexposure.discal.core.business.RsvpService
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.discord4j.hasElevatedPermissions
import org.dreamexposure.discal.core.`object`.GuildSettings
import org.dreamexposure.discal.core.`object`.new.GuildSettings
import org.dreamexposure.discal.core.utils.getCommonMsg
import org.springframework.stereotype.Component
import reactor.core.publisher.Mono
@@ -22,13 +19,15 @@ import reactor.core.publisher.Mono
class RsvpCommand(
private val rsvpService: RsvpService,
private val embedService: EmbedService,
private val permissionService: PermissionService,
private val calendarService: CalendarService,
) : SlashCommand {
override val name = "rsvp"
override val hasSubcommands = true
override val ephemeral = true
override suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
override suspend fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
return when (event.options[0].name) {
"ontime" -> onTime(event, settings)
"late" -> late(event, settings)
@@ -54,34 +53,39 @@ class RsvpCommand(
.get()
val userId = event.interaction.user.id
val guild = event.interaction.guild.awaitSingle()
val calendar = guild.getCalendar(calendarNumber).awaitSingleOrNull()
val calendarEvent = calendar?.getEvent(eventId)?.awaitSingleOrNull()
val calendar = calendarService.getCalendar(settings.guildId, calendarNumber)
val calendarEvent = calendarService.getEvent(settings.guildId, calendarNumber, eventId)
// Validate required conditions
if (calendar == null)
return event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.calendar", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
if (calendarEvent == null)
return event.followupEphemeral(getCommonMsg("error.notFound.event", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.event", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
if (calendarEvent.isOver())
return event.followupEphemeral(getCommonMsg("error.event.ended", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.event.ended", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
var rsvp = rsvpService.getRsvp(guild.id, eventId)
var rsvp = rsvpService.getRsvp(settings.guildId, eventId)
return if (rsvp.hasRoom(userId)) {
rsvp = rsvpService.upsertRsvp(rsvp.copyWithUserStatus(userId, goingOnTime = rsvp.goingOnTime + userId))
event.followupEphemeral(
getMessage("onTime.success", settings),
embedService.rsvpListEmbed(calendarEvent, rsvp, settings)
).awaitSingle()
event.createFollowup(getMessage("onTime.success", settings))
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
.withEphemeral(ephemeral)
.awaitSingle()
} else {
rsvp = rsvpService.upsertRsvp(rsvp.copyWithUserStatus(userId, waitlist = rsvp.waitlist + userId))
event.followupEphemeral(
getMessage("onTime.failure.limit", settings),
embedService.rsvpListEmbed(calendarEvent, rsvp, settings)
).awaitSingle()
event.createFollowup(getMessage("onTime.failure.limit", settings))
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
}
@@ -97,34 +101,39 @@ class RsvpCommand(
.get()
val userId = event.interaction.user.id
val guild = event.interaction.guild.awaitSingle()
val calendar = guild.getCalendar(calendarNumber).awaitSingleOrNull()
val calendarEvent = calendar?.getEvent(eventId)?.awaitSingleOrNull()
val calendar = calendarService.getCalendar(settings.guildId, calendarNumber)
val calendarEvent = calendarService.getEvent(settings.guildId, calendarNumber, eventId)
// Validate required conditions
if (calendar == null)
return event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.calendar", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
if (calendarEvent == null)
return event.followupEphemeral(getCommonMsg("error.notFound.event", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.event", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
if (calendarEvent.isOver())
return event.followupEphemeral(getCommonMsg("error.event.ended", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.event.ended", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
var rsvp = rsvpService.getRsvp(guild.id, eventId)
var rsvp = rsvpService.getRsvp(settings.guildId, eventId)
return if (rsvp.hasRoom(userId)) {
rsvp = rsvpService.upsertRsvp(rsvp.copyWithUserStatus(userId, goingLate = rsvp.goingLate + userId))
event.followupEphemeral(
getMessage("late.success", settings),
embedService.rsvpListEmbed(calendarEvent, rsvp, settings)
).awaitSingle()
event.createFollowup(getMessage("late.success", settings))
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
.withEphemeral(ephemeral)
.awaitSingle()
} else {
rsvp = rsvpService.upsertRsvp(rsvp.copy(waitlist = rsvp.waitlist + userId))
event.followupEphemeral(
getMessage("late.failure.limit", settings),
embedService.rsvpListEmbed(calendarEvent, rsvp, settings)
).awaitSingle()
event.createFollowup(getMessage("late.failure.limit", settings))
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
}
@@ -140,26 +149,31 @@ class RsvpCommand(
.get()
val userId = event.interaction.user.id
val guild = event.interaction.guild.awaitSingle()
val calendar = guild.getCalendar(calendarNumber).awaitSingleOrNull()
val calendarEvent = calendar?.getEvent(eventId)?.awaitSingleOrNull()
val calendar = calendarService.getCalendar(settings.guildId, calendarNumber)
val calendarEvent = calendarService.getEvent(settings.guildId, calendarNumber, eventId)
// Validate required conditions
if (calendar == null)
return event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.calendar", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
if (calendarEvent == null)
return event.followupEphemeral(getCommonMsg("error.notFound.event", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.event", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
if (calendarEvent.isOver())
return event.followupEphemeral(getCommonMsg("error.event.ended", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.event.ended", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
var rsvp = rsvpService.getRsvp(guild.id, eventId)
var rsvp = rsvpService.getRsvp(settings.guildId, eventId)
rsvp = rsvpService.upsertRsvp(rsvp.copyWithUserStatus(userId, undecided = rsvp.undecided + userId))
return event.followupEphemeral(
getMessage("unsure.success", settings),
embedService.rsvpListEmbed(calendarEvent, rsvp, settings)
).awaitSingle()
return event.createFollowup(getMessage("unsure.success", settings))
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
private suspend fun notGoing(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
@@ -174,26 +188,31 @@ class RsvpCommand(
.get()
val userId = event.interaction.user.id
val guild = event.interaction.guild.awaitSingle()
val calendar = guild.getCalendar(calendarNumber).awaitSingleOrNull()
val calendarEvent = calendar?.getEvent(eventId)?.awaitSingleOrNull()
val calendar = calendarService.getCalendar(settings.guildId, calendarNumber)
val calendarEvent = calendarService.getEvent(settings.guildId, calendarNumber, eventId)
// Validate required conditions
if (calendar == null)
return event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.calendar", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
if (calendarEvent == null)
return event.followupEphemeral(getCommonMsg("error.notFound.event", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.event", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
if (calendarEvent.isOver())
return event.followupEphemeral(getCommonMsg("error.event.ended", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.event.ended", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
var rsvp = rsvpService.getRsvp(guild.id, eventId)
var rsvp = rsvpService.getRsvp(settings.guildId, eventId)
rsvp = rsvpService.upsertRsvp(rsvp.copyWithUserStatus(userId, notGoing = rsvp.notGoing + userId))
return event.followupEphemeral(
getMessage("notGoing.success", settings),
embedService.rsvpListEmbed(calendarEvent, rsvp, settings)
).awaitSingle()
return event.createFollowup(getMessage("notGoing.success", settings))
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
private suspend fun remove(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
@@ -208,26 +227,31 @@ class RsvpCommand(
.get()
val userId = event.interaction.user.id
val guild = event.interaction.guild.awaitSingle()
val calendar = guild.getCalendar(calendarNumber).awaitSingleOrNull()
val calendarEvent = calendar?.getEvent(eventId)?.awaitSingleOrNull()
val calendar = calendarService.getCalendar(settings.guildId, calendarNumber)
val calendarEvent = calendarService.getEvent(settings.guildId, calendarNumber, eventId)
// Validate required conditions
if (calendar == null)
return event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.calendar", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
if (calendarEvent == null)
return event.followupEphemeral(getCommonMsg("error.notFound.event", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.event", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
if (calendarEvent.isOver())
return event.followupEphemeral(getCommonMsg("error.event.ended", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.event.ended", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
var rsvp = rsvpService.getRsvp(guild.id, eventId)
var rsvp = rsvpService.getRsvp(settings.guildId, eventId)
rsvp = rsvpService.upsertRsvp(rsvp.copyWithUserStatus(userId))
return event.followupEphemeral(
getMessage("remove.success", settings),
embedService.rsvpListEmbed(calendarEvent, rsvp, settings)
).awaitSingle()
return event.createFollowup(getMessage("remove.success", settings))
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
private suspend fun list(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
@@ -241,19 +265,25 @@ class RsvpCommand(
.map(ApplicationCommandInteractionOptionValue::asString)
.get()
val guild = event.interaction.guild.awaitSingle()
val calendar = guild.getCalendar(calendarNumber).awaitSingleOrNull()
val calendarEvent = calendar?.getEvent(eventId)?.awaitSingleOrNull()
val calendar = calendarService.getCalendar(settings.guildId, calendarNumber)
val calendarEvent = calendarService.getEvent(settings.guildId, calendarNumber, eventId)
// Validate required conditions
if (calendar == null)
return event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.calendar", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
if (calendarEvent == null)
return event.followupEphemeral(getCommonMsg("error.notFound.event", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.event", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
val rsvp = rsvpService.getRsvp(guild.id, eventId)
val rsvp = rsvpService.getRsvp(settings.guildId, eventId)
return event.followupEphemeral(embedService.rsvpListEmbed(calendarEvent, rsvp, settings)).awaitSingle()
return event.createFollowup()
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
private suspend fun limit(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
@@ -275,36 +305,45 @@ class RsvpCommand(
.get()
// Validate control role first to reduce work
val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle()
val hasControlRole = permissionService.hasControlRole(settings.guildId, event.interaction.user.id)
if (!hasControlRole)
return event.followupEphemeral(getCommonMsg("error.perms.privileged", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.perms.privileged", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
val guild = event.interaction.guild.awaitSingle()
val calendar = guild.getCalendar(calendarNumber).awaitSingleOrNull()
val calendarEvent = calendar?.getEvent(eventId)?.awaitSingleOrNull()
val calendar = calendarService.getCalendar(settings.guildId, calendarNumber)
val calendarEvent = calendarService.getEvent(settings.guildId, calendarNumber, eventId)
// Validate required conditions
if (calendar == null)
return event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.calendar", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
if (calendarEvent == null)
return event.followupEphemeral(getCommonMsg("error.notFound.event", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.event", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
if (calendarEvent.isOver())
return event.followupEphemeral(getCommonMsg("error.event.ended", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.event.ended", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
var rsvp = rsvpService.getRsvp(guild.id, eventId)
var rsvp = rsvpService.getRsvp(settings.guildId, eventId)
rsvp = rsvpService.upsertRsvp(rsvp.copy(limit = limit))
return event.followupEphemeral(
getMessage("limit.success", settings, limit.toString()),
embedService.rsvpListEmbed(calendarEvent, rsvp, settings)
).awaitSingle()
return event.createFollowup(getMessage("limit.success", settings, limit.toString()))
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
private suspend fun role(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
if (!settings.patronGuild)
return event.followupEphemeral(getCommonMsg("error.patronOnly", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.patronOnly", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
val calendarNumber = event.options[0].getOption("calendar")
.flatMap(ApplicationCommandInteractionOption::getValue)
@@ -321,29 +360,39 @@ class RsvpCommand(
// Validate control role first to reduce work
val hasElevatedPerms = event.interaction.member.get().hasElevatedPermissions().awaitSingle()
val hasElevatedPerms = permissionService.hasElevatedPermissions(settings.guildId, event.interaction.user.id)
if (!hasElevatedPerms)
return event.followupEphemeral(getCommonMsg("error.perms.elevated", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.perms.elevated", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
val guild = event.interaction.guild.awaitSingle()
val calendar = guild.getCalendar(calendarNumber).awaitSingleOrNull()
val calendarEvent = calendar?.getEvent(eventId)?.awaitSingleOrNull()
val calendar = calendarService.getCalendar(settings.guildId, calendarNumber)
val calendarEvent = calendarService.getEvent(settings.guildId, calendarNumber, eventId)
// Validate required conditions
if (calendar == null)
return event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.calendar", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
if (calendarEvent == null)
return event.followupEphemeral(getCommonMsg("error.notFound.event", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.event", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
if (calendarEvent.isOver())
return event.followupEphemeral(getCommonMsg("error.event.ended", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.event.ended", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
var rsvp = rsvpService.getRsvp(guild.id, eventId)
var rsvp = rsvpService.getRsvp(settings.guildId, eventId)
rsvp = rsvpService.upsertRsvp(rsvp.copy(role = if (role.isEveryone) null else role.id))
val embed = embedService.rsvpListEmbed(calendarEvent, rsvp, settings)
val message = if (role.isEveryone) getMessage("role.success.remove", settings) else getMessage("role.success.set", settings, role.name)
return if (role.isEveryone) event.followupEphemeral(getMessage("role.success.remove", settings), embed).awaitSingle()
else event.followupEphemeral(getMessage("role.success.set", settings, role.name), embed).awaitSingle()
return event.createFollowup(message)
.withEmbeds(embed)
.withEphemeral(ephemeral)
.awaitSingle()
}
}

View File

@@ -4,91 +4,99 @@ 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 kotlinx.coroutines.reactor.awaitSingle
import org.dreamexposure.discal.client.commands.SlashCommand
import org.dreamexposure.discal.client.message.embed.SettingsEmbed
import org.dreamexposure.discal.core.database.DatabaseManager
import org.dreamexposure.discal.core.enums.announcement.AnnouncementStyle
import org.dreamexposure.discal.core.business.EmbedService
import org.dreamexposure.discal.core.business.GuildSettingsService
import org.dreamexposure.discal.core.business.PermissionService
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.`object`.new.GuildSettings
import org.dreamexposure.discal.core.utils.getCommonMsg
import org.springframework.stereotype.Component
import reactor.core.publisher.Mono
import java.util.*
@Component
class SettingsCommand : SlashCommand {
class SettingsCommand(
private val settingsService: GuildSettingsService,
private val embedService: EmbedService,
private val permissionService: PermissionService,
) : SlashCommand {
override val name = "settings"
override val hasSubcommands = true
override val ephemeral = true
@Deprecated("Use new handleSuspend for K-coroutines")
override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
//Check if user has permission to use this
return event.interaction.member.get().hasElevatedPermissions().flatMap { hasPerm ->
if (hasPerm) {
return@flatMap when (event.options[0].name) {
"view" -> viewSubcommand(event, settings)
"role" -> roleSubcommand(event, settings)
"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.
}
} else {
event.followupEphemeral(getCommonMsg("error.perms.elevated", settings))
}
override suspend fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
// Validate permissions
val hasElevatedPerms = permissionService.hasElevatedPermissions(settings.guildId, event.interaction.user.id)
if (!hasElevatedPerms) return event.createFollowup(getCommonMsg("error.perms.elevated", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
return when (event.options[0].name) {
"view" -> view(event, settings)
"role" -> role(event, settings)
"announcement-style" -> announcementStyle(event, settings)
"language" -> language(event, settings)
"time-format" -> timeFormat(event, settings)
"keep-event-duration" -> eventKeepDuration(event, settings)
"branding" -> branding(event, settings)
else -> throw IllegalStateException("Invalid subcommand specified")
}
}
private fun viewSubcommand(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
return event.interaction.guild
.flatMap { SettingsEmbed.getView(it, settings) }
.flatMap(event::followup)
private suspend fun view(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
return event.createFollowup()
.withEmbeds(embedService.settingsEmbeds(settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
private fun roleSubcommand(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
return Mono.justOrEmpty(event.options[0].getOption("role"))
.map { it.value.get() }
.flatMap(ApplicationCommandInteractionOptionValue::asRole)
.doOnNext { settings.controlRole = it.id.asString() }
.flatMap { role ->
DatabaseManager.updateSettings(settings).then(
event.followupEphemeral(getMessage("role.success", settings, role.name))
)
}
private suspend fun role(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val roleId = event.options[0].getOption("role")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asSnowflake)
.orElse(settings.guildId)
val newSettings= settingsService.upsertSettings(settings.copy(controlRole = roleId))
return event.createFollowup(getMessage("role.success", settings))
.withEmbeds(embedService.settingsEmbeds(newSettings))
.withEphemeral(ephemeral)
.awaitSingle()
}
private fun announcementStyleSubcommand(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
val announcementStyle = event.options[0].getOption("style")
private suspend fun announcementStyle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val style = event.options[0].getOption("style")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asLong)
.map(Long::toInt)
.map(AnnouncementStyle::fromValue)
.map { v -> GuildSettings.AnnouncementStyle.entries.first { it.value == v } }
.get()
settings.announcementStyle = announcementStyle
val newSettings = settingsService.upsertSettings(settings.copy(interfaceStyle = settings.interfaceStyle.copy(announcementStyle = style)))
return DatabaseManager.updateSettings(settings)
.flatMap { event.followupEphemeral(getMessage("style.success", settings, announcementStyle.name)) }
return event.createFollowup(getMessage("style.success", settings, style.name))
.withEmbeds(embedService.settingsEmbeds(newSettings))
.withEphemeral(ephemeral)
.awaitSingle()
}
private fun languageSubcommand(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
val lang = event.options[0].getOption("lang")
private suspend fun language(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val locale = event.options[0].getOption("lang")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asString)
.map(Locale::forLanguageTag)
.get()
settings.lang = lang
val newSettings = settingsService.upsertSettings(settings.copy(locale = locale))
return DatabaseManager.updateSettings(settings)
.flatMap { event.followupEphemeral(getMessage("lang.success", settings)) }
return event.createFollowup(getMessage("lang.success", newSettings))
.withEmbeds(embedService.settingsEmbeds(newSettings))
.withEphemeral(ephemeral)
.awaitSingle()
}
private fun timeFormatSubcommand(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
private suspend fun timeFormat(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val timeFormat = event.options[0].getOption("format")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asLong)
@@ -96,37 +104,43 @@ class SettingsCommand : SlashCommand {
.map(TimeFormat::fromValue)
.get()
settings.timeFormat = timeFormat
val newSettings = settingsService.upsertSettings(settings.copy(interfaceStyle = settings.interfaceStyle.copy(timeFormat = timeFormat)))
return DatabaseManager.updateSettings(settings)
.flatMap { event.followupEphemeral(getMessage("format.success", settings, timeFormat.name)) }
return event.createFollowup(getMessage("format.success", settings, timeFormat.name))
.withEmbeds(embedService.settingsEmbeds(newSettings))
.withEphemeral(ephemeral)
.awaitSingle()
}
private fun eventKeepDurationSubcommand(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
private suspend fun eventKeepDuration(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val keepDuration = event.options[0].getOption("value")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asBoolean)
.get()
settings.eventKeepDuration = keepDuration
val newSettings = settingsService.upsertSettings(settings.copy(eventKeepDuration = keepDuration))
return DatabaseManager.updateSettings(settings)
.flatMap { event.followupEphemeral(getMessage("eventKeepDuration.success.$keepDuration", settings)) }
return event.createFollowup(getMessage("eventKeepDuration.success.$keepDuration", settings))
.withEmbeds(embedService.settingsEmbeds(newSettings))
.withEphemeral(ephemeral)
.awaitSingle()
}
private fun brandingSubcommand(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
return if (settings.patronGuild) {
val useBranding = event.options[0].getOption("use")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asBoolean)
.get()
private suspend fun branding(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val useBranding = event.options[0].getOption("use")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asBoolean)
.get()
settings.branded = useBranding
if (!settings.patronGuild) return event.createFollowup(getCommonMsg("error.patronOnly", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
DatabaseManager.updateSettings(settings)
.flatMap { event.followupEphemeral(getMessage("brand.success", settings, "$useBranding")) }
} else {
event.followupEphemeral(getCommonMsg("error.patronOnly", settings))
}
val newSettings = settingsService.upsertSettings(settings.copy(interfaceStyle = settings.interfaceStyle.copy(branded = useBranding)))
return event.createFollowup(getMessage("brand.success", settings, "$useBranding"))
.withEmbeds(embedService.settingsEmbeds(newSettings))
.withEphemeral(ephemeral)
.awaitSingle()
}
}

View File

@@ -5,38 +5,39 @@ import discord4j.core.`object`.command.ApplicationCommandInteractionOption
import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue
import discord4j.core.`object`.entity.Message
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.dreamexposure.discal.client.commands.SlashCommand
import org.dreamexposure.discal.core.business.CalendarService
import org.dreamexposure.discal.core.business.EmbedService
import org.dreamexposure.discal.core.extensions.discord4j.followup
import org.dreamexposure.discal.core.extensions.discord4j.getCalendar
import org.dreamexposure.discal.core.`object`.GuildSettings
import org.dreamexposure.discal.core.`object`.new.GuildSettings
import org.dreamexposure.discal.core.utils.getCommonMsg
import org.springframework.stereotype.Component
@Component
class TimeCommand(
private val embedService: EmbedService,
private val calendarService: CalendarService,
) : SlashCommand {
override val name = "time"
override val hasSubcommands = false
override val ephemeral = true
override suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
override suspend fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
val calendarNumber = event.getOption("calendar")
.flatMap(ApplicationCommandInteractionOption::getValue)
.map(ApplicationCommandInteractionOptionValue::asLong)
.map(Long::toInt)
.orElse(1)
val calendar = event.interaction.guild.flatMap {
it.getCalendar(calendarNumber)
}.awaitSingleOrNull()
val calendar = calendarService.getCalendar(settings.guildId, calendarNumber)
if (calendar == null) {
return event.followup(getCommonMsg("error.notFound.calendar", settings)).awaitSingle()
return event.createFollowup(getCommonMsg("error.notFound.calendar", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
}
return event.followup(embedService.calendarTimeEmbed(calendar, settings)).awaitSingle()
return event.createFollowup()
.withEmbeds(embedService.calendarTimeEmbed(calendar, settings))
.withEphemeral(ephemeral)
.awaitSingle()
}
}

View File

@@ -1,41 +1,49 @@
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 kotlinx.coroutines.reactor.awaitSingle
import org.dreamexposure.discal.client.commands.SlashCommand
import org.dreamexposure.discal.core.business.CalendarService
import org.dreamexposure.discal.core.business.PermissionService
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.`object`.new.GuildSettings
import org.dreamexposure.discal.core.utils.getCommonMsg
import org.springframework.stereotype.Component
import reactor.core.publisher.Mono
@Component
class AddCalCommand : SlashCommand {
class AddCalCommand(
private val calendarService: CalendarService,
private val permissionService: PermissionService,
) : SlashCommand {
override val name = "addcal"
override val hasSubcommands = false
override val ephemeral = true
@Deprecated("Use new handleSuspend for K-coroutines")
override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono<Message> {
override suspend fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
//TODO: Remove dev-only and switch to patron-only once this is completed
return if (settings.devGuild) {
Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasElevatedPermissions).flatMap {
//Check if a calendar can be added since non-premium only allows 1 calendar.
event.interaction.guild.filterWhen(Guild::canAddCalendar).flatMap {
event.followupEphemeral(getMessage("response.start", settings, getLink(settings)))
}.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.calendar.max", settings)))
}.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.elevated", settings)))
} else {
event.followupEphemeral(getCommonMsg("error.disabled", settings))
}
if (!settings.devGuild) return event.createFollowup(getCommonMsg("error.disabled", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
// Validate permissions
val hasElevatedPerms = permissionService.hasElevatedPermissions(settings.guildId, event.interaction.user.id)
if (!hasElevatedPerms)
return event.createFollowup(getCommonMsg("error.perms.elevated", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
val canAddCalendar = calendarService.canAddNewCalendar(settings.guildId)
if (!canAddCalendar) return event.createFollowup(getCommonMsg("error.calendar.max", settings.locale))
.withEphemeral(ephemeral)
.awaitSingle()
return event.createFollowup(getMessage("response.start", settings, getLink(settings)))
.withEphemeral(ephemeral)
.awaitSingle()
}
private fun getLink(settings: GuildSettings): String {
return "${Config.URL_BASE.getString()}/dashboard/${settings.guildID.asString()}/calendar/new?type=1&step=0"
return "${Config.URL_BASE.getString()}/dashboard/${settings.guildId.asLong()}/calendar/new?type=1&step=0"
}
}

View File

@@ -1,16 +0,0 @@
package org.dreamexposure.discal.client.config
import org.dreamexposure.discal.core.`object`.Wizard
import org.dreamexposure.discal.core.`object`.calendar.PreCalendar
import org.dreamexposure.discal.core.`object`.event.PreEvent
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class DisCalConfig {
@Bean
fun calendarWizard(): Wizard<PreCalendar> = Wizard()
@Bean
fun eventWizard(): Wizard<PreEvent> = Wizard()
}

View File

@@ -1,5 +1,6 @@
package org.dreamexposure.discal.client.config
import discord4j.common.ReactorResources
import discord4j.common.store.Store
import discord4j.common.store.legacy.LegacyStoreLayout
import discord4j.core.DiscordClient
@@ -40,7 +41,10 @@ class DiscordConfig {
stores: StoreService
): GatewayDiscordClient {
return DiscordClientBuilder.create(Config.SECRET_BOT_TOKEN.getString())
.build().gateway()
.setReactorResources(ReactorResources.builder()
.httpClient(ReactorResources.DEFAULT_HTTP_CLIENT.get().metrics(Config.INTEGRATIONS_REACTOR_METRICS.getBoolean()) { s -> s })
.build()
).build().gateway()
.setEnabledIntents(getIntents())
.setSharding(getStrategy())
.setStore(Store.fromLayout(LegacyStoreLayout.of(stores)))

View File

@@ -1,7 +1,7 @@
package org.dreamexposure.discal.client.interaction
import discord4j.core.event.domain.interaction.InteractionCreateEvent
import org.dreamexposure.discal.core.`object`.GuildSettings
import org.dreamexposure.discal.core.`object`.new.GuildSettings
interface InteractionHandler<T: InteractionCreateEvent> {
val ids: Array<String>

View File

@@ -4,7 +4,7 @@ import discord4j.core.event.domain.interaction.ButtonInteractionEvent
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.dreamexposure.discal.core.business.StaticMessageService
import org.dreamexposure.discal.core.logger.LOGGER
import org.dreamexposure.discal.core.`object`.GuildSettings
import org.dreamexposure.discal.core.`object`.new.GuildSettings
import org.dreamexposure.discal.core.utils.getCommonMsg
import org.springframework.stereotype.Component
@@ -21,11 +21,13 @@ class StaticMessageRefreshButton(
.withEphemeral(true)
.awaitSingleOrNull()
staticMessageService.updateStaticMessage(settings.guildID, event.messageId)
staticMessageService.updateStaticMessage(settings.guildId, event.messageId)
} catch (ex: Exception) {
LOGGER.error("Error handling static message refresh button | guildId:${settings.guildID.asLong()} | messageId: ${event.messageId.asLong()}", ex)
LOGGER.error("Error handling static message refresh button | guildId:${settings.guildId.asLong()} | messageId: ${event.messageId.asLong()}", ex)
event.createFollowup(getCommonMsg("error.unknown", settings.getLocale()))
event.createFollowup(getCommonMsg("error.unknown", settings.locale))
.withEphemeral(true)
.awaitSingleOrNull()
}
}
}

View File

@@ -1,7 +1,6 @@
package org.dreamexposure.discal.client.listeners.discord
import discord4j.core.event.domain.message.MessageCreateEvent
import discord4j.core.`object`.entity.Guild
import discord4j.core.`object`.entity.Message
import discord4j.core.`object`.entity.User
import kotlinx.coroutines.reactor.awaitSingle
@@ -9,11 +8,12 @@ import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.dreamexposure.discal.core.business.AnnouncementService
import org.dreamexposure.discal.core.business.CalendarService
import org.dreamexposure.discal.core.business.EmbedService
import org.dreamexposure.discal.core.extensions.discord4j.getSettings
import org.dreamexposure.discal.core.business.GuildSettingsService
import org.springframework.stereotype.Component
@Component
class BotMentionListener(
private val settingsService: GuildSettingsService,
private val announcementService: AnnouncementService,
private val calendarService: CalendarService,
private val embedService: EmbedService,
@@ -23,14 +23,17 @@ class BotMentionListener(
&& !event.message.author.map(User::isBot).orElse(false) // Not from a bot
&& onlyMentionsBot(event.message)
) {
val settings = event.guild.flatMap(Guild::getSettings).awaitSingle()
val settings = settingsService.getSettings(event.guildId.get())
val announcementCount = announcementService.getAnnouncementCount()
val calendarCount = calendarService.getCalendarCount()
val guildCount = event.client.guilds.count().awaitSingle()
val channel = event.message.channel.awaitSingle()
val embed = embedService.discalInfoEmbed(settings, calendarCount, announcementCount)
val embed = embedService.discalInfoEmbed(settings, guildCount, calendarCount, announcementCount)
channel.createMessage(embed).awaitSingleOrNull()
channel.createMessage(embed)
.withMessageReference(event.message.id)
.awaitSingleOrNull()
}
}

View File

@@ -1,11 +1,10 @@
package org.dreamexposure.discal.client.listeners.discord
import discord4j.core.event.domain.interaction.ButtonInteractionEvent
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.dreamexposure.discal.client.interaction.InteractionHandler
import org.dreamexposure.discal.core.business.GuildSettingsService
import org.dreamexposure.discal.core.business.MetricService
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.getCommonMsg
@@ -16,6 +15,7 @@ import java.util.*
@Component
class ButtonInteractionListener(
private val buttons: List<InteractionHandler<ButtonInteractionEvent>>,
private val settingsService: GuildSettingsService,
private val metricService: MetricService,
): EventListener<ButtonInteractionEvent> {
override suspend fun handle(event: ButtonInteractionEvent) {
@@ -31,7 +31,7 @@ class ButtonInteractionListener(
if (button != null) {
try {
val settings = DatabaseManager.getSettings(event.interaction.guildId.get()).awaitSingle()
val settings = settingsService.getSettings(event.interaction.guildId.get())
button.handle(event, settings)
} catch (e: Exception) {

View File

@@ -4,16 +4,18 @@ 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.business.GuildSettingsService
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<GuildCreateEvent> {
class GuildCreateListener(
private val settingsService: GuildSettingsService,
objectMapper: ObjectMapper
) : EventListener<GuildCreateEvent> {
private final val premiumCommands: List<ApplicationCommandRequest>
private final val devCommands: List<ApplicationCommandRequest>
@@ -39,9 +41,9 @@ class GuildCreateListener(objectMapper: ObjectMapper) : EventListener<GuildCreat
}
override suspend fun handle(event: GuildCreateEvent) {
val settings = DatabaseManager.getSettings(event.guild.id).awaitSingle()
val guildId = event.guild.id
val settings = settingsService.getSettings(guildId)
val appService = event.client.restClient.applicationService
val guildId = settings.guildID.asLong()
val appId = event.client.selfId.asLong()
val commands = mutableListOf<ApplicationCommandRequest>()
@@ -49,9 +51,9 @@ class GuildCreateListener(objectMapper: ObjectMapper) : EventListener<GuildCreat
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) }
appService.bulkOverwriteGuildApplicationCommand(appId, guildId.asLong(), commands)
.doOnNext { LOGGER.debug("Bulk guild overwrite read: {} | {}", it.name(), guildId) }
.doOnError { LOGGER.error(DEFAULT, "Bulk guild overwrite failed | ${guildId.asLong()}", it) }
.then()
.awaitSingleOrNull()
}

View File

@@ -1,25 +1,22 @@
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.business.GuildSettingsService
import org.dreamexposure.discal.core.business.RsvpService
import org.dreamexposure.discal.core.database.DatabaseManager
import org.springframework.stereotype.Component
@Component
class RoleDeleteListener(
private val rsvpService: RsvpService,
private val settingsService: GuildSettingsService,
) : EventListener<RoleDeleteEvent> {
override suspend fun handle(event: RoleDeleteEvent) {
rsvpService.removeRoleForAll(event.guildId, event.roleId)
DatabaseManager.getSettings(event.guildId)
.filter { !"everyone".equals(it.controlRole, true) }
.filter { event.roleId == Snowflake.of(it.controlRole) }
.doOnNext { it.controlRole = "everyone" }
.flatMap(DatabaseManager::updateSettings)
.awaitSingleOrNull()
val settings = settingsService.getSettings(event.guildId)
if (settings.controlRole != null && event.roleId == settings.controlRole) {
settingsService.upsertSettings(settings.copy(controlRole = null))
}
}
}

View File

@@ -1,11 +1,10 @@
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.business.GuildSettingsService
import org.dreamexposure.discal.core.business.MetricService
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.getCommonMsg
@@ -16,6 +15,7 @@ import java.util.*
@Component
class SlashCommandListener(
private val commands: List<SlashCommand>,
private val settingsService: GuildSettingsService,
private val metricService: MetricService,
) : EventListener<ChatInputInteractionEvent> {
@@ -32,12 +32,13 @@ class SlashCommandListener(
val subCommand = if (command?.hasSubcommands == true) event.options[0].name else null
if (command != null) {
event.deferReply().withEphemeral(command.ephemeral).awaitSingleOrNull()
if (command.shouldDefer(event)) event.deferReply().withEphemeral(command.ephemeral).awaitSingleOrNull()
try {
val settings = DatabaseManager.getSettings(event.interaction.guildId.get()).awaitSingle()
command.suspendHandle(event, settings)
val settings = settingsService.getSettings(event.interaction.guildId.get())
command.handle(event, settings)
} catch (e: Exception) {
LOGGER.error(DEFAULT, "Error handling slash command | $event", e)

View File

@@ -1,59 +0,0 @@
package org.dreamexposure.discal.client.message.embed
import discord4j.core.`object`.entity.Guild
import discord4j.core.spec.EmbedCreateSpec
import org.dreamexposure.discal.core.entities.Calendar
import org.dreamexposure.discal.core.extensions.embedDescriptionSafe
import org.dreamexposure.discal.core.extensions.embedFieldSafe
import org.dreamexposure.discal.core.extensions.embedTitleSafe
import org.dreamexposure.discal.core.extensions.toMarkdown
import org.dreamexposure.discal.core.`object`.GuildSettings
import org.dreamexposure.discal.core.`object`.calendar.PreCalendar
import org.dreamexposure.discal.core.utils.GlobalVal.discalColor
import org.dreamexposure.discal.core.utils.getCommonMsg
object CalendarEmbed : EmbedMaker {
@Deprecated("Use replacement in EmbedService")
fun link(guild: Guild, settings: GuildSettings, calendar: Calendar): EmbedCreateSpec {
val builder = defaultBuilder(guild, settings)
//Handle optional fields
if (calendar.name.isNotBlank())
builder.title(calendar.name.toMarkdown().embedTitleSafe())
if (calendar.description.isNotBlank())
builder.description(calendar.description.toMarkdown().embedDescriptionSafe())
return builder.addField(getMessage("calendar", "link.field.timezone", settings), calendar.zoneName, false)
.addField(getMessage("calendar", "link.field.host", settings), calendar.calendarData.host.name, true)
.addField(getMessage("calendar", "link.field.number", settings), "${calendar.calendarNumber}", true)
.addField(getMessage("calendar", "link.field.id", settings), calendar.calendarId, false)
.url(calendar.link)
.footer(getMessage("calendar", "link.footer.default", settings), null)
.color(discalColor)
.build()
}
fun pre(guild: Guild, settings: GuildSettings, preCal: PreCalendar): EmbedCreateSpec {
val builder = defaultBuilder(guild, settings)
.title(getMessage("calendar", "wizard.title", settings))
.addField(getMessage(
"calendar", "wizard.field.name", settings),
preCal.name.toMarkdown().embedFieldSafe(),
false
).addField(
getMessage("calendar", "wizard.field.description", settings),
preCal.description?.ifEmpty { getCommonMsg("embed.unset", settings) }?.toMarkdown()?.embedFieldSafe()
?: getCommonMsg("embed.unset", settings),
false
).addField(getMessage("calendar", "wizard.field.timezone", settings),
preCal.timezone?.id ?: getCommonMsg("embed.unset", settings),
true
).addField(getMessage("calendar", "wizard.field.host", settings), preCal.host.name, true)
.footer(getMessage("calendar", "wizard.footer", settings), null)
if (preCal.editing)
builder.addField(getMessage("calendar", "wizard.field.id", settings), preCal.calendar!!.calendarId, false)
return builder.build()
}
}

View File

@@ -1,26 +0,0 @@
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.config.Config
import org.dreamexposure.discal.core.`object`.GuildSettings
import org.dreamexposure.discal.core.utils.GlobalVal
import org.dreamexposure.discal.core.utils.getCommonMsg
import org.dreamexposure.discal.core.utils.getEmbedMessage
interface EmbedMaker {
fun defaultBuilder(guild: Guild, settings: GuildSettings): EmbedCreateSpec.Builder {
val builder = EmbedCreateSpec.builder()
if (settings.branded)
builder.author(guild.name, Config.URL_BASE.getString(), guild.getIconUrl(Image.Format.PNG).orElse(GlobalVal.iconUrl))
else
builder.author(getCommonMsg("bot.name", settings), Config.URL_BASE.getString(), GlobalVal.iconUrl)
return builder
}
fun getMessage(embed: String, key: String, settings: GuildSettings, vararg args: String) =
getEmbedMessage(embed, key, settings, *args)
}

View File

@@ -1,160 +0,0 @@
package org.dreamexposure.discal.client.message.embed
import discord4j.core.`object`.entity.Guild
import discord4j.core.spec.EmbedCreateSpec
import org.dreamexposure.discal.core.`object`.GuildSettings
import org.dreamexposure.discal.core.`object`.event.PreEvent
import org.dreamexposure.discal.core.entities.Event
import org.dreamexposure.discal.core.enums.time.DiscordTimestampFormat.LONG_DATETIME
import org.dreamexposure.discal.core.extensions.*
import org.dreamexposure.discal.core.utils.getCommonMsg
object EventEmbed : EmbedMaker {
fun getFull(guild: Guild, settings: GuildSettings, event: Event): EmbedCreateSpec {
val builder = defaultBuilder(guild, settings)
.footer(getMessage("event", "full.footer", settings, event.eventId), null)
.color(event.color.asColor())
if (event.name.isNotBlank())
builder.title(event.name.toMarkdown().embedTitleSafe())
if (event.description.isNotBlank())
builder.description(event.description.toMarkdown().embedDescriptionSafe())
builder.addField(
getMessage("event", "full.field.start", settings),
event.start.asDiscordTimestamp(LONG_DATETIME),
true)
builder.addField(
getMessage("event", "full.field.end", settings),
event.end.asDiscordTimestamp(LONG_DATETIME),
true
)
if (event.location.isNotBlank()) builder.addField(
getMessage("event", "full.field.location", settings),
event.location.toMarkdown().embedFieldSafe(),
false
)
builder.addField(getMessage("event", "full.field.cal", settings), "${event.calendar.calendarNumber}", false)
if (event.image.isNotEmpty())
builder.image(event.image)
return builder.build()
}
fun getCondensed(guild: Guild, settings: GuildSettings, event: Event): EmbedCreateSpec {
val builder = defaultBuilder(guild, settings)
.footer(getMessage("event", "con.footer", settings, event.eventId), null)
.color(event.color.asColor())
if (event.name.isNotBlank())
builder.title(event.name.toMarkdown().embedTitleSafe())
builder.addField(
getMessage("event", "con.field.start", settings),
event.start.asDiscordTimestamp(LONG_DATETIME),
true
)
if (event.location.isNotBlank()) builder.addField(
getMessage("event", "con.field.location", settings),
event.location.toMarkdown().embedFieldSafe(),
false
)
if (event.image.isNotBlank())
builder.thumbnail(event.image)
return builder.build()
}
fun pre(guild: Guild, settings: GuildSettings, event: PreEvent): EmbedCreateSpec {
val builder = defaultBuilder(guild, settings)
.title(getMessage("event", "wizard.title", settings))
.footer(getMessage("event", "wizard.footer", settings), null)
.color(event.color.asColor())
if (!event.name.isNullOrBlank()) builder.addField(
getMessage("event", "wizard.field.name", settings),
event.name!!.toMarkdown().embedFieldSafe(),
false
) else builder.addField(
getMessage("event", "wizard.field.name", settings),
getCommonMsg("embed.unset", settings),
false
)
if (!event.description.isNullOrBlank()) builder.addField(
getMessage("event", "wizard.field.desc", settings),
event.description!!.toMarkdown().embedFieldSafe(),
false
) else builder.addField(
getMessage("event", "wizard.field.desc", settings),
getCommonMsg("embed.unset", settings),
false
)
if (!event.location.isNullOrBlank()) builder.addField(
getMessage("event", "wizard.field.location", settings),
event.location!!.toMarkdown().embedFieldSafe(),
false
) else builder.addField(
getMessage("event", "wizard.field.location", settings),
getCommonMsg("embed.unset", settings),
false
)
if (event.start != null) builder.addField(
getMessage("event", "wizard.field.start", settings),
event.start!!.humanReadableFull(event.timezone, settings.timeFormat),
true
) else builder.addField(
getMessage("event", "wizard.field.start", settings),
getCommonMsg("embed.unset", settings),
true
)
if (event.end != null) builder.addField(
getMessage("event", "wizard.field.end", settings),
event.end!!.humanReadableFull(event.timezone, settings.timeFormat),
true
) else builder.addField(
getMessage("event", "wizard.field.end", settings),
getCommonMsg("embed.unset", settings),
true
)
if (event.recurrence != null) builder.addField(
getMessage("event", "wizard.field.recurrence", settings),
event.recurrence!!.toHumanReadable(),
true
) else if (event.editing && event.eventId != null && event.eventId!!.contains("_")) builder.addField(
getMessage("event", "wizard.field.recurrence", settings),
getMessage("event", "wizard.field.recurrence.child", settings, event.eventId!!.split("_")[0]),
false,
) else builder.addField(
getMessage("event", "wizard.field.recurrence", settings),
getCommonMsg("embed.unset", settings),
true
)
builder.addField(getMessage("event", "wizard.field.timezone", settings), event.timezone.id, false)
if (event.editing)
builder.addField(getMessage("event", "wizard.field.id", settings), event.eventId!!, true)
builder.addField(getMessage("event", "wizard.field.calendar", settings), event.calNumber.toString(), true)
if (event.image != null)
builder.image(event.image!!)
val warnings = event.generateWarnings(settings)
if (warnings.isNotEmpty()) {
val warnText = "```fix\n${warnings.joinToString("\n")}\n```"
builder.addField(getMessage("event", "wizard.field.warnings", settings), warnText, false)
}
return builder.build()
}
}

View File

@@ -1,29 +0,0 @@
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.extensions.discord4j.getControlRole
import org.dreamexposure.discal.core.`object`.GuildSettings
import reactor.core.publisher.Mono
object SettingsEmbed : EmbedMaker {
fun getView(guild: Guild, settings: GuildSettings): Mono<EmbedCreateSpec> {
return guild.getControlRole().map(Role::getName).map { roleName ->
defaultBuilder(guild, settings)
.title(getMessage("settings", "view.title", settings))
.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)
.addField(getMessage("settings", "view.field.cal", settings), "${settings.maxCalendars}", true)
.addField(getMessage("settings", "view.field.brand", settings), "${settings.branded}", false)
.footer(getMessage("settings", "view.footer", settings), null)
.build()
}
}
}

View File

@@ -14,27 +14,9 @@ class Application {
val instanceId: UUID = UUID.randomUUID()
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
This has been an open issue and PR for over 3 years now, and has gone stale as of March 3rd 2021.
So, in order to get the pod ordinal since its not directly exposed, we have to get the hostname, and parse
the ordinal out of that.
To make sure we don't use this when running anywhere but inside of k8s, we are mapping the hostname to an env
variable SHARD_POD_NAME and if that is present, parsing it for the pod ordinal to tell the bot its shard index.
This will be removed when/if they add this feature directly and SHARD_INDEX will be an env variable...
*/
//Check if we are running in k8s or not...
val shardPodName = System.getenv("SHARD_POD_NAME")
return if (shardPodName != null) {
//In k8s, parse this shit
val parts = shardPodName.split("-")
parts[parts.size -1].toInt()
} else {
//Fall back to config value
Config.SHARD_INDEX.getInt()
}
val k8sPodIndex = System.getenv("KUBERNETES_POD_INDEX")
return k8sPodIndex?.toInt() ?: // Fall back to config
Config.SHARD_INDEX.getInt()
}
fun getShardCount(): Int {

View File

@@ -8,15 +8,14 @@ import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.dreamexposure.discal.AnnouncementCache
import org.dreamexposure.discal.AnnouncementWizardStateCache
import org.dreamexposure.discal.core.config.Config
import org.dreamexposure.discal.core.database.AnnouncementData
import org.dreamexposure.discal.core.database.AnnouncementRepository
import org.dreamexposure.discal.core.database.DatabaseManager
import org.dreamexposure.discal.core.entities.Calendar
import org.dreamexposure.discal.core.entities.Event
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`.new.Announcement
import org.dreamexposure.discal.core.`object`.new.AnnouncementWizardState
import org.dreamexposure.discal.core.`object`.new.Event
import org.springframework.beans.factory.BeanFactory
import org.springframework.beans.factory.getBean
import org.springframework.stereotype.Component
@@ -31,12 +30,15 @@ class AnnouncementService(
private val announcementCache: AnnouncementCache,
private val announcementWizardStateCache: AnnouncementWizardStateCache,
private val embedService: EmbedService,
private val calendarService: CalendarService,
private val metricService: MetricService,
private val beanFactory: BeanFactory,
) {
private val discordClient: DiscordClient
get() = beanFactory.getBean()
private val PROCESS_GUILD_DEFAULT_UPCOMING_EVENTS_COUNT = Config.ANNOUNCEMENT_PROCESS_GUILD_DEFAULT_UPCOMING_EVENTS_COUNT.getInt()
suspend fun createAnnouncement(announcement: Announcement): Announcement {
val saved = announcementRepository.save(AnnouncementData(
announcementId = announcement.id,
@@ -62,7 +64,7 @@ class AnnouncementService(
return saved
}
suspend fun getAnnouncementCount(): Long = announcementRepository.count().awaitSingle()
suspend fun getAnnouncementCount(): Long = announcementRepository.countAll().awaitSingle()
suspend fun getAllAnnouncements(shardIndex: Int, shardCount: Int): List<Announcement> {
return announcementRepository.findAllByShardIndexAndEnabledIsTrue(shardCount, shardIndex)
@@ -121,9 +123,13 @@ class AnnouncementService(
.toTypedArray()
announcementCache.put(key = announcement.guildId, value = new)
}
// Cancel any existing wizards
cancelWizard(announcement.guildId, announcement.id)
}
suspend fun deleteAnnouncement(guildId: Snowflake, id: String) {
cancelWizard(guildId, id)
announcementRepository.deleteByAnnouncementId(id).awaitSingleOrNull()
val cached = announcementCache.get(key = guildId)
@@ -133,6 +139,7 @@ class AnnouncementService(
}
suspend fun deleteAnnouncements(guildId: Snowflake, eventId: String) {
cancelWizardByEvent(guildId, eventId)
announcementRepository.deleteAllByGuildIdAndEventId(guildId.asLong(), eventId).awaitSingleOrNull()
val cached = announcementCache.get(key = guildId)
@@ -141,6 +148,13 @@ class AnnouncementService(
}
}
suspend fun deleteAnnouncementsForCalendarDeletion(guildId: Snowflake, calendarNumber: Int) {
cancelWizard(guildId, calendarNumber)
announcementRepository.deleteAllByGuildIdAndCalendarNumber(guildId.asLong(), calendarNumber).awaitSingleOrNull()
announcementRepository.decrementCalendarsByGuildIdAndCalendarNumber(guildId.asLong(), calendarNumber).awaitSingleOrNull()
announcementCache.evict(key = guildId)
}
suspend fun sendAnnouncement(announcement: Announcement, event: Event) {
try {
val channel = discordClient.getChannelById(announcement.channelId)
@@ -154,11 +168,11 @@ class AnnouncementService(
deleteAnnouncement(announcement.guildId, announcement.id)
return
}
val settings = DatabaseManager.getSettings(announcement.guildId).awaitSingle()
val embed = embedService.determineAnnouncementEmbed(announcement, event, settings)
val embed = embedService.determineAnnouncementEmbed(announcement, event)
val message = channel.createMessage(MessageCreateRequest.builder()
.content(announcement.subscribers.buildMentions().messageContentSafe())
.addEmbed(embed.asRequest())
.build()
).awaitSingle()
@@ -197,49 +211,39 @@ class AnnouncementService(
val taskTimer = StopWatch()
taskTimer.start()
val guild = discordClient.getGuildById(guildId)
val calendars: MutableSet<Calendar> = mutableSetOf()
// Since we currently can't look up upcoming events from cache cuz I dunno how, we just hold in very temporary and scoped memory at least
val events: MutableMap<Int, List<Event>> = mutableMapOf()
// TODO: Need to break this out to add handling for modifiers
getAllAnnouncements(guildId, returnDisabled = false).forEach { announcement ->
// Get the calendar
var calendar = calendars.firstOrNull { it.calendarNumber == announcement.calendarNumber }
if (calendar == null) {
calendar = guild.getCalendar(announcement.calendarNumber).awaitSingleOrNull() ?: return@forEach
calendars.add(calendar)
}
// Handle specific type first, since we don't need to fetch all events for this
if (announcement.type == Announcement.Type.SPECIFIC) {
val event = calendar.getEvent(announcement.eventId!!).awaitSingleOrNull() ?: return@forEach
val event = calendarService.getEvent(guildId, announcement.calendarNumber, announcement.eventId!!) ?: return@forEach
if (isInRange(announcement, event, maxDifference)) {
sendAnnouncement(announcement, event)
}
}
// Get the events to filter through
var filteredEvents = events[calendar.calendarNumber]
var filteredEvents = events[announcement.calendarNumber]
if (filteredEvents == null) {
filteredEvents = calendar.getUpcomingEvents(20)
.collectList()
.awaitSingle()
events[calendar.calendarNumber] = filteredEvents
filteredEvents = calendarService.getUpcomingEvents(guildId, announcement.calendarNumber, PROCESS_GUILD_DEFAULT_UPCOMING_EVENTS_COUNT)
events[announcement.calendarNumber] = filteredEvents
}
// Handle filtering out events based on this announcement's types
if (announcement.type == Announcement.Type.COLOR) {
filteredEvents = filteredEvents?.filter { it.color == announcement.eventColor }
filteredEvents = filteredEvents.filter { it.color == announcement.eventColor }
} else if (announcement.type == Announcement.Type.RECUR) {
filteredEvents = filteredEvents
?.filter { it.eventId.contains("_") }
?.filter { it.eventId.split("_")[0] == announcement.eventId }
.filter { it.id.contains("_") }
.filter { it.id.split("_")[0] == announcement.eventId }
}
// Loop through filtered events and post any announcements in range
filteredEvents
?.filter { isInRange(announcement, it, maxDifference) }
?.forEach { sendAnnouncement(announcement, it) }
.filter { isInRange(announcement, it, maxDifference) }
.forEach { sendAnnouncement(announcement, it) }
}
@@ -264,4 +268,16 @@ class AnnouncementService(
.filter { it.entity.id == announcementId }
.forEach { announcementWizardStateCache.evict(guildId, it.userId) }
}
suspend fun cancelWizard(guildId: Snowflake, calendarNumber: Int) {
announcementWizardStateCache.getAll(guildId)
.filter { it.entity.calendarNumber == calendarNumber }
.forEach { announcementWizardStateCache.evict(guildId, it.userId) }
}
suspend fun cancelWizardByEvent(guildId: Snowflake, eventId: String) {
announcementWizardStateCache.getAll(guildId)
.filter { it.entity.eventId == eventId }
.forEach { announcementWizardStateCache.evict(guildId, it.userId) }
}
}

View File

@@ -0,0 +1,41 @@
package org.dreamexposure.discal.core.business
import discord4j.common.util.Snowflake
import org.dreamexposure.discal.core.`object`.new.Calendar
import org.dreamexposure.discal.core.`object`.new.CalendarMetadata
import org.dreamexposure.discal.core.`object`.new.CalendarMetadata.Host
import org.dreamexposure.discal.core.`object`.new.Event
import java.time.Instant
interface CalendarProvider {
val host: Host
/////////
/// Calendar functions
/////////
suspend fun getCalendar(metadata: CalendarMetadata): Calendar?
suspend fun createCalendar(guildId: Snowflake, spec: Calendar.CreateSpec): Calendar
suspend fun updateCalendar(guildId: Snowflake, metadata: CalendarMetadata, spec: Calendar.UpdateSpec): Calendar
suspend fun deleteCalendar(guildId: Snowflake, metadata: CalendarMetadata)
/////////
/// Event functions
/////////
suspend fun getEvent(calendar: Calendar, id: String): Event?
suspend fun getUpcomingEvents(calendar: Calendar, amount: Int): List<Event>
suspend fun getOngoingEvents(calendar: Calendar): List<Event>
suspend fun getEventsInTimeRange(calendar: Calendar, start: Instant, end: Instant): List<Event>
suspend fun createEvent(calendar: Calendar, spec: Event.CreateSpec): Event
suspend fun updateEvent(calendar: Calendar, spec: Event.UpdateSpec): Event
suspend fun deleteEvent(calendar: Calendar, id: String)
}

View File

@@ -4,42 +4,98 @@ import discord4j.common.util.Snowflake
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kotlinx.coroutines.reactor.mono
import org.dreamexposure.discal.CalendarCache
import org.dreamexposure.discal.*
import org.dreamexposure.discal.core.crypto.AESEncryption
import org.dreamexposure.discal.core.database.CalendarRepository
import org.dreamexposure.discal.core.`object`.new.Calendar
import org.dreamexposure.discal.core.database.CalendarMetadataData
import org.dreamexposure.discal.core.database.CalendarMetadataRepository
import org.dreamexposure.discal.core.exceptions.NotFoundException
import org.dreamexposure.discal.core.`object`.new.*
import org.springframework.beans.factory.BeanFactory
import org.springframework.beans.factory.getBean
import org.springframework.stereotype.Component
import java.time.Instant
import java.time.temporal.ChronoUnit
@Component
class CalendarService(
private val calendarRepository: CalendarRepository,
class CalendarService(
private val calendarMetadataRepository: CalendarMetadataRepository,
private val calendarMetadataCache: CalendarMetadataCache,
private val eventCache: EventCache,
private val calendarProviders: List<CalendarProvider>,
private val calendarCache: CalendarCache,
private val calendarWizardStateCache: CalendarWizardStateCache,
private val eventWizardStateCache: EventWizardStateCache,
private val settingsService: GuildSettingsService,
private val eventMetadataService: EventMetadataService,
private val beanFactory: BeanFactory,
) {
suspend fun getCalendarCount(): Long = calendarRepository.count().awaitSingle()
// We're going to fetch beans here for a lot of services that depend on CalendarService due to architecture (plus these are used in only one place so...)
private val staticMessageService
get() = beanFactory.getBean<StaticMessageService>()
private val rsvpService
get() = beanFactory.getBean<RsvpService>()
private val announcementService
get() = beanFactory.getBean<AnnouncementService>()
suspend fun getAllCalendars(guildId: Snowflake): List<Calendar> {
var calendars = calendarCache.get(key = guildId)?.toList()
/////////
/// Calendar count
/////////
suspend fun getCalendarCount(): Long = calendarMetadataRepository.countAll().awaitSingle()
suspend fun getCalendarCount(guildId: Snowflake) = calendarMetadataRepository.countAllByGuildId(guildId.asLong()).awaitSingle()
/////////
/// Calendar metadata - Prefer using full Calendar implementation
/////////
suspend fun getAllCalendarMetadata(guildId: Snowflake): List<CalendarMetadata> {
var calendars = calendarMetadataCache.get(key = guildId)?.toList()
if (calendars != null) return calendars
calendars = calendarRepository.findAllByGuildId(guildId.asLong())
.flatMap { mono { Calendar(it) } }
calendars = calendarMetadataRepository.findAllByGuildId(guildId.asLong())
.flatMap { mono { CalendarMetadata(it) } }
.collectList()
.awaitSingle()
calendarCache.put(key = guildId, value = calendars.toTypedArray())
calendarMetadataCache.put(key = guildId, value = calendars.toTypedArray())
return calendars
}
suspend fun getCalendar(guildId: Snowflake, number: Int): Calendar? {
return getAllCalendars(guildId).firstOrNull { it.number == number }
suspend fun getCalendarMetadata(guildId: Snowflake, number: Int): CalendarMetadata? {
return getAllCalendarMetadata(guildId).firstOrNull { it.number == number }
}
suspend fun updateCalendar(calendar: Calendar) {
suspend fun createCalendarMetadata(calendar: CalendarMetadata): CalendarMetadata {
val aes = AESEncryption(calendar.secrets.privateKey)
val encryptedRefreshToken = aes.encrypt(calendar.secrets.refreshToken).awaitSingle()
val encryptedAccessToken = aes.encrypt(calendar.secrets.accessToken).awaitSingle()
calendarRepository.updateCalendarByGuildIdAndCalendarNumber(
calendarMetadataRepository.save(CalendarMetadataData(
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 = encryptedAccessToken,
refreshToken = encryptedRefreshToken,
expiresAt = calendar.secrets.expiresAt.toEpochMilli(),
)).flatMap { mono { CalendarMetadata(it) } }.awaitSingle()
val cached = calendarMetadataCache.get(key = calendar.guildId)
if (cached != null) calendarMetadataCache.put(key = calendar.guildId, value = cached + calendar)
return calendar
}
suspend fun updateCalendarMetadata(calendar: CalendarMetadata) {
val aes = AESEncryption(calendar.secrets.privateKey)
val encryptedRefreshToken = aes.encrypt(calendar.secrets.refreshToken).awaitSingle()
val encryptedAccessToken = aes.encrypt(calendar.secrets.accessToken).awaitSingle()
calendarMetadataRepository.updateCalendarByGuildIdAndCalendarNumber(
guildId = calendar.guildId.asLong(),
calendarNumber = calendar.number,
host = calendar.host.name,
@@ -53,11 +109,282 @@ class CalendarService(
expiresAt = calendar.secrets.expiresAt.toEpochMilli(),
).awaitSingleOrNull()
val cached = calendarCache.get(key = calendar.guildId)
val cached = calendarMetadataCache.get(key = calendar.guildId)
if (cached != null) {
val newList = cached.toMutableList()
newList.removeIf { it.number == calendar.number }
calendarCache.put(key = calendar.guildId,value = (newList + calendar).toTypedArray())
calendarMetadataCache.put(key = calendar.guildId,value = (newList + calendar).toTypedArray())
}
val cachedFullCalendar = calendarCache.get(calendar.guildId, calendar.number)
if (cachedFullCalendar != null) calendarCache.put(calendar.guildId, calendar.number, cachedFullCalendar.copy(metadata = calendar))
}
suspend fun getNextCalendarNumber(guildId: Snowflake): Int = getAllCalendarMetadata(guildId).size + 1
/////////
/// Calendar
/////////
suspend fun getCalendar(guildId: Snowflake, number: Int): Calendar? {
var calendar = calendarCache.get(guildId, number)
if (calendar != null) return calendar
val metadata = getCalendarMetadata(guildId, number) ?: return null
calendar = calendarProviders
.first { it.host == metadata.host }
.getCalendar(metadata)
if (calendar != null) calendarCache.put(guildId, number, calendar)
return calendar
}
suspend fun getAllCalendars(guildId: Snowflake): List<Calendar> {
return getAllCalendarMetadata(guildId).map { getCalendar(guildId, it.number)!! }
}
suspend fun createCalendar(guildId: Snowflake, spec: Calendar.CreateSpec): Calendar {
val calendar = calendarProviders
.first { it.host == spec.host }
.createCalendar(guildId, spec)
createCalendarMetadata(calendar.metadata)
calendarCache.put(guildId, calendar.metadata.number, calendar)
return calendar
}
suspend fun updateCalendar(guildId: Snowflake, number: Int, spec: Calendar.UpdateSpec): Calendar {
val metadata = getCalendarMetadata(guildId, number) ?: throw NotFoundException("Cannot update a calendar that does not exist")
val calendar = calendarProviders
.first { it.host == metadata.host }
.updateCalendar(guildId, metadata, spec)
calendarCache.put(guildId, calendar.metadata.number, calendar)
// Cancel wizards
cancelCalendarWizard(guildId, number)
// Make sure static messages get updated
staticMessageService.updateStaticMessages(guildId, number)
return calendar
}
suspend fun deleteCalendar(guildId: Snowflake, number: Int) {
val metadata = getCalendarMetadata(guildId, number) ?: return
// Delete from 3rd party locations
calendarProviders.first { it.host == metadata.host }.deleteCalendar(guildId, metadata)
// Cancel any wizards
cancelCalendarWizard(guildId, number)
cancelEventWizard(guildId, number)
// Delete from db and handle "re-indexing" all calendar resources (because calendars are sequential for user-convenience)
calendarMetadataRepository.deleteAllByGuildIdAndCalendarNumber(guildId.asLong(), number).awaitSingleOrNull()
calendarMetadataRepository.decrementCalendarsByGuildIdAndCalendarNumber(guildId.asLong(), number).awaitSingleOrNull()
// Remove from caches
calendarCache.evict(guildId, metadata.number)
calendarMetadataCache.evict(key = guildId)
/*
This is a set of calls to replicate the behavior of the old monolith db call
that would go through all tables to handle deleting (as cascade delete constraints have not yet been added),
and to update the calendar number references down-stream. This is again for user-convenience, or so I tell myself
as a cope for how badly designed this project originally was and I just, can't let go of it,
so I keep trying to fix it bit by bit <3
*/
eventMetadataService.deleteEventMetadataForCalendarDeletion(guildId, number)
rsvpService.deleteRsvpForCalendarDeletion(guildId, number)
announcementService.deleteAnnouncementsForCalendarDeletion(guildId, number)
staticMessageService.deleteStaticMessagesForCalendarDeletion(guildId, number)
}
/////////
/// Event
/// TODO: Need to figure out if I can fetch event sets from cache one day (eg, ongoing events)
/////////
suspend fun getEvent(guildId: Snowflake, calendarNumber: Int, id: String): Event? {
var event = eventCache.get(guildId, id)
if (event != null) return event
val calendar = getCalendar(guildId, calendarNumber) ?: return null
event = calendarProviders
.first { it.host == calendar.metadata.host }
.getEvent(calendar, id)
if (event != null) eventCache.put(guildId, id, event)
// Since the underlying implementation will emit an error on a non 200/404 condition, we can safely do this
if (event == null) announcementService.deleteAnnouncements(guildId, id)
return event
}
suspend fun getUpcomingEvents(guildId: Snowflake, calendarNumber: Int, amount: Int): List<Event> {
val calendar = getCalendar(guildId, calendarNumber) ?: return emptyList()
val events = calendarProviders
.first { it.host == calendar.metadata.host }
.getUpcomingEvents(calendar, amount)
events.forEach { event -> eventCache.put(guildId, event.id, event) }
return events
}
suspend fun getOngoingEvents(guildId: Snowflake, calendarNumber: Int): List<Event> {
val calendar = getCalendar(guildId, calendarNumber) ?: return emptyList()
val events = calendarProviders
.first { it.host == calendar.metadata.host }
.getOngoingEvents(calendar)
events.forEach { event -> eventCache.put(guildId, event.id, event) }
return events
}
suspend fun getEventsInTimeRange(guildId: Snowflake, calendarNumber: Int, start: Instant, end: Instant): List<Event> {
val calendar = getCalendar(guildId, calendarNumber) ?: return emptyList()
val events = calendarProviders
.first { it.host == calendar.metadata.host }
.getEventsInTimeRange(calendar, start, end)
events.forEach { event -> eventCache.put(guildId, event.id, event) }
return events
}
suspend fun getEventsInNext24HourPeriod(guildId: Snowflake, calendarNumber: Int, start: Instant): List<Event> {
return getEventsInTimeRange(guildId, calendarNumber, start, start.plus(1, ChronoUnit.DAYS))
}
suspend fun getEventsInMonth(guildId: Snowflake, calendarNumber: Int, start: Instant, daysInMonth: Int): List<Event> {
return getEventsInTimeRange(guildId, calendarNumber, start, start.plus(daysInMonth.toLong(), ChronoUnit.DAYS))
}
suspend fun getEventsInNextNDays(guildId: Snowflake, calendarNumber: Int, days: Int): List<Event> {
return getEventsInTimeRange(guildId, calendarNumber, Instant.now(), Instant.now().plus(days.toLong(), ChronoUnit.DAYS))
}
suspend fun createEvent(guildId: Snowflake, calendarNumber: Int, spec: Event.CreateSpec): Event {
val calendar = getCalendar(guildId, calendarNumber) ?: throw NotFoundException("Cannot create a new event without a calendar")
val event = calendarProviders
.first { it.host == calendar.metadata.host }
.createEvent(calendar, spec)
eventCache.put(guildId, event.id, event)
// Update static messages in case event is visible
staticMessageService.updateStaticMessages(guildId, event.calendarNumber)
return event
}
suspend fun updateEvent(guildId: Snowflake, calendarNumber: Int, spec: Event.UpdateSpec): Event {
val calendar = getCalendar(guildId, calendarNumber) ?: throw NotFoundException("Cannot update event without a calendar")
val event = calendarProviders
.first { it.host == calendar.metadata.host }
.updateEvent(calendar, spec)
// This should make sure that any recurring children are removed from cache if this was a recurring parent being updated
if (!event.id.contains("_")) {
eventCache.getAll(guildId)
.filter { it.id.startsWith(event.id) }
.forEach { eventCache.evict(guildId, it.id) }
}
eventCache.put(guildId, event.id, event)
cancelEventWizard(guildId, event.id)
// Update static messages in case event is visible
staticMessageService.updateStaticMessages(guildId, event.calendarNumber)
return event
}
suspend fun deleteEvent(guildId: Snowflake, calendarNumber: Int, id: String) {
val calendar = getCalendar(guildId, calendarNumber) ?: return
calendarProviders
.first { it.host == calendar.metadata.host }
.deleteEvent(calendar, id)
// Make sure if this is a recurring parent, we delete all children from cache
if (!id.contains("_")) {
eventCache.getAll(guildId)
.filter { it.id.startsWith(id) }
.forEach { eventCache.evict(guildId, it.id) }
}
eventMetadataService.deleteEventMetadata(guildId, id)
announcementService.deleteAnnouncements(guildId, id)
staticMessageService.updateStaticMessages(guildId, calendarNumber)
cancelEventWizard(guildId, id)
}
/////////
/// Wizards
/////////
suspend fun getCalendarWizard(guildId: Snowflake, userId: Snowflake): CalendarWizardState? {
return calendarWizardStateCache.get(guildId, userId)
}
suspend fun putCalendarWizard(state: CalendarWizardState) {
calendarWizardStateCache.put(state.guildId, state.userId, state)
}
suspend fun cancelCalendarWizard(guildId: Snowflake, userId: Snowflake) {
calendarWizardStateCache.evict(guildId, userId)
}
suspend fun cancelCalendarWizard(guildId: Snowflake, calendarNumber: Int) {
calendarWizardStateCache.getAll(guildId)
.filter { it.entity.metadata.number == calendarNumber }
.forEach { calendarWizardStateCache.evict(guildId, it.userId) }
}
suspend fun getEventWizard(guildId: Snowflake, userId: Snowflake): EventWizardState? {
return eventWizardStateCache.get(guildId, userId)
}
suspend fun putEventWizard(state: EventWizardState) {
eventWizardStateCache.put(state.guildId, state.userId, state)
}
suspend fun cancelEventWizard(guildId: Snowflake, userId: Snowflake) {
eventWizardStateCache.evict(guildId, userId)
}
suspend fun cancelEventWizard(guildId: Snowflake, calendarNumber: Int) {
eventWizardStateCache.getAll(guildId)
.filter { it.entity.calendarNumber == calendarNumber }
.forEach { eventWizardStateCache.evict(guildId, it.userId) }
}
suspend fun cancelEventWizard(guildId: Snowflake, eventId: String) {
eventWizardStateCache.getAll(guildId)
.filter { it.entity.id == eventId }
.forEach { eventWizardStateCache.evict(guildId, it.userId) }
}
/////////
/// Extra functions
/////////
suspend fun canAddNewCalendar(guildId: Snowflake): Boolean {
val calCount = getCalendarCount(guildId)
if (calCount == 0L) return true
val settings = settingsService.getSettings(guildId)
return calCount < settings.maxCalendars
}
}

View File

@@ -4,21 +4,14 @@ import discord4j.common.util.Snowflake
import discord4j.core.DiscordClient
import discord4j.core.spec.EmbedCreateSpec
import kotlinx.coroutines.reactor.awaitSingle
import org.dreamexposure.discal.Application
import org.dreamexposure.discal.GitProperty
import org.dreamexposure.discal.*
import org.dreamexposure.discal.core.config.Config
import org.dreamexposure.discal.core.entities.Calendar
import org.dreamexposure.discal.core.entities.Event
import org.dreamexposure.discal.core.enums.announcement.AnnouncementStyle
import org.dreamexposure.discal.core.enums.event.EventColor
import org.dreamexposure.discal.core.enums.time.DiscordTimestampFormat
import org.dreamexposure.discal.core.enums.time.DiscordTimestampFormat.LONG_DATETIME
import org.dreamexposure.discal.core.extensions.*
import org.dreamexposure.discal.core.extensions.discord4j.getCalendar
import org.dreamexposure.discal.core.extensions.discord4j.getSettings
import org.dreamexposure.discal.core.`object`.GuildSettings
import org.dreamexposure.discal.core.`object`.new.Announcement
import org.dreamexposure.discal.core.`object`.new.Rsvp
import org.dreamexposure.discal.core.`object`.new.WizardState
import org.dreamexposure.discal.core.`object`.new.*
import org.dreamexposure.discal.core.`object`.new.GuildSettings.AnnouncementStyle.*
import org.dreamexposure.discal.core.utils.GlobalVal
import org.dreamexposure.discal.core.utils.getCommonMsg
import org.dreamexposure.discal.core.utils.getEmbedMessage
@@ -32,6 +25,7 @@ import java.time.temporal.ChronoUnit
@Component
class EmbedService(
private val settingsService: GuildSettingsService,
private val beanFactory: BeanFactory,
) {
private val discordClient: DiscordClient
@@ -39,15 +33,15 @@ class EmbedService(
private suspend fun defaultEmbedBuilder(settings: GuildSettings): EmbedCreateSpec.Builder {
val guild = discordClient.getGuildById(settings.guildID).data.awaitSingle()
val guild = discordClient.getGuildById(settings.guildId).data.awaitSingle()
val iconUrl = if (settings.branded && guild.icon().isPresent)
"${GlobalVal.discordCdnUrl}/icons/${settings.guildID.asString()}/${guild.icon().get()}.png"
val iconUrl = if (settings.interfaceStyle.branded && guild.icon().isPresent)
"${GlobalVal.discordCdnUrl}/icons/${settings.guildId.asString()}/${guild.icon().get()}.png"
else GlobalVal.iconUrl
return EmbedCreateSpec.builder()
.author(
if (settings.branded) guild.name() else getCommonMsg("bot.name", settings),
if (settings.interfaceStyle.branded) guild.name() else getCommonMsg("bot.name", settings.locale),
Config.URL_BASE.getString(),
iconUrl
)
@@ -56,47 +50,64 @@ class EmbedService(
////////////////////////////
////// General Embeds //////
////////////////////////////
suspend fun discalInfoEmbed(settings: GuildSettings, calendarCount: Long, announcementCount: Long): EmbedCreateSpec {
val guildCount = discordClient.guilds.count().awaitSingle()
suspend fun discalInfoEmbed(settings: GuildSettings, guildCount: Long, calendarCount: Long, announcementCount: Long): EmbedCreateSpec {
return defaultEmbedBuilder(settings)
.color(GlobalVal.discalColor)
.title(getEmbedMessage("discal", "info.title", settings))
.addField(getEmbedMessage("discal", "info.field.version", settings), GitProperty.DISCAL_VERSION.value, false)
.addField(getEmbedMessage("discal", "info.field.library", settings), "Discord4J ${GitProperty.DISCAL_VERSION_D4J.value}", false)
.addField(getEmbedMessage("discal", "info.field.shard", settings), "${Application.getShardIndex()}/${Application.getShardCount()}", true)
.addField(getEmbedMessage("discal", "info.field.guilds", settings), "$guildCount", true)
.title(getEmbedMessage("discal", "info.title", settings.locale))
.addField(getEmbedMessage("discal", "info.field.version", settings.locale), GitProperty.DISCAL_VERSION.value, false)
.addField(getEmbedMessage("discal", "info.field.library", settings.locale), "Discord4J ${GitProperty.DISCAL_VERSION_D4J.value}", false)
.addField(getEmbedMessage("discal", "info.field.shard", settings.locale), "${Application.getShardIndex()}/${Application.getShardCount()}", true)
.addField(getEmbedMessage("discal", "info.field.guilds", settings.locale), "$guildCount", true)
.addField(
getEmbedMessage("discal", "info.field.uptime", settings),
getEmbedMessage("discal", "info.field.uptime", settings.locale),
Application.getUptime().getHumanReadable(),
false
).addField(getEmbedMessage("discal", "info.field.calendars", settings), "$calendarCount", true)
.addField(getEmbedMessage("discal", "info.field.announcements", settings), "$announcementCount", true)
.addField(getEmbedMessage("discal", "info.field.links", settings),
).addField(getEmbedMessage("discal", "info.field.calendars", settings.locale), "$calendarCount", true)
.addField(getEmbedMessage("discal", "info.field.announcements", settings.locale), "$announcementCount", true)
.addField(getEmbedMessage("discal", "info.field.links", settings.locale),
getEmbedMessage("discal",
"info.field.links.value",
settings,
settings.locale,
"${Config.URL_BASE.getString()}/commands",
Config.URL_SUPPORT.getString(),
Config.URL_INVITE.getString(),
"https://www.patreon.com/Novafox"
),
false
).footer(getEmbedMessage("discal", "info.footer", settings), null)
).footer(getEmbedMessage("discal", "info.footer", settings.locale), null)
.build()
}
/////////////////////////////
////// Settings Embeds //////
/////////////////////////////
suspend fun settingsEmbeds(settings: GuildSettings): EmbedCreateSpec {
val controlRoleValue = if (settings.controlRole == null) "<@&${settings.guildId.asLong()}>" else "<@&${settings.controlRole}>"
return defaultEmbedBuilder(settings)
.title(getEmbedMessage("settings", "view.title", settings.locale))
.addField(getEmbedMessage("settings", "view.field.role", settings.locale), controlRoleValue, false)
.addField(getEmbedMessage("settings", "view.field.style", settings.locale), settings.interfaceStyle.announcementStyle.name, true)
.addField(getEmbedMessage("settings", "view.field.format", settings.locale), settings.interfaceStyle.timeFormat.name, true)
.addField(getEmbedMessage("settings", "view.field.eventKeepDuration", settings.locale), "${settings.eventKeepDuration}", true)
.addField(getEmbedMessage("settings", "view.field.lang", settings.locale), settings.locale.displayName, false)
.addField(getEmbedMessage("settings", "view.field.patron", settings.locale), "${settings.patronGuild}", true)
.addField(getEmbedMessage("settings", "view.field.dev", settings.locale), "${settings.devGuild}", true)
.addField(getEmbedMessage("settings", "view.field.cal", settings.locale), "${settings.maxCalendars}", true)
.addField(getEmbedMessage("settings", "view.field.brand", settings.locale), "${settings.interfaceStyle.branded}", false)
.footer(getEmbedMessage("settings", "view.footer", settings.locale), null)
.build()
}
/////////////////////////////
////// Calendar Embeds //////
/////////////////////////////
suspend fun calendarOverviewEmbed(calendar: Calendar, settings: GuildSettings, showUpdate: Boolean): EmbedCreateSpec {
suspend fun calendarOverviewEmbed(calendar: Calendar, events: List<Event>, showUpdate: Boolean): EmbedCreateSpec {
val settings = settingsService.getSettings(calendar.metadata.guildId)
val builder = defaultEmbedBuilder(settings)
// Get the events to build the overview
val events = calendar.getUpcomingEvents(15)
.collectList()
.map { it.groupByDate() }
.awaitSingle()
// Get events sorted and grouped
val groupedEvents = events.groupByDate()
//Handle optional fields
if (calendar.name.isNotBlank())
@@ -106,7 +117,7 @@ class EmbedService(
// Truncate dates to 23 due to discord enforcing the field limit
val truncatedEvents = mutableMapOf<ZonedDateTime, List<Event>>()
for (event in events) {
for (event in groupedEvents) {
if (truncatedEvents.size < 23) {
truncatedEvents[event.key] = event.value
} else break
@@ -114,7 +125,7 @@ class EmbedService(
// Show events
truncatedEvents.forEach { date ->
val title = date.key.toInstant().humanReadableDate(calendar.timezone, settings.timeFormat, longDay = true)
val title = date.key.toInstant().humanReadableDate(calendar.timezone, settings.interfaceStyle.timeFormat, longDay = true)
// sort events
val sortedEvents = date.value.sortedBy { it.start }
@@ -126,32 +137,32 @@ class EmbedService(
content.append("```\n")
// determine time length
val timeDisplayLen = ("${it.start.humanReadableTime(it.timezone, settings.timeFormat)} -" +
" ${it.end.humanReadableTime(it.timezone, settings.timeFormat)} ").length
val timeDisplayLen = ("${it.start.humanReadableTime(it.timezone, settings.interfaceStyle.timeFormat)} -" +
" ${it.end.humanReadableTime(it.timezone, settings.interfaceStyle.timeFormat)} ").length
// Displaying time
if (it.isAllDay()) {
content.append(getCommonMsg("generic.time.allDay", settings).padCenter(timeDisplayLen))
content.append(getCommonMsg("generic.time.allDay", settings.locale).padCenter(timeDisplayLen))
.append("| ")
} else {
// Add start text
var str = if (it.start.isBefore(date.key.toInstant())) {
"${getCommonMsg("generic.time.continued", settings)} - "
"${getCommonMsg("generic.time.continued", settings.locale)} - "
} else {
"${it.start.humanReadableTime(it.timezone, settings.timeFormat)} - "
"${it.start.humanReadableTime(it.timezone, settings.interfaceStyle.timeFormat)} - "
}
// Add end text
str += if (it.end.isAfter(date.key.toInstant().plus(1, ChronoUnit.DAYS))) {
getCommonMsg("generic.time.continued", settings)
getCommonMsg("generic.time.continued", settings.locale)
} else {
"${it.end.humanReadableTime(it.timezone, settings.timeFormat)} "
"${it.end.humanReadableTime(it.timezone, settings.interfaceStyle.timeFormat)} "
}
content.append(str.padCenter(timeDisplayLen))
.append("| ")
}
// Display name or ID if not set
if (it.name.isNotBlank()) content.append(it.name)
else content.append(getEmbedMessage("calendar", "link.field.id", settings)).append(" ${it.eventId}")
else content.append(getEmbedMessage("calendar", "link.field.id", settings.locale)).append(" ${it.id}")
content.append("\n")
if (it.location.isNotBlank()) content.append(" Location: ")
.append(it.location.embedFieldSafe())
@@ -168,25 +179,25 @@ class EmbedService(
// set footer
if (showUpdate) {
val lastUpdate = Instant.now().asDiscordTimestamp(DiscordTimestampFormat.RELATIVE_TIME)
builder.footer(getEmbedMessage("calendar", "link.footer.update", settings, lastUpdate), null)
builder.footer(getEmbedMessage("calendar", "link.footer.update", settings.locale, lastUpdate), null)
.timestamp(Instant.now())
} else builder.footer(getEmbedMessage("calendar", "link.footer.default", settings), null)
} else builder.footer(getEmbedMessage("calendar", "link.footer.default", settings.locale), null)
// finish and return
return builder.addField(getEmbedMessage("calendar", "link.field.timezone", settings), calendar.zoneName, true)
.addField(getEmbedMessage("calendar", "link.field.number", settings), "${calendar.calendarNumber}", true)
return builder.addField(getEmbedMessage("calendar", "link.field.timezone", settings.locale), calendar.timezone.id, true)
.addField(getEmbedMessage("calendar", "link.field.number", settings.locale), "${calendar.metadata.number}", true)
.url(calendar.link)
.color(GlobalVal.discalColor)
.build()
}
suspend fun linkCalendarEmbed(calendarNumber: Int, settings: GuildSettings, overview: Boolean): EmbedCreateSpec {
val calendar = discordClient.getGuildById(settings.guildID).getCalendar(calendarNumber).awaitSingle()
return if (overview) calendarOverviewEmbed(calendar, settings, showUpdate = false)
else linkCalendarEmbed(calendar, settings)
suspend fun linkCalendarEmbed(calendar: Calendar, events: List<Event>?): EmbedCreateSpec {
return if (events != null) calendarOverviewEmbed(calendar, events, showUpdate = false)
else linkCalendarEmbed(calendar)
}
suspend fun linkCalendarEmbed(calendar: Calendar, settings: GuildSettings): EmbedCreateSpec {
suspend fun linkCalendarEmbed(calendar: Calendar): EmbedCreateSpec {
val settings = settingsService.getSettings(calendar.metadata.guildId)
val builder = defaultEmbedBuilder(settings)
//Handle optional fields
@@ -195,41 +206,235 @@ class EmbedService(
if (calendar.description.isNotBlank())
builder.description(calendar.description.toMarkdown().embedDescriptionSafe())
return builder.addField(getEmbedMessage("calendar", "link.field.timezone", settings), calendar.zoneName, false)
.addField(getEmbedMessage("calendar", "link.field.host", settings), calendar.calendarData.host.name, true)
.addField(getEmbedMessage("calendar", "link.field.number", settings), "${calendar.calendarNumber}", true)
.addField(getEmbedMessage("calendar", "link.field.id", settings), calendar.calendarId, false)
return builder.addField(getEmbedMessage("calendar", "link.field.timezone", settings.locale), calendar.timezone.id, false)
.addField(getEmbedMessage("calendar", "link.field.host", settings.locale), calendar.metadata.host.name, true)
.addField(getEmbedMessage("calendar", "link.field.number", settings.locale), "${calendar.metadata.number}", true)
.addField(getEmbedMessage("calendar", "link.field.id", settings.locale), calendar.metadata.id, false)
.url(calendar.link)
.footer(getEmbedMessage("calendar", "link.footer.default", settings), null)
.footer(getEmbedMessage("calendar", "link.footer.default", settings.locale), null)
.color(GlobalVal.discalColor)
.build()
}
suspend fun calendarTimeEmbed(calendar: Calendar, settings: GuildSettings): EmbedCreateSpec {
val formattedTime = Instant.now().humanReadableFullSimple(calendar.timezone, settings.timeFormat)
val formattedTime = Instant.now().humanReadableFullSimple(calendar.timezone, settings.interfaceStyle.timeFormat)
val formattedLocal = Instant.now().asDiscordTimestamp(DiscordTimestampFormat.SHORT_DATETIME)
return defaultEmbedBuilder(settings)
.title(getEmbedMessage("time", "embed.title", settings))
.addField(getEmbedMessage("time", "embed.field.current", settings), formattedTime, true)
.addField(getEmbedMessage("time", "embed.field.timezone", settings), calendar.zoneName, true)
.addField(getEmbedMessage("time", "embed.field.local", settings), formattedLocal, false)
.footer(getEmbedMessage("time", "embed.footer", settings), null)
.title(getEmbedMessage("time", "embed.title", settings.locale))
.addField(getEmbedMessage("time", "embed.field.current", settings.locale), formattedTime, true)
.addField(getEmbedMessage("time", "embed.field.timezone", settings.locale), calendar.timezone.id, true)
.addField(getEmbedMessage("time", "embed.field.local", settings.locale), formattedLocal, false)
.footer(getEmbedMessage("time", "embed.footer", settings.locale), null)
.url(calendar.link)
.color(GlobalVal.discalColor)
.build()
}
suspend fun calendarWizardEmbed(wizard: CalendarWizardState, settings: GuildSettings): EmbedCreateSpec {
val builder = defaultEmbedBuilder(settings)
.title(getEmbedMessage("calendar", "wizard.title", settings.locale))
.footer(getEmbedMessage("calendar", "wizard.footer", settings.locale), null)
.addField(
getEmbedMessage("calendar", "wizard.field.name", settings.locale),
wizard.entity.name.toMarkdown().embedFieldSafe(),
false
).addField(
getEmbedMessage("calendar", "wizard.field.description", settings.locale),
wizard.entity.description.ifEmpty { getCommonMsg("embed.unset", settings.locale) }.toMarkdown().embedFieldSafe(),
false
).addField(
getEmbedMessage("calendar", "wizard.field.timezone", settings.locale),
wizard.entity.timezone.id,
true
).addField(
getEmbedMessage("calendar", "wizard.field.host", settings.locale),
wizard.entity.metadata.host.name,
true
)
if (wizard.editing) builder.addField(
getEmbedMessage("calendar", "wizard.field.id", settings.locale),
wizard.entity.metadata.id,
false
)
return builder.build()
}
//////////////////////////
////// Event Embeds //////
//////////////////////////
suspend fun fullEventEmbed(event: Event, settings: GuildSettings): EmbedCreateSpec {
val builder = defaultEmbedBuilder(settings)
.footer(getEmbedMessage("event", "full.footer", settings.locale, event.id), null)
.color(event.color.asColor())
if (event.name.isNotBlank())
builder.title(event.name.toMarkdown().embedTitleSafe())
if (event.description.isNotBlank())
builder.description(event.description.toMarkdown().embedDescriptionSafe())
builder.addField(
getEmbedMessage("event", "full.field.start", settings.locale),
event.start.asDiscordTimestamp(LONG_DATETIME),
true)
builder.addField(
getEmbedMessage("event", "full.field.end", settings.locale),
event.end.asDiscordTimestamp(LONG_DATETIME),
true
)
if (event.location.isNotBlank()) builder.addField(
getEmbedMessage("event", "full.field.location", settings.locale),
event.location.toMarkdown().embedFieldSafe(),
false
)
builder.addField(getEmbedMessage("event", "full.field.cal", settings.locale), "${event.calendarNumber}", false)
if (event.image.isNotEmpty())
builder.image(event.image)
return builder.build()
}
suspend fun condensedEventEmbed(event: Event, settings: GuildSettings): EmbedCreateSpec {
val builder = defaultEmbedBuilder(settings)
.footer(getEmbedMessage("event", "con.footer", settings.locale, event.id), null)
.color(event.color.asColor())
if (event.name.isNotBlank())
builder.title(event.name.toMarkdown().embedTitleSafe())
builder.addField(
getEmbedMessage("event", "con.field.start", settings.locale),
event.start.asDiscordTimestamp(LONG_DATETIME),
true
)
if (event.location.isNotBlank()) builder.addField(
getEmbedMessage("event", "con.field.location", settings.locale),
event.location.toMarkdown().embedFieldSafe(),
false
)
if (event.image.isNotBlank())
builder.thumbnail(event.image)
return builder.build()
}
suspend fun eventWizardEmbed(wizard: EventWizardState, settings: GuildSettings): EmbedCreateSpec {
val builder = defaultEmbedBuilder(settings)
.title(getEmbedMessage("event", "wizard.title", settings.locale))
.footer(getEmbedMessage("event", "wizard.footer", settings.locale), null)
.color(wizard.entity.color.asColor())
if (!wizard.entity.name.isNullOrBlank()) builder.addField(
getEmbedMessage("event", "wizard.field.name", settings.locale),
wizard.entity.name.toMarkdown().embedFieldSafe(),
false
) else builder.addField(
getEmbedMessage("event", "wizard.field.name", settings.locale),
getCommonMsg("embed.unset", settings.locale),
false
)
if (!wizard.entity.description.isNullOrBlank()) builder.addField(
getEmbedMessage("event", "wizard.field.desc", settings.locale),
wizard.entity.description.toMarkdown().embedFieldSafe(),
false
) else builder.addField(
getEmbedMessage("event", "wizard.field.desc", settings.locale),
getCommonMsg("embed.unset", settings.locale),
false
)
if (!wizard.entity.location.isNullOrBlank()) builder.addField(
getEmbedMessage("event", "wizard.field.location", settings.locale),
wizard.entity.location.toMarkdown().embedFieldSafe(),
false
) else builder.addField(
getEmbedMessage("event", "wizard.field.location", settings.locale),
getCommonMsg("embed.unset", settings.locale),
false
)
if (wizard.entity.start != null) builder.addField(
getEmbedMessage("event", "wizard.field.start", settings.locale),
wizard.entity.start.humanReadableFull(wizard.entity.timezone, settings.interfaceStyle.timeFormat),
true
) else builder.addField(
getEmbedMessage("event", "wizard.field.start", settings.locale),
getCommonMsg("embed.unset", settings.locale),
true
)
if (wizard.entity.end != null) builder.addField(
getEmbedMessage("event", "wizard.field.end", settings.locale),
wizard.entity.end.humanReadableFull(wizard.entity.timezone, settings.interfaceStyle.timeFormat),
true
) else builder.addField(
getEmbedMessage("event", "wizard.field.end", settings.locale),
getCommonMsg("embed.unset", settings.locale),
true
)
if (wizard.entity.recurrence != null) builder.addField(
getEmbedMessage("event", "wizard.field.recurrence", settings.locale),
wizard.entity.recurrence.toHumanReadable(),
true
) else if (wizard.editing && wizard.entity.id != null && wizard.entity.id.contains("_")) builder.addField(
getEmbedMessage("event", "wizard.field.recurrence", settings.locale),
getEmbedMessage("event", "wizard.field.recurrence.child", settings.locale, wizard.entity.id.split("_")[0]),
false,
) else builder.addField(
getEmbedMessage("event", "wizard.field.recurrence", settings.locale),
getCommonMsg("embed.unset", settings.locale),
true
)
builder.addField(getEmbedMessage("event", "wizard.field.timezone", settings.locale), wizard.entity.timezone.id, false)
if (wizard.editing)
builder.addField(getEmbedMessage("event", "wizard.field.id", settings.locale), wizard.entity.id!!, true)
builder.addField(getEmbedMessage("event", "wizard.field.calendar", settings.locale), wizard.entity.calendarNumber.toString(), true)
if (wizard.entity.image != null)
builder.image(wizard.entity.image)
// Handle displaying warnings
val warnings = mutableListOf<String>()
if (wizard.entity.name.isNullOrBlank()) {
warnings.add(getEmbedMessage("event", "warning.wizard.noName", settings.locale))
}
// Checking end time is not needed
if (wizard.entity.start != null && wizard.entity.start.isBefore(Instant.now())) {
warnings.add(getEmbedMessage("event", "warning.wizard.past", settings.locale))
}
if (wizard.entity.start != null && wizard.entity.end != null) {
if (Duration.between(wizard.entity.start, wizard.entity.end).toDays() > 30) {
warnings.add(getEmbedMessage("event", "warning.wizard.veryLong", settings.locale))
}
}
if (warnings.isNotEmpty()) {
val warnText = "```fix\n${warnings.joinToString("\n")}\n```"
builder.addField(getEmbedMessage("event", "wizard.field.warnings", settings.locale), warnText, false)
}
return builder.build()
}
/////////////////////////
////// RSVP Embeds //////
/////////////////////////
suspend fun rsvpDmFollowupEmbed(rsvp: Rsvp, userId: Snowflake): EmbedCreateSpec {
// TODO: These will be replaced by service calls eventually as I migrate components over to new patterns
suspend fun rsvpDmFollowupEmbed(rsvp: Rsvp, event: Event, userId: Snowflake): EmbedCreateSpec {
val restGuild = discordClient.getGuildById(rsvp.guildId)
val guildData = restGuild.data.awaitSingle()
val guildSettings = restGuild.getSettings().awaitSingle()
val event = restGuild.getCalendar(rsvp.calendarNumber)
.flatMap { it.getEvent(rsvp.eventId) }.awaitSingle()
val settings = settingsService.getSettings(rsvp.guildId)
val iconUrl = if (guildData.icon().isPresent)
@@ -239,20 +444,20 @@ class EmbedService(
val builder = EmbedCreateSpec.builder()
// Even without branding enabled, we want the user to know what guild this is because it's in DMs
.author(guildData.name(), Config.URL_BASE.getString(), iconUrl)
.title(getEmbedMessage("rsvp", "waitlist.title", guildSettings))
.description(getEmbedMessage("rsvp", "waitlist.desc", guildSettings, userId.asString(), event.name))
.title(getEmbedMessage("rsvp", "waitlist.title", settings.locale))
.description(getEmbedMessage("rsvp", "waitlist.desc", settings.locale, userId.asString(), event.name))
.addField(
getEmbedMessage("rsvp", "waitlist.field.start", guildSettings),
event.start.asDiscordTimestamp(DiscordTimestampFormat.LONG_DATETIME),
getEmbedMessage("rsvp", "waitlist.field.start", settings.locale),
event.start.asDiscordTimestamp(LONG_DATETIME),
true
).addField(
getEmbedMessage("rsvp", "waitlist.field.end", guildSettings),
event.end.asDiscordTimestamp(DiscordTimestampFormat.LONG_DATETIME),
getEmbedMessage("rsvp", "waitlist.field.end", settings.locale),
event.end.asDiscordTimestamp(LONG_DATETIME),
true
).footer(getEmbedMessage("rsvp", "waitlist.footer", guildSettings, event.eventId), null)
).footer(getEmbedMessage("rsvp", "waitlist.footer", settings.locale, event.id), null)
if (event.location.isNotBlank()) builder.addField(
getEmbedMessage("rsvp", "waitlist.field.location", guildSettings),
getEmbedMessage("rsvp", "waitlist.field.location", settings.locale),
event.location.toMarkdown().embedFieldSafe(),
false
)
@@ -307,87 +512,88 @@ class EmbedService(
}
val limitValue = if (rsvp.limit < 0) {
getEmbedMessage("rsvp", "list.field.limit.value", settings, "${rsvp.getCurrentCount()}")
getEmbedMessage("rsvp", "list.field.limit.value", settings.locale, "${rsvp.getCurrentCount()}")
} else "${rsvp.getCurrentCount()}/${rsvp.limit}"
return defaultEmbedBuilder(settings)
.color(event.color.asColor())
.title(getEmbedMessage("rsvp", "list.title", settings))
.addField(getEmbedMessage("rsvp", "list.field.event", settings), rsvp.eventId, false)
.addField(getEmbedMessage("rsvp", "list.field.limit", settings), limitValue, true)
.addField(getEmbedMessage("rsvp", "list.field.role", settings), role, true)
.addField(getEmbedMessage("rsvp", "list.field.onTime", settings), goingOnTime, false)
.addField(getEmbedMessage("rsvp", "list.field.late", settings), late, false)
.addField(getEmbedMessage("rsvp", "list.field.unsure", settings), undecided, false)
.addField(getEmbedMessage("rsvp", "list.field.notGoing", settings), notGoing, false)
.addField(getEmbedMessage("rsvp", "list.field.waitList", settings), waitList, false)
.footer(getEmbedMessage("rsvp", "list.footer", settings), null)
.title(getEmbedMessage("rsvp", "list.title", settings.locale))
.addField(getEmbedMessage("rsvp", "list.field.event", settings.locale), rsvp.eventId, false)
.addField(getEmbedMessage("rsvp", "list.field.limit", settings.locale), limitValue, true)
.addField(getEmbedMessage("rsvp", "list.field.role", settings.locale), role, true)
.addField(getEmbedMessage("rsvp", "list.field.onTime", settings.locale), goingOnTime, false)
.addField(getEmbedMessage("rsvp", "list.field.late", settings.locale), late, false)
.addField(getEmbedMessage("rsvp", "list.field.unsure", settings.locale), undecided, false)
.addField(getEmbedMessage("rsvp", "list.field.notGoing", settings.locale), notGoing, false)
.addField(getEmbedMessage("rsvp", "list.field.waitList", settings.locale), waitList, false)
.footer(getEmbedMessage("rsvp", "list.footer", settings.locale), null)
.build()
}
/////////////////////////////////
////// Announcement Embeds //////
/////////////////////////////////
suspend fun determineAnnouncementEmbed(announcement: Announcement, event: Event, settings: GuildSettings): EmbedCreateSpec {
return when (settings.announcementStyle) {
AnnouncementStyle.FULL -> fullAnnouncementEmbed(announcement, event, settings)
AnnouncementStyle.SIMPLE -> simpleAnnouncementEmbed(announcement, event, settings)
AnnouncementStyle.EVENT -> eventAnnouncementEmbed(announcement, event, settings)
suspend fun determineAnnouncementEmbed(announcement: Announcement, event: Event): EmbedCreateSpec {
val settings = settingsService.getSettings(announcement.guildId)
return when (settings.interfaceStyle.announcementStyle) {
FULL -> fullAnnouncementEmbed(announcement, event, settings)
SIMPLE -> simpleAnnouncementEmbed(announcement, event, settings)
EVENT -> eventAnnouncementEmbed(announcement, event, settings)
}
}
suspend fun fullAnnouncementEmbed(announcement: Announcement, event: Event, settings: GuildSettings): EmbedCreateSpec {
val builder = defaultEmbedBuilder(settings)
.color(event.color.asColor())
.title(getEmbedMessage("announcement", "full.title", settings))
.title(getEmbedMessage("announcement", "full.title", settings.locale))
if (event.name.isNotBlank()) builder.addField(
getEmbedMessage("announcement", "full.field.name", settings),
getEmbedMessage("announcement", "full.field.name", settings.locale),
event.name.toMarkdown().embedFieldSafe(),
false
)
if (event.description.isNotBlank()) builder.addField(
getEmbedMessage("announcement", "full.field.desc", settings),
getEmbedMessage("announcement", "full.field.desc", settings.locale),
event.description.toMarkdown().embedFieldSafe(),
false
)
builder.addField(
getEmbedMessage("announcement", "full.field.start", settings),
event.start.asDiscordTimestamp(DiscordTimestampFormat.LONG_DATETIME),
getEmbedMessage("announcement", "full.field.start", settings.locale),
event.start.asDiscordTimestamp(LONG_DATETIME),
true
)
builder.addField(
getEmbedMessage("announcement", "full.field.end", settings),
event.end.asDiscordTimestamp(DiscordTimestampFormat.LONG_DATETIME),
getEmbedMessage("announcement", "full.field.end", settings.locale),
event.end.asDiscordTimestamp(LONG_DATETIME),
true
)
if (event.location.isNotBlank()) builder.addField(
getEmbedMessage("announcement", "full.field.location", settings),
getEmbedMessage("announcement", "full.field.location", settings.locale),
event.location.toMarkdown().embedFieldSafe(),
false
)
if (!announcement.info.isNullOrBlank()) builder.addField(
getEmbedMessage("announcement", "full.field.info", settings),
getEmbedMessage("announcement", "full.field.info", settings.locale),
announcement.info.toMarkdown().embedFieldSafe(),
false
)
builder.addField(
getEmbedMessage("announcement", "full.field.calendar", settings),
"${event.calendar.calendarNumber}",
getEmbedMessage("announcement", "full.field.calendar", settings.locale),
"${event.calendarNumber}",
true
)
builder.addField(getEmbedMessage("announcement", "full.field.event", settings), event.eventId, true)
builder.addField(getEmbedMessage("announcement", "full.field.event", settings.locale), event.id, true)
if (event.image.isNotBlank())
builder.image(event.image)
builder.footer(getEmbedMessage("announcement", "full.footer", settings, announcement.id), null)
builder.footer(getEmbedMessage("announcement", "full.footer", settings.locale, announcement.id), null)
return builder.build()
}
@@ -395,33 +601,33 @@ class EmbedService(
suspend fun simpleAnnouncementEmbed(announcement: Announcement, event: Event, settings: GuildSettings): EmbedCreateSpec {
val builder = defaultEmbedBuilder(settings)
.color(event.color.asColor())
.title(getEmbedMessage("announcement", "simple.title", settings))
.title(getEmbedMessage("announcement", "simple.title", settings.locale))
if (event.name.isNotBlank()) builder.addField(
getEmbedMessage("announcement", "simple.field.name", settings),
getEmbedMessage("announcement", "simple.field.name", settings.locale),
event.name.toMarkdown().embedFieldSafe(),
false
)
if (event.description.isNotBlank()) builder.addField(
getEmbedMessage("announcement", "simple.field.desc", settings),
getEmbedMessage("announcement", "simple.field.desc", settings.locale),
event.description.toMarkdown().embedFieldSafe(),
false
)
builder.addField(
getEmbedMessage("announcement", "simple.field.start", settings),
event.start.asDiscordTimestamp(DiscordTimestampFormat.LONG_DATETIME),
getEmbedMessage("announcement", "simple.field.start", settings.locale),
event.start.asDiscordTimestamp(LONG_DATETIME),
true
)
if (event.location.isNotBlank()) builder.addField(
getEmbedMessage("announcement", "simple.field.location", settings),
getEmbedMessage("announcement", "simple.field.location", settings.locale),
event.location.toMarkdown().embedFieldSafe(),
false
)
if (!announcement.info.isNullOrBlank()) builder.addField(
getEmbedMessage("announcement", "simple.field.info", settings),
getEmbedMessage("announcement", "simple.field.info", settings.locale),
announcement.info.toMarkdown().embedFieldSafe(),
false
)
@@ -429,7 +635,7 @@ class EmbedService(
if (event.image.isNotEmpty())
builder.image(event.image)
builder.footer(getEmbedMessage("announcement", "simple.footer", settings, announcement.id), null)
builder.footer(getEmbedMessage("announcement", "simple.footer", settings.locale, announcement.id), null)
return builder.build()
}
@@ -437,45 +643,45 @@ class EmbedService(
suspend fun eventAnnouncementEmbed(announcement: Announcement, event: Event, settings: GuildSettings): EmbedCreateSpec {
val builder = defaultEmbedBuilder(settings)
.color(event.color.asColor())
.title(getEmbedMessage("announcement", "event.title", settings))
.title(getEmbedMessage("announcement", "event.title", settings.locale))
if (event.name.isNotBlank()) builder.addField(
getEmbedMessage("announcement", "event.field.name", settings),
getEmbedMessage("announcement", "event.field.name", settings.locale),
event.name.toMarkdown().embedFieldSafe(),
false
)
if (event.description.isNotBlank()) builder.addField(
getEmbedMessage("announcement", "event.field.desc", settings),
getEmbedMessage("announcement", "event.field.desc", settings.locale),
event.description.toMarkdown().embedFieldSafe(),
false
)
builder.addField(
getEmbedMessage("announcement", "event.field.start", settings),
event.start.asDiscordTimestamp(DiscordTimestampFormat.LONG_DATETIME),
getEmbedMessage("announcement", "event.field.start", settings.locale),
event.start.asDiscordTimestamp(LONG_DATETIME),
true
)
builder.addField(
getEmbedMessage("announcement", "event.field.end", settings),
event.end.asDiscordTimestamp(DiscordTimestampFormat.LONG_DATETIME),
getEmbedMessage("announcement", "event.field.end", settings.locale),
event.end.asDiscordTimestamp(LONG_DATETIME),
true
)
if (event.location.isNotBlank()) builder.addField(
getEmbedMessage("announcement", "event.field.location", settings),
getEmbedMessage("announcement", "event.field.location", settings.locale),
event.location.toMarkdown().embedFieldSafe(),
false
)
builder.addField(
getEmbedMessage("announcement", "event.field.calendar", settings),
"${event.calendar.calendarNumber}",
getEmbedMessage("announcement", "event.field.calendar", settings.locale),
"${event.calendarNumber}",
true
)
builder.addField(getEmbedMessage("announcement", "event.field.event", settings), event.eventId, true)
builder.addField(getEmbedMessage("announcement", "event.field.event", settings.locale), event.id, true)
if (!announcement.info.isNullOrBlank()) builder.addField(
getEmbedMessage("announcement", "event.field.info", settings),
getEmbedMessage("announcement", "event.field.info", settings.locale),
announcement.info.toMarkdown().embedFieldSafe(),
false
)
@@ -483,47 +689,47 @@ class EmbedService(
if (event.image.isNotBlank())
builder.image(event.image)
builder.footer(getEmbedMessage("announcement", "event.footer", settings, announcement.id), null)
builder.footer(getEmbedMessage("announcement", "event.footer", settings.locale, announcement.id), null)
return builder.build()
}
suspend fun viewAnnouncementEmbed(announcement: Announcement, settings: GuildSettings): EmbedCreateSpec {
val builder = defaultEmbedBuilder(settings)
.title(getEmbedMessage("announcement", "view.title", settings))
.addField(getEmbedMessage("announcement", "view.field.type", settings), announcement.type.name, true)
.addField(getEmbedMessage("announcement", "view.field.modifier", settings), announcement.modifier.name, true)
.addField(getEmbedMessage("announcement", "view.field.channel", settings), "<#${announcement.channelId.asLong()}>", false)
.addField(getEmbedMessage("announcement", "view.field.hours", settings), "${announcement.hoursBefore}", true)
.addField(getEmbedMessage("announcement", "view.field.minutes", settings), "${announcement.minutesBefore}", true)
.title(getEmbedMessage("announcement", "view.title", settings.locale))
.addField(getEmbedMessage("announcement", "view.field.type", settings.locale), announcement.type.name, true)
.addField(getEmbedMessage("announcement", "view.field.modifier", settings.locale), announcement.modifier.name, true)
.addField(getEmbedMessage("announcement", "view.field.channel", settings.locale), "<#${announcement.channelId.asLong()}>", false)
.addField(getEmbedMessage("announcement", "view.field.hours", settings.locale), "${announcement.hoursBefore}", true)
.addField(getEmbedMessage("announcement", "view.field.minutes", settings.locale), "${announcement.minutesBefore}", true)
if (!announcement.info.isNullOrBlank()) {
builder.addField(getEmbedMessage("announcement", "view.field.info", settings), announcement.info.toMarkdown().embedFieldSafe(), false)
builder.addField(getEmbedMessage("announcement", "view.field.info", settings.locale), announcement.info.toMarkdown().embedFieldSafe(), false)
}
builder.addField(getEmbedMessage("announcement", "view.field.calendar", settings), "${announcement.calendarNumber}", true)
builder.addField(getEmbedMessage("announcement", "view.field.calendar", settings.locale), "${announcement.calendarNumber}", true)
if (announcement.type == Announcement.Type.RECUR || announcement.type == Announcement.Type.SPECIFIC)
builder.addField(getEmbedMessage("announcement", "view.field.event", settings), announcement.eventId!!, true)
builder.addField(getEmbedMessage("announcement", "view.field.event", settings.locale), announcement.eventId!!, true)
if (announcement.type == Announcement.Type.COLOR) {
builder.color(announcement.eventColor.asColor())
builder.addField(getEmbedMessage("announcement", "view.field.color", settings), announcement.eventColor.name, true)
builder.addField(getEmbedMessage("announcement", "view.field.color", settings.locale), announcement.eventColor.name, true)
} else builder.color(GlobalVal.discalColor)
return builder.addField(getEmbedMessage("announcement", "view.field.id", settings), announcement.id, false)
.addField(getEmbedMessage("announcement", "view.field.enabled", settings), "${announcement.enabled}", true)
.addField(getEmbedMessage("announcement", "view.field.publish", settings), "${announcement.publish}", true)
return builder.addField(getEmbedMessage("announcement", "view.field.id", settings.locale), announcement.id, false)
.addField(getEmbedMessage("announcement", "view.field.enabled", settings.locale), "${announcement.enabled}", true)
.addField(getEmbedMessage("announcement", "view.field.publish", settings.locale), "${announcement.publish}", true)
.build()
}
suspend fun condensedAnnouncementEmbed(announcement: Announcement, settings: GuildSettings): EmbedCreateSpec {
val builder = defaultEmbedBuilder(settings)
.title(getEmbedMessage("announcement", "con.title", settings))
.addField(getEmbedMessage("announcement", "con.field.id", settings), announcement.id, false)
.addField(getEmbedMessage("announcement", "con.field.time", settings), "${announcement.hoursBefore}H${announcement.minutesBefore}m", true)
.addField(getEmbedMessage("announcement", "con.field.enabled", settings), "${announcement.enabled}", true)
.footer(getEmbedMessage("announcement", "con.footer", settings, announcement.type.name, announcement.modifier.name), null)
.title(getEmbedMessage("announcement", "con.title", settings.locale))
.addField(getEmbedMessage("announcement", "con.field.id", settings.locale), announcement.id, false)
.addField(getEmbedMessage("announcement", "con.field.time", settings.locale), "${announcement.hoursBefore}H${announcement.minutesBefore}m", true)
.addField(getEmbedMessage("announcement", "con.field.enabled", settings.locale), "${announcement.enabled}", true)
.footer(getEmbedMessage("announcement", "con.footer", settings.locale, announcement.type.name, announcement.modifier.name), null)
if (announcement.type == Announcement.Type.COLOR) builder.color(announcement.eventColor.asColor())
else builder.color(GlobalVal.discalColor)
@@ -531,24 +737,24 @@ class EmbedService(
return builder.build()
}
suspend fun announcementWizardEmbed(wizard: WizardState<Announcement>, settings: GuildSettings): EmbedCreateSpec {
suspend fun announcementWizardEmbed(wizard: AnnouncementWizardState, settings: GuildSettings): EmbedCreateSpec {
val announcement = wizard.entity
val builder = defaultEmbedBuilder(settings)
.title(getEmbedMessage("announcement", "wizard.title", settings))
.footer(getEmbedMessage("announcement", "wizard.footer", settings), null)
.title(getEmbedMessage("announcement", "wizard.title", settings.locale))
.footer(getEmbedMessage("announcement", "wizard.footer", settings.locale), null)
.color(announcement.eventColor.asColor())
//fields
.addField(getEmbedMessage("announcement", "wizard.field.type", settings), announcement.type.name, true)
.addField(getEmbedMessage("announcement", "wizard.field.modifier", settings), announcement.modifier.name, true)
.addField(getEmbedMessage("announcement", "wizard.field.type", settings.locale), announcement.type.name, true)
.addField(getEmbedMessage("announcement", "wizard.field.modifier", settings.locale), announcement.modifier.name, true)
if (announcement.type == Announcement.Type.COLOR) {
if (announcement.eventColor == EventColor.NONE) builder.addField(
getEmbedMessage("announcement", "wizard.field.color", settings),
getCommonMsg("embed.unset", settings),
getEmbedMessage("announcement", "wizard.field.color", settings.locale),
getCommonMsg("embed.unset", settings.locale),
false
) else builder.addField(
getEmbedMessage("announcement", "wizard.field.color", settings),
getEmbedMessage("announcement", "wizard.field.color", settings.locale),
announcement.eventColor.name,
false
)
@@ -556,56 +762,56 @@ class EmbedService(
if (announcement.type == Announcement.Type.SPECIFIC || announcement.type == Announcement.Type.RECUR) {
if (announcement.eventId.isNullOrBlank()) builder.addField(
getEmbedMessage("announcement", "wizard.field.event", settings),
getCommonMsg("embed.unset", settings),
getEmbedMessage("announcement", "wizard.field.event", settings.locale),
getCommonMsg("embed.unset", settings.locale),
false
) else builder.addField(
getEmbedMessage("announcement", "wizard.field.event", settings),
getEmbedMessage("announcement", "wizard.field.event", settings.locale),
announcement.eventId,
false
)
}
if (announcement.info.isNullOrBlank()) builder.addField(
getEmbedMessage("announcement", "wizard.field.info", settings),
getCommonMsg("embed.unset", settings),
getEmbedMessage("announcement", "wizard.field.info", settings.locale),
getCommonMsg("embed.unset", settings.locale),
false
) else builder.addField(
getEmbedMessage("announcement", "wizard.field.info", settings),
getEmbedMessage("announcement", "wizard.field.info", settings.locale),
announcement.info.embedFieldSafe().toMarkdown(),
false
)
builder.addField(getEmbedMessage("announcement", "wizard.field.channel", settings), "<#${announcement.channelId.asLong()}>", false)
builder.addField(getEmbedMessage("announcement", "wizard.field.minutes", settings), "${announcement.minutesBefore}", true)
builder.addField(getEmbedMessage("announcement", "wizard.field.hours", settings), "${announcement.hoursBefore}", true)
builder.addField(getEmbedMessage("announcement", "wizard.field.channel", settings.locale), "<#${announcement.channelId.asLong()}>", false)
builder.addField(getEmbedMessage("announcement", "wizard.field.minutes", settings.locale), "${announcement.minutesBefore}", true)
builder.addField(getEmbedMessage("announcement", "wizard.field.hours", settings.locale), "${announcement.hoursBefore}", true)
if (wizard.editing) builder.addField(getEmbedMessage("announcement", "wizard.field.id", settings), announcement.id, false)
if (wizard.editing) builder.addField(getEmbedMessage("announcement", "wizard.field.id", settings.locale), announcement.id, false)
else builder.addField(
getEmbedMessage("announcement", "wizard.field.id", settings),
getCommonMsg("embed.unset", settings),
getEmbedMessage("announcement", "wizard.field.id", settings.locale),
getCommonMsg("embed.unset", settings.locale),
false
)
builder.addField(getEmbedMessage("announcement", "wizard.field.publish", settings), "${announcement.publish}", true)
builder.addField(getEmbedMessage("announcement", "wizard.field.enabled", settings), "${announcement.enabled}", true)
builder.addField(getEmbedMessage("announcement", "wizard.field.calendar", settings), "${announcement.calendarNumber}", true)
builder.addField(getEmbedMessage("announcement", "wizard.field.publish", settings.locale), "${announcement.publish}", true)
builder.addField(getEmbedMessage("announcement", "wizard.field.enabled", settings.locale), "${announcement.enabled}", true)
builder.addField(getEmbedMessage("announcement", "wizard.field.calendar", settings.locale), "${announcement.calendarNumber}", true)
// Build up any warnings
val warningsBuilder = StringBuilder()
if ((announcement.type == Announcement.Type.SPECIFIC || announcement.type == Announcement.Type.RECUR) && announcement.eventId.isNullOrBlank())
warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.eventId", settings)).appendLine()
warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.eventId", settings.locale)).appendLine()
if (announcement.type == Announcement.Type.COLOR && announcement.eventColor == EventColor.NONE)
warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.color", settings)).appendLine()
warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.color", settings.locale)).appendLine()
if (announcement.getCalculatedTime() < Duration.ofMinutes(5))
warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.time", settings)).appendLine()
warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.time", settings.locale)).appendLine()
if (announcement.calendarNumber > settings.maxCalendars)
warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.calNum", settings))
warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.calNum", settings.locale))
if (warningsBuilder.isNotBlank()) {
builder.addField(getEmbedMessage("announcement", "wizard.field.warnings", settings), warningsBuilder.toString(), false)
builder.addField(getEmbedMessage("announcement", "wizard.field.warnings", settings.locale), warningsBuilder.toString(), false)
}
return builder.build()

View File

@@ -0,0 +1,85 @@
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.EventCache
import org.dreamexposure.discal.core.database.EventMetadataData
import org.dreamexposure.discal.core.database.EventMetadataRepository
import org.dreamexposure.discal.core.`object`.new.EventMetadata
import org.springframework.stereotype.Component
@Component
class EventMetadataService(
private val eventMetadataRepository: EventMetadataRepository,
private val eventCache: EventCache,
) {
/////////
/// Event metadata - Prefer using full Event implementation in CalendarService
/////////
suspend fun hasEventMetadata(guildId: Snowflake, eventId: String): Boolean {
val computedId = eventId.split("_")[0]
return eventMetadataRepository.existsByGuildIdAndEventId(guildId.asLong(), computedId)
.awaitSingle()
}
suspend fun getEventMetadata(guildId: Snowflake, eventId: String): EventMetadata? {
val computedId = eventId.split("_")[0]
return eventMetadataRepository.findByGuildIdAndEventId(guildId.asLong(), computedId)
.map(::EventMetadata)
.awaitSingleOrNull()
}
suspend fun getMultipleEventsMetadata(guildId: Snowflake, eventIds: List<String>): List<EventMetadata> {
val computedIds = eventIds.map { eventId -> eventId.split("_")[0] }
return eventMetadataRepository.findAllByGuildIdAndEventIdIn(guildId.asLong(), computedIds)
.map(::EventMetadata)
.collectList()
.awaitSingle()
}
suspend fun createEventMetadata(event: EventMetadata): EventMetadata {
val computedId = event.id.split("_")[0]
return eventMetadataRepository.save(EventMetadataData(
guildId = event.guildId.asLong(),
eventId = computedId,
calendarNumber = event.calendarNumber,
eventEnd = event.eventEnd.toEpochMilli(),
imageLink = event.imageLink,
)).map(::EventMetadata).awaitSingle()
}
suspend fun updateEventMetadata(event: EventMetadata) {
val computedId = event.id.split("_")[0]
eventMetadataRepository.updateByGuildIdAndEventId(
guildId = event.guildId.asLong(),
eventId = computedId,
calendarNumber = event.calendarNumber,
eventEnd = event.eventEnd.toEpochMilli(),
imageLink = event.imageLink
).awaitSingleOrNull()
}
suspend fun upsertEventMetadata(event: EventMetadata) {
if (hasEventMetadata(event.guildId, event.id)) updateEventMetadata(event)
else createEventMetadata(event)
}
suspend fun deleteEventMetadata(guildId: Snowflake, id: String) {
if (id.contains("_")) return // Don't delete if child of recurring parent.
eventMetadataRepository.deleteByGuildIdAndEventId(guildId.asLong(), id).awaitSingleOrNull()
}
suspend fun deleteEventMetadataForCalendarDeletion(guildId: Snowflake, calendarNumber: Int) {
eventMetadataRepository.deleteAllByGuildIdAndCalendarNumber(guildId.asLong(), calendarNumber).awaitSingleOrNull()
eventMetadataRepository.decrementCalendarsByGuildIdAndCalendarNumber(guildId.asLong(), calendarNumber).awaitSingleOrNull()
eventCache.evictAll(guildId)
}
}

View File

@@ -0,0 +1,82 @@
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.GuildSettingsCache
import org.dreamexposure.discal.core.database.GuildSettingsData
import org.dreamexposure.discal.core.database.GuildSettingsRepository
import org.dreamexposure.discal.core.logger.LOGGER
import org.dreamexposure.discal.core.`object`.new.GuildSettings
import org.springframework.stereotype.Component
@Component
class GuildSettingsService(
private val repository: GuildSettingsRepository,
private val cache: GuildSettingsCache,
) {
suspend fun hasSettings(guildId: Snowflake): Boolean {
return repository.existsByGuildId(guildId.asLong()).awaitSingle()
}
suspend fun getSettings(guildId: Snowflake): GuildSettings {
var settings = cache.get(key = guildId)
if (settings != null) return settings
settings = repository.findByGuildId(guildId.asLong())
.map(::GuildSettings)
.defaultIfEmpty(GuildSettings(guildId = guildId))
.awaitSingle()
cache.put(key = guildId, value = settings)
return settings
}
suspend fun createSettings(settings: GuildSettings): GuildSettings {
LOGGER.debug("Creating new settings for guild: {}", settings.guildId)
val saved = repository.save(GuildSettingsData(
guildId = settings.guildId.asLong(),
controlRole = settings.controlRole?.asString() ?: "everyone",
timeFormat = settings.interfaceStyle.timeFormat.value,
patronGuild = settings.patronGuild,
devGuild = settings.devGuild,
maxCalendars = settings.maxCalendars,
lang = settings.locale.toLanguageTag(),
branded = settings.interfaceStyle.branded,
announcementStyle = settings.interfaceStyle.announcementStyle.value,
eventKeepDuration = settings.eventKeepDuration,
)).map(::GuildSettings).awaitSingle()
cache.put(key = saved.guildId, value = saved)
return saved
}
suspend fun updateSettings(settings: GuildSettings) {
LOGGER.debug("Updating guild settings for {}", settings.guildId)
repository.updateByGuildId(
guildId = settings.guildId.asLong(),
controlRole = settings.controlRole?.asString() ?: "everyone",
timeFormat = settings.interfaceStyle.timeFormat.value,
patronGuild = settings.patronGuild,
devGuild = settings.devGuild,
maxCalendars = settings.maxCalendars,
lang = settings.locale.toLanguageTag(),
branded = settings.interfaceStyle.branded,
announcementStyle = settings.interfaceStyle.announcementStyle.value,
eventKeepDuration = settings.eventKeepDuration,
).awaitSingleOrNull()
cache.put(key = settings.guildId, value = settings)
}
suspend fun upsertSettings(settings: GuildSettings): GuildSettings {
if (hasSettings(settings.guildId)) updateSettings(settings)
else return createSettings(settings)
return settings
}
}

View File

@@ -0,0 +1,62 @@
package org.dreamexposure.discal.core.business
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.dreamexposure.discal.core.logger.LOGGER
import org.springframework.stereotype.Component
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
import java.net.MalformedURLException
import java.net.URI
import java.time.Duration
import javax.imageio.ImageIO
import javax.imageio.ImageReader
@Component
class ImageValidationService {
suspend fun validate(url: String, allowGif: Boolean): Boolean = withContext(Dispatchers.IO) {
return@withContext try {
val image = ImageIO.read(URI.create(url).toURL())
image != null
} catch (_: IOException) {
if (allowGif) validateGif(url)
else false
} catch (_: MalformedURLException) {
false
} catch (_: FileNotFoundException) {
false
} catch (ex: Exception) {
LOGGER.error("Image validation failed with unexpected exception", ex)
false
}
}
private suspend fun validateGif(url: String): Boolean = withContext(Dispatchers.IO) {
val connection = URI.create(url).toURL().openConnection()
connection.connectTimeout = Duration.ofSeconds(3).toMillis().toInt()
connection.readTimeout = Duration.ofSeconds(3).toMillis().toInt()
readGif(connection.inputStream)?.equals(".gif", true) == true
}
private suspend fun readGif(input: InputStream): String? = withContext(Dispatchers.IO) {
val stream = ImageIO.createImageInputStream(input)
val iterator = ImageIO.getImageReaders(stream)
if (!iterator.hasNext()) return@withContext null
var reader: ImageReader? = null
try {
reader = iterator.next()
reader.setInput(stream, true, true)
reader.read(0, reader.defaultReadParam)
} catch (_: Exception) {
} finally {
reader?.dispose()
}
reader?.formatName
}
}

View File

@@ -0,0 +1,55 @@
package org.dreamexposure.discal.core.business
import discord4j.common.util.Snowflake
import discord4j.core.DiscordClient
import discord4j.discordjson.json.RoleData
import discord4j.rest.util.Permission
import discord4j.rest.util.PermissionSet
import kotlinx.coroutines.reactor.awaitSingle
import org.springframework.beans.factory.BeanFactory
import org.springframework.beans.factory.getBean
import org.springframework.stereotype.Component
import java.util.function.Predicate
@Component
class PermissionService(
private val settingsService: GuildSettingsService,
private val beanFactory: BeanFactory
) {
private val discordClient
get() = beanFactory.getBean<DiscordClient>()
suspend fun hasControlRole(guildId: Snowflake, memberId: Snowflake): Boolean {
val settings = settingsService.getSettings(guildId)
if (settings.controlRole == null || settings.controlRole == guildId) return true
val memberData = discordClient.getMemberById(guildId, memberId).data.awaitSingle()
return memberData.roles().map(Snowflake::of).contains(settings.controlRole)
}
suspend fun hasPermissions(guildId: Snowflake, memberId: Snowflake, pred: Predicate<PermissionSet>): Boolean {
val guildData = discordClient.getGuildById(guildId).data.awaitSingle()
// Owner has full permissions, always
if (guildData.ownerId().asLong() == memberId.asLong()) return true
val memberData = discordClient.getMemberById(guildId, memberId).data.awaitSingle()
val computedPermissions = PermissionSet.of(
guildData.roles()
.filter { memberData.roles().contains(it.id()) }
.map(RoleData::permissions)
.reduceOrNull { acc, lng -> acc or lng } ?: 0L
)
return pred.test(computedPermissions)
}
suspend fun hasElevatedPermissions(guildId: Snowflake, memberId: Snowflake): Boolean {
return hasPermissions(guildId, memberId) {
it.contains(Permission.MANAGE_GUILD) || it.contains(Permission.ADMINISTRATOR)
}
}
}

View File

@@ -22,6 +22,7 @@ class RsvpService(
private val rsvpRepository: RsvpRepository,
private val rsvpCache: RsvpCache,
private val embedService: EmbedService,
private val calendarService: CalendarService,
private val beanFactory: BeanFactory,
) {
private val discordClient: DiscordClient
@@ -78,11 +79,11 @@ class RsvpService(
// Validate that role exists if changed
if (new.role != null && old.role != new.role) {
val exists = discordClient.getRoleById(new.guildId, new.role!!).data
val exists = discordClient.getRoleById(new.guildId, new.role).data
.transform(ClientException.emptyOnStatus(GlobalVal.STATUS_NOT_FOUND))
.hasElement()
.awaitSingle()
if (!exists) throw NotFoundException("Role not found for guild:${new.guildId.asString()} role:${new.role!!.asString()}")
if (!exists) throw NotFoundException("Role not found for guild:${new.guildId.asString()} role:${new.role.asString()}")
}
// Handle role change (remove roles, store to-add in list for later)
@@ -170,8 +171,10 @@ class RsvpService(
}
// Send out DMs
val event = calendarService.getEvent(rsvp.guildId, rsvp.calendarNumber, rsvp.eventId) ?: throw RuntimeException("This really should just not be possible lmao")
toDm.forEach { userId ->
val embed = embedService.rsvpDmFollowupEmbed(new, userId)
val embed = embedService.rsvpDmFollowupEmbed(new, event, userId)
discordClient.getUserById(userId).privateChannel.flatMap { channelData ->
discordClient.getChannelById(Snowflake.of(channelData.id()))
@@ -204,4 +207,10 @@ class RsvpService(
.forEach { rsvpCache.put(guildId, it.eventId, it) }
}
suspend fun deleteRsvpForCalendarDeletion(guildId: Snowflake, calendarNumber: Int) {
rsvpRepository.deleteAllByGuildIdAndCalendarNumber(guildId.asLong(), calendarNumber).awaitSingleOrNull()
rsvpRepository.decrementCalendarsByGuildIdAndCalendarNumber(guildId.asLong(), calendarNumber).awaitSingleOrNull()
rsvpCache.evictAll(guildId)
}
}

View File

@@ -8,11 +8,10 @@ import discord4j.rest.http.client.ClientException
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.dreamexposure.discal.StaticMessageCache
import org.dreamexposure.discal.core.config.Config
import org.dreamexposure.discal.core.database.StaticMessageData
import org.dreamexposure.discal.core.database.StaticMessageRepository
import org.dreamexposure.discal.core.exceptions.NotFoundException
import org.dreamexposure.discal.core.extensions.discord4j.getCalendar
import org.dreamexposure.discal.core.extensions.discord4j.getSettings
import org.dreamexposure.discal.core.`object`.new.StaticMessage
import org.springframework.beans.factory.BeanFactory
import org.springframework.beans.factory.getBean
@@ -27,6 +26,7 @@ import java.time.temporal.ChronoUnit
class StaticMessageService(
private val staticMessageRepository: StaticMessageRepository,
private val staticMessageCache: StaticMessageCache,
private val calendarService: CalendarService,
private val embedService: EmbedService,
private val componentService: ComponentService,
private val metricService: MetricService,
@@ -34,6 +34,7 @@ class StaticMessageService(
) {
private val discordClient: DiscordClient
get() = beanFactory.getBean()
private val OVERVIEW_EVENT_COUNT = Config.CALENDAR_OVERVIEW_DEFAULT_EVENT_COUNT.getInt()
suspend fun getStaticMessageCount() = staticMessageRepository.count().awaitSingle()
@@ -75,12 +76,10 @@ class StaticMessageService(
): StaticMessage {
// Gather everything we need
val settings = discordClient.getGuildById(guildId).getSettings().awaitSingle()
val calendar = discordClient.getGuildById(guildId)
.getCalendar(calendarNumber)
.awaitSingleOrNull() ?: throw NotFoundException("Calendar not found")
val calendar = calendarService.getCalendar(guildId, calendarNumber) ?: throw NotFoundException("Calendar not found")
val events = calendarService.getUpcomingEvents(guildId, calendarNumber, OVERVIEW_EVENT_COUNT)
val channel = discordClient.getChannelById(channelId)
val embed = embedService.calendarOverviewEmbed(calendar, settings, showUpdate = true)
val embed = embedService.calendarOverviewEmbed(calendar, events, showUpdate = true)
val nextUpdate = ZonedDateTime.now(calendar.timezone)
.truncatedTo(ChronoUnit.DAYS)
.plusHours(updateHour + 24)
@@ -127,13 +126,11 @@ class StaticMessageService(
return
}
val settings = discordClient.getGuildById(guildId).getSettings().awaitSingle()
val calendar = discordClient.getGuildById(guildId)
.getCalendar(old.calendarNumber)
.awaitSingleOrNull() ?: throw NotFoundException("Calendar not found")
val calendar = calendarService.getCalendar(guildId, old.calendarNumber) ?: throw NotFoundException("Calendar not found")
val events = calendarService.getUpcomingEvents(guildId, old.calendarNumber, OVERVIEW_EVENT_COUNT)
// Finally update the message
val embed = embedService.calendarOverviewEmbed(calendar, settings, showUpdate = true)
val embed = embedService.calendarOverviewEmbed(calendar, events, showUpdate = true)
discordClient.getMessageById(old.channelId, old.messageId).edit(
MessageEditRequest.builder()
@@ -168,11 +165,9 @@ class StaticMessageService(
taskTimer.start()
val oldVersions = getStaticMessagesForCalendar(guildId, calendarNumber)
val settings = discordClient.getGuildById(guildId).getSettings().awaitSingle()
val calendar = discordClient.getGuildById(guildId)
.getCalendar(calendarNumber)
.awaitSingleOrNull() ?: throw NotFoundException("Calendar not found")
val embed = embedService.calendarOverviewEmbed(calendar, settings, showUpdate = true)
val calendar = calendarService.getCalendar(guildId, calendarNumber) ?: throw NotFoundException("Calendar not found")
val events = calendarService.getUpcomingEvents(guildId, calendarNumber, OVERVIEW_EVENT_COUNT)
val embed = embedService.calendarOverviewEmbed(calendar, events, showUpdate = true)
oldVersions.forEach { old ->
val existingData = discordClient.getMessageById(old.channelId, old.messageId)
@@ -215,7 +210,13 @@ class StaticMessageService(
}
suspend fun deleteStaticMessage(guildId: Snowflake, messageId: Snowflake) {
staticMessageRepository.deleteByGuildIdAndMessageId(guildId.asLong(), messageId.asLong()).awaitSingleOrNull()
staticMessageRepository.deleteAllByGuildIdAndMessageId(guildId.asLong(), messageId.asLong()).awaitSingleOrNull()
staticMessageCache.evict(guildId, key = messageId)
}
suspend fun deleteStaticMessagesForCalendarDeletion(guildId: Snowflake, calendarNumber: Int) {
staticMessageRepository.deleteByGuildIdAndCalendarNumber(guildId.asLong(), calendarNumber).awaitSingleOrNull()
staticMessageRepository.decrementCalendarsByGuildIdAndCalendarNumber(guildId.asLong(), calendarNumber).awaitSingleOrNull()
staticMessageCache.evictAll(guildId)
}
}

View File

@@ -0,0 +1,50 @@
package org.dreamexposure.discal.core.business.api
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.executeAsync
import org.dreamexposure.discal.core.logger.LOGGER
import org.dreamexposure.discal.core.`object`.new.model.ResponseModel
import org.dreamexposure.discal.core.`object`.rest.ErrorResponse
import org.springframework.stereotype.Component
@Component
class ApiWrapperClient(
private val httpClient: OkHttpClient,
private val objectMapper: ObjectMapper,
) {
internal suspend fun <T> makeRequest(request: Request, valueType: Class<T>): ResponseModel<T> {
var response: Response? = null
try {
response = httpClient.newCall(request).executeAsync()
when (response.code) {
200 -> {
val data = objectMapper.readValue(response.body!!.string(), valueType)
response.body?.close()
response.close()
return ResponseModel(data)
}
else -> {
val error = objectMapper.readValue<ErrorResponse>(response.body!!.string())
response.body?.close()
response.close()
return ResponseModel(error, response.code)
}
}
} catch (ex: Exception) {
LOGGER.error("Error making request host:${request.url.host} | uri:${request.url.encodedPath} | code:${response?.code}", ex)
throw ex // Rethrow and let implementation decide proper handling for exception
} finally {
response?.body?.close()
response?.close()
}
}
}

View File

@@ -0,0 +1,69 @@
package org.dreamexposure.discal.core.business.api
import com.fasterxml.jackson.databind.ObjectMapper
import discord4j.common.util.Snowflake
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.dreamexposure.discal.core.config.Config
import org.dreamexposure.discal.core.enums.calendar.CalendarHost
import org.dreamexposure.discal.core.logger.LOGGER
import org.dreamexposure.discal.core.`object`.new.model.ResponseModel
import org.dreamexposure.discal.core.`object`.new.model.discal.cam.SecurityValidateV1Request
import org.dreamexposure.discal.core.`object`.new.model.discal.cam.SecurityValidateV1Response
import org.dreamexposure.discal.core.`object`.new.model.discal.cam.TokenV1Model
import org.dreamexposure.discal.core.utils.GlobalVal.JSON
import org.springframework.stereotype.Component
@Component
class CamApiWrapper(
private val apiWrapperClient: ApiWrapperClient,
private val objectMapper: ObjectMapper,
) {
private final val CAM_URL = Config.URL_CAM.getString()
private final val AUTH_HEADER = "Int ${Config.SECRET_DISCAL_API_KEY.getString()}"
suspend fun validateToken(requestBody: SecurityValidateV1Request): ResponseModel<SecurityValidateV1Response> {
val request = Request.Builder()
.url("${Config.URL_CAM.getString()}/v1/security/validate")
.post(objectMapper.writeValueAsString(requestBody).toRequestBody(JSON))
.header("Authorization", AUTH_HEADER)
.header("Content-Type", "application/json")
.build()
return apiWrapperClient.makeRequest(request, SecurityValidateV1Response::class.java)
}
suspend fun getCalendarToken(credentialId: Int): ResponseModel<TokenV1Model> {
LOGGER.debug("Getting calendar token for credential:$credentialId")
val url = "$CAM_URL/v1/token".toHttpUrl().newBuilder()
.addQueryParameter("host", CalendarHost.GOOGLE.name)
.addQueryParameter("id", credentialId.toString())
.build()
val request = Request.Builder().get()
.header("Authorization", AUTH_HEADER)
.url(url)
.build()
return apiWrapperClient.makeRequest(request, TokenV1Model::class.java)
}
suspend fun getCalendarToken(guildId: Snowflake, calNumber: Int, host: CalendarHost): ResponseModel<TokenV1Model> {
LOGGER.debug("Getting calendar token for guild:{} | host:{} | calendarId:{} ", guildId.asLong(), host.name, calNumber)
val url = "$CAM_URL/v1/token".toHttpUrl().newBuilder()
.addQueryParameter("host", host.name)
.addQueryParameter("guild", guildId.asString())
.addQueryParameter("id", calNumber.toString())
.build()
val request = Request.Builder().get()
.header("Authorization", AUTH_HEADER)
.url(url)
.build()
return apiWrapperClient.makeRequest(request, TokenV1Model::class.java)
}
}

View File

@@ -0,0 +1,44 @@
package org.dreamexposure.discal.core.business.google
import okhttp3.FormBody
import okhttp3.Request
import org.dreamexposure.discal.core.business.api.ApiWrapperClient
import org.dreamexposure.discal.core.config.Config
import org.dreamexposure.discal.core.exceptions.AccessRevokedException
import org.dreamexposure.discal.core.logger.LOGGER
import org.dreamexposure.discal.core.`object`.new.model.ResponseModel
import org.dreamexposure.discal.core.`object`.new.model.google.OauthV4RefreshTokenResponse
import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT
import org.springframework.stereotype.Component
@Component
class GoogleAuthApiWrapper(
private val apiWrapperClient: ApiWrapperClient,
) {
suspend fun refreshAccessToken(refreshToken: String): ResponseModel<OauthV4RefreshTokenResponse> {
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 = apiWrapperClient.makeRequest(request, OauthV4RefreshTokenResponse::class.java)
// TODO: Handling of this should be moved up higher in the impl?
if (response.error?.error == "invalid_grant") {
LOGGER.debug(DEFAULT, "Google Oauth invalid_grant for access token refresh")
throw AccessRevokedException() // TODO: How should I handle this for external calendars? Right now we just delete everything
} else if (response.error != null) {
LOGGER.error(DEFAULT, "[Google] Error requesting new access token | ${response.code} | ${response.error.error}")
}
return response
}
}

View File

@@ -0,0 +1,381 @@
package org.dreamexposure.discal.core.business.google
import com.google.api.client.googleapis.auth.oauth2.GoogleCredential
import com.google.api.client.googleapis.json.GoogleJsonResponseException
import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.gson.GsonFactory
import com.google.api.client.util.DateTime
import com.google.api.services.calendar.model.AclRule
import com.google.api.services.calendar.model.Calendar
import com.google.api.services.calendar.model.CalendarListEntry
import com.google.api.services.calendar.model.Event
import discord4j.common.util.Snowflake
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.dreamexposure.discal.CalendarTokenCache
import org.dreamexposure.discal.core.business.api.CamApiWrapper
import org.dreamexposure.discal.core.config.Config
import org.dreamexposure.discal.core.enums.calendar.CalendarHost
import org.dreamexposure.discal.core.extensions.asSnowflake
import org.dreamexposure.discal.core.extensions.isExpiredTtl
import org.dreamexposure.discal.core.logger.LOGGER
import org.dreamexposure.discal.core.`object`.new.CalendarMetadata
import org.dreamexposure.discal.core.`object`.new.model.ResponseModel
import org.dreamexposure.discal.core.`object`.rest.ErrorResponse
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import java.time.Instant
import com.google.api.services.calendar.Calendar as GoogleCalendarService
@Component
class GoogleCalendarApiWrapper(
private val camApiWrapper: CamApiWrapper,
private val calendarTokenCache: CalendarTokenCache, // Technically an antipattern, but it is domain-specific so...
) {
// Using this as guildId for caching discal owned credentials with guild owned ones cuz its efficient
private val discalId = Config.DISCORD_APP_ID.getLong().asSnowflake()
/////////
/// Auth
/////////
/* Get access token to calendar from cache or CAM */
private suspend fun getAccessToken(credentialId: Int): String {
val token = calendarTokenCache.get(guildId = discalId, credentialId)
if (token != null && !token.validUntil.isExpiredTtl()) return token.accessToken
LOGGER.debug("Fetching new local-copy of global google calendar token via CAM | credentialId:$credentialId")
val tokenResponse = camApiWrapper.getCalendarToken(credentialId)
return if (tokenResponse.entity != null) {
calendarTokenCache.put(guildId = discalId, credentialId, tokenResponse.entity)
tokenResponse.entity.accessToken
} else {
throw RuntimeException("Error requesting local google calendar token from CAM for credentialId: $credentialId | response: error: ${tokenResponse.error?.error}")
}
}
private suspend fun getAccessToken(guildId: Snowflake, calendarNumber: Int): String {
val token = calendarTokenCache.get(guildId, calendarNumber)
if (token != null && !token.validUntil.isExpiredTtl()) return token.accessToken
LOGGER.debug("Fetching new local-copy of external google calendar token via CAM | guild:${guildId.asLong()} calendarNumber:$calendarNumber")
val tokenResponse = camApiWrapper.getCalendarToken(guildId, calendarNumber, CalendarHost.GOOGLE)
return if (tokenResponse.entity != null) {
calendarTokenCache.put(guildId, calendarNumber, tokenResponse.entity)
tokenResponse.entity.accessToken
} else if (tokenResponse.code == HttpStatus.FORBIDDEN.value() && tokenResponse.error?.error.equals("Access to resource revoked")) {
// User MUST reauthorize DisCal in Google if we are seeing this error as the refresh token is invalid
// TODO: Call to delete calendar here to mimic old behavior. Consider marking instead so they can re-auth?
throw NotImplementedError("Call to delete calendar on refresh token revoked (or alternative) not yet implemented")
} else {
throw RuntimeException("Error requesting local google calendar token from CAM for guild:${guildId.asString()} calendarNumber: $calendarNumber | response: error: ${tokenResponse.error?.error}")
}
}
private suspend fun buildGoogleCalendarService(accessToken: String): GoogleCalendarService {
val credential = GoogleCredential().setAccessToken(accessToken)
return GoogleCalendarService.Builder(NetHttpTransport(), GsonFactory.getDefaultInstance(), credential)
.setApplicationName("DisCal")
.build()
}
private suspend fun getGoogleCalendarService(credentialId: Int) = buildGoogleCalendarService(getAccessToken(credentialId))
private suspend fun getGoogleCalendarService(guildId: Snowflake, calendarNumber: Int) = buildGoogleCalendarService(getAccessToken(guildId, calendarNumber))
private suspend fun getGoogleCalendarService(calendarMetadata: CalendarMetadata): GoogleCalendarService {
return if (calendarMetadata.external) getGoogleCalendarService(calendarMetadata.guildId, calendarMetadata.number)
else getGoogleCalendarService(calendarMetadata.secrets.credentialId)
}
/////////
/// ACL Rule
/////////
suspend fun insertAclRule(rule: AclRule, calMetadata: CalendarMetadata): ResponseModel<AclRule> = withContext(Dispatchers.IO) {
val service = getGoogleCalendarService(calMetadata)
try {
val aclRule = service.acl()
.insert(calMetadata.id, rule)
.setQuotaUser(calMetadata.guildId.asString())
.execute()
ResponseModel(aclRule)
} catch (e: Exception) {
LOGGER.error("Failed to insert ACL rule for Google Calendar", e)
ResponseModel(ErrorResponse("Failed to insert ACL rule for Google Calendar", e), 600)
}
}
/////////
/// Calendars
/////////
suspend fun createCalendar(calendar: Calendar, credentialId: Int, guildId: Snowflake): ResponseModel<Calendar> = withContext(Dispatchers.IO) {
val service = getGoogleCalendarService(credentialId)
try {
val calendar = service.calendars()
.insert(calendar)
.setQuotaUser(guildId.asString())
.execute()
ResponseModel(calendar)
} catch (e: Exception) {
LOGGER.error("Failed to create calendar for Google Calendar", e)
ResponseModel(ErrorResponse("Failed to create calendar for Google Calendar", e), 600)
}
}
suspend fun patchCalendar(calendar: Calendar, metadata: CalendarMetadata): ResponseModel<Calendar> = withContext(Dispatchers.IO) {
val service = getGoogleCalendarService(metadata)
try {
val calendar = service.calendars()
.patch(calendar.id, calendar)
.setQuotaUser(metadata.guildId.asString())
.execute()
ResponseModel(calendar)
} catch (e: Exception) {
LOGGER.error("Failed to patch calendar for Google Calendar", e)
ResponseModel(ErrorResponse("Failed to patch calendar for Google Calendar", e), 600)
}
}
suspend fun updateCalendar(calendar: Calendar, metadata: CalendarMetadata): ResponseModel<Calendar> = withContext(Dispatchers.IO) {
val service = getGoogleCalendarService(metadata)
try {
val calendar = service.calendars()
.patch(calendar.id, calendar)
.setQuotaUser(metadata.guildId.asString())
.execute()
ResponseModel(calendar)
} catch (e: Exception){
LOGGER.error("Failed to update calendar for Google Calendar", e)
ResponseModel(ErrorResponse("Failed to update calendar for Google Calendar", e), 600)
}
}
suspend fun getCalendar(metadata: CalendarMetadata): ResponseModel<Calendar> = withContext(Dispatchers.IO) {
val service = getGoogleCalendarService(metadata)
try {
val calendar= service.calendars()
.get(metadata.address)
.setQuotaUser(metadata.guildId.asString())
.execute()
ResponseModel(calendar)
} catch (e: Exception) {
LOGGER.error("Failed to get calendar from Google Calendar", e)
ResponseModel(ErrorResponse("Failed to get calendar from Google Calendar", e), 600)
}
}
suspend fun deleteCalendar(metadata: CalendarMetadata): ResponseModel<Boolean> = withContext(Dispatchers.IO) {
// Sanity check if calendar can be deleted
if (metadata.external || metadata.address.equals("primary", true)) return@withContext ResponseModel(false)
val service = getGoogleCalendarService(metadata)
try {
service.calendars()
.delete(metadata.address)
.setQuotaUser(metadata.guildId.asString())
.execute()
ResponseModel(true)
} catch (e: Exception) {
LOGGER.error("Failed to delete calendar from Google Calendar", e)
ResponseModel(600, false, ErrorResponse("Failed to delete calendar from Google Calendar", e))
}
}
suspend fun getUsersExternalCalendars(metadata: CalendarMetadata): ResponseModel<List<CalendarListEntry>> = withContext(Dispatchers.IO) {
val service = getGoogleCalendarService(metadata)
try {
val calendarList = service.calendarList()
.list()
.setMinAccessRole("writer")
.setQuotaUser(metadata.guildId.asString())
.execute()
ResponseModel(calendarList.items)
} catch (e: Exception) {
LOGGER.error("Failed to list external calendars", e)
ResponseModel(600, emptyList(), ErrorResponse("Failed to list external calendars", e))
}
}
/////////
/// Events
/////////
suspend fun createEvent(metadata: CalendarMetadata, event: Event): ResponseModel<Event> = withContext(Dispatchers.IO) {
val service = getGoogleCalendarService(metadata)
try {
val event = service.events()
.insert(metadata.id, event)
.setQuotaUser(metadata.guildId.asString())
.execute()
ResponseModel(event)
} catch (e: Exception) {
LOGGER.error("Failed to create event on Google Calendar", e)
ResponseModel(ErrorResponse("Failed to create event on Google Calendar", e), 600)
}
}
suspend fun patchEvent(metadata: CalendarMetadata, event: Event): ResponseModel<Event> = withContext(Dispatchers.IO) {
val service = getGoogleCalendarService(metadata)
try {
val event = service.events()
.patch(metadata.id, event.id, event)
.setQuotaUser(metadata.guildId.asString())
.execute()
ResponseModel(event)
} catch (e: Exception) {
LOGGER.error("Failed to patch event on Google Calendar", e)
ResponseModel(ErrorResponse("Failed to patch event on Google Calendar", e), 600)
}
}
suspend fun updateEvent(metadata: CalendarMetadata, event: Event): ResponseModel<Event> = withContext(Dispatchers.IO) {
val service = getGoogleCalendarService(metadata)
try {
val event = service.events()
.update(metadata.id, event.id, event)
.setQuotaUser(metadata.guildId.asString())
.execute()
ResponseModel(event)
} catch (e: Exception) {
LOGGER.error("Failed to update event on Google Calendar", e)
ResponseModel(ErrorResponse("Failed to update event on Google Calendar", e), 600)
}
}
suspend fun getEvent(metadata: CalendarMetadata, id: String): ResponseModel<Event> = withContext(Dispatchers.IO) {
val service = getGoogleCalendarService(metadata)
// This whole block can probably be shortened once old impl behavior is moved to a higher abstraction layer
try {
val event = service.events()
.get(metadata.id, id)
.setQuotaUser(metadata.guildId.asString())
.execute()
if (event.status.equals("cancelled", true)) ResponseModel(404, null, null)
else ResponseModel(event)
} catch (e: GoogleJsonResponseException) {
when (HttpStatus.valueOf(e.statusCode)) {
HttpStatus.GONE -> {
// Event is gone. Sometimes Google will return this if the event is deleted
ResponseModel(404, null, ErrorResponse("Event Deleted"))
}
HttpStatus.NOT_FOUND -> {
// Event not found. Was this ever an event?
ResponseModel(404, null, ErrorResponse("Event Not Found"))
} else -> {
LOGGER.error("Failed to get event on Google Calendar w/ GoogleResponseException", e)
ResponseModel(ErrorResponse("Failed to get event on Google Calendar w/ GoogleResponseException", e), e.statusCode)
}
}
} catch (e: Exception) {
LOGGER.error("Failed to get event from Google Calendar", e)
ResponseModel(ErrorResponse("Failed to get event from Google Calendar", e), 600)
}
}
suspend fun getEvents(metadata: CalendarMetadata, amount: Int, start: Instant): ResponseModel<List<Event>> = withContext(Dispatchers.IO) {
val service = getGoogleCalendarService(metadata)
try {
val events = service.events()
.list(metadata.id)
.setMaxResults(amount)
.setTimeMin(DateTime(start.toEpochMilli()))
.setOrderBy("startTime")
.setSingleEvents(true)
.setShowDeleted(false)
.setQuotaUser(metadata.guildId.asString())
.execute()
ResponseModel(events.items)
} catch (e: Exception) {
LOGGER.error("Failed to get events from Google Calendar by start date (variant 1)", e)
ResponseModel(600, emptyList(), ErrorResponse("Failed to get events from Google Calendar", e))
}
}
suspend fun getEvents(metadata: CalendarMetadata, amount: Int, start: Instant, end: Instant): ResponseModel<List<Event>> = withContext(Dispatchers.IO) {
val service = getGoogleCalendarService(metadata)
try {
val events = service.events()
.list(metadata.id)
.setMaxResults(amount)
.setTimeMin(DateTime(start.toEpochMilli()))
.setTimeMax(DateTime(end.toEpochMilli()))
.setOrderBy("startTime")
.setSingleEvents(true)
.setShowDeleted(false)
.setQuotaUser(metadata.guildId.asString())
.execute()
ResponseModel(events.items)
} catch (e: Exception) {
LOGGER.error("Failed to get events from Google Calendar by start and end date (variant 2)", e)
ResponseModel(600, emptyList(), ErrorResponse("Failed to get events from Google Calendar", e))
}
}
suspend fun getEvents(metadata: CalendarMetadata, start: Instant, end: Instant): ResponseModel<List<Event>> = withContext(Dispatchers.IO) {
val service = getGoogleCalendarService(metadata)
try {
val events = service.events()
.list(metadata.id)
.setTimeMin(DateTime(start.toEpochMilli()))
.setTimeMax(DateTime(end.toEpochMilli()))
.setOrderBy("startTime")
.setSingleEvents(true)
.setShowDeleted(false)
.setQuotaUser(metadata.guildId.asString())
.execute()
ResponseModel(events.items)
} catch (e: Exception) {
LOGGER.error("Failed to get events from Google Calendar by start and end date without amount (variant 3)", e)
ResponseModel(600, emptyList(), ErrorResponse("Failed to get events from Google Calendar", e))
}
}
suspend fun deleteEvent(metadata: CalendarMetadata, id: String): ResponseModel<Boolean> = withContext(Dispatchers.IO) {
val service = getGoogleCalendarService(metadata)
try {
val response = service.events()
.delete(metadata.id, id)
.setQuotaUser(metadata.guildId.asString())
.executeUnparsed()
//Google sends 4 possible status codes, 200, 204, 404, 410.
// First 2 should be treated as successful, and the other 2 as not found.
when (response.statusCode) {
200, 204 -> ResponseModel(true)
404, 410 -> ResponseModel(false)
else -> {
//Log response data and return false as google sent an unexpected response code.
LOGGER.error("Failed to delete event from Google Calendar w/ unknown response | ${response.statusCode} | ${response.statusMessage}")
ResponseModel(response.statusCode, false, ErrorResponse(response.statusMessage))
}
}
} catch (e: GoogleJsonResponseException) {
if (e.statusCode != 410 || e.statusCode != 404) {
LOGGER.error("Failed to delete event from Google Calendar", e)
ResponseModel(e.statusCode, false, ErrorResponse(e.statusMessage, e))
} else ResponseModel(true)
} catch (e: Exception) {
LOGGER.error("Failed to delete event from Google Calendar w/ unknown error", e)
ResponseModel(600, false, ErrorResponse("Failed to delete event from Google Calendar", e))
}
}
}

View File

@@ -0,0 +1,324 @@
package org.dreamexposure.discal.core.business.google
import com.google.api.client.util.DateTime
import com.google.api.services.calendar.model.AclRule
import com.google.api.services.calendar.model.EventDateTime
import discord4j.common.util.Snowflake
import org.dreamexposure.discal.core.business.CalendarProvider
import org.dreamexposure.discal.core.business.EventMetadataService
import org.dreamexposure.discal.core.config.Config
import org.dreamexposure.discal.core.crypto.KeyGenerator
import org.dreamexposure.discal.core.enums.event.EventColor
import org.dreamexposure.discal.core.exceptions.ApiException
import org.dreamexposure.discal.core.extensions.google.asInstant
import org.dreamexposure.discal.core.`object`.event.Recurrence
import org.dreamexposure.discal.core.`object`.new.Calendar
import org.dreamexposure.discal.core.`object`.new.CalendarMetadata
import org.dreamexposure.discal.core.`object`.new.Event
import org.dreamexposure.discal.core.`object`.new.EventMetadata
import org.springframework.stereotype.Component
import java.time.Instant
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import kotlin.random.Random
import com.google.api.services.calendar.model.Event as GoogleEvent
@Component
class GoogleCalendarProviderService(
val googleCalendarApiWrapper: GoogleCalendarApiWrapper,
val eventMetadataService: EventMetadataService,
) : CalendarProvider {
override val host = CalendarMetadata.Host.GOOGLE
/////////
/// Calendar
/////////
override suspend fun getCalendar(metadata: CalendarMetadata): Calendar? {
val response = googleCalendarApiWrapper.getCalendar(metadata)
if (response.entity == null) return null
return Calendar(
metadata = metadata,
name = response.entity.summary.orEmpty(),
description = response.entity.description.orEmpty(),
timezone = ZoneId.of(response.entity.timeZone),
hostLink = "https://calendar.google.com/calendar/embed?src=${metadata.id}"
)
}
override suspend fun createCalendar(guildId: Snowflake, spec: Calendar.CreateSpec): Calendar {
val credentialId = randomCredentialId()
val googleCalendar = com.google.api.services.calendar.model.Calendar()
googleCalendar.summary = spec.name
googleCalendar.description = spec.description
googleCalendar.timeZone = spec.timezone.id
val response = googleCalendarApiWrapper.createCalendar(googleCalendar, credentialId, guildId)
if (response.entity == null) throw ApiException(response.error?.error, response.error?.exception)
val metadata = CalendarMetadata(
guildId = guildId,
number = spec.number,
host = CalendarMetadata.Host.GOOGLE,
id = response.entity.id,
address = response.entity.id,
external = false,
secrets = CalendarMetadata.Secrets(
credentialId = credentialId,
privateKey = KeyGenerator.csRandomAlphaNumericString(16),
expiresAt = Instant.now(),
refreshToken = "",
accessToken = "",
)
)
// Add required ACL rule
val aclRuleResponse = googleCalendarApiWrapper.insertAclRule(
AclRule().setScope(AclRule.Scope().setType("default")).setRole("reader"),
metadata
)
if (aclRuleResponse.error != null) throw ApiException(
aclRuleResponse.error.error,
aclRuleResponse.error.exception
)
return Calendar(
metadata = metadata,
name = response.entity.summary.orEmpty(),
description = response.entity.description.orEmpty(),
timezone = ZoneId.of(response.entity.timeZone),
hostLink = "https://calendar.google.com/calendar/embed?src=${response.entity.id}",
)
}
override suspend fun updateCalendar(guildId: Snowflake, metadata: CalendarMetadata, spec: Calendar.UpdateSpec): Calendar {
val content = com.google.api.services.calendar.model.Calendar()
spec.name?.let { content.summary = it }
spec.description?.let { content.description = it }
spec.timezone?.let { content.timeZone = it.id }
val response = googleCalendarApiWrapper.patchCalendar(content, metadata)
if (response.entity == null) throw ApiException(response.error?.error, response.error?.exception)
// Add required ACL rule
val aclRuleResponse = googleCalendarApiWrapper.insertAclRule(
AclRule().setScope(AclRule.Scope().setType("default")).setRole("reader"),
metadata
)
if (aclRuleResponse.error != null) throw ApiException(
aclRuleResponse.error.error,
aclRuleResponse.error.exception
)
return Calendar(
metadata = metadata,
name = response.entity.summary.orEmpty(),
description = response.entity.description,
timezone = ZoneId.of(response.entity.timeZone),
hostLink = "https://calendar.google.com/calendar/embed?src=${response.entity.id}",
)
}
override suspend fun deleteCalendar(guildId: Snowflake, metadata: CalendarMetadata) {
val response = googleCalendarApiWrapper.deleteCalendar(metadata)
if (response.error != null) throw ApiException(response.error.error, response.error.exception)
}
/////////
/// Events
/////////
override suspend fun getEvent(calendar: Calendar, id: String): Event? {
val response = googleCalendarApiWrapper.getEvent(calendar.metadata, id)
if (response.code != 404 && response.code != 200) throw ApiException(response.error?.error, response.error?.exception)
if (response.entity == null) return null
val baseEvent = response.entity
val metadata = eventMetadataService.getEventMetadata(calendar.metadata.guildId, id)
?: EventMetadata(id, calendar.metadata.guildId, calendar.metadata.number)
return mapGoogleEventToDisCalEvent(calendar, baseEvent, metadata)
}
override suspend fun getUpcomingEvents(calendar: Calendar, amount: Int): List<Event> {
val response = googleCalendarApiWrapper.getEvents(calendar.metadata, amount, Instant.now())
if (response.entity == null) return emptyList()
return loadEvents(calendar, response.entity)
}
override suspend fun getOngoingEvents(calendar: Calendar): List<Event> {
val now = Instant.now()
val start = now.minus(14, ChronoUnit.DAYS) // 2 weeks ago
val end = now.plus(1, ChronoUnit.DAYS) // One day from now
val response = googleCalendarApiWrapper.getEvents(calendar.metadata, start, end)
if (response.entity == null) return emptyList()
// Filter for only the ongoing events
val filtered = response.entity
.filter { it.start.asInstant(calendar.timezone).isBefore(now) }
.filter { it.end.asInstant(calendar.timezone).isAfter(now) }
return loadEvents(calendar, filtered)
}
override suspend fun getEventsInTimeRange(calendar: Calendar, start: Instant, end: Instant): List<Event> {
val response = googleCalendarApiWrapper.getEvents(calendar.metadata, start, end)
if (response.entity == null) return emptyList()
return loadEvents(calendar, response.entity)
}
override suspend fun createEvent(calendar: Calendar, spec: Event.CreateSpec): Event {
val event = GoogleEvent()
event.id = KeyGenerator.generateEventId()
event.visibility = "public"
event.summary = spec.name
event.description = spec.description
event.location = spec.location
event.start = EventDateTime()
.setDateTime(DateTime(spec.start.toEpochMilli()))
.setTimeZone(calendar.timezone.id)
event.end = EventDateTime()
.setDateTime(DateTime(spec.end.toEpochMilli()))
.setTimeZone(calendar.timezone.id)
if (spec.color != EventColor.NONE)
event.colorId = spec.color.id.toString()
if (spec.recur && spec.recurrence != null)
event.recurrence = listOf(spec.recurrence.toRRule())
// Create event in google
val response = googleCalendarApiWrapper.createEvent(calendar.metadata, event)
if (response.error != null || response.entity == null) throw ApiException(response.error?.error, response.error?.exception)
// Create and save metadata
val metadata = eventMetadataService.createEventMetadata(EventMetadata(
id = event.id,
guildId = calendar.metadata.guildId,
calendarNumber = calendar.metadata.number,
eventEnd = spec.end,
imageLink = spec.image.orEmpty(),
))
return mapGoogleEventToDisCalEvent(calendar, response.entity, metadata)
}
override suspend fun updateEvent(calendar: Calendar, spec: Event.UpdateSpec): Event {
val event = GoogleEvent()
event.id = spec.id
event.summary = spec.name
event.description = spec.description
event.location = spec.location
// Always update start/end so that we can safely handle all day events without DateTime by overwriting it
event.start = EventDateTime()
.setDateTime(DateTime(spec.start.toEpochMilli()))
.setTimeZone(calendar.timezone.id)
event.end = EventDateTime()
.setDateTime(DateTime(spec.end.toEpochMilli()))
.setTimeZone(calendar.timezone.id)
if (spec.color == EventColor.NONE)
event.colorId = null
else
event.colorId = spec.color.id.toString()
// Special recurrence handling
if (spec.recur != null) {
if (spec.recur) {
//event now recurs, add the RRUle.
spec.recurrence?.let { event.recurrence = listOf(it.toRRule()) }
}
} else {
//Recur equals null, so it's not changing whether its recurring, so handle if RRule changes only
spec.recurrence?.let { event.recurrence = listOf(it.toRRule()) }
}
// Okay, all values are set, let's patch this event now
val response = googleCalendarApiWrapper.patchEvent(calendar.metadata, event)
if (response.error != null || response.entity == null) throw ApiException(response.error?.error, response.error?.exception)
// Upsert metadata
val metadata = eventMetadataService.getEventMetadata(calendar.metadata.guildId, event.id)
?: EventMetadata(
id = event.id,
guildId = calendar.metadata.guildId,
calendarNumber = calendar.metadata.number,
eventEnd = spec.end,
imageLink = spec.image.orEmpty(),
)
eventMetadataService.upsertEventMetadata(metadata)
return mapGoogleEventToDisCalEvent(calendar, response.entity, metadata)
}
override suspend fun deleteEvent(calendar: Calendar, id: String) {
val response = googleCalendarApiWrapper.deleteEvent(calendar.metadata, id)
if (response.error != null) throw ApiException(response.error.error, response.error.exception)
}
/////////
/// Private util functions
/////////
private fun randomCredentialId() = Random.nextInt(Config.SECRET_GOOGLE_CREDENTIAL_COUNT.getInt())
private suspend fun loadEvents(calendar: Calendar, events: List<GoogleEvent>): List<Event> {
val metadataList = eventMetadataService.getMultipleEventsMetadata(calendar.metadata.guildId, events.map { it.id})
return events.map { googleEvent ->
val computedId = googleEvent.id.split("_")[0]
val metadata = metadataList.firstOrNull { it.id == computedId }
?: EventMetadata(googleEvent.id, calendar.metadata.guildId, calendar.metadata.number)
mapGoogleEventToDisCalEvent(calendar, googleEvent, metadata)
}
}
private fun mapGoogleEventToDisCalEvent(calendar: Calendar, baseEvent: GoogleEvent, metadata: EventMetadata): Event {
return Event(
id = baseEvent.id,
guildId = calendar.metadata.guildId,
calendarNumber = calendar.metadata.number,
name = baseEvent.summary.orEmpty(),
description = baseEvent.description.orEmpty(),
location = baseEvent.location.orEmpty(),
link = baseEvent.htmlLink.orEmpty(),
color = if (!baseEvent.colorId.isNullOrBlank()) {
EventColor.fromNameOrHexOrId(baseEvent.colorId)
} else EventColor.NONE,
start = if (baseEvent.start.dateTime != null) {
Instant.ofEpochMilli(baseEvent.start.dateTime.value)
} else Instant.ofEpochMilli(baseEvent.start.date.value)
.plus(1, ChronoUnit.DAYS)
.atZone(calendar.timezone)
.truncatedTo(ChronoUnit.DAYS)
.toLocalDate()
.atStartOfDay()
.atZone(calendar.timezone)
.toInstant(),
end = if (baseEvent.end.dateTime != null) {
Instant.ofEpochMilli(baseEvent.end.dateTime.value)
} else Instant.ofEpochMilli(baseEvent.end.date.value)
.plus(1, ChronoUnit.DAYS)
.atZone(calendar.timezone)
.truncatedTo(ChronoUnit.DAYS)
.toLocalDate()
.atStartOfDay()
.atZone(calendar.timezone)
.toInstant(),
recur = !baseEvent.recurrence.isNullOrEmpty(),
recurrence = if (baseEvent.recurrence.isNullOrEmpty()) Recurrence() else Recurrence.fromRRule(baseEvent.recurrence[0]),
image = metadata.imageLink,
timezone = calendar.timezone,
)
}
}

View File

@@ -2,10 +2,13 @@ package org.dreamexposure.discal.core.cache
import discord4j.common.util.Snowflake
import java.time.Duration
import kotlin.random.Random
interface CacheRepository<K, V> {
val ttl: Duration
get() = Duration.ofMinutes(60)
val ttlWithJitter: Duration
get() = ttl.plusSeconds(Random.Default.nextLong(-333,333))
// Write
suspend fun put(guildId: Snowflake? = null, key: K, value: V)

View File

@@ -1,55 +0,0 @@
package org.dreamexposure.discal.core.cache
import discord4j.common.util.Snowflake
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<Snowflake, GuildSettings> = ConcurrentHashMap()
//guild id -> cal num -> calendar
private val calendars: MutableMap<Snowflake, ConcurrentHashMap<Int, Calendar>> = ConcurrentHashMap()
init {
//Automatically clear caches every so often...
Flux.interval(Duration.ofMinutes(15))
.doOnEach { invalidateAll() }
.subscribe()
}
fun invalidateAll() {
guildSettings.clear()
calendars.clear()
}
//Functions to stop direct modification
fun getCalendar(guildId: Snowflake, calNum: Int): Calendar? = calendars[guildId]?.get(calNum)
fun getAllCalendars(guildId: Snowflake): Collection<Calendar>? = calendars[guildId]?.values
fun putCalendar(calendar: Calendar) {
if (calendars.containsKey(calendar.guildId))
calendars[calendar.guildId]!![calendar.calendarNumber] = calendar
else {
val map = ConcurrentHashMap<Int, Calendar>()
map[calendar.calendarNumber] = calendar
calendars[calendar.guildId] = map
}
}
fun handleCalendarDelete(guildId: Snowflake) {
removeCalendars(guildId)
//Eventually other cached things will be here, like events, rsvp data, etc
}
fun removeCalendars(guildId: Snowflake) {
calendars.remove(guildId)
}
}

View File

@@ -19,14 +19,14 @@ class JdkCacheRepository<K : Any, V>(override val ttl: Duration) : CacheReposito
override suspend fun put(guildId: Snowflake?, key: K, value: V) {
val guildedMap = getGuildedCache(guildId)
guildedMap[key] = Pair(Instant.now().plus(ttl), value)
guildedMap[key] = Pair(Instant.now().plus(ttlWithJitter), value)
cache[guildId] = guildedMap
}
override suspend fun putMany(guildId: Snowflake?, values: Map<K, V>) {
val guildedMap = getGuildedCache(guildId)
guildedMap.putAll(values.mapValues { Pair(Instant.now().plus(ttl), it.value) })
guildedMap.putAll(values.mapValues { Pair(Instant.now().plus(ttlWithJitter), it.value) })
cache[guildId] = guildedMap
}

View File

@@ -25,12 +25,12 @@ class RedisStringCacheRepository<K, V>(
}
override suspend fun put(guildId: Snowflake?, key: K, value: V) {
valueOps.setAndAwait(formatKey(guildId, key), objectMapper.writeValueAsString(value), ttl)
valueOps.setAndAwait(formatKey(guildId, key), objectMapper.writeValueAsString(value), ttlWithJitter)
}
override suspend fun putMany(guildId: Snowflake?, values: Map<K, V>) {
values.forEach { (key, value) ->
valueOps.setAndAwait(formatKey(guildId, key), objectMapper.writeValueAsString(value), ttl)
valueOps.setAndAwait(formatKey(guildId, key), objectMapper.writeValueAsString(value), ttlWithJitter)
}
}

View File

@@ -4,6 +4,8 @@ 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 io.micrometer.core.instrument.binder.okhttp3.OkHttpObservationInterceptor
import io.micrometer.observation.ObservationRegistry
import okhttp3.OkHttpClient
import org.dreamexposure.discal.core.serializers.DurationMapper
import org.dreamexposure.discal.core.serializers.SnowflakeMapper
@@ -31,7 +33,14 @@ class BeanConfig {
}
@Bean
fun httpClient(): OkHttpClient {
return OkHttpClient()
fun httpClient(registry: ObservationRegistry): OkHttpClient {
val interceptor = OkHttpObservationInterceptor.builder(registry, "okhttp.requests")
// This can lead to tag cardinality explosion as it doesn't use uri patterns, should investigate options for that one day
.uriMapper { it.url.encodedPath }
.build()
return OkHttpClient.Builder()
.addInterceptor(interceptor)
.build()
}
}

View File

@@ -13,6 +13,7 @@ import org.springframework.data.redis.core.ReactiveStringRedisTemplate
@Configuration
class CacheConfig {
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()
@@ -20,9 +21,17 @@ class CacheConfig {
private val staticMessageTtl = Config.CACHE_TTL_STATIC_MESSAGE_MINUTES.getLong().asMinutes()
private val announcementTll = Config.CACHE_TTL_ANNOUNCEMENT_MINUTES.getLong().asMinutes()
private val wizardTtl = Config.TIMING_WIZARD_TIMEOUT_MINUTES.getLong().asMinutes()
private val calendarTokenTtl = Config.CACHE_TTL_CALENDAR_TOKEN_MINUTES.getLong().asMinutes()
private val eventTtl = Config.CACHE_TTL_EVENTS_MINUTES.getLong().asMinutes()
// Redis caching
@Bean
@Primary
@ConditionalOnProperty("bot.cache.redis", havingValue = "true")
fun guildSettingsRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): GuildSettingsCache =
RedisStringCacheRepository(objectMapper, redisTemplate, "GuildSettings", settingsTtl)
@Bean
@Primary
@ConditionalOnProperty("bot.cache.redis", havingValue = "true")
@@ -35,6 +44,12 @@ class CacheConfig {
fun oauthStateRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): OauthStateCache =
RedisStringCacheRepository(objectMapper, redisTemplate, "OauthStates", oauthStateTtl)
@Bean
@Primary
@ConditionalOnProperty("bot.cache.redis", havingValue = "true")
fun calendarMetadataRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): CalendarMetadataCache =
RedisStringCacheRepository(objectMapper, redisTemplate, "CalendarMetadata", calendarTtl)
@Bean
@Primary
@ConditionalOnProperty("bot.cache.redis", havingValue = "true")
@@ -65,14 +80,38 @@ class CacheConfig {
fun announcementWizardRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): AnnouncementWizardStateCache =
RedisStringCacheRepository(objectMapper, redisTemplate, "AnnouncementWizards", wizardTtl)
@Bean
@Primary
@ConditionalOnProperty("bot.cache.redis", havingValue = "true")
fun eventWizardRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): EventWizardStateCache =
RedisStringCacheRepository(objectMapper, redisTemplate, "EventWizards", wizardTtl)
@Bean
@Primary
@ConditionalOnProperty("bot.cache.redis", havingValue = "true")
fun calendarWizardRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): CalendarWizardStateCache =
RedisStringCacheRepository(objectMapper, redisTemplate, "CalendarWizards", wizardTtl)
@Bean
@Primary
@ConditionalOnProperty("bot.cache.redis", havingValue = "true")
fun eventRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): EventCache =
RedisStringCacheRepository(objectMapper, redisTemplate, "Events", eventTtl)
// In-memory fallback caching
@Bean
fun guildSettingsFallbackCache(): GuildSettingsCache = JdkCacheRepository(settingsTtl)
@Bean
fun credentialsFallbackCache(): CredentialsCache = JdkCacheRepository(credentialsTll)
@Bean
fun oauthStateFallbackCache(): OauthStateCache = JdkCacheRepository(oauthStateTtl)
@Bean
fun calendarMetadataFallbackCache(): CalendarMetadataCache = JdkCacheRepository(calendarTtl)
@Bean
fun calendarFallbackCache(): CalendarCache = JdkCacheRepository(calendarTtl)
@@ -87,4 +126,16 @@ class CacheConfig {
@Bean
fun announcementWizardFallbackCache(): AnnouncementWizardStateCache = JdkCacheRepository(wizardTtl)
@Bean
fun eventWizardFallbackCache(): EventWizardStateCache = JdkCacheRepository(wizardTtl)
@Bean
fun calendarWizardFallbackCache(): CalendarWizardStateCache = JdkCacheRepository(wizardTtl)
@Bean
fun calendarTokenFallbackCache(): CalendarTokenCache = JdkCacheRepository(calendarTokenTtl)
@Bean
fun eventFallbackCache(): EventCache = JdkCacheRepository(eventTtl)
}

View File

@@ -1,5 +1,6 @@
package org.dreamexposure.discal.core.config
import org.dreamexposure.discal.core.config.Config.entries
import java.io.FileReader
import java.util.*
@@ -29,6 +30,8 @@ enum class Config(private val key: String, private var value: Any? = null) {
CACHE_TTL_RSVP_MINUTES("bot.cache.ttl-minutes.rsvp", 60),
CACHE_TTL_STATIC_MESSAGE_MINUTES("bot.cache.ttl-minutes.static-messages", 60),
CACHE_TTL_ANNOUNCEMENT_MINUTES("bot.cache.ttl-minutes.announcements", 120),
CACHE_TTL_CALENDAR_TOKEN_MINUTES("bot.cache.ttl-minutes.calendar", 60),
CACHE_TTL_EVENTS_MINUTES("bot.cache.ttl-minutes.event", 15),
// Security configuration
@@ -64,6 +67,7 @@ enum class Config(private val key: String, private var value: Any? = null) {
// UI and UX
EMBED_RSVP_WAITLIST_DISPLAY_LENGTH("bot.ui.embed.rsvp.waitlist.length", 3),
CALENDAR_OVERVIEW_DEFAULT_EVENT_COUNT("bot.ui.embed.calendar.overview.event-count", 15),
// Everything else
SHARD_COUNT("bot.sharding.count"),
@@ -77,7 +81,9 @@ enum class Config(private val key: String, private var value: Any? = null) {
LOGGING_WEBHOOKS_ALL_ERRORS("bot.logging.webhooks.all-error", false),
INTEGRATIONS_UPDATE_BOT_LIST_SITES("bot.integrations.update-bot-sites", false),
INTEGRATIONS_REACTOR_METRICS("bot.integrations.reactor.metrics", false),
ANNOUNCEMENT_PROCESS_GUILD_DEFAULT_UPCOMING_EVENTS_COUNT("bot.announcement.process-global-default-upcoming-events", 30),
;

View File

@@ -7,6 +7,9 @@ import reactor.core.publisher.Mono
interface AnnouncementRepository: R2dbcRepository<AnnouncementData, String> {
@Query("SELECT COUNT(*) FROM announcements")
fun countAll(): Mono<Long>
fun findByGuildIdAndAnnouncementId(guildId: Long, announcementId: String): Mono<AnnouncementData>
fun findAllByGuildId(guildId: Long): Flux<AnnouncementData>
@@ -77,4 +80,13 @@ interface AnnouncementRepository: R2dbcRepository<AnnouncementData, String> {
fun deleteByAnnouncementId(announcementId: String): Mono<Void>
fun deleteAllByGuildIdAndEventId(guildId: Long, eventId: String): Mono<Void>
fun deleteAllByGuildIdAndCalendarNumber(guildId: Long, calendarNumber: Int): Mono<Void>
@Query("""
UPDATE announcements
SET calendar_number = calendar_number - 1
WHERE calendar_number >= :calendarNumber AND guild_id = :guildId
""")
fun decrementCalendarsByGuildIdAndCalendarNumber(guildId: Long, calendarNumber: Int): Mono<Long>
}

View File

@@ -3,7 +3,7 @@ package org.dreamexposure.discal.core.database
import org.springframework.data.relational.core.mapping.Table
@Table("calendars")
data class CalendarData(
data class CalendarMetadataData(
val guildId: Long,
val calendarNumber: Int,
val host: String,

View File

@@ -5,11 +5,16 @@ import org.springframework.data.r2dbc.repository.R2dbcRepository
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
interface CalendarRepository : R2dbcRepository<CalendarData, Long> {
interface CalendarMetadataRepository : R2dbcRepository<CalendarMetadataData, Long> {
fun findAllByGuildId(guildId: Long): Flux<CalendarData>
@Query("SELECT COUNT(*) FROM calendars")
fun countAll(): Mono<Long>
fun findByGuildIdAndCalendarNumber(guildId: Long, calendarNumber: Int): Mono<CalendarData>
fun countAllByGuildId(guildId: Long): Mono<Long>
fun findAllByGuildId(guildId: Long): Flux<CalendarMetadataData>
fun findByGuildIdAndCalendarNumber(guildId: Long, calendarNumber: Int): Mono<CalendarMetadataData>
@Query("""
UPDATE calendars
@@ -38,6 +43,12 @@ interface CalendarRepository : R2dbcRepository<CalendarData, Long> {
expiresAt: Long,
): Mono<Int>
@Query(" SELECT 1")
fun healthCheck(): Mono<Int>
fun deleteAllByGuildIdAndCalendarNumber(guildId: Long, calendarNumber: Int): Mono<Void>
@Query("""
UPDATE calendars
SET calendar_number = calendar_number - 1
WHERE calendar_number >= :calendarNumber AND guild_id = :guildId
""")
fun decrementCalendarsByGuildIdAndCalendarNumber(guildId: Long, calendarNumber: Int): Mono<Long>
}

View File

@@ -1,787 +0,0 @@
@file:Suppress("DuplicatedCode")
package org.dreamexposure.discal.core.database
import discord4j.common.util.Snowflake
import io.r2dbc.pool.ConnectionPool
import io.r2dbc.pool.ConnectionPoolConfiguration
import io.r2dbc.spi.Connection
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.AnnouncementStyle
import org.dreamexposure.discal.core.enums.calendar.CalendarHost
import org.dreamexposure.discal.core.enums.time.TimeFormat
import org.dreamexposure.discal.core.extensions.setFromString
import org.dreamexposure.discal.core.logger.LOGGER
import org.dreamexposure.discal.core.`object`.GuildSettings
import org.dreamexposure.discal.core.`object`.calendar.CalendarData
import org.dreamexposure.discal.core.`object`.event.EventData
import org.dreamexposure.discal.core.`object`.web.UserAPIAccount
import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT
import org.intellij.lang.annotations.Language
import reactor.core.publisher.Mono
import reactor.util.retry.Retry
import java.time.Duration
import java.time.Instant
import java.util.function.Function
object DatabaseManager {
private val pool: ConnectionPool
init {
val factory = ConnectionFactories.get(
builder()
.option(DRIVER, "pool")
.option(PROTOCOL, "mysql")
.from(parse(Config.SQL_URL.getString()))
.option(USER, Config.SQL_USERNAME.getString())
.option(PASSWORD, Config.SQL_PASSWORD.getString())
.build()
)
val conf = ConnectionPoolConfiguration.builder()
.connectionFactory(factory)
.maxLifeTime(Duration.ofHours(1))
.build()
pool = ConnectionPool(conf)
}
//FIXME: attempt to fix constant open/close of connections
private fun <T> connect(connection: Function<Connection, Mono<T>>): Mono<T> {
return Mono.usingWhen(pool.create(), connection::apply, Connection::close)
}
fun disconnectFromMySQL() = pool.dispose()
fun updateAPIAccount(acc: UserAPIAccount): Mono<Boolean> {
return connect { c ->
Mono.from(
c.createStatement(Queries.SELECT_API_KEY)
.bind(0, acc.APIKey)
.execute()
).flatMapMany { res ->
res.map { row, _ -> row }
}.hasElements().flatMap { exists ->
if (exists) {
val updateCommand = """ UPDATE ${Tables.API} SET
USER_ID = ?, BLOCKED = ?
WHERE API_KEY = ?
""".trimMargin()
Mono.from(
c.createStatement(updateCommand)
.bind(0, acc.userId)
.bind(1, acc.blocked)
.bind(2, acc.APIKey)
.execute()
).flatMap { res -> Mono.from(res.rowsUpdated) }
.thenReturn(true)
} else {
val insertCommand = """INSERT INTO ${Tables.API}
(USER_ID, API_KEY, BLOCKED, TIME_ISSUED)
VALUES (?, ?, ?, ?)
""".trimMargin()
Mono.from(
c.createStatement(insertCommand)
.bind(0, acc.userId)
.bind(1, acc.APIKey)
.bind(2, acc.blocked)
.bind(3, acc.timeIssued)
.execute()
).flatMap { res -> Mono.from(res.rowsUpdated) }
.thenReturn(true)
}
}.doOnError {
LOGGER.error(DEFAULT, "Failed to update API account", it)
}.onErrorResume { Mono.just(false) }
}
}
fun updateSettings(settings: GuildSettings): Mono<Boolean> {
DiscalCache.guildSettings[settings.guildID] = settings
return connect { c ->
Mono.from(
c.createStatement(Queries.SELECT_GUILD_SETTINGS)
.bind(0, settings.guildID.asLong())
.execute()
).flatMapMany { res ->
res.map { row, _ -> row }
}.hasElements().flatMap { exists ->
if (exists) {
val updateCommand = """UPDATE ${Tables.GUILD_SETTINGS} SET
CONTROL_ROLE = ?, ANNOUNCEMENT_STYLE = ?, TIME_FORMAT = ?,
LANG = ?, PREFIX = ?, PATRON_GUILD = ?, DEV_GUILD = ?,
MAX_CALENDARS = ?, DM_ANNOUNCEMENTS = ?,
BRANDED = ?, event_keep_duration = ? WHERE GUILD_ID = ?
""".trimMargin()
Mono.from(
c.createStatement(updateCommand)
.bind(0, settings.controlRole)
.bind(1, settings.announcementStyle.value)
.bind(2, settings.timeFormat.value)
.bind(3, settings.lang)
.bind(4, settings.prefix)
.bind(5, settings.patronGuild)
.bind(6, settings.devGuild)
.bind(7, settings.maxCalendars)
.bind(8, settings.getDmAnnouncementsString())
.bind(9, settings.branded)
.bind(10, settings.eventKeepDuration)
.bind(11, settings.guildID.asLong())
.execute()
).flatMap { res -> Mono.from(res.rowsUpdated) }
.hasElement()
.thenReturn(true)
} 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, event_keep_duration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimMargin()
Mono.from(
c.createStatement(insertCommand)
.bind(0, settings.guildID.asLong())
.bind(1, settings.controlRole)
.bind(2, settings.announcementStyle.value)
.bind(3, settings.timeFormat.value)
.bind(4, settings.lang)
.bind(5, settings.prefix)
.bind(6, settings.patronGuild)
.bind(7, settings.devGuild)
.bind(8, settings.maxCalendars)
.bind(9, settings.getDmAnnouncementsString())
.bind(10, settings.branded)
.bind(11, settings.eventKeepDuration)
.execute()
).flatMap { res -> Mono.from(res.rowsUpdated) }
.hasElement()
.thenReturn(true)
}
}.doOnError {
LOGGER.error(DEFAULT, "Failed to update guild settings", it)
}.onErrorResume { Mono.just(false) }
}
}
fun updateCalendar(calData: CalendarData): Mono<Boolean> {
return connect { c ->
Mono.from(
c.createStatement(Queries.SELECT_CALENDAR_BY_GUILD)
.bind(0, calData.guildId.asLong())
.bind(1, calData.calendarNumber)
.execute()
).flatMapMany { res ->
res.map { row, _ -> row }
}.hasElements().flatMap { exists ->
if (exists) {
val updateCommand = """UPDATE ${Tables.CALENDARS} SET
HOST = ?, CALENDAR_ID = ?,
CALENDAR_ADDRESS = ?, EXTERNAL = ?, CREDENTIAL_ID = ?,
PRIVATE_KEY = ?, ACCESS_TOKEN = ?, REFRESH_TOKEN = ?, EXPIRES_AT = ?
WHERE GUILD_ID = ? AND CALENDAR_NUMBER = ?
""".trimMargin()
Mono.from(
c.createStatement(updateCommand)
.bind(0, calData.host.name)
.bind(1, calData.calendarId)
.bind(2, calData.calendarAddress)
.bind(3, calData.external)
.bind(4, calData.credentialId)
.bind(5, calData.privateKey)
.bind(6, calData.encryptedAccessToken)
.bind(7, calData.encryptedRefreshToken)
.bind(8, calData.expiresAt.toEpochMilli())
.bind(9, calData.guildId.asLong())
.bind(10, calData.calendarNumber)
.execute()
).flatMapMany(Result::getRowsUpdated)
.hasElements()
.thenReturn(true)
} else {
val insertCommand = """INSERT INTO ${Tables.CALENDARS}
(GUILD_ID, CALENDAR_NUMBER, HOST, CALENDAR_ID,
CALENDAR_ADDRESS, EXTERNAL, CREDENTIAL_ID,
PRIVATE_KEY, ACCESS_TOKEN, REFRESH_TOKEN, EXPIRES_AT) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimMargin()
Mono.from(
c.createStatement(insertCommand)
.bind(0, calData.guildId.asLong())
.bind(1, calData.calendarNumber)
.bind(2, calData.host.name)
.bind(3, calData.calendarId)
.bind(4, calData.calendarAddress)
.bind(5, calData.external)
.bind(6, calData.credentialId)
.bind(7, calData.privateKey)
.bind(8, calData.encryptedAccessToken)
.bind(9, calData.encryptedRefreshToken)
.bind(10, calData.expiresAt.toEpochMilli())
.execute()
).flatMapMany(Result::getRowsUpdated)
.hasElements()
.thenReturn(true)
}
}.doOnError {
LOGGER.error(DEFAULT, "Failed to update calendar data", it)
}.onErrorResume { Mono.just(false) }
}
}
fun updateEventData(data: EventData): Mono<Boolean> {
val id = if (data.eventId.contains("_"))
data.eventId.split("_")[0]
else
data.eventId
return connect { c ->
Mono.from(
c.createStatement(Queries.SELECT_EVENT_BY_GUILD)
.bind(0, data.guildId.asLong())
.bind(1, id)
.execute()
).flatMapMany { res ->
res.map { row, _ -> row }
}.hasElements().flatMap { exists ->
if (exists) {
val updateCommand = """UPDATE ${Tables.EVENTS} SET
CALENDAR_NUMBER = ?, IMAGE_LINK = ?, EVENT_END = ?
WHERE EVENT_ID = ? AND GUILD_ID = ?
""".trimMargin()
Mono.from(
c.createStatement(updateCommand)
.bind(0, data.calendarNumber)
.bind(1, data.imageLink)
.bind(2, data.eventEnd)
.bind(3, id)
.bind(4, data.guildId.asLong())
.execute()
).flatMapMany(Result::getRowsUpdated)
.hasElements()
.thenReturn(true)
} else if (data.shouldBeSaved()) {
val insertCommand = """INSERT INTO ${Tables.EVENTS}
(GUILD_ID, EVENT_ID, CALENDAR_NUMBER, EVENT_END, IMAGE_LINK)
VALUES(?, ?, ?, ?, ?)
""".trimMargin()
Mono.from(
c.createStatement(insertCommand)
.bind(0, data.guildId.asLong())
.bind(1, id)
.bind(2, data.calendarNumber)
.bind(3, data.eventEnd)
.bind(4, data.imageLink)
.execute()
).flatMapMany(Result::getRowsUpdated)
.hasElements()
.thenReturn(true)
} else {
Mono.just(false)
}.doOnError {
LOGGER.error(DEFAULT, "Failed to update event data", it)
}.onErrorResume { Mono.just(false) }
}
}
}
fun getAPIAccount(APIKey: String): Mono<UserAPIAccount> {
return connect { c ->
Mono.from(
c.createStatement(Queries.SELECT_API_KEY)
.bind(0, APIKey)
.execute()
).flatMapMany { res ->
res.map { row, _ ->
UserAPIAccount(
row["USER_ID", String::class.java]!!,
APIKey,
row["BLOCKED", Boolean::class.java]!!,
row["TIME_ISSUED", Long::class.java]!!
)
}
}.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 API-key data", it)
}.onErrorResume { Mono.empty() }
}
}
fun getSettings(guildId: Snowflake): Mono<GuildSettings> {
if (DiscalCache.guildSettings.containsKey(guildId))
return Mono.just(DiscalCache.guildSettings[guildId]!!)
return connect { c ->
Mono.from(
c.createStatement(Queries.SELECT_GUILD_SETTINGS)
.bind(0, guildId.asLong())
.execute()
).flatMapMany { res ->
res.map { row, _ ->
val controlRole = row["CONTROL_ROLE", String::class.java]!!
val announcementStyle = AnnouncementStyle.fromValue(row["ANNOUNCEMENT_STYLE", Int::class.java]!!)
val timeFormat = TimeFormat.fromValue(row["TIME_FORMAT", Int::class.java]!!)
val lang = row["LANG", String::class.java]!!
val prefix = row["PREFIX", String::class.java]!!
val patron = row["PATRON_GUILD", Boolean::class.java]!!
val dev = row["DEV_GUILD", Boolean::class.java]!!
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, eventKeepDuration,
)
settings.dmAnnouncements.setFromString(dmAnnouncementsString)
//Store in cache...
DiscalCache.guildSettings[guildId] = settings
settings
}
}.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 guild settings", it)
}.onErrorReturn(GuildSettings.empty(guildId)).defaultIfEmpty(GuildSettings.empty(guildId))
}
}
fun getMainCalendar(guildId: Snowflake): Mono<CalendarData> = getCalendar(guildId, 1)
fun getCalendar(guildId: Snowflake, calendarNumber: Int): Mono<CalendarData> {
return connect { c ->
Mono.from(
c.createStatement(Queries.SELECT_CALENDAR_BY_GUILD)
.bind(0, guildId.asLong())
.bind(1, calendarNumber)
.execute()
).flatMapMany { res ->
res.map { row, _ ->
val calId = row["CALENDAR_ID", String::class.java]!!
val calNumber = row["CALENDAR_NUMBER", Int::class.java]!!
val calAddr = row["CALENDAR_ADDRESS", String::class.java]!!
val host = CalendarHost.valueOf(row["HOST", String::class.java]!!)
val external = row["EXTERNAL", Boolean::class.java]!!
val credId = row["CREDENTIAL_ID", Int::class.java]!!
val privateKey = row["PRIVATE_KEY", String::class.java]!!
val accessToken = row["ACCESS_TOKEN", String::class.java]!!
val refreshToken = row["REFRESH_TOKEN", String::class.java]!!
val expiresAt = Instant.ofEpochMilli(row["EXPIRES_AT", Long::class.java]!!)
CalendarData(
guildId, calNumber, host, calId, calAddr, external,
credId, privateKey, accessToken, refreshToken, expiresAt
)
}
}.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 all guild calendars", it)
}.onErrorResume { Mono.empty() }
}
}
fun getAllCalendars(guildId: Snowflake): Mono<List<CalendarData>> {
return connect { c ->
Mono.from(
c.createStatement(Queries.SELECT_ALL_CALENDARS_BY_GUILD)
.bind(0, guildId.asLong())
.execute()
).flatMapMany { res ->
res.map { row, _ ->
val calId = row["CALENDAR_ID", String::class.java]!!
val calNumber = row["CALENDAR_NUMBER", Int::class.java]!!
val calAddr = row["CALENDAR_ADDRESS", String::class.java]!!
val host = CalendarHost.valueOf(row["HOST", String::class.java]!!)
val external = row["EXTERNAL", Boolean::class.java]!!
val credId = row["CREDENTIAL_ID", Int::class.java]!!
val privateKey = row["PRIVATE_KEY", String::class.java]!!
val accessToken = row["ACCESS_TOKEN", String::class.java]!!
val refreshToken = row["REFRESH_TOKEN", String::class.java]!!
val expiresAt = Instant.ofEpochMilli(row["EXPIRES_AT", Long::class.java]!!)
CalendarData(
guildId, calNumber, host, calId, calAddr, external,
credId, privateKey, accessToken, refreshToken, expiresAt
)
}
}.collectList().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 all guild calendars", it)
}.onErrorReturn(mutableListOf())
}
}
fun getCalendarCount(guildId: Snowflake): Mono<Int> {
return connect { c ->
Mono.from(
c.createStatement(Queries.SELECT_CALENDAR_COUNT_BY_GUILD)
.bind(0, guildId.asLong())
.execute()
).flatMapMany { res ->
res.map { row, _ ->
val calendars = row.get(0, Long::class.java)!!
return@map calendars.toInt()
}
}.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 calendar count", it)
}.onErrorReturn(-1)
}.defaultIfEmpty(0)
}
fun getEventData(guildId: Snowflake, eventId: String): Mono<EventData> {
var eventIdLookup = eventId
if (eventId.contains("_"))
eventIdLookup = eventId.split("_")[0]
return connect { c ->
Mono.from(
c.createStatement(Queries.SELECT_EVENT_BY_GUILD)
.bind(0, guildId.asLong())
.bind(1, eventIdLookup)
.execute()
).flatMapMany { res ->
res.map { row, _ ->
val id = row["EVENT_ID", String::class.java]!!
val calNum = row["CALENDAR_NUMBER", Int::class.java]!!
val end = row["EVENT_END", Long::class.java]!!
val img = row["IMAGE_LINK", String::class.java]!!
EventData(guildId, id, calNum, end, img)
}
}.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 event data", it)
}.onErrorResume {
Mono.empty()
}
}.defaultIfEmpty(EventData(guildId, eventId = eventIdLookup))
}
fun deleteAnnouncementsForEvent(guildId: Snowflake, eventId: String): Mono<Boolean> {
return connect { c ->
Mono.from(
c.createStatement(Queries.DELETE_ANNOUNCEMENTS_FOR_EVENT)
.bind(0, eventId)
.bind(1, guildId.asLong())
.execute()
).flatMapMany(Result::getRowsUpdated)
.hasElements()
.thenReturn(true)
.doOnError {
LOGGER.error(DEFAULT, "Failed to delete announcements for event", it)
}.onErrorReturn(false)
}.defaultIfEmpty(false)
}
fun deleteEventData(eventId: String): Mono<Boolean> {
if (eventId.contains("_")) return Mono.empty() // Don't delete if child event of recurring parent.
return connect { c ->
Mono.from(
c.createStatement(Queries.DELETE_EVENT_DATA)
.bind(0, eventId)
.execute()
).flatMapMany(Result::getRowsUpdated)
.hasElements()
.thenReturn(true)
.doOnError {
LOGGER.error(DEFAULT, "Failed to delete event data", it)
}.onErrorReturn(false)
}.defaultIfEmpty(false)
}
/* Utility Deletion Methods */
fun deleteCalendarAndRelatedData(calendarData: CalendarData): Mono<Boolean> {
return connect { c ->
Mono.from(
c.createStatement(Queries.FULL_CALENDAR_DELETE) //Monolith 8 statement query
// calendar delete bindings
.bind(0, calendarData.guildId.asLong())
.bind(1, calendarData.calendarNumber)
// event delete bindings
.bind(2, calendarData.guildId.asLong())
.bind(3, calendarData.calendarNumber)
// rsvp delete bindings
.bind(4, calendarData.guildId.asLong())
.bind(5, calendarData.calendarNumber)
// announcement delete bindings
.bind(6, calendarData.guildId.asLong())
.bind(7, calendarData.calendarNumber)
// delete static message bindings
.bind(8, calendarData.guildId.asLong())
.bind(9, calendarData.calendarNumber)
// decrement calendar bindings
.bind(10, calendarData.calendarNumber)
.bind(11, calendarData.guildId.asLong())
// decrement event bindings
.bind(12, calendarData.calendarNumber)
.bind(13, calendarData.guildId.asLong())
// decrement rsvp bindings
.bind(14, calendarData.calendarNumber)
.bind(15, calendarData.guildId.asLong())
// decrement announcement bindings
.bind(16, calendarData.calendarNumber)
.bind(17, calendarData.guildId.asLong())
// decrement static message bindings
.bind(18, calendarData.calendarNumber)
.bind(19, calendarData.guildId.asLong())
.execute()
).flatMapMany(Result::getRowsUpdated)
.hasElements()
.thenReturn(true)
.doOnError {
LOGGER.error(DEFAULT, "Full calendar delete failed!", it)
}.onErrorReturn(false)
}.defaultIfEmpty(true) // If nothing was updated and no error was emitted, it's safe to return this worked.
}
fun deleteAllDataForGuild(guildId: Snowflake): Mono<Boolean> {
return connect { c ->
Mono.from(
c.createStatement(Queries.DELETE_EVERYTHING_FOR_GUILD) //Monolith 6 statement query
// settings delete bindings
.bind(0, guildId.asLong())
// calendar delete bindings
.bind(1, guildId.asLong())
// event delete bindings
.bind(2, guildId.asLong())
// rsvp delete bindings
.bind(3, guildId.asLong())
// announcement delete bindings
.bind(4, guildId.asLong())
// static message delete bindings
.bind(5, guildId.asLong())
.execute()
).flatMapMany(Result::getRowsUpdated)
.hasElements()
.thenReturn(true)
.doOnError {
LOGGER.error(DEFAULT, "Full data delete failed!", it)
}.onErrorReturn(false)
}.defaultIfEmpty(true) // If nothing was updated and no error was emitted, it's safe to return this worked.
}
/* Event Data */
fun getEventsData(guildId: Snowflake, eventIds: List<String>): Mono<Map<String, EventData>> {
// clean up IDs
val idsToUse = mutableListOf<String>()
eventIds.forEach {
val id = if (it.contains("_")) it.split("_")[0] else it
if (!idsToUse.contains(id)) idsToUse.add(id)
}
if (idsToUse.isEmpty()) return Mono.just(emptyMap())
// Convert our list of IDs to sql escaped string
val builder = StringBuilder()
idsToUse.withIndex().forEach {
if (it.index != idsToUse.size - 1) {
builder.append("'${it.value}', ")
} else {
builder.append("'${it.value}'")
}
}
return connect { c ->
Mono.from(
//Have to do it this way, sql injection is not possible as these IDs are not user input
c.createStatement(Queries.SELECT_MANY_EVENT_DATA.replace("?", builder.toString()))
.execute()
).flatMapMany { res ->
res.map { row, _ ->
val id = row["EVENT_ID", String::class.java]!!
val calNum = row["CALENDAR_NUMBER", Int::class.java]!!
val end = row["EVENT_END", Long::class.java]!!
val img = row["IMAGE_LINK", String::class.java]!!
EventData(guildId, id, calNum, end, img)
}
}.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 many event data", it)
}.onErrorResume {
Mono.empty()
}.collectMap {
it.eventId
}.defaultIfEmpty(emptyMap())
}
}
}
private object Queries {
@Language("MySQL")
val SELECT_API_KEY = """SELECT * FROM ${Tables.API}
WHERE API_KEY = ?
""".trimMargin()
@Language("MySQL")
val SELECT_GUILD_SETTINGS = """SELECT * FROM ${Tables.GUILD_SETTINGS}
WHERE GUILD_ID = ?
""".trimMargin()
@Language("MySQL")
val SELECT_CALENDAR_BY_GUILD = """SELECT * FROM ${Tables.CALENDARS}
WHERE GUILD_ID = ? AND CALENDAR_NUMBER = ?
""".trimMargin()
@Language("MySQL")
val SELECT_ALL_CALENDARS_BY_GUILD = """SELECT * FROM ${Tables.CALENDARS}
WHERE GUILD_ID = ?
""".trimMargin()
@Language("MySQL")
val SELECT_CALENDAR_COUNT_BY_GUILD = """SELECT COUNT(*) FROM ${Tables.CALENDARS}
WHERE GUILD_ID = ?
""".trimMargin()
@Language("MySQL")
val SELECT_EVENT_BY_GUILD = """SELECT * FROM ${Tables.EVENTS}
WHERE GUILD_ID = ? AND EVENT_ID = ?
""".trimMargin()
@Language("MySQL")
val DELETE_ANNOUNCEMENTS_FOR_EVENT = """DELETE FROM ${Tables.ANNOUNCEMENTS}
WHERE EVENT_ID = ? AND GUILD_ID = ?
""".trimMargin()
@Language("MySQL")
val DELETE_EVENT_DATA = """DELETE FROM ${Tables.EVENTS}
WHERE EVENT_ID = ?
""".trimMargin()
@Language("MySQL")
val DELETE_ALL_EVENT_DATA = """DELETE FROM ${Tables.EVENTS}
WHERE GUILD_ID = ? AND CALENDAR_NUMBER = ?
""".trimMargin()
@Language("MySQL")
val DELETE_ALL_ANNOUNCEMENT_DATA = """DELETE FROM ${Tables.ANNOUNCEMENTS}
WHERE GUILD_ID = ? AND CALENDAR_NUMBER = ?
""".trimMargin()
@Language("MySQL")
val DELETE_ALL_RSVP_DATA = """DELETE FROM ${Tables.RSVP}
WHERE GUILD_ID = ? AND CALENDAR_NUMBER = ?
""".trimMargin()
@Language("MySQL")
val DELETE_CALENDAR = """DELETE FROM ${Tables.CALENDARS}
WHERE GUILD_ID = ? AND calendar_number = ?
""".trimMargin()
@Language("MySQL")
val DECREMENT_CALENDARS = """UPDATE ${Tables.CALENDARS}
SET calendar_number = calendar_number - 1
WHERE calendar_number >=? AND guild_id = ?
""".trimMargin()
@Language("MySQL")
val DECREMENT_ANNOUNCEMENTS = """UPDATE ${Tables.ANNOUNCEMENTS}
SET calendar_number = calendar_number - 1
WHERE calendar_number >=? AND guild_id = ?
""".trimMargin()
@Language("MySQL")
val DECREMENT_EVENTS = """UPDATE ${Tables.EVENTS}
SET calendar_number = calendar_number - 1
WHERE calendar_number >=? AND guild_id = ?
""".trimMargin()
@Language("MySQL")
val DECREMENT_RSVPS = """UPDATE ${Tables.RSVP}
SET calendar_number = calendar_number - 1
WHERE calendar_number >=? AND guild_id = ?
""".trimMargin()
@Language("MySQL")
val DECREMENT_STATIC_MESSAGES = """UPDATE ${Tables.STATIC_MESSAGES}
SET calendar_number = calendar_number - 1
WHERE calendar_number >=? AND guild_id = ?
""".trimMargin()
@Language("MySQL")
val DELETE_ALL_STATIC_MESSAGES = """DELETE FROM ${Tables.STATIC_MESSAGES}
WHERE guild_id = ? AND calendar_number = ?
""".trimMargin()
@Language("MySQL")
val FULL_CALENDAR_DELETE = """
$DELETE_CALENDAR;$DELETE_ALL_EVENT_DATA;$DELETE_ALL_RSVP_DATA;$DELETE_ALL_ANNOUNCEMENT_DATA;$DELETE_ALL_STATIC_MESSAGES;
$DECREMENT_CALENDARS;$DECREMENT_EVENTS;$DECREMENT_RSVPS;$DECREMENT_ANNOUNCEMENTS;$DECREMENT_STATIC_MESSAGES
""".trimIndent()
@Language("MySQL")
val SELECT_MANY_EVENT_DATA = """SELECT * FROM ${Tables.EVENTS}
WHERE event_id in (?)
""".trimMargin()
/* Delete everything */
@Language("MySQL")
val DELETE_EVERYTHING_FOR_GUILD = """
delete from ${Tables.GUILD_SETTINGS} where GUILD_ID=?;
delete from ${Tables.CALENDARS} where GUILD_ID=?;
delete from ${Tables.EVENTS} where GUILD_ID=?;
delete from ${Tables.RSVP} where GUILD_ID=?;
delete from ${Tables.ANNOUNCEMENTS} where GUILD_ID=?;
delete from ${Tables.STATIC_MESSAGES} where guild_id=?;
""".trimMargin()
}
private object Tables {
/* The language annotations are there because IntelliJ is dumb and assumes this needs to be proper MySQL */
@Language("Kotlin")
const val API: String = "api"
@Language("Kotlin")
const val GUILD_SETTINGS = "guild_settings"
@Language("Kotlin")
const val CALENDARS = "calendars"
@Language("Kotlin")
const val ANNOUNCEMENTS = "announcements"
@Language("Kotlin")
const val EVENTS = "events"
@Language("Kotlin")
const val RSVP = "rsvp"
@Language("Kotlin")
const val STATIC_MESSAGES = "static_messages"
}

View File

@@ -0,0 +1,12 @@
package org.dreamexposure.discal.core.database
import org.springframework.data.relational.core.mapping.Table
@Table("events")
data class EventMetadataData(
val guildId: Long,
val eventId: String,
val calendarNumber: Int,
val eventEnd: Long,
val imageLink: String,
)

View File

@@ -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 EventMetadataRepository : R2dbcRepository<EventMetadataData, Long> {
fun existsByGuildIdAndEventId(guildId: Long, eventId: String): Mono<Boolean>
fun findByGuildIdAndEventId(guildId: Long, eventId: String): Mono<EventMetadataData>
fun findAllByGuildIdAndEventIdIn(guildId: Long, eventIds: Collection<String>): Flux<EventMetadataData>
@Query("""
UPDATE events
SET calendar_number = :calendarNumber,
event_end = :eventEnd,
image_link = :imageLink
WHERE guild_id = :guildId AND event_id = :eventId
""")
fun updateByGuildIdAndEventId(
guildId: Long,
eventId: String,
calendarNumber: Int,
eventEnd: Long,
imageLink: String,
): Mono<Long>
fun deleteByGuildIdAndEventId(guildId: Long, eventId: String): Mono<Void>
fun deleteAllByGuildIdAndCalendarNumber(guildId: Long, calendarNumber: Int): Mono<Void>
@Query("""
UPDATE events
SET calendar_number = calendar_number - 1
WHERE calendar_number >= :calendarNumber AND guild_id = :guildId
""")
fun decrementCalendarsByGuildIdAndCalendarNumber(guildId: Long, calendarNumber: Int): Mono<Long>
}

View File

@@ -0,0 +1,18 @@
package org.dreamexposure.discal.core.database
import org.springframework.data.relational.core.mapping.Table
@Table("guild_settings")
data class GuildSettingsData(
val guildId: Long,
val controlRole: String,
val timeFormat: Int,
val patronGuild: Boolean,
val devGuild : Boolean,
val maxCalendars: Int,
val lang: String,
val branded: Boolean,
val announcementStyle: Int,
val eventKeepDuration: Boolean,
)

View File

@@ -0,0 +1,39 @@
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 GuildSettingsRepository : R2dbcRepository<GuildSettingsData, Long> {
fun existsByGuildId(guildId: Long): Mono<Boolean>
fun findByGuildId(guildId: Long): Mono<GuildSettingsData>
@Query("""
UPDATE guild_settings
SET control_role = :controlRole,
time_format = :timeFormat,
patron_guild = :patronGuild,
dev_guild = :devGuild,
max_calendars = :maxCalendars,
lang = :lang,
branded = :branded,
announcement_style = :announcementStyle,
event_keep_duration = :eventKeepDuration
WHERE guild_id = :guildId
""")
fun updateByGuildId(
guildId: Long,
controlRole: String,
timeFormat: Int,
patronGuild: Boolean,
devGuild: Boolean,
maxCalendars: Int,
lang: String,
branded: Boolean,
announcementStyle: Int,
eventKeepDuration: Boolean,
): Mono<Int>
}

View File

@@ -43,4 +43,13 @@ interface RsvpRepository: R2dbcRepository<RsvpData, String> {
WHERE guild_id = :guildId AND rsvp_role = :rsvpRole
""")
fun removeRoleByGuildIdAndRsvpRole(guildId: Long, rsvpRole: Long): Mono<Int>
fun deleteAllByGuildIdAndCalendarNumber(guildId: Long, calendarNumber: Int): Mono<Void>
@Query("""
UPDATE rsvp
SET calendar_number = calendar_number - 1
WHERE calendar_number >= :calendarNumber AND guild_id = :guildId
""")
fun decrementCalendarsByGuildIdAndCalendarNumber(guildId: Long, calendarNumber: Int): Mono<Long>
}

View File

@@ -45,5 +45,14 @@ interface StaticMessageRepository: R2dbcRepository<StaticMessageData, Long> {
calendarNumber: Int,
): Mono<Int>
fun deleteByGuildIdAndMessageId(guildId: Long, messageId: Long): Mono<Void>
fun deleteAllByGuildIdAndMessageId(guildId: Long, messageId: Long): Mono<Void>
fun deleteByGuildIdAndCalendarNumber(guildId: Long, calendarNumber: Int): Mono<Void>
@Query("""
UPDATE static_messages
SET calendar_number = calendar_number - 1
WHERE calendar_number >= :calendarNumber AND guild_id = :guildId
""")
fun decrementCalendarsByGuildIdAndCalendarNumber(guildId: Long, calendarNumber: Int): Mono<Long>
}

View File

@@ -1,231 +0,0 @@
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`.calendar.CalendarData
import org.dreamexposure.discal.core.`object`.web.WebCalendar
import org.json.JSONObject
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.time.Instant
import java.time.ZoneId
import java.time.temporal.ChronoUnit
interface Calendar {
/**
* The ID of the [Guild] this calendar belongs to.
*/
val guildId: Snowflake
get() = calendarData.guildId
/**
* The base object in which most of the calendar data comes from
*/
val calendarData: CalendarData
/**
* The calendar's ID, usually but not always the same as the calendar's [address][calendarAddress].
* Use this for unique identification of the calendar
*/
val calendarId: String
get() = calendarData.calendarId
/**
* The calendar's address, usually but not always the same as the calendar's [ID][calendarId].
* This property may not be unique, use [calendarId] instead
*/
val calendarAddress: String
get() = calendarData.calendarAddress
/**
* The relative number of the calendar in order it was created in for the [Guild].
* Calendar number `1` is the `main` calendar for the [Guild], used as the default.
*/
val calendarNumber: Int
get() = calendarData.calendarNumber
/**
* Whether the calendar is "external" meaning it is owned by a user account.
* This does not indicate the service used to host the calendar, but whether it is owned by DisCal, or a user.
*/
val external: Boolean
get() = calendarData.external
/**
* The name of the calendar. Renamed from "summary" to be more user-friendly and clear.
*/
val name: String
/**
* A longer form description of the calendar.
* If this is not present, an empty string is returned
*/
val description: String
/**
* The timezone the calendar uses. Normally in its longer name, such as `America/New_York`
*/
val timezone: ZoneId
/**
* The timezone's name, derived from the ZoneId timezone
*/
val zoneName: String
get() = timezone.id
/**
* A link to view the calendar on the official discal website
*/
val link: String
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)
*/
val hostLink: String
//Reactive - Self
/**
* Attempts to delete the calendar and returns the result.
* If an error occurs, it is emitted through the [Mono].
*
* @return A [Mono] boolean telling whether the deletion was successful
*/
fun delete(): Mono<Boolean>
/**
* Attempts to update the calendar with the provided details and return the result.
* If an error occurs, it is emitted through the [Mono].
*
* @param spec The details to update the calendar with
* @return A [Mono] whereupon successful completion, returns an [UpdateCalendarResponse] with the new calendar
*/
fun update(spec: UpdateCalendarSpec): Mono<UpdateCalendarResponse>
//Reactive - Events
/**
* Requests to retrieve the [Event] with the ID.
* If an error occurs, it is emitted through the [Mono]
*
* @return A [Mono] of the [Event], or [Empty][Mono.empty] if not found
*/
fun getEvent(eventId: String): Mono<Event>
/**
* Requests to retrieve all upcoming [events][Event]
* If an error occurs, it is emitted through the [Flux]
*
* @param amount The upper limit of how many events to retrieve
* @return A [Flux] of [events][Event] that are upcoming
*/
fun getUpcomingEvents(amount: Int): Flux<Event>
/**
* Requests to retrieve all ongoing [events][Event] (starting no more than 2 weeks ago).
* If an error occurs, it is emitted through the [Flux]
*
* @return A [Flux] of [events][Event] that are currently ongoing
*/
fun getOngoingEvents(): Flux<Event>
/**
* Requests to retrieve all [events][Event] within the supplied time span (inclusive).
* If an error occurs, it is emitted through the [Flux]
*
* @return A [Flux] of [events][Event] that are happening within the supplied time range
*/
fun getEventsInTimeRange(start: Instant, end: Instant): Flux<Event>
/**
* Requests to retrieve all [events][Event] occurring withing the next 24-hour period from the supplied [Instant]
* (inclusive).
* If an error occurs, it is emitted through the [Flux]
*
* @return A [Flux] of [events][Event] that are happening within the next 24-hour period from the start.
*/
fun getEventsInNext24HourPeriod(start: Instant): Flux<Event> =
getEventsInTimeRange(start, start.plus(1, ChronoUnit.DAYS))
/**
* Requests to retrieve all [events][Event] within the month starting at the supplied [Instant].
* If an error occurs, it is emitted through the [Flux]
*
* @return A [Flux] of [events][Event] that are happening in the supplied 1-month period.
*/
fun getEventsInMonth(start: Instant, daysInMonth: Int): Flux<Event> =
getEventsInTimeRange(start, start.plus(daysInMonth.toLong(), ChronoUnit.DAYS))
fun getEventsInNextNDays(days: Int): Flux<Event> =
getEventsInTimeRange(Instant.now(), Instant.now().plus(days.toLong(), ChronoUnit.DAYS))
/**
* Requests to create an event with the supplied information.
* If an error occurs, it is emitted through the [Mono]
*
* @param spec The information to input into the new [Event]
* @return A [Mono] containing the newly created [Event]
*/
fun createEvent(spec: CreateEventSpec): Mono<Event>
//Convenience
/**
* Converts this entity into a [WebCalendar] object.
*
* @return A [WebCalendar] containing the information from this entity
*/
fun toWebCalendar(): WebCalendar {
return WebCalendar(
this.calendarId,
this.calendarAddress,
this.calendarNumber,
this.calendarData.host,
this.link,
this.hostLink,
this.name,
this.description,
this.timezone.id.replace("/", "___"),
this.external
)
}
fun toJson(): JSONObject {
return JSONObject()
.put("guild_id", guildId.asString())
.put("calendar_id", calendarId)
.put("calendar_address", calendarAddress)
.put("calendar_number", calendarNumber)
.put("host", calendarData.host.name)
.put("host_link", hostLink)
.put("external", external)
.put("name", name)
.put("description", description)
.put("timezone", timezone)
.put("link", link)
}
companion object {
/**
* Requests to retrieve the [Calendar] from the provided [CalendarData]
* If an error occurs, it is emitted through the [Mono]
*
* @param data The data object for the Calendar to be built with
* @return A [Mono] containing the [Calendar], if it does not exist, [empty][Mono.empty] is returned.
*/
fun from(data: CalendarData): Mono<Calendar> {
when (data.host) {
CalendarHost.GOOGLE -> {
return GoogleCalendar.get(data)
}
}
}
}
}

View File

@@ -1,181 +0,0 @@
package org.dreamexposure.discal.core.entities
import discord4j.common.util.Snowflake
import discord4j.core.`object`.entity.Guild
import kotlinx.serialization.encodeToString
import org.dreamexposure.discal.core.entities.response.UpdateEventResponse
import org.dreamexposure.discal.core.entities.spec.update.UpdateEventSpec
import org.dreamexposure.discal.core.enums.event.EventColor
import org.dreamexposure.discal.core.`object`.event.EventData
import org.dreamexposure.discal.core.`object`.event.Recurrence
import org.dreamexposure.discal.core.utils.GlobalVal.JSON_FORMAT
import org.json.JSONObject
import reactor.core.publisher.Mono
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.temporal.ChronoUnit
interface Event {
/**
* The ID of the event.
* In the format eXXXXXXXXXX if generated by DisCal, otherwise it was generated by the 3rd party calendar service
*/
val eventId: String
/**
* The ID of the [Guild] this event belongs to.
*/
val guildId: Snowflake
get() = calendar.guildId
/**
* The [Calendar] this Event belongs to
*/
val calendar: Calendar
/**
* The of the event saved to the DisCal database
*/
val eventData: EventData
/**
* The name of the event, renamed from "summary" to make it more user-friendly and clear.
*/
val name: String
/**
* A description of what the event is about.
*/
val description: String
/**
* The location at which the event occurs, usually a map location.
*/
val location: String
/**
* The link to view the event at
*/
val link: String
/**
* The color of the event. Used for visually identifying it in Discord embeds.
* If no event color is assigned, it returns [EventColor.NONE] which is DisCal blue.
*/
val color: EventColor
/**
* The start of the event, as an [Instant] representing the time starting from January 1st 1970.
*/
val start: Instant
/**
* The end of the event, as an [Instant] representing the time starting from January 1st 1970.
*/
val end: Instant
/**
* Whether the event is a recurring event.
*/
val recur: Boolean
/**
* The rules of the recurring event. Contains the RRule an human-readable information on how the event will recur
*/
val recurrence: Recurrence
/**
* A link to the image, if none is present, returns empty
*/
val image: String
get() = eventData.imageLink
/**
* The timezone that the event takes place in. This is always the same as the [Calendar]'s timezone
*/
val timezone: ZoneId
get() = calendar.timezone
//Reactive
/**
* Attempts to update the event and returns the result.
* If an error occurs, it is emitted through the [Mono].
*
* @param spec The information to update the event with
* @return A [Mono] that contains the [UpdateEventResponse] containing information on success and the changes.
*/
fun update(spec: UpdateEventSpec): Mono<UpdateEventResponse>
/**
* Attempts to delete the event and returns the result.
* If an error occurs, it is emitted through the [Mono].
*
* @return A [Mono] containing whether delete succeeded.
*/
fun delete(): Mono<Boolean>
fun isOngoing(): Boolean = start.isBefore(Instant.now()) && end.isAfter(Instant.now())
fun isOver(): Boolean = end.isBefore(Instant.now())
fun isStarted() = start.isBefore(Instant.now())
/**
* Whether the event is 24 hours long.
*
* @return Whether the event is 24 hours long
*/
fun is24Hours() = Duration.between(start, end).toHours() == 24L
/**
* Whether the event lasts for a full calendar day (midnight to midnight) or longer.
*
* @return Whether the event is all day
*/
fun isAllDay(): Boolean {
val start = this.start.atZone(timezone)
return start.hour == 0 && is24Hours()
}
/**
* Whether the event spans across multiple calendar days (ex Monday 8pm to Tuesday 3am)
*
* @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)
return when {
start.year != end.year -> true
start.month != end.month -> true
start.dayOfYear != end.dayOfYear -> true
else -> false
}
}
//Json bullshit
fun toJson(): JSONObject {
return JSONObject()
.put("guild_id", guildId)
.put("calendar", calendar.toJson())
.put("event_id", eventId)
.put("epoch_start", start.toEpochMilli())
.put("epoch_end", end.toEpochMilli())
.put("name", name)
.put("description", description)
.put("location", location)
.put("is_parent", !eventId.contains("_"))
.put("color", color.name)
.put("recur", recur)
.put("recurrence", JSONObject(JSON_FORMAT.encodeToString(recurrence)))
.put("rrule", recurrence.toRRule())
.put("image", eventData.imageLink)
}
}

View File

@@ -1,171 +0,0 @@
package org.dreamexposure.discal.core.entities.google
import com.google.api.client.util.DateTime
import com.google.api.services.calendar.model.AclRule
import com.google.api.services.calendar.model.EventDateTime
import org.dreamexposure.discal.core.`object`.calendar.CalendarData
import org.dreamexposure.discal.core.`object`.event.EventData
import org.dreamexposure.discal.core.cache.DiscalCache
import org.dreamexposure.discal.core.crypto.KeyGenerator
import org.dreamexposure.discal.core.database.DatabaseManager
import org.dreamexposure.discal.core.entities.Calendar
import org.dreamexposure.discal.core.entities.Event
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.event.EventColor
import org.dreamexposure.discal.core.extensions.google.asInstant
import org.dreamexposure.discal.core.wrapper.google.AclRuleWrapper
import org.dreamexposure.discal.core.wrapper.google.CalendarWrapper
import org.dreamexposure.discal.core.wrapper.google.EventWrapper
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import com.google.api.services.calendar.model.Calendar as GoogleCalendarModel
import com.google.api.services.calendar.model.Event as GoogleEventModel
class GoogleCalendar internal constructor(
override val calendarData: CalendarData,
private val baseCalendar: GoogleCalendarModel
) : Calendar {
override val name: String
get() = baseCalendar.summary.orEmpty()
override val description: String
get() = baseCalendar.description.orEmpty()
override val timezone: ZoneId
get() = ZoneId.of(baseCalendar.timeZone)
override val hostLink: String
get() = "https://calendar.google.com/calendar/embed?src=$calendarId"
override fun delete(): Mono<Boolean> {
//Delete self from cache
DiscalCache.handleCalendarDelete(guildId)
return CalendarWrapper.deleteCalendar(calendarData)
.then(DatabaseManager.deleteCalendarAndRelatedData(calendarData))
.thenReturn(true)
}
override fun update(spec: UpdateCalendarSpec): Mono<UpdateCalendarResponse> {
val content = GoogleCalendarModel()
content.id = this.calendarId
spec.name?.let { content.summary = it }
spec.description?.let { content.description = it }
spec.timezone?.let { content.timeZone = it.id }
return CalendarWrapper.patchCalendar(content, this.calendarData)
.timeout(Duration.ofSeconds(30))
.flatMap { confirmed ->
val rule = AclRule()
.setScope(AclRule.Scope().setType("default"))
.setRole("reader")
val new = GoogleCalendar(this.calendarData, confirmed)
//Update cache
DiscalCache.putCalendar(new)
return@flatMap AclRuleWrapper.insertRule(rule, this.calendarData)
.thenReturn(UpdateCalendarResponse(
old = this,
new = new,
success = true)
)
}.defaultIfEmpty(UpdateCalendarResponse(old = this, success = false))
}
override fun getEvent(eventId: String): Mono<Event> {
return GoogleEvent.get(this, eventId)
}
override fun getUpcomingEvents(amount: Int): Flux<Event> {
return EventWrapper.getEvents(calendarData, amount, System.currentTimeMillis())
.flatMapMany(this::loadEvents)
}
override fun getOngoingEvents(): Flux<Event> {
val start = System.currentTimeMillis() - Duration.ofDays(14).toMillis() // 2 weeks ago
val end = System.currentTimeMillis() + Duration.ofDays(1).toMillis() // One day from now
return EventWrapper.getEvents(calendarData, start, end)
.flatMapMany { Flux.fromIterable(it) }
.filter { it.start.asInstant(timezone).isBefore(Instant.now()) }
.filter { it.end.asInstant(timezone).isAfter(Instant.now()) }
.collectList()
.flatMapMany(this::loadEvents)
}
override fun getEventsInTimeRange(start: Instant, end: Instant): Flux<Event> {
return EventWrapper.getEvents(calendarData, start.toEpochMilli(), end.toEpochMilli())
.flatMapMany(this::loadEvents)
}
override fun createEvent(spec: CreateEventSpec): Mono<Event> {
val event = GoogleEventModel()
event.id = KeyGenerator.generateEventId()
event.visibility = "public"
spec.name?.let { event.summary = it }
spec.description?.let { event.description = it }
spec.location?.let { event.location = it }
event.start = EventDateTime()
.setDateTime(DateTime(spec.start.toEpochMilli()))
.setTimeZone(this.timezone.id)
event.end = EventDateTime()
.setDateTime(DateTime(spec.end.toEpochMilli()))
.setTimeZone(this.timezone.id)
if (spec.color != EventColor.NONE)
event.colorId = spec.color.id.toString()
if (spec.recur)
spec.recurrence?.let { event.recurrence = listOf(it.toRRule()) }
//Okay, all values are set, lets create the event now...
return EventWrapper.createEvent(this.calendarData, event).flatMap { confirmed ->
val data = EventData(
this.guildId,
confirmed.id,
calendarNumber,
spec.end.toEpochMilli(),
spec.image.orEmpty()
)
return@flatMap DatabaseManager.updateEventData(data)
.thenReturn(GoogleEvent(this, data, confirmed))
}
}
private fun loadEvents(events: List<GoogleEventModel>): Flux<GoogleEvent> {
return DatabaseManager.getEventsData(guildId, events.map { it.id }).flatMapMany { data ->
Flux.fromIterable(events).concatMap {
val id = if (it.id.contains("_")) it.id.split("_")[0] else it.id
if (data.containsKey(id)) Mono.just(GoogleEvent(this, data[id]!!, it))
else Mono.just(GoogleEvent(this, EventData(guildId, eventId = id), it))
}
}
}
internal companion object {
/**
* Requests to retrieve the [Calendar] from the provided [CalendarData]
* If an error occurs, it is emitted through the [Mono]
*
* @param calData The data object for the Calendar to be built with
* @return A [Mono] containing the [Calendar], if it does not exist, [empty][Mono.empty] is returned.
*/
fun get(calData: CalendarData): Mono<Calendar> {
return CalendarWrapper.getCalendar(calData)
.map { GoogleCalendar(calData, it) }
}
}
}

View File

@@ -1,184 +0,0 @@
package org.dreamexposure.discal.core.entities.google
import com.google.api.client.util.DateTime
import com.google.api.services.calendar.model.EventDateTime
import org.dreamexposure.discal.core.`object`.event.EventData
import org.dreamexposure.discal.core.`object`.event.Recurrence
import org.dreamexposure.discal.core.database.DatabaseManager
import org.dreamexposure.discal.core.entities.Calendar
import org.dreamexposure.discal.core.entities.Event
import org.dreamexposure.discal.core.entities.response.UpdateEventResponse
import org.dreamexposure.discal.core.entities.spec.update.UpdateEventSpec
import org.dreamexposure.discal.core.enums.event.EventColor
import org.dreamexposure.discal.core.wrapper.google.EventWrapper
import reactor.core.publisher.Mono
import java.time.Instant
import java.time.temporal.ChronoUnit
import com.google.api.services.calendar.model.Event as GoogleEventModel
class GoogleEvent internal constructor(
override val calendar: Calendar,
override val eventData: EventData,
private val baseEvent: GoogleEventModel,
) : Event {
override val eventId: String
get() = baseEvent.id
override val name: String
get() = baseEvent.summary.orEmpty()
override val description: String
get() = baseEvent.description.orEmpty()
override val location: String
get() = baseEvent.location.orEmpty()
override val link: String
get() = baseEvent.htmlLink.orEmpty()
override val color: EventColor
get() {
return if (baseEvent.colorId != null && baseEvent.colorId.isNotEmpty())
EventColor.fromNameOrHexOrId(baseEvent.colorId)
else
EventColor.NONE
}
override val start: Instant
get() {
return if (baseEvent.start.dateTime != null) {
Instant.ofEpochMilli(baseEvent.start.dateTime.value)
} else {
Instant.ofEpochMilli(baseEvent.start.date.value)
.plus(1, ChronoUnit.DAYS)
.atZone(timezone)
.truncatedTo(ChronoUnit.DAYS)
.toLocalDate()
.atStartOfDay()
.atZone(timezone)
.toInstant()
}
}
override val end: Instant
get() {
return if (baseEvent.end.dateTime != null) {
Instant.ofEpochMilli(baseEvent.end.dateTime.value)
} else {
Instant.ofEpochMilli(baseEvent.end.date.value)
.plus(1, ChronoUnit.DAYS)
.atZone(timezone)
.truncatedTo(ChronoUnit.DAYS)
.toLocalDate()
.atStartOfDay()
.atZone(timezone)
.toInstant()
}
}
override val recur: Boolean
get() = baseEvent.recurrence != null && baseEvent.recurrence.isNotEmpty()
override val recurrence: Recurrence
get() {
return if (recur)
Recurrence.fromRRule(baseEvent.recurrence[0])
else
Recurrence()
}
override fun update(spec: UpdateEventSpec): Mono<UpdateEventResponse> {
val event = GoogleEventModel()
event.id = this.eventId
spec.name?.let { event.summary = it }
spec.description?.let { event.description = it }
spec.location?.let { event.location = it }
//Always update start/end so that we can safely handle all day events without DateTime by overwriting it
if (spec.start != null) {
event.start = EventDateTime()
.setDateTime(DateTime(spec.start.toEpochMilli()))
.setTimeZone(this.timezone.id)
} else {
event.start = EventDateTime()
.setDateTime(DateTime(this.start.toEpochMilli()))
.setTimeZone(this.timezone.id)
}
if (spec.end != null) {
event.end = EventDateTime()
.setDateTime(DateTime(spec.end.toEpochMilli()))
.setTimeZone(this.timezone.id)
} else {
event.end = EventDateTime()
.setDateTime(DateTime(this.end.toEpochMilli()))
.setTimeZone(this.timezone.id)
}
spec.color?.let {
if (it == EventColor.NONE)
event.colorId = null
else
event.colorId = it.id.toString()
}
//Special recurrence handling
if (spec.recur != null) {
if (spec.recur) {
//event now recurs, add the RRUle.
spec.recurrence?.let { event.recurrence = listOf(it.toRRule()) }
}
} else {
//Recur equals null, so it's not changing whether its recurring, so handle if RRule changes only
spec.recurrence?.let { event.recurrence = listOf(it.toRRule()) }
}
//Okay, all values are set, lets patch this event now...
return EventWrapper.patchEvent(this.calendar.calendarData, event).flatMap { confirmed ->
val data = EventData(
this.guildId,
confirmed.id,
calendar.calendarNumber,
confirmed.end.dateTime.value,
spec.image ?: this.image
)
return@flatMap DatabaseManager.updateEventData(data)
.thenReturn(UpdateEventResponse(true, old = this, GoogleEvent(this.calendar, data, confirmed)))
}.defaultIfEmpty(UpdateEventResponse(false, old = this))
}
override fun delete(): Mono<Boolean> {
return EventWrapper.deleteEvent(calendar.calendarData, eventId)
.flatMap { success ->
if (success) {
Mono.`when`(
DatabaseManager.deleteAnnouncementsForEvent(guildId, eventId),
DatabaseManager.deleteEventData(eventId),
).thenReturn(true)
} else {
Mono.just(false)
}
}.defaultIfEmpty(false)
}
internal companion object {
/**
* Requests to retrieve the event with the provided ID from the provided [Calendar].
* If an error occurs, it is emitted through the [Mono]
*
* @param calendar The [Calendar] this event exists on.
* @param id The ID of the event
* @return A [Mono] containing the event, if it does not exist, the mono is empty.
*/
fun get(calendar: Calendar, id: String): Mono<Event> {
return EventWrapper.getEvent(calendar.calendarData, id)
.flatMap { event ->
DatabaseManager.getEventData(calendar.guildId, id)
.map {
GoogleEvent(calendar, it, event)
}
}
}
}
}

View File

@@ -1,9 +0,0 @@
package org.dreamexposure.discal.core.entities.response
import org.dreamexposure.discal.core.entities.Calendar
data class UpdateCalendarResponse(
val success: Boolean,
val old: Calendar? = null,
val new: Calendar? = null,
)

View File

@@ -1,9 +0,0 @@
package org.dreamexposure.discal.core.entities.response
import org.dreamexposure.discal.core.entities.Event
data class UpdateEventResponse(
val success: Boolean,
val old: Event? = null,
val new: Event? = null,
)

View File

@@ -1,16 +0,0 @@
package org.dreamexposure.discal.core.entities.spec.create
import org.dreamexposure.discal.core.enums.calendar.CalendarHost
import java.time.ZoneId
data class CreateCalendarSpec(
val host: CalendarHost,
val calNumber: Int,
val name: String,
val description: String? = null,
val timezone: ZoneId,
)

View File

@@ -1,25 +0,0 @@
package org.dreamexposure.discal.core.entities.spec.create
import org.dreamexposure.discal.core.`object`.event.Recurrence
import org.dreamexposure.discal.core.enums.event.EventColor
import java.time.Instant
data class CreateEventSpec(
val name: String? = null,
val description: String? = null,
val start: Instant,
val end: Instant,
val color: EventColor = EventColor.NONE,
val location: String? = null,
val image: String? = null,
val recur: Boolean = false,
val recurrence: Recurrence? = null,
)

View File

@@ -1,11 +0,0 @@
package org.dreamexposure.discal.core.entities.spec.update
import java.time.ZoneId
data class UpdateCalendarSpec(
val name: String? = null,
val description: String? = null,
val timezone: ZoneId? = null,
)

View File

@@ -1,25 +0,0 @@
package org.dreamexposure.discal.core.entities.spec.update
import org.dreamexposure.discal.core.`object`.event.Recurrence
import org.dreamexposure.discal.core.enums.event.EventColor
import java.time.Instant
data class UpdateEventSpec(
val name: String? = null,
val description: String? = null,
val start: Instant? = null,
val end: Instant? = null,
val color: EventColor? = null,
val location: String? = null,
val image: String? = null,
val recur: Boolean? = null,
val recurrence: Recurrence? = null
)

View File

@@ -1,22 +0,0 @@
package org.dreamexposure.discal.core.enums.announcement
enum class AnnouncementModifier {
BEFORE, DURING, END;
companion object {
fun isValid(value: String): Boolean {
return value.equals("BEFORE", true)
|| value.equals("B4", true)
|| value.equals("DURING", true)
|| value.equals("END", true)
}
fun fromValue(value: String): AnnouncementModifier {
return when (value.uppercase()) {
"DURING" -> DURING
"END" -> END
else -> BEFORE
}
}
}
}

View File

@@ -9,6 +9,7 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
@Serializable(with = AnnouncementStyleAsIntSerializer::class)
@Deprecated("Use new subclassed enum in GuildSettings")
enum class AnnouncementStyle(val value: Int = 1) {
FULL(1), SIMPLE(2), EVENT(3);

View File

@@ -1,25 +0,0 @@
package org.dreamexposure.discal.core.enums.announcement
enum class AnnouncementType {
UNIVERSAL, SPECIFIC, COLOR, RECUR;
companion object {
fun isValid(value: String): Boolean {
return value.equals("UNIVERSAL", true)
|| value.equals("SPECIFIC", true)
|| value.equals("COLOR", true)
|| value.equals("COLOUR", true)
|| value.equals("RECUR", true)
}
fun fromValue(value: String): AnnouncementType {
return when (value.uppercase()) {
"SPECIFIC" -> SPECIFIC
"COLOR" -> COLOR
"COLOUR" -> COLOR
"RECUR" -> RECUR
else -> UNIVERSAL
}
}
}
}

View File

@@ -0,0 +1,3 @@
package org.dreamexposure.discal.core.exceptions
class ApiException(override val message: String? = null, val exception: Exception? = null): Exception(message, exception)

View File

@@ -1,3 +0,0 @@
package org.dreamexposure.discal.core.exceptions.google
class GoogleAuthCancelException : Exception()

View File

@@ -1,6 +1,6 @@
package org.dreamexposure.discal.core.extensions
import org.dreamexposure.discal.core.entities.Event
import org.dreamexposure.discal.core.`object`.new.Event
import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
import java.time.temporal.TemporalAdjusters
@@ -19,11 +19,7 @@ fun List<String>.asStringList(): String {
return builder.toString()
}
fun MutableList<String>.setFromString(strList: String) {
this += strList.split(",").filter(String::isNotBlank)
}
fun MutableList<Event>.groupByDate(): Map<ZonedDateTime, List<Event>> {
fun List<Event>.groupByDate(): Map<ZonedDateTime, List<Event>> {
if (this.isEmpty()) return emptyMap()
// Get a list of all distinct days events take place on (the first start date and the last end date)
@@ -35,40 +31,13 @@ fun MutableList<Event>.groupByDate(): Map<ZonedDateTime, List<Event>> {
.with(TemporalAdjusters.ofDateAdjuster { identity -> identity })
// Get a list of all days between the start and end dates
val days = mutableListOf<ZonedDateTime>()
val days = mutableListOf<ZonedDateTime>(startDate, endDate)
var current = startDate
while (current.isBefore(endDate)) {
current = current.plusDays(1)
days.add(current)
}
/*
// First get a list of distinct dates each event starts on
var rawDates = this.map {
ZonedDateTime.ofInstant(it.start, it.timezone).truncatedTo(ChronoUnit.DAYS)
.with(TemporalAdjusters.ofDateAdjuster { identity -> identity })
}.toList()
// Add days for multi-day events including end dates
rawDates = rawDates.plus(this.asSequence().filter {
it.isMultiDay()
}.map {
val start = ZonedDateTime.ofInstant(it.start, it.timezone).truncatedTo(ChronoUnit.DAYS)
.with(TemporalAdjusters.ofDateAdjuster { identity -> identity })
val end = ZonedDateTime.ofInstant(it.end, it.timezone).truncatedTo(ChronoUnit.DAYS)
.with(TemporalAdjusters.ofDateAdjuster { identity -> identity })
val days = listOf<ZonedDateTime>()
var current = start
while (current.isBefore(end)) {
current = current.plusDays(1)
days.plus(current)
}
days
}.flatten())
*/
// Sort dates
val sortedDates = days.distinct().sorted().toList()

View File

@@ -2,7 +2,6 @@ package org.dreamexposure.discal.core.extensions
import org.dreamexposure.discal.core.enums.time.BadTimezone
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.*
@@ -21,24 +20,14 @@ fun String.toMarkdown(): String = GlobalVal.MARKDOWN_CONVERTER.convert(this.sani
fun String.unescapeNewLines(): String = this.replace(Regex("([\\\\\\n]+)(n)"), "\n").replace("=0D=0A", "\n")
fun String.isValidTimezone(): Boolean {
return try {
ZoneId.getAvailableZoneIds().contains(this) && !BadTimezone.isBad(this)
} catch (ignore: Exception) {
false
}
}
fun String.toZoneId(): ZoneId? {
return try {
if (!BadTimezone.isBad(this)) ZoneId.of(this) else null
} catch (ignore: Exception) {
} catch (_: Exception) {
null
}
}
fun String.isValidImage(allowGif: Boolean) = ImageValidator.validate(this, allowGif)
fun String.padCenter(length: Int, padChar: Char = ' '): String {
if (this.length >= length) return this

View File

@@ -1,87 +0,0 @@
package org.dreamexposure.discal.core.extensions.discord4j
import discord4j.common.util.Snowflake
import discord4j.core.`object`.entity.Guild
import discord4j.core.`object`.entity.Role
import discord4j.rest.entity.RestGuild
import discord4j.rest.http.client.ClientException
import io.netty.handler.codec.http.HttpResponseStatus
import org.dreamexposure.discal.core.database.DatabaseManager
import org.dreamexposure.discal.core.entities.Calendar
import org.dreamexposure.discal.core.entities.spec.create.CreateCalendarSpec
import org.dreamexposure.discal.core.`object`.GuildSettings
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
//Settings
fun Guild.getSettings(): Mono<GuildSettings> = getRestGuild().getSettings()
//Calendars
/**
* Attempts to request whether this [Guild] has at least one [Calendar].
* If an error occurs, it is emitted through the [Mono]
*
* @return A [Mono] containing whether this [Guild] has a [Calendar].
*/
fun Guild.hasCalendar(): Mono<Boolean> = getRestGuild().hasCalendar()
fun Guild.canAddCalendar(): Mono<Boolean> = getRestGuild().canAddCalendar()
fun Guild.determineNextCalendarNumber(): Mono<Int> = getRestGuild().determineNextCalendarNumber()
/**
* Attempts to retrieve this [Guild]'s main [Calendar] (calendar 1, this guild's first/primary calendar)
* If an error occurs, it is emitted through the [Mono]
*
* @return A [Mono] containing this [Guild]'s main [Calendar], if it does not exist, [empty][Mono.empty] is returned.
*/
fun Guild.getMainCalendar(): Mono<Calendar> = getRestGuild().getMainCalendar()
/**
* Attempts to retrieve this [Guild]'s [Calendar] with the supplied index.
* If an error occurs, it is emitted through the [Mono]
*
* @param calNumber The number of the [Calendar]. one-indexed
* @return A [Mono] containing the [Calendar] with the supplied index, if it does not exist, [empty][Mono.empty] is
* returned.
*/
fun Guild.getCalendar(calNumber: Int): Mono<Calendar> = getRestGuild().getCalendar(calNumber)
/**
* Attempts to retrieve all [calendars][Calendar] belonging to this [Guild].
* If an error occurs, it is emitted through the [Flux]
*
* @return A [Flux] containing all the [calendars][Calendar] belonging to this [Guild].
*/
fun Guild.getAllCalendars(): Flux<Calendar> = getRestGuild().getAllCalendars()
/**
* Attempts to create a [Calendar] with the supplied information on a 3rd party host.
* If an error occurs, it is emitted through the [Mono].
*
* @param spec The instructions for creating the [Calendar]
* @return A [Mono] containing the newly created [Calendar]
*/
fun Guild.createCalendar(spec: CreateCalendarSpec): Mono<Calendar> = getRestGuild().createCalendar(spec)
fun Guild.getControlRole(): Mono<Role> {
return getSettings().flatMap { settings ->
if (settings.controlRole.equals("everyone", true))
return@flatMap this.everyoneRole
else {
return@flatMap getRoleById(Snowflake.of(settings.controlRole))
.onErrorResume(ClientException::class.java) {
//If control role is deleted/not found, we reset it to everyone
if (it.status == HttpResponseStatus.NOT_FOUND) {
settings.controlRole = "everyone"
DatabaseManager.updateSettings(settings).then(everyoneRole)
} else
everyoneRole
}
}
}
}
fun Guild.getRestGuild(): RestGuild {
return client.rest().restGuild(data)
}

View File

@@ -1,60 +0,0 @@
package org.dreamexposure.discal.core.extensions.discord4j
import discord4j.core.`object`.entity.Message
import discord4j.core.event.domain.interaction.DeferrableInteractionEvent
import discord4j.core.spec.EmbedCreateSpec
import discord4j.core.spec.InteractionFollowupCreateSpec
import reactor.core.publisher.Mono
fun DeferrableInteractionEvent.followup(embed: EmbedCreateSpec): Mono<Message> {
val spec = InteractionFollowupCreateSpec.builder()
.addEmbed(embed)
.build()
return this.createFollowup(spec)
}
fun DeferrableInteractionEvent.followup(message: String): Mono<Message> {
val spec = InteractionFollowupCreateSpec.builder()
.content(message)
.build()
return this.createFollowup(spec)
}
fun DeferrableInteractionEvent.followup(message: String, embed: EmbedCreateSpec): Mono<Message> {
val spec = InteractionFollowupCreateSpec.builder()
.content(message)
.addEmbed(embed)
.build()
return this.createFollowup(spec)
}
fun DeferrableInteractionEvent.followupEphemeral(embed: EmbedCreateSpec): Mono<Message> {
val spec = InteractionFollowupCreateSpec.builder()
.addEmbed(embed)
.ephemeral(true)
.build()
return this.createFollowup(spec)
}
fun DeferrableInteractionEvent.followupEphemeral(message: String): Mono<Message> {
val spec = InteractionFollowupCreateSpec.builder()
.content(message)
.ephemeral(true)
.build()
return this.createFollowup(spec)
}
fun DeferrableInteractionEvent.followupEphemeral(message: String, embed: EmbedCreateSpec): Mono<Message> {
val spec = InteractionFollowupCreateSpec.builder()
.content(message)
.addEmbed(embed)
.ephemeral(true)
.build()
return this.createFollowup(spec)
}

View File

@@ -1,14 +0,0 @@
package org.dreamexposure.discal.core.extensions.discord4j
import discord4j.core.`object`.entity.Member
import discord4j.rest.entity.RestMember
import discord4j.rest.util.PermissionSet
import java.util.function.Predicate
fun Member.hasPermissions(pred: Predicate<PermissionSet>) = getRestMember().hasPermissions(pred)
fun Member.hasElevatedPermissions() = getRestMember().hasElevatedPermissions()
fun Member.hasControlRole() = getRestMember().hasControlRole()
fun Member.getRestMember(): RestMember = client.rest().restMember(guildId, memberData)

View File

@@ -1,139 +0,0 @@
package org.dreamexposure.discal.core.extensions.discord4j
import com.google.api.services.calendar.model.AclRule
import discord4j.core.`object`.entity.Guild
import discord4j.rest.entity.RestGuild
import org.dreamexposure.discal.core.cache.DiscalCache
import org.dreamexposure.discal.core.database.DatabaseManager
import org.dreamexposure.discal.core.entities.Calendar
import org.dreamexposure.discal.core.entities.google.GoogleCalendar
import org.dreamexposure.discal.core.entities.spec.create.CreateCalendarSpec
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.wrapper.google.AclRuleWrapper
import org.dreamexposure.discal.core.wrapper.google.CalendarWrapper
import org.dreamexposure.discal.core.wrapper.google.GoogleAuthWrapper
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.time.Duration
import java.util.*
import com.google.api.services.calendar.model.Calendar as GoogleCalendarModel
//Settings
fun RestGuild.getSettings(): Mono<GuildSettings> = DatabaseManager.getSettings(this.id)
//Calendars
/**
* Attempts to request whether this [Guild] has at least one [Calendar].
* If an error occurs, it is emitted through the [Mono]
*
* @return A [Mono] containing whether this [Guild] has a [Calendar].
*/
fun RestGuild.hasCalendar(): Mono<Boolean> {
return DatabaseManager.getAllCalendars(this.id).map(List<CalendarData>::isNotEmpty)
}
fun RestGuild.canAddCalendar(): Mono<Boolean> {
//Always check the live database and bypass cache
return DatabaseManager.getCalendarCount(this.id)
.flatMap { current ->
if (current == 0) Mono.just(true)
else getSettings().map { current < it.maxCalendars }
}
}
fun RestGuild.determineNextCalendarNumber(): Mono<Int> {
return DatabaseManager.getAllCalendars(this.id)
.map(List<CalendarData>::size)
.map { it + 1 }
}
/**
* Attempts to retrieve this [Guild]'s main [Calendar] (calendar 1, this guild's first/primary calendar)
* If an error occurs, it is emitted through the [Mono]
*
* @return A [Mono] containing this [Guild]'s main [Calendar], if it does not exist, [empty][Mono.empty] is returned.
*/
fun RestGuild.getMainCalendar(): Mono<Calendar> = this.getCalendar(1)
/**
* Attempts to retrieve this [Guild]'s [Calendar] with the supplied index.
* If an error occurs, it is emitted through the [Mono]
*
* @param calNumber The number of the [Calendar]. one-indexed
* @return A [Mono] containing the [Calendar] with the supplied index, if it does not exist, [empty][Mono.empty] is
* returned.
*/
fun RestGuild.getCalendar(calNumber: Int): Mono<Calendar> {
//Check cache first
val cal = DiscalCache.getCalendar(id, calNumber)
if (cal != null) return Mono.just(cal)
return DatabaseManager.getCalendar(this.id, calNumber)
.flatMap(Calendar.Companion::from)
.doOnNext(DiscalCache::putCalendar)
}
/**
* Attempts to retrieve all [calendars][Calendar] belonging to this [Guild].
* If an error occurs, it is emitted through the [Flux]
*
* @return A [Flux] containing all the [calendars][Calendar] belonging to this [Guild].
*/
fun RestGuild.getAllCalendars(): Flux<Calendar> {
//check cache first
val cals = DiscalCache.getAllCalendars(id)
if (cals != null) return Flux.fromIterable(cals)
return DatabaseManager.getAllCalendars(this.id)
.flatMapMany { Flux.fromIterable(it) }
.flatMap(Calendar.Companion::from)
.doOnNext(DiscalCache::putCalendar)
}
/**
* Attempts to create a [Calendar] with the supplied information on a 3rd party host.
* If an error occurs, it is emitted through the [Mono].
*
* @param spec The instructions for creating the [Calendar]
* @return A [Mono] containing the newly created [Calendar]
*/
fun RestGuild.createCalendar(spec: CreateCalendarSpec): Mono<Calendar> {
return Mono.defer {
when (spec.host) {
CalendarHost.GOOGLE -> {
val credId = GoogleAuthWrapper.randomCredentialId()
val googleCal = GoogleCalendarModel()
googleCal.summary = spec.name
spec.description?.let { googleCal.description = it }
googleCal.timeZone = spec.timezone.id
//Call google to create it
CalendarWrapper.createCalendar(googleCal, credId, this.id)
.timeout(Duration.ofSeconds(30))
.flatMap { confirmed ->
val data = CalendarData(
guildId = this.id,
calendarNumber = spec.calNumber,
host = CalendarHost.GOOGLE,
calendarId = confirmed.id,
calendarAddress = confirmed.id,
credentialId = credId
)
val rule = AclRule()
.setScope(AclRule.Scope().setType("default"))
.setRole("reader")
Mono.`when`(
DatabaseManager.updateCalendar(data),
AclRuleWrapper.insertRule(rule, data)
).thenReturn(GoogleCalendar(data, confirmed))
.doOnNext(DiscalCache::putCalendar)
}
}
}
}
}

View File

@@ -1,52 +0,0 @@
package org.dreamexposure.discal.core.extensions.discord4j
import discord4j.common.util.Snowflake
import discord4j.discordjson.Id
import discord4j.discordjson.json.MemberData
import discord4j.discordjson.json.RoleData
import discord4j.rest.entity.RestMember
import discord4j.rest.util.Permission
import discord4j.rest.util.PermissionSet
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.util.function.Predicate
fun RestMember.hasPermissions(pred: Predicate<PermissionSet>): Mono<Boolean> {
return this.guild().data.flatMap { guildData ->
if (guildData.ownerId().asLong() == this.id.asLong()) {
Mono.just(true)
} else {
this.data.flatMap { memberData ->
Flux.fromIterable(guildData.roles())
.filter { memberData.roles().contains(it.id()) }
.map(RoleData::permissions)
.reduce(0L) { perm: Long, accumulator: Long -> accumulator or perm }
.map(PermissionSet::of)
.map(pred::test)
}
}
}
}
fun RestMember.hasElevatedPermissions(): Mono<Boolean> {
return hasPermissions() {
it.contains(Permission.MANAGE_GUILD) || it.contains(Permission.ADMINISTRATOR)
}
}
fun RestMember.hasControlRole(): Mono<Boolean> {
return this.guild().getSettings().flatMap { settings ->
if (settings.controlRole.equals("everyone", true))
return@flatMap Mono.just(true)
if (Snowflake.of(settings.controlRole).equals(settings.guildID)) // Also everyone (older guilds)
return@flatMap Mono.just(true)
this.data
.map(MemberData::roles)
.flatMapMany { Flux.fromIterable(it) }
.map(Id::asString)
.collectList()
.map { it.contains(settings.controlRole) }
}
}

View File

@@ -10,6 +10,7 @@ import org.dreamexposure.discal.core.serializers.SnowflakeAsStringSerializer
import java.util.*
@Serializable
@Deprecated("Prefer to use new.GuildSettings impl")
data class GuildSettings(
@Serializable(with = SnowflakeAsStringSerializer::class)
@SerialName("guild_id")

View File

@@ -1,21 +0,0 @@
package org.dreamexposure.discal.core.`object`
import discord4j.common.util.Snowflake
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.time.Instant
import java.util.*
@Serializable
abstract class Pre(
@Transient
open val guildId: Snowflake = Snowflake.of(0),
@Transient
open val editing: Boolean = false,
) {
@Transient
var lastEdit: Instant = Instant.now()
open fun generateWarnings(settings: GuildSettings): List<String> = Collections.emptyList()
}

View File

@@ -1,40 +0,0 @@
package org.dreamexposure.discal.core.`object`
import discord4j.common.util.Snowflake
import reactor.core.publisher.Flux
import java.time.Duration
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.concurrent.ConcurrentHashMap
@Deprecated("This was dumb, what was I doing lmao")
class Wizard<T: Pre> {
private val active = ConcurrentHashMap<Snowflake, T>()
init {
Flux.interval(Duration.ofMinutes(30))
.map { removeOld() }
.subscribe()
}
fun get(id: Snowflake): T? {
val p = active[id]
if (p != null) p.lastEdit = Instant.now()
return p
}
fun start(pre: T): T? = active.put(pre.guildId, pre)
fun remove(id: Snowflake): T? = active.remove(id)
private fun removeOld() {
val toRemove = mutableListOf<Snowflake>()
active.forEach {
if (Instant.now().isAfter(it.value.lastEdit.plus(30, ChronoUnit.MINUTES))) {
toRemove.add(it.key)
}
}
toRemove.forEach { active.remove(it) }
}
}

View File

@@ -1,11 +0,0 @@
package org.dreamexposure.discal.core.`object`.calendar
import com.google.api.services.calendar.model.Calendar
import discord4j.core.`object`.entity.Message
data class CalendarCreatorResponse(
val successful: Boolean,
val edited: Boolean,
val creatorMessage: Message?,
val calendar: Calendar?
)

View File

@@ -1,56 +0,0 @@
package org.dreamexposure.discal.core.`object`.calendar
import discord4j.common.util.Snowflake
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.dreamexposure.discal.core.crypto.KeyGenerator
import org.dreamexposure.discal.core.enums.calendar.CalendarHost
import org.dreamexposure.discal.core.serializers.SnowflakeAsStringSerializer
import java.time.Instant
@Serializable
data class CalendarData(
@Serializable(with = SnowflakeAsStringSerializer::class)
@SerialName("guild_id")
val guildId: Snowflake = Snowflake.of(0),
@SerialName("calendar_number")
val calendarNumber: Int = 1,
val host: CalendarHost,
@SerialName("calendar_id")
val calendarId: String = "primary",
@SerialName("calendar_address")
val calendarAddress: String = "primary",
val external: Boolean = false,
//secure values that should not be serialized
@Transient
val credentialId: Int = 0,
@Transient
var privateKey: String = KeyGenerator.csRandomAlphaNumericString(16),
@Transient
var encryptedAccessToken: String = "N/a",
@Transient
var encryptedRefreshToken: String = "N/a",
@Transient
var expiresAt: Instant = Instant.now()
): Comparable<CalendarData> {
constructor(guildId: Snowflake, calendarNumber: Int, host: CalendarHost, calendarId: String,
calendarAddress: String, credentialId: Int) :
this(guildId, calendarNumber, host, calendarId, calendarAddress, false, credentialId)
companion object {
@JvmStatic
fun empty(guildId: Snowflake, host: CalendarHost) = CalendarData(guildId, host = host)
fun emptyExternal(guildId: Snowflake, host: CalendarHost) = CalendarData(guildId, external = true, host = host)
}
fun expired() = Instant.now().isAfter(expiresAt)
override fun compareTo(other: CalendarData): Int {
if (this.calendarNumber > other.calendarNumber) return 1
if (this.calendarNumber < other.calendarNumber) return -1
return 0
}
}

Some files were not shown because too many files have changed in this diff Show More