Ver Fonte

Mock Testing Framework (#1521)

Co-authored-by: Eritque arcus <1930893235@qq.com>
Co-authored-by: Him188 <Him188@mamoe.net>
微莹·纤绫 há 2 anos atrás
pai
commit
2db9804cf2
85 ficheiros alterados com 8018 adições e 0 exclusões
  1. 9 0
      buildSrc/src/main/kotlin/Versions.kt
  2. 1 0
      docs/.conf/nav.js
  3. 5 0
      docs/Bots.md
  4. 49 0
      docs/mocking/Mocking.md
  5. 21 0
      mirai-core-mock/README.md
  6. 37 0
      mirai-core-mock/build.gradle.kts
  7. 187 0
      mirai-core-mock/src/MockActions.kt
  8. 158 0
      mirai-core-mock/src/MockBot.kt
  9. 13 0
      mirai-core-mock/src/MockBotDSL.kt
  10. 62 0
      mirai-core-mock/src/MockBotFactory.kt
  11. 14 0
      mirai-core-mock/src/contact/MockAnonymousMember.kt
  12. 33 0
      mirai-core-mock/src/contact/MockContact.kt
  13. 17 0
      mirai-core-mock/src/contact/MockContactOrBot.kt
  14. 90 0
      mirai-core-mock/src/contact/MockFriend.kt
  15. 147 0
      mirai-core-mock/src/contact/MockGroup.kt
  16. 43 0
      mirai-core-mock/src/contact/MockGroupControlPane.kt
  17. 33 0
      mirai-core-mock/src/contact/MockMember.kt
  18. 21 0
      mirai-core-mock/src/contact/MockMsgSyncSupport.kt
  19. 107 0
      mirai-core-mock/src/contact/MockNormalMember.kt
  20. 14 0
      mirai-core-mock/src/contact/MockOtherClient.kt
  21. 62 0
      mirai-core-mock/src/contact/MockStranger.kt
  22. 104 0
      mirai-core-mock/src/contact/MockUser.kt
  23. 18 0
      mirai-core-mock/src/contact/MockUserOrBot.kt
  24. 69 0
      mirai-core-mock/src/contact/announcement/MockAnnouncements.kt
  25. 104 0
      mirai-core-mock/src/database/MessageDatabase.kt
  26. 105 0
      mirai-core-mock/src/internal/MockBotFactoryImpl.kt
  27. 189 0
      mirai-core-mock/src/internal/MockBotImpl.kt
  28. 339 0
      mirai-core-mock/src/internal/MockMiraiImpl.kt
  29. 54 0
      mirai-core-mock/src/internal/components/MockEventDispatcherImpl.kt
  30. 76 0
      mirai-core-mock/src/internal/contact/AbstractMockContact.kt
  31. 96 0
      mirai-core-mock/src/internal/contact/MockAnnouncementsImpl.kt
  32. 119 0
      mirai-core-mock/src/internal/contact/MockAnonymousMemberImpl.kt
  33. 168 0
      mirai-core-mock/src/internal/contact/MockFriendImpl.kt
  34. 353 0
      mirai-core-mock/src/internal/contact/MockGroupImpl.kt
  35. 237 0
      mirai-core-mock/src/internal/contact/MockNormalMemberImpl.kt
  36. 108 0
      mirai-core-mock/src/internal/contact/MockStrangerImpl.kt
  37. 76 0
      mirai-core-mock/src/internal/contact/friendfroup/MockFriendGroup.kt
  38. 55 0
      mirai-core-mock/src/internal/contact/friendfroup/MockFriendGroups.kt
  39. 68 0
      mirai-core-mock/src/internal/contact/roaming/MockRoamingMessages.kt
  40. 140 0
      mirai-core-mock/src/internal/contact/util.kt
  41. 102 0
      mirai-core-mock/src/internal/db/MsgDatabaseImpl.kt
  42. 176 0
      mirai-core-mock/src/internal/msgsrc/OnlineMsgSrc.kt
  43. 121 0
      mirai-core-mock/src/internal/remotefile/absolutefile/MockAbsoluteFile.kt
  44. 266 0
      mirai-core-mock/src/internal/remotefile/absolutefile/MockAbsoluteFolder.kt
  45. 51 0
      mirai-core-mock/src/internal/remotefile/absolutefile/MockRemoteFiles.kt
  46. 358 0
      mirai-core-mock/src/internal/remotefile/remotefile/MockRemoteFile.kt
  47. 368 0
      mirai-core-mock/src/internal/serverfs/MockServerFileDiskImpl.kt
  48. 153 0
      mirai-core-mock/src/internal/serverfs/TmpResourceServerImpl.kt
  49. 11 0
      mirai-core-mock/src/package.kt
  50. 25 0
      mirai-core-mock/src/resserver/MockServerFileDisk.kt
  51. 17 0
      mirai-core-mock/src/resserver/MockServerFileSystem.kt
  52. 51 0
      mirai-core-mock/src/resserver/MockServerRemoteFile.kt
  53. 95 0
      mirai-core-mock/src/resserver/TmpResourceServer.kt
  54. 171 0
      mirai-core-mock/src/userprofile/UserProfileService.kt
  55. 154 0
      mirai-core-mock/src/userprofile/contactinfos.kt
  56. 36 0
      mirai-core-mock/src/utils/MemberInfo.kt
  57. 158 0
      mirai-core-mock/src/utils/MockActionsScope.kt
  58. 76 0
      mirai-core-mock/src/utils/MockConversions.kt
  59. 38 0
      mirai-core-mock/src/utils/NameGenerator.kt
  60. 73 0
      mirai-core-mock/src/utils/NudgeDsl.kt
  61. 19 0
      mirai-core-mock/src/utils/event.kt
  62. 25 0
      mirai-core-mock/src/utils/http.kt
  63. 41 0
      mirai-core-mock/src/utils/image.kt
  64. 15 0
      mirai-core-mock/src/utils/mockdsl.kt
  65. 100 0
      mirai-core-mock/test/AbsoluteFileTest.kt
  66. 138 0
      mirai-core-mock/test/DslTest.kt
  67. 41 0
      mirai-core-mock/test/FsServerTest.kt
  68. 47 0
      mirai-core-mock/test/ImageUploadTest.kt
  69. 58 0
      mirai-core-mock/test/MockBotTestBase.kt
  70. 49 0
      mirai-core-mock/test/MsgDbTest.kt
  71. 50 0
      mirai-core-mock/test/TestBase.kt
  72. 118 0
      mirai-core-mock/test/TxFsDiskTest.kt
  73. 208 0
      mirai-core-mock/test/mock/MessagingTest.kt
  74. 102 0
      mirai-core-mock/test/mock/MockBotBaseTest.kt
  75. 80 0
      mirai-core-mock/test/mock/MockBotEventTest.kt
  76. 49 0
      mirai-core-mock/test/mock/MockFriendGroupsTest.kt
  77. 149 0
      mirai-core-mock/test/mock/MockFriendTest.kt
  78. 460 0
      mirai-core-mock/test/mock/MockGroupTest.kt
  79. 24 0
      mirai-core-mock/test/mock/MockMemberTest.kt
  80. 34 0
      mirai-core-mock/test/mock/MockStrangerTest.kt
  81. 10 0
      mirai-core-mock/test/package.kt
  82. 16 0
      mirai-core-utils/src/commonMain/kotlin/Conversions.kt
  83. 34 0
      mirai-core-utils/src/jvmBaseMain/kotlin/IO.jvm.shared.kt
  84. 49 0
      mirai-core-utils/src/jvmMain/kotlin/IO.jvm.kt
  85. 1 0
      settings.gradle.kts

+ 9 - 0
buildSrc/src/main/kotlin/Versions.kt

@@ -63,6 +63,11 @@ object Versions {
     const val yamlkt = "0.12.0"
     const val yamlkt = "0.12.0"
     const val intellijGradlePlugin = "1.7.0"
     const val intellijGradlePlugin = "1.7.0"
 
 
+    // https://github.com/google/jimfs
+    // Java In Memory File System
+    const val jimfs = "1.2"
+
+
     // don't update easily unless you want your disk space -= 1000 MB
     // don't update easily unless you want your disk space -= 1000 MB
     // (700 MB for IDEA, 150 MB for sources, 150 MB for JBR)
     // (700 MB for IDEA, 150 MB for sources, 150 MB for JBR)
     const val intellij = "222.3345-EAP-CANDIDATE-SNAPSHOT"
     const val intellij = "222.3345-EAP-CANDIDATE-SNAPSHOT"
@@ -115,6 +120,10 @@ val `ktor-client-logging` = ktor("client-logging", Versions.ktor)
 val `ktor-network` = ktor("network-jvm", Versions.ktor)
 val `ktor-network` = ktor("network-jvm", Versions.ktor)
 val `ktor-client-serialization` = ktor("client-serialization", Versions.ktor)
 val `ktor-client-serialization` = ktor("client-serialization", Versions.ktor)
 
 
+val `ktor-server-core` = ktor("server-core", Versions.ktor)
+val `ktor-server-netty` = ktor("server-netty", Versions.ktor)
+const val `java-in-memory-file-system` = "com.google.jimfs:jimfs:" + Versions.jimfs
+
 const val `logback-classic` = "ch.qos.logback:logback-classic:" + Versions.logback
 const val `logback-classic` = "ch.qos.logback:logback-classic:" + Versions.logback
 
 
 const val `slf4j-api` = "org.slf4j:slf4j-api:" + Versions.slf4j
 const val `slf4j-api` = "org.slf4j:slf4j-api:" + Versions.slf4j

+ 1 - 0
docs/.conf/nav.js

@@ -35,6 +35,7 @@ module.exports = {
                 {text: "事件列表", link: "/EventList.html"},
                 {text: "事件列表", link: "/EventList.html"},
                 {text: "Debugging Network", link: "/DebuggingNetwork.html"},
                 {text: "Debugging Network", link: "/DebuggingNetwork.html"},
                 {text: "Using Dev Snapshots", link: "/UsingSnapshots.html"},
                 {text: "Using Dev Snapshots", link: "/UsingSnapshots.html"},
+                {text: "mirai 模拟测试框架", link: "/mocking/Mocking.md"},
             ]
             ]
         },
         },
     ],
     ],

+ 5 - 0
docs/Bots.md

