Skip to content

Commit

Permalink
Merge pull request #28 from DreamExposure/develop
Browse files Browse the repository at this point in the history
Develop - v2.0.1 RC
  • Loading branch information
NovaFox161 authored Oct 9, 2022
2 parents c5502c8 + 69b2b10 commit 8a5b7ad
Show file tree
Hide file tree
Showing 67 changed files with 1,053 additions and 412 deletions.
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,25 @@ TicketBird is a simple help desk and ticket managing Discord bot allowing you to

# 💎 Core Features
- Simple setup (just use one command to get everything initiated)
- Easy for users to understand and use without a single command
- Easy for users to understand and use without a single command (but can also open tickets with a command)
- Allow users to pick from up to 25 topics (called projects), if desired
- Won't disrupt your community. TicketBird will not affect anything beyond the channels it manages for tickets.
- Auto-close tickets after 7 days of inactivity, and auto-delete tickets after being closed for 24 hours
- Auto-close tickets after 7 days of inactivity, and auto-delete tickets after being closed for 24 hours (fully configurable)
- Place tickets on hold until you can get to them; so they don't auto-close

## ⌨️ Commands
| Command | Description | Permissions |
|---------------------|-------------------------------------------------------------------------|-------------|
| /ticketbird | Shows info about the bot | Everyone |
| /support | Opens a new ticket | Everyone |
| /close | Closes a ticket when run in a ticket channel | Everyone |
| /hold | Places a ticket on hold when run in a ticket channel | Everyone |
| /setup init | Lets the bot setup by creating needed channels/categories | Admin-only |
| /setup repair | Attempts to automatically repair any configuration issues | Admin-only |
| /setup use-projects | Whether to use "projects" or ticket topics to help sort tickets | Admin-only |
| /setup timing | Allows configuring the timing of TicketBird's automated actions | Admin-only |
| /setup language | Allows you to select the language for the bot to use | Admin-only |
| /staff role | Allows users with the role to see all tickets as "TicketBird Staff" | Admin-only |
| /staff add | Add a user as being "TicketBird Staff" allowing them to see all tickets | Admin-only |
| /staff remove | Remove a user as "TicketBird Staff" | Admin-only |
| /staff list | List all users who are currently "TicketBird staff" | Admin-only |
Expand All @@ -47,7 +51,8 @@ This bot is a hobby project for me, please note that while these features are pl
- Java 17
- 100% Kotlin utilizing Kotlin Coroutines
- Spring Boot (Data, Dependency Injection, etc)
- Flyway for automatic database migrations
- Flyway for automatic database migrations (MySQL)
- Redis cluster caching
- Enterprise repository & service pattern for maintainability
- Fully containerized with Docker (hosted in Kubernetes, docker-compose for local development)

Expand Down
39 changes: 14 additions & 25 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@ import org.gradle.api.tasks.wrapper.Wrapper.DistributionType.ALL
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
kotlin("jvm") version "1.7.0"
java
// Kotlin
kotlin("jvm") version "1.7.20"

kotlin("plugin.spring") version "1.7.0"

id("com.google.cloud.tools.jib") version "3.2.1"
id("org.springframework.boot") version "2.7.2"
id("io.spring.dependency-management") version "1.0.12.RELEASE"
// Spring
kotlin("plugin.spring") version "1.7.20"
id("org.springframework.boot") version "2.7.4"
id("io.spring.dependency-management") version "1.0.14.RELEASE"

// Tooling
id("com.gorylenko.gradle-git-properties") version "2.4.1"
id("com.google.cloud.tools.jib") version "3.3.0"
}

