00ll00 5 rokov pred
commit
de96e1a837
76 zmenil súbory, kde vykonal 4675 pridanie a 0 odobranie
  1. 11 0
      build.gradle.kts
  2. 15 0
      src/main/kotlin/bot_luo_core/bot/BotAccount.kt
  3. 17 0
      src/main/kotlin/bot_luo_core/bot/BotContact.kt
  4. 6 0
      src/main/kotlin/bot_luo_core/bot/BotContactType.kt
  5. 18 0
      src/main/kotlin/bot_luo_core/bot/MessagePool.kt
  6. 262 0
      src/main/kotlin/bot_luo_core/bot/MultiBotHandler.kt
  7. 15 0
      src/main/kotlin/bot_luo_core/bot/VirtualMessageEvent.kt
  8. 22 0
      src/main/kotlin/bot_luo_core/cli/ArgHandler.kt
  9. 37 0
      src/main/kotlin/bot_luo_core/cli/Checker.kt
  10. 6 0
      src/main/kotlin/bot_luo_core/cli/CliConfig.kt
  11. 15 0
      src/main/kotlin/bot_luo_core/cli/Cmd.kt
  12. 84 0
      src/main/kotlin/bot_luo_core/cli/CmdCatalog.kt
  13. 167 0
      src/main/kotlin/bot_luo_core/cli/CmdContext.kt
  14. 101 0
      src/main/kotlin/bot_luo_core/cli/CmdExecutable.kt
  15. 8 0
      src/main/kotlin/bot_luo_core/cli/CmdExitState.kt
  16. 164 0
      src/main/kotlin/bot_luo_core/cli/CmdHandler.kt
  17. 83 0
      src/main/kotlin/bot_luo_core/cli/CmdParser.kt
  18. 24 0
      src/main/kotlin/bot_luo_core/cli/CmdPermissionLevel.kt
  19. 26 0
      src/main/kotlin/bot_luo_core/cli/CmdReceipt.kt
  20. 321 0
      src/main/kotlin/bot_luo_core/cli/MessageReader.kt
  21. 40 0
      src/main/kotlin/bot_luo_core/cli/annotation/Argument.kt
  22. 10 0
      src/main/kotlin/bot_luo_core/cli/annotation/Command.kt
  23. 42 0
      src/main/kotlin/bot_luo_core/cli/annotation/Method.kt
  24. 18 0
      src/main/kotlin/bot_luo_core/cli/checkers/BotRunningChecker.kt
  25. 16 0
      src/main/kotlin/bot_luo_core/cli/checkers/GroupCmdWorkingChecker.kt
  26. 18 0
      src/main/kotlin/bot_luo_core/cli/checkers/GroupPermissionChecker.kt
  27. 21 0
      src/main/kotlin/bot_luo_core/cli/checkers/PermissionChecker.kt
  28. 16 0
      src/main/kotlin/bot_luo_core/cli/checkers/UserCmdWorkingChecker.kt
  29. 15 0
      src/main/kotlin/bot_luo_core/cli/checkers/UserParallelExecutingChecker.kt
  30. 43 0
      src/main/kotlin/bot_luo_core/cli/commands/ControlCmd.kt
  31. 30 0
      src/main/kotlin/bot_luo_core/cli/commands/DebugCmd.kt
  32. 139 0
      src/main/kotlin/bot_luo_core/cli/commands/ExecuteCmd.kt
  33. 115 0
      src/main/kotlin/bot_luo_core/cli/commands/HelpCmd.kt
  34. 80 0
      src/main/kotlin/bot_luo_core/cli/commands/ListCmd.kt
  35. 90 0
      src/main/kotlin/bot_luo_core/cli/commands/PermissionCmd.kt
  36. 44 0
      src/main/kotlin/bot_luo_core/cli/commands/SayCmd.kt
  37. 112 0
      src/main/kotlin/bot_luo_core/cli/commands/SwitchCmd.kt
  38. 374 0
      src/main/kotlin/bot_luo_core/cli/commands/TagCmd.kt
  39. 16 0
      src/main/kotlin/bot_luo_core/cli/exceptions/CheckerFatal.kt
  40. 25 0
      src/main/kotlin/bot_luo_core/cli/exceptions/CliException.kt
  41. 11 0
      src/main/kotlin/bot_luo_core/cli/exceptions/CliExceptionLevel.kt
  42. 13 0
      src/main/kotlin/bot_luo_core/cli/exceptions/CliInternalError.kt
  43. 16 0
      src/main/kotlin/bot_luo_core/cli/exceptions/CmdExecutingError.kt
  44. 16 0
      src/main/kotlin/bot_luo_core/cli/exceptions/CmdExecutingWarn.kt
  45. 93 0
      src/main/kotlin/bot_luo_core/cli/exceptions/CmdParseFatal.kt
  46. 35 0
      src/main/kotlin/bot_luo_core/cli/handlers/CmdExArgHandler.kt
  47. 27 0
      src/main/kotlin/bot_luo_core/cli/handlers/CmdIdArgHandler.kt
  48. 41 0
      src/main/kotlin/bot_luo_core/cli/handlers/DefaultArgHandler.kt
  49. 27 0
      src/main/kotlin/bot_luo_core/cli/handlers/GreedyMessageArgHandler.kt
  50. 19 0
      src/main/kotlin/bot_luo_core/cli/handlers/GreedyStringArgHandler.kt
  51. 36 0
      src/main/kotlin/bot_luo_core/cli/handlers/GroupArgHandler.kt
  52. 17 0
      src/main/kotlin/bot_luo_core/cli/handlers/ImageArgHandler.kt
  53. 47 0
      src/main/kotlin/bot_luo_core/cli/handlers/MultiGroupArgHandler.kt
  54. 51 0
      src/main/kotlin/bot_luo_core/cli/handlers/MultiUserArgHandler.kt
  55. 25 0
      src/main/kotlin/bot_luo_core/cli/handlers/PmsGroupArgHandler.kt
  56. 22 0
      src/main/kotlin/bot_luo_core/cli/handlers/RegexArgHandler.kt
  57. 19 0
      src/main/kotlin/bot_luo_core/cli/handlers/TagArgHandler.kt
  58. 68 0
      src/main/kotlin/bot_luo_core/cli/handlers/TimeArgHandler.kt
  59. 41 0
      src/main/kotlin/bot_luo_core/cli/handlers/UserArgHandler.kt
  60. 9 0
      src/main/kotlin/bot_luo_core/data/CmdData.kt
  61. 45 0
      src/main/kotlin/bot_luo_core/data/CmdDataObj.kt
  62. 6 0
      src/main/kotlin/bot_luo_core/data/Config.kt
  63. 207 0
      src/main/kotlin/bot_luo_core/data/DataObj.kt
  64. 49 0
      src/main/kotlin/bot_luo_core/data/Group.kt
  65. 35 0
      src/main/kotlin/bot_luo_core/data/Groups.kt
  66. 13 0
      src/main/kotlin/bot_luo_core/data/PmsGroup.kt
  67. 66 0
      src/main/kotlin/bot_luo_core/data/PmsGroups.kt
  68. 191 0
      src/main/kotlin/bot_luo_core/data/Tags.kt
  69. 68 0
      src/main/kotlin/bot_luo_core/data/User.kt
  70. 42 0
      src/main/kotlin/bot_luo_core/data/Users.kt
  71. 30 0
      src/main/kotlin/bot_luo_core/util/JsonWorker.kt
  72. 127 0
      src/main/kotlin/bot_luo_core/util/Logger.kt
  73. 142 0
      src/main/kotlin/bot_luo_core/util/ResourceManager.kt
  74. 145 0
      src/main/kotlin/bot_luo_core/util/TableBuilder.kt
  75. 46 0
      src/main/kotlin/bot_luo_core/util/Text.kt
  76. 164 0
      src/main/kotlin/bot_luo_core/util/Time.kt

+ 11 - 0
build.gradle.kts

@@ -0,0 +1,11 @@
+
+version = "1.4.0"
+
+dependencies {
+    implementation(kotlin("stdlib"))
+    api("net.mamoe:mirai-core:2.7.0")
+    implementation("com.alibaba:fastjson:1.2.73")
+    implementation("org.reflections:reflections:0.9.12")
+    implementation ("org.apache.logging.log4j:log4j-api:2.14.0")
+    implementation ("org.apache.logging.log4j:log4j-core:2.14.0")
+}

+ 15 - 0
src/main/kotlin/bot_luo_core/bot/BotAccount.kt

@@ -0,0 +1,15 @@
+package bot_luo_core.bot
+
+/**
+ * # 机器人账号数据类
+ *
+ * @param id QQ号
+ * @param psw QQ密码
+ * @param device Mirai使用的设备文件路径
+ */
+data class BotAccount(
+    val id: Long,
+    val psw: String,
+    val device: String?,
+    val working: Boolean?
+)

+ 17 - 0
src/main/kotlin/bot_luo_core/bot/BotContact.kt

@@ -0,0 +1,17 @@
+package bot_luo_core.bot
+
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.contact.Contact
+import net.mamoe.mirai.event.events.EventCancelledException
+import net.mamoe.mirai.message.MessageReceipt
+import net.mamoe.mirai.message.data.Message
+
+interface BotContact {
+    val id: Long
+    var bot: Bot?
+    val contactType: BotContactType
+    val contact: Contact? get() = MultiBotHandler.getMiraiContact(this)
+
+    suspend fun sendMessage(msg: Message): MessageReceipt<Contact>? = contact?.sendMessage(msg)
+    suspend fun sendMessage(str: String): MessageReceipt<Contact>? = contact?.sendMessage(str)
+}

+ 6 - 0
src/main/kotlin/bot_luo_core/bot/BotContactType.kt

@@ -0,0 +1,6 @@
+package bot_luo_core.bot
+
+enum class BotContactType {
+    Single,
+    Group
+}

+ 18 - 0
src/main/kotlin/bot_luo_core/bot/MessagePool.kt

@@ -0,0 +1,18 @@
+package bot_luo_core.bot
+
+import net.mamoe.mirai.message.MessageReceipt
+
+/**
+ * 消息池
+ */
+object MessagePool {
+    private val pool = HashMap<Int, MessageReceipt<*>>()
+
+    fun add(receipt: MessageReceipt<*>): Int {
+        val hash = receipt.hashCode()
+        pool[hash] = receipt
+        return hash
+    }
+
+
+}

+ 262 - 0
src/main/kotlin/bot_luo_core/bot/MultiBotHandler.kt

@@ -0,0 +1,262 @@
+package bot_luo_core.bot
+
+import bot_luo_core.cli.CmdHandler
+import bot_luo_core.util.JsonWorker
+import bot_luo_core.util.Logger
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.BotFactory
+import net.mamoe.mirai.contact.*
+import net.mamoe.mirai.containsFriend
+import net.mamoe.mirai.event.*
+import net.mamoe.mirai.event.events.*
+import net.mamoe.mirai.message.data.MessageChain
+import net.mamoe.mirai.message.data.content
+import net.mamoe.mirai.utils.BotConfiguration
+import org.apache.logging.log4j.Level
+
+/**
+ * # 多机器人管理类
+ */
+object MultiBotHandler {
+    private const val FILE_PATH = "data/bots.json"
+
+    /**
+     * ## 所有机器人实例列表
+     */
+    private val bots get() =  Bot.instances
+
+    /**
+     * ## 机器人与群组映射关系
+     */
+    private val groupMap = HashMap<Long,ArrayList<Bot>>()
+
+    /**
+     * ## 登录所有机器人账号
+     *
+     * 登录所有`bot.json`中的账号并建立群组关系,只能调用一次
+     */
+    suspend fun loginAll() {
+        val accounts = JsonWorker.readJson<ArrayList<BotAccount>>(FILE_PATH)?: throw Exception("文件不存在 $FILE_PATH")
+        for (a in accounts) {
+            if (a.working == false) continue
+            val bot = BotFactory.newBot(a.id,a.psw) {
+                a.device?.let { fileBasedDeviceInfo("data/$it") }
+                protocol = BotConfiguration.MiraiProtocol.ANDROID_PAD
+            }
+            bot.login()
+
+//            //捕获群员退群(踢出)事件
+//            bot.eventChannel.subscribeAlways<MemberLeaveEvent> {
+//                if (bot.isMainBot(this.groupId)) {
+//                    Logger.receive(this)
+//                    if (this is MemberLeaveEvent.Kick)
+//                        WarnCMD.closeRec(Users.readUser(member.id),operator?.id, Time.time())
+//                    else
+//                        WarnCMD.closeRec(Users.readUser(member.id),null, Time.time())
+//                }
+//            }
+//
+//            //捕获加群事件
+//            bot.eventChannel.subscribeAlways<MemberJoinEvent> {
+//                if (bot.isMainBot(this.groupId)) {
+//                    Logger.receive(this)
+//                    if (group.id == Tags.readGroupTag("#rsc")[0].id) return@subscribeAlways
+//                    //查询Warn记录,若大于3分则在#clip群发送警告
+//                    val user = Users.readUser(this.member.id)
+//                    if (WarnCMD.getScore(user) >= 3) {
+//                        val clipId = Tags.readGroupTag("#clip")[0]
+//                        sendGroupMessage("死人 ${member.id} 想在 ${this.group.name}(${this.group.id}) 复活", clipId.id)
+//                        val env = CmdEnv(null, CmdEnvType.GROUP, Time.time(), clipId, null, User.virtualUser(), CmdParser(StringReader("")))
+//                        WarnCMD(env).get(user,null)
+//                    }
+//                }
+//            }
+//
+//            //捕获加群申请事件
+//            bot.eventChannel.subscribeAlways<MemberJoinRequestEvent> {
+//                if (bot.isMainBot(this.groupId)) {
+//                    Logger.receive(this)
+//                    val group = Groups.readGroup(this.groupId)
+//                    if (group.getCmdStateBoolean(GuardCMD::class.getName(),"auto")) {
+//                        GuardCMD.judge(this)
+//                    }
+//                }
+//            }
+        }
+
+        buildGroupMap()
+
+        GlobalEventChannel.subscribeAlways<BotOfflineEvent> {
+            Logger.log(this, Level.INFO)
+            buildGroupMap()
+        }
+        GlobalEventChannel.subscribeAlways<BotOnlineEvent> {
+            Logger.log(this, Level.INFO)
+            buildGroupMap()
+        }
+        GlobalEventChannel.subscribeAlways<BotJoinGroupEvent> {
+            Logger.log(this, Level.INFO)
+            buildGroupMap()
+        }
+        GlobalEventChannel.subscribeAlways<BotLeaveEvent> {
+            Logger.log(this, Level.INFO)
+            buildGroupMap()
+        }
+        GlobalEventChannel.subscribeAlways<BotGroupPermissionChangeEvent> {
+            Logger.log(this, Level.INFO)
+            buildGroupMap()
+        }
+
+        GlobalEventChannel.subscribeAlways<MessageEvent>(priority = EventPriority.NORMAL) {
+            if (this.sender is AnonymousMember) return@subscribeAlways
+            //TODO 解除群锁
+            if (this is GroupAwareMessageEvent && group.id != 565056329L) return@subscribeAlways
+            if (this is MessageSyncEvent) return@subscribeAlways
+            if (this is GroupAwareMessageEvent && !bot.isMainBotOf(this.group.id)) return@subscribeAlways
+            CmdHandler.call(this)
+            if (this.isIntercepted) Logger.log(this, Level.INFO) else Logger.log(this, Level.DEBUG)
+        }
+        GlobalEventChannel.subscribeAlways<GroupMessageSyncEvent>(priority = EventPriority.NORMAL) {
+            CmdHandler.call(this)
+            if (this.isIntercepted) Logger.log(this, Level.INFO) else Logger.log(this, Level.DEBUG)
+        }
+        GlobalEventChannel.subscribeAlways<VirtualMessageEvent>(priority = EventPriority.NORMAL) {
+            CmdHandler.call(this)
+            if (this.isIntercepted) Logger.log(this, Level.INFO) else Logger.log(this, Level.DEBUG)
+        }
+        GlobalEventChannel.subscribeAlways<MessagePreSendEvent> {
+            this.cancel()
+        }
+    }
+
+    /**
+     * ## 重新建立群组关系
+     *
+     * 当任意机器人发生加群,退群,群权限变动,掉线和重新登陆时调用
+     *
+     * 主bot(下标为0)应为所有伯特中群权限最大者
+     */
+    private fun buildGroupMap() {
+        groupMap.clear()
+        for (bot in bots) {
+            if (!bot.isOnline) continue
+            for (g in bot.groups) {
+                if (groupMap[g.id] == null)
+                    groupMap[g.id] = arrayListOf(bot)
+                else {
+                    groupMap[g.id]!!.add(bot)
+                }
+            }
+        }
+        for ((g,b) in groupMap) {
+            b.sortBy { it.getGroup(g)!!.botPermission }
+            b.reverse()
+        }
+    }
+
+    /**
+     * ## 是否为该群组的主Bot
+     *
+     * 只有主Bot负责接收该群的事件,避免重复处理
+     */
+    fun Bot.isMainBotOf(groupId: Long) = this == groupMap[groupId]?.get(0)
+
+    /**
+     * ## 通过群号获取[mirai群组][Group]
+     *
+     * 此方法获取的群组所关联的Bot是不确定的
+     *
+     * @return mirai群组,失败返回null
+     */
+    fun findGroupOrNull(id: Long): Group? {
+        for (b in bots) b.getGroup(id)?.let { return it }
+        return null
+    }
+
+    /**
+     * ## 通过群号获取[mirai用户][User]
+     *
+     * 此方法获取的群组所关联的Bot是不确定的
+     *
+     * @return mirai用户,失败返回null
+     */
+    fun findUserOrNull(id: Long): User? {
+        for (b in bots) b.getFriend(id)?.let { return it }
+        for (b in bots) b.getStranger(id)?.let { return it }
+        return null
+    }
+
+    /**
+     * ## 获取Mirai联系对象
+     *
+     * 根据[bot联系对象][BotContact]获取[Mirai联系对象][Contact]
+     *
+     * [contact]的type需为[BotContactType.Single]或[BotContactType.Group]
+     */
+    fun getMiraiContact(contact: BotContact): Contact? { with(contact) {
+        if (bot == null) bot = getContactableBots(this).randomOrNull()
+        return when (contactType) {
+            BotContactType.Group -> bot?.getGroup(id)
+            BotContactType.Single -> bot?.getFriend(id)?: bot?.getStranger(id)
+        }
+    } }
+
+    /**
+     * ## 根据[bot联系对象][BotContact]获取可联系bot列表
+     */
+    fun getContactableBots(contact: BotContact): List<Bot> { with(contact) {
+        return when (contactType) {
+            BotContactType.Group -> bots.filter { it.getGroup(contact.id)?.isBotMuted?.not() ?: false }
+            BotContactType.Single -> bots.filter { it.containsFriend(id) || it.strangers.contains(id) }
+        }
+    } }
+
+    /**
+     * ## 根据id获取bot,可能返回`null`
+     */
+    fun getBotOrNull(id: Long): Bot? = bots.firstOrNull{ it.id == id }
+
+    /**
+     * ## 捕获下一条相同环境相同用户的消息
+     *
+     * 若群组为0表示为私聊环境
+     *
+     * @param filter 过滤消息直到获取到符合要求的消息(返回`true`)
+     */
+    suspend fun catchNextMessage(userId: Long, groupId: Long, timeoutMillis: Long = -1L, filter: (msg: MessageChain)->Boolean = {true}): MessageChain {
+        return syncFromEvent<Event, MessageChain>(timeoutMillis, EventPriority.MONITOR) { event ->
+            return@syncFromEvent when (event) {
+                is VirtualMessageEvent -> if (event.user.id == userId && event.group.id == groupId) {
+                    if (filter(event.message)) {
+                        Logger.log(this, Level.INFO)
+                        event.message
+                    } else {
+                        Logger.log(this, Level.DEBUG)
+                        null
+                    }
+                } else null
+                is GroupMessageEvent -> if (event.sender.id == userId && event.group.id == groupId) {
+                    if (filter(event.message)) {
+                        Logger.log(this, Level.INFO)
+                        event.message
+                    } else {
+                        Logger.log(this, Level.DEBUG)
+                        null
+                    }
+                } else null
+                is UserMessageEvent -> if (event.sender.id == userId && 0L == groupId) {
+                    if (filter(event.message)) {
+                        Logger.log(this, Level.INFO)
+                        event.message
+                    } else {
+                        Logger.log(this, Level.DEBUG)
+                        null
+                    }
+                } else null
+                else -> null
+            }
+        }
+    }
+
+
+}

+ 15 - 0
src/main/kotlin/bot_luo_core/bot/VirtualMessageEvent.kt

@@ -0,0 +1,15 @@
+package bot_luo_core.bot
+
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.data.Group
+import bot_luo_core.data.User
+import net.mamoe.mirai.event.AbstractEvent
+import net.mamoe.mirai.message.data.MessageChain
+
+class VirtualMessageEvent(
+    val context: CmdContext
+): AbstractEvent() {
+    val message: MessageChain get() = context.reader.original
+    val user: User get() = context.user
+    val group: Group get() = context.group
+}

+ 22 - 0
src/main/kotlin/bot_luo_core/cli/ArgHandler.kt

@@ -0,0 +1,22 @@
+package bot_luo_core.cli
+
+import kotlin.jvm.Throws
+import kotlin.reflect.KType
+
+interface ArgHandler<T> {
+    /**
+     * 此解析器是否可能返回多个值
+     *
+     * 若为`true`,则[T]应为[Collection]<*>,且对应的参数注解必须注明为多值。
+     */
+    val multiValued: Boolean get() = false
+    val name: String
+
+    fun handle(
+        reader: MessageReader,
+        pos: Int = -1,
+        argName: String? = null,
+        type: KType? = null,
+        context: CmdContext? = null
+    ): T
+}

+ 37 - 0
src/main/kotlin/bot_luo_core/cli/Checker.kt

@@ -0,0 +1,37 @@
+package bot_luo_core.cli
+
+import bot_luo_core.cli.checkers.*
+import bot_luo_core.cli.exceptions.CheckerFatal
+import net.mamoe.mirai.message.data.Message
+import net.mamoe.mirai.message.data.content
+import net.mamoe.mirai.message.data.toPlainText
+import kotlin.jvm.Throws
+import kotlin.reflect.KClass
+
+interface Checker {
+
+    val name: String
+
+    @Throws(CheckerFatal::class)
+    fun check(cmd: CmdExecutable, context: CmdContext)
+
+    @Throws(CheckerFatal::class)
+    fun fatal(msg: Message) {
+        throw CheckerFatal(msg, "$name 检定失败:${msg.content}", this::class)
+    }
+
+    companion object {
+
+        //排序与最终抛出的可能性有关
+        val CHECKERS = arrayListOf<KClass<out Checker>>(
+            BotRunningChecker::class,
+            GroupCmdWorkingChecker::class,
+            UserCmdWorkingChecker::class,
+            UserParallelExecutingChecker::class,
+            PermissionChecker::class
+        )
+
+        fun KClass<out Checker>.order() = CHECKERS.indexOf(this)
+
+    }
+}

