Multi world support
This commit is contained in:
1 parent
91940a6f0a
commit
1df4776b68
12 files changed
+501
-52
No files matched your search
@@ -1,5 +1,17 @@
|
||||
# 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:
|
||||
|
||||
- Port all uses of PlaceholderAPI to MiniPlaceholders / Utilities-OG APIs.
|
||||
|
||||
@@ -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).
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
```./gradlew clean build eclipse --warning-mode all```
|
||||
|
||||
|
||||
@@ -52,6 +52,11 @@ internal class ChatOG : JavaPlugin() {
|
||||
val discordBridgeLock = ReentrantReadWriteLock()
|
||||
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 isLanguageDatabaseInitialized() = ::languageDatabase.isInitialized
|
||||
@@ -130,9 +135,13 @@ internal class ChatOG : JavaPlugin() {
|
||||
|
||||
val chatAPI = ChatAPI()
|
||||
this.server.servicesManager.register(ChatAPI::class.java, chatAPI, this, ServicePriority.Normal)
|
||||
|
||||
ready = true
|
||||
}
|
||||
|
||||
override fun onDisable() {
|
||||
ready = false
|
||||
|
||||
if (isConfigInitialized()) {
|
||||
if (discordBridge != null) {
|
||||
discordBridgeLock.read {
|
||||
|
||||
@@ -17,6 +17,8 @@ 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.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.legacyToMm
|
||||
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.entity.PlayerDeathEvent
|
||||
import org.bukkit.event.player.PlayerAdvancementDoneEvent
|
||||
import org.bukkit.event.player.PlayerChangedWorldEvent
|
||||
import org.bukkit.event.player.PlayerJoinEvent
|
||||
import org.bukkit.event.player.PlayerKickEvent
|
||||
import org.bukkit.event.player.PlayerQuitEvent
|
||||
@@ -32,6 +35,21 @@ import org.bukkit.event.server.BroadcastMessageEvent
|
||||
import xyz.jpenilla.announcerplus.listener.JoinQuitListener
|
||||
|
||||
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
|
||||
fun onJoin(event: PlayerJoinEvent) {
|
||||
if (!config.discord.enabled) {
|
||||
@@ -41,18 +59,17 @@ internal class Events : Listener {
|
||||
return
|
||||
}
|
||||
|
||||
val worldName = event.player.world.name
|
||||
val key = WorldChatSystem.keyForWorld(worldName)
|
||||
if (!reports(worldName, key)) {
|
||||
return
|
||||
}
|
||||
|
||||
val playerPartString = ChatUtil.getPlayerPartString(event.player)
|
||||
val message = "$playerPartString has joined the game. ${onlineCount(key)} player(s) online."
|
||||
|
||||
scope.launch {
|
||||
discordBridgeLock.read {
|
||||
discordBridge?.sendEmbed(
|
||||
"$playerPartString has joined the game. ${
|
||||
Bukkit.getOnlinePlayers().count()
|
||||
} player(s) online.",
|
||||
event.player.uniqueId,
|
||||
0x00FF00,
|
||||
)
|
||||
}
|
||||
discordBridgeLock.read { discordBridge?.sendEmbed(message, event.player.uniqueId, 0x00FF00, key) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,17 +82,47 @@ internal class Events : Listener {
|
||||
return
|
||||
}
|
||||
|
||||
val worldName = event.player.world.name
|
||||
val key = WorldChatSystem.keyForWorld(worldName)
|
||||
if (!reports(worldName, key)) {
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
discordBridgeLock.read {
|
||||
discordBridge?.sendEmbed(
|
||||
"$playerPartString has left the game. ${
|
||||
Bukkit.getOnlinePlayers().count() - 1
|
||||
} player(s) online.",
|
||||
event.player.uniqueId,
|
||||
0xFF0000,
|
||||
)
|
||||
if (leftMessage != null) discordBridge?.sendEmbed(leftMessage, event.player.uniqueId, 0xFF0000, fromKey)
|
||||
if (joinedMessage != null)
|
||||
discordBridge?.sendEmbed(joinedMessage, event.player.uniqueId, 0x00FF00, toKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,20 +136,20 @@ internal class Events : Listener {
|
||||
return
|
||||
}
|
||||
|
||||
val worldName = event.player.world.name
|
||||
val key = WorldChatSystem.keyForWorld(worldName)
|
||||
if (!reports(worldName, key)) {
|
||||
return
|
||||
}
|
||||
|
||||
val playerPartString = ChatUtil.getPlayerPartString(event.player)
|
||||
|
||||
val reason = PlainTextComponentSerializer.plainText().serialize(event.reason())
|
||||
val message =
|
||||
"$playerPartString was kicked with reason: \"${reason}\". ${onlineCount(key) - 1} player(s) online."
|
||||
|
||||
scope.launch {
|
||||
discordBridgeLock.read {
|
||||
discordBridge?.sendEmbed(
|
||||
"$playerPartString was kicked with reason: \"${reason}\". ${
|
||||
Bukkit.getOnlinePlayers().count() - 1
|
||||
} player(s) online.",
|
||||
event.player.uniqueId,
|
||||
0xFF0000,
|
||||
)
|
||||
}
|
||||
discordBridgeLock.read { discordBridge?.sendEmbed(message, event.player.uniqueId, 0xFF0000, key) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +162,12 @@ internal class Events : Listener {
|
||||
return
|
||||
}
|
||||
|
||||
val worldName = event.player.world.name
|
||||
val key = WorldChatSystem.keyForWorld(worldName)
|
||||
if (!reports(worldName, key)) {
|
||||
return
|
||||
}
|
||||
|
||||
val playerPartString = ChatUtil.getPlayerPartString(event.player)
|
||||
|
||||
val advancementTitleKey = event.advancement.display?.title() ?: return
|
||||
@@ -132,7 +185,7 @@ internal class Events : Listener {
|
||||
|
||||
scope.launch {
|
||||
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
|
||||
event.isCancelled = true
|
||||
|
||||
val worldName = event.player.world.name
|
||||
|
||||
scope.launch {
|
||||
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
|
||||
}
|
||||
|
||||
// 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()
|
||||
if (deathMessage is TextComponent) {
|
||||
scope.launch {
|
||||
discordBridgeLock.read {
|
||||
discordBridge?.sendEmbed(deathMessage.content(), event.player.uniqueId, 0xFF0000)
|
||||
if (report) {
|
||||
scope.launch {
|
||||
discordBridgeLock.read {
|
||||
discordBridge?.sendEmbed(deathMessage.content(), event.player.uniqueId, 0xFF0000, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -195,12 +261,16 @@ internal class Events : Listener {
|
||||
val newDeathMessage = builder.build()
|
||||
|
||||
event.deathMessage(newDeathMessage)
|
||||
if (!report) {
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
discordBridgeLock.read {
|
||||
discordBridge?.sendEmbed(
|
||||
PlainTextComponentSerializer.plainText().serialize(newDeathMessage),
|
||||
event.player.uniqueId,
|
||||
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)
|
||||
|
||||
// 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) {
|
||||
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)
|
||||
playerPartString = listOfNotNull(prefix, playerPartString).joinToString(" | ")
|
||||
|
||||
val discordPlayerPartString =
|
||||
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)
|
||||
|
||||
val messageComponent =
|
||||
ChatUtil.processText(text, player)?.color(PlayerUtils.getMessageColor(player.uniqueId)) ?: return
|
||||
|
||||
val chatComponent =
|
||||
UtilitiesOG.trueogColorize(
|
||||
ChatUtil.legacyToMm("$playerPartString<reset>${PlayerUtils.getSuffix(player.uniqueId)} &7> ")
|
||||
)
|
||||
|
||||
var textComponent = Component.join(JoinConfiguration.noSeparators(), chatComponent, messageComponent)
|
||||
var textComponent =
|
||||
formattedLine
|
||||
?: Component.join(
|
||||
JoinConfiguration.noSeparators(),
|
||||
UtilitiesOG.trueogColorize(
|
||||
ChatUtil.legacyToMm("$playerPartString<reset>${PlayerUtils.getSuffix(player.uniqueId)} &7> ")
|
||||
),
|
||||
messageComponent,
|
||||
)
|
||||
textComponent =
|
||||
textComponent.hoverEvent(
|
||||
HoverEvent.hoverEvent(
|
||||
|
||||
@@ -13,8 +13,11 @@ import org.bukkit.Bukkit
|
||||
|
||||
internal object GeneralChatSystem : ChatSystem() {
|
||||
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
|
||||
get() = Audience.audience(Bukkit.getOnlinePlayers())
|
||||
get() =
|
||||
Audience.audience(Bukkit.getOnlinePlayers().filter { WorldChatSystem.keyForWorld(it.world.name) == null })
|
||||
|
||||
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("premium") val premium: 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("listCommandText") val listCommandText: String,
|
||||
@param:JsonProperty("useColorCodeRoles") val useColorCodeRoles: Boolean,
|
||||
|
||||
@@ -30,6 +30,7 @@ import net.kyori.adventure.text.format.TextColor
|
||||
import net.trueog.utilitiesog.UtilitiesOG
|
||||
import nl.skbotnl.chatog.ChatOG.Companion.config
|
||||
import nl.skbotnl.chatog.ChatOG.Companion.plugin
|
||||
import nl.skbotnl.chatog.chatsystem.WorldChatSystem
|
||||
import nl.skbotnl.chatog.translation.command.TranslateMessage
|
||||
import nl.skbotnl.chatog.util.ChatUtil
|
||||
import nl.skbotnl.chatog.util.EmojiConverter
|
||||
@@ -42,8 +43,13 @@ internal class DiscordBridge private constructor() {
|
||||
private var staffWebhook: WebhookClient? = null
|
||||
private var premiumWebhook: WebhookClient? = null
|
||||
private var developerWebhook: WebhookClient? = null
|
||||
private val gameWebhooks = mutableMapOf<String, WebhookClient>()
|
||||
|
||||
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? {
|
||||
val discordBridge = DiscordBridge()
|
||||
if (config.discord.general.webhook != null) {
|
||||
@@ -84,6 +90,37 @@ internal class DiscordBridge private constructor() {
|
||||
} else if (config.discord.developer.enabled) {
|
||||
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) {
|
||||
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
|
||||
else true) &&
|
||||
(if (config.discord.developer.enabled) it.channel.id != config.discord.developer.channelId
|
||||
else true)
|
||||
else true) &&
|
||||
gameKeyOf(it.channel.id) == null
|
||||
) {
|
||||
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(
|
||||
JoinConfiguration.noSeparators(),
|
||||
discordComponent,
|
||||
@@ -302,10 +341,6 @@ internal class DiscordBridge private constructor() {
|
||||
contentComponent,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw RuntimeException("Invalid channel id")
|
||||
}
|
||||
}
|
||||
|
||||
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 -> {
|
||||
for (p in Bukkit.getOnlinePlayers()) {
|
||||
if (WorldChatSystem.keyForWorld(p.world.name) != null) continue
|
||||
p.sendMessage(messageComponent)
|
||||
}
|
||||
}
|
||||
|
||||
// A game channel reaches only the players inside that game's worlds.
|
||||
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())
|
||||
}
|
||||
|
||||
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
|
||||
if (uuid != null) {
|
||||
iconUrl = "https://minotar.net/helm/$uuid.png"
|
||||
@@ -486,6 +546,6 @@ internal class DiscordBridge private constructor() {
|
||||
.setAuthor(WebhookEmbed.EmbedAuthor(UtilitiesOG.stripFormatting(message), iconUrl, null))
|
||||
}
|
||||
|
||||
webhook.send(webhookMessage.build())
|
||||
target.send(webhookMessage.build())
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,25 @@ discord:
|
||||
channelId: ^{DISCORD_DEVELOPER_ID}
|
||||
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
|
||||
listCommandName: list
|
||||
# Available placeholders: %onlineplayers%, %maxplayers%
|
||||
|
||||
Reference in new issue
Block a user