Skip to content

Commit

Permalink
Merge pull request #202 from discord-diabetes/feat/nightscout-coroutines
Browse files Browse the repository at this point in the history
  • Loading branch information
cascer1 authored Nov 2, 2023
2 parents 36728f6 + 40394dc commit 613ec5b
Show file tree
Hide file tree
Showing 3 changed files with 141 additions and 122 deletions.
11 changes: 11 additions & 0 deletions bot/src/main/java/com/dongtronic/diabot/Extensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T> RestAction<T>.submitMono(): Mono<T> {
return submit().toMono()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
)
})
}
}

/**
Expand All @@ -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<Tuple2<NightscoutDTO, MessageEmbed>> {
private suspend fun getStoredData(event: CommandEvent): NightscoutUserDTO {
return getUserDto(event.author, event.member)
.flatMap { buildNightscoutResponse(it, event) }
}

/**
Expand All @@ -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<Tuple2<NightscoutDTO, MessageEmbed>> {
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 {
Expand All @@ -118,25 +116,22 @@ class NightscoutCommand(category: Category) : DiscordCommand(category, null) {
}
val mentionedMembers = event.event.message.mentions.members

val endpoint: Mono<NightscoutUserDTO> = 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<NightscoutUserDTO> ->
if (!t.isNightscoutPublic(event.guild.id)) {
u.error(NightscoutPrivateException(member.effectiveName))
} else {
u.next(t)
}
}
dto
}

args.isNotEmpty() && args[0].matches("^https?://.*".toRegex()) -> {
Expand All @@ -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<NightscoutUserDTO> ->
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
}

/**
Expand All @@ -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<NightscoutUserDTO> {
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<NightscoutUserDTO>()
}
/**
* 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<Tuple2<NightscoutDTO, MessageEmbed>> {
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()
}

/**
Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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<NightscoutUserDTO> {
private fun getUsersForDomain(domain: String): Flow<NightscoutUserDTO> {
return NightscoutDAO.instance.getUsersForURL(domain)
// if there are no users then return an empty Flux
.onErrorResume(NoSuchElementException::class) { Flux.empty() }
.asFlow()
}

/**
Expand All @@ -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<NightscoutUserDTO> {
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 {
Expand Down
Loading

0 comments on commit 613ec5b

Please sign in to comment.