+ 6 - 0
src/main/kotlin/bot_luo_core/cli/CliConfig.kt

@@ -0,0 +1,6 @@
+package bot_luo_core.cli
+
+object CliConfig {
+    val REPL_YES = arrayOf("y","ok","yes","好","是","好的","是的")
+    val REPL_NO = arrayOf("n","no","nop","不","否","不是","不要")
+}

+ 15 - 0
src/main/kotlin/bot_luo_core/cli/Cmd.kt

@@ -0,0 +1,15 @@
+package bot_luo_core.cli
+
+@Suppress("PropertyName")
+abstract class Cmd(val context: CmdContext) {
+
+    val SUCCESS: CmdReceipt get() = CmdReceipt(context.time, CmdExitState.SUCCESS)
+    val FATAL: CmdReceipt get() = CmdReceipt(context.time, CmdExitState.FATAL)
+    val FATAL_BUT_SET_TIME: CmdReceipt get() = CmdReceipt(context.time, CmdExitState.FATAL_BUT_SET_TIME)
+    val FATAL_BUT_ADD_COUNT: CmdReceipt get() = CmdReceipt(context.time, CmdExitState.FATAL_BUT_ADD_COUNT)
+
+    /**
+     * 命令执行完毕退出前执行
+     */
+    open fun onExit(receipt: CmdReceipt) {}
+}

+ 84 - 0
src/main/kotlin/bot_luo_core/cli/CmdCatalog.kt

@@ -0,0 +1,84 @@
+package bot_luo_core.cli
+
+import bot_luo_core.cli.annotation.Method
+import bot_luo_core.cli.commands.*
+import bot_luo_core.cli.exceptions.CliInternalError
+import bot_luo_core.util.Text.escapeRegex
+import kotlin.reflect.KClass
+import kotlin.reflect.KFunction
+import kotlin.reflect.full.functions
+import kotlin.reflect.full.hasAnnotation
+
+object CmdCatalog {
+    val COMMANDS = ArrayList<CmdExecutable>()
+
+    private val ILLEGAL_CHAR = arrayOf('.','*','\"','。','/','\\','[',']','(',')','{','}')
+
+    init {
+        //注册内建命令
+        registerCmd(
+            ControlCmd::class,
+            DebugCmd::class,
+            ExecuteCmd::class,
+            HelpCmd::class,
+            ListCmd::class,
+            PermissionCmd::class,
+            SayCmd::class,
+            SwitchCmd::class,
+            TagCmd::class
+        )
+    }
+
+    /**
+     * 注册命令
+     */
+    fun registerCmd(vararg cmds: KClass<out Cmd>) {
+        for (cmd in cmds) {
+            for (f in cmd.functions) {
+                if (f.hasAnnotation<Method>()) {
+                    @Suppress("UNCHECKED_CAST")
+                    val cex = CmdExecutable(cmd, f as KFunction<CmdReceipt>)
+
+                    if (cex.cmdHead.any { it.any { c -> c in ILLEGAL_CHAR } }) throw CliInternalError("命令 $cex 中存在不合法的字符")
+                    if (cex.methHead.any { it.any { c -> c in ILLEGAL_CHAR } }) throw CliInternalError("命令 $cex 中存在不合法的字符")
+
+                    COMMANDS.add(cex)
+                }
+            }
+        }
+    }
+
+    /**
+     * 使用字符串查找符合的命令方法
+     *
+     * 发起命令调用时使用此方法匹配命令头
+     */
+    fun findCmdEx(cmd: String, meth: String) = COMMANDS.filter { cmd in it.cmdHead && meth in it.methHead }.sortedBy { it.order }
+
+    /**
+     * 使用字符串查找符合的命令方法
+     *
+     * 发起命令调用时使用此方法匹配命令头
+     *
+     * 若无有效命令返回`null`
+     */
+    fun findCmdExOrNull(cmd: String, meth: String) = findCmdEx(cmd, meth).ifEmpty { null }
+
+    /**
+     * 使用通配符匹配命令方法
+     *
+     * 使用 `*` 匹配任意长度任意字符
+     */
+    fun matchCmdEx(cmd: String, meth: String): List<CmdExecutable> {
+        val cmdRegex = Regex(cmd.escapeRegex().replace("\\*",".*"))
+        val methRegex = Regex(meth.escapeRegex().replace("\\*",".*"))
+        return COMMANDS.filter { c -> c.cmdHead.any { cmdRegex.matches(it) } && c.methHead.any { methRegex.matches(it) } }.sortedBy { it.order }
+    }
+
+    /**
+     * 使用通配符匹配命令方法
+     *
+     * 无有效命令返回null
+     */
+    fun matchCmdExOrNull(cmd: String, meth: String) = matchCmdEx(cmd, meth).ifEmpty { null }
+}

+ 167 - 0
src/main/kotlin/bot_luo_core/cli/CmdContext.kt

@@ -0,0 +1,167 @@
+package bot_luo_core.cli
+
+import bot_luo_core.bot.BotContact
+import bot_luo_core.bot.MultiBotHandler
+import bot_luo_core.cli.CliConfig.REPL_NO
+import bot_luo_core.cli.CliConfig.REPL_YES
+import bot_luo_core.data.Group
+import bot_luo_core.data.User
+import bot_luo_core.util.Logger
+import bot_luo_core.util.Text.toLowercase
+import bot_luo_core.util.Time
+import kotlinx.coroutines.TimeoutCancellationException
+import net.mamoe.mirai.contact.Contact
+import net.mamoe.mirai.contact.MemberPermission
+import net.mamoe.mirai.event.events.EventCancelledException
+import net.mamoe.mirai.message.MessageReceipt
+import net.mamoe.mirai.message.data.*
+import kotlin.jvm.Throws
+
+class CmdContext(
+    val reader: MessageReader,
+    val user: User,
+    val group: Group,
+    val userF: User,
+    val groupF: Group,
+    val time: Long = Time.time()
+) {
+
+    constructor(
+        reader: MessageReader,
+        user: User,
+        group: Group,
+        time: Long = Time.time()
+    ): this(reader, user, group, user, group, time)
+
+    /**
+     * 直接回复消息所使用的对象,可能为`null`
+     *
+     * 使用[sendMessageWithLog]发送消息
+     */
+    val subject: BotContact?
+        get() = when {
+            !group.virtual -> group
+            !user.virtual -> user
+            else -> null
+        }
+
+    /**
+     * 接受返回值消息的对象,可能为`null`
+     *
+     * 使用[sendOutputWithLog]发送消息
+     */
+    val receiver: BotContact?
+        get() = when {
+            !groupF.virtual -> groupF
+            !userF.virtual -> userF
+            else -> null
+        }
+
+    val atOrEmpty: Message get() {
+        return if (!group.virtual && !user.virtual) user.getAt()
+        else EmptyMessageChain
+    }
+
+    private val mcb = MessageChainBuilder()
+    private val msgReceipt = ArrayList<Int>()
+
+    fun print(str: String) = mcb.add(str)
+    fun print(msg: Message) = mcb.add(msg)
+
+    fun println(str: String) {
+        print(str)
+        print("\n")
+    }
+    fun println(msg: Message) {
+        print(msg)
+        print("\n")
+    }
+    fun println() {
+        print("\n")
+    }
+
+    fun getOutput(): MessageChain = mcb.build()
+
+    suspend inline fun sendMessageWithLog(str: String): MessageReceipt<Contact>? = sendMessageWithLog(str.toPlainText())
+    suspend inline fun sendMessageWithLog(msg: Message): MessageReceipt<Contact>? = sendMessageWithLog(msg.toMessageChain())
+    suspend fun sendMessageWithLog(msg: MessageChain): MessageReceipt<Contact>? {
+        Logger.msgLog(msg, user, group)
+        return try {
+            subject?.sendMessage(msg)
+        } catch (e: EventCancelledException) {
+            null
+        }
+    }
+
+    suspend inline fun sendOutputWithLog(str: String): MessageReceipt<Contact>? = sendOutputWithLog(str.toPlainText())
+    suspend inline fun sendOutputWithLog(msg: Message): MessageReceipt<Contact>? = sendOutputWithLog(msg.toMessageChain())
+    suspend fun sendOutputWithLog(msg: MessageChain): MessageReceipt<Contact>? {
+        Logger.msgLog(msg, userF, groupF)
+        return try {
+            receiver?.sendMessage(msg)
+        } catch (e: EventCancelledException) {
+            null
+        }
+    }
+    suspend fun sendOutputWithLog(): MessageReceipt<Contact>? {
+        val msg = mcb.build()
+        if (!msg.isContentEmpty()) {
+            Logger.msgLog(msg, userF, groupF)
+            return try {
+                receiver?.sendMessage(msg)
+            } catch (e: EventCancelledException) {
+                null
+            }
+        }
+        return null
+    }
+
+    @Throws(TimeoutCancellationException::class)
+    suspend inline fun nextMessage(timeoutMillis: Long = -1L): MessageChain = MultiBotHandler.catchNextMessage(user.id, group.id, timeoutMillis)
+
+    suspend inline fun nextMessage(timeoutMillis: Long = -1L, timeoutAction: () -> Unit): MessageChain {
+        try {
+            return MultiBotHandler.catchNextMessage(user.id, group.id, timeoutMillis)
+        } catch (e: TimeoutCancellationException) {
+            timeoutAction()
+        }
+        return EmptyMessageChain
+    }
+    suspend inline fun nextYorN(timeoutMillis: Long, noinline timeoutAction: suspend () -> Boolean): Boolean {
+        return try {
+            MultiBotHandler.catchNextMessage(user.id, group.id, timeoutMillis) {
+                it.content.trim().toLowercase() in REPL_YES + REPL_NO
+            }.content.trim().toLowercase() in REPL_YES
+        } catch (e: TimeoutCancellationException) {
+            timeoutAction()
+        }
+    }
+
+    /**
+     * 分支一个新的[CmdContext],使用给的的环境
+     */
+    fun fork(
+        reader: MessageReader = MessageReader(this.reader),
+        user: User = this.user,
+        group: Group = this.group,
+        time: Long = this.time
+    ): CmdContext = CmdContext(
+        reader, user, group, userF, groupF, time
+    )
+
+    /**
+     * 获取一个[subject]的Mirai联系对象进行动作
+     *
+     * 获取失败返回`false`并放弃执行动作
+     *
+     * @return 是否获取成功
+     */
+    inline fun withServeBot(pms: MemberPermission = MemberPermission.MEMBER, action: (contact: Contact)->Unit): Boolean {
+        return when(subject) {
+            is Group -> group.withServeBot(pms, action)
+            is User -> user.withServeBot(action)
+            else -> false
+        }
+    }
+
+}

+ 101 - 0
src/main/kotlin/bot_luo_core/cli/CmdExecutable.kt

@@ -0,0 +1,101 @@
+package bot_luo_core.cli
+
+import bot_luo_core.cli.Checker.Companion.CHECKERS
+import bot_luo_core.cli.annotation.Argument
+import bot_luo_core.cli.annotation.Command
+import bot_luo_core.cli.annotation.Method
+import bot_luo_core.cli.exceptions.CliInternalError
+import bot_luo_core.data.Config.CMD_PREFIX
+import kotlinx.coroutines.runBlocking
+import kotlin.jvm.Throws
+import kotlin.reflect.KClass
+import kotlin.reflect.KFunction
+import kotlin.reflect.KParameter
+import kotlin.reflect.full.callSuspendBy
+import kotlin.reflect.full.findAnnotation
+import kotlin.reflect.full.instanceParameter
+import kotlin.reflect.full.primaryConstructor
+
+/**
+ * # 可执行命令类
+ *
+ * 包含命令类和命令方法的所有相关信息。
+ *
+ * 使用[CmdCatalog.registerCmd]方法自动构造此类并添加到命令目录。
+ *
+ * @param cmd 所属的[命令类][Cmd]
+ * @param meth 对应的命令方法函数,类型为[KFunction]<[CmdReceipt]>
+ *
+ * @property id 结构为[cmdName]-[cmdHead]的字符串,
+ */
+class CmdExecutable(private val cmd: KClass<out Cmd>, private val meth: KFunction<CmdReceipt>) {
+
+    val cmdAnn: Command by lazy { cmd.findAnnotation()?:throw CliInternalError("命令类 $cmd 未被注解") }
+    val methAnn: Method by lazy { meth.findAnnotation()?:throw CliInternalError("命令类 $cmd 中方法 ${meth.name} 未被注解") }
+    val paramsAnn: Map<KParameter, Argument> by lazy { meth.parameters.filter { it.kind == KParameter.Kind.VALUE }.associateWith { it.findAnnotation()?: throw CliInternalError("命令类 $cmd 方法 ${meth.name} 参数 ${it.name} 未被注解") } }
+
+    val cmdName = cmdAnn.name
+    val cmdHead = listOf(cmdAnn.name, cmdAnn.display, *cmdAnn.alias)
+    val methName = methAnn.name
+    val methHead = listOf(methAnn.name, *methAnn.alias)
+    val order = methAnn.order
+    val pmsLevel = methAnn.pmsLevel
+
+    val display = cmdAnn.display
+    val id get() = "$cmdName-$methName"
+    val idFixed get() = if (methName == "") cmdName else id
+    val head get() = CMD_PREFIX[0]+idFixed
+    val headPara = when {
+        methAnn.alias.isEmpty() -> head
+        methName == "" -> "${CMD_PREFIX[0]}$cmdName[-${methAnn.alias.joinToString(",")}]"
+        else -> "${CMD_PREFIX[0]}$id(${methAnn.alias.joinToString(",")})"
+    }
+
+    val checkers = CHECKERS.filter { it !in methAnn.ignoreCheckers }
+
+    val alias = cmdAnn.alias
+    val subAlias = methAnn.alias
+    val usage = cmdAnn.usage
+    val notice = cmdAnn.notice
+    val caption = cmdAnn.caption
+    val subTitle = methAnn.title
+    val subUsage = methAnn.usage
+    val simples = methAnn.simples
+
+    @Throws(Exception::class)
+    fun executeWith(context: CmdContext, argsIn: Map<KParameter,Any?>): CmdReceipt {
+        val ins = cmd.primaryConstructor!!.call(context)
+        val args = HashMap<KParameter,Any?>()
+        args[meth.instanceParameter!!] = ins
+        args.putAll(argsIn)
+        val receipt = if (meth.isSuspend) {
+            runBlocking {
+                meth.callSuspendBy(args)
+            }
+        } else {
+            meth.callBy(args)
+        }
+        ins.onExit(receipt)
+        return receipt
+    }
+
+    fun getParasFormat(): String {
+        val sb = StringBuilder()
+        paramsAnn.values.forEach {
+            sb.append(formatParameter(it)).append("  ")
+        }
+        return sb.toString().trimEnd()
+    }
+
+    override fun toString(): String {
+        return id
+    }
+}
+
+fun formatParameter(arg: Argument): String {
+    var p = arg.display
+    if (arg.multiValued) p += "..."
+    if (!arg.literal) p = "<$p>"
+    if (!arg.required) p = "[$p]"
+    return p
+}

+ 8 - 0
src/main/kotlin/bot_luo_core/cli/CmdExitState.kt

@@ -0,0 +1,8 @@
+package bot_luo_core.cli
+
+enum class CmdExitState(val addCount: Boolean, val setTime: Boolean) {
+    SUCCESS(true, true),
+    FATAL(false, false),
+    FATAL_BUT_SET_TIME(false, true),
+    FATAL_BUT_ADD_COUNT(true, false)
+}

+ 164 - 0
src/main/kotlin/bot_luo_core/cli/CmdHandler.kt

@@ -0,0 +1,164 @@
+package bot_luo_core.cli
+
+import bot_luo_core.bot.VirtualMessageEvent
+import bot_luo_core.cli.Checker.Companion.order
+import bot_luo_core.cli.exceptions.*
+import bot_luo_core.data.Config.CMD_PREFIX
+import bot_luo_core.data.Groups
+import bot_luo_core.data.Users
+import bot_luo_core.data.withAccessing
+import bot_luo_core.util.Logger
+import bot_luo_core.util.Text.firstNotWhitespace
+import kotlinx.coroutines.TimeoutCancellationException
+import net.mamoe.mirai.event.events.GroupAwareMessageEvent
+import net.mamoe.mirai.event.events.MessageEvent
+import net.mamoe.mirai.message.data.At
+import net.mamoe.mirai.message.data.content
+import net.mamoe.mirai.message.data.isContentEmpty
+import net.mamoe.mirai.message.data.toPlainText
+import java.lang.reflect.InvocationTargetException
+import kotlin.jvm.Throws
+import kotlin.reflect.full.createInstance
+
+object CmdHandler {
+
+    /**
+     * ## 消息事件唤起命令
+     *
+     * 初步过滤消息,若消息以[CMD_PREFIX]开头则发起命令执行,并拦截这个消息事件
+     */
+    suspend fun call(event: MessageEvent) {
+        if (event.message.content.firstNotWhitespace(3) !in CMD_PREFIX) return
+        event.intercept()
+        val context = CmdContext(
+            MessageReader(event.message),
+            Users.readUser(event.sender.id),
+            if (event is GroupAwareMessageEvent) Groups.readGroup(event.group.id) else Groups.readGroup(0)
+        )
+        call(context)
+    }
+
+    /**
+     * ## 虚拟消息事件唤起命令
+     *
+     * @see call
+     */
+    suspend fun call(event: VirtualMessageEvent) {
+        if (event.message.content.firstNotWhitespace(3) !in CMD_PREFIX) return
+        event.intercept()
+        call(event.context)
+    }
+
+    /**
+     * ## 命令上下文唤起命令
+     *
+     * 捕捉命令执行异常和发送回执消息
+     *
+     * 有需要时发送命令猜测
+     */
+    suspend fun call(context: CmdContext) {
+        try {
+            withAccessing(context.user, context.group) {
+                execute(context)
+            }
+        } catch (e: CliException) {
+            if (!e.output.isContentEmpty()) {
+                context.sendOutputWithLog(At(context.user.id) + e.output)
+            }
+        } catch (e: Exception) {
+            context.sendOutputWithLog("发生异常:${e::class.simpleName}\n${e.message}".toPlainText())
+        }
+        context.sendOutputWithLog()
+    }
+
+    /**
+     * ## 执行命令
+     *
+     * 对命令发起调用务必使用这个函数
+     *
+     * 需要对抛出的异常进行处理
+     */
+    @Throws(CliException::class)
+    fun execute(context: CmdContext) { with(context) {
+        val parser = CmdParser(reader)
+        val parseFatal = ArrayList<CmdParseFatal>()
+        val checkerFatal = ArrayList<CheckerFatal>()
+
+        for (cmd in parser.cmdExList) {
+            try {
+                cmd.checkers.forEach {it.createInstance().check(cmd, context)}
+                parser.reset()
+                val args = cmd.paramsAnn.mapValues { parser.parse(it.value, it.key.type, context) }
+                parser.finish()
+                try {
+                    context.user.cmdOnExecute(cmd)
+                    context.group.cmdOnExecute(cmd)
+
+                    Logger.log(cmd, context, "开始执行")
+
+                    val res = cmd.executeWith(context, args)
+
+                    Logger.log(cmd, context, "执行完成:${res.state}")
+
+                    if (res.state.addCount) {
+                        val ud = context.user.readCmdData(cmd)
+                        ud.totalCount++
+                        ud.dayCount++
+                        ud.specialCount++
+                        context.user.writeCmdData(cmd, ud)
+                        val gd = context.group.readCmdData(cmd)
+                        gd.totalCount++
+                        gd.dayCount++
+                        gd.specialCount++
+                        context.group.writeCmdData(cmd, gd)
+                    }
+
+                    if (res.state.setTime) {
+                        val ud = context.user.readCmdData(cmd)
+                        ud.lastTime = context.time
+                        context.user.writeCmdData(cmd, ud)
+                        val gd = context.group.readCmdData(cmd)
+                        gd.lastTime = context.time
+                        context.group.writeCmdData(cmd, gd)
+                    }
+                } catch (e: Exception) {
+                    when(e) {
+                        is CliException -> throw e
+                        is InvocationTargetException -> {
+                            when(e.targetException) {
+                                //不重要的异常
+                                is TimeoutCancellationException,
+                                    -> throw CmdExecutingWarn(e.targetException)
+                                else -> throw CmdExecutingError(e.targetException)
+                            }
+                        }
+                        else -> throw CliInternalError("调用命令时发送错误:\n${e::class.simpleName}".toPlainText(), "调用命令时发送错误:\n${e::class.simpleName}\n${e.message}")
+                    }
+                } finally {
+                    context.user.cmdFinished(cmd)
+                    context.group.cmdFinished(cmd)
+                    Logger.log(cmd, context, "命令退出")
+                }
+                return
+            } catch (e: CmdParseFatal) {
+                parseFatal.add(e)
+            } catch (e: CheckerFatal) {
+                checkerFatal.add(e)
+            }
+        }
+
+        //参数错误或条件错误,选择所有错误中最合适的抛出
+        if (parseFatal.isNotEmpty()) {
+            throw parseFatal.maxByOrNull { it.pos }!!
+        }
+
+        if (checkerFatal.isNotEmpty()) {
+            throw checkerFatal.minByOrNull { it.checker.order() }!!
+        }
+    } }
+
+    /**
+     * ## 命令错误后尝试猜测命令
+     */
+    fun guess(){}
+}

+ 83 - 0
src/main/kotlin/bot_luo_core/cli/CmdParser.kt

