diff --git a/bot/src/main/java/com/dongtronic/diabot/Extensions.kt b/bot/src/main/java/com/dongtronic/diabot/Extensions.kt index 79c7c512..c883c08b 100644 --- a/bot/src/main/java/com/dongtronic/diabot/Extensions.kt +++ b/bot/src/main/java/com/dongtronic/diabot/Extensions.kt @@ -30,6 +30,17 @@ fun CommandEvent.nameOf(user: User): String { return NicknameUtils.determineDisplayName(this, user).block()!! } +/** + * Gets the display name of a [User]. + * + * @receiver CommandEvent + * @param user The user to retrieve the display name of + * @return the display name of the given user + */ +suspend fun CommandEvent.suspendNameOf(user: User): String { + return NicknameUtils.suspendDetermineDisplayName(this, user) +} + fun RestAction.submitMono(): Mono { return submit().toMono() } diff --git a/bot/src/main/java/com/dongtronic/diabot/platforms/discord/commands/nightscout/NightscoutCommand.kt b/bot/src/main/java/com/dongtronic/diabot/platforms/discord/commands/nightscout/NightscoutCommand.kt index d01c3c33..e0a8bdf2 100644 --- a/bot/src/main/java/com/dongtronic/diabot/platforms/discord/commands/nightscout/NightscoutCommand.kt +++ b/bot/src/main/java/com/dongtronic/diabot/platforms/discord/commands/nightscout/NightscoutCommand.kt @@ -9,33 +9,29 @@ import com.dongtronic.diabot.exceptions.NightscoutFetchException import com.dongtronic.diabot.exceptions.NightscoutPrivateException import com.dongtronic.diabot.exceptions.UnconfiguredNightscoutException import com.dongtronic.diabot.logic.diabetes.BloodGlucoseConverter -import com.dongtronic.diabot.nameOf import com.dongtronic.diabot.platforms.discord.commands.DiscordCommand import com.dongtronic.diabot.platforms.discord.logic.NightscoutFacade -import com.dongtronic.diabot.submitMono +import com.dongtronic.diabot.suspendNameOf import com.dongtronic.diabot.util.logger import com.dongtronic.nightscout.Nightscout import com.dongtronic.nightscout.data.NightscoutDTO import com.dongtronic.nightscout.exceptions.NoNightscoutDataException import com.fasterxml.jackson.core.JsonProcessingException import com.jagrosh.jdautilities.command.CommandEvent +import dev.minn.jda.ktx.coroutines.await +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.reactor.awaitSingle import net.dv8tion.jda.api.EmbedBuilder import net.dv8tion.jda.api.entities.Member import net.dv8tion.jda.api.entities.Message import net.dv8tion.jda.api.entities.MessageEmbed import net.dv8tion.jda.api.entities.User -import net.dv8tion.jda.api.entities.channel.ChannelType import net.dv8tion.jda.api.entities.emoji.Emoji import net.dv8tion.jda.api.exceptions.InsufficientPermissionException import reactor.core.publisher.Flux -import reactor.core.publisher.Mono -import reactor.core.publisher.SynchronousSink -import reactor.core.scheduler.Schedulers -import reactor.kotlin.core.publisher.onErrorMap import reactor.kotlin.core.publisher.onErrorResume -import reactor.kotlin.core.publisher.switchIfEmpty -import reactor.kotlin.core.publisher.toMono -import reactor.util.function.Tuple2 import retrofit2.HttpException import java.awt.Color import java.net.UnknownHostException @@ -65,31 +61,34 @@ class NightscoutCommand(category: Category) : DiscordCommand(category, null) { ) } - override fun execute(event: CommandEvent) { + override suspend fun executeSuspend(event: CommandEvent) { val args = event.args.trim() // grab the necessary data - val embed = if (args.isBlank()) { - getStoredData(event) - } else { - getUnstoredData(event) - }.flatMap { data -> - // send the message - event.channel.sendMessageEmbeds(data.t2) - .submitMono() - .doOnSuccess { addReactions(data.t1, it) } - }.subscribeOn(Schedulers.boundedElastic()) - - embed.subscribe({ - logger.debug("Sent Nightscout embed: $it") - }, { + try { + val userDTO = if (args.isBlank()) { + getStoredData(event) + } else { + getUnstoredData(event) + } + + val nsDTO = fetchNightscoutData(userDTO) + + val embed = buildNightscoutResponse(userDTO, nsDTO, event) + + val message = event.channel.sendMessageEmbeds(embed).await() + + addReactions(nsDTO, message) + + logger.debug("Sent Nightscout embed: {}", message) + } catch (e: Exception) { event.reply( - if (it is NightscoutFetchException) { - handleGrabError(it.originalException, event.author, it.userDTO) + if (e is NightscoutFetchException) { + handleGrabError(e.originalException, event.author, e.userDTO) } else { - handleError(it) + handleError(e) } ) - }) + } } /** @@ -98,9 +97,8 @@ class NightscoutCommand(category: Category) : DiscordCommand(category, null) { * @param event Command event which called this command * @return A nightscout DTO and an embed based on it */ - private fun getStoredData(event: CommandEvent): Mono> { + private suspend fun getStoredData(event: CommandEvent): NightscoutUserDTO { return getUserDto(event.author, event.member) - .flatMap { buildNightscoutResponse(it, event) } } /** @@ -109,7 +107,7 @@ class NightscoutCommand(category: Category) : DiscordCommand(category, null) { * @param event Command event which called this command * @return A nightscout DTO and an embed based on it */ - private fun getUnstoredData(event: CommandEvent): Mono> { + private suspend fun getUnstoredData(event: CommandEvent): NightscoutUserDTO { val args = event.args.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() val namedMembers = event.event.guild.members.filter { @@ -118,25 +116,22 @@ class NightscoutCommand(category: Category) : DiscordCommand(category, null) { } val mentionedMembers = event.event.message.mentions.members - val endpoint: Mono = when { + val dto = when { mentionedMembers.size > 1 -> - IllegalArgumentException("Too many mentioned users.").toMono() + throw IllegalArgumentException("Too many mentioned users.") event.event.message.mentions.mentionsEveryone() -> - IllegalArgumentException("Cannot handle mentioning everyone.").toMono() + throw IllegalArgumentException("Cannot handle mentioning everyone.") mentionedMembers.size == 1 -> { val member = mentionedMembers[0] val exception = IllegalArgumentException("User does not have a configured Nightscout URL.") + val dto = getUserDto(member.user, member, exception) + if (!dto.isNightscoutPublic(event.guild.id)) { + throw NightscoutPrivateException(member.effectiveName) + } - getUserDto(member.user, member, exception) - .handle { t, u: SynchronousSink -> - if (!t.isNightscoutPublic(event.guild.id)) { - u.error(NightscoutPrivateException(member.effectiveName)) - } else { - u.next(t) - } - } + dto } args.isNotEmpty() && args[0].matches("^https?://.*".toRegex()) -> { @@ -149,25 +144,24 @@ class NightscoutCommand(category: Category) : DiscordCommand(category, null) { // Try to get nightscout data from username/nickname, otherwise just try to get from hostname val member = namedMembers.getOrNull(0) val domain = "https://${args[0]}.herokuapp.com" - val fallbackDto = NightscoutUserDTO(url = domain).toMono() - - if (member == null) { - fallbackDto - } else { - getUserDto(member.user, member) - .switchIfEmpty { fallbackDto } - .handle { userDTO, sink: SynchronousSink -> - if (!userDTO.isNightscoutPublic(event.guild.id)) { - sink.error(NightscoutPrivateException(member.effectiveName)) - } else { - sink.next(userDTO) - } - } + val fallbackDto = NightscoutUserDTO(url = domain) + + if (member != null) { + try { + val dto = getUserDto(member.user, member) + if (!dto.isNightscoutPublic(event.guild.id)) { + throw NightscoutPrivateException(member.effectiveName) + } + return dto + } catch (_: UnconfiguredNightscoutException) { + } } + + fallbackDto } } - return endpoint.flatMap { buildNightscoutResponse(it, event) } + return dto } /** @@ -177,71 +171,72 @@ class NightscoutCommand(category: Category) : DiscordCommand(category, null) { * @param event Command event which called this command * @return NS user DTO for this domain. This will be a generic DTO if there was no data found. */ - private fun getDataFromDomain(domain: String, event: CommandEvent): Mono { - val userDtos = getUsersForDomain(domain) + private suspend fun getDataFromDomain(domain: String, event: CommandEvent): NightscoutUserDTO { // temporary userDTO for if this domain does not belong to anyone in the guild - val fallback = NightscoutUserDTO(url = domain).toMono() + val fallback = NightscoutUserDTO(url = domain) - return userDtos - .flatMap { userDTO -> - val member = event.guild.getMemberById(userDTO.userId) - val publicInThisGuild = userDTO.isNightscoutPublic(event.guild.id) + val userDTO = getUsersForDomain(domain).firstOrNull() ?: return fallback + // use generic DTO if the user is not in the guild which we are replying in. + // this is to prevent users in other guilds from being able to see whether a NS belongs to any diabot user + val member = event.guild.getMemberById(userDTO.userId) ?: return fallback + val publicInThisGuild = userDTO.isNightscoutPublic(event.guild.id) + val user: User = member.user - if (member == null) { - // use generic DTO if the user is not in the guild which we are replying in. - // this is to prevent users in other guilds from being able to see whether a NS belongs to any diabot user - return@flatMap fallback - } + if (!publicInThisGuild) { + throw NightscoutPrivateException(event.suspendNameOf(user)) + } - val user: User = member.user + return userDTO.copy(jdaUser = user, jdaMember = member) + } - if (!publicInThisGuild) { - return@flatMap NightscoutPrivateException(event.nameOf(user)) - .toMono() - } + /** + * Loads all the necessary data from a Nightscout instance into a [NightscoutDTO]. + * + * @param userDTO The Nightscout instance to retrieve data from + * @return [NightscoutDTO] containing the necessary data for rendering an embed + */ + private suspend fun fetchNightscoutData(userDTO: NightscoutUserDTO): NightscoutDTO { + val api = Nightscout(userDTO.apiEndpoint, userDTO.token) + try { + var dto = api.getSettings().awaitSingle() + dto = api.getRecentSgv(dto).awaitSingle() + dto = api.getPebble(dto).awaitSingle() + return dto + } catch (ex: Exception) { + when (ex) { + is HttpException, + is UnknownHostException, + is JsonProcessingException, + is NoNightscoutDataException -> + throw NightscoutFetchException(userDTO, ex) - userDTO.copy(jdaUser = user, jdaMember = member).toMono() - } - .singleOrEmpty() - .switchIfEmpty { fallback } + else -> throw ex + } + } finally { + api.close() + } } /** * Loads all the necessary data from a Nightscout instance and creates an embed of it. * - * @param userDTO Data necessary for loading/rendering + * @param userDTO User Nightscout settings + * @param nsDTO Fetched Nightscout data * @param event Command event which called this command * @return A nightscout DTO and an embed based on it */ - private fun buildNightscoutResponse(userDTO: NightscoutUserDTO, event: CommandEvent): Mono> { - val api = Nightscout(userDTO.apiEndpoint, userDTO.token) - return Mono.from( - api.getSettings() - .flatMap { api.getRecentSgv(it) } - .flatMap { api.getPebble(it) } - ).doFinally { - api.close() - }.onErrorMap({ error -> - error is HttpException || - error is UnknownHostException || - error is JsonProcessingException || - error is NoNightscoutDataException - }, { - NightscoutFetchException(userDTO, it) - }).zipWhen { nsDto -> - // attach a message embed to the NightscoutDTO - val channelType = event.channelType - var isShort = false.toMono() - if (channelType == ChannelType.TEXT) { - isShort = if (userDTO.displayOptions.contains("simple")) { - true.toMono() - } else { - ChannelDAO.instance.hasAttribute(event.channel.id, ChannelDTO.ChannelAttribute.NIGHTSCOUT_SHORT) - } + private suspend fun buildNightscoutResponse(userDTO: NightscoutUserDTO, nsDTO: NightscoutDTO, event: CommandEvent): MessageEmbed { + val isShort = if (event.channelType.isGuild) { + if (userDTO.displayOptions.contains("simple")) { + true + } else { + ChannelDAO.instance.hasAttribute(event.channel.id, ChannelDTO.ChannelAttribute.NIGHTSCOUT_SHORT).awaitSingle() } - - isShort.map { buildResponse(nsDto, userDTO, it).build() } + } else { + false } + + return buildResponse(nsDTO, userDTO, isShort).build() } /** @@ -347,9 +342,9 @@ class NightscoutCommand(category: Category) : DiscordCommand(category, null) { * @param dto The Nightscout DTO holding the glucose data. * @param response The message to react to. */ - private fun addReactions(dto: NightscoutDTO, response: Message) { + private suspend fun addReactions(dto: NightscoutDTO, response: Message) { BloodGlucoseConverter.getReactions(dto.getNewestEntry().glucose.mmol, dto.getNewestEntry().glucose.mgdl).forEach { - response.addReaction(Emoji.fromUnicode(it)).queue() + response.addReaction(Emoji.fromUnicode(it)).await() } } @@ -394,10 +389,11 @@ class NightscoutCommand(category: Category) : DiscordCommand(category, null) { * @param domain The domain to look up * @return The [NightscoutUserDTO]s which match the given domain */ - private fun getUsersForDomain(domain: String): Flux { + private fun getUsersForDomain(domain: String): Flow { return NightscoutDAO.instance.getUsersForURL(domain) // if there are no users then return an empty Flux .onErrorResume(NoSuchElementException::class) { Flux.empty() } + .asFlow() } /** @@ -408,21 +404,21 @@ class NightscoutCommand(category: Category) : DiscordCommand(category, null) { * @param throwable The exception to throw if the user has not configured their Nightscout * @return A [NightscoutUserDTO] instance belonging to the given user */ - private fun getUserDto( + private suspend fun getUserDto( user: User, member: Member? = null, throwable: Throwable = UnconfiguredNightscoutException() - ): Mono { - return NightscoutDAO.instance.getUser(user.id) - .onErrorMap(NoSuchElementException::class) { throwable } - .flatMap { - if (it.url != null) { - it.copy(jdaUser = user, jdaMember = member).toMono() - } else { - // throw an exception if the url is blank - throwable.toMono() - } - } + ): NightscoutUserDTO { + try { + val nsUser = NightscoutDAO.instance.getUser(user.id).awaitSingle() + if (nsUser.url != null) { + return nsUser.copy(jdaUser = user, jdaMember = member) + } + } catch (_: NoSuchElementException) { + } + + // throw an exception if the url is blank or no user was found + throw throwable } companion object { diff --git a/bot/src/main/java/com/dongtronic/diabot/platforms/discord/utils/NicknameUtils.kt b/bot/src/main/java/com/dongtronic/diabot/platforms/discord/utils/NicknameUtils.kt index 4eeeb0cd..a0d85989 100644 --- a/bot/src/main/java/com/dongtronic/diabot/platforms/discord/utils/NicknameUtils.kt +++ b/bot/src/main/java/com/dongtronic/diabot/platforms/discord/utils/NicknameUtils.kt @@ -2,6 +2,7 @@ package com.dongtronic.diabot.platforms.discord.utils import com.dongtronic.diabot.submitMono import com.jagrosh.jdautilities.command.CommandEvent +import dev.minn.jda.ktx.coroutines.await import net.dv8tion.jda.api.entities.User import net.dv8tion.jda.api.entities.channel.ChannelType import reactor.core.publisher.Mono @@ -24,4 +25,15 @@ object NicknameUtils { fallback } } + + suspend fun suspendDetermineDisplayName(event: CommandEvent, user: User): String { + val fallback = user.name + + return try { + val member = event.guild.retrieveMember(user).await() + member.effectiveName + } catch (_: Exception) { + fallback + } + } }