@@ -21,6 +21,7 @@
   - [处理滑动验证码](#处理滑动验证码)
   - [处理滑动验证码](#处理滑动验证码)
   - [常见登录失败原因](#常见登录失败原因)
   - [常见登录失败原因](#常见登录失败原因)
 - [附录: 调试网络层](#附录-调试网络层)
 - [附录: 调试网络层](#附录-调试网络层)
+- [附录: 模拟测试框架](#附录-模拟测试框架)
 
 
 ## 1. 创建和配置 `Bot`
 ## 1. 创建和配置 `Bot`
 
 
@@ -284,6 +285,10 @@ contactListCache.setSaveIntervalMillis(60000) // 可选设置有更新时的保
 
 
 参阅 [DebuggingNetwork.md](DebuggingNetwork.md)
 参阅 [DebuggingNetwork.md](DebuggingNetwork.md)
 
 
+## 附录: 模拟测试框架
+
+参阅 [Mocking.md](mocking/Mocking.md)
+
 > 下一步,[Contacts](Contacts.md)
 > 下一步,[Contacts](Contacts.md)
 >
 >
 > [回到 Mirai 文档索引](CoreAPI.md)
 > [回到 Mirai 文档索引](CoreAPI.md)

+ 49 - 0
docs/mocking/Mocking.md

@@ -0,0 +1,49 @@
+# Mirai - Mocking
+
+本章节介绍 mirai 模拟环境
+
+> mirai 模拟环境从 `2.13` 开始支持
+>
+> 注:
+> - **不支持**同时运行模拟环境和真实环境
+> - **不支持**从模拟环境切换回真实环境
+
+-----------------------------------
+
+# 在非 console 中进行模拟
+
+## 环境准备
+
+要使用 mirai 模拟环境测试框架, 首先需要额外添加一项依赖
+
+```kotlin
+dependencies {
+    testImplementation("net.mamoe:mirai-core-mock:$VERSION")
+}
+```
+
+并在本地的测试入口添加以下的代码
+
+```kotlin
+internal fun main() {
+    MockBotFactory.initialize()
+    // .....
+}
+```
+
+## 创建 Bot
+
+对于创建 `MockBot`, 更好的方法是使用 `MockBotFactory.newMockBotBuilder()`
+
+也可以使用原始的 `BotFactory` 来创建一个新的 `MockBot`, 系统会使用默认值填充相关的信息
+
+## 使用
+
+关于 `MockBot` 可以在 [这里](https://github.com/mamoe/mirai/tree/dev/mirai-core-mock/test/mock)
+找到 mirai-core-mock 的相关用法
+
+----------------
+
+# 在 console 中进行模拟
+
+Work In Progress...

+ 21 - 0
mirai-core-mock/README.md

@@ -0,0 +1,21 @@
+# mirai-core-mock
+
+mirai 模拟环境测试框架
+
+> 模拟环境目前仅支持 JVM
+
+--------------
+
+# src 架构
+
+- `contact` - 与 `mirai-core-api` 架构一致
+- `database` - 数据库, 用于存储一些临时的零碎数据
+- `resserver` - 资源服务
+- `userprofile` - 与 `UserProfile` 相关的一些服务
+- `utils` - 工具类
+
+# test 架构
+
+- `<toplevel>` 与 mirai-core-api 关系不大或者一些独立的组件的测试
+- `.mock` 模拟的各个部分的测试, 每个测试都继承 `MockBotTestBase`
+

+ 37 - 0
mirai-core-mock/build.gradle.kts

@@ -0,0 +1,37 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+
+plugins {
+    kotlin("jvm")
+    kotlin("plugin.serialization")
+    `maven-publish`
+    id("me.him188.kotlin-jvm-blocking-bridge")
+}
+
+version = Versions.project
+description = "Mirai core mock testing framework"
+
+kotlin {
+    explicitApiWarning()
+}
+
+dependencies {
+    api(project(":mirai-core-api"))
+    implementation(project(":mirai-core-utils"))
+    implementation(project(":mirai-core"))
+
+    implementation(`ktor-server-core`)
+    implementation(`ktor-server-netty`)
+    implementation(`java-in-memory-file-system`)
+
+}
+
+configurePublishing("mirai-core-mock")
+tasks.named("shadowJar") { enabled = false }

+ 187 - 0
mirai-core-mock/src/MockActions.kt

@@ -0,0 +1,187 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock
+
+import me.him188.kotlin.jvm.blocking.bridge.JvmBlockingBridge
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.contact.Friend
+import net.mamoe.mirai.contact.Group
+import net.mamoe.mirai.contact.MemberPermission
+import net.mamoe.mirai.contact.User
+import net.mamoe.mirai.event.broadcast
+import net.mamoe.mirai.event.events.*
+import net.mamoe.mirai.message.MessageReceipt
+import net.mamoe.mirai.message.data.MessageChain
+import net.mamoe.mirai.message.data.MessageSource
+import net.mamoe.mirai.message.data.OnlineMessageSource
+import net.mamoe.mirai.message.data.source
+import net.mamoe.mirai.mock.contact.MockFriend
+import net.mamoe.mirai.mock.contact.MockNormalMember
+import net.mamoe.mirai.mock.contact.MockStranger
+import net.mamoe.mirai.mock.contact.MockUserOrBot
+import net.mamoe.mirai.mock.database.removeMessageInfo
+import net.mamoe.mirai.mock.utils.NudgeDsl
+import net.mamoe.mirai.mock.utils.mock
+import net.mamoe.mirai.mock.utils.nudged0
+import net.mamoe.mirai.utils.cast
+
+@JvmBlockingBridge
+public object MockActions {
+
+    /**
+     * 修改 [MockUserOrBot.nick] 并广播相关事件 (如 [FriendNickChangedEvent])
+     */
+    @JvmStatic
+    public suspend fun fireNickChanged(target: MockUserOrBot, value: String) {
+        when (target) {
+            is MockFriend -> {
+                val ov = target.nick
+                target.mockApi.nick = value
+                FriendNickChangedEvent(target, ov, target.nick).broadcast()
+            }
+
+            is MockStranger -> {
+                target.mockApi.nick = value
+                // TODO: StrangerNickChangedEvent
+            }
+
+            is MockNormalMember -> {
+                val friend0 = target.bot.getFriend(target.id)
+                if (friend0 != null) {
+                    return fireNickChanged(friend0, value)
+                }
+                target.mockApi.nick = value
+            }
+
+            is MockBot -> {
+                target.nick = value
+            }
+        }
+    }
+
+    /**
+     * 修改 [MockNormalMember.nameCard] 并广播 [MemberCardChangeEvent]
+     */
+    @JvmStatic
+    public suspend fun fireNameCardChanged(member: MockNormalMember, value: String) {
+        val ov = member.nameCard
+        member.mockApi.nameCard = value
+        MemberCardChangeEvent(ov, value, member).broadcast()
+    }
+
+    /**
+     * 修改 [MockNormalMember.specialTitle] 并广播 [MemberSpecialTitleChangeEvent]
+     */
+    @JvmStatic
+    public suspend fun fireSpecialTitleChanged(member: MockNormalMember, value: String) {
+        val ov = member.specialTitle
+        member.mockApi.specialTitle = value
+        MemberSpecialTitleChangeEvent(
+            ov,
+            value,
+            member,
+            operator = member.group.owner.takeIf { it.id != member.bot.id },
+        ).broadcast()
+    }
+
+    /**
+     * 修改一名成员的权限并广播 [MemberPermissionChangeEvent]
+     */
+    @JvmStatic
+    public suspend fun firePermissionChanged(member: MockNormalMember, perm: MemberPermission) {
+        if (perm == MemberPermission.OWNER || member == member.group.owner) {
+            error("Use group.changeOwner to modify group owner")
+        }
+        val ov = member.permission
+        member.mockApi.permission = perm
+        if (member.id == member.bot.id) {
+            BotGroupPermissionChangeEvent(member.group, ov, perm)
+        } else {
+            MemberPermissionChangeEvent(member, ov, perm)
+        }.broadcast()
+    }
+
+    /**
+     * 令 [operator] 撤回一条消息
+     *
+     * @param operator 当 [operator] 为 null 时代表是发送者自己撤回
+     */
+    @JvmStatic
+    public suspend fun fireMessageRecalled(chain: MessageChain, operator: User? = null) {
+        return fireMessageRecalled(chain.source, operator)
+    }
+
+    /**
+     * 令 [operator] 撤回一条消息
+     *
+     * @param operator 当 [operator] 为 null 时代表是发送者自己撤回
+     */
+    @JvmStatic
+    public suspend fun fireMessageRecalled(source: MessageSource, operator: User? = null) {
+        if (source is OnlineMessageSource) {
+            val from = source.sender
+            when (val target = source.target) {
+                is Group -> {
+                    from.bot.mock().msgDatabase.removeMessageInfo(source)
+                    MessageRecallEvent.GroupRecall(
+                        source.bot,
+                        from.id,
+                        source.ids,
+                        source.internalIds,
+                        source.time,
+                        operator?.cast(),
+                        target,
+                        when (from) {
+                            is Bot -> target.botAsMember
+                            else -> from.cast()
+                        }
+                    ).broadcast()
+                    return
+                }
+
+                is Friend -> {
+                    from.bot.mock().msgDatabase.removeMessageInfo(source)
+                    MessageRecallEvent.FriendRecall(
+                        source.bot,
+                        source.ids,
+                        source.internalIds,
+                        source.time,
+                        from.id,
+                        from.cast()
+                    ).broadcast()
+                    return
+                }
+            }
+        }
+        error("Unsupported message source type: ${source.javaClass}")
+    }
+
+    /**
+     * 令 [operator] 撤回一条消息
+     *
+     * @param operator 当 [operator] 为 null 时代表是发送者自己撤回
+     */
+    @JvmStatic
+    public suspend fun mockFireRecalled(receipt: MessageReceipt<*>, operator: User? = null) {
+        return fireMessageRecalled(receipt.source, operator)
+    }
+
+    /**
+     * 令 [actor] 戳一下 [actee]
+     *
+     * @param actor 发起戳一戳的人
+     * @param actee 被戳的人
+     */
+    @JvmStatic
+    public suspend fun fireNudge(actor: MockUserOrBot, actee: MockUserOrBot, dsl: NudgeDsl) {
+        actor.nudged0(actee, dsl)
+    }
+
+}

+ 158 - 0
mirai-core-mock/src/MockBot.kt

@@ -0,0 +1,158 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
+
+package net.mamoe.mirai.mock
+
+import me.him188.kotlin.jvm.blocking.bridge.JvmBlockingBridge
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.contact.ContactList
+import net.mamoe.mirai.event.broadcast
+import net.mamoe.mirai.event.events.BotAvatarChangedEvent
+import net.mamoe.mirai.event.events.BotOfflineEvent
+import net.mamoe.mirai.event.events.NewFriendRequestEvent
+import net.mamoe.mirai.message.data.Image
+import net.mamoe.mirai.message.data.OnlineAudio
+import net.mamoe.mirai.mock.contact.*
+import net.mamoe.mirai.mock.database.MessageDatabase
+import net.mamoe.mirai.mock.resserver.TmpResourceServer
+import net.mamoe.mirai.mock.userprofile.UserProfileService
+import net.mamoe.mirai.mock.utils.NameGenerator
+import net.mamoe.mirai.utils.ExternalResource
+import net.mamoe.mirai.utils.cast
+import kotlin.random.Random
+
+/**
+ * 一个虚拟的机器人对象. 继承于 [Bot]
+ *
+ * @see MockBotFactory 构造 [MockBot] 的工厂, [MockBot] 的唯一构造方式
+ */
+@Suppress("unused")
+@JvmBlockingBridge
+public interface MockBot : Bot, MockContactOrBot, MockUserOrBot {
+    override val bot: MockBot get() = this
+
+    /**
+     * bot 昵称, 访问此字段时与 [nick] 一致
+     * 修改此字段时不会广播事件
+     */
+    @MockBotDSL
+    public var nickNoEvent: String
+
+    /**
+     * bot 昵称
+     *
+     * 修改此字段时会广播事件
+     */
+    override var nick: String
+
+    /**
+     * Bot 头像, 可自定义, 修改时会广播 [BotAvatarChangedEvent]
+     */
+    @set:MockBotDSL
+    override var avatarUrl: String
+
+    /// Contact API override
+    override fun getFriend(id: Long): MockFriend? = super.getFriend(id)?.cast()
+
+    override fun getFriendOrFail(id: Long): MockFriend = super.getFriendOrFail(id).cast()
+
+    override fun getGroup(id: Long): MockGroup? = super.getGroup(id)?.cast()
+
+    override fun getGroupOrFail(id: Long): MockGroup = super.getGroupOrFail(id).cast()
+
+    override fun getStranger(id: Long): MockStranger? = super.getStranger(id)?.cast()
+
+    override fun getStrangerOrFail(id: Long): MockStranger = super.getStrangerOrFail(id).cast()
+
+    override val groups: ContactList<MockGroup>
+    override val friends: ContactList<MockFriend>
+    override val strangers: ContactList<MockStranger>
+    override val otherClients: ContactList<MockOtherClient>
+    override val asFriend: MockFriend
+    override val asStranger: MockStranger
+
+    /// All mock api will not broadcast event
+
+    public val nameGenerator: NameGenerator
+    public val tmpResourceServer: TmpResourceServer
+    public val msgDatabase: MessageDatabase
+    public val userProfileService: UserProfileService
+
+    /// Mock Contact API
+
+    @MockBotDSL
+    public fun addGroup(id: Long, name: String): MockGroup
+
+    @MockBotDSL
+    public fun addGroup(id: Long, uin: Long, name: String): MockGroup
+
+    @MockBotDSL
+    public fun addFriend(id: Long, name: String): MockFriend
+
+    @MockBotDSL
+    public fun addStranger(id: Long, name: String): MockStranger
+
+    /**
+     * 将 [resource] 上传到 [临时资源服务器][tmpResourceServer],
+     * 并返回一个 [OnlineAudio] 对象, 可用于测试语音接收
+     *
+     * @see MockUser.says
+     */
+    @MockBotDSL
+    public suspend fun uploadOnlineAudio(resource: ExternalResource): OnlineAudio
+
+    /**
+     * 将 [resource] 上传到 [临时资源服务器][tmpResourceServer]
+     * 并返回一个 [Image] 对象, 可用于测试图片接收
+     *
+     * @see MockUser.says
+     */
+    @MockBotDSL
+    public suspend fun uploadMockImage(resource: ExternalResource): Image
+
+    /**
+     * 广播 [Bot] 掉线事件
+     */
+    @MockBotDSL
+    public suspend fun broadcastOfflineEvent() {
+        BotOfflineEvent.Dropped(this, java.net.SocketException("socket closed")).broadcast()
+    }
+
+    /**
+     * 广播 [Bot] 头像更新事件
+     */
+    @MockBotDSL
+    public suspend fun broadcastAvatarChangeEvent() {
+        BotAvatarChangedEvent(this).broadcast()
+    }
+
+    /**
+     * 广播新好友添加事件
+     *
+     * @see NewFriendRequestEvent
+     */
+    @MockBotDSL
+    public suspend fun broadcastNewFriendRequestEvent(
+        requester: Long,
+        requesterNick: String,
+        fromGroup: Long,
+        message: String
+    ): NewFriendRequestEvent {
+        return NewFriendRequestEvent(
+            this,
+            eventId = Random.nextLong(),
+            fromId = requester,
+            fromGroupId = fromGroup,
+            message = message,
+            fromNick = requesterNick
+        ).broadcast()
+    }
+}

+ 13 - 0
mirai-core-mock/src/MockBotDSL.kt

@@ -0,0 +1,13 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock
+
+@DslMarker
+public annotation class MockBotDSL

+ 62 - 0
mirai-core-mock/src/MockBotFactory.kt

@@ -0,0 +1,62 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock
+
+import net.mamoe.mirai.BotFactory
+import net.mamoe.mirai.Mirai
+import net.mamoe.mirai.mock.database.MessageDatabase
+import net.mamoe.mirai.mock.internal.MockBotFactoryImpl
+import net.mamoe.mirai.mock.internal.MockMiraiImpl
+import net.mamoe.mirai.mock.resserver.TmpResourceServer
+import net.mamoe.mirai.mock.userprofile.UserProfileService
+import net.mamoe.mirai.mock.utils.NameGenerator
+import net.mamoe.mirai.utils.BotConfiguration
+
+public interface MockBotFactory : BotFactory {
+
+    public interface BotBuilder {
+        public fun id(value: Long): BotBuilder
+
+        public fun nick(value: String): BotBuilder
+
+        public fun configuration(value: BotConfiguration): BotBuilder
+
+        public fun nameGenerator(value: NameGenerator): BotBuilder
+
+        public fun tmpResourceServer(server: TmpResourceServer): BotBuilder
+
+        public fun msgDatabase(db: MessageDatabase): BotBuilder
+
+        public fun userProfileService(service: UserProfileService): BotBuilder
+
+        public fun create(): MockBot
+
+        public fun createNoInstanceRegister(): MockBot
+    }
+
+    public fun newMockBotBuilder(): BotBuilder
+
+    @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
+    public companion object : MockBotFactory by MockBotFactoryImpl() {
+        init {
+            Mirai
+            net.mamoe.mirai._MiraiInstance.set(MockMiraiImpl())
+        }
+
+        @JvmStatic
+        public fun initialize() {
+            // noop
+        }
+    }
+}
+
+public inline fun MockBotFactory.BotBuilder.configuration(
+    block: BotConfiguration.() -> Unit
+): MockBotFactory.BotBuilder = configuration(BotConfiguration(block))

+ 14 - 0
mirai-core-mock/src/contact/MockAnonymousMember.kt

@@ -0,0 +1,14 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.contact
+
+import net.mamoe.mirai.contact.AnonymousMember
+
+public interface MockAnonymousMember : AnonymousMember, MockMember

+ 33 - 0
mirai-core-mock/src/contact/MockContact.kt

@@ -0,0 +1,33 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.contact
+
+import me.him188.kotlin.jvm.blocking.bridge.JvmBlockingBridge
+import net.mamoe.mirai.contact.Contact
+import net.mamoe.mirai.mock.MockBotDSL
+
+@JvmBlockingBridge
+public interface MockContact : Contact, MockContactOrBot {
+    public interface MockApi {
+        public var avatarUrl: String
+    }
+
+    /**
+     * 获取直接修改字段内容的 API, 通过该 API 修改的值都不会触发广播
+     */
+    @MockBotDSL
+    public val mockApi: MockApi
+
+    /**
+     * 修改 [avatarUrl] 的地址, 同时会广播相关事件 (如果有)
+     */
+    @MockBotDSL
+    public fun changeAvatarUrl(newAvatar: String)
+}

+ 17 - 0
mirai-core-mock/src/contact/MockContactOrBot.kt

@@ -0,0 +1,17 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.contact
+
+import net.mamoe.mirai.contact.ContactOrBot
+import net.mamoe.mirai.mock.MockBot
+
+public interface MockContactOrBot : ContactOrBot {
+    override val bot: MockBot
+}

+ 90 - 0
mirai-core-mock/src/contact/MockFriend.kt

@@ -0,0 +1,90 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.contact
+
+import me.him188.kotlin.jvm.blocking.bridge.JvmBlockingBridge
+import net.mamoe.mirai.contact.Friend
+import net.mamoe.mirai.event.broadcast
+import net.mamoe.mirai.event.events.BotInvitedJoinGroupRequestEvent
+import net.mamoe.mirai.event.events.FriendAddEvent
+import net.mamoe.mirai.event.events.FriendInputStatusChangedEvent
+import net.mamoe.mirai.mock.MockBotDSL
+import kotlin.random.Random
+
+@JvmBlockingBridge
+public interface MockFriend : Friend, MockContact, MockUser, MockMsgSyncSupport {
+    public interface MockApi : MockContact.MockApi {
+        public val contact: MockFriend
+        public var nick: String
+        public var remark: String
+        public var friendGroupId: Int
+        public override var avatarUrl: String
+    }
+
+    /**
+     * 获取直接修改字段内容的 API, 通过该 API 修改的值都不会触发广播
+     */
+    @MockBotDSL
+    public override val mockApi: MockApi
+
+    /**
+     * 修改 nick 同时广播相关事件
+     */
+    override var nick: String
+
+    /**
+     * 修改 remark 同时广播相关事件
+     */
+    override var remark: String
+
+    /**
+     * 广播好友添加事件
+     */
+    @MockBotDSL
+    public suspend fun broadcastFriendAddEvent(): FriendAddEvent {
+        return FriendAddEvent(this).broadcast()
+    }
+
+    /**
+     * 广播好友邀请 [bot] 加入一个群聊的事件
+     */
+    @MockBotDSL
+    public suspend fun broadcastInviteBotJoinGroupRequestEvent(
+        groupId: Long, groupName: String,
+    ): BotInvitedJoinGroupRequestEvent {
+        return BotInvitedJoinGroupRequestEvent(
+            bot,
+            Random.nextLong(),
+            id,
+            groupId,
+            groupName,
+            nick
+        ).broadcast()
+    }
+
+    /**
+     * 广播好友主动删除 [bot] 好友的事件
+     *
+     * 即使该函数体实现为 [delete], 也请使用该方法广播 **bot 被好友删除**,
+     * 以确保不会受到未来的事件架构变更带来的影响
+     */
+    @MockBotDSL
+    public suspend fun broadcastFriendDelete() {
+        delete()
+    }
+
+    /**
+     * 广播好友输入状态改变事件
+     */
+    @MockBotDSL
+    public suspend fun broadcastFriendInputStateChange(inputting: Boolean) {
+        FriendInputStatusChangedEvent(this, inputting).broadcast()
+    }
+}

+ 147 - 0
mirai-core-mock/src/contact/MockGroup.kt

@@ -0,0 +1,147 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+
+package net.mamoe.mirai.mock.contact
+
+import me.him188.kotlin.jvm.blocking.bridge.JvmBlockingBridge
+import net.mamoe.mirai.contact.ContactList
+import net.mamoe.mirai.contact.Group
+import net.mamoe.mirai.contact.NormalMember
+import net.mamoe.mirai.data.GroupHonorType
+import net.mamoe.mirai.data.MemberInfo
+import net.mamoe.mirai.event.broadcast
+import net.mamoe.mirai.event.events.MemberHonorChangeEvent
+import net.mamoe.mirai.event.events.MemberJoinRequestEvent
+import net.mamoe.mirai.mock.MockBot
+import net.mamoe.mirai.mock.MockBotDSL
+import net.mamoe.mirai.mock.contact.announcement.MockAnnouncements
+import net.mamoe.mirai.mock.userprofile.MockMemberInfoBuilder
+import net.mamoe.mirai.utils.cast
+import kotlin.random.Random
+
+@JvmBlockingBridge
+public interface MockGroup : Group, MockContact, MockMsgSyncSupport {
+    /** @see net.mamoe.mirai.IMirai.getUin */
+    public val uin: Long
+    override val bot: MockBot
+    override val members: ContactList<MockNormalMember>
+    override val owner: MockNormalMember
+    override val botAsMember: MockNormalMember
+    override val avatarUrl: String
+    override val announcements: MockAnnouncements
+
+    public interface MockApi : MockContact.MockApi {
+        override var avatarUrl: String
+    }
+
+    override val mockApi: MockApi
+
+    /**
+     * 群荣耀, 可直接修改此属性, 修改此属性不会广播相关事件
+     *
+     * @see changeHonorMember
+     */
+    @MockBotDSL
+    public val honorMembers: MutableMap<GroupHonorType, MockNormalMember>
+
+    /**
+     * 更改拥有群荣耀的群成员.
+     *
+     * 会自动广播 [MemberHonorChangeEvent.Achieve] 和 [MemberHonorChangeEvent.Lose] 等相关事件.
+     *
+     * 此外如果 [honorType] 是 [GroupHonorType.TALKATIVE],
+     * 会额外广播 [net.mamoe.mirai.event.events.GroupTalkativeChangeEvent].
+     *
+     * 如果不需要广播事件, 可直接更改 [MockGroup.honorMembers]
+     */
+    @MockBotDSL
+    public fun changeHonorMember(member: MockNormalMember, honorType: GroupHonorType)
+
+    /**
+     * 获取群控制面板
+     *
+     * 注, 通过本属性获取的控制面板为原始数据存储面板, 修改并不会广播相关事件, 如果需要广播事件,
+     * 请使用 [MockGroupControlPane.withActor]
+     */
+    @MockBotDSL
+    public val controlPane: MockGroupControlPane
+
+    /** 添加一位成员, 该操作不会广播任何事件
+     * @see MockMemberInfoBuilder
+     */
+    @MockBotDSL
+    public fun appendMember(mockMember: MemberInfo): MockGroup //  chain call
+
+    /**
+     * 添加一位成员, 该操作不会广播任何事件
+     * @see MockMemberInfoBuilder
+     */
+    @MockBotDSL
+    public fun addMember(mockMember: MemberInfo): MockNormalMember
+
+    /** 添加一位成员, 该操作不会广播任何事件
+     */
+    @MockBotDSL
+    public fun appendMember(uin: Long, nick: String): MockGroup =
+        appendMember(MockMemberInfoBuilder.create { uin(uin).nick(nick) })
+
+    /** 添加一位成员, 该操作不会广播任何事件
+     * @see MockMemberInfoBuilder
+     */
+    @MockBotDSL
+    public fun addMember(uin: Long, nick: String): MockNormalMember =
+        addMember(MockMemberInfoBuilder.create { uin(uin).nick(nick) })
+
+
+    /**
+     * 修改群主, 该操作会广播群转让的相关事件
+     */
+    @MockBotDSL
+    public suspend fun changeOwner(member: NormalMember)
+
+    /**
+     * 修改群主, 该操作不会广播任何事件
+     */
+    @MockBotDSL
+    public fun changeOwnerNoEventBroadcast(member: NormalMember)
+
+    /**
+     * 创建新的匿名群成员.
+     *
+     * @param id 该匿名群成员的 id, 可自定义, 建议使用 ASCII 纯文本
+     */
+    @MockBotDSL
+    public fun newAnonymous(nick: String, id: String): MockAnonymousMember
+
+    override fun get(id: Long): MockNormalMember?
+    override fun getOrFail(id: Long): MockNormalMember = super.getOrFail(id).cast()
+
+    /**
+     * 主动广播有新成员申请加入的事件
+     */
+    @MockBotDSL
+    public suspend fun broadcastNewMemberJoinRequestEvent(
+        requester: Long,
+        requesterName: String,
+        message: String,
+        invitor: Long = 0L,
+    ): MemberJoinRequestEvent {
+        return MemberJoinRequestEvent(
+            bot, Random.nextLong(),
+            message,
+            requester,
+            this.id,
+            this.name,
+            requesterName,
+            invitor.takeIf { it != 0L },
+        ).broadcast()
+    }
+}
+

+ 43 - 0
mirai-core-mock/src/contact/MockGroupControlPane.kt

@@ -0,0 +1,43 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.contact
+
+/**
+ * 群设置面板, 如果是由 [withActor] 得到的面板在操作的同时会进行事件广播
+ *
+ * 与 [MockGroup.settings] 不同的是, 该控制面板不会进行权限校检
+ */
+public interface MockGroupControlPane {
+    public val group: MockGroup
+
+    /**
+     * 如果为 [MockGroup.controlPane] 获得的原始控制面板, 此属性为 [MockGroup.botAsMember]
+     *
+     * @see withActor
+     */
+    public val currentActor: MockNormalMember
+
+    public var isAllowMemberInvite: Boolean
+
+    public var isMuteAll: Boolean
+
+    public var isAllowMemberFileUploading: Boolean
+
+    public var isAnonymousChatAllowed: Boolean
+
+    public var isAllowConfessTalk: Boolean
+
+    public var groupName: String
+
+    /**
+     * 通过 [withActor] 得到的 [MockGroupControlPane] 在修改属性的同时会广播相关事件
+     */
+    public fun withActor(actor: MockNormalMember): MockGroupControlPane
+}

+ 33 - 0
mirai-core-mock/src/contact/MockMember.kt

@@ -0,0 +1,33 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.contact
+
+import me.him188.kotlin.jvm.blocking.bridge.JvmBlockingBridge
+import net.mamoe.mirai.contact.Member
+import net.mamoe.mirai.contact.MemberPermission
+import net.mamoe.mirai.mock.MockBotDSL
+
+@JvmBlockingBridge
+public interface MockMember : Member, MockContact, MockUser {
+    public interface MockApi : MockContact.MockApi {
+        public val member: MockMember
+        public var nick: String
+        public var remark: String
+        public var permission: MemberPermission
+    }
+
+    override val group: MockGroup
+
+    /**
+     * 获取直接修改字段内容的 API, 通过该 API 修改的值都不会触发广播
+     */
+    @MockBotDSL
+    public override val mockApi: MockApi
+}

+ 21 - 0
mirai-core-mock/src/contact/MockMsgSyncSupport.kt

@@ -0,0 +1,21 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.contact
+
+import net.mamoe.mirai.message.data.MessageChain
+import net.mamoe.mirai.mock.MockBotDSL
+
+public interface MockMsgSyncSupport : MockContact {
+    /**
+     * 广播消息同步事件
+     */
+    @MockBotDSL
+    public suspend fun broadcastMsgSyncEvent(message: MessageChain, time: Int)
+}

+ 107 - 0
mirai-core-mock/src/contact/MockNormalMember.kt

@@ -0,0 +1,107 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.contact
+
+import kotlinx.coroutines.cancel
+import me.him188.kotlin.jvm.blocking.bridge.JvmBlockingBridge
+import net.mamoe.mirai.contact.NormalMember
+import net.mamoe.mirai.event.broadcast
+import net.mamoe.mirai.event.events.BotLeaveEvent
+import net.mamoe.mirai.event.events.MemberJoinEvent
+import net.mamoe.mirai.event.events.MemberLeaveEvent
+import net.mamoe.mirai.mock.MockBotDSL
+import net.mamoe.mirai.mock.utils.broadcastBlocking
+import java.util.concurrent.CancellationException
+
+@JvmBlockingBridge
+public interface MockNormalMember : NormalMember, MockMember {
+    public interface MockApi : MockMember.MockApi {
+        override val member: MockNormalMember
+        public var lastSpeakTimestamp: Int
+        public var joinTimestamp: Int
+        public var nameCard: String
+        public var specialTitle: String
+
+        /**
+         * 单位 秒
+         */
+        public var muteTimeEndTimestamp: Long
+    }
+
+    /**
+     * 获取直接修改字段内容的 API, 通过该 API 修改的值都不会触发广播
+     */
+    @MockBotDSL
+    override val mockApi: MockApi
+
+    /**
+     * 广播该成员加入了群
+     */
+    @MockBotDSL
+    public suspend fun broadcastMemberJoinEvent() {
+        broadcastMemberJoinEvent(null)
+    }
+
+    /**
+     * 广播该成员加入了群
+     *
+     * @param invitor 邀请者, 当邀请者不为 `null` 时广播 [MemberJoinEvent.Invite]
+     */
+    @MockBotDSL
+    public suspend fun broadcastMemberJoinEvent(invitor: NormalMember?) {
+        if (invitor == null) {
+            MemberJoinEvent.Active(this)
+        } else {
+            MemberJoinEvent.Invite(this, invitor)
+        }.broadcast()
+    }
+
+    /**
+     * 广播该群员主动离开了群, 此方法同时会在 [group] 中移除此成员
+     */
+    @MockBotDSL
+    public suspend fun broadcastMemberLeave() {
+        if (group.members.delegate.remove(this)) {
+            MemberLeaveEvent.Quit(this).broadcast()
+            cancel(CancellationException("Member $id left"))
+        }
+    }
+
+    /**
+     * 广播该群员将 [bot] 踢出了群聊, 并同时在 [bot] 的群聊列表里删除该群
+     */
+    @MockBotDSL
+    public suspend fun broadcastKickBot() {
+        if (bot.groups.delegate.remove(group)) {
+            BotLeaveEvent.Kick(this).broadcast()
+            cancel(CancellationException("Bot was kicked"))
+        }
+    }
+
+    /**
+     * 广播 该群成员被 [actor] 踢出, 此方法同时会在 [group] 中移除此成员
+     */
+    @MockBotDSL
+    public suspend fun broadcastKickedBy(actor: MockNormalMember) {
+        if (group.members.delegate.remove(this)) {
+            MemberLeaveEvent.Kick(this, actor).broadcastBlocking()
+            cancel(CancellationException("Member $id kicked"))
+        }
+    }
+
+    /**
+     * 广播该群员 禁言了 [target], 此方法没有权限校检
+     *
+     * @param durationSeconds 0 为取消禁言
+     * @param target 被禁言群成员
+     */
+    @MockBotDSL
+    public suspend fun broadcastMute(target: MockNormalMember, durationSeconds: Int)
+}

+ 14 - 0
mirai-core-mock/src/contact/MockOtherClient.kt

@@ -0,0 +1,14 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.contact
+
+import net.mamoe.mirai.contact.OtherClient
+
+public interface MockOtherClient : OtherClient, MockContact

+ 62 - 0
mirai-core-mock/src/contact/MockStranger.kt

@@ -0,0 +1,62 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.contact
+
+import me.him188.kotlin.jvm.blocking.bridge.JvmBlockingBridge
+import net.mamoe.mirai.contact.Stranger
+import net.mamoe.mirai.event.broadcast
+import net.mamoe.mirai.event.events.StrangerAddEvent
+import net.mamoe.mirai.event.events.StrangerRelationChangeEvent
+import net.mamoe.mirai.mock.MockBotDSL
+
+@JvmBlockingBridge
+public interface MockStranger : Stranger, MockContact, MockUser {
+    public interface MockApi : MockContact.MockApi {
+        public val contact: MockStranger
+        public var nick: String
+        public var remark: String
+    }
+
+    /**
+     * 广播陌生人加入
+     */
+    @MockBotDSL
+    public suspend fun broadcastStrangerAddEvent(): StrangerAddEvent {
+        return StrangerAddEvent(this).broadcast()
+    }
+
+    /**
+     * 添加为好友
+     */
+    @MockBotDSL
+    public suspend fun addAsFriend() {
+        this.bot.addFriend(this.id, this.nick)
+        bot.strangers.delegate.remove(this)
+        StrangerRelationChangeEvent.Friended(this, bot.getFriend(this.id)!!).broadcast()
+    }
+
+    /**
+     * 获取直接修改字段内容的 API, 通过该 API 修改的值都不会触发广播
+     */
+    @MockBotDSL
+    public override val mockApi: MockApi
+
+    /**
+     * 广播陌生人主动解除与 [bot] 的关系的事件
+     *
+     * @suppress
+     * 即使该函数体实现为 [delete], 也请使用该方法广播 **bot 被陌生人删除**,
+     * 以确保不会受到未来的事件架构变更带来的影响
+     */
+    @MockBotDSL
+    public suspend fun broadcastStrangerDeleteEvent() {
+        delete()
+    }
+}

+ 104 - 0
mirai-core-mock/src/contact/MockUser.kt

@@ -0,0 +1,104 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
+
+package net.mamoe.mirai.mock.contact
+
+import me.him188.kotlin.jvm.blocking.bridge.JvmBlockingBridge
+import net.mamoe.mirai.contact.User
+import net.mamoe.mirai.event.events.GroupMessageEvent
+import net.mamoe.mirai.message.MessageReceipt
+import net.mamoe.mirai.message.data.*
+import net.mamoe.mirai.mock.MockActions
+import net.mamoe.mirai.mock.MockActions.mockFireRecalled
+import net.mamoe.mirai.mock.MockBotDSL
+import net.mamoe.mirai.mock.utils.broadcastMockEvents
+import net.mamoe.mirai.utils.JavaFriendlyAPI
+import java.util.function.Consumer
+import java.util.function.Supplier
+import kotlin.internal.LowPriorityInOverloadResolution
+
+@JvmBlockingBridge
+public interface MockUser : MockContact, MockUserOrBot, User {
+    /**
+     * 令 [MockUserOrBot] 撤回一条消息
+     *
+     * @see [mockFireRecalled]
+     */
+    @MockBotDSL
+    public suspend fun recallMessage(message: MessageChain) {
+        broadcastMockEvents {
+            message.recalledBy(this@MockUser)
+        }
+    }
+
+    /**
+     * 令 [MockUserOrBot] 撤回一条消息
+     *
+     * @see [mockFireRecalled]
+     */
+    @MockBotDSL
+    public suspend fun recallMessage(message: MessageSource) {
+        broadcastMockEvents {
+            message.recalledBy(this@MockUser)
+        }
+    }
+
+    /**
+     * 令 [MockUserOrBot] 撤回一条消息
+     *
+     * @see [mockFireRecalled]
+     */
+    @MockBotDSL
+    public suspend fun recallMessage(message: MessageReceipt<*>) {
+        mockFireRecalled(message, this)
+    }
+
+
+    /**
+     * 令 [MockContact] 发出一条信息, 并广播相关的消息事件 (如 [GroupMessageEvent])
+     *
+     * @return 返回 [MockContact] 发出的消息 (包含 [MessageSource]),
+     *         可用于测试消息发出后马上撤回 `says().recall()`
+     *
+     * @see [MockActions.mockFireRecalled]
+     * @see [MockUser.recallMessage]
+     */
+    @MockBotDSL
+    public suspend fun says(message: MessageChain): MessageChain
+
+
+    @MockBotDSL
+    public suspend fun says(message: Message): MessageChain {
+        return says(message.toMessageChain())
+    }
+
+    @MockBotDSL
+    public suspend fun says(message: String): MessageChain {
+        return says(PlainText(message))
+    }
+
+    @JavaFriendlyAPI
+    @LowPriorityInOverloadResolution
+    public suspend fun says(message: Consumer<MessageChainBuilder>): MessageChain {
+        return says(buildMessageChain { message.accept(this) })
+    }
+
+
+    @JavaFriendlyAPI
+    @LowPriorityInOverloadResolution
+    public suspend fun says(message: Supplier<Message>): MessageChain {
+        return says(message.get())
+    }
+
+    public suspend fun says(message: suspend MessageChainBuilder.() -> Unit): MessageChain {
+        return says(buildMessageChain { message(this) })
+    }
+}

+ 18 - 0
mirai-core-mock/src/contact/MockUserOrBot.kt

@@ -0,0 +1,18 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
+
+package net.mamoe.mirai.mock.contact
+
+import me.him188.kotlin.jvm.blocking.bridge.JvmBlockingBridge
+import net.mamoe.mirai.contact.UserOrBot
+
+@JvmBlockingBridge
+public interface MockUserOrBot : MockContactOrBot, UserOrBot

+ 69 - 0
mirai-core-mock/src/contact/announcement/MockAnnouncements.kt

@@ -0,0 +1,69 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.contact.announcement
+
+import net.mamoe.mirai.contact.Group
+import net.mamoe.mirai.contact.NormalMember
+import net.mamoe.mirai.contact.announcement.*
+import net.mamoe.mirai.mock.MockBotDSL
+import net.mamoe.mirai.utils.MiraiInternalApi
+
+public interface MockAnnouncements : Announcements {
+    /**
+     * 直接以 [actor] 的身份推送一则公告
+     *
+     * @param events 当为 `true` 时会广播相关事件
+     * @param announcement 见 [OfflineAnnouncement], [OfflineAnnouncement.create]
+     */
+    @MockBotDSL
+    public fun mockPublish(
+        announcement: Announcement,
+        actor: NormalMember,
+        events: Boolean
+    ): OnlineAnnouncement
+
+    @MockBotDSL
+    public fun mockPublish(
+        announcement: Announcement,
+        actor: NormalMember,
+    ): OnlineAnnouncement = mockPublish(announcement, actor, false)
+}
+
+public class MockOnlineAnnouncement @MiraiInternalApi public constructor(
+    override val content: String,
+    override val parameters: AnnouncementParameters,
+    override val senderId: Long,
+    override val fid: String = "",
+    override val allConfirmed: Boolean,
+    override val confirmedMembersCount: Int,
+    override val publicationTime: Long
+) : OnlineAnnouncement {
+
+    override lateinit var group: Group
+    override val sender: NormalMember? get() = group[senderId]
+}
+
+internal fun MockOnlineAnnouncement.copy(
+    content: String = this.content,
+    parameters: AnnouncementParameters = this.parameters,
+    senderId: Long = this.senderId,
+    fid: String = this.fid,
+    allConfirmed: Boolean = this.allConfirmed,
+    confirmedMembersCount: Int = this.confirmedMembersCount,
+    publicationTime: Long = this.publicationTime,
+): MockOnlineAnnouncement = MockOnlineAnnouncement(
+    content = content,
+    parameters = parameters,
+    senderId = senderId,
+    fid = fid,
+    allConfirmed = allConfirmed,
+    confirmedMembersCount = confirmedMembersCount,
+    publicationTime = publicationTime,
+)

+ 104 - 0
mirai-core-mock/src/database/MessageDatabase.kt

@@ -0,0 +1,104 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.database
+
+import net.mamoe.mirai.contact.Contact
+import net.mamoe.mirai.contact.roaming.RoamingMessageFilter
+import net.mamoe.mirai.message.data.MessageChain
+import net.mamoe.mirai.message.data.MessageSource
+import net.mamoe.mirai.message.data.MessageSourceKind
+import net.mamoe.mirai.mock.MockBot
+import net.mamoe.mirai.mock.internal.db.MsgDatabaseImpl
+import net.mamoe.mirai.utils.concatAsLong
+
+/**
+ * 一个消息数据库
+ *
+ * 该数据库用于存储发送者, 发送目标, 发送类型 等数据,
+ * 用于支持 撤回/消息获取 等相关的功能的实现
+ *
+ * 一般在测试结束后销毁整个数据库
+ */
+public interface MessageDatabase {
+    /**
+     * implementation note: 该方法可能同时被多个线程同时调用
+     *
+     * @param time 单位秒
+     */
+    public fun newMessageInfo(
+        sender: Long, subject: Long, kind: MessageSourceKind,
+        time: Long,
+        message: MessageChain,
+    ): MessageInfo
+
+    public fun queryMessageInfo(msgId: Long): MessageInfo?
+
+    public fun queryMessageInfosBy(
+        subject: Long, kind: MessageSourceKind,
+        contact: Contact,
+        timeStart: Long,
+        timeEnd: Long,
+        filter: RoamingMessageFilter
+    ): Sequence<MessageInfo>
+
+    /**
+     * implementation note: 该方法可能同时被多个线程同时调用
+     */
+    public fun removeMessageInfo(msgId: Long)
+
+    /**
+     * 断开与数据库的连接, 在 [MockBot.close] 时会自动调用
+     */
+    public fun disconnect()
+
+    /**
+     * 建立与数据库的连接, 在 [MockBot] 构造后马上调用,
+     * 抛出任何错误都会中断 [MockBot] 的初始化
+     */
+    public fun connect()
+
+    public companion object {
+        @JvmStatic
+        public fun newDefaultDatabase(): MessageDatabase {
+            return MsgDatabaseImpl()
+        }
+    }
+}
+
+public data class MessageInfo(
+    public val mixinedMsgId: Long,
+    public val sender: Long,
+    public val subject: Long,
+    public val kind: MessageSourceKind,
+    public val time: Long, // seconds
+    public val message: MessageChain,
+) {
+    // ids
+    public val id: Int get() = (mixinedMsgId shr 32).toInt()
+
+    // internalIds
+    public val internal: Int get() = mixinedMsgId.toInt()
+}
+
+public fun mockMsgDatabaseId(id: Int, internalId: Int): Long {
+    return id.concatAsLong(internalId)
+}
+
+public fun MessageDatabase.removeMessageInfo(id: Int, internalId: Int) {
+    removeMessageInfo(mockMsgDatabaseId(id, internalId))
+}
+
+public fun MessageDatabase.queryMessageInfo(ids: IntArray, internalIds: IntArray): MessageInfo? {
+    return queryMessageInfo(mockMsgDatabaseId(ids[0], internalIds[0]))
+}
+
+public fun MessageDatabase.removeMessageInfo(source: MessageSource) {
+    removeMessageInfo(source.ids[0], source.internalIds[0])
+}

+ 105 - 0
mirai-core-mock/src/internal/MockBotFactoryImpl.kt

@@ -0,0 +1,105 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.internal
+
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.mock.MockBot
+import net.mamoe.mirai.mock.MockBotFactory
+import net.mamoe.mirai.mock.database.MessageDatabase
+import net.mamoe.mirai.mock.resserver.TmpResourceServer
+import net.mamoe.mirai.mock.userprofile.UserProfileService
+import net.mamoe.mirai.mock.utils.NameGenerator
+import net.mamoe.mirai.utils.BotConfiguration
+import net.mamoe.mirai.utils.lateinitMutableProperty
+import kotlin.math.absoluteValue
+import kotlin.random.Random
+
+internal class MockBotFactoryImpl : MockBotFactory {
+    override fun newMockBotBuilder(): MockBotFactory.BotBuilder {
+        return object : MockBotFactory.BotBuilder {
+            var id: Long = Random.nextLong().absoluteValue
+            var nick_: String by lateinitMutableProperty {
+                "Mock Bot $id"
+            }
+            var configuration_: BotConfiguration by lateinitMutableProperty { BotConfiguration { } }
+            var nameGenerator: NameGenerator = NameGenerator.getDefault()
+            var tmpResourceServer_: TmpResourceServer by lateinitMutableProperty {
+                TmpResourceServer.newInMemoryTmpResourceServer()
+            }
+            var msgDb: MessageDatabase by lateinitMutableProperty {
+                MessageDatabase.newDefaultDatabase()
+            }
+            var userProfileService: UserProfileService by lateinitMutableProperty {
+                UserProfileService.getInstance()
+            }
+
+            override fun id(value: Long): MockBotFactory.BotBuilder = apply {
+                this.id = value
+            }
+
+            override fun nick(value: String): MockBotFactory.BotBuilder = apply {
+                this.nick_ = value
+            }
+
+            override fun configuration(value: BotConfiguration): MockBotFactory.BotBuilder = apply {
+                this.configuration_ = value
+            }
+
+            override fun nameGenerator(value: NameGenerator): MockBotFactory.BotBuilder = apply {
+                this.nameGenerator = value
+            }
+
+            override fun tmpResourceServer(server: TmpResourceServer): MockBotFactory.BotBuilder = apply {
+                tmpResourceServer_ = server
+            }
+
+            override fun msgDatabase(db: MessageDatabase): MockBotFactory.BotBuilder = apply {
+                msgDb = db
+            }
+
+            override fun userProfileService(service: UserProfileService): MockBotFactory.BotBuilder = apply {
+                userProfileService = service
+            }
+
+            override fun createNoInstanceRegister(): MockBot {
+                return MockBotImpl(
+                    configuration_,
+                    id,
+                    nick_,
+                    nameGenerator,
+                    tmpResourceServer_,
+                    msgDb,
+                    userProfileService,
+                )
+            }
+
+            @Suppress("INVISIBLE_MEMBER")
+            override fun create(): MockBot {
+                return createNoInstanceRegister().also {
+                    Bot._instances[id] = it
+                }
+            }
+        }
+    }
+
+    override fun newBot(qq: Long, password: String, configuration: BotConfiguration): Bot {
+        return newMockBotBuilder()
+            .id(qq)
+            .configuration(configuration)
+            .create()
+    }
+
+    override fun newBot(qq: Long, passwordMd5: ByteArray, configuration: BotConfiguration): Bot {
+        return newMockBotBuilder()
+            .id(qq)
+            .configuration(configuration)
+            .create()
+    }
+}

+ 189 - 0
mirai-core-mock/src/internal/MockBotImpl.kt

@@ -0,0 +1,189 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE", "CANNOT_OVERRIDE_INVISIBLE_MEMBER")
+
+package net.mamoe.mirai.mock.internal
+
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.isActive
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.Mirai
+import net.mamoe.mirai.contact.AvatarSpec
+import net.mamoe.mirai.contact.ContactList
+import net.mamoe.mirai.contact.ContactOrBot
+import net.mamoe.mirai.contact.MemberPermission
+import net.mamoe.mirai.contact.friendgroup.FriendGroups
+import net.mamoe.mirai.event.EventChannel
+import net.mamoe.mirai.event.GlobalEventChannel
+import net.mamoe.mirai.event.broadcast
+import net.mamoe.mirai.event.events.*
+import net.mamoe.mirai.internal.network.component.ComponentStorage
+import net.mamoe.mirai.internal.network.component.ConcurrentComponentStorage
+import net.mamoe.mirai.internal.network.components.EventDispatcher
+import net.mamoe.mirai.message.data.OnlineAudio
+import net.mamoe.mirai.mock.MockBot
+import net.mamoe.mirai.mock.contact.MockFriend
+import net.mamoe.mirai.mock.contact.MockGroup
+import net.mamoe.mirai.mock.contact.MockOtherClient
+import net.mamoe.mirai.mock.contact.MockStranger
+import net.mamoe.mirai.mock.database.MessageDatabase
+import net.mamoe.mirai.mock.internal.components.MockEventDispatcherImpl
+import net.mamoe.mirai.mock.internal.contact.*
+import net.mamoe.mirai.mock.internal.contact.friendfroup.MockFriendGroups
+import net.mamoe.mirai.mock.internal.serverfs.TmpResourceServerImpl
+import net.mamoe.mirai.mock.resserver.TmpResourceServer
+import net.mamoe.mirai.mock.userprofile.UserProfileService
+import net.mamoe.mirai.mock.utils.NameGenerator
+import net.mamoe.mirai.mock.utils.broadcastBlocking
+import net.mamoe.mirai.mock.utils.simpleMemberInfo
+import net.mamoe.mirai.utils.*
+import java.util.concurrent.CancellationException
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.coroutines.CoroutineContext
+import net.mamoe.mirai.internal.utils.subLoggerImpl as subLog
+
+internal class MockBotImpl(
+    override val configuration: BotConfiguration,
+    override val id: Long,
+    nick: String,
+    override val nameGenerator: NameGenerator,
+    override val tmpResourceServer: TmpResourceServer,
+    override val msgDatabase: MessageDatabase,
+    override val userProfileService: UserProfileService,
+) : MockBot, Bot, ContactOrBot {
+    private val loginBefore = AtomicBoolean(false)
+    override var nickNoEvent: String = nick
+    override var nick: String
+        get() = nickNoEvent
+        set(value) {
+            val ov = nickNoEvent
+            if (value == ov) return
+            nickNoEvent = value
+            BotNickChangedEvent(this, ov, value).broadcastBlocking()
+        }
+
+    override var avatarUrl: String
+        get() = asFriend.avatarUrl
+        set(value) {
+            asFriend.mockApi.avatarUrl = value
+            BotAvatarChangedEvent(this).broadcastBlocking()
+        }
+
+    override fun avatarUrl(spec: AvatarSpec): String {
+        return avatarUrl
+    }
+
+    override val logger: MiraiLogger by lazy {
+        configuration.botLoggerSupplier(this)
+    }
+
+    init {
+        if (tmpResourceServer is TmpResourceServerImpl) {
+            // Not using logger.subLogger caused by kotlin compile error
+            tmpResourceServer.logger =
+                subLog(this.logger, "TmpFsServer").takeUnless { it == this.logger } ?: kotlin.run {
+                    MiraiLogger.Factory.create(TmpResourceServerImpl::class.java, "TFS $id")
+                }
+        }
+        tmpResourceServer.startupServer()
+        msgDatabase.connect()
+    }
+
+    val components: ComponentStorage by lazy {
+        ConcurrentComponentStorage {
+            set(EventDispatcher, MockEventDispatcherImpl(coroutineContext, logger))
+        }
+    }
+
+    @TestOnly
+    internal suspend fun joinEventBroadcast() {
+        components[EventDispatcher].joinBroadcast()
+    }
+
+    override suspend fun login() {
+        BotOnlineEvent(this).broadcast()
+        if (!loginBefore.compareAndSet(false, true)) {
+            BotReloginEvent(this, null).broadcast()
+        }
+    }
+
+
+    @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
+    override fun close(cause: Throwable?) {
+        tmpResourceServer.close()
+        Bot._instances.remove(id, this)
+        cancel(when (cause) {
+            null -> CancellationException("Bot cancelled")
+            else -> CancellationException(cause.message).also { it.initCause(cause) }
+        })
+    }
+
+    override val groups: ContactList<MockGroup> = ContactList()
+    override val friends: ContactList<MockFriend> = ContactList()
+    override val strangers: ContactList<MockStranger> = ContactList()
+    override val otherClients: ContactList<MockOtherClient> = ContactList()
+    override val friendGroups: FriendGroups = MockFriendGroups(this)
+
+    @Suppress("DEPRECATION")
+    override fun addGroup(id: Long, name: String): MockGroup =
+        addGroup(id, Mirai.calculateGroupUinByGroupCode(id), name)
+
+    override fun addGroup(id: Long, uin: Long, name: String): MockGroup {
+        val group = MockGroupImpl(coroutineContext, this, id, uin, name)
+        groups.delegate.add(group)
+        group.appendMember(simpleMemberInfo(this.id, this.nick, permission = MemberPermission.OWNER))
+        return group
+    }
+
+    override fun addFriend(id: Long, name: String): MockFriend {
+        val friend = MockFriendImpl(coroutineContext, this, id, name, "")
+        friends.delegate.add(friend)
+        return friend
+    }
+
+    override fun addStranger(id: Long, name: String): MockStranger {
+        val stranger = MockStrangerImpl(coroutineContext, this, id, "", name)
+        strangers.delegate.add(stranger)
+        return stranger
+    }
+
+    override val isOnline: Boolean get() = isActive
+    override val eventChannel: EventChannel<BotEvent> =
+        GlobalEventChannel.filterIsInstance<BotEvent>().filter { it.bot === this@MockBotImpl }
+
+    override val asFriend: MockFriend by lazy {
+        MockFriendImpl(coroutineContext, this, id, nick, "").also { basm ->
+            @Suppress("QUALIFIED_SUPERTYPE_EXTENDED_BY_OTHER_SUPERTYPE", "RemoveExplicitSuperQualifier")
+            basm.initAvatarUrl(super<ContactOrBot>.avatarUrl(spec = AvatarSpec.LARGEST))
+        }
+    }
+    override val asStranger: MockStranger by lazy {
+        MockStrangerImpl(coroutineContext, this, id, "", nick)
+    }
+
+    override val coroutineContext: CoroutineContext by lazy {
+        configuration.parentCoroutineContext.childScopeContext()
+    }
+
+    override suspend fun uploadOnlineAudio(resource: ExternalResource): OnlineAudio {
+        return resource.mockImplUploadAudioAsOnline(this)
+    }
+
+    override suspend fun uploadMockImage(resource: ExternalResource): MockImage {
+        val md5 = resource.md5
+        val format = resource.formatName
+
+        return MockImage(generateImageId(md5, format), bot.tmpResourceServer.uploadResourceAsImage(resource).toString())
+    }
+
+    override fun toString(): String {
+        return "MockBot($id)"
+    }
+}

+ 339 - 0
mirai-core-mock/src/internal/MockMiraiImpl.kt

@@ -0,0 +1,339 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE", "CANNOT_OVERRIDE_INVISIBLE_MEMBER")
+
+package net.mamoe.mirai.mock.internal
+
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.BotFactory
+import net.mamoe.mirai.contact.*
+import net.mamoe.mirai.data.FriendInfo
+import net.mamoe.mirai.data.StrangerInfo
+import net.mamoe.mirai.data.UserProfile
+import net.mamoe.mirai.event.Event
+import net.mamoe.mirai.event.broadcast
+import net.mamoe.mirai.event.events.*
+import net.mamoe.mirai.internal.MiraiImpl
+import net.mamoe.mirai.internal.network.components.EventDispatcher
+import net.mamoe.mirai.message.action.Nudge
+import net.mamoe.mirai.message.data.*
+import net.mamoe.mirai.mock.MockBotFactory
+import net.mamoe.mirai.mock.contact.MockGroup
+import net.mamoe.mirai.mock.database.queryMessageInfo
+import net.mamoe.mirai.mock.internal.contact.AQQ_RECALL_FAILED_MESSAGE
+import net.mamoe.mirai.mock.internal.contact.MockFriendImpl
+import net.mamoe.mirai.mock.internal.contact.MockImage
+import net.mamoe.mirai.mock.internal.contact.MockStrangerImpl
+import net.mamoe.mirai.mock.utils.mock
+import net.mamoe.mirai.mock.utils.simpleMemberInfo
+import net.mamoe.mirai.utils.currentTimeSeconds
+
+internal class MockMiraiImpl : MiraiImpl() {
+    override suspend fun solveBotInvitedJoinGroupRequestEvent(
+        bot: Bot,
+        eventId: Long,
+        invitorId: Long,
+        groupId: Long,
+        accept: Boolean
+    ) {
+        bot.mock()
+        if (accept) {
+            val group = bot.addGroup(groupId, bot.nameGenerator.nextGroupName())
+            group.appendMember(
+                simpleMemberInfo(
+                    uin = 111111111,
+                    permission = MemberPermission.OWNER,
+                    name = "MockMember - Owner",
+                    nameCard = "Custom NameCard",
+                )
+            ).appendMember(
+                simpleMemberInfo(
+                    uin = 222222222,
+                    permission = MemberPermission.ADMINISTRATOR,
+                    name = "MockMember - Administrator",
+                    nameCard = "root",
+                )
+            )
+
+            group.appendMember(
+                simpleMemberInfo(
+                    uin = bot.id,
+                    permission = MemberPermission.MEMBER,
+                    name = bot.nick,
+                )
+            )
+
+
+            if (invitorId != 0L) {
+                val invitor = group[invitorId] ?: kotlin.run {
+                    group.addMember(
+                        simpleMemberInfo(
+                            uin = invitorId,
+                            permission = MemberPermission.ADMINISTRATOR,
+                            name = bot.getFriend(invitorId)?.nick ?: "A random invitor",
+                            nameCard = "invitor",
+                        )
+                    )
+                }
+                BotJoinGroupEvent.Invite(invitor)
+            } else {
+                BotJoinGroupEvent.Active(group)
+            }.broadcast()
+        }
+    }
+
+    override suspend fun solveMemberJoinRequestEvent(
+        bot: Bot,
+        eventId: Long,
+        fromId: Long,
+        fromNick: String,
+        groupId: Long,
+        accept: Boolean?,
+        blackList: Boolean,
+        message: String
+    ) {
+        if (accept == null || !accept) return // ignore
+
+        val member = bot.getGroupOrFail(groupId).mock().addMember(
+            simpleMemberInfo(
+                uin = fromId,
+                name = fromNick,
+                permission = MemberPermission.MEMBER
+            )
+        )
+        MemberJoinEvent.Active(member).broadcast()
+    }
+
+    override suspend fun solveNewFriendRequestEvent(
+        bot: Bot,
+        eventId: Long,
+        fromId: Long,
+        fromNick: String,
+        accept: Boolean,
+        blackList: Boolean
+    ) {
+        if (!accept) return
+
+        // No event broadcast in mirai-core
+        bot.mock().addFriend(fromId, fromNick)
+    }
+
+    override fun getUin(contactOrBot: ContactOrBot): Long {
+        if (contactOrBot is MockGroup) return contactOrBot.uin
+
+        return super.getUin(contactOrBot)
+    }
+
+    override suspend fun muteAnonymousMember(
+        bot: Bot,
+        anonymousId: String,
+        anonymousNick: String,
+        groupId: Long,
+        seconds: Int
+    ) {
+        // noop
+    }
+
+    override suspend fun recallFriendMessageRaw(
+        bot: Bot,
+        targetId: Long,
+        messageIds: IntArray,
+        messageInternalIds: IntArray,
+        time: Int
+    ): Boolean {
+        val info = bot.mock().msgDatabase.queryMessageInfo(messageIds, messageInternalIds) ?: return false
+        if (info.kind != MessageSourceKind.FRIEND) return false
+        if (info.sender != bot.id) return false
+        if (currentTimeSeconds() - info.time > 120) return false
+        bot.msgDatabase.removeMessageInfo(info.mixinedMsgId)
+
+        // MessageRecallEvent.FriendRecall() // TODO: Unknown Logic
+
+        return true
+    }
+
+    override suspend fun recallGroupMessageRaw(
+        bot: Bot,
+        groupCode: Long,
+        messageIds: IntArray,
+        messageInternalIds: IntArray
+    ): Boolean {
+        val info = bot.mock().msgDatabase.queryMessageInfo(messageIds, messageInternalIds) ?: return false
+        if (info.kind != MessageSourceKind.GROUP) return false
+        val group = bot.getGroup(info.subject) ?: return false
+        val canDelete = when (group.botPermission) {
+            MemberPermission.OWNER -> true
+            MemberPermission.ADMINISTRATOR -> kotlin.run w@{
+                val member = group.getMember(info.sender) ?: return@w true
+                member.permission == MemberPermission.MEMBER
+            }
+            else -> kotlin.run w@{
+                if (info.sender != bot.id) return@w false
+                currentTimeSeconds() - info.time <= 120
+            }
+        }
+        if (!canDelete) return false
+        bot.msgDatabase.removeMessageInfo(info.mixinedMsgId)
+
+        MessageRecallEvent.GroupRecall(
+            bot,
+            info.sender,
+            messageIds,
+            messageInternalIds,
+            info.time.toInt(),
+            null,
+            group,
+            group[info.sender] ?: return true
+        ).broadcast()
+
+        return true
+    }
+
+    override suspend fun recallGroupTempMessageRaw(
+        bot: Bot,
+        groupUin: Long,
+        targetId: Long,
+        messageIds: IntArray,
+        messageInternalIds: IntArray,
+        time: Int
+    ): Boolean = false // TODO: No recall event
+
+    override suspend fun recallMessage(bot: Bot, source: MessageSource) {
+        fun doFailed() {
+            error("Failed to recall message #${source.ids.contentToString()}: $AQQ_RECALL_FAILED_MESSAGE")
+        }
+        if (source is OnlineMessageSource) {
+            when (source) {
+                is OnlineMessageSource.Incoming.FromFriend,
+                is OnlineMessageSource.Outgoing.ToFriend,
+                -> {
+                    val resp = recallFriendMessageRaw(
+                        bot,
+                        source.subject.id,
+                        source.ids,
+                        source.internalIds,
+                        source.time
+                    )
+                    if (!resp) doFailed()
+                }
+                is OnlineMessageSource.Incoming.FromGroup,
+                is OnlineMessageSource.Outgoing.ToGroup,
+                -> {
+                    val resp = recallGroupMessageRaw(
+                        bot,
+                        source.subject.id,
+                        source.ids,
+                        source.internalIds
+                    )
+                    if (!resp) doFailed()
+                }
+                else -> {
+                    // TODO: No Event
+                }
+            }
+        } else {
+            source as OfflineMessageSource
+            when (source.kind) {
+                MessageSourceKind.GROUP -> {
+                    val resp = recallGroupMessageRaw(
+                        bot,
+                        source.targetId,
+                        source.ids,
+                        source.internalIds
+                    )
+                    if (!resp) doFailed()
+                }
+                MessageSourceKind.FRIEND -> {
+                    val resp = recallFriendMessageRaw(
+                        bot,
+                        source.targetId,
+                        source.ids,
+                        source.internalIds,
+                        source.time
+                    )
+                    if (!resp) doFailed()
+                }
+                MessageSourceKind.TEMP -> {
+                    // TODO: No Event
+                }
+                MessageSourceKind.STRANGER -> {
+                    // TODO: No Event
+                }
+            }
+        }
+    }
+
+    override suspend fun sendNudge(bot: Bot, nudge: Nudge, receiver: Contact): Boolean {
+        NudgeEvent(
+            from = bot,
+            target = nudge.target,
+            subject = receiver,
+            action = "戳了戳",
+            suffix = ""
+        ).broadcast()
+        return true
+    }
+
+    override suspend fun queryProfile(bot: Bot, targetId: Long): UserProfile {
+        return bot.mock().userProfileService.doQueryUserProfile(targetId)
+    }
+
+    override val BotFactory: BotFactory get() = MockBotFactory
+
+    /*override suspend fun getGroupVoiceDownloadUrl(bot: Bot, md5: ByteArray, groupId: Long, dstUin: Long): String {
+        return super.getGroupVoiceDownloadUrl(bot, md5, groupId, dstUin)
+    }*/
+
+    @Suppress("RETURN_TYPE_MISMATCH_ON_OVERRIDE")
+    override fun newFriend(bot: Bot, friendInfo: FriendInfo): Friend {
+        bot.mock()
+        return MockFriendImpl(
+            bot.coroutineContext,
+            bot,
+            friendInfo.uin,
+            friendInfo.nick,
+            friendInfo.remark,
+        )
+    }
+
+    @Suppress("RETURN_TYPE_MISMATCH_ON_OVERRIDE")
+    override fun newStranger(bot: Bot, strangerInfo: StrangerInfo): Stranger {
+        bot.mock()
+        return MockStrangerImpl(
+            bot.coroutineContext,
+            bot,
+            strangerInfo.uin,
+            strangerInfo.remark,
+            strangerInfo.nick,
+        )
+    }
+
+    override fun createImage(imageId: String): Image {
+        if (imageId matches Image.IMAGE_ID_REGEX) {
+            return MockImage(imageId, "images/" + imageId.substring(1..36))
+        }
+        //imageId.substring(1..36)
+        return super.createImage(imageId)
+    }
+
+    override suspend fun broadcastEvent(event: Event) {
+        if (event is BotEvent) {
+            val bot = event.bot
+            if (bot is MockBotImpl) {
+                bot.components[EventDispatcher].broadcast(event)
+                return
+            }
+        }
+        super.broadcastEvent(event)
+    }
+
+    override suspend fun refreshKeys(bot: Bot) {
+    }
+}

+ 54 - 0
mirai-core-mock/src/internal/components/MockEventDispatcherImpl.kt

@@ -0,0 +1,54 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE", "CANNOT_OVERRIDE_INVISIBLE_MEMBER")
+
+package net.mamoe.mirai.mock.internal.components
+
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.job
+import kotlinx.coroutines.launch
+import net.mamoe.mirai.event.Event
+import net.mamoe.mirai.internal.network.components.EventDispatcherImpl
+import net.mamoe.mirai.utils.MiraiLogger
+import kotlin.coroutines.CoroutineContext
+
+/*
+
+Copied from:
+
+mirai-core/src/commonTest/kotlin/network/framework/components/EventDispatcherImpl.kt
+
+ */
+internal open class MockEventDispatcherImpl(
+    lifecycleContext: CoroutineContext,
+    logger: MiraiLogger,
+) : EventDispatcherImpl(lifecycleContext, logger) {
+    override suspend fun broadcast(event: Event) {
+        if (isActive) {
+            // This requires the scope to be active, while the original one doesn't.
+
+            // so that [joinBroadcast] works.
+            launch(
+                start = CoroutineStart.UNDISPATCHED
+            ) {
+                super.broadcast(event)
+            }.join()
+        } else {
+            // Scope closed, typically when broadcasting `BotOfflineEvent` by StateObserver from `bot.close`
+            super.broadcast(event)
+        }
+    }
+
+    override suspend fun joinBroadcast() {
+        for (child in coroutineContext.job.children) {
+            child.join()
+        }
+    }
+}

+ 76 - 0
mirai-core-mock/src/internal/contact/AbstractMockContact.kt

@@ -0,0 +1,76 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE", "CANNOT_OVERRIDE_INVISIBLE_MEMBER")
+
+package net.mamoe.mirai.mock.internal.contact
+
+import net.mamoe.mirai.contact.Contact
+import net.mamoe.mirai.event.events.MessagePreSendEvent
+import net.mamoe.mirai.internal.contact.broadcastMessagePreSendEvent
+import net.mamoe.mirai.internal.contact.replaceMagicCodes
+import net.mamoe.mirai.message.MessageReceipt
+import net.mamoe.mirai.message.data.Image
+import net.mamoe.mirai.message.data.Message
+import net.mamoe.mirai.message.data.MessageChain
+import net.mamoe.mirai.message.data.OnlineMessageSource
+import net.mamoe.mirai.mock.MockBot
+import net.mamoe.mirai.mock.contact.MockContact
+import net.mamoe.mirai.utils.*
+import kotlin.coroutines.CoroutineContext
+
+internal abstract class AbstractMockContact(
+    parentCoroutineContext: CoroutineContext,
+    override val bot: MockBot,
+    override val id: Long
+) : MockContact {
+
+    override val coroutineContext: CoroutineContext = parentCoroutineContext.childScopeContext()
+
+    /**
+     * @return isCancelled
+     */
+    protected abstract fun newMessagePreSend(message: Message): MessagePreSendEvent
+    protected abstract suspend fun postMessagePreSend(message: MessageChain, receipt: MessageReceipt<*>)
+
+    protected abstract fun newMessageSource(message: MessageChain): OnlineMessageSource.Outgoing
+
+    override suspend fun sendMessage(message: Message): MessageReceipt<Contact> {
+        val msg = broadcastMessagePreSendEvent(message, false) { _, _ -> newMessagePreSend(message) }
+
+        val source = newMessageSource(msg)
+        val response = source.withMessage(msg)
+
+        bot.logger.verbose("$this <- $msg".replaceMagicCodes())
+
+
+        @Suppress("DEPRECATION_ERROR")
+        return MessageReceipt(source, this).also {
+            postMessagePreSend(response, it)
+        }
+    }
+
+
+    override suspend fun uploadImage(resource: ExternalResource): Image {
+        return bot.uploadMockImage(resource)
+    }
+
+    override fun toString(): String {
+        return "$id"
+    }
+}
+
+internal suspend inline fun <T : ExternalResource, R> T.inResource(action: () -> R): R {
+    return useAutoClose {
+        runBIO {
+            inputStream().dropContent(close = true)
+        }
+        action()
+    }
+}

+ 96 - 0
mirai-core-mock/src/internal/contact/MockAnnouncementsImpl.kt

@@ -0,0 +1,96 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.internal.contact
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import net.mamoe.mirai.contact.Group
+import net.mamoe.mirai.contact.NormalMember
+import net.mamoe.mirai.contact.PermissionDeniedException
+import net.mamoe.mirai.contact.announcement.Announcement
+import net.mamoe.mirai.contact.announcement.AnnouncementImage
+import net.mamoe.mirai.contact.announcement.OnlineAnnouncement
+import net.mamoe.mirai.contact.isOperator
+import net.mamoe.mirai.event.events.GroupEntranceAnnouncementChangeEvent
+import net.mamoe.mirai.mock.contact.announcement.MockAnnouncements
+import net.mamoe.mirai.mock.contact.announcement.MockOnlineAnnouncement
+import net.mamoe.mirai.mock.contact.announcement.copy
+import net.mamoe.mirai.mock.utils.broadcastBlocking
+import net.mamoe.mirai.utils.ExternalResource
+import net.mamoe.mirai.utils.currentTimeSeconds
+import net.mamoe.mirai.utils.generateImageId
+import java.util.*
+import java.util.concurrent.ConcurrentHashMap
+import java.util.stream.Stream
+
+internal class MockAnnouncementsImpl(
+    val group: Group,
+) : MockAnnouncements {
+    val announcements = ConcurrentHashMap<String, OnlineAnnouncement>()
+
+    override suspend fun asFlow(): Flow<OnlineAnnouncement> = announcements.values.asFlow()
+
+    override fun asStream(): Stream<OnlineAnnouncement> = announcements.values.toList().stream()
+
+    override suspend fun delete(fid: String): Boolean = announcements.remove(fid) != null
+
+    override suspend fun get(fid: String): OnlineAnnouncement? = announcements[fid]
+
+    @Suppress("MemberVisibilityCanBePrivate")
+    internal fun putDirect(announcement: MockOnlineAnnouncement) {
+        val annoc = if (announcement.fid.isEmpty()) {
+            announcement.copy(fid = UUID.randomUUID().toString())
+        } else announcement
+        if (annoc.parameters.sendToNewMember) {
+            announcements.entries.removeIf { (_, v) -> v.parameters.sendToNewMember }
+        }
+        announcements[annoc.fid] = annoc
+        annoc.group = group
+    }
+
+    override fun mockPublish(announcement: Announcement, actor: NormalMember, events: Boolean): OnlineAnnouncement {
+        val old = if (announcement.parameters.sendToNewMember)
+            announcements.elements().toList().firstOrNull { oa -> oa.parameters.sendToNewMember }
+        else null
+        val onac = MockOnlineAnnouncement(
+            content = announcement.content,
+            parameters = announcement.parameters,
+            senderId = actor.id,
+            fid = UUID.randomUUID().toString(),
+            allConfirmed = false,
+            confirmedMembersCount = 0,
+            publicationTime = currentTimeSeconds()
+        )
+        putDirect(onac)
+        if (!events) return onac
+
+        @Suppress("DEPRECATION")
+        GroupEntranceAnnouncementChangeEvent(
+            origin = old?.content.orEmpty(),
+            new = onac.content,
+            group = group,
+            operator = actor.takeUnless { it.id == group.bot.id }
+        ).broadcastBlocking()
+
+        // TODO: mirai-core no other events about announcements
+        return onac
+    }
+
+    override suspend fun publish(announcement: Announcement): OnlineAnnouncement {
+        if (!group.botPermission.isOperator()) {
+            throw PermissionDeniedException("Failed to publish a new announcement because bot don't have admin permission to perform it.")
+        }
+        return mockPublish(announcement, this.group.botAsMember, true)
+    }
+
+    override suspend fun uploadImage(resource: ExternalResource): AnnouncementImage = resource.inResource {
+        AnnouncementImage.create(generateImageId(resource.md5), 500, 500)
+    }
+}

+ 119 - 0
mirai-core-mock/src/internal/contact/MockAnonymousMemberImpl.kt

@@ -0,0 +1,119 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.internal.contact
+
+import kotlinx.coroutines.runBlocking
+import net.mamoe.mirai.contact.AvatarSpec
+import net.mamoe.mirai.contact.MemberPermission
+import net.mamoe.mirai.contact.nameCardOrNick
+import net.mamoe.mirai.event.broadcast
+import net.mamoe.mirai.event.events.GroupMessageEvent
+import net.mamoe.mirai.event.events.MessagePreSendEvent
+import net.mamoe.mirai.message.MessageReceipt
+import net.mamoe.mirai.message.data.Image
+import net.mamoe.mirai.message.data.Message
+import net.mamoe.mirai.message.data.MessageChain
+import net.mamoe.mirai.message.data.OnlineMessageSource
+import net.mamoe.mirai.mock.MockBot
+import net.mamoe.mirai.mock.contact.MockAnonymousMember
+import net.mamoe.mirai.mock.contact.MockGroup
+import net.mamoe.mirai.mock.contact.MockMember
+import net.mamoe.mirai.mock.internal.msgsrc.OnlineMsgSrcFromGroup
+import net.mamoe.mirai.mock.internal.msgsrc.newMsgSrc
+import net.mamoe.mirai.utils.ExternalResource
+import net.mamoe.mirai.utils.lateinitMutableProperty
+import kotlin.coroutines.CoroutineContext
+
+internal class MockAnonymousMemberImpl(
+    parentCoroutineContext: CoroutineContext,
+    bot: MockBot, id: Long,
+
+    override val anonymousId: String,
+    override val group: MockGroup,
+    nameCard: String
+) : AbstractMockContact(parentCoroutineContext, bot, id), MockAnonymousMember {
+    override fun newMessagePreSend(message: Message): MessagePreSendEvent {
+        throw AssertionError()
+    }
+
+    override fun avatarUrl(spec: AvatarSpec): String {
+        return avatarUrl
+    }
+
+    override suspend fun postMessagePreSend(message: MessageChain, receipt: MessageReceipt<*>) {
+        throw AssertionError()
+    }
+
+    override fun newMessageSource(message: MessageChain): OnlineMessageSource.Outgoing {
+        throw AssertionError()
+    }
+
+    @Suppress("DEPRECATION_ERROR")
+    override suspend fun sendMessage(message: Message): Nothing = super<MockAnonymousMember>.sendMessage(message)
+    override suspend fun uploadImage(resource: ExternalResource): Image =
+        super<AbstractMockContact>.uploadImage(resource)
+
+    override var permission: MemberPermission
+        get() = MemberPermission.MEMBER
+        set(value) {
+            error("Modifying permission of AnonymousMember")
+        }
+    override val specialTitle: String
+        get() = "匿名"
+
+    override suspend fun mute(durationSeconds: Int) {
+    }
+
+    override var remark: String
+        get() = ""
+        set(_) {}
+    override var nick: String
+        get() = nameCard
+        set(_) {}
+
+    override val nameCard: String
+        get() = mockApi.nick
+
+    override val mockApi: MockMember.MockApi = object : MockMember.MockApi {
+        override val member: MockMember
+            get() = this@MockAnonymousMemberImpl
+
+        override var nick: String = nameCard
+
+        override var remark: String
+            get() = ""
+            set(_) {}
+
+        override var permission: MemberPermission
+            get() = MemberPermission.MEMBER
+            set(_) {}
+        override var avatarUrl: String by lateinitMutableProperty { runBlocking { MockImage.random(bot).getUrl(bot) } }
+    }
+
+    // TODO
+    override val avatarUrl: String by mockApi::avatarUrl
+    override fun changeAvatarUrl(newAvatar: String) {
+        mockApi.avatarUrl = newAvatar
+    }
+
+
+    override suspend fun says(message: MessageChain): MessageChain {
+        val src = newMsgSrc(true, message) { ids, internalIds, time ->
+            OnlineMsgSrcFromGroup(ids, internalIds, time, message, bot, this)
+        }
+        val msg = src.withMessage(message)
+        GroupMessageEvent(nameCardOrNick, permission, this, msg, src.time).broadcast()
+        return msg
+    }
+
+    override fun toString(): String {
+        return "AnonymousMember($nameCard, $anonymousId)"
+    }
+}

+ 168 - 0
mirai-core-mock/src/internal/contact/MockFriendImpl.kt

@@ -0,0 +1,168 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE", "CANNOT_OVERRIDE_INVISIBLE_MEMBER")
+
+package net.mamoe.mirai.mock.internal.contact
+
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.runBlocking
+import net.mamoe.mirai.contact.AvatarSpec
+import net.mamoe.mirai.contact.Friend
+import net.mamoe.mirai.contact.friendgroup.FriendGroup
+import net.mamoe.mirai.contact.roaming.RoamingMessages
+import net.mamoe.mirai.event.broadcast
+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.OfflineAudio
+import net.mamoe.mirai.message.data.OnlineMessageSource
+import net.mamoe.mirai.mock.MockBot
+import net.mamoe.mirai.mock.contact.MockFriend
+import net.mamoe.mirai.mock.internal.contact.friendfroup.MockFriendGroups
+import net.mamoe.mirai.mock.internal.contact.roaming.MockRoamingMessages
+import net.mamoe.mirai.mock.internal.msgsrc.OnlineMsgSrcFromFriend
+import net.mamoe.mirai.mock.internal.msgsrc.OnlineMsgSrcToFriend
+import net.mamoe.mirai.mock.internal.msgsrc.newMsgSrc
+import net.mamoe.mirai.mock.utils.broadcastBlocking
+import net.mamoe.mirai.utils.ExternalResource
+import net.mamoe.mirai.utils.cast
+import net.mamoe.mirai.utils.lateinitMutableProperty
+import java.util.concurrent.CancellationException
+import kotlin.coroutines.CoroutineContext
+
+internal class MockFriendImpl(
+    parentCoroutineContext: CoroutineContext,
+    bot: MockBot,
+    id: Long,
+    nick: String,
+    remark: String
+) : AbstractMockContact(
+    parentCoroutineContext,
+    bot, id
+), MockFriend {
+    override val mockApi: MockFriend.MockApi = object : MockFriend.MockApi {
+        override val contact: MockFriend get() = this@MockFriendImpl
+
+        override var nick: String = nick
+        override var remark: String = remark
+        override var avatarUrl: String
+            get() = this@MockFriendImpl._avatarUrl
+            set(value) {
+                this@MockFriendImpl._avatarUrl = value
+                bot.groups.forEach { g ->
+                    val mems = if (this@MockFriendImpl.id == bot.id) {
+                        sequenceOf(g.botAsMember)
+                    } else g.members.asSequence().filter {
+                        it.id == this@MockFriendImpl.id
+                    }
+                    mems.forEach { m ->
+                        m.cast<MockNormalMemberImpl>().avatarUrl = value
+                    }
+                }
+                if (this@MockFriendImpl.id == bot.id) {
+                    sequenceOf(bot.asStranger)
+                } else {
+                    bot.strangers.asSequence().filter { s ->
+                        s.id == this@MockFriendImpl.id
+                    }
+                }.forEach { it.cast<MockStrangerImpl>().avatarUrl = value }
+            }
+
+        override var friendGroupId: Int = 0
+    }
+
+    override val friendGroup: FriendGroup
+        get() = bot.friendGroups.cast<MockFriendGroups>().findOrDefault(mockApi.friendGroupId)
+
+    private var _avatarUrl: String by lateinitMutableProperty { runBlocking { MockImage.random(bot).getUrl(bot) } }
+    override val avatarUrl: String get() = _avatarUrl
+    internal fun initAvatarUrl(v: String) {
+        _avatarUrl = v
+    }
+
+    override fun changeAvatarUrl(newAvatar: String) {
+        mockApi.avatarUrl = newAvatar
+        FriendAvatarChangedEvent(this).broadcastBlocking()
+    }
+
+    override fun avatarUrl(spec: AvatarSpec): String {
+        return avatarUrl
+    }
+
+    override var nick: String
+        get() = mockApi.nick
+        set(value) {
+            val ov = mockApi.nick
+            if (ov == value) return
+            mockApi.nick = value
+            FriendNickChangedEvent(this, ov, value).broadcastBlocking()
+        }
+
+    override var remark: String
+        get() = mockApi.remark
+        set(value) {
+            val ov = mockApi.remark
+            if (ov == value) return
+            mockApi.remark = value
+            FriendRemarkChangeEvent(this, ov, value).broadcastBlocking()
+        }
+
+    override fun newMessagePreSend(message: Message): MessagePreSendEvent {
+        return FriendMessagePreSendEvent(this, message)
+    }
+
+    override suspend fun postMessagePreSend(message: MessageChain, receipt: MessageReceipt<*>) {
+        FriendMessagePostSendEvent(this, message, null, receipt.cast()).broadcast()
+    }
+
+    override fun newMessageSource(message: MessageChain): OnlineMessageSource.Outgoing {
+        return newMsgSrc(false, message) { ids, internalIds, time ->
+            OnlineMsgSrcToFriend(ids, internalIds, time, message, bot, bot, this)
+        }
+    }
+
+    override suspend fun sendMessage(message: Message): MessageReceipt<Friend> {
+        return super<AbstractMockContact>.sendMessage(message).cast()
+    }
+
+    override suspend fun delete() {
+        if (bot.friends.delegate.remove(this)) {
+            FriendDeleteEvent(this).broadcast()
+            cancel(CancellationException("Friend deleted"))
+        }
+    }
+
+    override suspend fun uploadAudio(resource: ExternalResource): OfflineAudio =
+        resource.mockUploadAudio(bot)
+
+    override val roamingMessages: RoamingMessages = MockRoamingMessages(this)
+
+    override suspend fun says(message: MessageChain): MessageChain {
+        val src = newMsgSrc(true, message) { ids, internalIds, time ->
+            OnlineMsgSrcFromFriend(ids, internalIds, time, message, bot, this)
+        }
+        val msg = src.withMessage(message)
+        FriendMessageEvent(this, msg, src.time).broadcast()
+        return msg
+    }
+
+    override suspend fun broadcastMsgSyncEvent(message: MessageChain, time: Int) {
+        val src = newMsgSrc(true, message, time.toLong()) { ids, internalIds, time0 ->
+            OnlineMsgSrcToFriend(ids, internalIds, time0, message, bot, bot, this)
+        }
+        val msg = src.withMessage(message)
+        FriendMessageSyncEvent(this, msg, time).broadcast()
+    }
+
+    override fun toString(): String {
+        return "Friend($id)"
+    }
+}

+ 353 - 0
mirai-core-mock/src/internal/contact/MockGroupImpl.kt

@@ -0,0 +1,353 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE", "CANNOT_OVERRIDE_INVISIBLE_MEMBER")
+
+package net.mamoe.mirai.mock.internal.contact
+
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.runBlocking
+import net.mamoe.mirai.contact.*
+import net.mamoe.mirai.contact.announcement.OfflineAnnouncement
+import net.mamoe.mirai.contact.announcement.buildAnnouncementParameters
+import net.mamoe.mirai.contact.file.RemoteFiles
+import net.mamoe.mirai.data.GroupHonorType
+import net.mamoe.mirai.data.MemberInfo
+import net.mamoe.mirai.event.broadcast
+import net.mamoe.mirai.event.events.*
+import net.mamoe.mirai.internal.contact.uin
+import net.mamoe.mirai.message.MessageReceipt
+import net.mamoe.mirai.message.data.*
+import net.mamoe.mirai.mock.MockBot
+import net.mamoe.mirai.mock.contact.MockAnonymousMember
+import net.mamoe.mirai.mock.contact.MockGroup
+import net.mamoe.mirai.mock.contact.MockGroupControlPane
+import net.mamoe.mirai.mock.contact.MockNormalMember
+import net.mamoe.mirai.mock.internal.msgsrc.OnlineMsgSrcToGroup
+import net.mamoe.mirai.mock.internal.msgsrc.newMsgSrc
+import net.mamoe.mirai.mock.utils.broadcastBlocking
+import net.mamoe.mirai.mock.utils.mock
+import net.mamoe.mirai.utils.*
+import java.util.*
+import java.util.concurrent.CancellationException
+import kotlin.coroutines.CoroutineContext
+
+internal class MockGroupImpl(
+    parentCoroutineContext: CoroutineContext,
+    bot: MockBot,
+    id: Long,
+    override val uin: Long,
+    name: String,
+) : AbstractMockContact(
+    parentCoroutineContext, bot, id
+), MockGroup {
+    override val honorMembers: MutableMap<GroupHonorType, MockNormalMember> = EnumMap(GroupHonorType::class.java)
+    private val txFileSystem by lazy { bot.mock().tmpResourceServer.mockServerFileDisk.newFsSystem() }
+
+    override fun avatarUrl(spec: AvatarSpec): String {
+        return avatarUrl
+    }
+
+    override fun changeHonorMember(member: MockNormalMember, honorType: GroupHonorType) {
+        val onm = honorMembers[honorType]
+        honorMembers[honorType] = member
+        // reference net.mamoe.mirai.internal.network.notice.group.NoticePipelineContext.processGeneralGrayTip, GroupNotificationProcessor.kt#361L
+        if (honorType == GroupHonorType.TALKATIVE) {
+            if (onm != null) GroupTalkativeChangeEvent(this, member, onm).broadcastBlocking()
+        }
+        if (onm != null) MemberHonorChangeEvent.Lose(onm, honorType).broadcastBlocking()
+        MemberHonorChangeEvent.Achieve(member, honorType).broadcastBlocking()
+    }
+
+    override fun appendMember(mockMember: MemberInfo): MockGroup {
+        addMember(mockMember)
+        return this
+    }
+
+    override fun addMember(mockMember: MemberInfo): MockNormalMember {
+        val nMember = MockNormalMemberImpl(
+            this.coroutineContext,
+            bot,
+            mockMember.uin,
+            this,
+            mockMember.permission,
+            mockMember.remark,
+            mockMember.nick,
+            mockMember.muteTimestamp,
+            mockMember.joinTimestamp,
+            mockMember.lastSpeakTimestamp,
+            mockMember.specialTitle,
+            mockMember.nameCard
+        )
+
+        if (nMember.id == bot.id) {
+            botAsMember = nMember
+        } else {
+            members.delegate.removeAll { it.uin == nMember.id }
+            members.delegate.add(nMember)
+        }
+
+        if (nMember.permission == MemberPermission.OWNER) {
+            if (::owner.isInitialized) {
+                owner.mock().mockApi.permission = MemberPermission.MEMBER
+            }
+            owner = nMember
+        }
+        return nMember
+    }
+
+    override suspend fun changeOwner(member: NormalMember) {
+        val oldOwner = owner
+        val oldPerm = member.permission
+        member.mock().mockApi.permission = MemberPermission.OWNER
+        oldOwner.mock().mockApi.permission = MemberPermission.MEMBER
+        owner = member
+
+        if (member === botAsMember) {
+            BotGroupPermissionChangeEvent(this, oldPerm, MemberPermission.OWNER)
+        } else {
+            MemberPermissionChangeEvent(member, oldPerm, MemberPermission.OWNER)
+        }.broadcast()
+
+        if (oldOwner === botAsMember) {
+            BotGroupPermissionChangeEvent(this, MemberPermission.OWNER, MemberPermission.MEMBER)
+        } else {
+            MemberPermissionChangeEvent(oldOwner, MemberPermission.OWNER, MemberPermission.MEMBER)
+        }.broadcast()
+    }
+
+    override fun changeOwnerNoEventBroadcast(member: NormalMember) {
+        val oldOwner = owner
+        member.mock().mockApi.permission = MemberPermission.OWNER
+        oldOwner.mockApi.permission = MemberPermission.MEMBER
+        owner = member
+    }
+
+    override fun newAnonymous(nick: String, id: String): MockAnonymousMember {
+        return MockAnonymousMemberImpl(
+            coroutineContext, bot, 80000000, id, this, nick
+        )
+    }
+
+
+    private val rawGroupControlPane = object : MockGroupControlPane {
+        override val group: MockGroup get() = this@MockGroupImpl
+        override val currentActor: MockNormalMember get() = group.botAsMember
+        override var isAllowMemberInvite: Boolean = false
+        override var isMuteAll: Boolean = false
+        override var isAllowMemberFileUploading: Boolean = false
+        override var isAnonymousChatAllowed: Boolean = false
+        override var isAllowConfessTalk: Boolean = false
+        override var groupName: String = name
+
+        override fun withActor(actor: MockNormalMember): MockGroupControlPane {
+            return GroupControlPaneImpl(actor)
+        }
+    }
+
+    internal inner class GroupControlPaneImpl(
+        override val currentActor: MockNormalMember
+    ) : MockGroupControlPane {
+        override val group: MockGroup get() = this@MockGroupImpl
+        private val actorNullIfBot: MockNormalMember?
+            get() = currentActor.takeIf { it.id != bot.id }
+
+        override var groupName: String
+            get() = rawGroupControlPane.groupName
+            set(value) {
+                val ov = rawGroupControlPane.groupName
+                if (ov == value) return
+                rawGroupControlPane.groupName = value
+                GroupNameChangeEvent(ov, value, group, actorNullIfBot).broadcastBlocking()
+            }
+
+        override var isMuteAll: Boolean
+            get() = rawGroupControlPane.isMuteAll
+            set(value) {
+                val ov = rawGroupControlPane.isMuteAll
+                if (ov == value) return
+                rawGroupControlPane.isMuteAll = value
+                GroupMuteAllEvent(ov, value, group, actorNullIfBot).broadcastBlocking()
+            }
+
+        override var isAllowMemberFileUploading: Boolean
+            get() = rawGroupControlPane.isAllowMemberFileUploading
+            set(value) {
+                // TODO: core-api no event
+                rawGroupControlPane.isAllowMemberFileUploading = value
+            }
+
+        override var isAllowMemberInvite: Boolean
+            get() = rawGroupControlPane.isAllowMemberInvite
+            set(value) {
+                val ov = rawGroupControlPane.isAllowMemberInvite
+                if (ov == value) return
+                rawGroupControlPane.isAllowMemberInvite = value
+                GroupAllowMemberInviteEvent(ov, value, group, actorNullIfBot).broadcastBlocking()
+            }
+
+        override var isAnonymousChatAllowed: Boolean
+            get() = rawGroupControlPane.isAnonymousChatAllowed
+            set(value) {
+                val ov = rawGroupControlPane.isAnonymousChatAllowed
+                if (ov == value) return
+                rawGroupControlPane.isAnonymousChatAllowed = value
+                GroupAllowAnonymousChatEvent(ov, value, group, actorNullIfBot).broadcastBlocking()
+            }
+
+        override var isAllowConfessTalk: Boolean
+            get() = rawGroupControlPane.isAllowConfessTalk
+            set(value) {
+                val ov = rawGroupControlPane.isAllowConfessTalk
+                if (ov == value) return
+                rawGroupControlPane.isAllowConfessTalk = value
+                GroupAllowConfessTalkEvent(ov, value, group, currentActor.id == bot.id).broadcastBlocking()
+            }
+
+        override fun withActor(actor: MockNormalMember): MockGroupControlPane {
+            return GroupControlPaneImpl(actor)
+        }
+    }
+
+    override val controlPane: MockGroupControlPane get() = rawGroupControlPane
+
+    override var name: String
+        get() = controlPane.groupName
+        set(value) {
+            checkBotPermission(MemberPermission.ADMINISTRATOR)
+            controlPane.withActor(botAsMember).groupName = value
+        }
+
+    override val mockApi: MockGroup.MockApi = object : MockGroup.MockApi {
+        override var avatarUrl: String by lateinitMutableProperty {
+            runBlocking { MockImage.random(bot).getUrl(bot) }
+        }
+    }
+
+    override fun changeAvatarUrl(newAvatar: String) {
+        mockApi.avatarUrl = newAvatar
+    }
+
+    override val avatarUrl: String by mockApi::avatarUrl
+
+    override lateinit var owner: MockNormalMember
+    override lateinit var botAsMember: MockNormalMember
+    override val members: ContactList<MockNormalMember> = ContactList()
+    override fun get(id: Long): MockNormalMember? {
+        if (id == bot.id) return botAsMember
+        return members[id]
+    }
+
+    override fun contains(id: Long): Boolean = members.any { it.id == id }
+
+
+    override suspend fun quit(): Boolean {
+        return if (bot.groups.delegate.remove(this)) {
+            BotLeaveEvent.Active(this).broadcast()
+            cancel(CancellationException("Bot quited group $id"))
+            true
+        } else {
+            false
+        }
+    }
+
+    override val announcements = MockAnnouncementsImpl(this)
+
+    @Suppress("OverridingDeprecatedMember", "OVERRIDE_DEPRECATION")
+    override val settings: GroupSettings = object : GroupSettings {
+        override var entranceAnnouncement: String
+            get() = announcements.announcements.values.asSequence()
+                .filter { it.parameters.sendToNewMember }
+                .firstOrNull()?.content ?: ""
+            set(value) {
+                checkBotPermission(MemberPermission.ADMINISTRATOR)
+                announcements.mockPublish(OfflineAnnouncement.create(value, buildAnnouncementParameters {
+                    sendToNewMember = true
+                }), this@MockGroupImpl.botAsMember)
+            }
+
+        override var isMuteAll: Boolean
+            get() = rawGroupControlPane.isMuteAll
+            set(value) {
+                checkBotPermission(MemberPermission.ADMINISTRATOR)
+                rawGroupControlPane.withActor(botAsMember).isMuteAll = value
+            }
+
+        override var isAllowMemberInvite: Boolean
+            get() = rawGroupControlPane.isAllowMemberInvite
+            set(value) {
+                checkBotPermission(MemberPermission.ADMINISTRATOR)
+                rawGroupControlPane.withActor(botAsMember).isAllowMemberInvite = value
+            }
+
+        @MiraiExperimentalApi
+        override val isAutoApproveEnabled: Boolean
+            get() = false // TODO
+
+        override var isAnonymousChatEnabled: Boolean
+            get() = rawGroupControlPane.isAnonymousChatAllowed
+            set(value) {
+                checkBotPermission(MemberPermission.ADMINISTRATOR)
+                rawGroupControlPane.withActor(botAsMember).isAnonymousChatAllowed = value
+            }
+    }
+
+
+    override fun newMessagePreSend(message: Message): MessagePreSendEvent =
+        GroupMessagePreSendEvent(this, message)
+
+
+    override suspend fun postMessagePreSend(message: MessageChain, receipt: MessageReceipt<*>) {
+        GroupMessagePostSendEvent(this, message, null, receipt = receipt.cast())
+            .broadcast()
+    }
+
+    override fun newMessageSource(message: MessageChain): OnlineMessageSource.Outgoing {
+        return newMsgSrc(false, message) { ids, internalIds, time ->
+            OnlineMsgSrcToGroup(ids, internalIds, time, message, bot, bot, this)
+        }
+    }
+
+    override suspend fun broadcastMsgSyncEvent(message: MessageChain, time: Int) {
+        val src = newMsgSrc(true, message, time.toLong()) { ids, internalIds, time0 ->
+            OnlineMsgSrcToGroup(ids, internalIds, time0, message, bot, bot, this)
+        }
+        val msg = src.withMessage(message)
+        GroupMessageSyncEvent(this, msg, botAsMember, bot.nick, time).broadcast()
+    }
+
+    override suspend fun sendMessage(message: Message): MessageReceipt<Group> {
+        return super<AbstractMockContact>.sendMessage(message).cast()
+    }
+
+    @Suppress("OverridingDeprecatedMember", "DEPRECATION", "DEPRECATION_ERROR", "OVERRIDE_DEPRECATION")
+    override suspend fun uploadVoice(resource: ExternalResource): net.mamoe.mirai.message.data.Voice =
+        resource.mockUploadVoice(bot)
+
+    override suspend fun setEssenceMessage(source: MessageSource): Boolean {
+        return true
+    }
+
+    @Deprecated("Please use files instead.", replaceWith = ReplaceWith("files.root"))
+    @Suppress("OverridingDeprecatedMember", "DEPRECATION")
+    override val filesRoot: RemoteFile by lazy {
+        net.mamoe.mirai.mock.internal.remotefile.remotefile.RootRemoteFile(txFileSystem, this)
+    }
+
+    override val files: RemoteFiles by lazy {
+        net.mamoe.mirai.mock.internal.remotefile.absolutefile.MockRemoteFiles(this, txFileSystem)
+    }
+
+    override suspend fun uploadAudio(resource: ExternalResource): OfflineAudio =
+        resource.mockUploadAudio(bot)
+
+    override fun toString(): String {
+        return "Group($id)"
+    }
+}

+ 237 - 0
mirai-core-mock/src/internal/contact/MockNormalMemberImpl.kt

@@ -0,0 +1,237 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.internal.contact
+
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.runBlocking
+import net.mamoe.mirai.contact.*
+import net.mamoe.mirai.event.broadcast
+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.OnlineMessageSource
+import net.mamoe.mirai.mock.MockBot
+import net.mamoe.mirai.mock.contact.MockFriend
+import net.mamoe.mirai.mock.contact.MockGroup
+import net.mamoe.mirai.mock.contact.MockNormalMember
+import net.mamoe.mirai.mock.internal.msgsrc.OnlineMsgSrcFromGroup
+import net.mamoe.mirai.mock.internal.msgsrc.OnlineMsgSrcToTemp
+import net.mamoe.mirai.mock.internal.msgsrc.newMsgSrc
+import net.mamoe.mirai.mock.utils.broadcastBlocking
+import net.mamoe.mirai.utils.cast
+import net.mamoe.mirai.utils.currentTimeSeconds
+import net.mamoe.mirai.utils.lateinitMutableProperty
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.cancellation.CancellationException
+import kotlin.math.max
+
+internal class MockNormalMemberImpl(
+    parentCoroutineContext: CoroutineContext,
+    bot: MockBot,
+    id: Long,
+    override val group: MockGroup,
+    permission: MemberPermission,
+    remark: String,
+    nick: String,
+    muteTimeRemaining: Int,
+    joinTimestamp: Int,
+    lastSpeakTimestamp: Int,
+    specialTitle: String,
+    nameCard: String,
+) : AbstractMockContact(
+    parentCoroutineContext, bot,
+    id
+), MockNormalMember {
+    override var avatarUrl: String by lateinitMutableProperty {
+        bot.getFriend(id)?.let { return@lateinitMutableProperty it.avatarUrl }
+        runBlocking { MockImage.random(bot).getUrl(bot) }
+    }
+
+    override fun avatarUrl(spec: AvatarSpec): String {
+        return avatarUrl
+    }
+
+    override fun changeAvatarUrl(newAvatar: String) {
+        bot.getFriend(id)?.let { return it.changeAvatarUrl(newAvatar) }
+        this.avatarUrl = newAvatar
+    }
+
+    private inline fun <T> crossFriendAccess(
+        ifExists: (MockFriend) -> T,
+        ifNotExists: () -> T,
+    ): T {
+        val f = bot.getFriend(id) ?: return ifNotExists()
+        return ifExists(f)
+    }
+
+    override val mockApi: MockNormalMember.MockApi = object : MockNormalMember.MockApi {
+        override val member: MockNormalMember get() = this@MockNormalMemberImpl
+        override var lastSpeakTimestamp: Int = lastSpeakTimestamp
+        override var joinTimestamp: Int = joinTimestamp
+        override var muteTimeEndTimestamp: Long = currentTimeSeconds() + muteTimeRemaining
+
+        override var nick: String = nick
+            get() = crossFriendAccess(ifExists = { it.nick }, ifNotExists = { field })
+            set(value) {
+                crossFriendAccess(ifExists = { it.mockApi.nick = value }, ifNotExists = { field = value })
+            }
+
+        override var remark: String = remark
+            get() = crossFriendAccess(ifExists = { it.remark }, ifNotExists = { field })
+            set(value) {
+                crossFriendAccess(ifExists = { it.mockApi.remark = value }, ifNotExists = { field = value })
+            }
+
+        override var permission: MemberPermission = permission
+        override var nameCard: String = nameCard
+        override var specialTitle: String = specialTitle
+        override var avatarUrl: String
+            get() = this@MockNormalMemberImpl.avatarUrl
+            set(value) {
+                this@MockNormalMemberImpl.avatarUrl = value
+
+                bot.getFriend(this@MockNormalMemberImpl.id)?.let { f ->
+                    f.mockApi.avatarUrl = value
+                }
+            }
+    }
+
+    override val permission: MemberPermission
+        get() = mockApi.permission
+
+    override val joinTimestamp: Int
+        get() = mockApi.joinTimestamp
+
+    override val lastSpeakTimestamp: Int
+        get() = mockApi.lastSpeakTimestamp
+
+    override val muteTimeRemaining: Int
+        get() = max((mockApi.muteTimeEndTimestamp - currentTimeSeconds()).toInt(), 0)
+
+    override val remark: String
+        get() = mockApi.remark
+
+    override var nameCard: String
+        get() = mockApi.nameCard
+        set(value) {
+            if (!group.botPermission.isOperator()) {
+                throw PermissionDeniedException("Bot don't have permission to change the namecard of $this")
+            }
+            MemberCardChangeEvent(mockApi.nameCard, value, this).broadcastBlocking()
+            mockApi.nameCard = value
+        }
+
+    override var specialTitle: String
+        get() = mockApi.specialTitle
+        set(value) {
+            if (group.botPermission != MemberPermission.OWNER) {
+                throw PermissionDeniedException("Bot is not the owner of $group so bot cannot change the specialTitle of $this")
+            }
+            MemberSpecialTitleChangeEvent(mockApi.specialTitle, value, this, group.botAsMember).broadcastBlocking()
+            mockApi.specialTitle = value
+        }
+
+    override val nick: String
+        get() = mockApi.nick
+
+    override suspend fun unmute() {
+        requireBotPermissionHigherThanThis("unmute")
+        mockApi.muteTimeEndTimestamp = 0
+        MemberUnmuteEvent(this, null)
+    }
+
+    override suspend fun kick(message: String, block: Boolean) {
+        kick(message)
+    }
+
+    override suspend fun kick(message: String) {
+        requireBotPermissionHigherThanThis("kick")
+        if (group.members.delegate.remove(this)) {
+            MemberLeaveEvent.Kick(this, group.botAsMember).broadcastBlocking()
+            cancel(CancellationException("Member kicked: $message"))
+        }
+    }
+
+    override suspend fun modifyAdmin(operation: Boolean) {
+        if (group.botPermission != MemberPermission.OWNER) {
+            throw PermissionDeniedException("Bot is not the owner of group ${group.id}, can't modify the permission of $id($permission")
+        }
+        if (operation && permission > MemberPermission.MEMBER) return
+
+        if (permission == MemberPermission.OWNER) {
+            throw IllegalArgumentException("Not allowed modify permission of owner ($id, $permission)")
+        }
+        val newPerm = if (operation) MemberPermission.ADMINISTRATOR else MemberPermission.MEMBER
+        if (newPerm != permission) {
+            val oldPerm = permission
+            mockApi.permission = oldPerm
+            MemberPermissionChangeEvent(this, oldPerm, newPerm).broadcast()
+        }
+    }
+
+    override suspend fun sendMessage(message: Message): MessageReceipt<NormalMember> {
+        return super<AbstractMockContact>.sendMessage(message).cast()
+    }
+
+    override suspend fun mute(durationSeconds: Int) {
+        requireBotPermissionHigherThanThis("mute")
+        require(durationSeconds > 0) {
+            "$durationSeconds < 0"
+        }
+        mockApi.muteTimeEndTimestamp = currentTimeSeconds() + durationSeconds
+        MemberMuteEvent(this, durationSeconds, null)
+    }
+
+    override suspend fun broadcastMute(target: MockNormalMember, durationSeconds: Int) {
+        target.mockApi.muteTimeEndTimestamp = currentTimeSeconds() + durationSeconds
+        if (target.id == bot.id) {
+            if (durationSeconds == 0) {
+                BotUnmuteEvent(this)
+            } else {
+                BotMuteEvent(durationSeconds, this)
+            }
+        } else {
+            if (durationSeconds == 0) {
+                MemberUnmuteEvent(target, this)
+            } else {
+                MemberMuteEvent(target, durationSeconds, this)
+            }
+        }.broadcast()
+    }
+
+    override suspend fun says(message: MessageChain): MessageChain {
+        val src = newMsgSrc(true, message) { ids, internalIds, time ->
+            mockApi.lastSpeakTimestamp = time
+            OnlineMsgSrcFromGroup(ids, internalIds, time, message, bot, this)
+        }
+        val msg = src.withMessage(message)
+        GroupMessageEvent(nameCardOrNick, permission, this, msg, src.time).broadcast()
+        return msg
+    }
+
+    override fun newMessagePreSend(message: Message): MessagePreSendEvent {
+        return GroupTempMessagePreSendEvent(this, message)
+    }
+
+    override suspend fun postMessagePreSend(message: MessageChain, receipt: MessageReceipt<*>) {
+        GroupTempMessagePostSendEvent(this, message, null, receipt.cast()).broadcast()
+    }
+
+    override fun newMessageSource(message: MessageChain): OnlineMessageSource.Outgoing {
+        return newMsgSrc(false, message) { ids, internalIds, time ->
+            OnlineMsgSrcToTemp(ids, internalIds, time, message, bot, bot, this)
+        }
+    }
+
+    override fun toString(): String {
+        return "NormalMember($id)"
+    }
+}

+ 108 - 0
mirai-core-mock/src/internal/contact/MockStrangerImpl.kt

@@ -0,0 +1,108 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.internal.contact
+
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.runBlocking
+import net.mamoe.mirai.contact.AvatarSpec
+import net.mamoe.mirai.contact.Stranger
+import net.mamoe.mirai.event.broadcast
+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.OnlineMessageSource
+import net.mamoe.mirai.mock.MockBot
+import net.mamoe.mirai.mock.contact.MockStranger
+import net.mamoe.mirai.mock.internal.msgsrc.OnlineMsgSrcFromStranger
+import net.mamoe.mirai.mock.internal.msgsrc.OnlineMsgSrcToStranger
+import net.mamoe.mirai.mock.internal.msgsrc.newMsgSrc
+import net.mamoe.mirai.utils.cast
+import net.mamoe.mirai.utils.lateinitMutableProperty
+import java.util.concurrent.CancellationException
+import kotlin.coroutines.CoroutineContext
+
+internal class MockStrangerImpl(
+    parentCoroutineContext: CoroutineContext,
+    bot: MockBot,
+    id: Long,
+
+    remark: String,
+    nick: String
+) : AbstractMockContact(parentCoroutineContext, bot, id), MockStranger {
+
+    override val mockApi: MockStranger.MockApi = object : MockStranger.MockApi {
+        override val contact: MockStranger get() = this@MockStrangerImpl
+        override var nick: String = nick
+        override var remark: String = remark
+        override var avatarUrl: String
+            get() = this@MockStrangerImpl.avatarUrl
+            set(value) {
+                this@MockStrangerImpl.avatarUrl = value
+
+                bot.getFriend(this@MockStrangerImpl.id)?.let { f ->
+                    f.mockApi.avatarUrl = value
+                    return
+                }
+            }
+    }
+    override var avatarUrl: String by lateinitMutableProperty {
+        bot.getFriend(id)?.let { return@lateinitMutableProperty it.avatarUrl }
+        runBlocking { MockImage.random(bot).getUrl(bot) }
+    }
+
+    override fun avatarUrl(spec: AvatarSpec): String {
+        return avatarUrl
+    }
+
+    override fun changeAvatarUrl(newAvatar: String) {
+        this.avatarUrl = newAvatar
+        bot.getFriend(id)?.let { return it.changeAvatarUrl(newAvatar) }
+    }
+
+    override val nick: String
+        get() = mockApi.nick
+    override val remark: String
+        get() = mockApi.remark
+
+    override fun newMessagePreSend(message: Message): MessagePreSendEvent {
+        return StrangerMessagePreSendEvent(this, message)
+    }
+
+    override suspend fun postMessagePreSend(message: MessageChain, receipt: MessageReceipt<*>) {
+        StrangerMessagePostSendEvent(this, message, null, receipt.cast()).broadcast()
+    }
+
+    override fun newMessageSource(message: MessageChain): OnlineMessageSource.Outgoing {
+        return newMsgSrc(false, message) { ids, internalIds, time ->
+            OnlineMsgSrcToStranger(ids, internalIds, time, message, bot, bot, this)
+        }
+    }
+
+    override suspend fun sendMessage(message: Message): MessageReceipt<Stranger> {
+        return super<AbstractMockContact>.sendMessage(message).cast()
+    }
+
+    override suspend fun delete() {
+        if (bot.strangers.delegate.remove(this)) {
+            StrangerRelationChangeEvent.Deleted(this).broadcast()
+            cancel(CancellationException("Stranger deleted"))
+        }
+    }
+
+    override suspend fun says(message: MessageChain): MessageChain {
+        val src = newMsgSrc(true, message) { ids, internalIds, time ->
+            OnlineMsgSrcFromStranger(ids, internalIds, time, message, bot, this)
+        }
+        val msg = src.withMessage(message)
+        StrangerMessageEvent(this, msg, src.time).broadcast()
+        return msg
+    }
+}

+ 76 - 0
mirai-core-mock/src/internal/contact/friendfroup/MockFriendGroup.kt

@@ -0,0 +1,76 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.internal.contact.friendfroup
+
+import net.mamoe.mirai.contact.Friend
+import net.mamoe.mirai.contact.friendgroup.FriendGroup
+import net.mamoe.mirai.mock.MockBot
+import net.mamoe.mirai.mock.contact.MockFriend
+import net.mamoe.mirai.mock.utils.mock
+import net.mamoe.mirai.utils.cast
+
+internal class MockFriendGroup(
+    private val bot: MockBot,
+    override val id: Int,
+    override var name: String,
+) : FriendGroup {
+    override val friends: Collection<Friend> = object : AbstractCollection<Friend>() {
+
+        private val seq = sequence<Friend> {
+            bot.friends.forEach { mf ->
+                if (mf.mockApi.friendGroupId == id) {
+                    yield(mf)
+                }
+            }
+        }
+
+        override fun isEmpty(): Boolean {
+            return bot.friends.none { it.mockApi.friendGroupId == id }
+        }
+
+        override fun contains(element: Friend): Boolean {
+            if (element !is MockFriend) return false
+            if (element.bot !== bot) return false
+            return element.mockApi.friendGroupId == id
+        }
+
+        override val size: Int
+            get() = bot.friends.count { it.mockApi.friendGroupId == id }
+
+        override fun iterator(): Iterator<Friend> {
+            return seq.iterator()
+        }
+
+    }
+
+    override suspend fun renameTo(newName: String): Boolean {
+        name = newName
+        return true
+    }
+
+    override suspend fun moveIn(friend: Friend): Boolean {
+        val api = friend.mock().mockApi
+        if (api.friendGroupId == id) return false
+
+        api.friendGroupId = id
+        return true
+    }
+
+    override suspend fun delete(): Boolean {
+        if (id == 0) return false
+        if (bot.friendGroups.cast<MockFriendGroups>().groups.remove(this)) {
+            friends.forEach { it.mock().mockApi.friendGroupId = 0 }
+            return true
+        }
+        return false
+    }
+
+    override val count: Int get() = friends.size
+}

+ 55 - 0
mirai-core-mock/src/internal/contact/friendfroup/MockFriendGroups.kt

@@ -0,0 +1,55 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.internal.contact.friendfroup
+
+import net.mamoe.mirai.contact.friendgroup.FriendGroup
+import net.mamoe.mirai.contact.friendgroup.FriendGroups
+import net.mamoe.mirai.mock.MockBot
+import net.mamoe.mirai.utils.ConcurrentLinkedDeque
+import net.mamoe.mirai.utils.asImmutable
+import kotlin.math.absoluteValue
+import kotlin.random.Random
+
+internal class MockFriendGroups(
+    private val bot: MockBot,
+) : FriendGroups {
+    internal val groups = ConcurrentLinkedDeque<MockFriendGroup>()
+    private val defaultX = MockFriendGroup(bot, 0, "默认分组")
+
+    override val default: FriendGroup get() = defaultX
+
+    init {
+        groups.addLast(defaultX)
+    }
+
+    override suspend fun create(name: String): FriendGroup {
+        var newId: Int
+        do {
+            newId = Random.nextInt().absoluteValue
+        } while (groups.any { it.id == newId })
+
+        val newG = MockFriendGroup(bot, newId, name)
+        groups.addLast(newG)
+        return newG
+    }
+
+    override fun get(id: Int): FriendGroup? {
+        if (id == 0) return defaultX
+        return groups.find { it.id == id }
+    }
+
+    override fun asCollection(): Collection<FriendGroup> {
+        return groups.asImmutable()
+    }
+
+    fun findOrDefault(friendGroupId: Int): FriendGroup {
+        return get(friendGroupId) ?: defaultX
+    }
+}

+ 68 - 0
mirai-core-mock/src/internal/contact/roaming/MockRoamingMessages.kt

@@ -0,0 +1,68 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.internal.contact.roaming
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import net.mamoe.mirai.contact.Friend
+import net.mamoe.mirai.contact.Group
+import net.mamoe.mirai.contact.Stranger
+import net.mamoe.mirai.contact.roaming.RoamingMessageFilter
+import net.mamoe.mirai.contact.roaming.RoamingMessages
+import net.mamoe.mirai.contact.roaming.RoamingSupported
+import net.mamoe.mirai.message.data.MessageChain
+import net.mamoe.mirai.message.data.MessageSourceKind
+import net.mamoe.mirai.mock.internal.MockBotImpl
+import net.mamoe.mirai.utils.JavaFriendlyAPI
+import net.mamoe.mirai.utils.cast
+import java.util.stream.Stream
+import kotlin.streams.asStream
+
+internal class MockRoamingMessages(
+    internal val contact: RoamingSupported,
+) : RoamingMessages {
+    override suspend fun getMessagesIn(
+        timeStart: Long,
+        timeEnd: Long,
+        filter: RoamingMessageFilter?
+    ): Flow<MessageChain> {
+        return getMsg(timeStart, timeEnd, filter).asFlow()
+    }
+
+    private fun getMsg(
+        timeStart: Long,
+        timeEnd: Long,
+        filter: RoamingMessageFilter?
+    ): Sequence<MessageChain> {
+        val msgDb = contact.bot.cast<MockBotImpl>().msgDatabase
+        return msgDb.queryMessageInfosBy(
+            contact.id,
+            when (contact) {
+                is Friend -> MessageSourceKind.FRIEND
+                is Group -> MessageSourceKind.GROUP
+                is Stranger -> MessageSourceKind.STRANGER
+                else -> error(contact.javaClass.toString())
+            },
+            contact,
+            timeStart,
+            timeEnd,
+            filter ?: RoamingMessageFilter.ANY
+        ).map { it.message }
+    }
+
+    @JavaFriendlyAPI
+    override suspend fun getMessagesStream(
+        timeStart: Long,
+        timeEnd: Long,
+        filter: RoamingMessageFilter?
+    ): Stream<MessageChain> {
+        return getMsg(timeStart, timeEnd, filter).asStream()
+    }
+}

+ 140 - 0
mirai-core-mock/src/internal/contact/util.kt

@@ -0,0 +1,140 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE", "CANNOT_OVERRIDE_INVISIBLE_MEMBER")
+
+package net.mamoe.mirai.mock.internal.contact
+
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.contact.Group
+import net.mamoe.mirai.contact.Member
+import net.mamoe.mirai.contact.PermissionDeniedException
+import net.mamoe.mirai.internal.contact.uin
+import net.mamoe.mirai.internal.message.data.OnlineAudioImpl
+import net.mamoe.mirai.internal.message.image.DeferredOriginUrlAware
+import net.mamoe.mirai.message.data.*
+import net.mamoe.mirai.mock.MockBot
+import net.mamoe.mirai.mock.contact.MockGroup
+import net.mamoe.mirai.mock.utils.mock
+import net.mamoe.mirai.mock.utils.plusHttpSubpath
+import net.mamoe.mirai.mock.utils.randomImageContent
+import net.mamoe.mirai.utils.ExternalResource
+import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
+import net.mamoe.mirai.utils.cast
+import net.mamoe.mirai.utils.toUHexString
+
+
+internal fun Member.requireBotPermissionHigherThanThis(msg: String) {
+    if (this.permission < this.group.botPermission) return
+
+    throw PermissionDeniedException("bot current permission ${group.botPermission} can't modify $id($permission), $msg")
+}
+
+internal fun MessageSource.withMessage(msg: Message): MessageChain = buildMessageChain {
+    add(this@withMessage)
+    if (msg is MessageChain) {
+        msg.forEach { sub ->
+            if (sub !is MessageSource) {
+                add(sub)
+            }
+        }
+    } else if (msg !is MessageSource) {
+        add(msg)
+    }
+}
+
+@Suppress("UNUSED_PARAMETER")
+internal suspend fun ExternalResource.mockUploadAudio(bot: MockBot) = inResource {
+    OfflineAudio(
+        filename = md5.toUHexString() + ".amr",
+        fileMd5 = md5,
+        fileSize = size,
+        codec = AudioCodec.SILK,
+        extraData = null,
+    )
+}
+
+internal suspend fun ExternalResource.mockUploadVoice(bot: MockBot) = kotlin.run {
+    val md5 = this.md5
+    val size = this.size
+    @Suppress("DEPRECATION", "DEPRECATION_ERROR")
+    net.mamoe.mirai.message.data.Voice(
+        fileName = md5.toUHexString() + ".amr",
+        md5 = md5,
+        fileSize = size,
+        _url = bot.tmpResourceServer.uploadResourceAndGetUrl(this)
+    )
+}
+
+internal const val AQQ_RECALL_FAILED_MESSAGE: String = "No message meets the requirements"
+
+internal val Group.mockUin: Long
+    get() = when (this) {
+        is MockGroup -> this.uin
+        else -> this.uin
+    }
+
+
+internal suspend fun ExternalResource.mockImplUploadAudioAsOnline(bot: MockBot): OnlineAudio {
+    val md5 = this.md5
+    val size = this.size
+    return OnlineAudioImpl(
+        filename = md5.toUHexString() + ".amr",
+        fileMd5 = md5,
+        fileSize = size,
+        codec = AudioCodec.SILK,
+        url = bot.tmpResourceServer.uploadResourceAndGetUrl(this),
+        length = size,
+        originalPtt = null,
+    )
+}
+
+internal class MockImage(
+    override val imageId: String,
+    private val urlPath: String,
+    override val width: Int = 0,
+    override val height: Int = 0,
+    override val size: Long = 0,
+    override val imageType: ImageType = ImageType.UNKNOWN,
+) : DeferredOriginUrlAware, Image {
+
+    companion object {
+        // create a mockImage with random content
+        internal suspend fun random(bot: MockBot): MockImage {
+            val text = Image.randomImageContent()
+            return bot.uploadMockImage(text.toExternalResource().toAutoCloseable()).cast()
+        }
+    }
+
+    private val _stringValue: String? by lazy(LazyThreadSafetyMode.NONE) { "[mirai:image:$imageId]" }
+
+    override fun getUrl(bot: Bot): String {
+        if (urlPath.startsWith("http"))
+            return urlPath
+        return bot.mock().tmpResourceServer.storageRoot.toString().plusHttpSubpath(urlPath)
+    }
+
+    override fun toString(): String = _stringValue!!
+    override fun contentToString(): String = if (isEmoji) {
+        "[动画表情]"
+    } else {
+        "[图片]"
+    }
+
+    override fun appendMiraiCodeTo(builder: StringBuilder) {
+        builder.append("[mirai:image:").append(imageId).append("]")
+    }
+
+    override fun hashCode(): Int = imageId.hashCode()
+    override fun equals(other: Any?): Boolean {
+        if (other === this) return true
+        if (other !is Image) return false
+        return this.imageId == other.imageId
+    }
+}

+ 102 - 0
mirai-core-mock/src/internal/db/MsgDatabaseImpl.kt

@@ -0,0 +1,102 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.internal.db
+
+import net.mamoe.mirai.contact.Contact
+import net.mamoe.mirai.contact.roaming.RoamingMessage
+import net.mamoe.mirai.contact.roaming.RoamingMessageFilter
+import net.mamoe.mirai.message.data.MessageChain
+import net.mamoe.mirai.message.data.MessageSourceKind
+import net.mamoe.mirai.mock.database.MessageDatabase
+import net.mamoe.mirai.mock.database.MessageInfo
+import net.mamoe.mirai.mock.database.mockMsgDatabaseId
+import java.util.concurrent.ConcurrentLinkedDeque
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.random.Random
+
+internal class MsgDatabaseImpl : MessageDatabase {
+    override fun disconnect() {}
+    override fun connect() {}
+
+    val db = ConcurrentLinkedDeque<MessageInfo>()
+    val idCounter1 = AtomicInteger(Random.nextInt())
+    val idCounter2 = AtomicInteger(Random.nextInt())
+
+    override fun newMessageInfo(
+        sender: Long, subject: Long,
+        kind: MessageSourceKind,
+        time: Long,
+        message: MessageChain,
+    ): MessageInfo {
+        val dbid = mockMsgDatabaseId(idCounter1.getAndIncrement(), idCounter2.getAndDecrement())
+        val info = MessageInfo(
+            mixinedMsgId = dbid,
+            sender = sender,
+            subject = subject,
+            kind = kind,
+            time = time,
+            message = message,
+        )
+        db.add(info)
+        return info
+    }
+
+    override fun queryMessageInfo(msgId: Long): MessageInfo? {
+        return db.firstOrNull { it.mixinedMsgId == msgId }
+    }
+
+    override fun removeMessageInfo(msgId: Long) {
+        db.removeIf { it.mixinedMsgId == msgId }
+    }
+
+    override fun queryMessageInfosBy(
+        subject: Long, kind: MessageSourceKind,
+        contact: Contact,
+        timeStart: Long,
+        timeEnd: Long,
+        filter: RoamingMessageFilter
+    ): Sequence<MessageInfo> {
+        if (timeEnd < timeStart) return emptySequence()
+        return sequence<MessageInfo> {
+            val rm = object : RoamingMessage {
+                override val contact: Contact get() = contact
+                override var sender: Long = -1
+                override var target: Long = -1
+                override var time: Long = -1
+                override val ids: IntArray = intArrayOf(-1)
+                override val internalIds: IntArray = intArrayOf(-1)
+            }
+            for (msgInfo in db) {
+                if (msgInfo.kind != kind) continue
+                if (msgInfo.time < timeStart) continue
+                if (msgInfo.time > timeEnd) continue
+                if (msgInfo.subject != subject) continue
+
+                rm.sender = msgInfo.sender
+                if (kind != MessageSourceKind.GROUP) {
+                    if (msgInfo.sender == contact.id) {
+                        rm.target = contact.bot.id
+                    } else {
+                        rm.target = msgInfo.subject
+                    }
+                } else {
+                    rm.target = msgInfo.subject
+                }
+                rm.time = msgInfo.time
+                rm.ids[0] = msgInfo.id
+                rm.internalIds[0] = msgInfo.internal
+
+                if (filter.invoke(rm)) {
+                    yield(msgInfo)
+                }
+            }
+        }
+    }
+}

+ 176 - 0
mirai-core-mock/src/internal/msgsrc/OnlineMsgSrc.kt

@@ -0,0 +1,176 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.internal.msgsrc
+
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.contact.*
+import net.mamoe.mirai.message.data.MessageChain
+import net.mamoe.mirai.message.data.MessageSourceKind
+import net.mamoe.mirai.message.data.OnlineMessageSource
+import net.mamoe.mirai.mock.internal.contact.AbstractMockContact
+import net.mamoe.mirai.utils.currentTimeSeconds
+
+internal class OnlineMsgSrcToGroup(
+    override val ids: IntArray,
+    override val internalIds: IntArray,
+    override val time: Int,
+    override val originalMessage: MessageChain,
+    override val bot: Bot,
+    override val sender: Bot,
+    override val target: Group
+) : OnlineMessageSource.Outgoing.ToGroup() {
+    override val isOriginalMessageInitialized: Boolean get() = true
+}
+
+internal class OnlineMsgSrcToFriend(
+    override val ids: IntArray,
+    override val internalIds: IntArray,
+    override val time: Int,
+    override val originalMessage: MessageChain,
+    override val bot: Bot,
+    override val sender: Bot,
+    override val target: Friend
+) : OnlineMessageSource.Outgoing.ToFriend() {
+    override val isOriginalMessageInitialized: Boolean get() = true
+}
+
+internal class OnlineMsgSrcToStranger(
+    override val ids: IntArray,
+    override val internalIds: IntArray,
+    override val time: Int,
+    override val originalMessage: MessageChain,
+    override val bot: Bot,
+    override val sender: Bot,
+    override val target: Stranger
+) : OnlineMessageSource.Outgoing.ToStranger() {
+    override val isOriginalMessageInitialized: Boolean get() = true
+}
+
+internal class OnlineMsgSrcToTemp(
+    override val ids: IntArray,
+    override val internalIds: IntArray,
+    override val time: Int,
+    override val originalMessage: MessageChain,
+    override val bot: Bot,
+    override val sender: Bot,
+    override val target: Member
+) : OnlineMessageSource.Outgoing.ToTemp() {
+    override val isOriginalMessageInitialized: Boolean get() = true
+}
+
+internal class OnlineMsgFromGroup(
+    override val ids: IntArray,
+    override val internalIds: IntArray,
+    override val time: Int,
+    override val originalMessage: MessageChain,
+    override val bot: Bot,
+    override val sender: Member
+) : OnlineMessageSource.Incoming.FromGroup() {
+    override val isOriginalMessageInitialized: Boolean get() = true
+}
+
+internal class OnlineMsgSrcFromFriend(
+    override val ids: IntArray,
+    override val internalIds: IntArray,
+    override val time: Int,
+    override val originalMessage: MessageChain,
+    override val bot: Bot,
+    override val sender: Friend
+) : OnlineMessageSource.Incoming.FromFriend() {
+    override val isOriginalMessageInitialized: Boolean get() = true
+}
+
+internal class OnlineMsgSrcFromStranger(
+    override val ids: IntArray,
+    override val internalIds: IntArray,
+    override val time: Int,
+    override val originalMessage: MessageChain,
+    override val bot: Bot,
+    override val sender: Stranger
+) : OnlineMessageSource.Incoming.FromStranger() {
+    override val isOriginalMessageInitialized: Boolean get() = true
+}
+
+internal class OnlineMsgSrcFromTemp(
+    override val ids: IntArray,
+    override val internalIds: IntArray,
+    override val time: Int,
+    override val originalMessage: MessageChain,
+    override val bot: Bot,
+    override val sender: Member
+) : OnlineMessageSource.Incoming.FromTemp() {
+    override val isOriginalMessageInitialized: Boolean get() = true
+}
+
+internal class OnlineMsgSrcFromGroup(
+    override val ids: IntArray,
+    override val internalIds: IntArray,
+    override val time: Int,
+    override val originalMessage: MessageChain,
+    override val bot: Bot,
+    override val sender: Member
+) : OnlineMessageSource.Incoming.FromGroup() {
+    override val isOriginalMessageInitialized: Boolean get() = true
+}
+
+internal typealias MsgSrcConstructor<R> = (
+    ids: IntArray,
+    internalIds: IntArray,
+    time: Int,
+) -> R
+
+internal inline fun <R> AbstractMockContact.newMsgSrc(
+    isSaying: Boolean,
+    messageChain: MessageChain,
+    time: Long = currentTimeSeconds(),
+    constructor: MsgSrcConstructor<R>,
+): R {
+    val db = bot.msgDatabase
+    val info = if (isSaying) {
+        db.newMessageInfo(
+            sender = id,
+            subject = when (this) {
+                is Member -> group.id
+                is Stranger,
+                is Friend,
+                -> this.id
+                else -> error("Invalid contact: $this")
+            },
+            kind = when (this) {
+                is Member -> MessageSourceKind.GROUP
+                is Stranger -> MessageSourceKind.STRANGER
+                is Friend -> MessageSourceKind.FRIEND
+                else -> error("Invalid contact: $this")
+            },
+            message = messageChain,
+            time = time,
+        )
+    } else {
+        db.newMessageInfo(
+            sender = bot.id,
+            subject = this.id,
+            kind = when (this) {
+                is NormalMember -> MessageSourceKind.TEMP
+                is Stranger -> MessageSourceKind.STRANGER
+                is Friend -> MessageSourceKind.FRIEND
+                is Group -> MessageSourceKind.GROUP
+                else -> error("Invalid contact: $this")
+            },
+            message = messageChain,
+            time = time,
+        )
+    }
+    return constructor(
+        intArrayOf(info.id),
+        intArrayOf(info.internal),
+        info.time.toInt(),
+    )
+}
+

+ 121 - 0
mirai-core-mock/src/internal/remotefile/absolutefile/MockAbsoluteFile.kt

@@ -0,0 +1,121 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+@file:Suppress("invisible_member", "INVISIBLE_REFERENCE")
+
+package net.mamoe.mirai.mock.internal.remotefile.absolutefile
+
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.firstOrNull
+import net.mamoe.mirai.contact.FileSupported
+import net.mamoe.mirai.contact.file.AbsoluteFile
+import net.mamoe.mirai.contact.file.AbsoluteFolder
+import net.mamoe.mirai.internal.message.data.FileMessageImpl
+import net.mamoe.mirai.message.data.FileMessage
+import net.mamoe.mirai.mock.internal.remotefile.remotefile.MockRemoteFile
+import net.mamoe.mirai.mock.resserver.MockServerRemoteFile
+import net.mamoe.mirai.mock.utils.mock
+
+internal class MockAbsoluteFile(
+    override val sha1: ByteArray,
+    override val md5: ByteArray,
+    private val files: MockRemoteFiles,
+    override var parent: AbsoluteFolder?,
+    override val id: String,
+    override var name: String,
+    override var absolutePath: String,
+    override val contact: FileSupported = files.contact,
+    override var expiryTime: Long = 0L,
+    override val size: Long = 0,
+    override val isFile: Boolean = true,
+    override val isFolder: Boolean = false,
+    override val uploadTime: Long = 0,
+    override var lastModifiedTime: Long = 0,
+    override val uploaderId: Long = 0
+) : AbsoluteFile {
+    @Volatile
+    private var _exists = true
+    override suspend fun moveTo(folder: AbsoluteFolder): Boolean {
+        if (!exists()) return false
+        files.fileSystem.resolveById(id)!!.moveTo(files.fileSystem.resolveById(folder.id)!!)
+        this.parent = folder
+        refresh()
+        return true
+    }
+
+    override suspend fun getUrl(): String =
+        files.contact.bot.mock().tmpResourceServer.resolveHttpUrlByPath(
+            files.fileSystem.resolveById(id)!!.resolveNativePath()
+        ).toString()
+
+    override fun toMessage(): FileMessage {
+        //todo busId
+        return FileMessageImpl(id, 0, name, size)
+    }
+
+    override suspend fun refreshed(): AbsoluteFile? =
+        parent!!.files().filter { it.id == id }.firstOrNull()
+
+
+    private fun canModify(resolved: MockServerRemoteFile): Boolean {
+        return MockRemoteFile.canModify(resolved, contact)
+    }
+
+    override suspend fun exists(): Boolean = _exists
+
+    override suspend fun renameTo(newName: String): Boolean {
+        if (!exists()) return false
+        val resolved = files.fileSystem.resolveById(id) ?: return false
+        if (!canModify(resolved)) return false
+        if (resolved.rename(newName)) {
+            refresh()
+            return true
+        }
+        return false
+    }
+
+    override suspend fun delete(): Boolean {
+        if (!exists()) return false
+        val resolved = files.fileSystem.resolveById(id) ?: return false
+        if (!canModify(resolved)) return false
+        if (resolved.delete()) {
+            _exists = false
+            return true
+        }
+        return false
+    }
+
+    override suspend fun refresh(): Boolean {
+        val new = refreshed()
+        if (new == null) {
+            _exists = false
+            return false
+        }
+        _exists = true
+        this.parent = new.parent
+        this.expiryTime = new.expiryTime
+        this.name = new.name
+        this.lastModifiedTime = new.lastModifiedTime
+        this.absolutePath = new.absolutePath
+        return true
+    }
+
+    override fun toString(): String = "MockAbsoluteFile(id=$id,absolutePath=$absolutePath,name=$name)"
+    override fun equals(other: Any?): Boolean =
+        other != null && other is AbsoluteFile && other.id == id
+
+    override fun hashCode(): Int {
+        // from absoluteFileImpl
+        var result = super.hashCode()
+        result = 31 * result + expiryTime.hashCode()
+        result = 31 * result + size.hashCode()
+        result = 31 * result + sha1.contentHashCode()
+        result = 31 * result + md5.contentHashCode()
+        return result
+    }
+}

+ 266 - 0
mirai-core-mock/src/internal/remotefile/absolutefile/MockAbsoluteFolder.kt

@@ -0,0 +1,266 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+@file: Suppress("invisible_member", "invisible_reference")
+
+package net.mamoe.mirai.mock.internal.remotefile.absolutefile
+
+import kotlinx.coroutines.flow.*
+import net.mamoe.mirai.contact.FileSupported
+import net.mamoe.mirai.contact.PermissionDeniedException
+import net.mamoe.mirai.contact.file.AbsoluteFile
+import net.mamoe.mirai.contact.file.AbsoluteFileFolder
+import net.mamoe.mirai.contact.file.AbsoluteFolder
+import net.mamoe.mirai.contact.isOperator
+import net.mamoe.mirai.internal.utils.FileSystem
+import net.mamoe.mirai.mock.contact.MockGroup
+import net.mamoe.mirai.mock.internal.remotefile.remotefile.MockRemoteFile
+import net.mamoe.mirai.mock.resserver.MockServerRemoteFile
+import net.mamoe.mirai.utils.ExternalResource
+import net.mamoe.mirai.utils.JavaFriendlyAPI
+import net.mamoe.mirai.utils.ProgressionCallback
+import net.mamoe.mirai.utils.safeCast
+import java.util.stream.Stream
+import kotlin.streams.asStream
+
+private fun MockServerRemoteFile.toMockAbsFolder(files: MockRemoteFiles): AbsoluteFolder {
+    if (this == files.fileSystem.root) return files.root
+    val parent = this.parent.toMockAbsFolder(files)
+    return MockAbsoluteFolder(
+        files,
+        parent,
+        this.id,
+        this.name,
+        parent.absolutePath.removeSuffix("/") + "/" + this.name,
+        contentsCount = this.listFiles()?.count() ?: 0
+    )
+}
+
+private fun MockServerRemoteFile.toMockAbsFile(
+    files: MockRemoteFiles,
+    md5: ByteArray = byteArrayOf(),
+    sha1: ByteArray = byteArrayOf()
+): AbsoluteFile {
+    val parent = this.parent.toMockAbsFolder(files)
+    // todo md5 and sha
+    return MockAbsoluteFile(
+        sha1,
+        md5,
+        files,
+        parent,
+        this.id,
+        this.name,
+        parent.absolutePath.removeSuffix("/") + "/" + this.name
+    )
+}
+
+internal open class MockAbsoluteFolder(
+    internal val files: MockRemoteFiles,
+    override val parent: AbsoluteFolder? = null,
+    override val id: String = "/",
+    override var name: String = "/",
+    override var absolutePath: String = "/",
+    override val contact: FileSupported = files.contact,
+    override val isFile: Boolean = false,
+    override val isFolder: Boolean = true,
+    override val uploadTime: Long = 0L,
+    override var lastModifiedTime: Long = 0L,
+    override val uploaderId: Long = 0L,
+    override var contentsCount: Int = 0
+) : AbsoluteFolder {
+    private var _exists = true
+    override suspend fun refreshed(): AbsoluteFolder? = parent!!.resolveFolderById(id)
+
+    private fun currentTxRF() = files.fileSystem.resolveById(id)!!
+
+    override suspend fun folders(): Flow<AbsoluteFolder> =
+        currentTxRF().listFiles()?.filter { it.isDirectory }?.map { it.toMockAbsFolder(files) }?.asFlow() ?: emptyFlow()
+
+
+    @JavaFriendlyAPI
+    override suspend fun foldersStream(): Stream<AbsoluteFolder> =
+        currentTxRF().listFiles()?.filter { it.isDirectory }?.map { it.toMockAbsFolder(files) }?.asStream()
+            ?: Stream.empty()
+
+    override suspend fun files(): Flow<AbsoluteFile> =
+        currentTxRF().listFiles()?.filter { it.isFile }?.map { it.toMockAbsFile(files) }?.asFlow() ?: emptyFlow()
+
+    @JavaFriendlyAPI
+    override suspend fun filesStream(): Stream<AbsoluteFile> =
+        currentTxRF().listFiles()?.filter { it.isFile }?.map { it.toMockAbsFile(files) }?.asStream() ?: Stream.empty()
+
+    override suspend fun children(): Flow<AbsoluteFileFolder> =
+        files.fileSystem.resolveById(id)!!.listFiles()?.map {
+            if (it.isFile) it.toMockAbsFile(files)
+            else it.toMockAbsFolder(files)
+        }?.asFlow() ?: emptyFlow()
+
+    @JavaFriendlyAPI
+    override suspend fun childrenStream(): Stream<AbsoluteFileFolder> =
+        files.fileSystem.resolveById(id)!!.listFiles()?.map {
+            if (it.isFile) it.toMockAbsFile(files)
+            else it.toMockAbsFolder(files)
+        }?.asStream() ?: Stream.empty()
+
+    override suspend fun createFolder(name: String): AbsoluteFolder {
+        if (name.isBlank()) throw IllegalArgumentException("folder name cannot be blank.")
+
+        contact.safeCast<MockGroup>()?.let check@{ group ->
+            if (group.botPermission.isOperator()) return@check
+            throw IllegalStateException("Requires admin permission to create folder `$name`")
+        }
+
+        FileSystem.checkLegitimacy(name)
+        currentTxRF().mksubdir(name, 0L)
+        return resolveFolder(name)!!
+    }
+
+    override suspend fun resolveFolder(name: String): AbsoluteFolder? {
+        FileSystem.checkLegitimacy(name)
+        if (name.isBlank()) throw IllegalArgumentException("folder path cannot be blank")
+        val n = name.removePrefix("/").removeSuffix("/")
+        val a = absolutePath.removeSuffix("/")
+        val f = files.fileSystem.findByPath("$a/$n").firstOrNull() ?: return null
+        return f.toMockAbsFolder(files)
+    }
+
+    override suspend fun resolveFolderById(id: String): AbsoluteFolder? {
+        if (name.isBlank()) throw IllegalArgumentException("folder id cannot be blank.")
+        if (!FileSystem.isLegal(id)) return null
+        if (id == files.root.id) return files.root
+        if (this.id != files.root.id) return null // tx服务器只支持一层文件夹
+        val f = files.fileSystem.resolveById(id) ?: return null
+        if (!f.exists || !f.isDirectory) return null
+        return f.toMockAbsFolder(files)
+    }
+
+    override suspend fun resolveFileById(id: String, deep: Boolean): AbsoluteFile? {
+        if (id == "/" || id.isEmpty()) throw IllegalArgumentException("Illegal file id: $id")
+        files().firstOrNull { it.id == id }?.let { return it }
+        if (!deep) return null
+        return folders().map { it.resolveFileById(id, deep) }.firstOrNull { it != null }
+    }
+
+    override suspend fun resolveFiles(path: String): Flow<AbsoluteFile> {
+        if (path.isBlank()) throw IllegalArgumentException("path cannot be blank.")
+        if (!FileSystem.isLegal(path)) return emptyFlow()
+        if (path[0] == '/') return files.root.resolveFiles(path.removePrefix("/"))
+        return files.fileSystem.findByPath(absolutePath.removeSuffix("/") + "/" + path.removePrefix("/")).map {
+            it.toMockAbsFile(files)
+        }.asFlow()
+    }
+
+    @JavaFriendlyAPI
+    override suspend fun resolveFilesStream(path: String): Stream<AbsoluteFile> {
+        if (path.isBlank()) throw IllegalArgumentException("path cannot be blank.")
+        if (!FileSystem.isLegal(path)) return Stream.empty()
+        if (path[0] == '/') return files.root.resolveFilesStream(path.removePrefix("/"))
+        if (path.contains("/")) return resolveFolder(path.substringBefore("/"))?.resolveFilesStream(
+            path.substringAfter(
+                "/"
+            )
+        ) ?: Stream.empty()
+        return files.fileSystem.findByPath(absolutePath).map {
+            it.toMockAbsFile(files)
+        }.asStream()
+    }
+
+    override suspend fun resolveAll(path: String): Flow<AbsoluteFileFolder> {
+        if (path.isBlank()) throw IllegalArgumentException("path cannot be blank.")
+        FileSystem.checkLegitimacy(path)
+        val p = if (path.startsWith("/")) path
+        else "${absolutePath.removeSuffix("/")}/$path"
+        return files.fileSystem.findByPath(p).map {
+            if (it.isDirectory) it.toMockAbsFolder(files)
+            else it.toMockAbsFile(files)
+        }.asFlow()
+    }
+
+    @JavaFriendlyAPI
+    override suspend fun resolveAllStream(path: String): Stream<AbsoluteFileFolder> {
+        if (path.isBlank()) throw IllegalArgumentException("path cannot be blank.")
+        FileSystem.checkLegitimacy(path)
+        val p = if (path.startsWith("/")) path
+        else "${absolutePath.removeSuffix("/")}/$path"
+        return files.fileSystem.findByPath(p).map {
+            if (it.isDirectory) it.toMockAbsFolder(files)
+            else it.toMockAbsFile(files)
+        }.asStream()
+    }
+
+    override suspend fun uploadNewFile(
+        filepath: String, content: ExternalResource, callback: ProgressionCallback<AbsoluteFile, Long>?
+    ): AbsoluteFile {
+        contact.safeCast<MockGroup>()?.let check@{ group ->
+            if (group.controlPane.isAllowMemberFileUploading) return@check
+            if (group.botPermission.isOperator()) return@check
+            throw PermissionDeniedException("Group $group not allowed members to uploading new files.")
+        }
+
+        FileSystem.checkLegitimacy(filepath)
+        val folderName = filepath.removePrefix("/").substringBeforeLast("/")
+        val folder =
+            if (folderName == "") files.root
+            else if (filepath.removePrefix("/").contains("/")) resolveFolder(folderName) ?: createFolder(folderName)
+            else this
+        val f = files.fileSystem.resolveById(folder.id)!!
+            .uploadFile(filepath.substringAfterLast("/"), content, 0L)
+        return f.toMockAbsFile(files, content.md5, content.sha1)
+
+    }
+
+    override suspend fun exists(): Boolean = _exists
+
+    private fun canModify(resolved: MockServerRemoteFile): Boolean {
+        return MockRemoteFile.canModify(resolved, contact)
+    }
+
+    override suspend fun renameTo(newName: String): Boolean {
+        val resolved = files.fileSystem.resolveById(id) ?: return false
+        if (!canModify(resolved)) return false
+        if (resolved.rename(newName)) {
+            refresh()
+            return true
+        }
+        return false
+    }
+
+    override suspend fun delete(): Boolean {
+        if (!_exists) return false
+        val resolved = files.fileSystem.resolveById(id) ?: return false
+        if (!canModify(resolved)) return false
+        if (resolved.delete()) {
+            _exists = false
+            return true
+        }
+        return false
+    }
+
+    override suspend fun refresh(): Boolean {
+        val new = refreshed() ?: let {
+            _exists = false
+            return false
+        }
+        this.name = new.name
+        this.lastModifiedTime = new.lastModifiedTime
+        this.contentsCount = new.contentsCount
+        this.absolutePath = new.absolutePath
+        return false
+    }
+
+    override fun toString(): String = "MockAbsoluteFolder(id=$id,absolutePath=$absolutePath,name=$name"
+    override fun equals(other: Any?): Boolean =
+        other != null && other is AbsoluteFolder && other.id == id
+
+    override fun hashCode(): Int {
+        // from AbsoluteFolderImpl
+        var result = super.hashCode()
+        result = 31 * result + contentsCount.hashCode()
+        return result
+    }
+}

+ 51 - 0
mirai-core-mock/src/internal/remotefile/absolutefile/MockRemoteFiles.kt

@@ -0,0 +1,51 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:Suppress("ClassName")
+
+package net.mamoe.mirai.mock.internal.remotefile.absolutefile
+
+import net.mamoe.mirai.contact.FileSupported
+import net.mamoe.mirai.contact.file.AbsoluteFolder
+import net.mamoe.mirai.contact.file.RemoteFiles
+import net.mamoe.mirai.mock.resserver.MockServerFileSystem
+
+internal class MockRemoteFiles(
+    override val contact: FileSupported,
+    val fileSystem: MockServerFileSystem,
+) : RemoteFiles {
+    override val root: AbsoluteFolder = MRF_AbsoluteFolderRoot(this)
+}
+
+internal class MRF_AbsoluteFolderRoot(files: MockRemoteFiles) : MockAbsoluteFolder(files) {
+    override var contentsCount: Int
+        get() = 0
+        set(_) {}
+
+    override suspend fun refreshed(): AbsoluteFolder = MRF_AbsoluteFolderRoot(files)
+    override val parent: AbsoluteFolder? get() = null
+    override val id: String get() = "/"
+    override var name: String
+        get() = "/"
+        set(_) {}
+    override var absolutePath: String
+        get() = "/"
+        set(_) {}
+    override val isFile: Boolean get() = false
+    override val isFolder: Boolean get() = true
+    override val uploadTime: Long get() = 0
+    override var lastModifiedTime: Long
+        get() = 0
+        set(_) {}
+    override val uploaderId: Long get() = 0
+    override suspend fun exists(): Boolean = true
+    override suspend fun renameTo(newName: String): Boolean = false
+    override suspend fun delete(): Boolean = false
+    override suspend fun refresh(): Boolean = true
+}

+ 358 - 0
mirai-core-mock/src/internal/remotefile/remotefile/MockRemoteFile.kt

@@ -0,0 +1,358 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:Suppress("DEPRECATION", "INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
+
+package net.mamoe.mirai.mock.internal.remotefile.remotefile
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import net.mamoe.mirai.contact.Contact
+import net.mamoe.mirai.contact.FileSupported
+import net.mamoe.mirai.contact.PermissionDeniedException
+import net.mamoe.mirai.contact.isOperator
+import net.mamoe.mirai.internal.message.data.FileMessageImpl
+import net.mamoe.mirai.message.MessageReceipt
+import net.mamoe.mirai.message.data.FileMessage
+import net.mamoe.mirai.mock.contact.MockGroup
+import net.mamoe.mirai.mock.resserver.MockServerFileSystem
+import net.mamoe.mirai.mock.resserver.MockServerRemoteFile
+import net.mamoe.mirai.mock.utils.mock
+import net.mamoe.mirai.utils.*
+import kotlin.io.path.inputStream
+
+internal class RootRemoteFile(
+    val fileSystem: MockServerFileSystem,
+    override val contact: FileSupported,
+) : RemoteFile {
+    override val name: String get() = ""
+    override val id: String get() = "/"
+    override val path: String get() = "/"
+    override val parent: RemoteFile get() = this
+
+    override suspend fun isFile(): Boolean = false
+    override suspend fun length(): Long = 0
+    override suspend fun getInfo(): RemoteFile.FileInfo = fileSystem.root.fileInfo.let { inf ->
+        RemoteFile.FileInfo(
+            name = "/",
+            path = "/",
+            id = "/",
+            length = 0,
+            downloadTimes = 0,
+            uploaderId = inf.creator,
+            uploadTime = inf.createTime,
+            lastModifyTime = inf.lastUpdateTime,
+            sha1 = byteArrayOf(),
+            md5 = byteArrayOf(),
+        )
+    }
+
+    override suspend fun exists(): Boolean = true
+    override fun toString(): String = "MockRemoteFile[ROOT, contact=$contact]"
+
+    override fun resolve(relative: String): RemoteFile {
+        if (relative.isEmpty()) return this
+
+        val fixedPath = when {
+            relative[0] == '/' -> relative
+            else -> "/$relative"
+        }.let { ist ->
+            var end = ist.length
+            while (end > 1 && ist[end - 1] == '/') {
+                end--
+            }
+            ist.substring(0, end)
+        }
+
+        if (fixedPath == "/" || fixedPath == ".") return this
+
+        val fixedName = fixedPath.substringAfterLast('/')
+
+        return MockRemoteFile(
+            root = this,
+            parent = resolve(fixedPath.substring(0, fixedPath.lastIndexOf('/'))),
+            path = fixedPath,
+            fileId = null,
+            name = fixedName,
+        )
+    }
+
+    override fun resolve(relative: RemoteFile): RemoteFile = resolve(relative.path)
+
+    override suspend fun resolveById(id: String, deep: Boolean): RemoteFile? {
+        if (id == "/") return this
+        val resolved = fileSystem.resolveById(id) ?: return null
+        return convert(resolved)
+    }
+
+    internal fun convert(src: MockServerRemoteFile): RemoteFile {
+        if (src == fileSystem.root) return this
+        return MockRemoteFile(
+            name = src.name,
+            root = this,
+            path = src.path,
+            fileId = src.id,
+            parent = convert(src.parent)
+        )
+    }
+
+    override fun resolveSibling(relative: String): RemoteFile = resolve(relative)
+    override fun resolveSibling(relative: RemoteFile): RemoteFile = resolveSibling(relative.path)
+
+    override suspend fun delete(): Boolean = false
+    override suspend fun renameTo(name: String): Boolean = false
+    override suspend fun moveTo(target: RemoteFile): Boolean = false
+    override suspend fun mkdir(): Boolean = true
+
+    private fun listFilesSeq(): Sequence<RemoteFile> {
+        return fileSystem.root.listFiles()!!.map { convert(it) }
+    }
+
+    override suspend fun listFiles(): Flow<RemoteFile> = listFilesSeq().asFlow()
+
+    @JavaFriendlyAPI
+    override suspend fun listFilesIterator(lazy: Boolean): Iterator<RemoteFile> {
+        return listFilesSeq().iterator()
+    }
+
+    override suspend fun toMessage(): FileMessage? = null
+
+    @Deprecated(
+        "Use uploadAndSend instead.",
+        replaceWith = ReplaceWith("this.uploadAndSend(resource, callback)"),
+        level = DeprecationLevel.ERROR
+    )
+    override suspend fun upload(resource: ExternalResource, callback: RemoteFile.ProgressionCallback?): FileMessage {
+        error("Uploading as root directory")
+    }
+
+    @MiraiExperimentalApi
+    override suspend fun uploadAndSend(resource: ExternalResource): MessageReceipt<Contact> {
+        error("Uploading as root directory")
+    }
+
+    override suspend fun getDownloadInfo(): RemoteFile.DownloadInfo? = null
+
+    fun resolveTx(f: RemoteFile?): MockServerRemoteFile? {
+        if (f === this) return fileSystem.root
+        return f.cast<MockRemoteFile>().resolveFile()
+    }
+}
+
+@Suppress("DuplicatedCode")
+internal class MockRemoteFile(
+    val root: RootRemoteFile,
+    override val parent: RemoteFile,
+    override val path: String,
+    val fileId: String?,
+    override val name: String,
+) : RemoteFile {
+    override val id: String? get() = fileId
+    override val contact: FileSupported get() = root.contact
+    private val fileSystem get() = root.fileSystem
+    internal fun resolveFile(): MockServerRemoteFile? {
+        fileId?.let { fid ->
+            fileSystem.resolveById(fid)?.let { return it }
+        }
+        return fileSystem.findByPath(path).firstOrNull()
+    }
+
+    private fun convert(src: MockServerRemoteFile): RemoteFile = root.convert(src)
+
+    override suspend fun isFile(): Boolean {
+        return resolveFile()?.isFile ?: false
+    }
+
+    override suspend fun length(): Long {
+        val file = resolveFile() ?: return 0
+        return file.size
+    }
+
+    override suspend fun getInfo(): RemoteFile.FileInfo? {
+        val resolved = resolveFile() ?: return null
+        val fileInf = resolved.fileInfo
+        return RemoteFile.FileInfo(
+            name = resolved.name,
+            id = resolved.id,
+            path = resolved.path,
+            length = resolved.size,
+            downloadTimes = if (resolved.isFile) 1 else 0,
+            uploaderId = fileInf.creator,
+            uploadTime = fileInf.createTime,
+            lastModifyTime = fileInf.lastUpdateTime,
+            sha1 = if (resolved.isDirectory) {
+                byteArrayOf()
+            } else {
+                resolved.resolveNativePath().inputStream().use { it.sha1() }
+            },
+            md5 = if (resolved.isDirectory) {
+                byteArrayOf()
+            } else {
+                resolved.resolveNativePath().inputStream().use { it.md5() }
+            },
+        )
+    }
+
+    override suspend fun exists(): Boolean = resolveFile() != null
+
+    override fun toString(): String {
+        val resolved = resolveFile()
+        return "MockFile[c=$contact, resolved=$resolved]"
+    }
+
+    override fun resolve(relative: String): RemoteFile {
+        if (relative == "/" || relative == "" || relative[0] == '/') {
+            return root.resolve(relative)
+        }
+        return root.resolve("$path/$relative")
+    }
+
+    override fun resolve(relative: RemoteFile): RemoteFile = resolve(relative.path)
+
+    override suspend fun resolveById(id: String, deep: Boolean): RemoteFile? {
+        val resolved = fileSystem.resolveById(id) ?: return null
+        if (deep) return convert(resolved)
+        val thiz = resolveFile()
+        if (resolved.parent == thiz) return convert(resolved)
+        return null
+    }
+
+    override fun resolveSibling(relative: String): RemoteFile {
+        return parent.resolve(relative)
+    }
+
+    override fun resolveSibling(relative: RemoteFile): RemoteFile {
+        return parent.resolve(relative)
+    }
+
+    override suspend fun delete(): Boolean {
+        val resolved = resolveFile() ?: return false
+        if (!canModify(resolved, contact)) return false
+        return resolved.delete()
+    }
+
+    override suspend fun renameTo(name: String): Boolean {
+        val resolved = resolveFile() ?: return false
+
+        if (!canModify(resolved, contact)) return false
+
+        return resolved.rename(name)
+    }
+
+    override suspend fun moveTo(target: RemoteFile): Boolean {
+        val resolved = resolveFile() ?: return false
+
+        if (!canModify(resolved, contact)) return false
+
+        val targetF = root.resolveTx(target.parent) ?: return false
+        resolved.moveTo(targetF)
+        resolved.rename(target.name)
+        return true
+    }
+
+    override suspend fun mkdir(): Boolean {
+        contact.safeCast<MockGroup>()?.let check@{ group ->
+            if (group.botPermission.isOperator()) return@check
+            return false
+        }
+        if (resolveFile() != null) return false
+        val dirx = root.resolveTx(parent) ?: return false
+        return kotlin.runCatching {
+            dirx.mksubdir(name, contact.bot.id)
+        }.isSuccess
+    }
+
+    private fun listFilesSeq(): Sequence<RemoteFile> {
+        val resolved = resolveFile()?.listFiles() ?: return emptySequence()
+        return resolved.map { convert(it) }
+    }
+
+    override suspend fun listFiles(): Flow<RemoteFile> {
+        return listFilesSeq().asFlow()
+    }
+
+    @JavaFriendlyAPI
+    override suspend fun listFilesIterator(lazy: Boolean): Iterator<RemoteFile> {
+        return listFilesSeq().iterator()
+    }
+
+    override suspend fun toMessage(): FileMessage? {
+        val resolved = resolveFile() ?: return null
+        return FileMessageImpl(
+            name = resolved.name,
+            id = resolved.id,
+            size = resolved.size,
+            busId = 1544241
+        )
+    }
+
+    @Suppress("DEPRECATION", "DEPRECATION_ERROR", "OVERRIDE_DEPRECATION", "OverridingDeprecatedMember")
+    override suspend fun upload(resource: ExternalResource, callback: RemoteFile.ProgressionCallback?): FileMessage {
+        callback?.onBegin(this, resource)
+        try {
+            contact.safeCast<MockGroup>()?.let check@{ group ->
+                if (group.botPermission.isOperator()) return@check
+                if (group.controlPane.isAllowMemberFileUploading) return@check
+                throw PermissionDeniedException("Group $group disabled member file uploading...")
+            }
+
+
+            val parent = root.resolveTx(this.parent) ?: throw IllegalStateException("Parent ${this.parent} not found.")
+            val rsSize = resource.size
+            val rsp = parent.uploadFile(this.name, resource, contact.bot.id)
+            callback?.onProgression(this, resource, rsSize)
+            callback?.onSuccess(this, resource)
+            return FileMessageImpl(
+                name = rsp.name,
+                id = rsp.id,
+                size = rsp.size,
+                busId = 1544241
+            )
+        } catch (errx: Throwable) {
+            callback?.onFailure(this, resource, errx)
+            throw errx
+        }
+    }
+
+    @MiraiExperimentalApi
+    @Suppress("DEPRECATION_ERROR", "OverridingDeprecatedMember")
+    override suspend fun uploadAndSend(resource: ExternalResource): MessageReceipt<Contact> {
+        return contact.sendMessage(upload(resource))
+    }
+
+    override suspend fun getDownloadInfo(): RemoteFile.DownloadInfo? {
+        val resolved = resolveFile() ?: return null
+        if (!resolved.isFile) return null
+        val ntp = resolved.resolveNativePath()
+        return RemoteFile.DownloadInfo(
+            filename = resolved.name,
+            id = resolved.id,
+            path = resolved.path,
+            sha1 = ntp.inputStream().use { it.sha1() },
+            md5 = ntp.inputStream().use { it.md5() },
+            url = contact.bot.mock().tmpResourceServer.resolveHttpUrlByPath(ntp).toString()
+        )
+    }
+
+    companion object {
+        internal fun canModify(resolved: MockServerRemoteFile, contact: FileSupported): Boolean {
+            contact.safeCast<MockGroup>()?.let check@{ group ->
+                if (group.botPermission.isOperator()) return true
+                if (resolved.isDirectory) return false
+
+                val finf = resolved.fileInfo
+                if (finf.creator == group.bot.id) return true
+
+                return false
+            }
+
+            return true
+        }
+    }
+}

+ 368 - 0
mirai-core-mock/src/internal/serverfs/MockServerFileDiskImpl.kt

@@ -0,0 +1,368 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
+
+package net.mamoe.mirai.mock.internal.serverfs
+
+import io.ktor.utils.io.core.*
+import io.ktor.utils.io.streams.*
+import net.mamoe.mirai.mock.resserver.MockServerFileDisk
+import net.mamoe.mirai.mock.resserver.MockServerFileSystem
+import net.mamoe.mirai.mock.resserver.MockServerRemoteFile
+import net.mamoe.mirai.mock.resserver.TxRemoteFileInfo
+import net.mamoe.mirai.utils.*
+import java.io.InputStream
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.attribute.FileTime
+import java.util.*
+import java.util.concurrent.ConcurrentLinkedDeque
+import kotlin.io.path.*
+import kotlin.io.use
+import net.mamoe.mirai.internal.utils.FileSystem as MiraiFileSystem
+
+private fun allocateNewPath(base: Path): Path {
+    while (true) {
+        val p = base.resolve(UUID.randomUUID().toString())
+        if (!p.exists()) return p
+    }
+}
+
+private fun checkFileName(name: String) {
+    MiraiFileSystem.checkLegitimacy(name)
+    if (name.contains('/')) error("$name contains '/'")
+    if (name.isEmpty()) error("Empty name")
+}
+
+internal class MockServerFileDiskImpl(
+    internal val storage: Path
+) : MockServerFileDisk {
+    internal val fs: MutableCollection<MockServerFileSystem> = ConcurrentLinkedDeque()
+
+    override val availableSystems: Sequence<MockServerFileSystem> = Sequence { fs.iterator() }
+
+    override fun newFsSystem(): MockServerFileSystem = MockServerFileSystemImpl(this)
+}
+
+internal class MockServerFileSystemImpl(
+    override val disk: MockServerFileDiskImpl,
+) : MockServerFileSystem {
+    internal val storage: Path = allocateNewPath(disk.storage)
+
+    internal fun resolvePath(id: String): Path = when {
+        id.isEmpty() || id == "/" -> storage.resolve("root")
+        id[0] == '/' -> storage.resolve(id.substring(1))
+        else -> error("file not exists: $id")
+    }
+
+    internal fun fileDetails(id: String): Path? = when {
+        id.isEmpty() || id == "/" -> storage.resolve("details/root")
+        id[0] == '/' -> {
+            storage
+                .resolve("details")
+                .resolve(id.substring(1))
+        }
+
+        else -> null
+    }
+
+    internal fun resolveName(id: String): String = when {
+        id.isEmpty() || id == "/" -> ""
+        id[0] == '/' -> {
+            val nameMapping = fileDetails(id)?.resolve("name")
+            if (nameMapping == null) null
+            else if (nameMapping.isFile) {
+                nameMapping.readText()
+            } else null
+        }
+
+        else -> null
+    } ?: id.substringAfterLast('/')
+
+    fun resolveParent(id: String): MockServerFileImpl {
+        val details = fileDetails(id) ?: return root
+        val parent = details.resolve("parent")
+        if (parent.isFile) {
+            return resolveById(parent.readText()) ?: root
+        }
+        return root
+    }
+
+    init {
+        storage.mkdirs()
+        storage.resolve("details/root").mkdirs()
+        storage.resolve("root").mkdirs()
+        overrideDetails(fileDetails("/")!!, name = "", creator = 0, createTime = 0)
+        disk.fs.add(this)
+    }
+
+    override val root = MockServerFileImpl(this, "/")
+
+    override fun resolveById(id: String): MockServerFileImpl? {
+        if (id == "/" || id.isEmpty()) return root
+        if (id[0] != '/') return null
+        if (MiraiFileSystem.isLegal(id) && id.count { it == '/' } == 1) {
+            return MockServerFileImpl(this, id).takeIf { it.toPath.exists() }
+        }
+        return null
+    }
+
+    override fun findByPath(path: String): Sequence<MockServerRemoteFile> {
+        return root.findByPath(
+            MiraiFileSystem.normalize(path)
+                .removePrefix("/")
+                .split('/')
+                .toMutableList()
+        )
+    }
+
+    fun findDirByName(base: MockServerFileImpl, name: String): MockServerFileImpl? {
+        return (base.listFiles() ?: return null)
+            .filter { it.isDirectory }
+            .filter { it.name == name }
+            .firstOrNull()?.cast()
+    }
+
+    fun uploadFile(
+        name: String,
+        content: ExternalResource,
+        uploader: Long,
+        id: String,
+        toPath: Path
+    ): MockServerFileImpl {
+        val path = allocateNewPath(storage)
+        val fid = '/' + path.name
+
+        path.outputStream().buffered().use { output ->
+            content.inputStream().use { resource -> resource.copyTo(output) }
+        }
+
+        toPath.resolve(path.name).createFile()
+
+        val details = fileDetails(fid)!!
+        details.mkdirs()
+        overrideDetails(details, id, name, uploader, currentTimeMillis())
+
+        return MockServerFileImpl(this, fid)
+    }
+
+    fun overrideDetails(
+        details: Path,
+        parent: String? = null,
+        name: String? = null,
+        creator: Long = -1L,
+        createTime: Long = -1L,
+    ) {
+        if (parent != null) {
+            details.resolve("parent").writeText(parent)
+        }
+        if (name != null) {
+            details.resolve("name").writeText(name)
+        }
+        if (creator != -1L) {
+            details.resolve("creator").writeBytes(creator.toByteArray())
+        }
+        if (createTime != -1L) {
+            details.resolve("createTime").writeBytes(createTime.toByteArray())
+        }
+    }
+
+    fun mkdir(id: String, name: String, creator: Long, toPath: Path): MockServerFileImpl {
+        if (id != "/") error("Creating 2nd directories, MockServerFileSystem current not support")
+
+        // Find existing subdir
+        Files.newDirectoryStream(toPath).use { ptdirstream ->
+            val exists = ptdirstream.firstOrNull { subfile ->
+                if (storage.resolve(subfile).isFile) return@firstOrNull false
+                val nameFile = storage.resolve("details").resolve(subfile.fileName).resolve("name")
+                return@firstOrNull nameFile.readText() == name
+            }
+            if (exists != null) {
+                return MockServerFileImpl(this, "/" + exists.fileName)
+            }
+        }
+
+        val path = allocateNewPath(storage)
+        val fid = '/' + path.name
+        path.mkdir()
+
+        toPath.resolve(path.name).createFile()
+        val details = fileDetails(fid)!!
+        details.mkdirs()
+        overrideDetails(details, id, name, creator, currentTimeMillis())
+
+        return MockServerFileImpl(this, fid)
+    }
+
+    fun resolveAbsPath(id: String): String {
+        if (id == "/") return "/"
+
+        val details = fileDetails(id) ?: return "<not exists>"
+        val fileNamePath = details.resolve("name")
+        val fileName = fileNamePath.takeIf { it.isFile }?.readText() ?: "<not exists>"
+        val parentPath = details.resolve("parent")
+        if (!parentPath.isFile) {
+            return fileName
+        }
+        val pid = parentPath.readText()
+        val pabs = resolveAbsPath(pid)
+        if (pabs.endsWith("/")) return "$pabs$fileName"
+        return "$pabs/$fileName"
+    }
+}
+
+internal class MockServerFileImpl(
+    override val system: MockServerFileSystemImpl,
+    override val id: String,
+) : MockServerRemoteFile {
+    internal val toPath: Path get() = system.resolvePath(id)
+    override val exists: Boolean get() = toPath.exists()
+    override val isFile: Boolean get() = toPath.isFile
+    override val isDirectory: Boolean get() = toPath.isDirectory()
+    override val name: String get() = system.resolveName(id)
+    override val path: String get() = system.resolveAbsPath(id)
+    override val parent: MockServerFileImpl get() = system.resolveParent(id)
+    override val size: Long
+        get() {
+            val pt = toPath
+            if (pt.isFile) return pt.fileSize()
+            return 0
+        }
+
+    override fun listFiles(): Sequence<MockServerRemoteFile>? {
+        val pt = toPath
+        if (!pt.isDirectory()) {
+            return null
+        }
+        return pt.listDirectoryEntries().asSequence().filter {
+            it.exists()
+        }.map { MockServerFileImpl(system, '/' + it.name) }
+    }
+
+    override fun delete(): Boolean {
+        if (!toPath.deleteIfExists()) return false
+        val details = system.fileDetails(id) ?: return false
+        system.resolvePath(details.resolve("parent").readText())
+            .resolve(id.substring(1))
+            .deleteIfExists()
+        details.deleteRecursively()
+        return true
+    }
+
+    override fun rename(name: String): Boolean {
+        checkFileName(name)
+        if (id.isEmpty() || id == "/") return false
+        val details = system.fileDetails(id) ?: return false
+        details.resolve("name").writeText(name)
+        return true
+    }
+
+    override fun moveTo(path: MockServerRemoteFile) {
+        path.cast<MockServerFileImpl>()
+        if (path.system !== this.system) error("Cross file system moving")
+
+        if (!path.isDirectory) error("Remote file $path not exists")
+        if (id == "/") error("Moving root")
+
+        // TODO: 移动到自己的子目录
+
+        val details = system.fileDetails(id) ?: error("Moving ghost file: $id")
+
+        val currentParent = parent
+        currentParent.toPath.resolve(id.substring(1)).deleteIfExists()
+
+        details.resolve("parent").writeText(path.id)
+        path.toPath.resolve(id.substring(1)).createFile()
+    }
+
+    override fun resolveNativePath(): Path {
+        val pt = toPath
+        if (!pt.isFile) error("file not exists: $this <$pt>")
+        return pt
+    }
+
+    override fun asExternalResource(): ExternalResource {
+        val pt = toPath
+        if (!pt.isFile) error("file not exists: $pt")
+        return object : AbstractExternalResource() {
+            override fun inputStream0(): InputStream {
+                return toPath.inputStream()
+            }
+
+            override val size: Long
+                get() = toPath.fileSize()
+
+            @MiraiExperimentalApi
+            override fun input(): Input {
+                return inputStream0().asInput()
+            }
+        }
+    }
+
+    override fun uploadFile(name: String, content: ExternalResource, uploader: Long): MockServerFileImpl {
+        content.withAutoClose {
+            checkFileName(name)
+            val storage = toPath
+            if (storage.isFile) error("Uploading file to a file")
+            if (!storage.isDirectory()) error("$this not exists")
+
+            return system.uploadFile(name, content, uploader, id, toPath)
+        }
+    }
+
+    override fun mksubdir(name: String, creator: Long): MockServerRemoteFile {
+        checkFileName(name)
+        return system.mkdir(id, name, creator, toPath)
+    }
+
+    override var fileInfo: TxRemoteFileInfo
+        get() {
+            val details = system.fileDetails(id) ?: error("File not exists")
+            if (!details.isDirectory()) {
+                error("File not exists")
+            }
+            // parent, name, creator, createTime
+            return TxRemoteFileInfo(
+                creator = details.resolve("creator").readBytes().toLong(),
+                createTime = details.resolve("createTime").readBytes().toLong(),
+                lastUpdateTime = toPath.getLastModifiedTime().toMillis(),
+            )
+        }
+        set(value) {
+            val details = system.fileDetails(id) ?: error("File not exists")
+            if (!details.isDirectory()) {
+                error("File not exists")
+            }
+            details.resolve("creator").writeBytes(value.creator.toByteArray())
+            details.resolve("createTime").writeBytes(value.createTime.toByteArray())
+            toPath.setLastModifiedTime(FileTime.fromMillis(value.lastUpdateTime))
+        }
+
+    override fun toString(): String = "$path := $id"
+
+    override fun equals(other: Any?): Boolean {
+        if (other !is MockServerFileImpl) return false
+        if (other.system !== system) return false
+        return other.id == this.id
+    }
+
+    override fun hashCode(): Int {
+        return id.hashCode() + system.hashCode()
+    }
+
+    fun findByPath(path: MutableList<String>): Sequence<MockServerRemoteFile> {
+        if (path.isEmpty()) error("Empty path")
+        val nxt = path.removeAt(0)
+        if (nxt.isEmpty()) error("Empty subpath")
+        if (path.isEmpty()) return listFiles()?.filter { it.name == nxt } ?: emptySequence()
+
+        return system.findDirByName(this, nxt)?.findByPath(path) ?: emptySequence()
+    }
+}

+ 153 - 0
mirai-core-mock/src/internal/serverfs/TmpResourceServerImpl.kt

@@ -0,0 +1,153 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.internal.serverfs
+
+import io.ktor.server.application.*
+import io.ktor.server.engine.*
+import io.ktor.server.netty.*
+import io.ktor.server.plugins.*
+import io.ktor.server.response.*
+import net.mamoe.mirai.mock.resserver.MockServerFileDisk
+import net.mamoe.mirai.mock.resserver.TmpResourceServer
+import net.mamoe.mirai.utils.*
+import java.net.ServerSocket
+import java.net.URI
+import java.net.URLDecoder
+import java.net.URLEncoder
+import java.nio.file.Path
+import kotlin.io.path.*
+
+internal class TmpResourceServerImpl(
+    override val storageRoot: Path,
+    private val serverPort: Int,
+    private val closeSystemOnShutdown: Boolean,
+) : TmpResourceServer {
+    var logger by lateinitMutableProperty {
+        MiraiLogger.Factory.create(TmpResourceServerImpl::class.java, "TmpFsServer-${hashCode()}")
+    }
+    lateinit var server: NettyApplicationEngine
+
+    private var _serverUri: URI by lateinitMutableProperty {
+        URI.create("http://localhost:$serverPort")
+    }
+    override val serverUri: URI get() = _serverUri
+
+    override val mockServerFileDisk: MockServerFileDisk by lazy {
+        MockServerFileDiskImpl(storageRoot.resolve("tx-fs-disk"))
+    }
+
+    private var _isActive: Boolean = false
+    override val isActive: Boolean get() = _isActive
+
+    private val storage: Path = storageRoot.resolve("storage").mkdirsIfMissing()
+    private val images: Path = storageRoot.resolve("images").mkdirsIfMissing()
+
+    override suspend fun uploadResource(resource: ExternalResource): String {
+        fun ByteArray.hex() = toUHexString(separator = "")
+
+        resource.useAutoClose {
+            val resourceId = "${resource.size}-${resource.sha1.hex()}-${resource.md5.hex()}"
+            val locPath = storage.resolve(resourceId)
+            if (locPath.isFile) return resourceId
+            runBIO {
+                locPath.outputStream().use { output ->
+                    resource.inputStream().use { it.copyTo(output) }
+                }
+            }
+            return resourceId
+        }
+    }
+
+    override suspend fun uploadResourceAsImage(resource: ExternalResource): URI {
+        val imgId = generateUUID(resource.md5)
+        val resId = uploadResource(resource)
+        images.resolve(imgId).createLinkPointingTo(storage.resolve(resId))
+        return resolveImageUrl(imgId)
+    }
+
+    override fun resolveHttpUrl(resourceId: String): URI {
+        return serverUri.resolve("storage/$resourceId")
+    }
+
+    override fun resolveImageUrl(imgId: String): URI {
+        return serverUri.resolve("images/$imgId")
+    }
+
+    override suspend fun invalidateResource(resourceId: String) {
+        storage.resolve(resourceId).deleteIfExists()
+    }
+
+    override fun resolveHttpUrlByPath(path: Path): URI {
+        if (path.fileSystem !== storageRoot.fileSystem)
+            throw UnsupportedOperationException("Cross file system linking is not supported now")
+        val pt = path.toAbsolutePath().toString().replace('\\', '/')
+        return serverUri.resolve(
+            "abs/" + URLEncoder.encode(pt, "UTF-8")
+        )
+    }
+
+    override fun startupServer() {
+        val port = if (serverPort == 0) {
+            ServerSocket(0).use { it.localPort }
+        } else serverPort
+        _serverUri = URI.create("http://127.0.0.1:$port/")
+        logger.info { "Tmp Fs Server started: $serverUri" }
+
+        val server = embeddedServer(Netty, environment = applicationEngineEnvironment {
+            connector {
+                this.host = "127.0.0.1"
+                this.port = port
+            }
+            module {
+                @Suppress("BlockingMethodInNonBlockingContext")
+                intercept(ApplicationCallPipeline.Call) {
+                    val req = URI.create(call.request.origin.uri).path.removePrefix("/")
+                    val targetPath = if (req.startsWith("abs/")) {
+                        storageRoot.fileSystem.getPath(URLDecoder.decode(req.substring(4), "UTF-8"))
+                    } else {
+                        storageRoot.resolve(req)
+                    }
+                    if (targetPath.exists()) {
+                        call.respondOutputStream {
+                            net.mamoe.mirai.utils.runBIO {
+                                targetPath.inputStream().buffered().use { it.copyTo(this@respondOutputStream) }
+                            }
+                        }
+                        return@intercept
+                    }
+                    if (req.startsWith("images/")) {
+                        call.respondRedirect(
+                            "http://gchat.qpic.cn/gchatpic_new/1145141919/0-0-${
+                                req.substring(7)
+                            }/0?term=2", false
+                        )
+                        return@intercept
+                    }
+                }
+            }
+        })
+        this.server = server
+        server.start(wait = false)
+    }
+
+    override fun close() {
+        if (this::server.isInitialized) {
+            server.stop(0, 0)
+        }
+        if (closeSystemOnShutdown) {
+            storageRoot.fileSystem.close()
+        }
+    }
+}
+
+private fun Path.mkdirsIfMissing(): Path {
+    if (!exists()) createDirectories()
+    return this
+}

+ 11 - 0
mirai-core-mock/src/package.kt

@@ -0,0 +1,11 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock
+

+ 25 - 0
mirai-core-mock/src/resserver/MockServerFileDisk.kt

@@ -0,0 +1,25 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.resserver
+
+import net.mamoe.mirai.mock.internal.serverfs.MockServerFileDiskImpl
+import java.nio.file.Path
+
+public interface MockServerFileDisk {
+    public val availableSystems: Sequence<MockServerFileSystem>
+    public fun newFsSystem(): MockServerFileSystem
+
+    public companion object {
+        @JvmStatic
+        public fun newFileDisk(storage: Path): MockServerFileDisk {
+            return MockServerFileDiskImpl(storage)
+        }
+    }
+}

+ 17 - 0
mirai-core-mock/src/resserver/MockServerFileSystem.kt

@@ -0,0 +1,17 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.resserver
+
+public interface MockServerFileSystem {
+    public val disk: MockServerFileDisk
+    public val root: MockServerRemoteFile
+    public fun resolveById(id: String): MockServerRemoteFile?
+    public fun findByPath(path: String): Sequence<MockServerRemoteFile>
+}

+ 51 - 0
mirai-core-mock/src/resserver/MockServerRemoteFile.kt

@@ -0,0 +1,51 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.resserver
+
+import net.mamoe.mirai.utils.ExternalResource
+import java.nio.file.Path
+
+public interface MockServerRemoteFile {
+    public val system: MockServerFileSystem
+
+    public val isFile: Boolean
+    public val isDirectory: Boolean
+    public val name: String
+    public val path: String
+    public val id: String
+    public val exists: Boolean
+    public val parent: MockServerRemoteFile
+    public val size: Long
+
+    public fun listFiles(): Sequence<MockServerRemoteFile>?
+    public fun delete(): Boolean
+    public fun rename(name: String): Boolean
+
+    /**
+     * 移动文件
+     * @param path 目标目录
+     */
+    public fun moveTo(path: MockServerRemoteFile)
+
+    public fun asExternalResource(): ExternalResource
+    public fun resolveNativePath(): Path
+
+    public fun uploadFile(name: String, content: ExternalResource, uploader: Long): MockServerRemoteFile
+
+    public fun mksubdir(name: String, creator: Long): MockServerRemoteFile
+
+    public var fileInfo: TxRemoteFileInfo
+}
+
+public data class TxRemoteFileInfo(
+    @JvmField var creator: Long,
+    @JvmField var createTime: Long,
+    @JvmField var lastUpdateTime: Long,
+)

+ 95 - 0
mirai-core-mock/src/resserver/TmpResourceServer.kt

@@ -0,0 +1,95 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.resserver
+
+import com.google.common.jimfs.Configuration
+import com.google.common.jimfs.Jimfs
+import me.him188.kotlin.jvm.blocking.bridge.JvmBlockingBridge
+import net.mamoe.mirai.mock.MockBot
+import net.mamoe.mirai.mock.internal.serverfs.TmpResourceServerImpl
+import net.mamoe.mirai.utils.ExternalResource
+import java.io.Closeable
+import java.net.URI
+import java.nio.file.Path
+
+/**
+ * 临时资源中转服务器
+ *
+ * 此服务器用于中转测试中涉及到的各种临时数据, 如 图片、语音、群文件 等
+ *
+ * 如果 [TmpResourceServer] 被用于 [MockBot], 在 [MockBot] 关闭时也会同步关闭 [TmpResourceServer]
+ *
+ */
+@JvmBlockingBridge
+public interface TmpResourceServer : Closeable {
+    public val serverUri: URI
+    public val storageRoot: Path
+    public val mockServerFileDisk: MockServerFileDisk
+    public val isActive: Boolean
+
+    /**
+     * 上传一个资源
+     *
+     * @return 资源 ID, 可通过 [resolveHttpUrl] 获得 http 链接
+     */
+    public suspend fun uploadResource(resource: ExternalResource): String
+
+    /**
+     * 上传图片
+     *
+     * @return 图片的 http 链接
+     */
+    public suspend fun uploadResourceAsImage(resource: ExternalResource): URI
+    public suspend fun uploadResourceAndGetUrl(resource: ExternalResource): String {
+        return resolveHttpUrl(uploadResource(resource)).toString()
+    }
+
+    public fun resolveHttpUrl(resourceId: String): URI
+    public fun resolveImageUrl(imgId: String): URI
+
+    /**
+     * 立即释放目标资源, 此后再次访问该资源 ([resourceId]) 时会得到 404 Not Found
+     */
+    public suspend fun invalidateResource(resourceId: String)
+
+    /**
+     * 获取一个对应 [path] 的 http 链接
+     */
+    public fun resolveHttpUrlByPath(path: Path): URI
+
+    /**
+     * 启动 Http Server.
+     *
+     * 如果 [TmpResourceServer] 被用于 [MockBot], [MockBot] 会自动启动服务器, 请不要自行启动
+     */
+    public fun startupServer()
+
+    public companion object {
+        @JvmStatic
+        public fun of(
+            path: Path,
+            port: Int = 0,
+            closeFileSystemWhenClose: Boolean = false,
+        ): TmpResourceServer {
+            return TmpResourceServerImpl(path, port, closeFileSystemWhenClose)
+        }
+
+        @JvmStatic
+        public fun newInMemoryTmpResourceServer(port: Int = 0): TmpResourceServer {
+            val fs = Jimfs.newFileSystem(
+                Configuration.unix()
+                    .toBuilder()
+                    .setWorkingDirectory("/")
+                    .build()
+            )
+            return of(fs.getPath("/"), port, true)
+        }
+    }
+}

+ 171 - 0
mirai-core-mock/src/userprofile/UserProfileService.kt

@@ -0,0 +1,171 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.userprofile
+
+import me.him188.kotlin.jvm.blocking.bridge.JvmBlockingBridge
+import net.mamoe.mirai.IMirai
+import net.mamoe.mirai.data.UserProfile
+import net.mamoe.mirai.mock.MockBot
+import net.mamoe.mirai.mock.userprofile.MockUserProfileBuilder.Companion.invoke
+import net.mamoe.mirai.utils.runBIO
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
+
+/**
+ * 用户资料服务, 用于 [IMirai.queryProfile] 查询用户资料
+ *
+ * implementation note: Java 请实现 [JUserProfileService]
+ *
+ * @see MockBot.userProfileService
+ * @see MockUserProfileBuilder
+ */
+@JvmBlockingBridge
+public interface UserProfileService {
+    public suspend fun doQueryUserProfile(id: Long): UserProfile
+
+    /**
+     * 将 [id] 的用户资料指定为 [profile]
+     *
+     * implementation note:
+     *
+     * 框架内部并不会使用此接口, 该接口是设计于测试单元动态注册 [UserProfile],
+     * 如无调用此接口的需求可以实现为 `throw new UnsupportedOperationException()`
+     */
+    public suspend fun putUserProfile(id: Long, profile: UserProfile)
+
+    public companion object {
+        @JvmStatic
+        public fun getInstance(): UserProfileService {
+            return UserProfileServiceImpl()
+        }
+    }
+}
+
+/**
+ * 用于资料服务, 用于 [IMirai.queryProfile] 查询用户资料
+ *
+ * 该接口是为了方便 Java 实现 [UserProfileService],
+ * kotlin 请实现 [UserProfileService]
+ */
+@Suppress("ILLEGAL_JVM_NAME", "INAPPLICABLE_JVM_NAME")
+public interface JUserProfileService : UserProfileService {
+    override suspend fun doQueryUserProfile(id: Long): UserProfile {
+        return runBIO {
+            doQueryUserProfileJ(id) ?: buildUserProfile { }
+        }
+    }
+
+    override suspend fun putUserProfile(id: Long, profile: UserProfile) {
+        runBIO {
+            putUserProfileJ(id, profile)
+        }
+    }
+
+    // override UserProfileService @JvmBlockingBridge
+    @JvmName("doQueryUserProfile")
+    public fun doQueryUserProfileJ(id: Long): UserProfile?
+
+    @JvmName("putUserProfile")
+    public fun putUserProfileJ(id: Long, profile: UserProfile)
+}
+
+/**
+ * [UserProfile] 的构造器
+ *
+ * @see [invoke]
+ * @see [buildUserProfile]
+ */
+public interface MockUserProfileBuilder {
+    public fun build(): UserProfile
+
+    public fun nickname(value: String): MockUserProfileBuilder
+    public fun email(value: String): MockUserProfileBuilder
+    public fun age(value: Int): MockUserProfileBuilder
+    public fun qLevel(value: Int): MockUserProfileBuilder
+    public fun sex(value: UserProfile.Sex): MockUserProfileBuilder
+    public fun sign(value: String): MockUserProfileBuilder
+    public fun friendGroupId(value: Int): MockUserProfileBuilder
+
+    public companion object {
+        @JvmStatic
+        @JvmName("newBuilder")
+        public operator fun invoke(): MockUserProfileBuilder = MockUPBuilderImpl()
+    }
+}
+
+/**
+ * 构造一个 [UserProfile]
+ *
+ * @see MockUserProfileBuilder
+ */
+public inline fun buildUserProfile(block: MockUserProfileBuilder.() -> Unit): UserProfile {
+    contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
+    return MockUserProfileBuilder().apply(block).build()
+}
+
+internal class MockUPBuilderImpl : MockUserProfileBuilder, UserProfile {
+    override var nickname: String = ""
+    override var email: String = ""
+    override var age: Int = -1
+    override var qLevel: Int = -1
+    override var sex: UserProfile.Sex = UserProfile.Sex.UNKNOWN
+    override var sign: String = ""
+    override var friendGroupId: Int = 0
+
+    // unmodifiable
+    override fun build(): UserProfile {
+        return object : UserProfile by this {}
+    }
+
+    override fun nickname(value: String): MockUserProfileBuilder = apply {
+        nickname = value
+    }
+
+    override fun email(value: String): MockUserProfileBuilder = apply {
+        email = value
+    }
+
+    override fun age(value: Int): MockUserProfileBuilder = apply {
+        age = value
+    }
+
+    override fun qLevel(value: Int): MockUserProfileBuilder = apply {
+        qLevel = value
+    }
+
+    override fun sex(value: UserProfile.Sex): MockUserProfileBuilder = apply {
+        sex = value
+    }
+
+    override fun sign(value: String): MockUserProfileBuilder = apply {
+        sign = value
+    }
+
+    override fun friendGroupId(value: Int): MockUserProfileBuilder = apply {
+        friendGroupId = value
+    }
+
+}
+
+internal class UserProfileServiceImpl : UserProfileService {
+    val db = ConcurrentHashMap<Long, UserProfile>()
+    val def = buildUserProfile {
+    }
+
+    override suspend fun doQueryUserProfile(id: Long): UserProfile {
+        return db[id] ?: def
+    }
+
+    override suspend fun putUserProfile(id: Long, profile: UserProfile) {
+        db[id] = profile
+    }
+
+}

+ 154 - 0
mirai-core-mock/src/userprofile/contactinfos.kt

@@ -0,0 +1,154 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.userprofile
+
+import net.mamoe.mirai.contact.MemberPermission
+import net.mamoe.mirai.data.FriendInfo
+import net.mamoe.mirai.data.MemberInfo
+import net.mamoe.mirai.data.StrangerInfo
+import net.mamoe.mirai.data.UserInfo
+import net.mamoe.mirai.utils.currentTimeSeconds
+
+public interface MockUserInfoBuilder {
+    public fun uin(value: Long): MockUserInfoBuilder
+
+    public fun nick(value: String): MockUserInfoBuilder
+
+    public fun remark(value: String): MockUserInfoBuilder
+
+    public fun build(): UserInfo
+
+    public companion object {
+        @JvmStatic
+        @JvmName("builder")
+        public operator fun invoke(): MockUserInfoBuilder = ThreeInOneInfoBuilder()
+
+        @JvmSynthetic
+        public inline fun create(action: MockUserInfoBuilder.() -> Unit): UserInfo = invoke().apply(action).build()
+    }
+}
+
+public interface MockFriendInfoBuilder : MockUserInfoBuilder {
+    public override fun build(): FriendInfo
+
+    override fun uin(value: Long): MockFriendInfoBuilder
+
+    override fun nick(value: String): MockFriendInfoBuilder
+
+    override fun remark(value: String): MockFriendInfoBuilder
+
+    public fun friendGroupId(value: Int): MockFriendInfoBuilder
+
+    public companion object {
+        @JvmStatic
+        @JvmName("builder")
+        public operator fun invoke(): MockFriendInfoBuilder = ThreeInOneInfoBuilder()
+
+        @JvmSynthetic
+        public inline fun create(action: MockFriendInfoBuilder.() -> Unit): FriendInfo = invoke().apply(action).build()
+    }
+}
+
+public interface MockMemberInfoBuilder : MockUserInfoBuilder {
+    override fun build(): MemberInfo
+
+    public fun nameCard(value: String): MockMemberInfoBuilder
+
+    public fun specialTitle(value: String): MockMemberInfoBuilder
+
+    public fun anonymousId(value: String?): MockMemberInfoBuilder
+
+    public fun joinTimestamp(value: Int): MockMemberInfoBuilder
+
+    public fun lastSpeakTimestamp(value: Int): MockMemberInfoBuilder
+
+    public fun isOfficialBot(value: Boolean): MockMemberInfoBuilder
+
+    public fun permission(value: MemberPermission): MockMemberInfoBuilder
+
+    override fun uin(value: Long): MockMemberInfoBuilder
+
+    override fun nick(value: String): MockMemberInfoBuilder
+
+    override fun remark(value: String): MockMemberInfoBuilder
+
+    public companion object {
+        @JvmStatic
+        @JvmName("builder")
+        public operator fun invoke(): MockMemberInfoBuilder = ThreeInOneInfoBuilder()
+
+        @JvmSynthetic
+        public inline fun create(action: MockMemberInfoBuilder.() -> Unit): MemberInfo = invoke().apply(action).build()
+    }
+}
+
+public interface MockStrangerInfoBuilder : MockUserInfoBuilder {
+    public fun fromGroup(value: Long): MockUserInfoBuilder
+
+    override fun uin(value: Long): MockUserInfoBuilder
+
+    override fun nick(value: String): MockUserInfoBuilder
+
+    override fun remark(value: String): MockUserInfoBuilder
+
+    override fun build(): StrangerInfo
+
+    public companion object {
+        @JvmStatic
+        @JvmName("builder")
+        public operator fun invoke(): MockStrangerInfoBuilder = ThreeInOneInfoBuilder()
+
+
+        @JvmSynthetic
+        public inline fun create(action: MockStrangerInfoBuilder.() -> Unit): StrangerInfo =
+            invoke().apply(action).build()
+    }
+}
+
+private class ThreeInOneInfoBuilder :
+    MockUserInfoBuilder,
+    MockFriendInfoBuilder,
+    MockMemberInfoBuilder,
+    MockStrangerInfoBuilder,
+
+    UserInfo,
+    FriendInfo,
+    MemberInfo,
+    StrangerInfo {
+
+    override var nameCard: String = ""
+    override var permission: MemberPermission = MemberPermission.MEMBER
+    override var specialTitle: String = ""
+    override var muteTimestamp: Int = 0
+    override var joinTimestamp: Int = currentTimeSeconds().toInt()
+    override var lastSpeakTimestamp: Int = 0
+    override var isOfficialBot: Boolean = false
+    override var fromGroup: Long = 0L
+    override var remark: String = ""
+    override var uin: Long = 0
+    override var nick: String = ""
+    override var anonymousId: String? = null
+    override var friendGroupId: Int = 0
+
+    override fun build(): ThreeInOneInfoBuilder = this
+
+    override fun nameCard(value: String): ThreeInOneInfoBuilder = apply { this.nameCard = value }
+    override fun specialTitle(value: String): ThreeInOneInfoBuilder = apply { this.specialTitle = value }
+    override fun anonymousId(value: String?): ThreeInOneInfoBuilder = apply { this.anonymousId = value }
+    override fun joinTimestamp(value: Int): ThreeInOneInfoBuilder = apply { this.joinTimestamp = value }
+    override fun lastSpeakTimestamp(value: Int): ThreeInOneInfoBuilder = apply { this.lastSpeakTimestamp = value }
+    override fun isOfficialBot(value: Boolean): ThreeInOneInfoBuilder = apply { this.isOfficialBot = value }
+    override fun fromGroup(value: Long): ThreeInOneInfoBuilder = apply { this.fromGroup = value }
+    override fun uin(value: Long): ThreeInOneInfoBuilder = apply { this.uin = value }
+    override fun nick(value: String): ThreeInOneInfoBuilder = apply { this.nick = value }
+    override fun remark(value: String): ThreeInOneInfoBuilder = apply { this.remark = value }
+    override fun permission(value: MemberPermission): ThreeInOneInfoBuilder = apply { this.permission = value }
+    override fun friendGroupId(value: Int): ThreeInOneInfoBuilder = apply { this.friendGroupId = value }
+}

+ 36 - 0
mirai-core-mock/src/utils/MemberInfo.kt

@@ -0,0 +1,36 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.utils
+
+import net.mamoe.mirai.contact.MemberPermission
+import net.mamoe.mirai.data.MemberInfo
+
+public fun simpleMemberInfo(
+    uin: Long,
+    name: String,
+    nick: String = name,
+    nameCard: String = "",
+    remark: String = "",
+    permission: MemberPermission,
+    specialTitle: String = "",
+): MemberInfo {
+    return object : MemberInfo {
+        override val nameCard: String get() = nameCard
+        override val permission: MemberPermission get() = permission
+        override val specialTitle: String get() = specialTitle
+        override val muteTimestamp: Int get() = 0
+        override val joinTimestamp: Int get() = 0
+        override val lastSpeakTimestamp: Int get() = 0
+        override val isOfficialBot: Boolean get() = false
+        override val uin: Long get() = uin
+        override val nick: String get() = nick
+        override val remark: String get() = remark
+    }
+}

+ 158 - 0
mirai-core-mock/src/utils/MockActionsScope.kt

@@ -0,0 +1,158 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.utils
+
+import net.mamoe.mirai.contact.MemberPermission
+import net.mamoe.mirai.contact.User
+import net.mamoe.mirai.event.events.MemberPermissionChangeEvent
+import net.mamoe.mirai.event.events.MemberSpecialTitleChangeEvent
+import net.mamoe.mirai.message.MessageReceipt
+import net.mamoe.mirai.message.data.*
+import net.mamoe.mirai.mock.MockActions
+import net.mamoe.mirai.mock.contact.MockNormalMember
+import net.mamoe.mirai.mock.contact.MockUser
+import net.mamoe.mirai.mock.contact.MockUserOrBot
+
+/**
+ * 广播一些模拟事件
+ */
+public inline fun broadcastMockEvents(action: MockActionsScope.() -> Unit) {
+    return MockActionsScopeInstance.action()
+}
+
+@PublishedApi
+internal val MockActionsScopeInstance: MockActionsScope = object : MockActionsScope {}
+
+
+public interface MockActionsScope { // use context receivers in the future
+    /**
+     * 修改 [MockUserOrBot.nick] 并广播相关事件 (如 [FriendNickChangedEvent])
+     */
+    @MockActionsDsl
+    public suspend infix fun MockUserOrBot.nickChangesTo(value: String) {
+        return MockActions.fireNickChanged(this, value)
+    }
+
+    /**
+     * 修改 [MockNormalMember.nameCard] 并广播 [MemberCardChangeEvent]
+     */
+    @MockActionsDsl
+    public suspend infix fun MockNormalMember.nameCardChangesTo(value: String) {
+        return MockActions.fireNameCardChanged(this, value)
+    }
+
+    /**
+     * 修改 [MockNormalMember.specialTitle] 并广播 [MemberSpecialTitleChangeEvent]
+     */
+    @MockActionsDsl
+    public suspend infix fun MockNormalMember.specialTitleChangesTo(value: String) {
+        return MockActions.fireSpecialTitleChanged(this, value)
+    }
+
+    /**
+     * 修改一名成员的权限并广播 [MemberPermissionChangeEvent]
+     */
+    @MockActionsDsl
+    public suspend infix fun MockNormalMember.permissionChangesTo(perm: MemberPermission) {
+        return MockActions.firePermissionChanged(this, perm)
+    }
+
+    /**
+     * 广播 [this] 被 [actor] 戳了的事件([NudgeEvent])
+     *
+     * - [actor] 戳了戳 [this] 的 XXXX
+     */
+    @MockActionsDsl
+    public suspend fun MockUserOrBot.nudgedBy(actor: MockUserOrBot, block: NudgeDsl.() -> Unit = {}) {
+        actor.nudged0(this, NudgeDsl().also(block))
+    }
+
+    /**
+     * 广播 [target] 被 [this] 戳了的事件([NudgeEvent])
+     *
+     * - [this] 戳了戳 [target] 的 XXXX
+     */
+    @MockActionsDsl
+    public suspend fun MockUserOrBot.nudges(target: MockUserOrBot, block: NudgeDsl.() -> Unit = {}) {
+        nudged0(target, NudgeDsl().also(block))
+    }
+
+    /**
+     * @see [MockUser.says]
+     */
+    @MockActionsDsl
+    public suspend infix fun MockUser.says(block: MessageChainBuilder.() -> Unit): MessageChain {
+        return says(buildMessageChain(block))
+    }
+
+    /**
+     * @see [MockUser.says]
+     */
+    @MockActionsDsl
+    public suspend infix fun MockUser.saysMessage(block: () -> Message): MessageChain {
+        // no contract because compiler error
+//        contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
+        return says(block())
+    }
+
+    /**
+     * 令消息原作者撤回一条消息
+     */
+    @MockActionsDsl
+    public suspend fun MessageChain.recalledBySender() {
+        return MockActions.fireMessageRecalled(this, null)
+    }
+
+    /**
+     * 令消息原作者撤回一条消息
+     */
+    @MockActionsDsl
+    public suspend fun MessageSource.recalledBySender() {
+        return MockActions.fireMessageRecalled(this, null)
+    }
+
+    /**
+     * 令消息原作者撤回一条消息
+     */
+    @MockActionsDsl
+    public suspend fun MessageReceipt<*>.recalledBySender() {
+        this.source.recalledBy(null)
+    }
+
+    /**
+     * 令 [operator] 撤回一条消息
+     *
+     * @param operator 当 [operator] 为 null 时代表是发送者自己撤回
+     */
+    @MockActionsDsl
+    public suspend infix fun MessageChain.recalledBy(operator: User?) {
+        return MockActions.fireMessageRecalled(this, operator)
+    }
+
+    /**
+     * 令 [operator] 撤回一条消息
+     *
+     * @param operator 当 [operator] 为 null 时代表是发送者自己撤回
+     */
+    @MockActionsDsl
+    public suspend infix fun MessageSource.recalledBy(operator: User?) {
+        return MockActions.fireMessageRecalled(this, operator)
+    }
+
+    /**
+     * 令 [operator] 撤回一条消息
+     *
+     * @param operator 当 [operator] 为 null 时代表是发送者自己撤回
+     */
+    @MockActionsDsl
+    public suspend infix fun MessageReceipt<*>.recalledBy(operator: User?) {
+        this.source.recalledBy(operator)
+    }
+}

+ 76 - 0
mirai-core-mock/src/utils/MockConversions.kt

@@ -0,0 +1,76 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:JvmName("MockConversions")
+
+package net.mamoe.mirai.mock.utils
+
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.contact.*
+import net.mamoe.mirai.message.data.OnlineAudio
+import net.mamoe.mirai.mock.MockBot
+import net.mamoe.mirai.mock.MockBotDSL
+import net.mamoe.mirai.mock.contact.*
+import net.mamoe.mirai.utils.ExternalResource
+import kotlin.contracts.contract
+
+
+public fun Bot.mock(): MockBot {
+    contract { returns() implies (this@mock is MockBot) }
+    return this as MockBot
+}
+
+public fun Group.mock(): MockGroup {
+    contract { returns() implies (this@mock is MockGroup) }
+    return this as MockGroup
+}
+
+public fun NormalMember.mock(): MockNormalMember {
+    contract { returns() implies (this@mock is MockNormalMember) }
+    return this as MockNormalMember
+}
+
+public fun Contact.mock(): MockContact {
+    contract { returns() implies (this@mock is MockContact) }
+    return this as MockContact
+}
+
+public fun AnonymousMember.mock(): MockAnonymousMember {
+    contract { returns() implies (this@mock is MockAnonymousMember) }
+    return this as MockAnonymousMember
+}
+
+public fun Friend.mock(): MockFriend {
+    contract { returns() implies (this@mock is MockFriend) }
+    return this as MockFriend
+}
+
+public fun Member.mock(): MockMember {
+    contract { returns() implies (this@mock is MockMember) }
+    return this as MockMember
+}
+
+public fun OtherClient.mock(): MockOtherClient {
+    contract { returns() implies (this@mock is MockOtherClient) }
+    return this as MockOtherClient
+}
+
+public fun Stranger.mock(): MockStranger {
+    contract { returns() implies (this@mock is MockStranger) }
+    return this as MockStranger
+}
+
+/**
+ * @see MockBot.uploadOnlineAudio
+ */
+@MockBotDSL
+public suspend fun ExternalResource.mockUploadAsOnlineAudio(bot: MockBot): OnlineAudio {
+    return bot.uploadOnlineAudio(this)
+}
+

+ 38 - 0
mirai-core-mock/src/utils/NameGenerator.kt

@@ -0,0 +1,38 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.utils
+
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * 名称生成器
+ *
+ * 部分事件没有 `nick`, `name` 等相关的字段以确定名字,
+ * [NameGenerator] 的作用就是在无法确定一个准确的名字的时候生成一个默认的名字
+ */
+public interface NameGenerator {
+    public fun nextGroupName(): String
+    public fun nextFriendName(): String
+
+    public companion object {
+        private val DEFAULT: NameGenerator = SimpleNameGenerator()
+
+        @JvmStatic
+        public fun getDefault(): NameGenerator = DEFAULT
+    }
+}
+
+public open class SimpleNameGenerator : NameGenerator {
+    private val groupCounter = AtomicInteger(0)
+    private val friendCounter = AtomicInteger(0)
+
+    override fun nextGroupName(): String = "Testing Group #" + groupCounter.getAndIncrement()
+    override fun nextFriendName(): String = "Testing Friend #" + friendCounter.getAndIncrement()
+}

+ 73 - 0
mirai-core-mock/src/utils/NudgeDsl.kt

@@ -0,0 +1,73 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.utils
+
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.contact.*
+import net.mamoe.mirai.event.broadcast
+import net.mamoe.mirai.event.events.NudgeEvent
+import net.mamoe.mirai.mock.contact.MockUserOrBot
+
+/**
+ * 构造 Nudge 的 DSL
+ *
+ * @see MockActionsScope.nudgedBy
+ */
+public class NudgeDsl {
+    @set:JvmSynthetic
+    public var action: String = "戳了戳"
+
+    @set:JvmSynthetic
+    public var suffix: String = ""
+
+    @MockActionsDsl
+    public fun action(value: String): NudgeDsl = apply { action = value }
+
+    @MockActionsDsl
+    public fun suffix(value: String): NudgeDsl = apply { suffix = value }
+}
+
+@PublishedApi
+internal suspend fun MockUserOrBot.nudged0(target: MockUserOrBot, dsl: NudgeDsl) {
+
+    when {
+        this is Member && target is Member -> {
+            if (this.group != target.group)
+                error("Cross group nudging")
+        }
+
+        this is AnonymousMember -> error("anonymous member can't starting a nudge action")
+        target is AnonymousMember -> error("anonymous member is not nudgeable")
+
+        this is Bot && target is Bot -> error("Not yet support bot nudging bot")
+    }
+
+    val subject: Contact = when {
+        this is Member -> this.group
+        target is Member -> target.group
+
+        this is Friend -> this
+        target is Friend -> target
+
+        this is Stranger -> this
+        target is Stranger -> target
+
+        else -> error("Not yet support $target nudging $this")
+    }
+
+    NudgeEvent(
+        from = this,
+        target = target,
+        subject = subject,
+        action = dsl.action,
+        suffix = dsl.suffix,
+    ).broadcast()
+
+}

+ 19 - 0
mirai-core-mock/src/utils/event.kt

@@ -0,0 +1,19 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.utils
+
+import kotlinx.coroutines.runBlocking
+import net.mamoe.mirai.event.Event
+import net.mamoe.mirai.event.broadcast
+
+
+public fun <T : Event> T.broadcastBlocking(): T = apply {
+    runBlocking { broadcast() }
+}

+ 25 - 0
mirai-core-mock/src/utils/http.kt

@@ -0,0 +1,25 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:JvmMultifileClass
+@file:JvmName("MiraiUtils")
+@file:Suppress("NOTHING_TO_INLINE")
+
+package net.mamoe.mirai.mock.utils
+
+import kotlin.jvm.JvmMultifileClass
+import kotlin.jvm.JvmName
+
+public fun String.plusHttpSubpath(subpath: String): String {
+
+    if (this[this.lastIndex] == '/') return this + subpath
+
+    return "$this/$subpath"
+}
+

+ 41 - 0
mirai-core-mock/src/utils/image.kt

@@ -0,0 +1,41 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.utils
+
+import net.mamoe.mirai.message.data.Image
+
+import java.awt.Color
+import java.awt.image.BufferedImage
+import java.io.ByteArrayOutputStream
+import javax.imageio.ImageIO
+
+internal fun randomImage(): BufferedImage {
+    val width = (500..800).random()
+    val height = (500..800).random()
+    val image = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB)
+    val graphics = image.createGraphics()
+    for (x in 0 until width) {
+        for (y in 0 until height) {
+            graphics.color = Color(
+                (0..0xFFFFFF).random()
+            )
+            graphics.drawRect(x, y, 1, 1)
+        }
+    }
+    graphics.dispose()
+    return image
+}
+
+internal fun BufferedImage.saveToBytes(): ByteArray = ByteArrayOutputStream().apply {
+    ImageIO.write(this@saveToBytes, "png", this)
+}.toByteArray()
+
+
+public fun Image.Key.randomImageContent(): ByteArray = randomImage().saveToBytes()

+ 15 - 0
mirai-core-mock/src/utils/mockdsl.kt

@@ -0,0 +1,15 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
+
+package net.mamoe.mirai.mock.utils
+
+@DslMarker
+public annotation class MockActionsDsl

+ 100 - 0
mirai-core-mock/test/AbsoluteFileTest.kt

@@ -0,0 +1,100 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.test
+
+import com.google.common.jimfs.Configuration
+import com.google.common.jimfs.Jimfs
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.toList
+import net.mamoe.mirai.contact.MemberPermission
+import net.mamoe.mirai.event.events.GroupMessageEvent
+import net.mamoe.mirai.message.data.FileMessage
+import net.mamoe.mirai.mock.internal.remotefile.absolutefile.MockRemoteFiles
+import net.mamoe.mirai.mock.internal.serverfs.MockServerFileSystemImpl
+import net.mamoe.mirai.mock.utils.simpleMemberInfo
+import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
+import net.mamoe.mirai.utils.cast
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Test
+import java.nio.file.FileSystem
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+
+internal class AbsoluteFileTest : MockBotTestBase() {
+    private val tmpfs: FileSystem = Jimfs.newFileSystem(Configuration.unix())
+    private val disk = bot.tmpResourceServer.mockServerFileDisk
+    private val group = bot.addGroup(11L, "a").also { println(it.owner) }
+    private val fsys = MockServerFileSystemImpl(disk.cast())
+    private val files = MockRemoteFiles(group, fsys)
+
+    @AfterEach
+    internal fun release() {
+        tmpfs.close()
+    }
+
+    @Test
+    internal fun listFileAndFolder() = runTest {
+        val folder = files.root.createFolder("test1")
+        files.root.createFolder("test2")
+        val file = folder.uploadNewFile("test.txt", "cc".toByteArray().toExternalResource().toAutoCloseable())
+        folder.uploadNewFile("test.txt", "cac".toByteArray().toExternalResource().toAutoCloseable())
+        println(files.root.folders().toList())
+        println(files.root.resolveFolder("test1")!!.files().toList())
+        assertEquals(2, files.root.folders().toList().size)
+        assertEquals(2, files.root.resolveFolder("test1")!!.files().toList().size)
+        assertEquals("test.txt", files.root.resolveFolder("test1")!!.files().toList()[0].name)
+        assertEquals("test1", files.root.resolveFolderById(folder.id)!!.name)
+        assertEquals("test.txt", files.root.resolveFileById(file.id, true)!!.name)
+    }
+
+    @Test
+    internal fun testDeleteAndMoveTo() = runTest {
+        val f = files.root.createFolder("test")
+        val ff = f.uploadNewFile("test.txt", "ccc".toByteArray().toExternalResource())
+        val fff = files.root.resolveFileById(ff.id, true)!!
+        assertEquals(fff, ff)
+        f.renameTo("test2")
+        assertEquals("test2", files.root.folders().first().name)
+        fff.refresh()
+        assertEquals(f.absolutePath + "/" + fff.name, fff.absolutePath)
+        fff.moveTo(files.root)
+        assertEquals("/${fff.name}", fff.absolutePath)
+        assertEquals(files.root, fff.parent)
+        fff.delete()
+        assertEquals(false, fff.exists())
+        assertEquals(null, files.root.resolveFileById(fff.id))
+    }
+
+    @Test
+    internal fun testSendAndDownload() = runTest {
+        val f = files.root.uploadNewFile("test.txt", "c".toByteArray().toExternalResource())
+        println(files.fileSystem.findByPath("/test.txt").first().path)
+        runAndReceiveEventBroadcast {
+            group.addMember(simpleMemberInfo(222, "bb", permission = MemberPermission.MEMBER))
+                .saysMessage { f.toMessage() }
+        }.let { events ->
+            assertEquals(1, events.size)
+            assertEquals(true, events[0].cast<GroupMessageEvent>().message.contains(FileMessage))
+        }
+        assertEquals("c", f.getUrl()!!.toUrl().readText())
+    }
+
+    @Test
+    fun testRename() = runTest {
+        val folder = files.root.createFolder("test1")
+        val file = folder.uploadNewFile("test.txt", "content".toByteArray().toExternalResource().toAutoCloseable())
+        assertEquals(file.id, folder.resolveFiles("test.txt").first().id)
+        folder.renameTo("test2")
+        file.refresh()
+        assertEquals(true, file.exists())
+        assertNotEquals(null, folder.resolveFiles("test.txt").firstOrNull())
+    }
+}

+ 138 - 0
mirai-core-mock/test/DslTest.kt

@@ -0,0 +1,138 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.test
+
+import net.mamoe.mirai.contact.MemberPermission
+import net.mamoe.mirai.mock.MockActions
+import net.mamoe.mirai.mock.MockBotFactory
+import net.mamoe.mirai.mock.userprofile.MockMemberInfoBuilder
+import net.mamoe.mirai.mock.utils.NudgeDsl
+import net.mamoe.mirai.mock.utils.broadcastMockEvents
+import net.mamoe.mirai.mock.utils.mockUploadAsOnlineAudio
+import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
+import java.io.File
+
+
+/*
+ * This file only for showing MockDSL and how to use mock bot.
+ * Not included in testing running
+ */
+
+@Suppress("unused")
+internal suspend fun dslTest() {
+    val bot = MockBotFactory.newMockBotBuilder().create()
+
+    bot.addFriend(5, "OhMyFriend")
+
+    bot.addGroup(1, "").apply {
+        addMember(
+            MockMemberInfoBuilder.create {
+                uin(541)
+                nameCard("Dmo")
+                permission(MemberPermission.OWNER)
+            }
+        )
+    }
+    bot.addGroup(7, "")
+        .appendMember(MockMemberInfoBuilder.create {  // Kotlin
+            uin(571)
+            nameCard("Hi")
+            permission(MemberPermission.ADMINISTRATOR)
+        })
+        .appendMember(
+            MockMemberInfoBuilder.invoke() // Java, MockMemberInfoBuilder.builder() in java
+                .uin(1654441)
+                .nameCard("60")
+                .permission(MemberPermission.MEMBER)
+                .specialTitle("ST")
+                .build()
+        )
+
+
+    // 群成员 70 说了一句话
+    bot.getGroupOrFail(50).getOrFail(70).says("0")
+
+    // 群成员 1 发了一条语音
+    bot.getGroupOrFail(1).getOrFail(1).says { // Kotlin
+        +File("helloworld.amr").toExternalResource().toAutoCloseable().mockUploadAsOnlineAudio(bot)
+    }
+    /*
+    Java:
+    bot.getGroupOrFail(1).getOrFail(1).says(() -> {
+        return bot.uploadOnlineAudio(
+            ExternalResource.toExternalResource(new File("")).toAutoCloseable()
+        );
+    });
+    */
+
+
+
+    broadcastMockEvents { // Required for kotlin
+
+        // 50 拍了拍 bot 的 sys32
+        bot.getGroupOrFail(5).getOrFail(50).nudges(bot) {
+            action("拍了拍")
+            suffix("sys32")
+        }
+        MockActions.fireNudge( // Java
+            bot.getGroupOrFail(5).getOrFail(50),
+            bot,
+            /*new*/ NudgeDsl().action("拍了拍").suffix("sys32")
+        )
+
+        // 1 拍了拍 bot 的 sys32
+        bot.nudgedBy(bot.getGroupOrFail(1).getOrFail(1)) {
+            action("拍了拍")
+            suffix("sys32")
+        }
+
+
+        // 群成员 2 修改了群名片
+        bot.getGroupOrFail(1).getOrFail(2) nameCardChangesTo "Test"
+        MockActions.fireNameCardChanged( // Java
+            bot.getGroupOrFail(1).getOrFail(2), "Test"
+        )
+
+        // 群成员 2 被群主修改了头衔
+        bot.getGroupOrFail(1).getOrFail(2) specialTitleChangesTo "管埋员"
+        MockActions.fireSpecialTitleChanged( // Java
+            bot.getGroupOrFail(1).getOrFail(2), "管埋员"
+        )
+
+        // 群主修改了群成员 2 的权限为 Administrator
+        bot.getGroupOrFail(1).getOrFail(2) permissionChangesTo MemberPermission.ADMINISTRATOR
+        MockActions.firePermissionChanged( // Java
+            bot.getGroupOrFail(1).getOrFail(2),
+            MemberPermission.ADMINISTRATOR
+        )
+
+        // 群主撤回了一条群员消息
+        bot.getGroupOrFail(1).owner.recallMessage( // Kotlin & Java
+            bot.getGroupOrFail(1).getOrFail(1) says { append("SB") }
+        )
+    }
+
+    // 新的入群申请
+    bot.getGroupOrFail(50).broadcastNewMemberJoinRequestEvent(
+        requester = 3,
+        requesterName = "Him188moe",
+        message = "Hi!",
+    ).reject(message = "Hello!")
+
+    // 新的好友申请
+    bot.broadcastNewFriendRequestEvent(
+        requester = 1,
+        requesterNick = "Karlatemp",
+        fromGroup = 0,
+        message = "さくらが落ちる",
+    ).accept()
+
+    bot.broadcastNewFriendRequestEvent(9, "", 0, "").reject()
+}

+ 41 - 0
mirai-core-mock/test/FsServerTest.kt

@@ -0,0 +1,41 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:Suppress("DEPRECATION", "DEPRECATION_ERROR")
+
+package net.mamoe.mirai.mock.test
+
+import kotlinx.coroutines.runBlocking
+import net.mamoe.mirai.mock.resserver.TmpResourceServer
+import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
+import net.mamoe.mirai.utils.mkParentDirs
+import org.junit.jupiter.api.Test
+import kotlin.io.path.writeText
+import kotlin.test.assertEquals
+
+@Suppress("RemoveExplicitTypeArguments")
+internal class FsServerTest {
+    @Test
+    fun testFsServer() = runBlocking<Unit> {
+        val fsServer = TmpResourceServer.newInMemoryTmpResourceServer()
+        fsServer.startupServer()
+        val testFile = "Test".toByteArray().toExternalResource()
+        val resourceId = fsServer.uploadResource(testFile)
+        val response = fsServer.resolveHttpUrl(resourceId).toURL().readText()
+        assertEquals("Test", response)
+
+        val pt0 = fsServer.storageRoot.resolve("/rand/etc/randrand/somedata")
+        pt0.mkParentDirs()
+        pt0.writeText("Test")
+
+        assertEquals("Test", fsServer.resolveHttpUrlByPath(pt0).toURL().readText())
+
+        fsServer.close()
+    }
+}

+ 47 - 0
mirai-core-mock/test/ImageUploadTest.kt

@@ -0,0 +1,47 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.test
+
+import kotlinx.coroutines.runBlocking
+import net.mamoe.mirai.message.data.Image
+import net.mamoe.mirai.message.data.Image.Key.queryUrl
+import net.mamoe.mirai.mock.MockBotFactory
+import net.mamoe.mirai.mock.utils.randomImageContent
+import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInstance
+import java.net.URL
+import kotlin.test.assertTrue
+
+@TestInstance(TestInstance.Lifecycle.PER_METHOD)
+internal class ImageUploadTest {
+    internal val bot = MockBotFactory.newMockBotBuilder()
+        .id(1234567890)
+        .nick("Sakura")
+        .create()
+
+    @AfterEach
+    internal fun botDestroy() {
+        bot.close()
+    }
+
+    @Test
+    fun testImageUpload() = runBlocking<Unit> {
+        val data = Image.randomImageContent()
+        val img = bot.asFriend.uploadImage(
+            data.toExternalResource().toAutoCloseable()
+        )
+        println(img.imageId)
+        assertTrue {
+            data.contentEquals(URL(img.queryUrl()).readBytes())
+        }
+    }
+}

+ 58 - 0
mirai-core-mock/test/MockBotTestBase.kt

@@ -0,0 +1,58 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.test
+
+import net.mamoe.mirai.event.Event
+import net.mamoe.mirai.event.GlobalEventChannel
+import net.mamoe.mirai.mock.MockBotFactory
+import net.mamoe.mirai.mock.internal.MockBotImpl
+import net.mamoe.mirai.mock.utils.MockActionsScope
+import net.mamoe.mirai.mock.utils.broadcastMockEvents
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.TestInstance
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
+
+@TestInstance(TestInstance.Lifecycle.PER_METHOD)
+internal open class MockBotTestBase : TestBase() {
+    internal val bot = MockBotFactory.newMockBotBuilder()
+        .id((100000000L..321111111L).random())
+        .nick("Kafusumi")
+        .create()
+
+    @AfterEach
+    internal fun `$$bot dispose`() {
+        bot.close()
+    }
+
+    internal suspend fun runAndReceiveEventBroadcast(
+        action: suspend MockActionsScope.() -> Unit
+    ): List<Event> {
+
+        contract {
+            callsInPlace(action, InvocationKind.EXACTLY_ONCE)
+        }
+
+        val result = mutableListOf<Event>()
+        val listener = GlobalEventChannel.subscribeAlways<Event> {
+            result.add(this)
+        }
+
+        broadcastMockEvents {
+            action()
+        }
+
+        (bot as MockBotImpl).joinEventBroadcast()
+
+        listener.cancel()
+        return result
+    }
+
+}

+ 49 - 0
mirai-core-mock/test/MsgDbTest.kt

@@ -0,0 +1,49 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.test
+
+import net.mamoe.mirai.message.data.MessageSourceKind
+import net.mamoe.mirai.message.data.messageChainOf
+import net.mamoe.mirai.mock.database.MessageDatabase
+import net.mamoe.mirai.mock.database.MessageInfo
+import net.mamoe.mirai.mock.database.mockMsgDatabaseId
+import org.junit.jupiter.api.Test
+import kotlin.random.Random
+import kotlin.test.assertEquals
+
+internal class MsgDbTest {
+    @Test
+    fun testIdConversion() {
+        repeat(50) {
+            val id1 = Random.nextInt()
+            val id2 = Random.nextInt()
+            val msgInfo = MessageInfo(
+                mixinedMsgId = mockMsgDatabaseId(id1, id2),
+                sender = 0, subject = 0, kind = MessageSourceKind.FRIEND, time = 0,
+                messageChainOf()
+            )
+            assertEquals(id1, msgInfo.id)
+            assertEquals(id2, msgInfo.internal)
+        }
+    }
+
+    @Test
+    fun testDatabase() {
+        val db = MessageDatabase.newDefaultDatabase()
+        db.connect()
+
+        repeat(90) {
+            val info = db.newMessageInfo(Random.nextLong(), Random.nextLong(), MessageSourceKind.FRIEND, 0, messageChainOf())
+            assertEquals(info, db.queryMessageInfo(info.mixinedMsgId))
+        }
+
+        db.disconnect()
+    }
+}

+ 50 - 0
mirai-core-mock/test/TestBase.kt

@@ -0,0 +1,50 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.test
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.runBlocking
+import net.mamoe.mirai.event.Event
+import net.mamoe.mirai.event.events.MessageEvent
+import net.mamoe.mirai.event.events.MessagePostSendEvent
+import net.mamoe.mirai.event.events.MessagePreSendEvent
+import org.junit.jupiter.api.fail
+import java.net.URL
+import kotlin.reflect.jvm.jvmName
+import kotlin.test.assertFails
+
+internal open class TestBase {
+
+    internal inline fun <reified T> assertIsInstance(value: Any?, block: T.() -> Unit = {}) {
+        if (value !is T) {
+            fail { "Actual value $value (${value?.javaClass}) is not instanceof ${T::class.jvmName}" }
+        }
+        block(value)
+    }
+
+    internal fun runTest(action: suspend CoroutineScope.() -> Unit) {
+        runBlocking(block = action)
+    }
+
+    internal fun List<Event>.dropMessagePrePost() = filterNot {
+        it is MessagePreSendEvent || it is MessagePostSendEvent<*>
+    }
+
+    internal fun List<Event>.dropMsgChat() = filterNot {
+        it is MessageEvent || it is MessagePreSendEvent || it is MessagePostSendEvent<*>
+    }
+
+    internal fun String.toUrl(): URL = URL(this)
+
+    internal inline fun <T> T.runAndAssertFails(block: T.() -> Unit) {
+        assertFails { block() }
+    }
+
+}

+ 118 - 0
mirai-core-mock/test/TxFsDiskTest.kt

@@ -0,0 +1,118 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.test
+
+import com.google.common.jimfs.Configuration
+import com.google.common.jimfs.Jimfs
+import net.mamoe.mirai.mock.resserver.MockServerFileDisk
+import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Test
+import java.nio.file.Files
+import kotlin.test.assertEquals
+import kotlin.test.assertFails
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+internal class TxFsDiskTest {
+    val tmpfs = Jimfs.newFileSystem(Configuration.unix())
+    val disk = MockServerFileDisk.newFileDisk(tmpfs.getPath("/disk"))
+    private fun splitLine() = println("==================================================================")
+
+    @AfterEach
+    fun release() {
+        println("===================[ FILE SYSTEM STRUCT DUMP ]========================")
+        Files.walk(tmpfs.getPath("/")).use { s ->
+            s.forEach { pt ->
+                println(pt)
+            }
+        }
+        println("===================[                         ]========================")
+        tmpfs.close()
+    }
+
+    @Test
+    fun testDisk() {
+        val system = disk.newFsSystem()
+        val root = system.root
+        println(root)
+        println(root.fileInfo)
+
+        splitLine()
+
+        kotlin.run {
+            val subdir = root.mksubdir("a-dir", 0)
+            println(subdir)
+            println(subdir.fileInfo)
+            assertEquals("/a-dir", subdir.path)
+
+            assertFails { root.moveTo(subdir) }
+
+            val children = root.listFiles()!!.onEach { println(it) }.toList()
+            assertEquals(1, children.size)
+            assertEquals(subdir, children[0])
+            assertEquals(root, subdir.parent)
+
+            subdir.delete()
+            println(subdir)
+            assertFalse { subdir.exists }
+            assertFalse { subdir.isFile }
+            assertFalse { subdir.isDirectory }
+            assertTrue { subdir.toString().startsWith("<not exists>") }
+            assertFails { subdir.fileInfo }
+        }
+
+        splitLine()
+
+        kotlin.run {
+            val newFile = root.uploadFile(
+                "test.txt",
+                """A""".toByteArray().toExternalResource().toAutoCloseable(),
+                5
+            )
+            val newFileInfo = newFile.fileInfo
+            assertEquals(5, newFileInfo.creator)
+            assertEquals(root, newFile.parent)
+            assertEquals("test.txt", newFile.name)
+            assertEquals("/test.txt", newFile.path)
+
+            newFile.rename("hello world.bin")
+            assertEquals("hello world.bin", newFile.name)
+
+
+            val children = root.listFiles()!!.onEach { println(it) }.toList()
+            assertEquals(1, children.size)
+            assertEquals(children[0], newFile)
+
+            val subdir = root.mksubdir("1", 3)
+            newFile.moveTo(subdir)
+            assertEquals("/1/hello world.bin", newFile.path)
+
+            assertEquals(subdir, newFile.parent)
+
+            val children1 = subdir.listFiles()!!.toList()
+            assertEquals(1, children1.size)
+            assertEquals(newFile, children1[0])
+
+            val children2 = root.listFiles()!!.toList()
+            assertEquals(1, children2.size)
+            assertEquals(subdir, children2[0])
+
+
+            assertEquals(newFile, system.findByPath("/1/hello world.bin").firstOrNull())
+
+            println("TEST SUB DIR: $subdir")
+
+            // TODO: Download content
+        }
+
+    }
+
+}

+ 208 - 0
mirai-core-mock/test/mock/MessagingTest.kt

@@ -0,0 +1,208 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.test.mock
+
+import kotlinx.coroutines.flow.toList
+import net.mamoe.mirai.contact.MemberPermission
+import net.mamoe.mirai.event.events.*
+import net.mamoe.mirai.message.data.MessageSource.Key.recall
+import net.mamoe.mirai.message.data.OnlineMessageSource
+import net.mamoe.mirai.message.data.PlainText
+import net.mamoe.mirai.message.data.messageChainOf
+import net.mamoe.mirai.message.data.source
+import net.mamoe.mirai.mock.MockActions.mockFireRecalled
+import net.mamoe.mirai.mock.test.MockBotTestBase
+import net.mamoe.mirai.mock.utils.broadcastMockEvents
+import net.mamoe.mirai.mock.utils.simpleMemberInfo
+import org.junit.jupiter.api.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFails
+import kotlin.test.assertNull
+import kotlin.test.assertSame
+
+internal class MessagingTest: MockBotTestBase() {
+
+    @Test
+    internal fun testMessageEventBroadcast() = runTest {
+        runAndReceiveEventBroadcast {
+            bot.addGroup(5597122, "testing!")
+                .addMember(simpleMemberInfo(5971, "test", permission = MemberPermission.OWNER))
+                .says("Hello World")
+
+            bot.addFriend(9815, "tester").says("Msg By TestFriend")
+
+            bot.addStranger(987166, "sudo").says("How are you")
+
+            bot.getGroupOrFail(5597122).sendMessage("Testing message")
+            bot.getFriendOrFail(9815).sendMessage("Hi my friend")
+            bot.getStrangerOrFail(987166).sendMessage("How are you")
+        }.let { events ->
+            assertEquals(9, events.size)
+
+            assertIsInstance<GroupMessageEvent>(events[0]) {
+                assertEquals("Hello World", message.contentToString())
+                assertEquals("test", senderName)
+                assertEquals(5971, sender.id)
+                assertEquals(5597122, group.id)
+                assertIsInstance<OnlineMessageSource.Incoming.FromGroup>(message.source)
+            }
+            assertIsInstance<FriendMessageEvent>(events[1]) {
+                assertEquals("Msg By TestFriend", message.contentToString())
+                assertEquals("tester", senderName)
+                assertEquals(9815, sender.id)
+                assertIsInstance<OnlineMessageSource.Incoming.FromFriend>(message.source)
+
+            }
+            assertIsInstance<StrangerMessageEvent>(events[2]) {
+                assertEquals("How are you", message.contentToString())
+                assertEquals("sudo", senderName)
+                assertEquals(987166, sender.id)
+                assertIsInstance<OnlineMessageSource.Incoming.FromStranger>(message.source)
+            }
+
+            assertIsInstance<GroupMessagePreSendEvent>(events[3])
+            assertIsInstance<GroupMessagePostSendEvent>(events[4]) {
+                assertIsInstance<OnlineMessageSource.Outgoing.ToGroup>(receipt!!.source)
+            }
+            assertIsInstance<FriendMessagePreSendEvent>(events[5])
+            assertIsInstance<FriendMessagePostSendEvent>(events[6]) {
+                assertIsInstance<OnlineMessageSource.Outgoing.ToFriend>(receipt!!.source)
+            }
+            assertIsInstance<StrangerMessagePreSendEvent>(events[7])
+            assertIsInstance<StrangerMessagePostSendEvent>(events[8]) {
+                assertIsInstance<OnlineMessageSource.Outgoing.ToStranger>(receipt!!.source)
+            }
+        }
+    }
+
+    @Test
+    internal fun testNudge() = runTest {
+        val group = bot.addGroup(1, "1")
+        val nudgeSender = group.addMember(simpleMemberInfo(3, "3", permission = MemberPermission.MEMBER))
+        val nudged = group.addMember(simpleMemberInfo(4, "4", permission = MemberPermission.MEMBER))
+
+        val myFriend = bot.addFriend(1, "514")
+        val myStranger = bot.addStranger(2, "awef")
+
+        runAndReceiveEventBroadcast {
+            nudged.nudgedBy(nudgeSender)
+            nudged.nudge().sendTo(group)
+            myFriend.nudges(bot)
+            myStranger.nudges(bot)
+            myFriend.nudgedBy(bot)
+            myStranger.nudgedBy(bot)
+        }.let { events ->
+            assertEquals(6, events.size)
+            assertIsInstance<NudgeEvent>(events[0]) {
+                assertSame(nudgeSender, this.from)
+                assertSame(nudged, this.target)
+                assertSame(group, this.subject)
+            }
+            assertIsInstance<NudgeEvent>(events[1]) {
+                assertSame(bot, this.from)
+                assertSame(nudged, this.target)
+                assertSame(group, this.subject)
+            }
+            assertIsInstance<NudgeEvent>(events[2]) {
+                assertSame(myFriend, this.from)
+                assertSame(bot, this.target)
+                assertSame(myFriend, this.subject)
+            }
+            assertIsInstance<NudgeEvent>(events[3]) {
+                assertSame(myStranger, this.from)
+                assertSame(bot, this.target)
+                assertSame(myStranger, this.subject)
+            }
+            assertIsInstance<NudgeEvent>(events[4]) {
+                assertSame(bot, this.from)
+                assertSame(myFriend, this.target)
+                assertSame(myFriend, this.subject)
+            }
+            assertIsInstance<NudgeEvent>(events[5]) {
+                assertSame(bot, this.from)
+                assertSame(myStranger, this.target)
+                assertSame(myStranger, this.subject)
+            }
+        }
+    }
+
+    @Test
+    internal fun testRoamingMessages() = runTest {
+        val mockFriend = bot.addFriend(1, "1")
+        broadcastMockEvents {
+            mockFriend says { append("Testing!") }
+            mockFriend says { append("Test2!") }
+        }
+        mockFriend.sendMessage("Pong!")
+
+        mockFriend.roamingMessages.getAllMessages().toList().let { messages ->
+            assertEquals(3, messages.size)
+            assertEquals(messageChainOf(PlainText("Testing!")), messages[0])
+            assertEquals(messageChainOf(PlainText("Test2!")), messages[1])
+            assertEquals(messageChainOf(PlainText("Pong!")), messages[2])
+        }
+    }
+
+    @Test
+    internal fun testMessageRecallEventBroadcast() = runTest {
+        val group = bot.addGroup(8484846, "g")
+        val admin = group.addMember(simpleMemberInfo(945474, "admin", permission = MemberPermission.ADMINISTRATOR))
+        val sender = group.addMember(simpleMemberInfo(178711, "usr", permission = MemberPermission.MEMBER))
+
+        runAndReceiveEventBroadcast {
+            sender.says("Test").recalledBySender()
+            sender.says("Admin recall").recalledBy(admin)
+            mockFireRecalled(group.sendMessage("Hello world"), admin)
+            sender.says("Hi").recall()
+            admin.says("I'm admin").let { resp ->
+                resp.recall()
+                assertFails { resp.recall() }.let(::println)
+            }
+        }.dropMsgChat().let { events ->
+            assertEquals(5, events.size)
+            assertIsInstance<MessageRecallEvent.GroupRecall>(events[0]) {
+                assertNull(operator)
+                assertSame(sender, author)
+            }
+            assertIsInstance<MessageRecallEvent.GroupRecall>(events[1]) {
+                assertSame(admin, operator)
+                assertSame(sender, author)
+            }
+            assertIsInstance<MessageRecallEvent.GroupRecall>(events[2]) {
+                assertSame(admin, operator)
+                assertSame(group.botAsMember, author)
+            }
+            assertIsInstance<MessageRecallEvent.GroupRecall>(events[3]) {
+                assertSame(null, operator)
+                assertSame(sender, author)
+            }
+            assertIsInstance<MessageRecallEvent.GroupRecall>(events[4]) {
+                assertSame(null, operator)
+                assertSame(admin, author)
+            }
+        }
+
+        val root = group.addMember(simpleMemberInfo(54986565, "root", permission = MemberPermission.OWNER))
+
+        runAndReceiveEventBroadcast {
+            sender.says("0").runAndAssertFails { recall() }
+            admin.says("0").runAndAssertFails { recall() }
+            root.says("0").runAndAssertFails { recall() }
+            group.sendMessage("Hi").recall()
+        }.dropMsgChat().let { events ->
+            assertEquals(1, events.size)
+            assertIsInstance<MessageRecallEvent.GroupRecall>(events[0]) {
+                assertEquals(group.botAsMember, author)
+                assertEquals(null, operator)
+            }
+        }
+    }
+
+}

+ 102 - 0
mirai-core-mock/test/mock/MockBotBaseTest.kt

@@ -0,0 +1,102 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.test.mock
+
+import net.mamoe.mirai.Mirai
+import net.mamoe.mirai.contact.MemberPermission
+import net.mamoe.mirai.event.events.MemberPermissionChangeEvent
+import net.mamoe.mirai.mock.contact.MockNormalMember
+import net.mamoe.mirai.mock.test.MockBotTestBase
+import net.mamoe.mirai.mock.userprofile.buildUserProfile
+import net.mamoe.mirai.mock.utils.simpleMemberInfo
+import org.junit.jupiter.api.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+internal class MockBotBaseTest : MockBotTestBase() {
+
+    @Test
+    internal fun testMockBotMocking() = runTest {
+        repeat(50) { i ->
+            bot.addFriend(20000L + i, "usr$i")
+            bot.addStranger(10000L + i, "stranger$i")
+            bot.addGroup(798100000L + i, "group$i")
+        }
+        assertEquals(50, bot.friends.size)
+        assertEquals(50, bot.strangers.size)
+        assertEquals(50, bot.groups.size)
+
+        repeat(50) { i ->
+            assertEquals("usr$i", bot.getFriendOrFail(20000L + i).nick)
+            assertEquals("stranger$i", bot.getStrangerOrFail(10000L + i).nick)
+
+            val group = bot.getGroupOrFail(798100000L + i)
+            assertEquals("group$i", group.name)
+            assertSame(group.botAsMember, group.owner)
+            assertSame(MemberPermission.OWNER, group.botPermission)
+            assertEquals(0, group.members.size)
+        }
+
+        val mockGroup = bot.getGroupOrFail(798100000L)
+        repeat(50) { i ->
+            mockGroup.appendMember(simpleMemberInfo(3700000L + i, "member$i", permission = MemberPermission.MEMBER))
+        }
+        repeat(50) { i ->
+            val member = mockGroup.getOrFail(3700000L + i)
+            assertEquals(MemberPermission.MEMBER, member.permission)
+            assertEquals("member$i", member.nick)
+            assertTrue(member.nameCard.isEmpty())
+            assertEquals(MemberPermission.OWNER, mockGroup.botPermission)
+        }
+
+        val newOwner: MockNormalMember
+        runAndReceiveEventBroadcast {
+            newOwner = mockGroup.addMember(simpleMemberInfo(84485417, "root", permission = MemberPermission.OWNER))
+        }.let { events ->
+            assertEquals(0, events.size)
+        }
+        assertEquals(MemberPermission.OWNER, newOwner.permission)
+        assertEquals(MemberPermission.MEMBER, mockGroup.botPermission)
+        assertSame(newOwner, mockGroup.owner)
+
+        val newNewOwner = mockGroup.getOrFail(3700000L)
+        runAndReceiveEventBroadcast {
+            mockGroup.changeOwner(newNewOwner)
+        }.let { events ->
+            assertEquals(2, events.size)
+            assertIsInstance<MemberPermissionChangeEvent>(events[0]) {
+                assertSame(newNewOwner, member)
+                assertSame(MemberPermission.OWNER, new)
+                assertSame(MemberPermission.MEMBER, origin)
+            }
+            assertIsInstance<MemberPermissionChangeEvent>(events[1]) {
+                assertSame(newOwner, member)
+                assertSame(MemberPermission.OWNER, origin)
+                assertSame(MemberPermission.MEMBER, new)
+            }
+        }
+        assertEquals(MemberPermission.OWNER, newNewOwner.permission)
+        assertEquals(MemberPermission.MEMBER, newOwner.permission)
+        assertEquals(MemberPermission.MEMBER, mockGroup.botPermission)
+        assertSame(newNewOwner, mockGroup.owner)
+    }
+
+    @Test
+    internal fun testQueryProfile() = runTest {
+        val service = bot.userProfileService
+        val profile = buildUserProfile {
+            nickname("Test0")
+        }
+        service.putUserProfile(1, profile)
+        assertSame(profile, Mirai.queryProfile(bot, 1))
+    }
+
+}

+ 80 - 0
mirai-core-mock/test/mock/MockBotEventTest.kt

@@ -0,0 +1,80 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.test.mock
+
+import net.mamoe.mirai.event.events.*
+import net.mamoe.mirai.mock.test.MockBotTestBase
+import org.junit.jupiter.api.Test
+import kotlin.test.assertEquals
+
+internal class MockBotEventTest : MockBotTestBase() {
+    @Test
+    fun testBotOnlineEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            bot.login()
+        }.let { events ->
+            assertEquals(1, events.size)
+            assertIsInstance<BotOnlineEvent>(events[0])
+        }
+    }
+
+    @Test
+    fun testBotOfflineEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            bot.broadcastOfflineEvent()
+        }.let { events ->
+            assertEquals(1, events.size)
+            assertIsInstance<BotOfflineEvent>(events[0])
+        }
+    }
+
+    @Test
+    fun testBotRelogin() = runTest {
+        bot.login()
+        runAndReceiveEventBroadcast {
+            bot.login()
+        }.let { events ->
+            assertEquals(2, events.size)
+            assertIsInstance<BotOnlineEvent>(events[0])
+            assertIsInstance<BotReloginEvent>(events[1])
+        }
+    }
+
+    @Test
+    fun testMockAvatarChange() = runTest {
+        assertEquals("http://q.qlogo.cn/g?b=qq&nk=${bot.id}&s=640", bot.avatarUrl)
+        runAndReceiveEventBroadcast {
+            bot.avatarUrl = "http://localhost/test.png"
+            assertEquals("http://localhost/test.png", bot.avatarUrl)
+        }.let { events ->
+            assertEquals(1, events.size)
+            assertIsInstance<BotAvatarChangedEvent>(events[0])
+        }
+    }
+
+    @Test
+    fun testBotNickChangedEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            bot.nickNoEvent = "HiHi"
+            bot.nick = "AAAA"
+            bot nickChangesTo "BBBB"
+        }.let { events ->
+            assertEquals(2, events.size)
+            assertIsInstance<BotNickChangedEvent>(events[0]) {
+                assertEquals("HiHi", from)
+                assertEquals("AAAA", to)
+            }
+            assertIsInstance<BotNickChangedEvent>(events[1]) {
+                assertEquals("AAAA", from)
+                assertEquals("BBBB", to)
+            }
+        }
+    }
+}

+ 49 - 0
mirai-core-mock/test/mock/MockFriendGroupsTest.kt

@@ -0,0 +1,49 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.test.mock
+
+import net.mamoe.mirai.mock.test.MockBotTestBase
+import org.junit.jupiter.api.Test
+import kotlin.test.assertEquals
+
+internal class MockFriendGroupsTest : MockBotTestBase() {
+    @Test
+    internal fun testFriendGroupsDefaultEmpty() = runTest {
+        assertEquals(1, bot.friendGroups.asCollection().size)
+        assertEquals(bot.friendGroups.default, bot.friendGroups[0])
+        assertEquals(bot.friendGroups.default, bot.friendGroups.asCollection().iterator().next())
+    }
+
+    @Test
+    internal fun testFriendGroupCreating() = runTest {
+        val group = bot.friendGroups.create("Test")
+        println(group.id)
+        assertEquals(2, bot.friendGroups.asCollection().size)
+        assertEquals(group, bot.friendGroups[group.id])
+    }
+
+    @Test
+    internal fun testFriendGroupReferences() = runTest {
+        val group = bot.friendGroups.create("Test")
+
+        val friend = bot.addFriend(5, "Test")
+        assertEquals(bot.friendGroups.default, friend.friendGroup)
+        assertEquals(0, friend.mockApi.friendGroupId)
+
+        group.moveIn(friend)
+        assertEquals(group, friend.friendGroup)
+        assertEquals(group.id, friend.mockApi.friendGroupId)
+
+        group.delete()
+        assertEquals(bot.friendGroups.default, friend.friendGroup)
+
+        assertEquals(0, friend.mockApi.friendGroupId)
+    }
+}

+ 149 - 0
mirai-core-mock/test/mock/MockFriendTest.kt

@@ -0,0 +1,149 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.test.mock
+
+import net.mamoe.mirai.event.events.*
+import net.mamoe.mirai.mock.internal.contact.MockImage
+import net.mamoe.mirai.mock.test.MockBotTestBase
+import net.mamoe.mirai.utils.cast
+import org.junit.jupiter.api.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+internal class MockFriendTest : MockBotTestBase() {
+
+    @Test
+    internal fun testNewFriendRequest() = runTest {
+        runAndReceiveEventBroadcast {
+            bot.broadcastNewFriendRequestEvent(
+                1, "Hi", 0, "Hello!"
+            ).reject()
+        }.let { events ->
+            assertEquals(1, events.size)
+            assertIsInstance<NewFriendRequestEvent>(events[0]) {
+                assertEquals(1, fromId)
+                assertEquals("Hi", fromNick)
+                assertEquals(0, fromGroupId)
+                assertEquals("Hello!", message)
+            }
+            assertEquals(bot.friends.size, 0)
+        }
+
+        runAndReceiveEventBroadcast {
+            bot.broadcastNewFriendRequestEvent(
+                1, "Hi", 0, "Hello!"
+            ).accept()
+        }.let { events ->
+            assertEquals(2, events.size, events.toString())
+            assertIsInstance<NewFriendRequestEvent>(events[0]) {
+                assertEquals(1, fromId)
+                assertEquals("Hi", fromNick)
+                assertEquals(0, fromGroupId)
+                assertEquals("Hello!", message)
+            }
+
+            assertIsInstance<FriendAddEvent>(events[1]) {
+                assertEquals(1, friend.id)
+                assertEquals("Hi", friend.nick)
+                assertSame(friend, bot.getFriend(friend.id))
+            }
+
+            assertEquals(1, bot.friends.size)
+        }
+
+    }
+
+    @Test
+    fun testFriendAvatarChangedEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            bot.addFriend(111, "a").changeAvatarUrl(MockImage.random(bot).getUrl(bot))
+            bot.addFriend(222, "b")
+        }.let { events ->
+            assertIsInstance<FriendAvatarChangedEvent>(events[0])
+            assertEquals(111, events[0].cast<FriendAvatarChangedEvent>().friend.id)
+            assertNotEquals("", bot.getFriend(111)!!.avatarUrl)
+            assertNotEquals("", bot.getFriend(222)!!.avatarUrl)
+            assertNotEquals("", bot.getFriend(222)!!.avatarUrl.toUrl().readText())
+        }
+    }
+
+    @Test
+    fun testFriendRemarkChangeEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            bot.addFriend(1, "").remark = "Test"
+        }.let { events ->
+            assertEquals(1, events.size)
+            assertIsInstance<FriendRemarkChangeEvent>(events[0]) {
+                assertEquals(1, this.friend.id)
+                assertEquals("", oldRemark)
+                assertEquals("Test", newRemark)
+            }
+        }
+    }
+
+    @Test
+    fun testFriendRequestAndAddEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            bot.broadcastNewFriendRequestEvent(
+                1, "Test", 0, "Hi"
+            ).accept()
+            bot.broadcastNewFriendRequestEvent(
+                2, "Hi", 1, "0"
+            ).reject()
+        }.let { events ->
+            assertEquals(3, events.size)
+            assertIsInstance<NewFriendRequestEvent>(events[0]) {
+                assertEquals(1, fromId)
+                assertEquals("Test", fromNick)
+                assertEquals(0, fromGroupId)
+                assertEquals("Hi", message)
+            }
+            assertIsInstance<FriendAddEvent>(events[1]) {
+                assertEquals(1, friend.id)
+                assertEquals("Test", friend.nick)
+            }
+            assertIsInstance<NewFriendRequestEvent>(events[2]) {
+                assertEquals(2, fromId)
+                assertEquals("Hi", fromNick)
+                assertEquals(1, fromGroupId)
+                assertEquals("0", message)
+            }
+        }
+    }
+
+    @Test
+    fun testFriendNickChangedEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            bot.addFriend(0, "Old").nick = "Test"
+        }.let { events ->
+            assertEquals(1, events.size)
+            assertIsInstance<FriendNickChangedEvent>(events[0]) {
+                assertEquals("Old", from)
+                assertEquals("Test", to)
+            }
+        }
+    }
+
+    @Test
+    fun testFriendInputStatusChangedEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            bot.addFriend(1, "a").broadcastFriendInputStateChange(true)
+        }.let { events ->
+            assertEquals(1, events.size)
+            assertIsInstance<FriendInputStatusChangedEvent>(events[0]) {
+                assertTrue(inputting)
+                assertSame(bot.getFriend(1), friend)
+            }
+        }
+    }
+
+}

