summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSho Sakuma <me@m1sk9.dev>2026-01-09 20:44:56 +0900
committerGitHub <noreply@github.com>2026-01-09 20:44:56 +0900
commitb1c44dc64111710d4197d34e17e912e4f291602e (patch)
tree6d9f70d4d01b6689b72848567d5d94d23d2a8eb9
parentce65a31a598884753559e3657de259d66e156bb9 (diff)
parentda8fac8bdb04213c423d11abd75c0f1a054a02eb (diff)
downloadLunaticChat-b1c44dc64111710d4197d34e17e912e4f291602e.tar.gz
LunaticChat-b1c44dc64111710d4197d34e17e912e4f291602e.tar.bz2
LunaticChat-b1c44dc64111710d4197d34e17e912e4f291602e.zip
Merge pull request #13 from m1sk9/feat/add-command-system
feat: Add LunaticChat command system
-rw-r--r--README.md4
-rw-r--r--docker/compose.yaml6
-rw-r--r--engine/build.gradle.kts1
-rw-r--r--engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/exception/RequirePermissionException.kt12
-rw-r--r--engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNode.kt11
-rw-r--r--platform-paper/build.gradle.kts10
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt29
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/annotation/Command.kt16
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/annotation/Permission.kt15
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/annotation/PlayerOnly.kt9
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/CommandContext.kt68
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/CommandRegistry.kt65
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/CommandResult.kt37
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/LunaticCommand.kt110
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/handler/DirectMessageHandler.kt103
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/ReplyCommand.kt68
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommand.kt95
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/common/PermissionCollector.kt60
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt24
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt2
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/MessageFormatConfig.kt4
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityConfig.kt8
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerPresenceListener.kt16
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/SpyPermissionManager.kt54
-rw-r--r--platform-paper/src/main/resources/config.yml20
-rw-r--r--platform-paper/src/main/resources/paper-plugin.yml9
-rw-r--r--platform-velocity/build.gradle.kts5
27 files changed, 789 insertions, 72 deletions
diff --git a/README.md b/README.md
index 8237ac4..e3a306d 100644
--- a/README.md
+++ b/README.md
@@ -23,8 +23,8 @@ _[Supports Minecraft 1.21.X](https://minecraft.wiki/w/Java_Edition_version_histo
## Todo
-- [ ] 1on1 Direct Messaging System
-- [ ] Quick Reply Functionality
+- [x] 1on1 Direct Messaging System
+- [x] Quick Reply Functionality
- [ ] Romaji to Japanese Conversion
- [ ] Velocity Support
- [ ] Channel Chat System
diff --git a/docker/compose.yaml b/docker/compose.yaml
index 536242a..c3814d1 100644
--- a/docker/compose.yaml
+++ b/docker/compose.yaml
@@ -1,13 +1,13 @@
services:
minecraft:
image: itzg/minecraft-server:java21
- container_name: runecore-debug-server
+ container_name: debug-server
user: "0:0"
ports:
- "25565:25565"
- "25575:25575"
volumes:
- - minecraft-data:/data
+ - lunatic-debug-minecraft-data:/data
- ../platform-paper/build/libs:/plugins:ro
- ./plugins:/data/plugins
- ./bukkit.yml:/data/bukkit.yml
@@ -40,4 +40,4 @@ services:
stdin_open: true
volumes:
- minecraft-data:
+ lunatic-debug-minecraft-data:
diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts
index e1b7c51..7afc78f 100644
--- a/engine/build.gradle.kts
+++ b/engine/build.gradle.kts
@@ -4,6 +4,5 @@ plugins {
}
dependencies {
- // JSON シリアライゼーション
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
}
diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/exception/RequirePermissionException.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/exception/RequirePermissionException.kt
new file mode 100644
index 0000000..dace909
--- /dev/null
+++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/exception/RequirePermissionException.kt
@@ -0,0 +1,12 @@
+package dev.m1sk9.lunaticChat.engine.exception
+
+import dev.m1sk9.lunaticChat.engine.permission.LunaticChatPermissionNode
+
+/**
+ * Exception thrown when required permissions are missing.
+ *
+ * @param permissions The list of missing permissions.
+ */
+class RequirePermissionException(
+ permissions: List<LunaticChatPermissionNode>,
+) : Exception("Missing required permissions: ${permissions.joinToString { it.permissionNode }}")
diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNode.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNode.kt
new file mode 100644
index 0000000..304d531
--- /dev/null
+++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNode.kt
@@ -0,0 +1,11 @@
+package dev.m1sk9.lunaticChat.engine.permission
+
+sealed class LunaticChatPermissionNode(
+ val permissionNode: String,
+) {
+ object Tell : LunaticChatPermissionNode("lunaticchat.command.tell")
+
+ object Reply : LunaticChatPermissionNode("lunaticchat.command.reply")
+
+ object Spy : LunaticChatPermissionNode("lunaticchat.spy")
+}
diff --git a/platform-paper/build.gradle.kts b/platform-paper/build.gradle.kts
index 315061f..4624a4b 100644
--- a/platform-paper/build.gradle.kts
+++ b/platform-paper/build.gradle.kts
@@ -12,19 +12,21 @@ repositories {
}
dependencies {
- // engine モジュールを使用
api(project(":engine"))
-
- // Paper API
compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT")
+ implementation("org.jetbrains.kotlin:kotlin-reflect")
}
tasks {
shadowJar {
- archiveClassifier.set("all")
+ archiveClassifier.set("")
archiveBaseName.set("LunaticChat")
}
+ jar {
+ enabled = false
+ }
+
runServer {
minecraftVersion("1.21")
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt
index 360ebb9..00f2eb9 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt
@@ -1,9 +1,22 @@
package dev.m1sk9.lunaticChat.paper
+import dev.m1sk9.lunaticChat.paper.command.core.CommandRegistry
+import dev.m1sk9.lunaticChat.paper.command.handler.DirectMessageHandler
+import dev.m1sk9.lunaticChat.paper.command.impl.ReplyCommand
+import dev.m1sk9.lunaticChat.paper.command.impl.TellCommand
+import dev.m1sk9.lunaticChat.paper.common.SpyPermissionManager
import dev.m1sk9.lunaticChat.paper.config.ConfigManager
+import dev.m1sk9.lunaticChat.paper.listener.PlayerPresenceListener
+import org.bukkit.event.Listener
import org.bukkit.plugin.java.JavaPlugin
-class LunaticChat : JavaPlugin() {
+class LunaticChat :
+ JavaPlugin(),
+ Listener {
+ lateinit var directMessageHandler: DirectMessageHandler
+
+ private lateinit var commandRegistry: CommandRegistry
+
override fun onEnable() {
saveDefaultConfig()
val lunaticChatConfiguration = ConfigManager.loadConfiguration(config)
@@ -11,9 +24,21 @@ class LunaticChat : JavaPlugin() {
if (lunaticChatConfiguration.debug) {
logger.warning("LunaticChat is running in debug mode.")
logger.info("Debug: $lunaticChatConfiguration")
- // TODO: Enable debug features
}
+ directMessageHandler = DirectMessageHandler()
+
+ commandRegistry =
+ CommandRegistry(this)
+ .registerAll(
+ TellCommand(this, directMessageHandler),
+ ReplyCommand(this, directMessageHandler),
+ )
+ commandRegistry.initialize()
+
+ server.pluginManager.registerEvents(SpyPermissionManager, this)
+ server.pluginManager.registerEvents(PlayerPresenceListener(), this)
+
logger.info("LunaticChat enabled.")
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/annotation/Command.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/annotation/Command.kt
new file mode 100644
index 0000000..f16dd20
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/annotation/Command.kt
@@ -0,0 +1,16 @@
+package dev.m1sk9.lunaticChat.paper.command.annotation
+
+/**
+ * Marks a class as a LunaticChat command.
+ *
+ * @param name The primary command name (without leading slash)
+ * @param aliases List of alternative command names
+ * @param description Human-readable description for help text
+ */
+@Target(AnnotationTarget.CLASS)
+@Retention(AnnotationRetention.RUNTIME)
+annotation class Command(
+ val name: String,
+ val aliases: Array<String> = [],
+ val description: String = "",
+)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/annotation/Permission.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/annotation/Permission.kt
new file mode 100644
index 0000000..d96e1b5
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/annotation/Permission.kt
@@ -0,0 +1,15 @@
+package dev.m1sk9.lunaticChat.paper.command.annotation
+
+import dev.m1sk9.lunaticChat.engine.permission.LunaticChatPermissionNode
+import kotlin.reflect.KClass
+
+/**
+ * Restricts command execution to senders with the specified permission.
+ *
+ * @param value The permission node required to execute this command
+ */
+@Target(AnnotationTarget.CLASS)
+@Retention(AnnotationRetention.RUNTIME)
+annotation class Permission(
+ val value: KClass<out LunaticChatPermissionNode>,
+)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/annotation/PlayerOnly.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/annotation/PlayerOnly.kt
new file mode 100644
index 0000000..0669e28
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/annotation/PlayerOnly.kt
@@ -0,0 +1,9 @@
+package dev.m1sk9.lunaticChat.paper.command.annotation
+
+/**
+ * Restricts command execution to players only.
+ * Console execution will be rejected with an appropriate message.
+ */
+@Target(AnnotationTarget.CLASS)
+@Retention(AnnotationRetention.RUNTIME)
+annotation class PlayerOnly
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/CommandContext.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/CommandContext.kt
new file mode 100644
index 0000000..7f1bb15
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/CommandContext.kt
@@ -0,0 +1,68 @@
+package dev.m1sk9.lunaticChat.paper.command.core
+
+import io.papermc.paper.command.brigadier.CommandSourceStack
+import net.kyori.adventure.text.Component
+import net.kyori.adventure.text.event.ClickEvent
+import org.bukkit.command.CommandSender
+import org.bukkit.entity.Player
+
+/**
+ * Wrapper for Brigadier command context providing type-safe access
+ * to command sender and arguments.
+ */
+class CommandContext(
+ private val sourceStack: CommandSourceStack,
+) {
+ /** The raw command sender (player, console, or other) */
+ val sender: CommandSender
+ get() = sourceStack.sender
+
+ /** The player if sender is a player, null otherwise */
+ val player: Player?
+ get() = sender as? Player
+
+ /** Whether the sender is a player */
+ val isPlayer: Boolean
+ get() = sender is Player
+
+ /**
+ * Requires the sender to be a player.
+ *
+ * @return The player
+ * @throws IllegalStateException if sender is not a player
+ */
+ fun requirePlayer(): Player = player ?: throw IllegalStateException("This command can only be executed by a player")
+
+ /**
+ * Sends a message to the command sender.
+ *
+ * @param message The message component to send
+ */
+ fun reply(message: Component) {
+ sender.sendMessage(message)
+ }
+
+ /**
+ * Sends a message with a click event to the command sender.
+ *
+ * @param message The message component to send
+ * @param event The click event to attach to the message
+ */
+ fun replyWithEvent(
+ message: Component,
+ event: ClickEvent,
+ ) {
+ message
+ .clickEvent(event)
+ .let { sender.sendMessage(it) }
+ }
+
+ /**
+ * Sends a plain text message to the command sender.
+ *
+ * @param message The plain text message to send
+ */
+ fun replyPlain(message: String) {
+ sender.sendPlainMessage(message)
+ }
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/CommandRegistry.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/CommandRegistry.kt
new file mode 100644
index 0000000..11929cf
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/CommandRegistry.kt
@@ -0,0 +1,65 @@
+package dev.m1sk9.lunaticChat.paper.command.core
+
+import dev.m1sk9.lunaticChat.paper.LunaticChat
+import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents
+import java.util.logging.Logger
+
+/**
+ * Central registry for all LunaticChat commands.
+ * Handles registration with Paper's LifecycleEventManager.
+ */
+class CommandRegistry(
+ private val plugin: LunaticChat,
+) {
+ private val logger: Logger = plugin.logger
+ private val commands: MutableList<LunaticCommand> = mutableListOf()
+
+ /**
+ * Registers a command to be registered with the server.
+ * Must be called before initialize().
+ */
+ fun register(command: LunaticCommand): CommandRegistry {
+ commands.add(command)
+ return this
+ }
+
+ /**
+ * Registers multiple commands at once.
+ */
+ fun registerAll(vararg commands: LunaticCommand): CommandRegistry {
+ commands.forEach { register(it) }
+ return this
+ }
+
+ /**
+ * Initializes command registration with Paper's LifecycleEventManager.
+ * This should be called from the plugin's onEnable().
+ */
+ fun initialize() {
+ val lifecycleManager = plugin.lifecycleManager
+
+ lifecycleManager.registerEventHandler(LifecycleEvents.COMMANDS) { event ->
+ val registrar = event.registrar()
+
+ for (command in commands) {
+ try {
+ val builtCommand = command.buildWithChecks().build()
+
+ registrar.register(
+ builtCommand,
+ command.description,
+ command.aliases,
+ )
+
+ logger.info(
+ "Registered command: /${command.name} " +
+ "(aliases: ${command.aliases.joinToString(", ")})",
+ )
+ } catch (e: Exception) {
+ logger.severe("Failed to register command /${command.name}: ${e.message}")
+ e.printStackTrace()
+ }
+ }
+ }
+ }
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/CommandResult.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/CommandResult.kt
new file mode 100644
index 0000000..0a2de3d
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/CommandResult.kt
@@ -0,0 +1,37 @@
+package dev.m1sk9.lunaticChat.paper.command.core
+
+import net.kyori.adventure.text.Component
+
+/**
+ * Represents the result of a command execution.
+ * Uses Kotlin sealed classes for type-safe result handling.
+ */
+sealed class CommandResult {
+ /** Command executed successfully */
+ data object Success : CommandResult()
+
+ /** Command executed successfully with a message to display */
+ data class SuccessWithMessage(
+ val message: Component,
+ ) : CommandResult()
+
+ /** Command failed with an error message */
+ data class Failure(
+ val message: Component,
+ ) : CommandResult()
+
+ /** Command failed due to invalid usage */
+ data class InvalidUsage(
+ val usageHint: String,
+ ) : CommandResult()
+
+ /**
+ * Converts result to Brigadier return value.
+ * @return 1 for success, 0 for failure (Brigadier convention)
+ */
+ fun toBrigadierResult(): Int =
+ when (this) {
+ is Success, is SuccessWithMessage -> 1
+ is Failure, is InvalidUsage -> 0
+ }
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/LunaticCommand.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/LunaticCommand.kt
new file mode 100644
index 0000000..07a7823
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/LunaticCommand.kt
@@ -0,0 +1,110 @@
+package dev.m1sk9.lunaticChat.paper.command.core
+
+import com.mojang.brigadier.builder.LiteralArgumentBuilder
+import dev.m1sk9.lunaticChat.paper.LunaticChat
+import dev.m1sk9.lunaticChat.paper.command.annotation.Command
+import dev.m1sk9.lunaticChat.paper.command.annotation.Permission
+import dev.m1sk9.lunaticChat.paper.command.annotation.PlayerOnly
+import io.papermc.paper.command.brigadier.CommandSourceStack
+import net.kyori.adventure.text.Component
+import net.kyori.adventure.text.format.NamedTextColor
+
+/**
+ * Abstract base class for all LunaticChat commands.
+ * Provides common functionality and enforces consistent command structure.
+ */
+abstract class LunaticCommand(
+ protected val plugin: LunaticChat,
+) {
+ private val commandAnnotation: Command by lazy {
+ this::class.annotations.filterIsInstance<Command>().firstOrNull()
+ ?: throw IllegalStateException("Command class must be annotated with @Command")
+ }
+
+ private val permissionAnnotation: Permission? by lazy {
+ this::class.annotations.filterIsInstance<Permission>().firstOrNull()
+ }
+
+ private val isPlayerOnly: Boolean by lazy {
+ this::class.annotations.any { it is PlayerOnly }
+ }
+
+ /** The primary command name */
+ val name: String get() = commandAnnotation.name
+
+ /** Command aliases */
+ val aliases: List<String> get() = commandAnnotation.aliases.toList()
+
+ /** Command description for help text */
+ val description: String get() = commandAnnotation.description
+
+ /** Required permission node, if any */
+ val permission: String? get() = permissionAnnotation?.value?.objectInstance?.permissionNode
+
+ /**
+ * Build the Brigadier command tree.
+ * Subclasses implement this to define arguments and execution logic.
+ *
+ * @return The command builder with all arguments and executors attached
+ */
+ abstract fun buildCommand(): LiteralArgumentBuilder<CommandSourceStack>
+
+ /**
+ * Wraps the command builder with permission checks.
+ * Called by CommandRegistry during registration.
+ */
+ fun buildWithChecks(): LiteralArgumentBuilder<CommandSourceStack> {
+ var builder = buildCommand()
+ permission?.let { perm ->
+ builder =
+ builder.requires { source ->
+ source.sender.hasPermission(perm)
+ }
+ }
+
+ return builder
+ }
+
+ /**
+ * Helper method for checking player-only restriction.
+ * Called at the beginning of execute methods.
+ */
+ protected fun checkPlayerOnly(ctx: CommandContext): CommandResult? {
+ if (isPlayerOnly && !ctx.isPlayer) {
+ return CommandResult.Failure(
+ Component
+ .text("This command can only be executed by a player.")
+ .color(NamedTextColor.RED),
+ )
+ }
+
+ return null
+ }
+
+ /**
+ * Utility to wrap Brigadier context into LunaticChat CommandContext.
+ */
+ protected fun wrapContext(ctx: com.mojang.brigadier.context.CommandContext<CommandSourceStack>): CommandContext =
+ CommandContext(ctx.source)
+
+ /**
+ * Helper for handling command results and sending appropriate messages.
+ */
+ protected fun handleResult(
+ ctx: CommandContext,
+ result: CommandResult,
+ ): Int {
+ when (result) {
+ is CommandResult.Success -> {}
+ is CommandResult.SuccessWithMessage -> ctx.reply(result.message)
+ is CommandResult.Failure -> ctx.reply(result.message)
+ is CommandResult.InvalidUsage ->
+ ctx.reply(
+ Component
+ .text("Usage: ${result.usageHint}")
+ .color(NamedTextColor.RED),
+ )
+ }
+ return result.toBrigadierResult()
+ }
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/handler/DirectMessageHandler.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/handler/DirectMessageHandler.kt
new file mode 100644
index 0000000..0a28a2d
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/handler/DirectMessageHandler.kt
@@ -0,0 +1,103 @@
+package dev.m1sk9.lunaticChat.paper.command.handler
+
+import dev.m1sk9.lunaticChat.paper.common.SpyPermissionManager
+import dev.m1sk9.lunaticChat.paper.config.ConfigManager
+import net.kyori.adventure.text.Component
+import net.kyori.adventure.text.event.ClickEvent
+import org.bukkit.Bukkit
+import org.bukkit.entity.Player
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Manages direct message state including reply targets.
+ * Tracks the last player who messaged each player for /reply functionality.
+ */
+class DirectMessageHandler {
+ private var lunaticChatConfiguration = ConfigManager.getConfiguration()
+
+ private val lastMessager: ConcurrentHashMap<UUID, UUID> = ConcurrentHashMap()
+ private val lastRecipient: ConcurrentHashMap<UUID, UUID> = ConcurrentHashMap()
+
+ /**
+ * Records a direct message between two players.
+ * Updates both the sender's last recipient and the receiver's last messager.
+ */
+ fun recordMessage(
+ sender: Player,
+ recipient: Player,
+ ) {
+ lastRecipient[sender.uniqueId] = recipient.uniqueId
+ lastMessager[recipient.uniqueId] = sender.uniqueId
+ }
+
+ /**
+ * Gets the player to reply to.
+ * First checks if someone has messaged this player, otherwise falls back
+ * to the last person they messaged.
+ */
+ fun getReplyTarget(player: Player): Player? {
+ val messager = lastMessager[player.uniqueId]?.let { Bukkit.getPlayer(it) }
+ if (messager != null && messager.isOnline) {
+ return messager
+ }
+
+ val recipient = lastRecipient[player.uniqueId]?.let { Bukkit.getPlayer(it) }
+ if (recipient != null && recipient.isOnline) {
+ return recipient
+ }
+
+ return null
+ }
+
+ /**
+ * Clears message history for a player (called on disconnect).
+ */
+ fun clearPlayer(player: Player) {
+ lastMessager.remove(player.uniqueId)
+ lastRecipient.remove(player.uniqueId)
+ }
+
+ /**
+ * Sends a direct message from one player to another.
+ * Handles formatting and recording the conversation.
+ *
+ * @return true if message was sent successfully
+ */
+ fun sendDirectMessage(
+ sender: Player,
+ recipient: Player,
+ message: String,
+ ): Boolean {
+ recordMessage(sender, recipient)
+ val format = lunaticChatConfiguration.messageFormat.directMessageFormat
+ val formattedMessage = formatMessage(format, sender.name, recipient.name, message)
+
+ SpyPermissionManager.getDirectMessageSpyPlayers().values.forEach {
+ if (it.isOnline && it.uniqueId != sender.uniqueId && it.uniqueId != recipient.uniqueId) {
+ it.sendMessage(formattedMessage)
+ }
+ }
+
+ sender.sendMessage(formattedMessage)
+ recipient.sendMessage(formattedMessage)
+ return true
+ }
+
+ private fun formatMessage(
+ format: String,
+ senderName: String,
+ recipientName: String,
+ message: String,
+ ): Component {
+ val text =
+ format
+ .replace("{sender}", senderName)
+ .replace("{recipient}", recipientName)
+ .replace("{message}", message)
+
+ return Component
+ .text(text)
+ .clickEvent(ClickEvent.suggestCommand("/tell $senderName "))
+ }
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/ReplyCommand.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/ReplyCommand.kt
new file mode 100644
index 0000000..37e0a82
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/ReplyCommand.kt
@@ -0,0 +1,68 @@
+package dev.m1sk9.lunaticChat.paper.command.impl
+
+import com.mojang.brigadier.arguments.StringArgumentType
+import com.mojang.brigadier.builder.LiteralArgumentBuilder
+import dev.m1sk9.lunaticChat.engine.permission.LunaticChatPermissionNode
+import dev.m1sk9.lunaticChat.paper.LunaticChat
+import dev.m1sk9.lunaticChat.paper.command.annotation.Command
+import dev.m1sk9.lunaticChat.paper.command.annotation.Permission
+import dev.m1sk9.lunaticChat.paper.command.annotation.PlayerOnly
+import dev.m1sk9.lunaticChat.paper.command.core.CommandContext
+import dev.m1sk9.lunaticChat.paper.command.core.CommandResult
+import dev.m1sk9.lunaticChat.paper.command.core.LunaticCommand
+import dev.m1sk9.lunaticChat.paper.command.handler.DirectMessageHandler
+import io.papermc.paper.command.brigadier.CommandSourceStack
+import io.papermc.paper.command.brigadier.Commands
+import net.kyori.adventure.text.Component
+import net.kyori.adventure.text.format.NamedTextColor
+
+/**
+ * Quick reply command for responding to the last person who messaged you.
+ * Usage: /reply <message>
+ */
+@Command(
+ name = "reply",
+ aliases = ["r"],
+ description = "Reply to the last person who messaged you",
+)
+@Permission(LunaticChatPermissionNode.Reply::class)
+@PlayerOnly
+class ReplyCommand(
+ plugin: LunaticChat,
+ private val dmHandler: DirectMessageHandler,
+) : LunaticCommand(plugin) {
+ override fun buildCommand(): LiteralArgumentBuilder<CommandSourceStack> =
+ Commands
+ .literal(name)
+ .then(
+ Commands
+ .argument("message", StringArgumentType.greedyString())
+ .executes { ctx ->
+ val context = wrapContext(ctx)
+
+ checkPlayerOnly(context)?.let { return@executes handleResult(context, it) }
+ val message = StringArgumentType.getString(ctx, "message")
+ val result = execute(context, message)
+
+ handleResult(context, result)
+ },
+ )
+
+ private fun execute(
+ ctx: CommandContext,
+ message: String,
+ ): CommandResult {
+ val sender = ctx.requirePlayer()
+ val target =
+ dmHandler.getReplyTarget(sender)
+ ?: return CommandResult.Failure(
+ Component
+ .text("You have no one to reply to.")
+ .color(NamedTextColor.RED),
+ )
+
+ dmHandler.sendDirectMessage(sender, target, message)
+
+ return CommandResult.Success
+ }
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommand.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommand.kt
new file mode 100644
index 0000000..54ab036
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommand.kt
@@ -0,0 +1,95 @@
+package dev.m1sk9.lunaticChat.paper.command.impl
+
+import com.mojang.brigadier.arguments.StringArgumentType
+import com.mojang.brigadier.builder.LiteralArgumentBuilder
+import com.mojang.brigadier.suggestion.Suggestions
+import com.mojang.brigadier.suggestion.SuggestionsBuilder
+import dev.m1sk9.lunaticChat.engine.permission.LunaticChatPermissionNode
+import dev.m1sk9.lunaticChat.paper.LunaticChat
+import dev.m1sk9.lunaticChat.paper.command.annotation.Command
+import dev.m1sk9.lunaticChat.paper.command.annotation.Permission
+import dev.m1sk9.lunaticChat.paper.command.annotation.PlayerOnly
+import dev.m1sk9.lunaticChat.paper.command.core.CommandContext
+import dev.m1sk9.lunaticChat.paper.command.core.CommandResult
+import dev.m1sk9.lunaticChat.paper.command.core.LunaticCommand
+import dev.m1sk9.lunaticChat.paper.command.handler.DirectMessageHandler
+import io.papermc.paper.command.brigadier.CommandSourceStack
+import io.papermc.paper.command.brigadier.Commands
+import net.kyori.adventure.text.Component
+import net.kyori.adventure.text.format.NamedTextColor
+import org.bukkit.Bukkit
+import java.util.concurrent.CompletableFuture
+
+/**
+ * Direct messaging command.
+ * Usage: /tell <player> <message>
+ */
+@Command(
+ name = "tell",
+ aliases = ["t", "msg", "m", "w", "whisper"],
+ description = "Send a private message to another player",
+)
+@Permission(LunaticChatPermissionNode.Tell::class)
+@PlayerOnly
+class TellCommand(
+ plugin: LunaticChat,
+ private val directMessageHandler: DirectMessageHandler,
+) : LunaticCommand(plugin) {
+ override fun buildCommand(): LiteralArgumentBuilder<CommandSourceStack> =
+ Commands
+ .literal(name)
+ .then(
+ Commands
+ .argument("player", StringArgumentType.word())
+ .suggests { _, builder -> suggestOnlinePlayers(builder) }
+ .then(
+ Commands
+ .argument("message", StringArgumentType.greedyString())
+ .executes { ctx ->
+ val context = wrapContext(ctx)
+ checkPlayerOnly(context)?.let { return@executes handleResult(context, it) }
+
+ val targetName = StringArgumentType.getString(ctx, "player")
+ val message = StringArgumentType.getString(ctx, "message")
+
+ val result = execute(context, targetName, message)
+ handleResult(context, result)
+ },
+ ),
+ )
+
+ private fun execute(
+ ctx: CommandContext,
+ targetName: String,
+ message: String,
+ ): CommandResult {
+ val sender = ctx.requirePlayer()
+ val recipient =
+ Bukkit.getPlayer(targetName)
+ ?: return CommandResult.Failure(
+ Component
+ .text("Player '$targetName' is not online.")
+ .color(NamedTextColor.RED),
+ )
+
+ if (recipient.uniqueId == sender.uniqueId) {
+ return CommandResult.Failure(
+ Component
+ .text("You cannot send a message to yourself.")
+ .color(NamedTextColor.RED),
+ )
+ }
+ directMessageHandler.sendDirectMessage(sender, recipient, message)
+
+ return CommandResult.Success
+ }
+
+ private fun suggestOnlinePlayers(builder: SuggestionsBuilder): CompletableFuture<Suggestions> {
+ val input = builder.remaining.lowercase()
+ Bukkit
+ .getOnlinePlayers()
+ .filter { it.name.lowercase().startsWith(input) }
+ .forEach { builder.suggest(it.name) }
+ return builder.buildFuture()
+ }
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/common/PermissionCollector.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/common/PermissionCollector.kt
new file mode 100644
index 0000000..4edfe34
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/common/PermissionCollector.kt
@@ -0,0 +1,60 @@
+package dev.m1sk9.lunaticChat.paper.common
+
+import dev.m1sk9.lunaticChat.engine.exception.RequirePermissionException
+import dev.m1sk9.lunaticChat.engine.permission.LunaticChatPermissionNode
+import org.bukkit.entity.Player
+
+@DslMarker
+annotation class PermissionDsl
+
+@PermissionDsl
+class PermissionCollector {
+ internal val permissions = mutableListOf<LunaticChatPermissionNode>()
+
+ operator fun LunaticChatPermissionNode.unaryPlus() {
+ permissions.add(this)
+ }
+}
+
+fun LunaticChatPermissionNode.has(player: Player): Boolean = player.hasPermission(this.permissionNode)
+
+/**
+ * Checks if the player has at least one of the specified permissions.
+ *
+ * @param block A lambda with receiver to collect permissions.
+ * @return `true` if the player has at least one of the specified permissions.
+ */
+fun Player.hasAnyPermission(block: PermissionCollector.() -> Unit): Boolean =
+ PermissionCollector()
+ .apply(block)
+ .permissions
+ .any { it.has(this) }
+
+/**
+ * Checks if the player has all the specified permissions.
+ *
+ * @param block A lambda with receiver to collect permissions.
+ * @return `true` if the player has all the specified permissions.
+ */
+fun Player.hasAllPermission(block: PermissionCollector.() -> Unit): Boolean =
+ PermissionCollector()
+ .apply(block)
+ .permissions
+ .all { it.has(this) }
+
+/**
+ * Requires the player to have at least one of the specified permissions.
+ *
+ * @param block A lambda with receiver to collect permissions.
+ * @return Throws [RequirePermissionException] if the player lacks all specified permissions.
+ * @throws RequirePermissionException if the player lacks all specified permissions.
+ */
+fun Player.requirePermission(block: PermissionCollector.() -> Unit) {
+ val result =
+ PermissionCollector()
+ .apply(block)
+ .permissions
+ if (!result.any { it.has(this) }) {
+ throw RequirePermissionException(result)
+ }
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt
index b12e935..d10a4e6 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt
@@ -2,7 +2,6 @@ package dev.m1sk9.lunaticChat.paper.config
import dev.m1sk9.lunaticChat.paper.config.key.FeaturesConfig
import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig
-import dev.m1sk9.lunaticChat.paper.config.key.VelocityConfig
import org.bukkit.configuration.file.FileConfiguration
object ConfigManager {
@@ -24,29 +23,8 @@ object ConfigManager {
directMessageFormat =
configFile.getString(
"messageFormat.directMessageFormat",
- "&7[&e{sender} &7>>] &f{message}",
+ "§7[§e{sender} §7>> §e{recipient}§7] §f{message}",
)!!,
- crossServerMessageFormat =
- configFile.getString(
- "messageFormat.crossServerMessageFormat",
- "&7[&e{sender} &7(&b{server}&7)] &f{message}",
- )!!,
- crossServerDirectMessageFormat =
- configFile.getString(
- "messageFormat.crossServerDirectMessageFormat",
- "&7[&e{sender}@&7(&b{server}&7) >>] &f{message}",
- )!!,
- ),
- velocity =
- VelocityConfig(
- enabled = configFile.getBoolean("velocity.enabled", false),
- serverName = configFile.getString("velocity.serverName", "s1")!!,
- crossServerChatEnabled = configFile.getBoolean("velocity.crossServerChatEnabled", false),
- crossServerDirectMessagesEnabled =
- configFile.getBoolean(
- "velocity.crossServerDirectMessagesEnabled",
- false,
- ),
),
debug = configFile.getBoolean("debug", false),
)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt
index d474de7..2475086 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt
@@ -2,11 +2,9 @@ package dev.m1sk9.lunaticChat.paper.config
import dev.m1sk9.lunaticChat.paper.config.key.FeaturesConfig
import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig
-import dev.m1sk9.lunaticChat.paper.config.key.VelocityConfig
data class LunaticChatConfiguration(
val features: FeaturesConfig,
val messageFormat: MessageFormatConfig,
- val velocity: VelocityConfig,
val debug: Boolean = false,
)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/MessageFormatConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/MessageFormatConfig.kt
index 52eddae..53d4399 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/MessageFormatConfig.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/MessageFormatConfig.kt
@@ -1,7 +1,5 @@
package dev.m1sk9.lunaticChat.paper.config.key
data class MessageFormatConfig(
- val directMessageFormat: String = "&7[&e{sender} &7>>] &f{message}",
- val crossServerMessageFormat: String = "&7[&e{sender} &7(&b{server}&7)] &f{message}",
- val crossServerDirectMessageFormat: String = "&7[&e{sender}@&7(&b{server}&7) >>] &f{message}",
+ val directMessageFormat: String = "§7[§e{sender} §7>> §e{recipient}§7] §f{message}",
)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityConfig.kt
deleted file mode 100644
index 3932b41..0000000
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityConfig.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package dev.m1sk9.lunaticChat.paper.config.key
-
-data class VelocityConfig(
- val enabled: Boolean,
- val serverName: String,
- val crossServerChatEnabled: Boolean,
- val crossServerDirectMessagesEnabled: Boolean,
-)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerPresenceListener.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerPresenceListener.kt
new file mode 100644
index 0000000..ad53929
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerPresenceListener.kt
@@ -0,0 +1,16 @@
+package dev.m1sk9.lunaticChat.paper.listener
+
+import dev.m1sk9.lunaticChat.paper.LunaticChat
+import org.bukkit.event.EventHandler
+import org.bukkit.event.Listener
+import org.bukkit.event.player.PlayerQuitEvent
+
+class PlayerPresenceListener : Listener {
+ @EventHandler(ignoreCancelled = true)
+ fun onQuit(
+ lunaticChat: LunaticChat,
+ event: PlayerQuitEvent,
+ ) {
+ lunaticChat.directMessageHandler.clearPlayer(event.player)
+ }
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/SpyPermissionManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/SpyPermissionManager.kt
new file mode 100644
index 0000000..26de098
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/SpyPermissionManager.kt
@@ -0,0 +1,54 @@
+package dev.m1sk9.lunaticChat.paper.common
+
+import dev.m1sk9.lunaticChat.engine.permission.LunaticChatPermissionNode
+import org.bukkit.Bukkit
+import org.bukkit.entity.Player
+import org.bukkit.event.EventHandler
+import org.bukkit.event.Listener
+import org.bukkit.event.player.PlayerJoinEvent
+import org.bukkit.event.player.PlayerQuitEvent
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Manages spy permission cache for direct messages.
+ * Caches player references to avoid repeated lookups.
+ */
+object SpyPermissionManager : Listener {
+ private val directMessageSpyPlayers: ConcurrentHashMap<UUID, Player> = ConcurrentHashMap()
+
+ /**
+ * Gets all players with spy permission as a map of UUID to Player.
+ */
+ fun getDirectMessageSpyPlayers(): Map<UUID, Player> = directMessageSpyPlayers.toMap()
+
+ /**
+ * Gets all player UUIDs with spy permission.
+ */
+ fun getDirectMessageSpyPlayerIds(): Set<UUID> = directMessageSpyPlayers.keys
+
+ /**
+ * Updates the cache of players with direct message spy permission.
+ * Call this on player join/quit/permission change events.
+ */
+ fun updateSpyCache() {
+ directMessageSpyPlayers.clear()
+ Bukkit
+ .getOnlinePlayers()
+ .filter {
+ it.hasAllPermission {
+ +LunaticChatPermissionNode.Spy
+ }
+ }.associateByTo(directMessageSpyPlayers) { it.uniqueId }
+ }
+
+ @EventHandler(ignoreCancelled = true)
+ fun onPlayerJoin(e: PlayerJoinEvent) {
+ updateSpyCache()
+ }
+
+ @EventHandler(ignoreCancelled = true)
+ fun onPlayerQuit(event: PlayerQuitEvent) {
+ directMessageSpyPlayers.remove(event.player.uniqueId)
+ }
+}
diff --git a/platform-paper/src/main/resources/config.yml b/platform-paper/src/main/resources/config.yml
index e091954..dd5db15 100644
--- a/platform-paper/src/main/resources/config.yml
+++ b/platform-paper/src/main/resources/config.yml
@@ -38,23 +38,5 @@ features:
messageFormat:
# Configure the format for direct messages sent via /tell or /msg
- directMessageFormat: "&7[&e{sender} &7>>] &f{message}"
- # Configure the format for cross-server messages
- crossServerMessageFormat: "&7[&e{sender} &7(&b{server}&7)] &f{message}"
- # Configure the format for cross-server direct messages
- crossServerDirectMessageFormat: "&7[&e{sender}@&7(&b{server}&7) >>] &f{message}"
+ directMessageFormat: "§7[§e{sender} §7>> §e{recipient}§7] §f{message}"
-# ----------------------------------------------
-# ----------- Velocity Settings ------------
-# ----------------------------------------------
-
-velocity:
- # If enabled, Activate LunaticChat's Velocity integration features.
- # This setting overrides all other features; if set to `false`, features such as cross-server chat will become unavailable.
- enabled: false
- # The name of the server as recognized by the Velocity proxy.
- serverName: "s1"
- # If enabled, allows cross-server chat functionality.
- crossServerChatEnabled: false
- # If enabled, allows cross-server direct messaging functionality.
- crossServerDirectMessagesEnabled: false
diff --git a/platform-paper/src/main/resources/paper-plugin.yml b/platform-paper/src/main/resources/paper-plugin.yml
index b3aeacc..7ad79d6 100644
--- a/platform-paper/src/main/resources/paper-plugin.yml
+++ b/platform-paper/src/main/resources/paper-plugin.yml
@@ -7,3 +7,12 @@ load: STARTUP
authors: [ m1sk9 ]
description: Next-generation channel chat plugin for Paper/Velocity
website: lc.m1sk9.dev
+
+permissions:
+ lunaticchat.command.tell:
+ default: true
+ lunaticchat.command.reply:
+ default: true
+
+ lunaticchat.spy:
+ default: op
diff --git a/platform-velocity/build.gradle.kts b/platform-velocity/build.gradle.kts
index e259a94..6fa7401 100644
--- a/platform-velocity/build.gradle.kts
+++ b/platform-velocity/build.gradle.kts
@@ -11,12 +11,7 @@ repositories {
}
dependencies {
- // engine モジュールを使用
api(project(":engine"))
-
- // Velocity API (将来追加予定)
- // compileOnly("com.velocitypowered:velocity-api:3.4.0")
- // kapt("com.velocitypowered:velocity-api:3.4.0")
}
tasks {