diff --git a/README.md b/README.md
index e69de29..68f2870 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,275 @@
+# J Grasscutter Command
+# 在QQ群里远程执行命令的插件
+
+- 基于 [Mirai-Console](https://github.com/mamoe/mirai-console) 开发的插件
+- 服务端必须使用 [OpenCommand](https://github.com/jie65535/gc-opencommand-plugin) 插件
+
+Mirai机器人相关文档请参阅 [用户手册](https://github.com/mamoe/mirai/blob/dev/docs/UserManual.md),
+本项目**不会教你**如何安装和登录机器人,请自行了解Mirai相关信息。
+
+# 插件用法
+
+## 首先使用指令添加一个服务器
+```shell
+# 用法
+/jgc addServer
[name] [description] # 添加服务器
+# 示例
+/jgc addServer http://127.0.0.1:443 测试服 本地测试服务器
+# 成功返回 "服务器已添加,ID为[1],使用servers子命令查看服务器列表"
+# 失败返回 "只能设置装有 [OpenCommand](https://github.com/jie65535/gc-opencommand-plugin) 插件的服务器"
+# 失败的原因可能包含无法链接服务器、服务器没有安装插件等,具体可以参考日志
+
+# 如果你是服主,你可以添加OpenCommand的ConsoleToken
+/jgc setServerConsoleToken
+# 添加完成后,你可以将自己设置为管理员
+/jgc op
+# 例如设置123456为管理员
+/jgc op 123456
+# 取消管理的子命令是 deop
+
+# 管理员执行命令将默认使用控制台Token,无需验证(见后文)
+```
+
+## 将服务器绑定到机器人所在群聊
+```shell
+# 用法
+/jgc linkGroup/bindGroup/addGroup [group] # 绑定服务器到群,在群内执行可忽略群参数
+# 示例(控制台)
+/jgc linkGroup 1 群号
+# 示例(群里)
+/jgc linkGroup 1
+# 成功返回 "OK"
+```
+在聊天环境执行 Mirai-Console 命令需要另一个插件 [Chat Command](https://github.com/project-mirai/chat-command)
+执行GC命令不需要这个,见后文
+
+## 绑定账号
+玩家想要在群里执行命令,需要绑定自己的游戏UID,需要在群里发送 `绑定 ` 来向目标账号发送验证码,然后将验证码发到群里完成验证,如图所示
+
+
+
+_管理员无需绑定,默认使用控制台令牌执行命令_
+
+绑定命令的触发关键字可以通过 `/jgc setBindCommand ` 来修改
+
+## 执行命令
+默认执行GC命令前缀为 `!` ,用法是 `!<命令|别名>`
+
+_可以通过 `/jgc setBindCommand ` 来修改执行命令前缀 _(例如以下示例图中使用`run`作为前缀)__
+
+---
+
+执行命令示例:`!prop unlockmap 1`
+
+
+
+---
+
+执行别名示例:`!解锁地图`
+
+
+
+命令别名可以通过 `/jgc setCommand ` 来设置,通过 `/jgc removeCommand ` 来删除
+
+---
+
+命令还可以通过 @群员 来替代原先命令中的 @UID,前提是这个群员绑定了它的UID
+
+例如:`!permission list @张三`,其中`@张三`会被替换成其绑定的UID
+
+
+
+## 拉黑用户
+如果你想禁止某个用户使用本插件执行命令,可以使用 `/ban ` 来拉黑,使用 `/unban ` 可以解除黑名单
+_(只是一个凭想象增加的功能,也许能用上呢)_
+
+## 群消息同步
+
+当你设置了服务器的 consoleToken,那么你可以使用 `/jgc setServerSyncMessage ` 命令来开启群消息同步功能。
+
+示例:`/jgc setServerSyncMessage 1 true` 表示启用1号服务器的消息同步功能
+
+当群里收到消息时,会执行命令 `/say 用户名 消息内容` 来将消息发送到服务器,因此玩家收到的消息也是来自服务器发送的。
+
+注意,为了避免徒劳的尝试,当任何一次消息发送失败时,都会自动关闭同步消息功能,需要手动重新启用。
+你可以自己更改相关规则,只需要修改源代码。
+
+
+
+**注意,玩家在游戏内发送的消息是不会同步到群的,这只是单向同步!!**
+
+# 配置文件(config.yml)
+```yml
+# 管理员列表,仅管理员可以执行控制台命令
+administrators:
+ - 123456
+# 用户黑名单
+blacklist: []
+# 绑定命令:绑定 示例:绑定 10001
+bindCommand: 绑定
+# 聊天中执行GC命令前缀:!<命令|别名>
+# 示例1:!give 1096 lv90
+# 示例2:!位置
+commandPrefix: !
+# 命令别名
+commandAlias:
+ 无敌: '/prop god on'
+ 关闭无敌: '/prop god off'
+ 无限体力: '/prop ns on'
+ 关闭无限体力: '/prop ns off'
+ 无限能量: '/prop ue on'
+ 关闭无限能量: '/prop ue off'
+ 点亮地图: '/prop unlockmap 1'
+ 解锁地图: '/prop unlockmap 1'
+ 位置: '/pos'
+ 坐标: '/pos'
+```
+
+# 指令列表
+## 管理相关
+```shell
+/jgc help # 插件命令用法
+/jgc reload # 重载插件配置
+/jgc setCommandPrefix # 设置执行GC命令前缀
+/jgc setBindCommand # 设置绑定命令前缀
+/jgc op # 设置管理员
+/jgc setAdmin # 设置管理员
+/jgc deop # 解除管理员
+/jgc removeAdmin # 解除管理员
+/jgc ban # 禁止指定QQ使用插件
+/jgc unban # 解除禁止指定QQ使用插件
+```
+
+## 服务器相关
+```shell
+/jgc ping # 测试指定服务器是否安装插件
+/jgc addServer [name] [description] # 添加服务器
+/jgc servers # 列出服务器
+/jgc setServerIsEnabled # 设置服务器是否启用
+/jgc setServerAddress # 修改服务器地址
+/jgc setServerInfo # 设置服务器信息
+/jgc setServerConsoleToken # 设置服务器控制台令牌
+/jgc setServerSyncMessage # 设置是否同步群消息到服务器
+```
+
+## 群相关
+```shell
+/jgc linkGroup/bindGroup/addGroup [group] # 绑定服务器到群,在群内执行可忽略群参数
+/jgc enable [group] # 启用指定群执行,在群内执行可忽略群参数
+/jgc disable [group] # 禁用指定群执行,在群内执行可忽略群参数
+```
+
+## 命令别名相关
+```shell
+/jgc setCommand # 添加命令别名
+/jgc removeCommand # 删除命令别名
+```
+
+# 实体结构
+
+Server
+```kotlin
+@Serializable
+data class Server(
+ /**
+ * 服务器ID
+ * 自动递增
+ */
+ val id: Int,
+
+ /**
+ * 服务器地址
+ */
+ var address: String,
+
+ /**
+ * 服务器名称
+ */
+ var name: String = "",
+
+ /**
+ * 服务器说明
+ */
+ var description: String = "",
+
+ /**
+ * 控制台令牌
+ */
+ var consoleToken: String = "",
+
+ /**
+ * 服务器是否已启用
+ */
+ var isEnabled: Boolean = true,
+
+ /**
+ * 同步群消息到服务器,必须设置了控制台令牌
+ */
+ var syncMessage: Boolean = false,
+)
+```
+---
+GroupConfig
+```kotlin
+@Serializable
+data class GroupConfig(
+ /**
+ * 群ID(QQ群号)
+ */
+ val id: Long,
+
+ /**
+ * 服务器ID
+ */
+ var serverId: Int,
+
+ /**
+ * 是否启用(用于临时关闭)
+ */
+ var isEnabled: Boolean = true,
+)
+```
+---
+User
+```kotlin
+@Serializable
+data class User(
+ /**
+ * 用户ID(QQ帐号)
+ */
+ val id: Long,
+
+ /**
+ * 服务器ID
+ */
+ val serverId: Int,
+
+ /**
+ * 游戏UID
+ */
+ var uid: Int,
+) {
+ /**
+ * 令牌,失效时清空
+ */
+ var token: String = ""
+
+ /**
+ * 用户添加时间
+ */
+ @Serializable(LocalDateTimeSerializer::class)
+ val createTime: LocalDateTime = LocalDateTime.now()
+
+ /**
+ * 运行命令计数
+ */
+ var runCount: Int = 0
+
+ /**
+ * 最后运行时间
+ */
+ @Serializable(LocalDateTimeSerializer::class)
+ var lastRunTime: LocalDateTime? = null
+}
+```
+
diff --git a/build.gradle.kts b/build.gradle.kts
index b8247ef..a5fe075 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- val kotlinVersion = "1.7.20"
+ val kotlinVersion = "1.7.10"
kotlin("jvm") version kotlinVersion
kotlin("plugin.serialization") version kotlinVersion
diff --git a/src/main/kotlin/JGrasscutterCommand.kt b/src/main/kotlin/JGrasscutterCommand.kt
index f7686e9..b0d8ff9 100644
--- a/src/main/kotlin/JGrasscutterCommand.kt
+++ b/src/main/kotlin/JGrasscutterCommand.kt
@@ -1,8 +1,40 @@
+/*
+ * JGrasscutterCommand
+ * Copyright (C) 2022 jie65535
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
package top.jie65535.mirai
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.withTimeout
+import net.mamoe.mirai.console.command.CommandManager.INSTANCE.register
import net.mamoe.mirai.console.plugin.jvm.JvmPluginDescription
import net.mamoe.mirai.console.plugin.jvm.KotlinPlugin
-import net.mamoe.mirai.utils.info
+import net.mamoe.mirai.contact.nameCardOrNick
+import net.mamoe.mirai.event.*
+import net.mamoe.mirai.event.events.GroupMessageEvent
+import net.mamoe.mirai.event.events.MessageEvent
+import net.mamoe.mirai.message.data.At
+import net.mamoe.mirai.message.data.MessageSource.Key.quote
+import net.mamoe.mirai.message.data.PlainText
+import net.mamoe.mirai.message.data.firstIsInstance
+import net.mamoe.mirai.message.data.firstIsInstanceOrNull
+import top.jie65535.mirai.model.User
+import top.jie65535.mirai.opencommand.OpenCommandApi
+import java.time.LocalDateTime
object JGrasscutterCommand : KotlinPlugin(
JvmPluginDescription(
@@ -15,6 +47,218 @@ object JGrasscutterCommand : KotlinPlugin(
}
) {
override fun onEnable() {
- logger.info { "Plugin loaded" }
+ PluginConfig.reload()
+ PluginData.reload()
+ PluginCommands.register()
+
+ val eventChannel = GlobalEventChannel.parentScope(this)
+ // 监听群消息
+ eventChannel.subscribeAlways {
+ // 忽略被拉黑的用户发送的消息
+ if (PluginConfig.blacklist.contains(sender.id))
+ return@subscribeAlways
+
+ // 忽略未启用的群消息
+ val groupConfig = PluginData.groups.find { it.id == group.id }
+ if (groupConfig == null || !groupConfig.isEnabled)
+ return@subscribeAlways
+
+ // 忽略未启用的服务器
+ val server = PluginData.servers.find { it.id == groupConfig.serverId }
+ if (server == null || !server.isEnabled)
+ return@subscribeAlways
+
+ // 解析消息
+ val message = this.message.joinToString("") {
+ if (it is At) {
+ // 替换@群员为@其绑定的Uid
+ val user = PluginData.users.find { user -> user.id == it.target && user.serverId == server.id }
+ if (user == null) { it.contentToString() } else { "@${user.uid}" }
+ } else {
+ it.contentToString()
+ }
+ }
+
+ // 处理绑定命令
+ if (message.startsWith(PluginConfig.bindCommand)) {
+ val input = message.removePrefix(PluginConfig.bindCommand).trim()
+ val bindUid = input.toIntOrNull()
+ if (bindUid == null) {
+ this.subject.sendMessage(this.message.quote() + "请输入正确的UID")
+ return@subscribeAlways
+ }
+ logger.info("绑定用户 ${sender.nameCardOrNick}(${sender.id}) Uid $bindUid 到服务器 ${server.name} ${server.address}")
+ var user = PluginData.users.find { it.id == sender.id && it.serverId == server.id }
+ if (user == null) {
+ user = User(sender.id, server.id, bindUid)
+ PluginData.users.add(user)
+ } else {
+ if (user.uid != bindUid) {
+ user.uid = bindUid
+ // 更换绑定将重置token
+ user.token = ""
+ }
+ }
+ // 发送验证码,并提示要求回复验证码
+ sendCode(server.address, user)
+ }
+ // 处理执行游戏命令
+ else if (message.startsWith(PluginConfig.commandPrefix)) {
+ var command = message.removePrefix(PluginConfig.commandPrefix).trim()
+ if (command.isEmpty()) {
+ return@subscribeAlways
+ }
+ // 检查是否使用别名
+ val t = PluginConfig.commandAlias[command]
+ if (!t.isNullOrEmpty()) command = t
+ // 如果是斜杠开头,则移除斜杠,在控制台执行不需要斜杠
+ if (command[0] == '/') {
+ command = command.substring(1)
+ if (command.isEmpty())
+ return@subscribeAlways
+ }
+
+ // 执行的用户
+ var user: User? = null
+ // 获取token
+ // 若设置了控制台令牌,则验证是否为管理员执行
+ val token = if (server.consoleToken.isNotEmpty() && PluginConfig.administrators.contains(sender.id)) {
+ logger.info("管理员 ${sender.nameCardOrNick}(${sender.id}) 执行命令:$command")
+ // 设置控制台令牌
+ server.consoleToken
+ } else {
+ // 普通用户
+ user = PluginData.users.find { it.id == sender.id && it.serverId == server.id }
+ if (user == null || user.token.isEmpty()) {
+ return@subscribeAlways
+ }
+ logger.info("用户 ${sender.nameCardOrNick}(${sender.id}) 执行命令:$command")
+ // 使用用户缓存令牌
+ user.token
+ }
+ try {
+ // 调用接口执行命令
+ val response = OpenCommandApi.runCommand(server.address, token, command)
+ if (response.isNullOrEmpty()) {
+ subject.sendMessage(this.message.quote() + "OK")
+ } else {
+ subject.sendMessage(this.message.quote() + response)
+ }
+ if (user != null) {
+ // 计数并更新最后运行时间
+ ++user.runCount
+ user.lastRunTime = LocalDateTime.now()
+ }
+ } catch (e: OpenCommandApi.InvokeException) {
+ when (e.code) {
+ 404 -> {
+ subject.sendMessage(this.message.quote() + "玩家不存在或未上线")
+ }
+ 403 -> {
+ logger.warning("${sender.nameCardOrNick}(${sender.id}) 的命令执行失败,服务器已收到命令,但不做处理,可能是未验证通过")
+ // 403不理会用户
+ }
+ 401 -> {
+ logger.warning("${sender.nameCardOrNick}(${sender.id}) 的命令执行失败,未授权或已过期的令牌,可以修改插件配置以延长令牌过期时间")
+ subject.sendMessage(this.message.quote() + "令牌未授权或已过期,请重新绑定账号以更新令牌")
+ // TODO 此处可以重新发送验证码要求验证,但目前直接报错并要求重新绑定
+ }
+ 500 -> {
+ logger.warning("${sender.nameCardOrNick}(${sender.id}) 的命令执行失败,服务器内部错误:${e.message}")
+ subject.sendMessage(this.message.quote() + "服务器内部发生错误,命令执行失败")
+ }
+ else -> {
+ logger.warning("${sender.nameCardOrNick}(${sender.id}) 的命令执行失败,发生预期外异常:${e.message}")
+ }
+ }
+ } catch (e: Throwable) {
+ logger.warning("${sender.nameCardOrNick}(${sender.id}) 在执行命令时发生异常", e)
+ }
+ }
+ // 否则如果启用了同步消息,且控制台令牌不为空
+ else if (server.consoleToken.isNotEmpty() && server.syncMessage) {
+ try {
+ OpenCommandApi.runCommand(
+ server.address,
+ server.consoleToken,
+ "say ${sender.nameCardOrNick}:\n${this.message.contentToString()}")
+ } catch (e: Throwable) {
+ server.syncMessage = false
+ logger.warning("同步发送聊天消息失败,自动禁用同步消息,请手动重新启用", e)
+ }
+ }
+ }
+
+ logger.info("Plugin loaded")
+ }
+
+ /**
+ * 发送验证码,并监听用户回复
+ * @param host 服务器地址
+ * @param user 用户实例
+ */
+ private suspend fun MessageEvent.sendCode(host: String, user: User) {
+ try {
+ logger.info("${sender.nameCardOrNick}(${sender.id}) 正在请求向服务器[${user.serverId}] @${user.uid} 发送验证码")
+ // 请求发送验证码
+ user.token = OpenCommandApi.sendCode(host, user.uid)
+ // 提示用户
+ subject.sendMessage(message.quote() + "验证码已发送,请在一分钟内将游戏中收到的验证码发送以验证身份。")
+
+ // 最多等待1分钟
+ withTimeout(60 * 1000) {
+ // 当验证失败时循环重试,最多重试3次
+ var retry = 3
+ while (isActive && retry --> 0) {
+ // 监听消息事件
+ val nextEvent = GlobalEventChannel.nextEvent(priority = EventPriority.HIGH) { event ->
+ // 仅监听该用户的消息,并且消息内容为4位数字
+ event.sender.id == user.id && event.message.firstIsInstanceOrNull()
+ ?.content?.trim()
+ ?.toIntOrNull()
+ ?.let { it in 1000..9999 } == true
+ }
+
+ // 得到消息中的4位数字代码
+ val code = nextEvent.message.firstIsInstance().content.trim().toInt()
+ try {
+ // 请求验证
+ OpenCommandApi.verify(host, user.token, code)
+ logger.info("${nextEvent.sender.nameCardOrNick}(${nextEvent.sender.id}) 在服务器[${user.serverId}]验证通过,其游戏Uid为 ${user.uid}")
+ // 若无异常则验证通过
+ nextEvent.subject.sendMessage(nextEvent.message.quote() + "验证通过")
+ // 停止监听
+ return@withTimeout
+ } catch (e: OpenCommandApi.InvokeException) {
+ logger.warning("${nextEvent.sender.nameCardOrNick}(${nextEvent.sender.id}) 在服务器[${user.serverId}] @${user.uid} 验证失败,信息:${e.message}")
+ // 400为验证失败
+ if (e.code == 400) {
+ nextEvent.subject.sendMessage(nextEvent.message.quote() + "验证失败,请重试")
+ } else {
+ nextEvent.subject.sendMessage(nextEvent.message.quote() + e.message!!)
+ }
+ // 不返回,继续监听
+ } catch (e: Throwable) {
+ // 预期外异常,停止监听器
+ logger.warning("${nextEvent.sender.nameCardOrNick}(${nextEvent.sender.id}) 在向服务器[${user.serverId}] @${user.uid} 发起验证时发生了预期外异常", e)
+ user.token = ""
+ nextEvent.subject.sendMessage(nextEvent.message.quote() + "发生内部错误,已取消验证,请重试")
+ return@withTimeout
+ }
+ }
+ }
+ } catch (e: OpenCommandApi.InvokeException) {
+ subject.sendMessage(message.quote() +
+ when (e.code) {
+ 404 -> "目标玩家不存在或不在线"
+ 403 -> "请求太频繁,请稍后再试"
+ else -> e.message!!
+ })
+ } catch (e: TimeoutCancellationException) {
+ subject.sendMessage(message.quote() + "等待验证超时,请重试")
+ } catch (e: Throwable) {
+ logger.warning("发送验证码出现预期外的异常", e)
+ if (e.message != null) subject.sendMessage(message.quote() + e.message!!)
+ }
}
}
\ No newline at end of file
diff --git a/src/main/kotlin/PluginCommands.kt b/src/main/kotlin/PluginCommands.kt
index e792c32..0eff2e1 100644
--- a/src/main/kotlin/PluginCommands.kt
+++ b/src/main/kotlin/PluginCommands.kt
@@ -1,4 +1,330 @@
+/*
+ * JGrasscutterCommand
+ * Copyright (C) 2022 jie65535
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
package top.jie65535.mirai
-object PluginCommands {
+import net.mamoe.mirai.console.command.*
+import net.mamoe.mirai.contact.*
+import top.jie65535.mirai.JGrasscutterCommand.reload
+import top.jie65535.mirai.model.GroupConfig
+import top.jie65535.mirai.model.Server
+import top.jie65535.mirai.opencommand.OpenCommandApi
+
+object PluginCommands : CompositeCommand(
+ JGrasscutterCommand, "jgc",
+ description = "管理插件设置"
+) {
+ private val logger = JGrasscutterCommand.logger
+
+
+ @SubCommand
+ @Description("插件命令用法")
+ suspend fun CommandSender.help() {
+ sendMessage(usage)
+ }
+
+ @SubCommand
+ @Description("重载插件配置")
+ suspend fun CommandSender.reload() {
+ logger.info("重载插件配置")
+ PluginConfig.reload()
+ sendMessage("OK")
+ }
+
+ @SubCommand
+ @Description("设置执行GC命令前缀")
+ suspend fun CommandSender.setCommandPrefix(prefix: String) {
+ if (prefix.isEmpty()) {
+ sendMessage("前缀不能为空,这会导致每条消息都作为命令处理")
+ } else {
+ logger.info("设置执行GC命令前缀为 $prefix")
+ PluginConfig.commandPrefix = prefix
+ sendMessage("OK")
+ }
+ }
+
+ @SubCommand
+ @Description("设置绑定命令前缀")
+ suspend fun CommandSender.setBindCommand(prefix: String) {
+ if (prefix.isEmpty()) {
+ sendMessage("不能为空")
+ } else {
+ logger.info("设置绑定命令为 $prefix")
+ PluginConfig.bindCommand = prefix
+ sendMessage("OK")
+ }
+ }
+
+ @SubCommand("setAdmin", "op")
+ @Description("设置管理员")
+ suspend fun CommandSender.setAdmin(user: User) {
+ logger.info("添加管理员 ${user.nameCardOrNick}(${user.id})")
+ PluginConfig.administrators.add(user.id)
+ sendMessage("OK")
+ }
+
+ @SubCommand("removeAdmin", "deop")
+ @Description("解除管理员")
+ suspend fun CommandSender.removeAdmin(user: User) {
+ PluginConfig.administrators.remove(user.id)
+ logger.info("解除管理员 ${user.nameCardOrNick}(${user.id})")
+ sendMessage("OK")
+ }
+
+ @SubCommand
+ @Description("禁止指定QQ使用插件")
+ suspend fun CommandSender.ban(qq: Long) {
+ logger.info("禁止${qq}使用插件")
+ PluginConfig.blacklist.add(qq)
+ sendMessage("OK")
+ }
+
+ @SubCommand
+ @Description("解除禁止指定QQ使用插件")
+ suspend fun CommandSender.unban(qq: Long) {
+ logger.info("解除禁止${qq}使用插件")
+ PluginConfig.blacklist.remove(qq)
+ sendMessage("OK")
+ }
+
+
+ // region 服务器相关命令
+
+ @SubCommand
+ @Description("测试指定服务器是否安装插件")
+ suspend fun CommandSender.ping(address: String) {
+ val id = address.toIntOrNull()
+ val serverAddress = if (id != null) {
+ val server = PluginData.servers.find { it.id == id }
+ if (server == null) {
+ sendMessage("未找到指定服务器")
+ return
+ } else {
+ server.address
+ }
+ } else {
+ address
+ }
+ if (tryPing(serverAddress)) {
+ sendMessage("OK")
+ } else {
+ sendMessage("Error")
+ }
+ }
+
+ private suspend fun tryPing(address: String): Boolean {
+ return try {
+ logger.info("正在 ping $address")
+ OpenCommandApi.ping(address)
+ true
+ } catch (e: Throwable) {
+ logger.warning("ping $address 异常", e)
+ false
+ }
+ }
+
+ @SubCommand
+ @Description("添加服务器")
+ suspend fun CommandSender.addServer(address: String, name: String = "", vararg description: String = arrayOf()) {
+ if (address.isEmpty()) {
+ sendMessage("服务器地址不能为空!")
+ return
+ }
+ val descriptionStr = description.joinToString(" ")
+
+ if (tryPing(address)) {
+ logger.info("添加服务器:$address\tname=$name\tdescription=$descriptionStr")
+ val serverId = ++PluginData.lastServerId
+ PluginData.servers.add(Server(serverId, address, name, descriptionStr))
+ sendMessage("服务器已添加,ID为[$serverId],使用servers子命令查看服务器列表")
+ } else {
+ sendMessage("只能设置装有 [OpenCommand](https://github.com/jie65535/gc-opencommand-plugin) 插件的服务器")
+ }
+ }
+
+ @SubCommand
+ @Description("列出服务器")
+ suspend fun CommandSender.servers() {
+ if (PluginData.servers.isEmpty()) {
+ sendMessage("服务器列表为空,使用addServer子命令来添加服务器")
+ } else {
+ sendMessage(PluginData.servers.joinToString("\n") {
+ if (it.description.isNotEmpty())
+ "[${it.id}] ${it.name} ${it.address}\n${it.description}"
+ else
+ "[${it.id}] ${it.name} ${it.address}"
+ })
+ }
+ }
+
+ @SubCommand
+ @Description("设置服务器启用")
+ suspend fun CommandSender.setServerIsEnabled(id: Int, isEnabled: Boolean) {
+ val server = PluginData.servers.find { it.id == id }
+ if (server == null) {
+ sendMessage("未找到指定服务器")
+ } else {
+ logger.info("${if (isEnabled) "启用" else "禁用"}服务器[$id]")
+ server.isEnabled = isEnabled
+ sendMessage("OK")
+ }
+ }
+
+ @SubCommand
+ @Description("修改服务器地址")
+ suspend fun CommandSender.setServerAddress(id: Int, address: String) {
+ val server = PluginData.servers.find { it.id == id }
+ if (server == null) {
+ sendMessage("未找到指定服务器")
+ } else {
+ if (tryPing(address)) {
+ logger.info("修改服务器地址为:$address")
+ server.address = address
+ sendMessage("OK")
+ } else {
+ sendMessage("只能设置装有 [OpenCommand](https://github.com/jie65535/gc-opencommand-plugin) 插件的服务器")
+ }
+ }
+ }
+
+ @SubCommand
+ @Description("设置服务器信息")
+ suspend fun CommandSender.setServerInfo(id: Int, name: String, vararg description: String) {
+ val server = PluginData.servers.find { it.id == id }
+ if (server == null) {
+ sendMessage("未找到指定服务器")
+ } else {
+ val descriptionStr = description.joinToString(" ")
+ logger.info("设置服务器信息为:name=$name\tdescription=$descriptionStr")
+ server.name = name
+ server.description = descriptionStr
+ sendMessage("OK")
+ }
+ }
+
+ @SubCommand
+ @Description("设置服务器控制台令牌")
+ suspend fun CommandSender.setServerConsoleToken(id: Int, consoleToken: String) {
+ val server = PluginData.servers.find { it.id == id }
+ if (server == null) {
+ sendMessage("未找到指定服务器")
+ } else {
+ logger.info("设置服务器控制台令牌为:$consoleToken")
+ server.consoleToken = consoleToken
+ sendMessage("OK")
+ }
+ }
+
+ @SubCommand
+ @Description("设置是否同步群消息到服务器")
+ suspend fun CommandSender.setServerSyncMessage(id: Int, sync: Boolean) {
+ val server = PluginData.servers.find { it.id == id }
+ if (server == null) {
+ sendMessage("未找到指定服务器")
+ } else {
+ logger.info("服务器[$id]${if (sync) "启用" else "禁用"}消息同步")
+ server.syncMessage = sync
+ sendMessage("OK")
+ }
+ }
+
+ // endregion 服务器相关命令
+
+ // region 群相关命令
+
+ @SubCommand("linkGroup", "bindGroup", "addGroup")
+ @Description("绑定服务器到群")
+ suspend fun CommandSender.linkGroup(serverId: Int, group: Group? = getGroupOrNull()) {
+ if (group == null) {
+ sendMessage("必须指定群")
+ return
+ }
+
+ val server = PluginData.servers.find { it.id == serverId }
+ if (server == null) {
+ sendMessage("指定服务器ID不存在,请先添加服务器(使用addServer子命令)")
+ return
+ }
+
+ logger.info("将服务器[$serverId] ${server.name} ${server.address} 绑定到群 ${group.name}(${group.id})")
+ val g = PluginData.groups.find { it.id == group.id }
+ if (g == null) {
+ PluginData.groups.add(GroupConfig(group.id, serverId))
+ sendMessage("OK,默认已启用")
+ } else {
+ g.serverId = serverId
+ sendMessage("OK")
+ }
+ }
+
+ @SubCommand
+ @Description("启用指定群执行")
+ suspend fun CommandSender.enable(group: Group? = getGroupOrNull()) {
+ if (group == null) {
+ sendMessage("必须指定群")
+ } else {
+ val g = PluginData.groups.find { it.id == group.id }
+ if (g == null) {
+ sendMessage("请先绑定群到服务器(使用linkGroup子命令)")
+ } else {
+ logger.info("启用插件在群 ${group.name}(${group.id})")
+ g.isEnabled = true
+ sendMessage("OK")
+ }
+ }
+ }
+
+ @SubCommand
+ @Description("禁用指定群执行")
+ suspend fun CommandSender.disable(group: Group? = getGroupOrNull()) {
+ if (group == null) {
+ sendMessage("必须指定群")
+ } else {
+ val g = PluginData.groups.find { it.id == group.id }
+ if (g == null) {
+ sendMessage("请先绑定群到服务器(使用linkGroup子命令)")
+ } else {
+ logger.info("禁用插件在群 ${group.name}(${group.id})")
+ g.isEnabled = false
+ sendMessage("OK")
+ }
+ }
+ }
+
+ // endregion 群相关命令
+
+ // region 命令别名部分
+
+ @SubCommand
+ @Description("添加命令别名")
+ suspend fun CommandSender.setCommand(alias: String, vararg command: String) {
+ if (alias.isEmpty() || command.isEmpty() || command[0].isEmpty()) {
+ sendMessage("参数不能为空")
+ }
+ PluginConfig.commandAlias[alias] = command.joinToString(" ")
+ sendMessage("OK")
+ }
+
+ @SubCommand
+ @Description("删除命令别名")
+ suspend fun CommandSender.removeCommand(alias: String) {
+ PluginConfig.commandAlias.remove(alias)
+ sendMessage("OK")
+ }
+
+ // endregion
}
\ No newline at end of file
diff --git a/src/main/kotlin/PluginConfig.kt b/src/main/kotlin/PluginConfig.kt
index 1950482..5016b8a 100644
--- a/src/main/kotlin/PluginConfig.kt
+++ b/src/main/kotlin/PluginConfig.kt
@@ -1,4 +1,52 @@
+/*
+ * JGrasscutterCommand
+ * Copyright (C) 2022 jie65535
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
package top.jie65535.mirai
-object PluginConfig {
+import net.mamoe.mirai.console.data.*
+
+object PluginConfig : AutoSavePluginConfig("config") {
+ @ValueDescription("管理员列表,仅管理员可以执行控制台命令")
+ val administrators: MutableSet by value()
+
+ @ValueDescription("用户黑名单")
+ val blacklist: MutableSet by value()
+
+ @ValueDescription("绑定命令:绑定 示例:绑定 10001")
+ var bindCommand: String by value("绑定")
+
+ @ValueDescription("聊天中执行GC命令前缀:!<命令|别名>\n" +
+ "示例1:!give 1096 lv90\n" +
+ "示例2:!位置\n")
+ var commandPrefix: String by value("!")
+
+ @ValueDescription("命令别名")
+ val commandAlias: MutableMap by value(mutableMapOf(
+ "无敌" to "/prop god on",
+ "关闭无敌" to "/prop god off",
+ "无限体力" to "/prop ns on",
+ "关闭无限体力" to "/prop ns off",
+ "无限能量" to "/prop ue on",
+ "关闭无限能量" to "/prop ue off",
+ "点亮地图" to "/prop unlockmap 1",
+ "解锁地图" to "/prop unlockmap 1",
+ "位置" to "/pos",
+ "坐标" to "/pos",
+
+ // TODO ...
+ ))
}
\ No newline at end of file
diff --git a/src/main/kotlin/PluginData.kt b/src/main/kotlin/PluginData.kt
index 9528a93..f907cf0 100644
--- a/src/main/kotlin/PluginData.kt
+++ b/src/main/kotlin/PluginData.kt
@@ -1,4 +1,40 @@
+/*
+ * JGrasscutterCommand
+ * Copyright (C) 2022 jie65535
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
package top.jie65535.mirai
-object PluginData {
+import net.mamoe.mirai.console.data.AutoSavePluginData
+import net.mamoe.mirai.console.data.ValueDescription
+import net.mamoe.mirai.console.data.value
+import top.jie65535.mirai.model.GroupConfig
+import top.jie65535.mirai.model.Server
+import top.jie65535.mirai.model.User
+
+object PluginData : AutoSavePluginData("data") {
+
+ @ValueDescription("服务器列表")
+ val servers: MutableList by value()
+
+ @ValueDescription("最后服务器ID,用于递增")
+ var lastServerId: Int by value()
+
+ @ValueDescription("玩家列表")
+ val users: MutableList by value()
+
+ @ValueDescription("群列表")
+ val groups: MutableList by value()
}
\ No newline at end of file
diff --git a/src/main/kotlin/model/GroupConfig.kt b/src/main/kotlin/model/GroupConfig.kt
index c8ca917..d7da0ff 100644
--- a/src/main/kotlin/model/GroupConfig.kt
+++ b/src/main/kotlin/model/GroupConfig.kt
@@ -1,3 +1,20 @@
+/*
+ * JGrasscutterCommand
+ * Copyright (C) 2022 jie65535
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
package top.jie65535.mirai.model
import kotlinx.serialization.Serializable
@@ -6,7 +23,7 @@ import kotlinx.serialization.Serializable
* 群类型
*/
@Serializable
-data class Group(
+data class GroupConfig(
/**
* 群ID(QQ群号)
*/
@@ -20,5 +37,5 @@ data class Group(
/**
* 是否启用(用于临时关闭)
*/
- var enabled: Boolean = true,
+ var isEnabled: Boolean = true,
)
\ No newline at end of file
diff --git a/src/main/kotlin/model/Server.kt b/src/main/kotlin/model/Server.kt
index bdf4f34..ab1cf95 100644
--- a/src/main/kotlin/model/Server.kt
+++ b/src/main/kotlin/model/Server.kt
@@ -1,10 +1,62 @@
-package top.jie65535.mirai
+/*
+ * JGrasscutterCommand
+ * Copyright (C) 2022 jie65535
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package top.jie65535.mirai.model
import kotlinx.serialization.Serializable
+/**
+ * 服务器类型
+ */
@Serializable
-data class ServerConfig(
+data class Server(
+ /**
+ * 服务器ID
+ * 自动递增
+ */
val id: Int,
+
+ /**
+ * 服务器地址
+ */
var address: String,
- var consoleToken: String,
+
+ /**
+ * 服务器名称
+ */
+ var name: String = "",
+
+ /**
+ * 服务器说明
+ */
+ var description: String = "",
+
+ /**
+ * 控制台令牌
+ */
+ var consoleToken: String = "",
+
+ /**
+ * 服务器是否已启用
+ */
+ var isEnabled: Boolean = true,
+
+ /**
+ * 同步群消息到服务器,必须设置了控制台令牌
+ */
+ var syncMessage: Boolean = false,
)
\ No newline at end of file
diff --git a/src/main/kotlin/model/User.kt b/src/main/kotlin/model/User.kt
index e3b5a92..da4906b 100644
--- a/src/main/kotlin/model/User.kt
+++ b/src/main/kotlin/model/User.kt
@@ -1,4 +1,62 @@
+/*
+ * JGrasscutterCommand
+ * Copyright (C) 2022 jie65535
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
package top.jie65535.mirai.model
-class User {
+import kotlinx.serialization.Serializable
+import top.jie65535.mirai.serializers.LocalDateTimeSerializer
+import java.time.LocalDateTime
+
+@Serializable
+data class User(
+ /**
+ * 用户ID(QQ帐号)
+ */
+ val id: Long,
+
+ /**
+ * 服务器ID
+ */
+ val serverId: Int,
+
+ /**
+ * 游戏UID
+ */
+ var uid: Int,
+) {
+ /**
+ * 令牌,失效时清空
+ */
+ var token: String = ""
+
+ /**
+ * 用户添加时间
+ */
+ @Serializable(LocalDateTimeSerializer::class)
+ val createTime: LocalDateTime = LocalDateTime.now()
+
+ /**
+ * 运行命令计数
+ */
+ var runCount: Int = 0
+
+ /**
+ * 最后运行时间
+ */
+ @Serializable(LocalDateTimeSerializer::class)
+ var lastRunTime: LocalDateTime? = null
}
\ No newline at end of file
diff --git a/src/main/kotlin/opencommand/OpenCommandApi.kt b/src/main/kotlin/opencommand/OpenCommandApi.kt
index 95392e8..dcb0643 100644
--- a/src/main/kotlin/opencommand/OpenCommandApi.kt
+++ b/src/main/kotlin/opencommand/OpenCommandApi.kt
@@ -1,4 +1,152 @@
+/*
+ * JGrasscutterCommand
+ * Copyright (C) 2022 jie65535
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
package top.jie65535.mirai.opencommand
-object OCApi {
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.decodeFromStream
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+
+@OptIn(ExperimentalSerializationApi::class)
+object OpenCommandApi {
+ private val httpClient = OkHttpClient.Builder().build()
+ private val json = Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+ }
+
+ @Serializable
+ private data class PingRequest(
+ val action: String = "ping"
+ )
+ @Serializable
+ private data class SendCodeRequest(
+ @SerialName("data")
+ val uid: Int,
+ val action: String = "sendCode"
+ )
+ @Serializable
+ private data class VerifyRequest(
+ val token: String,
+ @SerialName("data")
+ val code: Int,
+ val action: String = "verify"
+ )
+ @Serializable
+ private data class CommandRequest(
+ val token: String,
+ @SerialName("data")
+ val command: String,
+ val action: String = "command",
+ )
+
+
+ @Serializable
+ private data class StringResponse(
+ val retcode: Int,
+ val message: String,
+ val data: String?
+ )
+
+ class HttpException(val code: Int, message: String) : Exception(message)
+ class InvokeException(val code: Int, message: String) : Exception(message)
+
+ private val JSON = "application/json; charset=utf-8".toMediaType()
+
+ /**
+ * 向服务器发起opencommand请求
+ * @param host 服务器地址
+ * @param jsonBody 请求正文(Json)
+ * @return 如果一切正常,返回相应数据,可能为空
+ * @exception InvokeException HTTP请求执行成功,但opencommand插件调用失败
+ * @exception HttpException HTTP请求完成,但HTTP响应错误代码(例如404、500等)
+ * @exception IOException 如果由于取消、连接问题或超时而无法执行请求。由于网络可能在交换期间发生故障,因此远程服务器可能在故障之前接受了请求
+ */
+ private suspend fun doRequest(host: String, jsonBody: String): String? {
+ val api = "$host/opencommand/api"
+ val request = Request.Builder()
+ .url(api)
+ .post(jsonBody.toRequestBody(JSON))
+ .build()
+// JGrasscutterCommand.logger.debug("POST to $api Body $jsonBody")
+ return withContext(Dispatchers.IO) {
+ httpClient.newCall(request).execute().use { httpResponse ->
+ val responseBody = httpResponse.body
+ if (httpResponse.code == 200 && responseBody != null) {
+ val response = json.decodeFromStream(responseBody.byteStream())
+ if (response.retcode != 200) {
+ throw InvokeException(response.retcode, response.data ?: response.message)
+ } else {
+ return@use response.data
+ }
+ } else {
+ throw HttpException(httpResponse.code, httpResponse.message)
+ }
+ }
+ }
+ }
+
+ /**
+ * 测试链接
+ * @param host 服务器地址
+ */
+ suspend fun ping(host: String) {
+ doRequest(host, json.encodeToString(PingRequest()))
+ }
+
+ /**
+ * 发送验证码
+ * @param host 服务器地址
+ * @param uid 目标玩家UID
+ * @return 返回临时Token用于验证
+ */
+ suspend fun sendCode(host: String, uid: Int): String {
+ return doRequest(host, json.encodeToString(SendCodeRequest(uid)))!!
+ }
+
+ /**
+ * 验证身份,验证失败时将抛出异常,异常详情参考doRequest描述
+ * @param host 服务器地址
+ * @param token 发送验证码时返回的临时令牌
+ * @param code 用户输入的验证代码
+ * @see doRequest
+ */
+ suspend fun verify(host: String, token: String, code: Int) {
+ doRequest(host, json.encodeToString(VerifyRequest(token, code)))
+ }
+
+ /**
+ * 运行命令,成功时返回命令执行结果,失败时抛出异常,异常详情参考doRequest描述
+ * @param host 服务器地址
+ * @param token 持久令牌
+ * @param command 命令行
+ * @return 命令执行结果
+ * @see doRequest
+ */
+ suspend fun runCommand(host: String, token: String, command: String): String? {
+ return doRequest(host, json.encodeToString(CommandRequest(token, command)))
+ }
}
\ No newline at end of file
diff --git a/src/main/kotlin/serializers/LocalDateTimeSerializer.kt b/src/main/kotlin/serializers/LocalDateTimeSerializer.kt
index 29b4819..9bdafd7 100644
--- a/src/main/kotlin/serializers/LocalDateTimeSerializer.kt
+++ b/src/main/kotlin/serializers/LocalDateTimeSerializer.kt
@@ -1,4 +1,38 @@
+/*
+ * JGrasscutterCommand
+ * Copyright (C) 2022 jie65535
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
package top.jie65535.mirai.serializers
-class LocalDateTimeSerializer {
-}
\ No newline at end of file
+import kotlinx.serialization.*
+import kotlinx.serialization.descriptors.*
+import kotlinx.serialization.encoding.*
+import java.time.LocalDateTime
+
+@OptIn(ExperimentalSerializationApi::class)
+@Serializer(LocalDateTime::class)
+object LocalDateTimeSerializer: KSerializer {
+
+ override val descriptor: SerialDescriptor =
+ PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
+
+ override fun deserialize(decoder: Decoder): LocalDateTime =
+ LocalDateTime.parse(decoder.decodeString())
+
+ override fun serialize(encoder: Encoder, value: LocalDateTime) {
+ encoder.encodeString(value.toString())
+ }
+}