Skip to content

Commit

Permalink
Allow users to use server-specific bot integration to send DM direct …
Browse files Browse the repository at this point in the history
…to user

This semi-WIP commit allows for future splitting of routes by jamId.

Using DMs instead of a ping channel allows any guild to use this bot
without undue faff required to integrate.
  • Loading branch information
Willdotwhite committed Apr 19, 2024
1 parent 5d1a653 commit 89537f4
Show file tree
Hide file tree
Showing 14 changed files with 237 additions and 163 deletions.
8 changes: 4 additions & 4 deletions api/src/main/kotlin/com/gmtkgamejam/Application.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
package com.gmtkgamejam

import com.gmtkgamejam.koin.DatabaseModule
import com.gmtkgamejam.koin.DiscordBotModule
import com.gmtkgamejam.routing.*
import com.gmtkgamejam.services.BotService
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.cors.*
import io.ktor.server.plugins.cors.routing.CORS
import io.ktor.server.plugins.cors.routing.*
import kotlinx.serialization.json.Json
import org.koin.core.context.startKoin
import org.koin.environmentProperties
Expand All @@ -19,7 +18,8 @@ fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)
fun Application.module() {
startKoin {
environmentProperties()
modules(DatabaseModule, DiscordBotModule)
modules(DatabaseModule)
BotService() // Initialise all bots on start up - hacky but working
}

configureRequestHandling()
Expand Down
35 changes: 35 additions & 0 deletions api/src/main/kotlin/com/gmtkgamejam/bot/BotMessageBuilder.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.gmtkgamejam.bot

import com.gmtkgamejam.services.PostService
import org.javacord.api.entity.message.embed.EmbedBuilder
import org.javacord.api.entity.user.User

object BotMessageBuilder {

private val postService = PostService()

fun canBuildEmbedFromUser(sender: User): Boolean = postService.getPostByAuthorId(sender.id.toString()) != null

fun embedMessage(recipient: User, sender: User): EmbedBuilder {
val postItem = postService.getPostByAuthorId(sender.id.toString())!!

val shortDescription = if (postItem.description.length > 240) postItem.description.take(237) + "..." else postItem.description

return EmbedBuilder()
.setTitle("${sender.name} wants to get in contact!")
.setDescription("Hey there ${recipient.name}! Someone wants to get in touch - this is a summary of their current post on the Team Finder!")
.setAuthor("GMTK Team Finder", "https://findyourjam.team/", "https://findyourjam.team/logos/jam-logo-stacked.webp")
.addField("Description", shortDescription)
.addField("${sender.name} is looking for:", postItem.skillsSought.toString())
.addField("${sender.name} can bring:", postItem.skillsPossessed.toString())
.addInlineField("Engine(s)", postItem.preferredTools.toString())
.addInlineField("Timezone(s)", postItem.timezoneOffsets.map { if (it < 0) "UTC-$it" else "UTC+$it" }.toString())
.addField("Like what you see?", "Check out their full post here to see more! https://findyourjam.team/gmtk/${postItem.id}/")
.setFooter("Feedback? DM @dotwo in the #developing-gtmk-team-finder-app channel")
}

// TODO: Add a variety of messages to mix things up a bit?
fun basicMessage(recipient: User, sender: User): String {
return "Hey ${recipient.mentionTag}, ${sender.mentionTag} wants to get in contact about your Team Finder post!"
}
}
29 changes: 13 additions & 16 deletions api/src/main/kotlin/com/gmtkgamejam/bot/DiscordBot.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package com.gmtkgamejam.bot

import com.gmtkgamejam.Config
import kotlinx.coroutines.future.await
import org.javacord.api.DiscordApi
import org.javacord.api.DiscordApiBuilder
import org.javacord.api.entity.channel.ServerTextChannel
import org.javacord.api.entity.intent.Intent
import org.javacord.api.entity.message.MessageBuilder
import org.javacord.api.entity.server.Server
Expand All @@ -13,34 +11,29 @@ import org.javacord.api.exception.DiscordException
import org.javacord.api.exception.MissingPermissionsException
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import kotlin.jvm.optionals.getOrElse

class DiscordBot {
class DiscordBot(
guildId: String,
botToken: String
) {

private val logger: Logger = LoggerFactory.getLogger(javaClass)

private lateinit var api: DiscordApi

private lateinit var server: Server

private lateinit var channel: ServerTextChannel

private val approvedUsers: MutableList<String> = mutableListOf()

init {
val token = Config.getString("bot.token")
val builder = DiscordApiBuilder().setToken(token).setIntents(Intent.GUILD_MEMBERS)

val guildId = Config.getString("jam.guildId")
val channelName = Config.getString("bot.pingChannel")
val builder = DiscordApiBuilder().setToken(botToken).setIntents(Intent.DIRECT_MESSAGES, Intent.GUILD_MEMBERS)

try {
api = builder.login().join()
server = api.getServerById(guildId).get()

channel = api.getServerTextChannelsByNameIgnoreCase(channelName).first()
logger.info("Discord bot is online and ready for action!")
} catch (ex: NoSuchElementException) { // NoSuchElementException triggered by calling `.first()` on Collection
logger.warn("Discord bot could not connect to pingChannel [$channelName] - ping message integration offline.")
} catch (ex: Exception) {
logger.warn("Discord bot could not be initialised - continuing...")
logger.warn(ex.toString())
Expand All @@ -51,9 +44,13 @@ class DiscordBot {
val recipient: User = api.getUserById(recipientUserId).await()
val sender: User = api.getUserById(senderUserId).await()

val messageContents = "Hey ${recipient.mentionTag}, ${sender.mentionTag} wants to get in contact about your Team Finder post!"
// TODO: Validate message actually sent, give error otherwise
channel.sendMessage(messageContents).await()
val dmChannel = recipient.privateChannel.getOrElse { recipient.openPrivateChannel().get() }

if (BotMessageBuilder.canBuildEmbedFromUser(sender)) {
dmChannel.sendMessage(BotMessageBuilder.embedMessage(recipient, sender))
} else {
dmChannel.sendMessage(BotMessageBuilder.basicMessage(recipient, sender))
}
}

suspend fun doesUserHaveValidPermissions(userId: String): Boolean {
Expand Down
10 changes: 0 additions & 10 deletions api/src/main/kotlin/com/gmtkgamejam/koin/DiscordBotModule.kt

This file was deleted.

3 changes: 3 additions & 0 deletions api/src/main/kotlin/com/gmtkgamejam/models/bot/BotRecord.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.gmtkgamejam.models.bot

data class BotRecord(val guildId: String, val jamId: String, val botToken: String)
24 changes: 14 additions & 10 deletions api/src/main/kotlin/com/gmtkgamejam/models/posts/Skills.kt
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package com.gmtkgamejam.models.posts

enum class Skills {
ART_2D,
ART_3D,
CODE,
DESIGN_PRODUCTION,
SFX,
MUSIC,
TESTING_SUPPORT,
TEAM_LEAD,
OTHER;
enum class Skills(private var readableName: String) {
ART_2D("2D Art"),
ART_3D("3D Art"),
CODE("Code"),
DESIGN_PRODUCTION("Design/Production"),
SFX("SFX"),
MUSIC("Music"),
TESTING_SUPPORT("Testing/Support"),
TEAM_LEAD("Team lead"),
OTHER("Other");

override fun toString(): String {
return readableName;
}
}
26 changes: 15 additions & 11 deletions api/src/main/kotlin/com/gmtkgamejam/models/posts/Tools.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
package com.gmtkgamejam.models.posts

// For the time being, Tool == Engine
enum class Tools {
UNITY,
CONSTRUCT,
GAME_MAKER_STUDIO,
GODOT,
TWINE,
BITSY,
UNREAL,
RPG_MAKER,
PICO_8,
OTHER,
enum class Tools(private var readableName: String) {
UNITY("Unity"),
CONSTRUCT("Construct"),
GAME_MAKER_STUDIO("Game Maker Studio"),
GODOT("Godot"),
TWINE("Twine"),
BITSY("Bitsy"),
UNREAL("Unreal"),
RPG_MAKER("RPG Maker"),
PICO_8("PICO 8"),
OTHER("Other");

override fun toString(): String {
return readableName;
}
}
108 changes: 58 additions & 50 deletions api/src/main/kotlin/com/gmtkgamejam/routing/DiscordBotRoutes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import com.gmtkgamejam.bot.DiscordBot
import com.gmtkgamejam.models.bot.dtos.BotDmDto
import com.gmtkgamejam.respondJSON
import com.gmtkgamejam.services.AuthService
import com.gmtkgamejam.services.BotService
import com.gmtkgamejam.toJsonElement
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.koin.ktor.ext.inject
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.time.LocalDateTime
Expand All @@ -23,7 +23,8 @@ fun Application.configureDiscordBotRouting() {
val logger: Logger = LoggerFactory.getLogger(javaClass)

val authService = AuthService()
val bot: DiscordBot by inject()
val botService = BotService()
val bots: Map<String, DiscordBot> = botService.getBots()

val userIdMessageTimes: MutableMap<String, LocalDateTime> = mutableMapOf()

Expand Down Expand Up @@ -53,61 +54,68 @@ fun Application.configureDiscordBotRouting() {
}

routing {
authenticate("auth-jwt") {
route("/{jamId}") {
route("/bot") {
post("/dm") {
val data = call.receive<BotDmDto>()
// TODO: /info route

val tokenSet = authService.getTokenSet(call) ?: return@post call.respondJSON(
"Your request couldn't be authorised",
status = HttpStatusCode.Unauthorized
)
authenticate("auth-jwt") {
post("/dm") {
val data = call.receive<BotDmDto>()

val senderId = tokenSet.discordId
val recipientId = data.recipientId

if (!canUserSendMessageToThisUser(senderId, recipientId)) {
return@post call.respondJSON(
"You can't message a single user again so quickly",
status = HttpStatusCode.TooManyRequests
val tokenSet = authService.getTokenSet(call) ?: return@post call.respondJSON(
"Your request couldn't be authorised",
status = HttpStatusCode.Unauthorized
)
}

if (!canUserSendMessage(senderId)) {
return@post call.respondJSON(
"You are sending too many messages - please wait a few minutes and try again",
status = HttpStatusCode.TooManyRequests
)
val senderId = tokenSet.discordId
val recipientId = data.recipientId

if (!canUserSendMessageToThisUser(senderId, recipientId)) {
return@post call.respondJSON(
"You can't message a single user again so quickly",
status = HttpStatusCode.TooManyRequests
)
}

if (!canUserSendMessage(senderId)) {
return@post call.respondJSON(
"You are sending too many messages - please wait a few minutes and try again",
status = HttpStatusCode.TooManyRequests
)
}

try {
val sendTime = LocalDateTime.now()

val jamId = call.parameters["jamId"]!!
val bot = bots[jamId] ?: return@post call.respondJSON("Could not load discord bot for jam!", HttpStatusCode.InternalServerError)

bot.createContactUserPingMessage(recipientId, senderId)
userIdMessageTimes[senderId] = sendTime
userIdPerUserMessageTimes[Pair(senderId, recipientId).toString()] = sendTime
logger.error("Sender [$senderId] has pinged Recipient [$recipientId] at [$sendTime]")
return@post call.respond(it)
} catch (ex: Exception) {
logger.error("Could not create ping message: $ex")
return@post call.respondJSON(
"This message could not be sent, please inform the Team Finder Support group in Discord",
status = HttpStatusCode.NotAcceptable
)
}
}

try {
val sendTime = LocalDateTime.now()

bot.createContactUserPingMessage(recipientId, senderId)
userIdMessageTimes[senderId] = sendTime
userIdPerUserMessageTimes[Pair(senderId, recipientId).toString()] = sendTime
logger.error("Sender [$senderId] has pinged Recipient [$recipientId] at [$sendTime]")
return@post call.respond(it)
} catch (ex: Exception) {
logger.error("Could not create ping message: $ex")
return@post call.respondJSON(
"This message could not be sent, please inform the Team Finder Support group in Discord",
status = HttpStatusCode.NotAcceptable
)
}
}

authenticate("auth-jwt-admin") {
get("/_monitoring") {
val data = mapOf(
"userTimeout" to userRateLimitTimeOutInSeconds,
"perRecipientTimeout" to perUserTimeoutInSeconds,
"userIdMessageTimes" to userIdMessageTimes,
"userIdPerUserMessageTimes" to userIdPerUserMessageTimes,
)

// .toJsonElement required as Ktor can't serialise collections of different element types
return@get call.respond(data.toJsonElement())
authenticate("auth-jwt-admin") {
get("/_monitoring") {
val data = mapOf(
"userTimeout" to userRateLimitTimeOutInSeconds,
"perRecipientTimeout" to perUserTimeoutInSeconds,
"userIdMessageTimes" to userIdMessageTimes,
"userIdPerUserMessageTimes" to userIdPerUserMessageTimes,
)

// .toJsonElement required as Ktor can't serialise collections of different element types
return@get call.respond(data.toJsonElement())
}
}
}
}
Expand Down
Loading

0 comments on commit 89537f4

Please sign in to comment.