Merge pull request #169 from DreamExposure/develop

4.2.9 RC
This commit is contained in:
Nova Maday
2025-08-21 00:02:16 -05:00
committed by GitHub
46 changed files with 459 additions and 239 deletions
+1
View File
@@ -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
View File
@@ -28,7 +28,7 @@ buildscript {
allprojects {
//Project props
group = "org.dreamexposure.discal"
version = "4.2.8"
version = "4.2.9"
description = "DisCal"
//Plugins
@@ -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")
@@ -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() }
}
}
@@ -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()
@@ -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()
}
@@ -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()
}
@@ -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)
@@ -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>
}
@@ -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,
@@ -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")
@@ -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),
}
}
@@ -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,
)
@@ -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,
}
}
@@ -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()
}
@@ -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
@@ -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)
))
}
}
}
@@ -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()
}
}
@@ -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)
@@ -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!")
}
}
@@ -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?) {
@@ -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>
@@ -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")