buildscript {
Expand All @@ -25,21 +26,13 @@ buildscript {

repositories {
mavenCentral()
mavenLocal()
maven {
url = uri("https://jitpack.io")
}

maven {
url = uri("https://repo.maven.apache.org/maven2/")
}
maven {
url = uri("https://oss.sonatype.org/content/repositories/snapshots")
}
maven("https://repo.maven.apache.org/maven2/")
maven("https://oss.sonatype.org/content/repositories/snapshots")
}
//versions
val d4jVersion = "3.2.3"
val d4jStoresVersion = "3.2.1"
val d4jStoresVersion = "3.2.2"

val kotlinSrcDir: File = buildDir.resolve("src/main/kotlin")

Expand Down Expand Up @@ -93,7 +86,7 @@ dependencies {
}

group = "org.dreamexposure"
version = "2.0.0.hf1"
version = "2.0.1-SNAPSHOT"
description = "TicketBird"
java.sourceCompatibility = JavaVersion.VERSION_17

Expand All @@ -109,8 +102,8 @@ jib {
gitProperties {
extProperty = "gitPropertiesExt"

val versionName = if (System.getenv("BUILD_NUMBER") != null) {
"$version.${System.getenv("BUILD_NUMBER")}"
val versionName = if (System.getenv("GITHUB_RUN_NUMBER") != null) {
"$version.b${System.getenv("GITHUB_RUN_NUMBER")}"
} else {
"$version.d${System.currentTimeMillis().div(1000)}" //Seconds since epoch
}
Expand Down Expand Up @@ -176,10 +169,6 @@ tasks {
useJUnitPlatform()
}

bootJar {
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}

wrapper {
distributionType = ALL
gradleVersion = "7.5.1"
Expand Down
4 changes: 1 addition & 3 deletions src/main/kotlin/org/dreamexposure/ticketbird/TicketBird.kt
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,7 @@ class TicketBird {

//Start spring
try {
SpringApplicationBuilder(TicketBird::class.java)
.profiles(BotSettings.PROFILE.get())
.run(*args)
SpringApplicationBuilder(TicketBird::class.java).run(*args)
} catch (e: Exception) {
e.printStackTrace()
LOGGER.error(DEFAULT, "Spring error!", e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import org.dreamexposure.ticketbird.`object`.GuildSettings
interface ComponentService {
suspend fun getStaticMessageComponents(settings: GuildSettings): Array<LayoutComponent>

suspend fun getProjectSelectComponents(settings: GuildSettings): Array<LayoutComponent>
suspend fun getProjectSelectComponents(settings: GuildSettings, withCreate: Boolean = false): Array<LayoutComponent>

suspend fun getTicketOpenModalComponents(settings: GuildSettings): Array<LayoutComponent>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,18 @@ class DefaultComponentService(
"create-ticket",
ReactionEmoji.unicode("\uD83D\uDCE8"), // Incoming envelop emote
localeService.getString(settings.locale, "button.create-ticket.label")
)
).disabled(settings.requiresRepair)

return arrayOf(ActionRow.of(button))
}

override suspend fun getProjectSelectComponents(settings: GuildSettings): Array<LayoutComponent> {
override suspend fun getProjectSelectComponents(settings: GuildSettings, withCreate: Boolean): Array<LayoutComponent> {
val projectsAsOptions = projectService.getAllProjects(settings.guildId)
.map { SelectMenu.Option.of(it.name, it.name) }

val selectMenu = SelectMenu.of("select-project", projectsAsOptions)
val id = if (withCreate) "select-project-with-create" else "select-project"

val selectMenu = SelectMenu.of(id, projectsAsOptions)
.withPlaceholder(localeService.getString(settings.locale, "dropdown.select-project.placeholder"))

return arrayOf(ActionRow.of(selectMenu))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,24 @@ import discord4j.core.GatewayDiscordClient
import discord4j.core.`object`.PermissionOverwrite
import discord4j.core.`object`.entity.channel.Category
import discord4j.core.`object`.entity.channel.TextChannel
import discord4j.rest.http.client.ClientException
import discord4j.rest.util.Permission
import discord4j.rest.util.PermissionSet
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.springframework.beans.factory.BeanFactory
import org.springframework.beans.factory.getBean
import org.springframework.stereotype.Component
import reactor.core.publisher.Mono

@Component
class DefaultEnvironmentService(
private val beanFactory: BeanFactory,
private val settingsService: GuildSettingsService,
private val permissionService: PermissionService,
private val localeService: LocaleService,
private val staticMessageService: StaticMessageService,
private val componentService: ComponentService,
) : EnvironmentService {
private val discordClient
get() = beanFactory.getBean<GatewayDiscordClient>()
Expand Down Expand Up @@ -53,4 +58,108 @@ class DefaultEnvironmentService(
).withTopic(localeService.getString(settings.locale, "env.channel.support.topic"))
.awaitSingle()
}

override suspend fun validateAllEntitiesExist(guildId: Snowflake): Boolean {
val settings = settingsService.getGuildSettings(guildId)
val guild = discordClient.getGuildById(guildId).awaitSingle()

var properSetup = settings.hasRequiredIdsSet() && !settings.requiresRepair

// Check awaiting response ticket category
if (settings.awaitingCategory != null) {
guild.getChannelById(settings.awaitingCategory!!)
.doOnError(ClientException.isStatusCode(404)) { settings.awaitingCategory = null }
.doOnError(ClientException.isStatusCode(404, 403)) { settings.requiresRepair = true; properSetup = false }
.onErrorResume { Mono.empty() }
.awaitSingleOrNull()
}

// Check responded ticket category
if (settings.respondedCategory != null) {
guild.getChannelById(settings.respondedCategory!!)
.doOnError(ClientException.isStatusCode(404)) { settings.respondedCategory = null }
.doOnError(ClientException.isStatusCode(404, 403)) { settings.requiresRepair = true; properSetup = false }
.onErrorResume { Mono.empty() }
.awaitSingleOrNull()
}

// Check held ticket category
if (settings.holdCategory != null) {
guild.getChannelById(settings.holdCategory!!)
.doOnError(ClientException.isStatusCode(404)) { settings.holdCategory = null }
.doOnError(ClientException.isStatusCode(404, 403)) { settings.requiresRepair = true; properSetup = false }
.onErrorResume { Mono.empty() }
.awaitSingleOrNull()
}

// Check closed ticket category
if (settings.closeCategory != null) {
guild.getChannelById(settings.closeCategory!!)
.doOnError(ClientException.isStatusCode(404)) { settings.closeCategory = null }
.doOnError(ClientException.isStatusCode(404, 403)) { settings.requiresRepair = true; properSetup = false }
.onErrorResume { Mono.empty() }
.awaitSingleOrNull()
}

// Check support channel
if (settings.supportChannel != null) {
guild.getChannelById(settings.supportChannel!!)
.doOnError(ClientException.isStatusCode(404)) { settings.supportChannel = null }
.doOnError(ClientException.isStatusCode(404, 403)) { settings.requiresRepair = true; properSetup = false }
.onErrorResume { Mono.empty() }
.awaitSingleOrNull()
}

// Check static message
if (settings.supportChannel != null && settings.staticMessage != null) {
guild.getChannelById(settings.supportChannel!!)
.ofType(TextChannel::class.java)
.flatMap { it.getMessageById(settings.staticMessage) }
.doOnError(ClientException.isStatusCode(404)) { settings.staticMessage = null }
.doOnError(ClientException.isStatusCode(404, 403)) { settings.requiresRepair = true; properSetup = false }
.onErrorResume { Mono.empty() }
.awaitSingleOrNull()
}

settingsService.createOrUpdateGuildSettings(settings)
return properSetup
}

override suspend fun recreateMissingEntities(guildId: Snowflake) {
val settings = settingsService.getGuildSettings(guildId)

if (settings.awaitingCategory == null) settings.awaitingCategory = createCategory(guildId, "awaiting").id
if (settings.respondedCategory == null) settings.respondedCategory = createCategory(guildId, "responded").id
if (settings.holdCategory == null) settings.holdCategory = createCategory(guildId, "hold").id
if (settings.closeCategory == null) settings.closeCategory = createCategory(guildId, "closed").id

if (settings.supportChannel == null) {
val supportChannel = createSupportChannel(guildId)
settings.supportChannel = supportChannel.id

// Create new static message since the old one is lost now
val embed = staticMessageService.getEmbed(settings) ?: throw IllegalStateException("Failed to get embed during recreate")
supportChannel.createMessage(embed)
.withComponents(*componentService.getStaticMessageComponents(settings))
.doOnNext { settings.staticMessage = it.id }
.awaitSingle()
}

// Just create new static message
if (settings.staticMessage == null) {
// Support channel exists, but not static message, just recreate
val supportChannel = discordClient.getChannelById(settings.supportChannel!!)
.ofType(TextChannel::class.java)
.awaitSingle()

val embed = staticMessageService.getEmbed(settings) ?: throw IllegalStateException("Failed to get embed during recreate")
supportChannel.createMessage(embed)
.withComponents(*componentService.getStaticMessageComponents(settings))
.doOnNext { settings.staticMessage = it.id }
.awaitSingle()
}

if (settings.hasRequiredIdsSet()) settings.requiresRepair = false
settingsService.createOrUpdateGuildSettings(settings)
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
package org.dreamexposure.ticketbird.business

import discord4j.common.util.Snowflake
import kotlinx.coroutines.reactive.awaitFirstOrDefault
import kotlinx.coroutines.reactive.awaitSingle
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.dreamexposure.ticketbird.business.cache.CacheRepository
import org.dreamexposure.ticketbird.GuildSettingsCache
import org.dreamexposure.ticketbird.database.GuildSettingsData
import org.dreamexposure.ticketbird.database.GuildSettingsRepository
import org.dreamexposure.ticketbird.extensions.asStringList
Expand All @@ -14,13 +13,11 @@ import org.springframework.stereotype.Component
@Component
class DefaultGuildSettingsService(
private val settingsRepository: GuildSettingsRepository,
private val settingsCache: CacheRepository<Long, GuildSettings>
private val settingsCache: GuildSettingsCache
) : GuildSettingsService {

override suspend fun hasGuildSettings(guildId: Snowflake): Boolean {
return settingsRepository.findByGuildId(guildId.asLong())
.map { true }
.awaitFirstOrDefault(false)
return settingsRepository.findByGuildId(guildId.asLong()).hasElement().awaitSingle()
}

override suspend fun getGuildSettings(guildId: Snowflake): GuildSettings {
Expand All @@ -43,6 +40,9 @@ class DefaultGuildSettingsService(
devGuild = settings.devGuild,
patronGuild = settings.patronGuild,
useProjects = settings.useProjects,
autoCloseHours = settings.autoClose.toHours().toInt(),
autoDeleteHours = settings.autoDelete.toHours().toInt(),
requiresRepair = settings.requiresRepair,

awaitingCategory = settings.awaitingCategory?.asLong(),
respondedCategory = settings.respondedCategory?.asLong(),
Expand All @@ -52,7 +52,8 @@ class DefaultGuildSettingsService(
staticMessage = settings.staticMessage?.asLong(),

nextId = settings.nextId,
staff = settings.staff.asStringList()
staff = settings.staff.asStringList(),
staffRole = settings.staffRole?.asLong(),
)).map(::GuildSettings).awaitSingle()

settingsCache.put(settings.guildId.asLong(), saved)
Expand All @@ -66,6 +67,9 @@ class DefaultGuildSettingsService(
devGuild = settings.devGuild,
patronGuild = settings.patronGuild,
useProjects = settings.useProjects,
autoCloseHours = settings.autoClose.toHours().toInt(),
autoDeleteHours = settings.autoDelete.toHours().toInt(),
requiresRepair = settings.requiresRepair,

awaitingCategory = settings.awaitingCategory?.asLong(),
respondedCategory = settings.respondedCategory?.asLong(),
Expand All @@ -75,7 +79,8 @@ class DefaultGuildSettingsService(
staticMessage = settings.staticMessage?.asLong(),

nextId = settings.nextId,
staff = settings.staff.asStringList()
staff = settings.staff.asStringList(),
staffRole = settings.staffRole?.asLong(),
).awaitSingleOrNull()

settingsCache.put(settings.guildId.asLong(), settings)
Expand All @@ -85,11 +90,4 @@ class DefaultGuildSettingsService(
settingsRepository.deleteByGuildId(guildId.asLong()).awaitSingle()
settingsCache.evict(guildId.asLong())
}

override suspend fun createOrUpdateGuildSettings(settings: GuildSettings): GuildSettings {
return if (hasGuildSettings(settings.guildId)) {
updateGuildSettings(settings)
settings
} else createGuildSettings(settings)
}
}
Loading

0 comments on commit 8a5b7ad

Please sign in to comment.