diff --git a/build.gradle.kts b/build.gradle.kts index ab1749f..59cfa0d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,7 +36,7 @@ val klogging: String by project dependencies { implementation(kotlin("stdlib")) implementation(kotlin("reflect")) - implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:$serialization") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$serialization") implementation("com.kotlindiscord.kord.extensions:kord-extensions:$kordex") diff --git a/src/main/kotlin/quest/laxla/supertrouper/AboutExtension.kt b/src/main/kotlin/quest/laxla/supertrouper/AboutExtension.kt new file mode 100644 index 0000000..1761e11 --- /dev/null +++ b/src/main/kotlin/quest/laxla/supertrouper/AboutExtension.kt @@ -0,0 +1,8 @@ +package quest.laxla.supertrouper + +class AboutExtension : TrouperExtension() { + override suspend fun setup() { + TODO("Not yet implemented") + } + +} diff --git a/src/main/kotlin/quest/laxla/supertrouper/App.kt b/src/main/kotlin/quest/laxla/supertrouper/App.kt index 53e487c..8754092 100644 --- a/src/main/kotlin/quest/laxla/supertrouper/App.kt +++ b/src/main/kotlin/quest/laxla/supertrouper/App.kt @@ -8,12 +8,13 @@ import kotlinx.coroutines.runBlocking import quest.laxla.supertrouper.messaging.PrivateMassagingExtension private val token = env("TOKEN") -private val testingServer = envOrNull("TESTING_SERVER") +val officialServer = env("OFFICIAL_SERVER") +val isDevelopmentEnvironment = envOrNull("IS_DEV_ENV").toBoolean() fun main() = runBlocking { ExtensibleBot(token) { applicationCommands { - testingServer?.let { defaultGuild(it) } + if (isDevelopmentEnvironment) defaultGuild(officialServer) } extensions { diff --git a/src/main/kotlin/quest/laxla/supertrouper/MaintenanceExtension.kt b/src/main/kotlin/quest/laxla/supertrouper/MaintenanceExtension.kt index 6222636..5290458 100644 --- a/src/main/kotlin/quest/laxla/supertrouper/MaintenanceExtension.kt +++ b/src/main/kotlin/quest/laxla/supertrouper/MaintenanceExtension.kt @@ -1,9 +1,9 @@ package quest.laxla.supertrouper import com.kotlindiscord.kord.extensions.checks.isBotAdmin -import com.kotlindiscord.kord.extensions.commands.application.slash.publicSubCommand import com.kotlindiscord.kord.extensions.extensions.publicSlashCommand import com.kotlindiscord.kord.extensions.extensions.slashCommandCheck +import dev.kord.common.entity.Snowflake class MaintenanceExtension : TrouperExtension() { override suspend fun setup() { @@ -12,18 +12,14 @@ class MaintenanceExtension : TrouperExtension() { } publicSlashCommand { - name = "maintenance" - description = "Maintenance commands for maintainers of the bot" + name = "stop" + description = "Stops the bot completely" + guildId = Snowflake(officialServer) - publicSubCommand { - name = "stop" - description = "Stops the bot completely" - - action { - //language=Markdown - respond { content = "# Invoking Protocol: Emergency Stop" } - bot.stop() - } + action { + //language=Markdown + respond { content = "# Invoking Protocol: Emergency Stop" } + bot.stop() } } } diff --git a/src/main/kotlin/quest/laxla/supertrouper/Overwrites.kt b/src/main/kotlin/quest/laxla/supertrouper/Overwrites.kt index d299df1..9ffff57 100644 --- a/src/main/kotlin/quest/laxla/supertrouper/Overwrites.kt +++ b/src/main/kotlin/quest/laxla/supertrouper/Overwrites.kt @@ -23,30 +23,32 @@ fun PermissionOverwritesBuilder.addOverwrite( fun PermissionOverwritesBuilder.sync( vararg overrides: Overwrite, - defaults: Iterable -) = sync(overrides.asIterable(), defaults) + defaults: Iterable, + neverAllow: Permissions = Permissions() +) = sync(overrides.asIterable(), defaults, neverAllow) fun PermissionOverwritesBuilder.sync( overrides: Iterable, - defaults: Iterable + defaults: Iterable, + neverAllow: Permissions = Permissions() ) { val permissions = mutableMapOf() defaults.forEach { default -> val override = overrides.find { it.id == default.target && it.type == default.type } - if (override == null) addOverwrite(default.target, default.type, default.allowed, default.denied) + if (override == null) addOverwrite(default.target, default.type, default.allowed - neverAllow, default.denied) else permissions[override] = default } overrides.forEach { override -> val default = permissions[override] - if (default == null) addOverwrite(override) + if (default == null) addOverwrite(override.copy(allow = override.allow - neverAllow)) else addOverwrite( default.target, default.type, - default.allowed - default.denied - override.deny + override.allow, + default.allowed - default.denied + override.allow - override.deny - neverAllow, default.denied - default.allowed - override.allow + override.deny ) } diff --git a/src/main/kotlin/quest/laxla/supertrouper/TargetedArguments.kt b/src/main/kotlin/quest/laxla/supertrouper/TargetedArguments.kt index 1f2ef39..52a8fe5 100644 --- a/src/main/kotlin/quest/laxla/supertrouper/TargetedArguments.kt +++ b/src/main/kotlin/quest/laxla/supertrouper/TargetedArguments.kt @@ -2,6 +2,7 @@ package quest.laxla.supertrouper import com.kotlindiscord.kord.extensions.commands.Arguments import com.kotlindiscord.kord.extensions.commands.application.slash.SlashCommandContext +import com.kotlindiscord.kord.extensions.commands.converters.impl.optionalMember import com.kotlindiscord.kord.extensions.commands.converters.impl.optionalUser import com.kotlindiscord.kord.extensions.components.forms.ModalForm @@ -9,11 +10,11 @@ private const val TargetArgumentName = "target" private const val TargetArgumentDescription = "Target of this command. Defaults to you." open class TargetedArguments : Arguments() { - val targetOrNull by optionalUser { + val targetOrNull by optionalMember { name = TargetArgumentName description = TargetArgumentDescription } } val C.target where C : SlashCommandContext<*, A, M>, A : TargetedArguments, M : ModalForm - get() = arguments.targetOrNull ?: user + get() = arguments.targetOrNull ?: member!! diff --git a/src/main/kotlin/quest/laxla/supertrouper/messaging/PrivateMassagingExtension.kt b/src/main/kotlin/quest/laxla/supertrouper/messaging/PrivateMassagingExtension.kt index 15d15dc..365ebef 100644 --- a/src/main/kotlin/quest/laxla/supertrouper/messaging/PrivateMassagingExtension.kt +++ b/src/main/kotlin/quest/laxla/supertrouper/messaging/PrivateMassagingExtension.kt @@ -4,20 +4,32 @@ import com.kotlindiscord.kord.extensions.checks.anyGuild import com.kotlindiscord.kord.extensions.checks.isNotBot import com.kotlindiscord.kord.extensions.extensions.* import com.kotlindiscord.kord.extensions.types.EphemeralInteractionContext -import dev.kord.common.entity.OverwriteType -import dev.kord.common.entity.Permission +import com.kotlindiscord.kord.extensions.utils.any +import dev.kord.common.entity.* import dev.kord.core.behavior.GuildBehavior +import dev.kord.core.behavior.UserBehavior +import dev.kord.core.behavior.channel.asChannelOfOrNull +import dev.kord.core.behavior.channel.createMessage import dev.kord.core.behavior.channel.createTextChannel import dev.kord.core.behavior.channel.edit import dev.kord.core.behavior.createCategory +import dev.kord.core.behavior.interaction.respondEphemeral +import dev.kord.core.behavior.interaction.respondPublic +import dev.kord.core.entity.Member +import dev.kord.core.entity.Role import dev.kord.core.entity.User import dev.kord.core.entity.channel.Category import dev.kord.core.entity.channel.TextChannel import dev.kord.core.event.guild.MemberJoinEvent +import dev.kord.core.event.interaction.GuildButtonInteractionCreateEvent +import dev.kord.core.event.interaction.GuildSelectMenuInteractionCreateEvent import dev.kord.gateway.Intent import dev.kord.gateway.PrivilegedIntent import dev.kord.rest.builder.channel.addMemberOverwrite import dev.kord.rest.builder.channel.addRoleOverwrite +import dev.kord.rest.builder.message.actionRow +import dev.kord.rest.builder.message.create.AbstractMessageCreateBuilder +import dev.kord.rest.builder.message.embed import kotlinx.coroutines.flow.count import quest.laxla.supertrouper.* @@ -31,10 +43,8 @@ class PrivateMassagingExtension : TrouperExtension() { event { action { - if (event.member.isEligible && event.guild.members.count() < memberLimit) getOrCreateChannel( - getOrCreateCategory(event.guild), - event.member - ) + if (event.member.isEligible && event.guild.members.count() < memberLimit) + getOrCreateChannel(getOrCreateCategory(event.guild), event.member) } } @@ -43,7 +53,7 @@ class PrivateMassagingExtension : TrouperExtension() { description = "Get a link to a user's private messages channel" action { - executeFindCommand(getOrCreateCategory(guild!!), target.asUser()) + executeFindCommand(getOrCreateCategory(guild!!), target.asUser(), user) } } @@ -51,11 +61,11 @@ class PrivateMassagingExtension : TrouperExtension() { name = "Private Message" action { - executeFindCommand(getOrCreateCategory(guild!!), targetUsers.single()) + executeFindCommand(getOrCreateCategory(guild!!), targetUsers.single(), user) } } - ephemeralSlashCommand(::TargetedArguments) slash@{ + ephemeralSlashCommand(::TargetedArguments) { name = "sync" description = "Syncs a private message channel's permissions with the category" @@ -75,6 +85,16 @@ class PrivateMassagingExtension : TrouperExtension() { executeSyncCommand(getOrCreateCategory(guild!!), user.asUser()) } } + + event { + action { + when (event.interaction.componentId) { + PingButton -> event.interaction.respondPublic { + executePingCommand(event.interaction.channel.asChannel(), event.interaction.user) + } + } + } + } } private suspend fun EphemeralInteractionContext.executeSyncCommand(category: Category, user: User) { @@ -88,12 +108,13 @@ class PrivateMassagingExtension : TrouperExtension() { val channelMention = channel.mention channel.edit { - reason = "Syncing $userMention" + reason = "Sync $channelMention with category for $userMention" sync( - overwrite(kord.selfId, OverwriteType.Member, allowed = privateMessageBotPermissions), - overwrite(user.id, OverwriteType.Member, allowed = privateMessageOwnerPermissions), - defaults = category.permissionOverwrites + overwrite(kord.selfId, OverwriteType.Member, allowed = pmBotPermissions), + overwrite(user.id, OverwriteType.Member, allowed = pmMemberPermissions), + defaults = category.permissionOverwrites, + neverAllow = kord.getSelf().asMember(category.guildId).getDeniedPermissions() ) } @@ -102,10 +123,14 @@ class PrivateMassagingExtension : TrouperExtension() { } } - private suspend fun EphemeralInteractionContext.executeFindCommand(category: Category, user: User) { + private suspend fun EphemeralInteractionContext.executeFindCommand( + category: Category, user: User, searcher: UserBehavior = user + ) { if (user.isEligible) { val channel = getOrCreateChannel(category, user) respond { content = channel.mention } + + channel.ping(searcher) } else respond { content = user.mention + " is not eligible for private messaging." } @@ -118,7 +143,7 @@ class PrivateMassagingExtension : TrouperExtension() { nsfw = false addMemberOverwrite(kord.selfId) { - allowed += privateMessageBotPermissions + allowed += pmBotPermissions } addRoleOverwrite(guild.id) { @@ -135,15 +160,34 @@ class PrivateMassagingExtension : TrouperExtension() { val channel = category.createTextChannel(user.username) { reason = "Created a PM with $mention." nsfw = category.data.nsfw.discordBoolean - topic = "This channel is $mention's private message." + topic = "$mention's private messaging channel." sync( - overwrite(kord.selfId, OverwriteType.Member, allowed = privateMessageBotPermissions), - overwrite(user.id, OverwriteType.Member, allowed = privateMessageOwnerPermissions), - defaults = category.permissionOverwrites + overwrite(kord.selfId, OverwriteType.Member, allowed = pmBotPermissions), + overwrite(user.id, OverwriteType.Member, allowed = pmMemberPermissions), + defaults = category.permissionOverwrites, + neverAllow = kord.getSelf().asMember(category.guildId).getDeniedPermissions() ) } + val avatar = (user.avatar ?: user.defaultAvatar).cdnUrl.toUrl() + + channel.createMessage { + embed { + description = "# $mention" + thumbnail { url = avatar } + } + + actionRow { + interactionButton(ButtonStyle.Primary, customId = PingButton) { + label = "Ping" + emoji = DiscordPartialEmoji(name = "\uD83D\uDD14") + } + } + } + + channel.ping(user) + return channel } } diff --git a/src/main/kotlin/quest/laxla/supertrouper/messaging/PrivateMessaging.kt b/src/main/kotlin/quest/laxla/supertrouper/messaging/PrivateMessaging.kt index 2afb6b4..25f858c 100644 --- a/src/main/kotlin/quest/laxla/supertrouper/messaging/PrivateMessaging.kt +++ b/src/main/kotlin/quest/laxla/supertrouper/messaging/PrivateMessaging.kt @@ -1,25 +1,36 @@ package quest.laxla.supertrouper.messaging -import com.kotlindiscord.kord.extensions.utils.any import com.kotlindiscord.kord.extensions.utils.envOrNull +import dev.kord.common.entity.ALL import dev.kord.common.entity.Permission +import dev.kord.common.entity.Permissions +import dev.kord.common.entity.Snowflake import dev.kord.core.behavior.GuildBehavior import dev.kord.core.behavior.UserBehavior +import dev.kord.core.behavior.channel.MessageChannelBehavior +import dev.kord.core.behavior.channel.createMessage +import dev.kord.core.entity.Member import dev.kord.core.entity.User import dev.kord.core.entity.channel.Category +import dev.kord.core.entity.channel.MessageChannel import dev.kord.core.entity.channel.TextChannel +import dev.kord.rest.builder.message.allowedMentions +import dev.kord.rest.builder.message.create.AbstractMessageCreateBuilder import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.firstOrNull const val PrivateMessagesCategoryName = "Private Messages" +const val InviteSelect = "PM.invite" +const val PingButton = "PM.ping" +const val ManagePmButton = "PM.manage" +const val UserIdCapturingGroup = "userID" val memberLimit = envOrNull("AUTOMATIC_CHANNEL_CREATION_MEMBER_LIMIT")?.toInt() ?: 30 -val privateMessageOwnerPermissions = Permission.ViewChannel + Permission.ReadMessageHistory -val privateMessageBotPermissions = - privateMessageOwnerPermissions + Permission.ManageChannels + Permission.SendMessages + Permission.ManageMessages +val pmMemberPermissions = Permission.ViewChannel + Permission.ReadMessageHistory +val pmBotPermissions = + pmMemberPermissions + Permission.ManageChannels + Permission.SendMessages + Permission.ManageMessages +val userMentionRegex = "<@(?<$UserIdCapturingGroup>[1-9][0-9]+)>".toRegex() -suspend infix fun TextChannel.isOf(user: UserBehavior) = topic?.contains(user.mention) == true || pinnedMessages.any { - it.author?.id == kord.selfId && it.mentionedUserIds.singleOrNull() == user.id -} +infix fun TextChannel.isOf(user: UserBehavior) = topic?.contains(user.mention) == true suspend fun getChannel(category: Category, user: User) = category.channels.filterIsInstance().firstOrNull { it.categoryId == category.id && it isOf user @@ -30,3 +41,36 @@ suspend fun getCategory(guild: GuildBehavior) = val User.isEligible get() = !isBot val Category.isUsableForPrivateMessaging get() = name.equals(PrivateMessagesCategoryName, ignoreCase = true) + +fun AbstractMessageCreateBuilder.executePingCommand( + channel: MessageChannel, + pinger: UserBehavior +) { + val owners = channel.owners?.toList() + + if (owners == null) { + allowedMentions() + + content = "The owner of this channel is unknown. They need to be mentioned in the channel's topic, " + + "Like this: `<@userID>`." + } else { + allowedMentions { + users.addAll(owners.asSequence().map { + Snowflake(it.groups[UserIdCapturingGroup]!!.value) + }) + } + + content = "Hey, " + owners.joinToString(separator = " ") { + it.value + } + ", y'all were pinged by " + pinger.mention + '!' + } +} + +suspend fun MessageChannelBehavior.ping(user: UserBehavior) = createMessage { + allowedMentions { users.add(user.id) } + content = user.mention +}.delete(reason = "Ghost pinged " + user.mention) + +val MessageChannel.owners get() = data.topic.value?.let { userMentionRegex.findAll(it) } + +suspend fun Member.getDeniedPermissions() = Permissions.ALL - getPermissions()