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,需要在群里发送 `绑定 ` 来向目标账号发送验证码,然后将验证码发到群里完成验证,如图所示 + +![群内验证示例图](screenshot/verification.png) + +_管理员无需绑定,默认使用控制台令牌执行命令_ + +绑定命令的触发关键字可以通过 `/jgc setBindCommand ` 来修改 + +## 执行命令 +默认执行GC命令前缀为 `!` ,用法是 `!<命令|别名>` + +_可以通过 `/jgc setBindCommand ` 来修改执行命令前缀 _(例如以下示例图中使用`run`作为前缀)__ + +--- + +执行命令示例:`!prop unlockmap 1` + +![运行命令示例图](screenshot/runCommand.png) + +--- + +执行别名示例:`!解锁地图` + +![运行别名示例图](screenshot/runAlias.png) + +命令别名可以通过 `/jgc setCommand ` 来设置,通过 `/jgc removeCommand ` 来删除 + +--- + +命令还可以通过 @群员 来替代原先命令中的 @UID,前提是这个群员绑定了它的UID + +例如:`!permission list @张三`,其中`@张三`会被替换成其绑定的UID + +![At群员示例图](screenshot/runAt.png) + +## 拉黑用户 +如果你想禁止某个用户使用本插件执行命令,可以使用 `/ban ` 来拉黑,使用 `/unban ` 可以解除黑名单 +_(只是一个凭想象增加的功能,也许能用上呢)_ + +## 群消息同步 + +当你设置了服务器的 consoleToken,那么你可以使用 `/jgc setServerSyncMessage ` 命令来开启群消息同步功能。 + +示例:`/jgc setServerSyncMessage 1 true` 表示启用1号服务器的消息同步功能 + +当群里收到消息时,会执行命令 `/say 用户名 消息内容` 来将消息发送到服务器,因此玩家收到的消息也是来自服务器发送的。 + +注意,为了避免徒劳的尝试,当任何一次消息发送失败时,都会自动关闭同步消息功能,需要手动重新启用。 +你可以自己更改相关规则,只需要修改源代码。 + +![同步消息示例图](screenshot/syncMessage.png) + +**注意,玩家在游戏内发送的消息是不会同步到群的,这只是单向同步!!** + +# 配置文件(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<PlainText>().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 <https://www.gnu.org/licenses/>. + */ 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 <https://www.gnu.org/licenses/>. + */ package top.jie65535.mirai -object PluginConfig { +import net.mamoe.mirai.console.data.* + +object PluginConfig : AutoSavePluginConfig("config") { + @ValueDescription("管理员列表,仅管理员可以执行控制台命令") + val administrators: MutableSet<Long> by value() + + @ValueDescription("用户黑名单") + val blacklist: MutableSet<Long> by value() + + @ValueDescription("绑定命令:绑定 <UID> 示例:绑定 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<String, String> 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 <https://www.gnu.org/licenses/>. + */ 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<Server> by value() + + @ValueDescription("最后服务器ID,用于递增") + var lastServerId: Int by value() + + @ValueDescription("玩家列表") + val users: MutableList<User> by value() + + @ValueDescription("群列表") + val groups: MutableList<GroupConfig> 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 <https://www.gnu.org/licenses/>. + */ 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 <https://www.gnu.org/licenses/>. + */ +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 <https://www.gnu.org/licenses/>. + */ 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 <https://www.gnu.org/licenses/>. + */ 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<StringResponse>(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 <https://www.gnu.org/licenses/>. + */ 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<LocalDateTime> { + + 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()) + } +}