@@ -0,0 +1,83 @@
+package bot_luo_core.cli
+
+import bot_luo_core.cli.annotation.Argument
+import bot_luo_core.cli.exceptions.*
+import bot_luo_core.data.Config.CMD_PREFIX
+import net.mamoe.mirai.message.data.content
+import net.mamoe.mirai.message.data.isContentEmpty
+import kotlin.jvm.Throws
+import kotlin.reflect.KType
+import kotlin.reflect.full.createInstance
+
+/**
+ * # 命令解析器类
+ */
+class CmdParser(private val reader: MessageReader) {
+
+    private val pos: Int = reader.getCursor()
+    private val argPos: Int
+    val cmdExList: List<CmdExecutable>
+
+    init {
+        val cmdName: String
+        val methName: String
+        if (reader.peek() in CMD_PREFIX) reader.skip()
+        if (reader.canRead() && reader.isWhitespace(reader.peek())) reader.skip()
+        val head = reader.readStringUntilWhiteSpace()
+        val index = head.indexOf("-")
+        if (index == -1) {
+            cmdName = head
+            methName = ""
+        } else {
+            cmdName = head.substring(0, index)
+            methName = head.substring(index + 1)
+        }
+        cmdExList = CmdCatalog.findCmdExOrNull(cmdName, methName) ?: throw NoCmdFound(head, pos)
+        argPos = reader.getCursor()
+    }
+
+    fun reset() {
+        reader.setCursor(argPos)
+    }
+
+    fun finish() {
+        val pos = reader.getCursor()
+        val rem = reader.readRemainingMessage()
+        if (!rem.isContentEmpty()) throw SurplusArg(pos, rem.content)
+    }
+
+    @Throws(CmdParseFatal::class)
+    fun parse(param: Argument, targetType: KType, context: CmdContext): Any? {
+        reader.skipWhitespace()
+        val pos = reader.getCursor()
+
+        val ins = param.handler.createInstance()
+        if (reader.canRead()) {
+            if (param.multiValued) {
+                val res = ArrayList<Any?>()
+                if (reader.isMultipleStringStart(reader.peek())) {
+                    for (m in reader.readMultipleMessage()) {
+                        val value = ins.handle(MessageReader(m), pos, param.display, targetType.arguments[0].type, context)
+                        if (ins.multiValued) {
+                            res.addAll(value as Collection<Any?>)
+                        } else {
+                            res.add(value)
+                        }
+                    }
+                } else {
+                    val value = ins.handle(reader, pos, param.display, targetType.arguments[0].type, context)
+                    if (ins.multiValued) {
+                        res.addAll(value as Collection<Any?>)
+                    } else {
+                        res.add(value)
+                    }
+                }
+                return res
+            } else {
+                return ins.handle(reader, pos, param.display, targetType, context)
+            }
+        } else if (!param.required) {
+            return null
+        } else throw NoMoreArg(pos, formatParameter(param))
+    }
+}

+ 24 - 0
src/main/kotlin/bot_luo_core/cli/CmdPermissionLevel.kt

@@ -0,0 +1,24 @@
+package bot_luo_core.cli
+
+import java.util.*
+
+enum class CmdPermissionLevel {
+    NONE,
+    NORMAL,
+    HIGH,
+    OP,
+    DEBUG;
+
+    companion object {
+        operator fun invoke(name: String): CmdPermissionLevel {
+            return when(name.uppercase(Locale.getDefault())) {
+                "NONE" -> NONE
+                "NORMAL" -> NORMAL
+                "HIGH" -> HIGH
+                "OP" -> OP
+                "DEBUG" -> DEBUG
+                else -> NONE
+            }
+        }
+    }
+}

+ 26 - 0
src/main/kotlin/bot_luo_core/cli/CmdReceipt.kt

@@ -0,0 +1,26 @@
+package bot_luo_core.cli
+
+import java.util.*
+import kotlin.collections.ArrayList
+
+data class CmdReceipt(
+    val time: Long,
+    val state: CmdExitState,
+    val messages: ArrayList<String> = ArrayList(),
+    val f: String? = null,
+    val b: String? = null
+) {
+    /**
+     * 允许撤销操作
+     */
+    fun undoable(): Boolean {
+        return f!=null && b!=null
+    }
+
+    /**
+     * 需要保存
+     */
+    fun needSave(): Boolean {
+        return state.setTime || state.addCount
+    }
+}

+ 321 - 0
src/main/kotlin/bot_luo_core/cli/MessageReader.kt

@@ -0,0 +1,321 @@
+package bot_luo_core.cli
+
+import bot_luo_core.cli.exceptions.SyntaxError
+import net.mamoe.mirai.message.data.*
+import java.lang.StringBuilder
+
+/**
+ * # 消息读取类
+ *
+ * 工作方式类似StringReader,增加了对多种消息类型的支持
+ */
+class MessageReader(val original: MessageChain) {
+
+    constructor(msg: Message) : this(msg.toMessageChain())
+    constructor(text: String) : this(PlainText(text))
+    constructor(reader: MessageReader) : this(reader.original) { setCursor(reader.cursor) }
+
+    private var cursor: Int = 0
+    private val index = ArrayList<IndexedMessage>()
+    private val string = original.content
+
+    init {
+        var i = 0
+        for (m in original) {
+            index.add(IndexedMessage(i,i+m.content.length,m))
+            i += m.content.length
+        }
+    }
+
+    fun getTotalLen() = string.length
+
+    fun setCursor(c: Int) { cursor = c }
+    fun getCursor() = cursor
+
+    fun canRead() = canRead(1)
+    fun canRead(len: Int): Boolean = cursor + len <= string.length
+
+    fun peek() = string[cursor]
+    fun peek(offset: Int) = string[cursor+offset]
+
+    fun skip() { cursor++ }
+    fun skip(len: Int) { cursor+=len }
+    fun skipWhitespace() {
+        while (canRead() && Character.isWhitespace(peek())) {
+            skip()
+        }
+    }
+    fun isWhitespace(c: Char) = Character.isWhitespace(c)
+    fun read() = string[cursor++]
+
+    fun getRead() = string.substring(0,cursor)
+    fun getRemaining() = string.substring(cursor)
+    fun readRemaining(): String {
+        val str = getRemaining()
+        setCursor(getTotalLen())
+        return str
+    }
+    fun getRemainingMessage(): Message {
+        val im = index[getIndex()]
+        val mcb = MessageChainBuilder()
+        val c = cursor
+        if (im.message is PlainText) {
+            mcb.add(im.message.content.substring(cursor-im.index))
+            cursor = im.next
+        }
+        while (canRead()) {
+            mcb.add(readMessage())
+        }
+        cursor = c
+        return mcb.build()
+    }
+    fun readRemainingMessage(): MessageChain {
+        val im = index.getOrNull(getIndex())?: return EmptyMessageChain
+        val mcb = MessageChainBuilder()
+        if (im.message is PlainText) {
+            mcb.add(im.message.content.substring(cursor-im.index))
+            cursor = im.next
+        }
+        while (canRead()) {
+            mcb.add(readMessage())
+        }
+        return mcb.build()
+    }
+
+    fun readStringUntil(terminator: Char): String {
+        val result = StringBuilder()
+        var escaped = false
+        while (canRead()) {
+            val c = read()
+            if (escaped) {
+                escaped = if (c == terminator || c == '\\') {
+                    result.append(c)
+                    false
+                } else {
+                    setCursor(getCursor() - 1)
+                    throw SyntaxError(getCursor(), "无效的转义")
+                }
+            } else if (c == '\\') {
+                escaped = true
+            } else if (c == terminator) {
+                return result.toString()
+            } else {
+                result.append(c)
+            }
+        }
+        throw SyntaxError( getCursor(), "无效的输入")
+    }
+
+    fun readStringUntilWhiteSpace(): String {
+        val result = StringBuilder()
+        while (canRead()) {
+            if (!Character.isWhitespace(peek()) && index[getIndex()].message is PlainText)
+                result.append(read())
+            else
+                break
+        }
+        return result.toString()
+    }
+
+    fun readUnquotedString(): String {
+        val start = cursor
+        while (canRead() && isAllowedInUnquotedString(peek()) && index[getIndex()].message is PlainText) {
+            skip()
+        }
+        return string.substring(start, cursor)
+    }
+
+    fun readSingleMultiUnquotedString(): String {
+        val start = cursor
+        while (canRead() && isAllowedInSingleMultiUnquotedString(peek()) && index[getIndex()].message is PlainText) {
+            skip()
+        }
+        return string.substring(start, cursor)
+    }
+
+    fun readSingleMultiUnquotedMessage(): Message {
+        return if (index[getIndex()].message is PlainText) {
+            val start = cursor
+            while (canRead() && isAllowedInSingleMultiUnquotedString(peek())) {
+                skip()
+            }
+            string.substring(start, cursor).toPlainText()
+        } else {
+            readMessage()
+        }
+    }
+
+    fun readQuotedString(): String {
+        if (!canRead()) {
+            return ""
+        }
+        val next = peek()
+        if (!isQuotedStringStart(next)) {
+            throw SyntaxError( getCursor(), "非法的引用字符串头")
+        }
+        skip()
+        return readStringUntil(next)
+    }
+
+    fun readString(): String {
+        if (!canRead()) {
+            return ""
+        }
+        val next = peek()
+        if (isQuotedStringStart(next)) {
+            skip()
+            return readStringUntil(next)
+        }
+        return readUnquotedString()
+    }
+
+    fun readSingleMultiString(): String {
+        if (!canRead()) {
+            return ""
+        }
+        val next = peek()
+        if (isQuotedStringStart(next)) {
+            skip()
+            return readStringUntil(next)
+        }
+        return readSingleMultiUnquotedString()
+    }
+
+    fun readSingleMultiMessage(): Message {
+        if (!canRead()) {
+            return "".toPlainText()
+        }
+        val next = peek()
+        if (isQuotedStringStart(next)) {
+            skip()
+            return readStringUntil(next).toPlainText()
+        }
+        return readSingleMultiUnquotedMessage()
+    }
+
+    private fun getIndex(): Int {
+        var i = 0
+        while (index.size > i && index[i].next <= cursor) i++
+        return i
+    }
+
+    /**
+     * 获取当前位置的Message
+     *
+     * 不影响cursor
+     */
+    fun peekMessage(): Message {
+        val i = getIndex()
+        return index[i].message
+    }
+
+    /**
+     * 读取当前位置的Message
+     *
+     * 读取成功会将cursor移动到下一个SingleMessage开头
+     */
+    fun readMessage(): Message {
+        val i = getIndex()
+        cursor = index[i].next
+        return index[i].message
+    }
+
+    private inline fun <reified T: Message> readTypedMessage(): T? {
+        val i = getIndex()
+        return if (index[i].message is T) {
+            cursor = index[i].next
+            index[i].message as T
+        } else null
+    }
+
+    fun readPlainText(): PlainText? = readTypedMessage()
+    fun readImage(): Image? = readTypedMessage()
+    fun readAt(): At? = readTypedMessage()
+    fun readAtAll(): AtAll? = readTypedMessage()
+    fun readFace(): Face? = readTypedMessage()
+    fun readQuoteReply(): QuoteReply? = readTypedMessage()
+
+    /**
+     * 读取多值字符串
+     *
+     * 多值字符串为"()","[]","{}"包括的使用' ',',',',','\n'分隔的字符串
+     * @return 读取到的多个字符串
+     * @throws CmdSyntaxException
+     */
+    fun readMultipleString(): ArrayList<String> {
+        val list = ArrayList<String>()
+        if (!canRead()) {
+            return list
+        }
+        val end = when (peek()) {
+            '(' -> ')'
+            '[' -> ']'
+            '{' -> '}'
+            else -> throw SyntaxError(getCursor(), "非法的多值字符串头")
+        }
+        skip()
+        skipWhitespace()
+        while (canRead()) {
+            if (peek()==end) {
+                skip()
+                return list
+            } else {
+                list.add(readSingleMultiString())
+                while (canRead() && peek() in arrayOf(' ',',',',','\n','\r')) skip()
+            }
+        }
+        throw SyntaxError(getCursor(), "多值字符串括号未闭合")
+    }
+
+    fun readMultipleMessage(): ArrayList<Message> {
+        val list = ArrayList<Message>()
+        if (!canRead()) {
+            return list
+        }
+        val end = when (peek()) {
+            '(' -> ')'
+            '[' -> ']'
+            '{' -> '}'
+            else -> throw SyntaxError(getCursor(), "非法的多值字符串头")
+        }
+        skip()
+        skipWhitespace()
+        while (canRead()) {
+            if (peek()==end) {
+                skip()
+                return list
+            } else {
+                list.add(readSingleMultiMessage())
+                while (canRead() && peek() in arrayOf(' ',',',',','\n','\r')) skip()
+            }
+        }
+        throw SyntaxError(getCursor(), "多值字符串括号未闭合")
+    }
+
+    fun isQuotedStringStart(c: Char): Boolean {
+        return c == '"' || c == '\''
+    }
+
+    fun isMultipleStringStart(c: Char): Boolean {
+        return c in "([{"
+    }
+
+    fun isAllowedInUnquotedString(c: Char): Boolean {
+        return c !in arrayOf(' ','"','\'','\n','\r')
+    }
+
+    fun isAllowedInSingleMultiUnquotedString(c: Char): Boolean {
+        return c !in arrayOf(' ','"','\'','\n','\r','(',')','[',']','{','}',',',',')
+    }
+
+    /**
+     * 带起止位置的[SingleMessage]
+     *
+     * @param next 为下一个Message的[index]
+     */
+    private data class IndexedMessage(
+        val index: Int,
+        val next: Int,
+        val message: SingleMessage
+    ): SingleMessage by message
+}

+ 40 - 0
src/main/kotlin/bot_luo_core/cli/annotation/Argument.kt

@@ -0,0 +1,40 @@
+package bot_luo_core.cli.annotation
+
+import bot_luo_core.cli.ArgHandler
+import bot_luo_core.cli.handlers.DefaultArgHandler
+import kotlin.reflect.KClass
+
+annotation class Argument (
+    /**
+     * 显示名称,用于命令参数格式化显示
+     */
+    val display: String,
+
+    /**
+     * 是否为多值参数
+     *
+     * 为`true`则允许多次使用[handler]解析参数,返回一个[ArrayList]。
+     *
+     * 若使用多值处理器则必须为`true`,反之不一定
+     */
+    val multiValued: Boolean = false,
+
+    /**
+     * 是否为字面参数
+     */
+    val literal: Boolean = false,
+
+    /**
+     * 是否为必须参数
+     *
+     * 为`false`时对应的参数应为可null
+     */
+    val required: Boolean = true,
+
+    /**
+     * 参数处理器
+     *
+     * 若使用的处理器为多值处理器,则[multiValued]必须为`true`
+     */
+    val handler: KClass<out ArgHandler<*>> = DefaultArgHandler::class
+        )

+ 10 - 0
src/main/kotlin/bot_luo_core/cli/annotation/Command.kt

@@ -0,0 +1,10 @@
+package bot_luo_core.cli.annotation
+
+annotation class Command(
+    val name: String,
+    val display: String,
+    val alias: Array<String> = [],
+    val usage: String = "",
+    val notice: Array<String> = [],
+    val caption: Array<String> = []
+)

+ 42 - 0
src/main/kotlin/bot_luo_core/cli/annotation/Method.kt

@@ -0,0 +1,42 @@
+package bot_luo_core.cli.annotation
+
+import bot_luo_core.cli.Checker
+import bot_luo_core.cli.CmdPermissionLevel
+import kotlin.reflect.KClass
+
+annotation class Method(
+    /**
+     * 同一个命令下[name]相同的方法会视为等效的方法,
+     * 以此实现同一命令方法拥有多种参数结构。
+     *
+     * 这种情况下,有关的[Method]注解需要有相同的[pmsLevel],[ignoreCheckers],[usage],[title]
+     * 最好也保证[alias]相同
+     */
+    val name: String = "",
+
+    /**
+     * 别名
+     */
+    val alias: Array<String> = [],
+
+    /**
+     * 忽略的检查器
+     */
+    val ignoreCheckers: Array<KClass<out Checker>> = [],
+
+    /**
+     * 默认的权限配置,用于生成默认权限组文件
+     */
+    val pmsLevel: CmdPermissionLevel = CmdPermissionLevel.DEBUG,
+
+    /**
+     * 命令唤起的尝试顺序,数值越大越靠后
+     *
+     * 若两个命令方法同时满足所有参数和条件,则执行[order]较小者。若[order]相等则无法确定会执行哪一个
+     */
+    val order: Int = 0,
+
+    val usage: String = "",
+    val title: String = "",
+    val simples: Array<String> = []
+)

+ 18 - 0
src/main/kotlin/bot_luo_core/cli/checkers/BotRunningChecker.kt

@@ -0,0 +1,18 @@
+package bot_luo_core.cli.checkers
+
+import bot_luo_core.cli.Checker
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.CmdExecutable
+import net.mamoe.mirai.message.data.toPlainText
+
+class BotRunningChecker: Checker {
+
+    override val name = "机器人运行检定器"
+
+    override fun check(cmd: CmdExecutable, context: CmdContext) {
+        if (!BOT_RUNNING) fatal("机器人已关闭".toPlainText())
+    }
+
+}
+
+var BOT_RUNNING = true

+ 16 - 0
src/main/kotlin/bot_luo_core/cli/checkers/GroupCmdWorkingChecker.kt

@@ -0,0 +1,16 @@
+package bot_luo_core.cli.checkers
+
+import bot_luo_core.cli.Checker
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.CmdExecutable
+import net.mamoe.mirai.message.data.toPlainText
+
+class GroupCmdWorkingChecker: Checker {
+
+    override val name = "群组命令启用检定器"
+
+    override fun check(cmd: CmdExecutable, context: CmdContext) {
+        val data = context.group.readCmdData(cmd)
+        if (!data.working) fatal("此命令未在群组 ${context.group.name}(${context.group.id}) 启用".toPlainText())
+    }
+}

+ 18 - 0
src/main/kotlin/bot_luo_core/cli/checkers/GroupPermissionChecker.kt

@@ -0,0 +1,18 @@
+package bot_luo_core.cli.checkers
+
+import bot_luo_core.cli.Checker
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.CmdExecutable
+import bot_luo_core.data.PmsGroups.readPmsOn
+import net.mamoe.mirai.message.data.toPlainText
+
+class GroupPermissionChecker: Checker {
+
+    override val name = "群组权限检定器"
+
+    override fun check(cmd: CmdExecutable, context: CmdContext) {
+        val p = context.groupF.pmsGroup.readPmsOn(cmd)
+        if (p >= cmd.pmsLevel) fatal("群组权限不足,要求:${cmd.pmsLevel.name} 群组权限:${p.name}".toPlainText())
+    }
+
+}

+ 21 - 0
src/main/kotlin/bot_luo_core/cli/checkers/PermissionChecker.kt

@@ -0,0 +1,21 @@
+package bot_luo_core.cli.checkers
+
+import bot_luo_core.cli.Checker
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.CmdExecutable
+import bot_luo_core.data.PmsGroups.readPmsOn
+import net.mamoe.mirai.message.data.toPlainText
+
+class PermissionChecker: Checker {
+
+    override val name = "权限检定器"
+
+    override fun check(cmd: CmdExecutable, context: CmdContext) {
+        val p1 = context.userF.pmsGroup.readPmsOn(cmd)
+        val p2 = context.groupF.pmsGroup.readPmsOn(cmd)
+        val b1 = p1 >= cmd.pmsLevel
+        val b2 = p2 >= cmd.pmsLevel
+        if (!b1 && !b2) fatal("权限不足,要求:${cmd.pmsLevel.name} 群组权限:${p2.name} 用户权限:${p1.name}".toPlainText())
+    }
+
+}

+ 16 - 0
src/main/kotlin/bot_luo_core/cli/checkers/UserCmdWorkingChecker.kt

@@ -0,0 +1,16 @@
+package bot_luo_core.cli.checkers
+
+import bot_luo_core.cli.Checker
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.CmdExecutable
+import net.mamoe.mirai.message.data.toPlainText
+
+class UserCmdWorkingChecker: Checker {
+
+    override val name = "用户命令启用检定器"
+
+    override fun check(cmd: CmdExecutable, context: CmdContext) {
+        val data = context.user.readCmdData(cmd)
+        if (!data.working) fatal("此命令已对用户 ${context.user.name}(${context.user.id}) 关闭".toPlainText())
+    }
+}

+ 15 - 0
src/main/kotlin/bot_luo_core/cli/checkers/UserParallelExecutingChecker.kt

@@ -0,0 +1,15 @@
+package bot_luo_core.cli.checkers
+
+import bot_luo_core.cli.Checker
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.CmdExecutable
+import net.mamoe.mirai.message.data.toPlainText
+
+class UserParallelExecutingChecker: Checker {
+    override val name = "用户命令并发检定器"
+
+    override fun check(cmd: CmdExecutable, context: CmdContext) {
+        if (!context.user.isCmdFree(cmd)) fatal("请等待命令执行完毕".toPlainText())
+    }
+
+}

+ 43 - 0
src/main/kotlin/bot_luo_core/cli/commands/ControlCmd.kt

@@ -0,0 +1,43 @@
+package bot_luo_core.cli.commands
+
+import bot_luo_core.cli.*
+import bot_luo_core.cli.annotation.Command
+import bot_luo_core.cli.annotation.Method
+import bot_luo_core.cli.checkers.BOT_RUNNING
+import bot_luo_core.data.Groups
+import bot_luo_core.data.Users
+
+@Command(
+    name = "control",
+    display = "控制",
+    alias = ["ctrl"],
+    usage = "控制类命令组,约等于Ctrl键"
+)
+class ControlCmd(context: CmdContext) : Cmd(context) {
+
+    @Method(name = "s", alias = [], pmsLevel = CmdPermissionLevel.OP, title = "保存")
+    fun save (): CmdReceipt {
+        Users.saveAll()
+        Groups.saveAll()
+        context.print("数据已保存")
+        return SUCCESS
+    }
+
+    @Method(name = "p", alias = [], pmsLevel = CmdPermissionLevel.OP, title = "暂停")
+    fun pause (): CmdReceipt {
+        BOT_RUNNING = !BOT_RUNNING
+        context.print("运行中:$BOT_RUNNING")
+        return SUCCESS
+    }
+
+//    @Method(name = "z", alias = [], pmsLevel = CmdPermissionLevel.HIGH, title = "撤消")
+//    fun undo (): CmdReceipt {
+//
+//    }
+//
+//    @Method(name = "y", alias = [], pmsLevel = CmdPermissionLevel.HIGH, title = "重做")
+//    fun redo (): CmdReceipt {
+//
+//    }
+
+}

+ 30 - 0
src/main/kotlin/bot_luo_core/cli/commands/DebugCmd.kt

@@ -0,0 +1,30 @@
+package bot_luo_core.cli.commands
+
+import bot_luo_core.cli.*
+import bot_luo_core.cli.annotation.Command
+import bot_luo_core.cli.annotation.Method
+import bot_luo_core.data.DataObj
+import bot_luo_core.data.Groups
+import bot_luo_core.data.Users
+import bot_luo_core.util.TableBuilder
+
+@Command(
+    name = "debug",
+    display = "调试",
+    alias = ["dbg","打不过"],
+    usage = "显示调试信息,调试命令"
+)
+class DebugCmd(context: CmdContext) : Cmd(context) {
+
+    @Method(name = "", alias = ["show"], pmsLevel = CmdPermissionLevel.DEBUG)
+    fun show (): CmdReceipt {
+        val table = TableBuilder(4)
+        table.th("调试信息 ——").br()
+        table.tr("active_groups:").tb(Groups.activeGroupsCount)
+        table.tr("active_users:").tb(Users.activeUsersCount)
+        table.tr("save_jobs:").tb(DataObj.saveJobs.size)
+        context.print(table.toString())
+        return SUCCESS
+    }
+
+}