+ 460 - 0
mirai-core-mock/test/mock/MockGroupTest.kt

@@ -0,0 +1,460 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.test.mock
+
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.toList
+import net.mamoe.mirai.contact.MemberPermission
+import net.mamoe.mirai.contact.announcement.AnnouncementParametersBuilder
+import net.mamoe.mirai.contact.isBotMuted
+import net.mamoe.mirai.data.GroupHonorType
+import net.mamoe.mirai.event.events.*
+import net.mamoe.mirai.message.data.FileMessage
+import net.mamoe.mirai.mock.contact.announcement.MockOnlineAnnouncement
+import net.mamoe.mirai.mock.test.MockBotTestBase
+import net.mamoe.mirai.mock.userprofile.MockMemberInfoBuilder
+import net.mamoe.mirai.mock.utils.simpleMemberInfo
+import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
+import net.mamoe.mirai.utils.cast
+import org.junit.jupiter.api.Test
+import kotlin.test.*
+
+internal class MockGroupTest : MockBotTestBase() {
+    @Test
+    internal fun testMockGroupJoinRequest() = runTest {
+        val group = bot.addGroup(9875555515, "test")
+
+        runAndReceiveEventBroadcast {
+            group.broadcastNewMemberJoinRequestEvent(
+                100000000, "demo", "msg"
+            ).accept()
+        }.let { events ->
+            assertEquals(2, events.size)
+            assertIsInstance<MemberJoinRequestEvent>(events[0]) {
+                assertEquals(100000000, fromId)
+                assertEquals("demo", fromNick)
+                assertEquals("msg", message)
+            }
+            assertIsInstance<MemberJoinEvent>(events[1]) {
+                assertEquals(100000000, member.id)
+                assertEquals("demo", member.nick)
+            }
+        }
+
+        val member = group.getOrFail(100000000)
+        assertEquals(MemberPermission.MEMBER, member.permission)
+    }
+
+    @Test
+    internal fun testMockBotJoinGroupRequest() = runTest {
+        val invitor = bot.addFriend(5710, "demo")
+        runAndReceiveEventBroadcast {
+            invitor.broadcastInviteBotJoinGroupRequestEvent(999999999, "test")
+                .accept()
+        }.let { events ->
+            assertEquals(2, events.size)
+            assertIsInstance<BotInvitedJoinGroupRequestEvent>(events[0]) {
+                assertEquals(5710, invitorId)
+                assertEquals("demo", invitorNick)
+                assertEquals(999999999, groupId)
+                assertEquals("test", groupName)
+            }
+            assertIsInstance<BotJoinGroupEvent>(events[1]) {
+                assertNotSame(group.botAsMember, group.owner)
+                assertEquals(MemberPermission.MEMBER, group.botPermission)
+                assertEquals(999999999, group.id)
+                assertEquals(MemberPermission.OWNER, group.owner.permission)
+            }
+        }
+    }
+
+    @Test
+    internal fun testGroupAnnouncements() = runTest {
+        val group = bot.addGroup(8484541, "87")
+        runAndReceiveEventBroadcast {
+            group.announcements.publish(
+                MockOnlineAnnouncement(
+                    content = "dlroW olleH",
+                    parameters = AnnouncementParametersBuilder().apply { this.sendToNewMember = true }.build(),
+                    senderId = 9711221,
+                    allConfirmed = false,
+                    confirmedMembersCount = 0,
+                    publicationTime = 0
+                )
+            )
+            group.announcements.publish(
+                MockOnlineAnnouncement(
+                    content = "Hello World",
+                    parameters = AnnouncementParametersBuilder().apply { this.sendToNewMember = true }.build(),
+                    senderId = 971121,
+                    allConfirmed = false,
+                    confirmedMembersCount = 0,
+                    publicationTime = 0
+                )
+            )
+        }.let { events ->
+            assertEquals(2, events.size)
+            assertIsInstance<GroupEntranceAnnouncementChangeEvent>(events[0])
+        }
+        val anc = group.announcements.asFlow().toList()
+        assertEquals(1, anc.size)
+        assertEquals("Hello World", anc[0].content)
+        assertFalse(anc[0].fid.isEmpty())
+        assertEquals(anc[0], group.announcements.get(anc[0].fid))
+    }
+
+    @Test
+    internal fun testLeave() = runTest {
+        runAndReceiveEventBroadcast {
+            bot.addGroup(1, "1").quit()
+            bot.addFriend(2, "2").delete()
+            bot.addStranger(3, "3").delete()
+            bot.addGroup(4, "4")
+                .addMember(simpleMemberInfo(5, "5", permission = MemberPermission.MEMBER))
+                .broadcastMemberLeave()
+            bot.addGroup(6, "6")
+                .addMember(simpleMemberInfo(7, "7", permission = MemberPermission.OWNER))
+                .broadcastKickBot()
+        }.let { events ->
+            assertEquals(5, events.size)
+            assertIsInstance<BotLeaveEvent.Active>(events[0]) {
+                assertEquals(1, group.id)
+            }
+            assertIsInstance<FriendDeleteEvent>(events[1]) {
+                assertEquals(2, friend.id)
+            }
+            assertIsInstance<StrangerRelationChangeEvent.Deleted>(events[2]) {
+                assertEquals(3, stranger.id)
+            }
+            assertIsInstance<MemberLeaveEvent>(events[3]) {
+                assertEquals(4, group.id)
+                assertEquals(5, member.id)
+            }
+            assertIsInstance<BotLeaveEvent.Kick>(events[4]) {
+                assertEquals(6, group.id)
+                assertEquals(7, operator.id)
+            }
+        }
+    }
+
+    @Suppress("DEPRECATION")
+    @Test
+    internal fun testGroupFileV1() = runTest {
+        val fsroot = bot.addGroup(5417, "58aw").filesRoot
+        fsroot.resolve("helloworld.txt").uploadAndSend(
+            "HelloWorld".toByteArray().toExternalResource().toAutoCloseable()
+        )
+        assertEquals(1, fsroot.listFilesCollection().size)
+        assertEquals(
+            "HelloWorld",
+            fsroot.resolve("helloworld.txt")
+                .getDownloadInfo()!!
+                .url.toUrl()
+                .also { println("Mock file url: $it") }
+                .readText()
+        )
+        fsroot.resolve("helloworld.txt").delete()
+        assertEquals(0, fsroot.listFilesCollection().size)
+    }
+
+    @Test
+    internal fun testMemberHonorChangeEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            val group = bot.addGroup(111, "aa")
+            val member1 = group.addMember(simpleMemberInfo(222, "bb", permission = MemberPermission.MEMBER))
+            val member2 = group.addMember(simpleMemberInfo(333, "cc", permission = MemberPermission.MEMBER))
+            group.honorMembers[GroupHonorType.ACTIVE] = member1
+            group.changeHonorMember(member2, GroupHonorType.ACTIVE)
+        }.let { events ->
+            assertEquals(2, events.size)
+            assertIsInstance<MemberHonorChangeEvent.Lose>(events[0])
+            assertEquals(222, events[0].cast<MemberHonorChangeEvent.Lose>().member.id)
+            assertEquals(GroupHonorType.ACTIVE, events[1].cast<MemberHonorChangeEvent.Achieve>().honorType)
+            assertEquals(333, events[1].cast<MemberHonorChangeEvent.Achieve>().member.id)
+            assertIsInstance<MemberHonorChangeEvent.Achieve>(events[1])
+        }
+    }
+
+    @Test
+    internal fun testGroupFileUpload() = runTest {
+        val files = bot.addGroup(111, "aaa").files
+        val file = files.uploadNewFile("aaa", "ccc".toByteArray().toExternalResource().toAutoCloseable())
+        assertEquals("ccc", file.getUrl()!!.toUrl().readText())
+        runAndReceiveEventBroadcast {
+            bot.getGroup(111)!!.addMember(simpleMemberInfo(222, "bbb", permission = MemberPermission.ADMINISTRATOR))
+                .says(file.toMessage())
+        }.let { events ->
+            assertTrue(events[0].cast<GroupMessageEvent>().message.contains(FileMessage))
+        }
+    }
+
+    @Test
+    internal fun testAvatar() = runTest {
+        assertNotEquals("", bot.addGroup(111, "aaa").avatarUrl.toUrl().readText())
+    }
+
+    @Test
+    internal fun testBotLeaveGroup() = runTest {
+        runAndReceiveEventBroadcast {
+            bot.addGroup(1, "A").quit()
+            bot.addGroup(2, "B")
+                .addMember(MockMemberInfoBuilder.create {
+                    uin(3).nick("W")
+                    permission(MemberPermission.ADMINISTRATOR)
+                }).broadcastKickBot()
+            // TODO: BotLeaveEvent.Disband
+        }.let { events ->
+            assertEquals(2, events.size)
+            assertIsInstance<BotLeaveEvent.Active>(events[0]) {
+                assertEquals(1, group.id)
+                assertEquals("A", group.name)
+            }
+            assertIsInstance<BotLeaveEvent.Kick>(events[1]) {
+                assertEquals(2, group.id)
+                assertEquals("B", group.name)
+                assertEquals(3, operator.id)
+                assertEquals("W", operator.nick)
+            }
+            assertNull(bot.getGroup(1))
+            assertNull(bot.getGroup(2))
+        }
+    }
+
+    @Test
+    fun testBotGroupPermissionChangeEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            bot.addGroup(1, "")
+                .appendMember(MockMemberInfoBuilder.create {
+                    uin(1).nick("o")
+                    permission(MemberPermission.OWNER)
+                })
+                .botAsMember permissionChangesTo MemberPermission.ADMINISTRATOR
+
+            bot.addGroup(2, "")
+                .appendMember(MockMemberInfoBuilder.create {
+                    uin(1).nick("o")
+                    permission(MemberPermission.OWNER)
+                })
+                .let {
+                    it.changeOwner(it.botAsMember)
+                }
+        }.let { events ->
+            assertEquals(3, events.size)
+            assertIsInstance<BotGroupPermissionChangeEvent>(events[0]) {
+                assertEquals(MemberPermission.ADMINISTRATOR, new)
+                assertEquals(MemberPermission.MEMBER, origin)
+            }
+            assertIsInstance<BotGroupPermissionChangeEvent>(events[1]) {
+                assertEquals(MemberPermission.OWNER, new)
+                assertEquals(MemberPermission.MEMBER, origin)
+            }
+            assertIsInstance<MemberPermissionChangeEvent>(events[2]) {
+                assertEquals(1, member.id)
+                assertEquals("o", member.nick)
+                assertEquals(MemberPermission.MEMBER, new)
+                assertEquals(MemberPermission.OWNER, origin)
+            }
+        }
+    }
+
+    @Test
+    fun testMuteEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            val group = bot.addGroup(1, "")
+                .appendMember(2, "")
+
+            group.botAsMember.let {
+                it.broadcastMute(it, 2)
+                assertTrue { it.isMuted }
+                it.broadcastMute(it, 0)
+                assertFalse { it.isMuted }
+                it.broadcastMute(it, 5)
+                assertTrue { group.isBotMuted }
+                assertTrue { it.isMuted }
+            }
+
+            group.getOrFail(2).let {
+                it.broadcastMute(it, 2)
+                assertTrue { it.isMuted }
+                it.broadcastMute(it, 0)
+                assertFalse { it.isMuted }
+                it.broadcastMute(it, 5)
+                assertTrue { it.isMuted }
+            }
+        }.let { events ->
+            assertEquals(6, events.size)
+            assertIsInstance<BotMuteEvent>(events[0])
+            assertIsInstance<BotUnmuteEvent>(events[1])
+            assertIsInstance<BotMuteEvent>(events[2])
+
+            assertIsInstance<MemberMuteEvent>(events[3])
+            assertIsInstance<MemberUnmuteEvent>(events[4])
+            assertIsInstance<MemberMuteEvent>(events[5])
+
+            delay(6000L)
+            assertFalse { bot.getGroupOrFail(1).isBotMuted }
+            assertFalse { bot.getGroupOrFail(1).getOrFail(2).isMuted }
+        }
+    }
+
+    @Test
+    fun testGroupNameChangeEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            val g = bot.addGroup(1, "").appendMember(7, "A")
+            g.controlPane.groupName = "OOOOO"
+            g.name = "Test"
+            g.controlPane.withActor(g.getOrFail(7)).groupName = "Hi"
+        }.let { events ->
+            assertEquals(2, events.size)
+            assertIsInstance<GroupNameChangeEvent>(events[0]) {
+                assertEquals("OOOOO", origin)
+                assertEquals("Test", new)
+                assertEquals(1, group.id)
+                assertNull(operator)
+            }
+            assertIsInstance<GroupNameChangeEvent>(events[1]) {
+                assertEquals("Test", origin)
+                assertEquals("Hi", new)
+                assertEquals(1, group.id)
+                assertEquals(7, operator!!.id)
+            }
+        }
+    }
+
+    @Test
+    fun testGroupMuteAllEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            val g = bot.addGroup(1, "").appendMember(7, "A")
+            g.controlPane.isMuteAll = true
+            g.settings.isMuteAll = false
+            g.controlPane.withActor(g.getOrFail(7)).isMuteAll = true
+        }.let { events ->
+            assertEquals(2, events.size)
+            assertIsInstance<GroupMuteAllEvent>(events[0]) {
+                assertEquals(true, origin)
+                assertEquals(false, new)
+                assertEquals(1, group.id)
+                assertNull(operator)
+            }
+            assertIsInstance<GroupMuteAllEvent>(events[1]) {
+                assertEquals(false, origin)
+                assertEquals(true, new)
+                assertEquals(1, group.id)
+                assertEquals(7, operator!!.id)
+            }
+        }
+    }
+
+    @Test
+    fun testGroupAllowAnonymousChatEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            val g = bot.addGroup(1, "").appendMember(7, "A")
+            g.controlPane.isAnonymousChatAllowed = true
+            g.settings.isAnonymousChatEnabled = false
+            g.controlPane.withActor(g.getOrFail(7)).isAnonymousChatAllowed = true
+        }.let { events ->
+            assertEquals(2, events.size)
+            assertIsInstance<GroupAllowAnonymousChatEvent>(events[0]) {
+                assertEquals(true, origin)
+                assertEquals(false, new)
+                assertEquals(1, group.id)
+                assertNull(operator)
+            }
+            assertIsInstance<GroupAllowAnonymousChatEvent>(events[1]) {
+                assertEquals(false, origin)
+                assertEquals(true, new)
+                assertEquals(1, group.id)
+                assertEquals(7, operator!!.id)
+            }
+        }
+    }
+
+    @Test
+    fun testGroupAllowConfessTalkEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            val g = bot.addGroup(1, "").appendMember(7, "A")
+            g.controlPane.isAllowConfessTalk = true
+            g.controlPane.withActor(g.botAsMember).isAllowConfessTalk = false
+            g.controlPane.withActor(g.getOrFail(7)).isAllowConfessTalk = true
+        }.let { events ->
+            assertEquals(2, events.size)
+            assertIsInstance<GroupAllowConfessTalkEvent>(events[0]) {
+                assertEquals(true, origin)
+                assertEquals(false, new)
+                assertEquals(1, group.id)
+                assertTrue(isByBot)
+            }
+            assertIsInstance<GroupAllowConfessTalkEvent>(events[1]) {
+                assertEquals(false, origin)
+                assertEquals(true, new)
+                assertEquals(1, group.id)
+                assertFalse(isByBot)
+            }
+        }
+    }
+
+    @Test
+    fun testGroupAllowMemberInviteEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            val g = bot.addGroup(1, "").appendMember(7, "A")
+            g.controlPane.isAllowMemberInvite = true
+            g.settings.isAllowMemberInvite = false
+            g.controlPane.withActor(g.getOrFail(7)).isAllowMemberInvite = true
+        }.let { events ->
+            assertEquals(2, events.size)
+            assertIsInstance<GroupAllowMemberInviteEvent>(events[0]) {
+                assertEquals(true, origin)
+                assertEquals(false, new)
+                assertEquals(1, group.id)
+                assertNull(operator)
+            }
+            assertIsInstance<GroupAllowMemberInviteEvent>(events[1]) {
+                assertEquals(false, origin)
+                assertEquals(true, new)
+                assertEquals(1, group.id)
+                assertEquals(7, operator!!.id)
+            }
+        }
+    }
+
+    @Test
+    fun testMemberCardChangeEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            bot.addGroup(1, "")
+                .addMember(MockMemberInfoBuilder.create {
+                    uin(2)
+                    nameCard("Hi")
+                }).nameCardChangesTo("Hello")
+        }.let { events ->
+            assertEquals(1, events.size)
+            assertIsInstance<MemberCardChangeEvent>(events[0]) {
+                assertEquals("Hi", origin)
+                assertEquals("Hello", new)
+                assertEquals(2, member.id)
+                assertEquals(1, member.group.id)
+            }
+        }
+    }
+
+    @Test
+    fun testMemberSpecialTitleChangeEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            bot.addGroup(1, "").addMember(2, "") specialTitleChangesTo "Hello"
+        }.let { events ->
+            assertEquals(1, events.size)
+            assertIsInstance<MemberSpecialTitleChangeEvent>(events[0]) {
+                assertEquals("", origin)
+                assertEquals("Hello", new)
+                assertEquals(2, member.id)
+                assertEquals(1, member.group.id)
+            }
+        }
+    }
+}

