Multi world support

This commit is contained in:
NotAlexNoyle committed 2026-08-05 12:00:11 -04:00
1 parent 91940a6f0a
commit 1df4776b68
12 files changed
+501 -52

No files matched your search

+12
View File
@@ -1,5 +1,17 @@
# Changelog # Changelog
NotAlexNoyle 08/05/26:
- Add multi world game chat. Worlds named `<prefix><number>-hub` or `<prefix><number>-<map>` get their
own isolated chat and link to the Discord channel configured under `discord.games` for that prefix.
- Tell a game's worlds apart in Discord with the configurable `gameLobbyLabel` and `gameWorldLabel`.
- Route joins, quits, kicks, deaths and advancements to the world's channel. The main world and its
nether and end keep using the general channel; any other world is not reported.
- Post a joined and left pair to a game channel when a player is teleported in or out of its worlds,
so its player count stays balanced.
- Add `ChatOGAPI`, letting a plugin register a `WorldChatFormatter` for the worlds it owns.
- Stop relaying a message to Discord when the blocklist has already suppressed it in game.
NotAlexNoyle 05/06/26: NotAlexNoyle 05/06/26:
- Port all uses of PlaceholderAPI to MiniPlaceholders / Utilities-OG APIs. - Port all uses of PlaceholderAPI to MiniPlaceholders / Utilities-OG APIs.
+108 -3
View File
@@ -7,14 +7,119 @@ Chat plugin for [TrueOG](https://github.com/true-og/true-og)
- Translates chat messages using any OpenAI(-compatible) API. Click any chat or Discord message to translate it on-demand, and run command `/translatesettings <language>` to pick your preferred language (preferences persisted in KeyDB). - Translates chat messages using any OpenAI(-compatible) API. Click any chat or Discord message to translate it on-demand, and run command `/translatesettings <language>` to pick your preferred language (preferences persisted in KeyDB).
- Discord bridge with custom emoji (Minecraft → Discord), animated NQN emoji support, clickable attachment links, and Unicode emoji-to-shortcode conversion (Emoji 17.0). - Discord bridge with custom emoji (Minecraft → Discord), animated NQN emoji support, clickable attachment links, and Unicode emoji-to-shortcode conversion (Emoji 17.0).
- Multi-channel chat: general (`/g` / `/gc`), staff (`/s` / `/sc`), premium (`/p` / `/pc`) and developer (`/d` / `/dc`), each with its own Discord webhook. - Multi-channel chat: general (`/g` / `/gc`), staff (`/s` / `/sc`), premium (`/p` / `/pc`) and developer (`/d` / `/dc`), each with its own Discord webhook.
- Private messaging (`/msg`, `/whisper`, `/pm`) with `/r` and `/reply` shortcuts. - Multi world game chat: every world of a game gets its own chat, and all of a game's worlds share one Discord channel.
- Private messaging (`/msg`, `/whisper`, `/pm`) with `/r` and `/reply` shortcuts, working across every world.
- Forwards joins, quits, kicks, advancements, deaths and broadcasts to Discord, with full Vanish-OG awareness so vanished players never leak into Discord. - Forwards joins, quits, kicks, advancements, deaths and broadcasts to Discord, with full Vanish-OG awareness so vanished players never leak into Discord.
- LuckPerms-driven Discord role color mapping so that player rank based formatting permissions carry over to Discord. - LuckPerms-driven Discord role color mapping so that player rank based formatting permissions carry over to Discord.
- Pings in chat alert the mentioned player. Words both ways from Minecraft <-> Discord. - Pings in chat alert the mentioned player. Works both ways from Minecraft <-> Discord.
- Censor `@everyone`, `@here` and role mentions on the Discord side. - Censor `@everyone`, `@here` and role mentions on the Discord side.
- API for other plugins to send messages through the Discord bridge. - API for other plugins to send messages through the Discord bridge and to style multi world game chat.
- Run command `/chatconfigreload` to fully reload the config and the Discord bridge at runtime. - Run command `/chatconfigreload` to fully reload the config and the Discord bridge at runtime.
## Multi world games
A game is a set of worlds sharing one Discord channel. Worlds are matched by name: `HB1-hub` is a
lobby, `HB1-Ancient_Plateau` is a live game world, and both belong to the `HB` channel.
```yaml
discord:
games:
HB:
enabled: true
channelId: "..."
webhook: "https://discord.com/api/webhooks/..."
# %id% is the lobby id, for example HB1.
gameLobbyLabel: "[%id% - Lobby]"
gameWorldLabel: "[%id% - In Game]"
```
- Chat in a game world reaches only that world, plus the game's Discord channel.
- Joins, quits, kicks, deaths and advancements follow the same routing. The main world and its nether
and end keep using the general channel; any other world is not reported.
- Staff, premium and developer chat, private messages and `/broadcast` stay global in every world.
- `games` ships empty, so nothing changes until it is filled in.
## API
Add Chat-OG as a submodule:
```bash
git submodule add https://github.com/true-og/Chat-OG libs/Chat-OG
```
`build.gradle.kts`:
```kotlin
extra["kotlinAttribute"] = Attribute.of("kotlin-tag", Boolean::class.javaObjectType)
val kotlinAttribute: Attribute<Boolean> by rootProject.extra
dependencies {
compileOnlyApi(project(":libs:Chat-OG")) { attributes { attribute(kotlinAttribute, true) } }
}
```
`plugin.yml`:
```yaml
softdepend:
- Chat-OG
```
### Styling a game's chat
Chat-OG decides who receives a message; your plugin decides how it looks. Register one formatter per
game key, the same key used under `discord.games`.
Kotlin:
```kotlin
ChatOGAPI.setFormatter("HB") { sender, message, worldName, lobbyId ->
Component.join(
JoinConfiguration.noSeparators(),
UtilitiesOG.trueogColorize("&9${sender.name}&8 » &r"),
message,
)
}
```
Java:
```java
ChatOGAPI.setFormatter("HB", (sender, message, worldName, lobbyId) ->
Component.join(JoinConfiguration.noSeparators(),
UtilitiesOG.trueogColorize("&9" + sender.getName() + "&8 » &r"),
message));
```
`message` is already blocklist checked, colorized and permission gated, so build around it instead of
turning it back into a string. `trueogColorize` accepts both modern and legacy color codes.
Return `null` to use Chat-OG's default format, or `Component.empty()` to drop the message. A
formatter that throws is logged and falls back to the default, so it cannot break chat.
### `ChatOGAPI`
| Method | Purpose |
| --- | --- |
| `isAvailable()` | Whether Chat-OG is enabled and ready. |
| `setFormatter(key, formatter)` | Sets a game's chat formatter. Pass `null` to clear it. |
| `getLobbyId(worldName)` | The lobby id for a world, e.g. `HB1`, or `null` if it is not a game world. |
| `broadcast(worldName, message)` | Sends a message to everyone in a world, in game only. |
All of them are safe to call before Chat-OG loads.
### Sending to Discord
`ChatAPI` posts to the built-in channels and is registered with Bukkit's `ServicesManager`:
```java
ChatAPI api = Bukkit.getServicesManager().getRegistration(ChatAPI.class).getProvider();
api.sendMessage("Hello", "SomeName", uuid);
```
It also offers `sendStaffMessage`, `sendPremiumMessage`, `sendMessageWithBot` and `sendEmbed`.
## Building ## Building
```./gradlew clean build eclipse --warning-mode all``` ```./gradlew clean build eclipse --warning-mode all```
@@ -52,6 +52,11 @@ internal class ChatOG : JavaPlugin() {
val discordBridgeLock = ReentrantReadWriteLock() val discordBridgeLock = ReentrantReadWriteLock()
var lastMessagedMap: MutableMap<UUID, UUID> = HashMap() var lastMessagedMap: MutableMap<UUID, UUID> = HashMap()
// Only true once onEnable has fully succeeded, so the API never runs against a half started plugin.
@Volatile private var ready = false
fun isReady() = ready
fun isConfigInitialized() = ::config.isInitialized fun isConfigInitialized() = ::config.isInitialized
fun isLanguageDatabaseInitialized() = ::languageDatabase.isInitialized fun isLanguageDatabaseInitialized() = ::languageDatabase.isInitialized
@@ -130,9 +135,13 @@ internal class ChatOG : JavaPlugin() {
val chatAPI = ChatAPI() val chatAPI = ChatAPI()
this.server.servicesManager.register(ChatAPI::class.java, chatAPI, this, ServicePriority.Normal) this.server.servicesManager.register(ChatAPI::class.java, chatAPI, this, ServicePriority.Normal)
ready = true
} }
override fun onDisable() { override fun onDisable() {
ready = false
if (isConfigInitialized()) { if (isConfigInitialized()) {
if (discordBridge != null) { if (discordBridge != null) {
discordBridgeLock.read { discordBridgeLock.read {
+100 -30
View File
@@ -17,6 +17,8 @@ import nl.skbotnl.chatog.ChatOG.Companion.config
import nl.skbotnl.chatog.ChatOG.Companion.discordBridge import nl.skbotnl.chatog.ChatOG.Companion.discordBridge
import nl.skbotnl.chatog.ChatOG.Companion.discordBridgeLock import nl.skbotnl.chatog.ChatOG.Companion.discordBridgeLock
import nl.skbotnl.chatog.ChatOG.Companion.scope import nl.skbotnl.chatog.ChatOG.Companion.scope
import nl.skbotnl.chatog.chatsystem.GeneralChatSystem
import nl.skbotnl.chatog.chatsystem.WorldChatSystem
import nl.skbotnl.chatog.util.ChatUtil import nl.skbotnl.chatog.util.ChatUtil
import nl.skbotnl.chatog.util.ChatUtil.legacyToMm import nl.skbotnl.chatog.util.ChatUtil.legacyToMm
import nl.skbotnl.chatog.util.PlayerExtensions.chatSystem import nl.skbotnl.chatog.util.PlayerExtensions.chatSystem
@@ -25,6 +27,7 @@ import org.bukkit.event.EventHandler
import org.bukkit.event.Listener import org.bukkit.event.Listener
import org.bukkit.event.entity.PlayerDeathEvent import org.bukkit.event.entity.PlayerDeathEvent
import org.bukkit.event.player.PlayerAdvancementDoneEvent import org.bukkit.event.player.PlayerAdvancementDoneEvent
import org.bukkit.event.player.PlayerChangedWorldEvent
import org.bukkit.event.player.PlayerJoinEvent import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.event.player.PlayerKickEvent import org.bukkit.event.player.PlayerKickEvent
import org.bukkit.event.player.PlayerQuitEvent import org.bukkit.event.player.PlayerQuitEvent
@@ -32,6 +35,21 @@ import org.bukkit.event.server.BroadcastMessageEvent
import xyz.jpenilla.announcerplus.listener.JoinQuitListener import xyz.jpenilla.announcerplus.listener.JoinQuitListener
internal class Events : Listener { internal class Events : Listener {
// The main world and its nether and end, taken from level-name rather than assumed to be "world".
private fun isMainWorld(worldName: String): Boolean {
val main = Bukkit.getWorlds().firstOrNull()?.name ?: return false
return worldName.equals(main, true) ||
worldName.equals("${main}_nether", true) ||
worldName.equals("${main}_the_end", true)
}
// A multi world game world reports to its own channel, the main world to general, anything else nowhere.
private fun reports(worldName: String, key: String?) = key != null || isMainWorld(worldName)
// A game channel counts only its own players, since the server total would mislead there.
private fun onlineCount(key: String?) =
if (key == null) Bukkit.getOnlinePlayers().count() else WorldChatSystem.playersForKey(key).size
@EventHandler @EventHandler
fun onJoin(event: PlayerJoinEvent) { fun onJoin(event: PlayerJoinEvent) {
if (!config.discord.enabled) { if (!config.discord.enabled) {
@@ -41,18 +59,17 @@ internal class Events : Listener {
return return
} }
val worldName = event.player.world.name
val key = WorldChatSystem.keyForWorld(worldName)
if (!reports(worldName, key)) {
return
}
val playerPartString = ChatUtil.getPlayerPartString(event.player) val playerPartString = ChatUtil.getPlayerPartString(event.player)
val message = "$playerPartString has joined the game. ${onlineCount(key)} player(s) online."
scope.launch { scope.launch {
discordBridgeLock.read { discordBridgeLock.read { discordBridge?.sendEmbed(message, event.player.uniqueId, 0x00FF00, key) }
discordBridge?.sendEmbed(
"$playerPartString has joined the game. ${
Bukkit.getOnlinePlayers().count()
} player(s) online.",
event.player.uniqueId,
0x00FF00,
)
}
} }
} }
@@ -65,17 +82,47 @@ internal class Events : Listener {
return return
} }
val worldName = event.player.world.name
val key = WorldChatSystem.keyForWorld(worldName)
if (!reports(worldName, key)) {
return
}
val playerPartString = ChatUtil.getPlayerPartString(event.player) val playerPartString = ChatUtil.getPlayerPartString(event.player)
val message = "$playerPartString has left the game. ${onlineCount(key) - 1} player(s) online."
scope.launch {
discordBridgeLock.read { discordBridge?.sendEmbed(message, event.player.uniqueId, 0xFF0000, key) }
}
}
// Game players are teleported in rather than connecting, so without this a game channel only sees leaves.
@EventHandler
fun onWorldChange(event: PlayerChangedWorldEvent) {
if (!config.discord.enabled) {
return
}
if (VanishAPI.isVanished(event.player)) {
return
}
// Only the game channels track a per world population; general counts the whole server, which
// a world change does not alter.
val fromKey = WorldChatSystem.keyForWorld(event.from.name)
val toKey = WorldChatSystem.keyForWorld(event.player.world.name)
if (fromKey == toKey) {
return
}
val playerPartString = ChatUtil.getPlayerPartString(event.player)
val leftMessage = fromKey?.let { "$playerPartString has left the game. ${onlineCount(it)} player(s) online." }
val joinedMessage = toKey?.let { "$playerPartString has joined the game. ${onlineCount(it)} player(s) online." }
scope.launch { scope.launch {
discordBridgeLock.read { discordBridgeLock.read {
discordBridge?.sendEmbed( if (leftMessage != null) discordBridge?.sendEmbed(leftMessage, event.player.uniqueId, 0xFF0000, fromKey)
"$playerPartString has left the game. ${ if (joinedMessage != null)
Bukkit.getOnlinePlayers().count() - 1 discordBridge?.sendEmbed(joinedMessage, event.player.uniqueId, 0x00FF00, toKey)
} player(s) online.",
event.player.uniqueId,
0xFF0000,
)
} }
} }
} }
@@ -89,20 +136,20 @@ internal class Events : Listener {
return return
} }
val worldName = event.player.world.name
val key = WorldChatSystem.keyForWorld(worldName)
if (!reports(worldName, key)) {
return
}
val playerPartString = ChatUtil.getPlayerPartString(event.player) val playerPartString = ChatUtil.getPlayerPartString(event.player)
val reason = PlainTextComponentSerializer.plainText().serialize(event.reason()) val reason = PlainTextComponentSerializer.plainText().serialize(event.reason())
val message =
"$playerPartString was kicked with reason: \"${reason}\". ${onlineCount(key) - 1} player(s) online."
scope.launch { scope.launch {
discordBridgeLock.read { discordBridgeLock.read { discordBridge?.sendEmbed(message, event.player.uniqueId, 0xFF0000, key) }
discordBridge?.sendEmbed(
"$playerPartString was kicked with reason: \"${reason}\". ${
Bukkit.getOnlinePlayers().count() - 1
} player(s) online.",
event.player.uniqueId,
0xFF0000,
)
}
} }
} }
@@ -115,6 +162,12 @@ internal class Events : Listener {
return return
} }
val worldName = event.player.world.name
val key = WorldChatSystem.keyForWorld(worldName)
if (!reports(worldName, key)) {
return
}
val playerPartString = ChatUtil.getPlayerPartString(event.player) val playerPartString = ChatUtil.getPlayerPartString(event.player)
val advancementTitleKey = event.advancement.display?.title() ?: return val advancementTitleKey = event.advancement.display?.title() ?: return
@@ -132,7 +185,7 @@ internal class Events : Listener {
scope.launch { scope.launch {
discordBridgeLock.read { discordBridgeLock.read {
discordBridge?.sendEmbed("$playerPartString $advancementMessage.", event.player.uniqueId, 0xFFFF00) discordBridge?.sendEmbed("$playerPartString $advancementMessage.", event.player.uniqueId, 0xFFFF00, key)
} }
} }
} }
@@ -161,9 +214,15 @@ internal class Events : Listener {
if (event.isCancelled) return if (event.isCancelled) return
event.isCancelled = true event.isCancelled = true
val worldName = event.player.world.name
scope.launch { scope.launch {
val eventMessage = event.message() as TextComponent val eventMessage = event.message() as TextComponent
event.player.chatSystem.sendMessage(eventMessage.content(), event.player) val chatSystem = event.player.chatSystem
// A multi world game world replaces general chat only, so staff, premium and developer stay global.
val effective =
if (chatSystem === GeneralChatSystem) WorldChatSystem.forWorld(worldName) ?: chatSystem else chatSystem
effective.sendMessage(eventMessage.content(), event.player)
} }
} }
@@ -176,11 +235,18 @@ internal class Events : Listener {
return return
} }
// The in game death message is still rewritten below even when the world reports nowhere.
val worldName = event.player.world.name
val key = WorldChatSystem.keyForWorld(worldName)
val report = reports(worldName, key)
val deathMessage = event.deathMessage() val deathMessage = event.deathMessage()
if (deathMessage is TextComponent) { if (deathMessage is TextComponent) {
scope.launch { if (report) {
discordBridgeLock.read { scope.launch {
discordBridge?.sendEmbed(deathMessage.content(), event.player.uniqueId, 0xFF0000) discordBridgeLock.read {
discordBridge?.sendEmbed(deathMessage.content(), event.player.uniqueId, 0xFF0000, key)
}
} }
} }
return return
@@ -195,12 +261,16 @@ internal class Events : Listener {
val newDeathMessage = builder.build() val newDeathMessage = builder.build()
event.deathMessage(newDeathMessage) event.deathMessage(newDeathMessage)
if (!report) {
return
}
scope.launch { scope.launch {
discordBridgeLock.read { discordBridgeLock.read {
discordBridge?.sendEmbed( discordBridge?.sendEmbed(
PlainTextComponentSerializer.plainText().serialize(newDeathMessage), PlainTextComponentSerializer.plainText().serialize(newDeathMessage),
event.player.uniqueId, event.player.uniqueId,
0xFF0000, 0xFF0000,
key,
) )
} }
} }
@@ -0,0 +1,34 @@
package nl.skbotnl.chatog.api
import net.kyori.adventure.text.Component
import nl.skbotnl.chatog.ChatOG
import nl.skbotnl.chatog.chatsystem.WorldChatSystem
// Multi world game chat API. Worlds are bound to Discord channels in Chat-OG's config, so a plugin
// only ever supplies formatting. Callers must declare softdepend: [Chat-OG] in their plugin.yml.
object ChatOGAPI {
// False until Chat-OG has fully enabled, and again once it disables.
@JvmStatic fun isAvailable(): Boolean = ChatOG.isReady()
// Sets the chat formatter for a game key, for example "HB". False when Chat-OG is not ready.
@JvmStatic
fun setFormatter(key: String, formatter: WorldChatFormatter?): Boolean {
if (!isAvailable()) return false
WorldChatSystem.setFormatter(key, formatter)
return true
}
// Lobby id for a world, for example "HB1", or null if it is not part of a multi world game.
@JvmStatic
fun getLobbyId(worldName: String): String? {
if (!isAvailable()) return null
return WorldChatSystem.forWorld(worldName)?.id
}
// Sends a message to everyone in a world and returns how many players received it.
@JvmStatic
fun broadcast(worldName: String, message: Component): Int {
if (!isAvailable()) return 0
return WorldChatSystem.broadcast(worldName, message)
}
}
@@ -0,0 +1,13 @@
package nl.skbotnl.chatog.api
import net.kyori.adventure.text.Component
import org.bukkit.entity.Player
// Lets a plugin render its own chat line for the multi world game it owns.
fun interface WorldChatFormatter {
// The message is already blocklist checked, colorized and permission gated by Chat-OG.
// Never serialize it back to a string and re-parse it, or players without chat-og.color regain formatting.
// Compose instead, for example Component.join(noSeparators(), UtilitiesOG.trueogColorize(prefix), message).
// Return the finished line, null to use Chat-OG's default format, or Component.empty() to drop the message.
fun format(sender: Player, message: Component, worldName: String, lobbyId: String): Component?
}
@@ -39,24 +39,35 @@ internal abstract class ChatSystem {
abstract fun sendDiscordMessage(text: String, playerPartString: String, uuid: UUID) abstract fun sendDiscordMessage(text: String, playerPartString: String, uuid: UUID)
// Lets a subclass render its own chat line. Null keeps the default format, empty drops the message.
protected open fun formatLine(player: Player, message: Component): Component? = null
fun sendMessage(text: String, player: Player) { fun sendMessage(text: String, player: Player) {
val messageComponent =
ChatUtil.processText(text, player)?.color(PlayerUtils.getMessageColor(player.uniqueId)) ?: return
// Runs before the relay so a dropped message never reaches Discord either.
val formattedLine = formatLine(player, messageComponent)
if (formattedLine == Component.empty()) return
var playerPartString = ChatUtil.getPlayerPartString(player) var playerPartString = ChatUtil.getPlayerPartString(player)
playerPartString = listOfNotNull(prefix, playerPartString).joinToString(" | ") playerPartString = listOfNotNull(prefix, playerPartString).joinToString(" | ")
val discordPlayerPartString = val discordPlayerPartString =
listOfNotNull(prefix, ChatUtil.getPlayerPartString(player, includeSuffix = true)).joinToString(" | ") listOfNotNull(prefix, ChatUtil.getPlayerPartString(player, includeSuffix = true)).joinToString(" | ")
// Relayed only after the blocklist check so a suppressed message never reaches Discord.
sendDiscordMessage(resolveChatItems(text), discordPlayerPartString, player.uniqueId) sendDiscordMessage(resolveChatItems(text), discordPlayerPartString, player.uniqueId)
val messageComponent = var textComponent =
ChatUtil.processText(text, player)?.color(PlayerUtils.getMessageColor(player.uniqueId)) ?: return formattedLine
?: Component.join(
val chatComponent = JoinConfiguration.noSeparators(),
UtilitiesOG.trueogColorize( UtilitiesOG.trueogColorize(
ChatUtil.legacyToMm("$playerPartString<reset>${PlayerUtils.getSuffix(player.uniqueId)} &7> ") ChatUtil.legacyToMm("$playerPartString<reset>${PlayerUtils.getSuffix(player.uniqueId)} &7> ")
) ),
messageComponent,
var textComponent = Component.join(JoinConfiguration.noSeparators(), chatComponent, messageComponent) )
textComponent = textComponent =
textComponent.hoverEvent( textComponent.hoverEvent(
HoverEvent.hoverEvent( HoverEvent.hoverEvent(
@@ -13,8 +13,11 @@ import org.bukkit.Bukkit
internal object GeneralChatSystem : ChatSystem() { internal object GeneralChatSystem : ChatSystem() {
override val prefix = null override val prefix = null
// Multi world game worlds have their own chat, so global chat must not reach into them either.
override val audience: Audience override val audience: Audience
get() = Audience.audience(Bukkit.getOnlinePlayers()) get() =
Audience.audience(Bukkit.getOnlinePlayers().filter { WorldChatSystem.keyForWorld(it.world.name) == null })
override val name = "general" override val name = "general"
@@ -0,0 +1,106 @@
package nl.skbotnl.chatog.chatsystem
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlin.concurrent.read
import kotlinx.coroutines.launch
import net.kyori.adventure.audience.Audience
import net.kyori.adventure.text.Component
import nl.skbotnl.chatog.ChatOG.Companion.config
import nl.skbotnl.chatog.ChatOG.Companion.discordBridge
import nl.skbotnl.chatog.ChatOG.Companion.discordBridgeLock
import nl.skbotnl.chatog.ChatOG.Companion.plugin
import nl.skbotnl.chatog.ChatOG.Companion.scope
import nl.skbotnl.chatog.api.WorldChatFormatter
import nl.skbotnl.chatog.util.ChatUtil
import org.bukkit.Bukkit
import org.bukkit.entity.Player
// Chat for one world of a multi world game, isolated in game but sharing the game's Discord channel.
internal class WorldChatSystem(private val worldName: String, val key: String, val id: String, lobby: Boolean) :
ChatSystem() {
// No label in game, since everyone who can read this chat is already in the same world.
override val prefix = null
// The label only tags the webhook username, which is what tells a game's worlds apart in Discord.
private val label =
(if (lobby) config.discord.gameLobbyLabel ?: DEFAULT_LOBBY_LABEL
else config.discord.gameWorldLabel ?: DEFAULT_WORLD_LABEL)
.replace("%id%", id)
// Filtering the online players avoids touching World.getPlayers() from the async chat coroutine.
override val audience: Audience
get() = Audience.audience(Bukkit.getOnlinePlayers().filter { it.world.name == worldName })
override val name = "world:$worldName"
override fun sendDiscordMessage(text: String, playerPartString: String, uuid: UUID) {
if (!config.discord.enabled || config.discord.games[key]?.enabled != true) return
val discordMessageString = ChatUtil.convertEmojis(text)
scope.launch {
discordBridgeLock.read {
discordBridge?.sendGameMessage(key, discordMessageString, "$label $playerPartString", uuid)
}
}
}
// A formatter belongs to another plugin, so a failure there must not take the chat line down with it.
override fun formatLine(player: Player, message: Component): Component? {
val formatter = formatters[key.uppercase()] ?: return null
return try {
formatter.format(player, message, worldName, id)
} catch (throwable: Throwable) {
plugin.logger.warning("The chat formatter for game \"$key\" failed: $throwable")
null
}
}
companion object {
private const val DEFAULT_LOBBY_LABEL = "[%id% - Lobby]"
private const val DEFAULT_WORLD_LABEL = "[%id% - In Game]"
// Keyed by uppercased game key so registration order and config casing cannot disagree.
private val formatters = ConcurrentHashMap<String, WorldChatFormatter>()
// Worlds are named <prefix><number>-hub for a lobby and <prefix><number>-<map> for a game, so HB1-hub and
// HB1-Ancient_Plateau both belong to the configured discord.games key "HB".
fun forWorld(worldName: String): WorldChatSystem? {
var index = 0
while (index < worldName.length && worldName[index].isLetter()) index++
if (index == 0) return null
val digitStart = index
while (index < worldName.length && worldName[index].isDigit()) index++
if (index == digitStart) return null
if (index >= worldName.length || worldName[index] != '-') return null
val rest = worldName.substring(index + 1)
if (rest.isEmpty()) return null
val prefix = worldName.substring(0, digitStart)
// Resolved from the configured key alone, so turning a game's Discord channel off does not
// also drop its worlds back into global chat.
val key = config.discord.games.keys.firstOrNull { it.equals(prefix, ignoreCase = true) } ?: return null
return WorldChatSystem(worldName, key, worldName.substring(0, index), rest.equals("hub", true))
}
fun keyForWorld(worldName: String): String? = forWorld(worldName)?.key
fun playersForKey(key: String): List<Player> =
Bukkit.getOnlinePlayers().filter { keyForWorld(it.world.name) == key }
// A null formatter clears the one registered for that game.
fun setFormatter(key: String, formatter: WorldChatFormatter?) {
if (formatter == null) formatters.remove(key.uppercase()) else formatters[key.uppercase()] = formatter
}
// In game only. Chat is what belongs in Discord, not every game announcement.
fun broadcast(worldName: String, message: Component): Int {
val recipients = Bukkit.getOnlinePlayers().filter { it.world.name == worldName }
Audience.audience(recipients).sendMessage(message)
return recipients.size
}
}
}
@@ -18,6 +18,13 @@ constructor(
@param:JsonProperty("staff") val staff: DiscordRoleChatConfig, @param:JsonProperty("staff") val staff: DiscordRoleChatConfig,
@param:JsonProperty("premium") val premium: DiscordRoleChatConfig, @param:JsonProperty("premium") val premium: DiscordRoleChatConfig,
@param:JsonProperty("developer") val developer: DiscordRoleChatConfig, @param:JsonProperty("developer") val developer: DiscordRoleChatConfig,
// Multi world game channels keyed by lobby prefix, e.g. "HB" for the worlds HB1-hub and HB1-<map>.
@param:JsonProperty("games")
@field:JsonSetter(nulls = Nulls.AS_EMPTY)
val games: Map<String, DiscordRoleChatConfig>,
// Null falls back to the defaults in WorldChatSystem so older configs keep loading.
@param:JsonProperty("gameLobbyLabel") val gameLobbyLabel: String?,
@param:JsonProperty("gameWorldLabel") val gameWorldLabel: String?,
@param:JsonProperty("listCommandName") val listCommandName: String, @param:JsonProperty("listCommandName") val listCommandName: String,
@param:JsonProperty("listCommandText") val listCommandText: String, @param:JsonProperty("listCommandText") val listCommandText: String,
@param:JsonProperty("useColorCodeRoles") val useColorCodeRoles: Boolean, @param:JsonProperty("useColorCodeRoles") val useColorCodeRoles: Boolean,
@@ -30,6 +30,7 @@ import net.kyori.adventure.text.format.TextColor
import net.trueog.utilitiesog.UtilitiesOG import net.trueog.utilitiesog.UtilitiesOG
import nl.skbotnl.chatog.ChatOG.Companion.config import nl.skbotnl.chatog.ChatOG.Companion.config
import nl.skbotnl.chatog.ChatOG.Companion.plugin import nl.skbotnl.chatog.ChatOG.Companion.plugin
import nl.skbotnl.chatog.chatsystem.WorldChatSystem
import nl.skbotnl.chatog.translation.command.TranslateMessage import nl.skbotnl.chatog.translation.command.TranslateMessage
import nl.skbotnl.chatog.util.ChatUtil import nl.skbotnl.chatog.util.ChatUtil
import nl.skbotnl.chatog.util.EmojiConverter import nl.skbotnl.chatog.util.EmojiConverter
@@ -42,8 +43,13 @@ internal class DiscordBridge private constructor() {
private var staffWebhook: WebhookClient? = null private var staffWebhook: WebhookClient? = null
private var premiumWebhook: WebhookClient? = null private var premiumWebhook: WebhookClient? = null
private var developerWebhook: WebhookClient? = null private var developerWebhook: WebhookClient? = null
private val gameWebhooks = mutableMapOf<String, WebhookClient>()
companion object { companion object {
// The discord.games key whose channel this is, or null when it is not a game channel.
private fun gameKeyOf(channelId: String): String? =
config.discord.games.entries.firstOrNull { it.value.enabled && it.value.channelId == channelId }?.key
fun create(): DiscordBridge? { fun create(): DiscordBridge? {
val discordBridge = DiscordBridge() val discordBridge = DiscordBridge()
if (config.discord.general.webhook != null) { if (config.discord.general.webhook != null) {
@@ -84,6 +90,37 @@ internal class DiscordBridge private constructor() {
} else if (config.discord.developer.enabled) { } else if (config.discord.developer.enabled) {
plugin.logger.warning("You have enabled developer Discord but have not set up the developer webhook") plugin.logger.warning("You have enabled developer Discord but have not set up the developer webhook")
} }
for ((key, game) in config.discord.games) {
if (!game.enabled) continue
if (game.channelId == null) {
plugin.logger.warning(
"You have enabled game Discord \"$key\" but have not set up its channelId, so Discord to Minecraft will not work for it"
)
}
if (game.webhook == null) {
plugin.logger.warning("You have enabled game Discord \"$key\" but have not set up its webhook")
continue
}
try {
discordBridge.gameWebhooks[key] = WebhookClient.withUrl(game.webhook.toString())
} catch (_: IllegalArgumentException) {
plugin.logger.warning("Config option \"games.$key.webhook\" is invalid")
}
}
// Two channels sharing an id would silently steal each other's messages, so say so loudly.
val channelIds =
listOfNotNull(
config.discord.general.channelId,
config.discord.staff.channelId.takeIf { config.discord.staff.enabled },
config.discord.premium.channelId.takeIf { config.discord.premium.enabled },
config.discord.developer.channelId.takeIf { config.discord.developer.enabled },
) + config.discord.games.values.filter { it.enabled }.mapNotNull { it.channelId }
channelIds
.groupingBy { it }
.eachCount()
.filterValues { it > 1 }
.keys
.forEach { plugin.logger.severe("Discord channel id \"$it\" is configured for more than one channel") }
if (config.discord.botToken == null) { if (config.discord.botToken == null) {
plugin.logger.severe("You have enabled Discord but have not set up the bot token") plugin.logger.severe("You have enabled Discord but have not set up the bot token")
@@ -146,7 +183,8 @@ internal class DiscordBridge private constructor() {
(if (config.discord.premium.enabled) it.channel.id != config.discord.premium.channelId (if (config.discord.premium.enabled) it.channel.id != config.discord.premium.channelId
else true) && else true) &&
(if (config.discord.developer.enabled) it.channel.id != config.discord.developer.channelId (if (config.discord.developer.enabled) it.channel.id != config.discord.developer.channelId
else true) else true) &&
gameKeyOf(it.channel.id) == null
) { ) {
return@listener return@listener
} }
@@ -293,7 +331,8 @@ internal class DiscordBridge private constructor() {
) )
} }
config.discord.general.channelId -> { // General and the game channels are both unlabelled.
else -> {
Component.join( Component.join(
JoinConfiguration.noSeparators(), JoinConfiguration.noSeparators(),
discordComponent, discordComponent,
@@ -302,10 +341,6 @@ internal class DiscordBridge private constructor() {
contentComponent, contentComponent,
) )
} }
else -> {
throw RuntimeException("Invalid channel id")
}
} }
messageComponent = messageComponent =
@@ -347,14 +382,20 @@ internal class DiscordBridge private constructor() {
} }
} }
// Skips the game worlds, which have their own channel and their own chat.
config.discord.general.channelId -> { config.discord.general.channelId -> {
for (p in Bukkit.getOnlinePlayers()) { for (p in Bukkit.getOnlinePlayers()) {
if (WorldChatSystem.keyForWorld(p.world.name) != null) continue
p.sendMessage(messageComponent) p.sendMessage(messageComponent)
} }
} }
// A game channel reaches only the players inside that game's worlds.
else -> { else -> {
throw RuntimeException("Invalid channel id") val gameKey = gameKeyOf(it.channel.id) ?: return@listener
for (p in WorldChatSystem.playersForKey(gameKey)) {
p.sendMessage(messageComponent)
}
} }
} }
@@ -473,7 +514,26 @@ internal class DiscordBridge private constructor() {
developerWebhook!!.send(webhookMessage.build()) developerWebhook!!.send(webhookMessage.build())
} }
fun sendEmbed(message: String, uuid: UUID?, color: Int?) { suspend fun sendGameMessage(key: String, message: String, name: String, uuid: UUID?) {
// create() already warned about this once at startup, so stay quiet rather than log per message.
val gameWebhook = gameWebhooks[key] ?: return
val webhookMessage =
WebhookMessageBuilder().apply {
setUsername(UtilitiesOG.stripFormatting(name))
setContent(
UtilitiesOG.stripFormatting(ChatUtil.stripGroupMentions((ChatUtil.convertMentions(message))))
)
if (uuid != null) setAvatarUrl("https://minotar.net/helm/$uuid.png")
}
gameWebhook.send(webhookMessage.build())
}
// A null key sends to the general channel.
fun sendEmbed(message: String, uuid: UUID?, color: Int?, key: String? = null) {
val target = if (key == null) webhook else gameWebhooks[key] ?: return
var iconUrl: String? = null var iconUrl: String? = null
if (uuid != null) { if (uuid != null) {
iconUrl = "https://minotar.net/helm/$uuid.png" iconUrl = "https://minotar.net/helm/$uuid.png"
@@ -486,6 +546,6 @@ internal class DiscordBridge private constructor() {
.setAuthor(WebhookEmbed.EmbedAuthor(UtilitiesOG.stripFormatting(message), iconUrl, null)) .setAuthor(WebhookEmbed.EmbedAuthor(UtilitiesOG.stripFormatting(message), iconUrl, null))
} }
webhook.send(webhookMessage.build()) target.send(webhookMessage.build())
} }
} }
+19
View File
@@ -58,6 +58,25 @@ discord:
channelId: ^{DISCORD_DEVELOPER_ID} channelId: ^{DISCORD_DEVELOPER_ID}
webhook: ^{DISCORD_DEVELOPER_WEBHOOK} webhook: ^{DISCORD_DEVELOPER_WEBHOOK}
# Multi world game channels, keyed by the lobby prefix used by the game's own config.
# Worlds are named <prefix><number>-hub for a lobby and <prefix><number>-<map> for a game,
# so HB1-hub and HB1-Ancient_Plateau both link to the HB channel.
# Chat, joins, quits, kicks, deaths and advancements in those worlds go there instead of general.
# Every entry needs all three keys, and an unresolved webhook stops the plugin from loading.
games:
HB:
enabled: true
channelId: ^{DISCORD_HB_ID}
webhook: ^{DISCORD_HB_WEBHOOK}
SP:
enabled: true
channelId: ^{DISCORD_SP_ID}
webhook: ^{DISCORD_SP_WEBHOOK}
# Discord label for multi world game worlds. %id% is the lobby id, for example HB1.
gameLobbyLabel: "[%id% - Lobby]"
gameWorldLabel: "[%id% - In Game]"
# List players command # List players command
listCommandName: list listCommandName: list
# Available placeholders: %onlineplayers%, %maxplayers% # Available placeholders: %onlineplayers%, %maxplayers%