+ 139 - 0
src/main/kotlin/bot_luo_core/cli/commands/ExecuteCmd.kt

@@ -0,0 +1,139 @@
+package bot_luo_core.cli.commands
+
+import bot_luo_core.cli.*
+import bot_luo_core.cli.annotation.Argument
+import bot_luo_core.cli.annotation.Command
+import bot_luo_core.cli.annotation.Method
+import bot_luo_core.cli.checkers.UserParallelExecutingChecker
+import bot_luo_core.cli.exceptions.*
+import bot_luo_core.cli.handlers.MultiGroupArgHandler
+import bot_luo_core.cli.handlers.MultiUserArgHandler
+import bot_luo_core.cli.handlers.TimeArgHandler
+import net.mamoe.mirai.message.data.MessageChainBuilder
+import net.mamoe.mirai.message.data.isContentEmpty
+import kotlin.reflect.KType
+
+@Command(
+    name = "execute",
+    display = "执行",
+    alias = ["exe"],
+    usage = "使用指定的上下文执行命令",
+    caption = [
+        "子命令列表",
+        "as  <用户...>  <子命令>",
+        "in  <群组...>  <子命令>",
+        "at  <时间>  <子命令>",
+        "run  <命令>"
+    ]
+)
+class ExecuteCmd(context: CmdContext) : Cmd(context) {
+
+    @Method(name = "", alias = [], pmsLevel = CmdPermissionLevel.OP, ignoreCheckers = [UserParallelExecutingChecker::class])
+    fun run (
+        @Argument("子命令", handler = ExecuteArgHandler::class)
+        contexts: ArrayList<CmdContext>
+    ): CmdReceipt {
+        if (contexts.size > MAX_CONTEXTS) {
+            context.print("选择的上下文数量超过限制值(${contexts.size}:$MAX_CONTEXTS)")
+            return FATAL
+        }
+        val mcb = MessageChainBuilder()
+        mcb.add ("执行输出 ——\n\n")
+        var success = 0
+        var outputNum = 0
+        for (ctx in contexts) {
+            try {
+                CmdHandler.execute(ctx)
+                success++
+                val output = ctx.getOutput()
+                if (!output.isContentEmpty()) {
+                    outputNum++
+                    mcb.add ("[I]-U(${ctx.user.id})-G(${ctx.group.id}):{\n")
+                    mcb.add (output)
+                    mcb.add ("\n}\n")
+                }
+            } catch (e: CliException) {
+                val output = e.output
+                if (!output.isContentEmpty()) {
+                    outputNum++
+                    mcb.add ("[E]-U(${ctx.user.id})-G(${ctx.group.id}):{\n")
+                    mcb.add (output)
+                    mcb.add ("\n}\n")
+                }
+            }
+        }
+        if (outputNum > 0) context.print(mcb.build())
+        if (success == 0) return FATAL
+        return SUCCESS
+    }
+
+    companion object {
+        const val MAX_CONTEXTS = 20
+    }
+}
+
+class ExecuteArgHandler: ArgHandler<ArrayList<CmdContext>> {
+
+    override val name = "execute子命令参数解析器"
+
+    override fun handle(
+        reader: MessageReader,
+        pos: Int,
+        argName: String?,
+        type: KType?,
+        context: CmdContext?
+    ): ArrayList<CmdContext> {
+        if (context == null) throw ContextNeeded(pos, argName)
+        reader.skipWhitespace()
+        val users = arrayListOf(context.user)
+        val groups = arrayListOf(context.group)
+        var time = context.time
+        while (reader.canRead()) {
+            when(val sub = reader.readString()) {
+                "as" -> {
+                    users.clear()
+                    reader.skipWhitespace()
+                    if (reader.canRead()) {
+                        if (reader.isMultipleStringStart(reader.peek())) {
+                            for (m in reader.readMultipleMessage()) {
+                                users.addAll(MultiUserArgHandler().handle(MessageReader(m), pos, "用户", type, context))
+                            }
+                        } else {
+                            users.addAll(MultiUserArgHandler().handle(reader, pos, "用户", type, context))
+                        }
+                    } else throw NoMoreArg(pos, "<用户...>")
+                }
+                "in" -> {
+                    groups.clear()
+                    reader.skipWhitespace()
+                    if (reader.canRead()) {
+                        if (reader.isMultipleStringStart(reader.peek())) {
+                            for (m in reader.readMultipleMessage()) {
+                                groups.addAll(MultiGroupArgHandler().handle(MessageReader(m), pos, "群组", type, context))
+                            }
+                        } else {
+                            groups.addAll(MultiGroupArgHandler().handle(reader, pos, "群组", type, context))
+                        }
+                    } else throw NoMoreArg(pos, "<群组...>")
+                }
+                "at" -> {
+                    reader.skipWhitespace()
+                    time = TimeArgHandler().handle(reader, pos, argName, type, context)
+                }
+                "run" -> {
+                    reader.skipWhitespace()
+                    val cmd = reader.readRemainingMessage()
+                    if (cmd.isContentEmpty()) throw NoMoreArg(pos, "<命令>")
+                    val res = ArrayList<CmdContext>()
+                    for (u in users) for (g in groups) {
+                        res.add(context.fork(reader = MessageReader(cmd),user = u, group = g, time = time))
+                    }
+                    return res
+                }
+                else -> throw HandlerFatal(sub, argName, pos, type)
+            }
+            reader.skipWhitespace()
+        }
+        throw UncompletedContent(pos, argName, "缺少run子命令")
+    }
+}

+ 115 - 0
src/main/kotlin/bot_luo_core/cli/commands/HelpCmd.kt

@@ -0,0 +1,115 @@
+package bot_luo_core.cli.commands
+
+import bot_luo_core.cli.*
+import bot_luo_core.cli.annotation.Argument
+import bot_luo_core.cli.annotation.Command
+import bot_luo_core.cli.annotation.Method
+import bot_luo_core.cli.checkers.GroupCmdWorkingChecker
+import bot_luo_core.cli.checkers.GroupPermissionChecker
+import bot_luo_core.cli.checkers.PermissionChecker
+import bot_luo_core.cli.checkers.UserCmdWorkingChecker
+import bot_luo_core.cli.exceptions.CheckerFatal
+import bot_luo_core.cli.handlers.CmdExArgHandler
+import bot_luo_core.util.TableBuilder
+import kotlin.reflect.full.createInstance
+
+/**
+ * ## 使用自定义帮助 ##
+ *
+ * 自定义一个有相同[Command]注解的命令类并注册以定义帮助
+ *
+ * 例如:
+ *
+ * ```
+ *
+ * @Command(
+ *  name = "help",
+ *  display = "帮助",
+ *  alias = ["?","?"]
+ *  )
+ *  class HelpCmd(context: CmdContext) : Cmd(context) {
+ *
+ *      @Method(name = "", alias = [], pmsLevel = CmdPermissionLevel.NORMAL, order = 0)
+ *      suspend fun help(): CmdReceipt {
+ *          context.sendMessage("这是您点的帮助")
+ *          return SUCCESS
+ *      }
+ *  }
+ *
+ * ```
+ */
+@Command(
+    name = "help",
+    display = "帮助",
+    alias = ["?","?"]
+)
+class HelpCmd(context: CmdContext) : Cmd(context) {
+
+    /*  ========================  cmd  ========================  */
+
+    @Method(name = "cmd", alias = ["c"], pmsLevel = CmdPermissionLevel.NORMAL)
+    suspend fun helpCmd(
+        @Argument(display = "命令", required = true, handler = CmdExArgHandler::class)
+        cmds: List<CmdExecutable>
+    ): CmdReceipt {
+
+        val filtered = cmds.filter {
+            try {
+                justCheck.forEach{checker -> if (checker in it.checkers) checker.createInstance().check(it,context)}
+                true
+            } catch (ignore: CheckerFatal) {
+                false
+            }
+        }
+
+        return if (filtered.isEmpty()) {
+            context.sendMessageWithLog("帮助 —— 403 Forbidden")
+            FATAL
+        } else {
+            context.sendMessageWithLog(cmdHelpGen(filtered)!!)
+            SUCCESS
+        }
+    }
+
+    companion object {
+
+        val justCheck = arrayOf(
+            GroupPermissionChecker::class,
+            GroupCmdWorkingChecker::class,
+            UserCmdWorkingChecker::class
+        )
+
+        fun cmdHelpGen(cmds: List<CmdExecutable>): String? {
+            if (cmds.isEmpty()) return null
+            val table = TableBuilder(4)
+            cmds.groupBy { it.cmdName }.forEach{ (k ,v ) ->
+                val map = HashMap<String, ArrayList<CmdExecutable>>()
+                val spl = ArrayList<String>()
+                for (c in v) {
+                    if (map[c.methName] == null) map[c.methName] = ArrayList()
+                    map[c.methName]!!.add(c)
+                    c.simples.forEach {
+                        spl.add(c.head+"  "+it)
+                    }
+                }
+                table.th("帮助 —— 命令($k)[${v[0].cmdHead.joinToString(",")}]").br()
+                table.p(v[0].usage).br().br()
+                map.forEach { (_ ,value ) ->
+                    table.prettyLines(value[0].subTitle, value.size) { index, builder ->
+                        builder.tb(value[index].headPara).tb(value[index].getParasFormat())
+                    }.tb(value[0].subUsage)
+                }
+                table.th().prettyLines("示例:",spl.size) { index, builder ->
+                    builder.tb(spl[index])
+                }
+                table.th().prettyLines("说明:",v[0].caption.size) { index, builder ->
+                    builder.tb(v[0].caption[index])
+                }
+                table.th().prettyLines("注意:",v[0].notice.size) { index, builder ->
+                    builder.tb(v[0].notice[index])
+                }
+            }
+            return table.toString()
+        }
+    }
+}

+ 80 - 0
src/main/kotlin/bot_luo_core/cli/commands/ListCmd.kt

@@ -0,0 +1,80 @@
+package bot_luo_core.cli.commands
+
+import bot_luo_core.cli.*
+import bot_luo_core.cli.annotation.Command
+import bot_luo_core.cli.annotation.Method
+import bot_luo_core.cli.checkers.GroupCmdWorkingChecker
+import bot_luo_core.cli.checkers.GroupPermissionChecker
+import bot_luo_core.cli.checkers.PermissionChecker
+import bot_luo_core.cli.checkers.UserCmdWorkingChecker
+import bot_luo_core.cli.exceptions.CheckerFatal
+import bot_luo_core.util.TableBuilder
+import kotlin.reflect.full.createInstance
+
+@Command(
+    name = "list",
+    display = "命令列表",
+    alias = [],
+    usage = "显示可用命令"
+)
+class ListCmd(context: CmdContext) : Cmd(context) {
+
+    @Method(name = "", alias = [], pmsLevel = CmdPermissionLevel.NORMAL, title = "命令列表")
+    suspend fun list (): CmdReceipt { with(context) {
+        val table = TableBuilder(4)
+        table.th("命令列表 —— 群组 ${group.name}(${group.id})").br()
+
+        val map = HashMap<String, Pair<Boolean, CmdExecutable>>()
+        for (cmd in CmdCatalog.COMMANDS) {
+            if (map[cmd.cmdName]?.first == true) continue
+            try {
+                justCheck.forEach { it.createInstance().check(cmd, context) }
+                map[cmd.cmdName] = group.readCmdData(cmd).working to cmd
+            } catch (ignore: CheckerFatal) {}
+        }
+        map.values.forEach { (working, cmd) ->
+            table.tr(if (working) "[√]" else "[×]").tb(cmd.display).tb("(${cmd.cmdName})")
+        }
+
+        sendMessageWithLog(table.toString())
+
+        return SUCCESS
+    }}
+
+    @Method(name = "detail", alias = ["d"], pmsLevel = CmdPermissionLevel.HIGH, title = "详细列表")
+    suspend fun listD (): CmdReceipt { with(context) {
+        val table = TableBuilder(4)
+        table.th("命令列表 —— 群组 ${group.name}(${group.id})").br()
+
+        for (cex in CmdCatalog.COMMANDS) {
+            try {
+                justCheck.forEach { it.createInstance().check(cex, context) }
+                table.tr(if (group.readCmdData(cex).working) "[√]" else "[×]").tb(cex.idFixed).tb(cex.subTitle)
+            } catch (ignore: CheckerFatal) {}
+        }
+
+        sendMessageWithLog(table.toString())
+
+        return SUCCESS
+    }}
+
+    @Method(name = "all", alias = ["a"], pmsLevel = CmdPermissionLevel.OP, title = "全部命令")
+    suspend fun listA (): CmdReceipt { with(context) {
+        val table = TableBuilder(4)
+        table.th("命令列表 —— 群组 ${group.name}(${group.id})").br()
+
+        for (cex in CmdCatalog.COMMANDS) {
+            table.tr(if (group.readCmdData(cex).working) "[√]" else "[×]").tb(cex.idFixed).tb(cex.subTitle)
+        }
+
+        sendMessageWithLog(table.toString())
+
+        return SUCCESS
+    }}
+
+    companion object{
+        val justCheck = arrayOf(
+            GroupPermissionChecker::class
+        )
+    }
+}

+ 90 - 0
src/main/kotlin/bot_luo_core/cli/commands/PermissionCmd.kt

@@ -0,0 +1,90 @@
+package bot_luo_core.cli.commands
+
+import bot_luo_core.cli.Cmd
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.CmdReceipt
+import bot_luo_core.cli.CmdPermissionLevel
+import bot_luo_core.cli.annotation.Argument
+import bot_luo_core.cli.annotation.Command
+import bot_luo_core.cli.annotation.Method
+import bot_luo_core.cli.handlers.*
+import bot_luo_core.data.Group
+import bot_luo_core.data.PmsGroup
+import bot_luo_core.data.User
+import bot_luo_core.data.withLockedAccessing
+import bot_luo_core.util.TableBuilder
+
+@Command(
+    name = "permission",
+    display = "权限",
+    alias = ["pms"],
+    usage = "查看或设置权限"
+)
+class PermissionCmd(context: CmdContext) : Cmd(context) {
+
+    /*  ========================  get  ========================  */
+
+    @Method(name = "",alias = ["get-group","gg","g"],pmsLevel = CmdPermissionLevel.OP,title = "查看群组")
+    fun getG(
+        @Argument(display = "群组", required = false, handler = GroupArgHandler::class)
+        groupIn: Group?
+    ): CmdReceipt {
+        val group = groupIn?: context.group
+        val table = TableBuilder(4)
+        table.th("权限组 —— 群组 ${group.name}(${group.id})").br()
+        table.tr(group.pmsGroup.name)
+        context.print(table.toString())
+        return SUCCESS
+    }
+
+    @Method(name = "get-user",alias = ["gu","u"],pmsLevel = CmdPermissionLevel.OP,title = "查看用户")
+    fun getU(
+        @Argument(display = "用户", required = false, handler = UserArgHandler::class)
+        userIn: User?
+    ): CmdReceipt {
+        val user = userIn?: context.user
+        val table = TableBuilder(4)
+        table.th("权限组 —— 用户 ${user.name}(${user.id})").br()
+        table.tr(user.pmsGroup.name)
+        context.print(table.toString())
+        return SUCCESS
+    }
+
+    /*  ========================  set  ========================  */
+
+    @Method(name = "set-group",alias = ["sg"],pmsLevel = CmdPermissionLevel.OP,title = "设置群组")
+    suspend fun setG(
+        @Argument(display = "权限组", handler = PmsGroupArgHandler::class)
+        pms: PmsGroup,
+        @Argument(display = "群组", required = false, multiValued = true, handler = MultiGroupArgHandler::class)
+        groupsIn: ArrayList<Group>?
+    ): CmdReceipt {
+        val groups = groupsIn?: arrayListOf(context.group)
+        val table = TableBuilder(4)
+        table.th("更改权限组 —— 群组").br()
+        for (group in groups) { withLockedAccessing(group) {
+            table.tr("${group.name}(${group.id})").tb(group.pmsGroup.name).tb("->").tb(pms.name)
+            group.pmsGroup = pms
+        } }
+        context.print(table.toString())
+        return SUCCESS
+    }
+
+    @Method(name = "set-user",alias = ["su"],pmsLevel = CmdPermissionLevel.OP,title = "设置用户")
+    suspend fun setU(
+        @Argument(display = "权限组", handler = PmsGroupArgHandler::class)
+        pms: PmsGroup,
+        @Argument(display = "用户", required = false, multiValued = true, handler = MultiUserArgHandler::class)
+        usersIn: ArrayList<User>?
+    ): CmdReceipt {
+        val users = usersIn?: arrayListOf(context.user)
+        val table = TableBuilder(4)
+        table.th("更改权限组 —— 用户").br()
+        for (user in users) { withLockedAccessing(user) {
+            table.tr("${user.name}(${user.id})").tb(user.pmsGroup.name).tb("->").tb(pms.name)
+            user.pmsGroup = pms
+        } }
+        context.print(table.toString())
+        return SUCCESS
+    }
+}

+ 44 - 0
src/main/kotlin/bot_luo_core/cli/commands/SayCmd.kt

@@ -0,0 +1,44 @@
+package bot_luo_core.cli.commands
+
+import bot_luo_core.bot.MultiBotHandler
+import bot_luo_core.bot.VirtualMessageEvent
+import bot_luo_core.cli.*
+import bot_luo_core.cli.annotation.Argument
+import bot_luo_core.cli.annotation.Command
+import bot_luo_core.cli.annotation.Method
+import bot_luo_core.cli.handlers.GreedyMessageArgHandler
+import net.mamoe.mirai.event.broadcast
+import net.mamoe.mirai.message.data.MessageChain
+
+@Command(
+    name = "say",
+    display = "说",
+    alias = [],
+    usage = "模拟在当前上下文中发送消息",
+    caption = [
+        "可以使用execute指定bot账号发送消息:",
+        "  - as  bot  使用指定的bot账号",
+        "  - as  #any  使用任意可用bot"
+    ]
+)
+class SayCmd(context: CmdContext) : Cmd(context) {
+
+    @Method(name = "", alias = [], pmsLevel = CmdPermissionLevel.OP )
+    suspend fun say (
+        @Argument(display = "消息", handler = GreedyMessageArgHandler::class)
+        msg: MessageChain
+    ): CmdReceipt { with(context) {
+
+        if (!group.virtual) {
+            group.bot = if (user.virtual) MultiBotHandler.getContactableBots(group).randomOrNull()
+            else MultiBotHandler.getBotOrNull(user.id)
+            group.sendMessage(msg)
+        }
+
+        VirtualMessageEvent(
+            context.fork(reader = MessageReader(msg))
+        ).broadcast()
+
+        return SUCCESS
+    } }
+}

+ 112 - 0
src/main/kotlin/bot_luo_core/cli/commands/SwitchCmd.kt

@@ -0,0 +1,112 @@
+package bot_luo_core.cli.commands
+
+import bot_luo_core.cli.Cmd
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.CmdReceipt
+import bot_luo_core.cli.CmdPermissionLevel
+import bot_luo_core.cli.annotation.Argument
+import bot_luo_core.cli.annotation.Command
+import bot_luo_core.cli.annotation.Method
+import bot_luo_core.cli.checkers.GroupCmdWorkingChecker
+import bot_luo_core.cli.handlers.CmdIdArgHandler
+import bot_luo_core.cli.handlers.GroupArgHandler
+import bot_luo_core.data.Group
+import bot_luo_core.util.TableBuilder
+
+@Command(
+    name = "switch",
+    display = "开关",
+    alias = ["sw"],
+    usage = "开启或关闭命令"
+)
+class SwitchCmd(context: CmdContext) : Cmd(context) {
+
+    /*  ========================  show  ========================  */
+
+    @Method(name = "",alias = ["show"],pmsLevel = CmdPermissionLevel.OP,order = 0,ignoreCheckers = [GroupCmdWorkingChecker::class],
+    title = "查看", usage = "获取命令是否开启")
+    fun show(
+        @Argument(display = "命令", handler = CmdIdArgHandler::class, multiValued = true)
+        ids: ArrayList<String>,
+        @Argument(display = "群组", handler = GroupArgHandler::class, required = false)
+        groupIn: Group?
+    ): CmdReceipt {
+        val group = groupIn?: context.group
+        val table = TableBuilder(4)
+        table.th("电闸 —— 群组(${group.id})").br()
+        for (id in ids) {
+            val data = group.readCmdData(id)
+            table.tr(id).tb(":").tb(if (data.working) "ON" else "OFF")
+        }
+        context.println(table.toString())
+        return SUCCESS
+    }
+
+    @Method(name = "",alias = ["show"],pmsLevel = CmdPermissionLevel.OP,order = 0,ignoreCheckers = [GroupCmdWorkingChecker::class])
+    fun show(
+        @Argument(display = "群组", handler = GroupArgHandler::class)
+        groupIn: Group,
+        @Argument(display = "命令", handler = CmdIdArgHandler::class, multiValued = true)
+        ids: ArrayList<String>
+    ) = show(ids, groupIn)
+
+    /*  ========================  on  ========================  */
+
+    @Method(name = "on",pmsLevel = CmdPermissionLevel.OP,order = 0,ignoreCheckers = [GroupCmdWorkingChecker::class])
+    fun on(
+        @Argument(display = "命令", handler = CmdIdArgHandler::class, multiValued = true)
+        ids: ArrayList<String>,
+        @Argument(display = "群组", handler = GroupArgHandler::class, required = false)
+        groupIn: Group?
+    ): CmdReceipt {
+        val group = groupIn?: context.group
+        val table = TableBuilder(4)
+        table.th("电闸开启 —— 群组(${group.id})").br()
+        for (id in ids) {
+            val data = group.readCmdData(id)
+            table.tr(id).tb(":").tb(if (data.working) "ON" else "OFF").tb("->").tb("ON")
+            data.working = true
+            group.writeCmdData(id, data)
+        }
+        context.println(table.toString())
+        return SUCCESS
+    }
+
+    @Method(name = "on",pmsLevel = CmdPermissionLevel.OP,order = 0,ignoreCheckers = [GroupCmdWorkingChecker::class])
+    fun on(
+        @Argument(display = "群组", handler = GroupArgHandler::class)
+        groupIn: Group,
+        @Argument(display = "命令", handler = CmdIdArgHandler::class, multiValued = true)
+        ids: ArrayList<String>
+    ) = on (ids, groupIn)
+
+    /*  ========================  off  ========================  */
+
+    @Method(name = "off",pmsLevel = CmdPermissionLevel.OP,order = 0,ignoreCheckers = [GroupCmdWorkingChecker::class])
+    fun off(
+        @Argument(display = "命令", handler = CmdIdArgHandler::class, multiValued = true)
+        ids: ArrayList<String>,
+        @Argument(display = "群组", handler = GroupArgHandler::class, required = false)
+        groupIn: Group?
+    ): CmdReceipt {
+        val group = groupIn?: context.group
+        val table = TableBuilder(4)
+        table.th("电闸关闭 —— 群组(${group.id})").br()
+        for (id in ids) {
+            val data = group.readCmdData(id)
+            table.tr(id).tb(":").tb(if (data.working) "ON" else "OFF").tb("->").tb("OFF")
+            data.working = false
+            group.writeCmdData(id, data)
+        }
+        context.println(table.toString())
+        return SUCCESS
+    }
+
+    @Method(name = "off",pmsLevel = CmdPermissionLevel.OP,order = 0,ignoreCheckers = [GroupCmdWorkingChecker::class])
+    fun off(
+        @Argument(display = "群组", handler = GroupArgHandler::class)
+        groupIn: Group,
+        @Argument(display = "命令", handler = CmdIdArgHandler::class, multiValued = true)
+        ids: ArrayList<String>
+    ) = off (ids, groupIn)
+}