+ 24 - 0
mirai-core-mock/test/mock/MockMemberTest.kt

@@ -0,0 +1,24 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.test.mock
+
+import net.mamoe.mirai.contact.MemberPermission
+import net.mamoe.mirai.mock.test.MockBotTestBase
+import net.mamoe.mirai.mock.utils.simpleMemberInfo
+import org.junit.jupiter.api.Test
+import kotlin.test.assertNotEquals
+
+internal class MockMemberTest : MockBotTestBase() {
+    @Test
+    internal fun testAvatar() = runTest {
+        val m = bot.addGroup(111, "aaa").addMember(simpleMemberInfo(222, "bbb", permission = MemberPermission.MEMBER))
+        assertNotEquals("", m.avatarUrl)
+    }
+}

+ 34 - 0
mirai-core-mock/test/mock/MockStrangerTest.kt

@@ -0,0 +1,34 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.test.mock
+
+import net.mamoe.mirai.event.events.StrangerRelationChangeEvent
+import net.mamoe.mirai.mock.test.MockBotTestBase
+import net.mamoe.mirai.utils.cast
+import org.junit.jupiter.api.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+
+internal class MockStrangerTest : MockBotTestBase() {
+    @Test
+    internal fun testStrangerRelationChangeEvent() = runTest {
+        runAndReceiveEventBroadcast {
+            bot.addStranger(111, "aa").addAsFriend()
+            bot.addStranger(222, "bb").delete()
+        }.let { events ->
+            assertEquals(2, events.size)
+            assertIsInstance<StrangerRelationChangeEvent.Friended>(events[0])
+            assertEquals(111, events[0].cast<StrangerRelationChangeEvent.Friended>().friend.id)
+            assertIsInstance<StrangerRelationChangeEvent.Deleted>(events[1])
+            assertEquals(222, events[1].cast<StrangerRelationChangeEvent.Deleted>().stranger.id)
+            assertNotEquals("", bot.getFriend(111)!!.avatarUrl)
+        }
+    }
+}

