From 8ef2b186d8c3a113212f0993563772660e8995ae Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Sat, 20 Jan 2024 23:54:09 -0600 Subject: [PATCH 01/43] Next development version --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 0bc0dabb1..f0df171b3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,7 +27,7 @@ buildscript { allprojects { //Project props group = "org.dreamexposure.discal" - version = "4.2.5" + version = "4.2.6" description = "DisCal" //Plugins From ba968d6a937397947b096e4350d90ac365493498 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Tue, 6 Feb 2024 22:25:00 -0600 Subject: [PATCH 02/43] Update README.md --- README.md | 201 +++++++++++++++++++++++------------------------------- 1 file changed, 84 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index fe41f9dcd..6f016fc99 100644 --- a/README.md +++ b/README.md @@ -1,124 +1,91 @@ # DisCal - [![Discord](https://img.shields.io/discord/375357265198317579?label=DreamExposure&style=flat-square)](https://discord.gg/2TFqyuy) -![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/DreamExposure/DisCal-Discord-Bot/gradle.yml?branch=develop&label=Build&style=flat-square) -[![Website](https://img.shields.io/website?down_color=red&down_message=offline&label=Status&style=flat-square&up_message=online&url=https%3A%2F%2Fwww.discalbot.com)](https://discalbot.com) +[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/DreamExposure/DisCal-Discord-Bot/gradle.yml?branch=develop&label=Build&style=flat-square)](https://github.com/DreamExposure/DisCal-Discord-Bot/actions) +[![Website](https://img.shields.io/website?down_color=red&down_message=offline&label=Status&style=flat-square&up_message=online&url=https%3A%2F%2Fwww.discalbot.com)](https://discalbot.com/status) -DisCal is a discord bot that connects Discord and Google Calendar as seamlessly as possible with a wide feature set for -calendar management and information. -# πŸ”— Quick Links +A calendar bot made for communities, +DisCal integrates directly with calendar services to bring you superior support and features. +Custom calendars, events, automated reminders and more, ready for you, and ready for your community. -* [Website](https://www.discalbot.com) -* [Discord](https://discord.gg/2TFqyuy) +# πŸ”— Quick Links +- [Invite](https://discord.com/api/oauth2/authorize?client_id=265523588918935552&permissions=420979666000&scope=bot%20applications.commands) +- [Website](https://discalbot.com) (rewrite in progress, dev version available [here](https://dev.discalbot.com)) +- [Discord Support Server](https://discord.gg/2TFqyuy) +- [Patreon](https://www.patreon.com/Novafox) # πŸ’Ž Core Features - -* Powerful in-server integration of google calendar -* Custom Calendar creation and editing -* Event creation, editing, and deletion -* Automated announcement system to remind users of events -* Customizable prefix and mentionable commands -* Versatile and built for all communities -* Web dashboard for bot and calendar management - -## πŸŽ‰ Patron-Only Features - -Patrons and supporters on the $5/month plans get access to work in progress and exclusive features. - -* External Calendars - - Use an already existing calendar that is on your Google account with DisCal -* Web Dashboard (WIP) - - Use the web dashboard to manage the bot, calendar, and more without the need for commands. - - Still very early in development -* Server Branding - - Hide the DisCal name in favor of using your server's name on announcements and embeds. -* Announcement Publishing - - Announcements posted in news channels can be (optionally) automatically "published" so servers following the news - channel receive them as well! -* Gif support for event images -* Automatically assign roles to users when RSVPing to an event. - -## πŸ“ Planned Features & Work in Progress Changes - -* Multiple calendars per server (WIP) -* Advanced announcement configuration (WIP) -* Complex recurring event configuration (WIP) -* Proper patreon integration for automated setup. -* Better translation support (Right now using the JSON files is really messy and hard to maintain) -* And so much more! - -# πŸ“¦ Modules & Services -* Core - * The central inner workings shared across other modules -* Server - * The backend API responsible for network health monitoring and houses the RESTful API -* Client - * Does all the heavy lifting. This is the discord bot and runs a single shard per instance -* Web - * The official website. This houses all the frontend code and handles logging in with Discord for the Dashboard -* C.A.M - * The Central Authentication Manager. This service maintains the credentials for services used by DisCal - -# 🧰 Technologies - -DisCal is primarily written in Java with a TypeScript powered web-frontend. We use the following technologies throughout -the project: - -* [Discord4J](https://github.com/Discord4J/Discord4J) API wrapper -* Project Reactor for fully reactive code -* SpringBoot web backend -* MySQL with Redis caching - -# βš™οΈ Developer RESTful API - -DisCal was written for the community, and to aid in that goal, DisCal has a fully functioning REST API to allow -developers to bring their applications to DisCal. - -Current API Version: v2 - -To get an API token, please contact the development team. - -* [API Docs](https://www.discalbot.com/docs/api/overview) - -# βœ’οΈ Contributing - -DisCal is an open source project and is maintained in our free time. We always welcome and love contributions. - -## πŸ“š Code - -1. Fork this repo and make changes in your own copy -2. Write your code and add any new tests if applicable -3. Run the new and/or existing tests with `mvn clean test` to make sure they pass -4. Commit your changes and push to your fork `git push origin master` -5. Create a new pull request and submit it back to us! - -## πŸ—ΊοΈ Translations - -> This section is a work in progress. Thank you for your understanding - -DisCal reaches far and wide, and to help reach more people, we want to support fully localized text throughout the bot -and website. To do that, we use a simple but robust system. If you are fluent in English and another language, we -welcome your help in translating the bot's text. Below are instructions and the conventions we use to keep translations -orderly and working. - -We ask that you do not use services like Google Translate as the context of a sentence can be lost or misinterpreted by -software causing confusion for non-english speakers. Thank you. - -### πŸ“– Conventions - -* Language files are located in `/core/src/main/resources/i18n/` -* All file names follow the format `name_lang-code.properties` - - For example, the Spanish common file would use `common_es.properties` -* File contents is formatted as `key=value` where `key` should not be modified -* Variables are input as `{n}` where `n` is the zero-indexed order it is passed through in code. - - In english, these are always in order `0, 1, 2... 5`. Some languages these may be out of order in order to - maintain the correct variables in the correct place `1, 0, 3, 2...5`. - - If the english variant has a variable, the translated version must also have that somewhere in the string. - -### βœ’οΈ Adding Translations - -1. First fork this repository. -2. Then to translate a file, create a new file in the same folder as the english variant, following our conventions - above. Then translate each of the value strings from the original english into the new language. -3. Finally, once you have completed your additions, open a pull request and submit it to us! +- Custom Calendars + - Create a fully custom calendar to suit your community's needs, without feeling out of place. + - Powerful in-server integration of Google Calendar (plus more in the future) +- Unlimited Events + - Have a busy community? DisCal can make sure all your community events are scheduled, no matter the amount. +- Automated Reminders + - DisCal can automatically remind your community of upcoming events, so no one misses out. +- Integrated RSVP + - Need to know who is planning to attend? Community members can let you know whether they are attending. + + +## πŸŽ‰ Patron features +Patrons and supporters on the $5/month plans (or more) get access to work in progress and exclusive features. +These features aren't required for core functionality and help support the development and hosting of this bot. + +- Web Dashboard (Early WIP) +- Multiple calendars +- Server branding + - Hide the DisCal name in favor of using your server's name on announcements and embeds. +- Announcement Publishing + - Announcements posted in news channels can be (optionally) automatically "published" so servers following the news channel receive them as well! +- Gif support for event images +- Automatically assign roles to users when RSVPing to an event. + +## ⌨️ Commands +- [Commands](https://discalbot.com/commands) +- If you would like a mobile-friendly experience, you can try the [dev site's version](https://dev.discalbot.com/commands) + + + + +# πŸ—“οΈ Planned & Work In Progress +This bot is a hobby project for me, please not that white these features are planned, there's no solid timeline. +- Website rewrite (it's old and ugly) +- [WIP] Migration to Spring data +- [WIP] Kotlin coroutines rewrite + +# 🧰 Tech stack +- Java 17 +- [Discord4J](https://github.com/Discord4J/DIscord4J) +- Spring Boot (DI, Data, Actuator, etc.) +- Flyway for automatic database migrations (MySQL) +- Redis cluster caching +- Fully containerized with Docker (hosted in Kubernetes, docker-compose for local development) + +# ✏️ Contributing +DisCal is an open source, GPL-3 project. We always welcome and appreciate contributions. + +## πŸ’» Development & Local Testing +For development, you need JDK 17+ and Docker installed. + +1. Fork this repository and open it in your favorite editor (IntelliJ recommended for Kotlin) +2. Write your code and add applicable tests +3. Compile and build the docker image with `./gradlew clean jibDockerBuild` +4. Place config in `./docker/{api/bot/cam}/application.properties` +5. Start the bot and dependencies for testing with `docker compose up -d` + - You can connect to the Java debugger at port `5005` +6. Create a pull request and describe your changes! <3 + +## 🌐 Localization & Internationalization +Please only submit localizations if you speak and/or write the language you are translating to. +We want to keep these translations correct and high quality, running the strings through Google Translate or DeepL is not acceptable. +Thank you for understanding + +In the early days of the bot, we had a pretty dis-organized json file system for translated strings. +This was messy and somewhat confusing. Since the 2.0 update, we now utilize properties files + +1. The base english locale file is located at `/src/main/resources/locale/values.properties` +2. Files are named `values_{lang-code}.properties`. For example, the Spanish locale file would be `values_es.properties` +3. Translate the strings and submit it back to us (either via Discord, or a pull request to this repo) + +> **NOTE**: Variables use `{N}` where `N` is the zero-indexed order it is passed through in code. +> +> In English, these are always in order `0, 1, 2... 5`. From 26acbfe9171d878cd7ec6d4c0c095619ecdbe33f Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Sun, 11 Feb 2024 17:04:39 -0600 Subject: [PATCH 03/43] Add commands documentation to README.md Skip ci --- README.md | 145 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6f016fc99..f46c61eb6 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,152 @@ These features aren't required for core functionality and help support the devel - [Commands](https://discalbot.com/commands) - If you would like a mobile-friendly experience, you can try the [dev site's version](https://dev.discalbot.com/commands) - +
+How permissions are handled + +DisCal uses a simple-to-understand permission scheme for handling access to commands. +- **Elevated** + - Requires `ADMINISTRATOR` or `MANAGE_SERVER` permission nodes, or being the guild owner +- **Privileged** + - Requires DisCal control role (default control role is `@everyone`) +- **Everyone** + - Everyone will always be able to access (unless commands are disabled for the channel) +- **Patron-Only** + - Requires guild to be a patron-guild at the early access tier or higher +- **Dev-Only** + - Only DisCal Developers are able to use these commands +
+ +
+Calendar Commands (/calendar) + +| Command | Description | Permissions | +|-------------------------|----------------------------------------|-------------| +| `/calendar create` | Starts the calendar creation wizard | elevated | +| `/calendar name` | Sets the calendar's name | elevated | +| `/calendar description` | Sets the calendar's description | elevated | +| `/calendar timezone` | Sets the calendar's timezone | elevated | +| `/calendar review` | Displays the calendar's properties | elevated | +| `/calendar confirm` | Commits the changes made in the wizard | elevated | +| `/calendar cancel` | Cancels the wizard | elevated | +| `/calendar delete` | Deletes the calendar | elevated | +| `/calendar edit` | Starts the calendar edit wizard | elevated | +
+ +
+Displaycal Commands (/displaycal) + +| Command | Description | Permissions | +|----------------------|-------------------------------------------------------|-------------| +| `/displaycal new` | Creates a new auto-updating calendar overview message | elevated | +| `/displaycal update` | Updates an existing calendar overview | elevated | +
+ +
+Event Commands (/event) + +| Command | Description | Permissions | +|----------------------|-----------------------------------------------------|-------------------------------------| +| `/event view` | Displays the event's details | everyone | +| `/event create` | Starts the event creation wizard | privileged | +| `/event name` | Sets the event's name | privileged | +| `/event description` | Sets the event's description | privileged | +| `/event start` | Sets the event's start | privileged | +| `/event end` | Sets the event's end | privileged | +| `/event color` | Sets the event's color | privileged | +| `/event location` | Sets the event's location | privileged | +| `/event image` | Sets the event's image | privileged, gif support patron-only | +| `/event recur` | Toggles whether the event recurs, and how it recurs | privileged | +| `/event review` | Displays the event's properties | privileged | +| `/event confirm` | Commits the changes made in the wizard | privileged | +| `/event cancel` | Cancels the wizard | privileged | +| `/event edit` | Starts the event edit wizard | privileged | +| `/event copy` | Copies an existing event's details to a new event | privileged | +| `/event delete` | Deletes an event | privileged | +
+ +
+Events Commands (/events) + +| Command | Description | Permissions | +|--------------------|---------------------------------------------------|-------------| +| `/events upcoming` | Lists the next X upcoming events | everyone | +| `/events ongoing` | Lists the ongoing events | everyone | +| `/events today` | Lists the events occurring in the next 24 hours | everyone | +| `/events range` | Lists the events found in the date range provided | everyone | +
+ +
+RSVP Commands (/rsvp) + +| Command | Description | Permissions | +|----------------|--------------------------------------------------------------------------------------------------------------------------------------|-----------------------| +| `/rsvp ontime` | RSVPs as going to the event on time | everyone | +| `/rsvp late` | RSVPs as going to the event, but arriving late | everyone | +| `/rsvp not` | RSVPs as not going to the event | everyone | +| `/rsvp unsure` | RSVPs as unsure if you will be able to attend | everyone | +| `/rsvp remove` | Removes your RSVP status from the event | everyone | +| `/rsvp list` | Lists who has RSVPed to the event | everyone | +| `/rsvp limit` | Sets the max number of people allowed to attend. `-1` to disable the limit | privileged | +| `/rsvp role` | Sets the role assigned when RSVP'd to the event. `@everyone` to disable. *NOTE:* These roles are currently not automatically removed | elevated, patron-only | +
+ +
+Announcement Commands (/announcement) + +| Command | Description | Permissions | +|-----------------------------|-------------------------------------------------------------------------------------|-------------------------| +| `/announcement create` | Starts the announcement create wizard | privileged | +| `/announcement type` | Sets the announcement type. Valid types: UNIVERSAL, SPECIFIC, COLOR, RECUR | privileged | +| `/announcement event` | Sets the announcement's event. Only needed when using SPECIFIC or RECUR types | privileged | +| `/announcement color` | Sets the announcement's color. Only needed when using COLOR type | privileged | +| `/announcement channel` | Sets the channel the announcement will be posted in | privileged | +| `/announcement minutes` | Sets the minutes before an event to announce. Added to hours | privileged | +| `/announcement hours` | Sets the hours before an event to announce. Added to minutes | privileged | +| `/announcement info` | Sets the additional info to be posted along with the event. No text input to remove | privileged | +| `/announcement calendar` | Sets the calendar the announcement will read from. Defaults to 1 (main calendar) | privileged | +| `/announcement publish` | Toggles if the announcement should be pushed to channel subscribers | privileged, patron-only | +| `/announcement review` | Displays the announcement properties in the wizard | privileged | +| `/announcement confirm` | Commits the changes made in the wizard | privileged | +| `/announcement cancel` | Cancels the announcement wizard | privileged | +| `/announcement edit` | Starts the announcement edit wizard | privileged | +| `/announcement copy` | Copies an existing announcement to a new one | privileged | +| `/announcement delete` | Deletes an announcement | privileged | +| `/announcement enable` | Sets whether an announcement is enabled | privileged | +| `/announcement view` | Displays an existing announcement's properties | everyone | +| `/announcement list` | Lists announcements, -1 for all | everyone | +| `/announcement subscribe` | Subscribes to an announcement to be pinged when it is posted | everyone | +| `/announcement unsubscribe` | Unsubscribes to an announcement, to stop being pinged when it is posted | everyone | +
+ +
+Settings Commands (/settings) + +| Command | Description | Permissions | +|--------------------------------|--------------------------------------------------------------------------|-----------------------| +| `/settings view` | Displays the current settings for the guild | elevated | +| `/settings role` | Sets the role required to use privileged commands | elevated | +| `/settings announcement-style` | Changes the style announcements will be posted as | 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 | +
+ +
+All Other Commands + +| Command | Description | Permissions | +|-----------------|--------------------------------------------------------------|-----------------------| +| `/discal` | Displays information about the bot | everyone | +| `/linkcal` | Provides info and a link to view the guild's calendar | everyone | +| `/time` | Displays the current time as seen by the calendar's timezone | everyone | +| [WIP] `/addcal` | Starts the process to add a pre-existing calendar | patron-only, dev-only | +| `help` | Links to the commands page and documentation | everyone | +
# πŸ—“οΈ Planned & Work In Progress -This bot is a hobby project for me, please not that white these features are planned, there's no solid timeline. +This bot is a hobby project for me, please note that while these features are planned, there's no solid timeline. - Website rewrite (it's old and ugly) - [WIP] Migration to Spring data - [WIP] Kotlin coroutines rewrite From 8886e6cc859193c0799a75c59b592591fe6eefc5 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Sun, 11 Feb 2024 17:39:52 -0600 Subject: [PATCH 04/43] Attempting another fix for corrupted access tokens --- .../kotlin/org/dreamexposure/discal/cam/google/GoogleAuth.kt | 2 +- .../org/dreamexposure/discal/core/object/new/Calendar.kt | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleAuth.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleAuth.kt index 1749c4e75..1674aa205 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleAuth.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleAuth.kt @@ -99,7 +99,7 @@ class GoogleAuth( if (body.error == "invalid_grant") { LOGGER.debug(DEFAULT, "[Google] Access to resource has been revoked") - throw AccessRevokedException() + throw AccessRevokedException() // TODO: How should I handle this for external calendars? Right now we just delete everything } else { LOGGER.error(DEFAULT, "[Google] Error requesting new access token | ${response.code} | ${response.message} | $body") null diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Calendar.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Calendar.kt index b5bb5fbcf..abb563afb 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Calendar.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Calendar.kt @@ -7,6 +7,7 @@ import org.dreamexposure.discal.core.database.CalendarData import org.dreamexposure.discal.core.enums.calendar.CalendarHost import org.dreamexposure.discal.core.extensions.asInstantMilli import org.dreamexposure.discal.core.extensions.asSnowflake +import org.dreamexposure.discal.core.extensions.isExpiredTtl import java.time.Instant import javax.crypto.IllegalBlockSizeException @@ -22,11 +23,11 @@ data class Calendar private constructor( companion object { suspend operator fun invoke(data: CalendarData): Calendar { val aes = AESEncryption(data.privateKey) - val accessToken = try { + val accessToken = if (!data.expiresAt.asInstantMilli().isExpiredTtl()) try { aes.decrypt(data.accessToken).awaitSingle() } catch (ex: IllegalBlockSizeException) { null - } + } else null // No point in trying to decrypt if it's expired return Calendar( guildId = data.guildId.asSnowflake(), From 9d2df2124aad4f2561a52c77b62467b2189d383c Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Mon, 19 Feb 2024 20:14:19 -0600 Subject: [PATCH 05/43] Rewrite RSVP business code into k-coroutines --- .../client/commands/global/RsvpCommand.kt | 421 +++++++++--------- .../listeners/discord/RoleDeleteListener.kt | 13 +- .../discal/client/message/embed/RsvpEmbed.kt | 82 ---- .../discal/core/business/EmbedService.kt | 150 +++++++ .../discal/core/business/RsvpService.kt | 207 +++++++++ .../discal/core/config/CacheConfig.kt | 11 + .../discal/core/config/Config.kt | 6 +- .../discal/core/database/DatabaseManager.kt | 144 ------ .../discal/core/database/RsvpData.kt | 18 + .../discal/core/database/RsvpRepository.kt | 47 ++ .../discal/core/entities/Event.kt | 10 - .../core/exceptions/NotFoundException.kt | 2 +- .../discal/core/object/new/Rsvp.kt | 67 +++ .../discal/core/object/new/security/Scope.kt | 3 + .../org/dreamexposure/discal/typealiases.kt | 2 + .../endpoints/v2/rsvp/GetRsvpEndpoint.kt | 53 --- .../endpoints/v2/rsvp/UpdateRsvpEndpoint.kt | 165 ------- .../server/endpoints/v3/RsvpController.kt | 30 ++ 18 files changed, 761 insertions(+), 670 deletions(-) delete mode 100644 client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/RsvpEmbed.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/business/RsvpService.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/database/RsvpData.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/database/RsvpRepository.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Rsvp.kt delete mode 100644 server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/rsvp/GetRsvpEndpoint.kt delete mode 100644 server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/rsvp/UpdateRsvpEndpoint.kt create mode 100644 server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/RsvpController.kt diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/RsvpCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/RsvpCommand.kt index baa8be3b4..9baad6865 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/RsvpCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/RsvpCommand.kt @@ -1,29 +1,33 @@ package org.dreamexposure.discal.client.commands.global +import discord4j.core.event.domain.interaction.ChatInputInteractionEvent import discord4j.core.`object`.command.ApplicationCommandInteractionOption import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue -import discord4j.core.`object`.entity.Member import discord4j.core.`object`.entity.Message -import discord4j.core.event.domain.interaction.ChatInputInteractionEvent +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull import org.dreamexposure.discal.client.commands.SlashCommand -import org.dreamexposure.discal.client.message.embed.RsvpEmbed -import org.dreamexposure.discal.core.`object`.GuildSettings +import org.dreamexposure.discal.core.business.EmbedService +import org.dreamexposure.discal.core.business.RsvpService import org.dreamexposure.discal.core.extensions.discord4j.followupEphemeral import org.dreamexposure.discal.core.extensions.discord4j.getCalendar import org.dreamexposure.discal.core.extensions.discord4j.hasControlRole import org.dreamexposure.discal.core.extensions.discord4j.hasElevatedPermissions +import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.utils.getCommonMsg import org.springframework.stereotype.Component import reactor.core.publisher.Mono -import reactor.function.TupleUtils.function @Component -class RsvpCommand : SlashCommand { +class RsvpCommand( + private val rsvpService: RsvpService, + private val embedService: EmbedService, +) : SlashCommand { override val name = "rsvp" override val ephemeral = true - @Deprecated("Use new handleSuspend for K-coroutines") - override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + + override suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message { return when (event.options[0].name) { "ontime" -> onTime(event, settings) "late" -> late(event, settings) @@ -33,218 +37,225 @@ class RsvpCommand : SlashCommand { "list" -> list(event, settings) "limit" -> limit(event, settings) "role" -> role(event, settings) - else -> Mono.empty() //Never can reach this, makes compiler happy. + else -> throw IllegalStateException("Invalid subcommand specified") } } - private fun onTime(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun onTime(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val calendarNumber = event.options[0].getOption("calendar") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) .map(Long::toInt) .orElse(1) - val eventId = event.options[0].getOption("event") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) .get() - return event.interaction.guild.flatMap { guild -> - guild.getCalendar(calendarNumber).flatMap { cal -> - cal.getEvent(eventId).flatMap { calEvent -> - if (!calEvent.isOver()) { - val member = event.interaction.member.get() - calEvent.getRsvp().flatMap { rsvp -> - if (rsvp.hasRoom(member.id.asString())) { - rsvp.removeCompletely(member) - .flatMap { it.addGoingOnTime(member).thenReturn(it) } - .flatMap { calEvent.updateRsvp(it).thenReturn(it) } - .flatMap { RsvpEmbed.list(guild, settings, calEvent, it) } - .flatMap { - event.followupEphemeral(getMessage("onTime.success", settings), it) - } - } else { - // No room, add to waitlist instead - rsvp.removeCompletely(member) - .doOnNext { it.waitlist.add(member.id.asString()) } - .flatMap { calEvent.updateRsvp(it).thenReturn(it) } - .flatMap { RsvpEmbed.list(guild, settings, calEvent, it) } - .flatMap { - event.followupEphemeral(getMessage("onTime.failure.limit", settings), it) - } - } - } - } else { - event.followupEphemeral(getCommonMsg("error.event.ended", settings)) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.event", settings))) - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings))) + val userId = event.interaction.user.id + val guild = event.interaction.guild.awaitSingle() + val calendar = guild.getCalendar(calendarNumber).awaitSingleOrNull() + val calendarEvent = calendar?.getEvent(eventId)?.awaitSingleOrNull() + + // Validate required conditions + if (calendar == null) + return event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)).awaitSingle() + if (calendarEvent == null) + return event.followupEphemeral(getCommonMsg("error.notFound.event", settings)).awaitSingle() + if (calendarEvent.isOver()) + return event.followupEphemeral(getCommonMsg("error.event.ended", settings)).awaitSingle() + + var rsvp = rsvpService.getRsvp(guild.id, eventId) + + return if (rsvp.hasRoom(userId)) { + rsvp = rsvpService.upsertRsvp(rsvp.copyWithUserStatus(userId, goingOnTime = rsvp.goingOnTime + userId)) + + event.followupEphemeral( + getMessage("onTime.success", settings), + embedService.rsvpListEmbed(calendarEvent, rsvp, settings) + ).awaitSingle() + } else { + rsvp = rsvpService.upsertRsvp(rsvp.copyWithUserStatus(userId, waitlist = rsvp.waitlist + userId)) + + event.followupEphemeral( + getMessage("onTime.failure.limit", settings), + embedService.rsvpListEmbed(calendarEvent, rsvp, settings) + ).awaitSingle() } } - private fun late(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun late(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val calendarNumber = event.options[0].getOption("calendar") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) .map(Long::toInt) .orElse(1) - val eventId = event.options[0].getOption("event") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) .get() - return event.interaction.guild.flatMap { guild -> - guild.getCalendar(calendarNumber).flatMap { cal -> - cal.getEvent(eventId).flatMap { calEvent -> - if (!calEvent.isOver()) { - val member = event.interaction.member.get() - calEvent.getRsvp().flatMap { rsvp -> - if (rsvp.hasRoom(member.id.asString())) { - rsvp.removeCompletely(member) - .flatMap { it.addGoingLate(member).thenReturn(it) } - .flatMap { calEvent.updateRsvp(it).thenReturn(it) } - .flatMap { RsvpEmbed.list(guild, settings, calEvent, it) } - .flatMap { event.followupEphemeral(getMessage("late.success", settings), it) } - } else { - // No room, add to waitlist instead - rsvp.removeCompletely(member) - .doOnNext { it.waitlist.add(member.id.asString()) } - .flatMap { calEvent.updateRsvp(it).thenReturn(it) } - .flatMap { RsvpEmbed.list(guild, settings, calEvent, it) } - .flatMap { - event.followupEphemeral(getMessage("late.failure.limit", settings), it) - } - } - } - } else { - event.followupEphemeral(getCommonMsg("error.event.ended", settings)) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.event", settings))) - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings))) + val userId = event.interaction.user.id + val guild = event.interaction.guild.awaitSingle() + val calendar = guild.getCalendar(calendarNumber).awaitSingleOrNull() + val calendarEvent = calendar?.getEvent(eventId)?.awaitSingleOrNull() + + // Validate required conditions + if (calendar == null) + return event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)).awaitSingle() + if (calendarEvent == null) + return event.followupEphemeral(getCommonMsg("error.notFound.event", settings)).awaitSingle() + if (calendarEvent.isOver()) + return event.followupEphemeral(getCommonMsg("error.event.ended", settings)).awaitSingle() + + var rsvp = rsvpService.getRsvp(guild.id, eventId) + + return if (rsvp.hasRoom(userId)) { + rsvp = rsvpService.upsertRsvp(rsvp.copyWithUserStatus(userId, goingLate = rsvp.goingLate + userId)) + + event.followupEphemeral( + getMessage("late.success", settings), + embedService.rsvpListEmbed(calendarEvent, rsvp, settings) + ).awaitSingle() + } else { + rsvp = rsvpService.upsertRsvp(rsvp.copy(waitlist = rsvp.waitlist + userId)) + + event.followupEphemeral( + getMessage("late.failure.limit", settings), + embedService.rsvpListEmbed(calendarEvent, rsvp, settings) + ).awaitSingle() } } - private fun unsure(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun unsure(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val calendarNumber = event.options[0].getOption("calendar") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) .map(Long::toInt) .orElse(1) - val eventId = event.options[0].getOption("event") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) .get() - return event.interaction.guild.flatMap { guild -> - guild.getCalendar(calendarNumber).flatMap { cal -> - cal.getEvent(eventId).flatMap { calEvent -> - if (!calEvent.isOver()) { - val member = event.interaction.member.get() - calEvent.getRsvp() - .flatMap { it.removeCompletely(member) } - .doOnNext { it.undecided.add(member.id.asString()) } - .flatMap { calEvent.updateRsvp(it).thenReturn(it) } - .flatMap { RsvpEmbed.list(guild, settings, calEvent, it) } - .flatMap { - event.followupEphemeral(getMessage("unsure.success", settings), it) - } - } else { - event.followupEphemeral(getCommonMsg("error.event.ended", settings)) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.event", settings))) - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings))) - } + val userId = event.interaction.user.id + val guild = event.interaction.guild.awaitSingle() + val calendar = guild.getCalendar(calendarNumber).awaitSingleOrNull() + val calendarEvent = calendar?.getEvent(eventId)?.awaitSingleOrNull() + + // Validate required conditions + if (calendar == null) + return event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)).awaitSingle() + if (calendarEvent == null) + return event.followupEphemeral(getCommonMsg("error.notFound.event", settings)).awaitSingle() + if (calendarEvent.isOver()) + return event.followupEphemeral(getCommonMsg("error.event.ended", settings)).awaitSingle() + + var rsvp = rsvpService.getRsvp(guild.id, eventId) + + rsvp = rsvpService.upsertRsvp(rsvp.copyWithUserStatus(userId, undecided = rsvp.undecided + userId)) + + return event.followupEphemeral( + getMessage("unsure.success", settings), + embedService.rsvpListEmbed(calendarEvent, rsvp, settings) + ).awaitSingle() } - private fun notGoing(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun notGoing(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val calendarNumber = event.options[0].getOption("calendar") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) .map(Long::toInt) .orElse(1) - val eventId = event.options[0].getOption("event") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) .get() - return event.interaction.guild.flatMap { guild -> - guild.getCalendar(calendarNumber).flatMap { cal -> - cal.getEvent(eventId).flatMap { calEvent -> - if (!calEvent.isOver()) { - val member = event.interaction.member.get() - calEvent.getRsvp() - .flatMap { it.removeCompletely(member) } - .doOnNext { it.notGoing.add(member.id.asString()) } - .flatMap { calEvent.updateRsvp(it).thenReturn(it) } - .flatMap { RsvpEmbed.list(guild, settings, calEvent, it) } - .flatMap { - event.followupEphemeral(getMessage("notGoing.success", settings), it) - } - } else { - event.followupEphemeral(getCommonMsg("error.event.ended", settings)) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.event", settings))) - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings))) - } + val userId = event.interaction.user.id + val guild = event.interaction.guild.awaitSingle() + val calendar = guild.getCalendar(calendarNumber).awaitSingleOrNull() + val calendarEvent = calendar?.getEvent(eventId)?.awaitSingleOrNull() + + // Validate required conditions + if (calendar == null) + return event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)).awaitSingle() + if (calendarEvent == null) + return event.followupEphemeral(getCommonMsg("error.notFound.event", settings)).awaitSingle() + if (calendarEvent.isOver()) + return event.followupEphemeral(getCommonMsg("error.event.ended", settings)).awaitSingle() + + var rsvp = rsvpService.getRsvp(guild.id, eventId) + + rsvp = rsvpService.upsertRsvp(rsvp.copyWithUserStatus(userId, notGoing = rsvp.notGoing + userId)) + + return event.followupEphemeral( + getMessage("notGoing.success", settings), + embedService.rsvpListEmbed(calendarEvent, rsvp, settings) + ).awaitSingle() } - private fun remove(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun remove(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val calendarNumber = event.options[0].getOption("calendar") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) .map(Long::toInt) .orElse(1) - val eventId = event.options[0].getOption("event") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) .get() - return event.interaction.guild.flatMap { guild -> - guild.getCalendar(calendarNumber).flatMap { cal -> - cal.getEvent(eventId).flatMap { calEvent -> - if (!calEvent.isOver()) { - val member = event.interaction.member.get() - calEvent.getRsvp().flatMap { rsvp -> - // Add next person on waitlist if this user was previously going to attend - rsvp.removeCompletely(member, true) - .flatMap { calEvent.updateRsvp(it).thenReturn(it) } - .flatMap { RsvpEmbed.list(guild, settings, calEvent, it) } - .flatMap { event.followupEphemeral(getMessage("remove.success", settings), it) } - } - } else { - event.followupEphemeral(getCommonMsg("error.event.ended", settings)) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.event", settings))) - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings))) - } + val userId = event.interaction.user.id + val guild = event.interaction.guild.awaitSingle() + val calendar = guild.getCalendar(calendarNumber).awaitSingleOrNull() + val calendarEvent = calendar?.getEvent(eventId)?.awaitSingleOrNull() + + // Validate required conditions + if (calendar == null) + return event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)).awaitSingle() + if (calendarEvent == null) + return event.followupEphemeral(getCommonMsg("error.notFound.event", settings)).awaitSingle() + if (calendarEvent.isOver()) + return event.followupEphemeral(getCommonMsg("error.event.ended", settings)).awaitSingle() + + var rsvp = rsvpService.getRsvp(guild.id, eventId) + + rsvp = rsvpService.upsertRsvp(rsvp.copyWithUserStatus(userId)) + + return event.followupEphemeral( + getMessage("remove.success", settings), + embedService.rsvpListEmbed(calendarEvent, rsvp, settings) + ).awaitSingle() } - private fun list(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun list(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val calendarNumber = event.options[0].getOption("calendar") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) .map(Long::toInt) .orElse(1) - val eventId = event.options[0].getOption("event") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) .get() - return event.interaction.guild.flatMap { guild -> - guild.getCalendar(calendarNumber).flatMap { cal -> - cal.getEvent(eventId).flatMap { calEvent -> - RsvpEmbed.list(guild, settings, calEvent).flatMap { event.followupEphemeral(it) } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.event", settings))) - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings))) - } + val guild = event.interaction.guild.awaitSingle() + val calendar = guild.getCalendar(calendarNumber).awaitSingleOrNull() + val calendarEvent = calendar?.getEvent(eventId)?.awaitSingleOrNull() + + // Validate required conditions + if (calendar == null) + return event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)).awaitSingle() + if (calendarEvent == null) + return event.followupEphemeral(getCommonMsg("error.notFound.event", settings)).awaitSingle() + + val rsvp = rsvpService.getRsvp(guild.id, eventId) + + return event.followupEphemeral(embedService.rsvpListEmbed(calendarEvent, rsvp, settings)).awaitSingle() } - private fun limit(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun limit(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val calendarNumber = event.options[0].getOption("calendar") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) @@ -262,82 +273,76 @@ class RsvpCommand : SlashCommand { .map(Long::toInt) .get() - return Mono.justOrEmpty(event.interaction.member) - .filterWhen(Member::hasControlRole) - .flatMap { event.interaction.guild } - .flatMap { guild -> - guild.getCalendar(calendarNumber).flatMap { cal -> - cal.getEvent(eventId).flatMap { calEvent -> - if (!calEvent.isOver()) { - calEvent.getRsvp() - .doOnNext { it.limit = limit } - // Handle adding other users to going in the event the limit was increased/removed - .flatMap { it.fillRemaining(guild, settings) } - .flatMap { calEvent.updateRsvp(it).thenReturn(it) } - .flatMap { RsvpEmbed.list(guild, settings, calEvent, it) } - .flatMap { - event.followupEphemeral(getMessage("limit.success", settings, "$limit"), it) - } - } else { - event.followupEphemeral(getCommonMsg("error.event.ended", settings)) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.event", settings))) - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings))) - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) + // Validate control role first to reduce work + val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle() + if (!hasControlRole) + return event.followupEphemeral(getCommonMsg("error.perms.privileged", settings)).awaitSingle() + + + val guild = event.interaction.guild.awaitSingle() + val calendar = guild.getCalendar(calendarNumber).awaitSingleOrNull() + val calendarEvent = calendar?.getEvent(eventId)?.awaitSingleOrNull() + + // Validate required conditions + if (calendar == null) + return event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)).awaitSingle() + if (calendarEvent == null) + return event.followupEphemeral(getCommonMsg("error.notFound.event", settings)).awaitSingle() + if (calendarEvent.isOver()) + return event.followupEphemeral(getCommonMsg("error.event.ended", settings)).awaitSingle() + + var rsvp = rsvpService.getRsvp(guild.id, eventId) + rsvp = rsvpService.upsertRsvp(rsvp.copy(limit = limit)) + + + return event.followupEphemeral( + getMessage("limit.success", settings), + embedService.rsvpListEmbed(calendarEvent, rsvp, settings) + ).awaitSingle() } - private fun role(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun role(event: ChatInputInteractionEvent, settings: GuildSettings): Message { + if (!settings.patronGuild) + return event.followupEphemeral(getCommonMsg("error.patronOnly", settings)).awaitSingle() + val calendarNumber = event.options[0].getOption("calendar") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) .map(Long::toInt) .orElse(1) - val eventId = event.options[0].getOption("event") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) .get() + val role = Mono.justOrEmpty( + event.options[0].getOption("role").flatMap(ApplicationCommandInteractionOption::getValue) + ).flatMap(ApplicationCommandInteractionOptionValue::asRole).awaitSingle() + + + // Validate control role first to reduce work + val hasElevatedPerms = event.interaction.member.get().hasElevatedPermissions().awaitSingle() + if (!hasElevatedPerms) + return event.followupEphemeral(getCommonMsg("error.perms.elevated", settings)).awaitSingle() + + val guild = event.interaction.guild.awaitSingle() + val calendar = guild.getCalendar(calendarNumber).awaitSingleOrNull() + val calendarEvent = calendar?.getEvent(eventId)?.awaitSingleOrNull() + + // Validate required conditions + if (calendar == null) + return event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)).awaitSingle() + if (calendarEvent == null) + return event.followupEphemeral(getCommonMsg("error.notFound.event", settings)).awaitSingle() + if (calendarEvent.isOver()) + return event.followupEphemeral(getCommonMsg("error.event.ended", settings)).awaitSingle() + + + var rsvp = rsvpService.getRsvp(guild.id, eventId) + rsvp = rsvpService.upsertRsvp(rsvp.copy(role = if (role.isEveryone) null else role.id)) + + val embed = embedService.rsvpListEmbed(calendarEvent, rsvp, settings) - val roleMono = Mono.justOrEmpty( - event.options[0].getOption("role") - .flatMap(ApplicationCommandInteractionOption::getValue) - ).flatMap(ApplicationCommandInteractionOptionValue::asRole) - - return Mono.zip(event.interaction.guild, roleMono).flatMap(function { guild, role -> - if (!settings.patronGuild) { - return@function event.followupEphemeral(getCommonMsg("error.patronOnly", settings)) - } - - Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasElevatedPermissions).flatMap { member -> - guild.getCalendar(calendarNumber).flatMap { cal -> - cal.getEvent(eventId).flatMap { calEvent -> - if (!calEvent.isOver()) { - calEvent.getRsvp().flatMap { rsvp -> - if (role.isEveryone) { - rsvp.clearRole(member.client.rest()) - .flatMap { calEvent.updateRsvp(it).thenReturn(it) } - .flatMap { RsvpEmbed.list(guild, settings, calEvent, it) } - .flatMap { - event.followupEphemeral(getMessage("role.success.remove", settings), it) - } - } else { - rsvp.setRole(role) - .flatMap { calEvent.updateRsvp(it).thenReturn(it) } - .flatMap { RsvpEmbed.list(guild, settings, calEvent, it) } - .flatMap { - event.followupEphemeral( - getMessage("role.success.set", settings, role.name), - it - ) - } - } - } - } else { - event.followupEphemeral(getCommonMsg("error.event.ended", settings)) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.event", settings))) - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings))) - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.elevated", settings))) - }) + return if (role.isEveryone) event.followupEphemeral(getMessage("role.success.remove", settings), embed).awaitSingle() + else event.followupEphemeral(getMessage("role.success.set", settings, role.name), embed).awaitSingle() } } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/RoleDeleteListener.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/RoleDeleteListener.kt index 1c8f2510c..37f8373b2 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/RoleDeleteListener.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/RoleDeleteListener.kt @@ -3,22 +3,23 @@ package org.dreamexposure.discal.client.listeners.discord import discord4j.common.util.Snowflake import discord4j.core.event.domain.role.RoleDeleteEvent import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.dreamexposure.discal.core.business.RsvpService import org.dreamexposure.discal.core.database.DatabaseManager import org.springframework.stereotype.Component -import reactor.core.publisher.Mono @Component -class RoleDeleteListener : EventListener { +class RoleDeleteListener( + private val rsvpService: RsvpService, +) : EventListener { override suspend fun handle(event: RoleDeleteEvent) { - val updateRsvps = DatabaseManager.removeRsvpRole(event.guildId, event.roleId) + rsvpService.removeRoleForAll(event.guildId, event.roleId) - val updateControlRole = DatabaseManager.getSettings(event.guildId) + DatabaseManager.getSettings(event.guildId) .filter { !"everyone".equals(it.controlRole, true) } .filter { event.roleId == Snowflake.of(it.controlRole) } .doOnNext { it.controlRole = "everyone" } .flatMap(DatabaseManager::updateSettings) - - Mono.`when`(updateRsvps, updateControlRole).awaitSingleOrNull() + .awaitSingleOrNull() } } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/RsvpEmbed.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/RsvpEmbed.kt deleted file mode 100644 index ed6924766..000000000 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/RsvpEmbed.kt +++ /dev/null @@ -1,82 +0,0 @@ -package org.dreamexposure.discal.client.message.embed - -import discord4j.core.`object`.entity.Guild -import discord4j.core.`object`.entity.Member -import discord4j.core.`object`.entity.Role -import discord4j.core.spec.EmbedCreateSpec -import org.dreamexposure.discal.core.`object`.GuildSettings -import org.dreamexposure.discal.core.`object`.event.RsvpData -import org.dreamexposure.discal.core.entities.Event -import org.dreamexposure.discal.core.extensions.asStringList -import org.dreamexposure.discal.core.extensions.discord4j.getMembersFromId -import reactor.core.publisher.Mono -import reactor.function.TupleUtils - -object RsvpEmbed : EmbedMaker { - fun list(guild: Guild, settings: GuildSettings, event: Event): Mono { - return event.getRsvp().flatMap { list(guild, settings, event, it) } - } - - fun list(guild: Guild, settings: GuildSettings, event: Event, rsvp: RsvpData): Mono { - val roleMono = Mono.justOrEmpty(rsvp.roleId) - .flatMap { guild.getRoleById(it) } - .map(Role::getName) - .defaultIfEmpty("None") - - val onTimeMono = guild.getMembersFromId(rsvp.goingOnTime) - .map(Member::getUsername) - .collectList() - .map(MutableList::asStringList) - .map { it.ifEmpty { "N/a" } } - - val lateMono = guild.getMembersFromId(rsvp.goingLate) - .map(Member::getUsername) - .collectList() - .map(MutableList::asStringList) - .map { it.ifEmpty { "N/a" } } - - val undecidedMono = guild.getMembersFromId(rsvp.undecided) - .map(Member::getUsername) - .collectList() - .map(MutableList::asStringList) - .map { it.ifEmpty { "N/a" } } - - val notMono = guild.getMembersFromId(rsvp.notGoing) - .map(Member::getUsername) - .collectList() - .map(MutableList::asStringList) - .map { it.ifEmpty { "N/a" } } - - // Wait list users (show up to 3, with (+X) if there are more) - val display = 3 - val waitListMono = guild.getMembersFromId(rsvp.waitlist.take(display)) - .map(Member::getUsername) - .collectList() - .map { list -> - if (rsvp.waitlist.size > display) "${list.asStringList()} +${rsvp.waitlist.size - display} more" - else if (list.isNotEmpty()) list.asStringList() - else "N/a" - } - - return Mono.zip(roleMono, onTimeMono, lateMono, undecidedMono, notMono, waitListMono) - .map(TupleUtils.function { role, onTime, late, undecided, notGoing, waitList -> - val limitValue = if (rsvp.limit < 0) { - getMessage("rsvp", "list.field.limit.value", settings, "${rsvp.getCurrentCount()}") - } else "${rsvp.getCurrentCount()}/${rsvp.limit}" - - defaultBuilder(guild, settings) - .color(event.color.asColor()) - .title(getMessage("rsvp", "list.title", settings)) - .addField(getMessage("rsvp", "list.field.event", settings), rsvp.eventId, false) - .addField(getMessage("rsvp", "list.field.limit", settings), limitValue, true) - .addField(getMessage("rsvp", "list.field.role", settings), role, true) - .addField(getMessage("rsvp", "list.field.onTime", settings), onTime, false) - .addField(getMessage("rsvp", "list.field.late", settings), late, false) - .addField(getMessage("rsvp", "list.field.unsure", settings), undecided, false) - .addField(getMessage("rsvp", "list.field.notGoing", settings), notGoing, false) - .addField(getMessage("rsvp", "list.field.waitList", settings), waitList, false) - .footer(getMessage("rsvp", "list.footer", settings), null) - .build() - }) - } -} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt new file mode 100644 index 000000000..180e66f66 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt @@ -0,0 +1,150 @@ +package org.dreamexposure.discal.core.business + +import discord4j.common.util.Snowflake +import discord4j.core.DiscordClient +import discord4j.core.spec.EmbedCreateSpec +import kotlinx.coroutines.reactor.awaitSingle +import org.dreamexposure.discal.core.config.Config +import org.dreamexposure.discal.core.entities.Event +import org.dreamexposure.discal.core.enums.time.DiscordTimestampFormat +import org.dreamexposure.discal.core.extensions.asDiscordTimestamp +import org.dreamexposure.discal.core.extensions.discord4j.getCalendar +import org.dreamexposure.discal.core.extensions.discord4j.getSettings +import org.dreamexposure.discal.core.extensions.embedFieldSafe +import org.dreamexposure.discal.core.extensions.toMarkdown +import org.dreamexposure.discal.core.`object`.GuildSettings +import org.dreamexposure.discal.core.`object`.new.Rsvp +import org.dreamexposure.discal.core.utils.GlobalVal +import org.dreamexposure.discal.core.utils.getCommonMsg +import org.dreamexposure.discal.core.utils.getEmbedMessage +import org.springframework.beans.factory.BeanFactory +import org.springframework.beans.factory.getBean +import org.springframework.stereotype.Component + +@Component +class EmbedService( + private val beanFactory: BeanFactory, +) { + private val discordClient: DiscordClient + get() = beanFactory.getBean() + + + private suspend fun defaultEmbedBuilder(settings: GuildSettings): EmbedCreateSpec.Builder { + val guild = discordClient.getGuildById(settings.guildID).data.awaitSingle() + + val iconUrl = if (settings.branded && guild.icon().isPresent) + "${GlobalVal.discordCdnUrl}/icons/${settings.guildID.asString()}/${guild.icon().get()}.png" + else GlobalVal.iconUrl + + return EmbedCreateSpec.builder() + .author( + if (settings.branded) guild.name() else getCommonMsg("bot.name", settings), + Config.URL_BASE.getString(), + iconUrl + ) + } + + suspend fun rsvpDmFollowupEmbed(rsvp: Rsvp, userId: Snowflake): EmbedCreateSpec { + // TODO: These will be replaced by service calls eventually as I migrate components over to new patterns + val restGuild = discordClient.getGuildById(rsvp.guildId) + val guildData = restGuild.data.awaitSingle() + val guildSettings = restGuild.getSettings().awaitSingle() + val event = restGuild.getCalendar(rsvp.calendarNumber) + .flatMap { it.getEvent(rsvp.eventId) }.awaitSingle() + + + val iconUrl = if (guildData.icon().isPresent) + "${GlobalVal.discordCdnUrl}/icons/${rsvp.guildId.asString()}/${guildData.icon().get()}.png" + else GlobalVal.iconUrl + + val builder = EmbedCreateSpec.builder() + // Even without branding enabled, we want the user to know what guild this is because it's in DMs + .author(guildData.name(), Config.URL_BASE.getString(), iconUrl) + .title(getEmbedMessage("rsvp", "waitlist.title", guildSettings)) + .description(getEmbedMessage("rsvp", "waitlist.desc", guildSettings, userId.asString(), event.name)) + .addField( + getEmbedMessage("rsvp", "waitlist.field.start", guildSettings), + event.start.asDiscordTimestamp(DiscordTimestampFormat.LONG_DATETIME), + true + ).addField( + getEmbedMessage("rsvp", "waitlist.field.end", guildSettings), + event.end.asDiscordTimestamp(DiscordTimestampFormat.LONG_DATETIME), + true + ).footer(getEmbedMessage("rsvp", "waitlist.footer", guildSettings, event.eventId), null) + + if (event.location.isNotBlank()) builder.addField( + getEmbedMessage("rsvp", "waitlist.field.location", guildSettings), + event.location.toMarkdown().embedFieldSafe(), + false + ) + + if (event.image.isNotBlank()) builder.thumbnail(event.image) + + + return builder.build() + } + + suspend fun rsvpListEmbed(event: Event, rsvp: Rsvp, settings: GuildSettings): EmbedCreateSpec { + val waitlistDisplayLimit = Config.EMBED_RSVP_WAITLIST_DISPLAY_LENGTH.getInt() + + val role = if (rsvp.role != null) "<@&${rsvp.role.asString()}>" else "None" + + val goingOnTime = rsvp.goingOnTime.map { + discordClient.getUserById(it).data.awaitSingle() + }.joinToString(", ") { + it.globalName().orElse(it.username()) + }.ifEmpty { "N/a" } + + val late = rsvp.goingLate.map { + discordClient.getUserById(it).data.awaitSingle() + }.joinToString(", ") { + it.globalName().orElse(it.username()) + }.ifEmpty { "N/a" } + + val undecided = rsvp.undecided.map { + discordClient.getUserById(it).data.awaitSingle() + }.joinToString(", ") { + it.globalName().orElse(it.username()) + }.ifEmpty { "N/a" } + + val notGoing = rsvp.notGoing.map { + discordClient.getUserById(it).data.awaitSingle() + }.joinToString(", ") { + it.globalName().orElse(it.username()) + }.ifEmpty { "N/a" } + + val waitList = if (rsvp.waitlist.size > waitlistDisplayLimit) { + rsvp.waitlist.map { + discordClient.getUserById(it).data.awaitSingle() + }.joinToString(", ") { + it.globalName().orElse(it.username()) + }.plus("+${rsvp.waitlist.size - waitlistDisplayLimit} more") + } else { + rsvp.waitlist.map { + discordClient.getUserById(it).data.awaitSingle() + }.joinToString(", ") { + it.globalName().orElse(it.username()) + }.ifEmpty { "N/a" } + } + + val limitValue = if (rsvp.limit < 0) { + getEmbedMessage("rsvp", "list.field.limit.value", settings, "${rsvp.getCurrentCount()}") + } else "${rsvp.getCurrentCount()}/${rsvp.limit}" + + + + return defaultEmbedBuilder(settings) + .color(event.color.asColor()) + .title(getEmbedMessage("rsvp", "list.title", settings)) + .addField(getEmbedMessage("rsvp", "list.field.event", settings), rsvp.eventId, false) + .addField(getEmbedMessage("rsvp", "list.field.limit", settings), limitValue, true) + .addField(getEmbedMessage("rsvp", "list.field.role", settings), role, true) + .addField(getEmbedMessage("rsvp", "list.field.onTime", settings), goingOnTime, false) + .addField(getEmbedMessage("rsvp", "list.field.late", settings), late, false) + .addField(getEmbedMessage("rsvp", "list.field.unsure", settings), undecided, false) + .addField(getEmbedMessage("rsvp", "list.field.notGoing", settings), notGoing, false) + .addField(getEmbedMessage("rsvp", "list.field.waitList", settings), waitList, false) + .footer(getEmbedMessage("rsvp", "list.footer", settings), null) + .build() + } +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/RsvpService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/RsvpService.kt new file mode 100644 index 000000000..39caaea00 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/RsvpService.kt @@ -0,0 +1,207 @@ +package org.dreamexposure.discal.core.business + +import discord4j.common.util.Snowflake +import discord4j.core.DiscordClient +import discord4j.rest.http.client.ClientException +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.dreamexposure.discal.RsvpCache +import org.dreamexposure.discal.core.database.RsvpData +import org.dreamexposure.discal.core.database.RsvpRepository +import org.dreamexposure.discal.core.exceptions.NotFoundException +import org.dreamexposure.discal.core.extensions.asStringList +import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.`object`.new.Rsvp +import org.dreamexposure.discal.core.utils.GlobalVal +import org.springframework.beans.factory.BeanFactory +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono + +@Component +class RsvpService( + private val rsvpRepository: RsvpRepository, + private val rsvpCache: RsvpCache, + private val embedService: EmbedService, + private val beanFactory: BeanFactory, +) { + private val discordClient: DiscordClient + get() = beanFactory.getBean(DiscordClient::class.java) + + suspend fun getRsvp(guildId: Snowflake, eventId: String): Rsvp { + var rsvp = rsvpCache.get(guildId, eventId) + if (rsvp != null) return rsvp + + rsvp = rsvpRepository.findByGuildIdAndEventId(guildId.asLong(), eventId) + .map(::Rsvp) + .defaultIfEmpty(Rsvp(guildId, eventId)) + .awaitSingle() + + rsvpCache.put(guildId, eventId, rsvp) + return rsvp + } + + suspend fun createRsvp(rsvp: Rsvp): Rsvp { + LOGGER.debug("Creating new rsvp data for guild:{} event:{}", rsvp.guildId.asString(), rsvp.eventId) + + val saved = rsvpRepository.save( + RsvpData( + guildId = rsvp.guildId.asLong(), + eventId = rsvp.eventId, + calendarNumber = rsvp.calendarNumber, + + eventEnd = rsvp.eventEnd.toEpochMilli(), + + goingOnTime = rsvp.goingOnTime.map(Snowflake::asString).asStringList(), + goingLate = rsvp.goingLate.map(Snowflake::asString).asStringList(), + notGoing = rsvp.notGoing.map(Snowflake::asString).asStringList(), + undecided = rsvp.undecided.map(Snowflake::asString).asStringList(), + waitlist = rsvp.waitlist.map(Snowflake::asString).asStringList(), + + rsvpLimit = rsvp.limit.coerceAtLeast(-1), + rsvpRole = rsvp.role?.asLong(), + ) + ).map(::Rsvp).awaitSingle() + + rsvpCache.put(rsvp.guildId, rsvp.eventId, saved) + return saved + } + + suspend fun updateRsvp(rsvp: Rsvp): Rsvp { + LOGGER.debug("Updating rsvp data for guild:{} event:{}", rsvp.guildId.asString(), rsvp.eventId) + + var new = rsvp.copy(limit = rsvp.limit.coerceAtLeast(-1)) + val old = getRsvp(new.guildId, new.eventId) + + val removeOldRoleFrom = mutableSetOf() + val addNewRoleTo = mutableSetOf() + val toDm = mutableSetOf() + + // Validate that role exists if changed + if (new.role != null && old.role != new.role) { + val exists = discordClient.getRoleById(new.guildId, new.role!!).data + .transform(ClientException.emptyOnStatus(GlobalVal.STATUS_NOT_FOUND)) + .hasElement() + .awaitSingle() + if (!exists) throw NotFoundException("Role not found for guild:${new.guildId.asString()} role:${new.role!!.asString()}") + } + + // Handle role change (remove roles, store to-add in list for later) + if (old.role != null && old.role != new.role) { + // Need to remove old role from all users going to event + removeOldRoleFrom.addAll(old.goingOnTime) + removeOldRoleFrom.addAll(old.goingLate) + } + if (new.role != null && new.role != old.role) { + // Need to add new role to all users going to event + addNewRoleTo.addAll(new.goingOnTime) + addNewRoleTo.addAll(new.goingLate) + } + + // Handle removals (first just in case they are using the limit) + if (old.role != null) { + removeOldRoleFrom += old.goingOnTime.filterNot(new.goingOnTime::contains) + removeOldRoleFrom += old.goingLate.filterNot(new.goingLate::contains) + } + + // Handle additions (add these users to role-add list) + if (new.role != null) { + addNewRoleTo += new.goingOnTime.filterNot(old.goingOnTime::contains) + addNewRoleTo += new.goingLate.filterNot(old.goingLate::contains) + } + + // Handle waitlist in order + while (new.hasRoom() && new.waitlist.isNotEmpty()) { + val userId = new.waitlist.first() + + new = new.copy(waitlist = new.waitlist.drop(1), goingOnTime = new.goingOnTime + userId) + addNewRoleTo += userId + toDm += userId + } + + // Update db + rsvpRepository.updateByGuildIdAndEventId( + guildId = new.guildId.asLong(), + eventId = new.eventId, + calendarNumber = new.calendarNumber, + + eventEnd = new.eventEnd.toEpochMilli(), + + goingOnTime = new.goingOnTime.map(Snowflake::asString).asStringList(), + goingLate = new.goingLate.map(Snowflake::asString).asStringList(), + notGoing = new.notGoing.map(Snowflake::asString).asStringList(), + undecided = new.undecided.map(Snowflake::asString).asStringList(), + waitlist = new.waitlist.map(Snowflake::asString).asStringList(), + + rsvpLimit = new.limit, + rsvpRole = new.role?.asLong(), + ).awaitSingleOrNull() + + rsvpCache.put(new.guildId, new.eventId, new) + + + // Do Discord actions + + // Do role removal + removeOldRoleFrom.forEach { userId -> + discordClient.getGuildById(new.guildId) + .removeMemberRole(userId, old.role, "Removed RSVP to event with ID ${new.eventId}") + .doOnError { + LOGGER.debug( + "Failed to remove role:${old.role?.asString()} from user:${userId.asString()}", + it + ) + } + .onErrorResume(ClientException::class.java) { Mono.empty() } + .subscribe() + } + + // Do role adds + addNewRoleTo.forEach { userId -> + discordClient.getGuildById(new.guildId) + .addMemberRole(userId, new.role, "RSVP'd to event with ID: ${new.eventId}") + .doOnError { + LOGGER.debug( + "Failed to add role:${old.role?.asString()} to user:${userId.asString()}", + it + ) + } + .onErrorResume(ClientException::class.java) { Mono.empty() } + .subscribe() + } + + // Send out DMs + toDm.forEach { userId -> + val embed = embedService.rsvpDmFollowupEmbed(new, userId) + + discordClient.getUserById(userId).privateChannel.flatMap { channelData -> + discordClient.getChannelById(Snowflake.of(channelData.id())) + .createMessage(embed.asRequest()) + }.doOnError { + LOGGER.error("Failed to DM user for RSVP followup for event:${new.eventId}", it) + }.onErrorResume { + Mono.empty() + }.subscribe() + } + + return new + } + + suspend fun upsertRsvp(rsvp: Rsvp): Rsvp { + val exists = rsvpRepository.existsByGuildIdAndEventId(rsvp.guildId.asLong(), rsvp.eventId).awaitSingle() + + return if (exists) updateRsvp(rsvp) else createRsvp(rsvp) + } + + + suspend fun removeRoleForAll(guildId: Snowflake, roleId: Snowflake) { + LOGGER.debug("Removing role:{} from all rsvp data for guild:{}", roleId.asString(), guildId.asString()) + + rsvpRepository.removeRoleByGuildIdAndRsvpRole(guildId.asLong(), roleId.asLong()).awaitSingleOrNull() + + rsvpCache.getAll(guildId) + .filter { it.role == roleId } + .map { it.copy(role = null) } + .forEach { rsvpCache.put(guildId, it.eventId, it) } + } + +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt index 2c28e1e98..fbee9e5a7 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import org.dreamexposure.discal.CalendarCache import org.dreamexposure.discal.CredentialsCache import org.dreamexposure.discal.OauthStateCache +import org.dreamexposure.discal.RsvpCache import org.dreamexposure.discal.core.cache.JdkCacheRepository import org.dreamexposure.discal.core.cache.RedisStringCacheRepository import org.dreamexposure.discal.core.extensions.asMinutes @@ -18,6 +19,7 @@ class CacheConfig { private val credentialsTll = Config.CACHE_TTL_CREDENTIALS_MINUTES.getLong().asMinutes() private val oauthStateTtl = Config.CACHE_TTL_OAUTH_STATE_MINUTES.getLong().asMinutes() private val calendarTtl = Config.CACHE_TTL_CALENDAR_MINUTES.getLong().asMinutes() + private val rsvpTtl = Config.CACHE_TTL_RSVP_MINUTES.getLong().asMinutes() // Redis caching @@ -39,6 +41,12 @@ class CacheConfig { fun calendarRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): CalendarCache = RedisStringCacheRepository(objectMapper, redisTemplate, "Calendars", calendarTtl) + @Bean + @Primary + @ConditionalOnProperty("bot.cache.redis", havingValue = "true") + fun rsvpRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): RsvpCache = + RedisStringCacheRepository(objectMapper, redisTemplate, "Rsvps", rsvpTtl) + // In-memory fallback caching @Bean @@ -49,4 +57,7 @@ class CacheConfig { @Bean fun calendarFallbackCache(): CalendarCache = JdkCacheRepository(calendarTtl) + + @Bean + fun rsvpFallbackCache(): RsvpCache = JdkCacheRepository(rsvpTtl) } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt index 334b64070..cbf5070bd 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt @@ -24,7 +24,8 @@ enum class Config(private val key: String, private var value: Any? = null) { CACHE_TTL_CREDENTIALS_MINUTES("bot.cache.ttl-minutes.credentials", 120), CACHE_TTL_ACCOUNTS_MINUTES("bot.cache.ttl-minutes.accounts", 60), CACHE_TTL_OAUTH_STATE_MINUTES("bot.cache.ttl-minutes.oauth.state", 5), - CACHE_TTL_CALENDAR_MINUTES("bots.cache.ttl-minutes.calendar", 120), + CACHE_TTL_CALENDAR_MINUTES("bot.cache.ttl-minutes.calendar", 120), + CACHE_TTL_RSVP_MINUTES("bot.cache.ttl-minutes.rsvp", 60), // Security configuration @@ -52,6 +53,9 @@ enum class Config(private val key: String, private var value: Any? = null) { URL_INVITE("bot.url.invite"), URL_DISCORD_REDIRECT("bot.url.discord.redirect"), + // UI and UX + EMBED_RSVP_WAITLIST_DISPLAY_LENGTH("bot.ui.embed.rsvp.waitlist.length", 3), + // Everything else SHARD_COUNT("bot.sharding.count"), SHARD_INDEX("bot.sharding.index", 0), diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt index b32bd440f..74bdc1bcc 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt @@ -25,7 +25,6 @@ import org.dreamexposure.discal.core.`object`.StaticMessage import org.dreamexposure.discal.core.`object`.announcement.Announcement import org.dreamexposure.discal.core.`object`.calendar.CalendarData import org.dreamexposure.discal.core.`object`.event.EventData -import org.dreamexposure.discal.core.`object`.event.RsvpData import org.dreamexposure.discal.core.`object`.web.UserAPIAccount import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT import org.intellij.lang.annotations.Language @@ -378,83 +377,6 @@ object DatabaseManager { } } - fun updateRsvpData(data: RsvpData): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_RSVP_BY_GUILD) - .bind(0, data.guildId.asLong()) - .bind(1, data.eventId) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> row } - }.hasElements().flatMap { exists -> - if (exists) { - val updateCommand = """UPDATE ${Tables.RSVP} SET - CALENDAR_NUMBER = ?, EVENT_END = ?, GOING_ON_TIME = ?, GOING_LATE = ?, - NOT_GOING = ?, UNDECIDED = ?, waitlist = ?, RSVP_LIMIT = ?, RSVP_ROLE = ? - WHERE EVENT_ID = ? AND GUILD_ID = ? - """.trimMargin() - - Mono.just( - c.createStatement(updateCommand) - .bind(0, data.calendarNumber) - .bind(1, data.eventEnd) - .bind(2, data.goingOnTime.asStringList()) - .bind(3, data.goingLate.asStringList()) - .bind(4, data.notGoing.asStringList()) - .bind(5, data.undecided.asStringList()) - .bind(6, data.waitlist.asStringList()) - .bind(7, data.limit) - //8 deal with nullable role below - .bind(9, data.eventId) - .bind(10, data.guildId.asLong()) - ).doOnNext { statement -> - if (data.roleId == null) - statement.bindNull(8, Long::class.java) - else - statement.bind(8, data.roleId!!.asLong()) - }.flatMap { - Mono.from(it.execute()) - }.flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - } else if (data.shouldBeSaved()) { - val insertCommand = """INSERT INTO ${Tables.RSVP} - (GUILD_ID, EVENT_ID, CALENDAR_NUMBER, EVENT_END, GOING_ON_TIME, GOING_LATE, - NOT_GOING, UNDECIDED, waitlist, RSVP_LIMIT, RSVP_ROLE) - VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """.trimMargin() - - Mono.just( - c.createStatement(insertCommand) - .bind(0, data.guildId.asLong()) - .bind(1, data.eventId) - .bind(2, data.calendarNumber) - .bind(3, data.eventEnd) - .bind(4, data.goingOnTime.asStringList()) - .bind(5, data.goingLate.asStringList()) - .bind(6, data.notGoing.asStringList()) - .bind(7, data.undecided.asStringList()) - .bind(8, data.waitlist.asStringList()) - .bind(9, data.limit) - ).doOnNext { statement -> - if (data.roleId == null) - statement.bindNull(10, Long::class.java) - else - statement.bind(10, data.roleId!!.asLong()) - }.flatMap { - Mono.from(it.execute()) - }.flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - } else { - Mono.just(false) - }.doOnError { - LOGGER.error(DEFAULT, "Failed to update rsvp data", it) - }.onErrorResume { Mono.just(false) } - } - } - } fun getAPIAccount(APIKey: String): Mono { return connect { c -> @@ -664,44 +586,6 @@ object DatabaseManager { }.defaultIfEmpty(EventData(guildId, eventId = eventIdLookup)) } - fun getRsvpData(guildId: Snowflake, eventId: String): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_RSVP_BY_GUILD) - .bind(0, guildId.asLong()) - .bind(1, eventId) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val calNumber = row["CALENDAR_NUMBER", Int::class.java]!! - - val data = RsvpData(guildId, eventId, calNumber) - - data.eventEnd = row["EVENT_END", Long::class.java]!! - data.goingOnTime.setFromString(row["GOING_ON_TIME", String::class.java]!!) - data.goingLate.setFromString(row["GOING_LATE", String::class.java]!!) - data.notGoing.setFromString(row["NOT_GOING", String::class.java]!!) - data.undecided.setFromString(row["UNDECIDED", String::class.java]!!) - data.waitlist.setFromString(row["waitlist", String::class.java]!!) - data.limit = row["RSVP_LIMIT", Int::class.java]!! - - //Handle new rsvp role - if (row.get("RSVP_ROLE") != null) - data.setRole(Snowflake.of(row["RSVP_ROLE", Long::class.java]!!)) - - data - } - }.next().retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get rsvp data", it) - }.onErrorResume { - Mono.empty() - }.defaultIfEmpty(RsvpData(guildId, eventId)) - } - } - fun getAnnouncement(announcementId: String, guildId: Snowflake): Mono { return connect { c -> Mono.from( @@ -1095,23 +979,6 @@ object DatabaseManager { }.defaultIfEmpty(false) } - fun removeRsvpRole(guildId: Snowflake, roleId: Snowflake): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.REMOVE_RSVP_ROLE) - .bindNull(0, Long::class.java) - .bind(1, guildId.asLong()) - .bind(2, roleId.asLong()) - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - .doOnError { - LOGGER.error(DEFAULT, "Failed update all rsvp with role for guild ", it) - }.onErrorReturn(false) - }.defaultIfEmpty(false) - } - /* Utility Deletion Methods */ fun deleteCalendarAndRelatedData(calendarData: CalendarData): Mono { @@ -1476,11 +1343,6 @@ private object Queries { WHERE GUILD_ID = ? AND EVENT_ID = ? """.trimMargin() - @Language("MySQL") - val SELECT_RSVP_BY_GUILD = """SELECT * FROM ${Tables.RSVP} - WHERE GUILD_ID = ? AND EVENT_ID = ? - """.trimMargin() - @Language("MySQL") val SELECT_ANNOUNCEMENT_BY_GUILD = """SELECT * FROM ${Tables.ANNOUNCEMENTS} WHERE GUILD_ID = ? and ANNOUNCEMENT_ID = ? @@ -1558,12 +1420,6 @@ private object Queries { WHERE GUILD_ID = ? AND CALENDAR_NUMBER = ? """.trimMargin() - @Language("MySQL") - val REMOVE_RSVP_ROLE = """UPDATE ${Tables.RSVP} - SET RSVP_ROLE = ? - WHERE GUILD_ID = ? AND RSVP_ROLE = ? - """.trimMargin() - @Language("MySQL") val DELETE_CALENDAR = """DELETE FROM ${Tables.CALENDARS} WHERE GUILD_ID = ? AND calendar_number = ? diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/RsvpData.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/RsvpData.kt new file mode 100644 index 000000000..3d3d0c687 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/RsvpData.kt @@ -0,0 +1,18 @@ +package org.dreamexposure.discal.core.database + +import org.springframework.data.relational.core.mapping.Table + +@Table("rsvp") +data class RsvpData( + val guildId: Long, + val eventId: String, + val calendarNumber: Int, + val eventEnd: Long, + val goingOnTime: String, + val goingLate: String, + val notGoing: String, + val undecided: String, + val waitlist: String, + val rsvpLimit: Int, + val rsvpRole: Long?, +) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/RsvpRepository.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/RsvpRepository.kt new file mode 100644 index 000000000..038e33296 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/RsvpRepository.kt @@ -0,0 +1,47 @@ +package org.dreamexposure.discal.core.database + +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.r2dbc.repository.R2dbcRepository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +interface RsvpRepository: R2dbcRepository { + fun existsByGuildIdAndEventId(guildId: Long, eventId: String): Mono + + fun findByGuildIdAndEventId(guildId: Long, eventId: String): Mono + + + @Query(""" + UPDATE rsvp + SET calendar_number = :calendarNumber, + event_end = :eventEnd, + going_on_time = :goingOnTime, + going_late = :goingLate, + not_going = :notGoing, + undecided = :undecided, + waitlist = :waitlist, + rsvp_limit = :rsvpLimit, + rsvp_role = :rsvpRole + WHERE guild_id = :guildId AND event_id = :eventId + """) + fun updateByGuildIdAndEventId( + guildId: Long, + eventId: String, + calendarNumber: Int, + eventEnd: Long, + goingOnTime: String, + goingLate: String, + notGoing: String, + undecided: String, + waitlist: String, + rsvpLimit: Int, + rsvpRole: Long?, + ): Mono + + @Query(""" + UPDATE rsvp + SET rsvp_role = null + WHERE guild_id = :guildId AND rsvp_role = :rsvpRole + """) + fun removeRoleByGuildIdAndRsvpRole(guildId: Long, rsvpRole: Long): Mono +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/entities/Event.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/entities/Event.kt index fb37abc68..820b651f6 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/entities/Event.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/entities/Event.kt @@ -11,7 +11,6 @@ import org.dreamexposure.discal.core.enums.event.EventColor import org.dreamexposure.discal.core.`object`.announcement.Announcement import org.dreamexposure.discal.core.`object`.event.EventData import org.dreamexposure.discal.core.`object`.event.Recurrence -import org.dreamexposure.discal.core.`object`.event.RsvpData import org.dreamexposure.discal.core.utils.GlobalVal.JSON_FORMAT import org.json.JSONObject import reactor.core.publisher.Flux @@ -124,15 +123,6 @@ interface Event { } } - /** - * Attempts to request the [RsvpData] of the event. - * If an error occurs, it is emitted through the Mono. - * - * @return A [Mono] containing the [RsvpData] of the event - */ - fun getRsvp(): Mono = DatabaseManager.getRsvpData(guildId, eventId) - - fun updateRsvp(rsvp: RsvpData) = DatabaseManager.updateRsvpData(rsvp) /** * Attempts to update the event and returns the result. diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/exceptions/NotFoundException.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/exceptions/NotFoundException.kt index 59f882b57..af5d02fac 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/exceptions/NotFoundException.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/exceptions/NotFoundException.kt @@ -1,3 +1,3 @@ package org.dreamexposure.discal.core.exceptions -class NotFoundException: Exception("resource not found") +class NotFoundException(message: String? = null): Exception(message ?: message ?: "resource not found") diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Rsvp.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Rsvp.kt new file mode 100644 index 000000000..db62e0fdd --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Rsvp.kt @@ -0,0 +1,67 @@ +package org.dreamexposure.discal.core.`object`.new + +import discord4j.common.util.Snowflake +import org.dreamexposure.discal.core.database.RsvpData +import org.dreamexposure.discal.core.extensions.asInstantMilli +import org.dreamexposure.discal.core.extensions.asSnowflake +import org.dreamexposure.discal.core.extensions.asStringListFromDatabase +import java.time.Instant + +data class Rsvp( + val guildId: Snowflake, + val eventId: String, + val calendarNumber: Int = 1, + + val eventEnd: Instant = Instant.MIN, + + val goingOnTime: Set = setOf(), + val goingLate: Set = setOf(), + val notGoing: Set = setOf(), + val undecided: Set = setOf(), + val waitlist: List = listOf(), + + val limit: Int = -1, + val role: Snowflake? = null, +) { + + constructor(data: RsvpData) : this( + guildId = data.guildId.asSnowflake(), + eventId = data.eventId, + calendarNumber = data.calendarNumber, + + eventEnd = data.eventEnd.asInstantMilli(), + + goingOnTime = data.goingOnTime.asStringListFromDatabase().map(Snowflake::of).toSet(), + goingLate = data.goingLate.asStringListFromDatabase().map(Snowflake::of).toSet(), + notGoing = data.notGoing.asStringListFromDatabase().map(Snowflake::of).toSet(), + undecided = data.undecided.asStringListFromDatabase().map(Snowflake::of).toSet(), + waitlist = data.waitlist.asStringListFromDatabase().map(Snowflake::of), + + limit = data.rsvpLimit, + role = data.rsvpRole?.asSnowflake(), + ) + + fun getCurrentCount() = this.goingOnTime.size + this.goingLate.size + + fun hasRoom() = limit < 0 || getCurrentCount() < limit + + fun hasRoom(userId: Snowflake) = hasRoom() || (goingOnTime.contains(userId) || goingLate.contains(userId)) + + fun copyWithUserStatus( + userId: Snowflake, + goingOnTime: Set? = null, + goingLate: Set? = null, + notGoing: Set? = null, + undecided: Set? = null, + waitlist: List? = null, + ): Rsvp { + return this.copy( + goingOnTime = goingOnTime ?: (this.goingOnTime - userId), + goingLate = goingLate ?: (this.goingLate - userId), + notGoing = notGoing ?: (this.notGoing - userId), + undecided = undecided ?: (this.undecided - userId), + waitlist = waitlist ?: (this.waitlist - userId), + ) + } + +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/security/Scope.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/security/Scope.kt index 4ada3f211..e8163cd5d 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/security/Scope.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/security/Scope.kt @@ -3,6 +3,9 @@ package org.dreamexposure.discal.core.`object`.new.security enum class Scope { CALENDAR_TOKEN_READ, + EVENT_RSVP_READ, + EVENT_RSVP_WRITE, + OAUTH2_DISCORD, INTERNAL_CAM_VALIDATE_TOKEN, diff --git a/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt b/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt index 0d973422b..5b4bddf58 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt @@ -4,9 +4,11 @@ import discord4j.common.util.Snowflake import org.dreamexposure.discal.core.cache.CacheRepository import org.dreamexposure.discal.core.`object`.new.Calendar import org.dreamexposure.discal.core.`object`.new.Credential +import org.dreamexposure.discal.core.`object`.new.Rsvp // Cache //typealias GuildSettingsCache = CacheRepository typealias CredentialsCache = CacheRepository typealias OauthStateCache = CacheRepository typealias CalendarCache = CacheRepository> +typealias RsvpCache = CacheRepository diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/rsvp/GetRsvpEndpoint.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/rsvp/GetRsvpEndpoint.kt deleted file mode 100644 index 6c6e68081..000000000 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/rsvp/GetRsvpEndpoint.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.dreamexposure.discal.server.endpoints.v2.rsvp - -import discord4j.common.util.Snowflake -import kotlinx.serialization.encodeToString -import org.dreamexposure.discal.core.annotations.SecurityRequirement -import org.dreamexposure.discal.core.database.DatabaseManager -import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.utils.GlobalVal -import org.dreamexposure.discal.server.utils.Authentication -import org.dreamexposure.discal.server.utils.responseMessage -import org.json.JSONException -import org.json.JSONObject -import org.springframework.http.server.reactive.ServerHttpResponse -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -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 - -@RestController -@RequestMapping("/v2/rsvp") -class GetRsvpEndpoint { - @PostMapping("/get", produces = ["application/json"]) - @SecurityRequirement(disableSecurity = true, scopes = []) - fun getRsvp(swe: ServerWebExchange, response: ServerHttpResponse, @RequestBody rBody: String): Mono { - return Authentication.authenticate(swe).flatMap { authState -> - if (!authState.success) { - response.rawStatusCode = authState.status - return@flatMap Mono.just(GlobalVal.JSON_FORMAT.encodeToString(authState)) - } - - //Handle request - val body = JSONObject(rBody) - val guildId = Snowflake.of(body.getString("guild_id")) - val eventId = body.getString("event_id") - - return@flatMap DatabaseManager.getRsvpData(guildId, eventId) - .map { GlobalVal.JSON_FORMAT.encodeToString(it) } - .doOnNext { response.rawStatusCode = GlobalVal.STATUS_SUCCESS } - }.onErrorResume(JSONException::class.java) { - LOGGER.trace("[API-v2] JSON error. Bad request?", it) - - response.rawStatusCode = GlobalVal.STATUS_BAD_REQUEST - return@onErrorResume responseMessage("Bad Request") - }.onErrorResume { - LOGGER.error(GlobalVal.DEFAULT, "[API-v2] get RSVP error", it) - - response.rawStatusCode = GlobalVal.STATUS_INTERNAL_ERROR - return@onErrorResume responseMessage("Internal Server Error") - } - } -} diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/rsvp/UpdateRsvpEndpoint.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/rsvp/UpdateRsvpEndpoint.kt deleted file mode 100644 index 2f81b5526..000000000 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/rsvp/UpdateRsvpEndpoint.kt +++ /dev/null @@ -1,165 +0,0 @@ -package org.dreamexposure.discal.server.endpoints.v2.rsvp - -import discord4j.common.util.Snowflake -import discord4j.core.DiscordClient -import discord4j.rest.http.client.ClientException -import kotlinx.serialization.encodeToString -import org.dreamexposure.discal.core.annotations.SecurityRequirement -import org.dreamexposure.discal.core.database.DatabaseManager -import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.utils.GlobalVal -import org.dreamexposure.discal.server.utils.Authentication -import org.dreamexposure.discal.server.utils.responseMessage -import org.json.JSONException -import org.json.JSONObject -import org.springframework.http.server.reactive.ServerHttpResponse -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import org.springframework.web.server.ServerWebExchange -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono -import reactor.function.TupleUtils - -@RestController -@RequestMapping("/v2/rsvp") -class UpdateRsvpEndpoint(val client: DiscordClient) { - @PostMapping(value = ["/update"], produces = ["application/json"]) - @SecurityRequirement(disableSecurity = true, scopes = []) - fun updateRsvp(swe: ServerWebExchange, response: ServerHttpResponse, @RequestBody rBody: String): Mono { - return Authentication.authenticate(swe).flatMap { authState -> - if (!authState.success) { - response.rawStatusCode = authState.status - return@flatMap Mono.just(GlobalVal.JSON_FORMAT.encodeToString(authState)) - } else if (authState.readOnly) { - response.rawStatusCode = GlobalVal.STATUS_AUTHORIZATION_DENIED - return@flatMap responseMessage("Read-Only key not allowed") - } - - //Handle request - val body = JSONObject(rBody) - val guildId = Snowflake.of(body.getString("guild_id")) - val eventId = body.getString("event_id") - - val rsvpMono = DatabaseManager.getRsvpData(guildId, eventId) - val settingsMono = DatabaseManager.getSettings(guildId) - Mono.zip(rsvpMono, settingsMono).flatMap(TupleUtils.function { rsvp, settings -> - //Handle limit change - rsvp.limit = body.optInt("limit", rsvp.limit) - - //Handle role change - val roleChangeMono: Mono = Mono.just(body).filter { - it.has("role_id") && (settings.patronGuild || settings.devGuild) - }.flatMap { jsonBody -> - if (jsonBody.isNull("role_id") || jsonBody.getString("role_id").equals("none", true)) { - rsvp.clearRole(client) - } else { - val roleId = Snowflake.of(jsonBody.getString("role_id")) - - client.getRoleById(guildId, roleId).data - .transform(ClientException.emptyOnStatus(GlobalVal.STATUS_NOT_FOUND)) - .hasElement() - .then(rsvp.setRole(roleId, client)) - } - }.then() - - //Handle removals (we do this first just in case they are using the limit) - val removalMono: Mono = Mono.just(body).filter { - it.has("to_remove") - }.map { - it.getJSONObject("to_remove") - }.flatMap { toRemoveJson -> - val toRemove: MutableList = mutableListOf() - - if (toRemoveJson.has("on_time")) { - val ar = toRemoveJson.getJSONArray("on_time") - (0 until ar.length()).forEach { toRemove.add(ar.getString(it)) } - } - if (toRemoveJson.has("late")) { - val ar = toRemoveJson.getJSONArray("late") - (0 until ar.length()).forEach { toRemove.add(ar.getString(it)) } - } - if (toRemoveJson.has("not_going")) { - val ar = toRemoveJson.getJSONArray("not_going") - (0 until ar.length()).forEach { toRemove.add(ar.getString(it)) } - } - if (toRemoveJson.has("undecided")) { - val ar = toRemoveJson.getJSONArray("undecided") - (0 until ar.length()).forEach { toRemove.add(ar.getString(it)) } - } - - Flux.fromIterable(toRemove).flatMap { rsvp.removeCompletely(it, client) }.then() - } - - //Handle additions - val addMono: Mono = Mono.just(body).filter { - it.has("to_add") - }.map { - it.getJSONObject("to_add") - }.flatMap { toAddJson -> - val allTheMonos = mutableListOf>() - - if (toAddJson.has("on_time")) { - val ar = toAddJson.getJSONArray("on_time") - for (i in 0 until ar.length()) { - if (rsvp.hasRoom(ar.getString(i))) { - allTheMonos.add(rsvp.removeCompletely(ar.getString(i), client) - .then(rsvp.addGoingOnTime(ar.getString(i), client))) - } - } - } - - if (toAddJson.has("late")) { - val ar = toAddJson.getJSONArray("late") - for (i in 0 until ar.length()) { - if (rsvp.hasRoom(ar.getString(i))) { - allTheMonos.add(rsvp.removeCompletely(ar.getString(i), client) - .then(rsvp.addGoingLate(ar.getString(i), client))) - } - } - } - - if (toAddJson.has("not_going")) { - val ar = toAddJson.getJSONArray("on_time") - for (i in 0 until ar.length()) { - if (rsvp.hasRoom(ar.getString(i))) { - allTheMonos.add(rsvp.removeCompletely(ar.getString(i), client) - .then(Mono.from { rsvp.notGoing.add(ar.getString(i)) })) - } - } - } - - if (toAddJson.has("undecided")) { - val ar = toAddJson.getJSONArray("undecided") - for (i in 0 until ar.length()) { - if (rsvp.hasRoom(ar.getString(i))) { - allTheMonos.add(rsvp.removeCompletely(ar.getString(i), client) - .then(Mono.from { rsvp.undecided.add(ar.getString(i)) })) - } - } - } - - Flux.fromIterable(allTheMonos).then() - } - - - //Honestly no fucking idea if this will work, like at all. - roleChangeMono.then(removalMono).then(addMono).then(DatabaseManager.updateRsvpData(rsvp)) - }) - .then(responseMessage("Success!")) - .doOnNext { response.rawStatusCode = GlobalVal.STATUS_SUCCESS } - }.onErrorResume(JSONException::class.java) { - LOGGER.trace("[API-v2] JSON error. Bad request?", it) - - response.rawStatusCode = GlobalVal.STATUS_BAD_REQUEST - return@onErrorResume responseMessage("Bad Request") - }.onErrorResume { - LOGGER.error(GlobalVal.DEFAULT, "[API-v2] Update RSVP error", it) - - response.rawStatusCode = GlobalVal.STATUS_INTERNAL_ERROR - return@onErrorResume responseMessage("Internal Server Error") - } - } - -} diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/RsvpController.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/RsvpController.kt new file mode 100644 index 000000000..c995a06b1 --- /dev/null +++ b/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/RsvpController.kt @@ -0,0 +1,30 @@ +package org.dreamexposure.discal.server.endpoints.v3 + +import discord4j.common.util.Snowflake +import org.dreamexposure.discal.core.annotations.SecurityRequirement +import org.dreamexposure.discal.core.business.RsvpService +import org.dreamexposure.discal.core.`object`.new.Rsvp +import org.dreamexposure.discal.core.`object`.new.security.Scope +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/v3/guilds/{guildId}/events/{eventId}/rsvp") +class RsvpController( + private val rsvpService: RsvpService, +) { + + @SecurityRequirement(scopes = [Scope.EVENT_RSVP_READ]) + @GetMapping(produces = ["application/json"]) + suspend fun getRsvp(@PathVariable guildId: Snowflake, @PathVariable eventId: String): Rsvp { + // TODO: Need way to check if authenticated user has access to this guild + return rsvpService.getRsvp(guildId, eventId) + } + + + @SecurityRequirement(scopes = [Scope.EVENT_RSVP_WRITE]) + @PatchMapping(produces = ["application/json"], consumes = ["application/json"]) + suspend fun patchRsvp(@PathVariable guildId: Snowflake, @PathVariable eventId: String, @RequestBody rsvp: Rsvp): Rsvp { + // TODO: Need a way to check if authenticated user has access to this guild + return rsvpService.updateRsvp(rsvp) + } +} From cc5afb75a044ff4929065788b794b028db6217a8 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Mon, 19 Feb 2024 21:11:52 -0600 Subject: [PATCH 06/43] It doesn't like Instant.MIN I guess --- .../org/dreamexposure/discal/core/database/RsvpRepository.kt | 1 - .../kotlin/org/dreamexposure/discal/core/object/new/Rsvp.kt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/RsvpRepository.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/RsvpRepository.kt index 038e33296..f404f86aa 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/database/RsvpRepository.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/RsvpRepository.kt @@ -2,7 +2,6 @@ package org.dreamexposure.discal.core.database import org.springframework.data.r2dbc.repository.Query import org.springframework.data.r2dbc.repository.R2dbcRepository -import reactor.core.publisher.Flux import reactor.core.publisher.Mono interface RsvpRepository: R2dbcRepository { diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Rsvp.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Rsvp.kt index db62e0fdd..104df406a 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Rsvp.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Rsvp.kt @@ -12,7 +12,7 @@ data class Rsvp( val eventId: String, val calendarNumber: Int = 1, - val eventEnd: Instant = Instant.MIN, + val eventEnd: Instant = Instant.ofEpochMilli(0), val goingOnTime: Set = setOf(), val goingLate: Set = setOf(), From 46b8d684e28a67cfa9ab85393a82710a3f1c9c40 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Tue, 20 Feb 2024 00:17:29 -0600 Subject: [PATCH 07/43] Fix minor bugs from refactor --- .../discal/client/commands/global/RsvpCommand.kt | 2 +- .../org/dreamexposure/discal/core/business/RsvpService.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/RsvpCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/RsvpCommand.kt index 9baad6865..479ad5fb2 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/RsvpCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/RsvpCommand.kt @@ -296,7 +296,7 @@ class RsvpCommand( return event.followupEphemeral( - getMessage("limit.success", settings), + getMessage("limit.success", settings, limit.toString()), embedService.rsvpListEmbed(calendarEvent, rsvp, settings) ).awaitSingle() } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/RsvpService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/RsvpService.kt index 39caaea00..04402f497 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/RsvpService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/RsvpService.kt @@ -146,7 +146,7 @@ class RsvpService( discordClient.getGuildById(new.guildId) .removeMemberRole(userId, old.role, "Removed RSVP to event with ID ${new.eventId}") .doOnError { - LOGGER.debug( + LOGGER.error( "Failed to remove role:${old.role?.asString()} from user:${userId.asString()}", it ) @@ -160,7 +160,7 @@ class RsvpService( discordClient.getGuildById(new.guildId) .addMemberRole(userId, new.role, "RSVP'd to event with ID: ${new.eventId}") .doOnError { - LOGGER.debug( + LOGGER.error( "Failed to add role:${old.role?.asString()} to user:${userId.asString()}", it ) From ca715f6dd9ca3d4dafbebc4ad43b66abb37465c2 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Tue, 27 Feb 2024 22:25:25 -0600 Subject: [PATCH 08/43] Refactor static message related code Next is adding some targeted interactions to improve UX --- README.md | 1 - .../cronjob/StaticMessageUpdateCronJob.kt | 59 +++ .../client/commands/global/CalendarCommand.kt | 16 +- .../commands/global/DisplayCalendarCommand.kt | 126 ++--- .../client/commands/global/EventCommand.kt | 469 +++++++++--------- .../discal/client/config/DiscordConfig.kt | 2 +- .../client/message/embed/CalendarEmbed.kt | 1 + .../client/service/StaticMessageService.kt | 135 ----- .../discal/core/business/EmbedService.kt | 109 +++- .../discal/core/business/MetricService.kt | 2 +- .../core/business/StaticMessageService.kt | 209 ++++++++ .../discal/core/config/CacheConfig.kt | 15 +- .../discal/core/config/Config.kt | 2 + .../discal/core/database/DatabaseManager.kt | 200 -------- .../discal/core/database/StaticMessageData.kt | 15 + .../core/database/StaticMessageRepository.kt | 49 ++ .../discal/core/object/StaticMessage.kt | 49 -- .../discal/core/object/new/StaticMessage.kt | 42 ++ .../org/dreamexposure/discal/typealiases.kt | 2 + .../resources/commands/global/displayCal.json | 13 - 20 files changed, 779 insertions(+), 737 deletions(-) create mode 100644 client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StaticMessageUpdateCronJob.kt delete mode 100644 client/src/main/kotlin/org/dreamexposure/discal/client/service/StaticMessageService.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/business/StaticMessageService.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/database/StaticMessageData.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/database/StaticMessageRepository.kt delete mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/object/StaticMessage.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/object/new/StaticMessage.kt diff --git a/README.md b/README.md index f46c61eb6..301cdfa9d 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,6 @@ DisCal uses a simple-to-understand permission scheme for handling access to comm | Command | Description | Permissions | |----------------------|-------------------------------------------------------|-------------| | `/displaycal new` | Creates a new auto-updating calendar overview message | elevated | -| `/displaycal update` | Updates an existing calendar overview | elevated |
diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StaticMessageUpdateCronJob.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StaticMessageUpdateCronJob.kt new file mode 100644 index 000000000..c4b600f20 --- /dev/null +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StaticMessageUpdateCronJob.kt @@ -0,0 +1,59 @@ +package org.dreamexposure.discal.client.business.cronjob + +import kotlinx.coroutines.reactor.mono +import org.dreamexposure.discal.Application.Companion.getShardCount +import org.dreamexposure.discal.Application.Companion.getShardIndex +import org.dreamexposure.discal.core.business.MetricService +import org.dreamexposure.discal.core.business.StaticMessageService +import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT +import org.springframework.boot.ApplicationArguments +import org.springframework.boot.ApplicationRunner +import org.springframework.stereotype.Component +import org.springframework.util.StopWatch +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.time.Duration +import java.time.Instant + +@Component +class StaticMessageUpdateCronJob( + private val staticMessageService: StaticMessageService, + private val metricService: MetricService, +):ApplicationRunner { + override fun run(args: ApplicationArguments?) { + Flux.interval(Duration.ofHours(1)) + .onBackpressureDrop() + .flatMap { doUpdate() } + .onErrorResume { Mono.empty() } + .subscribe() + } + + private fun doUpdate() = mono { + val taskTimer = StopWatch() + taskTimer.start() + + try { + val messages = staticMessageService.getStaticMessagesForShard(getShardIndex(), getShardCount()) + //We have no interest in updating the message so close to its last update + .filter { Duration.between(Instant.now(), it.lastUpdate).abs().toMinutes() >= 30 } + // Only update messages in range + .filter { Duration.between(Instant.now(), it.scheduledUpdate).toMinutes() <= 60 } + + LOGGER.debug("StaticMessageUpdateCronJob | Found ${messages.size} messages to update for shard ${getShardIndex()}") + + messages.forEach { + try { + staticMessageService.updateStaticMessage(it.guildId, it.messageId) + } catch (ex: Exception) { + LOGGER.error("Failed to update static message | guildId:${it.guildId} | messageId:${it.messageId}", ex) + } + } + } catch (ex: Exception) { + LOGGER.error(DEFAULT, "StaticMessageUpdateCronJob failure", ex) + } finally { + taskTimer.stop() + metricService.recordStaticMessageTaskDuration("overall", taskTimer.totalTimeMillis) + } + } +} diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/CalendarCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/CalendarCommand.kt index 54c458649..a3e5c99ef 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/CalendarCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/CalendarCommand.kt @@ -6,9 +6,10 @@ import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue import discord4j.core.`object`.entity.Guild import discord4j.core.`object`.entity.Member import discord4j.core.`object`.entity.Message +import kotlinx.coroutines.reactor.mono import org.dreamexposure.discal.client.commands.SlashCommand import org.dreamexposure.discal.client.message.embed.CalendarEmbed -import org.dreamexposure.discal.client.service.StaticMessageService +import org.dreamexposure.discal.core.business.StaticMessageService import org.dreamexposure.discal.core.entities.response.UpdateCalendarResponse import org.dreamexposure.discal.core.enums.calendar.CalendarHost import org.dreamexposure.discal.core.extensions.discord4j.* @@ -24,7 +25,10 @@ import reactor.core.publisher.Mono import java.time.ZoneId @Component -class CalendarCommand(val wizard: Wizard, val staticMessageSrv: StaticMessageService) : SlashCommand { +class CalendarCommand( + private val wizard: Wizard, + private val staticMessageService: StaticMessageService +) : SlashCommand { override val name = "calendar" override val ephemeral = true @@ -200,11 +204,9 @@ class CalendarCommand(val wizard: Wizard, val staticMessageSrv: Sta .filter(UpdateCalendarResponse::success) .doOnNext { wizard.remove(settings.guildID) } .flatMap { ucr -> - val updateMessages = staticMessageSrv.updateStaticMessages( - guild, - ucr.new!!, - settings - ) + val updateMessages = mono { + staticMessageService.updateStaticMessages(settings.guildID, ucr.new!!.calendarNumber) + } event.followupEphemeral( getMessage("confirm.success.edit", settings), CalendarEmbed.link(guild, settings, ucr.new!!) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DisplayCalendarCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DisplayCalendarCommand.kt index 9def46dc2..861216f07 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DisplayCalendarCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DisplayCalendarCommand.kt @@ -1,125 +1,59 @@ package org.dreamexposure.discal.client.commands.global -import discord4j.common.util.Snowflake +import discord4j.core.event.domain.interaction.ChatInputInteractionEvent import discord4j.core.`object`.command.ApplicationCommandInteractionOption import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue -import discord4j.core.`object`.entity.Member import discord4j.core.`object`.entity.Message -import discord4j.core.event.domain.interaction.ChatInputInteractionEvent -import discord4j.core.spec.MessageCreateSpec -import discord4j.rest.http.client.ClientException +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull import org.dreamexposure.discal.client.commands.SlashCommand -import org.dreamexposure.discal.client.message.embed.CalendarEmbed -import org.dreamexposure.discal.core.`object`.GuildSettings -import org.dreamexposure.discal.core.`object`.StaticMessage -import org.dreamexposure.discal.core.database.DatabaseManager +import org.dreamexposure.discal.core.business.StaticMessageService import org.dreamexposure.discal.core.extensions.discord4j.followupEphemeral import org.dreamexposure.discal.core.extensions.discord4j.getCalendar import org.dreamexposure.discal.core.extensions.discord4j.hasElevatedPermissions +import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.utils.getCommonMsg import org.springframework.stereotype.Component -import reactor.core.publisher.Mono -import reactor.function.TupleUtils -import java.time.Instant -import java.time.ZonedDateTime -import java.time.temporal.ChronoUnit @Component -class DisplayCalendarCommand : SlashCommand { +class DisplayCalendarCommand( + private val staticMessageService: StaticMessageService, +) : SlashCommand { override val name = "displaycal" override val ephemeral = true - @Deprecated("Use new handleSuspend for K-coroutines") - override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + + override suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message { return when (event.options[0].name) { "new" -> new(event, settings) - "update" -> update(event, settings) - else -> Mono.empty() //Never can reach this, makes compiler happy. + else -> throw IllegalStateException("Invalid subcommand specified") } } - private fun new(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun new(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val hour = event.options[0].getOption("time") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .orElse(0) // default to midnight - + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .orElse(0) // default to midnight val calendarNumber = event.options[0].getOption("calendar") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .orElse(1) + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .orElse(1) + // Validate control role + val hasElevatedPerms = event.interaction.member.get().hasElevatedPermissions().awaitSingle() + if (!hasElevatedPerms) + return event.followupEphemeral(getCommonMsg("error.perms.elevated", settings)).awaitSingle() + // Validate calendar exists + val calendar = event.interaction.guild.flatMap { it.getCalendar(calendarNumber) }.awaitSingleOrNull() + if (calendar == null) + return event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings)).awaitSingle() - return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasElevatedPermissions).flatMap { - event.interaction.guild.flatMap { guild -> - guild.getCalendar(calendarNumber).flatMap { cal -> - CalendarEmbed.overview(guild, settings, cal, true).flatMap { embed -> - event.interaction.channel.flatMap { - it.createMessage( - MessageCreateSpec.builder() - .addEmbed(embed) - .build() - ) - }.flatMap { msg -> - val nextUpdate = ZonedDateTime.now(cal.timezone) - .truncatedTo(ChronoUnit.DAYS) - .plusHours(hour + 24) - .toInstant() + // Create and respond + staticMessageService.createStaticMessage(settings.guildID, event.interaction.channelId, calendarNumber, hour) - val staticMsg = StaticMessage( - settings.guildID, - msg.id, - msg.channelId, - StaticMessage.Type.CALENDAR_OVERVIEW, - Instant.now(), - nextUpdate, - calendarNumber, - ) - - DatabaseManager.updateStaticMessage(staticMsg) - .then(event.followupEphemeral(getCommonMsg("success.generic", settings))) - } - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings))) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.elevated", settings))) - } - - private fun update(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { - return Mono.defer { - val messageIdString = event.options[0].getOption("message") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asString) - .get() - - Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasElevatedPermissions).flatMap { - val messageId = Snowflake.of(messageIdString) - DatabaseManager.getStaticMessage(settings.guildID, messageId) - .filter { it.type == StaticMessage.Type.CALENDAR_OVERVIEW } - .flatMap { static -> - event.client.getMessageById(static.channelId, static.messageId) - .onErrorResume(ClientException.isStatusCode(403, 404)) { - Mono.empty() - }.flatMap { msg -> - val gMono = event.interaction.guild.cache() - val cMono = gMono.flatMap { it.getCalendar(static.calendarNumber) } - - Mono.zip(gMono, cMono).flatMap(TupleUtils.function { guild, calendar -> - CalendarEmbed.overview(guild, settings, calendar, true) - .flatMap { msg.edit().withEmbedsOrNull(listOf(it)) } - .flatMap { - DatabaseManager.updateStaticMessage( - static.copy(lastUpdate = Instant.now()) - ) - }.then(event.followupEphemeral(getCommonMsg("success.generic", settings))) - }) - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.message", settings))) - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.staticMessage", settings))) - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.elevated", settings))) - }.onErrorResume(NumberFormatException::class.java) { - event.followupEphemeral(getCommonMsg("error.format.snowflake.message", settings)) - } + return event.followupEphemeral(getCommonMsg("success.generic", settings)).awaitSingle() } } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/EventCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/EventCommand.kt index c839f2274..f4a6a8730 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/EventCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/EventCommand.kt @@ -6,9 +6,10 @@ import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue import discord4j.core.`object`.entity.Member import discord4j.core.`object`.entity.Message import discord4j.core.spec.MessageCreateSpec +import kotlinx.coroutines.reactor.mono import org.dreamexposure.discal.client.commands.SlashCommand import org.dreamexposure.discal.client.message.embed.EventEmbed -import org.dreamexposure.discal.client.service.StaticMessageService +import org.dreamexposure.discal.core.business.StaticMessageService import org.dreamexposure.discal.core.entities.Event import org.dreamexposure.discal.core.entities.response.UpdateEventResponse import org.dreamexposure.discal.core.enums.event.EventColor @@ -30,7 +31,10 @@ import java.time.temporal.ChronoUnit @Suppress("DuplicatedCode") @Component -class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMessageService) : SlashCommand { +class EventCommand( + private val wizard: Wizard, + private val staticMessageService: StaticMessageService +) : SlashCommand { override val name = "event" override val ephemeral = true @@ -59,9 +63,9 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes private fun create(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { val name = event.options[0].getOption("name") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asString) - .orElse("") + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asString) + .orElse("") val description = event.options[0].getOption("description") .flatMap(ApplicationCommandInteractionOption::getValue) @@ -74,10 +78,10 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes .orElse("") val calendarNumber = event.options[0].getOption("calendar") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .orElse(1) + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .orElse(1) return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { if (wizard.get(settings.guildID) == null) { @@ -89,13 +93,16 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes pre.location = location wizard.start(pre) - event.followupEphemeral(getMessage("create.success", settings), EventEmbed.pre(guild, settings, pre)) + event.followupEphemeral( + getMessage("create.success", settings), + EventEmbed.pre(guild, settings, pre) + ) }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings))) } } else { event.interaction.guild - .map { EventEmbed.pre(it, settings, wizard.get(settings.guildID)!!) } - .flatMap { event.followupEphemeral(getMessage("error.wizard.started", settings)) } + .map { EventEmbed.pre(it, settings, wizard.get(settings.guildID)!!) } + .flatMap { event.followupEphemeral(getMessage("error.wizard.started", settings)) } } }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) @@ -103,10 +110,10 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes private fun name(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { val name = event.options[0].getOption("name") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asString) - .filter { !it.equals("N/a") || !it.equals("None") } - .orElse("") + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asString) + .filter { !it.equals("N/a") || !it.equals("None") } + .orElse("") return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { @@ -114,8 +121,8 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes if (pre != null) { pre.name = name event.interaction.guild - .map { EventEmbed.pre(it, settings, pre) } - .flatMap { event.followupEphemeral(getMessage("name.success", settings), it) } + .map { EventEmbed.pre(it, settings, pre) } + .flatMap { event.followupEphemeral(getMessage("name.success", settings), it) } } else { event.followupEphemeral(getMessage("error.wizard.notStarted", settings)) } @@ -124,10 +131,10 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes private fun description(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { val description = event.options[0].getOption("description") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asString) - .filter { !it.equals("N/a") || !it.equals("None") } - .orElse("") + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asString) + .filter { !it.equals("N/a") || !it.equals("None") } + .orElse("") return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { @@ -135,8 +142,8 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes if (pre != null) { pre.description = description event.interaction.guild - .map { EventEmbed.pre(it, settings, pre) } - .flatMap { event.followupEphemeral(getMessage("description.success", settings), it) } + .map { EventEmbed.pre(it, settings, pre) } + .flatMap { event.followupEphemeral(getMessage("description.success", settings), it) } } else { event.followupEphemeral(getMessage("error.wizard.notStarted", settings)) } @@ -145,34 +152,34 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes private fun start(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { val year = event.options[0].getOption("year") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .map { it.coerceAtLeast(Year.MIN_VALUE).coerceAtMost(Year.MAX_VALUE) } - .get() + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .map { it.coerceAtLeast(Year.MIN_VALUE).coerceAtMost(Year.MAX_VALUE) } + .get() val month = event.options[0].getOption("month") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .map(Month::of) - .get() + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .map(Month::of) + .get() val day = event.options[0].getOption("day") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .map { it.coerceAtLeast(1).coerceAtMost(month.maxLength()) } - .get() + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .map { it.coerceAtLeast(1).coerceAtMost(month.maxLength()) } + .get() val hour = event.options[0].getOption("hour") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .orElse(0) + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .orElse(0) val minute = event.options[0].getOption("minute") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .map { it.coerceAtLeast(0).coerceAtMost(59) } - .orElse(0) + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .map { it.coerceAtLeast(0).coerceAtMost(59) } + .orElse(0) val keepDuration = event.options[0].getOption("keep-duration") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asBoolean) @@ -183,8 +190,8 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes if (pre != null) { //Build date time object val start = ZonedDateTime.of( - LocalDateTime.of(year, month, day, hour, minute), - pre.timezone + LocalDateTime.of(year, month, day, hour, minute), + pre.timezone ).toInstant() if (pre.end == null) { @@ -192,13 +199,13 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes pre.end = start.plus(1, ChronoUnit.HOURS) // Add default end time to 1 hour after start. if (pre.start!!.isAfter(Instant.now())) { event.interaction.guild - .map { EventEmbed.pre(it, settings, pre) } - .flatMap { event.followupEphemeral(getMessage("start.success", settings), it) } + .map { EventEmbed.pre(it, settings, pre) } + .flatMap { event.followupEphemeral(getMessage("start.success", settings), it) } } else { // scheduled for the past, allow but add a warning. event.interaction.guild - .map { EventEmbed.pre(it, settings, pre) } - .flatMap { event.followupEphemeral(getMessage("start.success.past", settings), it) } + .map { EventEmbed.pre(it, settings, pre) } + .flatMap { event.followupEphemeral(getMessage("start.success.past", settings), it) } } } else { // Event end already set, make sure everything is in order @@ -211,19 +218,19 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes if (pre.start!!.isAfter(Instant.now())) { event.interaction.guild - .map { EventEmbed.pre(it, settings, pre) } - .flatMap { event.followupEphemeral(getMessage("start.success", settings), it) } + .map { EventEmbed.pre(it, settings, pre) } + .flatMap { event.followupEphemeral(getMessage("start.success", settings), it) } } else { // scheduled for the past, allow but add a warning. event.interaction.guild - .map { EventEmbed.pre(it, settings, pre) } - .flatMap { event.followupEphemeral(getMessage("start.success.past", settings), it) } + .map { EventEmbed.pre(it, settings, pre) } + .flatMap { event.followupEphemeral(getMessage("start.success.past", settings), it) } } } else { // Event end cannot be before event start event.interaction.guild - .map { EventEmbed.pre(it, settings, pre) } - .flatMap { event.followupEphemeral(getMessage("start.failure.afterEnd", settings), it) } + .map { EventEmbed.pre(it, settings, pre) } + .flatMap { event.followupEphemeral(getMessage("start.failure.afterEnd", settings), it) } } } } else { @@ -234,34 +241,34 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes private fun end(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { val year = event.options[0].getOption("year") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .map { it.coerceAtLeast(Year.MIN_VALUE).coerceAtMost(Year.MAX_VALUE) } - .get() + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .map { it.coerceAtLeast(Year.MIN_VALUE).coerceAtMost(Year.MAX_VALUE) } + .get() val month = event.options[0].getOption("month") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .map(Month::of) - .get() + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .map(Month::of) + .get() val day = event.options[0].getOption("day") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .map { it.coerceAtLeast(1).coerceAtMost(month.maxLength()) } - .get() + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .map { it.coerceAtLeast(1).coerceAtMost(month.maxLength()) } + .get() val hour = event.options[0].getOption("hour") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .orElse(0) + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .orElse(0) val minute = event.options[0].getOption("minute") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .map { it.coerceAtLeast(0).coerceAtMost(59) } - .orElse(0) + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .map { it.coerceAtLeast(0).coerceAtMost(59) } + .orElse(0) val keepDuration = event.options[0].getOption("keep-duration") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asBoolean) @@ -272,8 +279,8 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes if (pre != null) { //Build date time object val end = ZonedDateTime.of( - LocalDateTime.of(year, month, day, hour, minute), - pre.timezone + LocalDateTime.of(year, month, day, hour, minute), + pre.timezone ).toInstant() if (pre.start == null) { @@ -281,13 +288,13 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes pre.start = end.minus(1, ChronoUnit.HOURS) // Add default start time to 1 hour before end. if (pre.end!!.isAfter(Instant.now())) { event.interaction.guild - .map { EventEmbed.pre(it, settings, pre) } - .flatMap { event.followupEphemeral(getMessage("end.success", settings), it) } + .map { EventEmbed.pre(it, settings, pre) } + .flatMap { event.followupEphemeral(getMessage("end.success", settings), it) } } else { // scheduled for the past, allow but add a warning. event.interaction.guild - .map { EventEmbed.pre(it, settings, pre) } - .flatMap { event.followupEphemeral(getMessage("end.success.past", settings), it) } + .map { EventEmbed.pre(it, settings, pre) } + .flatMap { event.followupEphemeral(getMessage("end.success.past", settings), it) } } } else { // Event start already set, make sure everything is in order @@ -300,19 +307,19 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes if (pre.end!!.isAfter(Instant.now())) { event.interaction.guild - .map { EventEmbed.pre(it, settings, pre) } - .flatMap { event.followupEphemeral(getMessage("end.success", settings), it) } + .map { EventEmbed.pre(it, settings, pre) } + .flatMap { event.followupEphemeral(getMessage("end.success", settings), it) } } else { // scheduled for the past, allow but add a warning. event.interaction.guild - .map { EventEmbed.pre(it, settings, pre) } - .flatMap { event.followupEphemeral(getMessage("end.success.past", settings), it) } + .map { EventEmbed.pre(it, settings, pre) } + .flatMap { event.followupEphemeral(getMessage("end.success.past", settings), it) } } } else { // Event start cannot be after event end event.interaction.guild - .map { EventEmbed.pre(it, settings, pre) } - .flatMap { event.followupEphemeral(getMessage("end.failure.beforeStart", settings), it) } + .map { EventEmbed.pre(it, settings, pre) } + .flatMap { event.followupEphemeral(getMessage("end.failure.beforeStart", settings), it) } } } } else { @@ -323,19 +330,19 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes private fun color(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { val color = event.options[0].getOption("color") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .map(EventColor.Companion::fromId) - .get() + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .map(EventColor.Companion::fromId) + .get() return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { val pre = wizard.get(settings.guildID) if (pre != null) { pre.color = color event.interaction.guild - .map { EventEmbed.pre(it, settings, pre) } - .flatMap { event.followupEphemeral(getMessage("color.success", settings), it) } + .map { EventEmbed.pre(it, settings, pre) } + .flatMap { event.followupEphemeral(getMessage("color.success", settings), it) } } else { event.followupEphemeral(getMessage("error.wizard.notStarted", settings)) } @@ -344,18 +351,18 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes private fun location(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { val location = event.options[0].getOption("location") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asString) - .filter { !it.equals("N/a") || !it.equals("None") } - .orElse("") + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asString) + .filter { !it.equals("N/a") || !it.equals("None") } + .orElse("") return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { val pre = wizard.get(settings.guildID) if (pre != null) { pre.location = location event.interaction.guild - .map { EventEmbed.pre(it, settings, pre) } - .flatMap { event.followupEphemeral(getMessage("location.success", settings), it) } + .map { EventEmbed.pre(it, settings, pre) } + .flatMap { event.followupEphemeral(getMessage("location.success", settings), it) } } else { event.followupEphemeral(getMessage("error.wizard.notStarted", settings)) } @@ -364,9 +371,9 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes private fun image(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { val image = event.options[0].getOption("image") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asString) - .get() + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asString) + .get() return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { val pre = wizard.get(settings.guildID) @@ -374,11 +381,11 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes Mono.just(image).filterWhen { it.isValidImage(settings.patronGuild || settings.devGuild) }.flatMap { pre.image = image event.interaction.guild - .map { EventEmbed.pre(it, settings, pre) } - .flatMap { event.followupEphemeral(getMessage("image.success", settings), it) } - }.switchIfEmpty(event.interaction.guild .map { EventEmbed.pre(it, settings, pre) } - .flatMap { event.followupEphemeral(getMessage("image.failure", settings), it) } + .flatMap { event.followupEphemeral(getMessage("image.success", settings), it) } + }.switchIfEmpty(event.interaction.guild + .map { EventEmbed.pre(it, settings, pre) } + .flatMap { event.followupEphemeral(getMessage("image.failure", settings), it) } ) } else { event.followupEphemeral(getMessage("error.wizard.notStarted", settings)) @@ -388,24 +395,24 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes private fun recur(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { val shouldRecur = event.options[0].getOption("recur") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asBoolean) - .orElse(true) + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asBoolean) + .orElse(true) val frequency = event.options[0].getOption("frequency") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asString) - .map(EventFrequency.Companion::fromValue) - .orElse(EventFrequency.WEEKLY) + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asString) + .map(EventFrequency.Companion::fromValue) + .orElse(EventFrequency.WEEKLY) val interval = event.options[0].getOption("interval") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .orElse(1) + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .orElse(1) val count = event.options[0].getOption("count") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .orElse(-1) + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .orElse(-1) return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { val pre = wizard.get(settings.guildID) @@ -413,13 +420,13 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes if (shouldRecur) { pre.recurrence = Recurrence(frequency, interval, count) event.interaction.guild - .map { EventEmbed.pre(it, settings, pre) } - .flatMap { event.followupEphemeral(getMessage("recur.success.enable", settings), it) } + .map { EventEmbed.pre(it, settings, pre) } + .flatMap { event.followupEphemeral(getMessage("recur.success.enable", settings), it) } } else { pre.recurrence = null event.interaction.guild - .map { EventEmbed.pre(it, settings, pre) } - .flatMap { event.followupEphemeral(getMessage("recur.success.disable", settings), it) } + .map { EventEmbed.pre(it, settings, pre) } + .flatMap { event.followupEphemeral(getMessage("recur.success.disable", settings), it) } } } else { event.followupEphemeral(getMessage("error.wizard.notStarted", settings)) @@ -452,54 +459,58 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes if (!pre.editing) { // New event guild.getCalendar(pre.calNumber) - .flatMap { it.createEvent(pre.createSpec()) } - .doOnNext { wizard.remove(settings.guildID) } - .flatMap { calEvent -> - val updateMessages = staticMessageSrv.updateStaticMessages( - guild, - calEvent.calendar, - settings + .flatMap { it.createEvent(pre.createSpec()) } + .doOnNext { wizard.remove(settings.guildID) } + .flatMap { calEvent -> + val updateMessages = mono { + staticMessageService.updateStaticMessages( + settings.guildID, + calEvent.calendar.calendarNumber ) - val embedMono = event.interaction.channel.flatMap { - val spec = MessageCreateSpec.builder() - .content(getMessage("confirm.success.create", settings)) - .addEmbed(EventEmbed.getFull(guild, settings, calEvent)) - .build() - - it.createMessage(spec) - } - val followupMono = event.followupEphemeral(getCommonMsg("success.generic", settings)) - - embedMono.then(followupMono).flatMap { updateMessages.thenReturn(it) } - }.doOnError { - LOGGER.error("Create event with command failure", it) - }.onErrorResume { - event.followupEphemeral(getMessage("confirm.failure.create", settings)) - }.switchIfEmpty(event.followupEphemeral(getMessage("confirm.failure.create", settings))) + } + val embedMono = event.interaction.channel.flatMap { + val spec = MessageCreateSpec.builder() + .content(getMessage("confirm.success.create", settings)) + .addEmbed(EventEmbed.getFull(guild, settings, calEvent)) + .build() + + it.createMessage(spec) + } + val followupMono = event.followupEphemeral(getCommonMsg("success.generic", settings)) + + embedMono.then(followupMono).flatMap { updateMessages.thenReturn(it) } + }.doOnError { + LOGGER.error("Create event with command failure", it) + }.onErrorResume { + event.followupEphemeral(getMessage("confirm.failure.create", settings)) + }.switchIfEmpty(event.followupEphemeral(getMessage("confirm.failure.create", settings))) } else { // Editing pre.event!!.update(pre.updateSpec()) - .filter(UpdateEventResponse::success) - .doOnNext { wizard.remove(settings.guildID) } - .flatMap { uer -> - val updateMessages = staticMessageSrv.updateStaticMessages( - guild, - uer.new!!.calendar, - settings - ) - val embedMono = event.interaction.channel.flatMap { - val spec = MessageCreateSpec.builder() - .content(getMessage("confirm.success.edit", settings)) - .addEmbed(EventEmbed.getFull(guild, settings, uer.new!!)) - .build() + .filter(UpdateEventResponse::success) + .doOnNext { wizard.remove(settings.guildID) } + .flatMap { uer -> - it.createMessage(spec) - } - val followupMono = event.followupEphemeral(getCommonMsg("success.generic", settings)) - embedMono.then(followupMono).flatMap { updateMessages.thenReturn(it) } + val updateMessages = mono { + staticMessageService.updateStaticMessages( + settings.guildID, + uer.new!!.calendar.calendarNumber + ) } - .switchIfEmpty(event.followupEphemeral(getMessage("confirm.failure.edit", settings))) + val embedMono = event.interaction.channel.flatMap { + val spec = MessageCreateSpec.builder() + .content(getMessage("confirm.success.edit", settings)) + .addEmbed(EventEmbed.getFull(guild, settings, uer.new!!)) + .build() + + it.createMessage(spec) + } + val followupMono = event.followupEphemeral(getCommonMsg("success.generic", settings)) + + embedMono.then(followupMono).flatMap { updateMessages.thenReturn(it) } + } + .switchIfEmpty(event.followupEphemeral(getMessage("confirm.failure.edit", settings))) } } } else { @@ -518,81 +529,81 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes private fun edit(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { val eventId = event.options[0].getOption("event") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asString) - .get() + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asString) + .get() val calendarNumber = event.options[0].getOption("calendar") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .orElse(1) + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .orElse(1) return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { if (wizard.get(settings.guildID) == null) { event.interaction.guild.flatMap { guild -> guild.getCalendar(calendarNumber).flatMap { calendar -> calendar.getEvent(eventId) - .map { PreEvent.edit(it) } - .doOnNext { wizard.start(it) } - .map { EventEmbed.pre(guild, settings, it) } - .flatMap { event.followupEphemeral(getMessage("edit.success", settings), it) } - .switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.event", settings))) + .map { PreEvent.edit(it) } + .doOnNext { wizard.start(it) } + .map { EventEmbed.pre(guild, settings, it) } + .flatMap { event.followupEphemeral(getMessage("edit.success", settings), it) } + .switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.event", settings))) }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings))) } } else { event.interaction.guild - .map { EventEmbed.pre(it, settings, wizard.get(settings.guildID)!!) } - .flatMap { event.followupEphemeral(getMessage("error.wizard.started", settings), it) } + .map { EventEmbed.pre(it, settings, wizard.get(settings.guildID)!!) } + .flatMap { event.followupEphemeral(getMessage("error.wizard.started", settings), it) } } }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) } private fun copy(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { val eventId = event.options[0].getOption("event") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asString) - .get() + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asString) + .get() val calendarNumber = event.options[0].getOption("calendar") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .orElse(1) + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .orElse(1) val targetCalendarNumber = event.options[0].getOption("target") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .orElse(calendarNumber) + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .orElse(calendarNumber) return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { if (wizard.get(settings.guildID) == null) { event.interaction.guild.flatMap { guild -> guild.getCalendar(calendarNumber).flatMap { calendar -> calendar.getEvent(eventId) - .flatMap { PreEvent.copy(guild, it, targetCalendarNumber) } - .doOnNext { wizard.start(it) } - .map { EventEmbed.pre(guild, settings, it) } - .flatMap { event.followupEphemeral(getMessage("copy.success", settings), it) } - .switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.event", settings))) + .flatMap { PreEvent.copy(guild, it, targetCalendarNumber) } + .doOnNext { wizard.start(it) } + .map { EventEmbed.pre(guild, settings, it) } + .flatMap { event.followupEphemeral(getMessage("copy.success", settings), it) } + .switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.event", settings))) }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings))) } } else { event.interaction.guild - .map { EventEmbed.pre(it, settings, wizard.get(settings.guildID)!!) } - .flatMap { event.followupEphemeral(getMessage("error.wizard.started", settings), it) } + .map { EventEmbed.pre(it, settings, wizard.get(settings.guildID)!!) } + .flatMap { event.followupEphemeral(getMessage("error.wizard.started", settings), it) } } }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) } private fun view(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { val eventId = event.options[0].getOption("event") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asString) - .get() + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asString) + .get() val calendarNumber = event.options[0].getOption("calendar") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .orElse(1) + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .orElse(1) return event.interaction.guild.flatMap { guild -> guild.getCalendar(calendarNumber).flatMap { calendar -> @@ -600,7 +611,7 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes event.interaction.channel.flatMap { // Create message so others can see event.followupEphemeral(getMessage("view.success", settings)).then( - it.createMessage(EventEmbed.getFull(guild, settings, calEvent)) + it.createMessage(EventEmbed.getFull(guild, settings, calEvent)) ) } }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings))) @@ -610,14 +621,14 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes private fun delete(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { val eventId = event.options[0].getOption("event") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asString) - .get() + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asString) + .get() val calendarNumber = event.options[0].getOption("calendar") - .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asLong) - .map(Long::toInt) - .orElse(1) + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asLong) + .map(Long::toInt) + .orElse(1) return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { // Before we delete the event, if the wizard is editing that event we need to cancel the wizard @@ -626,10 +637,14 @@ class EventCommand(val wizard: Wizard, val staticMessageSrv: StaticMes event.interaction.guild.flatMap { it.getCalendar(calendarNumber) }.flatMap { calendar -> calendar.getEvent(eventId) - .flatMap(Event::delete) - .flatMap { event.followupEphemeral(getMessage("delete.success", settings)) } - .flatMap { staticMessageSrv.updateStaticMessage(calendar, settings).thenReturn(it) } - .switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.event", settings))) + .flatMap(Event::delete) + .flatMap { event.followupEphemeral(getMessage("delete.success", settings)) } + .flatMap { + mono { + staticMessageService.updateStaticMessages(settings.guildID, calendarNumber) + }.thenReturn(it) + } + .switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.event", settings))) }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings))) }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/config/DiscordConfig.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/config/DiscordConfig.kt index 99dd21854..4ec87ac6b 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/config/DiscordConfig.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/config/DiscordConfig.kt @@ -64,7 +64,7 @@ class DiscordConfig { @Bean fun discordStores(): StoreService { - val useRedis = Config.CACHE_USE_REDIS.getBoolean() + val useRedis = Config.CACHE_USE_REDIS_D4J.getBoolean() val redisHost = Config.REDIS_HOST.getString() val redisPassword = Config.REDIS_PASSWORD.getString().toCharArray() val redisPort = Config.REDIS_PORT.getInt() diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/CalendarEmbed.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/CalendarEmbed.kt index bf572d70b..c4b48e423 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/CalendarEmbed.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/CalendarEmbed.kt @@ -45,6 +45,7 @@ object CalendarEmbed : EmbedMaker { .build() } + @Deprecated("Use replacement in EmbedService") fun overview(guild: Guild, settings: GuildSettings, calendar: Calendar, showUpdate: Boolean): Mono { return calendar.getUpcomingEvents(15).collectList().map { it.groupByDate() }.map { events -> val builder = defaultBuilder(guild, settings) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/service/StaticMessageService.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/service/StaticMessageService.kt deleted file mode 100644 index 17135a837..000000000 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/service/StaticMessageService.kt +++ /dev/null @@ -1,135 +0,0 @@ -package org.dreamexposure.discal.client.service - -import discord4j.core.GatewayDiscordClient -import discord4j.core.`object`.entity.Guild -import discord4j.core.spec.MessageEditSpec -import discord4j.rest.http.client.ClientException -import org.dreamexposure.discal.Application -import org.dreamexposure.discal.Application.Companion.getShardIndex -import org.dreamexposure.discal.client.message.embed.CalendarEmbed -import org.dreamexposure.discal.core.business.MetricService -import org.dreamexposure.discal.core.database.DatabaseManager -import org.dreamexposure.discal.core.entities.Calendar -import org.dreamexposure.discal.core.extensions.discord4j.getCalendar -import org.dreamexposure.discal.core.extensions.discord4j.getSettings -import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.`object`.GuildSettings -import org.dreamexposure.discal.core.`object`.StaticMessage -import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT -import org.springframework.beans.factory.BeanFactory -import org.springframework.beans.factory.getBean -import org.springframework.boot.ApplicationArguments -import org.springframework.boot.ApplicationRunner -import org.springframework.stereotype.Component -import org.springframework.util.StopWatch -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono -import reactor.function.TupleUtils -import java.time.Duration -import java.time.Instant -import java.time.temporal.ChronoUnit - -@Component -class StaticMessageService( - private val metricService: MetricService, - private val beanFactory: BeanFactory -) : ApplicationRunner { - private val discordClient: GatewayDiscordClient - get() = beanFactory.getBean() - - override fun run(args: ApplicationArguments?) { - Flux.interval(Duration.ofHours(1)) - .onBackpressureDrop() - .flatMap { doMessageUpdateLogic() } - .doOnError { LOGGER.error(DEFAULT, "!-Static Message Service Error-!", it) } - .subscribe() - } - - - private fun doMessageUpdateLogic(): Mono { - val taskTimer = StopWatch() - taskTimer.start() - - return DatabaseManager.getStaticMessagesForShard(Application.getShardCount(), getShardIndex()) - .flatMapMany { Flux.fromIterable(it) } - //We have no interest in updating the message so close to its last update - .filter { Duration.between(Instant.now(), it.lastUpdate).abs().toMinutes() >= 30 } - // Only update messages in range - .filter { Duration.between(Instant.now(), it.scheduledUpdate).toMinutes() <= 60 } - .flatMap { data -> - discordClient.getMessageById(data.channelId, data.messageId).flatMap { message -> - when (data.type) { - StaticMessage.Type.CALENDAR_OVERVIEW -> { - val guildMono = message.guild.cache() - val setMono = guildMono.flatMap(Guild::getSettings) - val calMono = guildMono.flatMap { it.getCalendar(data.calendarNumber) } - - Mono.zip(guildMono, setMono, calMono).flatMap( - TupleUtils.function { guild, settings, calendar -> - CalendarEmbed.overview(guild, settings, calendar, true).flatMap { - message.edit( - MessageEditSpec.builder() - .embedsOrNull(listOf(it)) - .build() - ).doOnNext { - metricService.incrementStaticMessagesUpdated(data.type) - }.then( - DatabaseManager.updateStaticMessage( - data.copy( - lastUpdate = Instant.now(), - scheduledUpdate = data.scheduledUpdate.plus(1, ChronoUnit.DAYS) - ) - ) - ) - } - }) - } - } - }.onErrorResume(ClientException.isStatusCode(403, 404)) { - //Message or channel was deleted OR access was revoked, delete from database - DatabaseManager.deleteStaticMessage(data.guildId, data.messageId) - } - }.doOnError { - LOGGER.error(DEFAULT, "Static message update error", it) - }.onErrorResume { - Mono.empty() - }.doFinally { - taskTimer.stop() - metricService.recordStaticMessageTaskDuration("overall", taskTimer.totalTimeMillis) - }.then() - } - - fun updateStaticMessage(calendar: Calendar, settings: GuildSettings): Mono { - return discordClient.getGuildById(settings.guildID) - .flatMap { updateStaticMessages(it, calendar, settings) } - } - - fun updateStaticMessages(guild: Guild, calendar: Calendar, settings: GuildSettings): Mono { - return DatabaseManager.getStaticMessagesForCalendar(guild.id, calendar.calendarNumber) - .flatMapMany { Flux.fromIterable(it) } - .flatMap { msg -> - when (msg.type) { - StaticMessage.Type.CALENDAR_OVERVIEW -> { - CalendarEmbed.overview(guild, settings, calendar, true).flatMap { - guild.client.getMessageById(msg.channelId, msg.messageId).flatMap { message -> - message.edit( - MessageEditSpec.builder() - .embedsOrNull(listOf(it)) - .build() - ).doOnNext { - metricService.incrementStaticMessagesUpdated(msg.type) - }.then(DatabaseManager.updateStaticMessage(msg.copy(lastUpdate = Instant.now()))) - }.onErrorResume(ClientException.isStatusCode(403, 404)) { - //Message or channel was deleted OR access was revoked, delete from database - DatabaseManager.deleteStaticMessage(msg.guildId, msg.messageId) - } - } - } - } - }.doOnError { - LOGGER.error(DEFAULT, "Static message update error", it) - }.onErrorResume { - Mono.empty() - }.then() - } -} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt index 180e66f66..78385e977 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt @@ -5,13 +5,12 @@ import discord4j.core.DiscordClient import discord4j.core.spec.EmbedCreateSpec import kotlinx.coroutines.reactor.awaitSingle import org.dreamexposure.discal.core.config.Config +import org.dreamexposure.discal.core.entities.Calendar import org.dreamexposure.discal.core.entities.Event import org.dreamexposure.discal.core.enums.time.DiscordTimestampFormat -import org.dreamexposure.discal.core.extensions.asDiscordTimestamp +import org.dreamexposure.discal.core.extensions.* import org.dreamexposure.discal.core.extensions.discord4j.getCalendar import org.dreamexposure.discal.core.extensions.discord4j.getSettings -import org.dreamexposure.discal.core.extensions.embedFieldSafe -import org.dreamexposure.discal.core.extensions.toMarkdown import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.`object`.new.Rsvp import org.dreamexposure.discal.core.utils.GlobalVal @@ -20,6 +19,9 @@ import org.dreamexposure.discal.core.utils.getEmbedMessage import org.springframework.beans.factory.BeanFactory import org.springframework.beans.factory.getBean import org.springframework.stereotype.Component +import java.time.Instant +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit @Component class EmbedService( @@ -44,6 +46,107 @@ class EmbedService( ) } + ///////////////////////////// + ////// Calendar Embeds ////// + ///////////////////////////// + suspend fun calendarOverviewEmbed( + calendar: Calendar, + settings: GuildSettings, + showUpdate: Boolean + ): EmbedCreateSpec { + val builder = defaultEmbedBuilder(settings) + + // Get the events to build the overview + val events = calendar.getUpcomingEvents(15) + .collectList() + .map { it.groupByDate() } + .awaitSingle() + + //Handle optional fields + if (calendar.name.isNotBlank()) + builder.title(calendar.name.toMarkdown().embedTitleSafe()) + if (calendar.description.isNotBlank()) + builder.description(calendar.description.toMarkdown().embedDescriptionSafe()) + + // Truncate dates to 23 due to discord enforcing the field limit + val truncatedEvents = mutableMapOf>() + for (event in events) { + if (truncatedEvents.size < 23) { + truncatedEvents[event.key] = event.value + } else break + } + + // Show events + truncatedEvents.forEach { date -> + val title = date.key.toInstant().humanReadableDate(calendar.timezone, settings.timeFormat, longDay = true) + + // sort events + val sortedEvents = date.value.sortedBy { it.start } + + val content = StringBuilder() + + sortedEvents.forEach { + // Start event + content.append("```\n") + + // determine time length + val timeDisplayLen = ("${it.start.humanReadableTime(it.timezone, settings.timeFormat)} -" + + " ${it.end.humanReadableTime(it.timezone, settings.timeFormat)} ").length + + // Displaying time + if (it.isAllDay()) { + content.append(getCommonMsg("generic.time.allDay", settings).padCenter(timeDisplayLen)) + .append("| ") + } else { + // Add start text + var str = if (it.start.isBefore(date.key.toInstant())) { + "${getCommonMsg("generic.time.continued", settings)} - " + } else { + "${it.start.humanReadableTime(it.timezone, settings.timeFormat)} - " + } + // Add end text + str += if (it.end.isAfter(date.key.toInstant().plus(1, ChronoUnit.DAYS))) { + getCommonMsg("generic.time.continued", settings) + } else { + "${it.end.humanReadableTime(it.timezone, settings.timeFormat)} " + } + content.append(str.padCenter(timeDisplayLen)) + .append("| ") + } + // Display name or ID if not set + if (it.name.isNotBlank()) content.append(it.name) + else content.append(getEmbedMessage("calendar", "link.field.id", settings)).append(" ${it.eventId}") + content.append("\n") + if (it.location.isNotBlank()) content.append(" Location: ") + .append(it.location.embedFieldSafe()) + .append("\n") + + // Finish event + content.append("```\n") + } + + if (content.isNotBlank()) + builder.addField(title, content.toString().embedFieldSafe(), false) + } + + // set footer + if (showUpdate) { + val lastUpdate = Instant.now().asDiscordTimestamp(DiscordTimestampFormat.RELATIVE_TIME) + builder.footer(getEmbedMessage("calendar", "link.footer.update", settings, lastUpdate), null) + .timestamp(Instant.now()) + } else builder.footer(getEmbedMessage("calendar", "link.footer.default", settings), null) + + // finish and return + return builder.addField(getEmbedMessage("calendar", "link.field.timezone", settings), calendar.zoneName, true) + .addField(getEmbedMessage("calendar", "link.field.number", settings), "${calendar.calendarNumber}", true) + .url(calendar.link) + .color(GlobalVal.discalColor) + .build() + } + + ///////////////////////// + ////// RSVP Embeds ////// + ///////////////////////// suspend fun rsvpDmFollowupEmbed(rsvp: Rsvp, userId: Snowflake): EmbedCreateSpec { // TODO: These will be replaced by service calls eventually as I migrate components over to new patterns val restGuild = discordClient.getGuildById(rsvp.guildId) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/MetricService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/MetricService.kt index 62ba6aa0b..81385b993 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/MetricService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/MetricService.kt @@ -2,7 +2,7 @@ package org.dreamexposure.discal.core.business import io.micrometer.core.instrument.MeterRegistry import io.micrometer.core.instrument.Tag -import org.dreamexposure.discal.core.`object`.StaticMessage +import org.dreamexposure.discal.core.`object`.new.StaticMessage import org.springframework.stereotype.Component import java.time.Duration diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/StaticMessageService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/StaticMessageService.kt new file mode 100644 index 000000000..51ee6f5fd --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/StaticMessageService.kt @@ -0,0 +1,209 @@ +package org.dreamexposure.discal.core.business + +import discord4j.common.util.Snowflake +import discord4j.core.DiscordClient +import discord4j.discordjson.json.MessageEditRequest +import discord4j.rest.http.client.ClientException +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.dreamexposure.discal.StaticMessageCache +import org.dreamexposure.discal.core.database.StaticMessageData +import org.dreamexposure.discal.core.database.StaticMessageRepository +import org.dreamexposure.discal.core.exceptions.NotFoundException +import org.dreamexposure.discal.core.extensions.discord4j.getCalendar +import org.dreamexposure.discal.core.extensions.discord4j.getSettings +import org.dreamexposure.discal.core.`object`.new.StaticMessage +import org.springframework.beans.factory.BeanFactory +import org.springframework.beans.factory.getBean +import org.springframework.stereotype.Component +import org.springframework.util.StopWatch +import reactor.core.publisher.Mono +import java.time.Instant +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit + +@Component +class StaticMessageService( + private val staticMessageRepository: StaticMessageRepository, + private val staticMessageCache: StaticMessageCache, + private val embedService: EmbedService, + private val metricService: MetricService, + private val beanFactory: BeanFactory, +) { + private val discordClient: DiscordClient + get() = beanFactory.getBean() + + suspend fun getStaticMessageCount() = staticMessageRepository.count().awaitSingle() + + suspend fun getStaticMessage(guildId: Snowflake, messageId: Snowflake): StaticMessage? { + var message = staticMessageCache.get(guildId, key = messageId) + if (message != null) return message + + message = staticMessageRepository.findByGuildIdAndMessageId(guildId.asLong(), messageId.asLong()) + .map(::StaticMessage) + .awaitSingleOrNull() + + if (message != null) staticMessageCache.put(guildId, key = messageId, message) + return message + } + + suspend fun getStaticMessagesForCalendar(guildId: Snowflake, calendarNumber: Int): List { + // TODO: I'm hoping one day I figure out how to do this with caching more easily + return staticMessageRepository.findAllByGuildIdAndCalendarNumber(guildId.asLong(), calendarNumber) + .map(::StaticMessage) + .collectList() + .awaitSingle() + } + + suspend fun getStaticMessagesForShard(shardIndex: Int, shardCount: Int): List { + return staticMessageRepository.findAllByShardIndex(shardIndex, shardCount) + .map(::StaticMessage) + .collectList() + .awaitSingle() + } + + /////////////////////////////////////////////////////////////////////////////////////////// + ////// TODO: Need to be able to break some of this out for when I support more types ////// + /////////////////////////////////////////////////////////////////////////////////////////// + suspend fun createStaticMessage( + guildId: Snowflake, + channelId: Snowflake, + calendarNumber: Int, + updateHour: Long + ): StaticMessage { + + // Gather everything we need + val settings = discordClient.getGuildById(guildId).getSettings().awaitSingle() + val calendar = discordClient.getGuildById(guildId) + .getCalendar(calendarNumber) + .awaitSingleOrNull() ?: throw NotFoundException("Calendar not found") + val channel = discordClient.getChannelById(channelId) + val embed = embedService.calendarOverviewEmbed(calendar, settings, showUpdate = true) + val nextUpdate = ZonedDateTime.now(calendar.timezone) + .truncatedTo(ChronoUnit.DAYS) + .plusHours(updateHour + 24) + .toInstant() + + + // Finally create the message + val message = channel.createMessage(embed.asRequest()).awaitSingle() + val saved = staticMessageRepository.save( + StaticMessageData( + guildId = guildId.asLong(), + messageId = message.id().asLong(), + channelId = channelId.asLong(), + type = StaticMessage.Type.CALENDAR_OVERVIEW.value, + lastUpdate = Instant.now(), + scheduledUpdate = nextUpdate, + calendarNumber = calendarNumber, + ) + ).map(::StaticMessage).awaitSingle() + + staticMessageCache.put(guildId, key = saved.messageId, saved) + return saved + } + + suspend fun updateStaticMessage(guildId: Snowflake, messageId: Snowflake) { + val taskTimer = StopWatch() + taskTimer.start() + + val old = getStaticMessage(guildId, messageId) ?: throw NotFoundException("Static message not found") + + // While we don't need the message data, we do want to make sure it exists + val existingData = discordClient.getMessageById(old.channelId, old.messageId) + .data.onErrorResume(ClientException.isStatusCode(403, 404)) { Mono.empty() } + .awaitSingleOrNull() + + if (existingData == null) { + // Message or channel was deleted OR access was revoked, treat this as deleted + deleteStaticMessage(guildId, old.messageId) + return + } + + val settings = discordClient.getGuildById(guildId).getSettings().awaitSingle() + val calendar = discordClient.getGuildById(guildId) + .getCalendar(old.calendarNumber) + .awaitSingleOrNull() ?: throw NotFoundException("Calendar not found") + + // Finally update the message + val embed = embedService.calendarOverviewEmbed(calendar, settings, showUpdate = true) + + discordClient.getMessageById(old.channelId, old.messageId) + .edit(MessageEditRequest.builder().addEmbed(embed.asRequest()).build()) + .awaitSingleOrNull() + + val updated = old.copy( + lastUpdate = Instant.now(), + scheduledUpdate = old.scheduledUpdate.plus(1, ChronoUnit.DAYS) + ) + staticMessageRepository.updateByGuildIdAndMessageId( + guildId = updated.guildId.asLong(), + messageId = updated.messageId.asLong(), + channelId = updated.channelId.asLong(), + type = updated.type.value, + lastUpdate = updated.lastUpdate, + scheduledUpdate = updated.scheduledUpdate, + calendarNumber = updated.calendarNumber, + ).awaitSingleOrNull() + + staticMessageCache.put(guildId, key = updated.messageId, updated) + + taskTimer.stop() + metricService.recordStaticMessageTaskDuration("single", taskTimer.totalTimeMillis) + metricService.incrementStaticMessagesUpdated(updated.type) + } + + suspend fun updateStaticMessages(guildId: Snowflake, calendarNumber: Int) { + val taskTimer = StopWatch() + taskTimer.start() + + val oldVersions = getStaticMessagesForCalendar(guildId, calendarNumber) + val settings = discordClient.getGuildById(guildId).getSettings().awaitSingle() + val calendar = discordClient.getGuildById(guildId) + .getCalendar(calendarNumber) + .awaitSingleOrNull() ?: throw NotFoundException("Calendar not found") + val embed = embedService.calendarOverviewEmbed(calendar, settings, showUpdate = true) + + oldVersions.forEach { old -> + val existingData = discordClient.getMessageById(old.channelId, old.messageId) + .data.onErrorResume(ClientException.isStatusCode(403, 404)) { Mono.empty() } + .awaitSingleOrNull() + + if (existingData == null) { + // Message or channel was deleted OR access was revoked, treat this as deleted + deleteStaticMessage(guildId, old.messageId) + return@forEach + } + + discordClient.getMessageById(old.channelId, old.messageId) + .edit(MessageEditRequest.builder().addEmbed(embed.asRequest()).build()) + .awaitSingleOrNull() + + val updated = old.copy( + lastUpdate = Instant.now(), + scheduledUpdate = old.scheduledUpdate.plus(1, ChronoUnit.DAYS) + ) + staticMessageRepository.updateByGuildIdAndMessageId( + guildId = updated.guildId.asLong(), + messageId = updated.messageId.asLong(), + channelId = updated.channelId.asLong(), + type = updated.type.value, + lastUpdate = updated.lastUpdate, + scheduledUpdate = updated.scheduledUpdate, + calendarNumber = updated.calendarNumber, + ).awaitSingleOrNull() + + staticMessageCache.put(guildId, key = updated.messageId, updated) + metricService.incrementStaticMessagesUpdated(updated.type) + } + + taskTimer.stop() + metricService.recordStaticMessageTaskDuration("guild_calendar", taskTimer.totalTimeMillis) + } + + suspend fun deleteStaticMessage(guildId: Snowflake, messageId: Snowflake) { + staticMessageRepository.deleteByGuildIdAndMessageId(guildId.asLong(), messageId.asLong()).awaitSingleOrNull() + staticMessageCache.evict(guildId, key = messageId) + } + +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt index fbee9e5a7..50da5e8b8 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt @@ -1,10 +1,7 @@ package org.dreamexposure.discal.core.config import com.fasterxml.jackson.databind.ObjectMapper -import org.dreamexposure.discal.CalendarCache -import org.dreamexposure.discal.CredentialsCache -import org.dreamexposure.discal.OauthStateCache -import org.dreamexposure.discal.RsvpCache +import org.dreamexposure.discal.* import org.dreamexposure.discal.core.cache.JdkCacheRepository import org.dreamexposure.discal.core.cache.RedisStringCacheRepository import org.dreamexposure.discal.core.extensions.asMinutes @@ -20,6 +17,7 @@ class CacheConfig { private val oauthStateTtl = Config.CACHE_TTL_OAUTH_STATE_MINUTES.getLong().asMinutes() private val calendarTtl = Config.CACHE_TTL_CALENDAR_MINUTES.getLong().asMinutes() private val rsvpTtl = Config.CACHE_TTL_RSVP_MINUTES.getLong().asMinutes() + private val staticMessageTtl = Config.CACHE_TTL_STATIC_MESSAGE_MINUTES.getLong().asMinutes() // Redis caching @@ -47,6 +45,12 @@ class CacheConfig { fun rsvpRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): RsvpCache = RedisStringCacheRepository(objectMapper, redisTemplate, "Rsvps", rsvpTtl) + @Bean + @Primary + @ConditionalOnProperty("bot.cache.redis", havingValue = "true") + fun staticMessageRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): StaticMessageCache = + RedisStringCacheRepository(objectMapper, redisTemplate, "StaticMessages", staticMessageTtl) + // In-memory fallback caching @Bean @@ -60,4 +64,7 @@ class CacheConfig { @Bean fun rsvpFallbackCache(): RsvpCache = JdkCacheRepository(rsvpTtl) + + @Bean + fun staticMessageFallbackCache(): StaticMessageCache = JdkCacheRepository(staticMessageTtl) } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt index cbf5070bd..2e5ca0c1a 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt @@ -18,6 +18,7 @@ enum class Config(private val key: String, private var value: Any? = null) { REDIS_PASSWORD("spring.data.redis.password", ""), CACHE_REDIS_IS_CLUSTER("redis.cluster", false), CACHE_USE_REDIS("bot.cache.redis", false), + CACHE_USE_REDIS_D4J("bot.cache.redis.d4j", false), CACHE_PREFIX("bot.cache.prefix", "discal"), CACHE_TTL_SETTINGS_MINUTES("bot.cache.ttl-minutes.settings", 60), @@ -26,6 +27,7 @@ enum class Config(private val key: String, private var value: Any? = null) { CACHE_TTL_OAUTH_STATE_MINUTES("bot.cache.ttl-minutes.oauth.state", 5), CACHE_TTL_CALENDAR_MINUTES("bot.cache.ttl-minutes.calendar", 120), CACHE_TTL_RSVP_MINUTES("bot.cache.ttl-minutes.rsvp", 60), + CACHE_TTL_STATIC_MESSAGE_MINUTES("bot.cache.ttl-minutes.static-messages", 60), // Security configuration diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt index 74bdc1bcc..57be06d56 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt @@ -21,7 +21,6 @@ import org.dreamexposure.discal.core.extensions.asStringList import org.dreamexposure.discal.core.extensions.setFromString import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.`object`.GuildSettings -import org.dreamexposure.discal.core.`object`.StaticMessage import org.dreamexposure.discal.core.`object`.announcement.Announcement import org.dreamexposure.discal.core.`object`.calendar.CalendarData import org.dreamexposure.discal.core.`object`.event.EventData @@ -1051,171 +1050,6 @@ object DatabaseManager { }.defaultIfEmpty(true) // If nothing was updated and no error was emitted, it's safe to return this worked. } - /* Static message */ - - fun updateStaticMessage(message: StaticMessage): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_STATIC_MESSAGE) - .bind(0, message.guildId.asLong()) - .bind(1, message.messageId.asLong()) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> row } - }.hasElements().flatMap { exists -> - if (exists) { - Mono.from( - c.createStatement(Queries.UPDATE_STATIC_MESSAGE) - .bind(0, message.lastUpdate) - .bind(1, message.scheduledUpdate) - .bind(2, message.guildId.asLong()) - .bind(3, message.messageId.asLong()) - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - } else { - Mono.from( - c.createStatement(Queries.INSERT_STATIC_MESSAGE) - .bind(0, message.guildId.asLong()) - .bind(1, message.messageId.asLong()) - .bind(2, message.channelId.asLong()) - .bind(3, message.type.value) - .bind(4, message.lastUpdate) - .bind(5, message.scheduledUpdate) - .bind(6, message.calendarNumber) - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - }.doOnError { - LOGGER.error(DEFAULT, "Failed to update static message data", it) - }.onErrorResume { Mono.just(false) } - } - } - } - - fun getStaticMessage(guildId: Snowflake, messageId: Snowflake): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_STATIC_MESSAGE) - .bind(0, guildId.asLong()) - .bind(1, messageId.asLong()) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val channelId = Snowflake.of(row["channel_id", Long::class.java]!!) - val type = StaticMessage.Type.valueOf(row["type", Int::class.java]!!) - val lastUpdate = row["last_update", Instant::class.java]!! - val scheduledUpdate = row["scheduled_update", Instant::class.java]!! - val calNum = row["calendar_number", Int::class.java]!! - - StaticMessage(guildId, messageId, channelId, type, lastUpdate, scheduledUpdate, calNum) - } - }.next().retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get static message data", it) - }.onErrorResume { - Mono.empty() - } - } - } - - fun deleteStaticMessage(guildId: Snowflake, messageId: Snowflake): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.DELETE_STATIC_MESSAGE) - .bind(0, guildId.asLong()) - .bind(1, messageId.asLong()) - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - .doOnError { - LOGGER.error(DEFAULT, "Failed to delete static message data", it) - }.onErrorReturn(false) - }.defaultIfEmpty(false) - } - - fun getStaticMessageCount(): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_STATIC_MESSAGE_COUNT) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val messages = row.get(0, Long::class.java)!! - return@map messages.toInt() - } - }.next().retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get static message count", it) - }.onErrorReturn(-1) - } - } - - fun getStaticMessagesForShard(shardCount: Int, shardIndex: Int): Mono> { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_STATIC_MESSAGES_FOR_SHARD) - .bind(0, shardCount) - .bind(1, shardIndex) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val guildId = Snowflake.of(row["guild_id", Long::class.java]!!) - val messageId = Snowflake.of(row["message_id", Long::class.java]!!) - val channelId = Snowflake.of(row["channel_id", Long::class.java]!!) - val type = StaticMessage.Type.valueOf(row["type", Int::class.java]!!) - val lastUpdate = row["last_update", Instant::class.java]!! - val scheduledUpdate = row["scheduled_update", Instant::class.java]!! - val calNum = row["calendar_number", Int::class.java]!! - - StaticMessage(guildId, messageId, channelId, type, lastUpdate, scheduledUpdate, calNum) - } - }.retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get static messages for shard", it) - }.onErrorResume { - Mono.empty() - }.collectList() - } - } - - fun getStaticMessagesForCalendar(guildId: Snowflake, calendarNumber: Int): Mono> { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_STATIC_MESSAGES_FOR_CALENDAR) - .bind(0, guildId.asLong()) - .bind(1, calendarNumber) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val messageId = Snowflake.of(row["message_id", Long::class.java]!!) - val channelId = Snowflake.of(row["channel_id", Long::class.java]!!) - val type = StaticMessage.Type.valueOf(row["type", Int::class.java]!!) - val lastUpdate = row["last_update", Instant::class.java]!! - val scheduledUpdate = row["scheduled_update", Instant::class.java]!! - - StaticMessage(guildId, messageId, channelId, type, lastUpdate, scheduledUpdate, calendarNumber) - } - }.retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get static messages for calendar", it) - }.onErrorResume { - Mono.empty() - }.collectList() - } - } - /* Event Data */ fun getEventsData(guildId: Snowflake, eventIds: List): Mono> { @@ -1466,40 +1300,6 @@ private object Queries { $DECREMENT_CALENDARS;$DECREMENT_EVENTS;$DECREMENT_RSVPS;$DECREMENT_ANNOUNCEMENTS;$DECREMENT_STATIC_MESSAGES """.trimIndent() - @Language("MySQL") - val SELECT_STATIC_MESSAGE = """SELECT * FROM ${Tables.STATIC_MESSAGES} - WHERE guild_id = ? AND message_id = ? - """.trimMargin() - - @Language("MySQL") - val SELECT_STATIC_MESSAGES_FOR_SHARD = """SELECT * FROM ${Tables.STATIC_MESSAGES} - WHERE MOD(guild_id >> 22, ?) = ? - """.trimMargin() - - @Language("MySQL") - val SELECT_STATIC_MESSAGES_FOR_CALENDAR = """SELECT * FROM ${Tables.STATIC_MESSAGES} - WHERE guild_id = ? AND calendar_number = ? - """.trimMargin() - - @Language("MySQL") - val INSERT_STATIC_MESSAGE = """INSERT INTO ${Tables.STATIC_MESSAGES} - (guild_id, message_id, channel_id, type, last_update, scheduled_update, calendar_number) - VALUES(?, ?, ?, ?, ?, ?, ?) - """.trimMargin() - - @Language("MySQL") - val UPDATE_STATIC_MESSAGE = """UPDATE ${Tables.STATIC_MESSAGES} SET - last_update = ?, scheduled_update = ? - WHERE guild_id = ? AND message_id = ? - """.trimMargin() - - @Language("MySQL") - val DELETE_STATIC_MESSAGE = """DELETE FROM ${Tables.STATIC_MESSAGES} - WHERE guild_id = ? AND message_id = ? - """.trimMargin() - - @Language("MySQL") - val SELECT_STATIC_MESSAGE_COUNT = """SELECT COUNT(*) FROM ${Tables.STATIC_MESSAGES}""" @Language("MySQL") val SELECT_MANY_EVENT_DATA = """SELECT * FROM ${Tables.EVENTS} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/StaticMessageData.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/StaticMessageData.kt new file mode 100644 index 000000000..ac05f2b32 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/StaticMessageData.kt @@ -0,0 +1,15 @@ +package org.dreamexposure.discal.core.database + +import org.springframework.data.relational.core.mapping.Table +import java.time.Instant + +@Table("static_messages") +data class StaticMessageData( + val guildId: Long, + val messageId: Long, + val channelId: Long, + val type: Int, + val lastUpdate: Instant, + val scheduledUpdate: Instant, + val calendarNumber: Int, +) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/StaticMessageRepository.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/StaticMessageRepository.kt new file mode 100644 index 000000000..f272fea5a --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/StaticMessageRepository.kt @@ -0,0 +1,49 @@ +package org.dreamexposure.discal.core.database + +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.r2dbc.repository.R2dbcRepository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.time.Instant + +interface StaticMessageRepository: R2dbcRepository { + fun existsByGuildIdAndMessageId(guildId: Long, messageId: Long): Mono + + fun findByGuildIdAndMessageId(guildId: Long, messageId: Long): Mono + + fun findAllByGuildIdAndCalendarNumber(guildId: Long, calendarNumber: Int): Flux + + @Query(""" + SELECT guild_id, + message_id, + channel_id, + type, + last_update, + scheduled_update, + calendar_number + FROM static_messages + WHERE MOD(guild_id >> 22, :shardCount) = :shardIndex + """) + fun findAllByShardIndex(shardIndex: Int, shardCount: Int): Flux + + @Query(""" + UPDATE static_messages + SET channel_id = :channelId, + type = :type, + last_update = :lastUpdate, + scheduled_update = :scheduledUpdate, + calendar_number = :calendarNumber + WHERE guild_id = :guildId AND message_id = :messageId + """) + fun updateByGuildIdAndMessageId( + guildId: Long, + messageId: Long, + channelId: Long, + type: Int, + lastUpdate: Instant, + scheduledUpdate: Instant, + calendarNumber: Int, + ): Mono + + fun deleteByGuildIdAndMessageId(guildId: Long, messageId: Long): Mono +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/StaticMessage.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/StaticMessage.kt deleted file mode 100644 index 2522a59fe..000000000 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/StaticMessage.kt +++ /dev/null @@ -1,49 +0,0 @@ -package org.dreamexposure.discal.core.`object` - -import discord4j.common.util.Snowflake -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import org.dreamexposure.discal.core.serializers.InstantAsStringSerializer -import org.dreamexposure.discal.core.serializers.SnowflakeAsStringSerializer -import java.time.Instant - -@Serializable -data class StaticMessage( - @SerialName("guild_id") - @Serializable(with = SnowflakeAsStringSerializer::class) - val guildId: Snowflake, - - @SerialName("message_id") - @Serializable(with = SnowflakeAsStringSerializer::class) - val messageId: Snowflake, - - @SerialName("channel_id") - @Serializable(with = SnowflakeAsStringSerializer::class) - val channelId: Snowflake, - - val type: Type, - - @SerialName("last_update") - @Serializable(with = InstantAsStringSerializer::class) - val lastUpdate: Instant, - - @SerialName("scheduled_update") - @Serializable(with = InstantAsStringSerializer::class) - val scheduledUpdate: Instant, - - @SerialName("calendar_number") - val calendarNumber: Int -) { - enum class Type(val value: Int) { - CALENDAR_OVERVIEW(1); - - companion object { - fun valueOf(type: Int): Type { - return when (type) { - 1 -> CALENDAR_OVERVIEW - else -> throw IllegalArgumentException("Unknown type: $type") - } - } - } - } -} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/StaticMessage.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/StaticMessage.kt new file mode 100644 index 000000000..551969230 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/StaticMessage.kt @@ -0,0 +1,42 @@ +package org.dreamexposure.discal.core.`object`.new + +import discord4j.common.util.Snowflake +import org.dreamexposure.discal.core.database.StaticMessageData +import org.dreamexposure.discal.core.extensions.asSnowflake +import java.time.Instant + +data class StaticMessage( + val guildId: Snowflake, + val messageId: Snowflake, + val channelId: Snowflake, + + val type: Type, + + val lastUpdate: Instant, + val scheduledUpdate: Instant, + + val calendarNumber: Int +) { + constructor(data: StaticMessageData): this( + guildId = data.guildId.asSnowflake(), + messageId = data.messageId.asSnowflake(), + channelId = data.channelId.asSnowflake(), + + type = Type.getByValue(data.type), + + lastUpdate = data.lastUpdate, + scheduledUpdate = data.scheduledUpdate, + + calendarNumber = data.calendarNumber, + ) + + + + enum class Type(val value: Int) { + CALENDAR_OVERVIEW(1); + + companion object { + fun getByValue(value: Int) = entries.first { it.value == value } + } + } +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt b/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt index 5b4bddf58..874bca979 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt @@ -5,6 +5,7 @@ import org.dreamexposure.discal.core.cache.CacheRepository import org.dreamexposure.discal.core.`object`.new.Calendar import org.dreamexposure.discal.core.`object`.new.Credential import org.dreamexposure.discal.core.`object`.new.Rsvp +import org.dreamexposure.discal.core.`object`.new.StaticMessage // Cache //typealias GuildSettingsCache = CacheRepository @@ -12,3 +13,4 @@ typealias CredentialsCache = CacheRepository typealias OauthStateCache = CacheRepository typealias CalendarCache = CacheRepository> typealias RsvpCache = CacheRepository +typealias StaticMessageCache = CacheRepository diff --git a/core/src/main/resources/commands/global/displayCal.json b/core/src/main/resources/commands/global/displayCal.json index 69397d368..d92a9455a 100644 --- a/core/src/main/resources/commands/global/displayCal.json +++ b/core/src/main/resources/commands/global/displayCal.json @@ -119,19 +119,6 @@ "min_value": 1 } ] - }, - { - "name": "update", - "type": 1, - "description": "Manually update the calendar overview message with the provided message ID", - "options": [ - { - "name": "message", - "type": 3, - "description": "The ID of the message to update", - "required": true - } - ] } ] } From 59b1d5b8d0463d13815289014690dd5400969295 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Tue, 27 Feb 2024 22:39:26 -0600 Subject: [PATCH 09/43] Refactor linkCal command to kotlin coroutines --- .../commands/global/LinkCalendarCommand.kt | 31 +++-- .../client/message/embed/CalendarEmbed.kt | 110 +----------------- .../discal/core/business/EmbedService.kt | 31 ++++- 3 files changed, 50 insertions(+), 122 deletions(-) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/LinkCalendarCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/LinkCalendarCommand.kt index 108df5ea1..b42bf7f55 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/LinkCalendarCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/LinkCalendarCommand.kt @@ -1,38 +1,45 @@ package org.dreamexposure.discal.client.commands.global +import discord4j.core.event.domain.interaction.ChatInputInteractionEvent import discord4j.core.`object`.command.ApplicationCommandInteractionOption import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue import discord4j.core.`object`.entity.Message -import discord4j.core.event.domain.interaction.ChatInputInteractionEvent +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull import org.dreamexposure.discal.client.commands.SlashCommand -import org.dreamexposure.discal.client.message.embed.CalendarEmbed -import org.dreamexposure.discal.core.`object`.GuildSettings +import org.dreamexposure.discal.core.business.EmbedService import org.dreamexposure.discal.core.extensions.discord4j.followup +import org.dreamexposure.discal.core.extensions.discord4j.getCalendar +import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.utils.getCommonMsg import org.springframework.stereotype.Component -import reactor.core.publisher.Mono @Component -class LinkCalendarCommand : SlashCommand { +class LinkCalendarCommand( + private val embedService: EmbedService, +) : SlashCommand { override val name = "linkcal" override val ephemeral = false - @Deprecated("Use new handleSuspend for K-coroutines") - override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + + override suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val showOverview = event.getOption("overview") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asBoolean) .orElse(true) - val calendarNumber = event.getOption("calendar") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) .map(Long::toInt) .orElse(1) - return event.interaction.guild.flatMap { guild -> - CalendarEmbed.link(guild, settings, calendarNumber, showOverview) - .flatMap(event::followup) - }.switchIfEmpty(event.followup(getCommonMsg("error.notFound.calendar", settings))) + val calendar = event.interaction.guild.flatMap { + it.getCalendar(calendarNumber) + }.awaitSingleOrNull() + if (calendar == null) { + return event.followup(getCommonMsg("error.notFound.calendar", settings)).awaitSingle() + } + + return event.followup(embedService.linkCalendarEmbed(calendarNumber, settings, showOverview)).awaitSingle() } } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/CalendarEmbed.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/CalendarEmbed.kt index c4b48e423..40a16a6db 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/CalendarEmbed.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/CalendarEmbed.kt @@ -3,30 +3,23 @@ package org.dreamexposure.discal.client.message.embed import discord4j.core.`object`.entity.Guild import discord4j.core.spec.EmbedCreateSpec import org.dreamexposure.discal.core.entities.Calendar -import org.dreamexposure.discal.core.entities.Event -import org.dreamexposure.discal.core.enums.time.DiscordTimestampFormat import org.dreamexposure.discal.core.enums.time.TimeFormat -import org.dreamexposure.discal.core.extensions.* import org.dreamexposure.discal.core.extensions.discord4j.getCalendar +import org.dreamexposure.discal.core.extensions.embedDescriptionSafe +import org.dreamexposure.discal.core.extensions.embedFieldSafe +import org.dreamexposure.discal.core.extensions.embedTitleSafe +import org.dreamexposure.discal.core.extensions.toMarkdown import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.`object`.calendar.PreCalendar import org.dreamexposure.discal.core.utils.GlobalVal.discalColor import org.dreamexposure.discal.core.utils.getCommonMsg import reactor.core.publisher.Mono -import java.time.Instant import java.time.LocalDateTime -import java.time.ZonedDateTime import java.time.format.DateTimeFormatter -import java.time.temporal.ChronoUnit object CalendarEmbed : EmbedMaker { - fun link(guild: Guild, settings: GuildSettings, calNumber: Int, overview: Boolean): Mono { - return guild.getCalendar(calNumber).flatMap { - if (overview) overview(guild, settings, it, false) - else Mono.just(link(guild, settings, it)) - } - } + @Deprecated("Use replacement in EmbedService") fun link(guild: Guild, settings: GuildSettings, calendar: Calendar): EmbedCreateSpec { val builder = defaultBuilder(guild, settings) //Handle optional fields @@ -45,99 +38,6 @@ object CalendarEmbed : EmbedMaker { .build() } - @Deprecated("Use replacement in EmbedService") - fun overview(guild: Guild, settings: GuildSettings, calendar: Calendar, showUpdate: Boolean): Mono { - return calendar.getUpcomingEvents(15).collectList().map { it.groupByDate() }.map { events -> - val builder = defaultBuilder(guild, settings) - - - //Handle optional fields - if (calendar.name.isNotBlank()) - builder.title(calendar.name.toMarkdown().embedTitleSafe()) - if (calendar.description.isNotBlank()) - builder.description(calendar.description.toMarkdown().embedDescriptionSafe()) - - // Truncate dates to 23 due to discord enforcing the field limit - val truncatedEvents = mutableMapOf>() - for (event in events) { - if (truncatedEvents.size < 23) { - truncatedEvents[event.key] = event.value - } else break - } - - // Show events - truncatedEvents.forEach { date -> - - val title = date.key.toInstant().humanReadableDate(calendar.timezone, settings.timeFormat, longDay = true) - - // sort events - val sortedEvents = date.value.sortedBy { it.start } - - val content = StringBuilder() - - sortedEvents.forEach { - // Start event - content.append("```\n") - - - // determine time length - val timeDisplayLen = ("${it.start.humanReadableTime(it.timezone, settings.timeFormat)} -" + - " ${it.end.humanReadableTime(it.timezone,settings.timeFormat)} ").length - - // Displaying time - if (it.isAllDay()) { - content.append(getCommonMsg("generic.time.allDay", settings).padCenter(timeDisplayLen)) - .append("| ") - } else { - // Add start text - var str = if (it.start.isBefore(date.key.toInstant())) { - "${getCommonMsg("generic.time.continued", settings)} - " - } else { - "${it.start.humanReadableTime(it.timezone, settings.timeFormat)} - " - } - // Add end text - str += if (it.end.isAfter(date.key.toInstant().plus(1, ChronoUnit.DAYS))) { - getCommonMsg("generic.time.continued", settings) - } else { - "${it.end.humanReadableTime(it.timezone, settings.timeFormat)} " - } - content.append(str.padCenter(timeDisplayLen)) - .append("| ") - } - // Display name or ID if not set - if (it.name.isNotBlank()) content.append(it.name) - else content.append(getMessage("calendar", "link.field.id", settings)).append(" ${it.eventId}") - content.append("\n") - if (it.location.isNotBlank()) content.append(" Location: ") - .append(it.location.embedFieldSafe()) - .append("\n") - - // Finish event - content.append("```\n") - } - - if (content.isNotBlank()) - builder.addField(title, content.toString().embedFieldSafe(), false) - } - - - // set footer - if (showUpdate) { - val lastUpdate = Instant.now().asDiscordTimestamp(DiscordTimestampFormat.RELATIVE_TIME) - builder.footer(getMessage("calendar", "link.footer.update", settings, lastUpdate), null) - .timestamp(Instant.now()) - } else builder.footer(getMessage("calendar", "link.footer.default", settings), null) - - // finish and return - builder.addField(getMessage("calendar", "link.field.timezone", settings), calendar.zoneName, true) - .addField(getMessage("calendar", "link.field.number", settings), "${calendar.calendarNumber}", true) - .url(calendar.link) - .color(discalColor) - .build() - } - } - - fun time(guild: Guild, settings: GuildSettings, calNumber: Int): Mono { return guild.getCalendar(calNumber).map { cal -> val ldt = LocalDateTime.now(cal.timezone) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt index 78385e977..c103d231b 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt @@ -49,11 +49,7 @@ class EmbedService( ///////////////////////////// ////// Calendar Embeds ////// ///////////////////////////// - suspend fun calendarOverviewEmbed( - calendar: Calendar, - settings: GuildSettings, - showUpdate: Boolean - ): EmbedCreateSpec { + suspend fun calendarOverviewEmbed(calendar: Calendar, settings: GuildSettings, showUpdate: Boolean): EmbedCreateSpec { val builder = defaultEmbedBuilder(settings) // Get the events to build the overview @@ -144,6 +140,31 @@ class EmbedService( .build() } + suspend fun linkCalendarEmbed(calendarNumber: Int, settings: GuildSettings, overview: Boolean): EmbedCreateSpec { + val calendar = discordClient.getGuildById(settings.guildID).getCalendar(calendarNumber).awaitSingle() + return if (overview) calendarOverviewEmbed(calendar, settings, showUpdate = false) + else linkCalendarEmbed(calendar, settings) + } + + suspend fun linkCalendarEmbed(calendar: Calendar, settings: GuildSettings): EmbedCreateSpec { + val builder = defaultEmbedBuilder(settings) + + //Handle optional fields + if (calendar.name.isNotBlank()) + builder.title(calendar.name.toMarkdown().embedTitleSafe()) + if (calendar.description.isNotBlank()) + builder.description(calendar.description.toMarkdown().embedDescriptionSafe()) + + return builder.addField(getEmbedMessage("calendar", "link.field.timezone", settings), calendar.zoneName, false) + .addField(getEmbedMessage("calendar", "link.field.host", settings), calendar.calendarData.host.name, true) + .addField(getEmbedMessage("calendar", "link.field.number", settings), "${calendar.calendarNumber}", true) + .addField(getEmbedMessage("calendar", "link.field.id", settings), calendar.calendarId, false) + .url(calendar.link) + .footer(getEmbedMessage("calendar", "link.footer.default", settings), null) + .color(GlobalVal.discalColor) + .build() + } + ///////////////////////// ////// RSVP Embeds ////// ///////////////////////// From 01f9fbf3c1009e76c3239c4753cad07cc4aaec82 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Thu, 29 Feb 2024 13:17:36 -0600 Subject: [PATCH 10/43] Add refresh button for static messages --- .../client/interaction/InteractionHandler.kt | 10 ++++ .../interaction/StaticMessageRefreshButton.kt | 31 +++++++++++ .../discord/ButtonInteractionListener.kt | 54 +++++++++++++++++++ .../listeners/discord/GuildCreateListener.kt | 4 +- .../listeners/discord/SlashCommandListener.kt | 8 +-- .../discal/core/business/ComponentService.kt | 21 ++++++++ .../core/business/StaticMessageService.kt | 27 +++++++--- .../discal/core/utils/MessageSourceLoader.kt | 9 ++-- .../src/main/resources/i18n/common.properties | 3 +- 9 files changed, 151 insertions(+), 16 deletions(-) create mode 100644 client/src/main/kotlin/org/dreamexposure/discal/client/interaction/InteractionHandler.kt create mode 100644 client/src/main/kotlin/org/dreamexposure/discal/client/interaction/StaticMessageRefreshButton.kt create mode 100644 client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/ButtonInteractionListener.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/business/ComponentService.kt diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/interaction/InteractionHandler.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/interaction/InteractionHandler.kt new file mode 100644 index 000000000..5873e6596 --- /dev/null +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/interaction/InteractionHandler.kt @@ -0,0 +1,10 @@ +package org.dreamexposure.discal.client.interaction + +import discord4j.core.event.domain.interaction.InteractionCreateEvent +import org.dreamexposure.discal.core.`object`.GuildSettings + +interface InteractionHandler { + val ids: Array + + suspend fun handle(event: T, settings: GuildSettings) +} diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/interaction/StaticMessageRefreshButton.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/interaction/StaticMessageRefreshButton.kt new file mode 100644 index 000000000..ebe4ebce2 --- /dev/null +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/interaction/StaticMessageRefreshButton.kt @@ -0,0 +1,31 @@ +package org.dreamexposure.discal.client.interaction + +import discord4j.core.event.domain.interaction.ButtonInteractionEvent +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.dreamexposure.discal.core.business.StaticMessageService +import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.`object`.GuildSettings +import org.dreamexposure.discal.core.utils.getCommonMsg +import org.springframework.stereotype.Component + +@Component +class StaticMessageRefreshButton( + private val staticMessageService: StaticMessageService, +): InteractionHandler { + override val ids = arrayOf("refresh-static-message") + + override suspend fun handle(event: ButtonInteractionEvent, settings: GuildSettings) { + try { + // Defer, this process can take a bit + event.deferEdit() + .withEphemeral(true) + .awaitSingleOrNull() + + staticMessageService.updateStaticMessage(settings.guildID, event.messageId) + } catch (ex: Exception) { + LOGGER.error("Error handling static message refresh button | guildId:${settings.guildID.asLong()} | messageId: ${event.messageId.asLong()}", ex) + + event.createFollowup(getCommonMsg("error.unknown", settings.getLocale())) + } + } +} diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/ButtonInteractionListener.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/ButtonInteractionListener.kt new file mode 100644 index 000000000..e3a3be4e6 --- /dev/null +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/ButtonInteractionListener.kt @@ -0,0 +1,54 @@ +package org.dreamexposure.discal.client.listeners.discord + +import discord4j.core.event.domain.interaction.ButtonInteractionEvent +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.dreamexposure.discal.client.interaction.InteractionHandler +import org.dreamexposure.discal.core.business.MetricService +import org.dreamexposure.discal.core.database.DatabaseManager +import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT +import org.dreamexposure.discal.core.utils.getCommonMsg +import org.springframework.stereotype.Component +import org.springframework.util.StopWatch +import java.util.* + +@Component +class ButtonInteractionListener( + private val buttons: List>, + private val metricService: MetricService, +): EventListener { + override suspend fun handle(event: ButtonInteractionEvent) { + val timer = StopWatch() + timer.start() + + if (!event.interaction.guildId.isPresent) { + event.reply(getCommonMsg("error.dm.not-supported", Locale.ENGLISH)) + return + } + + val button = buttons.firstOrNull { it.ids.contains(event.customId) } + + if (button != null) { + try { + val settings = DatabaseManager.getSettings(event.interaction.guildId.get()).awaitSingle() + + button.handle(event, settings) + } catch (e: Exception) { + LOGGER.error(DEFAULT, "Error handling button interaction | $event", e) + + // Attempt to provide a message if there's an unhandled exception + event.createFollowup(getCommonMsg("error.unknown", Locale.ENGLISH)) + .withEphemeral(true) + .awaitSingleOrNull() + } + } else { + event.createFollowup(getCommonMsg("error.unknown", Locale.ENGLISH)) + .withEphemeral(true) + .awaitSingleOrNull() + } + + timer.stop() + metricService.recordInteractionDuration(event.customId, "button", timer.totalTimeMillis) + } +} diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/GuildCreateListener.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/GuildCreateListener.kt index 3cfbfc64a..098ee7765 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/GuildCreateListener.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/GuildCreateListener.kt @@ -14,8 +14,8 @@ import org.springframework.stereotype.Component @Component class GuildCreateListener(objectMapper: ObjectMapper) : EventListener { - private val premiumCommands: List - private val devCommands: List + private final val premiumCommands: List + private final val devCommands: List init { val matcher = PathMatchingResourcePatternResolver() diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/SlashCommandListener.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/SlashCommandListener.kt index 1539a43e5..2fb9b3fdc 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/SlashCommandListener.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/SlashCommandListener.kt @@ -8,8 +8,10 @@ import org.dreamexposure.discal.core.business.MetricService import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT +import org.dreamexposure.discal.core.utils.getCommonMsg import org.springframework.stereotype.Component import org.springframework.util.StopWatch +import java.util.* @Component class SlashCommandListener( @@ -22,7 +24,7 @@ class SlashCommandListener( timer.start() if (!event.interaction.guildId.isPresent) { - event.reply("Commands not supported in DMs.").awaitSingleOrNull() + event.reply(getCommonMsg("error.dm.not-supported", Locale.ENGLISH)).awaitSingleOrNull() return } @@ -39,12 +41,12 @@ class SlashCommandListener( LOGGER.error(DEFAULT, "Error handling slash command | $event", e) // Attempt to provide a message if there's an unhandled exception - event.createFollowup("An unknown error has occurred") + event.createFollowup(getCommonMsg("error.unknown", Locale.ENGLISH)) .withEphemeral(command.ephemeral) .awaitSingleOrNull() } } else { - event.createFollowup("An unknown error has occurred. Please try again and/or contact DisCal support.") + event.createFollowup(getCommonMsg("error.unknown", Locale.ENGLISH)) .withEphemeral(true) .awaitSingleOrNull() } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/ComponentService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/ComponentService.kt new file mode 100644 index 000000000..d227c9ca3 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/ComponentService.kt @@ -0,0 +1,21 @@ +package org.dreamexposure.discal.core.business + +import discord4j.common.util.Snowflake +import discord4j.core.`object`.component.ActionRow +import discord4j.core.`object`.component.Button +import discord4j.core.`object`.component.LayoutComponent +import discord4j.core.`object`.reaction.ReactionEmoji +import org.springframework.stereotype.Component + +@Component +class ComponentService { + + fun getStaticMessageComponents(): Array { + val refreshButton = Button.secondary( + "refresh-static-message", + ReactionEmoji.custom(Snowflake.of(1175580426585247815), "refresh_ts", false) + ) + + return arrayOf(ActionRow.of(refreshButton)) + } +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/StaticMessageService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/StaticMessageService.kt index 51ee6f5fd..f2e86d593 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/StaticMessageService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/StaticMessageService.kt @@ -2,6 +2,7 @@ package org.dreamexposure.discal.core.business import discord4j.common.util.Snowflake import discord4j.core.DiscordClient +import discord4j.discordjson.json.MessageCreateRequest import discord4j.discordjson.json.MessageEditRequest import discord4j.rest.http.client.ClientException import kotlinx.coroutines.reactor.awaitSingle @@ -27,6 +28,7 @@ class StaticMessageService( private val staticMessageRepository: StaticMessageRepository, private val staticMessageCache: StaticMessageCache, private val embedService: EmbedService, + private val componentService: ComponentService, private val metricService: MetricService, private val beanFactory: BeanFactory, ) { @@ -86,7 +88,12 @@ class StaticMessageService( // Finally create the message - val message = channel.createMessage(embed.asRequest()).awaitSingle() + val message = channel.createMessage( + MessageCreateRequest.builder() + .addEmbed(embed.asRequest()) + .components(componentService.getStaticMessageComponents().map { it.data }) + .build() + ).awaitSingle() val saved = staticMessageRepository.save( StaticMessageData( guildId = guildId.asLong(), @@ -128,9 +135,12 @@ class StaticMessageService( // Finally update the message val embed = embedService.calendarOverviewEmbed(calendar, settings, showUpdate = true) - discordClient.getMessageById(old.channelId, old.messageId) - .edit(MessageEditRequest.builder().addEmbed(embed.asRequest()).build()) - .awaitSingleOrNull() + discordClient.getMessageById(old.channelId, old.messageId).edit( + MessageEditRequest.builder() + .addEmbed(embed.asRequest()) + .components(componentService.getStaticMessageComponents().map { it.data }) + .build() + ).awaitSingleOrNull() val updated = old.copy( lastUpdate = Instant.now(), @@ -175,9 +185,12 @@ class StaticMessageService( return@forEach } - discordClient.getMessageById(old.channelId, old.messageId) - .edit(MessageEditRequest.builder().addEmbed(embed.asRequest()).build()) - .awaitSingleOrNull() + discordClient.getMessageById(old.channelId, old.messageId).edit( + MessageEditRequest.builder() + .addEmbed(embed.asRequest()) + .components(componentService.getStaticMessageComponents().map { it.data }) + .build() + ).awaitSingleOrNull() val updated = old.copy( lastUpdate = Instant.now(), diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/utils/MessageSourceLoader.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/utils/MessageSourceLoader.kt index 9b5289e41..dd3fb773d 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/utils/MessageSourceLoader.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/utils/MessageSourceLoader.kt @@ -3,6 +3,7 @@ package org.dreamexposure.discal.core.utils import org.dreamexposure.discal.core.`object`.GuildSettings import org.springframework.context.support.ResourceBundleMessageSource import java.nio.charset.StandardCharsets +import java.util.* object MessageSourceLoader { private val sources: MutableMap = mutableMapOf() @@ -28,10 +29,12 @@ object MessageSourceLoader { //FIXME: I think the varargs is bugging out with 3 or more provided. Will need to debug this later, but not today. -fun getCommonMsg(key: String, settings: GuildSettings, vararg args: String): String { - val src = MessageSourceLoader.getSourceByPath("common") +fun getCommonMsg(key: String, settings: GuildSettings, vararg args: String) = getCommonMsg(key, settings.getLocale(), *args) - return src.getMessage(key, args, settings.getLocale()) +fun getCommonMsg(key: String, locale: Locale, vararg args: String): String { + val src= MessageSourceLoader.getSourceByPath("common") + + return src.getMessage(key, args, locale) } fun getEmbedMessage(embed: String, key: String, settings: GuildSettings, vararg args: String): String { diff --git a/core/src/main/resources/i18n/common.properties b/core/src/main/resources/i18n/common.properties index 11c9210be..608cd9783 100644 --- a/core/src/main/resources/i18n/common.properties +++ b/core/src/main/resources/i18n/common.properties @@ -5,7 +5,8 @@ success.generic=Success generic.time.allDay=All day generic.time.continued=Cont. -error.unknown=Sorry, an unknown error has occurred. +error.unknown=Sorry, an unknown error has occurred. Please try again and/or contact DisCal support. +error.dm.not-supported=Sorry, this feature is currently not supported in DMs. error.notFound.event=No event with that ID was found. There may be a typo, or it may have been deleted. error.notFound.calendar=Calendar not found. There may be a typo, or it may have been deleted. From 716bf703697de4b6a0a9adea670e2bf3b4856ae3 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Fri, 1 Mar 2024 16:40:47 -0600 Subject: [PATCH 11/43] Uh, I killed the laptop --- .../core/business/AnnouncementService.kt | 100 +++++++++++ .../discal/core/business/CalendarService.kt | 2 +- .../core/business/StaticMessageService.kt | 1 - .../discal/core/config/CacheConfig.kt | 10 ++ .../discal/core/config/Config.kt | 1 + .../discal/core/database/AnnouncementData.kt | 22 +++ .../core/database/AnnouncementRepository.kt | 80 +++++++++ .../discal/core/database/DatabaseManager.kt | 164 ------------------ .../discal/core/object/new/Announcement.kt | 72 ++++++++ .../org/dreamexposure/discal/typealiases.kt | 6 +- .../endpoints/v3/AnnouncementController.kt | 8 + 11 files changed, 296 insertions(+), 170 deletions(-) create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/database/AnnouncementData.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/database/AnnouncementRepository.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Announcement.kt create mode 100644 server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/AnnouncementController.kt diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt new file mode 100644 index 000000000..76b1c1f5e --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt @@ -0,0 +1,100 @@ +package org.dreamexposure.discal.core.business + +import discord4j.common.util.Snowflake +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.dreamexposure.discal.AnnouncementCache +import org.dreamexposure.discal.core.database.AnnouncementRepository +import org.dreamexposure.discal.core.`object`.new.Announcement +import org.springframework.stereotype.Component + +@Component +class AnnouncementService( + private val announcementRepository: AnnouncementRepository, + private val announcementCache: AnnouncementCache, +) { + suspend fun getAnnouncementCount(): Long = announcementRepository.count().awaitSingle() + + suspend fun getAllAnnouncements(shardIndex: Int, shardCount: Int): List { + return announcementRepository.findAllByShardIndexAndEnabledIsTrue(shardCount, shardIndex) + .map(::Announcement) + .collectList() + .awaitSingle() + } + + suspend fun getAllAnnouncements(guildId: Snowflake): List { + var announcements = announcementCache.get(key = guildId)?.toList() + if (announcements != null) return announcements + + announcements = announcementRepository.findAllByGuildId(guildId.asLong()) + .map(::Announcement) + .collectList() + .awaitSingle() + + announcementCache.put(key = guildId, value = announcements.toTypedArray()) + return announcements + } + + suspend fun getAllAnnouncements(guildId: Snowflake, type: Announcement.Type): List { + return getAllAnnouncements(guildId).filter { it.type == type } + } + + suspend fun getEnabledAnnouncements(guildId: Snowflake): List { + return getAllAnnouncements(guildId).filter(Announcement::enabled) + } + + suspend fun getEnabledAnnouncements(guildId: Snowflake, type: Announcement.Type): List { + return getEnabledAnnouncements(guildId).filter { it.type == type } + } + + suspend fun getAnnouncement(guildId: Snowflake, id: String): Announcement? { + return getAllAnnouncements(guildId).firstOrNull { it.id == id } + } + + suspend fun updateAnnouncement(announcement: Announcement) { + announcementRepository.updateByGuildIdAndAnnouncementId( + guildId = announcement.guildId.asLong(), + announcementId = announcement.id, + calendarNumber = announcement.calendarNumber, + subscribersRole = announcement.subscribers.roles.joinToString(","), + subscribersUser = announcement.subscribers.users.map(Snowflake::asLong).joinToString(","), + channelId = announcement.channelId.asString(), + announcementType = announcement.type.name, + modifier = announcement.modifier.name, + eventId = announcement.eventId, + eventColor = announcement.eventColor.name, + hoursBefore = announcement.hoursBefore, + minutesBefore = announcement.minutesBefore, + info = announcement.info, + enabled = announcement.enabled, + publish = announcement.publish, + ).awaitSingleOrNull() + + val cached = announcementCache.get(key = announcement.guildId) + if (cached != null) { + val new = cached + .filterNot { it.id == announcement.id } + .plus(announcement) + .toTypedArray() + announcementCache.put(key = announcement.guildId, value = new) + } + } + + suspend fun deleteAnnouncement(guildId: Snowflake, id: String) { + announcementRepository.deleteByAnnouncementId(id).awaitSingleOrNull() + + val cached = announcementCache.get(key = guildId) + if (cached != null) { + announcementCache.put(key = guildId, value = cached.filterNot { it.id == id }.toTypedArray()) + } + } + + suspend fun deleteAnnouncements(guildId: Snowflake, eventId: String) { + announcementRepository.deleteAllByGuildIdAndEventId(guildId.asLong(), eventId).awaitSingleOrNull() + + val cached = announcementCache.get(key = guildId) + if (cached != null) { + announcementCache.put(key = guildId, value = cached.filterNot { it.eventId == eventId }.toTypedArray()) + } + } +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/CalendarService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/CalendarService.kt index 903b46b54..6b6d195f3 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/CalendarService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/CalendarService.kt @@ -29,7 +29,7 @@ class DefaultCalendarService( } override suspend fun getCalendar(guildId: Snowflake, number: Int): Calendar? { - return getAllCalendars(guildId).first { it.number == number } + return getAllCalendars(guildId).firstOrNull { it.number == number } } override suspend fun updateCalendar(calendar: Calendar) { diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/StaticMessageService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/StaticMessageService.kt index f2e86d593..7a271d613 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/StaticMessageService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/StaticMessageService.kt @@ -218,5 +218,4 @@ class StaticMessageService( staticMessageRepository.deleteByGuildIdAndMessageId(guildId.asLong(), messageId.asLong()).awaitSingleOrNull() staticMessageCache.evict(guildId, key = messageId) } - } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt index 50da5e8b8..11e9945d7 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt @@ -18,6 +18,7 @@ class CacheConfig { private val calendarTtl = Config.CACHE_TTL_CALENDAR_MINUTES.getLong().asMinutes() private val rsvpTtl = Config.CACHE_TTL_RSVP_MINUTES.getLong().asMinutes() private val staticMessageTtl = Config.CACHE_TTL_STATIC_MESSAGE_MINUTES.getLong().asMinutes() + private val announcementTll = Config.CACHE_TTL_ANNOUNCEMENT_MINUTES.getLong().asMinutes() // Redis caching @@ -51,6 +52,12 @@ class CacheConfig { fun staticMessageRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): StaticMessageCache = RedisStringCacheRepository(objectMapper, redisTemplate, "StaticMessages", staticMessageTtl) + @Bean + @Primary + @ConditionalOnProperty("bot.cache.redis", havingValue = "true") + fun announcementRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): AnnouncementCache = + RedisStringCacheRepository(objectMapper, redisTemplate, "Announcements", announcementTll) + // In-memory fallback caching @Bean @@ -67,4 +74,7 @@ class CacheConfig { @Bean fun staticMessageFallbackCache(): StaticMessageCache = JdkCacheRepository(staticMessageTtl) + + @Bean + fun announcementFallbackCache(): AnnouncementCache = JdkCacheRepository(announcementTll) } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt index 2e5ca0c1a..53eb1042f 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt @@ -28,6 +28,7 @@ enum class Config(private val key: String, private var value: Any? = null) { CACHE_TTL_CALENDAR_MINUTES("bot.cache.ttl-minutes.calendar", 120), CACHE_TTL_RSVP_MINUTES("bot.cache.ttl-minutes.rsvp", 60), CACHE_TTL_STATIC_MESSAGE_MINUTES("bot.cache.ttl-minutes.static-messages", 60), + CACHE_TTL_ANNOUNCEMENT_MINUTES("bot.cache.ttl-minutes.announcements", 120), // Security configuration diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/AnnouncementData.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/AnnouncementData.kt new file mode 100644 index 000000000..7ea8d8c54 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/AnnouncementData.kt @@ -0,0 +1,22 @@ +package org.dreamexposure.discal.core.database + +import org.springframework.data.relational.core.mapping.Table + +@Table("announcements") +data class AnnouncementData( + val announcementId: String, + val calendarNumber: Int, + val guildId: Long, + val subscribersRole: String, + val subscribersUser: String, + val channelId: String, + val announcementType: String, + val modifier: String, + val eventId: String, + val eventColor: String, + val hoursBefore: Int, + val minutesBefore: Int, + val info: String, + val enabled: Boolean, + val publish: Boolean, +) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/AnnouncementRepository.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/AnnouncementRepository.kt new file mode 100644 index 000000000..b47dbb431 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/AnnouncementRepository.kt @@ -0,0 +1,80 @@ +package org.dreamexposure.discal.core.database + +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.r2dbc.repository.R2dbcRepository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +interface AnnouncementRepository: R2dbcRepository { + + fun findByGuildIdAndAnnouncementId(guildId: Long, announcementId: String): Mono + + fun findAllByGuildId(guildId: Long): Flux + + fun findAllByGuildIdAndEnabledIsTrue(guildId: Long): Flux + + fun findAllByGuildIdAndAnnouncementTypeAndEnabledIsTrue(guildId: Long, announcementType: String): Flux + + @Query(""" + SELECT + announcement_id, + calendar_number, + guild_id, + subscribers_role, + subscribers_user, + channel_id, + announcement_type, + modifier, + event_id, + event_color, + hours_before, + minutes_before, + info, + enabled, + publish + FROM announcements + WHERE MOD(guild_id >> 22, :shardCount) = :shardIndex + AND ENABLED = 1 + """) + fun findAllByShardIndexAndEnabledIsTrue(shardIndex: Int, shardCount: Int): Flux + + @Query(""" + UPDATE announcements + SET calendar_number = :calendarNumber, + subscribers_role = :subscribersRole, + subscribers_user = :subscribersUser, + channel_id = :channelId, + announcement_type = :announcementType, + modifier = :modifier, + event_id = :eventId, + event_color = :eventColor, + hours_before = :hoursBefore, + minutes_before = :minutesBefore, + info = :info, + enabled = :enabled, + publish = :publish + WHERE guild_id = :guildId + AND announcement_id = :announcementId + """) + fun updateByGuildIdAndAnnouncementId( + guildId: Long, + announcementId: String, + calendarNumber: Int, + subscribersRole: String, + subscribersUser: String, + channelId: String, + announcementType: String, + modifier: String, + eventId: String, + eventColor: String, + hoursBefore: Int, + minutesBefore: Int, + info: String, + enabled: Boolean, + publish: Boolean, + ): Mono + + fun deleteByAnnouncementId(announcementId: String): Mono + + fun deleteAllByGuildIdAndEventId(guildId: Long, eventId: String): Mono +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt index 57be06d56..8440b0030 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt @@ -693,115 +693,6 @@ object DatabaseManager { }.defaultIfEmpty(mutableListOf()) } - fun getAnnouncements(): Mono> { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_ALL_ANNOUNCEMENTS) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val announcementId = row["ANNOUNCEMENT_ID", String::class.java]!! - val guildId = Snowflake.of(row["GUILD_ID", Long::class.java]!!) - - val a = Announcement(guildId, announcementId) - a.calendarNumber = row["CALENDAR_NUMBER", Int::class.java]!! - a.subscriberRoleIds.setFromString(row["SUBSCRIBERS_ROLE", String::class.java]!!) - a.subscriberUserIds.setFromString(row["SUBSCRIBERS_USER", String::class.java]!!) - a.announcementChannelId = row["CHANNEL_ID", String::class.java]!! - a.type = AnnouncementType.valueOf(row["ANNOUNCEMENT_TYPE", String::class.java]!!) - a.modifier = AnnouncementModifier.valueOf(row["MODIFIER", String::class.java]!!) - a.eventId = row["EVENT_ID", String::class.java]!! - a.eventColor = fromNameOrHexOrId(row["EVENT_COLOR", String::class.java]!!) - a.hoursBefore = row["HOURS_BEFORE", Int::class.java]!! - a.minutesBefore = row["MINUTES_BEFORE", Int::class.java]!! - a.info = row["INFO", String::class.java]!! - a.enabled = row["ENABLED", Boolean::class.java]!! - a.publish = row["PUBLISH", Boolean::class.java]!! - - a - } - }.collectList().retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get all announcements", it) - }.onErrorReturn(mutableListOf()) - }.defaultIfEmpty(mutableListOf()) - } - - fun getAnnouncements(type: AnnouncementType): Mono> { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_ALL_ANNOUNCEMENTS_BY_TYPE) - .bind(0, type.name) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val announcementId = row["ANNOUNCEMENT_ID", String::class.java]!! - val guildId = Snowflake.of(row["GUILD_ID", Long::class.java]!!) - - val a = Announcement(guildId, announcementId) - a.calendarNumber = row["CALENDAR_NUMBER", Int::class.java]!! - a.subscriberRoleIds.setFromString(row["SUBSCRIBERS_ROLE", String::class.java]!!) - a.subscriberUserIds.setFromString(row["SUBSCRIBERS_USER", String::class.java]!!) - a.announcementChannelId = row["CHANNEL_ID", String::class.java]!! - a.type = AnnouncementType.valueOf(row["ANNOUNCEMENT_TYPE", String::class.java]!!) - a.modifier = AnnouncementModifier.valueOf(row["MODIFIER", String::class.java]!!) - a.eventId = row["EVENT_ID", String::class.java]!! - a.eventColor = fromNameOrHexOrId(row["EVENT_COLOR", String::class.java]!!) - a.hoursBefore = row["HOURS_BEFORE", Int::class.java]!! - a.minutesBefore = row["MINUTES_BEFORE", Int::class.java]!! - a.info = row["INFO", String::class.java]!! - a.enabled = row["ENABLED", Boolean::class.java]!! - a.publish = row["PUBLISH", Boolean::class.java]!! - - a - } - }.collectList().retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get announcements by type", it) - }.onErrorReturn(mutableListOf()) - }.defaultIfEmpty(mutableListOf()) - } - - fun getEnabledAnnouncements(): Mono> { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_ALL_ENABLED_ANNOUNCEMENTS) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val announcementId = row["ANNOUNCEMENT_ID", String::class.java]!! - val guildId = Snowflake.of(row["GUILD_ID", Long::class.java]!!) - - val a = Announcement(guildId, announcementId) - a.calendarNumber = row["CALENDAR_NUMBER", Int::class.java]!! - a.subscriberRoleIds.setFromString(row["SUBSCRIBERS_ROLE", String::class.java]!!) - a.subscriberUserIds.setFromString(row["SUBSCRIBERS_USER", String::class.java]!!) - a.announcementChannelId = row["CHANNEL_ID", String::class.java]!! - a.type = AnnouncementType.valueOf(row["ANNOUNCEMENT_TYPE", String::class.java]!!) - a.modifier = AnnouncementModifier.valueOf(row["MODIFIER", String::class.java]!!) - a.eventId = row["EVENT_ID", String::class.java]!! - a.eventColor = fromNameOrHexOrId(row["EVENT_COLOR", String::class.java]!!) - a.hoursBefore = row["HOURS_BEFORE", Int::class.java]!! - a.minutesBefore = row["MINUTES_BEFORE", Int::class.java]!! - a.info = row["INFO", String::class.java]!! - a.enabled = row["ENABLED", Boolean::class.java]!! - a.publish = row["PUBLISH", Boolean::class.java]!! - - a - } - }.collectList().retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get enabled announcements", it) - }.onErrorReturn(mutableListOf()) - }.defaultIfEmpty(mutableListOf()) - } - fun getEnabledAnnouncements(guildId: Snowflake): Mono> { return connect { c -> Mono.from( @@ -838,43 +729,6 @@ object DatabaseManager { }.defaultIfEmpty(mutableListOf()) } - fun getEnabledAnnouncements(announcementType: AnnouncementType): Mono> { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_ENABLED_ANNOUNCEMENTS_BY_TYPE) - .bind(0, announcementType.name) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val announcementId = row["ANNOUNCEMENT_ID", String::class.java]!! - val guildId = Snowflake.of(row["GUILD_ID", Long::class.java]!!) - - val a = Announcement(guildId, announcementId) - a.calendarNumber = row["CALENDAR_NUMBER", Int::class.java]!! - a.subscriberRoleIds.setFromString(row["SUBSCRIBERS_ROLE", String::class.java]!!) - a.subscriberUserIds.setFromString(row["SUBSCRIBERS_USER", String::class.java]!!) - a.announcementChannelId = row["CHANNEL_ID", String::class.java]!! - a.type = AnnouncementType.valueOf(row["ANNOUNCEMENT_TYPE", String::class.java]!!) - a.modifier = AnnouncementModifier.valueOf(row["MODIFIER", String::class.java]!!) - a.eventId = row["EVENT_ID", String::class.java]!! - a.eventColor = fromNameOrHexOrId(row["EVENT_COLOR", String::class.java]!!) - a.hoursBefore = row["HOURS_BEFORE", Int::class.java]!! - a.minutesBefore = row["MINUTES_BEFORE", Int::class.java]!! - a.info = row["INFO", String::class.java]!! - a.enabled = row["ENABLED", Boolean::class.java]!! - a.publish = row["PUBLISH", Boolean::class.java]!! - - a - } - }.collectList().retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get enabled announcements by type", it) - }.onErrorReturn(mutableListOf()) - }.defaultIfEmpty(mutableListOf()) - } - fun getEnabledAnnouncements(guildId: Snowflake, type: AnnouncementType): Mono> { return connect { c -> Mono.from( @@ -1182,9 +1036,6 @@ private object Queries { WHERE GUILD_ID = ? and ANNOUNCEMENT_ID = ? """.trimMargin() - @Language("MySQL") - val SELECT_ALL_ANNOUNCEMENTS = """SELECT * FROM ${Tables.ANNOUNCEMENTS}""" - @Language("MySQL") val SELECT_ALL_ANNOUNCEMENTS_BY_GUILD = """SELECT * FROM ${Tables.ANNOUNCEMENTS} WHERE GUILD_ID = ? @@ -1195,26 +1046,11 @@ private object Queries { WHERE GUILD_ID = ? AND ANNOUNCEMENT_TYPE = ? """.trimMargin() - @Language("MySQL") - val SELECT_ALL_ANNOUNCEMENTS_BY_TYPE = """SELECT * FROM ${Tables.ANNOUNCEMENTS} - WHERE ANNOUNCEMENT_TYPE = ? - """.trimMargin() - - @Language("MySQL") - val SELECT_ALL_ENABLED_ANNOUNCEMENTS = """SELECT * FROM ${Tables.ANNOUNCEMENTS} - WHERE ENABLED = 1 - """.trimMargin() - @Language("MySQL") val SELECT_ENABLED_ANNOUNCEMENTS_BY_GUILD = """SELECT * FROM ${Tables.ANNOUNCEMENTS} WHERE ENABLED = 1 and GUILD_ID = ? """.trimMargin() - @Language("MySQL") - val SELECT_ENABLED_ANNOUNCEMENTS_BY_TYPE = """SELECT * FROM ${Tables.ANNOUNCEMENTS} - WHERE ENABLED = 1 and ANNOUNCEMENT_TYPE = ? - """.trimMargin() - @Language("MySQL") val SELECT_ENABLED_ANNOUNCEMENTS_BY_TYPE_GUILD = """SELECT * FROM ${Tables.ANNOUNCEMENTS} WHERE ENABLED = 1 AND GUILD_ID = ? AND ANNOUNCEMENT_TYPE = ? diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Announcement.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Announcement.kt new file mode 100644 index 000000000..e63e19b9e --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Announcement.kt @@ -0,0 +1,72 @@ +package org.dreamexposure.discal.core.`object`.new + +import discord4j.common.util.Snowflake +import org.dreamexposure.discal.core.database.AnnouncementData +import org.dreamexposure.discal.core.enums.event.EventColor +import org.dreamexposure.discal.core.extensions.asSnowflake +import org.dreamexposure.discal.core.extensions.asStringListFromDatabase + +data class Announcement( + val id: String, + val guildId: Snowflake, + val calendarNumber: Int = 1, + + val type: Type = Type.UNIVERSAL, + val modifier: Modifier = Modifier.BEFORE, + val channelId: Snowflake, + + + val subscribers: Subscribers = Subscribers(), + val eventId: String = "N/a", + val eventColor: EventColor = EventColor.NONE, + + val hoursBefore: Int = 0, + val minutesBefore: Int = 0, + + val info: String = "None", + val enabled: Boolean = true, + val publish: Boolean = false, +) { + constructor(data: AnnouncementData) : this( + id = data.announcementId, + guildId = data.guildId.asSnowflake(), + calendarNumber = data.calendarNumber, + + type = Type.valueOf(data.announcementType), + modifier = Modifier.valueOf(data.modifier), + channelId = Snowflake.of(data.channelId), + + subscribers = Subscribers( + roles = data.subscribersRole.asStringListFromDatabase(), + users = data.subscribersUser.asStringListFromDatabase().map(Snowflake::of), + ), + eventId = data.eventId, + eventColor = EventColor.fromNameOrHexOrId(data.eventColor), + + hoursBefore = data.hoursBefore, + minutesBefore = data.minutesBefore, + + info = data.info, + enabled = data.enabled, + publish = data.publish, + ) + + + data class Subscribers( + val roles: List = listOf(), + val users: List = listOf(), + ) + + enum class Type { + UNIVERSAL, + SPECIFIC, + COLOR, + RECUR, + } + + enum class Modifier { + BEFORE, + DURING, + END, + } +} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt b/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt index 874bca979..3af3dc9c3 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt @@ -2,10 +2,7 @@ package org.dreamexposure.discal import discord4j.common.util.Snowflake import org.dreamexposure.discal.core.cache.CacheRepository -import org.dreamexposure.discal.core.`object`.new.Calendar -import org.dreamexposure.discal.core.`object`.new.Credential -import org.dreamexposure.discal.core.`object`.new.Rsvp -import org.dreamexposure.discal.core.`object`.new.StaticMessage +import org.dreamexposure.discal.core.`object`.new.* // Cache //typealias GuildSettingsCache = CacheRepository @@ -14,3 +11,4 @@ typealias OauthStateCache = CacheRepository typealias CalendarCache = CacheRepository> typealias RsvpCache = CacheRepository typealias StaticMessageCache = CacheRepository +typealias AnnouncementCache = CacheRepository> diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/AnnouncementController.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/AnnouncementController.kt new file mode 100644 index 000000000..82d99c3b5 --- /dev/null +++ b/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/AnnouncementController.kt @@ -0,0 +1,8 @@ +package org.dreamexposure.discal.server.endpoints.v3 + +import org.springframework.web.bind.annotation.RestController + +@RestController("v3/guilds/{guildId}/announcements") +class AnnouncementController { + +} From 70bc75904045250093c5df3930b8fc5ea8a1e53b Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Mon, 4 Mar 2024 13:40:00 -0600 Subject: [PATCH 12/43] Further refactor for announcement related code This one refactors the announcement cron job, status update, announcement endpoints, and various related code Next will be updating the announcement command code, which will open up possibilities for new interactions and better UX --- .../business/cronjob/AnnouncementCronJob.kt | 54 ++++ .../business/cronjob/StatusUpdateCronJob.kt | 89 +++++++ .../client/commands/global/DiscalCommand.kt | 28 +- .../discal/client/config/DiscordConfig.kt | 6 +- .../listeners/discord/BotMentionListener.kt | 18 +- .../client/message/embed/AnnouncementEmbed.kt | 169 +----------- .../client/message/embed/DiscalEmbed.kt | 54 ---- .../client/service/AnnouncementService.kt | 247 ------------------ .../discal/client/service/StatusChanger.kt | 69 ----- .../core/business/AnnouncementService.kt | 152 +++++++++++ .../discal/core/business/CalendarService.kt | 23 +- .../discal/core/business/EmbedService.kt | 198 ++++++++++++++ .../discal/core/business/MetricService.kt | 2 +- .../discal/core/config/Config.kt | 4 + .../discal/core/database/DatabaseManager.kt | 92 ------- .../discal/core/entities/Event.kt | 24 -- .../discal/core/extensions/discord4j/Guild.kt | 59 +---- .../core/extensions/discord4j/RestGuild.kt | 52 +--- .../discal/core/object/new/Announcement.kt | 6 +- .../discal/core/object/new/security/Scope.kt | 3 + .../CreateAnnouncementEndpoint.kt | 92 ------- .../DeleteAnnouncementEndpoint.kt | 58 ---- .../announcement/GetAnnouncementEndpoint.kt | 57 ---- .../announcement/ListAnnouncementEndpoint.kt | 57 ---- .../UpdateAnnouncementEndpoint.kt | 116 -------- .../endpoints/v3/AnnouncementController.kt | 44 +++- .../server/endpoints/v3/RsvpController.kt | 3 +- .../server/network/discal/NetworkManager.kt | 26 +- 28 files changed, 610 insertions(+), 1192 deletions(-) create mode 100644 client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/AnnouncementCronJob.kt create mode 100644 client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StatusUpdateCronJob.kt delete mode 100644 client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/DiscalEmbed.kt delete mode 100644 client/src/main/kotlin/org/dreamexposure/discal/client/service/AnnouncementService.kt delete mode 100644 client/src/main/kotlin/org/dreamexposure/discal/client/service/StatusChanger.kt delete mode 100644 server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/CreateAnnouncementEndpoint.kt delete mode 100644 server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/DeleteAnnouncementEndpoint.kt delete mode 100644 server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/GetAnnouncementEndpoint.kt delete mode 100644 server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/ListAnnouncementEndpoint.kt delete mode 100644 server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/UpdateAnnouncementEndpoint.kt diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/AnnouncementCronJob.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/AnnouncementCronJob.kt new file mode 100644 index 000000000..a2add01ce --- /dev/null +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/AnnouncementCronJob.kt @@ -0,0 +1,54 @@ +package org.dreamexposure.discal.client.business.cronjob + +import discord4j.core.GatewayDiscordClient +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.mono +import org.dreamexposure.discal.core.business.AnnouncementService +import org.dreamexposure.discal.core.business.MetricService +import org.dreamexposure.discal.core.config.Config +import org.dreamexposure.discal.core.extensions.asMinutes +import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT +import org.springframework.boot.ApplicationArguments +import org.springframework.boot.ApplicationRunner +import org.springframework.stereotype.Component +import org.springframework.util.StopWatch +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +@Component +class AnnouncementCronJob( + private val discordClient: GatewayDiscordClient, + private val announcementService: AnnouncementService, + private val metricService: MetricService, +) : ApplicationRunner { + private val interval = Config.TIMING_ANNOUNCEMENT_TASK_RUN_INTERVAL_MINUTES.getLong().asMinutes() + private val maxDifference = interval + + override fun run(args: ApplicationArguments?) { + Flux.interval(interval) + .onBackpressureDrop() + .flatMap { doAction() } + .doOnError { LOGGER.error(DEFAULT, "!-Announcement run error-!", it) } + .onErrorResume { Mono.empty() } + .subscribe() + } + + private fun doAction() = mono { + val taskTimer = StopWatch() + taskTimer.start() + + val guilds = discordClient.guilds.collectList().awaitSingle() + + guilds.forEach { guild -> + try { + announcementService.processAnnouncementsForGuild(guild.id, maxDifference) + } catch (ex: Exception) { + LOGGER.error("Failed to process announcements for guild | guildId:${guild.id.asLong()}", ex) + } + } + + taskTimer.stop() + metricService.recordAnnouncementTaskDuration("overall", taskTimer.totalTimeMillis) + } +} diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StatusUpdateCronJob.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StatusUpdateCronJob.kt new file mode 100644 index 000000000..4ba90ab6f --- /dev/null +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StatusUpdateCronJob.kt @@ -0,0 +1,89 @@ +package org.dreamexposure.discal.client.business.cronjob + +import discord4j.core.GatewayDiscordClient +import discord4j.core.`object`.presence.ClientActivity +import discord4j.core.`object`.presence.ClientPresence +import kotlinx.coroutines.reactor.awaitSingleOrNull +import kotlinx.coroutines.reactor.mono +import org.dreamexposure.discal.Application +import org.dreamexposure.discal.GitProperty +import org.dreamexposure.discal.core.business.AnnouncementService +import org.dreamexposure.discal.core.business.CalendarService +import org.dreamexposure.discal.core.business.MetricService +import org.dreamexposure.discal.core.config.Config +import org.dreamexposure.discal.core.extensions.asMinutes +import org.dreamexposure.discal.core.logger.LOGGER +import org.springframework.boot.ApplicationArguments +import org.springframework.boot.ApplicationRunner +import org.springframework.stereotype.Component +import org.springframework.util.StopWatch +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.util.concurrent.atomic.AtomicInteger + +@Component +class StatusUpdateCronJob( + private val discordClient: GatewayDiscordClient, + private val calendarService: CalendarService, + private val announcementService: AnnouncementService, + private val metricService: MetricService, +): ApplicationRunner { + private val index = AtomicInteger(0) + + private final val status = listOf( + "/discal for info & help", + "Trans rights are human rights", + "Version {version}", + "{calendar_count} calendars managed!", + "Now has interactions!", + "Proudly written in Kotlin using Discord4J", + "Free Palestine!", + "https://discalbot.com", + "I swear DisCal isn't abandoned", + "Powered by Discord4J v{d4j_version}", + "{shards} total shards!", + "Slava Ukraini!", + "Support DisCal on Patreon", + "{announcement_count} announcements running!", + "Finally fixing the annoying stuff" + ) + + override fun run(args: ApplicationArguments?) { + Flux.interval(Config.TIMING_BOT_STATUS_UPDATE_MINUTES.getLong().asMinutes()) + .onBackpressureDrop() + .flatMap { update() } + .doOnError { LOGGER.error("Failed to update status", it) } + .onErrorResume { Mono.empty()} + .subscribe() + } + + private fun update() = mono { + val taskTimer = StopWatch() + taskTimer.start() + + val currentIndex = index.get() + // Update index + if (currentIndex + 1 >= status.size) index.lazySet(0) + else index.lazySet(currentIndex + 1) + + // Get status to change to + var status = status[currentIndex] + .replace("{version}", GitProperty.DISCAL_VERSION.value) + .replace("{d4j_version}", GitProperty.DISCAL_VERSION_D4J.value) + .replace("{shards}", Application.getShardCount().toString()) + + if (status.contains("{calendar_count}")) { + val count = calendarService.getCalendarCount() + status = status.replace("{calendar_count}", count.toString()) + } + if (status.contains("{announcement_count}")) { + val count = announcementService.getAnnouncementCount() + status = status.replace("{announcement_count}", count.toString()) + } + + discordClient.updatePresence(ClientPresence.online(ClientActivity.playing(status))).awaitSingleOrNull() + + taskTimer.stop() + metricService.recordTaskDuration("status_update", duration = taskTimer.totalTimeMillis) + } +} diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DiscalCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DiscalCommand.kt index e9875cf9f..41640560a 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DiscalCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DiscalCommand.kt @@ -1,23 +1,31 @@ package org.dreamexposure.discal.client.commands.global -import discord4j.core.`object`.entity.Message import discord4j.core.event.domain.interaction.ChatInputInteractionEvent +import discord4j.core.`object`.entity.Message +import kotlinx.coroutines.reactor.awaitSingle import org.dreamexposure.discal.client.commands.SlashCommand -import org.dreamexposure.discal.client.message.embed.DiscalEmbed -import org.dreamexposure.discal.core.`object`.GuildSettings +import org.dreamexposure.discal.core.business.AnnouncementService +import org.dreamexposure.discal.core.business.CalendarService +import org.dreamexposure.discal.core.business.EmbedService import org.dreamexposure.discal.core.extensions.discord4j.followup +import org.dreamexposure.discal.core.`object`.GuildSettings import org.springframework.stereotype.Component -import reactor.core.publisher.Mono @Component -class DiscalCommand : SlashCommand { +class DiscalCommand( + private val announcementService: AnnouncementService, + private val calendarService: CalendarService, + private val embedService: EmbedService, +) : SlashCommand { override val name = "discal" override val ephemeral = false - @Deprecated("Use new handleSuspend for K-coroutines") - override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { - return event.interaction.guild - .flatMap(DiscalEmbed::info) - .flatMap(event::followup) + override suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message { + val announcementCount = announcementService.getAnnouncementCount() + val calendarCount = calendarService.getCalendarCount() + + val embed = embedService.discalInfoEmbed(settings, calendarCount, announcementCount) + + return event.followup(embed).awaitSingle() } } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/config/DiscordConfig.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/config/DiscordConfig.kt index 4ec87ac6b..944569990 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/config/DiscordConfig.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/config/DiscordConfig.kt @@ -2,6 +2,7 @@ package org.dreamexposure.discal.client.config import discord4j.common.store.Store import discord4j.common.store.legacy.LegacyStoreLayout +import discord4j.core.DiscordClient import discord4j.core.DiscordClientBuilder import discord4j.core.GatewayDiscordClient import discord4j.core.event.domain.Event @@ -13,7 +14,6 @@ import discord4j.discordjson.json.GuildData import discord4j.discordjson.json.MessageData import discord4j.gateway.intent.Intent import discord4j.gateway.intent.IntentSet -import discord4j.rest.RestClient import discord4j.store.api.mapping.MappingStoreService import discord4j.store.api.service.StoreService import discord4j.store.jdk.JdkStoreService @@ -58,8 +58,8 @@ class DiscordConfig { } @Bean - fun discordRestClient(gatewayDiscordClient: GatewayDiscordClient): RestClient { - return gatewayDiscordClient.restClient + fun discordClient(gatewayDiscordClient: GatewayDiscordClient): DiscordClient { + return gatewayDiscordClient.rest() } @Bean diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/BotMentionListener.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/BotMentionListener.kt index 8ea543ae7..ed8d8da57 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/BotMentionListener.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/BotMentionListener.kt @@ -1,23 +1,35 @@ package org.dreamexposure.discal.client.listeners.discord import discord4j.core.event.domain.message.MessageCreateEvent +import discord4j.core.`object`.entity.Guild import discord4j.core.`object`.entity.Message import discord4j.core.`object`.entity.User import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull -import org.dreamexposure.discal.client.message.embed.DiscalEmbed +import org.dreamexposure.discal.core.business.AnnouncementService +import org.dreamexposure.discal.core.business.CalendarService +import org.dreamexposure.discal.core.business.EmbedService +import org.dreamexposure.discal.core.extensions.discord4j.getSettings import org.springframework.stereotype.Component @Component -class BotMentionListener: EventListener { +class BotMentionListener( + private val announcementService: AnnouncementService, + private val calendarService: CalendarService, + private val embedService: EmbedService, +): EventListener { override suspend fun handle(event: MessageCreateEvent) { if (event.guildId.isPresent // in guild && !event.message.author.map(User::isBot).orElse(false) // Not from a bot && onlyMentionsBot(event.message) ) { - val embed = event.guild.flatMap(DiscalEmbed::info).awaitSingle() + val settings = event.guild.flatMap(Guild::getSettings).awaitSingle() + val announcementCount = announcementService.getAnnouncementCount() + val calendarCount = calendarService.getCalendarCount() val channel = event.message.channel.awaitSingle() + val embed = embedService.discalInfoEmbed(settings, calendarCount, announcementCount) + channel.createMessage(embed).awaitSingleOrNull() } } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/AnnouncementEmbed.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/AnnouncementEmbed.kt index 46a7473cc..40b1e2063 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/AnnouncementEmbed.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/AnnouncementEmbed.kt @@ -2,181 +2,16 @@ package org.dreamexposure.discal.client.message.embed import discord4j.core.`object`.entity.Guild import discord4j.core.spec.EmbedCreateSpec -import org.dreamexposure.discal.core.`object`.GuildSettings -import org.dreamexposure.discal.core.`object`.announcement.Announcement -import org.dreamexposure.discal.core.entities.Event -import org.dreamexposure.discal.core.enums.announcement.AnnouncementStyle import org.dreamexposure.discal.core.enums.announcement.AnnouncementType import org.dreamexposure.discal.core.enums.event.EventColor -import org.dreamexposure.discal.core.enums.time.DiscordTimestampFormat.LONG_DATETIME -import org.dreamexposure.discal.core.extensions.asDiscordTimestamp -import org.dreamexposure.discal.core.extensions.discord4j.getSettings import org.dreamexposure.discal.core.extensions.embedFieldSafe import org.dreamexposure.discal.core.extensions.toMarkdown +import org.dreamexposure.discal.core.`object`.GuildSettings +import org.dreamexposure.discal.core.`object`.announcement.Announcement import org.dreamexposure.discal.core.utils.GlobalVal import org.dreamexposure.discal.core.utils.getCommonMsg -import reactor.core.publisher.Mono object AnnouncementEmbed : EmbedMaker { - fun determine(ann: Announcement, event: Event, guild: Guild): Mono { - return guild.getSettings().map { settings -> - when (settings.announcementStyle) { - AnnouncementStyle.FULL -> full(ann, event, guild, settings) - AnnouncementStyle.SIMPLE -> simple(ann, event, guild, settings) - AnnouncementStyle.EVENT -> event(ann, event, guild, settings) - } - } - } - - private fun full(ann: Announcement, event: Event, guild: Guild, settings: GuildSettings): EmbedCreateSpec { - val builder = defaultBuilder(guild, settings) - .color(event.color.asColor()) - .title(getMessage("announcement", "full.title", settings)) - - if (event.name.isNotBlank()) builder.addField( - getMessage("announcement", "full.field.name", settings), - event.name.toMarkdown().embedFieldSafe(), - false - ) - if (event.description.isNotBlank()) builder.addField( - getMessage("announcement", "full.field.desc", settings), - event.description.toMarkdown().embedFieldSafe(), - false - ) - - builder.addField( - getMessage("announcement", "full.field.start", settings), - event.start.asDiscordTimestamp(LONG_DATETIME), - true - ) - builder.addField( - getMessage("announcement", "full.field.end", settings), - event.end.asDiscordTimestamp(LONG_DATETIME), - true - ) - - if (event.location.isNotBlank()) builder.addField( - getMessage("announcement", "full.field.location", settings), - event.location.toMarkdown().embedFieldSafe(), - false - ) - - if (ann.info.isNotBlank() && !ann.info.equals("None", true)) builder.addField( - getMessage("announcement", "full.field.info", settings), - ann.info.toMarkdown().embedFieldSafe(), - false - ) - - builder.addField( - getMessage("announcement", "full.field.calendar", settings), - "${event.calendar.calendarNumber}", - true - ) - builder.addField(getMessage("announcement", "full.field.event", settings), event.eventId, true) - - if (event.image.isNotBlank()) - builder.image(event.image) - - builder.footer(getMessage("announcement", "full.footer", settings, ann.id), null) - - return builder.build() - } - - private fun simple(ann: Announcement, event: Event, guild: Guild, settings: GuildSettings): EmbedCreateSpec { - val builder = defaultBuilder(guild, settings) - .color(event.color.asColor()) - .title(getMessage("announcement", "simple.title", settings)) - - if (event.name.isNotBlank()) builder.addField( - getMessage("announcement", "simple.field.name", settings), - event.name.toMarkdown().embedFieldSafe(), - false - ) - if (event.description.isNotBlank()) builder.addField( - getMessage("announcement", "simple.field.desc", settings), - event.description.toMarkdown().embedFieldSafe(), - false - ) - - builder.addField( - getMessage("announcement", "simple.field.start", settings), - event.start.asDiscordTimestamp(LONG_DATETIME), - true - ) - - if (event.location.isNotBlank()) builder.addField( - getMessage("announcement", "simple.field.location", settings), - event.location.toMarkdown().embedFieldSafe(), - false - ) - - if (ann.info.isNotBlank() && !ann.info.equals("None", true)) builder.addField( - getMessage("announcement", "simple.field.info", settings), - ann.info.toMarkdown().embedFieldSafe(), - false - ) - - if (event.image.isNotEmpty()) - builder.image(event.image) - - builder.footer(getMessage("announcement", "simple.footer", settings, ann.id), null) - - return builder.build() - } - - fun event(ann: Announcement, event: Event, guild: Guild, settings: GuildSettings): EmbedCreateSpec { - val builder = defaultBuilder(guild, settings) - .color(event.color.asColor()) - .title(getMessage("announcement", "event.title", settings)) - - if (event.name.isNotBlank()) builder.addField( - getMessage("announcement", "event.field.name", settings), - event.name.toMarkdown().embedFieldSafe(), - false - ) - if (event.description.isNotBlank()) builder.addField( - getMessage("announcement", "event.field.desc", settings), - event.description.toMarkdown().embedFieldSafe(), - false - ) - - builder.addField( - getMessage("announcement", "event.field.start", settings), - event.start.asDiscordTimestamp(LONG_DATETIME), - true - ) - builder.addField( - getMessage("announcement", "event.field.end", settings), - event.end.asDiscordTimestamp(LONG_DATETIME), - true - ) - - if (event.location.isNotBlank()) builder.addField( - getMessage("announcement", "event.field.location", settings), - event.location.toMarkdown().embedFieldSafe(), - false - ) - - builder.addField( - getMessage("announcement", "event.field.calendar", settings), - "${event.calendar.calendarNumber}", - true - ) - builder.addField(getMessage("announcement", "event.field.event", settings), event.eventId, true) - - if (ann.info.isNotBlank() && !ann.info.equals("None", true)) builder.addField( - getMessage("announcement", "event.field.info", settings), - ann.info.toMarkdown().embedFieldSafe(), - false - ) - - if (event.image.isNotBlank()) - builder.image(event.image) - - builder.footer(getMessage("announcement", "event.footer", settings, ann.id), null) - - return builder.build() - } fun condensed(ann: Announcement, guild: Guild, settings: GuildSettings): EmbedCreateSpec { val builder = defaultBuilder(guild, settings) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/DiscalEmbed.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/DiscalEmbed.kt deleted file mode 100644 index 5e5d9a3ed..000000000 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/DiscalEmbed.kt +++ /dev/null @@ -1,54 +0,0 @@ -package org.dreamexposure.discal.client.message.embed - -import discord4j.core.`object`.entity.Guild -import discord4j.core.spec.EmbedCreateSpec -import org.dreamexposure.discal.Application -import org.dreamexposure.discal.GitProperty.DISCAL_VERSION -import org.dreamexposure.discal.GitProperty.DISCAL_VERSION_D4J -import org.dreamexposure.discal.core.config.Config -import org.dreamexposure.discal.core.database.DatabaseManager -import org.dreamexposure.discal.core.extensions.discord4j.getSettings -import org.dreamexposure.discal.core.extensions.getHumanReadable -import org.dreamexposure.discal.core.utils.GlobalVal -import reactor.core.publisher.Mono -import reactor.function.TupleUtils - -object DiscalEmbed : EmbedMaker { - - fun info(guild: Guild): Mono { - val gMono = guild.client.guilds.count().map(Long::toInt) - val cMono = DatabaseManager.getCalendarCount() - val aMono = DatabaseManager.getAnnouncementCount() - - return Mono.zip(gMono, cMono, aMono, guild.getSettings()) - .map(TupleUtils.function { guilds, cal, ann, settings -> - defaultBuilder(guild, settings) - .color(GlobalVal.discalColor) - .title(getMessage("discal", "info.title", settings)) - .addField(getMessage("discal", "info.field.version", settings), DISCAL_VERSION.value, false) - .addField(getMessage("discal", "info.field.library", settings), "Discord4J ${DISCAL_VERSION_D4J.value}", false) - .addField(getMessage("discal", "info.field.shard", settings), formattedIndex(), true) - .addField(getMessage("discal", "info.field.guilds", settings), "$guilds", true) - .addField( - getMessage("discal", "info.field.uptime", settings), - Application.getUptime().getHumanReadable(), - false - ).addField(getMessage("discal", "info.field.calendars", settings), "$cal", true) - .addField(getMessage("discal", "info.field.announcements", settings), "$ann", true) - .addField(getMessage("discal", "info.field.links", settings), - getMessage("discal", - "info.field.links.value", - settings, - "${Config.URL_BASE.getString()}/commands", - Config.URL_SUPPORT.getString(), - Config.URL_INVITE.getString(), - "https://www.patreon.com/Novafox" - ), - false - ).footer(getMessage("discal", "info.footer", settings), null) - .build() - }) - } - - private fun formattedIndex() = "${Application.getShardIndex()}/${Application.getShardCount()}" -} diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/service/AnnouncementService.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/service/AnnouncementService.kt deleted file mode 100644 index 8af75d721..000000000 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/service/AnnouncementService.kt +++ /dev/null @@ -1,247 +0,0 @@ -package org.dreamexposure.discal.client.service - -import discord4j.common.util.Snowflake -import discord4j.core.GatewayDiscordClient -import discord4j.core.`object`.entity.Guild -import discord4j.core.`object`.entity.Message -import discord4j.core.`object`.entity.channel.GuildMessageChannel -import discord4j.core.spec.MessageCreateSpec -import discord4j.rest.http.client.ClientException -import io.netty.handler.codec.http.HttpResponseStatus -import kotlinx.coroutines.reactor.awaitSingle -import kotlinx.coroutines.reactor.awaitSingleOrNull -import kotlinx.coroutines.reactor.mono -import org.dreamexposure.discal.client.message.embed.AnnouncementEmbed -import org.dreamexposure.discal.core.business.MetricService -import org.dreamexposure.discal.core.database.DatabaseManager -import org.dreamexposure.discal.core.entities.Calendar -import org.dreamexposure.discal.core.entities.Event -import org.dreamexposure.discal.core.enums.announcement.AnnouncementModifier -import org.dreamexposure.discal.core.enums.announcement.AnnouncementType.* -import org.dreamexposure.discal.core.extensions.discord4j.getCalendar -import org.dreamexposure.discal.core.extensions.messageContentSafe -import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.`object`.announcement.Announcement -import org.dreamexposure.discal.core.`object`.announcement.AnnouncementCache -import org.dreamexposure.discal.core.utils.GlobalVal -import org.springframework.boot.ApplicationArguments -import org.springframework.boot.ApplicationRunner -import org.springframework.stereotype.Component -import org.springframework.util.StopWatch -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono -import java.time.Duration -import java.util.concurrent.ConcurrentHashMap - -@Component -class AnnouncementService( - private val discordClient: GatewayDiscordClient, - private val metricService: MetricService, -) : ApplicationRunner { - private val maxDifferenceMs = Duration.ofMinutes(5).toMillis() - - private val cached = ConcurrentHashMap() - - // Start - override fun run(args: ApplicationArguments?) { - Flux.interval(Duration.ofMinutes(5)) - .onBackpressureDrop() - .flatMap { doAnnouncementCycle() } - .doOnError { LOGGER.error(GlobalVal.DEFAULT, "!-Announcement run error-!", it) } - .subscribe() - } - - // Runner - private fun doAnnouncementCycle(): Mono { - val taskTimer = StopWatch() - taskTimer.start() - - return discordClient.guilds.flatMap { guild -> - val guildTimer = StopWatch() - guildTimer.start() - - mono { - val announcements = DatabaseManager.getEnabledAnnouncements(guild.id).awaitSingle() - announcements.forEach { announcement -> - when (announcement.modifier) { - AnnouncementModifier.BEFORE -> handleBeforeModifier(guild, announcement).awaitSingleOrNull() - AnnouncementModifier.DURING -> handleDuringModifier(guild, announcement).awaitSingleOrNull() - AnnouncementModifier.END -> handleEndModifier(guild, announcement).awaitSingleOrNull() - } - } - }.doOnError { - LOGGER.error(GlobalVal.DEFAULT, "Announcement error", it) - }.onErrorResume { - Mono.empty() - }.doFinally { - guildTimer.stop() - metricService.recordAnnouncementTaskDuration("guild", guildTimer.totalTimeMillis) - } - }.doOnError { - LOGGER.error(GlobalVal.DEFAULT, "Announcement error", it) - }.onErrorResume { - Mono.empty() - }.doFinally { - cached.clear() - taskTimer.stop() - metricService.recordAnnouncementTaskDuration("overall", taskTimer.totalTimeMillis) - }.then() - /* - // Get announcements for this shard, then group by guild to make caching easier - return DatabaseManager.getAnnouncementsForShard(Application.getShardCount(), getShardIndex().toInt()).map { list -> - list.groupBy { it.guildId } - }.flatMapMany { groupedAnnouncements -> - Flux.fromIterable(groupedAnnouncements.entries).flatMap { entry -> - DisCalClient.client!!.getGuildById(entry.key).flatMapMany { guild -> - val announcements = groupedAnnouncements[guild.id] ?: emptyList() - - Flux.fromIterable(announcements).flatMap { announcement -> - when (announcement.modifier) { - AnnouncementModifier.BEFORE -> handleBeforeModifier(guild, announcement) - AnnouncementModifier.DURING -> handleDuringModifier(guild, announcement) - AnnouncementModifier.END -> handleEndModifier(guild, announcement) - } - }.doOnError { - LOGGER.error(GlobalVal.DEFAULT, "Announcement error", it) - }.onErrorResume { Mono.empty() } - }.onErrorResume(ClientException.isStatusCode(403)) { - //FIXME: great way to wipe the database, not sure what the fuck happened here. - // DisCal is no longer in the guild, remove all it's from the database - - //DatabaseManager.deleteAllDataForGuild(entry.key).then() - Mono.empty() - } - } - */ - } - - // Modifier handling - private fun handleBeforeModifier(guild: Guild, announcement: Announcement): Mono { - when (announcement.type) { - SPECIFIC -> { - return getCalendar(guild, announcement) - .flatMap { it.getEvent(announcement.eventId) } - //Event announcement is tied to was deleted -- This should now be handled at a lower level - //.switchIfEmpty(DatabaseManager.deleteAnnouncement(announcement.id.toString()).then(Mono.empty())) - .filterWhen { isInRange(announcement, it) } - .flatMap { sendAnnouncement(guild, announcement, it) } - // Delete specific announcement after posted - .flatMap { DatabaseManager.deleteAnnouncement(announcement.id) } - .then() - } - - UNIVERSAL -> { - return getEvents(guild, announcement) - .filterWhen { isInRange(announcement, it) } - .flatMap { sendAnnouncement(guild, announcement, it) } - .then() - } - - COLOR -> { - return getEvents(guild, announcement) - .filter { it.color == announcement.eventColor } - .filterWhen { isInRange(announcement, it) } - .flatMap { sendAnnouncement(guild, announcement, it) } - .then() - } - - RECUR -> { - return getEvents(guild, announcement) - .filter { it.eventId.contains("_") && it.eventId.split("_")[0] == announcement.eventId } - .filterWhen { isInRange(announcement, it) } - .flatMap { sendAnnouncement(guild, announcement, it) } - .then() - } - } - } - - @Suppress("UNUSED_PARAMETER") - private fun handleDuringModifier(guild: Guild, announcement: Announcement): Mono { - //TODO: Not yet implemented - - return Mono.empty() - } - - @Suppress("UNUSED_PARAMETER") - private fun handleEndModifier(guild: Guild, announcement: Announcement): Mono { - //TODO: Not yet implemented - - return Mono.empty() - } - - // Utility - private fun isInRange(announcement: Announcement, event: Event): Mono { - val announcementTime = Duration - .ofHours(announcement.hoursBefore.toLong()) - .plusMinutes(announcement.minutesBefore.toLong()) - .toMillis() - val timeUntilEvent = event.start.minusMillis(System.currentTimeMillis()).toEpochMilli() - - val difference = timeUntilEvent - announcementTime - - if (difference < 0) { - //event past, delete if specific type - if (announcement.type == SPECIFIC) { - return DatabaseManager.deleteAnnouncement(announcement.id) - .thenReturn(false) - } - return Mono.just(false) - } else return Mono.just(difference <= maxDifferenceMs) - } - - private fun sendAnnouncement(guild: Guild, announcement: Announcement, event: Event): Mono { - return guild.getChannelById(Snowflake.of(announcement.announcementChannelId)) - .ofType(GuildMessageChannel::class.java) - .flatMap { channel -> - AnnouncementEmbed.determine(announcement, event, guild).flatMap { embed -> - channel.createMessage( - MessageCreateSpec.builder() - .content(announcement.buildMentions().messageContentSafe()) - .addEmbed(embed) - .build() - ) - }.flatMap { message -> - if (announcement.publish) { - message.publish() - } else Mono.just(message) - } - }.onErrorResume(ClientException::class.java) { - Mono.just(it) - .filter(HttpResponseStatus.NOT_FOUND::equals) - // Channel announcement should post to was deleted - .flatMap { DatabaseManager.deleteAnnouncement(announcement.id) } - .then(Mono.empty()) - }.doFinally { - metricService.incrementAnnouncementPosted() - } - } - - // Cache things - private fun getCalendar(guild: Guild, announcement: Announcement): Mono { - val cached = getCached(announcement.guildId) - - return if (!cached.calendars.contains(announcement.calendarNumber)) { - guild.getCalendar(announcement.calendarNumber) - .doOnNext { cached.calendars[it.calendarNumber] = it } - } else Mono.justOrEmpty(cached.calendars[announcement.calendarNumber]) - } - - private fun getEvents(guild: Guild, announcement: Announcement): Flux { - val cached = getCached(announcement.guildId) - if (cached.events.contains(announcement.calendarNumber)) - return Flux.fromIterable(cached.events[announcement.calendarNumber]!!) - - return getCalendar(guild, announcement).flatMapMany { - it.getUpcomingEvents(20) - }.collectList() - .doOnNext { cached.events[announcement.calendarNumber] = it } - .flatMapIterable { it } - } - - private fun getCached(guildId: Snowflake): AnnouncementCache { - if (!cached.contains(guildId)) - cached[guildId] = AnnouncementCache(guildId) - - return cached[guildId]!! - } -} diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/service/StatusChanger.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/service/StatusChanger.kt deleted file mode 100644 index cd05835e9..000000000 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/service/StatusChanger.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.dreamexposure.discal.client.service - -import discord4j.core.GatewayDiscordClient -import discord4j.core.`object`.presence.ClientActivity -import discord4j.core.`object`.presence.ClientPresence -import org.dreamexposure.discal.Application -import org.dreamexposure.discal.GitProperty -import org.dreamexposure.discal.core.database.DatabaseManager -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 reactor.function.TupleUtils -import java.time.Duration -import java.util.concurrent.atomic.AtomicInteger - -@Component -class StatusChanger( - private val discordClient: GatewayDiscordClient, -): ApplicationRunner { - private val index = AtomicInteger(0) - - private val statuses = listOf( - "Discord Calendar", - "!help for help", - "!DisCal for info", - "Powered by DreamExposure", - "{guilds} guilds on shard!", - "{calendars} calendars managed!", - "{announcements} announcements running!", - "{shards} total shards!", - "Version {version}", - "DisCal is on Patreon!", - ) - - private fun update(): Mono { - val guCountMono = discordClient.guilds.count() - val calCountMono = DatabaseManager.getCalendarCount() - val annCountMono = DatabaseManager.getAnnouncementCount() - - return Mono.zip(guCountMono, calCountMono, annCountMono) - .flatMap(TupleUtils.function { guilds, calendars, announcements -> - val currentIndex = index.get() - //Update index - if (currentIndex + 1 >= statuses.size) - index.lazySet(0) - else - index.lazySet(currentIndex + 1) - - //Get status we want to change to - val status = statuses[currentIndex] - .replace("{guilds}", guilds.toString()) - .replace("{calendars}", calendars.toString()) - .replace("{announcements}", announcements.toString()) - .replace("{shards}", Application.getShardCount().toString()) - .replace("{version}", GitProperty.DISCAL_VERSION.value) - - - discordClient.updatePresence(ClientPresence.online(ClientActivity.playing(status))) - }) - } - - override fun run(args: ApplicationArguments?) { - Flux.interval(Duration.ofMinutes(5)) - .flatMap { update() } - .subscribe() - } -} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt index 76b1c1f5e..cdc904d35 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt @@ -1,18 +1,64 @@ package org.dreamexposure.discal.core.business import discord4j.common.util.Snowflake +import discord4j.core.DiscordClient +import discord4j.discordjson.json.MessageCreateRequest +import discord4j.rest.http.client.ClientException import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull import org.dreamexposure.discal.AnnouncementCache +import org.dreamexposure.discal.core.database.AnnouncementData import org.dreamexposure.discal.core.database.AnnouncementRepository +import org.dreamexposure.discal.core.database.DatabaseManager +import org.dreamexposure.discal.core.entities.Calendar +import org.dreamexposure.discal.core.entities.Event +import org.dreamexposure.discal.core.extensions.discord4j.getCalendar +import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.`object`.new.Announcement +import org.springframework.beans.factory.BeanFactory +import org.springframework.beans.factory.getBean import org.springframework.stereotype.Component +import org.springframework.util.StopWatch +import reactor.core.publisher.Mono +import java.time.Duration +import java.time.Instant @Component class AnnouncementService( private val announcementRepository: AnnouncementRepository, private val announcementCache: AnnouncementCache, + private val embedService: EmbedService, + private val metricService: MetricService, + private val beanFactory: BeanFactory, ) { + private val discordClient: DiscordClient + get() = beanFactory.getBean() + + suspend fun createAnnouncement(announcement: Announcement): Announcement { + val saved = announcementRepository.save(AnnouncementData( + announcementId = announcement.id, + calendarNumber = announcement.calendarNumber, + guildId = announcement.guildId.asLong(), + subscribersRole = announcement.subscribers.roles.joinToString(","), + subscribersUser = announcement.subscribers.users.map(Snowflake::asLong).joinToString(","), + channelId = announcement.channelId.asString(), + announcementType = announcement.type.name, + modifier = announcement.modifier.name, + eventId = announcement.eventId, + eventColor = announcement.eventColor.name, + hoursBefore = announcement.hoursBefore, + minutesBefore = announcement.minutesBefore, + info = announcement.info, + enabled = announcement.enabled, + publish = announcement.publish, + )).map(::Announcement).awaitSingle() + + val cached = announcementCache.get(key = announcement.guildId) + announcementCache.put(key = announcement.guildId, value = cached?.plus(saved) ?: arrayOf(saved)) + + return saved + } + suspend fun getAnnouncementCount(): Long = announcementRepository.count().awaitSingle() suspend fun getAllAnnouncements(shardIndex: Int, shardCount: Int): List { @@ -97,4 +143,110 @@ class AnnouncementService( announcementCache.put(key = guildId, value = cached.filterNot { it.eventId == eventId }.toTypedArray()) } } + + suspend fun sendAnnouncement(announcement: Announcement, event: Event) { + try { + val channel = discordClient.getChannelById(announcement.channelId) + // While we don't need the channel data, we do want to make sure it exists + val existingData = channel + .data.onErrorResume(ClientException.isStatusCode(404)) { Mono.empty() } + .awaitSingleOrNull() + + if (existingData == null) { + // Channel was deleted + deleteAnnouncement(announcement.guildId, announcement.id) + return + } + val settings = DatabaseManager.getSettings(announcement.guildId).awaitSingle() + + val embed = embedService.determineAnnouncementEmbed(announcement, event, settings) + + val message = channel.createMessage(MessageCreateRequest.builder() + .addEmbed(embed.asRequest()) + .build() + ).awaitSingle() + + if (announcement.publish) { + discordClient.getMessageById(announcement.channelId, Snowflake.of(message.id())) + .publish() + .awaitSingleOrNull() + } + + if (announcement.type == Announcement.Type.SPECIFIC) { + deleteAnnouncement(announcement.guildId, announcement.id) + } + } catch (ex: Exception) { + LOGGER.error("Failed to send announcement | guildId:${announcement.guildId.asLong()} | announcementId:${announcement.id}", ex) + } finally { + metricService.incrementAnnouncementPosted() + } + } + + suspend fun isInRange(announcement: Announcement, event: Event, maxDifference: Duration): Boolean { + val timeUntilEvent = Duration.between(Instant.now(), event.start) + + val difference = timeUntilEvent - announcement.getCalculatedTime() + + return if (difference.isNegative) { + // Event has past, check delete conditions + if (announcement.type == Announcement.Type.SPECIFIC) deleteAnnouncement(announcement.guildId, announcement.id) + + false + } else difference <= maxDifference + + } + + suspend fun processAnnouncementsForGuild(guildId: Snowflake, maxDifference: Duration) { + val taskTimer = StopWatch() + taskTimer.start() + + val guild = discordClient.getGuildById(guildId) + val calendars: MutableSet = mutableSetOf() + val events: MutableMap> = mutableMapOf() + + // TODO: Need to break this out to add handling for modifiers + getEnabledAnnouncements(guildId).forEach { announcement -> + // Get the calendar + var calendar = calendars.firstOrNull { it.calendarNumber == announcement.calendarNumber } + if (calendar == null) { + calendar = guild.getCalendar(announcement.calendarNumber).awaitSingleOrNull() ?: return@forEach + calendars.add(calendar) + } + + // Handle specific type first, since we don't need to fetch all events for this + if (announcement.type == Announcement.Type.SPECIFIC) { + val event = calendar.getEvent(announcement.eventId).awaitSingleOrNull() ?: return@forEach + if (isInRange(announcement, event, maxDifference)) { + sendAnnouncement(announcement, event) + } + } + + // Get the events to filter through + var filteredEvents = events[calendar.calendarNumber] + if (filteredEvents == null) { + filteredEvents = calendar.getUpcomingEvents(20) + .collectList() + .awaitSingle() + events[calendar.calendarNumber] = filteredEvents + } + + // Handle filtering out events based on this announcement's types + if (announcement.type == Announcement.Type.COLOR) { + filteredEvents = filteredEvents?.filter { it.color == announcement.eventColor } + } else if (announcement.type == Announcement.Type.RECUR) { + filteredEvents = filteredEvents + ?.filter { it.eventId.contains("_") } + ?.filter { it.eventId.split("_")[0] == announcement.eventId } + } + + // Loop through filtered events and post any announcements in range + filteredEvents + ?.filter { isInRange(announcement, it, maxDifference) } + ?.forEach { sendAnnouncement(announcement, it) } + + } + + taskTimer.stop() + metricService.recordAnnouncementTaskDuration("guild", taskTimer.totalTimeMillis) + } } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/CalendarService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/CalendarService.kt index 6b6d195f3..e0c67e568 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/CalendarService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/CalendarService.kt @@ -11,11 +11,13 @@ import org.dreamexposure.discal.core.`object`.new.Calendar import org.springframework.stereotype.Component @Component -class DefaultCalendarService( +class CalendarService( private val calendarRepository: CalendarRepository, private val calendarCache: CalendarCache, -) : CalendarService { - override suspend fun getAllCalendars(guildId: Snowflake): List { +) { + suspend fun getCalendarCount(): Long = calendarRepository.count().awaitSingle() + + suspend fun getAllCalendars(guildId: Snowflake): List { var calendars = calendarCache.get(key = guildId)?.toList() if (calendars != null) return calendars @@ -28,11 +30,11 @@ class DefaultCalendarService( return calendars } - override suspend fun getCalendar(guildId: Snowflake, number: Int): Calendar? { + suspend fun getCalendar(guildId: Snowflake, number: Int): Calendar? { return getAllCalendars(guildId).firstOrNull { it.number == number } } - override suspend fun updateCalendar(calendar: Calendar) { + suspend fun updateCalendar(calendar: Calendar) { val aes = AESEncryption(calendar.secrets.privateKey) val encryptedRefreshToken = aes.encrypt(calendar.secrets.refreshToken).awaitSingle() val encryptedAccessToken = aes.encrypt(calendar.secrets.accessToken).awaitSingle() @@ -58,15 +60,4 @@ class DefaultCalendarService( calendarCache.put(key = calendar.guildId,value = (newList + calendar).toTypedArray()) } } - -} - -interface CalendarService { - // TODO: Need a function to invalidate cache because bot and API are using Db Manager - - suspend fun getAllCalendars(guildId: Snowflake): List - - suspend fun getCalendar(guildId: Snowflake, number: Int): Calendar? - - suspend fun updateCalendar(calendar: Calendar) } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt index c103d231b..3f7a93261 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt @@ -4,14 +4,18 @@ import discord4j.common.util.Snowflake import discord4j.core.DiscordClient import discord4j.core.spec.EmbedCreateSpec import kotlinx.coroutines.reactor.awaitSingle +import org.dreamexposure.discal.Application +import org.dreamexposure.discal.GitProperty import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.entities.Calendar import org.dreamexposure.discal.core.entities.Event +import org.dreamexposure.discal.core.enums.announcement.AnnouncementStyle import org.dreamexposure.discal.core.enums.time.DiscordTimestampFormat import org.dreamexposure.discal.core.extensions.* import org.dreamexposure.discal.core.extensions.discord4j.getCalendar import org.dreamexposure.discal.core.extensions.discord4j.getSettings import org.dreamexposure.discal.core.`object`.GuildSettings +import org.dreamexposure.discal.core.`object`.new.Announcement import org.dreamexposure.discal.core.`object`.new.Rsvp import org.dreamexposure.discal.core.utils.GlobalVal import org.dreamexposure.discal.core.utils.getCommonMsg @@ -46,6 +50,39 @@ class EmbedService( ) } + //////////////////////////// + ////// General Embeds ////// + //////////////////////////// + suspend fun discalInfoEmbed(settings: GuildSettings, calendarCount: Long, announcementCount: Long): EmbedCreateSpec { + val guildCount = discordClient.guilds.count().awaitSingle() + + return defaultEmbedBuilder(settings) + .color(GlobalVal.discalColor) + .title(getEmbedMessage("discal", "info.title", settings)) + .addField(getEmbedMessage("discal", "info.field.version", settings), GitProperty.DISCAL_VERSION.value, false) + .addField(getEmbedMessage("discal", "info.field.library", settings), "Discord4J ${GitProperty.DISCAL_VERSION_D4J.value}", false) + .addField(getEmbedMessage("discal", "info.field.shard", settings), "${Application.getShardIndex()}/${Application.getShardCount()}", true) + .addField(getEmbedMessage("discal", "info.field.guilds", settings), "$guildCount", true) + .addField( + getEmbedMessage("discal", "info.field.uptime", settings), + Application.getUptime().getHumanReadable(), + false + ).addField(getEmbedMessage("discal", "info.field.calendars", settings), "$calendarCount", true) + .addField(getEmbedMessage("discal", "info.field.announcements", settings), "$announcementCount", true) + .addField(getEmbedMessage("discal", "info.field.links", settings), + getEmbedMessage("discal", + "info.field.links.value", + settings, + "${Config.URL_BASE.getString()}/commands", + Config.URL_SUPPORT.getString(), + Config.URL_INVITE.getString(), + "https://www.patreon.com/Novafox" + ), + false + ).footer(getEmbedMessage("discal", "info.footer", settings), null) + .build() + } + ///////////////////////////// ////// Calendar Embeds ////// ///////////////////////////// @@ -271,4 +308,165 @@ class EmbedService( .footer(getEmbedMessage("rsvp", "list.footer", settings), null) .build() } + + ///////////////////////////////// + ////// Announcement Embeds ////// + ///////////////////////////////// + suspend fun determineAnnouncementEmbed(announcement: Announcement, event: Event, settings: GuildSettings): EmbedCreateSpec { + return when(settings.announcementStyle) { + AnnouncementStyle.FULL -> fullAnnouncementEmbed(announcement, event, settings) + AnnouncementStyle.SIMPLE -> simpleAnnouncementEmbed(announcement, event, settings) + AnnouncementStyle.EVENT -> eventAnnouncementEmbed(announcement, event, settings) + } + } + + suspend fun fullAnnouncementEmbed(announcement: Announcement, event: Event, settings: GuildSettings): EmbedCreateSpec { + val builder = defaultEmbedBuilder(settings) + .color(event.color.asColor()) + .title(getEmbedMessage("announcement", "full.title", settings)) + + if (event.name.isNotBlank()) builder.addField( + getEmbedMessage("announcement", "full.field.name", settings), + event.name.toMarkdown().embedFieldSafe(), + false + ) + if (event.description.isNotBlank()) builder.addField( + getEmbedMessage("announcement", "full.field.desc", settings), + event.description.toMarkdown().embedFieldSafe(), + false + ) + + builder.addField( + getEmbedMessage("announcement", "full.field.start", settings), + event.start.asDiscordTimestamp(DiscordTimestampFormat.LONG_DATETIME), + true + ) + builder.addField( + getEmbedMessage("announcement", "full.field.end", settings), + event.end.asDiscordTimestamp(DiscordTimestampFormat.LONG_DATETIME), + true + ) + + if (event.location.isNotBlank()) builder.addField( + getEmbedMessage("announcement", "full.field.location", settings), + event.location.toMarkdown().embedFieldSafe(), + false + ) + + if (announcement.info.isNotBlank() && !announcement.info.equals("None", true)) builder.addField( + getEmbedMessage("announcement", "full.field.info", settings), + announcement.info.toMarkdown().embedFieldSafe(), + false + ) + + builder.addField( + getEmbedMessage("announcement", "full.field.calendar", settings), + "${event.calendar.calendarNumber}", + true + ) + builder.addField(getEmbedMessage("announcement", "full.field.event", settings), event.eventId, true) + + if (event.image.isNotBlank()) + builder.image(event.image) + + builder.footer(getEmbedMessage("announcement", "full.footer", settings, announcement.id), null) + + return builder.build() + } + + suspend fun simpleAnnouncementEmbed(announcement: Announcement, event: Event, settings: GuildSettings): EmbedCreateSpec { + val builder = defaultEmbedBuilder(settings) + .color(event.color.asColor()) + .title(getEmbedMessage("announcement", "simple.title", settings)) + + if (event.name.isNotBlank()) builder.addField( + getEmbedMessage("announcement", "simple.field.name", settings), + event.name.toMarkdown().embedFieldSafe(), + false + ) + if (event.description.isNotBlank()) builder.addField( + getEmbedMessage("announcement", "simple.field.desc", settings), + event.description.toMarkdown().embedFieldSafe(), + false + ) + + builder.addField( + getEmbedMessage("announcement", "simple.field.start", settings), + event.start.asDiscordTimestamp(DiscordTimestampFormat.LONG_DATETIME), + true + ) + + if (event.location.isNotBlank()) builder.addField( + getEmbedMessage("announcement", "simple.field.location", settings), + event.location.toMarkdown().embedFieldSafe(), + false + ) + + if (announcement.info.isNotBlank() && !announcement.info.equals("None", true)) builder.addField( + getEmbedMessage("announcement", "simple.field.info", settings), + announcement.info.toMarkdown().embedFieldSafe(), + false + ) + + if (event.image.isNotEmpty()) + builder.image(event.image) + + builder.footer(getEmbedMessage("announcement", "simple.footer", settings, announcement.id), null) + + return builder.build() + } + + suspend fun eventAnnouncementEmbed(announcement: Announcement, event: Event, settings: GuildSettings): EmbedCreateSpec { + val builder = defaultEmbedBuilder(settings) + .color(event.color.asColor()) + .title(getEmbedMessage("announcement", "event.title", settings)) + + if (event.name.isNotBlank()) builder.addField( + getEmbedMessage("announcement", "event.field.name", settings), + event.name.toMarkdown().embedFieldSafe(), + false + ) + if (event.description.isNotBlank()) builder.addField( + getEmbedMessage("announcement", "event.field.desc", settings), + event.description.toMarkdown().embedFieldSafe(), + false + ) + + builder.addField( + getEmbedMessage("announcement", "event.field.start", settings), + event.start.asDiscordTimestamp(DiscordTimestampFormat.LONG_DATETIME), + true + ) + builder.addField( + getEmbedMessage("announcement", "event.field.end", settings), + event.end.asDiscordTimestamp(DiscordTimestampFormat.LONG_DATETIME), + true + ) + + if (event.location.isNotBlank()) builder.addField( + getEmbedMessage("announcement", "event.field.location", settings), + event.location.toMarkdown().embedFieldSafe(), + false + ) + + builder.addField( + getEmbedMessage("announcement", "event.field.calendar", settings), + "${event.calendar.calendarNumber}", + true + ) + builder.addField(getEmbedMessage("announcement", "event.field.event", settings), event.eventId, true) + + if (announcement.info.isNotBlank() && !announcement.info.equals("None", true)) builder.addField( + getEmbedMessage("announcement", "event.field.info", settings), + announcement.info.toMarkdown().embedFieldSafe(), + false + ) + + if (event.image.isNotBlank()) + builder.image(event.image) + + builder.footer(getEmbedMessage("announcement", "event.footer", settings, announcement.id), null) + + return builder.build() + } } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/MetricService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/MetricService.kt index 81385b993..ea58cf9db 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/MetricService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/MetricService.kt @@ -17,7 +17,7 @@ class MetricService( ).record(Duration.ofMillis(duration)) } - fun recordTaskDuration(task: String, tags: List, duration: Long) { + fun recordTaskDuration(task: String, tags: List = listOf(), duration: Long) { meterRegistry.timer( "bot.task.duration", tags.plus(Tag.of("task", task)) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt index 53eb1042f..9191c9d71 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt @@ -32,6 +32,10 @@ enum class Config(private val key: String, private var value: Any? = null) { // Security configuration + // Global bot timings + TIMING_BOT_STATUS_UPDATE_MINUTES("bot.timing.status-update.minutes", 5), + TIMING_ANNOUNCEMENT_TASK_RUN_INTERVAL_MINUTES("bot.timing.announcement.task-run-interval.minutes", 5), + // Bot secrets SECRET_DISCAL_API_KEY("bot.secret.api-token"), SECRET_BOT_TOKEN("bot.secret.token"), diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt index 8440b0030..b35818a02 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt @@ -514,25 +514,6 @@ object DatabaseManager { } } - fun getCalendarCount(): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_ALL_CALENDAR_COUNT) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val calendars = row.get(0, Long::class.java)!! - return@map calendars.toInt() - } - }.next().retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get calendar count", it) - }.onErrorReturn(-1) - } - } - fun getCalendarCount(guildId: Snowflake): Mono { return connect { c -> Mono.from( @@ -766,25 +747,6 @@ object DatabaseManager { }.defaultIfEmpty(mutableListOf()) } - fun getAnnouncementCount(): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_ALL_ANNOUNCEMENT_COUNT) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val announcements = row[0, Long::class.java]!! - return@map announcements.toInt() - } - }.next().retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get announcement count", it) - }.onErrorReturn(-1) - }.defaultIfEmpty(-1) - } - fun deleteAnnouncement(announcementId: String): Mono { return connect { c -> Mono.from( @@ -953,48 +915,6 @@ object DatabaseManager { }.defaultIfEmpty(emptyMap()) } } - - /* Announcement Data */ - - fun getAnnouncementsForShard(shardCount: Int, shardIndex: Int): Mono> { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_ANNOUNCEMENTS_FOR_SHARD) - .bind(0, shardCount) - .bind(1, shardIndex) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val announcementId = row["ANNOUNCEMENT_ID", String::class.java]!! - val guildId = Snowflake.of(row["GUILD_ID", Long::class.java]!!) - - val a = Announcement(guildId, announcementId) - a.calendarNumber = row["CALENDAR_NUMBER", Int::class.java]!! - a.subscriberRoleIds.setFromString(row["SUBSCRIBERS_ROLE", String::class.java]!!) - a.subscriberUserIds.setFromString(row["SUBSCRIBERS_USER", String::class.java]!!) - a.announcementChannelId = row["CHANNEL_ID", String::class.java]!! - a.type = AnnouncementType.valueOf(row["ANNOUNCEMENT_TYPE", String::class.java]!!) - a.modifier = AnnouncementModifier.valueOf(row["MODIFIER", String::class.java]!!) - a.eventId = row["EVENT_ID", String::class.java]!! - a.eventColor = fromNameOrHexOrId(row["EVENT_COLOR", String::class.java]!!) - a.hoursBefore = row["HOURS_BEFORE", Int::class.java]!! - a.minutesBefore = row["MINUTES_BEFORE", Int::class.java]!! - a.info = row["INFO", String::class.java]!! - a.enabled = row["ENABLED", Boolean::class.java]!! - a.publish = row["PUBLISH", Boolean::class.java]!! - - a - } - }.retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get announcements for shard", it) - }.onErrorResume { - Mono.empty() - }.collectList() - } - } } private object Queries { @@ -1018,9 +938,6 @@ private object Queries { WHERE GUILD_ID = ? """.trimMargin() - @Language("MySQL") - val SELECT_ALL_CALENDAR_COUNT = """SELECT COUNT(*) FROM ${Tables.CALENDARS}""" - @Language("MySQL") val SELECT_CALENDAR_COUNT_BY_GUILD = """SELECT COUNT(*) FROM ${Tables.CALENDARS} WHERE GUILD_ID = ? @@ -1056,10 +973,6 @@ private object Queries { WHERE ENABLED = 1 AND GUILD_ID = ? AND ANNOUNCEMENT_TYPE = ? """.trimMargin() - - @Language("MySQL") - val SELECT_ALL_ANNOUNCEMENT_COUNT = """SELECT COUNT(*) FROM ${Tables.ANNOUNCEMENTS}""" - @Language("MySQL") val DELETE_ANNOUNCEMENT = """DELETE FROM ${Tables.ANNOUNCEMENTS} WHERE ANNOUNCEMENT_ID = ? @@ -1142,11 +1055,6 @@ private object Queries { WHERE event_id in (?) """.trimMargin() - @Language("MySQL") - val SELECT_ANNOUNCEMENTS_FOR_SHARD = """SELECT * FROM ${Tables.ANNOUNCEMENTS} - WHERE MOD(guild_id >> 22, ?) = ? - """.trimMargin() - /* Delete everything */ @Language("MySQL") diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/entities/Event.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/entities/Event.kt index 820b651f6..b5d7247fa 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/entities/Event.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/entities/Event.kt @@ -3,17 +3,13 @@ package org.dreamexposure.discal.core.entities import discord4j.common.util.Snowflake import discord4j.core.`object`.entity.Guild import kotlinx.serialization.encodeToString -import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.entities.response.UpdateEventResponse import org.dreamexposure.discal.core.entities.spec.update.UpdateEventSpec -import org.dreamexposure.discal.core.enums.announcement.AnnouncementType import org.dreamexposure.discal.core.enums.event.EventColor -import org.dreamexposure.discal.core.`object`.announcement.Announcement import org.dreamexposure.discal.core.`object`.event.EventData import org.dreamexposure.discal.core.`object`.event.Recurrence import org.dreamexposure.discal.core.utils.GlobalVal.JSON_FORMAT import org.json.JSONObject -import reactor.core.publisher.Flux import reactor.core.publisher.Mono import java.time.Duration import java.time.Instant @@ -103,26 +99,6 @@ interface Event { //Reactive - /** - * Attempts to request the announcements linked to the event, such as a [SPECIFIC][AnnouncementType.SPECIFIC] - * type announcement. - * If an error occurs, it is emitted through the Flux. - * - * @return A [Flux] of all announcements that are linked to the event. - */ - fun getLinkedAnnouncements(): Flux { - return DatabaseManager.getAnnouncements(this.guildId) - .flatMapMany { Flux.fromIterable(it) } - .filter { ann -> - when (ann.type) { - AnnouncementType.UNIVERSAL -> return@filter true - AnnouncementType.COLOR -> return@filter ann.eventColor == this.color - AnnouncementType.SPECIFIC -> return@filter ann.eventId == this.eventId - AnnouncementType.RECUR -> return@filter this.eventId.contains(ann.eventId) - } - } - } - /** * Attempts to update the event and returns the result. diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/discord4j/Guild.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/discord4j/Guild.kt index 17e9cb309..59391deac 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/discord4j/Guild.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/discord4j/Guild.kt @@ -2,16 +2,14 @@ package org.dreamexposure.discal.core.extensions.discord4j import discord4j.common.util.Snowflake import discord4j.core.`object`.entity.Guild -import discord4j.core.`object`.entity.Member import discord4j.core.`object`.entity.Role import discord4j.rest.entity.RestGuild import discord4j.rest.http.client.ClientException import io.netty.handler.codec.http.HttpResponseStatus -import org.dreamexposure.discal.core.`object`.GuildSettings -import org.dreamexposure.discal.core.`object`.announcement.Announcement import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.entities.Calendar import org.dreamexposure.discal.core.entities.spec.create.CreateCalendarSpec +import org.dreamexposure.discal.core.`object`.GuildSettings import reactor.core.publisher.Flux import reactor.core.publisher.Mono @@ -66,47 +64,6 @@ fun Guild.getAllCalendars(): Flux = getRestGuild().getAllCalendars() */ fun Guild.createCalendar(spec: CreateCalendarSpec): Mono = getRestGuild().createCalendar(spec) -//Announcements -/** - * Requests to check if an announcement with the supplied ID exists. - * If an error occurs, it is emitted through the Mono. - * - * @param id The ID of the announcement to check for - * @return A Mono, whereupon successful completion, returns a boolean as to if the announcement exists or not - */ -fun Guild.announcementExists(id: String): Mono = getRestGuild().announcementExists(id) - -/** - * Attempts to retrieve an [Announcement] with the supplied ID. - * If an error occurs, it is emitted through the [Mono] - * - * @param id The ID of the [Announcement] - * @return A [Mono] of the [Announcement] with the supplied ID, otherwise [empty][Mono.empty] is returned. - */ -fun Guild.getAnnouncement(id: String): Mono = getRestGuild().getAnnouncement(id) - -/** - * Attempts to retrieve all [announcements][Announcement] belonging to this [Guild]. - * If an error occurs, it is emitted through the [Flux] - * - * @return A Flux of all [announcements][Announcement] belonging to this [Guild] - */ -fun Guild.getAllAnnouncements(): Flux = getRestGuild().getAllAnnouncements() - -/** - * Attempts to retrieve all [announcements][Announcement] belonging to this [Guild] that are enabled. - * If an error occurs, it is emitted through the [Flux] - * - * @return A [Flux] of all [announcements][Announcement] belonging to this [Guild] that are enabled. - */ -fun Guild.getEnabledAnnouncements(): Flux = getRestGuild().getEnabledAnnouncements() - -fun Guild.createAnnouncement(ann: Announcement): Mono = getRestGuild().createAnnouncement(ann) - -fun Guild.updateAnnouncement(ann: Announcement): Mono = getRestGuild().updateAnnouncement(ann) - -fun Guild.deleteAnnouncement(id: String): Mono = getRestGuild().deleteAnnouncement(id) - fun Guild.getControlRole(): Mono { return getSettings().flatMap { settings -> if (settings.controlRole.equals("everyone", true)) @@ -125,20 +82,6 @@ fun Guild.getControlRole(): Mono { } } -fun Guild.getMemberFromStringId(id: String): Mono { - return Mono.just(id) - .filter(String::isNotEmpty) - .filter { it.matches(Regex("[0-9]+")) } - .map(Snowflake::of) - .flatMap(this::getMemberById) - .onErrorResume { Mono.empty() } -} - -fun Guild.getMembersFromId(ids: List): Flux { - return Flux.fromIterable(ids) - .flatMap(this::getMemberFromStringId) -} - fun Guild.getRestGuild(): RestGuild { return client.rest().restGuild(data) } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/discord4j/RestGuild.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/discord4j/RestGuild.kt index cecfe7012..55c1c15fc 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/discord4j/RestGuild.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/discord4j/RestGuild.kt @@ -3,15 +3,14 @@ package org.dreamexposure.discal.core.extensions.discord4j import com.google.api.services.calendar.model.AclRule import discord4j.core.`object`.entity.Guild import discord4j.rest.entity.RestGuild -import org.dreamexposure.discal.core.`object`.GuildSettings -import org.dreamexposure.discal.core.`object`.announcement.Announcement -import org.dreamexposure.discal.core.`object`.calendar.CalendarData import org.dreamexposure.discal.core.cache.DiscalCache import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.entities.Calendar import org.dreamexposure.discal.core.entities.google.GoogleCalendar import org.dreamexposure.discal.core.entities.spec.create.CreateCalendarSpec import org.dreamexposure.discal.core.enums.calendar.CalendarHost +import org.dreamexposure.discal.core.`object`.GuildSettings +import org.dreamexposure.discal.core.`object`.calendar.CalendarData import org.dreamexposure.discal.core.wrapper.google.AclRuleWrapper import org.dreamexposure.discal.core.wrapper.google.CalendarWrapper import org.dreamexposure.discal.core.wrapper.google.GoogleAuthWrapper @@ -138,50 +137,3 @@ fun RestGuild.createCalendar(spec: CreateCalendarSpec): Mono { } } } - -//Announcements -/** - * Requests to check if an announcement with the supplied ID exists. - * If an error occurs, it is emitted through the Mono. - * - * @param id The ID of the announcement to check for - * @return A Mono, whereupon successful completion, returns a boolean as to if the announcement exists or not - */ -fun RestGuild.announcementExists(id: String): Mono = this.getAnnouncement(id).hasElement() - -/** - * Attempts to retrieve an [Announcement] with the supplied ID. - * If an error occurs, it is emitted through the [Mono] - * - * @param id The ID of the [Announcement] - * @return A [Mono] of the [Announcement] with the supplied ID, otherwise [empty][Mono.empty] is returned. - */ -fun RestGuild.getAnnouncement(id: String): Mono = DatabaseManager.getAnnouncement(id, this.id) - -/** - * Attempts to retrieve all [announcements][Announcement] belonging to this [Guild]. - * If an error occurs, it is emitted through the [Flux] - * - * @return A Flux of all [announcements][Announcement] belonging to this [Guild] - */ -fun RestGuild.getAllAnnouncements(): Flux { - return DatabaseManager.getAnnouncements(this.id) - .flatMapMany { Flux.fromIterable(it) } -} - -/** - * Attempts to retrieve all [announcements][Announcement] belonging to this [Guild] that are enabled. - * If an error occurs, it is emitted through the [Flux] - * - * @return A [Flux] of all [announcements][Announcement] belonging to this [Guild] that are enabled. - */ -fun RestGuild.getEnabledAnnouncements(): Flux { - return DatabaseManager.getEnabledAnnouncements(this.id) - .flatMapMany { Flux.fromIterable(it) } -} - -fun RestGuild.createAnnouncement(ann: Announcement): Mono = DatabaseManager.updateAnnouncement(ann) - -fun RestGuild.updateAnnouncement(ann: Announcement): Mono = DatabaseManager.updateAnnouncement(ann) - -fun RestGuild.deleteAnnouncement(id: String): Mono = DatabaseManager.deleteAnnouncement(id) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Announcement.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Announcement.kt index e63e19b9e..d2a3dc7ac 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Announcement.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Announcement.kt @@ -1,13 +1,15 @@ package org.dreamexposure.discal.core.`object`.new import discord4j.common.util.Snowflake +import org.dreamexposure.discal.core.crypto.KeyGenerator import org.dreamexposure.discal.core.database.AnnouncementData import org.dreamexposure.discal.core.enums.event.EventColor import org.dreamexposure.discal.core.extensions.asSnowflake import org.dreamexposure.discal.core.extensions.asStringListFromDatabase +import java.time.Duration data class Announcement( - val id: String, + val id: String = KeyGenerator.generateAnnouncementId(), val guildId: Snowflake, val calendarNumber: Int = 1, @@ -51,6 +53,8 @@ data class Announcement( publish = data.publish, ) + fun getCalculatedTime(): Duration = Duration.ofHours(hoursBefore.toLong()).plusMinutes(minutesBefore.toLong()) + data class Subscribers( val roles: List = listOf(), diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/security/Scope.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/security/Scope.kt index e8163cd5d..29843897b 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/security/Scope.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/security/Scope.kt @@ -6,6 +6,9 @@ enum class Scope { EVENT_RSVP_READ, EVENT_RSVP_WRITE, + ANNOUNCEMENT_READ, + ANNOUNCEMENT_WRITE, + OAUTH2_DISCORD, INTERNAL_CAM_VALIDATE_TOKEN, diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/CreateAnnouncementEndpoint.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/CreateAnnouncementEndpoint.kt deleted file mode 100644 index f3f20624c..000000000 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/CreateAnnouncementEndpoint.kt +++ /dev/null @@ -1,92 +0,0 @@ -package org.dreamexposure.discal.server.endpoints.v2.announcement - -import discord4j.common.util.Snowflake -import discord4j.core.DiscordClient -import kotlinx.serialization.encodeToString -import org.dreamexposure.discal.core.annotations.SecurityRequirement -import org.dreamexposure.discal.core.enums.announcement.AnnouncementModifier -import org.dreamexposure.discal.core.enums.announcement.AnnouncementType -import org.dreamexposure.discal.core.enums.event.EventColor -import org.dreamexposure.discal.core.extensions.discord4j.createAnnouncement -import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.`object`.announcement.Announcement -import org.dreamexposure.discal.core.utils.GlobalVal -import org.dreamexposure.discal.server.utils.Authentication -import org.dreamexposure.discal.server.utils.responseMessage -import org.json.JSONException -import org.json.JSONObject -import org.springframework.http.server.reactive.ServerHttpResponse -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -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 - -@RestController -@RequestMapping("/v2/announcement") -class CreateAnnouncementEndpoint(val client: DiscordClient) { - @PostMapping("/create", produces = ["application/json"]) - @SecurityRequirement(disableSecurity = true, scopes = []) - fun create(swe: ServerWebExchange, response: ServerHttpResponse, @RequestBody rBody: String): Mono { - return Authentication.authenticate(swe).flatMap { authState -> - if (!authState.success) { - response.rawStatusCode = authState.status - return@flatMap Mono.just(GlobalVal.JSON_FORMAT.encodeToString(authState)) - } else if (authState.readOnly) { - response.rawStatusCode = GlobalVal.STATUS_AUTHORIZATION_DENIED - return@flatMap responseMessage("Read-Only key not allowed") - } - - //Handle request - val body = JSONObject(rBody) - val guildId = Snowflake.of(body.getString("guild_id")) - - val announcement = Announcement(guildId) - - announcement.announcementChannelId = body.getString("channel") - announcement.type = AnnouncementType.fromValue(body.getString("type")) - - announcement.modifier = AnnouncementModifier.fromValue(body.optString("modifier", "BEFORE")) - - if (announcement.type == AnnouncementType.COLOR) { - announcement.eventColor = EventColor.fromNameOrHexOrId(body.getString("color")) - } - - if (announcement.type == AnnouncementType.RECUR || announcement.type == AnnouncementType.SPECIFIC) { - announcement.eventId = body.getString("event_id") - } - - announcement.hoursBefore = body.optInt("hours", 0) - announcement.minutesBefore = body.optInt("minutes", 0) - - announcement.info = body.optString("info", "N/a") - - announcement.publish = body.optBoolean("publish", false) - - return@flatMap client.getGuildById(guildId).createAnnouncement(announcement).flatMap { success -> - if (success) { - response.rawStatusCode = GlobalVal.STATUS_SUCCESS - - val json = JSONObject() - json.put("message", "Success").put("announcement", GlobalVal.JSON_FORMAT.encodeToString(announcement)) - - Mono.just(json.toString()) - } else { - response.rawStatusCode = GlobalVal.STATUS_INTERNAL_ERROR - responseMessage("Internal Server Error") - } - } - }.onErrorResume(JSONException::class.java) { - LOGGER.trace("[API-v2] JSON error. Bad request?", it) - - response.rawStatusCode = GlobalVal.STATUS_BAD_REQUEST - return@onErrorResume responseMessage("Bad Request") - }.onErrorResume { - LOGGER.error(GlobalVal.DEFAULT, "[API-v2] create announcement error", it) - - response.rawStatusCode = GlobalVal.STATUS_INTERNAL_ERROR - return@onErrorResume responseMessage("Internal Server Error") - } - } -} diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/DeleteAnnouncementEndpoint.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/DeleteAnnouncementEndpoint.kt deleted file mode 100644 index 230e1ee34..000000000 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/DeleteAnnouncementEndpoint.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.dreamexposure.discal.server.endpoints.v2.announcement - -import discord4j.common.util.Snowflake -import discord4j.core.DiscordClient -import kotlinx.serialization.encodeToString -import org.dreamexposure.discal.core.annotations.SecurityRequirement -import org.dreamexposure.discal.core.extensions.discord4j.deleteAnnouncement -import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.utils.GlobalVal -import org.dreamexposure.discal.server.utils.Authentication -import org.dreamexposure.discal.server.utils.responseMessage -import org.json.JSONException -import org.json.JSONObject -import org.springframework.http.server.reactive.ServerHttpResponse -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -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 - -@RestController -@RequestMapping("/v2/announcement") -class DeleteAnnouncementEndpoint(val client: DiscordClient) { - @PostMapping("/delete", produces = ["application/json"]) - @SecurityRequirement(disableSecurity = true, scopes = []) - fun delete(swe: ServerWebExchange, response: ServerHttpResponse, @RequestBody rBody: String): Mono { - return Authentication.authenticate(swe).flatMap { authState -> - if (!authState.success) { - response.rawStatusCode = authState.status - return@flatMap Mono.just(GlobalVal.JSON_FORMAT.encodeToString(authState)) - } else if (authState.readOnly) { - response.rawStatusCode = GlobalVal.STATUS_AUTHORIZATION_DENIED - return@flatMap responseMessage("Read-Only key not allowed") - } - - //Handle request - val body = JSONObject(rBody) - val guildId = Snowflake.of(body.getString("guild_id")) - val announcementId = body.getString("announcement_id") - - return@flatMap client.getGuildById(guildId).deleteAnnouncement(announcementId) - .then(responseMessage("Success") - .doOnNext { response.rawStatusCode = GlobalVal.STATUS_SUCCESS } - ) - }.onErrorResume(JSONException::class.java) { - LOGGER.trace("[API-v2] JSON error. Bad request?", it) - - response.rawStatusCode = GlobalVal.STATUS_BAD_REQUEST - return@onErrorResume responseMessage("Bad Request") - }.onErrorResume { - LOGGER.error(GlobalVal.DEFAULT, "[API-v2] delete announcement error", it) - - response.rawStatusCode = GlobalVal.STATUS_INTERNAL_ERROR - return@onErrorResume responseMessage("Internal Server Error") - } - } -} diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/GetAnnouncementEndpoint.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/GetAnnouncementEndpoint.kt deleted file mode 100644 index 5c362650c..000000000 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/GetAnnouncementEndpoint.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.dreamexposure.discal.server.endpoints.v2.announcement - -import discord4j.common.util.Snowflake -import discord4j.core.DiscordClient -import kotlinx.serialization.encodeToString -import org.dreamexposure.discal.core.annotations.SecurityRequirement -import org.dreamexposure.discal.core.extensions.discord4j.getAnnouncement -import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.utils.GlobalVal -import org.dreamexposure.discal.server.utils.Authentication -import org.dreamexposure.discal.server.utils.responseMessage -import org.json.JSONException -import org.json.JSONObject -import org.springframework.http.server.reactive.ServerHttpResponse -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -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 - -@RestController -@RequestMapping("/v2/announcement") -class GetAnnouncementEndpoint(val client: DiscordClient) { - @PostMapping("/get", produces = ["application/json"]) - @SecurityRequirement(disableSecurity = true, scopes = []) - fun get(swe: ServerWebExchange, response: ServerHttpResponse, @RequestBody rBody: String): Mono { - return Authentication.authenticate(swe).flatMap { authState -> - if (!authState.success) { - response.rawStatusCode = authState.status - return@flatMap Mono.just(GlobalVal.JSON_FORMAT.encodeToString(authState)) - } - - //Handle request - val body = JSONObject(rBody) - val guildId = Snowflake.of(body.getString("guild_id")) - val announcementId = body.getString("announcement_id") - - return@flatMap client.getGuildById(guildId).getAnnouncement(announcementId) - .map { GlobalVal.JSON_FORMAT.encodeToString(it) } - .doOnNext { response.rawStatusCode = GlobalVal.STATUS_SUCCESS } - .switchIfEmpty(responseMessage("Announcement not found") - .doOnNext { response.rawStatusCode = GlobalVal.STATUS_NOT_FOUND } - ) - }.onErrorResume(JSONException::class.java) { - LOGGER.trace("[API-v2] JSON error. Bad request?", it) - - response.rawStatusCode = GlobalVal.STATUS_BAD_REQUEST - return@onErrorResume responseMessage("Bad Request") - }.onErrorResume { - LOGGER.error(GlobalVal.DEFAULT, "[API-v2] get announcement error", it) - - response.rawStatusCode = GlobalVal.STATUS_INTERNAL_ERROR - return@onErrorResume responseMessage("Internal Server Error") - } - } -} diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/ListAnnouncementEndpoint.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/ListAnnouncementEndpoint.kt deleted file mode 100644 index 7c5604dd9..000000000 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/ListAnnouncementEndpoint.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.dreamexposure.discal.server.endpoints.v2.announcement - -import discord4j.common.util.Snowflake -import discord4j.core.DiscordClient -import kotlinx.serialization.encodeToString -import org.dreamexposure.discal.core.annotations.SecurityRequirement -import org.dreamexposure.discal.core.extensions.discord4j.getAllAnnouncements -import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.utils.GlobalVal -import org.dreamexposure.discal.server.utils.Authentication -import org.dreamexposure.discal.server.utils.responseMessage -import org.json.JSONArray -import org.json.JSONException -import org.json.JSONObject -import org.springframework.http.server.reactive.ServerHttpResponse -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -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 - -@RestController -@RequestMapping("/v2/announcement") -class ListAnnouncementEndpoint(val client: DiscordClient) { - @PostMapping("/list", produces = ["application/json"]) - @SecurityRequirement(disableSecurity = true, scopes = []) - fun list(swe: ServerWebExchange, response: ServerHttpResponse, @RequestBody rBody: String): Mono { - return Authentication.authenticate(swe).flatMap { authState -> - if (!authState.success) { - response.rawStatusCode = authState.status - return@flatMap Mono.just(GlobalVal.JSON_FORMAT.encodeToString(authState)) - } - - //Handle request - val body = JSONObject(rBody) - val guildId = Snowflake.of(body.getString("guild_id")) - - return@flatMap client.getGuildById(guildId).getAllAnnouncements() - .map { GlobalVal.JSON_FORMAT.encodeToString(it) } - .collectList() - .map { JSONArray(it) } - .map { JSONObject().put("message", "Success").put("announcements", it).toString() } - .doOnNext { response.rawStatusCode = GlobalVal.STATUS_SUCCESS } - }.onErrorResume(JSONException::class.java) { - LOGGER.trace("[API-v2] JSON error. Bad request?", it) - - response.rawStatusCode = GlobalVal.STATUS_BAD_REQUEST - return@onErrorResume responseMessage("Bad Request") - }.onErrorResume { - LOGGER.error(GlobalVal.DEFAULT, "[API-v2] list announcements error", it) - - response.rawStatusCode = GlobalVal.STATUS_INTERNAL_ERROR - return@onErrorResume responseMessage("Internal Server Error") - } - } -} diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/UpdateAnnouncementEndpoint.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/UpdateAnnouncementEndpoint.kt deleted file mode 100644 index c3a9ebf2e..000000000 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v2/announcement/UpdateAnnouncementEndpoint.kt +++ /dev/null @@ -1,116 +0,0 @@ -package org.dreamexposure.discal.server.endpoints.v2.announcement - -import discord4j.common.util.Snowflake -import discord4j.core.DiscordClient -import kotlinx.serialization.encodeToString -import org.dreamexposure.discal.core.annotations.SecurityRequirement -import org.dreamexposure.discal.core.enums.announcement.AnnouncementModifier -import org.dreamexposure.discal.core.enums.announcement.AnnouncementType -import org.dreamexposure.discal.core.enums.event.EventColor -import org.dreamexposure.discal.core.extensions.discord4j.getAnnouncement -import org.dreamexposure.discal.core.extensions.discord4j.updateAnnouncement -import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.utils.GlobalVal -import org.dreamexposure.discal.server.utils.Authentication -import org.dreamexposure.discal.server.utils.responseMessage -import org.json.JSONException -import org.json.JSONObject -import org.springframework.http.server.reactive.ServerHttpResponse -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -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 - -@RestController -@RequestMapping("/v2/announcement") -class UpdateAnnouncementEndpoint(val client: DiscordClient) { - @SecurityRequirement(disableSecurity = true, scopes = []) - @PostMapping("/update", produces = ["application/json"]) - fun update(swe: ServerWebExchange, response: ServerHttpResponse, @RequestBody rBody: String): Mono { - return Authentication.authenticate(swe).flatMap { authState -> - if (!authState.success) { - response.rawStatusCode = authState.status - return@flatMap Mono.just(GlobalVal.JSON_FORMAT.encodeToString(authState)) - } else if (authState.readOnly) { - response.rawStatusCode = GlobalVal.STATUS_AUTHORIZATION_DENIED - return@flatMap responseMessage("Read-Only key not allowed") - } - - //Handle request - val body = JSONObject(rBody) - val guildId = Snowflake.of(body.getString("guild_id")) - val announcementId = body.getString("announcement_id") - - val guild = client.getGuildById(guildId) - - return@flatMap guild.getAnnouncement(announcementId).flatMap { ann -> - ann.announcementChannelId = body.optString("channel", ann.announcementChannelId) - ann.eventId = body.optString("event_id", ann.eventId) - ann.hoursBefore = body.optInt("hours", ann.hoursBefore) - ann.minutesBefore = body.optInt("hours", ann.minutesBefore) - ann.info = body.optString("info", ann.info) - ann.enabled = body.optBoolean("enabled", ann.enabled) - ann.publish = body.optBoolean("publish", ann.publish) - - if (body.has("event_color")) - ann.eventColor = EventColor.fromNameOrHexOrId(body.getString("event_color")) - if (body.has("type")) - ann.type = AnnouncementType.fromValue(body.getString("type")) - if (body.has("modifier")) - ann.modifier = AnnouncementModifier.fromValue(body.getString("modifier")) - - //Handle subscribers - if (body.has("remove_subscriber_roles")) { - val jList = body.getJSONArray("remove_subscriber_roles") - for (i in 0 until jList.length()) - ann.subscriberRoleIds.remove(jList.getString(i)) - } - if (body.has("remove_subscriber_users")) { - val jList = body.getJSONArray("remove_subscriber_users") - for (i in 0 until jList.length()) - ann.subscriberUserIds.remove(jList.getString(i)) - } - - if (body.has("add_subscriber_roles")) { - val jList = body.getJSONArray("add_subscriber_roles") - for (i in 0 until jList.length()) - ann.subscriberRoleIds.add(jList.getString(i)) - } - if (body.has("add_subscriber_users")) { - val jList = body.getJSONArray("add_subscriber_users") - for (i in 0 until jList.length()) - ann.subscriberUserIds.add(jList.getString(i)) - } - - - guild.updateAnnouncement(ann).flatMap { success -> - if (success) { - response.rawStatusCode = GlobalVal.STATUS_SUCCESS - - val json = JSONObject() - json.put("message", "Success").put("announcement", GlobalVal.JSON_FORMAT.encodeToString(ann)) - - Mono.just(json.toString()) - } else { - response.rawStatusCode = GlobalVal.STATUS_INTERNAL_ERROR - responseMessage("Internal Server Error") - } - } - }.switchIfEmpty(responseMessage("Announcement not found") - .doOnNext { response.rawStatusCode = GlobalVal.STATUS_NOT_FOUND } - ) - }.onErrorResume(JSONException::class.java) { - LOGGER.trace("[API-v2] JSON error. Bad request?", it) - - response.rawStatusCode = GlobalVal.STATUS_BAD_REQUEST - return@onErrorResume responseMessage("Bad Request") - }.onErrorResume { - LOGGER.error(GlobalVal.DEFAULT, "[API-v2] update announcement error", it) - - response.rawStatusCode = GlobalVal.STATUS_INTERNAL_ERROR - return@onErrorResume responseMessage("Internal Server Error") - } - } -} diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/AnnouncementController.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/AnnouncementController.kt index 82d99c3b5..1558af2c5 100644 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/AnnouncementController.kt +++ b/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/AnnouncementController.kt @@ -1,8 +1,46 @@ package org.dreamexposure.discal.server.endpoints.v3 -import org.springframework.web.bind.annotation.RestController +import discord4j.common.util.Snowflake +import org.dreamexposure.discal.core.annotations.SecurityRequirement +import org.dreamexposure.discal.core.business.AnnouncementService +import org.dreamexposure.discal.core.`object`.new.Announcement +import org.dreamexposure.discal.core.`object`.new.security.Scope +import org.springframework.web.bind.annotation.* -@RestController("v3/guilds/{guildId}/announcements") -class AnnouncementController { +@RestController +@RequestMapping("/v3/guilds/{guildId}/announcements") +class AnnouncementController( + private val announcementService: AnnouncementService, +) { + // TODO: Need way to check if authenticated user has access to the guild... + @SecurityRequirement(scopes = [Scope.ANNOUNCEMENT_WRITE]) + @PostMapping(produces = ["application/json"], consumes = ["application/json"]) + suspend fun createAnnouncement(@PathVariable guildId: Snowflake, @RequestBody announcement: Announcement): Announcement { + return announcementService.createAnnouncement(announcement) + } + + @SecurityRequirement(scopes = [Scope.ANNOUNCEMENT_READ]) + @GetMapping(produces = ["application/json"]) + suspend fun getAllAnnouncements(@PathVariable guildId: Snowflake): List { + return announcementService.getAllAnnouncements(guildId) + } + + @SecurityRequirement(scopes = [Scope.ANNOUNCEMENT_READ]) + @GetMapping("/{announcementId}") + suspend fun getAnnouncement(@PathVariable guildId: Snowflake, @PathVariable announcementId: String): Announcement? { + return announcementService.getAnnouncement(guildId, announcementId) + } + + @SecurityRequirement(scopes = [Scope.ANNOUNCEMENT_WRITE]) + @PatchMapping("/{announcementId}", produces = ["application/json"], consumes = ["application/json"]) + suspend fun patchAnnouncement(@PathVariable guildId: Snowflake, @PathVariable announcementId: String, @RequestBody announcement: Announcement) { + announcementService.updateAnnouncement(announcement) + } + + @SecurityRequirement(scopes = [Scope.ANNOUNCEMENT_WRITE]) + @DeleteMapping("/{announcementId}") + suspend fun deleteAnnouncement(@PathVariable guildId: Snowflake, @PathVariable announcementId: String) { + announcementService.deleteAnnouncement(guildId, announcementId) + } } diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/RsvpController.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/RsvpController.kt index c995a06b1..ba1f8712f 100644 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/RsvpController.kt +++ b/server/src/main/kotlin/org/dreamexposure/discal/server/endpoints/v3/RsvpController.kt @@ -12,11 +12,11 @@ import org.springframework.web.bind.annotation.* class RsvpController( private val rsvpService: RsvpService, ) { + // TODO: Need way to check if authenticated user has access to the guild... @SecurityRequirement(scopes = [Scope.EVENT_RSVP_READ]) @GetMapping(produces = ["application/json"]) suspend fun getRsvp(@PathVariable guildId: Snowflake, @PathVariable eventId: String): Rsvp { - // TODO: Need way to check if authenticated user has access to this guild return rsvpService.getRsvp(guildId, eventId) } @@ -24,7 +24,6 @@ class RsvpController( @SecurityRequirement(scopes = [Scope.EVENT_RSVP_WRITE]) @PatchMapping(produces = ["application/json"], consumes = ["application/json"]) suspend fun patchRsvp(@PathVariable guildId: Snowflake, @PathVariable eventId: String, @RequestBody rsvp: Rsvp): Rsvp { - // TODO: Need a way to check if authenticated user has access to this guild return rsvpService.updateRsvp(rsvp) } } diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/NetworkManager.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/NetworkManager.kt index bdd62e1e3..21c916762 100644 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/NetworkManager.kt +++ b/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/NetworkManager.kt @@ -1,8 +1,10 @@ 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.database.DatabaseManager 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 @@ -13,13 +15,16 @@ import org.springframework.boot.ApplicationRunner import org.springframework.stereotype.Component import reactor.core.publisher.Flux import reactor.core.publisher.Mono -import reactor.function.TupleUtils 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 : ApplicationRunner { +class NetworkManager( + private val calendarService: CalendarService, + private val announcementService: AnnouncementService, +) : ApplicationRunner { private val status: NetworkData = NetworkData(apiStatus = InstanceData()) fun getStatus() = status.copy() @@ -55,16 +60,12 @@ class NetworkManager : ApplicationRunner { status.botStatus.sortWith(Comparator.comparingInt(BotInstanceData::shardIndex)) } - private fun updateAndReturnStatus(): Mono { - return Mono.zip(DatabaseManager.getCalendarCount(), DatabaseManager.getAnnouncementCount()).map( - TupleUtils.function { calCount, annCount -> - status.totalCalendars = calCount - status.totalAnnouncements = annCount - status.apiStatus = status.apiStatus.copy(lastHeartbeat = Instant.now(), uptime = Application.getUptime()) + 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() - } - ) + status.copy() } private fun doRestartBot(bot: BotInstanceData): Mono { @@ -108,3 +109,4 @@ class NetworkManager : ApplicationRunner { }.subscribe() } } +K From 3a15a7414dfc1efd65967a91069e58b492c13951 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Mon, 4 Mar 2024 13:43:48 -0600 Subject: [PATCH 13/43] Lmao last minute typo made it into the commit --- .../dreamexposure/discal/server/network/discal/NetworkManager.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/NetworkManager.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/NetworkManager.kt index 21c916762..30a1e2f69 100644 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/NetworkManager.kt +++ b/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/NetworkManager.kt @@ -109,4 +109,3 @@ class NetworkManager( }.subscribe() } } -K From f6dee006fb50c43693c9383b103e95984231b8d3 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Mon, 4 Mar 2024 14:16:12 -0600 Subject: [PATCH 14/43] Refactor time command to new patterns, also add Discord timestamp --- .../client/commands/global/TimeCommand.kt | 24 +++++++++----- .../client/message/embed/CalendarEmbed.kt | 31 ------------------ .../discal/core/business/EmbedService.kt | 17 +++++++++- .../discal/core/enums/time/TimeFormat.kt | 32 +++++++++++++++---- .../core/extensions/InstantExtension.kt | 4 +++ .../main/resources/i18n/embed/time.properties | 1 + 6 files changed, 62 insertions(+), 47 deletions(-) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/TimeCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/TimeCommand.kt index 51fec260d..bc9ad7f92 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/TimeCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/TimeCommand.kt @@ -5,15 +5,19 @@ import discord4j.core.`object`.command.ApplicationCommandInteractionOption import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue import discord4j.core.`object`.entity.Message import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull import org.dreamexposure.discal.client.commands.SlashCommand -import org.dreamexposure.discal.client.message.embed.CalendarEmbed -import org.dreamexposure.discal.core.extensions.discord4j.followupEphemeral +import org.dreamexposure.discal.core.business.EmbedService +import org.dreamexposure.discal.core.extensions.discord4j.followup +import org.dreamexposure.discal.core.extensions.discord4j.getCalendar import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.utils.getCommonMsg import org.springframework.stereotype.Component @Component -class TimeCommand : SlashCommand { +class TimeCommand( + private val embedService: EmbedService, +) : SlashCommand { override val name = "time" override val ephemeral = true @@ -24,10 +28,14 @@ class TimeCommand : SlashCommand { .map(Long::toInt) .orElse(1) - return event.interaction.guild.flatMap { guild -> - CalendarEmbed.time(guild, settings, calendarNumber).flatMap { - event.followupEphemeral(it) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.calendar", settings))).awaitSingle() + + val calendar = event.interaction.guild.flatMap { + it.getCalendar(calendarNumber) + }.awaitSingleOrNull() + if (calendar == null) { + return event.followup(getCommonMsg("error.notFound.calendar", settings)).awaitSingle() + } + + return event.followup(embedService.calendarTimeEmbed(calendar, settings)).awaitSingle() } } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/CalendarEmbed.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/CalendarEmbed.kt index 40a16a6db..be62a8615 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/CalendarEmbed.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/CalendarEmbed.kt @@ -3,8 +3,6 @@ package org.dreamexposure.discal.client.message.embed import discord4j.core.`object`.entity.Guild import discord4j.core.spec.EmbedCreateSpec import org.dreamexposure.discal.core.entities.Calendar -import org.dreamexposure.discal.core.enums.time.TimeFormat -import org.dreamexposure.discal.core.extensions.discord4j.getCalendar import org.dreamexposure.discal.core.extensions.embedDescriptionSafe import org.dreamexposure.discal.core.extensions.embedFieldSafe import org.dreamexposure.discal.core.extensions.embedTitleSafe @@ -13,9 +11,6 @@ import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.`object`.calendar.PreCalendar import org.dreamexposure.discal.core.utils.GlobalVal.discalColor import org.dreamexposure.discal.core.utils.getCommonMsg -import reactor.core.publisher.Mono -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter object CalendarEmbed : EmbedMaker { @@ -38,32 +33,6 @@ object CalendarEmbed : EmbedMaker { .build() } - fun time(guild: Guild, settings: GuildSettings, calNumber: Int): Mono { - return guild.getCalendar(calNumber).map { cal -> - val ldt = LocalDateTime.now(cal.timezone) - - val fmt: DateTimeFormatter = - if (settings.timeFormat == TimeFormat.TWELVE_HOUR) - DateTimeFormatter.ofPattern("yyyy/MM/dd hh:mm:ss a") - else - DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss") - - - val correctTime = fmt.format(ldt) - val builder = defaultBuilder(guild, settings) - - builder.title(getMessage("time", "embed.title", settings)) - builder.addField(getMessage("time", "embed.field.current", settings), correctTime, false) - builder.addField(getMessage("time", "embed.field.timezone", settings), cal.zoneName, false) - builder.footer(getMessage("time", "embed.footer", settings), null) - builder.url(cal.link) - - builder.color(discalColor) - - builder.build() - } - } - fun pre(guild: Guild, settings: GuildSettings, preCal: PreCalendar): EmbedCreateSpec { val builder = defaultBuilder(guild, settings) .title(getMessage("calendar", "wizard.title", settings)) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt index 3f7a93261..e80e68d4a 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt @@ -202,6 +202,21 @@ class EmbedService( .build() } + suspend fun calendarTimeEmbed(calendar: Calendar, settings: GuildSettings): EmbedCreateSpec { + val formattedTime = Instant.now().humanReadableFullSimple(calendar.timezone, settings.timeFormat) + val formattedLocal = Instant.now().asDiscordTimestamp(DiscordTimestampFormat.SHORT_DATETIME) + + return defaultEmbedBuilder(settings) + .title(getEmbedMessage("time", "embed.title", settings)) + .addField(getEmbedMessage("time", "embed.field.current", settings), formattedTime, true) + .addField(getEmbedMessage("time", "embed.field.timezone", settings), calendar.zoneName, true) + .addField(getEmbedMessage("time", "embed.field.local", settings), formattedLocal, false) + .footer(getEmbedMessage("time", "embed.footer", settings), null) + .url(calendar.link) + .color(GlobalVal.discalColor) + .build() + } + ///////////////////////// ////// RSVP Embeds ////// ///////////////////////// @@ -313,7 +328,7 @@ class EmbedService( ////// Announcement Embeds ////// ///////////////////////////////// suspend fun determineAnnouncementEmbed(announcement: Announcement, event: Event, settings: GuildSettings): EmbedCreateSpec { - return when(settings.announcementStyle) { + return when (settings.announcementStyle) { AnnouncementStyle.FULL -> fullAnnouncementEmbed(announcement, event, settings) AnnouncementStyle.SIMPLE -> simpleAnnouncementEmbed(announcement, event, settings) AnnouncementStyle.EVENT -> eventAnnouncementEmbed(announcement, event, settings) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/enums/time/TimeFormat.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/enums/time/TimeFormat.kt index 46f410bdc..30eec6db0 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/enums/time/TimeFormat.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/enums/time/TimeFormat.kt @@ -9,15 +9,33 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder @Serializable(with = TimeFormatAsIntSerializer::class) -enum class TimeFormat(val value: Int = 1, val full: String, val date: String, val longDate: String, val time: String, val dayOfWeek: String) { - TWENTY_FOUR_HOUR(1, "LLLL dd yyyy '@' HH:mm", "yyyy/MM/dd", "dd LLLL yyyy", "HH:mm", "EEEE '-' dd LLLL yyyy"), - TWELVE_HOUR(2, "LLLL dd yyyy '@' hh:mm a", "yyyy/MM/dd", "dd LLLL yyyy", "hh:mm a", "EEEE '-' dd LLLL yyyy"); +enum class TimeFormat( + val value: Int, + val fullSimple: String, + val full: String, + val date: String, + val longDate: String, + val time: String, + val dayOfWeek: String +) { + TWENTY_FOUR_HOUR( + value = 1, + fullSimple = "yyyy/MM/dd hh:mm:ss", + full = "LLLL dd yyyy '@' HH:mm", + date = "yyyy/MM/dd", + longDate = "dd LLLL yyyy", + time = "HH:mm", + dayOfWeek = "EEEE '-' dd LLLL yyyy"), + TWELVE_HOUR( + value = 2, + fullSimple = "yyyy/MM/dd hh:mm:ss a", + full = "LLLL dd yyyy '@' hh:mm a", + date = "yyyy/MM/dd", + longDate = "dd LLLL yyyy", + time = "hh:mm a", + dayOfWeek = "EEEE '-' dd LLLL yyyy"); companion object { - fun isValid(i: Int): Boolean { - return i == 1 || i == 2 - } - fun fromValue(i: Int): TimeFormat { return when (i) { 1 -> TWENTY_FOUR_HOUR diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/InstantExtension.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/InstantExtension.kt index 5b730f1c0..70222a527 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/InstantExtension.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/extensions/InstantExtension.kt @@ -12,6 +12,10 @@ fun Instant.humanReadableFull(timezone: ZoneId, format: TimeFormat): String { return DateTimeFormatter.ofPattern(format.full).withZone(timezone).format(this) } +fun Instant.humanReadableFullSimple(timezone: ZoneId, format: TimeFormat): String { + return DateTimeFormatter.ofPattern(format.fullSimple).withZone(timezone).format(this) +} + fun Instant.humanReadableDate(timezone: ZoneId, format: TimeFormat, long: Boolean = true, longDay: Boolean = false): String { return if (long && longDay) DateTimeFormatter.ofPattern(format.dayOfWeek).withZone(timezone).format(this) else if (long) DateTimeFormatter.ofPattern(format.longDate).withZone(timezone).format(this) diff --git a/core/src/main/resources/i18n/embed/time.properties b/core/src/main/resources/i18n/embed/time.properties index 97f975d3c..be21e8513 100644 --- a/core/src/main/resources/i18n/embed/time.properties +++ b/core/src/main/resources/i18n/embed/time.properties @@ -1,4 +1,5 @@ embed.title=Current Calendar Time embed.field.current=Current Time embed.field.timezone=Time Zone +embed.field.local=Current Time (local) embed.footer=This is the current time for this calendar From 74d540d7bc79ac8bd2c238ad5aced06ad150b916 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Mon, 4 Mar 2024 14:47:27 -0600 Subject: [PATCH 15/43] Update Jib and add image digest --- gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 6860a67f4..e5c777e8c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ kotlinVersion=1.9.10 # Plugins springDependencyManagementVersion=1.1.3 -jibVersion=3.4.0 +jibVersion=3.4.1 gitPropertiesVersion=2.4.1 # Buildscript tooling @@ -38,4 +38,4 @@ copyDownVersion=1.1 jsoupVersion=1.16.1 # Jib properties -baseImage=eclipse-temurin:17-jdk-alpine +baseImage=eclipse-temurin:17-jdk-alpine@sha256:0e6e494ac4da6509a038b7689250bc7ea68beaf8a5efbca5ed7c8692457b283c From 8562223327a6819a829a145de5a74be6a2f4f255 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Mon, 4 Mar 2024 15:12:26 -0600 Subject: [PATCH 16/43] Set jib output to plain mode --- .github/workflows/gradle.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 69746d09d..8c5238293 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -82,7 +82,7 @@ jobs: SCW_USER: ${{ secrets.SCW_USER }} SCW_SECRET: ${{ secrets.SCW_SECRET }} with: - command: ./gradlew jib -Djib.to.auth.username=${SCW_USER} -Djib.to.auth.password=${SCW_SECRET} + command: ./gradlew jib -Djib.to.auth.username=${SCW_USER} -Djib.to.auth.password=${SCW_SECRET} -Djib.console=plain attempt_limit: 25 # 1 minute in ms attempt_delay: 60000 From e7df77ab4365b1fa082841d00b2122c2a0df94ad Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Mon, 4 Mar 2024 15:27:02 -0600 Subject: [PATCH 17/43] Refactor help command lol --- .../discal/client/commands/global/HelpCommand.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/HelpCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/HelpCommand.kt index 7d1f398d1..3d2bc45e2 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/HelpCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/HelpCommand.kt @@ -2,22 +2,21 @@ package org.dreamexposure.discal.client.commands.global import discord4j.core.event.domain.interaction.ChatInputInteractionEvent import discord4j.core.`object`.entity.Message +import kotlinx.coroutines.reactor.awaitSingle import org.dreamexposure.discal.client.commands.SlashCommand import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.extensions.discord4j.followupEphemeral import org.dreamexposure.discal.core.`object`.GuildSettings import org.springframework.stereotype.Component -import reactor.core.publisher.Mono @Component class HelpCommand : SlashCommand { override val name = "help" override val ephemeral = true - @Deprecated("Use new handleSuspend for K-coroutines") - override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + override suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message { return event.followupEphemeral( getMessage("error.workInProgress", settings, "${Config.URL_BASE.getString()}/commands") - ) + ).awaitSingle() } } From adecb44e995e445fe3079e022d32e0bf333c229c Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Mon, 4 Mar 2024 18:07:22 -0600 Subject: [PATCH 18/43] Trying to see if missing scope is why this is failing to log metrics --- .../business/cronjob/AnnouncementCronJob.kt | 22 +++++++++++-------- .../business/cronjob/StatusUpdateCronJob.kt | 3 ++- .../discal/core/business/MetricService.kt | 2 +- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/AnnouncementCronJob.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/AnnouncementCronJob.kt index a2add01ce..10ae11eb3 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/AnnouncementCronJob.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/AnnouncementCronJob.kt @@ -38,17 +38,21 @@ class AnnouncementCronJob( val taskTimer = StopWatch() taskTimer.start() - val guilds = discordClient.guilds.collectList().awaitSingle() + try { + val guilds = discordClient.guilds.collectList().awaitSingle() - guilds.forEach { guild -> - try { - announcementService.processAnnouncementsForGuild(guild.id, maxDifference) - } catch (ex: Exception) { - LOGGER.error("Failed to process announcements for guild | guildId:${guild.id.asLong()}", ex) + guilds.forEach { guild -> + try { + announcementService.processAnnouncementsForGuild(guild.id, maxDifference) + } catch (ex: Exception) { + LOGGER.error("Failed to process announcements for guild | guildId:${guild.id.asLong()}", ex) + } } + } catch (ex: Exception) { + LOGGER.error("Failed to process announcements for all guilds", ex) + } finally { + taskTimer.stop() + metricService.recordAnnouncementTaskDuration("overall", taskTimer.totalTimeMillis) } - - taskTimer.stop() - metricService.recordAnnouncementTaskDuration("overall", taskTimer.totalTimeMillis) } } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StatusUpdateCronJob.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StatusUpdateCronJob.kt index 4ba90ab6f..2cbea2f79 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StatusUpdateCronJob.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StatusUpdateCronJob.kt @@ -3,6 +3,7 @@ package org.dreamexposure.discal.client.business.cronjob import discord4j.core.GatewayDiscordClient import discord4j.core.`object`.presence.ClientActivity import discord4j.core.`object`.presence.ClientPresence +import io.micrometer.core.instrument.Tag import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.reactor.mono import org.dreamexposure.discal.Application @@ -84,6 +85,6 @@ class StatusUpdateCronJob( discordClient.updatePresence(ClientPresence.online(ClientActivity.playing(status))).awaitSingleOrNull() taskTimer.stop() - metricService.recordTaskDuration("status_update", duration = taskTimer.totalTimeMillis) + metricService.recordTaskDuration("status_update", listOf(Tag.of("scope", "cron_job")), taskTimer.totalTimeMillis) } } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/MetricService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/MetricService.kt index ea58cf9db..7b7d70c23 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/MetricService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/MetricService.kt @@ -33,7 +33,7 @@ class MetricService( } fun recordStaticMessageTaskDuration(scope: String, duration: Long) { - recordTaskDuration("static-message", listOf(Tag.of("scope", scope)), duration) + recordTaskDuration("static_message", listOf(Tag.of("scope", scope)), duration) } fun incrementStaticMessagesUpdated(type: StaticMessage.Type) { From 1ad42e364c21b914d6f161229843fced679f5b81 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Fri, 8 Mar 2024 12:45:57 -0600 Subject: [PATCH 19/43] Refactor announcement command --- .../commands/global/AnnouncementCommand.kt | 965 +++++++++--------- .../discal/client/config/DisCalConfig.kt | 4 - .../client/message/embed/AnnouncementEmbed.kt | 138 --- .../core/business/AnnouncementService.kt | 121 ++- .../discal/core/business/EmbedService.kt | 132 ++- .../discal/core/config/CacheConfig.kt | 10 + .../discal/core/config/Config.kt | 1 + .../discal/core/database/DatabaseManager.kt | 307 ------ .../discal/core/object/Wizard.kt | 1 + .../core/object/announcement/Announcement.kt | 97 -- .../object/announcement/AnnouncementCache.kt | 12 - .../AnnouncementCreatorResponse.kt | 6 - .../discal/core/object/command/CommandInfo.kt | 9 - .../discal/core/object/new/Announcement.kt | 39 +- .../discal/core/object/new/WizardState.kt | 10 + .../discal/core/object/web/WebGuild.kt | 18 +- .../org/dreamexposure/discal/typealiases.kt | 1 + .../commands/global/announcement.json | 26 +- .../announcement/announcement.properties | 11 +- .../i18n/embed/announcement.properties | 8 +- 20 files changed, 790 insertions(+), 1126 deletions(-) delete mode 100644 client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/AnnouncementEmbed.kt delete mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/object/announcement/Announcement.kt delete mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/object/announcement/AnnouncementCache.kt delete mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/object/announcement/AnnouncementCreatorResponse.kt delete mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/object/command/CommandInfo.kt create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/object/new/WizardState.kt diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt index e75c9c95d..bf71c3ed0 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt @@ -3,37 +3,35 @@ package org.dreamexposure.discal.client.commands.global import discord4j.core.event.domain.interaction.ChatInputInteractionEvent import discord4j.core.`object`.command.ApplicationCommandInteractionOption import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue -import discord4j.core.`object`.entity.Member import discord4j.core.`object`.entity.Message -import discord4j.core.`object`.entity.channel.MessageChannel -import discord4j.core.spec.InteractionFollowupCreateSpec import discord4j.rest.util.AllowedMentions +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull import org.dreamexposure.discal.client.commands.SlashCommand -import org.dreamexposure.discal.client.message.embed.AnnouncementEmbed +import org.dreamexposure.discal.core.business.AnnouncementService +import org.dreamexposure.discal.core.business.EmbedService import org.dreamexposure.discal.core.crypto.KeyGenerator -import org.dreamexposure.discal.core.database.DatabaseManager -import org.dreamexposure.discal.core.enums.announcement.AnnouncementType import org.dreamexposure.discal.core.enums.event.EventColor import org.dreamexposure.discal.core.extensions.discord4j.followup import org.dreamexposure.discal.core.extensions.discord4j.followupEphemeral import org.dreamexposure.discal.core.extensions.discord4j.getCalendar import org.dreamexposure.discal.core.extensions.discord4j.hasControlRole -import org.dreamexposure.discal.core.extensions.messageContentSafe import org.dreamexposure.discal.core.`object`.GuildSettings -import org.dreamexposure.discal.core.`object`.Wizard -import org.dreamexposure.discal.core.`object`.announcement.Announcement +import org.dreamexposure.discal.core.`object`.new.Announcement +import org.dreamexposure.discal.core.`object`.new.WizardState import org.dreamexposure.discal.core.utils.getCommonMsg import org.springframework.stereotype.Component -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono +import kotlin.jvm.optionals.getOrNull @Component -class AnnouncementCommand(val wizard: Wizard) : SlashCommand { +class AnnouncementCommand( + private val announcementService: AnnouncementService, + private val embedService: EmbedService, +) : SlashCommand { override val name = "announcement" override val ephemeral = true - @Deprecated("Use new handleSuspend for K-coroutines") - override fun handle(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + override suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message { return when (event.options[0].name) { "create" -> create(event, settings) "type" -> type(event, settings) @@ -56,122 +54,153 @@ class AnnouncementCommand(val wizard: Wizard) : SlashCommand { "list" -> list(event, settings) "subscribe" -> subscribe(event, settings) "unsubscribe" -> unsubscribe(event, settings) - else -> Mono.empty() // Never can reach this, makes compiler happy. + else -> throw IllegalStateException("Invalid subcommand specified") } } - private fun create(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun create(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val type = event.options[0].getOption("type") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) - .map(AnnouncementType.Companion::fromValue) - .orElse(AnnouncementType.UNIVERSAL) - - val channelMono = event.options[0].getOption("channel") + .map(Announcement.Type::valueOf) + .orElse(Announcement.Type.UNIVERSAL) + val channelId = event.options[0].getOption("channel") .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asChannel) - .map { it.ofType(MessageChannel::class.java) } - .orElse(event.interaction.channel) - + .map(ApplicationCommandInteractionOptionValue::asSnowflake) + .orElse(event.interaction.channelId) val minutes = event.options[0].getOption("minutes") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) .map(Long::toInt) .orElse(0) - val hours = event.options[0].getOption("hours") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) .map(Long::toInt) .orElse(0) - val calendar = event.options[0].getOption("calendar") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) .map(Long::toInt) .orElse(1) - return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { - if (wizard.get(settings.guildID) == null) { - channelMono.flatMap { channel -> - val pre = Announcement(settings.guildID, - type = type, - announcementChannelId = channel.id.asString(), - minutesBefore = minutes, - hoursBefore = hours, - calendarNumber = calendar - ) - wizard.start(pre) - - event.interaction.guild - .map { AnnouncementEmbed.pre(it, pre, settings) } - .flatMap { event.followupEphemeral(getMessage("create.success", settings), it) } - } - } else { - event.interaction.guild - .map { AnnouncementEmbed.pre(it, wizard.get(settings.guildID)!!, settings) } - .flatMap { event.followup(getMessage("error.wizard.started", settings), it) } - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) + // Validate permissions + val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle() + if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle() + + // Check if wizard already started + val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id) + if (existingWizard != null) { + return event.createFollowup(getMessage("error.wizard.started", settings)) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(existingWizard, settings)) + .awaitSingle() + } + + val newWizard = WizardState( + guildId = settings.guildID, + userId = event.interaction.user.id, + editing = false, + entity = Announcement( + guildId = settings.guildID, + calendarNumber = calendar, + type = type, + channelId = channelId, + hoursBefore = hours, + minutesBefore = minutes, + ) + ) + announcementService.putWizard(newWizard) + + return event.createFollowup(getMessage("create.success", settings)) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(newWizard, settings)) + .awaitSingle() } - private fun type(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun type(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val type = event.options[0].getOption("type") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) - .map(AnnouncementType.Companion::fromValue) + .map(Announcement.Type::valueOf) .get() - return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { - val pre = wizard.get(settings.guildID) - if (pre != null) { - pre.type = type + // Validate permissions + val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle() + if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle() - // Handle edge case where event is already set, but has recurrence suffix. - pre.eventId = pre.eventId.split("_")[0] + // Check if wizard not started + val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id) + ?: return event.createFollowup(getMessage("error.wizard.notStarted", settings)) + .withEphemeral(ephemeral) + .awaitSingle() - event.interaction.guild - .map { AnnouncementEmbed.pre(it, pre, settings) } - .flatMap { event.followupEphemeral(getMessage("type.success", settings), it) } - } else { - event.followupEphemeral(getMessage("error.wizard.notStarted", settings)) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) + val altered = existingWizard.copy( + entity = existingWizard.entity.copy( + type = type, + // Handle edge case where event is already set, but has recurrence suffix. + eventId = existingWizard.entity.eventId?.split("_")?.get(0) + ) + ) + announcementService.putWizard(altered) + + return event.createFollowup(getMessage("type.success", settings)) + .withEphemeral(true) + .withEmbeds(embedService.announcementWizardEmbed(altered, settings)) + .awaitSingle() } - private fun event(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun event(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val eventId = event.options[0].getOption("event") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) .get() - return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { - val pre = wizard.get(settings.guildID) - if (pre != null) { - if (pre.type == AnnouncementType.RECUR || pre.type == AnnouncementType.SPECIFIC) { - event.interaction.guild - .flatMap { it.getCalendar(pre.calendarNumber) } - .flatMap { it.getEvent(eventId) } - .flatMap { calEvent -> - if (pre.type == AnnouncementType.RECUR) pre.eventId = calEvent.eventId.split("_")[0] - else pre.eventId = eventId - - event.interaction.guild - .map { AnnouncementEmbed.pre(it, pre, settings) } - .flatMap { event.followupEphemeral(getMessage("event.success", settings), it) } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.event", settings))) - } else { - event.interaction.guild - .map { AnnouncementEmbed.pre(it, pre, settings) } - .flatMap { event.followupEphemeral(getMessage("event.failure.type", settings), it) } - } - } else { - event.followupEphemeral(getMessage("error.wizard.notStarted", settings)) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) + // Validate permissions + val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle() + if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle() + + // Check if wizard not started + val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id) + ?: return event.createFollowup(getMessage("error.wizard.notStarted", settings)) + .withEphemeral(ephemeral) + .awaitSingle() + val announcement = existingWizard.entity + + // Validate current type + if (announcement.type != Announcement.Type.RECUR && announcement.type != Announcement.Type.SPECIFIC) { + return event.createFollowup(getMessage("event.failure.type", settings)) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(existingWizard, settings)) + .awaitSingle() + } + + // Validate event actually exists + val calendarEvent = event.interaction.guild + .flatMap { it.getCalendar(announcement.calendarNumber) } + .flatMap { it.getEvent(eventId) } + .awaitSingleOrNull() + if (calendarEvent == null) { + return event.createFollowup(getCommonMsg("error.notFound.event", settings)) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(existingWizard, settings)) + .awaitSingle() + } + + // Handle what format the ID is actually saved in + val idToSet = if (announcement.type == Announcement.Type.RECUR) calendarEvent.eventId.split("_")[0] + else eventId + + val alteredWizard = existingWizard.copy(entity = announcement.copy(eventId = idToSet)) + announcementService.putWizard(alteredWizard) + + return event.createFollowup(getMessage("event.success", settings)) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(alteredWizard, settings)) + .awaitSingle() } - private fun color(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun color(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val color = event.options[0].getOption("color") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) @@ -179,501 +208,521 @@ class AnnouncementCommand(val wizard: Wizard) : SlashCommand { .map(EventColor.Companion::fromId) .get() - return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { - val pre = wizard.get(settings.guildID) - if (pre != null) { - if (pre.type == AnnouncementType.COLOR) { - pre.eventColor = color - - event.interaction.guild - .map { AnnouncementEmbed.pre(it, pre, settings) } - .flatMap { event.followupEphemeral(getMessage("color.success", settings), it) } - } else { - event.interaction.guild - .map { AnnouncementEmbed.pre(it, pre, settings) } - .flatMap { event.followupEphemeral(getMessage("color.failure.type", settings), it) } - } - } else { - event.followupEphemeral(getMessage("error.wizard.notStarted", settings)) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) + // Validate permissions + val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle() + if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle() + + // Check if wizard not started + val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id) + ?: return event.createFollowup(getMessage("error.wizard.notStarted", settings)) + .withEphemeral(ephemeral) + .awaitSingle() + + // Make sure type matches + if (existingWizard.entity.type != Announcement.Type.COLOR) { + return event.createFollowup(getMessage("color.failure.type", settings)) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(existingWizard, settings)) + .awaitSingle() + } + + val alteredWizard = existingWizard.copy(entity = existingWizard.entity.copy(eventColor = color)) + announcementService.putWizard(alteredWizard) + + return event.createFollowup(getMessage("color.success", settings)) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(alteredWizard, settings)) + .awaitSingle() } - private fun channel(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { - val channelMono = event.options[0].getOption("channel") + private suspend fun channel(event: ChatInputInteractionEvent, settings: GuildSettings): Message { + val channelId = event.options[0].getOption("channel") .flatMap(ApplicationCommandInteractionOption::getValue) - .map(ApplicationCommandInteractionOptionValue::asChannel) - .map { it.ofType(MessageChannel::class.java) } - .orElse(event.interaction.channel) - - return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { - val pre = wizard.get(settings.guildID) - if (pre != null) { - channelMono.flatMap { channel -> - pre.announcementChannelId = channel.id.asString() - - event.interaction.guild - .map { AnnouncementEmbed.pre(it, pre, settings) } - .flatMap { event.followupEphemeral(getMessage("channel.success", settings), it) } - } - } else { - event.followupEphemeral(getMessage("error.wizard.notStarted", settings)) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) + .map(ApplicationCommandInteractionOptionValue::asSnowflake) + .orElse(event.interaction.channelId) + + // Validate permissions + val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle() + if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle() + + // Check if wizard not started + val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id) + ?: return event.createFollowup(getMessage("error.wizard.notStarted", settings)) + .withEphemeral(ephemeral) + .awaitSingle() + val announcement = existingWizard.entity + + val alteredWizard = existingWizard.copy(entity = announcement.copy(channelId = channelId)) + announcementService.putWizard(alteredWizard) + + return event.createFollowup(getMessage("channel.success", settings)) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(alteredWizard, settings)) + .awaitSingle() } - private fun minutes(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun minutes(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val minutes = event.options[0].getOption("minutes") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) .map(Long::toInt) .get() - return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { - val pre = wizard.get(settings.guildID) - if (pre != null) { - pre.minutesBefore = minutes - event.interaction.guild - .map { AnnouncementEmbed.pre(it, pre, settings) } - .flatMap { event.followupEphemeral(getMessage("minutes.success", settings), it) } - } else { - event.followupEphemeral(getMessage("error.wizard.notStarted", settings)) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) + // Validate permissions + val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle() + if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle() + + // Check if wizard not started + val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id) + ?: return event.createFollowup(getMessage("error.wizard.notStarted", settings)) + .withEphemeral(ephemeral) + .awaitSingle() + val announcement = existingWizard.entity + + val alteredWizard = existingWizard.copy(entity = announcement.copy(minutesBefore = minutes)) + announcementService.putWizard(alteredWizard) + + return event.createFollowup(getMessage("minutes.success", settings)) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(alteredWizard, settings)) + .awaitSingle() } - private fun hours(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun hours(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val hours = event.options[0].getOption("hours") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) .map(Long::toInt) .orElse(0) - return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { - val pre = wizard.get(settings.guildID) - if (pre != null) { - pre.hoursBefore = hours - event.interaction.guild - .map { AnnouncementEmbed.pre(it, pre, settings) } - .flatMap { event.followupEphemeral(getMessage("hours.success", settings), it) } - } else { - event.followupEphemeral(getMessage("error.wizard.notStarted", settings)) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) + // Validate permissions + val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle() + if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle() + + // Check if wizard not started + val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id) + ?: return event.createFollowup(getMessage("error.wizard.notStarted", settings)) + .withEphemeral(ephemeral) + .awaitSingle() + val announcement = existingWizard.entity + + val alteredWizard = existingWizard.copy(entity = announcement.copy(hoursBefore = hours)) + announcementService.putWizard(alteredWizard) + + return event.createFollowup(getMessage("hours.success", settings)) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(alteredWizard, settings)) + .awaitSingle() } - private fun info(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun info(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val info = event.options[0].getOption("info") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) - .orElse("None") - - return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { - val pre = wizard.get(settings.guildID) - if (pre != null) { - pre.info = info - event.interaction.guild - .map { AnnouncementEmbed.pre(it, pre, settings) } - .flatMap { event.followupEphemeral(getMessage("info.success.set", settings), it) } - } else { - event.followupEphemeral(getMessage("error.wizard.notStarted", settings)) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) + .filter { it.isNotBlank() || !it.equals("None", true) } + .getOrNull() + + // Validate permissions + val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle() + if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle() + + // Check if wizard not started + val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id) + ?: return event.createFollowup(getMessage("error.wizard.notStarted", settings)) + .withEphemeral(ephemeral) + .awaitSingle() + val announcement = existingWizard.entity + + val alteredWizard = existingWizard.copy(entity = announcement.copy(info = info)) + announcementService.putWizard(alteredWizard) + + return event.createFollowup(getMessage("info.success.set", settings)) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(alteredWizard, settings)) + .awaitSingle() } - private fun calendar(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun calendar(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val calendar = event.options[0].getOption("calendar") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) .map(Long::toInt) .get() - return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { - val pre = wizard.get(settings.guildID) - if (pre != null) { - pre.calendarNumber = calendar - event.interaction.guild - .map { AnnouncementEmbed.pre(it, pre, settings) } - .flatMap { event.followupEphemeral(getMessage("calendar.success", settings), it) } - } else { - event.followupEphemeral(getMessage("error.wizard.notStarted", settings)) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) + // Validate permissions + val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle() + if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle() + + // Check if wizard not started + val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id) + ?: return event.createFollowup(getMessage("error.wizard.notStarted", settings)) + .withEphemeral(ephemeral) + .awaitSingle() + val announcement = existingWizard.entity + + val alteredWizard = existingWizard.copy(entity = announcement.copy(calendarNumber = calendar)) + announcementService.putWizard(alteredWizard) + + return event.createFollowup(getMessage("calendar.success", settings)) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(alteredWizard, settings)) + .awaitSingle() } - private fun publish(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun publish(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val publish = event.options[0].getOption("publish") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asBoolean) .get() - return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { - val pre = wizard.get(settings.guildID) - if (pre != null) { - if (settings.patronGuild) { - pre.publish = publish - event.interaction.guild - .map { AnnouncementEmbed.pre(it, pre, settings) } - .flatMap { event.followupEphemeral(getMessage("publish.success", settings), it) } - } else { - event.interaction.guild - .map { AnnouncementEmbed.pre(it, pre, settings) } - .flatMap { event.followupEphemeral(getCommonMsg("error.patronOnly", settings), it) } - } - } else { - event.followupEphemeral(getMessage("error.wizard.notStarted", settings)) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) + // Validate permissions + val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle() + if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle() + + // Check if wizard not started + val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id) + ?: return event.createFollowup(getMessage("error.wizard.notStarted", settings)) + .withEphemeral(ephemeral) + .awaitSingle() + val announcement = existingWizard.entity + + // Confirm guild has access to feature + if (!settings.patronGuild) { + return event.createFollowup(getCommonMsg("error.patronOnly", settings)) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(existingWizard, settings)) + .awaitSingle() + } + + val alteredWizard = existingWizard.copy(entity = announcement.copy(publish = publish)) + announcementService.putWizard(alteredWizard) + + return event.createFollowup(getMessage("publish.success", settings)) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(alteredWizard, settings)) + .awaitSingle() } - private fun review(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { - return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { - val pre = wizard.get(settings.guildID) - if (pre != null) { - event.interaction.guild - .map { AnnouncementEmbed.pre(it, pre, settings) } - .flatMap { event.followupEphemeral(it) } - } else { - event.followupEphemeral(getMessage("error.wizard.notStarted", settings)) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) + private suspend fun review(event: ChatInputInteractionEvent, settings: GuildSettings): Message { + // Validate permissions + val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle() + if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle() + + // Check if wizard not started + val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id) + ?: return event.createFollowup(getMessage("error.wizard.notStarted", settings)) + .withEphemeral(ephemeral) + .awaitSingle() + + return event.createFollowup() + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(existingWizard, settings)) + .awaitSingle() } - private fun confirm(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { - return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { - val pre = wizard.get(settings.guildID) - if (pre != null) { - if (pre.hasRequiredValues()) { - DatabaseManager.updateAnnouncement(pre).flatMap { success -> - if (success) { - // Close wizard - wizard.remove(settings.guildID) - - val msg = if (pre.editing) getMessage("confirm.success.edit", settings) - else getMessage("confirm.success.create", settings) - - event.interaction.guild.map { AnnouncementEmbed.view(pre, it, settings) }.flatMap { embed -> - event.interaction.channel.flatMap { - it.createMessage(msg).withEmbeds(embed) - .then(event.followupEphemeral(getCommonMsg("success.generic", settings))) - } - } - } else { - val msg = if (pre.editing) getMessage("confirm.failure.edit", settings) - else getMessage("confirm.failure.create", settings) - - event.interaction.guild - .map { AnnouncementEmbed.pre(it, pre, settings) } - .flatMap { event.followupEphemeral(msg, it) } - } - } - } else { - event.interaction.guild - .map { AnnouncementEmbed.pre(it, pre, settings) } - .flatMap { event.followupEphemeral(getMessage("confirm.failure.missing", settings), it) } - } - } else { - event.followupEphemeral(getMessage("error.wizard.notStarted", settings)) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) + private suspend fun confirm(event: ChatInputInteractionEvent, settings: GuildSettings): Message { + // Validate permissions + val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle() + if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle() + + // Check if wizard not started + val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id) + ?: return event.createFollowup(getMessage("error.wizard.notStarted", settings)) + .withEphemeral(ephemeral) + .awaitSingle() + val announcement = existingWizard.entity + + // Check if required values set + val failureReason = + if ((announcement.type == Announcement.Type.SPECIFIC || announcement.type == Announcement.Type.RECUR) && announcement.eventId.isNullOrBlank()) { + getMessage("confirm.failure.missing-event-id", settings) + } else if (announcement.type == Announcement.Type.COLOR && announcement.eventColor == EventColor.NONE) { + getMessage("confirm.failure.missing-event-color", settings) + } else if (announcement.getCalculatedTime().isZero) { + getMessage("confirm.failure.minimum-time", settings) + } else null + if (failureReason != null) { + return event.createFollowup(failureReason) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(existingWizard, settings)) + .awaitSingle() + } + + if (existingWizard.editing) announcementService.updateAnnouncement(announcement) + else announcementService.createAnnouncement(announcement) + announcementService.cancelWizard(settings.guildID, event.interaction.user.id) + + val message = if (existingWizard.editing) getMessage("confirm.success.edit", settings) + else getMessage("confirm.success.create", settings) + + return event.createFollowup(message) + .withEphemeral(false) + .withEmbeds(embedService.viewAnnouncementEmbed(announcement, settings)) + .awaitSingle() } - private fun cancel(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { - return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { - wizard.remove(settings.guildID) + private suspend fun cancel(event: ChatInputInteractionEvent, settings: GuildSettings): Message { + // Validate permissions + val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle() + if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle() + + announcementService.cancelWizard(settings.guildID, event.interaction.user.id) - event.followupEphemeral(getMessage("cancel.success", settings)) - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) + return event.createFollowup(getMessage("cancel.success", settings)) + .withEphemeral(ephemeral) + .awaitSingle() } - private fun edit(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun edit(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val announcementId = event.options[0].getOption("announcement") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) .get() - return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { - if (wizard.get(settings.guildID) == null) { - DatabaseManager.getAnnouncement(announcementId, settings.guildID).flatMap { ann -> - val pre = ann.copy(editing = true) - wizard.start(pre) - - event.interaction.guild - .map { AnnouncementEmbed.pre(it, pre, settings) } - .flatMap { event.followupEphemeral(getMessage("edit.success", settings), it) } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings))) - } else { - event.interaction.guild - .map { AnnouncementEmbed.pre(it, wizard.get(settings.guildID)!!, settings) } - .flatMap { event.followup(getMessage("error.wizard.started", settings), it) } - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) + // Validate permissions + val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle() + if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle() + + // Check if wizard already started + val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id) + if (existingWizard != null) { + return event.createFollowup(getMessage("error.wizard.started", settings)) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(existingWizard, settings)) + .awaitSingle() + } + + val announcement = announcementService.getAnnouncement(settings.guildID, announcementId) + ?: return event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings)).awaitSingle() + + val newWizard = WizardState( + guildId = settings.guildID, + userId = event.interaction.user.id, + editing = true, + entity = announcement + ) + announcementService.putWizard(newWizard) + + return event.createFollowup(getMessage("edit.success", settings)) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(newWizard, settings)) + .awaitSingle() } - private fun copy(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun copy(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val announcementId = event.options[0].getOption("announcement") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) .get() - return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { - if (wizard.get(settings.guildID) == null) { - DatabaseManager.getAnnouncement(announcementId, settings.guildID).flatMap { ann -> - val pre = ann.copy(id = KeyGenerator.generateAnnouncementId()) - wizard.start(pre) - - event.interaction.guild - .map { AnnouncementEmbed.pre(it, pre, settings) } - .flatMap { event.followupEphemeral(getMessage("copy.success", settings), it) } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings))) - } else { - event.interaction.guild - .map { AnnouncementEmbed.pre(it, wizard.get(settings.guildID)!!, settings) } - .flatMap { event.followup(getMessage("error.wizard.started", settings), it) } - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) + // Validate permissions + val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle() + if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle() + + // Check if wizard already started + val existingWizard = announcementService.getWizard(settings.guildID, event.interaction.user.id) + if (existingWizard != null) { + return event.createFollowup(getMessage("error.wizard.started", settings)) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(existingWizard, settings)) + .awaitSingle() + } + + val announcement = announcementService.getAnnouncement(settings.guildID, announcementId) + ?: return event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings)).awaitSingle() + + val newWizard = WizardState( + guildId = settings.guildID, + userId = event.interaction.user.id, + editing = false, + entity = announcement.copy(id = KeyGenerator.generateAnnouncementId()) + ) + announcementService.putWizard(newWizard) + + return event.createFollowup(getMessage("copy.success", settings)) + .withEphemeral(ephemeral) + .withEmbeds(embedService.announcementWizardEmbed(newWizard, settings)) + .awaitSingle() } - private fun delete(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun delete(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val announcementId = event.options[0].getOption("announcement") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) .get() - return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { - // Before we delete the announcement, if the wizard is editing it, we need to cancel the wizard - val pre = wizard.get(settings.guildID) - if (pre != null && pre.id == announcementId) wizard.remove(settings.guildID) + // Validate permissions + val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle() + if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle() - DatabaseManager.getAnnouncement(announcementId, settings.guildID).flatMap { announcement -> - DatabaseManager.deleteAnnouncement(announcement.id) - .then(event.followupEphemeral(getMessage("delete.success", settings))) - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings))) - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) + // If announcement is being edited, cancel the editor + announcementService.cancelWizard(settings.guildID, announcementId) + + announcementService.deleteAnnouncement(settings.guildID, announcementId) + + return event.createFollowup(getMessage("delete.success", settings)) + .withEphemeral(ephemeral) + .awaitSingle() } - private fun enable(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun enable(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val announcementId = event.options[0].getOption("announcement") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) .get() - val enabled = event.options[0].getOption("enabled") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asBoolean) .get() - return Mono.justOrEmpty(event.interaction.member).filterWhen(Member::hasControlRole).flatMap { - DatabaseManager.getAnnouncement(announcementId, settings.guildID).flatMap { announcement -> - announcement.enabled = enabled - - DatabaseManager.updateAnnouncement(announcement).flatMap { - if (enabled) { - event.interaction.guild - .map { AnnouncementEmbed.view(announcement, it, settings) } - .flatMap { event.followupEphemeral(getMessage("enable.success", settings), it) } - } else { - event.interaction.guild - .map { AnnouncementEmbed.view(announcement, it, settings) } - .flatMap { event.followupEphemeral(getMessage("disable.success", settings), it) } - } - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings))) - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.perms.privileged", settings))) + // Validate permissions + val hasControlRole = event.interaction.member.get().hasControlRole().awaitSingle() + if (!hasControlRole) return event.followup(getCommonMsg("error.perms.privileged", settings)).awaitSingle() + + val announcement = announcementService.getAnnouncement(settings.guildID, announcementId) + ?: return event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings)).awaitSingle() + + val new = announcement.copy(enabled = enabled) + announcementService.updateAnnouncement(new) + + val message = if (enabled) "enable.success" else "disable.success" + return event.createFollowup() + .withEphemeral(ephemeral) + .withContent("${getMessage(message, settings)}\n\n${announcement.subscribers.buildMentions()}") + .withEmbeds(embedService.viewAnnouncementEmbed(announcement, settings)) + .withAllowedMentions(AllowedMentions.suppressAll()) + .awaitSingle() } - private fun view(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun view(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val announcementId = event.options[0].getOption("announcement") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) .get() - return DatabaseManager.getAnnouncement(announcementId, settings.guildID).flatMap { announcement -> - return@flatMap event.interaction.guild.map { AnnouncementEmbed.view(announcement, it, settings) }.flatMap { embed -> - event.createFollowup(InteractionFollowupCreateSpec.builder() - .content(announcement.buildMentions().messageContentSafe()) - .addEmbed(embed) - .allowedMentions(AllowedMentions.suppressAll()) - .build() - ) - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings))) + val announcement = announcementService.getAnnouncement(settings.guildID, announcementId) + ?: return event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings)).awaitSingle() + + return event.createFollowup() + .withEphemeral(ephemeral) + .withEmbeds(embedService.viewAnnouncementEmbed(announcement, settings)) + .withContent(announcement.subscribers.buildMentions()) + .withAllowedMentions(AllowedMentions.suppressAll()) + .awaitSingle() } - private fun list(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun list(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val amount = event.options[0].getOption("amount") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) .map(Long::toInt) .get() - val calendar = event.options[0].getOption("calendar") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asLong) .map(Long::toInt) .orElse(1) - val showDisabled = event.options[0].getOption("show-disabled") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asBoolean) .orElse(false) - val type = event.options[0].getOption("type") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) - .map(AnnouncementType.Companion::fromValue) - - // Determine which db query to use. - val announcementsMono = if (!showDisabled) { - if (type.isPresent) { - DatabaseManager.getEnabledAnnouncements(settings.guildID, type.get()) - } else { - DatabaseManager.getEnabledAnnouncements(settings.guildID) - } + .map(Announcement.Type::valueOf) + .getOrNull() + + // Get filtered announcements + val announcements = announcementService.getAllAnnouncements(settings.guildID, type, showDisabled) + .filter { it.calendarNumber == calendar } + + return if (announcements.isEmpty()) { + event.followupEphemeral(getMessage("list.success.none", settings)).awaitSingle() + } else if (announcements.size == 1) { + event.createFollowup() + .withEphemeral(ephemeral) + .withEmbeds(embedService.viewAnnouncementEmbed(announcements[0], settings)) + .withContent(announcements[0].subscribers.buildMentions()) + .withAllowedMentions(AllowedMentions.suppressAll()) + .awaitSingle() } else { - if (type.isPresent) { - DatabaseManager.getAnnouncements(settings.guildID, type.get()) - } else { - DatabaseManager.getAnnouncements(settings.guildID) - } - } + val limit = if (amount > 0) amount.coerceAtMost(announcements.size) else announcements.size - return announcementsMono.map { it.filter { a -> a.calendarNumber == calendar } }.flatMap { announcements -> - if (announcements.isEmpty()) { - event.followupEphemeral(getMessage("list.success.none", settings)) - } else if (announcements.size == 1) { - event.interaction.guild.map { AnnouncementEmbed.view(announcements[0], it, settings) }.flatMap { - event.createFollowup(InteractionFollowupCreateSpec.builder() - .content(getMessage("list.success.one", settings)) - .addEmbed(it) - .allowedMentions(AllowedMentions.suppressAll()) - .build() - ) - } - } else { - val limit = if (amount > 0) amount.coerceAtMost(announcements.size) else announcements.size - val guildMono = event.interaction.guild.cache() - - val successMessage = event.followupEphemeral(getMessage("list.success.many", settings, "$limit")) - val condAns = guildMono.flatMapMany { guild -> - Flux.fromIterable(announcements.subList(0, limit)).flatMap { a -> - event.followupEphemeral(AnnouncementEmbed.condensed(a, guild, settings)) - } - } - - successMessage.then(condAns.last()) + val message = event.followupEphemeral(getMessage("list.success.many", settings, "$limit")).awaitSingle() + + announcements.subList(0, limit).forEach { announcement -> + event.followupEphemeral(embedService.condensedAnnouncementEmbed(announcement, settings)).awaitSingle() } + + message } + + } - private fun subscribe(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun subscribe(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val announcementId = event.options[0].getOption("announcement") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) .get() - - val subId = event.options[0].getOption("sub") + val userId = event.options[0].getOption("user") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asSnowflake) + .getOrNull() + val roleId = event.options[0].getOption("role") + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asSnowflake) + .getOrNull() - return DatabaseManager.getAnnouncement(announcementId, settings.guildID).flatMap { announcement -> - event.interaction.guild.flatMap { guild -> - if (subId.isPresent) { - val memberMono = guild.getMemberById(subId.get()).onErrorResume { Mono.empty() } - val roleMono = guild.getRoleById(subId.get()).onErrorResume { Mono.empty() } - - @Suppress("DuplicatedCode") - memberMono.flatMap { member -> - announcement.subscriberUserIds.remove(member.id.asString()) - announcement.subscriberUserIds.add(member.id.asString()) - - DatabaseManager.updateAnnouncement(announcement).flatMap { - event.createFollowup(InteractionFollowupCreateSpec.builder() - .content(getMessage("subscribe.success.other", settings, member.nicknameMention)) - .addEmbed(AnnouncementEmbed.view(announcement, guild, settings)) - .allowedMentions(AllowedMentions.suppressAll()) - .build() - ) - } - }.switchIfEmpty(roleMono.flatMap { role -> - announcement.subscriberRoleIds.remove(role.id.asString()) - announcement.subscriberRoleIds.add(role.id.asString()) - - DatabaseManager.updateAnnouncement(announcement).flatMap { - event.createFollowup(InteractionFollowupCreateSpec.builder() - .content(getMessage("subscribe.success.other", settings, role.mention)) - .addEmbed(AnnouncementEmbed.view(announcement, guild, settings)) - .allowedMentions(AllowedMentions.suppressAll()) - .build() - ) - } - }) - } else { - announcement.subscriberUserIds.remove(event.interaction.user.id.asString()) - announcement.subscriberUserIds.add(event.interaction.user.id.asString()) - - DatabaseManager.updateAnnouncement(announcement).flatMap { - event.followupEphemeral( - getMessage("subscribe.success.self", settings), - AnnouncementEmbed.view(announcement, guild, settings) - ) - } - } - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings))) + val announcement = announcementService.getAnnouncement(settings.guildID, announcementId) + ?: return event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings)).awaitSingle() + + var newSubs = announcement.subscribers + if (userId != null) newSubs = newSubs.copy(users = newSubs.users + userId) + if (roleId != null) newSubs = newSubs.copy(roles = newSubs.roles + roleId.asString()) + if (roleId == null && userId == null) newSubs = newSubs.copy(users = newSubs.users + event.interaction.user.id) + + val new = announcement.copy(subscribers = newSubs) + announcementService.updateAnnouncement(new) + + return event.createFollowup() + .withEphemeral(ephemeral) + .withContent("${getMessage("subscribe.success", settings)}\n\n${announcement.subscribers.buildMentions()}") + .withEmbeds(embedService.viewAnnouncementEmbed(announcement, settings)) + .withAllowedMentions(AllowedMentions.suppressAll()) + .awaitSingle() } - private fun unsubscribe(event: ChatInputInteractionEvent, settings: GuildSettings): Mono { + private suspend fun unsubscribe(event: ChatInputInteractionEvent, settings: GuildSettings): Message { val announcementId = event.options[0].getOption("announcement") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asString) .get() - - val subId = event.options[0].getOption("sub") + val userId = event.options[0].getOption("user") + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(ApplicationCommandInteractionOptionValue::asSnowflake) + .getOrNull() + val roleId = event.options[0].getOption("role") .flatMap(ApplicationCommandInteractionOption::getValue) .map(ApplicationCommandInteractionOptionValue::asSnowflake) + .getOrNull() - return DatabaseManager.getAnnouncement(announcementId, settings.guildID).flatMap { announcement -> - event.interaction.guild.flatMap { guild -> - if (subId.isPresent) { - val memberMono = guild.getMemberById(subId.get()).onErrorResume { Mono.empty() } - val roleMono = guild.getRoleById(subId.get()).onErrorResume { Mono.empty() } - - @Suppress("DuplicatedCode") - memberMono.flatMap { member -> - announcement.subscriberUserIds.remove(member.id.asString()) - - DatabaseManager.updateAnnouncement(announcement).flatMap { - event.createFollowup(InteractionFollowupCreateSpec.builder() - .content(getMessage("unsubscribe.success.other", settings, member.nicknameMention)) - .addEmbed(AnnouncementEmbed.view(announcement, guild, settings)) - .allowedMentions(AllowedMentions.suppressAll()) - .build() - ) - } - }.switchIfEmpty(roleMono.flatMap { role -> - announcement.subscriberRoleIds.remove(role.id.asString()) - - DatabaseManager.updateAnnouncement(announcement).flatMap { - event.createFollowup(InteractionFollowupCreateSpec.builder() - .content(getMessage("unsubscribe.success.other", settings, role.mention)) - .addEmbed(AnnouncementEmbed.view(announcement, guild, settings)) - .allowedMentions(AllowedMentions.suppressAll()) - .build() - ) - } - }) - } else { - announcement.subscriberUserIds.remove(event.interaction.user.id.asString()) - - DatabaseManager.updateAnnouncement(announcement).flatMap { - event.followupEphemeral( - getMessage("unsubscribe.success.self", settings), - AnnouncementEmbed.view(announcement, guild, settings) - ) - } - } - } - }.switchIfEmpty(event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings))) + val announcement = announcementService.getAnnouncement(settings.guildID, announcementId) + ?: return event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings)).awaitSingle() + + var newSubs = announcement.subscribers + if (userId != null) newSubs = newSubs.copy(users = newSubs.users - userId) + if (roleId != null) newSubs = newSubs.copy(roles = newSubs.roles - roleId.asString()) + if (roleId == null && userId == null) newSubs = newSubs.copy(users = newSubs.users - event.interaction.user.id) + + val new = announcement.copy(subscribers = newSubs) + announcementService.updateAnnouncement(new) + + return event.createFollowup() + .withEphemeral(ephemeral) + .withContent("${getMessage("unsubscribe.success", settings)}\n\n${announcement.subscribers.buildMentions()}") + .withEmbeds(embedService.viewAnnouncementEmbed(announcement, settings)) + .withAllowedMentions(AllowedMentions.suppressAll()) + .awaitSingle() } } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/config/DisCalConfig.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/config/DisCalConfig.kt index 7d6ed92c8..f11d7a3ff 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/config/DisCalConfig.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/config/DisCalConfig.kt @@ -1,7 +1,6 @@ package org.dreamexposure.discal.client.config import org.dreamexposure.discal.core.`object`.Wizard -import org.dreamexposure.discal.core.`object`.announcement.Announcement import org.dreamexposure.discal.core.`object`.calendar.PreCalendar import org.dreamexposure.discal.core.`object`.event.PreEvent import org.springframework.context.annotation.Bean @@ -14,7 +13,4 @@ class DisCalConfig { @Bean fun eventWizard(): Wizard = Wizard() - - @Bean - fun announcementWizard(): Wizard = Wizard() } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/AnnouncementEmbed.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/AnnouncementEmbed.kt deleted file mode 100644 index 40b1e2063..000000000 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/message/embed/AnnouncementEmbed.kt +++ /dev/null @@ -1,138 +0,0 @@ -package org.dreamexposure.discal.client.message.embed - -import discord4j.core.`object`.entity.Guild -import discord4j.core.spec.EmbedCreateSpec -import org.dreamexposure.discal.core.enums.announcement.AnnouncementType -import org.dreamexposure.discal.core.enums.event.EventColor -import org.dreamexposure.discal.core.extensions.embedFieldSafe -import org.dreamexposure.discal.core.extensions.toMarkdown -import org.dreamexposure.discal.core.`object`.GuildSettings -import org.dreamexposure.discal.core.`object`.announcement.Announcement -import org.dreamexposure.discal.core.utils.GlobalVal -import org.dreamexposure.discal.core.utils.getCommonMsg - -object AnnouncementEmbed : EmbedMaker { - - fun condensed(ann: Announcement, guild: Guild, settings: GuildSettings): EmbedCreateSpec { - val builder = defaultBuilder(guild, settings) - .title(getMessage("announcement", "con.title", settings)) - .addField(getMessage("announcement", "con.field.id", settings), ann.id, false) - .addField(getMessage("announcement", "con.field.time", settings), condensedTime(ann), true) - .addField(getMessage("announcement", "con.field.enabled", settings), "${ann.enabled}", true) - .footer(getMessage("announcement", "con.footer", settings, ann.type.name, ann.modifier.name), null) - - if (ann.type == AnnouncementType.COLOR) - builder.color(ann.eventColor.asColor()) - else - builder.color(GlobalVal.discalColor) - - return builder.build() - } - - fun view(ann: Announcement, guild: Guild, settings: GuildSettings): EmbedCreateSpec { - val builder = defaultBuilder(guild, settings) - .title(getMessage("announcement", "view.title", settings)) - .addField(getMessage("announcement", "view.field.type", settings), ann.type.name, true) - .addField(getMessage("announcement", "view.field.modifier", settings), ann.modifier.name, true) - .addField(getMessage("announcement", "view.field.channel", settings), "<#${ann.announcementChannelId}>", false) - .addField(getMessage("announcement", "view.field.hours", settings), "${ann.hoursBefore}", true) - .addField(getMessage("announcement", "view.field.minutes", settings), "${ann.minutesBefore}", true) - - if (ann.info.isNotBlank() && !ann.info.equals("None", true)) { - builder.addField(getMessage("announcement", "view.field.info", settings), ann.info.toMarkdown().embedFieldSafe(), false) - } - - builder.addField(getMessage("announcement", "view.field.calendar", settings), "${ann.calendarNumber}", true) - if (ann.type == AnnouncementType.RECUR || ann.type == AnnouncementType.SPECIFIC) - builder.addField(getMessage("announcement", "view.field.event", settings), ann.eventId, true) - - if (ann.type == AnnouncementType.COLOR) { - builder.color(ann.eventColor.asColor()) - builder.addField(getMessage("announcement", "view.field.color", settings), ann.eventColor.name, true) - } else - builder.color(GlobalVal.discalColor) - - return builder.addField(getMessage("announcement", "view.field.id", settings), ann.id, false) - .addField(getMessage("announcement", "view.field.enabled", settings), "${ann.enabled}", true) - .addField(getMessage("announcement", "view.field.publish", settings), "${ann.publish}", true) - .build() - } - - fun pre(guild: Guild, ann: Announcement, settings: GuildSettings): EmbedCreateSpec { - val builder = defaultBuilder(guild, settings) - .title(getMessage("announcement", "wizard.title", settings)) - .footer(getMessage("announcement", "wizard.footer", settings), null) - .color(ann.eventColor.asColor()) - //fields - .addField(getMessage("announcement", "wizard.field.type", settings), ann.type.name, true) - .addField(getMessage("announcement", "wizard.field.modifier", settings), ann.modifier.name, true) - - if (ann.type == AnnouncementType.COLOR) { - if (ann.eventColor == EventColor.NONE) builder.addField( - getMessage("announcement", "wizard.field.color", settings), - getCommonMsg("embed.unset", settings), - false - ) else builder.addField( - getMessage("announcement", "wizard.field.color", settings), - ann.eventColor.name, - false - ) - } - - if (ann.type == AnnouncementType.SPECIFIC || ann.type == AnnouncementType.RECUR) { - if (ann.eventId == "N/a") builder.addField( - getMessage("announcement", "wizard.field.event", settings), - getCommonMsg("embed.unset", settings), - false - ) else builder.addField( - getMessage("announcement", "wizard.field.event", settings), - ann.eventId, - false - ) - } - - if (ann.info == "None") builder.addField( - getMessage("announcement", "wizard.field.info", settings), - getCommonMsg("embed.unset", settings), - false - ) else builder.addField( - getMessage("announcement", "wizard.field.info", settings), - ann.info.embedFieldSafe().toMarkdown(), - false - ) - - if (ann.announcementChannelId == "N/a") builder.addField( - getMessage("announcement", "wizard.field.channel", settings), - getCommonMsg("embed.unset", settings), - false - ) else builder.addField( - getMessage("announcement", "wizard.field.channel", settings), - "<#${ann.announcementChannelId}>", - false - ) - - builder.addField(getMessage("announcement", "wizard.field.minutes", settings), "${ann.minutesBefore}", true) - builder.addField(getMessage("announcement", "wizard.field.hours", settings), "${ann.hoursBefore}", true) - - if (ann.editing) builder.addField(getMessage("announcement", "wizard.field.id", settings), ann.id, false) - else builder.addField( - getMessage("announcement", "wizard.field.id", settings), - getCommonMsg("embed.unset", settings), - false - ) - - builder.addField(getMessage("announcement", "wizard.field.publish", settings), "${ann.publish}", true) - builder.addField(getMessage("announcement", "wizard.field.enabled", settings), "${ann.enabled}", true) - builder.addField(getMessage("announcement", "wizard.field.calendar", settings), "${ann.calendarNumber}", true) - - val warnings = ann.generateWarnings(settings) - if (warnings.isNotEmpty()) { - val warnText = "```fix\n${warnings.joinToString("\n")}\n```" - builder.addField(getMessage("announcement", "wizard.field.warnings", settings), warnText, false) - } - - return builder.build() - } - - private fun condensedTime(a: Announcement): String = "${a.hoursBefore}H${a.minutesBefore}m" -} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt index cdc904d35..422ee3e74 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt @@ -7,14 +7,15 @@ import discord4j.rest.http.client.ClientException import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull import org.dreamexposure.discal.AnnouncementCache +import org.dreamexposure.discal.AnnouncementWizardStateCache import org.dreamexposure.discal.core.database.AnnouncementData import org.dreamexposure.discal.core.database.AnnouncementRepository import org.dreamexposure.discal.core.database.DatabaseManager -import org.dreamexposure.discal.core.entities.Calendar import org.dreamexposure.discal.core.entities.Event import org.dreamexposure.discal.core.extensions.discord4j.getCalendar import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.`object`.new.Announcement +import org.dreamexposure.discal.core.`object`.new.WizardState import org.springframework.beans.factory.BeanFactory import org.springframework.beans.factory.getBean import org.springframework.stereotype.Component @@ -27,6 +28,7 @@ import java.time.Instant class AnnouncementService( private val announcementRepository: AnnouncementRepository, private val announcementCache: AnnouncementCache, + private val announcementWizardStateCache: AnnouncementWizardStateCache, private val embedService: EmbedService, private val metricService: MetricService, private val beanFactory: BeanFactory, @@ -44,11 +46,11 @@ class AnnouncementService( channelId = announcement.channelId.asString(), announcementType = announcement.type.name, modifier = announcement.modifier.name, - eventId = announcement.eventId, + eventId = announcement.eventId ?: "N/a", eventColor = announcement.eventColor.name, hoursBefore = announcement.hoursBefore, minutesBefore = announcement.minutesBefore, - info = announcement.info, + info = announcement.info ?: "None", enabled = announcement.enabled, publish = announcement.publish, )).map(::Announcement).awaitSingle() @@ -81,16 +83,10 @@ class AnnouncementService( return announcements } - suspend fun getAllAnnouncements(guildId: Snowflake, type: Announcement.Type): List { - return getAllAnnouncements(guildId).filter { it.type == type } - } - - suspend fun getEnabledAnnouncements(guildId: Snowflake): List { - return getAllAnnouncements(guildId).filter(Announcement::enabled) - } - - suspend fun getEnabledAnnouncements(guildId: Snowflake, type: Announcement.Type): List { - return getEnabledAnnouncements(guildId).filter { it.type == type } + suspend fun getAllAnnouncements(guildId: Snowflake, type: Announcement.Type? = null, returnDisabled: Boolean? = true): List { + return getAllAnnouncements(guildId).filter { + type?.equals(it.type) ?: true + }.filter { returnDisabled?.equals(true) ?: it.enabled } } suspend fun getAnnouncement(guildId: Snowflake, id: String): Announcement? { @@ -107,11 +103,11 @@ class AnnouncementService( channelId = announcement.channelId.asString(), announcementType = announcement.type.name, modifier = announcement.modifier.name, - eventId = announcement.eventId, + eventId = announcement.eventId ?: "N/a", eventColor = announcement.eventColor.name, hoursBefore = announcement.hoursBefore, minutesBefore = announcement.minutesBefore, - info = announcement.info, + info = announcement.info ?: "None", enabled = announcement.enabled, publish = announcement.publish, ).awaitSingleOrNull() @@ -201,52 +197,71 @@ class AnnouncementService( taskTimer.start() val guild = discordClient.getGuildById(guildId) - val calendars: MutableSet = mutableSetOf() - val events: MutableMap> = mutableMapOf() // TODO: Need to break this out to add handling for modifiers - getEnabledAnnouncements(guildId).forEach { announcement -> - // Get the calendar - var calendar = calendars.firstOrNull { it.calendarNumber == announcement.calendarNumber } - if (calendar == null) { - calendar = guild.getCalendar(announcement.calendarNumber).awaitSingleOrNull() ?: return@forEach - calendars.add(calendar) - } - - // Handle specific type first, since we don't need to fetch all events for this - if (announcement.type == Announcement.Type.SPECIFIC) { - val event = calendar.getEvent(announcement.eventId).awaitSingleOrNull() ?: return@forEach - if (isInRange(announcement, event, maxDifference)) { - sendAnnouncement(announcement, event) + getAllAnnouncements(guildId = guildId, returnDisabled = false) + .groupBy { it.calendarNumber } + .forEach { calendarPair -> + // Get the calendar + val calendar = guild.getCalendar(calendarPair.key).awaitSingleOrNull() ?: return@forEach + + var events: List? = null + + // Loop through announcements + for (announcement in calendarPair.value) { + // Handle specific type first, since we don't need to fetch all events for this + if (announcement.type == Announcement.Type.SPECIFIC) { + val event = calendar.getEvent(announcement.eventId!!).awaitSingleOrNull() ?: continue + if (isInRange(announcement, event, maxDifference)) { + sendAnnouncement(announcement, event) + } + } + + // Get the events to filter through, we only need to fetch this once for the set of announcements, + if (events == null) { + events = calendar.getUpcomingEvents(20) + .collectList() + .awaitSingle() + } + + + // Handle filtering out events based on this announcement's types + var filteredEvents = events + + if (announcement.type == Announcement.Type.COLOR) { + filteredEvents = filteredEvents?.filter { it.color == announcement.eventColor } + } else if (announcement.type == Announcement.Type.RECUR) { + filteredEvents = filteredEvents + ?.filter { it.eventId.contains("_") } + ?.filter { it.eventId.split("_")[0] == announcement.eventId } + } + + // Loop through filtered events and post any announcements in range + filteredEvents + ?.filter { isInRange(announcement, it, maxDifference) } + ?.forEach { sendAnnouncement(announcement, it) } } } - // Get the events to filter through - var filteredEvents = events[calendar.calendarNumber] - if (filteredEvents == null) { - filteredEvents = calendar.getUpcomingEvents(20) - .collectList() - .awaitSingle() - events[calendar.calendarNumber] = filteredEvents - } + taskTimer.stop() + metricService.recordAnnouncementTaskDuration("guild", taskTimer.totalTimeMillis) + } - // Handle filtering out events based on this announcement's types - if (announcement.type == Announcement.Type.COLOR) { - filteredEvents = filteredEvents?.filter { it.color == announcement.eventColor } - } else if (announcement.type == Announcement.Type.RECUR) { - filteredEvents = filteredEvents - ?.filter { it.eventId.contains("_") } - ?.filter { it.eventId.split("_")[0] == announcement.eventId } - } + suspend fun getWizard(guildId: Snowflake, userId: Snowflake): WizardState? { + return announcementWizardStateCache.get(guildId, userId) + } - // Loop through filtered events and post any announcements in range - filteredEvents - ?.filter { isInRange(announcement, it, maxDifference) } - ?.forEach { sendAnnouncement(announcement, it) } + suspend fun putWizard(state: WizardState) { + announcementWizardStateCache.put(state.guildId, state.userId, state) + } - } + suspend fun cancelWizard(guildId: Snowflake, userId: Snowflake) { + announcementWizardStateCache.evict(guildId, userId) + } - taskTimer.stop() - metricService.recordAnnouncementTaskDuration("guild", taskTimer.totalTimeMillis) + suspend fun cancelWizard(guildId: Snowflake, announcementId: String) { + announcementWizardStateCache.getAll(guildId) + .filter { it.entity.id == announcementId } + .forEach { announcementWizardStateCache.evict(guildId, it.userId) } } } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt index e80e68d4a..59e2d3874 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt @@ -10,6 +10,7 @@ import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.entities.Calendar import org.dreamexposure.discal.core.entities.Event import org.dreamexposure.discal.core.enums.announcement.AnnouncementStyle +import org.dreamexposure.discal.core.enums.event.EventColor import org.dreamexposure.discal.core.enums.time.DiscordTimestampFormat import org.dreamexposure.discal.core.extensions.* import org.dreamexposure.discal.core.extensions.discord4j.getCalendar @@ -17,12 +18,14 @@ import org.dreamexposure.discal.core.extensions.discord4j.getSettings import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.`object`.new.Announcement import org.dreamexposure.discal.core.`object`.new.Rsvp +import org.dreamexposure.discal.core.`object`.new.WizardState import org.dreamexposure.discal.core.utils.GlobalVal import org.dreamexposure.discal.core.utils.getCommonMsg import org.dreamexposure.discal.core.utils.getEmbedMessage import org.springframework.beans.factory.BeanFactory import org.springframework.beans.factory.getBean import org.springframework.stereotype.Component +import java.time.Duration import java.time.Instant import java.time.ZonedDateTime import java.time.temporal.ChronoUnit @@ -368,7 +371,7 @@ class EmbedService( false ) - if (announcement.info.isNotBlank() && !announcement.info.equals("None", true)) builder.addField( + if (!announcement.info.isNullOrBlank()) builder.addField( getEmbedMessage("announcement", "full.field.info", settings), announcement.info.toMarkdown().embedFieldSafe(), false @@ -417,7 +420,7 @@ class EmbedService( false ) - if (announcement.info.isNotBlank() && !announcement.info.equals("None", true)) builder.addField( + if (!announcement.info.isNullOrBlank()) builder.addField( getEmbedMessage("announcement", "simple.field.info", settings), announcement.info.toMarkdown().embedFieldSafe(), false @@ -471,7 +474,7 @@ class EmbedService( ) builder.addField(getEmbedMessage("announcement", "event.field.event", settings), event.eventId, true) - if (announcement.info.isNotBlank() && !announcement.info.equals("None", true)) builder.addField( + if (!announcement.info.isNullOrBlank()) builder.addField( getEmbedMessage("announcement", "event.field.info", settings), announcement.info.toMarkdown().embedFieldSafe(), false @@ -484,4 +487,127 @@ class EmbedService( return builder.build() } + + suspend fun viewAnnouncementEmbed(announcement: Announcement, settings: GuildSettings): EmbedCreateSpec { + val builder = defaultEmbedBuilder(settings) + .title(getEmbedMessage("announcement", "view.title", settings)) + .addField(getEmbedMessage("announcement", "view.field.type", settings), announcement.type.name, true) + .addField(getEmbedMessage("announcement", "view.field.modifier", settings), announcement.modifier.name, true) + .addField(getEmbedMessage("announcement", "view.field.channel", settings), "<#${announcement.channelId.asLong()}>", false) + .addField(getEmbedMessage("announcement", "view.field.hours", settings), "${announcement.hoursBefore}", true) + .addField(getEmbedMessage("announcement", "view.field.minutes", settings), "${announcement.minutesBefore}", true) + + if (!announcement.info.isNullOrBlank()) { + builder.addField(getEmbedMessage("announcement", "view.field.info", settings), announcement.info.toMarkdown().embedFieldSafe(), false) + } + + builder.addField(getEmbedMessage("announcement", "view.field.calendar", settings), "${announcement.calendarNumber}", true) + + if (announcement.type == Announcement.Type.RECUR || announcement.type == Announcement.Type.SPECIFIC) + builder.addField(getEmbedMessage("announcement", "view.field.event", settings), announcement.eventId!!, true) + + if (announcement.type == Announcement.Type.COLOR) { + builder.color(announcement.eventColor.asColor()) + builder.addField(getEmbedMessage("announcement", "view.field.color", settings), announcement.eventColor.name, true) + } else builder.color(GlobalVal.discalColor) + + return builder.addField(getEmbedMessage("announcement", "view.field.id", settings), announcement.id, false) + .addField(getEmbedMessage("announcement", "view.field.enabled", settings), "${announcement.enabled}", true) + .addField(getEmbedMessage("announcement", "view.field.publish", settings), "${announcement.publish}", true) + .build() + } + + suspend fun condensedAnnouncementEmbed(announcement: Announcement, settings: GuildSettings): EmbedCreateSpec { + val builder = defaultEmbedBuilder(settings) + .title(getEmbedMessage("announcement", "con.title", settings)) + .addField(getEmbedMessage("announcement", "con.field.id", settings), announcement.id, false) + .addField(getEmbedMessage("announcement", "con.field.time", settings), "${announcement.hoursBefore}H${announcement.minutesBefore}m", true) + .addField(getEmbedMessage("announcement", "con.field.enabled", settings), "${announcement.enabled}", true) + .footer(getEmbedMessage("announcement", "con.footer", settings, announcement.type.name, announcement.modifier.name), null) + + if (announcement.type == Announcement.Type.COLOR) builder.color(announcement.eventColor.asColor()) + else builder.color(GlobalVal.discalColor) + + return builder.build() + } + + suspend fun announcementWizardEmbed(wizard: WizardState, settings: GuildSettings): EmbedCreateSpec { + val announcement = wizard.entity + + val builder = defaultEmbedBuilder(settings) + .title(getEmbedMessage("announcement", "wizard.title", settings)) + .footer(getEmbedMessage("announcement", "wizard.footer", settings), null) + .color(announcement.eventColor.asColor()) + //fields + .addField(getEmbedMessage("announcement", "wizard.field.type", settings), announcement.type.name, true) + .addField(getEmbedMessage("announcement", "wizard.field.modifier", settings), announcement.modifier.name, true) + + if (announcement.type == Announcement.Type.COLOR) { + if (announcement.eventColor == EventColor.NONE) builder.addField( + getEmbedMessage("announcement", "wizard.field.color", settings), + getCommonMsg("embed.unset", settings), + false + ) else builder.addField( + getEmbedMessage("announcement", "wizard.field.color", settings), + announcement.eventColor.name, + false + ) + } + + if (announcement.type == Announcement.Type.SPECIFIC || announcement.type == Announcement.Type.RECUR) { + if (announcement.eventId.isNullOrBlank()) builder.addField( + getEmbedMessage("announcement", "wizard.field.event", settings), + getCommonMsg("embed.unset", settings), + false + ) else builder.addField( + getEmbedMessage("announcement", "wizard.field.event", settings), + announcement.eventId, + false + ) + } + + if (announcement.info == "None" || announcement.info.isNullOrBlank()) builder.addField( + getEmbedMessage("announcement", "wizard.field.info", settings), + getCommonMsg("embed.unset", settings), + false + ) else builder.addField( + getEmbedMessage("announcement", "wizard.field.info", settings), + announcement.info.embedFieldSafe().toMarkdown(), + false + ) + + builder.addField(getEmbedMessage("announcement", "wizard.field.channel", settings), "<#${announcement.channelId.asLong()}>", false) + builder.addField(getEmbedMessage("announcement", "wizard.field.minutes", settings), "${announcement.minutesBefore}", true) + builder.addField(getEmbedMessage("announcement", "wizard.field.hours", settings), "${announcement.hoursBefore}", true) + + if (wizard.editing) builder.addField(getEmbedMessage("announcement", "wizard.field.id", settings), announcement.id, false) + else builder.addField( + getEmbedMessage("announcement", "wizard.field.id", settings), + getCommonMsg("embed.unset", settings), + false + ) + + builder.addField(getEmbedMessage("announcement", "wizard.field.publish", settings), "${announcement.publish}", true) + builder.addField(getEmbedMessage("announcement", "wizard.field.enabled", settings), "${announcement.enabled}", true) + builder.addField(getEmbedMessage("announcement", "wizard.field.calendar", settings), "${announcement.calendarNumber}", true) + + // Build up any warnings + val warningsBuilder = StringBuilder() + if ((announcement.type == Announcement.Type.SPECIFIC || announcement.type == Announcement.Type.RECUR) && announcement.eventId.isNullOrBlank()) + warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.eventId", settings)) + if (announcement.type == Announcement.Type.COLOR && announcement.eventColor == EventColor.NONE) + warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.color", settings)) + if (announcement.getCalculatedTime() < Duration.ofMinutes(5)) + warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.time", settings)) + if (announcement.calendarNumber > settings.maxCalendars) + warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.calNum", settings)) + + + + if (warningsBuilder.isNotBlank()) { + builder.addField(getEmbedMessage("announcement", "wizard.field.warnings", settings), warningsBuilder.toString(), false) + } + + return builder.build() + } } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt index 11e9945d7..44c8d1330 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt @@ -19,6 +19,7 @@ class CacheConfig { private val rsvpTtl = Config.CACHE_TTL_RSVP_MINUTES.getLong().asMinutes() private val staticMessageTtl = Config.CACHE_TTL_STATIC_MESSAGE_MINUTES.getLong().asMinutes() private val announcementTll = Config.CACHE_TTL_ANNOUNCEMENT_MINUTES.getLong().asMinutes() + private val wizardTtl = Config.TIMING_WIZARD_TIMEOUT_MINUTES.getLong().asMinutes() // Redis caching @@ -58,6 +59,12 @@ class CacheConfig { fun announcementRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): AnnouncementCache = RedisStringCacheRepository(objectMapper, redisTemplate, "Announcements", announcementTll) + @Bean + @Primary + @ConditionalOnProperty("bot.cache.redis", havingValue = "true") + fun announcementWizardRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): AnnouncementWizardStateCache = + RedisStringCacheRepository(objectMapper, redisTemplate, "Wizards.Announcements", wizardTtl) + // In-memory fallback caching @Bean @@ -77,4 +84,7 @@ class CacheConfig { @Bean fun announcementFallbackCache(): AnnouncementCache = JdkCacheRepository(announcementTll) + + @Bean + fun announcementWizardFallbackCache(): AnnouncementWizardStateCache = JdkCacheRepository(wizardTtl) } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt index 9191c9d71..e19e837af 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt @@ -35,6 +35,7 @@ enum class Config(private val key: String, private var value: Any? = null) { // Global bot timings TIMING_BOT_STATUS_UPDATE_MINUTES("bot.timing.status-update.minutes", 5), TIMING_ANNOUNCEMENT_TASK_RUN_INTERVAL_MINUTES("bot.timing.announcement.task-run-interval.minutes", 5), + TIMING_WIZARD_TIMEOUT_MINUTES("bot.timing.wizard-timeout.minutes", 30), // Bot secrets SECRET_DISCAL_API_KEY("bot.secret.api-token"), diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt index b35818a02..72d2c1631 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/DatabaseManager.kt @@ -11,17 +11,12 @@ import io.r2dbc.spi.ConnectionFactoryOptions.* import io.r2dbc.spi.Result import org.dreamexposure.discal.core.cache.DiscalCache import org.dreamexposure.discal.core.config.Config -import org.dreamexposure.discal.core.enums.announcement.AnnouncementModifier import org.dreamexposure.discal.core.enums.announcement.AnnouncementStyle -import org.dreamexposure.discal.core.enums.announcement.AnnouncementType import org.dreamexposure.discal.core.enums.calendar.CalendarHost -import org.dreamexposure.discal.core.enums.event.EventColor.Companion.fromNameOrHexOrId import org.dreamexposure.discal.core.enums.time.TimeFormat -import org.dreamexposure.discal.core.extensions.asStringList import org.dreamexposure.discal.core.extensions.setFromString import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.`object`.GuildSettings -import org.dreamexposure.discal.core.`object`.announcement.Announcement import org.dreamexposure.discal.core.`object`.calendar.CalendarData import org.dreamexposure.discal.core.`object`.event.EventData import org.dreamexposure.discal.core.`object`.web.UserAPIAccount @@ -242,82 +237,6 @@ object DatabaseManager { } } - fun updateAnnouncement(announcement: Announcement): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_ANNOUNCEMENT_BY_GUILD) - .bind(0, announcement.guildId.asLong()) - .bind(1, announcement.id) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> row } - }.hasElements().flatMap { exists -> - if (exists) { - val updateCommand = """UPDATE ${Tables.ANNOUNCEMENTS} SET - CALENDAR_NUMBER = ?, SUBSCRIBERS_ROLE = ?, SUBSCRIBERS_USER = ?, CHANNEL_ID = ?, - ANNOUNCEMENT_TYPE = ?, MODIFIER = ?, EVENT_ID = ?, EVENT_COLOR = ?, - HOURS_BEFORE = ?, MINUTES_BEFORE = ?, - INFO = ?, ENABLED = ?, PUBLISH = ? - WHERE ANNOUNCEMENT_ID = ? AND GUILD_ID = ? - """.trimMargin() - - Mono.from( - c.createStatement(updateCommand) - .bind(0, announcement.calendarNumber) - .bind(1, announcement.subscriberRoleIds.asStringList()) - .bind(2, announcement.subscriberUserIds.asStringList()) - .bind(3, announcement.announcementChannelId) - .bind(4, announcement.type.name) - .bind(5, announcement.modifier.name) - .bind(6, announcement.eventId) - .bind(7, announcement.eventColor.name) - .bind(8, announcement.hoursBefore) - .bind(9, announcement.minutesBefore) - .bind(10, announcement.info) - .bind(11, announcement.enabled) - .bind(12, announcement.publish) - .bind(13, announcement.id) - .bind(14, announcement.guildId.asLong()) - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - } else { - val insertCommand = """INSERT INTO ${Tables.ANNOUNCEMENTS} - (ANNOUNCEMENT_ID, CALENDAR_NUMBER, GUILD_ID, SUBSCRIBERS_ROLE, SUBSCRIBERS_USER, - CHANNEL_ID, ANNOUNCEMENT_TYPE, MODIFIER, EVENT_ID, EVENT_COLOR, - HOURS_BEFORE, MINUTES_BEFORE, INFO, ENABLED, PUBLISH) - VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """.trimMargin() - - Mono.from( - c.createStatement(insertCommand) - .bind(0, announcement.id) - .bind(1, announcement.calendarNumber) - .bind(2, announcement.guildId.asLong()) - .bind(3, announcement.subscriberRoleIds.asStringList()) - .bind(4, announcement.subscriberUserIds.asStringList()) - .bind(5, announcement.announcementChannelId) - .bind(6, announcement.type.name) - .bind(7, announcement.modifier.name) - .bind(8, announcement.eventId) - .bind(9, announcement.eventColor.name) - .bind(10, announcement.hoursBefore) - .bind(11, announcement.minutesBefore) - .bind(12, announcement.info) - .bind(13, announcement.enabled) - .bind(14, announcement.publish) - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - } - }.doOnError { - LOGGER.error(DEFAULT, "Failed to update announcement", it) - }.onErrorResume { Mono.just(false) } - } - } - fun updateEventData(data: EventData): Mono { val id = if (data.eventId.contains("_")) data.eventId.split("_")[0] @@ -566,202 +485,6 @@ object DatabaseManager { }.defaultIfEmpty(EventData(guildId, eventId = eventIdLookup)) } - fun getAnnouncement(announcementId: String, guildId: Snowflake): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_ANNOUNCEMENT_BY_GUILD) - .bind(0, guildId.asLong()) - .bind(1, announcementId) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val a = Announcement(guildId, announcementId) - a.calendarNumber = row["CALENDAR_NUMBER", Int::class.java]!! - a.subscriberRoleIds.setFromString(row["SUBSCRIBERS_ROLE", String::class.java]!!) - a.subscriberUserIds.setFromString(row["SUBSCRIBERS_USER", String::class.java]!!) - a.announcementChannelId = row["CHANNEL_ID", String::class.java]!! - a.type = AnnouncementType.valueOf(row["ANNOUNCEMENT_TYPE", String::class.java]!!) - a.modifier = AnnouncementModifier.valueOf(row["MODIFIER", String::class.java]!!) - a.eventId = row["EVENT_ID", String::class.java]!! - a.eventColor = fromNameOrHexOrId(row["EVENT_COLOR", String::class.java]!!) - a.hoursBefore = row["HOURS_BEFORE", Int::class.java]!! - a.minutesBefore = row["MINUTES_BEFORE", Int::class.java]!! - a.info = row["INFO", String::class.java]!! - a.enabled = row["ENABLED", Boolean::class.java]!! - a.publish = row["PUBLISH", Boolean::class.java]!! - - a - } - }.next().retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get announcement", it) - }.onErrorResume { Mono.empty() } - } - } - - fun getAnnouncements(guildId: Snowflake): Mono> { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_ALL_ANNOUNCEMENTS_BY_GUILD) - .bind(0, guildId.asLong()) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val announcementId = row["ANNOUNCEMENT_ID", String::class.java]!! - - val a = Announcement(guildId, announcementId) - a.calendarNumber = row["CALENDAR_NUMBER", Int::class.java]!! - a.subscriberRoleIds.setFromString(row["SUBSCRIBERS_ROLE", String::class.java]!!) - a.subscriberUserIds.setFromString(row["SUBSCRIBERS_USER", String::class.java]!!) - a.announcementChannelId = row["CHANNEL_ID", String::class.java]!! - a.type = AnnouncementType.valueOf(row["ANNOUNCEMENT_TYPE", String::class.java]!!) - a.modifier = AnnouncementModifier.valueOf(row["MODIFIER", String::class.java]!!) - a.eventId = row["EVENT_ID", String::class.java]!! - a.eventColor = fromNameOrHexOrId(row["EVENT_COLOR", String::class.java]!!) - a.hoursBefore = row["HOURS_BEFORE", Int::class.java]!! - a.minutesBefore = row["MINUTES_BEFORE", Int::class.java]!! - a.info = row["INFO", String::class.java]!! - a.enabled = row["ENABLED", Boolean::class.java]!! - a.publish = row["PUBLISH", Boolean::class.java]!! - - a - } - }.collectList().retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get announcements for guild", it) - }.onErrorReturn(mutableListOf()) - }.defaultIfEmpty(mutableListOf()) - } - - fun getAnnouncements(guildId: Snowflake, type: AnnouncementType): Mono> { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_ANNOUNCEMENTS_BY_GUILD_AND_TYPE) - .bind(0, guildId.asLong()) - .bind(1, type.name) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val announcementId = row["ANNOUNCEMENT_ID", String::class.java]!! - - val a = Announcement(guildId, announcementId) - a.calendarNumber = row["CALENDAR_NUMBER", Int::class.java]!! - a.subscriberRoleIds.setFromString(row["SUBSCRIBERS_ROLE", String::class.java]!!) - a.subscriberUserIds.setFromString(row["SUBSCRIBERS_USER", String::class.java]!!) - a.announcementChannelId = row["CHANNEL_ID", String::class.java]!! - a.type = AnnouncementType.valueOf(row["ANNOUNCEMENT_TYPE", String::class.java]!!) - a.modifier = AnnouncementModifier.valueOf(row["MODIFIER", String::class.java]!!) - a.eventId = row["EVENT_ID", String::class.java]!! - a.eventColor = fromNameOrHexOrId(row["EVENT_COLOR", String::class.java]!!) - a.hoursBefore = row["HOURS_BEFORE", Int::class.java]!! - a.minutesBefore = row["MINUTES_BEFORE", Int::class.java]!! - a.info = row["INFO", String::class.java]!! - a.enabled = row["ENABLED", Boolean::class.java]!! - a.publish = row["PUBLISH", Boolean::class.java]!! - - a - } - }.collectList().retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get guild's announcements by type", it) - }.onErrorReturn(mutableListOf()) - }.defaultIfEmpty(mutableListOf()) - } - - fun getEnabledAnnouncements(guildId: Snowflake): Mono> { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_ENABLED_ANNOUNCEMENTS_BY_GUILD) - .bind(0, guildId.asLong()) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val announcementId = row["ANNOUNCEMENT_ID", String::class.java]!! - - val a = Announcement(guildId, announcementId) - a.calendarNumber = row["CALENDAR_NUMBER", Int::class.java]!! - a.subscriberRoleIds.setFromString(row["SUBSCRIBERS_ROLE", String::class.java]!!) - a.subscriberUserIds.setFromString(row["SUBSCRIBERS_USER", String::class.java]!!) - a.announcementChannelId = row["CHANNEL_ID", String::class.java]!! - a.type = AnnouncementType.valueOf(row["ANNOUNCEMENT_TYPE", String::class.java]!!) - a.modifier = AnnouncementModifier.valueOf(row["MODIFIER", String::class.java]!!) - a.eventId = row["EVENT_ID", String::class.java]!! - a.eventColor = fromNameOrHexOrId(row["EVENT_COLOR", String::class.java]!!) - a.hoursBefore = row["HOURS_BEFORE", Int::class.java]!! - a.minutesBefore = row["MINUTES_BEFORE", Int::class.java]!! - a.info = row["INFO", String::class.java]!! - a.enabled = row["ENABLED", Boolean::class.java]!! - a.publish = row["PUBLISH", Boolean::class.java]!! - - a - } - }.collectList().retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get guild's enabled announcements", it) - }.onErrorReturn(mutableListOf()) - }.defaultIfEmpty(mutableListOf()) - } - - fun getEnabledAnnouncements(guildId: Snowflake, type: AnnouncementType): Mono> { - return connect { c -> - Mono.from( - c.createStatement(Queries.SELECT_ENABLED_ANNOUNCEMENTS_BY_TYPE_GUILD) - .bind(0, guildId.asLong()) - .bind(1, type.name) - .execute() - ).flatMapMany { res -> - res.map { row, _ -> - val announcementId = row["ANNOUNCEMENT_ID", String::class.java]!! - - val a = Announcement(guildId, announcementId) - a.calendarNumber = row["CALENDAR_NUMBER", Int::class.java]!! - a.subscriberRoleIds.setFromString(row["SUBSCRIBERS_ROLE", String::class.java]!!) - a.subscriberUserIds.setFromString(row["SUBSCRIBERS_USER", String::class.java]!!) - a.announcementChannelId = row["CHANNEL_ID", String::class.java]!! - a.type = AnnouncementType.valueOf(row["ANNOUNCEMENT_TYPE", String::class.java]!!) - a.modifier = AnnouncementModifier.valueOf(row["MODIFIER", String::class.java]!!) - a.eventId = row["EVENT_ID", String::class.java]!! - a.eventColor = fromNameOrHexOrId(row["EVENT_COLOR", String::class.java]!!) - a.hoursBefore = row["HOURS_BEFORE", Int::class.java]!! - a.minutesBefore = row["MINUTES_BEFORE", Int::class.java]!! - a.info = row["INFO", String::class.java]!! - a.enabled = row["ENABLED", Boolean::class.java]!! - a.publish = row["PUBLISH", Boolean::class.java]!! - - a - } - }.collectList().retryWhen(Retry.max(3) - .filter(IllegalStateException::class::isInstance) - .filter { it.message != null && it.message!!.contains("Request queue was disposed") } - ).doOnError { - LOGGER.error(DEFAULT, "Failed to get guild's enabled announcements by type", it) - }.onErrorReturn(mutableListOf()) - }.defaultIfEmpty(mutableListOf()) - } - - fun deleteAnnouncement(announcementId: String): Mono { - return connect { c -> - Mono.from( - c.createStatement(Queries.DELETE_ANNOUNCEMENT) - .bind(0, announcementId) - .execute() - ).flatMapMany(Result::getRowsUpdated) - .hasElements() - .thenReturn(true) - .doOnError { - LOGGER.error(DEFAULT, "Failed to delete announcements", it) - }.onErrorReturn(false) - }.defaultIfEmpty(false) - } - fun deleteAnnouncementsForEvent(guildId: Snowflake, eventId: String): Mono { return connect { c -> Mono.from( @@ -948,36 +671,6 @@ private object Queries { WHERE GUILD_ID = ? AND EVENT_ID = ? """.trimMargin() - @Language("MySQL") - val SELECT_ANNOUNCEMENT_BY_GUILD = """SELECT * FROM ${Tables.ANNOUNCEMENTS} - WHERE GUILD_ID = ? and ANNOUNCEMENT_ID = ? - """.trimMargin() - - @Language("MySQL") - val SELECT_ALL_ANNOUNCEMENTS_BY_GUILD = """SELECT * FROM ${Tables.ANNOUNCEMENTS} - WHERE GUILD_ID = ? - """.trimMargin() - - @Language("MySQL") - val SELECT_ANNOUNCEMENTS_BY_GUILD_AND_TYPE = """SELECT * FROM ${Tables.ANNOUNCEMENTS} - WHERE GUILD_ID = ? AND ANNOUNCEMENT_TYPE = ? - """.trimMargin() - - @Language("MySQL") - val SELECT_ENABLED_ANNOUNCEMENTS_BY_GUILD = """SELECT * FROM ${Tables.ANNOUNCEMENTS} - WHERE ENABLED = 1 and GUILD_ID = ? - """.trimMargin() - - @Language("MySQL") - val SELECT_ENABLED_ANNOUNCEMENTS_BY_TYPE_GUILD = """SELECT * FROM ${Tables.ANNOUNCEMENTS} - WHERE ENABLED = 1 AND GUILD_ID = ? AND ANNOUNCEMENT_TYPE = ? - """.trimMargin() - - @Language("MySQL") - val DELETE_ANNOUNCEMENT = """DELETE FROM ${Tables.ANNOUNCEMENTS} - WHERE ANNOUNCEMENT_ID = ? - """.trimMargin() - @Language("MySQL") val DELETE_ANNOUNCEMENTS_FOR_EVENT = """DELETE FROM ${Tables.ANNOUNCEMENTS} WHERE EVENT_ID = ? AND GUILD_ID = ? diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/Wizard.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/Wizard.kt index bda50040b..3b325604c 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/Wizard.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/Wizard.kt @@ -7,6 +7,7 @@ import java.time.Instant import java.time.temporal.ChronoUnit import java.util.concurrent.ConcurrentHashMap +@Deprecated("This was dumb, what was I doing lmao") class Wizard { private val active = ConcurrentHashMap() diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/announcement/Announcement.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/announcement/Announcement.kt deleted file mode 100644 index 0deb27694..000000000 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/announcement/Announcement.kt +++ /dev/null @@ -1,97 +0,0 @@ -package org.dreamexposure.discal.core.`object`.announcement - -import discord4j.common.util.Snowflake -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient -import org.dreamexposure.discal.core.`object`.GuildSettings -import org.dreamexposure.discal.core.`object`.Pre -import org.dreamexposure.discal.core.crypto.KeyGenerator -import org.dreamexposure.discal.core.enums.announcement.AnnouncementModifier -import org.dreamexposure.discal.core.enums.announcement.AnnouncementType -import org.dreamexposure.discal.core.enums.event.EventColor -import org.dreamexposure.discal.core.serializers.SnowflakeAsStringSerializer -import org.dreamexposure.discal.core.utils.getEmbedMessage -import java.util.concurrent.CopyOnWriteArrayList - -@Serializable -data class Announcement( - @Serializable(with = SnowflakeAsStringSerializer::class) - @SerialName("guild_id") - override val guildId: Snowflake, - - val id: String = KeyGenerator.generateAnnouncementId(), - - @Transient - override val editing: Boolean = false, - - @SerialName("subscriber_roles") - val subscriberRoleIds: MutableList = CopyOnWriteArrayList(), - - @SerialName("subscriber_users") - val subscriberUserIds: MutableList = CopyOnWriteArrayList(), - - @SerialName("channel_id") - var announcementChannelId: String = "N/a", - var type: AnnouncementType = AnnouncementType.UNIVERSAL, - var modifier: AnnouncementModifier = AnnouncementModifier.BEFORE, - - @SerialName("calendar_number") - var calendarNumber: Int = 1, - - @SerialName("event_id") - var eventId: String = "N/a", - - @SerialName("event_color") - var eventColor: EventColor = EventColor.NONE, - - @SerialName("hours") - var hoursBefore: Int = 0, - - @SerialName("minutes") - var minutesBefore: Int = 0, - var info: String = "None", - - var enabled: Boolean = true, - - var publish: Boolean = false, -) : Pre(guildId) { - - fun hasRequiredValues(): Boolean { - return !((this.type == AnnouncementType.SPECIFIC || this.type == AnnouncementType.RECUR) && this.eventId == "N/a") - && this.announcementChannelId != "N/a" - } - - override fun generateWarnings(settings: GuildSettings): List { - val warnings = mutableListOf() - - if ((this.type == AnnouncementType.SPECIFIC || this.type == AnnouncementType.RECUR) && this.eventId == "N/a") - warnings.add(getEmbedMessage("announcement", "warning.wizard.eventId", settings)) - if (this.type == AnnouncementType.COLOR && this.eventColor == EventColor.NONE) - warnings.add(getEmbedMessage("announcement", "warning.wizard.color", settings)) - if (this.minutesBefore + (this.hoursBefore * 60) < 5) - warnings.add(getEmbedMessage("announcement", "warning.wizard.time", settings)) - if (this.calendarNumber > settings.maxCalendars) - warnings.add(getEmbedMessage("announcement", "warning.wizard.calNum", settings)) - - return warnings - } - - fun buildMentions(): String { - if (subscriberUserIds.isEmpty() && subscriberRoleIds.isEmpty()) return "" - - val userMentions = subscriberUserIds.map { "<@$it> " } - - val roleMentions = subscriberRoleIds.map { - if (it.equals("everyone", true)) "@everyone " - else if (it.equals("here", true)) "@here " - else "<@&$it> " - } - - return StringBuilder() - .append("Subscribers: ") - .append(*userMentions.toTypedArray()) - .append(*roleMentions.toTypedArray()) - .toString() - } -} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/announcement/AnnouncementCache.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/announcement/AnnouncementCache.kt deleted file mode 100644 index 145314baf..000000000 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/announcement/AnnouncementCache.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.dreamexposure.discal.core.`object`.announcement - -import discord4j.common.util.Snowflake -import org.dreamexposure.discal.core.entities.Calendar -import org.dreamexposure.discal.core.entities.Event -import java.util.concurrent.ConcurrentHashMap - -data class AnnouncementCache( - val id: Snowflake, - val calendars: ConcurrentHashMap = ConcurrentHashMap(), - val events: ConcurrentHashMap> = ConcurrentHashMap(), -) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/announcement/AnnouncementCreatorResponse.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/announcement/AnnouncementCreatorResponse.kt deleted file mode 100644 index 402356060..000000000 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/announcement/AnnouncementCreatorResponse.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.dreamexposure.discal.core.`object`.announcement - -data class AnnouncementCreatorResponse( - val successful: Boolean, - val announcement: Announcement? -) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/command/CommandInfo.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/command/CommandInfo.kt deleted file mode 100644 index ab4933bda..000000000 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/command/CommandInfo.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.dreamexposure.discal.core.`object`.command - -data class CommandInfo( - val name: String, - val description: String, - val example: String -) { - val subCommands: MutableMap = mutableMapOf() -} diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Announcement.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Announcement.kt index d2a3dc7ac..9bf30c880 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Announcement.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/Announcement.kt @@ -19,13 +19,13 @@ data class Announcement( val subscribers: Subscribers = Subscribers(), - val eventId: String = "N/a", + val eventId: String? = null, val eventColor: EventColor = EventColor.NONE, val hoursBefore: Int = 0, val minutesBefore: Int = 0, - val info: String = "None", + val info: String? = null, val enabled: Boolean = true, val publish: Boolean = false, ) { @@ -39,16 +39,16 @@ data class Announcement( channelId = Snowflake.of(data.channelId), subscribers = Subscribers( - roles = data.subscribersRole.asStringListFromDatabase(), - users = data.subscribersUser.asStringListFromDatabase().map(Snowflake::of), + roles = data.subscribersRole.asStringListFromDatabase().toSet(), + users = data.subscribersUser.asStringListFromDatabase().map(Snowflake::of).toSet(), ), - eventId = data.eventId, + eventId = if (data.eventId.isBlank() || data.eventId.equals("N/a", ignoreCase = true)) null else data.eventId, eventColor = EventColor.fromNameOrHexOrId(data.eventColor), hoursBefore = data.hoursBefore, minutesBefore = data.minutesBefore, - info = data.info, + info = if (data.info.isBlank() || data.info.equals("None", ignoreCase = true)) null else data.info, enabled = data.enabled, publish = data.publish, ) @@ -56,10 +56,31 @@ data class Announcement( fun getCalculatedTime(): Duration = Duration.ofHours(hoursBefore.toLong()).plusMinutes(minutesBefore.toLong()) + //////////////////////////// + ////// Nested classes ////// + //////////////////////////// data class Subscribers( - val roles: List = listOf(), - val users: List = listOf(), - ) + val roles: Set = setOf(), + val users: Set = setOf(), + ) { + fun buildMentions(): String { + if (users.isEmpty() && roles.isEmpty()) return "" + + val userMentions = users.map { "<@${it.asLong()}> " } + + val roleMentions = roles.map { + if (it.equals("everyone", true)) "@everyone " + else if (it.equals("here", true)) "@here " + else "<@&$it> " + } + + return StringBuilder() + .append("Subscribers: ") + .append(*userMentions.toTypedArray()) + .append(*roleMentions.toTypedArray()) + .toString() + } + } enum class Type { UNIVERSAL, diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/WizardState.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/WizardState.kt new file mode 100644 index 000000000..c602499bc --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/WizardState.kt @@ -0,0 +1,10 @@ +package org.dreamexposure.discal.core.`object`.new + +import discord4j.common.util.Snowflake + +data class WizardState( + val guildId: Snowflake, + val userId: Snowflake, + val editing: Boolean, + val entity: T, +) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/web/WebGuild.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/web/WebGuild.kt index 5ccf9447a..2eee7a8dd 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/web/WebGuild.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/web/WebGuild.kt @@ -18,11 +18,11 @@ import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.exceptions.BotNotInGuildException import org.dreamexposure.discal.core.extensions.discord4j.getMainCalendar import org.dreamexposure.discal.core.`object`.GuildSettings -import org.dreamexposure.discal.core.`object`.announcement.Announcement import reactor.core.publisher.Mono import reactor.core.publisher.Mono.justOrEmpty import reactor.function.TupleUtils +@Deprecated("Yeah, this is a disaster") @Serializable data class WebGuild( @Serializable(with = LongAsStringSerializer::class) @@ -46,7 +46,7 @@ data class WebGuild( ) { val roles: MutableList = mutableListOf() val channels: MutableList = mutableListOf() - val announcements: MutableList = mutableListOf() + val announcements: List = emptyList() @SerialName("available_langs") val availableLangs: MutableList = mutableListOf() @@ -77,20 +77,17 @@ data class WebGuild( .map { channel -> WebChannel.fromChannel(channel) } .collectList() - val announcements = DatabaseManager.getAnnouncements(id) - //TODO: Support multi-cal val calendar = g.getMainCalendar() .map { it.toWebCalendar() } .defaultIfEmpty(WebCalendar.empty()) - Mono.zip(botNick, settings, roles, webChannels, announcements, calendar) - .map(TupleUtils.function { bn, s, r, wc, a, c -> + Mono.zip(botNick, settings, roles, webChannels, calendar) + .map(TupleUtils.function { bn, s, r, wc, c -> WebGuild(id.asLong(), name, ico, s, bn, elevatedAccess = false, discalRole = false, c).apply { this.roles.addAll(r) this.channels.addAll(wc) - this.announcements.addAll(a) } }) }.onErrorResume(ClientException::class.java) { @@ -119,19 +116,16 @@ data class WebGuild( .map { channel -> WebChannel.fromChannel(channel) } .collectList() - val announcements = DatabaseManager.getAnnouncements(g.id) - //TODO: Support multi-cal val calendar = g.getMainCalendar() .map { it.toWebCalendar() } .defaultIfEmpty(WebCalendar.empty()) - return Mono.zip(botNick, settings, roles, channels, announcements, calendar) - .map(TupleUtils.function { bn, s, r, wc, a, c -> + return Mono.zip(botNick, settings, roles, channels, calendar) + .map(TupleUtils.function { bn, s, r, wc, c -> WebGuild(id, name, icon, s, bn, elevatedAccess = false, discalRole = false, c).apply { this.roles.addAll(r) this.channels.addAll(wc) - this.announcements.addAll(a) } }) } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt b/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt index 3af3dc9c3..470054578 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt @@ -12,3 +12,4 @@ typealias CalendarCache = CacheRepository> typealias RsvpCache = CacheRepository typealias StaticMessageCache = CacheRepository typealias AnnouncementCache = CacheRepository> +typealias AnnouncementWizardStateCache = CacheRepository> diff --git a/core/src/main/resources/commands/global/announcement.json b/core/src/main/resources/commands/global/announcement.json index 0b0025043..b1903b722 100644 --- a/core/src/main/resources/commands/global/announcement.json +++ b/core/src/main/resources/commands/global/announcement.json @@ -422,9 +422,15 @@ "required": true }, { - "name": "sub", - "type": 9, - "description": "A role or user to subscribe to the announcement", + "name": "user", + "type": 6, + "description": "A user to subscribe to the announcement (Do not include to self-subscribe)", + "required": false + }, + { + "name": "role", + "type": 8, + "description": "A role to subscribe to the announcement (Do not include to self-subscribe)", "required": false } ] @@ -436,15 +442,15 @@ "required": false, "options": [ { - "name": "announcement", - "type": 3, - "description": "The announcement to unsubscribe from", - "required": true + "name": "user", + "type": 6, + "description": "A user to unsubscribe from the announcement (Do not include to self-unsubscribe)", + "required": false }, { - "name": "sub", - "type": 9, - "description": "A role or user to unsubscribe from the announcement", + "name": "role", + "type": 8, + "description": "A role to unsubscribe from the announcement (Do not include to self-unsubscribe)", "required": false } ] diff --git a/core/src/main/resources/i18n/command/announcement/announcement.properties b/core/src/main/resources/i18n/command/announcement/announcement.properties index 7df0b25ed..cdc98e5e4 100644 --- a/core/src/main/resources/i18n/command/announcement/announcement.properties +++ b/core/src/main/resources/i18n/command/announcement/announcement.properties @@ -24,6 +24,11 @@ publish.success=Successfully toggled announcement publish (only possible when po confirm.success.create=Announcement successfully created! confirm.success.edit=Announcement successfully edited! +confirm.failure.missing-event-id=Please make sure to provide an event ID for this type of event with `/announcement event`. +confirm.failure.missing-event-color=Please make sure to provide a color for this type of event with `/announcement color`. +confirm.failure.minimum-time=Time (hours + minutes) is less than 5 minutes, we cannot guarantee this announcement will be sent. \n\ + Please set the time with `/announcement minutes` and/or `/announcement hours`. + confirm.failure.missing=Please make sure all required properties are set. confirm.failure.create=Announcement creation failed. Please double-check that everything is correct and try again.\n\n\ If this continues, please request support in our Discord Guild. @@ -49,11 +54,9 @@ list.success.none=No announcements were found. list.success.one=1 announcement was found. list.success.many={0} announcements were found. There may be a delay in listing the announcements... -subscribe.success.self=Successfully subscribed to the announcement. -subscribe.success.other=Successfully subscribed {0} to the announcement. +subscribe.success=Successfully subscribed to the announcement. -unsubscribe.success.self=Successfully unsubscribed from the announcement. -unsubscribe.success.other=Successfully unsubscribed {0} from the announcement. +unsubscribe.success=Successfully unsubscribed from the announcement. error.wizard.started=Announcement wizard has already been started. diff --git a/core/src/main/resources/i18n/embed/announcement.properties b/core/src/main/resources/i18n/embed/announcement.properties index 6c2a292c4..0dc86be46 100644 --- a/core/src/main/resources/i18n/embed/announcement.properties +++ b/core/src/main/resources/i18n/embed/announcement.properties @@ -64,7 +64,7 @@ wizard.field.publish=Publish wizard.field.warnings=\u26A0 Warnings wizard.footer=\u2666 Required Information -warning.wizard.eventId=- Event ID is required for this announcement type -warning.wizard.color=- Color is required for this announcement type -warning.wizard.time=- Time (hours + minutes) is less than 5 minutes, we cannot guarantee this announcement will be sent. -warning.wizard.calNum=- Calendar may not exist. Are you sure a calendar with the specified number has been created? +warning.wizard.eventId=`- Event ID is required for this announcement type` +warning.wizard.color=`- Color is required for this announcement type` +warning.wizard.time=`- Time (hours + minutes) is less than 5 minutes, we cannot guarantee this announcement will be sent.` +warning.wizard.calNum=`- Calendar may not exist. Are you sure a calendar with the specified number has been created?` From dd3216f2146903a34c1ca145a831f35a646a934b Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Fri, 8 Mar 2024 13:08:41 -0600 Subject: [PATCH 20/43] Fix announcement filtering --- .../discal/core/business/AnnouncementService.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt index 422ee3e74..bbf4d1c44 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt @@ -83,10 +83,10 @@ class AnnouncementService( return announcements } - suspend fun getAllAnnouncements(guildId: Snowflake, type: Announcement.Type? = null, returnDisabled: Boolean? = true): List { - return getAllAnnouncements(guildId).filter { - type?.equals(it.type) ?: true - }.filter { returnDisabled?.equals(true) ?: it.enabled } + suspend fun getAllAnnouncements(guildId: Snowflake, type: Announcement.Type? = null, returnDisabled: Boolean = true): List { + return getAllAnnouncements(guildId) + .filter { if (type == null) true else it.type == type } + .filter { if (returnDisabled) true else it.enabled } } suspend fun getAnnouncement(guildId: Snowflake, id: String): Announcement? { From d479aaf58200f17f8658a3e28ccefd17a5633604 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Fri, 8 Mar 2024 23:43:49 -0600 Subject: [PATCH 21/43] Update Spring and some other dependencies --- build.gradle.kts | 8 ++++++-- gradle.properties | 13 +++++++------ .../discal/server/utils/Authentication.kt | 5 +++-- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index f0df171b3..85d50de4f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,6 +45,7 @@ allprojects { val mySqlConnectorJava: String by properties // Serialization val kotlinxSerializationJsonVersion: String by properties + val orgJsonVersion: String by properties // Observability val logbackContribVersion: String by properties // Google libs @@ -76,7 +77,10 @@ allprojects { // Discord implementation("com.discord4j:discord4j-core:$discord4jVersion") implementation("com.discord4j:stores-redis:$discord4jStoresVersion") - implementation("club.minnced:discord-webhooks:$discordWebhookVersion") + implementation("club.minnced:discord-webhooks:$discordWebhookVersion") { + // Due to vulnerability in older versions: https://github.com/advisories/GHSA-rm7j-f5g5-27vv + exclude(group = "org.json", module = "json") + } // Spring implementation("org.springframework.boot:spring-boot-starter-data-jdbc") @@ -94,7 +98,7 @@ allprojects { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationJsonVersion") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") - implementation("org.json:json") + implementation("org.json:json:$orgJsonVersion") // Observability implementation("ch.qos.logback.contrib:logback-json-classic:$logbackContribVersion") diff --git a/gradle.properties b/gradle.properties index e5c777e8c..cce3be705 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,13 +1,13 @@ # Language -kotlinVersion=1.9.10 +kotlinVersion=1.9.23 # Plugins -springDependencyManagementVersion=1.1.3 +springDependencyManagementVersion=1.1.4 jibVersion=3.4.1 gitPropertiesVersion=2.4.1 # Buildscript tooling -kotlinPoetVersion=1.14.2 +kotlinPoetVersion=1.16.0 # Discord discord4jVersion=3.2.6 @@ -15,14 +15,15 @@ discord4jStoresVersion=3.2.2 discordWebhookVersion=0.8.4 # Spring -springVersion=3.1.3 +springVersion=3.2.3 # Database mikuR2dbcMySqlVersion=0.8.2.RELEASE mySqlConnectorJava=8.0.33 # Serialization -kotlinxSerializationJsonVersion=1.6.0 +kotlinxSerializationJsonVersion=1.6.3 +orgJsonVersion=20240303 # Observability logbackContribVersion=0.1.5 @@ -33,7 +34,7 @@ googleServicesCalendarVersion=v3-rev20220715-2.0.0 googleOauthClientVersion=1.34.1 # Various Libs -okhttpVersion=4.11.0 +okhttpVersion=4.12.0 copyDownVersion=1.1 jsoupVersion=1.16.1 diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/utils/Authentication.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/utils/Authentication.kt index e50d56d2a..163c193fd 100644 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/utils/Authentication.kt +++ b/server/src/main/kotlin/org/dreamexposure/discal/server/utils/Authentication.kt @@ -6,6 +6,7 @@ import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.`object`.web.AuthenticationState import org.dreamexposure.discal.core.utils.GlobalVal import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT +import org.springframework.http.HttpMethod import org.springframework.web.server.ServerWebExchange import reactor.core.publisher.Flux import reactor.core.publisher.Mono @@ -27,8 +28,8 @@ object Authentication { fun authenticate(swe: ServerWebExchange): Mono { //Check if correct method - if (!swe.request.methodValue.equals("POST", true) || swe.request.methodValue.equals("GET", true)) { - LOGGER.debug("Denied access | Method: ${swe.request.methodValue}") + if (swe.request.method != HttpMethod.POST || swe.request.method == HttpMethod.GET) { + LOGGER.debug("Denied access | Method: ${swe.request.method.name()}") return Mono.just(AuthenticationState(false) .status(GlobalVal.STATUS_NOT_ALLOWED) .reason("Method not allowed") From 5a6d0cd4f8043431a0c008e25c44df865a3fe66e Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Sun, 10 Mar 2024 22:16:53 -0500 Subject: [PATCH 22/43] Upgrade GH actions to run on node 20 --- .github/workflows/gradle.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 8c5238293..c9b74929e 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -16,9 +16,9 @@ jobs: matrix: java: [ 17 ] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - uses: actions/cache@v1 + - uses: actions/cache@v4 with: path: ~/.gradle/caches key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} @@ -26,18 +26,18 @@ jobs: ${{ runner.os }}-gradle- - name: Install NPM - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: 14.8.0 - name: Set up JDK ${{ matrix.java }} - uses: actions/setup-java@v2 + uses: actions/setup-java@v4 with: java-version: ${{ matrix.java }} distribution: 'adopt' - name: Validate gradle - uses: gradle/wrapper-validation-action@v1 + uses: gradle/wrapper-validation-action@v2 - name: Change wrapper permissions run: chmod +x ./gradlew @@ -51,11 +51,11 @@ jobs: if: ${{ github.event_name != 'pull_request' && (github.ref_name == 'develop' || github.ref_name == 'master') }} needs: build steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/cache@v1 + - uses: actions/cache@v4 with: path: ~/.gradle/caches key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} @@ -63,12 +63,12 @@ jobs: ${{ runner.os }}-gradle- - name: Install NPM - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: 14.8.0 - name: Set up JDK - uses: actions/setup-java@v2 + uses: actions/setup-java@v4 with: java-version: 17 distribution: 'adopt' @@ -82,7 +82,7 @@ jobs: SCW_USER: ${{ secrets.SCW_USER }} SCW_SECRET: ${{ secrets.SCW_SECRET }} with: - command: ./gradlew jib -Djib.to.auth.username=${SCW_USER} -Djib.to.auth.password=${SCW_SECRET} -Djib.console=plain + command: ./gradlew clean jib -Djib.to.auth.username=${SCW_USER} -Djib.to.auth.password=${SCW_SECRET} -Djib.console=plain attempt_limit: 25 # 1 minute in ms attempt_delay: 60000 @@ -92,7 +92,7 @@ jobs: if: github.ref_name == 'develop' needs: publish-artifacts steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 From 7094d64f0aa9c8b2d44397e9be9ab8bb0d811863 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Mon, 11 Mar 2024 00:12:18 -0500 Subject: [PATCH 23/43] Trying to use less tags for images --- cam/build.gradle.kts | 2 +- client/build.gradle.kts | 2 +- server/build.gradle.kts | 2 +- web/build.gradle.kts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cam/build.gradle.kts b/cam/build.gradle.kts index ef96508e7..2b564cd2a 100644 --- a/cam/build.gradle.kts +++ b/cam/build.gradle.kts @@ -33,7 +33,7 @@ jib { } image = "rg.nl-ams.scw.cloud/dreamexposure/discal-cam" - tags = mutableSetOf("latest", version.toString(), buildVersion) + tags = mutableSetOf("latest", buildVersion) } val baseImage: String by properties diff --git a/client/build.gradle.kts b/client/build.gradle.kts index c12a3bdd3..24bec7297 100644 --- a/client/build.gradle.kts +++ b/client/build.gradle.kts @@ -32,7 +32,7 @@ jib { } image = "rg.nl-ams.scw.cloud/dreamexposure/discal-client" - tags = mutableSetOf("latest", version.toString(), buildVersion) + tags = mutableSetOf("latest", buildVersion) } val baseImage: String by properties diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 2417960e0..0a8c2af3c 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -36,7 +36,7 @@ jib { } image = "rg.nl-ams.scw.cloud/dreamexposure/discal-server" - tags = mutableSetOf("latest", version.toString(), buildVersion) + tags = mutableSetOf("latest", buildVersion) } val baseImage: String by properties diff --git a/web/build.gradle.kts b/web/build.gradle.kts index c9fea34e1..454d2d6b0 100644 --- a/web/build.gradle.kts +++ b/web/build.gradle.kts @@ -46,7 +46,7 @@ jib { } image = "rg.nl-ams.scw.cloud/dreamexposure/discal-web" - tags = mutableSetOf("latest", version.toString(), buildVersion) + tags = mutableSetOf("latest", buildVersion) } val baseImage: String by properties From d3907b1b8dce30711a43e9cfc85fc1ff710a22f0 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Mon, 11 Mar 2024 16:30:47 -0500 Subject: [PATCH 24/43] Attempting to fix unexplained casing exception --- .../discal/core/business/AnnouncementService.kt | 6 +++--- .../org/dreamexposure/discal/core/config/CacheConfig.kt | 2 +- .../src/main/kotlin/org/dreamexposure/discal/typealiases.kt | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt index bbf4d1c44..76881db28 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt @@ -7,6 +7,7 @@ import discord4j.rest.http.client.ClientException import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull import org.dreamexposure.discal.AnnouncementCache +import org.dreamexposure.discal.AnnouncementWizard import org.dreamexposure.discal.AnnouncementWizardStateCache import org.dreamexposure.discal.core.database.AnnouncementData import org.dreamexposure.discal.core.database.AnnouncementRepository @@ -15,7 +16,6 @@ import org.dreamexposure.discal.core.entities.Event import org.dreamexposure.discal.core.extensions.discord4j.getCalendar import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.`object`.new.Announcement -import org.dreamexposure.discal.core.`object`.new.WizardState import org.springframework.beans.factory.BeanFactory import org.springframework.beans.factory.getBean import org.springframework.stereotype.Component @@ -247,11 +247,11 @@ class AnnouncementService( metricService.recordAnnouncementTaskDuration("guild", taskTimer.totalTimeMillis) } - suspend fun getWizard(guildId: Snowflake, userId: Snowflake): WizardState? { + suspend fun getWizard(guildId: Snowflake, userId: Snowflake): AnnouncementWizard? { return announcementWizardStateCache.get(guildId, userId) } - suspend fun putWizard(state: WizardState) { + suspend fun putWizard(state: AnnouncementWizard) { announcementWizardStateCache.put(state.guildId, state.userId, state) } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt index 44c8d1330..6437ead55 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/config/CacheConfig.kt @@ -63,7 +63,7 @@ class CacheConfig { @Primary @ConditionalOnProperty("bot.cache.redis", havingValue = "true") fun announcementWizardRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): AnnouncementWizardStateCache = - RedisStringCacheRepository(objectMapper, redisTemplate, "Wizards.Announcements", wizardTtl) + RedisStringCacheRepository(objectMapper, redisTemplate, "AnnouncementWizards", wizardTtl) // In-memory fallback caching diff --git a/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt b/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt index 470054578..49bf9db0a 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt @@ -12,4 +12,6 @@ typealias CalendarCache = CacheRepository> typealias RsvpCache = CacheRepository typealias StaticMessageCache = CacheRepository typealias AnnouncementCache = CacheRepository> -typealias AnnouncementWizardStateCache = CacheRepository> +typealias AnnouncementWizardStateCache = CacheRepository + +typealias AnnouncementWizard = WizardState From 57dc352f33c75427065fcdac57e379f89ee77ad8 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Mon, 11 Mar 2024 18:28:48 -0500 Subject: [PATCH 25/43] Switch to new MySQL drivers --- build.gradle.kts | 7 ++----- gradle.properties | 4 ---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 85d50de4f..ad8ab89db 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -40,9 +40,6 @@ allprojects { val discord4jVersion: String by properties val discord4jStoresVersion: String by properties val discordWebhookVersion: String by properties - // Database\ - val mikuR2dbcMySqlVersion: String by properties - val mySqlConnectorJava: String by properties // Serialization val kotlinxSerializationJsonVersion: String by properties val orgJsonVersion: String by properties @@ -91,8 +88,8 @@ allprojects { implementation("org.springframework.boot:spring-boot-starter-actuator") // Database - implementation("dev.miku:r2dbc-mysql:$mikuR2dbcMySqlVersion") - implementation("mysql:mysql-connector-java:$mySqlConnectorJava") + implementation("io.asyncer:r2dbc-mysql") + implementation("com.mysql:mysql-connector-j") // Serialization implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationJsonVersion") diff --git a/gradle.properties b/gradle.properties index cce3be705..174b76957 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,10 +17,6 @@ discordWebhookVersion=0.8.4 # Spring springVersion=3.2.3 -# Database -mikuR2dbcMySqlVersion=0.8.2.RELEASE -mySqlConnectorJava=8.0.33 - # Serialization kotlinxSerializationJsonVersion=1.6.3 orgJsonVersion=20240303 From 7ca110b5ff53bedeaf9ba1783a06794b06932edb Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Mon, 11 Mar 2024 20:53:22 -0500 Subject: [PATCH 26/43] I'm bummed that I can't do this in a cleaner way --- .../commands/global/AnnouncementCommand.kt | 8 ++++---- .../discal/core/business/AnnouncementService.kt | 6 +++--- .../discal/core/object/new/WizardState.kt | 17 ++++++++++++----- .../org/dreamexposure/discal/typealiases.kt | 4 +--- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt index bf71c3ed0..9dbefd02c 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt @@ -18,7 +18,7 @@ import org.dreamexposure.discal.core.extensions.discord4j.getCalendar import org.dreamexposure.discal.core.extensions.discord4j.hasControlRole import org.dreamexposure.discal.core.`object`.GuildSettings import org.dreamexposure.discal.core.`object`.new.Announcement -import org.dreamexposure.discal.core.`object`.new.WizardState +import org.dreamexposure.discal.core.`object`.new.AnnouncementWizardState import org.dreamexposure.discal.core.utils.getCommonMsg import org.springframework.stereotype.Component import kotlin.jvm.optionals.getOrNull @@ -97,7 +97,7 @@ class AnnouncementCommand( .awaitSingle() } - val newWizard = WizardState( + val newWizard = AnnouncementWizardState( guildId = settings.guildID, userId = event.interaction.user.id, editing = false, @@ -495,7 +495,7 @@ class AnnouncementCommand( val announcement = announcementService.getAnnouncement(settings.guildID, announcementId) ?: return event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings)).awaitSingle() - val newWizard = WizardState( + val newWizard = AnnouncementWizardState( guildId = settings.guildID, userId = event.interaction.user.id, editing = true, @@ -531,7 +531,7 @@ class AnnouncementCommand( val announcement = announcementService.getAnnouncement(settings.guildID, announcementId) ?: return event.followupEphemeral(getCommonMsg("error.notFound.announcement", settings)).awaitSingle() - val newWizard = WizardState( + val newWizard = AnnouncementWizardState( guildId = settings.guildID, userId = event.interaction.user.id, editing = false, diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt index 76881db28..09b41c49f 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt @@ -7,7 +7,6 @@ import discord4j.rest.http.client.ClientException import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull import org.dreamexposure.discal.AnnouncementCache -import org.dreamexposure.discal.AnnouncementWizard import org.dreamexposure.discal.AnnouncementWizardStateCache import org.dreamexposure.discal.core.database.AnnouncementData import org.dreamexposure.discal.core.database.AnnouncementRepository @@ -16,6 +15,7 @@ import org.dreamexposure.discal.core.entities.Event import org.dreamexposure.discal.core.extensions.discord4j.getCalendar import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.`object`.new.Announcement +import org.dreamexposure.discal.core.`object`.new.AnnouncementWizardState import org.springframework.beans.factory.BeanFactory import org.springframework.beans.factory.getBean import org.springframework.stereotype.Component @@ -247,11 +247,11 @@ class AnnouncementService( metricService.recordAnnouncementTaskDuration("guild", taskTimer.totalTimeMillis) } - suspend fun getWizard(guildId: Snowflake, userId: Snowflake): AnnouncementWizard? { + suspend fun getWizard(guildId: Snowflake, userId: Snowflake): AnnouncementWizardState? { return announcementWizardStateCache.get(guildId, userId) } - suspend fun putWizard(state: AnnouncementWizard) { + suspend fun putWizard(state: AnnouncementWizardState) { announcementWizardStateCache.put(state.guildId, state.userId, state) } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/WizardState.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/WizardState.kt index c602499bc..3b04b6de5 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/WizardState.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/object/new/WizardState.kt @@ -2,9 +2,16 @@ package org.dreamexposure.discal.core.`object`.new import discord4j.common.util.Snowflake -data class WizardState( - val guildId: Snowflake, - val userId: Snowflake, - val editing: Boolean, - val entity: T, +abstract class WizardState( + open val guildId: Snowflake, + open val userId: Snowflake, + open val editing: Boolean, + open val entity: T, ) + +data class AnnouncementWizardState( + override val guildId: Snowflake, + override val userId: Snowflake, + override val editing: Boolean, + override val entity: Announcement, +) : WizardState(guildId, userId, editing, entity) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt b/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt index 49bf9db0a..a517cd433 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/typealiases.kt @@ -12,6 +12,4 @@ typealias CalendarCache = CacheRepository> typealias RsvpCache = CacheRepository typealias StaticMessageCache = CacheRepository typealias AnnouncementCache = CacheRepository> -typealias AnnouncementWizardStateCache = CacheRepository - -typealias AnnouncementWizard = WizardState +typealias AnnouncementWizardStateCache = CacheRepository From 95532fa40c9d82fd7336cc41fc597ad0d2a8977c Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Tue, 12 Mar 2024 13:10:28 -0500 Subject: [PATCH 27/43] Fix formatting in announcement wizard embed and attempt to fix weird redis bug --- .../discal/core/business/AnnouncementService.kt | 10 ++++++---- .../dreamexposure/discal/core/business/EmbedService.kt | 8 ++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt index 09b41c49f..9fa2aaec9 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt @@ -125,18 +125,20 @@ class AnnouncementService( suspend fun deleteAnnouncement(guildId: Snowflake, id: String) { announcementRepository.deleteByAnnouncementId(id).awaitSingleOrNull() - val cached = announcementCache.get(key = guildId) + val cached = announcementCache.get(key = guildId)?.toMutableList() if (cached != null) { - announcementCache.put(key = guildId, value = cached.filterNot { it.id == id }.toTypedArray()) + cached.removeIf { it.id == id } + announcementCache.put(key = guildId, value = cached.toTypedArray()) } } suspend fun deleteAnnouncements(guildId: Snowflake, eventId: String) { announcementRepository.deleteAllByGuildIdAndEventId(guildId.asLong(), eventId).awaitSingleOrNull() - val cached = announcementCache.get(key = guildId) + val cached = announcementCache.get(key = guildId)?.toMutableList() if (cached != null) { - announcementCache.put(key = guildId, value = cached.filterNot { it.eventId == eventId }.toTypedArray()) + cached.removeIf { it.eventId == eventId } + announcementCache.put(key = guildId, value = cached.toTypedArray()) } } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt index 59e2d3874..6b3b35db9 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/EmbedService.kt @@ -566,7 +566,7 @@ class EmbedService( ) } - if (announcement.info == "None" || announcement.info.isNullOrBlank()) builder.addField( + if (announcement.info.isNullOrBlank()) builder.addField( getEmbedMessage("announcement", "wizard.field.info", settings), getCommonMsg("embed.unset", settings), false @@ -594,11 +594,11 @@ class EmbedService( // Build up any warnings val warningsBuilder = StringBuilder() if ((announcement.type == Announcement.Type.SPECIFIC || announcement.type == Announcement.Type.RECUR) && announcement.eventId.isNullOrBlank()) - warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.eventId", settings)) + warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.eventId", settings)).appendLine() if (announcement.type == Announcement.Type.COLOR && announcement.eventColor == EventColor.NONE) - warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.color", settings)) + warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.color", settings)).appendLine() if (announcement.getCalculatedTime() < Duration.ofMinutes(5)) - warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.time", settings)) + warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.time", settings)).appendLine() if (announcement.calendarNumber > settings.maxCalendars) warningsBuilder.appendLine(getEmbedMessage("announcement", "warning.wizard.calNum", settings)) From 2c371a51664a3353d1495062a4b1ed3572b6ab5b Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Tue, 12 Mar 2024 13:53:28 -0500 Subject: [PATCH 28/43] Adding try/catch for debugging --- .../client/commands/global/AnnouncementCommand.kt | 1 - .../discal/core/business/AnnouncementService.kt | 14 +++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt index 9dbefd02c..42089b6ad 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt @@ -557,7 +557,6 @@ class AnnouncementCommand( // If announcement is being edited, cancel the editor announcementService.cancelWizard(settings.guildID, announcementId) - announcementService.deleteAnnouncement(settings.guildID, announcementId) return event.createFollowup(getMessage("delete.success", settings)) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt index 9fa2aaec9..eecdeea2e 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt @@ -123,12 +123,16 @@ class AnnouncementService( } suspend fun deleteAnnouncement(guildId: Snowflake, id: String) { - announcementRepository.deleteByAnnouncementId(id).awaitSingleOrNull() + try { + announcementRepository.deleteByAnnouncementId(id).awaitSingleOrNull() - val cached = announcementCache.get(key = guildId)?.toMutableList() - if (cached != null) { - cached.removeIf { it.id == id } - announcementCache.put(key = guildId, value = cached.toTypedArray()) + val cached = announcementCache.get(key = guildId)?.toMutableList() + if (cached != null) { + cached.removeIf { it.id == id } + announcementCache.put(key = guildId, value = cached.toTypedArray()) + } + } catch (ex: Exception) { + LOGGER.debug("Failed to delete announcement | guildId:${guildId.asLong()} | announcementId:$id") } } From 9f225baf45b939cc9b1afc8f69a872d262ab832d Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Wed, 13 Mar 2024 00:18:58 -0500 Subject: [PATCH 29/43] I think I found where the error might be --- .../core/business/AnnouncementService.kt | 18 ++++------- .../core/cache/RedisStringCacheRepository.kt | 32 +++++++++++-------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt index eecdeea2e..09b41c49f 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt @@ -123,26 +123,20 @@ class AnnouncementService( } suspend fun deleteAnnouncement(guildId: Snowflake, id: String) { - try { - announcementRepository.deleteByAnnouncementId(id).awaitSingleOrNull() + announcementRepository.deleteByAnnouncementId(id).awaitSingleOrNull() - val cached = announcementCache.get(key = guildId)?.toMutableList() - if (cached != null) { - cached.removeIf { it.id == id } - announcementCache.put(key = guildId, value = cached.toTypedArray()) - } - } catch (ex: Exception) { - LOGGER.debug("Failed to delete announcement | guildId:${guildId.asLong()} | announcementId:$id") + val cached = announcementCache.get(key = guildId) + if (cached != null) { + announcementCache.put(key = guildId, value = cached.filterNot { it.id == id }.toTypedArray()) } } suspend fun deleteAnnouncements(guildId: Snowflake, eventId: String) { announcementRepository.deleteAllByGuildIdAndEventId(guildId.asLong(), eventId).awaitSingleOrNull() - val cached = announcementCache.get(key = guildId)?.toMutableList() + val cached = announcementCache.get(key = guildId) if (cached != null) { - cached.removeIf { it.eventId == eventId } - announcementCache.put(key = guildId, value = cached.toTypedArray()) + announcementCache.put(key = guildId, value = cached.filterNot { it.eventId == eventId }.toTypedArray()) } } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/cache/RedisStringCacheRepository.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/cache/RedisStringCacheRepository.kt index d47e7ebc0..70506d69f 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/cache/RedisStringCacheRepository.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/cache/RedisStringCacheRepository.kt @@ -51,21 +51,27 @@ class RedisStringCacheRepository( } override suspend fun getAll(guildId: Snowflake?): List { - val keys = redisTemplate.scan(ScanOptions.scanOptions() - .type(DataType.STRING) - .match(formatKeySearch(guildId)) - .build() - ).collectList().awaitSingle() - - val rawValues = valueOps.multiGetAndAwait(keys) + try { + val keys = redisTemplate.scan(ScanOptions.scanOptions() + .type(DataType.STRING) + .match(formatKeySearch(guildId)) + .build() + ).collectList().awaitSingle() + if (keys.isEmpty()) return emptyList() + + val rawValues = valueOps.multiGetAndAwait(keys) + + return try { + rawValues.map { objectMapper.readValue(it, valueType) } + } catch (ex: Exception) { + LOGGER.error("Failed to read value from redis... evicting all | guildId:$guildId | keys:${keys.joinToString(",")} | data:${rawValues.joinToString(",")}", ex) + evictAll(guildId) - return try { - rawValues.map { objectMapper.readValue(it, valueType) } + emptyList() + } } catch (ex: Exception) { - LOGGER.error("Failed to read value from redis... evicting all | guildId:$guildId | keys:${keys.joinToString(",")} | data:${rawValues.joinToString(",")}", ex) - evictAll(guildId) - - emptyList() + LOGGER.error("Failed to fetch all | guildId:${guildId?.asLong()}", ex) + return emptyList() } } From 1d949900c97d96b173b3524da3117cc4e5c16961 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Wed, 13 Mar 2024 01:01:49 -0500 Subject: [PATCH 30/43] Fix redis cache implementation bugs --- .../core/cache/RedisStringCacheRepository.kt | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/cache/RedisStringCacheRepository.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/cache/RedisStringCacheRepository.kt index 70506d69f..d521efb32 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/cache/RedisStringCacheRepository.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/cache/RedisStringCacheRepository.kt @@ -51,27 +51,22 @@ class RedisStringCacheRepository( } override suspend fun getAll(guildId: Snowflake?): List { - try { - val keys = redisTemplate.scan(ScanOptions.scanOptions() - .type(DataType.STRING) - .match(formatKeySearch(guildId)) - .build() - ).collectList().awaitSingle() - if (keys.isEmpty()) return emptyList() - - val rawValues = valueOps.multiGetAndAwait(keys) - - return try { - rawValues.map { objectMapper.readValue(it, valueType) } - } catch (ex: Exception) { - LOGGER.error("Failed to read value from redis... evicting all | guildId:$guildId | keys:${keys.joinToString(",")} | data:${rawValues.joinToString(",")}", ex) - evictAll(guildId) + val keys = redisTemplate.scan(ScanOptions.scanOptions() + .type(DataType.STRING) + .match(formatKeySearch(guildId)) + .build() + ).collectList().awaitSingle() + if (keys.isEmpty()) return emptyList() - emptyList() - } + val rawValues = valueOps.multiGetAndAwait(keys) + + return try { + rawValues.map { objectMapper.readValue(it, valueType) } } catch (ex: Exception) { - LOGGER.error("Failed to fetch all | guildId:${guildId?.asLong()}", ex) - return emptyList() + LOGGER.error("Failed to read value from redis... evicting all | guildId:$guildId | keys:${keys.joinToString(",")} | data:${rawValues.joinToString(",")}", ex) + evictAll(guildId) + + emptyList() } } @@ -115,6 +110,7 @@ class RedisStringCacheRepository( .match(formatKeySearch(guildId)) .build() ).collectList().awaitSingle() + if (keys.isEmpty()) return redisTemplate.deleteAndAwait(*keys.toTypedArray()) } From 33c7b0984ec044e9bd6f77618a42e4996398a6ca Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Wed, 13 Mar 2024 16:22:28 -0500 Subject: [PATCH 31/43] Fix showing incorrect wizard state --- .../client/commands/global/AnnouncementCommand.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt index 42089b6ad..09b2eaeab 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt @@ -587,8 +587,8 @@ class AnnouncementCommand( val message = if (enabled) "enable.success" else "disable.success" return event.createFollowup() .withEphemeral(ephemeral) - .withContent("${getMessage(message, settings)}\n\n${announcement.subscribers.buildMentions()}") - .withEmbeds(embedService.viewAnnouncementEmbed(announcement, settings)) + .withContent("${getMessage(message, settings)}\n\n${new.subscribers.buildMentions()}") + .withEmbeds(embedService.viewAnnouncementEmbed(new, settings)) .withAllowedMentions(AllowedMentions.suppressAll()) .awaitSingle() } @@ -686,8 +686,8 @@ class AnnouncementCommand( return event.createFollowup() .withEphemeral(ephemeral) - .withContent("${getMessage("subscribe.success", settings)}\n\n${announcement.subscribers.buildMentions()}") - .withEmbeds(embedService.viewAnnouncementEmbed(announcement, settings)) + .withContent("${getMessage("subscribe.success", settings)}\n\n${new.subscribers.buildMentions()}") + .withEmbeds(embedService.viewAnnouncementEmbed(new, settings)) .withAllowedMentions(AllowedMentions.suppressAll()) .awaitSingle() } @@ -719,8 +719,8 @@ class AnnouncementCommand( return event.createFollowup() .withEphemeral(ephemeral) - .withContent("${getMessage("unsubscribe.success", settings)}\n\n${announcement.subscribers.buildMentions()}") - .withEmbeds(embedService.viewAnnouncementEmbed(announcement, settings)) + .withContent("${getMessage("unsubscribe.success", settings)}\n\n${new.subscribers.buildMentions()}") + .withEmbeds(embedService.viewAnnouncementEmbed(new, settings)) .withAllowedMentions(AllowedMentions.suppressAll()) .awaitSingle() } From 2d384fdf115940b488e1ac9748b249d79ccf53cf Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Wed, 13 Mar 2024 16:45:26 -0500 Subject: [PATCH 32/43] Missing announcement arg in unsubscribe command --- core/src/main/resources/commands/global/announcement.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/src/main/resources/commands/global/announcement.json b/core/src/main/resources/commands/global/announcement.json index b1903b722..6bcff6a11 100644 --- a/core/src/main/resources/commands/global/announcement.json +++ b/core/src/main/resources/commands/global/announcement.json @@ -441,6 +441,12 @@ "description": "Unsubscribes to an announcement", "required": false, "options": [ + { + "name": "announcement", + "type": 3, + "description": "The announcement to subscribe to", + "required": true + }, { "name": "user", "type": 6, From cee25873f3dc0b4ca8aace86e1adb2b6dd6e44a8 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Wed, 13 Mar 2024 17:42:31 -0500 Subject: [PATCH 33/43] Fix bug when updating static messages --- .../discal/core/business/StaticMessageService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/StaticMessageService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/StaticMessageService.kt index 7a271d613..2aaa63db6 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/StaticMessageService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/StaticMessageService.kt @@ -144,7 +144,7 @@ class StaticMessageService( val updated = old.copy( lastUpdate = Instant.now(), - scheduledUpdate = old.scheduledUpdate.plus(1, ChronoUnit.DAYS) + scheduledUpdate = if (old.scheduledUpdate.isBefore(Instant.now())) old.scheduledUpdate.plus(1, ChronoUnit.DAYS) else old.scheduledUpdate ) staticMessageRepository.updateByGuildIdAndMessageId( guildId = updated.guildId.asLong(), @@ -194,7 +194,7 @@ class StaticMessageService( val updated = old.copy( lastUpdate = Instant.now(), - scheduledUpdate = old.scheduledUpdate.plus(1, ChronoUnit.DAYS) + scheduledUpdate = if (old.scheduledUpdate.isBefore(Instant.now())) old.scheduledUpdate.plus(1, ChronoUnit.DAYS) else old.scheduledUpdate ) staticMessageRepository.updateByGuildIdAndMessageId( guildId = updated.guildId.asLong(), From ac536daddf250df4f0b4a42c39ebf8937f0f8fca Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Wed, 13 Mar 2024 19:31:16 -0500 Subject: [PATCH 34/43] This seemed to be more performant (and reliable for that matter) --- .../business/cronjob/AnnouncementCronJob.kt | 23 ++---- .../core/business/AnnouncementService.kt | 82 +++++++++---------- 2 files changed, 50 insertions(+), 55 deletions(-) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/AnnouncementCronJob.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/AnnouncementCronJob.kt index 10ae11eb3..b7ebd8143 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/AnnouncementCronJob.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/AnnouncementCronJob.kt @@ -29,7 +29,7 @@ class AnnouncementCronJob( Flux.interval(interval) .onBackpressureDrop() .flatMap { doAction() } - .doOnError { LOGGER.error(DEFAULT, "!-Announcement run error-!", it) } + .doOnError { LOGGER.error(DEFAULT, "!-Announcement run error-! Failed to process announcements for all guilds", it) } .onErrorResume { Mono.empty() } .subscribe() } @@ -38,21 +38,16 @@ class AnnouncementCronJob( val taskTimer = StopWatch() taskTimer.start() - try { - val guilds = discordClient.guilds.collectList().awaitSingle() + val guilds = discordClient.guilds.collectList().awaitSingle() - guilds.forEach { guild -> - try { - announcementService.processAnnouncementsForGuild(guild.id, maxDifference) - } catch (ex: Exception) { - LOGGER.error("Failed to process announcements for guild | guildId:${guild.id.asLong()}", ex) - } + guilds.forEach { guild -> + try { + announcementService.processAnnouncementsForGuild(guild.id, maxDifference) + } catch (ex: Exception) { + LOGGER.error("Failed to process announcements for guild | guildId:${guild.id.asLong()}", ex) } - } catch (ex: Exception) { - LOGGER.error("Failed to process announcements for all guilds", ex) - } finally { - taskTimer.stop() - metricService.recordAnnouncementTaskDuration("overall", taskTimer.totalTimeMillis) } + taskTimer.stop() + metricService.recordAnnouncementTaskDuration("overall", taskTimer.totalTimeMillis) } } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt index 09b41c49f..57cb37f57 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/business/AnnouncementService.kt @@ -11,6 +11,7 @@ import org.dreamexposure.discal.AnnouncementWizardStateCache import org.dreamexposure.discal.core.database.AnnouncementData import org.dreamexposure.discal.core.database.AnnouncementRepository import org.dreamexposure.discal.core.database.DatabaseManager +import org.dreamexposure.discal.core.entities.Calendar import org.dreamexposure.discal.core.entities.Event import org.dreamexposure.discal.core.extensions.discord4j.getCalendar import org.dreamexposure.discal.core.logger.LOGGER @@ -197,52 +198,51 @@ class AnnouncementService( taskTimer.start() val guild = discordClient.getGuildById(guildId) + val calendars: MutableSet = mutableSetOf() + val events: MutableMap> = mutableMapOf() // TODO: Need to break this out to add handling for modifiers - getAllAnnouncements(guildId = guildId, returnDisabled = false) - .groupBy { it.calendarNumber } - .forEach { calendarPair -> - // Get the calendar - val calendar = guild.getCalendar(calendarPair.key).awaitSingleOrNull() ?: return@forEach - - var events: List? = null - - // Loop through announcements - for (announcement in calendarPair.value) { - // Handle specific type first, since we don't need to fetch all events for this - if (announcement.type == Announcement.Type.SPECIFIC) { - val event = calendar.getEvent(announcement.eventId!!).awaitSingleOrNull() ?: continue - if (isInRange(announcement, event, maxDifference)) { - sendAnnouncement(announcement, event) - } - } - - // Get the events to filter through, we only need to fetch this once for the set of announcements, - if (events == null) { - events = calendar.getUpcomingEvents(20) - .collectList() - .awaitSingle() - } - - - // Handle filtering out events based on this announcement's types - var filteredEvents = events - - if (announcement.type == Announcement.Type.COLOR) { - filteredEvents = filteredEvents?.filter { it.color == announcement.eventColor } - } else if (announcement.type == Announcement.Type.RECUR) { - filteredEvents = filteredEvents - ?.filter { it.eventId.contains("_") } - ?.filter { it.eventId.split("_")[0] == announcement.eventId } - } - - // Loop through filtered events and post any announcements in range - filteredEvents - ?.filter { isInRange(announcement, it, maxDifference) } - ?.forEach { sendAnnouncement(announcement, it) } + getAllAnnouncements(guildId, returnDisabled = false).forEach { announcement -> + // Get the calendar + var calendar = calendars.firstOrNull { it.calendarNumber == announcement.calendarNumber } + if (calendar == null) { + calendar = guild.getCalendar(announcement.calendarNumber).awaitSingleOrNull() ?: return@forEach + calendars.add(calendar) + } + + // Handle specific type first, since we don't need to fetch all events for this + if (announcement.type == Announcement.Type.SPECIFIC) { + val event = calendar.getEvent(announcement.eventId!!).awaitSingleOrNull() ?: return@forEach + if (isInRange(announcement, event, maxDifference)) { + sendAnnouncement(announcement, event) } } + // Get the events to filter through + var filteredEvents = events[calendar.calendarNumber] + if (filteredEvents == null) { + filteredEvents = calendar.getUpcomingEvents(20) + .collectList() + .awaitSingle() + events[calendar.calendarNumber] = filteredEvents + } + + // Handle filtering out events based on this announcement's types + if (announcement.type == Announcement.Type.COLOR) { + filteredEvents = filteredEvents?.filter { it.color == announcement.eventColor } + } else if (announcement.type == Announcement.Type.RECUR) { + filteredEvents = filteredEvents + ?.filter { it.eventId.contains("_") } + ?.filter { it.eventId.split("_")[0] == announcement.eventId } + } + + // Loop through filtered events and post any announcements in range + filteredEvents + ?.filter { isInRange(announcement, it, maxDifference) } + ?.forEach { sendAnnouncement(announcement, it) } + + } + taskTimer.stop() metricService.recordAnnouncementTaskDuration("guild", taskTimer.totalTimeMillis) } From efd8c2a5a1bf968ae62293db50e12a8b6316c457 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Thu, 14 Mar 2024 00:47:01 -0500 Subject: [PATCH 35/43] Add health check for database connectivity --- .../core/database/CalendarRepository.kt | 3 +++ .../health/DisCalDbReactiveHealthIndicator.kt | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/health/DisCalDbReactiveHealthIndicator.kt diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/database/CalendarRepository.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/database/CalendarRepository.kt index 6274fb86d..ff7f209ee 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/database/CalendarRepository.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/database/CalendarRepository.kt @@ -37,4 +37,7 @@ interface CalendarRepository : R2dbcRepository { refreshToken: String, expiresAt: Long, ): Mono + + @Query(" SELECT 1") + fun healthCheck(): Mono } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/health/DisCalDbReactiveHealthIndicator.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/health/DisCalDbReactiveHealthIndicator.kt new file mode 100644 index 000000000..6c3f6f120 --- /dev/null +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/health/DisCalDbReactiveHealthIndicator.kt @@ -0,0 +1,27 @@ +package org.dreamexposure.discal.core.health + +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.mono +import org.dreamexposure.discal.core.database.CalendarRepository +import org.dreamexposure.discal.core.logger.LOGGER +import org.springframework.boot.actuate.health.Health +import org.springframework.boot.actuate.health.ReactiveHealthIndicator +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono + +@Component +class DisCalDbReactiveHealthIndicator( + private val calendarRepository: CalendarRepository, +): ReactiveHealthIndicator { + override fun health(): Mono = mono { + return@mono try { + calendarRepository.healthCheck().awaitSingle() + + Health.up().build() + } catch (ex: Exception) { + LOGGER.error("DisCal database health check failed!", ex) + + Health.outOfService().withException(ex).build() + } + } +} From 680af04888cae6a8c9905b3f94a52ff99d6edea7 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Thu, 14 Mar 2024 17:58:16 -0500 Subject: [PATCH 36/43] Okay, so custom health indicator is too hard to setup, but whatever because I can possibly include r2dbc directly and maybe that'll warm up the connection pool??? --- .../health/DisCalDbReactiveHealthIndicator.kt | 27 ------------------- docker-compose.yml | 1 + 2 files changed, 1 insertion(+), 27 deletions(-) delete mode 100644 core/src/main/kotlin/org/dreamexposure/discal/core/health/DisCalDbReactiveHealthIndicator.kt diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/health/DisCalDbReactiveHealthIndicator.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/health/DisCalDbReactiveHealthIndicator.kt deleted file mode 100644 index 6c3f6f120..000000000 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/health/DisCalDbReactiveHealthIndicator.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.dreamexposure.discal.core.health - -import kotlinx.coroutines.reactor.awaitSingle -import kotlinx.coroutines.reactor.mono -import org.dreamexposure.discal.core.database.CalendarRepository -import org.dreamexposure.discal.core.logger.LOGGER -import org.springframework.boot.actuate.health.Health -import org.springframework.boot.actuate.health.ReactiveHealthIndicator -import org.springframework.stereotype.Component -import reactor.core.publisher.Mono - -@Component -class DisCalDbReactiveHealthIndicator( - private val calendarRepository: CalendarRepository, -): ReactiveHealthIndicator { - override fun health(): Mono = mono { - return@mono try { - calendarRepository.healthCheck().awaitSingle() - - Health.up().build() - } catch (ex: Exception) { - LOGGER.error("DisCal database health check failed!", ex) - - Health.outOfService().withException(ex).build() - } - } -} diff --git a/docker-compose.yml b/docker-compose.yml index bc746853f..729738734 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,7 @@ services: ports: - "8081:8080" - "5006:5005" + - "8008:8008" volumes: - ./.docker/cam:/discal working_dir: /discal From 20a99ec5d3d52b5283f45e0b03dced2844b2a994 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Thu, 14 Mar 2024 22:07:33 -0500 Subject: [PATCH 37/43] Update logback config --- core/src/main/resources/logback-spring.xml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/src/main/resources/logback-spring.xml b/core/src/main/resources/logback-spring.xml index 93f142433..1b2fc863b 100644 --- a/core/src/main/resources/logback-spring.xml +++ b/core/src/main/resources/logback-spring.xml @@ -17,7 +17,7 @@ - + @@ -35,7 +35,7 @@ - + yyyy-MM-dd' 'HH:mm:ss.SSS @@ -47,7 +47,7 @@ - + @@ -79,12 +79,12 @@ - + - + From a89a268725a47a17aa54face80448cc0c491d346 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Fri, 15 Mar 2024 11:16:32 -0500 Subject: [PATCH 38/43] Make static message update task frequency configurable Also default it to every 30 minutes instead of every 60 --- .../client/business/cronjob/StaticMessageUpdateCronJob.kt | 4 +++- .../kotlin/org/dreamexposure/discal/core/config/Config.kt | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StaticMessageUpdateCronJob.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StaticMessageUpdateCronJob.kt index c4b600f20..0d9505da6 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StaticMessageUpdateCronJob.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StaticMessageUpdateCronJob.kt @@ -5,6 +5,8 @@ import org.dreamexposure.discal.Application.Companion.getShardCount import org.dreamexposure.discal.Application.Companion.getShardIndex import org.dreamexposure.discal.core.business.MetricService import org.dreamexposure.discal.core.business.StaticMessageService +import org.dreamexposure.discal.core.config.Config +import org.dreamexposure.discal.core.extensions.asMinutes import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT import org.springframework.boot.ApplicationArguments @@ -22,7 +24,7 @@ class StaticMessageUpdateCronJob( private val metricService: MetricService, ):ApplicationRunner { override fun run(args: ApplicationArguments?) { - Flux.interval(Duration.ofHours(1)) + Flux.interval(Config.TIMING_STATIC_MESSAGE_UPDATE_TASK_RUN_INTERVAL_MINUTES.getLong().asMinutes()) .onBackpressureDrop() .flatMap { doUpdate() } .onErrorResume { Mono.empty() } diff --git a/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt b/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt index e19e837af..2b90ea3f7 100644 --- a/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt +++ b/core/src/main/kotlin/org/dreamexposure/discal/core/config/Config.kt @@ -36,6 +36,7 @@ enum class Config(private val key: String, private var value: Any? = null) { TIMING_BOT_STATUS_UPDATE_MINUTES("bot.timing.status-update.minutes", 5), TIMING_ANNOUNCEMENT_TASK_RUN_INTERVAL_MINUTES("bot.timing.announcement.task-run-interval.minutes", 5), TIMING_WIZARD_TIMEOUT_MINUTES("bot.timing.wizard-timeout.minutes", 30), + TIMING_STATIC_MESSAGE_UPDATE_TASK_RUN_INTERVAL_MINUTES("bot.timing.static-message.update.task-run-interval.minutes", 30), // Bot secrets SECRET_DISCAL_API_KEY("bot.secret.api-token"), From fbd60eb999d625366b503b388ce8a240371ac371 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Sun, 17 Mar 2024 20:08:14 -0500 Subject: [PATCH 39/43] This should allow metrics to see subcommand usage --- .../dreamexposure/discal/client/commands/SlashCommand.kt | 2 +- .../dreamexposure/discal/client/commands/dev/DevCommand.kt | 7 ++++--- .../discal/client/commands/global/AnnouncementCommand.kt | 1 + .../discal/client/commands/global/CalendarCommand.kt | 1 + .../discal/client/commands/global/DiscalCommand.kt | 1 + .../client/commands/global/DisplayCalendarCommand.kt | 1 + .../discal/client/commands/global/EventCommand.kt | 1 + .../discal/client/commands/global/EventsCommand.kt | 1 + .../discal/client/commands/global/HelpCommand.kt | 1 + .../discal/client/commands/global/LinkCalendarCommand.kt | 1 + .../discal/client/commands/global/RsvpCommand.kt | 1 + .../discal/client/commands/global/SettingsCommand.kt | 1 + .../discal/client/commands/global/TimeCommand.kt | 1 + .../discal/client/commands/premium/AddCalCommand.kt | 1 + .../client/listeners/discord/SlashCommandListener.kt | 5 ++++- 15 files changed, 21 insertions(+), 5 deletions(-) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/SlashCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/SlashCommand.kt index c04553a1a..3cc219ad2 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/SlashCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/SlashCommand.kt @@ -10,7 +10,7 @@ import reactor.core.publisher.Mono interface SlashCommand { val name: String - + val hasSubcommands: Boolean val ephemeral: Boolean @Deprecated("Use new handleSuspend for K-coroutines") diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/dev/DevCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/dev/DevCommand.kt index 5b86be54f..87b4aa8a6 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/dev/DevCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/dev/DevCommand.kt @@ -1,16 +1,16 @@ package org.dreamexposure.discal.client.commands.dev +import discord4j.core.event.domain.interaction.ChatInputInteractionEvent import discord4j.core.`object`.command.ApplicationCommandInteractionOption import discord4j.core.`object`.command.ApplicationCommandInteractionOptionValue import discord4j.core.`object`.entity.Message -import discord4j.core.event.domain.interaction.ChatInputInteractionEvent import org.dreamexposure.discal.client.commands.SlashCommand -import org.dreamexposure.discal.core.`object`.GuildSettings -import org.dreamexposure.discal.core.`object`.web.UserAPIAccount import org.dreamexposure.discal.core.crypto.KeyGenerator.csRandomAlphaNumericString import org.dreamexposure.discal.core.database.DatabaseManager import org.dreamexposure.discal.core.extensions.discord4j.followupEphemeral import org.dreamexposure.discal.core.logger.LOGGER +import org.dreamexposure.discal.core.`object`.GuildSettings +import org.dreamexposure.discal.core.`object`.web.UserAPIAccount import org.dreamexposure.discal.core.utils.GlobalVal import org.springframework.stereotype.Component import reactor.core.publisher.Mono @@ -18,6 +18,7 @@ import reactor.core.publisher.Mono @Component class DevCommand : SlashCommand { override val name = "dev" + override val hasSubcommands = true override val ephemeral = true @Deprecated("Use new handleSuspend for K-coroutines") diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt index 09b2eaeab..edd74151f 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/AnnouncementCommand.kt @@ -29,6 +29,7 @@ class AnnouncementCommand( private val embedService: EmbedService, ) : SlashCommand { override val name = "announcement" + override val hasSubcommands = true override val ephemeral = true override suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message { diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/CalendarCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/CalendarCommand.kt index a3e5c99ef..854f0d8de 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/CalendarCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/CalendarCommand.kt @@ -30,6 +30,7 @@ class CalendarCommand( private val staticMessageService: StaticMessageService ) : SlashCommand { override val name = "calendar" + override val hasSubcommands = true override val ephemeral = true @Deprecated("Use new handleSuspend for K-coroutines") diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DiscalCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DiscalCommand.kt index 41640560a..e5c2efadb 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DiscalCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DiscalCommand.kt @@ -18,6 +18,7 @@ class DiscalCommand( private val embedService: EmbedService, ) : SlashCommand { override val name = "discal" + override val hasSubcommands = false override val ephemeral = false override suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message { diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DisplayCalendarCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DisplayCalendarCommand.kt index 861216f07..70156bd24 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DisplayCalendarCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/DisplayCalendarCommand.kt @@ -20,6 +20,7 @@ class DisplayCalendarCommand( private val staticMessageService: StaticMessageService, ) : SlashCommand { override val name = "displaycal" + override val hasSubcommands = true override val ephemeral = true diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/EventCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/EventCommand.kt index f4a6a8730..2528575ab 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/EventCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/EventCommand.kt @@ -36,6 +36,7 @@ class EventCommand( private val staticMessageService: StaticMessageService ) : SlashCommand { override val name = "event" + override val hasSubcommands = true override val ephemeral = true @Deprecated("Use new handleSuspend for K-coroutines") diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/EventsCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/EventsCommand.kt index df7e2316c..f7231dcd3 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/EventsCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/EventsCommand.kt @@ -23,6 +23,7 @@ import java.time.format.DateTimeParseException @Component class EventsCommand : SlashCommand { override val name = "events" + override val hasSubcommands = true override val ephemeral = false @Deprecated("Use new handleSuspend for K-coroutines") diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/HelpCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/HelpCommand.kt index 3d2bc45e2..98e815968 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/HelpCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/HelpCommand.kt @@ -12,6 +12,7 @@ import org.springframework.stereotype.Component @Component class HelpCommand : SlashCommand { override val name = "help" + override val hasSubcommands = false override val ephemeral = true override suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message { diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/LinkCalendarCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/LinkCalendarCommand.kt index b42bf7f55..0091caf2b 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/LinkCalendarCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/LinkCalendarCommand.kt @@ -19,6 +19,7 @@ class LinkCalendarCommand( private val embedService: EmbedService, ) : SlashCommand { override val name = "linkcal" + override val hasSubcommands = false override val ephemeral = false diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/RsvpCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/RsvpCommand.kt index 479ad5fb2..e266020c8 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/RsvpCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/RsvpCommand.kt @@ -24,6 +24,7 @@ class RsvpCommand( private val embedService: EmbedService, ) : SlashCommand { override val name = "rsvp" + override val hasSubcommands = true override val ephemeral = true diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/SettingsCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/SettingsCommand.kt index a3ddc9582..c147425c2 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/SettingsCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/SettingsCommand.kt @@ -20,6 +20,7 @@ import reactor.core.publisher.Mono @Component class SettingsCommand : SlashCommand { override val name = "settings" + override val hasSubcommands = true override val ephemeral = true @Deprecated("Use new handleSuspend for K-coroutines") diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/TimeCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/TimeCommand.kt index bc9ad7f92..1d7c6bc65 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/TimeCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/global/TimeCommand.kt @@ -19,6 +19,7 @@ class TimeCommand( private val embedService: EmbedService, ) : SlashCommand { override val name = "time" + override val hasSubcommands = false override val ephemeral = true override suspend fun suspendHandle(event: ChatInputInteractionEvent, settings: GuildSettings): Message { diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/premium/AddCalCommand.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/premium/AddCalCommand.kt index 4cbd0903a..80e449c84 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/commands/premium/AddCalCommand.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/commands/premium/AddCalCommand.kt @@ -17,6 +17,7 @@ import reactor.core.publisher.Mono @Component class AddCalCommand : SlashCommand { override val name = "addcal" + override val hasSubcommands = false override val ephemeral = true @Deprecated("Use new handleSuspend for K-coroutines") diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/SlashCommandListener.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/SlashCommandListener.kt index 2fb9b3fdc..1b7fd3e21 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/SlashCommandListener.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/discord/SlashCommandListener.kt @@ -29,6 +29,7 @@ class SlashCommandListener( } val command = commands.firstOrNull { it.name == event.commandName } + val subCommand = if (command?.hasSubcommands == true) event.options[0].name else null if (command != null) { event.deferReply().withEphemeral(command.ephemeral).awaitSingleOrNull() @@ -52,6 +53,8 @@ class SlashCommandListener( } timer.stop() - metricService.recordInteractionDuration(event.commandName, "chat-input", timer.totalTimeMillis) + + val computedInteractionName = if (subCommand != null) "/${event.commandName}#$subCommand" else "/${event.commandName}" + metricService.recordInteractionDuration(computedInteractionName, "chat-input", timer.totalTimeMillis) } } From 84d6da28195bb20e21bcf9b19f304109d27546ab Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Sun, 17 Mar 2024 21:33:28 -0500 Subject: [PATCH 40/43] Maybe metrics will be better if I standardize some of the label values --- .../discal/client/business/cronjob/AnnouncementCronJob.kt | 2 +- .../client/business/cronjob/StaticMessageUpdateCronJob.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/AnnouncementCronJob.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/AnnouncementCronJob.kt index b7ebd8143..48c4d9a0f 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/AnnouncementCronJob.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/AnnouncementCronJob.kt @@ -48,6 +48,6 @@ class AnnouncementCronJob( } } taskTimer.stop() - metricService.recordAnnouncementTaskDuration("overall", taskTimer.totalTimeMillis) + metricService.recordAnnouncementTaskDuration("cronjob", taskTimer.totalTimeMillis) } } diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StaticMessageUpdateCronJob.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StaticMessageUpdateCronJob.kt index 0d9505da6..a10439a78 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StaticMessageUpdateCronJob.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/business/cronjob/StaticMessageUpdateCronJob.kt @@ -55,7 +55,7 @@ class StaticMessageUpdateCronJob( LOGGER.error(DEFAULT, "StaticMessageUpdateCronJob failure", ex) } finally { taskTimer.stop() - metricService.recordStaticMessageTaskDuration("overall", taskTimer.totalTimeMillis) + metricService.recordStaticMessageTaskDuration("cronjob", taskTimer.totalTimeMillis) } } } From 74ee20b5865e19a9ac2af222226a23086ef066e3 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Wed, 20 Mar 2024 19:43:31 -0500 Subject: [PATCH 41/43] Move shutdown hook package location --- .../client/{service => listeners/runtime}/ShutdownHook.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename client/src/main/kotlin/org/dreamexposure/discal/client/{service => listeners/runtime}/ShutdownHook.kt (72%) diff --git a/client/src/main/kotlin/org/dreamexposure/discal/client/service/ShutdownHook.kt b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/runtime/ShutdownHook.kt similarity index 72% rename from client/src/main/kotlin/org/dreamexposure/discal/client/service/ShutdownHook.kt rename to client/src/main/kotlin/org/dreamexposure/discal/client/listeners/runtime/ShutdownHook.kt index d898b43da..cb66633aa 100644 --- a/client/src/main/kotlin/org/dreamexposure/discal/client/service/ShutdownHook.kt +++ b/client/src/main/kotlin/org/dreamexposure/discal/client/listeners/runtime/ShutdownHook.kt @@ -1,9 +1,9 @@ -package org.dreamexposure.discal.client.service +package org.dreamexposure.discal.client.listeners.runtime import discord4j.core.GatewayDiscordClient import jakarta.annotation.PreDestroy import org.dreamexposure.discal.core.logger.LOGGER -import org.dreamexposure.discal.core.utils.GlobalVal.STATUS +import org.dreamexposure.discal.core.utils.GlobalVal import org.springframework.stereotype.Component // Required to be a component for shutdown hook due to lifecycle management of the discord client @@ -11,7 +11,7 @@ import org.springframework.stereotype.Component class ShutdownHook(private val discordClient: GatewayDiscordClient) { @PreDestroy fun onShutdown() { - LOGGER.info(STATUS, "Shutting down shard") + LOGGER.info(GlobalVal.STATUS, "Shutting down shard") discordClient.logout().subscribe() } From 4e37215b5a39e0d692f7c62e4e2d337bdd548411 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Wed, 20 Mar 2024 19:55:33 -0500 Subject: [PATCH 42/43] CAM is now free of using any deprecated methods --- .../discal/cam/discord/DiscordOauthHandler.kt | 9 +++++---- .../org/dreamexposure/discal/cam/google/GoogleAuth.kt | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/discord/DiscordOauthHandler.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/discord/DiscordOauthHandler.kt index dad3ec25e..16a660c4c 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/discord/DiscordOauthHandler.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/discord/DiscordOauthHandler.kt @@ -4,13 +4,13 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import kotlinx.coroutines.reactor.awaitSingle import okhttp3.FormBody +import okhttp3.OkHttpClient import okhttp3.Request import org.dreamexposure.discal.cam.json.discord.AccessTokenResponse import org.dreamexposure.discal.cam.json.discord.AuthorizationInfo import org.dreamexposure.discal.core.config.Config import org.dreamexposure.discal.core.exceptions.AuthenticationException import org.dreamexposure.discal.core.utils.GlobalVal -import org.dreamexposure.discal.core.utils.GlobalVal.HTTP_CLIENT import org.springframework.stereotype.Component import reactor.core.publisher.Mono import reactor.core.scheduler.Schedulers @@ -18,6 +18,7 @@ import reactor.core.scheduler.Schedulers @Component class DiscordOauthHandler( private val objectMapper: ObjectMapper, + private val httpClient: OkHttpClient, ) { private val cdnUrl = "https://cdn.discordapp.com" private val redirectUrl = Config.URL_DISCORD_REDIRECT.getString() @@ -38,7 +39,7 @@ class DiscordOauthHandler( .header("Content-Type", "application/x-www-form-urlencoded") .build() - val response = Mono.fromCallable(HTTP_CLIENT.newCall(tokenExchangeRequest)::execute) + val response = Mono.fromCallable(httpClient.newCall(tokenExchangeRequest)::execute) .subscribeOn(Schedulers.boundedElastic()) .awaitSingle() @@ -66,7 +67,7 @@ class DiscordOauthHandler( .header("Content-Type", "application/x-www-form-urlencoded") .build() - val response = Mono.fromCallable(HTTP_CLIENT.newCall(tokenExchangeRequest)::execute) + val response = Mono.fromCallable(httpClient.newCall(tokenExchangeRequest)::execute) .subscribeOn(Schedulers.boundedElastic()) .awaitSingle() @@ -88,7 +89,7 @@ class DiscordOauthHandler( .header("Authorization", "Bearer $accessToken") .build() - val response = Mono.fromCallable(HTTP_CLIENT.newCall(request)::execute) + val response = Mono.fromCallable(httpClient.newCall(request)::execute) .subscribeOn(Schedulers.boundedElastic()) .awaitSingle() diff --git a/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleAuth.kt b/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleAuth.kt index 1674aa205..8e1d0f190 100644 --- a/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleAuth.kt +++ b/cam/src/main/kotlin/org/dreamexposure/discal/cam/google/GoogleAuth.kt @@ -6,6 +6,7 @@ import com.google.api.client.http.HttpStatusCodes.STATUS_CODE_BAD_REQUEST import com.google.api.client.http.HttpStatusCodes.STATUS_CODE_OK import kotlinx.coroutines.reactor.awaitSingle import okhttp3.FormBody +import okhttp3.OkHttpClient import okhttp3.Request import org.dreamexposure.discal.cam.json.google.ErrorData import org.dreamexposure.discal.cam.json.google.RefreshData @@ -20,7 +21,6 @@ import org.dreamexposure.discal.core.logger.LOGGER import org.dreamexposure.discal.core.`object`.network.discal.CredentialData import org.dreamexposure.discal.core.`object`.new.Calendar import org.dreamexposure.discal.core.utils.GlobalVal.DEFAULT -import org.dreamexposure.discal.core.utils.GlobalVal.HTTP_CLIENT import org.springframework.stereotype.Component import reactor.core.publisher.Mono import reactor.core.scheduler.Schedulers @@ -32,6 +32,7 @@ class GoogleAuth( private val credentialService: CredentialService, private val calendarService: CalendarService, private val objectMapper: ObjectMapper, + private val httpClient: OkHttpClient, ) { suspend fun requestNewAccessToken(calendar: Calendar): CredentialData? { @@ -79,7 +80,7 @@ class GoogleAuth( .build() - val response = Mono.fromCallable(HTTP_CLIENT.newCall(request)::execute) + val response = Mono.fromCallable(httpClient.newCall(request)::execute) .subscribeOn(Schedulers.boundedElastic()) .awaitSingle() From 5a2af2957bb2deeb008d6443a379e0e9c6632250 Mon Sep 17 00:00:00 2001 From: NovaFox161 Date: Sat, 23 Mar 2024 16:46:25 -0500 Subject: [PATCH 43/43] Fix network manager not removing old instances --- .../discal/server/network/discal/NetworkManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/NetworkManager.kt b/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/NetworkManager.kt index 30a1e2f69..0bf022126 100644 --- a/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/NetworkManager.kt +++ b/server/src/main/kotlin/org/dreamexposure/discal/server/network/discal/NetworkManager.kt @@ -98,10 +98,10 @@ class NetworkManager( Flux.interval(Duration.ofMinutes(1)) .flatMap { updateAndReturnStatus() } //Update local status every minute .flatMap { - val bot = Flux.from { status.botStatus } + val bot = Flux.fromIterable(status.botStatus) .filter { Instant.now().isAfter(it.instanceData.lastHeartbeat.plus(5, ChronoUnit.MINUTES)) } .flatMap(this::doRestartBot) - val cam = Flux.from { status.camStatus } + val cam = Flux.fromIterable(status.camStatus) .filter { Instant.now().isAfter(it.lastHeartbeat.plus(5, ChronoUnit.MINUTES)) } .flatMap(this::doRestartCam)