+ 374 - 0
src/main/kotlin/bot_luo_core/cli/commands/TagCmd.kt

@@ -0,0 +1,374 @@
+package bot_luo_core.cli.commands
+
+import bot_luo_core.cli.Cmd
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.CmdPermissionLevel
+import bot_luo_core.cli.CmdReceipt
+import bot_luo_core.cli.annotation.Argument
+import bot_luo_core.cli.annotation.Command
+import bot_luo_core.cli.annotation.Method
+import bot_luo_core.cli.handlers.GroupArgHandler
+import bot_luo_core.cli.handlers.TagArgHandler
+import bot_luo_core.cli.handlers.UserArgHandler
+import bot_luo_core.data.Group
+import bot_luo_core.data.Tags
+import bot_luo_core.data.Tags.BUILTIN_GROUP_TAGS
+import bot_luo_core.data.Tags.BUILTIN_USER_TAGS
+import bot_luo_core.data.Tags.addToGroupTag
+import bot_luo_core.data.Tags.addToUserTag
+import bot_luo_core.data.Tags.groupTag
+import bot_luo_core.data.Tags.readGroupTag
+import bot_luo_core.data.Tags.readUserTag
+import bot_luo_core.data.Tags.remFromGroupTag
+import bot_luo_core.data.Tags.remFromUserTag
+import bot_luo_core.data.Tags.remGroupTag
+import bot_luo_core.data.Tags.remUserTag
+import bot_luo_core.data.Tags.userTag
+import bot_luo_core.data.User
+import bot_luo_core.util.TableBuilder
+import bot_luo_core.util.Text.toLowercase
+import kotlin.collections.ArrayList
+
+@Command(
+    name = "tag",
+    display = "标签",
+    alias = [],
+    usage = "管理用户和群组标签"
+)
+class TagCmd(context: CmdContext) : Cmd(context) {
+
+    /*  ========================  add  ========================  */
+
+    @Method(name = "group-add", alias = ["ga"], pmsLevel = CmdPermissionLevel.OP, title = "添加群组标签", order = 0)
+    fun groupAdd(
+        @Argument(display = "被操作标签", handler = TagArgHandler::class)
+        tag: String,
+        @Argument(display = "待加入标签", handler = TagArgHandler::class, multiValued = true)
+        tagIn: ArrayList<String>
+    ): CmdReceipt {
+        if (tag in BUILTIN_GROUP_TAGS) {
+            context.print("不允许修改内建标签“$tag”")
+            return FATAL
+        }
+
+        var suc = 0
+        val table = TableBuilder(4)
+        table.th("群组标签添加 —— 标签($tag)").br()
+
+        for (t in tagIn) {
+            if (addToGroupTag(tag, t)) {
+                table.tr().tb(t).tb("[√] success added")
+                suc++
+            } else {
+                table.tr().tb(t).tb("[×] duplicated item or circulated tag")
+            }
+        }
+        context.print(table.toString())
+        return if (suc > 0) SUCCESS else FATAL
+    }
+
+    @Method(name = "group-add", alias = ["ga"], pmsLevel = CmdPermissionLevel.OP, title = "添加群组标签", order = 1)
+    fun groupAdd1(
+        @Argument(display = "被操作标签", handler = TagArgHandler::class)
+        tag: String,
+        @Argument(display = "待加入群组", handler = GroupArgHandler::class, multiValued = true)
+        groupIn: ArrayList<Group>
+    ): CmdReceipt {
+        if (tag in BUILTIN_GROUP_TAGS) {
+            context.print("不允许修改内建标签“$tag”")
+            return FATAL
+        }
+
+        var suc = 0
+        val table = TableBuilder(4)
+        table.th("群组标签添加 —— 标签($tag)").br()
+
+        for (g in groupIn) {
+            if (addToGroupTag(tag, g.id.toString())) {
+                table.tr(g.name).tb("(${g.id})").tb("[√] success added")
+                suc++
+            } else {
+                table.tr(g.name).tb("(${g.id})").tb("[×] duplicated item")
+            }
+        }
+        context.print(table.toString())
+        return if (suc > 0) SUCCESS else FATAL
+    }
+
+    @Method(name = "user-add", alias = ["ua"], pmsLevel = CmdPermissionLevel.OP, title = "添加用户标签", order = 0)
+    fun userAdd(
+        @Argument(display = "被操作标签", handler = TagArgHandler::class)
+        tag: String,
+        @Argument(display = "待加入标签", handler = TagArgHandler::class, multiValued = true)
+        tagIn: ArrayList<String>
+    ): CmdReceipt {
+        if (tag in BUILTIN_USER_TAGS) {
+            context.print("不允许修改内建标签“$tag”")
+            return FATAL
+        }
+
+        var suc = 0
+        val table = TableBuilder(4)
+        table.th("用户标签添加 —— 标签($tag)").br()
+
+        for (t in tagIn) {
+            if (addToUserTag(tag, t)) {
+                table.tr().tb(t).tb("[√] success added")
+                suc++
+            } else {
+                table.tr().tb(t).tb("[×] duplicated item or circulated tag")
+            }
+        }
+        context.print(table.toString())
+        return if (suc > 0) SUCCESS else FATAL
+    }
+
+    @Method(name = "user-add", alias = ["ua"], pmsLevel = CmdPermissionLevel.OP, title = "添加用户标签", order = 1)
+    fun userAdd1(
+        @Argument(display = "被操作标签", handler = TagArgHandler::class)
+        tag: String,
+        @Argument(display = "待加入用户", handler = UserArgHandler::class, multiValued = true)
+        userIn: ArrayList<User>
+    ): CmdReceipt {
+        if (tag in BUILTIN_GROUP_TAGS) {
+            context.print("不允许修改内建标签“$tag”")
+            return FATAL
+        }
+
+        var suc = 0
+        val table = TableBuilder(4)
+        table.th("用户标签添加 —— 标签($tag)").br()
+
+        for (u in userIn) {
+            if (addToUserTag(tag, u.id.toString())) {
+                table.tr(u.name).tb("(${u.id})").tb("[√] success added")
+                suc++
+            } else {
+                table.tr(u.name).tb("(${u.id})").tb("[×] duplicated item")
+            }
+        }
+        context.print(table.toString())
+        return if (suc > 0) SUCCESS else FATAL
+    }
+
+    /*  ========================  list  ========================  */
+
+    @Method(name = "group-list", alias = ["gl"], pmsLevel = CmdPermissionLevel.OP, title = "列出群组标签")
+    fun groupList(
+        @Argument(display = "标签", handler = TagArgHandler::class, required = false)
+        tag: String?
+    ): CmdReceipt {
+        val table = TableBuilder(4)
+        if (tag == null) {
+            table.th("群组标签 —— 所有标签").br()
+            for (t in groupTag.keys) {
+                table.tr(t)
+            }
+            for (t in BUILTIN_GROUP_TAGS) {
+                table.tr(t).tb("*")
+            }
+            context.print(table.toString())
+            return SUCCESS
+        } else {
+            table.th("群组标签 —— 标签($tag)").br()
+            for (g in readGroupTag(tag, context)!!) {
+                table.tr("G(${g.id})").tb(g.name)
+            }
+            context.print(table.toString())
+            return SUCCESS
+        }
+    }
+
+    @Method(name = "user-list", alias = ["ul"], pmsLevel = CmdPermissionLevel.OP, title = "列出用户标签")
+    fun userList(
+        @Argument(display = "标签", handler = TagArgHandler::class, required = false)
+        tag: String?
+    ): CmdReceipt {
+        val table = TableBuilder(4)
+        if (tag == null) {
+            table.th("用户标签 —— 所有标签").br()
+            for (t in userTag.keys) {
+                table.tr(t)
+            }
+            for (t in BUILTIN_USER_TAGS) {
+                table.tr(t).tb("*")
+            }
+            context.print(table.toString())
+            return SUCCESS
+        } else {
+            table.th("用户标签 —— 标签($tag)").br()
+            for (u in readUserTag(tag, context)!!) {
+                table.tr("U(${u.id})")
+            }
+            context.print(table.toString())
+            return SUCCESS
+        }
+    }
+
+    /*  ========================  tree  ========================  */
+
+    @Method(name = "group-tree", alias = ["gt"], pmsLevel = CmdPermissionLevel.OP, title = "展开群组标签")
+    fun groupTree(
+        @Argument(display = "标签", handler = TagArgHandler::class)
+        tag: String
+    ): CmdReceipt {
+        val table = TableBuilder(1)
+        table.th("展开群组标签 —— 标签($tag)")
+        tree(tag, table) { groupTag[it] ?: ArrayList() }
+        context.print(table.toString())
+        return SUCCESS
+    }
+
+    @Method(name = "user-tree", alias = ["ut"], pmsLevel = CmdPermissionLevel.OP, title = "展开用户标签")
+    fun userTree(
+        @Argument(display = "标签", handler = TagArgHandler::class)
+        tag: String
+    ): CmdReceipt {
+        val table = TableBuilder(1)
+        table.th("展开用户标签 —— 标签($tag)")
+        tree(tag, table) { userTag[it] ?: ArrayList() }
+        context.print(table.toString())
+        return SUCCESS
+    }
+
+    /*  ========================  remove  ========================  */
+
+    @Method(name = "group-remove", alias = ["gr"], pmsLevel = CmdPermissionLevel.OP, title = "删除群组标签", order = 0)
+    suspend fun groupRem(
+        @Argument(display = "待删除标签", handler = TagArgHandler::class)
+        tag: String
+    ): CmdReceipt {
+        if (tag in BUILTIN_GROUP_TAGS) {
+            context.print("不允许修改内建标签")
+            return FATAL
+        }
+        context.sendMessageWithLog(context.user.getAt() + "是否要删除整个 $tag 标签及其引用(y/n)")
+        return if (context.nextYorN(timeoutMillis = 15000) {
+            context.print("操作超时,")
+            false
+        }) {
+            if (remGroupTag(tag)) {
+                context.print("已移除标签 $tag 及其引用")
+                SUCCESS
+            } else {
+                context.print("标签 $tag 不存在且未被引用")
+                FATAL
+            }
+        } else {
+            context.print("已放弃操作")
+            FATAL
+        }
+    }
+
+    @Method(name = "user-remove", alias = ["ur"], pmsLevel = CmdPermissionLevel.OP, title = "删除用户标签", order = 0)
+    suspend fun userRem(
+        @Argument(display = "待删除标签", handler = TagArgHandler::class)
+        tag: String
+    ): CmdReceipt {
+        if (tag in BUILTIN_USER_TAGS) {
+            context.print("不允许修改内建标签")
+            return FATAL
+        }
+        context.sendMessageWithLog(context.user.getAt() + "是否要删除整个 $tag 标签及其引用(y/n)")
+        return if (context.nextYorN(timeoutMillis = 15000) {
+                context.print("操作超时,")
+                false
+            }) {
+            if (remUserTag(tag)) {
+                context.print("已移除标签 $tag 及其引用")
+                SUCCESS
+            } else {
+                context.print("标签 $tag 不存在且未被引用")
+                FATAL
+            }
+        } else {
+            context.print("已放弃操作")
+            FATAL
+        }
+    }
+
+    @Method(name = "group-remove", alias = ["gr"], pmsLevel = CmdPermissionLevel.OP, title = "移除群组标签", order = 1)
+    fun groupRem(
+        @Argument(display = "被操作标签", handler = TagArgHandler::class)
+        tag: String,
+        @Argument(display = "待移除群组或标签", multiValued = true)
+        itemsIn: ArrayList<String>
+    ): CmdReceipt {
+        if (tag in BUILTIN_GROUP_TAGS) {
+            context.print("不允许修改内建标签")
+            return FATAL
+        }
+        val table = TableBuilder(4)
+        table.th("移除群组标签 —— 标签($tag)")
+        for (i in itemsIn) {
+            if (remFromGroupTag(tag, i.toLowercase()))
+                table.tr().tb(i).tb("[√] success removed")
+            else
+                table.tr().tb(i).tb("[×] tag or item not found")
+        }
+        context.print(table.toString())
+        return SUCCESS
+    }
+
+    @Method(name = "group-remove", alias = ["gr"], pmsLevel = CmdPermissionLevel.OP, title = "移除群组标签", order = 1)
+    fun userRem(
+        @Argument(display = "被操作标签", handler = TagArgHandler::class)
+        tag: String,
+        @Argument(display = "待移除用户或标签", multiValued = true)
+        itemsIn: ArrayList<String>
+    ): CmdReceipt {
+        if (tag in BUILTIN_GROUP_TAGS) {
+            context.print("不允许修改内建标签")
+            return FATAL
+        }
+        val table = TableBuilder(4)
+        table.th("移除用户标签 —— 标签($tag)")
+        for (i in itemsIn) {
+            if (remFromUserTag(tag, i.toLowercase()))
+                table.tr().tb(i).tb("[√] success removed")
+            else
+                table.tr().tb(i).tb("[×] tag or item not found")
+        }
+        context.print(table.toString())
+        return SUCCESS
+    }
+
+    override fun onExit(receipt: CmdReceipt) {
+        if (receipt.needSave()) Tags.saveTags()
+    }
+
+    companion object {
+        private fun tree(
+            tag: String,
+            table: TableBuilder,
+            line: ArrayList<Int> = ArrayList(),
+            tagGetter: (tag: String) -> ArrayList<String>
+        ) {
+            table.tr()
+            line.forEachIndexed { index, l ->
+                if (index < line.lastIndex) {
+                    if (l > 0) table.tb("│")
+                    else table.tb("  ")
+                } else {
+                    if (l > 0) table.tb("├")
+                    else table.tb("└")
+                }
+            }
+            table.tb(tag)
+            val items = tagGetter(tag)
+            items.sortedWith { o1, o2 ->
+                var r = 0
+                if (o1.startsWith('#')) r++
+                if (o2.startsWith('#')) r--
+                r
+            }
+            items.forEachIndexed { index, i ->
+                line.add(items.lastIndex - index)
+
+                tree(i, table, line, tagGetter)
+
+                line.removeLast()
+            }
+        }
+    }
+}

+ 16 - 0
src/main/kotlin/bot_luo_core/cli/exceptions/CheckerFatal.kt

@@ -0,0 +1,16 @@
+package bot_luo_core.cli.exceptions
+
+import bot_luo_core.cli.Checker
+import net.mamoe.mirai.message.data.Message
+import kotlin.reflect.KClass
+
+/**
+ * 检定器检定失败
+ */
+class CheckerFatal(
+    override val output: Message,
+    override val message: String,
+    val checker: KClass<out Checker>
+    ) : CliException() {
+    override val level = CliExceptionLevel.INFO
+}

+ 25 - 0
src/main/kotlin/bot_luo_core/cli/exceptions/CliException.kt

@@ -0,0 +1,25 @@
+package bot_luo_core.cli.exceptions
+
+import net.mamoe.mirai.message.data.Message
+
+/**
+ * # CLI异常基类
+ *
+ * 所有命令行正常解析、执行命令过程中抛出的异常均继承此类
+ */
+abstract class CliException: Exception() {
+    /**
+     * 等级
+     */
+    abstract val level: CliExceptionLevel
+
+    /**
+     * 输出消息,用于错误信息
+     */
+    abstract val output: Message
+
+    /**
+     * 详细信息,用于记录到log
+     */
+    abstract override val message: String
+}

+ 11 - 0
src/main/kotlin/bot_luo_core/cli/exceptions/CliExceptionLevel.kt

@@ -0,0 +1,11 @@
+package bot_luo_core.cli.exceptions
+
+enum class CliExceptionLevel {
+    /**
+     * info是纯粹的返回信息,不会在log中打印堆栈
+     */
+    INFO,
+    WARN,
+    ERROR,
+    DEBUG
+}

+ 13 - 0
src/main/kotlin/bot_luo_core/cli/exceptions/CliInternalError.kt

@@ -0,0 +1,13 @@
+package bot_luo_core.cli.exceptions
+
+import net.mamoe.mirai.message.data.Message
+import net.mamoe.mirai.message.data.toPlainText
+
+/**
+ * 内部错误,写出bug了
+ */
+class CliInternalError(override val output: Message, override val message: String) : CliException() {
+    override val level = CliExceptionLevel.ERROR
+
+    constructor(msg: String): this(msg.toPlainText(), msg)
+}

+ 16 - 0
src/main/kotlin/bot_luo_core/cli/exceptions/CmdExecutingError.kt

@@ -0,0 +1,16 @@
+package bot_luo_core.cli.exceptions
+
+import net.mamoe.mirai.message.data.Message
+import net.mamoe.mirai.message.data.toPlainText
+
+/**
+ * 命令执行错误,通常由执行命令时接受到命令内部抛出的非[CliException]错误转换得到
+ */
+class CmdExecutingError(override val output: Message, override val message: String) : CliException() {
+    override val level = CliExceptionLevel.ERROR
+
+    constructor(e: Throwable): this (
+        "命令执行中发生错误:\n${e::class.simpleName}".toPlainText(),
+        "[ERROR]发生错误:${e.message}"
+            )
+}

+ 16 - 0
src/main/kotlin/bot_luo_core/cli/exceptions/CmdExecutingWarn.kt

@@ -0,0 +1,16 @@
+package bot_luo_core.cli.exceptions
+
+import net.mamoe.mirai.message.data.Message
+import net.mamoe.mirai.message.data.toPlainText
+
+/**
+ * 命令执行错误,通常由执行命令时接受到命令内部抛出的非[CliException]错误转换得到
+ */
+class CmdExecutingWarn(override val output: Message, override val message: String) : CliException() {
+    override val level = CliExceptionLevel.WARN
+
+    constructor(e: Throwable): this (
+        "命令执行中发生意外:\n${e::class.simpleName}".toPlainText(),
+        "[WARN]发生错误:${e.message}"
+    )
+}

+ 93 - 0
src/main/kotlin/bot_luo_core/cli/exceptions/CmdParseFatal.kt

@@ -0,0 +1,93 @@
+package bot_luo_core.cli.exceptions
+
+import net.mamoe.mirai.message.data.Message
+import net.mamoe.mirai.message.data.toPlainText
+import kotlin.reflect.KType
+
+/**
+ * 命令行解析错误
+ */
+abstract class CmdParseFatal: CliException() {
+    override val level = CliExceptionLevel.INFO
+
+    abstract val pos: Int
+}
+
+/**
+ * 无匹配命令
+ */
+class NoCmdFound(input: String, override val pos: Int): CmdParseFatal() {
+    override val output = "无效的命令“$input”".toPlainText()
+    override val message = "无效的命令“$input”  pos:$pos"
+}
+
+/**
+ * 解析器解析失败
+ */
+open class HandlerFatal(
+    input: String,
+    argName: String?,
+    final override val pos: Int,
+    val type: KType?
+) : CmdParseFatal() {
+    override val output: Message = "无法将“${input}”解析为$argName".toPlainText()
+    override val message: String = "无法将“${input}”解析为$argName  pos:$pos  type:${type}"
+}
+
+/**
+ * 语法错误
+ */
+class SyntaxError(override val pos: Int, msg: ()->Message): CmdParseFatal() {
+    override val message: String = msg().toString()
+    override val output: Message = msg()
+
+    constructor(pos: Int, msg: String): this(pos, {msg.toPlainText()})
+}
+
+/**
+ * 缺少上下文
+ */
+class ContextNeeded(override val pos: Int, argName: String?): CmdParseFatal() {
+    override val output: Message = "在解析“$argName”时缺少上下文".toPlainText()
+    override val message: String = "在解析“$argName”时缺少上下文  pos:$pos"
+}
+
+/**
+ * 空标签
+ */
+class EmptyTag(override val pos: Int, tag: String): CmdParseFatal() {
+    override val output = "空的标签“$tag”".toPlainText()
+    override val message = "空的标签“$tag”  pos:$pos"
+}
+
+/**
+ * 标签解析出多值
+ */
+class TagMultiValued(override val pos: Int, tag: String): CmdParseFatal() {
+    override val output = "标签“$tag”指向多个对象".toPlainText()
+    override val message = "标签“$tag”指向多个对象  pos:$pos"
+}
+
+/**
+ * 缺少必要参数
+ */
+class NoMoreArg(override val pos: Int, argFormat: String): CmdParseFatal() {
+    override val output = "缺少参数$argFormat".toPlainText()
+    override val message = "缺少参数$argFormat  pos:$pos"
+}
+
+/**
+ * 未完成的内容
+ */
+class UncompletedContent(override val pos: Int, argName: String?, detail: String): CmdParseFatal() {
+    override val output = "参数“$argName”未完成:$detail".toPlainText()
+    override val message = "参数“$argName”未完成:$detail  pos:$pos"
+}
+
+/**
+ * 多余参数
+ */
+class SurplusArg(override val pos: Int, input: String?): CmdParseFatal() {
+    override val output = "多余的参数“$input”".toPlainText()
+    override val message = "多余的参数“$input”  pos:$pos"
+}

+ 35 - 0
src/main/kotlin/bot_luo_core/cli/handlers/CmdExArgHandler.kt

