Initial commit

This commit is contained in:
2022-09-04 14:07:47 +08:00
parent 10fa82b900
commit ea83aec168
11 changed files with 1253 additions and 15 deletions

275
README.md
View File

@ -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 <address> [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 <id> <consoleToken>
# 添加完成后,你可以将自己设置为管理员
/jgc op <user>
# 例如设置123456为管理员
/jgc op 123456
# 取消管理的子命令是 deop
# 管理员执行命令将默认使用控制台Token无需验证见后文
```
## 将服务器绑定到机器人所在群聊
```shell
# 用法
/jgc linkGroup/bindGroup/addGroup <serverId> [group] # 绑定服务器到群,在群内执行可忽略群参数
# 示例(控制台)
/jgc linkGroup 1 群号
# 示例(群里)
/jgc linkGroup 1
# 成功返回 "OK"
```
在聊天环境执行 Mirai-Console 命令需要另一个插件 [Chat Command](https://github.com/project-mirai/chat-command)
执行GC命令不需要这个见后文
## 绑定账号
玩家想要在群里执行命令需要绑定自己的游戏UID需要在群里发送 `绑定 <uid>` 来向目标账号发送验证码,然后将验证码发到群里完成验证,如图所示
![群内验证示例图](screenshot/verification.png)
_管理员无需绑定默认使用控制台令牌执行命令_
绑定命令的触发关键字可以通过 `/jgc setBindCommand <prefix>` 来修改
## 执行命令
默认执行GC命令前缀为 `!` ,用法是 `!<命令|别名>`
_可以通过 `/jgc setBindCommand <prefix>` 来修改执行命令前缀 _例如以下示例图中使用`run`作为前缀__
---
执行命令示例:`!prop unlockmap 1`
![运行命令示例图](screenshot/runCommand.png)
---
执行别名示例:`!解锁地图`
![运行别名示例图](screenshot/runAlias.png)
命令别名可以通过 `/jgc setCommand <alias> <command>` 来设置,通过 `/jgc removeCommand <alias>` 来删除
---
命令还可以通过 @群员 来替代原先命令中的 @UID前提是这个群员绑定了它的UID
例如:`!permission list @张三`,其中`@张三`会被替换成其绑定的UID
![At群员示例图](screenshot/runAt.png)
## 拉黑用户
如果你想禁止某个用户使用本插件执行命令,可以使用 `/ban <qq>` 来拉黑,使用 `/unban <qq>` 可以解除黑名单
_只是一个凭想象增加的功能也许能用上呢_
## 群消息同步
当你设置了服务器的 consoleToken那么你可以使用 `/jgc setServerSyncMessage <id> <sync>` 命令来开启群消息同步功能。
示例:`/jgc setServerSyncMessage 1 true` 表示启用1号服务器的消息同步功能
当群里收到消息时,会执行命令 `/say 用户名 消息内容` 来将消息发送到服务器,因此玩家收到的消息也是来自服务器发送的。
注意,为了避免徒劳的尝试,当任何一次消息发送失败时,都会自动关闭同步消息功能,需要手动重新启用。
你可以自己更改相关规则,只需要修改源代码。
![同步消息示例图](screenshot/syncMessage.png)
**注意,玩家在游戏内发送的消息是不会同步到群的,这只是单向同步!!**
# 配置文件(config.yml)
```yml
# 管理员列表,仅管理员可以执行控制台命令
administrators:
- 123456
# 用户黑名单
blacklist: []
# 绑定命令:绑定 <UID> 示例:绑定 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 <prefix> # 设置执行GC命令前缀
/jgc setBindCommand <prefix> # 设置绑定命令前缀
/jgc op <user> # 设置管理员
/jgc setAdmin <user> # 设置管理员
/jgc deop <user> # 解除管理员
/jgc removeAdmin <user> # 解除管理员
/jgc ban <user> # 禁止指定QQ使用插件
/jgc unban <user> # 解除禁止指定QQ使用插件
```
## 服务器相关
```shell
/jgc ping <address|id> # 测试指定服务器是否安装插件
/jgc addServer <address> [name] [description] # 添加服务器
/jgc servers # 列出服务器
/jgc setServerIsEnabled <id> <isEnabled> # 设置服务器是否启用
/jgc setServerAddress <id> <address> # 修改服务器地址
/jgc setServerInfo <id> <name> <description> # 设置服务器信息
/jgc setServerConsoleToken <id> <consoleToken> # 设置服务器控制台令牌
/jgc setServerSyncMessage <id> <sync> # 设置是否同步群消息到服务器
```
## 群相关
```shell
/jgc linkGroup/bindGroup/addGroup <serverId> [group] # 绑定服务器到群,在群内执行可忽略群参数
/jgc enable [group] # 启用指定群执行,在群内执行可忽略群参数
/jgc disable [group] # 禁用指定群执行,在群内执行可忽略群参数
```
## 命令别名相关
```shell
/jgc setCommand <alias> <command> # 添加命令别名
/jgc removeCommand <alias> # 删除命令别名
```
# 实体结构
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(
/**
* 群IDQQ群号
*/
val id: Long,
/**
* 服务器ID
*/
var serverId: Int,
/**
* 是否启用(用于临时关闭)
*/
var isEnabled: Boolean = true,
)
```
---
User
```kotlin
@Serializable
data class User(
/**
* 用户IDQQ帐号
*/
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
}
```

View File

@ -1,5 +1,5 @@
plugins {
val kotlinVersion = "1.7.20"
val kotlinVersion = "1.7.10"
kotlin("jvm") version kotlinVersion
kotlin("plugin.serialization") version kotlinVersion

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<GroupMessageEvent> {
// 忽略被拉黑的用户发送的消息
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 <color=green>${sender.nameCardOrNick}</color>:\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<MessageEvent>(priority = EventPriority.HIGH) { event ->
// 仅监听该用户的消息并且消息内容为4位数字
event.sender.id == user.id && event.message.firstIsInstanceOrNull<PlainText>()
?.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!!)
}
}
}

View File

@ -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
}

View File

@ -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 ...
))
}

View File

@ -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()
}

View File

@ -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(
/**
* 群IDQQ群号
*/
@ -20,5 +37,5 @@ data class Group(
/**
* 是否启用用于临时关闭
*/
var enabled: Boolean = true,
var isEnabled: Boolean = true,
)

View File

@ -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,
)

View File

@ -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(
/**
* 用户IDQQ帐号
*/
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
}

View File

@ -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响应错误代码例如404500
* @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)))
}
}

View File

@ -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 {
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())
}
}