+ 10 - 0
mirai-core-mock/test/package.kt

@@ -0,0 +1,10 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.mock.test

+ 16 - 0
mirai-core-utils/src/commonMain/kotlin/Conversions.kt

@@ -156,6 +156,22 @@ public fun ByteArray.toInt(offset: Int = 0): Int =
         .plus(this[offset + 2].toInt().and(255).shl(8))
         .plus(this[offset + 2].toInt().and(255).shl(8))
         .plus(this[offset + 3].toInt().and(255).shl(0))
         .plus(this[offset + 3].toInt().and(255).shl(0))
 
 
+/**
+ * Converts 8 bytes to an Long in network order (big-endian).
+ */
+public fun ByteArray.toLong(): Long {
+    var rsp: Long = 0
+    rsp += this[0].toLong().and(255).shl(56)
+    rsp += this[1].toLong().and(255).shl(48)
+    rsp += this[2].toLong().and(255).shl(40)
+    rsp += this[3].toLong().and(255).shl(32)
+    rsp += this[4].toLong().and(255).shl(24)
+    rsp += this[5].toLong().and(255).shl(16)
+    rsp += this[6].toLong().and(255).shl(8)
+    rsp += this[7].toLong().and(255).shl(0)
+    return rsp
+}
+
 
 
 ///////////////////////////////////////////////////////////////////////////
 ///////////////////////////////////////////////////////////////////////////
 // hexToBytes
 // hexToBytes