@@ -0,0 +1,35 @@
+package bot_luo_core.cli.handlers
+
+import bot_luo_core.cli.*
+import bot_luo_core.cli.exceptions.HandlerFatal
+import bot_luo_core.data.Config.CMD_PREFIX
+import kotlin.reflect.KType
+
+class CmdExArgHandler: ArgHandler<List<CmdExecutable>> {
+
+    override val name = "命令方法参数解析器"
+    override val multiValued = true
+
+    override fun handle(
+        reader: MessageReader,
+        pos: Int,
+        argName: String?,
+        type: KType?,
+        context: CmdContext?
+    ): List<CmdExecutable> {
+        val cmdName: String
+        val methName: String
+        if (reader.peek() in CMD_PREFIX) reader.skip()
+        if (reader.isWhitespace(reader.peek())) reader.skip()
+        val head = reader.readStringUntilWhiteSpace()
+        val index = head.indexOf("-")
+        if (index == -1) {
+            cmdName = head
+            methName = "*"
+        } else {
+            cmdName = head.substring(0,index)
+            methName = head.substring(index+1)
+        }
+        return CmdCatalog.matchCmdExOrNull(cmdName, methName)?: throw HandlerFatal(head, argName, pos, type)
+    }
+}

+ 27 - 0
src/main/kotlin/bot_luo_core/cli/handlers/CmdIdArgHandler.kt

@@ -0,0 +1,27 @@
+package bot_luo_core.cli.handlers
+
+import bot_luo_core.cli.ArgHandler
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.MessageReader
+import kotlin.reflect.KType
+
+class CmdIdArgHandler: ArgHandler<ArrayList<String>> {
+
+    override val multiValued = true
+
+    override val name = "命令ID解析器"
+
+    override fun handle(
+        reader: MessageReader,
+        pos: Int,
+        argName: String?,
+        type: KType?,
+        context: CmdContext?
+    ): ArrayList<String> {
+        val res = ArrayList<String>()
+        CmdExArgHandler().handle(reader, pos, argName, type).forEach{
+            if (!res.contains(it.id)) res.add(it.id)
+        }
+        return res  //由于CmdExArgHandler返回结果一定非空,因此不用检查
+    }
+}

+ 41 - 0
src/main/kotlin/bot_luo_core/cli/handlers/DefaultArgHandler.kt

@@ -0,0 +1,41 @@
+package bot_luo_core.cli.handlers
+
+import bot_luo_core.cli.ArgHandler
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.MessageReader
+import bot_luo_core.cli.exceptions.HandlerFatal
+import java.util.*
+import kotlin.reflect.KType
+
+/**
+ * # 默认参数解析器
+ *
+ * 根据参数的类型自动处理以下类型
+ * - [String]
+ * - [Int]
+ * - [Long]
+ * - [Float]
+ * - [Double]
+ * - [Boolean]
+ *
+ * 当给定的类型不在以上范围内时读取String并返回
+ */
+class DefaultArgHandler: ArgHandler<Any> {
+
+    override val name = "默认参数解析器"
+
+    override fun handle(reader: MessageReader, pos: Int, argName: String?, type: KType?, context: CmdContext?): Any {
+        if (type == null) return reader.readString()
+        val input = reader.readString()
+        return when (type.classifier) {
+            String::class -> input
+            Int::class -> input.toIntOrNull()?:throw HandlerFatal(input, argName, pos, type)
+            Long::class -> input.toLongOrNull()?:throw HandlerFatal(input, argName, pos, type)
+            Float::class -> input.toFloatOrNull()?:throw HandlerFatal(input, argName, pos, type)
+            Double::class -> input.toDoubleOrNull()?:throw HandlerFatal(input, argName, pos, type)
+            Boolean::class -> input.lowercase(Locale.getDefault()).toBooleanStrictOrNull()?:throw HandlerFatal(input, argName, pos, type)
+            else -> throw HandlerFatal(input, argName, pos, type)
+        }
+    }
+
+}

+ 27 - 0
src/main/kotlin/bot_luo_core/cli/handlers/GreedyMessageArgHandler.kt

@@ -0,0 +1,27 @@
+package bot_luo_core.cli.handlers
+
+import bot_luo_core.cli.ArgHandler
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.MessageReader
+import bot_luo_core.cli.exceptions.HandlerFatal
+import net.mamoe.mirai.message.data.MessageChain
+import net.mamoe.mirai.message.data.content
+import net.mamoe.mirai.message.data.isContentEmpty
+import kotlin.reflect.KType
+
+class GreedyMessageArgHandler: ArgHandler<MessageChain> {
+
+    override val name = "贪婪消息参数解析器"
+
+    override fun handle(
+        reader: MessageReader,
+        pos: Int,
+        argName: String?,
+        type: KType?,
+        context: CmdContext?
+    ): MessageChain {
+        val msg = reader.readRemainingMessage()
+        if (msg.isContentEmpty()) throw HandlerFatal(msg.content, argName, pos, type)
+        else return msg
+    }
+}

+ 19 - 0
src/main/kotlin/bot_luo_core/cli/handlers/GreedyStringArgHandler.kt

@@ -0,0 +1,19 @@
+package bot_luo_core.cli.handlers
+
+import bot_luo_core.cli.ArgHandler
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.MessageReader
+import bot_luo_core.cli.exceptions.HandlerFatal
+import kotlin.reflect.KType
+
+class GreedyStringArgHandler: ArgHandler<String> {
+
+    override val name = "贪婪字符串参数解析器"
+
+    override fun handle(reader: MessageReader, pos: Int, argName: String?, type: KType?, context: CmdContext?): String {
+        val input = reader.readRemaining()
+        if (input.isNotBlank()) return input
+        else throw HandlerFatal(input, argName, pos,  type)
+    }
+
+}

+ 36 - 0
src/main/kotlin/bot_luo_core/cli/handlers/GroupArgHandler.kt

@@ -0,0 +1,36 @@
+package bot_luo_core.cli.handlers
+
+import bot_luo_core.cli.ArgHandler
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.MessageReader
+import bot_luo_core.cli.exceptions.ContextNeeded
+import bot_luo_core.cli.exceptions.EmptyTag
+import bot_luo_core.cli.exceptions.HandlerFatal
+import bot_luo_core.cli.exceptions.TagMultiValued
+import bot_luo_core.data.Group
+import bot_luo_core.data.Groups
+import bot_luo_core.data.Tags
+import kotlin.reflect.KType
+
+class GroupArgHandler: ArgHandler<Group> {
+
+    override val name = "群组参数解析器"
+
+    override fun handle(reader: MessageReader, pos: Int, argName: String?, type: KType?, context: CmdContext?): Group {
+        reader.skipWhitespace()
+
+        if (reader.canRead() && reader.peek() == '#') {
+            val tag = reader.readString()
+            val res = Tags.readGroupTag(tag, context) ?: throw ContextNeeded(pos, argName)
+            when (res.size) {
+                0 -> throw EmptyTag(pos, tag)
+                1 -> return  res[0]
+                else -> throw TagMultiValued(pos, tag)
+            }
+        }
+
+        val input = reader.readString()
+        val id = input.toLongOrNull()?: throw HandlerFatal(input, argName, pos, type)
+        return Groups.readGroup(id)
+    }
+}

+ 17 - 0
src/main/kotlin/bot_luo_core/cli/handlers/ImageArgHandler.kt

@@ -0,0 +1,17 @@
+package bot_luo_core.cli.handlers
+
+import bot_luo_core.cli.ArgHandler
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.MessageReader
+import bot_luo_core.cli.exceptions.HandlerFatal
+import net.mamoe.mirai.message.data.Image
+import net.mamoe.mirai.message.data.content
+import kotlin.reflect.KType
+
+class ImageArgHandler: ArgHandler<Image> {
+    override val name: String = "图片参数解析器"
+
+    override fun handle(reader: MessageReader, pos: Int, argName: String?, type: KType?, context: CmdContext?): Image {
+        return reader.readImage()?: throw HandlerFatal(reader.readMessage().content, argName, pos, type)
+    }
+}

+ 47 - 0
src/main/kotlin/bot_luo_core/cli/handlers/MultiGroupArgHandler.kt

@@ -0,0 +1,47 @@
+package bot_luo_core.cli.handlers
+
+import bot_luo_core.cli.ArgHandler
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.MessageReader
+import bot_luo_core.cli.exceptions.ContextNeeded
+import bot_luo_core.cli.exceptions.EmptyTag
+import bot_luo_core.cli.exceptions.HandlerFatal
+import bot_luo_core.cli.exceptions.SyntaxError
+import bot_luo_core.data.*
+import net.mamoe.mirai.message.data.toPlainText
+import java.util.*
+import kotlin.collections.ArrayList
+import kotlin.reflect.KType
+
+/**
+ * 可以处理群号,以#开头的标签
+ */
+class MultiGroupArgHandler: ArgHandler<ArrayList<Group>> {
+
+    override val name = "多群组参数解析器"
+
+    override val multiValued = true
+
+    override fun handle(
+        reader: MessageReader,
+        pos: Int,
+        argName: String?,
+        type: KType?,
+        context: CmdContext?
+    ): ArrayList<Group> {
+        reader.skipWhitespace()
+
+        if (reader.canRead() && reader.peek() == '#') {
+            val tag = reader.readString()
+            val res = Tags.readGroupTag(tag, context) ?: throw ContextNeeded(pos, argName)
+            if (res.isNotEmpty())
+                return res
+            else
+                throw EmptyTag(pos, tag)
+        }
+
+        val input = reader.readString()
+        val id = input.toLongOrNull() ?: throw HandlerFatal(input, argName, pos, type)
+        return arrayListOf(Groups.readGroup(id))
+    }
+}

+ 51 - 0
src/main/kotlin/bot_luo_core/cli/handlers/MultiUserArgHandler.kt

@@ -0,0 +1,51 @@
+package bot_luo_core.cli.handlers
+
+import bot_luo_core.cli.ArgHandler
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.MessageReader
+import bot_luo_core.cli.exceptions.ContextNeeded
+import bot_luo_core.cli.exceptions.EmptyTag
+import bot_luo_core.cli.exceptions.HandlerFatal
+import bot_luo_core.cli.exceptions.SyntaxError
+import bot_luo_core.data.Tags
+import bot_luo_core.data.User
+import bot_luo_core.data.Users
+import net.mamoe.mirai.message.data.toPlainText
+import java.util.*
+import kotlin.collections.ArrayList
+import kotlin.reflect.KType
+
+/**
+ * 可以处理QQ号,At,以#开头的标签
+ */
+class MultiUserArgHandler: ArgHandler<ArrayList<User>> {
+
+    override val name = "多用户参数解析器"
+
+    override val multiValued = true
+
+    override fun handle(
+        reader: MessageReader,
+        pos: Int,
+        argName: String?,
+        type: KType?,
+        context: CmdContext?
+    ): ArrayList<User> {
+        reader.skipWhitespace()
+        val at = reader.readAt()
+        if (at != null) return arrayListOf(Users.readUser(at.target))
+
+        if (reader.canRead() && reader.peek() == '#') {
+            val tag = reader.readString()
+            val res = Tags.readUserTag(tag, context) ?: throw ContextNeeded(pos, argName)
+            if (res.isNotEmpty())
+                return res
+            else
+                throw EmptyTag(pos, tag)
+        }
+
+        val input = reader.readString()
+        val id = input.toLongOrNull() ?: throw HandlerFatal(input, argName, pos, type)
+        return arrayListOf(Users.readUser(id))
+    }
+}

+ 25 - 0
src/main/kotlin/bot_luo_core/cli/handlers/PmsGroupArgHandler.kt

@@ -0,0 +1,25 @@
+package bot_luo_core.cli.handlers
+
+import bot_luo_core.cli.ArgHandler
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.MessageReader
+import bot_luo_core.cli.exceptions.HandlerFatal
+import bot_luo_core.data.PmsGroup
+import bot_luo_core.data.PmsGroups
+import kotlin.reflect.KType
+
+class PmsGroupArgHandler: ArgHandler<PmsGroup> {
+
+    override val name = "权限组参数解析器"
+
+    override fun handle(
+        reader: MessageReader,
+        pos: Int,
+        argName: String?,
+        type: KType?,
+        context: CmdContext?
+    ): PmsGroup {
+        val str = reader.readString()
+        return PmsGroups.getPmsGroupOrNull(str)?: throw HandlerFatal(str, argName, pos, type)
+    }
+}

+ 22 - 0
src/main/kotlin/bot_luo_core/cli/handlers/RegexArgHandler.kt

@@ -0,0 +1,22 @@
+package bot_luo_core.cli.handlers
+
+import bot_luo_core.cli.ArgHandler
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.MessageReader
+import bot_luo_core.cli.exceptions.HandlerFatal
+import java.util.regex.PatternSyntaxException
+import kotlin.reflect.KType
+
+class RegexArgHandler: ArgHandler<Regex> {
+
+    override val name = "正则表达式参数解析器"
+
+    override fun handle(reader: MessageReader, pos: Int, argName: String?, type: KType?, context: CmdContext?): Regex {
+        val input = reader.readString()
+        try {
+            return Regex(input)
+        } catch (e: PatternSyntaxException) {
+            throw HandlerFatal(input, argName, pos, type)
+        }
+    }
+}

+ 19 - 0
src/main/kotlin/bot_luo_core/cli/handlers/TagArgHandler.kt

@@ -0,0 +1,19 @@
+package bot_luo_core.cli.handlers
+
+import bot_luo_core.cli.ArgHandler
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.MessageReader
+import bot_luo_core.cli.exceptions.SyntaxError
+import java.util.*
+import kotlin.reflect.KType
+
+class TagArgHandler: ArgHandler<String> {
+
+    override val name = "标签参数处理器"
+
+    override fun handle(reader: MessageReader, pos: Int, argName: String?, type: KType?, context: CmdContext?): String {
+        val tag = reader.readString()
+        if (!Regex("#[a-zA-Z0-9_-]+").matches(tag)) throw SyntaxError(pos, "标签结构错误或包含非法字符")
+        return tag.lowercase(Locale.getDefault())
+    }
+}

+ 68 - 0
src/main/kotlin/bot_luo_core/cli/handlers/TimeArgHandler.kt

@@ -0,0 +1,68 @@
+package bot_luo_core.cli.handlers
+
+import bot_luo_core.cli.ArgHandler
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.MessageReader
+import bot_luo_core.cli.exceptions.ContextNeeded
+import bot_luo_core.cli.exceptions.HandlerFatal
+import bot_luo_core.cli.exceptions.SyntaxError
+import bot_luo_core.util.Time
+import java.text.ParseException
+import java.util.*
+import kotlin.reflect.KType
+
+class TimeArgHandler: ArgHandler<Long> {
+
+    override val name = "时间参数解析器"
+
+    override fun handle(reader: MessageReader, pos: Int, argName: String?, type: KType?, context: CmdContext?): Long {
+        if (reader.canRead() && reader.peek()=='~') {
+            context?: throw ContextNeeded(pos, "~")
+            reader.skip()
+            val calendar = Calendar.getInstance(TimeZone.getTimeZone("GMT+08:00"))
+            val back = reader.peek() == '-'
+            if (reader.peek() in "-+") reader.skip()
+            var i = 0
+            do {
+                val c = reader.read()
+                when (c) {
+                    in '0'..'9' -> {
+                        i *= 10
+                        i += c - '0'
+                    }
+                    'd' -> {
+                        if (back) i *= -1
+                        calendar.add(Calendar.DAY_OF_MONTH,i)
+                        i = 0
+                    }
+                    'h' -> {
+                        if (back) i *= -1
+                        calendar.add(Calendar.HOUR_OF_DAY,i)
+                        i = 0
+                    }
+                    'm' -> {
+                        if (back) i *= -1
+                        calendar.add(Calendar.MINUTE,i)
+                        i = 0
+                    }
+                    's' -> {
+                        if (back) i *= -1
+                        calendar.add(Calendar.SECOND,i)
+                        i = 0
+                    }
+                    else -> throw SyntaxError(pos,"无效的时间单位 $c")
+                }
+            } while (reader.canRead() && !Character.isWhitespace(reader.peek()))
+            if (i != 0) throw SyntaxError(pos,"$i 缺少单位")
+            return calendar.timeInMillis
+        } else {
+            val str = reader.readString()
+            try {
+                return Time.parse(str)
+            } catch (e: ParseException) {
+                throw HandlerFatal(str, argName, pos, type)
+            }
+
+        }
+    }
+}

+ 41 - 0
src/main/kotlin/bot_luo_core/cli/handlers/UserArgHandler.kt

@@ -0,0 +1,41 @@
+package bot_luo_core.cli.handlers
+
+import bot_luo_core.cli.ArgHandler
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.MessageReader
+import bot_luo_core.cli.exceptions.ContextNeeded
+import bot_luo_core.cli.exceptions.EmptyTag
+import bot_luo_core.cli.exceptions.HandlerFatal
+import bot_luo_core.cli.exceptions.TagMultiValued
+import bot_luo_core.data.Tags
+import bot_luo_core.data.User
+import bot_luo_core.data.Users
+import kotlin.reflect.KType
+
+/**
+ * 可以处理QQ号,At,和以#开头的单值标签
+ */
+class UserArgHandler: ArgHandler<User> {
+
+    override val name = "用户参数解析器"
+
+    override fun handle(reader: MessageReader, pos: Int, argName: String?, type: KType?, context: CmdContext?): User {
+        reader.skipWhitespace()
+        val at = reader.readAt()
+        if (at != null) return Users.readUser(at.target)
+
+        if (reader.canRead() && reader.peek() == '#') {
+            val tag = reader.readString()
+            val res = Tags.readUserTag(tag, context) ?: throw ContextNeeded(pos, argName)
+            when (res.size) {
+                0 -> throw EmptyTag(pos, tag)
+                1 -> return  res[0]
+                else -> throw TagMultiValued(pos, tag)
+            }
+        }
+
+        val input = reader.readString()
+        val id = input.toLongOrNull() ?: throw HandlerFatal(input, argName, pos, type)
+        return Users.readUser(id)
+    }
+}

+ 9 - 0
src/main/kotlin/bot_luo_core/data/CmdData.kt

@@ -0,0 +1,9 @@
+package bot_luo_core.data
+
+data class CmdData(
+    var lastTime: Long,
+    var totalCount: Int,
+    var dayCount: Int,
+    var specialCount: Int,
+    var working: Boolean
+)

+ 45 - 0
src/main/kotlin/bot_luo_core/data/CmdDataObj.kt

@@ -0,0 +1,45 @@
+package bot_luo_core.data
+
+import bot_luo_core.cli.CmdExecutable
+import kotlinx.atomicfu.AtomicInt
+import kotlinx.atomicfu.atomic
+
+abstract class CmdDataObj(filePath: String): DataObj(filePath) {
+
+    private val cmdData: HashMap<String, CmdData> =  getObj("cmdData")?: HashMap()
+    private val cmdHandling = HashMap<CmdExecutable, AtomicInt>()
+
+    abstract val defaultData: CmdData
+
+    fun cmdOnExecute(cmd: CmdExecutable) {
+        if (cmdHandling[cmd] == null) cmdHandling[cmd] = atomic(0)
+        cmdHandling[cmd]!!.addAndGet(1)
+    }
+
+    fun cmdFinished(cmd: CmdExecutable) {
+        cmdHandling[cmd]!!.addAndGet(-1)
+    }
+
+    fun isCmdFree(cmd: CmdExecutable, accurately: Boolean = false): Boolean {
+        return if (accurately) cmdHandling[cmd]?.value?.equals(0) ?: true
+        else cmdHandling.filterKeys { it.cmdName == cmd.cmdName }.all {
+            it.value.value == 0
+        }
+    }
+
+    open fun readCmdData(id: String): CmdData {
+        return cmdData[id]?: defaultData
+    }
+
+    open fun readCmdData(cmd: CmdExecutable) = readCmdData(cmd.id)
+
+    /**
+     * 写入命令数据,自动存入jsonObj
+     */
+    open fun writeCmdData(id: String, data: CmdData) {
+        cmdData[id] = data
+        setObj("cmdData", cmdData)
+    }
+
+    open fun writeCmdData(cmd: CmdExecutable, data: CmdData) = writeCmdData(cmd.id, data)
+}

+ 6 - 0
src/main/kotlin/bot_luo_core/data/Config.kt

@@ -0,0 +1,6 @@
+package bot_luo_core.data
+
+object Config {
+    val CMD_PREFIX = arrayOf('.','。','/')
+    val REP_YES = arrayOf("y","ok","yes","是","好","对","嗯","好的","是的","没错")
+}

+ 207 - 0
src/main/kotlin/bot_luo_core/data/DataObj.kt

