mirror of
https://github.com/DreamExposure/DisCal-Discord-Bot.git
synced 2026-05-07 17:59:52 -05:00
@@ -170,6 +170,7 @@ DisCal uses a simple-to-understand permission scheme for handling access to comm
|
||||
| `/settings role` | Sets the role required to use privileged commands | elevated |
|
||||
| `/settings announcement-style` | Changes the style announcements will be posted as | elevated |
|
||||
| `/settings pause-announcements` | Allows pausing and unpausing all announcements for the guild for a period of time | elevated |
|
||||
| `/settings show-rsvp-dropdown` | Allows showing/hiding of the "RSVP" dropdown on all announcements and event posts | elevated |
|
||||
| `/settings language` | Changes the language the bot will use in responses | elevated |
|
||||
| `/settings time-format` | Changes what format to display date/time when needed | elevated |
|
||||
| `/settings branding` | Toggles between DisCal branding or the guild's name/image where possible | elevated, patron-only |
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ buildscript {
|
||||
allprojects {
|
||||
//Project props
|
||||
group = "org.dreamexposure.discal"
|
||||
version = "4.2.8"
|
||||
version = "4.2.9"
|
||||
description = "DisCal"
|
||||
|
||||
//Plugins
|
||||
|
||||
+3
-4
@@ -9,9 +9,8 @@ import okhttp3.Response
|
||||
import org.dreamexposure.discal.core.config.Config
|
||||
import org.dreamexposure.discal.core.extensions.asSeconds
|
||||
import org.dreamexposure.discal.core.logger.LOGGER
|
||||
import org.dreamexposure.discal.core.`object`.network.discal.InstanceData
|
||||
import org.dreamexposure.discal.core.`object`.rest.HeartbeatRequest
|
||||
import org.dreamexposure.discal.core.`object`.rest.HeartbeatType
|
||||
import org.dreamexposure.discal.core.`object`.new.model.discal.HeartbeatV3RequestModel
|
||||
import org.dreamexposure.discal.core.`object`.new.model.discal.InstanceDataV3Model
|
||||
import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT
|
||||
import org.dreamexposure.discal.core.utils.GlobalVal.JSON
|
||||
import org.springframework.boot.ApplicationArguments
|
||||
@@ -38,7 +37,7 @@ class HeartbeatCronJob(
|
||||
|
||||
private fun heartbeat() = mono {
|
||||
try {
|
||||
val requestBody = HeartbeatRequest(HeartbeatType.CAM, instanceData = InstanceData())
|
||||
val requestBody = HeartbeatV3RequestModel(HeartbeatV3RequestModel.Type.CAM, instance = InstanceDataV3Model())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("$apiUrl/v3/status/heartbeat")
|
||||
|
||||
+12
-19
@@ -2,8 +2,6 @@ package org.dreamexposure.discal.client.business.cronjob
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import discord4j.core.GatewayDiscordClient
|
||||
import kotlinx.coroutines.reactor.awaitSingle
|
||||
import kotlinx.coroutines.reactor.mono
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
@@ -11,9 +9,8 @@ import okhttp3.Response
|
||||
import org.dreamexposure.discal.core.config.Config
|
||||
import org.dreamexposure.discal.core.extensions.asSeconds
|
||||
import org.dreamexposure.discal.core.logger.LOGGER
|
||||
import org.dreamexposure.discal.core.`object`.network.discal.BotInstanceData
|
||||
import org.dreamexposure.discal.core.`object`.rest.HeartbeatRequest
|
||||
import org.dreamexposure.discal.core.`object`.rest.HeartbeatType
|
||||
import org.dreamexposure.discal.core.`object`.new.model.discal.BotInstanceDataV3Model
|
||||
import org.dreamexposure.discal.core.`object`.new.model.discal.HeartbeatV3RequestModel
|
||||
import org.dreamexposure.discal.core.utils.GlobalVal
|
||||
import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT
|
||||
import org.springframework.boot.ApplicationArguments
|
||||
@@ -39,26 +36,22 @@ class HeartbeatCronJob(
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
private fun heartbeat() = mono {
|
||||
try {
|
||||
val data = BotInstanceData.load(discordClient).awaitSingle()
|
||||
private fun heartbeat(): Mono<Void> {
|
||||
return discordClient.guilds.count().map(Long::toInt).map { guildCount ->
|
||||
val requestBody = HeartbeatV3RequestModel(HeartbeatV3RequestModel.Type.BOT, bot = BotInstanceDataV3Model(guilds = guildCount))
|
||||
|
||||
val requestBody = HeartbeatRequest(HeartbeatType.BOT, botInstanceData = data)
|
||||
val request = Request.Builder()
|
||||
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(DEFAULT, "[Heartbeat] Failed to heartbeat", it) }
|
||||
.onErrorResume { Mono.empty() }
|
||||
.subscribe()
|
||||
} catch (ex: Exception) {
|
||||
LOGGER.error(DEFAULT, "[Heartbeat] Failed to heartbeat", ex)
|
||||
}
|
||||
.flatMap { Mono.fromCallable(httpClient.newCall(it)::execute) }
|
||||
.map(Response::close)
|
||||
.then()
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.doOnError { LOGGER.error(DEFAULT, "[Heartbeat] Failed to heartbeat", it) }
|
||||
.onErrorResume { Mono.empty() }
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -329,6 +329,7 @@ class CalendarCommand(
|
||||
)
|
||||
)
|
||||
calendarService.cancelCalendarWizard(settings.guildId, calendar.metadata.number)
|
||||
calendarService.cancelCalendarWizard(existingWizard.guildId, event.interaction.user.id)
|
||||
|
||||
val message = if (existingWizard.editing) getMessage("confirm.success.edit", settings)
|
||||
else getMessage("confirm.success.create", settings)
|
||||
|
||||
@@ -621,6 +621,8 @@ class EventCommand(
|
||||
getMessage("confirm.success.edit", settings)
|
||||
else getMessage("confirm.success.create", settings)
|
||||
|
||||
calendarService.cancelEventWizard(existingWizard.guildId, event.interaction.user.id)
|
||||
|
||||
// Basically, since the first followup is just editing the original, what if I delete the original defer message and then create a non-ephemeral followup???
|
||||
event.interactionResponse.deleteInitialResponse().awaitSingleOrNull()
|
||||
|
||||
|
||||
+10
-10
@@ -75,7 +75,7 @@ class RsvpCommand(
|
||||
|
||||
event.createFollowup(getMessage("onTime.success", settings))
|
||||
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings, alwaysShow = true))
|
||||
.withEphemeral(ephemeral)
|
||||
.awaitSingle()
|
||||
} else {
|
||||
@@ -83,7 +83,7 @@ class RsvpCommand(
|
||||
|
||||
event.createFollowup(getMessage("onTime.failure.limit", settings))
|
||||
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings, alwaysShow = true))
|
||||
.withEphemeral(ephemeral)
|
||||
.awaitSingle()
|
||||
}
|
||||
@@ -125,7 +125,7 @@ class RsvpCommand(
|
||||
|
||||
event.createFollowup(getMessage("late.success", settings))
|
||||
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings, alwaysShow = true))
|
||||
.withEphemeral(ephemeral)
|
||||
.awaitSingle()
|
||||
} else {
|
||||
@@ -133,7 +133,7 @@ class RsvpCommand(
|
||||
|
||||
event.createFollowup(getMessage("late.failure.limit", settings))
|
||||
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings, alwaysShow = true))
|
||||
.withEphemeral(ephemeral)
|
||||
.awaitSingle()
|
||||
}
|
||||
@@ -174,7 +174,7 @@ class RsvpCommand(
|
||||
|
||||
return event.createFollowup(getMessage("unsure.success", settings))
|
||||
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings, alwaysShow = true))
|
||||
.withEphemeral(ephemeral)
|
||||
.awaitSingle()
|
||||
}
|
||||
@@ -214,7 +214,7 @@ class RsvpCommand(
|
||||
|
||||
return event.createFollowup(getMessage("notGoing.success", settings))
|
||||
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings, alwaysShow = true))
|
||||
.withEphemeral(ephemeral)
|
||||
.awaitSingle()
|
||||
}
|
||||
@@ -254,7 +254,7 @@ class RsvpCommand(
|
||||
|
||||
return event.createFollowup(getMessage("remove.success", settings))
|
||||
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings, alwaysShow = true))
|
||||
.withEphemeral(ephemeral)
|
||||
.awaitSingle()
|
||||
}
|
||||
@@ -287,7 +287,7 @@ class RsvpCommand(
|
||||
|
||||
return event.createFollowup()
|
||||
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings, alwaysShow = true))
|
||||
.withEphemeral(ephemeral)
|
||||
.awaitSingle()
|
||||
}
|
||||
@@ -341,7 +341,7 @@ class RsvpCommand(
|
||||
|
||||
return event.createFollowup(getMessage("limit.success", settings, limit.toString()))
|
||||
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings, alwaysShow = true))
|
||||
.withEphemeral(ephemeral)
|
||||
.awaitSingle()
|
||||
}
|
||||
@@ -399,7 +399,7 @@ class RsvpCommand(
|
||||
|
||||
return event.createFollowup(message)
|
||||
.withEmbeds(embed)
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings, alwaysShow = true))
|
||||
.withEphemeral(ephemeral)
|
||||
.awaitSingle()
|
||||
}
|
||||
|
||||
+15
@@ -43,6 +43,7 @@ class SettingsCommand(
|
||||
"language" -> language(event, settings)
|
||||
"time-format" -> timeFormat(event, settings)
|
||||
"keep-event-duration" -> eventKeepDuration(event, settings)
|
||||
"show-rsvp-dropdown" -> showRsvpDropdown(event, settings)
|
||||
"branding" -> branding(event, settings)
|
||||
"pause-announcements" -> pauseAnnouncements(event, settings)
|
||||
else -> throw IllegalStateException("Invalid subcommand specified")
|
||||
@@ -131,6 +132,20 @@ class SettingsCommand(
|
||||
.awaitSingle()
|
||||
}
|
||||
|
||||
private suspend fun showRsvpDropdown(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
|
||||
val shown = event.options[0].getOption("shown")
|
||||
.flatMap(ApplicationCommandInteractionOption::getValue)
|
||||
.map(ApplicationCommandInteractionOptionValue::asBoolean)
|
||||
.get()
|
||||
|
||||
val newSettings = settingsService.upsertSettings(settings.copy(showRsvpDropdown = shown))
|
||||
|
||||
return event.createFollowup(getMessage("showRsvpDropdown.success.$shown", settings))
|
||||
.withEmbeds(embedService.settingsEmbeds(newSettings))
|
||||
.withEphemeral(ephemeral)
|
||||
.awaitSingle()
|
||||
}
|
||||
|
||||
private suspend fun branding(event: ChatInputInteractionEvent, settings: GuildSettings): Message {
|
||||
val useBranding = event.options[0].getOption("use")
|
||||
.flatMap(ApplicationCommandInteractionOption::getValue)
|
||||
|
||||
@@ -87,7 +87,7 @@ class RsvpDropdown(
|
||||
|
||||
event.createFollowup(message)
|
||||
.withEmbeds(embedService.rsvpListEmbed(calendarEvent, rsvp, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings))
|
||||
.withComponents(*componentService.getEventRsvpComponents(calendarEvent, settings, alwaysShow = true))
|
||||
.withEphemeral(ephemeral)
|
||||
.awaitSingle()
|
||||
}
|
||||
|
||||
+3
@@ -73,6 +73,7 @@ class WizardConfirmButton(
|
||||
)
|
||||
)
|
||||
calendarService.cancelCalendarWizard(settings.guildId, calendar.metadata.number)
|
||||
calendarService.cancelCalendarWizard(settings.guildId, event.interaction.user.id)
|
||||
|
||||
val message = if (existingWizard.editing) getCmdMessage("calendar", "confirm.success.edit", settings.locale)
|
||||
else getCmdMessage("calendar", "confirm.success.create", settings.locale)
|
||||
@@ -177,6 +178,8 @@ class WizardConfirmButton(
|
||||
getCmdMessage("event", "confirm.success.edit", settings.locale)
|
||||
else getCmdMessage("event", "confirm.success.create", settings.locale)
|
||||
|
||||
calendarService.cancelEventWizard(existingWizard.guildId, event.interaction.user.id)
|
||||
|
||||
// Basically, since the first followup is just editing the original, what if I delete the original defer message and then create a non-ephemeral followup???
|
||||
event.interactionResponse.deleteInitialResponse().awaitSingleOrNull()
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ interface CalendarProvider {
|
||||
/////////
|
||||
suspend fun getEvent(calendar: Calendar, id: String): Event?
|
||||
|
||||
suspend fun getUpcomingEvents(calendar: Calendar, amount: Int): List<Event>
|
||||
suspend fun getUpcomingEvents(calendar: Calendar, amount: Int, maxDays: Int? = null): List<Event>
|
||||
|
||||
suspend fun getOngoingEvents(calendar: Calendar): List<Event>
|
||||
|
||||
|
||||
@@ -225,12 +225,12 @@ class CalendarService(
|
||||
return event
|
||||
}
|
||||
|
||||
suspend fun getUpcomingEvents(guildId: Snowflake, calendarNumber: Int, amount: Int): List<Event> {
|
||||
suspend fun getUpcomingEvents(guildId: Snowflake, calendarNumber: Int, amount: Int, maxDays: Int? = null): List<Event> {
|
||||
val calendar = getCalendar(guildId, calendarNumber) ?: return emptyList()
|
||||
|
||||
val events = calendarProviders
|
||||
.first { it.host == calendar.metadata.host }
|
||||
.getUpcomingEvents(calendar, amount)
|
||||
.getUpcomingEvents(calendar, amount, maxDays)
|
||||
events.forEach { event -> eventCache.put(guildId, event.id, event) }
|
||||
|
||||
return events
|
||||
|
||||
@@ -22,7 +22,9 @@ class ComponentService {
|
||||
return arrayOf(ActionRow.of(refreshButton))
|
||||
}
|
||||
|
||||
fun getEventRsvpComponents(event: Event, settings: GuildSettings): Array<LayoutComponent> {
|
||||
fun getEventRsvpComponents(event: Event, settings: GuildSettings, alwaysShow: Boolean = false): Array<LayoutComponent> {
|
||||
if (!alwaysShow && !settings.showRsvpDropdown) return emptyArray() // This way we don't need the message UI code to get cluttered
|
||||
|
||||
val goingOnTime = SelectMenu.Option.of(getCommonMsg("dropdown.rsvp.option.on-time.label", settings.locale), "rsvp_on_time")
|
||||
.withEmoji(Emoji.custom(Snowflake.of(1390911034708988025), "rsvp_on_time_ts", false))
|
||||
|
||||
|
||||
@@ -101,7 +101,8 @@ class EmbedService(
|
||||
.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)
|
||||
.addField(getEmbedMessage("settings", "view.field.pauseAnnouncements", settings.locale), announcementsPausedUntil, false)
|
||||
.addField(getEmbedMessage("settings", "view.field.pauseAnnouncements", settings.locale), announcementsPausedUntil, true)
|
||||
.addField(getEmbedMessage("settings", "view.field.showRsvpDropdown", settings.locale), "${settings.showRsvpDropdown}", true)
|
||||
.footer(getEmbedMessage("settings", "view.footer", settings.locale), null)
|
||||
.build()
|
||||
}
|
||||
@@ -559,6 +560,7 @@ class EmbedService(
|
||||
FULL -> fullAnnouncementEmbed(announcement, event, settings)
|
||||
SIMPLE -> simpleAnnouncementEmbed(announcement, event, settings)
|
||||
EVENT -> eventAnnouncementEmbed(announcement, event, settings)
|
||||
MINIMAL -> minimalAnnouncementEmbed(announcement, event, settings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,6 +720,44 @@ class EmbedService(
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
suspend fun minimalAnnouncementEmbed(announcement: Announcement, event: Event, settings: GuildSettings): EmbedCreateSpec {
|
||||
val builder = defaultEmbedBuilder(settings)
|
||||
.color(event.color.asColor())
|
||||
.author(getEmbedMessage("announcement", "minimal.author.name", settings.locale), Config.URL_BASE.getString(), GlobalVal.iconUrl)
|
||||
|
||||
|
||||
if (event.name.isNotBlank()) builder.title(event.name.embedTitleSafe())
|
||||
if (event.description.isNotBlank()) builder.description(event.description.embedDescriptionSafe())
|
||||
|
||||
builder.addField(
|
||||
getEmbedMessage("announcement", "minimal.field.start", settings.locale),
|
||||
event.start.asDiscordTimestamp(LONG_DATETIME),
|
||||
true
|
||||
)
|
||||
builder.addField(
|
||||
getEmbedMessage("announcement", "minimal.field.end", settings.locale),
|
||||
event.end.asDiscordTimestamp(LONG_DATETIME),
|
||||
true
|
||||
)
|
||||
|
||||
if (event.location.isNotBlank()) builder.addField(
|
||||
getEmbedMessage("announcement", "minimal.field.location", settings.locale),
|
||||
event.location.toMarkdown().embedFieldSafe(),
|
||||
false
|
||||
)
|
||||
|
||||
if (!announcement.info.isNullOrBlank()) builder.addField(
|
||||
getEmbedMessage("announcement", "minimal.field.info", settings.locale),
|
||||
announcement.info.toMarkdown().embedFieldSafe(),
|
||||
false
|
||||
)
|
||||
|
||||
if (event.image.isNotBlank())
|
||||
builder.image(event.image)
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
suspend fun viewAnnouncementEmbed(announcement: Announcement, settings: GuildSettings): EmbedCreateSpec {
|
||||
val builder = defaultEmbedBuilder(settings)
|
||||
.title(getEmbedMessage("announcement", "view.title", settings.locale))
|
||||
|
||||
@@ -48,6 +48,7 @@ class GuildSettingsService(
|
||||
branded = settings.interfaceStyle.branded,
|
||||
announcementStyle = settings.interfaceStyle.announcementStyle.value,
|
||||
eventKeepDuration = settings.eventKeepDuration,
|
||||
showRsvpDropdown = settings.showRsvpDropdown,
|
||||
pauseAnnouncementsUntil = settings.pauseAnnouncementsUntil,
|
||||
)).map(::GuildSettings).awaitSingle()
|
||||
|
||||
@@ -70,6 +71,7 @@ class GuildSettingsService(
|
||||
branded = settings.interfaceStyle.branded,
|
||||
announcementStyle = settings.interfaceStyle.announcementStyle.value,
|
||||
eventKeepDuration = settings.eventKeepDuration,
|
||||
showRsvpDropdown = settings.showRsvpDropdown,
|
||||
pauseAnnouncementsUntil = settings.pauseAnnouncementsUntil,
|
||||
).awaitSingleOrNull()
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ class StaticMessageService(
|
||||
private val discordClient: DiscordClient
|
||||
get() = beanFactory.getBean()
|
||||
private val OVERVIEW_EVENT_COUNT = Config.CALENDAR_OVERVIEW_DEFAULT_EVENT_COUNT.getInt()
|
||||
private val MAX_CUTOFF_DAYS = Config.CALENDAR_OVERVIEW_DEFAULT_CUTOFF_DAYS.getInt()
|
||||
|
||||
suspend fun getStaticMessageCount() = staticMessageRepository.count().awaitSingle()
|
||||
|
||||
@@ -127,7 +128,7 @@ class StaticMessageService(
|
||||
}
|
||||
|
||||
val calendar = calendarService.getCalendar(guildId, old.calendarNumber) ?: throw NotFoundException("Calendar not found")
|
||||
val events = calendarService.getUpcomingEvents(guildId, old.calendarNumber, OVERVIEW_EVENT_COUNT)
|
||||
val events = calendarService.getUpcomingEvents(guildId, old.calendarNumber, OVERVIEW_EVENT_COUNT, MAX_CUTOFF_DAYS)
|
||||
|
||||
// Finally update the message
|
||||
val embed = embedService.calendarOverviewEmbed(calendar, events, showUpdate = true)
|
||||
|
||||
+4
-1
@@ -143,7 +143,10 @@ class GoogleCalendarProviderService(
|
||||
return mapGoogleEventToDisCalEvent(calendar, baseEvent, metadata)
|
||||
}
|
||||
|
||||
override suspend fun getUpcomingEvents(calendar: Calendar, amount: Int): List<Event> {
|
||||
override suspend fun getUpcomingEvents(calendar: Calendar, amount: Int, maxDays: Int?): List<Event> {
|
||||
if (maxDays == null) googleCalendarApiWrapper.getEvents(calendar.metadata, amount, Instant.now())
|
||||
else googleCalendarApiWrapper.getEvents(calendar.metadata, amount, Instant.now(), Instant.now().plus(maxDays.toLong(), ChronoUnit.DAYS))
|
||||
|
||||
val response = googleCalendarApiWrapper.getEvents(calendar.metadata, amount, Instant.now())
|
||||
if (response.entity == null) return emptyList()
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
@@ -24,7 +23,6 @@ class BeanConfig {
|
||||
.registerKotlinModule()
|
||||
.registerModule(JavaTimeModule())
|
||||
.registerModule(SnowflakeMapper())
|
||||
.registerModule(DurationMapper())
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -23,6 +23,7 @@ class CacheConfig {
|
||||
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()
|
||||
private val networkStatusTtl = Config.CACHE_TTL_NETWORK_STATUS_MINUTES.getLong().asMinutes()
|
||||
|
||||
|
||||
// Redis caching
|
||||
@@ -98,6 +99,12 @@ class CacheConfig {
|
||||
fun eventRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): EventCache =
|
||||
RedisStringCacheRepository(objectMapper, redisTemplate, "Events", eventTtl)
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
@ConditionalOnProperty("bot.cache.redis", havingValue = "true")
|
||||
fun networkStatusRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): NetworkStatusCache =
|
||||
RedisStringCacheRepository(objectMapper, redisTemplate, "NetworkStatus", networkStatusTtl)
|
||||
|
||||
|
||||
// In-memory fallback caching
|
||||
@Bean
|
||||
@@ -138,4 +145,7 @@ class CacheConfig {
|
||||
|
||||
@Bean
|
||||
fun eventFallbackCache(): EventCache = JdkCacheRepository(eventTtl)
|
||||
|
||||
@Bean
|
||||
fun networkStatusFallbackCache(): NetworkStatusCache = JdkCacheRepository(networkStatusTtl)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ enum class Config(private val key: String, private var value: Any? = null) {
|
||||
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),
|
||||
CACHE_TTL_NETWORK_STATUS_MINUTES("bot.cache.ttl-minutes.network-status", 30),
|
||||
|
||||
// Security configuration
|
||||
|
||||
@@ -69,6 +70,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),
|
||||
CALENDAR_OVERVIEW_DEFAULT_CUTOFF_DAYS("bot.ui.embed.calendar.overview.cutoff-days", 365),
|
||||
|
||||
// Everything else
|
||||
SHARD_COUNT("bot.sharding.count"),
|
||||
|
||||
@@ -16,5 +16,6 @@ data class GuildSettingsData(
|
||||
val branded: Boolean,
|
||||
val announcementStyle: Int,
|
||||
val eventKeepDuration: Boolean,
|
||||
val showRsvpDropdown: Boolean,
|
||||
val pauseAnnouncementsUntil: Instant?,
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ interface GuildSettingsRepository : R2dbcRepository<GuildSettingsData, Long> {
|
||||
branded = :branded,
|
||||
announcement_style = :announcementStyle,
|
||||
event_keep_duration = :eventKeepDuration,
|
||||
show_rsvp_dropdown = :showRsvpDropdown,
|
||||
pause_announcements_until = :pauseAnnouncementsUntil
|
||||
WHERE guild_id = :guildId
|
||||
""")
|
||||
@@ -37,6 +38,7 @@ interface GuildSettingsRepository : R2dbcRepository<GuildSettingsData, Long> {
|
||||
branded: Boolean,
|
||||
announcementStyle: Int,
|
||||
eventKeepDuration: Boolean,
|
||||
showRsvpDropdown: Boolean,
|
||||
pauseAnnouncementsUntil: Instant?,
|
||||
): Mono<Int>
|
||||
}
|
||||
|
||||
+2
-1
@@ -7,9 +7,10 @@ import kotlinx.serialization.Serializable
|
||||
import org.dreamexposure.discal.Application
|
||||
import reactor.core.publisher.Mono
|
||||
|
||||
@Deprecated("Prefer to use new v3 impl")
|
||||
@Suppress("DataClassPrivateConstructor")
|
||||
@Serializable
|
||||
data class BotInstanceData private constructor(
|
||||
data class BotInstanceData constructor(
|
||||
@SerialName("instance")
|
||||
@JsonProperty("instance")
|
||||
val instanceData: InstanceData,
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Deprecated("Prefer to use new v3 impl")
|
||||
@Serializable
|
||||
data class InstanceData(
|
||||
@SerialName("instance_id")
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Serializable
|
||||
@Deprecated("Prefer to use new v3 implementation, this exists so I don't have to mess with the web module too much")
|
||||
data class NetworkData(
|
||||
@SerialName("total_calendars")
|
||||
var totalCalendars: Int = 0,
|
||||
|
||||
@@ -17,6 +17,7 @@ data class GuildSettings(
|
||||
val maxCalendars: Int = 1,
|
||||
val locale: Locale = Locale.ENGLISH,
|
||||
val eventKeepDuration: Boolean = false,
|
||||
val showRsvpDropdown: Boolean = true,
|
||||
val pauseAnnouncementsUntil: Instant? = null,
|
||||
|
||||
val interfaceStyle: InterfaceStyle = InterfaceStyle()
|
||||
@@ -30,6 +31,7 @@ data class GuildSettings(
|
||||
maxCalendars = data.maxCalendars,
|
||||
locale = data.lang.asLocale(),
|
||||
eventKeepDuration = data.eventKeepDuration,
|
||||
showRsvpDropdown = data.showRsvpDropdown,
|
||||
pauseAnnouncementsUntil = data.pauseAnnouncementsUntil,
|
||||
|
||||
interfaceStyle = InterfaceStyle(
|
||||
@@ -53,5 +55,6 @@ data class GuildSettings(
|
||||
FULL(1),
|
||||
SIMPLE(2),
|
||||
EVENT(3),
|
||||
MINIMAL(4),
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package org.dreamexposure.discal.core.`object`.new.model.discal
|
||||
|
||||
import org.dreamexposure.discal.Application
|
||||
|
||||
data class BotInstanceDataV3Model(
|
||||
val instanceData: InstanceDataV3Model = InstanceDataV3Model(),
|
||||
val shardIndex: Int = Application.Companion.getShardIndex(),
|
||||
val shardCount: Int = Application.Companion.getShardCount(),
|
||||
val guilds: Int,
|
||||
)
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package org.dreamexposure.discal.core.`object`.new.model.discal
|
||||
|
||||
data class HeartbeatV3RequestModel(
|
||||
val type: Type,
|
||||
val instance: InstanceDataV3Model? = null,
|
||||
val bot: BotInstanceDataV3Model? = null,
|
||||
) {
|
||||
enum class Type {
|
||||
BOT, WEBSITE, CAM,
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package org.dreamexposure.discal.core.`object`.new.model.discal
|
||||
|
||||
import org.dreamexposure.discal.Application
|
||||
import org.dreamexposure.discal.GitProperty
|
||||
import org.dreamexposure.discal.core.extensions.getHumanReadable
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
data class InstanceDataV3Model(
|
||||
val instanceId: String = Application.instanceId.toString(),
|
||||
val version: String = GitProperty.DISCAL_VERSION.value,
|
||||
val d4jVersion: String = GitProperty.DISCAL_VERSION_D4J.value,
|
||||
val uptime: Duration = Application.getUptime(),
|
||||
// TODO: This really should just be instant, but my custom jackson mapper doesn't seem to be working
|
||||
val lastHeartbeat: String = Instant.now().toString(),
|
||||
val memory: Double = Application.getMemoryUsedInMb(),
|
||||
) {
|
||||
val humanUptime: String
|
||||
get() = uptime.getHumanReadable()
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package org.dreamexposure.discal.core.`object`.new.model.discal
|
||||
|
||||
data class NetworkDataV3Model(
|
||||
val totalCalendars: Int = 0,
|
||||
val totalAnnouncements: Int = 0,
|
||||
|
||||
val apiStatus: List<InstanceDataV3Model> = emptyList(),
|
||||
val camStatus: List<InstanceDataV3Model> = emptyList(),
|
||||
val websiteStatus: InstanceDataV3Model? = null,
|
||||
val botStatus: List<BotInstanceDataV3Model> = emptyList(),
|
||||
) {
|
||||
val expectedShardCount: Int
|
||||
get() = botStatus.getOrNull(0)?.shardCount ?: 0
|
||||
val totalGuildsCount: Int
|
||||
get() = botStatus.sumOf(BotInstanceDataV3Model::guilds)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package org.dreamexposure.discal.core.`object`.rest
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.dreamexposure.discal.core.`object`.network.discal.BotInstanceData
|
||||
import org.dreamexposure.discal.core.`object`.network.discal.InstanceData
|
||||
|
||||
@Serializable
|
||||
data class HeartbeatRequest(
|
||||
val type: HeartbeatType,
|
||||
|
||||
@JsonProperty("instance")
|
||||
@SerialName("instance")
|
||||
val instanceData: InstanceData? = null,
|
||||
|
||||
@JsonProperty("bot_instance")
|
||||
@SerialName("bot_instance")
|
||||
val botInstanceData: BotInstanceData? = null
|
||||
)
|
||||
|
||||
enum class HeartbeatType {
|
||||
BOT, WEBSITE, CAM,
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package org.dreamexposure.discal.core.serializers
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.databind.DeserializationContext
|
||||
import com.fasterxml.jackson.databind.SerializerProvider
|
||||
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer
|
||||
import java.time.Duration
|
||||
|
||||
class DurationMapper: SimpleModule() {
|
||||
init {
|
||||
addSerializer(DurationSerializer())
|
||||
addDeserializer(Duration::class.java, DurationDeserializer())
|
||||
}
|
||||
|
||||
class DurationSerializer : StdSerializer<Duration>(Duration::class.java) {
|
||||
override fun serialize(value: Duration?, gen: JsonGenerator?, provider: SerializerProvider?) {
|
||||
gen?.writeString(value?.toMillis().toString())
|
||||
}
|
||||
}
|
||||
|
||||
class DurationDeserializer: StdDeserializer<Duration>(Duration::class.java) {
|
||||
override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): Duration {
|
||||
val raw = p?.valueAsString
|
||||
return if (raw != null) Duration.ofMillis(raw.toLong()) else throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.dreamexposure.discal
|
||||
import discord4j.common.util.Snowflake
|
||||
import org.dreamexposure.discal.core.cache.CacheRepository
|
||||
import org.dreamexposure.discal.core.`object`.new.*
|
||||
import org.dreamexposure.discal.core.`object`.new.model.discal.NetworkDataV3Model
|
||||
import org.dreamexposure.discal.core.`object`.new.model.discal.cam.TokenV1Model
|
||||
|
||||
// Cache
|
||||
@@ -19,3 +20,4 @@ typealias EventWizardStateCache = CacheRepository<Snowflake, EventWizardState>
|
||||
typealias CalendarWizardStateCache = CacheRepository<Snowflake, CalendarWizardState>
|
||||
typealias CalendarTokenCache = CacheRepository<Int, TokenV1Model>
|
||||
typealias EventCache = CacheRepository<String, Event>
|
||||
typealias NetworkStatusCache = CacheRepository<Int, NetworkDataV3Model>
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
{
|
||||
"name": "Event Only",
|
||||
"value": 3
|
||||
},
|
||||
{
|
||||
"name": "Minimal",
|
||||
"value": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -107,6 +111,19 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "show-rsvp-dropdown",
|
||||
"type": 1,
|
||||
"description": "Toggles whether a dropdown to RSVP to events is shown on all announcement and event posts",
|
||||
"options": [
|
||||
{
|
||||
"name": "shown",
|
||||
"type": 5,
|
||||
"description": "Whether or not to show the RSVP dropdown",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "branding",
|
||||
"type": 1,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE guild_settings
|
||||
ADD COLUMN show_rsvp_dropdown BIT NOT NULL DEFAULT 1
|
||||
AFTER event_keep_duration;
|
||||
@@ -15,7 +15,9 @@ brand.success=Server branding has been set to `{0}`.
|
||||
eventKeepDuration.success.true=Events will keep their duration by default when adjusting the start or end of the event.\n\
|
||||
This can be overridden when changing an event's start/end.
|
||||
eventKeepDuration.success.false=Events will *not* keep their duration by default when adjusting the start or end of the event.\n\
|
||||
This can be overridden when changing an event's start/
|
||||
This can be overridden when changing an event's start/end.
|
||||
showRsvpDropdown.success.true=A dropdown to RSVP to events will now show on all event, announcement, and rsvp posts.
|
||||
showRsvpDropdown.success.false=A dropdown to RSVP to events will no longer be shown on all event and announcement posts.
|
||||
|
||||
pauseAnnouncements.success.pause=Successfully paused announcements until {0}.
|
||||
pauseAnnouncements.success.unpause=Successfully unpaused announcements.
|
||||
|
||||
@@ -29,6 +29,12 @@ event.field.event=Event ID
|
||||
event.field.info=Extra Info
|
||||
event.footer=Announcement ID: {0}
|
||||
|
||||
minimal.author.name=DisCal - Event Announcement
|
||||
minimal.field.start=Start
|
||||
minimal.field.end=End
|
||||
minimal.field.location=Location
|
||||
minimal.field.info=Extra Info
|
||||
|
||||
con.title=Condensed Announcement Info
|
||||
con.field.id=Announcement ID
|
||||
con.field.time=Time Before
|
||||
|
||||
@@ -9,4 +9,5 @@ view.field.dev=Dev Guild
|
||||
view.field.cal=Max Calendars
|
||||
view.field.brand=Using Branding
|
||||
view.field.pauseAnnouncements=Announcements Paused Until
|
||||
view.field.showRsvpDropdown=Show RSVP Dropdowns
|
||||
view.footer=Become a patron today and support DisCal! https://www.patreon.com/Novafox
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package org.dreamexposure.discal.server.business
|
||||
|
||||
import org.dreamexposure.discal.Application
|
||||
import org.dreamexposure.discal.NetworkStatusCache
|
||||
import org.dreamexposure.discal.core.business.AnnouncementService
|
||||
import org.dreamexposure.discal.core.business.CalendarService
|
||||
import org.dreamexposure.discal.core.logger.LOGGER
|
||||
import org.dreamexposure.discal.core.`object`.new.model.discal.BotInstanceDataV3Model
|
||||
import org.dreamexposure.discal.core.`object`.new.model.discal.InstanceDataV3Model
|
||||
import org.dreamexposure.discal.core.`object`.new.model.discal.NetworkDataV3Model
|
||||
import org.dreamexposure.discal.core.utils.GlobalVal.STATUS
|
||||
import org.springframework.stereotype.Component
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
@Component
|
||||
class NetworkStatusService(
|
||||
private val calendarService: CalendarService,
|
||||
private val announcementService: AnnouncementService,
|
||||
private val networkStatusCache: NetworkStatusCache,
|
||||
) {
|
||||
suspend fun getNetworkStatus(): NetworkDataV3Model {
|
||||
var status = networkStatusCache.get(key = 0)
|
||||
if (status != null) return status
|
||||
|
||||
LOGGER.info(STATUS, "Cached network state not stored, treating as lost state")
|
||||
|
||||
status = NetworkDataV3Model(apiStatus = listOf(InstanceDataV3Model()))
|
||||
networkStatusCache.put(key = 0, value = status)
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
suspend fun updateNetworkStatus(status: NetworkDataV3Model) {
|
||||
networkStatusCache.put(key = 0, value = status)
|
||||
}
|
||||
|
||||
suspend fun updateAndReturnStatus(): NetworkDataV3Model {
|
||||
// Get the things we need
|
||||
val status = getNetworkStatus()
|
||||
val thisInstance = status.apiStatus
|
||||
.find { it.instanceId == Application.instanceId.toString() }
|
||||
?: InstanceDataV3Model()
|
||||
val totalCalendars = calendarService.getCalendarCount().toInt()
|
||||
val totalAnnouncements = announcementService.getAnnouncementCount().toInt()
|
||||
|
||||
// Update that shit
|
||||
val updatedStatus = status.copy(
|
||||
apiStatus = status.apiStatus
|
||||
.filter { it.instanceId != thisInstance.instanceId } // Remove this instance's old data
|
||||
.plus(thisInstance.copy(lastHeartbeat = Instant.now().toString(), uptime = Application.getUptime())),
|
||||
totalCalendars = totalCalendars,
|
||||
totalAnnouncements = totalAnnouncements,
|
||||
)
|
||||
|
||||
updateNetworkStatus(updatedStatus)
|
||||
return updatedStatus
|
||||
}
|
||||
|
||||
suspend fun handleWebsiteHeartbeat(data: InstanceDataV3Model) {
|
||||
val networkStatus = getNetworkStatus()
|
||||
|
||||
if (networkStatus.websiteStatus == null)
|
||||
LOGGER.info(STATUS, "Website now connected")
|
||||
else if (networkStatus.websiteStatus!!.instanceId != data.instanceId)
|
||||
LOGGER.info(STATUS, "Website instance ID changed")
|
||||
|
||||
updateNetworkStatus(networkStatus.copy(websiteStatus = data))
|
||||
}
|
||||
|
||||
suspend fun handleCamHeartbeat(data: InstanceDataV3Model) {
|
||||
val networkStatus = getNetworkStatus()
|
||||
val existing = networkStatus.camStatus.find { it.instanceId == data.instanceId }
|
||||
|
||||
if (existing == null) LOGGER.info(STATUS, "CAM instance connected to network | Id: ${data.instanceId}")
|
||||
|
||||
updateNetworkStatus(networkStatus.copy(
|
||||
camStatus = networkStatus.camStatus
|
||||
.filter { it.instanceId != data.instanceId } // Remove updated instance
|
||||
.plus(data)
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun handleBotHeartbeat(data: BotInstanceDataV3Model) {
|
||||
val networkStatus = getNetworkStatus()
|
||||
val existing = networkStatus.botStatus.find { it.shardIndex == data.shardIndex }
|
||||
if (existing == null)
|
||||
LOGGER.info(STATUS, "Shard connected to network | Index ${data.shardIndex}")
|
||||
else if (existing.instanceData.instanceId != data.instanceData.instanceId)
|
||||
LOGGER.info(STATUS, "Shard instance ID changed | Index ${data.shardIndex}")
|
||||
|
||||
updateNetworkStatus(networkStatus.copy(
|
||||
botStatus = networkStatus.botStatus
|
||||
.filter { it.shardIndex != data.shardIndex } // Remove updated instance
|
||||
.plus(data)
|
||||
.sortedWith(Comparator.comparingInt(BotInstanceDataV3Model::shardIndex))
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun doNetworkStatusHealthCheck() {
|
||||
val status = updateAndReturnStatus()
|
||||
|
||||
// I should do something to attempt to restart these, but that's not something I plan to implement any time soon
|
||||
val oldApiInstances = status.apiStatus
|
||||
.filter { Instant.now().isAfter(Instant.parse(it.lastHeartbeat).plus(5, ChronoUnit.MINUTES)) }
|
||||
.onEach { LOGGER.warn(STATUS, "API instance disconnected from network | Id: ${it.instanceId}") }
|
||||
val oldCamInstances = status.camStatus
|
||||
.filter { Instant.now().isAfter(Instant.parse(it.lastHeartbeat).plus(5, ChronoUnit.MINUTES)) }
|
||||
.onEach { LOGGER.warn(STATUS, "Cam disconnected from network | Id: ${it.instanceId}") }
|
||||
val oldBotInstances = status.botStatus
|
||||
.filter { Instant.now().isAfter(Instant.parse(it.instanceData.lastHeartbeat).plus(5, ChronoUnit.MINUTES)) }
|
||||
.onEach { LOGGER.warn(STATUS, "Client disconnected from network | Index: ${it.shardIndex}") }
|
||||
|
||||
if (oldApiInstances.isNotEmpty() || oldCamInstances.isNotEmpty() || oldBotInstances.isNotEmpty()) {
|
||||
updateNetworkStatus(status.copy(
|
||||
apiStatus = status.apiStatus.minus(oldApiInstances),
|
||||
camStatus = status.camStatus.minus(oldCamInstances),
|
||||
botStatus = status.botStatus.minus(oldBotInstances)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package org.dreamexposure.discal.server.business.cronjob
|
||||
|
||||
import kotlinx.coroutines.reactor.mono
|
||||
import org.dreamexposure.discal.core.logger.LOGGER
|
||||
import org.dreamexposure.discal.core.utils.GlobalVal
|
||||
import org.dreamexposure.discal.server.business.NetworkStatusService
|
||||
import org.springframework.boot.ApplicationArguments
|
||||
import org.springframework.boot.ApplicationRunner
|
||||
import org.springframework.stereotype.Component
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Mono
|
||||
import java.time.Duration
|
||||
|
||||
@Component
|
||||
class NetworkStatusCronJob(
|
||||
private val networkStatusService: NetworkStatusService,
|
||||
): ApplicationRunner {
|
||||
|
||||
override fun run(args: ApplicationArguments?) {
|
||||
Flux.interval(Duration.ofMinutes(1))
|
||||
.flatMap { mono { networkStatusService.doNetworkStatusHealthCheck() } }
|
||||
.doOnError { LOGGER.error(GlobalVal.DEFAULT, "[NetworkStatus] Network status cronjob failure", it) }
|
||||
.onErrorResume { Mono.empty() }
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
+59
-5
@@ -1,11 +1,15 @@
|
||||
package org.dreamexposure.discal.server.endpoints.v2.status
|
||||
|
||||
import kotlinx.coroutines.reactor.mono
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.encodeToString
|
||||
import org.dreamexposure.discal.core.annotations.SecurityRequirement
|
||||
import org.dreamexposure.discal.core.logger.LOGGER
|
||||
import org.dreamexposure.discal.core.`object`.network.discal.BotInstanceData
|
||||
import org.dreamexposure.discal.core.`object`.network.discal.InstanceData
|
||||
import org.dreamexposure.discal.core.`object`.network.discal.NetworkData
|
||||
import org.dreamexposure.discal.core.utils.GlobalVal
|
||||
import org.dreamexposure.discal.server.network.discal.NetworkManager
|
||||
import org.dreamexposure.discal.server.business.NetworkStatusService
|
||||
import org.dreamexposure.discal.server.utils.Authentication
|
||||
import org.dreamexposure.discal.server.utils.responseMessage
|
||||
import org.springframework.http.server.reactive.ServerHttpResponse
|
||||
@@ -14,26 +18,76 @@ import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.server.ServerWebExchange
|
||||
import reactor.core.publisher.Mono
|
||||
import java.time.Instant
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v2/status")
|
||||
class GetStatusEndpoint(
|
||||
private val networkManager: NetworkManager,
|
||||
private val networkStatusService: NetworkStatusService,
|
||||
private val authentication: Authentication,
|
||||
) {
|
||||
|
||||
@PostMapping("/get", produces = ["application/json"])
|
||||
@SecurityRequirement(disableSecurity = true, scopes = [])
|
||||
fun getStatus(swe: ServerWebExchange, response: ServerHttpResponse): Mono<String> {
|
||||
return authentication.authenticate(swe).map { authState ->
|
||||
return authentication.authenticate(swe).flatMap { authState ->
|
||||
if (!authState.success) {
|
||||
response.rawStatusCode = authState.status
|
||||
return@map GlobalVal.JSON_FORMAT.encodeToString(authState)
|
||||
return@flatMap Mono.just(GlobalVal.JSON_FORMAT.encodeToString(authState))
|
||||
}
|
||||
|
||||
//Handle request
|
||||
response.rawStatusCode = GlobalVal.STATUS_SUCCESS
|
||||
return@map GlobalVal.JSON_FORMAT.encodeToString(networkManager.getStatus())
|
||||
|
||||
// Build the legacy object
|
||||
return@flatMap mono { networkStatusService.getNetworkStatus() }
|
||||
.map { data ->
|
||||
NetworkData(totalCalendars = data.totalCalendars,
|
||||
totalAnnouncements = data.totalAnnouncements,
|
||||
apiStatus = data.apiStatus.map {
|
||||
InstanceData(
|
||||
instanceId = it.instanceId,
|
||||
version = it.version,
|
||||
d4jVersion = it.d4jVersion,
|
||||
uptime = it.uptime,
|
||||
lastHeartbeat = Instant.parse(it.lastHeartbeat),
|
||||
memory = it.memory
|
||||
)
|
||||
}.first(),
|
||||
websiteStatus = if (data.websiteStatus == null) null else InstanceData(
|
||||
instanceId = data.websiteStatus!!.instanceId,
|
||||
version = data.websiteStatus!!.version,
|
||||
d4jVersion = data.websiteStatus!!.d4jVersion,
|
||||
uptime = data.websiteStatus!!.uptime,
|
||||
lastHeartbeat = Instant.parse(data.websiteStatus!!.lastHeartbeat),
|
||||
memory = data.websiteStatus!!.memory
|
||||
),
|
||||
camStatus = data.camStatus.map {
|
||||
InstanceData(
|
||||
instanceId = it.instanceId,
|
||||
version = it.version,
|
||||
d4jVersion = it.d4jVersion,
|
||||
uptime = it.uptime,
|
||||
lastHeartbeat = Instant.parse(it.lastHeartbeat),
|
||||
memory = it.memory
|
||||
)
|
||||
}.toMutableList(),
|
||||
botStatus = data.botStatus.map {
|
||||
BotInstanceData(
|
||||
instanceData = InstanceData(
|
||||
instanceId = it.instanceData.instanceId,
|
||||
version = it.instanceData.version,
|
||||
d4jVersion = it.instanceData.d4jVersion,
|
||||
uptime = it.instanceData.uptime,
|
||||
lastHeartbeat = Instant.parse(it.instanceData.lastHeartbeat),
|
||||
memory = it.instanceData.memory
|
||||
),
|
||||
shardIndex = it.shardIndex,
|
||||
shardCount = it.shardCount,
|
||||
guilds = it.guilds,
|
||||
)
|
||||
}.toMutableList())
|
||||
}.map { GlobalVal.JSON_FORMAT.encodeToString(it) }
|
||||
}.onErrorResume(SerializationException::class.java) {
|
||||
LOGGER.trace("[API-v2] JSON error. Bad request?", it)
|
||||
|
||||
|
||||
+12
-10
@@ -1,31 +1,33 @@
|
||||
package org.dreamexposure.discal.server.endpoints.v3
|
||||
|
||||
import org.dreamexposure.discal.core.annotations.SecurityRequirement
|
||||
import org.dreamexposure.discal.core.`object`.network.discal.NetworkData
|
||||
import org.dreamexposure.discal.core.`object`.new.model.discal.HeartbeatV3RequestModel
|
||||
import org.dreamexposure.discal.core.`object`.new.model.discal.NetworkDataV3Model
|
||||
import org.dreamexposure.discal.core.`object`.new.security.Scope.INTERNAL_HEARTBEAT
|
||||
import org.dreamexposure.discal.core.`object`.new.security.TokenType.INTERNAL
|
||||
import org.dreamexposure.discal.core.`object`.rest.GenericResponse
|
||||
import org.dreamexposure.discal.core.`object`.rest.HeartbeatRequest
|
||||
import org.dreamexposure.discal.core.`object`.rest.HeartbeatType
|
||||
import org.dreamexposure.discal.server.network.discal.NetworkManager
|
||||
import org.dreamexposure.discal.server.business.NetworkStatusService
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v3/status")
|
||||
class StatusController(val networkManager: NetworkManager) {
|
||||
class StatusController(
|
||||
private val networkStatusService: NetworkStatusService,
|
||||
) {
|
||||
|
||||
@SecurityRequirement(disableSecurity = true, scopes = [])
|
||||
@GetMapping(produces = ["application/json"])
|
||||
fun get(): NetworkData = networkManager.getStatus()
|
||||
suspend fun get(): NetworkDataV3Model = networkStatusService.getNetworkStatus()
|
||||
|
||||
@SecurityRequirement(schemas = [INTERNAL], scopes = [INTERNAL_HEARTBEAT])
|
||||
@PostMapping("/heartbeat", produces = ["application/json"])
|
||||
fun post(@RequestBody body: HeartbeatRequest): GenericResponse {
|
||||
suspend fun post(@RequestBody body: HeartbeatV3RequestModel): GenericResponse {
|
||||
when (body.type) {
|
||||
HeartbeatType.BOT -> networkManager.handleBot(body.botInstanceData!!)
|
||||
HeartbeatType.WEBSITE -> networkManager.handleWebsite(body.instanceData!!)
|
||||
HeartbeatType.CAM -> networkManager.handleCam(body.instanceData!!)
|
||||
HeartbeatV3RequestModel.Type.BOT -> networkStatusService.handleBotHeartbeat(body.bot!!)
|
||||
HeartbeatV3RequestModel.Type.WEBSITE -> networkStatusService.handleWebsiteHeartbeat(body.instance!!)
|
||||
HeartbeatV3RequestModel.Type.CAM -> networkStatusService.handleCamHeartbeat(body.instance!!)
|
||||
}
|
||||
|
||||
return GenericResponse("Success!")
|
||||
}
|
||||
}
|
||||
|
||||
+13
-9
@@ -1,12 +1,14 @@
|
||||
package org.dreamexposure.discal.server.network.dbotsgg
|
||||
|
||||
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
||||
import kotlinx.coroutines.reactor.mono
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.dreamexposure.discal.core.config.Config
|
||||
import org.dreamexposure.discal.core.logger.LOGGER
|
||||
import org.dreamexposure.discal.core.utils.GlobalVal
|
||||
import org.dreamexposure.discal.server.network.discal.NetworkManager
|
||||
import org.dreamexposure.discal.server.business.NetworkStatusService
|
||||
import org.json.JSONObject
|
||||
import org.springframework.boot.ApplicationArguments
|
||||
import org.springframework.boot.ApplicationRunner
|
||||
@@ -19,16 +21,18 @@ import java.time.Duration
|
||||
@Component
|
||||
@ConditionalOnProperty("bot.integrations.update-bot-sites", havingValue = "true")
|
||||
class UpdateDBotsData(
|
||||
private val networkManager: NetworkManager,
|
||||
private val networkStatusService: NetworkStatusService,
|
||||
private val httpClient: OkHttpClient
|
||||
) : ApplicationRunner {
|
||||
private val token = Config.SECRET_INTEGRATION_D_BOTS_GG_TOKEN.getString()
|
||||
|
||||
private fun update(): Mono<Void> {
|
||||
return Mono.fromCallable {
|
||||
private fun update() = mono {
|
||||
val status = networkStatusService.getNetworkStatus()
|
||||
|
||||
Mono.fromCallable {
|
||||
val json = JSONObject()
|
||||
.put("guildCount", networkManager.getStatus().totalGuilds)
|
||||
.put("shardCount", networkManager.getStatus().expectedShardCount)
|
||||
.put("guildCount", status.totalGuildsCount)
|
||||
.put("shardCount", status.expectedShardCount)
|
||||
|
||||
val body = json.toString().toRequestBody(GlobalVal.JSON)
|
||||
val request = Request.Builder()
|
||||
@@ -41,13 +45,13 @@ class UpdateDBotsData(
|
||||
httpClient.newCall(request).execute()
|
||||
}.doOnNext { response ->
|
||||
if (response.code != GlobalVal.STATUS_SUCCESS) {
|
||||
LOGGER.debug("Failed to update DBots.gg stats | Body: ${response.body?.string()}")
|
||||
response.body?.close()
|
||||
LOGGER.debug("Failed to update DBots.gg stats | Body: ${response.body.string()}")
|
||||
response.body.close()
|
||||
response.close()
|
||||
}
|
||||
}.onErrorResume {
|
||||
Mono.empty()
|
||||
}.then()
|
||||
}.awaitSingleOrNull()
|
||||
}
|
||||
|
||||
override fun run(args: ApplicationArguments?) {
|
||||
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
package org.dreamexposure.discal.server.network.discal
|
||||
|
||||
import kotlinx.coroutines.reactor.mono
|
||||
import org.dreamexposure.discal.Application
|
||||
import org.dreamexposure.discal.core.business.AnnouncementService
|
||||
import org.dreamexposure.discal.core.business.CalendarService
|
||||
import org.dreamexposure.discal.core.config.Config
|
||||
import org.dreamexposure.discal.core.logger.LOGGER
|
||||
import org.dreamexposure.discal.core.`object`.network.discal.BotInstanceData
|
||||
import org.dreamexposure.discal.core.`object`.network.discal.InstanceData
|
||||
import org.dreamexposure.discal.core.`object`.network.discal.NetworkData
|
||||
import org.dreamexposure.discal.core.utils.GlobalVal.STATUS
|
||||
import org.springframework.boot.ApplicationArguments
|
||||
import org.springframework.boot.ApplicationRunner
|
||||
import org.springframework.stereotype.Component
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Mono
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
// TODO: This whole class needs to be refactored at some point, its total spaghetti lmao
|
||||
@Component
|
||||
class NetworkManager(
|
||||
private val calendarService: CalendarService,
|
||||
private val announcementService: AnnouncementService,
|
||||
) : ApplicationRunner {
|
||||
private val status: NetworkData = NetworkData(apiStatus = InstanceData())
|
||||
|
||||
fun getStatus() = status.copy()
|
||||
|
||||
fun handleCam(data: InstanceData) {
|
||||
val existing = status.camStatus.find { it.instanceId == data.instanceId }
|
||||
if (existing == null)
|
||||
LOGGER.info(STATUS, "CAM instance connected to network | Id: ${data.instanceId}")
|
||||
|
||||
status.camStatus.remove(existing)
|
||||
status.camStatus.add(data)
|
||||
}
|
||||
|
||||
fun handleWebsite(data: InstanceData) {
|
||||
val existing = status.websiteStatus
|
||||
if (existing == null)
|
||||
LOGGER.info(STATUS, "Website now connected")
|
||||
else if (existing.instanceId != data.instanceId)
|
||||
LOGGER.info(STATUS, "Website instance ID changed")
|
||||
|
||||
status.websiteStatus = data
|
||||
}
|
||||
|
||||
fun handleBot(data: BotInstanceData) {
|
||||
val existing = status.botStatus.find { it.shardIndex == data.shardIndex }
|
||||
if (existing == null)
|
||||
LOGGER.info(STATUS, "Shard connected to network | Index ${data.shardIndex}")
|
||||
else if (existing.instanceData.instanceId != data.instanceData.instanceId)
|
||||
LOGGER.info(STATUS, "Shard instance ID changed | Index ${data.shardIndex}")
|
||||
|
||||
status.botStatus.remove(existing)
|
||||
status.botStatus.add(data)
|
||||
status.botStatus.sortWith(Comparator.comparingInt(BotInstanceData::shardIndex))
|
||||
}
|
||||
|
||||
private fun updateAndReturnStatus() = mono {
|
||||
status.totalCalendars = calendarService.getCalendarCount().toInt()
|
||||
status.totalAnnouncements = announcementService.getAnnouncementCount().toInt()
|
||||
status.apiStatus = status.apiStatus.copy(lastHeartbeat = Instant.now(), uptime = Application.getUptime())
|
||||
|
||||
status.copy()
|
||||
}
|
||||
|
||||
private fun doRestartBot(bot: BotInstanceData): Mono<Void> {
|
||||
//Gotta actually see if it needs to be restarted
|
||||
|
||||
|
||||
if (!Config.RESTART_SERVICE_ENABLED.getBoolean()) {
|
||||
status.botStatus.removeIf { it.shardIndex == bot.shardIndex }
|
||||
LOGGER.warn(STATUS, "Client disconnected from network | Index: ${bot.shardIndex} | Reason: Restart service not active!")
|
||||
} else {
|
||||
//TODO: Actually support restarting clients automatically one day
|
||||
}
|
||||
return Mono.empty()
|
||||
}
|
||||
|
||||
private fun doRestartCam(cam: InstanceData): Mono<Void> {
|
||||
//Gotta actually see if it needs to be restarted
|
||||
|
||||
|
||||
if (!Config.RESTART_SERVICE_ENABLED.getBoolean()) {
|
||||
status.camStatus.removeIf { it.instanceId == cam.instanceId }
|
||||
LOGGER.warn(STATUS, "Cam disconnected from network | Id: ${cam.instanceId} | Reason: Restart service not active!")
|
||||
} else {
|
||||
//TODO: Actually support restarting clients automatically one day
|
||||
}
|
||||
return Mono.empty()
|
||||
}
|
||||
|
||||
override fun run(args: ApplicationArguments?) {
|
||||
Flux.interval(Duration.ofMinutes(1))
|
||||
.flatMap { updateAndReturnStatus() } //Update local status every minute
|
||||
.flatMap {
|
||||
val bot = Flux.fromIterable(status.botStatus)
|
||||
.filter { Instant.now().isAfter(it.instanceData.lastHeartbeat.plus(5, ChronoUnit.MINUTES)) }
|
||||
.flatMap(this::doRestartBot)
|
||||
val cam = Flux.fromIterable(status.camStatus)
|
||||
.filter { Instant.now().isAfter(it.lastHeartbeat.plus(5, ChronoUnit.MINUTES)) }
|
||||
.flatMap(this::doRestartCam)
|
||||
|
||||
Mono.`when`(bot, cam)
|
||||
}.subscribe()
|
||||
}
|
||||
}
|
||||
@@ -951,6 +951,14 @@
|
||||
<td class="tb-flex-row tb-access" role="cell">elevated</td>
|
||||
</tr>
|
||||
|
||||
<!--Start show-rsvp-dropdown command-->
|
||||
<tr class="flex-table" role="rowgroup">
|
||||
<td class="tb-flex-row tb-command" role="cell">show-rsvp-dropdown</td>
|
||||
<td class="tb-flex-row" role="cell">Allows showing/hiding the "RSVP" dropdown on all event and announcement posts</td>
|
||||
<td class="tb-flex-row tb-usage" role="cell">/settings pause-announcements [true/false]</td>
|
||||
<td class="tb-flex-row tb-access" role="cell">elevated</td>
|
||||
</tr>
|
||||
|
||||
<!--Start language command-->
|
||||
<tr class="flex-table" role="rowgroup">
|
||||
<td class="tb-flex-row tb-command" role="cell">language</td>
|
||||
|
||||
+3
-4
@@ -9,9 +9,8 @@ import okhttp3.Response
|
||||
import org.dreamexposure.discal.core.config.Config
|
||||
import org.dreamexposure.discal.core.extensions.asSeconds
|
||||
import org.dreamexposure.discal.core.logger.LOGGER
|
||||
import org.dreamexposure.discal.core.`object`.network.discal.InstanceData
|
||||
import org.dreamexposure.discal.core.`object`.rest.HeartbeatRequest
|
||||
import org.dreamexposure.discal.core.`object`.rest.HeartbeatType
|
||||
import org.dreamexposure.discal.core.`object`.new.model.discal.HeartbeatV3RequestModel
|
||||
import org.dreamexposure.discal.core.`object`.new.model.discal.InstanceDataV3Model
|
||||
import org.dreamexposure.discal.core.utils.GlobalVal
|
||||
import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT
|
||||
import org.springframework.boot.ApplicationArguments
|
||||
@@ -38,7 +37,7 @@ class HeartbeatCronJob(
|
||||
|
||||
private fun heartbeat() = mono {
|
||||
try {
|
||||
val requestBody = HeartbeatRequest(HeartbeatType.WEBSITE, instanceData = InstanceData())
|
||||
val requestBody = HeartbeatV3RequestModel(HeartbeatV3RequestModel.Type.WEBSITE, instance = InstanceDataV3Model())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("$apiUrl/v3/status/heartbeat")
|
||||
|
||||
Reference in New Issue
Block a user