+ 34 - 0
mirai-core-utils/src/jvmBaseMain/kotlin/IO.jvm.shared.kt

@@ -0,0 +1,34 @@
+/*
+ * Copyright 2019-2022 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:JvmMultifileClass
+@file:JvmName("MiraiUtils")
+@file:Suppress("NOTHING_TO_INLINE")
+
+package net.mamoe.mirai.utils
+
+import java.io.InputStream
+
+private fun dropContent0(stream: InputStream, buffer: ByteArray) {
+    while (true) {
+        val len = stream.read(buffer)
+        if (len == -1) break
+    }
+}
+
+public fun InputStream.dropContent(
+    buffer: Int = 2048,
+    close: Boolean = false,
+) {
+    if (close) {
+        dropContent0(this, ByteArray(buffer))
+    } else {
+        this.use { dropContent0(it, ByteArray(buffer)) }
+    }
+}

+ 49 - 0
mirai-core-utils/src/jvmMain/kotlin/IO.jvm.kt

@@ -0,0 +1,49 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:JvmMultifileClass
+@file:JvmName("MiraiUtils")
+
+@file:Suppress("NOTHING_TO_INLINE")
+
+package net.mamoe.mirai.utils
+
+import java.nio.file.Files
+import java.nio.file.Path
+import kotlin.io.path.deleteIfExists
+import kotlin.io.path.exists
+import kotlin.io.path.isDirectory
+import kotlin.io.path.listDirectoryEntries
+
+public val Path.isFile: Boolean get() = Files.exists(this) && !Files.isDirectory(this)
+
+public inline fun Path.mkdir() {
+    Files.createDirectory(this)
+}
+
+public inline fun Path.mkdirs() {
+    Files.createDirectories(this)
+}
+
+public fun Path.mkParentDirs() {
+    val current = parent ?: return
+    if (current == this) return
+    if (current.exists()) return
+    current.mkParentDirs()
+    current.mkdir()
+}
+
+public fun Path.deleteRecursively(): Boolean {
+    if (isFile) return deleteIfExists()
+    if (isDirectory()) {
+        listDirectoryEntries().forEach { it.deleteRecursively() }
+        return deleteIfExists()
+    }
+    return false
+}

+ 1 - 0
settings.gradle.kts

@@ -58,6 +58,7 @@ fun includeConsoleProject(projectPath: String, dir: String? = null) =
 includeProject(":mirai-core-utils")
 includeProject(":mirai-core-utils")
 includeProject(":mirai-core-api")
 includeProject(":mirai-core-api")
 includeProject(":mirai-core")
 includeProject(":mirai-core")
+includeProject(":mirai-core-mock")
 
 
 includeProject(":mirai-core-all")
 includeProject(":mirai-core-all")
 includeProject(":mirai-bom")
 includeProject(":mirai-bom")