@@ -0,0 +1,207 @@
+package bot_luo_core.data
+
+import com.alibaba.fastjson.JSON
+import com.alibaba.fastjson.JSONObject
+import com.alibaba.fastjson.TypeReference
+import kotlinx.atomicfu.atomic
+import kotlinx.coroutines.*
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.io.File
+
+/**
+ * # 数据对象类
+ *
+ * 继承[Mutex]
+ *
+ * 建议使用[withAccessing]进行读访问,使用[withLockedAccessing]进行读写访问
+ */
+abstract class DataObj(
+    /**
+     * JSON数据文件路径
+     */
+    private val filePath: String
+): Mutex by Mutex() {
+
+    /**
+     * 使用[getObj]和[setObj]进行访问
+     */
+    val jsonObj: JSONObject
+
+    /**
+     * 自动保存间隔时间(ms)
+     *
+     * 在访问空闲超过间隔后自动保存
+     */
+    abstract val autoSaveInterval: Long
+
+    /**
+     * 是否自动卸载
+     *
+     * 为`true`时将把读取数据算作访问,且在自动保存后调用[unload]方法
+     */
+    abstract val saveAndUnload: Boolean
+
+    private var saveJob: Job? = null
+    private val context = Dispatchers.IO
+
+    /**
+     * 访问数量
+     *
+     * 以此为依据判断是否在活跃,当访问数归零时开始计时尝试保存或卸载
+     *
+     * 并不能确保线程安全,仅保证保存的数据有效
+     *
+     * 使用[access]和[]方法更新
+     */
+    private var accessing = atomic(0)
+    private var changed = false
+
+    /**
+     * 进行访问,[accessing]加一
+     *
+     * @see withAccessing
+     * @see withLockedAccessing
+     */
+    fun access() {
+        accessing.addAndGet(1)
+        saveJob?.cancel()
+        saveJob = null
+    }
+
+    /**
+     * 退出访问,[accessing]减一
+     *
+     * @see withAccessing
+     * @see withLockedAccessing
+     */
+    fun exit() {
+        if ( accessing.addAndGet(-1) == 0 ) {
+            launchSaveJob()
+        }
+    }
+
+    /**
+     * 保存兼卸载任务
+     */
+    private fun launchSaveJob() {
+        saveJob?.cancel()
+        saveJobs.remove(saveJob)
+        saveJob = CoroutineScope(context).launch {
+            try {
+                delay(autoSaveInterval)
+            } catch (ignore: CancellationException) {
+                return@launch
+            }
+            if (changed) save()
+            if (saveAndUnload) unload()
+        }.apply {
+            saveJobs.add(this)
+            this.invokeOnCompletion { saveJobs.remove(this) }
+        }
+    }
+
+    init {
+        val file = File(filePath)
+        jsonObj = if (file.exists())
+            JSON.parseObject(file.readText().ifBlank { "{}" })
+        else
+            JSONObject(HashMap())
+        launchSaveJob()
+    }
+
+    /**
+     * 保存前需将有变动的数据使用[setObj]存入[jsonObj]
+     *
+     * 会自动创建不存在的文件和文件夹
+     */
+    fun save() {
+        val file = File(filePath)
+        if (!file.exists()) {
+            if (!file.parentFile.exists()) file.parentFile.mkdirs()
+            file.createNewFile()
+        }
+        file.writeText(JSON.toJSONString(jsonObj, true))
+        changed = false
+    }
+
+    /**
+     * 移除数据对象
+     *
+     * 确保卸载时数据已使用[save]存入磁盘
+     */
+    abstract fun unload()
+
+    /**
+     * 是否已有记录文件存在
+     */
+    fun exists() = File(filePath).exists()
+
+    /**
+     * 读取数据,若不存在则返回`null`
+     *
+     * 自动更新访问时间
+     */
+    inline fun <reified T> getObj(key: String): T? {
+        return if (jsonObj.containsKey(key)) {
+            val tr = object : TypeReference<T>() {}
+            jsonObj.getObject(key, tr)
+        } else null
+    }
+    inline fun <reified T: Map<*, *>> getObj(): T? {
+        val tr = object : TypeReference<T>() {}
+        return jsonObj.toJavaObject(tr)
+    }
+
+    /**
+     * 写入数据,不触发保存文件
+     *
+     * 此方法应在[withAccessing]或[withLockedAccessing]代码块中使用
+     *
+     * 自动设置被更改状态
+     *
+     * @see withAccessing
+     * @see withLockedAccessing
+     */
+    fun <T> setObj(key: String, value: T?) {
+        jsonObj[key] = value
+        changed = true
+    }
+    fun <T: Map<out String, *>> setObj(value: T?) {
+        jsonObj.fluentClear().fluentPutAll(value)
+        changed = true
+    }
+
+    companion object {
+        val saveJobs = ArrayList<Job>()
+    }
+
+}
+
+/**
+ * 用于记录[DataObj]使用状况,实现自动保存
+ *
+ * 此方法不对数据加锁
+ */
+inline fun <T> withAccessing(vararg obj: DataObj, action: ()->T): T {
+    obj.forEach { it.access() }
+    try {
+        return action()
+    } finally {
+        obj.forEach { it.exit() }
+    }
+}
+
+/**
+ * 用于记录[DataObj]使用状况,实现自动保存
+ *
+ * 此方法带锁,使用时需避免锁死
+ */
+suspend inline fun <T> withLockedAccessing(vararg obj: DataObj, owner: Any? = null, action: ()->T): T {
+    obj.forEach { it.lock(owner); it.access() }
+    try {
+        return action()
+    } finally {
+        obj.forEach { it.exit(); it.unlock(owner) }
+    }
+}

+ 49 - 0
src/main/kotlin/bot_luo_core/data/Group.kt

@@ -0,0 +1,49 @@
+package bot_luo_core.data
+
+import bot_luo_core.bot.BotContact
+import bot_luo_core.bot.BotContactType
+import bot_luo_core.bot.MultiBotHandler
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.contact.Group
+import net.mamoe.mirai.contact.MemberPermission
+import net.mamoe.mirai.message.data.Message
+
+/**
+ * # 群组类
+ *
+ * 使用[Groups.readGroup]或[Groups.readGroupOrNull]方法获取实例
+ */
+class Group internal constructor(
+    override val id: Long,
+    override var bot: Bot? = null
+) : CmdDataObj("data/groups/$id.json"), BotContact {
+    override val contactType = BotContactType.Group
+    override val defaultData: CmdData
+        get() = CmdData(0,0,0,0,false)//群组命令默认关闭
+    override val autoSaveInterval = 30000L
+    override val saveAndUnload = true
+
+    val virtual: Boolean = id==0L
+
+    override fun unload() {
+        Groups.remove(this)
+    }
+
+    val name: String
+        get() = (contact as Group?)?.name ?: getObj("name") ?: ""
+
+    var pmsGroup: PmsGroup
+        get() = PmsGroups.getPmsGroup(getObj("pmsGroup")?: "NONE")
+        set(value) = setObj("pmsGroup", value.name)
+
+    inline fun withServeBot(pms: MemberPermission = MemberPermission.MEMBER, action: (contact: Group)->Unit): Boolean {
+        if (virtual) return false
+        val contact = if (bot == null) MultiBotHandler.getContactableBots(this).map { it.getGroup(id) }.filter { it != null && it.botPermission >= pms }.randomOrNull()
+        else bot?.getGroup(id)
+        return if (contact == null) false
+        else {
+            action(contact)
+            true
+        }
+    }
+}

+ 35 - 0
src/main/kotlin/bot_luo_core/data/Groups.kt

@@ -0,0 +1,35 @@
+package bot_luo_core.data
+
+object Groups {
+
+    private val groups = HashMap<Long, Group>()
+    val activeGroupsCount: Int get() = groups.size
+    val virtualGroup: Group get() = readGroup(0)
+
+    fun readGroup(id: Long): Group {
+        if (id !in groups.keys) {
+            groups[id] = Group(id)
+        }
+        return groups[id]!!
+    }
+
+    fun readGroupOrNull(id: Long): Group? {
+        return if (id in groups.keys) {
+            groups[id]
+        } else {
+            val group = Group(id)
+            if (group.exists()) {
+                groups[id] = group
+                group
+            } else null
+        }
+    }
+
+    fun saveAll() {
+            groups.values.forEach { it.save() }
+    }
+
+    fun remove(group: Group) {
+            groups.remove(group.id)
+    }
+}

+ 13 - 0
src/main/kotlin/bot_luo_core/data/PmsGroup.kt

@@ -0,0 +1,13 @@
+package bot_luo_core.data
+
+import bot_luo_core.cli.CmdPermissionLevel
+
+data class PmsGroup (
+    val name: String,
+    val description: String,
+    /**
+     * 基础值,填写[CmdPermissionLevel]的名称或结构为'@'+其他权限组名称的链接
+     */
+    val base: String,
+    val modify: HashMap<String, CmdPermissionLevel>?
+)

+ 66 - 0
src/main/kotlin/bot_luo_core/data/PmsGroups.kt

@@ -0,0 +1,66 @@
+package bot_luo_core.data
+
+import bot_luo_core.cli.CmdExecutable
+import bot_luo_core.cli.CmdPermissionLevel
+import bot_luo_core.util.JsonWorker
+
+object PmsGroups {
+
+    private val pmsGroups = HashMap<String, PmsGroup>()
+    private const val filePath = "data/pms_groups.json"
+
+    private const val DESC_NONE = "虚空组,无任何命令权限"
+    private const val DESC_NORMAL = "普通组,拥有执行最低级命令的权限"
+    private const val DESC_HIGH = "高级组,拥有执行较高级命令的权限"
+    private const val DESC_OP = "管理组,拥有执行管理命令的权限"
+    private const val DESC_DEBUG = "调试组,拥有执行调试中命令的权限"
+
+    init {
+        pmsGroups["NONE"] = PmsGroup("NONE", DESC_NONE, CmdPermissionLevel.NONE.name, null)
+        pmsGroups["NORMAL"] = PmsGroup("NORMAL", DESC_NORMAL, CmdPermissionLevel.NORMAL.name, null)
+        pmsGroups["HIGH"] = PmsGroup("HIGH", DESC_HIGH, CmdPermissionLevel.HIGH.name, null)
+        pmsGroups["OP"] = PmsGroup("OP", DESC_OP, CmdPermissionLevel.OP.name, null)
+        pmsGroups["DEBUG"] = PmsGroup("DEBUG", DESC_DEBUG, CmdPermissionLevel.DEBUG.name, null)
+        JsonWorker.readJson<ArrayList<PmsGroup>?>(filePath)?.forEach { pmsGroups[it.name] = it }
+    }
+
+    fun getPmsGroupOrNull(name: String) = pmsGroups[name]
+    fun getPmsGroup(name: String) = pmsGroups[name]?: pmsGroups["NONE"]!!
+
+    fun PmsGroup.readPmsOn(id: String): CmdPermissionLevel {
+        return this.modify?.get(id)?: if (this.base.startsWith("@")) {
+            getPmsGroup(base.substring(1)).readPmsOn(id)
+        } else CmdPermissionLevel(this.base)
+    }
+    fun PmsGroup.readPmsOn(cmd: CmdExecutable) = readPmsOn(cmd.id)
+
+    /**
+     * 设置权限组
+     *
+     * 使用前应先使用[isCyclingRef]检查是否出现循环引用
+     */
+    fun setPmsGroup(pmsGroup: PmsGroup) {
+        pmsGroups[pmsGroup.name] = pmsGroup
+        save()
+    }
+
+    /**
+     * 检查是否出现循环引用
+     *
+     * 出现循环引用返回`true`
+     */
+    fun isCyclingRef(pmsGroup: PmsGroup): Boolean {
+        var p = pmsGroup
+        while (p.base.startsWith('@')) {
+            val b = p.base.substring(1)
+            if (b == pmsGroup.name) return true
+            else p = getPmsGroup(b)
+        }
+        return false
+    }
+
+    private fun save() {
+        JsonWorker.writeJson(filePath, pmsGroups.values)
+    }
+
+}

+ 191 - 0
src/main/kotlin/bot_luo_core/data/Tags.kt

@@ -0,0 +1,191 @@
+package bot_luo_core.data
+
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.exceptions.ContextNeeded
+import bot_luo_core.util.JsonWorker
+import java.util.*
+import kotlin.collections.ArrayList
+import kotlin.collections.HashMap
+
+object Tags {
+    private const val userFilePath = "data/user_tags.json"
+    private const val groupFilePath = "data/group_tags.json"
+
+    val userTag = HashMap<String, ArrayList<String>>()
+    val groupTag = HashMap<String, ArrayList<String>>()
+
+    val BUILTIN_GROUP_TAGS = listOf("#this", "#none")
+    val BUILTIN_USER_TAGS = listOf("#this")
+
+    init {
+        loadTags()
+    }
+
+    /**
+     * 加载标签文件
+     */
+    fun loadTags() {
+        val ut: HashMap<String,ArrayList<String>> = JsonWorker.readJson(userFilePath)?: HashMap()
+        userTag.clear()
+        userTag.putAll(ut)
+
+        val gt: HashMap<String,ArrayList<String>> = JsonWorker.readJson(groupFilePath)?: HashMap()
+        userTag.clear()
+        userTag.putAll(gt)
+    }
+
+    /**
+     * 保存标签文件
+     */
+    fun saveTags() {
+        JsonWorker.writeJson(userFilePath, userTag)
+        JsonWorker.writeJson(groupFilePath, groupTag)
+    }
+
+    /**
+     * 从标签读取群组列表
+     *
+     * 标签为'#'开头的字符串,大小写不敏感
+     *
+     * "#none"指向私聊虚拟群组(0)
+     *
+     * @return 群组[ArrayList]?,标签无效则返回空列表,缺少环境时返回`null`
+     */
+    fun readGroupTag(tag: String, context: CmdContext?): ArrayList<Group>? {
+        val res1 = ArrayList<String>()
+        fun readGroupTag1(tag: String, result: ArrayList<String>) {
+            if (tag in BUILTIN_GROUP_TAGS) {
+                result.add(tag)
+            } else if (groupTag.containsKey(tag)) {
+                for (item in groupTag[tag]!!) {
+                    if (item !in result)
+                        if (item.startsWith('#')) {
+                            readGroupTag1(item, result)
+                        } else {
+                            result.add(item)
+                        }
+                }
+            }
+        }
+        readGroupTag1(tag.lowercase(Locale.getDefault()).trim(), res1)
+        val res = ArrayList<Group>()
+        res.addAll(res1.mapNotNull { it.toLongOrNull() }.map { Groups.readGroup(it) })
+        if (res1.contains("#this")) {
+            if (context == null) return null
+            else res.add(context.group)
+        }
+        if (res1.contains("#none")) res.add(Groups.virtualGroup)
+        return res
+    }
+
+    fun readGroupTag(tag: String) = readGroupTag(tag,null)
+
+    /**
+     * 从标签读取用户列表
+     *
+     * 标签为'#'开头的字符串,大小写不敏感
+     *
+     * @return 用户[ArrayList]?,标签无效则返回空列表,缺少环境时返回`null`
+     */
+    fun readUserTag(tag: String, context: CmdContext?): ArrayList<User>? {
+        val res1 = ArrayList<String>()
+        fun readUserTag1(tag: String, result: ArrayList<String>) {
+            if (tag in BUILTIN_USER_TAGS) {
+                result.add(tag)
+            } else if (userTag.containsKey(tag)) {
+                for (item in userTag[tag]!!) {
+                    if (item !in result)
+                        if (item.startsWith('#')) {
+                            readUserTag1(item, result)
+                        } else {
+                            result.add(item)
+                        }
+                }
+            }
+        }
+        readUserTag1(tag.lowercase(Locale.getDefault()).trim(), res1)
+        val res = ArrayList<User>()
+        res.addAll(res1.mapNotNull { it.toLongOrNull() }.map { Users.readUser(it) })
+        if (res1.contains("#this")) {
+            if (context == null) return null
+            else res.add(context.user)
+        }
+        return res
+    }
+
+    fun readUserTag(tag: String) = readUserTag(tag,null)
+
+    fun addToGroupTag(tag: String, item: String) = addToTag(groupTag, tag, item)
+
+    fun addToUserTag(tag: String, item: String) = addToTag(userTag, tag, item)
+
+    /**
+     * ## 向指定标签池的指定标签下添加项
+     *
+     * 如果项以'#'开头则视为标签嵌套
+     *
+     * @return 如果出现重复或标签循环嵌套则返回`false`
+     */
+    fun addToTag(tagPool: HashMap<String, ArrayList<String>>, tag: String, item: String): Boolean {
+        if (tagPool.containsKey(tag) && tagPool[tag]!!.contains(item)) return false
+        if (item.startsWith('#')) {
+            if (item == tag) return false
+            val res = ArrayList<String>()
+            getParentTags(tagPool, tag, res)
+            if (item in res) return false
+        }
+        if (!tagPool.containsKey(tag)) tagPool[tag] = ArrayList()
+        tagPool[tag]!!.add(item)
+        return true
+    }
+
+    private fun getParentTags(tagPool: HashMap<String, ArrayList<String>>, tag: String, result: ArrayList<String>) {
+        for (t in tagPool.keys) {
+            if (t in result) continue
+            if (tagPool[t]!!.contains(tag)) {
+                result.add(t)
+                getParentTags(tagPool, t, result)
+            }
+        }
+    }
+
+    fun remFromGroupTag(tag: String, item: String) = remFromTag(groupTag,tag, item)
+    fun remFromUserTag(tag: String, item: String) = remFromTag(userTag,tag, item)
+
+    /**
+     * ## 从指定标签池的指定标签下移除项
+     *
+     * @return 如果标签不存在或项不存在于标签下返回`false`
+     */
+    private fun remFromTag(tagPool: HashMap<String, ArrayList<String>>, tag: String, item: String): Boolean {
+        if (tagPool.containsKey(tag) && tagPool[tag]!!.contains(item)) {
+            tagPool[tag]!!.remove(item)
+            return true
+        }
+        return false
+    }
+
+    fun remGroupTag(tag: String) = remTag(groupTag,tag)
+    fun remUserTag(tag: String) = remTag(userTag,tag)
+
+    /**
+     * ## 移除整个标签(包括引用)
+     *
+     * @return 如果标签不存在且标签未被引用返回`false`
+     */
+    private fun remTag(tagPool: HashMap<String, ArrayList<String>>, tag: String): Boolean {
+        var res = false
+        if (tagPool.containsKey(tag)) {
+            tagPool.remove(tag)
+            res = true
+        }
+
+        for (i in tagPool.values) {
+            if (i.contains(tag)) {
+                i.remove(tag)
+                res = true
+            }
+        }
+        return res
+    }
+}

+ 68 - 0
src/main/kotlin/bot_luo_core/data/User.kt

@@ -0,0 +1,68 @@
+package bot_luo_core.data
+
+import bot_luo_core.bot.BotContact
+import bot_luo_core.bot.BotContactType
+import bot_luo_core.bot.MultiBotHandler
+import bot_luo_core.cli.CmdExecutable
+import bot_luo_core.cli.CmdReceipt
+import com.alibaba.fastjson.annotation.JSONField
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.contact.*
+import net.mamoe.mirai.contact.Group
+import net.mamoe.mirai.contact.User
+import net.mamoe.mirai.message.data.At
+import net.mamoe.mirai.message.data.Message
+import kotlin.coroutines.CoroutineContext
+
+/**
+ * # 用户类
+ *
+ * 使用[Users.readUser]或[Users.readUserOrNull]方法获取实例
+ */
+class User internal constructor (
+    override val id: Long,
+    override var bot: Bot? = null
+) : CmdDataObj("data/users/$id.json"), BotContact {
+    override val contactType = BotContactType.Single
+    override val defaultData: CmdData get() = CmdData(0,0,0,0,true)//用户命令默认开启
+    override val autoSaveInterval = 30000L
+    override val saveAndUnload = true
+
+    val virtual: Boolean = id==0L
+
+    var cmdHistory: ArrayList<CmdReceipt>
+        get() = getObj("cmdHistory")?: ArrayList()
+        set(value) = setObj("cmdHistory", value)
+
+    override fun unload() {
+        Users.remove(this)
+    }
+
+    val name: String
+        get() = when(contact) {
+            is Member -> (contact as NormalMember).nameCardOrNick
+            is User -> (contact as User).nameCardOrNick
+            else -> ""
+        }
+
+    fun getAt() = At(id)
+
+    var pmsGroup: PmsGroup
+        get() = PmsGroups.getPmsGroup(getObj("pmsGroup")?: "NONE")
+        set(value) = setObj("pmsGroup", value.name)
+
+    inline fun withServeBot(action: (contact: User)->Unit): Boolean {
+        if (virtual) return false
+        val contact = if (bot == null) MultiBotHandler.getContactableBots(this).map { it.getFriend(id)?:it.getStranger(id) }.randomOrNull()
+        else bot?.getFriend(id)?:bot?.getStranger(id)
+        return if (contact == null) false
+        else {
+            action(contact)
+            true
+        }
+    }
+}

+ 42 - 0
src/main/kotlin/bot_luo_core/data/Users.kt

@@ -0,0 +1,42 @@
+package bot_luo_core.data
+
+object Users {
+
+    private val users = HashMap<Long, User>()
+    val activeUsersCount: Int get() = users.size
+    val virtualUser: User get() = readUser(0)
+
+    /**
+     * 根据id读取一个[User],若无记录则自动新建
+     */
+    fun readUser(id: Long): User {
+        if (id !in users.keys) {
+            users[id] = User(id)
+        }
+        return users[id]!!
+    }
+
+    /**
+     * 根据id读取一个[User],若无记录则返回`null`
+     */
+    fun readUserOrNull(id: Long): User? {
+        return if (id in users.keys) {
+            users[id]
+        } else {
+            val user = User(id)
+            if (user.exists()) {
+                users[id] = user
+                user
+            } else null
+        }
+
+    }
+
+    fun saveAll() {
+        users.values.forEach { it.save() }
+    }
+
+    fun remove(user: User) {
+        users.remove(user.id)
+    }
+}

+ 30 - 0
src/main/kotlin/bot_luo_core/util/JsonWorker.kt

@@ -0,0 +1,30 @@
+package bot_luo_core.util
+
+import com.alibaba.fastjson.JSON
+import com.alibaba.fastjson.TypeReference
+import java.io.File
+import java.lang.ClassCastException
+import kotlin.jvm.Throws
+
+
+/**
+ * # JSON数据读写工具类
+ */
+object JsonWorker {
+
+    @Throws(ClassCastException::class)
+    inline fun <reified T> readJson (filePath: String): T?{
+        val file = File(filePath)
+        if (!file.exists()) {
+            if (!file.parentFile.exists())
+                file.parentFile.mkdirs()
+            file.createNewFile()
+            return null
+        }
+        val typeRef = object : TypeReference<T>() {}
+        return JSON.parseObject(file.readText(),typeRef)
+    }
+
+    fun writeJson (filePath: String, obj: Any) = File(filePath).writeText(JSON.toJSONString(obj, true))
+
+}

+ 127 - 0
src/main/kotlin/bot_luo_core/util/Logger.kt

@@ -0,0 +1,127 @@
+package bot_luo_core.util
+
+import bot_luo_core.bot.VirtualMessageEvent
+import bot_luo_core.cli.CmdContext
+import bot_luo_core.cli.CmdExecutable
+import bot_luo_core.data.Group
+import bot_luo_core.data.User
+import bot_luo_core.util.Text.escapeJson
+import net.mamoe.mirai.contact.Contact
+import net.mamoe.mirai.contact.nameCardOrNick
+import net.mamoe.mirai.event.Event
+import net.mamoe.mirai.event.events.*
+import net.mamoe.mirai.message.MessageReceipt
+import net.mamoe.mirai.message.data.Message
+import net.mamoe.mirai.message.data.MessageChain
+import net.mamoe.mirai.message.data.toMessageChain
+import net.mamoe.mirai.message.data.toPlainText
+import org.apache.logging.log4j.Level
+import org.apache.logging.log4j.LogManager
+
+object Logger {
+
+    private val sysLogger = LogManager.getLogger("SYS")
+    private val msgLogger = LogManager.getLogger("MSG")
+    private val eventLogger = LogManager.getLogger("EVENT")
+    private val cliLogger = LogManager.getLogger("CLI")
+
+    /*  ========================  SYS  ========================  */
+
+    fun sysLog(level: Level, msg: String) = sysLogger.log(level, msg)
+
+    /*  ========================  MSG  ========================  */
+
+    fun msgLog(level: Level, msg: String) = msgLogger.log(level, msg)
+
+    fun log(e: MessageEvent, level: Level = Level.INFO) = when(e) {
+        is GroupAwareMessageEvent -> log(e, level)
+        is UserMessageEvent -> log(e, level)
+        else -> msgLog(level, "[U:${e.sender.nameCardOrNick}(${e.sender.id})] -> ${e.message.serializeToMiraiCode()}")
+    }
+
+    fun log(e: GroupAwareMessageEvent, level: Level = Level.INFO) =
+        msgLog(level, "[U:${e.sender.nameCardOrNick}(${e.sender.id})][G:${e.group.name}(${e.group.id})] -> ${e.message.serializeToMiraiCode()}")
+
+    fun log(e: UserMessageEvent, level: Level = Level.INFO) =
+        msgLog(level, "[U:${e.sender.nameCardOrNick}(${e.sender.id})][B:${e.bot.nick}(${e.bot.id})] -> ${e.message.serializeToMiraiCode()}")
+
+    fun log(e: VirtualMessageEvent, level: Level = Level.INFO) =
+        eventLog(level, "-> <VMSG> [U:${e.user.name}(${e.user.id})][G:${e.group.name}(${e.group.id})] -> ${e.message.serializeToMiraiCode()}")
+
+    fun msgLog(m: MessageChain, u: User, g:Group, level: Level = Level.INFO) = when {
+        !g.virtual -> msgLog(level, "[U:${u.name}(${u.id})][G:${g.name}(${g.id})] <- ${m.serializeToMiraiCode()}")
+        !u.virtual -> msgLog(level, "[U:${u.name}(${u.id})] <- ${m.serializeToMiraiCode()}")
+        else -> msgLog(level, "|<- ${m.serializeToMiraiCode()}")
+    }
+
+    suspend inline fun Contact.sendMessageWithLog(str: String, level: Level = Level.INFO): MessageReceipt<Contact>? = sendMessageWithLog(str.toPlainText(), level)
+    suspend inline fun Contact.sendMessageWithLog(msg: Message, level: Level = Level.INFO): MessageReceipt<Contact>? = sendMessageWithLog(msg.toMessageChain(), level)
+    suspend inline fun Contact.sendMessageWithLog(msg: MessageChain, level: Level = Level.INFO): MessageReceipt<Contact>? {
+        when (this) {
+            is net.mamoe.mirai.contact.Group -> msgLog(level, "[G:${this.name}(${this.id})] <- ${msg.serializeToMiraiCode()}")
+            is net.mamoe.mirai.contact.User -> msgLog(level, "[U:${this.nameCardOrNick}(${this.id})] <- ${msg.serializeToMiraiCode()}")
+            else -> msgLog(Level.WARN, "[UNK] <- ${msg.serializeToMiraiCode()}")
+        }
+        return try {
+            this.sendMessage(msg)
+        } catch (e: EventCancelledException) {
+            null
+        }
+    }
+
+    /*  ========================  EVENT  ========================  */
+
+    fun eventLog(level: Level, msg: String) = eventLogger.log(level, msg)
+
+    fun log(e: Event, level: Level = Level.INFO) =
+        eventLog(Level.WARN, "-> <UNK> ${e::class.simpleName}")
+
+    fun log(e: BotJoinGroupEvent, level: Level = Level.INFO) =
+        eventLog(level, "[B:${e.bot.nick}(${e.bot.id})][G:${e.group.name}(${e.group.id})] -> <BOT JOIN>")
+
+    fun log(e: BotLeaveEvent, level: Level = Level.INFO) =
+        eventLog(level, "[B:${e.bot.nick}(${e.bot.id})][G:${e.group.name}(${e.group.id})] -> <BOT LEAVE>")
+
+    fun log(e: BotGroupPermissionChangeEvent, level: Level = Level.INFO) =
+        eventLog(level, "[B:${e.bot.nick}(${e.bot.id})][G:${e.group.name}(${e.group.id})] -> <BOT PMS> from ${e.origin} to ${e.new}")
+
+    fun log(e: BotOnlineEvent, level: Level = Level.INFO) =
+        eventLog(level, "[B:${e.bot.nick}(${e.bot.id})] -> <BOT ONLINE>")
+
+    fun log(e: BotOfflineEvent, level: Level = Level.INFO) =
+        eventLog(level, "[B:${e.bot.nick}(${e.bot.id})] -> <BOT OFFLINE>")
+
+    fun log(e: BotMuteEvent, level: Level = Level.INFO) =
+        eventLog(level, "[B:${e.bot.nick}(${e.bot.id})][G:${e.group.name}(${e.group.id})] -> <BOT MUTE> ${e.durationSeconds}s, by ${e.operator.nick}(${e.operator.id})")
+
+    fun log(e: BotUnmuteEvent, level: Level = Level.INFO) =
+        eventLog(level, "[B:${e.bot.nick}(${e.bot.id})][G:${e.group.name}(${e.group.id})] -> <BOT UNMUTE> by ${e.operator.nick}(${e.operator.id})")
+
+    fun log(e: MemberJoinRequestEvent, level: Level = Level.INFO) = if(e.invitor == null) {
+        eventLog(level, "[U:${e.fromNick}(${e.fromId})][G:${e.group?.name}(${e.group?.id})] -> <JOIN REQ> ${e.message.escapeJson()}")
+    } else {
+        eventLog(level, "[U:${e.fromNick}(${e.fromId})][G:${e.group?.name}(${e.group?.id})] -> <JOIN REQ> invite by ${e.invitor?.nick}(${e.invitor?.id})")
+    }
+
+    fun log(e: MemberJoinEvent, level: Level = Level.INFO) = when(e) {
+        is MemberJoinEvent.Invite -> eventLog(level, "[U:${e.member.nameCardOrNick}(${e.member.id})][G:${e.group.name}(${e.group.id})] -> <JOIN> invite by ${e.invitor.nick}(${e.invitor.id})")
+        is MemberJoinEvent.Active -> eventLog(level, "[U:${e.member.nameCardOrNick}(${e.member.id})][G:${e.group.name}(${e.group.id})] -> <JOIN> active")
+        else -> eventLog(level, "[U:${e.member.nameCardOrNick}(${e.member.id})][G:${e.group.name}(${e.group.id})] -> <JOIN> ${e::class.simpleName}")
+    }
+
+    fun log(e: MemberLeaveEvent, level: Level = Level.INFO) = when(e) {
+        is MemberLeaveEvent.Kick -> eventLog(level, "[U:${e.member.nameCardOrNick}(${e.member.id})][G:${e.group.name}(${e.group.id})] -> <LEAVE> kick by ${e.operatorOrBot.nick}(${e.operatorOrBot.id})")
+        is MemberLeaveEvent.Quit -> eventLog(level, "[U:${e.member.nameCardOrNick}(${e.member.id})][G:${e.group.name}(${e.group.id})] -> <LEAVE> quit")
+        else -> eventLog(level, "[U:${e.member.nameCardOrNick}(${e.member.id})][G:${e.group.name}(${e.group.id})] -> <LEAVE> ${e::class.simpleName}")
+    }
+
+    /*  ========================  CLI  ========================  */
+
+    fun cliLog(level: Level, msg: String) = cliLogger.log(level, msg)
+
+    fun log(cmd: CmdExecutable, c: CmdContext, info: String, level: Level = Level.INFO) =
+        cliLog(level, "[U:${c.user.name}(${c.user.id})][G:${c.group.name}(${c.group.id})] <${cmd.id}> $info")
+
+    fun log(cmd: CmdExecutable, info: String, level: Level = Level.INFO) =
+        cliLog(level, "<${cmd.id}> $info")
+}

+ 142 - 0
src/main/kotlin/bot_luo_core/util/ResourceManager.kt

@@ -0,0 +1,142 @@
+package bot_luo_core.util
+
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import net.mamoe.mirai.contact.AudioSupported
+import net.mamoe.mirai.contact.Contact
+import net.mamoe.mirai.message.data.*
+import net.mamoe.mirai.message.data.Image.Key.queryUrl
+import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
+import java.io.*
+import java.net.HttpURLConnection
+import java.net.URL
+import kotlin.jvm.Throws
+
+object ResourceManager {
+
+    private const val imageDir = "data/images/"
+    private const val audioDir = "data/audios/"
+    fun saveImageRef() = JsonWorker.writeJson(imageDir + "ref_map.json", imgRefMap)
+    fun saveAudioRef() = JsonWorker.writeJson(audioDir + "ref_map.json", audRefMap)
+
+    private val imgRefMap: HashMap<String, Int>  = JsonWorker.readJson(imageDir+"ref_map.json")?: HashMap()
+    private val audRefMap: HashMap<String, Int>  = JsonWorker.readJson(audioDir+"ref_map.json")?: HashMap()
+
+    private val lock = Mutex()
+
+    /**
+     * 将消息链的特殊内容储存于本地,避免消息内容被服务器清理
+     *
+     * 会检查文件是否已存在,会更新文件引用表
+     *
+     * 特殊存储的消息有:
+     * - [Image]
+     * - [OnlineAudio]
+     */
+    suspend fun MessageChain.downloadResource() {
+        lock.withLock {
+            this.forEach {
+                when (it) {
+                    is Image -> {
+                        imgRefMap[it.imageId] = imgRefMap[it.imageId]?.inc() ?: 1
+                        val file = File(imageDir + it.imageId)
+                        if (!file.exists()) downloadHttpFile(it.queryUrl(), file)
+                    }
+                    is OnlineAudio -> {
+                        audRefMap[it.filename] = audRefMap[it.filename]?.inc() ?: 1
+                        val file = File(audioDir + it.filename)
+                        if (!file.exists()) downloadHttpFile(it.urlForDownload, file)
+                    }
+                }
+            }
+            saveImageRef()
+            saveAudioRef()
+        }
+    }
+
+    /**
+     * 上传消息链的资源文件
+     *
+     * **为避免出现意外,应使用函数返回值代替原消息进行发送**
+     */
+    suspend fun MessageChain.uploadResource(contact: Contact): MessageChain {
+        val mcb = MessageChainBuilder()
+        lock.withLock {
+            this.forEach {
+                when (it) {
+                    is Image -> {
+                        val file = File(imageDir+it.imageId)
+                        if (file.exists()) {
+                            file.inputStream().use { input ->
+                                mcb.add(contact.uploadImage(input.toExternalResource()))
+                            }
+                        } else {
+                            mcb.add("[图片失效]")
+                        }
+                    }
+                    is Audio -> {
+                        if (contact is AudioSupported) {
+                            val file = File(audioDir + it.filename)
+                            if (file.exists()) {
+                                file.inputStream().use { input ->
+                                    mcb.add(contact.uploadAudio(input.toExternalResource()))
+                                }
+                            } else {
+                                mcb.add("[音频失效]")
+                            }
+                        } else {
+                            mcb.add("[环境不支持音频消息]")
+                        }
+                    }
+                    else -> mcb.add(it)
+                }
+            }
+        }
+        return mcb.build()
+    }
+
+    /**
+     * 删除本地存储的消息链时使用
+     *
+     * 更新相应文件的引用表,如果无引用则删除文件
+     */
+    suspend fun MessageChain.deleteResourceIfNeeded() {
+        lock.withLock {
+            this.forEach {
+                when (it) {
+                    is Image -> {
+                        imgRefMap[it.imageId] = imgRefMap[it.imageId]?.dec() ?: 0
+                        if (imgRefMap[it.imageId] == 0) {
+                            imgRefMap.remove(it.imageId)
+                            File(imageDir+it.imageId).delete()
+                        }
+                    }
+                    is Audio -> {
+                        audRefMap[it.filename] = audRefMap[it.filename]?.dec() ?: 0
+                        if (audRefMap[it.filename] == 0) {
+                            audRefMap.remove(it.filename)
+                            File(audioDir+it.filename).delete()
+                        }
+                    }
+                }
+            }
+            saveImageRef()
+            saveAudioRef()
+        }
+    }
+
+    @Throws(IOException::class)
+    fun downloadHttpFile(url: String, file: File) {
+        val conn = URL(url).openConnection() as HttpURLConnection
+        try {
+            conn.connect()
+            conn.inputStream.use { input ->
+                BufferedOutputStream (FileOutputStream(file)).use { output ->
+                    input.copyTo(output)
+                }
+            }
+        } finally {
+            conn.disconnect()
+        }
+    }
+}

+ 145 - 0
src/main/kotlin/bot_luo_core/util/TableBuilder.kt

@@ -0,0 +1,145 @@
+package bot_luo_core.util
+
+import bot_luo_core.util.Text.times
+import net.mamoe.mirai.message.data.*
+
+class TableBuilder (
+    /**
+     * 单元间分隔符
+     */
+    private val separator: String
+        ) {
+
+    constructor(space: Int) : this(' ' * space)
+
+    private val mcb = MessageChainBuilder()
+    private val table = ArrayList<ArrayList<String>>()
+    private val ps = HashMap<Int, MessageChain>()
+    private val len = ArrayList<Int>()
+    private var x = -1
+    private var y = -1
+
+    /**
+     * 插入一个表头,若输入不为空则自带一个换行
+     *
+     * 完成上一个表的排版,新开一个表
+     */
+    fun th(msg: Message? = null): TableBuilder {
+        build()
+        if (msg != null) {
+            mcb.append(msg)
+            mcb.append('\n')
+        }
+        return this
+    }
+    fun th(str: String) = th(str.toPlainText())
+
+    /**
+     * 新开一行
+     *
+     * @param str 文本,为`null`则不添加单元
+     */
+    fun tr(str: String? = null): TableBuilder {
+        x = 0
+        table.add(ArrayList())
+        y++
+        if (str != null) tb(str)
+        return this
+    }
+    fun tr(any: Any?): TableBuilder = tr(any?.toString())
+
+    /**
+     * 附加一个单元
+     *
+     * 必须在[tr]之后使用
+     *
+     * @param str 文本,默认为""
+     */
+    fun tb(str: String = ""): TableBuilder {
+        table[y].add(str)
+        while (len.size <= x) len.add(0)
+        if (len[x] < str.length) len[x] = str.length
+        x++
+        return this
+    }
+    fun tb(any: Any?) = tb(any?.toString()?:"")
+
+    /**
+     * 附加到独立的一行,不影响当前表格布局
+     */
+    fun p(msg: Message): TableBuilder {
+        if (y == -1) mcb.append(msg)
+        else {
+            if (ps[y] == null) ps[y] = EmptyMessageChain
+            ps[y] = ps[y]!!.plus(msg)
+        }
+        return this
+    }
+    fun p(str: String) = p(str.toPlainText())
+
+    /**
+     * 插入一个间隔到独立行
+     */
+    fun sp(): TableBuilder {
+        p(separator)
+        return this
+    }
+
+    /**
+     * 独立行换行,不影响表格布局
+     */
+    fun br(): TableBuilder {
+        if (y == -1) mcb.append('\n')
+        else {
+            if (ps[y] == null) ps[y] = EmptyMessageChain
+            ps[y] = ps[y]!!.plus("\n")
+        }
+        return this
+    }
+
+    private fun build() {
+        for (i in table.indices) {
+            for (j in table[i].indices) {
+                mcb.append(table[i][j]).append("  " * (len[j]-table[i][j].length))
+                if (j < table[i].lastIndex) mcb.append(separator)
+            }
+            mcb.append('\n')
+            if (ps[i] != null) mcb.append(ps[i]!!)
+        }
+        table.clear()
+        len.clear()
+        ps.clear()
+        //操作开始前x,y总是指向下一个空位
+        x = -1
+        y = -1
+    }
+
+    fun prettyLines(title: String, lines: Int, action: (index: Int, builder: TableBuilder)->Unit): TableBuilder {
+        when (lines) {
+            0 -> {}
+            1 -> {
+                tr(title)
+                action(0, this)
+            }
+            else -> {
+                tr(title)
+                for (i in 0 until lines) {
+                    tr("")
+                    action(i, this)
+                }
+            }
+        }
+        return this
+    }
+
+    override fun toString(): String {
+        build()
+        return mcb.build().content.trim()
+    }
+
+    fun toMessage(): MessageChain {
+        build()
+        return mcb.build()
+    }
+
+}

+ 46 - 0
src/main/kotlin/bot_luo_core/util/Text.kt

@@ -0,0 +1,46 @@
+package bot_luo_core.util
+
+import com.alibaba.fastjson.JSON
+import net.mamoe.mirai.message.code.MiraiCode.deserializeMiraiCode
+import net.mamoe.mirai.message.data.content
+import java.util.*
+
+object Text {
+
+    fun String.escapeRegex(): String {
+        val sb = StringBuilder()
+        [email protected] {
+            if (it in  ".\\+*?[^]\$(){}=!<>|:-" ) sb.append('\\')
+            sb.append(it)
+        }
+        return sb.toString()
+    }
+
+    fun String.escapeJson(): String = JSON.toJSONString(this@escapeJson)
+
+    infix operator fun String.times(n: Int): String {
+        val sb = StringBuilder()
+        repeat(n) { sb.append(this) }
+        return sb.toString()
+    }
+
+    infix operator fun Char.times(n: Int): String {
+        val sb = StringBuilder()
+        repeat(n) { sb.append(this) }
+        return sb.toString()
+    }
+
+    fun String.firstNotWhitespace(max: Int = this.length): Char? {
+        for (i in 0 until max) {
+            if (!Character.isWhitespace(this[i])) return this[i]
+        }
+        return null
+    }
+
+    fun String.toLowercase(): String = this.lowercase(Locale.getDefault())
+
+    /**
+     * 将此字符串视为MiraiCode进行解码并获取其内容
+     */
+    val String.miraiCodeContent: String get() = this.deserializeMiraiCode().content
+}

+ 164 - 0
src/main/kotlin/bot_luo_core/util/Time.kt

@@ -0,0 +1,164 @@
+package bot_luo_core.util
+
+import java.text.SimpleDateFormat
+import java.util.*
+import kotlin.math.abs
+
+object Time {
+    var calendar: Calendar = Calendar.getInstance().let { it.timeZone=TimeZone.getTimeZone("GMT+08:00"); it }
+    private val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX")
+    /**
+     * 获取当前时间戳
+     * @return [Long] 时间戳(毫秒)
+     */
+    fun time() = System.currentTimeMillis()
+
+    /**
+     * 获取当前日期
+     */
+    fun year(time: Long) = calendar.let { it.timeInMillis= time; it.get(Calendar.YEAR) }
+    fun year() = year(time())
+    fun month(time: Long) = calendar.let { it.timeInMillis= time; it.get(Calendar.MONTH) }
+    fun month() = month(time())
+    fun day(time: Long) = calendar.let { it.timeInMillis= time; it.get(Calendar.DAY_OF_MONTH) }
+    fun day() = day(time())
+
+    /**
+     * 当前时分秒
+     */
+    fun hour(time: Long) = calendar.let { it.timeInMillis= time; it.get(Calendar.HOUR_OF_DAY) }
+    fun hour() = hour(time())
+    fun minute(time: Long) = calendar.let { it.timeInMillis= time(); it.get(Calendar.MINUTE) }
+    fun minute() = minute(time())
+    fun second(time: Long) = calendar.let { it.timeInMillis= time(); it.get(Calendar.SECOND) }
+    fun second() = second(time())
+
+    /**
+     * 检测时间戳是否在今天
+     */
+    fun isSameDay(time: Long, timeRef: Long): Boolean {
+        val c = Calendar.getInstance()
+        c.timeInMillis = time
+        c.timeZone = TimeZone.getTimeZone("GMT+08:00")
+        val cRef = Calendar.getInstance()
+        cRef.timeInMillis = timeRef
+        cRef.timeZone = TimeZone.getTimeZone("GMT+08:00")
+        return (c.get(Calendar.YEAR) == cRef.get(Calendar.YEAR) && c.get(Calendar.MONTH) == cRef.get(Calendar.MONTH) && c.get(Calendar.DAY_OF_MONTH) == cRef.get(Calendar.DAY_OF_MONTH))
+    }
+
+    /**
+     * 时间戳(毫秒)转换为可读时间格式
+     * @return [String] 时间码字符串
+     */
+    fun format(time: Long): String = format.format(Date(time))
+
+    /**
+     * 解析时间为时间戳(毫秒)
+     * @return [Long] 时间戳(毫秒)
+     * @throws java.text.ParseException
+     */
+    fun parse(source: String) = format.parse(source).toInstant().epochSecond*1000
+
+    /**
+     * 解析时间(ms)为文本(简略版)
+     * @return [String] 时间文本
+     */
+    fun formatSpanE(time: Long): String{
+        var t = time
+        val year = t/(1000*60*60*24*365L)
+        t%=1000*60*60*24*365L
+        val mon = t/(1000*60*60*24*30L)
+        t%=1000*60*60*24*30L
+        val day = t/(1000*60*60*24)
+        t%=1000*60*60*24
+        val hour = t/(1000*60*60)
+        t%=1000*60*60
+        val min = t/(1000*60)
+        t%=1000*60
+        val sec = t/1000
+
+        return if (year>5)
+            "5年以上"
+        else if (year in 1 until 5)
+            if (mon > 0)
+                "${year}年${mon}月"
+            else
+                "${year}年"
+        else if (mon in 4..12)
+            "${mon}月"
+        else if (mon in 1..3)
+            if (day > 0)
+                "${mon}月${day}天"
+            else
+                "${mon}月"
+        else if (day in 4..30)
+            "${day}天"
+        else if (day in 1..3)
+            if (hour > 0)
+                "${day}天${hour}小时"
+            else
+                "${day}天"
+        else if (hour in 11..60)
+            "${hour}小时"
+        else if (hour in 1..10)
+            if (min > 0)
+                "${hour}小时${min}分钟"
+            else
+                "${hour}小时"
+        else if (min in 11..60)
+            "${min}分钟"
+        else if (min in 1..10)
+            if (sec > 0)
+                "${min}分钟${sec}秒"
+            else
+                "${min}分钟"
+        else
+            "一会"
+    }
+    /**
+     * 解析相对时刻(ms)为文本(简略版)
+     * @param [time] 正数为之前,负数为之后
+     * @return [String] 时间文本
+     */
+    fun formatRelativeTimeE(time: Long) = formatSpanE(abs(time)) + if (time>0) "前" else "后"
+
+    /**
+     * 解析时间(ms)为文本
+     * @return [String] 时间文本
+     */
+    fun formatSpan(time: Long): String{
+        var t = time
+        val year = t/(1000*60*60*24*365L)
+        t%=1000*60*60*24*365L
+        val mon = t/(1000*60*60*24*30L)
+        t%=1000*60*60*24*30L
+        val day = t/(1000*60*60*24)
+        t%=1000*60*60*24
+        val hour = t/(1000*60*60)
+        t%=1000*60*60
+        val min = t/(1000*60)
+        t%=1000*60
+        val sec = t/1000
+
+        val sb = StringBuilder()
+        if (year>0)
+            sb.append("${year}年")
+        if (mon>0)
+            sb.append("${mon}月")
+        if (day>0)
+            sb.append("${day}天")
+        if (hour>0)
+            sb.append("${hour}小时")
+        if (min>0)
+            sb.append("${min}分钟")
+        if (sec>0)
+            sb.append("${sec}秒")
+        return sb.toString()
+    }
+    /**
+     * 解析相对时刻(ms)为文本
+     * @param [time] 正数为之前,负数为之后
+     * @return [String] 时间文本
+     */
+    fun formatRelativeTime(time: Long) = formatSpan(abs(time)) + if (time>0) "前" else "后"
+}