diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 304449863..35ef783f0 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -63,6 +63,11 @@ object Versions { const val yamlkt = "0.12.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 // (700 MB for IDEA, 150 MB for sources, 150 MB for JBR) 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-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 `slf4j-api` = "org.slf4j:slf4j-api:" + Versions.slf4j diff --git a/docs/.conf/nav.js b/docs/.conf/nav.js index f26ec6edf..d914358bc 100644 --- a/docs/.conf/nav.js +++ b/docs/.conf/nav.js @@ -35,6 +35,7 @@ module.exports = { {text: "事件列表", link: "/EventList.html"}, {text: "Debugging Network", link: "/DebuggingNetwork.html"}, {text: "Using Dev Snapshots", link: "/UsingSnapshots.html"}, + {text: "mirai 模拟测试框架", link: "/mocking/Mocking.md"}, ] }, ], diff --git a/docs/Bots.md b/docs/Bots.md index bdcdea4af..b09cea194 100644 --- a/docs/Bots.md +++ b/docs/Bots.md @@ -21,6 +21,7 @@ - [处理滑动验证码](#处理滑动验证码) - [常见登录失败原因](#常见登录失败原因) - [附录: 调试网络层](#附录-调试网络层) +- [附录: 模拟测试框架](#附录-模拟测试框架) ## 1. 创建和配置 `Bot` @@ -284,6 +285,10 @@ contactListCache.setSaveIntervalMillis(60000) // 可选设置有更新时的保 参阅 [DebuggingNetwork.md](DebuggingNetwork.md) +## 附录: 模拟测试框架 + +参阅 [Mocking.md](mocking/Mocking.md) + > 下一步,[Contacts](Contacts.md) > > [回到 Mirai 文档索引](CoreAPI.md) diff --git a/docs/mocking/Mocking.md b/docs/mocking/Mocking.md new file mode 100644 index 000000000..6df2f3f16 --- /dev/null +++ b/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... diff --git a/mirai-core-mock/README.md b/mirai-core-mock/README.md new file mode 100644 index 000000000..5c89a5553 --- /dev/null +++ b/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 架构 + +- `` 与 mirai-core-api 关系不大或者一些独立的组件的测试 +- `.mock` 模拟的各个部分的测试, 每个测试都继承 `MockBotTestBase` + diff --git a/mirai-core-mock/build.gradle.kts b/mirai-core-mock/build.gradle.kts new file mode 100644 index 000000000..351de933b --- /dev/null +++ b/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 } diff --git a/mirai-core-mock/src/MockActions.kt b/mirai-core-mock/src/MockActions.kt new file mode 100644 index 000000000..ea1d7772f --- /dev/null +++ b/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) + } + +} \ No newline at end of file diff --git a/mirai-core-mock/src/MockBot.kt b/mirai-core-mock/src/MockBot.kt new file mode 100644 index 000000000..bf6a98c8b --- /dev/null +++ b/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 + override val friends: ContactList + override val strangers: ContactList + override val otherClients: ContactList + 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() + } +} diff --git a/mirai-core-mock/src/MockBotDSL.kt b/mirai-core-mock/src/MockBotDSL.kt new file mode 100644 index 000000000..f28bcb0d2 --- /dev/null +++ b/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 diff --git a/mirai-core-mock/src/MockBotFactory.kt b/mirai-core-mock/src/MockBotFactory.kt new file mode 100644 index 000000000..05079357a --- /dev/null +++ b/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)) diff --git a/mirai-core-mock/src/contact/MockAnonymousMember.kt b/mirai-core-mock/src/contact/MockAnonymousMember.kt new file mode 100644 index 000000000..a3dbdad3d --- /dev/null +++ b/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 diff --git a/mirai-core-mock/src/contact/MockContact.kt b/mirai-core-mock/src/contact/MockContact.kt new file mode 100644 index 000000000..a0c5acbfc --- /dev/null +++ b/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) +} diff --git a/mirai-core-mock/src/contact/MockContactOrBot.kt b/mirai-core-mock/src/contact/MockContactOrBot.kt new file mode 100644 index 000000000..cb730e226 --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/mirai-core-mock/src/contact/MockFriend.kt b/mirai-core-mock/src/contact/MockFriend.kt new file mode 100644 index 000000000..54798d4a5 --- /dev/null +++ b/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() + } +} \ No newline at end of file diff --git a/mirai-core-mock/src/contact/MockGroup.kt b/mirai-core-mock/src/contact/MockGroup.kt new file mode 100644 index 000000000..f0ddfb547 --- /dev/null +++ b/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 + 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 + + /** + * 更改拥有群荣耀的群成员. + * + * 会自动广播 [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() + } +} + diff --git a/mirai-core-mock/src/contact/MockGroupControlPane.kt b/mirai-core-mock/src/contact/MockGroupControlPane.kt new file mode 100644 index 000000000..7385e4f71 --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/mirai-core-mock/src/contact/MockMember.kt b/mirai-core-mock/src/contact/MockMember.kt new file mode 100644 index 000000000..154922814 --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/mirai-core-mock/src/contact/MockMsgSyncSupport.kt b/mirai-core-mock/src/contact/MockMsgSyncSupport.kt new file mode 100644 index 000000000..6402e9628 --- /dev/null +++ b/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) +} \ No newline at end of file diff --git a/mirai-core-mock/src/contact/MockNormalMember.kt b/mirai-core-mock/src/contact/MockNormalMember.kt new file mode 100644 index 000000000..09636682c --- /dev/null +++ b/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) +} \ No newline at end of file diff --git a/mirai-core-mock/src/contact/MockOtherClient.kt b/mirai-core-mock/src/contact/MockOtherClient.kt new file mode 100644 index 000000000..efdb5e9b7 --- /dev/null +++ b/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 diff --git a/mirai-core-mock/src/contact/MockStranger.kt b/mirai-core-mock/src/contact/MockStranger.kt new file mode 100644 index 000000000..879eeb872 --- /dev/null +++ b/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() + } +} diff --git a/mirai-core-mock/src/contact/MockUser.kt b/mirai-core-mock/src/contact/MockUser.kt new file mode 100644 index 000000000..b7747abc8 --- /dev/null +++ b/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): MessageChain { + return says(buildMessageChain { message.accept(this) }) + } + + + @JavaFriendlyAPI + @LowPriorityInOverloadResolution + public suspend fun says(message: Supplier): MessageChain { + return says(message.get()) + } + + public suspend fun says(message: suspend MessageChainBuilder.() -> Unit): MessageChain { + return says(buildMessageChain { message(this) }) + } +} \ No newline at end of file diff --git a/mirai-core-mock/src/contact/MockUserOrBot.kt b/mirai-core-mock/src/contact/MockUserOrBot.kt new file mode 100644 index 000000000..6c412d6cc --- /dev/null +++ b/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 \ No newline at end of file diff --git a/mirai-core-mock/src/contact/announcement/MockAnnouncements.kt b/mirai-core-mock/src/contact/announcement/MockAnnouncements.kt new file mode 100644 index 000000000..aff01c568 --- /dev/null +++ b/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, +) diff --git a/mirai-core-mock/src/database/MessageDatabase.kt b/mirai-core-mock/src/database/MessageDatabase.kt new file mode 100644 index 000000000..a9861b932 --- /dev/null +++ b/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 + + /** + * 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]) +} diff --git a/mirai-core-mock/src/internal/MockBotFactoryImpl.kt b/mirai-core-mock/src/internal/MockBotFactoryImpl.kt new file mode 100644 index 000000000..10eec38c3 --- /dev/null +++ b/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() + } +} \ No newline at end of file diff --git a/mirai-core-mock/src/internal/MockBotImpl.kt b/mirai-core-mock/src/internal/MockBotImpl.kt new file mode 100644 index 000000000..d431dfa8f --- /dev/null +++ b/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 = ContactList() + override val friends: ContactList = ContactList() + override val strangers: ContactList = ContactList() + override val otherClients: ContactList = 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 = + GlobalEventChannel.filterIsInstance().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.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)" + } +} \ No newline at end of file diff --git a/mirai-core-mock/src/internal/MockMiraiImpl.kt b/mirai-core-mock/src/internal/MockMiraiImpl.kt new file mode 100644 index 000000000..4dbc71269 --- /dev/null +++ b/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) { + } +} diff --git a/mirai-core-mock/src/internal/components/MockEventDispatcherImpl.kt b/mirai-core-mock/src/internal/components/MockEventDispatcherImpl.kt new file mode 100644 index 000000000..60ca7f2b6 --- /dev/null +++ b/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() + } + } +} diff --git a/mirai-core-mock/src/internal/contact/AbstractMockContact.kt b/mirai-core-mock/src/internal/contact/AbstractMockContact.kt new file mode 100644 index 000000000..de810a9c3 --- /dev/null +++ b/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 { + 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.inResource(action: () -> R): R { + return useAutoClose { + runBIO { + inputStream().dropContent(close = true) + } + action() + } +} diff --git a/mirai-core-mock/src/internal/contact/MockAnnouncementsImpl.kt b/mirai-core-mock/src/internal/contact/MockAnnouncementsImpl.kt new file mode 100644 index 000000000..73156d1c2 --- /dev/null +++ b/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() + + override suspend fun asFlow(): Flow = announcements.values.asFlow() + + override fun asStream(): Stream = 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) + } +} diff --git a/mirai-core-mock/src/internal/contact/MockAnonymousMemberImpl.kt b/mirai-core-mock/src/internal/contact/MockAnonymousMemberImpl.kt new file mode 100644 index 000000000..c9b4e0a38 --- /dev/null +++ b/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.sendMessage(message) + override suspend fun uploadImage(resource: ExternalResource): Image = + super.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)" + } +} \ No newline at end of file diff --git a/mirai-core-mock/src/internal/contact/MockFriendImpl.kt b/mirai-core-mock/src/internal/contact/MockFriendImpl.kt new file mode 100644 index 000000000..2958c018d --- /dev/null +++ b/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().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().avatarUrl = value } + } + + override var friendGroupId: Int = 0 + } + + override val friendGroup: FriendGroup + get() = bot.friendGroups.cast().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 { + return super.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)" + } +} \ No newline at end of file diff --git a/mirai-core-mock/src/internal/contact/MockGroupImpl.kt b/mirai-core-mock/src/internal/contact/MockGroupImpl.kt new file mode 100644 index 000000000..aa18f23a8 --- /dev/null +++ b/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 = 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 = 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 { + return super.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)" + } +} \ No newline at end of file diff --git a/mirai-core-mock/src/internal/contact/MockNormalMemberImpl.kt b/mirai-core-mock/src/internal/contact/MockNormalMemberImpl.kt new file mode 100644 index 000000000..19cdf55f3 --- /dev/null +++ b/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 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 { + return super.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)" + } +} \ No newline at end of file diff --git a/mirai-core-mock/src/internal/contact/MockStrangerImpl.kt b/mirai-core-mock/src/internal/contact/MockStrangerImpl.kt new file mode 100644 index 000000000..ebe06d78c --- /dev/null +++ b/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 { + return super.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 + } +} \ No newline at end of file diff --git a/mirai-core-mock/src/internal/contact/friendfroup/MockFriendGroup.kt b/mirai-core-mock/src/internal/contact/friendfroup/MockFriendGroup.kt new file mode 100644 index 000000000..82d27bf7a --- /dev/null +++ b/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 = object : AbstractCollection() { + + private val seq = sequence { + 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 { + 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().groups.remove(this)) { + friends.forEach { it.mock().mockApi.friendGroupId = 0 } + return true + } + return false + } + + override val count: Int get() = friends.size +} \ No newline at end of file diff --git a/mirai-core-mock/src/internal/contact/friendfroup/MockFriendGroups.kt b/mirai-core-mock/src/internal/contact/friendfroup/MockFriendGroups.kt new file mode 100644 index 000000000..6204e8e2a --- /dev/null +++ b/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() + 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 { + return groups.asImmutable() + } + + fun findOrDefault(friendGroupId: Int): FriendGroup { + return get(friendGroupId) ?: defaultX + } +} \ No newline at end of file diff --git a/mirai-core-mock/src/internal/contact/roaming/MockRoamingMessages.kt b/mirai-core-mock/src/internal/contact/roaming/MockRoamingMessages.kt new file mode 100644 index 000000000..d1cd7b21c --- /dev/null +++ b/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 { + return getMsg(timeStart, timeEnd, filter).asFlow() + } + + private fun getMsg( + timeStart: Long, + timeEnd: Long, + filter: RoamingMessageFilter? + ): Sequence { + val msgDb = contact.bot.cast().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 { + return getMsg(timeStart, timeEnd, filter).asStream() + } +} diff --git a/mirai-core-mock/src/internal/contact/util.kt b/mirai-core-mock/src/internal/contact/util.kt new file mode 100644 index 000000000..a7fc55be2 --- /dev/null +++ b/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 + } +} diff --git a/mirai-core-mock/src/internal/db/MsgDatabaseImpl.kt b/mirai-core-mock/src/internal/db/MsgDatabaseImpl.kt new file mode 100644 index 000000000..2bef81e7a --- /dev/null +++ b/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() + 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 { + if (timeEnd < timeStart) return emptySequence() + return sequence { + 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) + } + } + } + } +} \ No newline at end of file diff --git a/mirai-core-mock/src/internal/msgsrc/OnlineMsgSrc.kt b/mirai-core-mock/src/internal/msgsrc/OnlineMsgSrc.kt new file mode 100644 index 000000000..093755c2b --- /dev/null +++ b/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 = ( + ids: IntArray, + internalIds: IntArray, + time: Int, +) -> R + +internal inline fun AbstractMockContact.newMsgSrc( + isSaying: Boolean, + messageChain: MessageChain, + time: Long = currentTimeSeconds(), + constructor: MsgSrcConstructor, +): 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(), + ) +} + diff --git a/mirai-core-mock/src/internal/remotefile/absolutefile/MockAbsoluteFile.kt b/mirai-core-mock/src/internal/remotefile/absolutefile/MockAbsoluteFile.kt new file mode 100644 index 000000000..2f28d013a --- /dev/null +++ b/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 + } +} \ No newline at end of file diff --git a/mirai-core-mock/src/internal/remotefile/absolutefile/MockAbsoluteFolder.kt b/mirai-core-mock/src/internal/remotefile/absolutefile/MockAbsoluteFolder.kt new file mode 100644 index 000000000..78d5b1ad9 --- /dev/null +++ b/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 = + currentTxRF().listFiles()?.filter { it.isDirectory }?.map { it.toMockAbsFolder(files) }?.asFlow() ?: emptyFlow() + + + @JavaFriendlyAPI + override suspend fun foldersStream(): Stream = + currentTxRF().listFiles()?.filter { it.isDirectory }?.map { it.toMockAbsFolder(files) }?.asStream() + ?: Stream.empty() + + override suspend fun files(): Flow = + currentTxRF().listFiles()?.filter { it.isFile }?.map { it.toMockAbsFile(files) }?.asFlow() ?: emptyFlow() + + @JavaFriendlyAPI + override suspend fun filesStream(): Stream = + currentTxRF().listFiles()?.filter { it.isFile }?.map { it.toMockAbsFile(files) }?.asStream() ?: Stream.empty() + + override suspend fun children(): Flow = + files.fileSystem.resolveById(id)!!.listFiles()?.map { + if (it.isFile) it.toMockAbsFile(files) + else it.toMockAbsFolder(files) + }?.asFlow() ?: emptyFlow() + + @JavaFriendlyAPI + override suspend fun childrenStream(): Stream = + 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()?.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 { + 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 { + 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 { + 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 { + 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 { + contact.safeCast()?.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 + } +} \ No newline at end of file diff --git a/mirai-core-mock/src/internal/remotefile/absolutefile/MockRemoteFiles.kt b/mirai-core-mock/src/internal/remotefile/absolutefile/MockRemoteFiles.kt new file mode 100644 index 000000000..c0fcd2f7f --- /dev/null +++ b/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 +} diff --git a/mirai-core-mock/src/internal/remotefile/remotefile/MockRemoteFile.kt b/mirai-core-mock/src/internal/remotefile/remotefile/MockRemoteFile.kt new file mode 100644 index 000000000..0e2132f41 --- /dev/null +++ b/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 { + return fileSystem.root.listFiles()!!.map { convert(it) } + } + + override suspend fun listFiles(): Flow = listFilesSeq().asFlow() + + @JavaFriendlyAPI + override suspend fun listFilesIterator(lazy: Boolean): Iterator { + 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 { + 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().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()?.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 { + val resolved = resolveFile()?.listFiles() ?: return emptySequence() + return resolved.map { convert(it) } + } + + override suspend fun listFiles(): Flow { + return listFilesSeq().asFlow() + } + + @JavaFriendlyAPI + override suspend fun listFilesIterator(lazy: Boolean): Iterator { + 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()?.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 { + 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()?.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 + } + } +} diff --git a/mirai-core-mock/src/internal/serverfs/MockServerFileDiskImpl.kt b/mirai-core-mock/src/internal/serverfs/MockServerFileDiskImpl.kt new file mode 100644 index 000000000..745b06963 --- /dev/null +++ b/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 = ConcurrentLinkedDeque() + + override val availableSystems: Sequence = 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 { + 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 "" + val fileNamePath = details.resolve("name") + val fileName = fileNamePath.takeIf { it.isFile }?.readText() ?: "" + 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? { + 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() + 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): Sequence { + 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() + } +} diff --git a/mirai-core-mock/src/internal/serverfs/TmpResourceServerImpl.kt b/mirai-core-mock/src/internal/serverfs/TmpResourceServerImpl.kt new file mode 100644 index 000000000..a705c690e --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/mirai-core-mock/src/package.kt b/mirai-core-mock/src/package.kt new file mode 100644 index 000000000..a6b41a3ad --- /dev/null +++ b/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 + diff --git a/mirai-core-mock/src/resserver/MockServerFileDisk.kt b/mirai-core-mock/src/resserver/MockServerFileDisk.kt new file mode 100644 index 000000000..78c6481b5 --- /dev/null +++ b/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 + public fun newFsSystem(): MockServerFileSystem + + public companion object { + @JvmStatic + public fun newFileDisk(storage: Path): MockServerFileDisk { + return MockServerFileDiskImpl(storage) + } + } +} diff --git a/mirai-core-mock/src/resserver/MockServerFileSystem.kt b/mirai-core-mock/src/resserver/MockServerFileSystem.kt new file mode 100644 index 000000000..011e676a7 --- /dev/null +++ b/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 +} diff --git a/mirai-core-mock/src/resserver/MockServerRemoteFile.kt b/mirai-core-mock/src/resserver/MockServerRemoteFile.kt new file mode 100644 index 000000000..011c5dd7a --- /dev/null +++ b/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? + 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, +) diff --git a/mirai-core-mock/src/resserver/TmpResourceServer.kt b/mirai-core-mock/src/resserver/TmpResourceServer.kt new file mode 100644 index 000000000..1f0d6cc68 --- /dev/null +++ b/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) + } + } +} diff --git a/mirai-core-mock/src/userprofile/UserProfileService.kt b/mirai-core-mock/src/userprofile/UserProfileService.kt new file mode 100644 index 000000000..07cb4275e --- /dev/null +++ b/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() + 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 + } + +} diff --git a/mirai-core-mock/src/userprofile/contactinfos.kt b/mirai-core-mock/src/userprofile/contactinfos.kt new file mode 100644 index 000000000..0364422e3 --- /dev/null +++ b/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 } +} diff --git a/mirai-core-mock/src/utils/MemberInfo.kt b/mirai-core-mock/src/utils/MemberInfo.kt new file mode 100644 index 000000000..e3df6b852 --- /dev/null +++ b/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 + } +} diff --git a/mirai-core-mock/src/utils/MockActionsScope.kt b/mirai-core-mock/src/utils/MockActionsScope.kt new file mode 100644 index 000000000..25ced9d46 --- /dev/null +++ b/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) + } +} \ No newline at end of file diff --git a/mirai-core-mock/src/utils/MockConversions.kt b/mirai-core-mock/src/utils/MockConversions.kt new file mode 100644 index 000000000..e8396c378 --- /dev/null +++ b/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) +} + diff --git a/mirai-core-mock/src/utils/NameGenerator.kt b/mirai-core-mock/src/utils/NameGenerator.kt new file mode 100644 index 000000000..c44313eec --- /dev/null +++ b/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() +} diff --git a/mirai-core-mock/src/utils/NudgeDsl.kt b/mirai-core-mock/src/utils/NudgeDsl.kt new file mode 100644 index 000000000..fcbdf3002 --- /dev/null +++ b/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() + +} diff --git a/mirai-core-mock/src/utils/event.kt b/mirai-core-mock/src/utils/event.kt new file mode 100644 index 000000000..9402427cc --- /dev/null +++ b/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.broadcastBlocking(): T = apply { + runBlocking { broadcast() } +} diff --git a/mirai-core-mock/src/utils/http.kt b/mirai-core-mock/src/utils/http.kt new file mode 100644 index 000000000..a1b6fd1c5 --- /dev/null +++ b/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" +} + diff --git a/mirai-core-mock/src/utils/image.kt b/mirai-core-mock/src/utils/image.kt new file mode 100644 index 000000000..829414d2e --- /dev/null +++ b/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() diff --git a/mirai-core-mock/src/utils/mockdsl.kt b/mirai-core-mock/src/utils/mockdsl.kt new file mode 100644 index 000000000..3fd9db77d --- /dev/null +++ b/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 diff --git a/mirai-core-mock/test/AbsoluteFileTest.kt b/mirai-core-mock/test/AbsoluteFileTest.kt new file mode 100644 index 000000000..788b0833a --- /dev/null +++ b/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().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()) + } +} \ No newline at end of file diff --git a/mirai-core-mock/test/DslTest.kt b/mirai-core-mock/test/DslTest.kt new file mode 100644 index 000000000..d1a49ba94 --- /dev/null +++ b/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() +} diff --git a/mirai-core-mock/test/FsServerTest.kt b/mirai-core-mock/test/FsServerTest.kt new file mode 100644 index 000000000..a5ee11745 --- /dev/null +++ b/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 { + 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() + } +} diff --git a/mirai-core-mock/test/ImageUploadTest.kt b/mirai-core-mock/test/ImageUploadTest.kt new file mode 100644 index 000000000..8cc0ceb53 --- /dev/null +++ b/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 { + val data = Image.randomImageContent() + val img = bot.asFriend.uploadImage( + data.toExternalResource().toAutoCloseable() + ) + println(img.imageId) + assertTrue { + data.contentEquals(URL(img.queryUrl()).readBytes()) + } + } +} diff --git a/mirai-core-mock/test/MockBotTestBase.kt b/mirai-core-mock/test/MockBotTestBase.kt new file mode 100644 index 000000000..189346b66 --- /dev/null +++ b/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 { + + contract { + callsInPlace(action, InvocationKind.EXACTLY_ONCE) + } + + val result = mutableListOf() + val listener = GlobalEventChannel.subscribeAlways { + result.add(this) + } + + broadcastMockEvents { + action() + } + + (bot as MockBotImpl).joinEventBroadcast() + + listener.cancel() + return result + } + +} \ No newline at end of file diff --git a/mirai-core-mock/test/MsgDbTest.kt b/mirai-core-mock/test/MsgDbTest.kt new file mode 100644 index 000000000..3dbb677c5 --- /dev/null +++ b/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() + } +} diff --git a/mirai-core-mock/test/TestBase.kt b/mirai-core-mock/test/TestBase.kt new file mode 100644 index 000000000..08ae0e5b4 --- /dev/null +++ b/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 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.dropMessagePrePost() = filterNot { + it is MessagePreSendEvent || it is MessagePostSendEvent<*> + } + + internal fun List.dropMsgChat() = filterNot { + it is MessageEvent || it is MessagePreSendEvent || it is MessagePostSendEvent<*> + } + + internal fun String.toUrl(): URL = URL(this) + + internal inline fun T.runAndAssertFails(block: T.() -> Unit) { + assertFails { block() } + } + +} \ No newline at end of file diff --git a/mirai-core-mock/test/TxFsDiskTest.kt b/mirai-core-mock/test/TxFsDiskTest.kt new file mode 100644 index 000000000..e36b8ae0b --- /dev/null +++ b/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("") } + 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 + } + + } + +} \ No newline at end of file diff --git a/mirai-core-mock/test/mock/MessagingTest.kt b/mirai-core-mock/test/mock/MessagingTest.kt new file mode 100644 index 000000000..8d341bc8c --- /dev/null +++ b/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(events[0]) { + assertEquals("Hello World", message.contentToString()) + assertEquals("test", senderName) + assertEquals(5971, sender.id) + assertEquals(5597122, group.id) + assertIsInstance(message.source) + } + assertIsInstance(events[1]) { + assertEquals("Msg By TestFriend", message.contentToString()) + assertEquals("tester", senderName) + assertEquals(9815, sender.id) + assertIsInstance(message.source) + + } + assertIsInstance(events[2]) { + assertEquals("How are you", message.contentToString()) + assertEquals("sudo", senderName) + assertEquals(987166, sender.id) + assertIsInstance(message.source) + } + + assertIsInstance(events[3]) + assertIsInstance(events[4]) { + assertIsInstance(receipt!!.source) + } + assertIsInstance(events[5]) + assertIsInstance(events[6]) { + assertIsInstance(receipt!!.source) + } + assertIsInstance(events[7]) + assertIsInstance(events[8]) { + assertIsInstance(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(events[0]) { + assertSame(nudgeSender, this.from) + assertSame(nudged, this.target) + assertSame(group, this.subject) + } + assertIsInstance(events[1]) { + assertSame(bot, this.from) + assertSame(nudged, this.target) + assertSame(group, this.subject) + } + assertIsInstance(events[2]) { + assertSame(myFriend, this.from) + assertSame(bot, this.target) + assertSame(myFriend, this.subject) + } + assertIsInstance(events[3]) { + assertSame(myStranger, this.from) + assertSame(bot, this.target) + assertSame(myStranger, this.subject) + } + assertIsInstance(events[4]) { + assertSame(bot, this.from) + assertSame(myFriend, this.target) + assertSame(myFriend, this.subject) + } + assertIsInstance(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(events[0]) { + assertNull(operator) + assertSame(sender, author) + } + assertIsInstance(events[1]) { + assertSame(admin, operator) + assertSame(sender, author) + } + assertIsInstance(events[2]) { + assertSame(admin, operator) + assertSame(group.botAsMember, author) + } + assertIsInstance(events[3]) { + assertSame(null, operator) + assertSame(sender, author) + } + assertIsInstance(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(events[0]) { + assertEquals(group.botAsMember, author) + assertEquals(null, operator) + } + } + } + +} \ No newline at end of file diff --git a/mirai-core-mock/test/mock/MockBotBaseTest.kt b/mirai-core-mock/test/mock/MockBotBaseTest.kt new file mode 100644 index 000000000..f3600647d --- /dev/null +++ b/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(events[0]) { + assertSame(newNewOwner, member) + assertSame(MemberPermission.OWNER, new) + assertSame(MemberPermission.MEMBER, origin) + } + assertIsInstance(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)) + } + +} \ No newline at end of file diff --git a/mirai-core-mock/test/mock/MockBotEventTest.kt b/mirai-core-mock/test/mock/MockBotEventTest.kt new file mode 100644 index 000000000..0d21b82a4 --- /dev/null +++ b/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(events[0]) + } + } + + @Test + fun testBotOfflineEvent() = runTest { + runAndReceiveEventBroadcast { + bot.broadcastOfflineEvent() + }.let { events -> + assertEquals(1, events.size) + assertIsInstance(events[0]) + } + } + + @Test + fun testBotRelogin() = runTest { + bot.login() + runAndReceiveEventBroadcast { + bot.login() + }.let { events -> + assertEquals(2, events.size) + assertIsInstance(events[0]) + assertIsInstance(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(events[0]) + } + } + + @Test + fun testBotNickChangedEvent() = runTest { + runAndReceiveEventBroadcast { + bot.nickNoEvent = "HiHi" + bot.nick = "AAAA" + bot nickChangesTo "BBBB" + }.let { events -> + assertEquals(2, events.size) + assertIsInstance(events[0]) { + assertEquals("HiHi", from) + assertEquals("AAAA", to) + } + assertIsInstance(events[1]) { + assertEquals("AAAA", from) + assertEquals("BBBB", to) + } + } + } +} \ No newline at end of file diff --git a/mirai-core-mock/test/mock/MockFriendGroupsTest.kt b/mirai-core-mock/test/mock/MockFriendGroupsTest.kt new file mode 100644 index 000000000..76d79b995 --- /dev/null +++ b/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) + } +} \ No newline at end of file diff --git a/mirai-core-mock/test/mock/MockFriendTest.kt b/mirai-core-mock/test/mock/MockFriendTest.kt new file mode 100644 index 000000000..1c61f8d71 --- /dev/null +++ b/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(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(events[0]) { + assertEquals(1, fromId) + assertEquals("Hi", fromNick) + assertEquals(0, fromGroupId) + assertEquals("Hello!", message) + } + + assertIsInstance(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(events[0]) + assertEquals(111, events[0].cast().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(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(events[0]) { + assertEquals(1, fromId) + assertEquals("Test", fromNick) + assertEquals(0, fromGroupId) + assertEquals("Hi", message) + } + assertIsInstance(events[1]) { + assertEquals(1, friend.id) + assertEquals("Test", friend.nick) + } + assertIsInstance(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(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(events[0]) { + assertTrue(inputting) + assertSame(bot.getFriend(1), friend) + } + } + } + +} \ No newline at end of file diff --git a/mirai-core-mock/test/mock/MockGroupTest.kt b/mirai-core-mock/test/mock/MockGroupTest.kt new file mode 100644 index 000000000..02b1502a4 --- /dev/null +++ b/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(events[0]) { + assertEquals(100000000, fromId) + assertEquals("demo", fromNick) + assertEquals("msg", message) + } + assertIsInstance(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(events[0]) { + assertEquals(5710, invitorId) + assertEquals("demo", invitorNick) + assertEquals(999999999, groupId) + assertEquals("test", groupName) + } + assertIsInstance(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(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(events[0]) { + assertEquals(1, group.id) + } + assertIsInstance(events[1]) { + assertEquals(2, friend.id) + } + assertIsInstance(events[2]) { + assertEquals(3, stranger.id) + } + assertIsInstance(events[3]) { + assertEquals(4, group.id) + assertEquals(5, member.id) + } + assertIsInstance(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(events[0]) + assertEquals(222, events[0].cast().member.id) + assertEquals(GroupHonorType.ACTIVE, events[1].cast().honorType) + assertEquals(333, events[1].cast().member.id) + assertIsInstance(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().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(events[0]) { + assertEquals(1, group.id) + assertEquals("A", group.name) + } + assertIsInstance(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(events[0]) { + assertEquals(MemberPermission.ADMINISTRATOR, new) + assertEquals(MemberPermission.MEMBER, origin) + } + assertIsInstance(events[1]) { + assertEquals(MemberPermission.OWNER, new) + assertEquals(MemberPermission.MEMBER, origin) + } + assertIsInstance(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(events[0]) + assertIsInstance(events[1]) + assertIsInstance(events[2]) + + assertIsInstance(events[3]) + assertIsInstance(events[4]) + assertIsInstance(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(events[0]) { + assertEquals("OOOOO", origin) + assertEquals("Test", new) + assertEquals(1, group.id) + assertNull(operator) + } + assertIsInstance(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(events[0]) { + assertEquals(true, origin) + assertEquals(false, new) + assertEquals(1, group.id) + assertNull(operator) + } + assertIsInstance(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(events[0]) { + assertEquals(true, origin) + assertEquals(false, new) + assertEquals(1, group.id) + assertNull(operator) + } + assertIsInstance(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(events[0]) { + assertEquals(true, origin) + assertEquals(false, new) + assertEquals(1, group.id) + assertTrue(isByBot) + } + assertIsInstance(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(events[0]) { + assertEquals(true, origin) + assertEquals(false, new) + assertEquals(1, group.id) + assertNull(operator) + } + assertIsInstance(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(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(events[0]) { + assertEquals("", origin) + assertEquals("Hello", new) + assertEquals(2, member.id) + assertEquals(1, member.group.id) + } + } + } +} \ No newline at end of file diff --git a/mirai-core-mock/test/mock/MockMemberTest.kt b/mirai-core-mock/test/mock/MockMemberTest.kt new file mode 100644 index 000000000..653d534a6 --- /dev/null +++ b/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) + } +} \ No newline at end of file diff --git a/mirai-core-mock/test/mock/MockStrangerTest.kt b/mirai-core-mock/test/mock/MockStrangerTest.kt new file mode 100644 index 000000000..d3d2abac7 --- /dev/null +++ b/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(events[0]) + assertEquals(111, events[0].cast().friend.id) + assertIsInstance(events[1]) + assertEquals(222, events[1].cast().stranger.id) + assertNotEquals("", bot.getFriend(111)!!.avatarUrl) + } + } +} \ No newline at end of file diff --git a/mirai-core-mock/test/package.kt b/mirai-core-mock/test/package.kt new file mode 100644 index 000000000..d3f3c2f2b --- /dev/null +++ b/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 diff --git a/mirai-core-utils/src/commonMain/kotlin/Conversions.kt b/mirai-core-utils/src/commonMain/kotlin/Conversions.kt index fb0b8ba12..0cf837907 100644 --- a/mirai-core-utils/src/commonMain/kotlin/Conversions.kt +++ b/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 + 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 diff --git a/mirai-core-utils/src/jvmBaseMain/kotlin/IO.jvm.shared.kt b/mirai-core-utils/src/jvmBaseMain/kotlin/IO.jvm.shared.kt new file mode 100644 index 000000000..e67f6c95d --- /dev/null +++ b/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)) } + } +} diff --git a/mirai-core-utils/src/jvmMain/kotlin/IO.jvm.kt b/mirai-core-utils/src/jvmMain/kotlin/IO.jvm.kt new file mode 100644 index 000000000..6a51d4b9e --- /dev/null +++ b/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 +} diff --git a/settings.gradle.kts b/settings.gradle.kts index c3e877ef5..aafe7892d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -58,6 +58,7 @@ fun includeConsoleProject(projectPath: String, dir: String? = null) = includeProject(":mirai-core-utils") includeProject(":mirai-core-api") includeProject(":mirai-core") +includeProject(":mirai-core-mock") includeProject(":mirai-core-all") includeProject(":mirai-bom")