diff --git a/.gitignore b/.gitignore index ddf6174..539acf9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .idea/* !.idea/codeStyles/ build/ +.kotlin/ diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index bd93f4e..b59b03b 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -2,6 +2,10 @@ diff --git a/README.md b/README.md index d81b40e..ec1c594 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,22 @@ Because we want to have a chat system that actually wOREks for us. | `/profile about ` | `chattore.profile.about` | Set your about | `/playerprofile` | | `/profile setabout ` | `chattore.profile.about.others` | Set another player's about | `/playerprofile` | +## Bubble Commands + +| Command | Permission | Description | Aliases | +|------------------------------------|--------------------------|--------------------------------------------------------|------------------------| +| `/bubble create [players]` | `chattore.bubble` | Create ("blow") a bubble and invite players | `/bb create\|/bb blow` | +| `/bubble invite ` | `chattore.bubble` | Invite players to your bubble (if it is private) | `/bb invite` | +| `/bubble join ` | `chattore.bubble` | Join a player's bubble | `/bb join` | +| `/bubble leave` | `chattore.bubble` | Leave your current bubble | `/bb leave` | +| `/bubble delete` | `chattore.bubble` | Delete ("pop") your own bubble | `/bb delete\|/bb pop` | +| `/bubble kick ` | `chattore.bubble` | Kick a player from your own bubble | `/bb kick` | +| `/bubble setprivate ` | `chattore.bubble` | Set the visibility of your own bubble | `/bb setprivate` | +| `/bubble list` | `chattore.bubble` | List all bubbles | `/bb list` | +| `/shout ` | `chattore.bubble` | Send a message to global chat when in a bubble | No aliases | +| `/bubble burst ` | `chattore.bubble.manage` | Burst (delete) someone's bubble | `/bb burst` | +| `/bubble showglobalchat ` | `chattore.bubble` | Control the visibility of global chat when in a bubble | `/bb sgc` | + ## Other Commands | Command | Permission | Description | Aliases | diff --git a/build.gradle.kts b/build.gradle.kts index 251744d..8fdf68c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,3 +6,9 @@ plugins { alias(libs.plugins.pluginYml) apply false alias(libs.plugins.buildconfig) apply false } + +allprojects { + tasks.withType { + failOnNoDiscoveredTests = false + } +} diff --git a/chattore/src/main/kotlin/ChattORE.kt b/chattore/src/main/kotlin/ChattORE.kt index 5029a10..25acc50 100644 --- a/chattore/src/main/kotlin/ChattORE.kt +++ b/chattore/src/main/kotlin/ChattORE.kt @@ -1,16 +1,13 @@ package org.openredstone.chattore -import co.aikar.commands.BaseCommand -import co.aikar.commands.CommandIssuer -import co.aikar.commands.RegisteredCommand -import co.aikar.commands.VelocityCommandManager +import co.aikar.commands.* +import co.aikar.commands.velocity.contexts.OnlinePlayer import com.google.inject.Inject import com.velocitypowered.api.event.Subscribe import com.velocitypowered.api.event.proxy.ProxyInitializeEvent import com.velocitypowered.api.plugin.Dependency import com.velocitypowered.api.plugin.Plugin import com.velocitypowered.api.plugin.annotation.DataDirectory -import com.velocitypowered.api.proxy.Player import com.velocitypowered.api.proxy.ProxyServer import net.luckperms.api.LuckPermsProvider import org.openredstone.chattore.feature.* @@ -26,12 +23,12 @@ import kotlin.io.path.exists url = "https://openredstone.org", description = "Because we want to have a chat system that actually wOREks for us.", authors = ["Nickster258", "PaukkuPalikka", "StackDoubleFlow", "sodiboo", "Waffle [Wueffi]"], - dependencies = [Dependency(id = "luckperms")] + dependencies = [Dependency(id = "luckperms")], ) class ChattORE @Inject constructor( private val proxy: ProxyServer, private val logger: Logger, - @DataDirectory private val dataFolder: Path, + @param:DataDirectory private val dataFolder: Path, ) { @Subscribe fun onProxyInitialization(event: ProxyInitializeEvent) { @@ -42,33 +39,37 @@ class ChattORE @Inject constructor( val pluginScope = PluginScope(this, ChattORE::class.java, proxy, dataFolder, logger, commandManager) commandManager.apply { setDefaultExceptionHandler(::handleCommandException, false) - commandCompletions.registerCompletion("username") { listOf(it.player.username) } + commandCompletions.registerStaticCompletion("boolean", arrayOf("true", "false")) + commandCompletions.setDefaultCompletion("boolean", Boolean::class.java) + commandCompletions.setDefaultCompletion("players", OnlinePlayer::class.java) + @Suppress("DEPRECATION") + enableUnstableAPI("help") } pluginScope.apply { val emojis = createEmojiFeature() - val messenger = createMessenger(emojis, database, luckPerms, config.format.global) val userCache = createUserCache(database.database) + val wiretap = createSpyingFeature(database, config.format) + val messenger = createMessenger(emojis, database, luckPerms, config.format, wiretap, userCache) + val chatConfirmations = createChatConfirmations(ChatConfirmationConfig(config.regexes)) + val bubbleManager = createBubbleFeature(messenger, database, chatConfirmations, config.format, userCache) createAliasFeature() - createChatFeature( - messenger, - ChatConfirmationConfig(config.regexes), - ) + createChatFeature(messenger, chatConfirmations, bubbleManager) createChattoreFeature() createDiscordFeature(messenger, emojis, config.discord) - createFunCommandsFeature() - createHelpOpFeature() + createFunCommandsFeature(chatConfirmations) + createHelpOpFeature(chatConfirmations) createJoinLeaveFeature(config.format) - createMailFeature(database, userCache) - createMessageFeature(messenger) + createMailFeature(database, userCache, chatConfirmations, wiretap) + createMessageFeature(messenger, chatConfirmations, wiretap) createNicknameFeature( - database, userCache, NicknameConfig( + database, userCache, + NicknameConfig( config.clearNicknameOnChange, // IDK, this when config config.nicknamePresets.mapValues { (_, v) -> NickPreset(v) }.toSortedMap(), - ) + ), ) - createProfileFeature(database, luckPerms, userCache) - createSpyingFeature(database) + createProfileFeature(database, luckPerms, userCache, chatConfirmations) } } @@ -91,11 +92,8 @@ class ChattORE @Inject constructor( ): Boolean { val exception = throwable as? ChattoreException ?: return false val message = exception.message ?: "Something went wrong!" - if (sender is Player) { - sender.sendSimpleS("Oh NO ! : ", message) - } else { - sender.sendMessage("Error: $message") - } + // cast ok because we're running on Velocity + (sender as VelocityCommandIssuer).issuer.sendError(message) return true } // TODO reloading functionality diff --git a/chattore/src/main/kotlin/ChattOREConfig.kt b/chattore/src/main/kotlin/ChattOREConfig.kt index 5c348d8..cc9ee42 100644 --- a/chattore/src/main/kotlin/ChattOREConfig.kt +++ b/chattore/src/main/kotlin/ChattOREConfig.kt @@ -15,9 +15,12 @@ data class ChattOREConfig( ) data class FormatConfig( - val global: String = " | : ", + val chatMessage: String = " | : ", val join: String = " has joined the network", val leave: String = " has left the network", + val bubblePrefix: String = "\uD83D\uDCAC", + val spyPrefix: String = "\uD83D\uDD75", + val shoutPrefix: String = "\uD83D\uDD15", val joinDiscord: String = "**%player% has joined the network**", val leaveDiscord: String = "**%player% has left the network**", ) diff --git a/chattore/src/main/kotlin/Config.kt b/chattore/src/main/kotlin/Config.kt index efb537d..49704d5 100644 --- a/chattore/src/main/kotlin/Config.kt +++ b/chattore/src/main/kotlin/Config.kt @@ -99,8 +99,17 @@ private val removeUnnecessaryStuffAndReorganize: Migration = { } } -private val migrations = arrayOf( +private val bubbleIntroduction: Migration = { + // format.global -> format.chatMessage + getObject("format").apply { + replace("chatMessage", get("global")) + remove("global") + } +} + +private val migrations = arrayOf( removeUnnecessaryStuffAndReorganize, + bubbleIntroduction, ) private val currentConfigVersion: ConfigVersion = migrations.size diff --git a/chattore/src/main/kotlin/Messenger.kt b/chattore/src/main/kotlin/Messenger.kt index 7c01e79..44efb2d 100644 --- a/chattore/src/main/kotlin/Messenger.kt +++ b/chattore/src/main/kotlin/Messenger.kt @@ -7,24 +7,30 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import net.kyori.adventure.text.Component +import net.kyori.adventure.text.Component.space import net.kyori.adventure.text.TextReplacementConfig +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer import net.luckperms.api.LuckPerms -import org.openredstone.chattore.feature.DiscordBroadcastEvent -import org.openredstone.chattore.feature.Emojis -import org.openredstone.chattore.feature.NickPreset +import org.openredstone.chattore.feature.* +import org.slf4j.Logger import java.net.URI +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import kotlin.jvm.optionals.getOrNull fun PluginScope.createMessenger( emojis: Emojis, database: Storage, luckPerms: LuckPerms, - chatBroadcastFormat: String, + formatConfig: FormatConfig, + wiretap: Wiretap, + userCache: UserCache, ): Messenger { val fileTypeMap = Json.parseToJsonElement(loadResourceAsString("filetypes.json")) .jsonObject.mapValues { (_, value) -> value.jsonArray.map { it.jsonPrimitive.content } } .onEach { (key, values) -> logger.info("Loaded ${values.size} of type $key") } - return Messenger(emojis, proxy, database, luckPerms, chatBroadcastFormat, fileTypeMap) + return Messenger(emojis, proxy, database, luckPerms, formatConfig, fileTypeMap, wiretap, logger, userCache) } class Messenger( @@ -32,8 +38,11 @@ class Messenger( private val proxy: ProxyServer, private val database: Storage, private val luckPerms: LuckPerms, - private val chatBroadcastFormat: String, + private val formatConfig: FormatConfig, private val fileTypeMap: Map>, + private val wiretap: Wiretap, + private val logger: Logger, + private val userCache: UserCache, ) { private val urlRegex = """]+)?)>?""".toRegex() @@ -44,6 +53,7 @@ class Messenger( formatReplacement("~~", "st"), buildEmojiReplacement(emojis), ) + val excludedFromGlobalChat: MutableSet = ConcurrentHashMap.newKeySet() private fun formatReplacement(key: String, tag: String): TextReplacementConfig = TextReplacementConfig.builder() @@ -67,36 +77,60 @@ class Messenger( } .build() - fun broadcastChatMessage(originServer: String, player: Player, message: String) { - val userId = player.uniqueId - val userManager = luckPerms.userManager - val luckUser = userManager.getUser(userId) ?: return - val name = database.getNickname(userId) ?: NickPreset(player.username) - val sender = - "Click for more'>" - .renderSimpleC(name.render(player.username)) - + private fun formatPrefix(player: Player): Component { + val luckUser = luckPerms.userManager.getUser(player.uniqueId)!! // online users guaranteed to be loaded val prefix = luckUser.cachedData.metaData.prefix ?: luckUser.primaryGroup.replaceFirstChar(Char::uppercaseChar) + return prefix.legacyDeserialize() + } - val compoPrefix = prefix.legacyDeserialize() - proxy.all.sendRichMessage( - chatBroadcastFormat, - "message" toC prepareChatMessage(message, player), - "sender" toC sender, - "prefix" toC compoPrefix, - ) + private fun formatSender(player: Player): Component { + val name = database.getNickname(player.uniqueId) ?: NickPreset(player.username) + return "Click for more'>" + .renderSimpleC(name.render(player.username)) + } + + fun formatChatMessage( + message: String, + player: Player, + sender: Component = formatSender(player), + prefix: Component = formatPrefix(player), + ) = formatConfig.chatMessage.render( + "message" toC prepareChatMessage(message, player), + "sender" toC sender, + "prefix" toC prefix, + ) + + val globalChat = proxy.all { it.uniqueId !in excludedFromGlobalChat } + + fun broadcastChatMessage(player: Player, message: String) { + logger.info("${player.username} (${player.uniqueId}): $message") + val originServer = player.currentServer.getOrNull()?.serverInfo?.name ?: "VOID" + val compoPrefix = formatPrefix(player) + globalChat.sendMessage(formatChatMessage(message, player, prefix = compoPrefix)) val plainPrefix = PlainTextComponentSerializer.plainText().serialize(compoPrefix) val discordBroadcast = DiscordBroadcastEvent( plainPrefix, player.username, originServer, - message + message, ) proxy.eventManager.fireAndForget(discordBroadcast) } + fun broadcastBubbleMessage(player: Player, message: String, bubble: Bubble) { + logger.info("[Bubble] ${player.username} (${player.uniqueId}): $message") + val formattedMessage = formatChatMessage(message, player) + val bubbleInfo = Placeholder.styling("bubble_info", bubble.formatInfo(userCache)) + val renderedMessage = + Component.textOfChildren(formatConfig.bubblePrefix.render(bubbleInfo), space(), formattedMessage) + bubble.players.forEach { uuid -> + proxy.playerOrNull(uuid)?.sendMessage(renderedMessage) + } + wiretap(renderedMessage) + } + fun prepareChatMessage( message: String, player: Player?, diff --git a/chattore/src/main/kotlin/Storage.kt b/chattore/src/main/kotlin/Storage.kt index 24c2b13..e8ba1d5 100644 --- a/chattore/src/main/kotlin/Storage.kt +++ b/chattore/src/main/kotlin/Storage.kt @@ -1,6 +1,9 @@ package org.openredstone.chattore +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.transactions.transaction @@ -45,7 +48,7 @@ object JsonSetting : Table("setting") { val uuidKeyIndex = index("setting_uuid_key_index", true, uuid, key) } -class Setting(val key: String) +class Setting(val key: String, val default: T) class Storage( dbFile: Path, @@ -61,7 +64,7 @@ class Storage( private fun initTables() = transaction(database) { SchemaUtils.create( - About, Mail, Nick, UsernameCache, JsonSetting + About, Mail, Nick, UsernameCache, JsonSetting, ) } @@ -127,7 +130,7 @@ class Storage( it[Mail.id], it[Mail.timestamp], UUID.fromString(it[Mail.sender]), - it[Mail.read] + it[Mail.read], ) } } @@ -138,19 +141,38 @@ class Storage( } } - inline fun setSetting(setting: Setting, uuid: UUID, value: T) = transaction(database) { - JsonSetting.upsert { - it[JsonSetting.uuid] = uuid.toString() - it[key] = setting.key - it[JsonSetting.value] = Json.encodeToString(value) + private val settingCache = ConcurrentHashMap, UUID>, Any>() + + // these ugly wrappers are needed due to reified generics and to keep settingCache private + inline fun setSetting(setting: Setting, uuid: UUID, value: T) = + unsafeSetSetting(setting, uuid, value, Json.serializersModule.serializer()) + + fun unsafeSetSetting(setting: Setting, uuid: UUID, value: T, serializer: SerializationStrategy) { + transaction(database) { + JsonSetting.upsert { + it[JsonSetting.uuid] = uuid.toString() + it[key] = setting.key + it[JsonSetting.value] = Json.encodeToString(serializer, value) + } } + settingCache[setting to uuid] = value } - inline fun getSetting(setting: Setting, uuid: UUID): T? = transaction { - val result = JsonSetting.selectAll().where { - (JsonSetting.uuid eq uuid.toString()) and (JsonSetting.key eq setting.key) - }.singleOrNull() ?: return@transaction null - val jsonString = result[JsonSetting.value] - Json.decodeFromString(jsonString) + inline fun getSetting(setting: Setting, uuid: UUID): T = + unsafeGetSetting(setting, uuid, Json.serializersModule.serializer()) + + fun unsafeGetSetting(setting: Setting, uuid: UUID, deserializer: DeserializationStrategy): T { + val cached = settingCache[setting to uuid] + @Suppress("UNCHECKED_CAST") + if (cached != null) return cached as T + val value = transaction { + val result = JsonSetting.selectAll().where { + (JsonSetting.uuid eq uuid.toString()) and (JsonSetting.key eq setting.key) + }.singleOrNull() ?: return@transaction null + val jsonString = result[JsonSetting.value] + Json.decodeFromString(deserializer, jsonString) + } ?: setting.default + settingCache[setting to uuid] = value + return value } } diff --git a/chattore/src/main/kotlin/Util.kt b/chattore/src/main/kotlin/Util.kt index 8347cce..5f9d04e 100644 --- a/chattore/src/main/kotlin/Util.kt +++ b/chattore/src/main/kotlin/Util.kt @@ -62,10 +62,12 @@ fun Audience.sendSimpleC(format: String, message: Component) = sendMessage(forma fun Audience.sendSimpleS(format: String, message: String) = sendSimpleC(format, message.toComponent()) fun Audience.sendSimpleMM(format: String, message: String) = sendSimpleC(format, message.render()) -private const val infoFormat = "[ChattORE] " -fun Audience.sendInfo(message: String) = sendSimpleC(infoFormat, message.toComponent()) -fun Audience.sendInfoMM(message: String, vararg resolvers: TagResolver) = - sendSimpleC(infoFormat, message.render(*resolvers)) +private const val infoFormat = "[ChattORE] " +fun Audience.sendInfoC(message: Component) = sendSimpleC(infoFormat, message) +fun Audience.sendInfo(message: String) = sendInfoC(message.toComponent()) +fun Audience.sendInfoMM(message: String, vararg resolvers: TagResolver) = sendInfoC(message.render(*resolvers)) + +fun Audience.sendError(message: String) = sendInfoMM("", "message" toS message) /** Mirrors Player.sendRichMessage **/ fun Audience.sendRichMessage(message: String, vararg resolvers: TagResolver) = sendMessage(message.render(*resolvers)) diff --git a/chattore/src/main/kotlin/feature/Bubble.kt b/chattore/src/main/kotlin/feature/Bubble.kt new file mode 100644 index 0000000..8708c60 --- /dev/null +++ b/chattore/src/main/kotlin/feature/Bubble.kt @@ -0,0 +1,357 @@ +package org.openredstone.chattore.feature + +import co.aikar.commands.BaseCommand +import co.aikar.commands.CommandHelp +import co.aikar.commands.InvalidCommandArgument +import co.aikar.commands.annotation.* +import co.aikar.commands.velocity.contexts.OnlinePlayer +import com.velocitypowered.api.command.CommandSource +import com.velocitypowered.api.proxy.Player +import com.velocitypowered.api.proxy.ProxyServer +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.Component.* +import net.kyori.adventure.text.event.ClickEvent.runCommand +import net.kyori.adventure.text.event.HoverEvent +import net.kyori.adventure.text.event.HoverEvent.showText +import org.openredstone.chattore.* +import java.util.* +import kotlin.jvm.optionals.getOrNull + +private val ShowGlobalChatInBubble = Setting("showGlobalChatInBubble", default = false) +private const val BUBBLE_OWNED = "bubbleOwned" + +fun PluginScope.createBubbleFeature( + messenger: Messenger, + database: Storage, + chatConfirmations: ChatConfirmations, + formatConfig: FormatConfig, + userCache: UserCache, +): BubbleManager { + val bubbleManager = BubbleManager() + commandManager.apply { + commandContexts.registerIssuerOnlyContext(Bubble::class.java) { ctx -> + val sender = ctx.sender as? Player + ?: throw InvalidCommandArgument("This command can only be used by players!", false) + val bubble = bubbleManager.getBubbleByPlayer(sender) + ?: throw InvalidCommandArgument("You are not in a bubble!", false) + if (ctx.hasFlag(BUBBLE_OWNED) && bubble.owner != sender.uniqueId) { + val ownerName = proxy.playerOrNull(bubble.owner)?.username ?: bubble.owner.toString() + throw InvalidCommandArgument( + "You must be the owner of the bubble to use this command. (Current owner: $ownerName)", + false, + ) + } + bubble + } + commandContexts.registerContext(Array::class.java) { ctx -> + // distinct() is called here already so that sendError is not called more than once per string. + // If this is upgraded to do fuzzy matching, then something different is needed. + ctx.args.map(String::lowercase).distinct() + .mapNotNull { arg -> + proxy.getPlayer(arg).getOrNull() ?: run { + ctx.sender.sendError("Player $arg is not online!") + null + } + } + .toTypedArray() + } + commandCompletions.setDefaultCompletion("players", Array::class.java) + } + registerCommands( + BubbleCommand( + messenger, + proxy, + database, + bubbleManager, + chatConfirmations, + formatConfig, + userCache, + ), + ) + return bubbleManager +} + +@CommandAlias("bubble|bb") +@CommandPermission("chattore.bubble") +private class BubbleCommand( + private val messenger: Messenger, + private val proxy: ProxyServer, + private val database: Storage, + private val bubbleManager: BubbleManager, + private val chatConfirmations: ChatConfirmations, + private val formatConfig: FormatConfig, + private val userCache: UserCache, +) : BaseCommand() { + + @CatchUnknown + @HelpCommand + @Subcommand("help") + fun help(sender: CommandSource, help: CommandHelp) { + sender.sendInfoMM("Bubble help") + help.showHelp() + } + + // NOTE: for some reason Array still requires the explicit CommandCompletion annotation + @Subcommand("create|blow") + @Description("Create (\"blow\") a bubble and invite players") + @CommandCompletion("@players") + fun create(sender: Player, @ConsumesRest players: Array) { + if (bubbleManager.getBubbleByPlayer(sender) != null) + throw ChattoreException("You are already in a bubble!") + val bubble = bubbleManager.createBubble(sender.uniqueId) + addExcluded(sender.uniqueId) + sender.sendInfo("Bubble created.") + sendInvites(sender, bubble, players) + } + + @Subcommand("invite") + @Description("Invite players to your bubble (if it is private)") + @CommandCompletion("@players") + fun invite(sender: Player, bubble: Bubble, @ConsumesRest targets: Array) { + if (!bubble.isPrivate) + throw ChattoreException("Your bubble is public, anyone can join without invitation.") + if (targets.isEmpty()) + throw ChattoreException("Please specify one or more players to invite.") + sendInvites(sender, bubble, targets) + } + + private fun sendInvites(sender: Player, bubble: Bubble, targets: Array) { + for (player in targets) { + try { + doInvite(sender, bubble, player) + } catch (e: ChattoreException) { + sender.sendError(e.message ?: throw e) + } + } + } + + private fun doInvite(sender: Player, bubble: Bubble, player: Player) { + if (sender == player) + throw ChattoreException("You cannot invite yourself!") + if (player.uniqueId in bubble.invitedPlayers) + throw ChattoreException("${player.username} is already invited!") + if (player.uniqueId in bubble.players) + throw ChattoreException("${player.username} is already in the bubble!") + + bubble.invitedPlayers.add(player.uniqueId) + sender.sendInfo("Invited ${player.username}.") + player.sendInfoC( + textOfChildren( + text("${sender.username} invited you to their bubble. "), + bubble.joinButton(userCache), + newline(), + text("Currently in the bubble: ${bubble.playersString(userCache)}"), + ), + ) + } + + @Subcommand("join") + @Description("Join a player's bubble") + fun join(sender: Player, target: OnlinePlayer) { + if (bubbleManager.getBubbleByPlayer(sender) != null) + throw ChattoreException("You are already in a bubble!") + + val player = target.player + val bubble = bubbleManager.getBubbleByPlayer(player) + ?: throw ChattoreException("${player.username} is not in a bubble!") + + if (bubble.isPrivate && sender.uniqueId !in bubble.invitedPlayers && !sender.hasBubblePrivilege) + throw ChattoreException("You are not invited to the bubble!") + + bubble.invitedPlayers.remove(sender.uniqueId) + bubble.players.add(sender.uniqueId) + addExcluded(sender.uniqueId) + bubble.sendInfos( + sender, + "You joined ${player.username}'s bubble.", + "${sender.username} joined the bubble.", + ) + } + + @Subcommand("leave") + @Description("Leave your current bubble") + fun leave(sender: Player, bubble: Bubble) { + bubble.players.remove(sender.uniqueId) + messenger.excludedFromGlobalChat.remove(sender.uniqueId) + sender.sendInfo("You left the bubble.") + if (bubble.players.isEmpty()) { + bubbleManager.removeBubble(bubble) + return + } + bubble.broadcastInfo("${sender.username} left the bubble.") + if (bubble.owner == sender.uniqueId) { + val newOwner = bubble.players.first() + bubble.owner = newOwner + proxy.playerOrNull(newOwner)?.sendInfo("You are now the owner of the bubble.") + } + } + + @Subcommand("delete|pop") + @Description("Delete (\"pop\") your own bubble") + fun delete(sender: Player, @Flags(BUBBLE_OWNED) bubble: Bubble) { + bubble.sendInfos( + sender, + "You popped the bubble.", + "${sender.username} popped the bubble.", + ) + messenger.excludedFromGlobalChat.removeAll(bubble.players) + bubbleManager.removeBubble(bubble) + } + + @Subcommand("kick") + @Description("Kick a player from your own bubble") + fun kick(sender: Player, @Flags(BUBBLE_OWNED) bubble: Bubble, target: OnlinePlayer) { + val player = target.player + if (player.uniqueId == sender.uniqueId) + throw ChattoreException("You cannot kick yourself!") + + bubble.players.remove(player.uniqueId) + messenger.excludedFromGlobalChat.remove(player.uniqueId) + player.sendInfo("You were kicked out of the bubble.") + bubble.sendInfos( + sender, + "You kicked ${player.username} from the bubble.", + "${sender.username} kicked ${player.username} from the bubble.", + ) + } + + @Subcommand("setprivate") + @Description("Set the visibility of your own bubble") + fun setPrivate(sender: Player, @Flags(BUBBLE_OWNED) bubble: Bubble, isPrivate: Boolean) { + bubble.isPrivate = isPrivate + val visibility = if (isPrivate) "private" else "public" + bubble.sendInfos( + sender, + "The bubble is now $visibility.", + "${sender.username} set the bubble to $visibility.", + ) + } + + private fun Player.canSee(bubble: Bubble) = hasBubblePrivilege + || !bubble.isPrivate || uniqueId in bubble.players || uniqueId in bubble.invitedPlayers + + private val Player.hasBubblePrivilege: Boolean get() = hasPermission("chattore.bubble.manage") + + @Subcommand("list") + @Description("List all bubbles") + fun list(sender: Player) { + val bubbles = bubbleManager.bubbles.filter { sender.canSee(it) } + if (bubbles.isEmpty()) { + sender.sendInfo("There are currently no bubbles.") + return + } + sender.sendRichMessage("Bubbles:") + for (bubble in bubbles) { + val info = if (sender.uniqueId in bubble.players) { + "Your current bubble".render() + } else { + bubble.joinButton(userCache) + } + sender.sendMessage( + textOfChildren( + text(bubble.playersString(userCache)), + " | ".render(), + info, + ), + ) + } + } + + @Subcommand("burst") + @CommandPermission("chattore.bubble.manage") + @Description("Burst (delete) someone's bubble") + fun burst(sender: Player, target: OnlinePlayer) { + val player = target.player + val bubble = bubbleManager.getBubbleByPlayer(player) + ?: throw ChattoreException("${player.username} is not in a bubble!") + bubble.broadcastInfo("The bubble was burst by ${sender.username}.") + messenger.excludedFromGlobalChat.removeAll(bubble.players) + bubbleManager.removeBubble(bubble) + sender.sendInfo("You burst ${player.username}'s bubble.") + } + + @Subcommand("showglobalchat|sgc") + @Description("Control the visibility of global chat when in a bubble") + fun showGlobalChat(sender: Player, showGlobalChat: Boolean) { + database.setSetting(ShowGlobalChatInBubble, sender.uniqueId, showGlobalChat) + if (showGlobalChat) { + messenger.excludedFromGlobalChat.remove(sender.uniqueId) + } else if (bubbleManager.getBubbleByPlayer(sender) != null) { + messenger.excludedFromGlobalChat.add(sender.uniqueId) + } + sender.sendInfo( + if (showGlobalChat) "You will now see global chat in bubbles." + else "You will no longer see global chat in bubbles.", + ) + } + + @CommandAlias("shout") + @Description("Send a message to global chat when in a bubble") + fun shout(sender: Player, message: String) { + chatConfirmations.submit(sender, message) { sender -> + messenger.broadcastChatMessage(sender, message) + if (sender.uniqueId in messenger.excludedFromGlobalChat) { + sender.sendMessage( + textOfChildren( + formatConfig.shoutPrefix.render(), + space(), + messenger.formatChatMessage(message, sender), + ), + ) + } + } + } + + private fun Bubble.sendInfos(you: Player, yourMessage: String, theirMessage: String) { + players.forEach { uuid -> + val player = proxy.playerOrNull(uuid) ?: return@forEach + player.sendInfo(if (player == you) yourMessage else theirMessage) + } + } + + private fun Bubble.broadcastInfo(message: String) { + players.forEach { uuid -> + proxy.playerOrNull(uuid)?.sendInfo(message) + } + } + + private fun addExcluded(uuid: UUID) { + if (!database.getSetting(ShowGlobalChatInBubble, uuid)) { + messenger.excludedFromGlobalChat.add(uuid) + } + } +} + +class Bubble( + var owner: UUID, + val players: MutableSet, + val invitedPlayers: MutableSet, + var isPrivate: Boolean, +) { + fun playersString(userCache: UserCache) = + players.joinToString(", ", transform = userCache::usernameOrUuid) + + fun formatInfo(userCache: UserCache): HoverEvent = + showText(text("Bubble: ${playersString(userCache)}")) + + fun joinButton(userCache: UserCache): Component { + val ownerName = userCache.usernameOrUuid(owner) + return "[Join]".render() + .clickEvent(runCommand("/bubble join $ownerName")) + .hoverEvent(showText(text("Click to join bubble"))) + } +} + +class BubbleManager { + private val _bubbles: MutableList = mutableListOf() + val bubbles: List get() = _bubbles + + fun createBubble(player: UUID): Bubble = + Bubble(player, mutableSetOf(player), mutableSetOf(), isPrivate = true).also(_bubbles::add) + + fun removeBubble(bubble: Bubble) { + _bubbles.remove(bubble) + } + + fun getBubbleByPlayer(player: Player): Bubble? = _bubbles.firstOrNull { player.uniqueId in it.players } +} diff --git a/chattore/src/main/kotlin/feature/Chat.kt b/chattore/src/main/kotlin/feature/Chat.kt index da8a982..525996e 100644 --- a/chattore/src/main/kotlin/feature/Chat.kt +++ b/chattore/src/main/kotlin/feature/Chat.kt @@ -1,92 +1,41 @@ package org.openredstone.chattore.feature -import co.aikar.commands.BaseCommand -import co.aikar.commands.annotation.CommandAlias -import co.aikar.commands.annotation.CommandPermission -import co.aikar.commands.annotation.Default import com.velocitypowered.api.event.Subscribe import com.velocitypowered.api.event.player.PlayerChatEvent -import com.velocitypowered.api.proxy.Player -import org.openredstone.chattore.ChattoreException import org.openredstone.chattore.Messenger import org.openredstone.chattore.PluginScope -import org.openredstone.chattore.sendSimpleMM -import org.slf4j.Logger -import java.util.* -import java.util.concurrent.ConcurrentHashMap - -data class ChatConfirmationConfig( - val regexes: List = listOf(), -) +import org.openredstone.chattore.sendError fun PluginScope.createChatFeature( messenger: Messenger, - config: ChatConfirmationConfig, + confirmations: ChatConfirmations, + bubbleManager: BubbleManager, ) { - val flaggedMessages = ConcurrentHashMap() - registerCommands(ConfirmMessage(flaggedMessages, logger, messenger)) - registerListeners(ChatListener(config, flaggedMessages, logger, messenger)) + registerListeners(ChatListener(confirmations, messenger, bubbleManager)) } private class ChatListener( - private val config: ChatConfirmationConfig, - private val flaggedMessages: ConcurrentHashMap, - private val logger: Logger, + private val confirmations: ChatConfirmations, private val messenger: Messenger, + private val bubbleManager: BubbleManager, ) { - - private val regexes = config.regexes.mapNotNull { pattern -> - runCatching { Regex(pattern, RegexOption.IGNORE_CASE) } - .onFailure { logger.error("Invalid regex $pattern: ${it.message}") } - .getOrNull() - } - @Subscribe fun onChatEvent(event: PlayerChatEvent) { val player = event.player val message = event.message - if (isFlagged(player, message)) return - logger.info("${player.username} (${player.uniqueId}): $message") - player.currentServer.ifPresent { server -> - messenger.broadcastChatMessage(server.serverInfo.name, player, message) + val bubble = bubbleManager.getBubbleByPlayer(player) + if (bubble == null) { + confirmations.submit(player, message) { player -> + messenger.broadcastChatMessage(player, message) + } + return } - } - - private fun isFlagged(player: Player, message: String): Boolean { - val matches = regexes.filter { it.containsMatchIn(message) } - if (matches.isEmpty()) { - flaggedMessages.remove(player.uniqueId) - return false - } - fun String.highlight(r: Regex) = r.replace(this) { match -> "${match.value}" } - val highlighted = matches.fold(message, String::highlight) - logger.info("${player.username} (${player.uniqueId}) Attempting to send flagged message: $message") - player.sendSimpleMM( - "The following message was not sent because it contained " + - "potentially inappropriate language:To send this message anyway, run " + - "/confirmmessage.", - highlighted, - ) - flaggedMessages[player.uniqueId] = message - return true - } -} - -@CommandAlias("confirmmessage") -@CommandPermission("chattore.confirmmessage") -private class ConfirmMessage( - private val flaggedMessages: ConcurrentHashMap, - private val logger: Logger, - private val messenger: Messenger, -) : BaseCommand() { - @Default - fun default(player: Player) { - val message = flaggedMessages[player.uniqueId] ?: throw ChattoreException("You have no message to confirm!") - player.sendRichMessage("Override recognized") - flaggedMessages.remove(player.uniqueId) - logger.info("${player.username} (${player.uniqueId}) FLAGGED MESSAGE OVERRIDE: $message") - player.currentServer.ifPresent { server -> - messenger.broadcastChatMessage(server.serverInfo.name, player, message) + confirmations.submit(player, message) { player -> + if (bubbleManager.getBubbleByPlayer(player) != bubble) { + player.sendError("You are no longer in the bubble you're trying to send a message to") + return@submit + } + messenger.broadcastBubbleMessage(player, message, bubble) } } } diff --git a/chattore/src/main/kotlin/feature/ChatConfirmations.kt b/chattore/src/main/kotlin/feature/ChatConfirmations.kt new file mode 100644 index 0000000..8feaff1 --- /dev/null +++ b/chattore/src/main/kotlin/feature/ChatConfirmations.kt @@ -0,0 +1,74 @@ +package org.openredstone.chattore.feature + +import co.aikar.commands.BaseCommand +import co.aikar.commands.annotation.CommandAlias +import co.aikar.commands.annotation.CommandPermission +import co.aikar.commands.annotation.Default +import com.velocitypowered.api.proxy.Player +import org.openredstone.chattore.ChattoreException +import org.openredstone.chattore.PluginScope +import org.openredstone.chattore.sendSimpleMM +import org.slf4j.Logger +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +data class ChatConfirmationConfig( + val regexes: List = listOf(), +) + +fun PluginScope.createChatConfirmations(config: ChatConfirmationConfig): ChatConfirmations = + ChatConfirmations(config, logger).also { registerCommands(it.ConfirmMessage()) } + +class ChatConfirmations( + config: ChatConfirmationConfig, + private val logger: Logger, +) { + private val regexes = config.regexes.mapNotNull { pattern -> + runCatching { Regex(pattern, RegexOption.IGNORE_CASE) } + .onFailure { logger.error("Invalid regex $pattern: ${it.message}") } + .getOrNull() + } + private val flags = ConcurrentHashMap Unit>>() + + /** + * Submit [message] for flagging. [proceed] will be called with an up-to-date instance of [player] + * upon confirmation or if no flagging happens. This is for the rare case when [player] disconnects + * after submit is called and joins back to do /confirmmessage. In that case, the Player object is invalidated + * due to disconnecting, so we can supply back a fresh one from the /confirmmessage event handler in order to + * simplify calls to [submit]. The recommended way to call this is to shadow the variable for [player] in the + * [proceed] lambda's argument. See: literally any call to this function. + */ + fun submit(player: Player, message: String, proceed: (Player) -> Unit) { + val matches = regexes.filter { it.containsMatchIn(message) } + if (matches.isEmpty()) { + flags.remove(player.uniqueId) + proceed(player) + return + } + fun String.highlight(r: Regex) = r.replace(this) { match -> "${match.value}" } + val highlighted = matches.fold(message, String::highlight) + logger.info("${player.username} (${player.uniqueId}) Attempting to send flagged message: $message") + player.sendSimpleMM( + "The following message was not sent because it contained " + + "potentially inappropriate language:To send this message anyway, run " + + "/confirmmessage.", + highlighted, + ) + flags[player.uniqueId] = message to proceed + return + } + + @CommandAlias("confirmmessage") + @CommandPermission("chattore.confirmmessage") + inner class ConfirmMessage : BaseCommand() { + @Default + fun default(player: Player) { + val (message, proceed) = flags[player.uniqueId] + ?: throw ChattoreException("You have no message to confirm!") + player.sendRichMessage("Override recognized") + flags.remove(player.uniqueId) + logger.info("${player.username} (${player.uniqueId}) FLAGGED MESSAGE OVERRIDE: $message") + proceed(player) + } + } +} diff --git a/chattore/src/main/kotlin/feature/Discord.kt b/chattore/src/main/kotlin/feature/Discord.kt index ae05be1..9cf4297 100644 --- a/chattore/src/main/kotlin/feature/Discord.kt +++ b/chattore/src/main/kotlin/feature/Discord.kt @@ -72,7 +72,7 @@ fun PluginScope.createDiscordFeature( val discordMap = spawnServerBots(proxy, logger, config) val serverChannels = discordMap.mapValues { (_, api) -> getGameChat(api, config.channelId) } val mainBotChannel = getGameChat(discordNetwork, config.channelId) - val listener = DiscordListener(logger, messenger, proxy, emojis, config) + val listener = DiscordListener(logger, messenger, emojis, config) @OptIn(KordPreview::class) mainBotChannel.live().onMessageCreate(block = listener::onMessageCreate) registerListeners(DiscordBroadcastListener(config, serverChannels, mainBotChannel, this)) @@ -124,7 +124,6 @@ private class DiscordBroadcastListener( private class DiscordListener( private val logger: Logger, private val messenger: Messenger, - private val proxy: ProxyServer, private val emojis: Emojis, private val config: DiscordConfig, ) { @@ -151,7 +150,7 @@ private class DiscordListener( val url = matchResult.groupValues[2].trim() "$text: $url" }.replace("""\s+""".toRegex(), " ") - proxy.all.sendRichMessage( + messenger.globalChat.sendRichMessage( config.ingameFormat, "sender" toS displayName, "message" toC messenger.prepareChatMessage(transformedMessage, null), diff --git a/chattore/src/main/kotlin/feature/Funcommands.kt b/chattore/src/main/kotlin/feature/Funcommands.kt index 67a5d1b..41fd8c9 100644 --- a/chattore/src/main/kotlin/feature/Funcommands.kt +++ b/chattore/src/main/kotlin/feature/Funcommands.kt @@ -16,9 +16,9 @@ import net.kyori.adventure.text.event.HoverEvent.showText import org.openredstone.chattore.* import org.slf4j.Logger -fun PluginScope.createFunCommandsFeature() { +fun PluginScope.createFunCommandsFeature(chatConfirmations: ChatConfirmations) { val commands = Json.decodeFromString>(loadDataResourceAsString("commands.json")) - createFunCommands(logger, proxy, proxy.commandManager, commands) + createFunCommands(logger, proxy, chatConfirmations, proxy.commandManager, commands) registerCommands(FunCommandsCommand(commands)) } @@ -92,6 +92,7 @@ private data class FunCommand( private fun createFunCommands( logger: Logger, proxy: ProxyServer, + chatConfirmations: ChatConfirmations, commandManager: CommandManager, commands: List, ) { @@ -117,17 +118,20 @@ private fun createFunCommands( return@SimpleCommand } + val allArgs = args.joinToString(" ") val replacements = arrayOf( "name" toS source.username, - "arg-all" toS args.joinToString(" "), + "arg-all" toS allArgs, "arg-1" toS (args.getOrNull(1) ?: ""), - "arg-2" toS (args.getOrNull(2) ?: "") + "arg-2" toS (args.getOrNull(2) ?: ""), ) - cmd.globalChat?.let { proxy.all.sendRichMessage(it, *replacements) } - cmd.localChat?.let { source.sendRichMessage(it, *replacements) } - cmd.othersChat?.let { proxy.allBut(source).sendRichMessage(it, *replacements) } - cmd.run?.let { executeAction(it, source) } + chatConfirmations.submit(source, "/${invocation.alias()} $allArgs") { source -> + cmd.globalChat?.let { proxy.all.sendRichMessage(it, *replacements) } + cmd.localChat?.let { source.sendRichMessage(it, *replacements) } + cmd.othersChat?.let { proxy.allBut(source).sendRichMessage(it, *replacements) } + cmd.run?.let { executeAction(it, source) } + } } commands.forEach { commandConfig -> diff --git a/chattore/src/main/kotlin/feature/HelpOp.kt b/chattore/src/main/kotlin/feature/HelpOp.kt index 6fdeb7f..dd367cb 100644 --- a/chattore/src/main/kotlin/feature/HelpOp.kt +++ b/chattore/src/main/kotlin/feature/HelpOp.kt @@ -10,8 +10,8 @@ import com.velocitypowered.api.proxy.ProxyServer import org.openredstone.chattore.* import org.slf4j.Logger -fun PluginScope.createHelpOpFeature() { - registerCommands(HelpOp(logger, proxy)) +fun PluginScope.createHelpOpFeature(chatConfirmations: ChatConfirmations) { + registerCommands(HelpOp(logger, proxy, chatConfirmations)) } @CommandAlias("helpop|ac") @@ -19,17 +19,20 @@ fun PluginScope.createHelpOpFeature() { private class HelpOp( private val logger: Logger, private val proxy: ProxyServer, + private val chatConfirmations: ChatConfirmations, ) : BaseCommand() { @Default @Syntax("[message]") fun default(player: Player, statement: String) { if (statement.isEmpty()) throw ChattoreException("You have to have a problem first!") // : ) - logger.info("[HelpOp] ${player.username}: $statement") - proxy.all { it.hasChattorePrivilege || it.uniqueId == player.uniqueId } - .sendRichMessage( - "[Help] : ", - "message" toS statement, - "sender" toS player.username, - ) + chatConfirmations.submit(player, statement) { player -> + logger.info("[HelpOp] ${player.username}: $statement") + proxy.all { it.hasChattorePrivilege || it.uniqueId == player.uniqueId } + .sendRichMessage( + "[Help] : ", + "message" toS statement, + "sender" toS player.username, + ) + } } } diff --git a/chattore/src/main/kotlin/feature/Mail.kt b/chattore/src/main/kotlin/feature/Mail.kt index 32bca5e..f4eb5a7 100644 --- a/chattore/src/main/kotlin/feature/Mail.kt +++ b/chattore/src/main/kotlin/feature/Mail.kt @@ -19,8 +19,10 @@ import kotlin.math.min fun PluginScope.createMailFeature( database: Storage, userCache: UserCache, + chatConfirmations: ChatConfirmations, + wiretap: Wiretap, ) { - registerCommands(Mail(database, userCache)) + registerCommands(Mail(database, userCache, chatConfirmations, wiretap)) registerListeners(MailListener(plugin, database, proxy)) } @@ -50,7 +52,7 @@ private class MailContainer(private val userCache: UserCache, private val messag private val pageSize = 6 fun getPage(page: Int = 0): Component { val maxPage = messages.size / pageSize - if (page > maxPage || page < 0) { + if (page !in 0..maxPage) { return "Invalid page requested".toComponent() } val pageStart = page * pageSize @@ -97,6 +99,8 @@ private class MailContainer(private val userCache: UserCache, private val messag private class Mail( private val database: Storage, private val userCache: UserCache, + private val chatConfirmations: ChatConfirmations, + private val wiretap: Wiretap, ) : BaseCommand() { private val mailTimeouts = mutableMapOf() @@ -107,7 +111,7 @@ private class Mail( fun mailbox(player: Player, @Default("0") page: Int) { val container = MailContainer( userCache, - database.getMessages(player.uniqueId) + database.getMessages(player.uniqueId), ) player.sendMessage(container.getPage(page)) } @@ -122,13 +126,19 @@ private class Mail( } val targetUuid = userCache.uuidOrNull(target) ?: throw ChattoreException("We do not recognize that user!") - mailTimeouts[player.uniqueId] = now - database.insertMessage(player.uniqueId, targetUuid, message) - player.sendRichMessage( - "[To ] ", - "message" toS message, - "recipient" toS target, - ) + chatConfirmations.submit(player, message) { player -> + mailTimeouts[player.uniqueId] = now + database.insertMessage(player.uniqueId, targetUuid, message) + player.sendRichMessage( + "[To ] ", + "message" toS message, + "recipient" toS target, + ) + wiretap( + "[From to ] " + .render("message" toS message, "sender" toS player.username, "recipient" toS target), + ) + } } @Subcommand("read") @@ -152,11 +162,14 @@ private class MailListener( fun joinEvent(event: LoginEvent) { val unreadCount = database.getMessages(event.player.uniqueId).filter { !it.read }.size if (unreadCount > 0) - proxy.scheduler.buildTask(plugin, Runnable { - event.player.sendRichMessage( - "You have unread message(s)! Click here to view.", - "count" toS unreadCount.toString(), - ) - }).delay(2L, TimeUnit.SECONDS).schedule() + proxy.scheduler.buildTask( + plugin, + Runnable { + event.player.sendRichMessage( + "You have unread message(s)! Click here to view.", + "count" toS unreadCount.toString(), + ) + }, + ).delay(2L, TimeUnit.SECONDS).schedule() } } diff --git a/chattore/src/main/kotlin/feature/Message.kt b/chattore/src/main/kotlin/feature/Message.kt index 253f989..7f07881 100644 --- a/chattore/src/main/kotlin/feature/Message.kt +++ b/chattore/src/main/kotlin/feature/Message.kt @@ -1,80 +1,71 @@ package org.openredstone.chattore.feature import co.aikar.commands.BaseCommand -import co.aikar.commands.annotation.* +import co.aikar.commands.annotation.CommandAlias +import co.aikar.commands.annotation.CommandPermission +import co.aikar.commands.annotation.Default +import co.aikar.commands.annotation.Syntax import co.aikar.commands.velocity.contexts.OnlinePlayer import com.velocitypowered.api.proxy.Player -import com.velocitypowered.api.proxy.ProxyServer import org.openredstone.chattore.* -import org.slf4j.Logger import java.util.* import java.util.concurrent.ConcurrentHashMap fun PluginScope.createMessageFeature( messenger: Messenger, + chatConfirmations: ChatConfirmations, + wiretap: Wiretap, ) { val replyMap = ConcurrentHashMap() - registerCommands( - Message(logger, messenger, replyMap), - Reply(proxy, logger, messenger, replyMap), - ) -} -@CommandAlias("m|pm|msg|message|vmsg|vmessage|whisper|tell") -@CommandPermission("chattore.message") -private class Message( - private val logger: Logger, - private val messenger: Messenger, - private val replyMap: ConcurrentHashMap, -) : BaseCommand() { - @Default - @Syntax("[target] ") - // unsure if this is needed - @CommandCompletion("@players") - fun default(sender: Player, recipient: OnlinePlayer, message: String) { - sendMessage(logger, messenger, replyMap, sender, recipient.player, message) + fun sendMessage(sender: Player, recipient: Player, message: String) { + logger.info( + "${sender.username} (${sender.uniqueId}) -> " + + "${recipient.username} (${recipient.uniqueId}): $message", + ) + + val preparedMessage = messenger.prepareChatMessage(message, sender) + fun renderDM(senderName: String, recipientName: String) = + "[ -> ] ".render( + "sender" toS senderName, "recipient" toS recipientName, "message" toC preparedMessage, + ) + + sender.sendMessage(renderDM("me", recipient.username)) + recipient.sendMessage(renderDM(sender.username, "me")) + wiretap(renderDM(sender.username, recipient.username)) + + replyMap[recipient.uniqueId] = sender.uniqueId + replyMap[sender.uniqueId] = recipient.uniqueId } -} -@CommandAlias("r|reply") -@CommandPermission("chattore.message") -private class Reply( - private val proxy: ProxyServer, - private val logger: Logger, - private val messenger: Messenger, - private val replyMap: ConcurrentHashMap, -) : BaseCommand() { - @Default - fun default(sender: Player, message: String) { - val recipientUuid = replyMap[sender.uniqueId] ?: throw ChattoreException("You have no one to reply to!") - val recipient = proxy.playerOrNull(recipientUuid) - ?: throw ChattoreException("The person you are trying to reply to is no longer online!") - sendMessage(logger, messenger, replyMap, sender, recipient, message) + @CommandAlias("m|pm|msg|message|vmsg|vmessage|whisper|tell") + @CommandPermission("chattore.message") + class Message : BaseCommand() { + @Default + @Syntax("[target] ") + fun default(sender: Player, recipient: OnlinePlayer, message: String) { + val recipientUuid = recipient.player.uniqueId + chatConfirmations.submit(sender, message) { sender -> + val recipientPlayer = proxy.playerOrNull(recipientUuid) + ?: throw ChattoreException("The person you're trying to message is no longer online!") + sendMessage(sender, recipientPlayer, message) + } + } } -} -private fun sendMessage( - logger: Logger, - messenger: Messenger, - replyMap: MutableMap, - sender: Player, - recipient: Player, - message: String, -) { - logger.info( - "${sender.username} (${sender.uniqueId}) -> " + - "${recipient.username} (${recipient.uniqueId}): $message" - ) - sender.sendRichMessage( - "[me -> ] ", - "message" toC messenger.prepareChatMessage(message, sender), - "recipient" toS recipient.username, - ) - recipient.sendRichMessage( - "[ -> me] ", - "message" toC messenger.prepareChatMessage(message, sender), - "sender" toS sender.username, - ) - replyMap[recipient.uniqueId] = sender.uniqueId - replyMap[sender.uniqueId] = recipient.uniqueId + @CommandAlias("r|reply") + @CommandPermission("chattore.message") + class Reply : BaseCommand() { + @Default + fun default(sender: Player, message: String) { + val recipientUuid = replyMap[sender.uniqueId] ?: throw ChattoreException("You have no one to reply to!") + chatConfirmations.submit(sender, message) { sender -> + val recipient = proxy.playerOrNull(recipientUuid) + ?: throw ChattoreException("The person you are trying to reply to is no longer online!") + sendMessage(sender, recipient, message) + } + } + } + + registerCommands(Message(), Reply()) } diff --git a/chattore/src/main/kotlin/feature/Nickname.kt b/chattore/src/main/kotlin/feature/Nickname.kt index 3c6e5ae..88fad1c 100644 --- a/chattore/src/main/kotlin/feature/Nickname.kt +++ b/chattore/src/main/kotlin/feature/Nickname.kt @@ -57,12 +57,14 @@ fun PluginScope.createNicknameFeature( listOf() }) } + commandCompletions.registerCompletion(COMPLETION_SENDER_USERNAME) { listOfNotNull(it.player?.username) } } registerCommands(Nickname(database, proxy, userCache, config)) registerListeners(NicknameListener(database, userCache, config)) } private const val COMPLETION_COLORS = "colors" +private const val COMPLETION_SENDER_USERNAME = "senderUsername" val hexColorMap = mapOf( "0" to Pair("#000000", "black"), @@ -146,7 +148,7 @@ private class Nickname( @Subcommand("presets") @CommandPermission("chattore.nick.preset") - @CommandCompletion("@username") + @CommandCompletion("@${COMPLETION_SENDER_USERNAME}") fun presets(player: Player, @Optional shownText: String?) { val renderedPresets = ArrayList() for ((presetName, preset) in config.presets) { diff --git a/chattore/src/main/kotlin/feature/Profile.kt b/chattore/src/main/kotlin/feature/Profile.kt index 51ae306..3be9ede 100644 --- a/chattore/src/main/kotlin/feature/Profile.kt +++ b/chattore/src/main/kotlin/feature/Profile.kt @@ -16,8 +16,9 @@ fun PluginScope.createProfileFeature( database: Storage, luckPerm: LuckPerms, userCache: UserCache, + chatConfirmations: ChatConfirmations, ) { - registerCommands(Profile(proxy, database, luckPerm, userCache)) + registerCommands(Profile(proxy, database, luckPerm, userCache, chatConfirmations)) } @CommandAlias("profile|playerprofile") @@ -27,6 +28,7 @@ private class Profile( private val database: Storage, private val luckPerms: LuckPerms, private val userCache: UserCache, + private val chatConfirmations: ChatConfirmations, ) : BaseCommand() { @Subcommand("info") @@ -41,8 +43,10 @@ private class Profile( @Subcommand("about") @CommandPermission("chattore.profile.about") fun about(player: Player, about: String) { - database.setAbout(player.uniqueId, about) - player.sendInfo("Set your about to '$about'.") + chatConfirmations.submit(player, "/$execCommandLabel about $about") { player -> + database.setAbout(player.uniqueId, about) + player.sendInfo("Set your about to '$about'.") + } } @Subcommand("setabout") diff --git a/chattore/src/main/kotlin/feature/Spying.kt b/chattore/src/main/kotlin/feature/Spying.kt index a096165..cea9a73 100644 --- a/chattore/src/main/kotlin/feature/Spying.kt +++ b/chattore/src/main/kotlin/feature/Spying.kt @@ -3,56 +3,66 @@ package org.openredstone.chattore.feature import co.aikar.commands.BaseCommand import co.aikar.commands.annotation.CommandAlias import co.aikar.commands.annotation.CommandPermission -import co.aikar.commands.annotation.Default import com.velocitypowered.api.event.Subscribe import com.velocitypowered.api.event.command.CommandExecuteEvent import com.velocitypowered.api.proxy.Player -import com.velocitypowered.api.proxy.ProxyServer import net.kyori.adventure.audience.Audience +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.Component.space +import net.kyori.adventure.text.Component.textOfChildren import org.openredstone.chattore.* -private val SpyEnabled = Setting("spy") +// TODO: rename the key, requires a DB migration +private val CommandSpyEnabled = Setting("spy", default = false) +private val SocialSpyEnabled = Setting("socialSpyEnabled", default = false) -fun PluginScope.createSpyingFeature( - database: Storage, -) { - registerCommands(CommandSpy(database)) - registerListeners(CommandListener(database, proxy)) -} +typealias Wiretap = (Component) -> Unit + +fun PluginScope.createSpyingFeature(database: Storage, formatConfig: FormatConfig): Wiretap { + fun spyAudience(spyEnabled: Setting) = + proxy.all { it.hasChattorePrivilege && database.getSetting(spyEnabled, it.uniqueId) } -private class CommandListener( - private val database: Storage, - proxy: ProxyServer, -) { - private val Player.isSpying: Boolean get() = database.getSetting(SpyEnabled, uniqueId) ?: false - private val spies: Audience = proxy.all { it.hasChattorePrivilege && it.isSpying } + val commandSpies = spyAudience(CommandSpyEnabled) + val socialSpies = spyAudience(SocialSpyEnabled) + registerCommands(SpyCommands(database)) + registerListeners(CommandListener(commandSpies)) + + val spyPrefix = formatConfig.spyPrefix.render() + return { secrets -> socialSpies.sendMessage(textOfChildren(spyPrefix, space(), secrets)) } +} +private class CommandListener(private val spies: Audience) { @Subscribe fun onCommandEvent(event: CommandExecuteEvent) { - spies.sendMessage( - ": ".render( - "message" toS event.command, - "sender" toS ((event.commandSource as? Player)?.username ?: "Console"), - ) + spies.sendRichMessage( + ": ", + "message" toS event.command, + "sender" toS ((event.commandSource as? Player)?.username ?: "Console"), ) } } -@CommandAlias("commandspy") -@CommandPermission("chattore.commandspy") -private class CommandSpy( - private val database: Storage, -) : BaseCommand() { - @Default - fun default(player: Player) { - val setting = database.getSetting(SpyEnabled, player.uniqueId) - val newSetting = !(setting ?: false) - database.setSetting(SpyEnabled, player.uniqueId, newSetting) +private class SpyCommands(private val database: Storage) : BaseCommand() { + @CommandAlias("commandspy") + @CommandPermission("chattore.commandspy") + fun commandSpy(player: Player) { + toggleSpy(player, CommandSpyEnabled, "Command") + } + + @CommandAlias("socialspy") + @CommandPermission("chattore.socialspy") + fun socialSpy(player: Player) { + toggleSpy(player, SocialSpyEnabled, "Social") + } + + private fun toggleSpy(player: Player, spyEnabled: Setting, kind: String) { + val newSetting = !database.getSetting(spyEnabled, player.uniqueId) + database.setSetting(spyEnabled, player.uniqueId, newSetting) player.sendInfo( if (newSetting) { - "You are now spying on commands." + "$kind spy enabled." } else { - "You are no longer spying on commands." + "$kind spy disabled." }, ) } diff --git a/gradle.properties b/gradle.properties index bcd1bad..6e3afa4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,2 @@ -version=1.2 +version=1.3 group=org.openredstone.chattore diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4e4f082..515d894 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,7 +23,7 @@ jackson-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-modul jackson-databind = { group = "com.fasterxml.jackson.core", name = "jackson-databind", version.ref = "jackson" } [plugins] -shadow = { id = "com.gradleup.shadow", version = "8.3.6" } +shadow = { id = "com.gradleup.shadow", version = "9.4.2" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ca5caab..a006b7f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Jan 21 15:46:13 EST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists