Kaynağa Gözat

Support ForwardMessage DSL

Him188 5 yıl önce
ebeveyn
işleme
1b4e1475a0

+ 30 - 17
mirai-core-qqandroid/src/commonMain/kotlin/net/mamoe/mirai/qqandroid/QQAndroidBot.common.kt

@@ -552,12 +552,14 @@ internal abstract class QQAndroidBotBase constructor(
     @MiraiExperimentalAPI
     internal suspend fun lowLevelSendGroupLongOrForwardMessage(
         groupCode: Long,
-        message: Collection<MessageChain>,
-        isLong: Boolean
+        message: Collection<ForwardMessage.INode>,
+        isLong: Boolean,
+        forwardMessage: ForwardMessage?
     ): MessageReceipt<Group> {
         message.forEach {
-            it.firstIsInstanceOrNull<QuoteReply>()?.source?.ensureSequenceIdAvailable()
+            it.message.ensureSequenceIdAvailable()
         }
+
         val group = getGroup(groupCode)
 
         val time = currentTimeSeconds
@@ -566,10 +568,8 @@ internal abstract class QQAndroidBotBase constructor(
         network.run {
             val data = message.calculateValidationDataForGroup(
                 sequenceId = sequenceId,
-                time = time.toInt(),
                 random = Random.nextInt().absoluteValue.toUInt(),
                 groupCode = groupCode,
-                botId = [email protected],
                 botMemberNameCard = group.botAsMember.nameCardOrNick
             )
 
@@ -646,21 +646,30 @@ internal abstract class QQAndroidBotBase constructor(
             return if (isLong) {
                 group.sendMessage(
                     RichMessage.longMessage(
-                        brief = message.joinToString(limit = 27) { it.contentToString() },
+                        brief = message.joinToString(limit = 27) { it.message.contentToString() },
                         resId = resId,
                         timeSeconds = time
                     )
                 )
             } else {
+                checkNotNull(forwardMessage) { "Internal error: forwardMessage is null when sending forward" }
                 group.sendMessage(
                     RichMessage.forwardMessage(
                         resId = resId,
                         timeSeconds = time,
-                        preview = message.take(3).joinToString {
-                            """
-                                <title size="26" color="#777777" maxLines="2" lineSpace="12">${it.joinToString(limit = 10)}</title>
-                            """.trimIndent()
-                        }
+                        //  preview = message.take(5).joinToString {
+                        //      """
+                        //          <title size="26" color="#777777" maxLines="2" lineSpace="12">${it.message.asMessageChain().joinToString(limit = 10)}</title>
+                        //      """.trimIndent()
+                        //  },
+                        preview = forwardMessage.displayStrategy.generatePreview(forwardMessage).take(4)
+                            .map {
+                                """<title size="26" color="#777777" maxLines="2" lineSpace="12">$it</title>"""
+                            }.joinToString(""),
+                        title = forwardMessage.displayStrategy.generateTitle(forwardMessage),
+                        brief = forwardMessage.displayStrategy.generateBrief(forwardMessage),
+                        source = forwardMessage.displayStrategy.generateSource(forwardMessage),
+                        summary = forwardMessage.displayStrategy.generateSummary(forwardMessage)
                     )
                 )
             }
@@ -769,21 +778,25 @@ private fun RichMessage.Templates.longMessage(brief: String, resId: String, time
 private fun RichMessage.Templates.forwardMessage(
     resId: String,
     timeSeconds: Long,
-    preview: String
+    preview: String,
+    title: String,
+    brief: String,
+    source: String,
+    summary: String
 ): ForwardMessageInternal {
     val template = """
         <?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
-        <msg serviceID="35" templateID="1" action="viewMultiMsg" brief="[聊天记录]"
+        <msg serviceID="35" templateID="1" action="viewMultiMsg" brief="$brief"
              m_resid="$resId" m_fileName="$timeSeconds"
              tSum="3" sourceMsgId="0" url="" flag="3" adverSign="0" multiMsgFlag="0">
             <item layout="1" advertiser_id="0" aid="0">
-                <title size="34" maxLines="2" lineSpace="12">群聊的聊天记录</title>
+                <title size="34" maxLines="2" lineSpace="12">$title</title>
                 $preview
                 <hr hidden="false" style="0"/>
-                <summary size="26" color="#777777">查看3条转发消息</summary>
+                <summary size="26" color="#777777">$summary</summary>
             </item>
-            <source name="聊天记录" icon="" action="" appid="-1"/>
+            <source name="$source" icon="" action="" appid="-1"/>
         </msg>
-    """.trimIndent()
+    """.trimIndent().replace("\n", " ")
     return ForwardMessageInternal(template)
 }

+ 26 - 4
mirai-core-qqandroid/src/commonMain/kotlin/net/mamoe/mirai/qqandroid/contact/GroupImpl.kt

@@ -295,7 +295,13 @@ internal class GroupImpl(
             }
         }
         if (message is ForwardMessage) {
-            return bot.lowLevelSendGroupLongOrForwardMessage(this.id, message.messageList, false)
+            check(message.nodeList.size < 200) {
+                throw MessageTooLargeException(
+                    this, message, message,
+                    "ForwardMessage allows up to 200 nodes, but found ${message.nodeList.size}")
+            }
+
+            return bot.lowLevelSendGroupLongOrForwardMessage(this.id, message.nodeList, false, message)
         }
 
         val msg: MessageChain
@@ -321,8 +327,16 @@ internal class GroupImpl(
                 )
             }
 
-            if (length > 702 || imageCnt > 2)
-                return bot.lowLevelSendGroupLongOrForwardMessage(this.id, listOf(event.message), true)
+            if (length > 702 || imageCnt > 2) {
+                return bot.lowLevelSendGroupLongOrForwardMessage(this.id,
+                    listOf(ForwardMessage.Node(
+                        senderId = bot.id,
+                        time = currentTimeSeconds.toInt(),
+                        message = event.message,
+                        senderName = bot.nick)
+                    ),
+                    true, null)
+            }
 
             msg = event.message
         } else msg = message.asMessageChain()
@@ -343,7 +357,15 @@ internal class GroupImpl(
                     120 -> throw BotIsBeingMutedException(this@GroupImpl)
                     34 -> {
                         kotlin.runCatching { // allow retry once
-                            return bot.lowLevelSendGroupLongOrForwardMessage(id, listOf(msg), true)
+                            return bot.lowLevelSendGroupLongOrForwardMessage(
+                                id, listOf(
+                                    ForwardMessage.Node(
+                                        senderId = bot.id,
+                                        time = currentTimeSeconds.toInt(),
+                                        message = msg,
+                                        senderName = bot.nick
+                                    )
+                                ), true, null)
                         }.getOrElse {
                             throw IllegalStateException("internal error: send message failed(34)", it)
                         }

+ 13 - 0
mirai-core-qqandroid/src/commonMain/kotlin/net/mamoe/mirai/qqandroid/message/incomingSourceImpl.kt

@@ -15,6 +15,7 @@ import net.mamoe.mirai.Bot
 import net.mamoe.mirai.contact.Friend
 import net.mamoe.mirai.contact.Member
 import net.mamoe.mirai.event.internal.MiraiAtomicBoolean
+import net.mamoe.mirai.message.data.Message
 import net.mamoe.mirai.message.data.MessageChain
 import net.mamoe.mirai.message.data.MessageSource
 import net.mamoe.mirai.message.data.OnlineMessageSource
@@ -47,6 +48,18 @@ internal suspend inline fun MessageSource.ensureSequenceIdAvailable() {
     }*/
 }
 
+@Suppress("RedundantSuspendModifier", "unused")
+internal suspend inline fun Message.ensureSequenceIdAvailable() {
+    // no suspend.
+
+    // obsolete but keep for future
+    return
+    /*
+    if (this is MessageSourceToGroupImpl) {
+        this.ensureSequenceIdAvailable()
+    }*/
+}
+
 internal class MessageSourceFromFriendImpl(
     override val bot: Bot,
     val msg: MsgComm.Msg

+ 11 - 11
mirai-core-qqandroid/src/commonMain/kotlin/net/mamoe/mirai/qqandroid/network/protocol/data/proto/MultiMsg.kt

@@ -9,32 +9,32 @@ import kotlin.jvm.JvmField
 @Serializable
 internal class MultiMsg : ProtoBuf {
     @Serializable
-internal class ExternMsg(
+    internal class ExternMsg(
         @ProtoId(1) @JvmField val channelType: Int = 0
     ) : ProtoBuf
 
     @Serializable
-internal class MultiMsgApplyDownReq(
+    internal class MultiMsgApplyDownReq(
         @ProtoId(1) @JvmField val msgResid: ByteArray = EMPTY_BYTE_ARRAY,
         @ProtoId(2) @JvmField val msgType: Int = 0,
         @ProtoId(3) @JvmField val srcUin: Long = 0L
     ) : ProtoBuf
 
     @Serializable
-internal class MultiMsgApplyDownRsp(
+    internal class MultiMsgApplyDownRsp(
         @ProtoId(1) @JvmField val result: Int = 0,
         @ProtoId(2) @JvmField val thumbDownPara: ByteArray = EMPTY_BYTE_ARRAY,
         @ProtoId(3) @JvmField val msgKey: ByteArray = EMPTY_BYTE_ARRAY,
         @ProtoId(4) @JvmField val uint32DownIp: List<Int>? = null,
         @ProtoId(5) @JvmField val uint32DownPort: List<Int>? = null,
         @ProtoId(6) @JvmField val msgResid: ByteArray = EMPTY_BYTE_ARRAY,
-        @ProtoId(7) @JvmField val msgExternInfo: MultiMsg.ExternMsg? = null,
+        @ProtoId(7) @JvmField val msgExternInfo: ExternMsg? = null,
         @ProtoId(8) @JvmField val bytesDownIpV6: List<ByteArray>? = null,
         @ProtoId(9) @JvmField val uint32DownV6Port: List<Int>? = null
     ) : ProtoBuf
 
     @Serializable
-internal class MultiMsgApplyUpReq(
+    internal class MultiMsgApplyUpReq(
         @ProtoId(1) @JvmField val dstUin: Long = 0L,
         @ProtoId(2) @JvmField val msgSize: Long = 0L,
         @ProtoId(3) @JvmField val msgMd5: ByteArray = EMPTY_BYTE_ARRAY,
@@ -43,24 +43,24 @@ internal class MultiMsgApplyUpReq(
     ) : ProtoBuf
 
     @Serializable
-internal class MultiMsgApplyUpRsp(
+    internal class MultiMsgApplyUpRsp(
         @ProtoId(1) @JvmField val result: Int = 0,
         @ProtoId(2) @JvmField val msgResid: String = "",
         @ProtoId(3) @JvmField val msgUkey: ByteArray = EMPTY_BYTE_ARRAY,
-        @ProtoId(4) @JvmField val uint32UpIp: List<Int>,
-        @ProtoId(5) @JvmField val uint32UpPort: List<Int>,
+        @ProtoId(4) @JvmField val uint32UpIp: List<Int> = listOf(),
+        @ProtoId(5) @JvmField val uint32UpPort: List<Int> = listOf(),
         @ProtoId(6) @JvmField val blockSize: Long = 0L,
         @ProtoId(7) @JvmField val upOffset: Long = 0L,
         @ProtoId(8) @JvmField val applyId: Int = 0,
         @ProtoId(9) @JvmField val msgKey: ByteArray = EMPTY_BYTE_ARRAY,
         @ProtoId(10) @JvmField val msgSig: ByteArray = EMPTY_BYTE_ARRAY,
-        @ProtoId(11) @JvmField val msgExternInfo: MultiMsg.ExternMsg? = null,
+        @ProtoId(11) @JvmField val msgExternInfo: ExternMsg? = null,
         @ProtoId(12) @JvmField val bytesUpIpV6: List<ByteArray>? = null,
         @ProtoId(13) @JvmField val uint32UpV6Port: List<Int>? = null
     ) : ProtoBuf
 
     @Serializable
-internal class ReqBody(
+    internal class ReqBody(
         @ProtoId(1) @JvmField val subcmd: Int = 0,
         @ProtoId(2) @JvmField val termType: Int = 0,
         @ProtoId(3) @JvmField val platformType: Int = 0,
@@ -73,7 +73,7 @@ internal class ReqBody(
     ) : ProtoBuf
 
     @Serializable
-internal class RspBody(
+    internal class RspBody(
         @ProtoId(1) @JvmField val subcmd: Int = 0,
         @ProtoId(2) @JvmField val multimsgApplyupRsp: List<MultiMsg.MultiMsgApplyUpRsp>? = null,
         @ProtoId(3) @JvmField val multimsgApplydownRsp: List<MultiMsg.MultiMsgApplyDownRsp>? = null

+ 8 - 8
mirai-core-qqandroid/src/commonMain/kotlin/net/mamoe/mirai/qqandroid/network/protocol/packet/chat/MultiMsg.kt

@@ -12,7 +12,8 @@
 package net.mamoe.mirai.qqandroid.network.protocol.packet.chat
 
 import kotlinx.io.core.ByteReadPacket
-import net.mamoe.mirai.message.data.MessageChain
+import net.mamoe.mirai.message.data.ForwardMessage
+import net.mamoe.mirai.message.data.asMessageChain
 import net.mamoe.mirai.qqandroid.QQAndroidBot
 import net.mamoe.mirai.qqandroid.message.toRichTextElems
 import net.mamoe.mirai.qqandroid.network.Packet
@@ -42,12 +43,10 @@ internal class MessageValidationData @OptIn(MiraiInternalAPI::class) constructor
 }
 
 @OptIn(MiraiInternalAPI::class)
-internal fun Collection<MessageChain>.calculateValidationDataForGroup(
+internal fun Collection<ForwardMessage.INode>.calculateValidationDataForGroup(
     sequenceId: Int,
-    time: Int,
     random: UInt,
     groupCode: Long,
-    botId: Long,
     botMemberNameCard: String
 ): MessageValidationData {
 
@@ -55,9 +54,9 @@ internal fun Collection<MessageChain>.calculateValidationDataForGroup(
         msg = this.map { chain ->
             MsgComm.Msg(
                 msgHead = MsgComm.MsgHead(
-                    fromUin = botId,
+                    fromUin = chain.senderId,
                     msgSeq = sequenceId,
-                    msgTime = time,
+                    msgTime = chain.time,
                     msgUid = 0x01000000000000000L or random.toLong(),
                     mutiltransHead = MsgComm.MutilTransHead(
                         status = 0,
@@ -66,13 +65,14 @@ internal fun Collection<MessageChain>.calculateValidationDataForGroup(
                     msgType = 82, // troop
                     groupInfo = MsgComm.GroupInfo(
                         groupCode = groupCode,
-                        groupCard = botMemberNameCard // Cinnamon
+                        groupCard = chain.senderName // Cinnamon
                     ),
                     isSrcMsg = false
                 ),
                 msgBody = ImMsgBody.MsgBody(
                     richText = ImMsgBody.RichText(
-                        elems = chain.toRichTextElems(forGroup = true, withGeneralFlags = false).toMutableList()
+                        elems = chain.message.asMessageChain()
+                            .toRichTextElems(forGroup = true, withGeneralFlags = false).toMutableList()
                     )
                 )
             )

+ 525 - 14
mirai-core/src/commonMain/kotlin/net.mamoe.mirai/message/data/ForwardMessage.kt

@@ -7,42 +7,553 @@
  * https://github.com/mamoe/mirai/blob/master/LICENSE
  */
 
-@file:Suppress("MemberVisibilityCanBePrivate")
+@file:Suppress("MemberVisibilityCanBePrivate", "INVISIBLE_REFERENCE", "INVISIBLE_MEMBER", "unused")
 
 package net.mamoe.mirai.message.data
 
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.contact.*
+import net.mamoe.mirai.message.ContactMessage
+import net.mamoe.mirai.message.data.ForwardMessage.DisplayStrategy
 import net.mamoe.mirai.utils.MiraiExperimentalAPI
 import net.mamoe.mirai.utils.SinceMirai
+import net.mamoe.mirai.utils.currentTimeSeconds
+import kotlin.jvm.JvmOverloads
+import kotlin.jvm.JvmSynthetic
 
 
 /**
- * 合并转发
+ * 合并转发消息
+ *
+ * @param [displayStrategy] 卡片显示方案
+ *
+ * ### 显示方案
+ *
+ * #### 移动端
+ * 在移动客户端将会显示为卡片
+ *
+ * `<title>`: [DisplayStrategy.generateTitle]
+ *
+ * `<preview>`: [DisplayStrategy.generatePreview]
+ *
+ * `<summary>`: [DisplayStrategy.generateSummary]
+ *
+ * ```
+ * |-------------------------|
+ * | <title>                 |
+ * | <preview>               |
+ * |-------------------------|
+ * | <summary>               |
+ * |-------------------------|
+ * ```
+ *
+ * 默认显示方案:
+ * ```
+ * |-------------------------|
+ * | 群聊的聊天记录             |
+ * | <消息 1>                 |
+ * | <消息 2>                 |
+ * | <消息 3>                 |
+ * |-------------------------|
+ * | 查看 3 条转发消息          |
+ * |-------------------------|
+ * ```
+ *
+ * #### PC 端
+ * 在部分 PC 端显示为类似移动端的卡片, 在其他 PC 端显示为以下格式
+ * ```
+ * 鸽子 A 2020/04/23 11:27:54
+ * 咕
+ * 鸽子 B 2020/04/23 11:27:55
+ * 咕
+ * 鸽子 C 1970/01/01 08:00:00
+ * 咕咕咕
+ * ```
+ *
+ * ### 构造
+ * - 使用 [DSL][buildForwardMessage]
+ * - 通过 [ContactMessage] 集合转换: [toForwardMessage]
+ *
+ * @see buildForwardMessage
  */
 @SinceMirai("0.39.0")
-class ForwardMessage(
-    val messageList: Collection<MessageChain>
+class ForwardMessage @JvmOverloads constructor(
+    /**
+     * 消息列表
+     */
+    val nodeList: Collection<INode>,
+    val displayStrategy: DisplayStrategy = DisplayStrategy
 ) : MessageContent {
-    companion object Key : Message.Key<ForwardMessage> {
-        override val typeName: String get() = "ForwardMessage"
+    /**
+     * @see ForwardMessage
+     */
+    abstract class DisplayStrategy {
+        /**
+         * 修改后卡片标题会变为 "转发的聊天记录", 而此函数的返回值会显示在 preview 前
+         */
+        open fun generateTitle(forward: ForwardMessage): String = "群聊的聊天记录"
+
+        /**
+         * 显示在消息列表中的预览.
+         */
+        open fun generateBrief(forward: ForwardMessage): String = "[聊天记录]"
+
+        /**
+         * 目前未发现在哪能显示
+         */
+        open fun generateSource(forward: ForwardMessage): String = "聊天记录"
+
+        /**
+         * 显示在卡片 body 中, 只会显示 sequence 前四个元素.
+         * Java 用户: 使用 [sequenceOf] (`SequenceKt.sequenceOf`) 或 [asSequence] (`SequenceKt.asSequence`)
+         */
+        open fun generatePreview(forward: ForwardMessage): Sequence<String> =
+            forward.nodeList.asSequence().map { it.senderName + ": " + it.message.contentToString() }
+
+        /**
+         * 显示在卡片底部
+         */
+        open fun generateSummary(forward: ForwardMessage): String = "查看 ${forward.nodeList.size} 条转发消息"
+
+        companion object Default : DisplayStrategy() {
+            @JvmSynthetic
+            inline operator fun invoke(
+                crossinline generateTitle: (forward: ForwardMessage) -> String = Default::generateTitle,
+                crossinline generateBrief: (forward: ForwardMessage) -> String = Default::generateBrief,
+                crossinline generateSource: (forward: ForwardMessage) -> String = Default::generateSource,
+                crossinline generatePreview: (forward: ForwardMessage) -> Sequence<String> = Default::generatePreview,
+                crossinline generateSummary: (forward: ForwardMessage) -> String = Default::generateSummary
+            ): DisplayStrategy = object : DisplayStrategy() {
+                override fun generateTitle(forward: ForwardMessage): String = generateTitle(forward)
+                override fun generateBrief(forward: ForwardMessage): String = generateBrief(forward)
+                override fun generateSource(forward: ForwardMessage): String = generateSource(forward)
+                override fun generatePreview(forward: ForwardMessage): Sequence<String> = generatePreview(forward)
+                override fun generateSummary(forward: ForwardMessage): String = generateSummary(forward)
+            }
+        }
     }
 
-    override fun toString(): String = "[mirai:forward:$messageList]"
 
+    data class Node(
+        override val senderId: Long,
+        override val time: Int,
+        override val senderName: String,
+        override val message: Message
+    ) : INode
+
+    interface INode {
+        /**
+         * 发送人 [User.id]
+         */
+        val senderId: Long
+
+        /**
+         * 时间戳秒
+         */
+        val time: Int
 
-    private val contentToString: String by lazy {
-        messageList.joinToString("\n")
+        /**
+         * 发送人名称
+         */
+        val senderName: String
+
+        /**
+         * 消息内容
+         */
+        val message: Message
     }
 
-    @MiraiExperimentalAPI
-    override fun contentToString(): String = contentToString
+    companion object Key : Message.Key<ForwardMessage> {
+        override val typeName: String get() = "ForwardMessage"
+    }
 
-    override val length: Int
-        get() = contentToString.length
+    override fun toString(): String = "[mirai:forward:$nodeList]"
+    private val contentToString: String by lazy { nodeList.joinToString("\n") }
 
+    @MiraiExperimentalAPI
+    override fun contentToString(): String = contentToString
+    override val length: Int get() = contentToString.length
     override fun get(index: Int): Char = contentToString[length]
-
     override fun subSequence(startIndex: Int, endIndex: Int): CharSequence =
         contentToString.subSequence(startIndex, endIndex)
 
     override fun compareTo(other: String): Int = contentToString.compareTo(other)
+}
+
+
+/**
+ * 转换为 [ForwardMessage]
+ */
+@SinceMirai("0.39.0")
+@JvmOverloads
+fun Iterable<ContactMessage>.toForwardMessage(displayStrategy: DisplayStrategy = DisplayStrategy): ForwardMessage {
+    val iterator = this.iterator()
+    if (!iterator.hasNext()) return ForwardMessage(emptyList(), displayStrategy)
+    return ForwardMessage(
+        this.map { ForwardMessage.Node(it.sender.id, it.time, it.senderName, it.message) }, displayStrategy)
+}
+
+/**
+ * 转换为 [ForwardMessage]
+ */
+fun Message.toForwardMessage(
+    sender: User,
+    time: Int = currentTimeSeconds.toInt(),
+    displayStrategy: DisplayStrategy = DisplayStrategy
+): ForwardMessage = this.toForwardMessage(sender.id, sender.nameCardOrNick, time, displayStrategy)
+
+/**
+ * 转换为 [ForwardMessage]
+ */
+@SinceMirai("0.39.0")
+@JvmOverloads
+fun Message.toForwardMessage(
+    senderId: Long,
+    senderName: String,
+    time: Int = currentTimeSeconds.toInt(),
+    displayStrategy: DisplayStrategy = DisplayStrategy
+): ForwardMessage = ForwardMessage(listOf(ForwardMessage.Node(senderId, time, senderName, this)), displayStrategy)
+
+/**
+ * 构造一条 [ForwardMessage]
+ *
+ * @see ForwardMessageBuilder 查看 DSL 帮助
+ * @see ForwardMessage 查看转发消息说明
+ */
+@SinceMirai("0.39.0")
+@JvmSynthetic
+inline fun buildForwardMessage(
+    context: Contact,
+    displayStrategy: DisplayStrategy = DisplayStrategy,
+    block: ForwardMessageBuilder.() -> Unit
+): ForwardMessage = ForwardMessageBuilder(context).apply { this.displayStrategy = displayStrategy }.apply(block).build()
+
+/**
+ * 使用 DSL 构建一个 [ForwardMessage].
+ *
+ * @see ForwardMessageBuilder 查看 DSL 帮助
+ * @see ForwardMessage 查看转发消息说明
+ */
+@SinceMirai("0.39.0")
+@JvmSynthetic
+inline fun ContactMessage.buildForwardMessage(
+    context: Contact = this.subject,
+    displayStrategy: DisplayStrategy = DisplayStrategy,
+    block: ForwardMessageBuilder.() -> Unit
+): ForwardMessage = ForwardMessageBuilder(context).apply {
+    this.displayStrategy = displayStrategy
+}.apply(block).build()
+
+/**
+ * 标记转发消息 DSL
+ */
+@SinceMirai("0.39.0")
+@Target(AnnotationTarget.FUNCTION, AnnotationTarget.TYPE)
+@DslMarker
+annotation class ForwardMessageDsl
+
+/**
+ * 转发消息 DSL 构建器.
+ *
+ * # 总览
+ *
+ * 使用 DSL 构造一个转发:
+ * ```
+ * buildForwardMessage {
+ *     123456789 named "鸽子 A" says "咕" // 意为 名为 "鸽子 A" 的用户 123456789 发送了一条内容为 "咕" 的消息
+ *     100200300 named "鸽子 C" at 1582315452 says "咕咕咕" // at 设置时间 (在 PC 端显示, 在手机端不影响顺序)
+ *     987654321 named "鸽子 B" says "咕" // 未指定时间, 则自动顺序安排时间
+ *     5555555 says "咕" // 未指定发送人
+ *     bot says { // 构造消息链, 同 `buildMessageChain`
+ *         +"发个图片试试"
+ *         +Image("{90CCED1C-2D64-313B-5D66-46625CAB31D7}.jpg")
+ *     }
+ *     val member: Member = ...
+ *     member says "我是幸运群员" // 使用 `User says` 则会同时设置发送人名称
+ * }
+ * ```
+ *
+ * # 语法
+ *
+ * 下文中 `S` 代表消息发送人. 可接受: 发送人账号 id([Long] 或 [Int]) 或 [User]
+ * 下文中 `M` 代表消息内容. 可接受: [String], [Message], 或 [构造消息链][MessageChainBuilder] 的 DSL 代码块
+ *
+ * ## 陈述一条消息
+ * 使用 [`infix fun S.says(M)`][ForwardMessageBuilder.says]
+ *
+ * 语句 `123456789 named "鸽子 A" says "咕"` 创建并添加了一条名为 "鸽子 A" 的用户 123456789 发送的内容为 "咕" 的消息
+ *
+ *
+ * ### 陈述
+ * 一条 '陈述' 必须包含以下属性:
+ * - 发送人. 只可以作为 infix 函数的接收者 (receiver) 设置, 如 `sender says M`, `sender named "xxx"`, `sender at 123`
+ * - 消息内容. 只可以通过 `says` 函数的参数设置, 即 `says M`.
+ *
+ * ### 组合陈述
+ * 现支持的可选属性为 `named`, `at`
+ *
+ *
+ * 最基础的陈述为 `S says M`. 可在 `says` 前按任意顺序添加组合属性:
+ *
+ * `S named "xxx" says M`;
+ *
+ * `S at 123456 says M`; 其中 `123456` 为发信时间
+ *
+ *
+ * 属性的顺序并不重要. 如下两句陈述效果相同.
+ *
+ * `S named "xxx" at 123456 says M`;
+ *
+ * `S at 123456 named "xxx" says M`;
+ *
+ * ### 重复属性
+ * 若属性有重复, **新属性会替换旧属性**.
+ *
+ * `S named "name1" named "name2" says M` 最终的发送人名称为 `"name2"`
+ */
+@SinceMirai("0.39.0")
+class ForwardMessageBuilder private constructor(
+    /**
+     * 消息语境. 可为 [Group] 或 [User]
+     */
+    val context: Contact,
+    private val container: MutableList<ForwardMessage.INode>
+) : MutableList<ForwardMessage.INode> by container {
+    /**
+     * @see ForwardMessage.displayStrategy
+     */
+    var displayStrategy: DisplayStrategy = DisplayStrategy
+
+    private var built: Boolean = false
+    private fun checkBuilt() {
+        check(!built) { "ForwardMessageBuilder is already built therefore can't be modified" }
+    }
+
+    constructor(context: Contact) : this(context, mutableListOf())
+    constructor(context: Contact, initialSize: Int) : this(context, ArrayList<ForwardMessage.INode>(initialSize))
+
+    /**
+     * 当前时间.
+     * 在使用 [says] 时若不指定之间, 则会使用 [currentTime] 自增 1 的事件.
+     */
+    var currentTime: Int = currentTimeSeconds.toInt()
+
+    inner class BuilderNode : ForwardMessage.INode {
+
+        /**
+         * 发送人 [User.id]
+         */
+        override var senderId: Long = 0
+
+        /**
+         * 时间戳秒
+         */
+        override var time: Int = currentTime++
+
+        /**
+         * 发送人名称
+         */
+        override var senderName: String = ""
+
+        /**
+         * 消息内容
+         */
+        override lateinit var message: Message
+
+
+        /**
+         * 指定发送人 id 和名称.
+         */
+        @ForwardMessageDsl
+        infix fun sender(user: User): BuilderNode = apply { this.senderId(user.id); this.named(user.nameCardOrNick) }
+
+        /**
+         * 指定发送人 id.
+         */
+        @ForwardMessageDsl
+        infix fun senderId(id: Int): BuilderNode = apply { this.senderId = id.toLongUnsigned() }
+
+        /**
+         * 指定发送人 id.
+         */
+        @ForwardMessageDsl
+        infix fun senderId(id: Long): BuilderNode = apply { this.senderId = id }
+
+        /**
+         * 指定发送人名称.
+         */
+        @ForwardMessageDsl
+        infix fun named(name: String): BuilderNode = apply { this.senderName = name }
+
+        /**
+         * 指定发送人名称.
+         */
+        @ForwardMessageDsl
+        infix fun senderName(name: String): BuilderNode = apply { this.senderName = name }
+
+        /**
+         * 指定时间.
+         * @time 时间戳, 单位为秒
+         */
+        @ForwardMessageDsl
+        infix fun at(time: Int): BuilderNode = this.apply { this.time = time }
+
+        /**
+         * 指定时间.
+         * @time 时间戳, 单位为秒
+         */
+        @ForwardMessageDsl
+        infix fun time(time: Int): BuilderNode = this.apply { this.time = time }
+
+        /**
+         * 指定消息内容
+         */
+        @ForwardMessageDsl
+        infix fun message(message: Message): BuilderNode = this.apply { this.message = message }
+
+        /**
+         * 指定消息内容
+         */
+        @ForwardMessageDsl
+        infix fun message(message: String): BuilderNode = this.apply { this.message = message.toMessage() }
+
+        /** 添加一条消息  */
+        @ForwardMessageDsl
+        infix fun says(message: Message): ForwardMessageBuilder = [email protected] {
+            checkBuilt()
+            [email protected] = message
+            add(this@BuilderNode)
+        }
+
+        /** 添加一条消息  */
+        @ForwardMessageDsl
+        infix fun says(message: String): ForwardMessageBuilder = this.says(message.toMessage())
+
+        /** 构造并添加一个 [MessageChain] */
+        @ForwardMessageDsl
+        inline infix fun says(chain: @ForwardMessageDsl MessageChainBuilder.() -> Unit): ForwardMessageBuilder =
+            says(MessageChainBuilder().apply(chain).asMessageChain())
+    }
+
+    // region general `says`
+
+    /** 添加一条消息, 自动按顺序调整时间  */
+    @ForwardMessageDsl
+    infix fun Long.says(message: String): ForwardMessageBuilder = says(message.toMessage())
+
+    /** 添加一条消息, 自动按顺序调整时间  */
+    @ForwardMessageDsl
+    infix fun Int.says(message: String): ForwardMessageBuilder =
+        this.toLong().and(0xFFFF_FFFF).says(message.toMessage())
+
+    /** 添加一条消息, 自动按顺序调整时间 */
+    @ForwardMessageDsl
+    infix fun Long.says(message: Message): ForwardMessageBuilder =
+        [email protected] {
+            checkBuilt()
+            add(BuilderNode().apply {
+                senderId = this@says
+                this.message = message
+            })
+        }
+
+    /** 添加一条消息, 自动按顺序调整时间  */
+    @ForwardMessageDsl
+    infix fun Int.says(message: Message): ForwardMessageBuilder = this.toLong().and(0xFFFF_FFFF).says(message)
+
+    /** 构造并添加一个 [MessageChain], 自动按顺序调整时间 */
+    @ForwardMessageDsl
+    inline infix fun Long.says(chain: @ForwardMessageDsl MessageChainBuilder.() -> Unit): ForwardMessageBuilder =
+        says(MessageChainBuilder().apply(chain).asMessageChain())
+
+    /** 添加一条消息, 自动按顺序调整时间  */
+    @ForwardMessageDsl
+    inline infix fun Int.says(chain: @ForwardMessageDsl MessageChainBuilder.() -> Unit): ForwardMessageBuilder =
+        this.toLong().and(0xFFFF_FFFF).says(chain)
+
+
+    /** 添加一条消息, 自动按顺序调整时间 */
+    @ForwardMessageDsl
+    infix fun Bot.says(message: String): ForwardMessageBuilder = this.id named this.smartName() says message
+
+    /** 添加一条消息, 自动按顺序调整时间 */
+    @ForwardMessageDsl
+    infix fun User.says(message: String): ForwardMessageBuilder = this.id named this.nameCardOrNick says message
+
+    /** 添加一条消息, 自动按顺序调整时间 */
+    @ForwardMessageDsl
+    infix fun User.says(message: Message): ForwardMessageBuilder = this.id named this.nameCardOrNick says message
+
+    /** 添加一条消息, 自动按顺序调整时间 */
+    @ForwardMessageDsl
+    infix fun Bot.says(message: Message): ForwardMessageBuilder = this.id named this.smartName() says message
+
+    /** 构造并添加一个 [MessageChain], 自动按顺序调整时间 */
+    @ForwardMessageDsl
+    inline infix fun User.says(chain: @ForwardMessageDsl MessageChainBuilder.() -> Unit): ForwardMessageBuilder =
+        this says (MessageChainBuilder().apply(chain).asMessageChain())
+
+    /** 构造并添加一个 [MessageChain], 自动按顺序调整时间 */
+    @ForwardMessageDsl
+    inline infix fun Bot.says(chain: @ForwardMessageDsl MessageChainBuilder.() -> Unit): ForwardMessageBuilder =
+        this says (MessageChainBuilder().apply(chain).asMessageChain())
+
+    // endregion
+
+
+    // region timed
+
+    /**
+     * 为一条消息指定时间.
+     * @time 时间戳, 单位为秒
+     */
+    @ForwardMessageDsl
+    infix fun Int.at(time: Int): BuilderNode = this.toLongUnsigned() at time
+
+    /**
+     * 为一条消息指定时间.
+     * @time 时间戳, 单位为秒
+     */
+    @ForwardMessageDsl
+    infix fun Long.at(time: Int): BuilderNode = BuilderNode().apply { senderId = this@at;this.time = time }
+
+    /**
+     * 为一条消息指定时间和发送人名称.
+     * @time 时间戳, 单位为秒
+     */
+    @ForwardMessageDsl
+    infix fun User.at(time: Int): BuilderNode = this.id named this.nameCardOrNick at time
+
+    // endregion
+
+
+    // region named
+
+    /** 为一条消息指定发送人名称. */
+    @ForwardMessageDsl
+    infix fun Int.named(name: String): BuilderNode = this.toLongUnsigned().named(name)
+
+    /** 为一条消息指定发送人名称. */
+    @ForwardMessageDsl
+    infix fun Long.named(name: String): BuilderNode =
+        BuilderNode().apply { senderId = this@named;this.senderName = name }
+
+    /** 为一条消息指定发送人名称. */
+    @ForwardMessageDsl
+    infix fun User.named(name: String): BuilderNode = this.id.named(name)
+
+    // endregion
+
+    /** 构造 [ForwardMessage] */
+    fun build(): ForwardMessage = ForwardMessage(container.toList(), this.displayStrategy)
+
+
+    @Suppress("NOTHING_TO_INLINE")
+    private inline fun Int.toLongUnsigned(): Long = this.toLong().and(0xFFFF_FFFF)
+
+    @OptIn(MiraiExperimentalAPI::class)
+    internal fun Bot.smartName(): String = when (val c = [email protected]) {
+        is Group -> c.botAsMember.nameCardOrNick
+        else -> nick
+    }
 }

+ 1 - 0
mirai-core/src/commonMain/kotlin/net.mamoe.mirai/message/data/Message.kt

@@ -358,6 +358,7 @@ interface ConstrainSingle<out M : Message> : MessageMetadata {
  * @see Image 图片
  * @see RichMessage 富文本
  * @see Face 原生表情
+ * @see ForwardMessage 合并转发
  */
 interface MessageContent : SingleMessage
 

+ 4 - 1
mirai-core/src/commonMain/kotlin/net.mamoe.mirai/message/data/RichMessage.kt

@@ -13,6 +13,7 @@
 
 package net.mamoe.mirai.message.data
 
+import net.mamoe.mirai.contact.Contact
 import net.mamoe.mirai.utils.MiraiExperimentalAPI
 import net.mamoe.mirai.utils.SinceMirai
 import kotlin.annotation.AnnotationTarget.*
@@ -151,6 +152,8 @@ constructor(serviceId: Int = 60, content: String) : ServiceMessage(serviceId, co
 
 /**
  * 长消息.
+ *
+ * 不需要手动区分长消息和普通消息, 在 [Contact.sendMessage] 时会自动判断.
  */
 @SinceMirai("0.31.0")
 @MiraiExperimentalAPI
@@ -164,8 +167,8 @@ class LongMessage internal constructor(content: String, val resId: String) : Ser
  * 合并转发消息
  * @suppress 此 API 非常不稳定
  */
+@OptIn(MiraiExperimentalAPI::class)
 @SinceMirai("0.39.0")
-@MiraiExperimentalAPI("此 API 非常不稳定")
 internal class ForwardMessageInternal(content: String) : ServiceMessage(35, content)
 
 /*