diff --git a/mirai-core-api/compatibility-validation/android/api/android.api b/mirai-core-api/compatibility-validation/android/api/android.api index 6ce030efc..aeedc3ce5 100644 --- a/mirai-core-api/compatibility-validation/android/api/android.api +++ b/mirai-core-api/compatibility-validation/android/api/android.api @@ -435,6 +435,7 @@ public abstract interface class net/mamoe/mirai/contact/Group : kotlinx/coroutin public abstract fun getBotAsMember ()Lnet/mamoe/mirai/contact/NormalMember; public fun getBotMuteRemaining ()I public fun getBotPermission ()Lnet/mamoe/mirai/contact/MemberPermission; + public abstract fun getEssences ()Lnet/mamoe/mirai/contact/essence/Essences; public abstract fun getId ()J public abstract fun getMembers ()Lnet/mamoe/mirai/contact/ContactList; public abstract fun getName ()Ljava/lang/String; @@ -954,6 +955,32 @@ public final class net/mamoe/mirai/contact/announcement/OnlineAnnouncementKt { public static final fun getBot (Lnet/mamoe/mirai/contact/announcement/OnlineAnnouncement;)Lnet/mamoe/mirai/Bot; } +public final class net/mamoe/mirai/contact/essence/EssenceMessageRecord { + public final fun getFullSource ()Lnet/mamoe/mirai/message/data/MessageSource; + public final fun getFullSource (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getGroup ()Lnet/mamoe/mirai/contact/Group; + public final fun getOperator ()Lnet/mamoe/mirai/contact/NormalMember; + public final fun getOperatorId ()J + public final fun getOperatorNick ()Ljava/lang/String; + public final fun getOperatorTime ()I + public final fun getSender ()Lnet/mamoe/mirai/contact/NormalMember; + public final fun getSenderId ()J + public final fun getSenderNick ()Ljava/lang/String; + public final fun getSenderTime ()I + public final fun getSource ()Lnet/mamoe/mirai/message/data/MessageSource; + public final fun getSource (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun toString ()Ljava/lang/String; +} + +public abstract interface class net/mamoe/mirai/contact/essence/Essences : net/mamoe/mirai/utils/Streamable { + public fun getPage (II)Ljava/util/List; + public abstract fun getPage (IILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun remove (Lnet/mamoe/mirai/message/data/MessageSource;)V + public abstract fun remove (Lnet/mamoe/mirai/message/data/MessageSource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun share (Lnet/mamoe/mirai/message/data/MessageSource;)Ljava/lang/String; + public abstract fun share (Lnet/mamoe/mirai/message/data/MessageSource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public abstract interface class net/mamoe/mirai/contact/file/AbsoluteFile : net/mamoe/mirai/contact/file/AbsoluteFileFolder { public abstract fun getExpiryTime ()J public abstract fun getMd5 ()[B diff --git a/mirai-core-api/compatibility-validation/jvm/api/jvm.api b/mirai-core-api/compatibility-validation/jvm/api/jvm.api index 02bbd4f55..beee562cf 100644 --- a/mirai-core-api/compatibility-validation/jvm/api/jvm.api +++ b/mirai-core-api/compatibility-validation/jvm/api/jvm.api @@ -435,6 +435,7 @@ public abstract interface class net/mamoe/mirai/contact/Group : kotlinx/coroutin public abstract fun getBotAsMember ()Lnet/mamoe/mirai/contact/NormalMember; public fun getBotMuteRemaining ()I public fun getBotPermission ()Lnet/mamoe/mirai/contact/MemberPermission; + public abstract fun getEssences ()Lnet/mamoe/mirai/contact/essence/Essences; public abstract fun getId ()J public abstract fun getMembers ()Lnet/mamoe/mirai/contact/ContactList; public abstract fun getName ()Ljava/lang/String; @@ -954,6 +955,32 @@ public final class net/mamoe/mirai/contact/announcement/OnlineAnnouncementKt { public static final fun getBot (Lnet/mamoe/mirai/contact/announcement/OnlineAnnouncement;)Lnet/mamoe/mirai/Bot; } +public final class net/mamoe/mirai/contact/essence/EssenceMessageRecord { + public final fun getFullSource ()Lnet/mamoe/mirai/message/data/MessageSource; + public final fun getFullSource (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getGroup ()Lnet/mamoe/mirai/contact/Group; + public final fun getOperator ()Lnet/mamoe/mirai/contact/NormalMember; + public final fun getOperatorId ()J + public final fun getOperatorNick ()Ljava/lang/String; + public final fun getOperatorTime ()I + public final fun getSender ()Lnet/mamoe/mirai/contact/NormalMember; + public final fun getSenderId ()J + public final fun getSenderNick ()Ljava/lang/String; + public final fun getSenderTime ()I + public final fun getSource ()Lnet/mamoe/mirai/message/data/MessageSource; + public final fun getSource (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun toString ()Ljava/lang/String; +} + +public abstract interface class net/mamoe/mirai/contact/essence/Essences : net/mamoe/mirai/utils/Streamable { + public fun getPage (II)Ljava/util/List; + public abstract fun getPage (IILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun remove (Lnet/mamoe/mirai/message/data/MessageSource;)V + public abstract fun remove (Lnet/mamoe/mirai/message/data/MessageSource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun share (Lnet/mamoe/mirai/message/data/MessageSource;)Ljava/lang/String; + public abstract fun share (Lnet/mamoe/mirai/message/data/MessageSource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public abstract interface class net/mamoe/mirai/contact/file/AbsoluteFile : net/mamoe/mirai/contact/file/AbsoluteFileFolder { public abstract fun getExpiryTime ()J public abstract fun getMd5 ()[B diff --git a/mirai-core-api/src/commonMain/kotlin/contact/Group.kt b/mirai-core-api/src/commonMain/kotlin/contact/Group.kt index 069f6ae2a..ea7e314c6 100644 --- a/mirai-core-api/src/commonMain/kotlin/contact/Group.kt +++ b/mirai-core-api/src/commonMain/kotlin/contact/Group.kt @@ -1,5 +1,5 @@ /* - * Copyright 2019-2022 Mamoe Technologies and contributors. + * Copyright 2019-2023 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. @@ -17,6 +17,7 @@ import me.him188.kotlin.jvm.blocking.bridge.JvmBlockingBridge import net.mamoe.mirai.Bot import net.mamoe.mirai.contact.active.GroupActive import net.mamoe.mirai.contact.announcement.Announcements +import net.mamoe.mirai.contact.essence.Essences import net.mamoe.mirai.contact.file.RemoteFiles import net.mamoe.mirai.contact.roaming.RoamingSupported import net.mamoe.mirai.event.events.* @@ -229,6 +230,13 @@ public interface Group : Contact, CoroutineScope, FileSupported, AudioSupported, */ public suspend fun setEssenceMessage(source: MessageSource): Boolean + /** + * 群精华消息相关功能接口 + * + * @since 2.15 + */ + public val essences: Essences + public companion object { /** * 将一条消息设置为群精华消息, 需要管理员或群主权限. diff --git a/mirai-core-api/src/commonMain/kotlin/contact/essence/EssenceMessageRecord.kt b/mirai-core-api/src/commonMain/kotlin/contact/essence/EssenceMessageRecord.kt new file mode 100644 index 000000000..0a848525d --- /dev/null +++ b/mirai-core-api/src/commonMain/kotlin/contact/essence/EssenceMessageRecord.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2019-2023 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.contact.essence + +import me.him188.kotlin.jvm.blocking.bridge.JvmBlockingBridge +import net.mamoe.mirai.contact.Group +import net.mamoe.mirai.contact.NormalMember +import net.mamoe.mirai.message.data.MessageSource +import net.mamoe.mirai.utils.MiraiInternalApi + +/** + * 精华消息记录 + * @since 2.15 + * @param group 记录的群聊 + * @param sender 消息的发送者 + * @param senderId 消息的发送者的ID + * @param senderNick 消息的发送者的Nick + * @param senderTime 消息的发送的时间 * + * @param operator 设置精华的操作者 + * @param operatorId 设置精华的操作者的ID + * @param operatorNick 设置精华的操作者的Nick + * @param operatorTime 设置精华的时间 + */ +public class EssenceMessageRecord @MiraiInternalApi constructor( + public val group: Group, + public val sender: NormalMember?, + public val senderId: Long, + public val senderNick: String, + public val senderTime: Int, + public val operator: NormalMember?, + public val operatorId: Long, + public val operatorNick: String, + public val operatorTime: Int, + private val loadMessageSource: suspend (parse: Boolean) -> MessageSource +) { + override fun toString(): String { + return "EssenceMessageRecord(group=${group}, sender=${senderNick}(${senderId}), senderTime=${senderTime}, operator=${operatorNick}(${operatorId}), operatorTime=${operatorTime})" + } + + /** + * 获取消息源 + * + * 其中的 [MessageSource.originalMessage] 将会尝试以加载为原消息格式 + * + * **注意** 当精华消息中包含 图片 时,会尝试将其下载然后重新上传, 以保证可用性 + * + * @see getSource + */ + @JvmBlockingBridge + public suspend fun getFullSource(): MessageSource { + return loadMessageSource(true) + } + + /** + * 获取消息源 + * + * @see getFullSource + */ + @JvmBlockingBridge + public suspend fun getSource(): MessageSource { + return loadMessageSource(false) + } +} \ No newline at end of file diff --git a/mirai-core-api/src/commonMain/kotlin/contact/essence/Essences.kt b/mirai-core-api/src/commonMain/kotlin/contact/essence/Essences.kt new file mode 100644 index 000000000..1a550972e --- /dev/null +++ b/mirai-core-api/src/commonMain/kotlin/contact/essence/Essences.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2019-2023 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.contact.essence + +import me.him188.kotlin.jvm.blocking.bridge.JvmBlockingBridge +import net.mamoe.mirai.message.data.MessageSource +import net.mamoe.mirai.contact.Group +import net.mamoe.mirai.utils.NotStableForInheritance +import net.mamoe.mirai.utils.Streamable + +/** + * 表示一个群精华消息管理. + * + * ## 获取 [Essences] 实例 + * + * 只可以通过 [Group.essences] 获取一个群的精华消息管理, 即 [Essences] 实例. + * + * ### 获取精华消息列表 + * + * 通过 [asFlow] 或 `asStream` 可以获取到*惰性*流, 在从流中收集数据时才会请求服务器获取数据. 通常建议在 Kotlin 使用协程的 [asFlow], 在 Java 使用 `asStream`. + * + * 若要获取全部精华消息列表, 可使用 [toList]. + * + * ### 获取精华消息分享链接 + * + * 通过 [share] 可以获得一个精华消息的分享链接 + * + * ### 移除精华消息 + * + * 通过 [remove] 可以从列表中移除指定精华消息 (WEB API) + * + * @since 2.15 + */ +@NotStableForInheritance +public interface Essences : Streamable { + + /** + * 按页获取精华消息记录 + * @param start 起始索引 从 0 开始 + * @param limit 页大小 返回的记录最大数量,最大取 50 + * @throws IllegalStateException [limit] 过大或其他参数错误时会触发异常 + */ + @JvmBlockingBridge + public suspend fun getPage(start: Int, limit: Int): List + + /** + * 分享精华消息 + * @param source 要分享的消息源 + * @throws IllegalStateException [source] 不为精华消息时将会触发异常 + * @return 分享 URL + */ + @JvmBlockingBridge + public suspend fun share(source: MessageSource): String + + /** + * 移除精华消息 + * @throws IllegalStateException [source] 不为精华消息或权限不足时将会触发异常 + * @param source 要移除的消息源 + */ + @JvmBlockingBridge + public suspend fun remove(source: MessageSource) +} \ No newline at end of file diff --git a/mirai-core-mock/src/contact/essence/MockEssences.kt b/mirai-core-mock/src/contact/essence/MockEssences.kt new file mode 100644 index 000000000..5fd55ec32 --- /dev/null +++ b/mirai-core-mock/src/contact/essence/MockEssences.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2019-2023 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.essence + +import net.mamoe.mirai.contact.NormalMember +import net.mamoe.mirai.contact.essence.Essences +import net.mamoe.mirai.message.data.MessageSource +import net.mamoe.mirai.mock.MockBotDSL + +public interface MockEssences : Essences { + + /** + * 直接以 [actor] 的身份设置一条精华消息 + */ + @MockBotDSL + public fun mockSetEssences(source: MessageSource, actor: NormalMember) +} \ 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 index 75a18d32a..280ec2949 100644 --- a/mirai-core-mock/src/internal/contact/MockGroupImpl.kt +++ b/mirai-core-mock/src/internal/contact/MockGroupImpl.kt @@ -31,7 +31,9 @@ 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.contact.active.MockGroupActive +import net.mamoe.mirai.mock.contact.essence.MockEssences import net.mamoe.mirai.mock.internal.contact.active.MockGroupActiveImpl +import net.mamoe.mirai.mock.internal.contact.essence.MockEssencesImpl import net.mamoe.mirai.mock.internal.contact.roaming.MockRoamingMessages import net.mamoe.mirai.mock.internal.msgsrc.OnlineMsgSrcToGroup import net.mamoe.mirai.mock.internal.msgsrc.newMsgSrc @@ -337,9 +339,13 @@ internal class MockGroupImpl( resource.mockUploadVoice(bot) override suspend fun setEssenceMessage(source: MessageSource): Boolean { + checkBotPermission(MemberPermission.ADMINISTRATOR) + essences.mockSetEssences(source, this.botAsMember) return true } + override val essences: MockEssences = MockEssencesImpl(this) + @Deprecated("Please use files instead.", replaceWith = ReplaceWith("files.root")) @Suppress("OverridingDeprecatedMember", "DEPRECATION", "DEPRECATION_ERROR") override val filesRoot: RemoteFile by lazy { diff --git a/mirai-core-mock/src/internal/contact/essence/MockEssences.kt b/mirai-core-mock/src/internal/contact/essence/MockEssences.kt new file mode 100644 index 000000000..321d419c8 --- /dev/null +++ b/mirai-core-mock/src/internal/contact/essence/MockEssences.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2019-2023 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.essence + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import net.mamoe.mirai.contact.NormalMember +import net.mamoe.mirai.contact.essence.EssenceMessageRecord +import net.mamoe.mirai.message.data.MessageSource +import net.mamoe.mirai.mock.contact.essence.MockEssences +import net.mamoe.mirai.mock.internal.contact.MockGroupImpl +import net.mamoe.mirai.utils.ConcurrentHashMap +import net.mamoe.mirai.utils.currentTimeSeconds + +internal class MockEssencesImpl( + private val group: MockGroupImpl +) : MockEssences { + + private val cache: MutableMap = ConcurrentHashMap() + + override fun mockSetEssences(source: MessageSource, actor: NormalMember) { + val record = EssenceMessageRecord( + group = group, + sender = group[source.fromId], + senderId = source.fromId, + senderNick = group[source.fromId]?.nick.orEmpty(), + senderTime = source.time, + operator = actor, + operatorId = actor.id, + operatorNick = actor.nick, + operatorTime = currentTimeSeconds().toInt(), + loadMessageSource = { source } + ) + cache[source] = record + } + + override suspend fun getPage(start: Int, limit: Int): List { + return cache.values.toList().subList(start, start + limit) + } + + override suspend fun share(source: MessageSource): String { + return "https://qun.qq.com/essence/share?_wv=3&_wwv=128&_wvx=2&sharekey=..." + } + + override suspend fun remove(source: MessageSource) { + cache.remove(source) + } + + override fun asFlow(): Flow { + return cache.values.asFlow() + } +} \ No newline at end of file diff --git a/mirai-core/src/commonMain/kotlin/contact/GroupImpl.kt b/mirai-core/src/commonMain/kotlin/contact/GroupImpl.kt index 7b879c7dc..cbd48659c 100644 --- a/mirai-core/src/commonMain/kotlin/contact/GroupImpl.kt +++ b/mirai-core/src/commonMain/kotlin/contact/GroupImpl.kt @@ -19,6 +19,7 @@ import net.mamoe.mirai.LowLevelApi import net.mamoe.mirai.contact.* import net.mamoe.mirai.contact.active.GroupActive import net.mamoe.mirai.contact.announcement.Announcements +import net.mamoe.mirai.contact.essence.Essences import net.mamoe.mirai.contact.file.RemoteFiles import net.mamoe.mirai.contact.roaming.RoamingMessages import net.mamoe.mirai.data.GroupHonorType @@ -29,6 +30,7 @@ import net.mamoe.mirai.event.events.* import net.mamoe.mirai.internal.QQAndroidBot import net.mamoe.mirai.internal.contact.active.GroupActiveImpl import net.mamoe.mirai.internal.contact.announcement.AnnouncementsImpl +import net.mamoe.mirai.internal.contact.essence.EssencesImpl import net.mamoe.mirai.internal.contact.file.RemoteFilesImpl import net.mamoe.mirai.internal.contact.info.MemberInfoImpl import net.mamoe.mirai.internal.contact.roaming.RoamingMessagesImplGroup @@ -393,6 +395,27 @@ internal abstract class CommonGroupImpl constructor( override val roamingMessages: RoamingMessages by lazy { RoamingMessagesImplGroup(this) } + // 鉴于在 [essences] 中 有相同的功能的 Web API 所以此方法移除 +// override suspend fun removeEssenceMessage(source: MessageSource): Boolean { +// checkBotPermission(MemberPermission.ADMINISTRATOR) +// val result = bot.network.sendAndExpect( +// TroopEssenceMsgManager.RemoveEssence( +// bot.client, +// this@CommonGroupImpl.uin, +// source.internalIds.first(), +// source.ids.first() +// ), 5000, 2 +// ) +// return result.success +// } + + override val essences: Essences by lazy { + EssencesImpl( + this as GroupImpl, + bot.network.logger.subLogger("Group $id"), + ) + } + override fun toString(): String = "Group($id)" } diff --git a/mirai-core/src/commonMain/kotlin/contact/essence/EssencesImpl.kt b/mirai-core/src/commonMain/kotlin/contact/essence/EssencesImpl.kt new file mode 100644 index 000000000..3811f317d --- /dev/null +++ b/mirai-core/src/commonMain/kotlin/contact/essence/EssencesImpl.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2019-2023 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.internal.contact.essence + +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.isActive +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive +import net.mamoe.mirai.contact.MemberPermission +import net.mamoe.mirai.contact.checkBotPermission +import net.mamoe.mirai.contact.essence.EssenceMessageRecord +import net.mamoe.mirai.contact.essence.Essences +import net.mamoe.mirai.internal.contact.GroupImpl +import net.mamoe.mirai.internal.network.protocol.packet.chat.TroopEssenceMsgManager +import net.mamoe.mirai.message.data.* +import net.mamoe.mirai.utils.* +import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource + +internal class EssencesImpl( + internal val group: GroupImpl, + internal val logger: MiraiLogger, +) : Essences { + + private suspend fun parse(content: JsonObject): Message { + return when (content.getValue("msg_type").jsonPrimitive.intOrNull) { + 1 -> PlainText(content = content.getValue("text").jsonPrimitive.content) + 2 -> Face(id = content.getValue("face_index").jsonPrimitive.int) + 3 -> { + val url = content.getValue("image_url").jsonPrimitive.content + + try { + // url -> bytes -> group.upload + val bytes = group.bot.downloadEssenceMessageImage(url) + bytes.toExternalResource().use { group.uploadImage(it) } + } catch (cause: Exception) { + logger.debug({ "essence message image $url download fail." }, cause) + val match = IMAGE_MD5_REGEX.find(url) ?: return emptyMessageChain() + val (md5, ext) = match.destructured + val imageId = buildString { + append(md5) + insert(8,"-") + insert(13,"-") + insert(18,"-") + insert(23,"-") + insert(0, "{") + append("}.") + append(ext.replace("jpeg", "jpg")) + } + Image(imageId) + } + } + else -> { + // XXX: unknown message type + logger.warning { "unknown digest message type for $content" } + emptyMessageChain() + } + } + } + + private fun plain(content: JsonObject): String { + return when (content.getValue("msg_type").jsonPrimitive.intOrNull) { + 1 -> content.getValue("text").jsonPrimitive.content + 2 -> Face(id = content.getValue("face_index").jsonPrimitive.int).content + 3 -> "[图片]" + else -> "" + } + } + + private suspend fun source(digests: DigestMessage, parse: Boolean): MessageSource { + return group.bot.buildMessageSource(MessageSourceKind.GROUP) { + ids = intArrayOf(digests.msgSeq) + internalIds = intArrayOf(digests.msgRandom) + time = digests.senderTime + + fromId = digests.senderUin + targetId = group.id + + if (parse) { + messages(digests.msgContent.map { content -> parse(content) }) + } else { + messages(digests.msgContent.joinToString { content -> plain(content) }.toPlainText()) + } + } + } + + private fun record(digests: DigestMessage): EssenceMessageRecord { + return EssenceMessageRecord( + group = group, + sender = group[digests.senderUin], + senderId = digests.senderUin, + senderNick = digests.senderNick, + senderTime = digests.senderTime, + operator = group[digests.addDigestUin], + operatorId = digests.addDigestUin, + operatorNick = digests.addDigestNick, + operatorTime = digests.addDigestTime, + loadMessageSource = { source(digests = digests, parse = false) } + ) + } + + override suspend fun getPage(start: Int, limit: Int): List { + val page = group.bot.getDigestList( + groupCode = group.id, + pageStart = start, + pageLimit = limit + ) + + return page.messages.map(this::record) + } + + override suspend fun share(source: MessageSource): String { + val share = group.bot.shareDigest( + groupCode = group.id, + msgSeq = source.ids.first(), + msgRandom = source.internalIds.first(), + targetGroupCode = 0 + ) + return "https://qun.qq.com/essence/share?_wv=3&_wwv=128&_wvx=2&sharekey=${share.shareKey}" + } + + override suspend fun remove(source: MessageSource) { + group.checkBotPermission(MemberPermission.ADMINISTRATOR) + val result = group.bot.network.sendAndExpect( + TroopEssenceMsgManager.RemoveEssence( + group.bot.client, + group.uin, + source.internalIds.first(), + source.ids.first() + ), 5000, 2 + ) + if (result.success.not()) { + try { + group.bot.cancelDigest( + groupCode = group.id, + msgSeq = source.ids.first(), + msgRandom = source.internalIds.first() + ) + } catch (cause: IllegalStateException) { + cause.addSuppressed(IllegalStateException(result.msg)) + throw cause + } + } + } + + override fun asFlow(): Flow { + return flow { + var offset = 0 + while (currentCoroutineContext().isActive) { + val page = group.bot.getDigestList( + groupCode = group.id, + pageStart = offset, + pageLimit = 30 + ) + for (message in page.messages) { + emit(record(message)) + } + if (page.isEnd) break + if (page.messages.isEmpty()) break + offset += 30 + } + } + } +} \ No newline at end of file diff --git a/mirai-core/src/commonMain/kotlin/contact/essence/GroupDigestProtocol.kt b/mirai-core/src/commonMain/kotlin/contact/essence/GroupDigestProtocol.kt new file mode 100644 index 000000000..dce157f38 --- /dev/null +++ b/mirai-core/src/commonMain/kotlin/contact/essence/GroupDigestProtocol.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2019-2023 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.internal.contact.essence + +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import net.mamoe.mirai.internal.QQAndroidBot +import net.mamoe.mirai.internal.contact.active.defaultJson +import net.mamoe.mirai.internal.network.components.HttpClientProvider +import net.mamoe.mirai.internal.network.psKey +import net.mamoe.mirai.internal.network.sKey +import net.mamoe.mirai.utils.CheckableResponseA +import net.mamoe.mirai.utils.JsonStruct +import net.mamoe.mirai.utils.loadAs + +@Serializable +internal data class DigestData( + @SerialName("data") val `data`: JsonElement = JsonNull, + @SerialName("wording") val reason: String = "", + @SerialName("retmsg") override val errorMessage: String, + @SerialName("retcode") override val errorCode: Int +) : CheckableResponseA(), JsonStruct + +@Serializable +internal data class DigestList( + @SerialName("group_role") + val role: Int = 0, + @SerialName("is_end") + val isEnd: Boolean = false, + @SerialName("msg_list") + val messages: List = emptyList(), + @SerialName("show_tips") + val showTips: Boolean = false +) + +@Serializable +internal data class DigestMessage( + @SerialName("add_digest_nick") + val addDigestNick: String = "", + @SerialName("add_digest_time") + val addDigestTime: Int = 0, + @SerialName("add_digest_uin") + val addDigestUin: Long = 0, + @SerialName("group_code") + val groupCode: String = "", + @SerialName("msg_content") + val msgContent: List = emptyList(), + @SerialName("msg_random") + val msgRandom: Int = 0, + @SerialName("msg_seq") + val msgSeq: Int = 0, + @SerialName("sender_nick") + val senderNick: String = "", + @SerialName("sender_time") + val senderTime: Int = 0, + @SerialName("sender_uin") + val senderUin: Long = 0 +) + +internal val IMAGE_MD5_REGEX: Regex = """([0-9a-fA-F]{32})\.([0-9a-zA-Z]+)""".toRegex() + +@Serializable +internal data class DigestShare( + @SerialName("share_key") + val shareKey: String = "" +) + +private fun DigestData.loadData(serializer: KSerializer): T { + return try { + defaultJson.decodeFromJsonElement(serializer, this.data) + } catch (cause: Exception) { + throw IllegalStateException("parse digest data error, status: $errorCode - $errorMessage", cause) + } +} + +internal suspend fun QQAndroidBot.getDigestList( + groupCode: Long, pageStart: Int, pageLimit: Int +): DigestList { + return components[HttpClientProvider].getHttpClient().get { + url("https://qun.qq.com/cgi-bin/group_digest/digest_list") + parameter("group_code", groupCode) + parameter("page_start", pageStart) + parameter("page_limit", pageLimit) + parameter("bkn", client.wLoginSigInfo.bkn) + + headers { + // ktor bug + append( + "cookie", + "uin=o${id}; skey=${sKey}; p_uin=o${id}; p_skey=${psKey(host)};" + ) + } + }.bodyAsText().loadAs(DigestData.serializer()).loadData(DigestList.serializer()) +} + +internal suspend fun QQAndroidBot.cancelDigest( + groupCode: Long, msgSeq: Int, msgRandom: Int +) { + val data = components[HttpClientProvider].getHttpClient().get { + url("https://qun.qq.com/cgi-bin/group_digest/cancel_digest") + parameter("group_code", groupCode) + parameter("msg_seq", msgSeq) + parameter("msg_random", msgRandom) + parameter("bkn", client.wLoginSigInfo.bkn) + + headers { + // ktor bug + append( + "cookie", + "uin=o${id}; skey=${sKey}; p_uin=o${id}; p_skey=${psKey(host)};" + ) + } + }.bodyAsText().loadAs(DigestData.serializer()) + + when (data.errorCode) { + 0, 11007, 11001 -> Unit + else -> throw IllegalStateException("cancel digest error, status: ${data.errorCode} - ${data.errorMessage}, reason: ${data.reason}") + } +} + +internal suspend fun QQAndroidBot.shareDigest( + groupCode: Long, msgSeq: Int, msgRandom: Int, targetGroupCode: Long +): DigestShare { + return components[HttpClientProvider].getHttpClient().get { + url("https://qun.qq.com/cgi-bin/group_digest/share_digest") + parameter("group_code", groupCode) + parameter("msg_seq", msgSeq) + parameter("msg_random", msgRandom) + parameter("target_group_code", targetGroupCode) + parameter("bkn", client.wLoginSigInfo.bkn) + + headers { + // ktor bug + append( + "cookie", + "uin=o${id}; skey=${sKey}; p_uin=o${id}; p_skey=${psKey(host)};" + ) + } + }.bodyAsText().loadAs(DigestData.serializer()).loadData(DigestShare.serializer()) +} + +internal suspend fun QQAndroidBot.downloadEssenceMessageImage(urlString: String): ByteArray { + return components[HttpClientProvider].getHttpClient().get { + url(urlString) + }.body() +} \ No newline at end of file diff --git a/mirai-core/src/commonMain/kotlin/network/protocol/packet/PacketFactory.kt b/mirai-core/src/commonMain/kotlin/network/protocol/packet/PacketFactory.kt index 2c6ab32e0..a80683f3c 100644 --- a/mirai-core/src/commonMain/kotlin/network/protocol/packet/PacketFactory.kt +++ b/mirai-core/src/commonMain/kotlin/network/protocol/packet/PacketFactory.kt @@ -1,5 +1,5 @@ /* - * Copyright 2019-2022 Mamoe Technologies and contributors. + * Copyright 2019-2023 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. @@ -165,6 +165,7 @@ internal object KnownPacketFactories { TroopManagement.Kick, TroopManagement.SwitchAnonymousChat, TroopEssenceMsgManager.SetEssence, + TroopEssenceMsgManager.RemoveEssence, NudgePacket, Heartbeat.Alive, PbMessageSvc.PbMsgWithDraw, diff --git a/mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/TroopEssenceMsgManager.kt b/mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/TroopEssenceMsgManager.kt index c89f9645d..9e708d91c 100644 --- a/mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/TroopEssenceMsgManager.kt +++ b/mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/TroopEssenceMsgManager.kt @@ -28,15 +28,16 @@ import net.mamoe.mirai.internal.utils.io.serialization.writeProtoBuf * */ internal class TroopEssenceMsgManager { - internal object SetEssence : OutgoingPacketFactory("OidbSvc.0xeac_1") { - internal data class Response(val success: Boolean, val msg: String?) : Packet + internal data class Response(val success: Boolean, val msg: String?) : Packet + + internal object SetEssence : OutgoingPacketFactory("OidbSvc.0xeac_1") { operator fun invoke( client: QQAndroidClient, troopUin: Long, - msg_random: Int, - msg_seq: Int + msgRandom: Int, + msgSeq: Int ) = buildOutgoingUniPacket(client) { writeProtoBuf( OidbSso.OIDBSSOPkg.serializer(), OidbSso.OIDBSSOPkg( @@ -45,8 +46,40 @@ internal class TroopEssenceMsgManager { serviceType = 1, bodybuffer = Oidb0xeac.ReqBody( groupCode = troopUin, - msgSeq = msg_seq.and(-1), - msgRandom = msg_random + msgSeq = msgSeq.and(-1), + msgRandom = msgRandom + ).toByteArray(Oidb0xeac.ReqBody.serializer()), + ) + ) + } + + override suspend fun ByteReadPacket.decode(bot: QQAndroidBot): Response { + readProtoBuf(OidbSso.OIDBSSOPkg.serializer()).let { pkg -> + pkg.bodybuffer.loadAs(Oidb0xeac.RspBody.serializer()).let { data -> + return Response(data.errorCode == 0, data.wording) + } + } + + } + } + + internal object RemoveEssence : OutgoingPacketFactory("OidbSvc.0xeac_2") { + + operator fun invoke( + client: QQAndroidClient, + troopUin: Long, + msgRandom: Int, + msgSeq: Int + ) = buildOutgoingUniPacket(client) { + writeProtoBuf( + OidbSso.OIDBSSOPkg.serializer(), OidbSso.OIDBSSOPkg( + command = 3756, + result = 0, + serviceType = 1, + bodybuffer = Oidb0xeac.ReqBody( + groupCode = troopUin, + msgSeq = msgSeq.and(-1), + msgRandom = msgRandom ).toByteArray(Oidb0xeac.ReqBody.serializer()), ) ) diff --git a/mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/TroopManagement.kt b/mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/TroopManagement.kt index 36a914def..bbf170b7c 100644 --- a/mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/TroopManagement.kt +++ b/mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/TroopManagement.kt @@ -1,5 +1,5 @@ /* - * Copyright 2019-2022 Mamoe Technologies and contributors. + * Copyright 2019-2023 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.