Browse Source

feature: add `ShortVideo` message support (#2739)

* initial support for ShortVideo message

* dump api

* [core] upload protocol

* [core] short video upload event

* [core] doc

* [core] protocol

* [core] fix mp4 file check

* [core] extract fileName from `OnlineShortVideo` to `ShortVideo`

* [core] ShortVideo.Builder

* [core] mirai code support for `ShortVideo`

* [core] add doc for OnlineShortVideo and OfflineShortVideo

* [core] fix text

* dump api

* update `Contact.uploadShortVideo`·` doc

* [core] remove mirai code support for ShortVideo

* [core] ensure Mirai service is loaded before load other services

* [core] introduce `CombinedExternalResource` to reference multiple external resources for combined calculation.

* [core] move refine context key defined in `OnlineShortVideoMsgInternal` to `RefineContext`

* [core] remove data class

* [core] broadcast `ShortVideoUploadEvent.Failed` event

* [core] warn when cannot determine fromId

* [core] add `contentToString` and `toString` for `OnlineShortVideoMsgInternal`

* [core] optimize imports

* [core] import

* [core] revert

* [core] doc

* [core] auto close resource

* dump api

* keep consistence of param name

* update doc

* move Builder to OfflineShortVideo

* optimize RefineContext

* RefineContext.merge

* dump api

* fix test

* show more video info

* optimize constructor and builder of offline short video

* optimize thumbnail

* move thumbnail to main constructor arg

* dump api

* avoid null cast exception.

* combine format transition

* cleanup
StageGuard 2 years ago
parent
commit
5b3e508b75
38 changed files with 1893 additions and 39 deletions
  1. 96 0
      mirai-core-api/compatibility-validation/android/api/android.api
  2. 96 0
      mirai-core-api/compatibility-validation/jvm/api/jvm.api
  3. 20 2
      mirai-core-api/src/commonMain/kotlin/contact/Contact.kt
  4. 93 0
      mirai-core-api/src/commonMain/kotlin/event/events/ShortVideoUploadEvent.kt
  5. 1 0
      mirai-core-api/src/commonMain/kotlin/message/data/Image.kt
  6. 250 0
      mirai-core-api/src/commonMain/kotlin/message/data/ShortVideo.kt
  7. 6 0
      mirai-core-api/src/commonMain/kotlin/message/data/visitor/MessageVisitor.kt
  8. 3 1
      mirai-core-api/src/commonMain/kotlin/utils/ExternalResource.kt
  9. 9 4
      mirai-core-mock/src/internal/contact/AbstractMockContact.kt
  10. 21 13
      mirai-core-utils/src/commonMain/kotlin/Files.kt
  11. 1 3
      mirai-core/src/commonMain/kotlin/MiraiImpl.kt
  12. 129 1
      mirai-core/src/commonMain/kotlin/contact/AbstractContact.kt
  13. 14 1
      mirai-core/src/commonMain/kotlin/contact/roaming/RoamingMessagesImplGroup.kt
  14. 9 2
      mirai-core/src/commonMain/kotlin/contact/roaming/TimeBasedRoamingMessagesImpl.kt
  15. 32 5
      mirai-core/src/commonMain/kotlin/message/ReceiveMessageHandler.kt
  16. 43 0
      mirai-core/src/commonMain/kotlin/message/RefinableMessage.kt
  17. 243 0
      mirai-core/src/commonMain/kotlin/message/data/shortVideo.kt
  18. 41 0
      mirai-core/src/commonMain/kotlin/message/image/InternalShortVideoProtocolImpl.kt
  19. 5 1
      mirai-core/src/commonMain/kotlin/message/protocol/MessageProtocolFacade.kt
  20. 1 0
      mirai-core/src/commonMain/kotlin/message/protocol/decode/MessageDecoderPipeline.kt
  21. 98 0
      mirai-core/src/commonMain/kotlin/message/protocol/impl/ShortVideoProtocol.kt
  22. 7 1
      mirai-core/src/commonMain/kotlin/message/source/offlineSourceImpl.kt
  23. 2 0
      mirai-core/src/commonMain/kotlin/network/highway/Highway.kt
  24. 26 2
      mirai-core/src/commonMain/kotlin/network/notice/group/GroupMessageProcessor.kt
  25. 14 1
      mirai-core/src/commonMain/kotlin/network/notice/priv/PrivateMessageProcessor.kt
  26. 237 0
      mirai-core/src/commonMain/kotlin/network/protocol/data/proto/PttShortVideo.kt
  27. 3 0
      mirai-core/src/commonMain/kotlin/network/protocol/packet/PacketFactory.kt
  28. 187 0
      mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/shortvideo/PttCenterSvr.kt
  29. 15 0
      mirai-core/src/commonMain/kotlin/utils/ExternalResourceImpl.kt
  30. 4 0
      mirai-core/src/commonMain/kotlin/utils/MiraiCoreServices.kt
  31. 1 0
      mirai-core/src/commonMain/resources/META-INF/services/net.mamoe.mirai.internal.message.protocol.MessageProtocol
  32. 10 0
      mirai-core/src/commonMain/resources/META-INF/services/net.mamoe.mirai.message.data.InternalShortVideoProtocol
  33. 55 0
      mirai-core/src/commonTest/kotlin/message/RefineContextTest.kt
  34. 8 1
      mirai-core/src/commonTest/kotlin/message/data/MessageRefineTest.kt
  35. 1 0
      mirai-core/src/commonTest/kotlin/message/protocol/MessageProtocolFacadeTest.kt
  36. 8 1
      mirai-core/src/commonTest/kotlin/message/protocol/impl/AbstractMessageProtocolTest.kt
  37. 59 0
      mirai-core/src/jvmBaseMain/kotlin/utils/ExternalResourceImpl.kt
  38. 45 0
      mirai-core/src/jvmBaseTest/kotlin/utils/CombinedExternalResourceTest.kt

+ 96 - 0
mirai-core-api/compatibility-validation/android/api/android.api

@@ -366,6 +366,10 @@ public abstract interface class net/mamoe/mirai/contact/Contact : kotlinx/corout
 	public static fun uploadImage (Lnet/mamoe/mirai/contact/Contact;Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public fun uploadImage (Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/message/data/Image;
 	public abstract fun uploadImage (Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun uploadShortVideo (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ExternalResource;Ljava/lang/String;)Lnet/mamoe/mirai/message/data/ShortVideo;
+	public abstract fun uploadShortVideo (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ExternalResource;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static synthetic fun uploadShortVideo$default (Lnet/mamoe/mirai/contact/Contact;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ExternalResource;Ljava/lang/String;ILjava/lang/Object;)Lnet/mamoe/mirai/message/data/ShortVideo;
+	public static synthetic fun uploadShortVideo$default (Lnet/mamoe/mirai/contact/Contact;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ExternalResource;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
 }
 
 public final class net/mamoe/mirai/contact/Contact$Companion {
@@ -1888,6 +1892,13 @@ public final class net/mamoe/mirai/event/events/BeforeImageUploadEvent : net/mam
 	public fun toString ()Ljava/lang/String;
 }
 
+public final class net/mamoe/mirai/event/events/BeforeShortVideoUploadEvent : net/mamoe/mirai/event/AbstractEvent, net/mamoe/mirai/event/CancellableEvent, net/mamoe/mirai/event/events/BotActiveEvent, net/mamoe/mirai/event/events/BotEvent, net/mamoe/mirai/internal/event/VerboseEvent {
+	public fun getBot ()Lnet/mamoe/mirai/Bot;
+	public final fun getTarget ()Lnet/mamoe/mirai/contact/Contact;
+	public final fun getThumbnailSource ()Lnet/mamoe/mirai/utils/ExternalResource;
+	public final fun getVideoSource ()Lnet/mamoe/mirai/utils/ExternalResource;
+}
+
 public abstract interface class net/mamoe/mirai/event/events/BotActiveEvent : net/mamoe/mirai/event/events/BotEvent {
 }
 
@@ -2948,6 +2959,30 @@ public final class net/mamoe/mirai/event/events/OtherClientOnlineEvent : net/mam
 	public fun toString ()Ljava/lang/String;
 }
 
+public abstract class net/mamoe/mirai/event/events/ShortVideoUploadEvent : net/mamoe/mirai/event/AbstractEvent, net/mamoe/mirai/event/events/BotActiveEvent, net/mamoe/mirai/event/events/BotEvent, net/mamoe/mirai/internal/event/VerboseEvent {
+	public fun getBot ()Lnet/mamoe/mirai/Bot;
+	public abstract fun getTarget ()Lnet/mamoe/mirai/contact/Contact;
+	public abstract fun getThumbnailSource ()Lnet/mamoe/mirai/utils/ExternalResource;
+	public abstract fun getVideoSource ()Lnet/mamoe/mirai/utils/ExternalResource;
+}
+
+public final class net/mamoe/mirai/event/events/ShortVideoUploadEvent$Failed : net/mamoe/mirai/event/events/ShortVideoUploadEvent {
+	public final fun getErrno ()I
+	public final fun getMessage ()Ljava/lang/String;
+	public fun getTarget ()Lnet/mamoe/mirai/contact/Contact;
+	public fun getThumbnailSource ()Lnet/mamoe/mirai/utils/ExternalResource;
+	public fun getVideoSource ()Lnet/mamoe/mirai/utils/ExternalResource;
+	public fun toString ()Ljava/lang/String;
+}
+
+public final class net/mamoe/mirai/event/events/ShortVideoUploadEvent$Succeed : net/mamoe/mirai/event/events/ShortVideoUploadEvent {
+	public fun getTarget ()Lnet/mamoe/mirai/contact/Contact;
+	public fun getThumbnailSource ()Lnet/mamoe/mirai/utils/ExternalResource;
+	public final fun getVideo ()Lnet/mamoe/mirai/message/data/ShortVideo;
+	public fun getVideoSource ()Lnet/mamoe/mirai/utils/ExternalResource;
+	public fun toString ()Ljava/lang/String;
+}
+
 public final class net/mamoe/mirai/event/events/SignEvent : net/mamoe/mirai/event/AbstractEvent, net/mamoe/mirai/event/events/BotEvent, net/mamoe/mirai/internal/network/Packet {
 	public fun getBot ()Lnet/mamoe/mirai/Bot;
 	public final fun getRank ()Ljava/lang/Integer;
@@ -4830,6 +4865,39 @@ public abstract class net/mamoe/mirai/message/data/OfflineMessageSource : net/ma
 public final class net/mamoe/mirai/message/data/OfflineMessageSource$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
 }
 
+public abstract interface class net/mamoe/mirai/message/data/OfflineShortVideo : net/mamoe/mirai/message/data/ShortVideo {
+	public static final field Key Lnet/mamoe/mirai/message/data/OfflineShortVideo$Key;
+	public static final field SERIAL_NAME Ljava/lang/String;
+}
+
+public final class net/mamoe/mirai/message/data/OfflineShortVideo$Builder {
+	public static final field Companion Lnet/mamoe/mirai/message/data/OfflineShortVideo$Builder$Companion;
+	public final fun build ()Lnet/mamoe/mirai/message/data/OfflineShortVideo;
+	public final fun getFileFormat ()Ljava/lang/String;
+	public final fun getFileMd5 ()[B
+	public final fun getFileName ()Ljava/lang/String;
+	public final fun getFileSize ()J
+	public final fun getThumbnailMd5 ()[B
+	public final fun getThumbnailSize ()J
+	public final fun getVideoId ()Ljava/lang/String;
+	public static final fun newBuilder (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[BJ)Lnet/mamoe/mirai/message/data/OfflineShortVideo$Builder;
+	public final fun setFileFormat (Ljava/lang/String;)V
+	public final fun setFileMd5 ([B)V
+	public final fun setFileName (Ljava/lang/String;)V
+	public final fun setFileSize (J)V
+	public final fun setThumbnailMd5 ([B)V
+	public final fun setThumbnailSize (J)V
+	public final fun setVideoId (Ljava/lang/String;)V
+}
+
+public final class net/mamoe/mirai/message/data/OfflineShortVideo$Builder$Companion {
+	public final fun newBuilder (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[BJ)Lnet/mamoe/mirai/message/data/OfflineShortVideo$Builder;
+}
+
+public final class net/mamoe/mirai/message/data/OfflineShortVideo$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
+	public static final field SERIAL_NAME Ljava/lang/String;
+}
+
 public abstract interface class net/mamoe/mirai/message/data/OnlineAudio : net/mamoe/mirai/message/data/Audio {
 	public static final field Key Lnet/mamoe/mirai/message/data/OnlineAudio$Key;
 	public static final field SERIAL_NAME Ljava/lang/String;
@@ -4978,6 +5046,16 @@ public abstract class net/mamoe/mirai/message/data/OnlineMessageSource$Outgoing$
 public final class net/mamoe/mirai/message/data/OnlineMessageSource$Outgoing$ToTemp$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
 }
 
+public abstract interface class net/mamoe/mirai/message/data/OnlineShortVideo : net/mamoe/mirai/message/data/ShortVideo {
+	public static final field Key Lnet/mamoe/mirai/message/data/OnlineShortVideo$Key;
+	public static final field SERIAL_NAME Ljava/lang/String;
+	public abstract fun getUrlForDownload ()Ljava/lang/String;
+}
+
+public final class net/mamoe/mirai/message/data/OnlineShortVideo$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
+	public static final field SERIAL_NAME Ljava/lang/String;
+}
+
 public final class net/mamoe/mirai/message/data/OrNullDelegate {
 	public static final synthetic fun box-impl (Ljava/lang/Object;)Lnet/mamoe/mirai/message/data/OrNullDelegate;
 	public static fun constructor-impl (Ljava/lang/Object;)Ljava/lang/Object;
@@ -5223,6 +5301,24 @@ public abstract interface class net/mamoe/mirai/message/data/ServiceMessage : ne
 public final class net/mamoe/mirai/message/data/ServiceMessage$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
 }
 
+public abstract interface class net/mamoe/mirai/message/data/ShortVideo : net/mamoe/mirai/message/data/ConstrainSingle, net/mamoe/mirai/message/data/MessageContent {
+	public static final field Key Lnet/mamoe/mirai/message/data/ShortVideo$Key;
+	public abstract fun getFileFormat ()Ljava/lang/String;
+	public abstract fun getFileMd5 ()[B
+	public abstract fun getFileSize ()J
+	public abstract fun getFilename ()Ljava/lang/String;
+	public fun getKey ()Lnet/mamoe/mirai/message/data/MessageKey;
+	public abstract fun getVideoId ()Ljava/lang/String;
+}
+
+public final class net/mamoe/mirai/message/data/ShortVideo$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
+}
+
+public final class net/mamoe/mirai/message/data/ShortVideoKt {
+	public static final synthetic fun OfflineShortVideo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[BJ[BJ)Lnet/mamoe/mirai/message/data/OfflineShortVideo;
+	public static synthetic fun OfflineShortVideo$default (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[BJ[BJILjava/lang/Object;)Lnet/mamoe/mirai/message/data/OfflineShortVideo;
+}
+
 public final class net/mamoe/mirai/message/data/ShowImageFlag : net/mamoe/mirai/message/data/AbstractMessageKey, net/mamoe/mirai/message/data/ConstrainSingle, net/mamoe/mirai/message/data/MessageMetadata {
 	public static final field INSTANCE Lnet/mamoe/mirai/message/data/ShowImageFlag;
 	public static final field SERIAL_NAME Ljava/lang/String;

+ 96 - 0
mirai-core-api/compatibility-validation/jvm/api/jvm.api

@@ -366,6 +366,10 @@ public abstract interface class net/mamoe/mirai/contact/Contact : kotlinx/corout
 	public static fun uploadImage (Lnet/mamoe/mirai/contact/Contact;Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public fun uploadImage (Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/message/data/Image;
 	public abstract fun uploadImage (Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun uploadShortVideo (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ExternalResource;Ljava/lang/String;)Lnet/mamoe/mirai/message/data/ShortVideo;
+	public abstract fun uploadShortVideo (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ExternalResource;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static synthetic fun uploadShortVideo$default (Lnet/mamoe/mirai/contact/Contact;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ExternalResource;Ljava/lang/String;ILjava/lang/Object;)Lnet/mamoe/mirai/message/data/ShortVideo;
+	public static synthetic fun uploadShortVideo$default (Lnet/mamoe/mirai/contact/Contact;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ExternalResource;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
 }
 
 public final class net/mamoe/mirai/contact/Contact$Companion {
@@ -1888,6 +1892,13 @@ public final class net/mamoe/mirai/event/events/BeforeImageUploadEvent : net/mam
 	public fun toString ()Ljava/lang/String;
 }
 
+public final class net/mamoe/mirai/event/events/BeforeShortVideoUploadEvent : net/mamoe/mirai/event/AbstractEvent, net/mamoe/mirai/event/CancellableEvent, net/mamoe/mirai/event/events/BotActiveEvent, net/mamoe/mirai/event/events/BotEvent, net/mamoe/mirai/internal/event/VerboseEvent {
+	public fun getBot ()Lnet/mamoe/mirai/Bot;
+	public final fun getTarget ()Lnet/mamoe/mirai/contact/Contact;
+	public final fun getThumbnailSource ()Lnet/mamoe/mirai/utils/ExternalResource;
+	public final fun getVideoSource ()Lnet/mamoe/mirai/utils/ExternalResource;
+}
+
 public abstract interface class net/mamoe/mirai/event/events/BotActiveEvent : net/mamoe/mirai/event/events/BotEvent {
 }
 
@@ -2948,6 +2959,30 @@ public final class net/mamoe/mirai/event/events/OtherClientOnlineEvent : net/mam
 	public fun toString ()Ljava/lang/String;
 }
 
+public abstract class net/mamoe/mirai/event/events/ShortVideoUploadEvent : net/mamoe/mirai/event/AbstractEvent, net/mamoe/mirai/event/events/BotActiveEvent, net/mamoe/mirai/event/events/BotEvent, net/mamoe/mirai/internal/event/VerboseEvent {
+	public fun getBot ()Lnet/mamoe/mirai/Bot;
+	public abstract fun getTarget ()Lnet/mamoe/mirai/contact/Contact;
+	public abstract fun getThumbnailSource ()Lnet/mamoe/mirai/utils/ExternalResource;
+	public abstract fun getVideoSource ()Lnet/mamoe/mirai/utils/ExternalResource;
+}
+
+public final class net/mamoe/mirai/event/events/ShortVideoUploadEvent$Failed : net/mamoe/mirai/event/events/ShortVideoUploadEvent {
+	public final fun getErrno ()I
+	public final fun getMessage ()Ljava/lang/String;
+	public fun getTarget ()Lnet/mamoe/mirai/contact/Contact;
+	public fun getThumbnailSource ()Lnet/mamoe/mirai/utils/ExternalResource;
+	public fun getVideoSource ()Lnet/mamoe/mirai/utils/ExternalResource;
+	public fun toString ()Ljava/lang/String;
+}
+
+public final class net/mamoe/mirai/event/events/ShortVideoUploadEvent$Succeed : net/mamoe/mirai/event/events/ShortVideoUploadEvent {
+	public fun getTarget ()Lnet/mamoe/mirai/contact/Contact;
+	public fun getThumbnailSource ()Lnet/mamoe/mirai/utils/ExternalResource;
+	public final fun getVideo ()Lnet/mamoe/mirai/message/data/ShortVideo;
+	public fun getVideoSource ()Lnet/mamoe/mirai/utils/ExternalResource;
+	public fun toString ()Ljava/lang/String;
+}
+
 public final class net/mamoe/mirai/event/events/SignEvent : net/mamoe/mirai/event/AbstractEvent, net/mamoe/mirai/event/events/BotEvent, net/mamoe/mirai/internal/network/Packet {
 	public fun getBot ()Lnet/mamoe/mirai/Bot;
 	public final fun getRank ()Ljava/lang/Integer;
@@ -4830,6 +4865,39 @@ public abstract class net/mamoe/mirai/message/data/OfflineMessageSource : net/ma
 public final class net/mamoe/mirai/message/data/OfflineMessageSource$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
 }
 
+public abstract interface class net/mamoe/mirai/message/data/OfflineShortVideo : net/mamoe/mirai/message/data/ShortVideo {
+	public static final field Key Lnet/mamoe/mirai/message/data/OfflineShortVideo$Key;
+	public static final field SERIAL_NAME Ljava/lang/String;
+}
+
+public final class net/mamoe/mirai/message/data/OfflineShortVideo$Builder {
+	public static final field Companion Lnet/mamoe/mirai/message/data/OfflineShortVideo$Builder$Companion;
+	public final fun build ()Lnet/mamoe/mirai/message/data/OfflineShortVideo;
+	public final fun getFileFormat ()Ljava/lang/String;
+	public final fun getFileMd5 ()[B
+	public final fun getFileName ()Ljava/lang/String;
+	public final fun getFileSize ()J
+	public final fun getThumbnailMd5 ()[B
+	public final fun getThumbnailSize ()J
+	public final fun getVideoId ()Ljava/lang/String;
+	public static final fun newBuilder (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[BJ)Lnet/mamoe/mirai/message/data/OfflineShortVideo$Builder;
+	public final fun setFileFormat (Ljava/lang/String;)V
+	public final fun setFileMd5 ([B)V
+	public final fun setFileName (Ljava/lang/String;)V
+	public final fun setFileSize (J)V
+	public final fun setThumbnailMd5 ([B)V
+	public final fun setThumbnailSize (J)V
+	public final fun setVideoId (Ljava/lang/String;)V
+}
+
+public final class net/mamoe/mirai/message/data/OfflineShortVideo$Builder$Companion {
+	public final fun newBuilder (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[BJ)Lnet/mamoe/mirai/message/data/OfflineShortVideo$Builder;
+}
+
+public final class net/mamoe/mirai/message/data/OfflineShortVideo$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
+	public static final field SERIAL_NAME Ljava/lang/String;
+}
+
 public abstract interface class net/mamoe/mirai/message/data/OnlineAudio : net/mamoe/mirai/message/data/Audio {
 	public static final field Key Lnet/mamoe/mirai/message/data/OnlineAudio$Key;
 	public static final field SERIAL_NAME Ljava/lang/String;
@@ -4978,6 +5046,16 @@ public abstract class net/mamoe/mirai/message/data/OnlineMessageSource$Outgoing$
 public final class net/mamoe/mirai/message/data/OnlineMessageSource$Outgoing$ToTemp$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
 }
 
+public abstract interface class net/mamoe/mirai/message/data/OnlineShortVideo : net/mamoe/mirai/message/data/ShortVideo {
+	public static final field Key Lnet/mamoe/mirai/message/data/OnlineShortVideo$Key;
+	public static final field SERIAL_NAME Ljava/lang/String;
+	public abstract fun getUrlForDownload ()Ljava/lang/String;
+}
+
+public final class net/mamoe/mirai/message/data/OnlineShortVideo$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
+	public static final field SERIAL_NAME Ljava/lang/String;
+}
+
 public final class net/mamoe/mirai/message/data/OrNullDelegate {
 	public static final synthetic fun box-impl (Ljava/lang/Object;)Lnet/mamoe/mirai/message/data/OrNullDelegate;
 	public static fun constructor-impl (Ljava/lang/Object;)Ljava/lang/Object;
@@ -5223,6 +5301,24 @@ public abstract interface class net/mamoe/mirai/message/data/ServiceMessage : ne
 public final class net/mamoe/mirai/message/data/ServiceMessage$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
 }
 
+public abstract interface class net/mamoe/mirai/message/data/ShortVideo : net/mamoe/mirai/message/data/ConstrainSingle, net/mamoe/mirai/message/data/MessageContent {
+	public static final field Key Lnet/mamoe/mirai/message/data/ShortVideo$Key;
+	public abstract fun getFileFormat ()Ljava/lang/String;
+	public abstract fun getFileMd5 ()[B
+	public abstract fun getFileSize ()J
+	public abstract fun getFilename ()Ljava/lang/String;
+	public fun getKey ()Lnet/mamoe/mirai/message/data/MessageKey;
+	public abstract fun getVideoId ()Ljava/lang/String;
+}
+
+public final class net/mamoe/mirai/message/data/ShortVideo$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
+}
+
+public final class net/mamoe/mirai/message/data/ShortVideoKt {
+	public static final synthetic fun OfflineShortVideo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[BJ[BJ)Lnet/mamoe/mirai/message/data/OfflineShortVideo;
+	public static synthetic fun OfflineShortVideo$default (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[BJ[BJILjava/lang/Object;)Lnet/mamoe/mirai/message/data/OfflineShortVideo;
+}
+
 public final class net/mamoe/mirai/message/data/ShowImageFlag : net/mamoe/mirai/message/data/AbstractMessageKey, net/mamoe/mirai/message/data/ConstrainSingle, net/mamoe/mirai/message/data/MessageMetadata {
 	public static final field INSTANCE Lnet/mamoe/mirai/message/data/ShowImageFlag;
 	public static final field SERIAL_NAME Ljava/lang/String;

+ 20 - 2
mirai-core-api/src/commonMain/kotlin/contact/Contact.kt

@@ -72,8 +72,6 @@ public interface Contact : ContactOrBot, CoroutineScope {
     /**
      * 上传一个 [资源][ExternalResource] 作为图片以备发送.
      *
-     * **无论上传是否成功都不会关闭 [resource]. 需要调用方手动关闭资源**
-     *
      * 也可以使用其他扩展: [ExternalResource.uploadAsImage] 使用 [File], [InputStream] 等上传.
      *
      * @see Image 查看有关图片的更多信息, 如上传图片
@@ -88,6 +86,26 @@ public interface Contact : ContactOrBot, CoroutineScope {
      */
     public suspend fun uploadImage(resource: ExternalResource): Image
 
+    /**
+     * 上传 [资源][ExternalResource] 作为短视频发送.
+     * 同时需要上传缩略图作为视频消息显示的封面.
+     *
+     * @see ShortVideo 查看有关短视频的更多信息
+     *
+     * @see BeforeShortVideoUploadEvent 短视频发送前事件,可通过中断来拦截视频上传.
+     * @see ShortVideoUploadEvent 短视频上传完成事件,不可拦截.
+     *
+     * @param thumbnail 短视频封面图,为图片资源.
+     * @param video 视频资源,目前仅支持上传 mp4 格式的视频.
+     * @param fileName 文件名,若为 `null` 则根据 [video] 自动生成.
+     */
+    public suspend fun uploadShortVideo(
+        thumbnail: ExternalResource,
+        video: ExternalResource,
+        fileName: String? = null
+    ): ShortVideo
+
+    @JvmBlockingBridge
     public companion object {
         /**
          * 读取 [InputStream] 到临时文件并将其作为图片发送到指定联系人

+ 93 - 0
mirai-core-api/src/commonMain/kotlin/event/events/ShortVideoUploadEvent.kt

@@ -0,0 +1,93 @@
+/*
+ * 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
+ */
+
+@file:JvmMultifileClass
+@file:JvmName("BotEventsKt")
+
+package net.mamoe.mirai.event.events
+
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.contact.Contact
+import net.mamoe.mirai.event.AbstractEvent
+import net.mamoe.mirai.event.CancellableEvent
+import net.mamoe.mirai.event.events.ShortVideoUploadEvent.Failed
+import net.mamoe.mirai.event.events.ShortVideoUploadEvent.Succeed
+import net.mamoe.mirai.internal.event.VerboseEvent
+import net.mamoe.mirai.message.data.ShortVideo
+import net.mamoe.mirai.utils.ExternalResource
+import net.mamoe.mirai.utils.MiraiInternalApi
+
+
+/**
+ * 短视频上传前. 可以阻止上传.
+ *
+ * 此事件总是在 [ShortVideoUploadEvent] 之前广播.
+ * 若此事件被取消, [ShortVideoUploadEvent] 不会广播.
+ *
+ * @see Contact.uploadShortVideo 上传短视频. 为广播这个事件的唯一途径
+ * @since 2.16
+ */
+@OptIn(MiraiInternalApi::class)
+public class BeforeShortVideoUploadEvent @MiraiInternalApi constructor(
+    public val target: Contact,
+    public val thumbnailSource: ExternalResource,
+    public val videoSource: ExternalResource
+) : BotEvent, BotActiveEvent, AbstractEvent(), CancellableEvent, VerboseEvent {
+    public override val bot: Bot
+        get() = target.bot
+}
+
+/**
+ * 短视频上传完成.
+ *
+ * 此事件总是在 [BeforeImageUploadEvent] 之后广播.
+ * 若 [BeforeImageUploadEvent] 被取消, 此事件不会广播.
+ *
+ * @see Contact.uploadShortVideo 上传短视频. 为广播这个事件的唯一途径
+ * @see Succeed
+ * @see Failed
+ * @since 2.16
+ */
+@OptIn(MiraiInternalApi::class)
+public sealed class ShortVideoUploadEvent : BotEvent, BotActiveEvent, AbstractEvent(), VerboseEvent {
+    public abstract val target: Contact
+    public abstract val thumbnailSource: ExternalResource
+    public abstract val videoSource: ExternalResource
+    public override val bot: Bot
+        get() = target.bot
+
+    public class Succeed @MiraiInternalApi constructor(
+        override val target: Contact,
+        override val thumbnailSource: ExternalResource,
+        override val videoSource: ExternalResource,
+        public val video: ShortVideo
+    ) : ShortVideoUploadEvent() {
+        override fun toString(): String {
+            return "ShortVideoUploadEvent.Succeed(target=$target, " +
+                    "thumbnailSource=$thumbnailSource, " +
+                    "videoSource=$videoSource, " +
+                    "video=$video)"
+        }
+    }
+
+    public class Failed @MiraiInternalApi constructor(
+        override val target: Contact,
+        override val thumbnailSource: ExternalResource,
+        override val videoSource: ExternalResource,
+        public val errno: Int,
+        public val message: String
+    ) : ShortVideoUploadEvent() {
+        override fun toString(): String {
+            return "ShortVideoUploadEvent.Failed(target=$target, " +
+                    "thumbnailSource=$thumbnailSource, " +
+                    "videoSource=$videoSource, " +
+                    "errno=$errno, message='$message')"
+        }
+    }
+}

+ 1 - 0
mirai-core-api/src/commonMain/kotlin/message/data/Image.kt

@@ -572,6 +572,7 @@ public interface InternalImageProtocol { // naming it Internal* to assign it a l
     @MiraiInternalApi
     public companion object {
         public val instance: InternalImageProtocol by lazy {
+            Mirai // initialize MiraiImpl first
             loadService(
                 InternalImageProtocol::class,
                 "net.mamoe.mirai.internal.message.InternalImageProtocolImpl"

+ 250 - 0
mirai-core-api/src/commonMain/kotlin/message/data/ShortVideo.kt

@@ -0,0 +1,250 @@
+/*
+ * 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.message.data
+
+import kotlinx.serialization.KSerializer
+import net.mamoe.mirai.Mirai
+import net.mamoe.mirai.contact.AudioSupported
+import net.mamoe.mirai.contact.Contact
+import net.mamoe.mirai.message.MessageSerializers
+import net.mamoe.mirai.message.data.MessageChain.Companion.serializeToJsonString
+import net.mamoe.mirai.message.data.visitor.MessageVisitor
+import net.mamoe.mirai.utils.*
+
+/**
+ * 短视频消息, 指的是可在聊天界面在线播放的视频消息, 而非在群文件上传的视频文件.
+ *
+ * 短视频消息分为 [OnlineShortVideo] 与 [OfflineShortVideo]. 在本地上传的短视频为 [OfflineShortVideo]. 从服务器接收的短视频为 [OnlineShortVideo].
+ *
+ * 最推荐存储的方式是下载视频文件, 每次都通过上传该文件获取视频消息.
+ * 在上传视频时服务器会根据缓存情况选择回复已有视频 ID 或要求客户端上传.
+ *
+ * # 获取短视频消息示例
+ *
+ * ## 上传短视频
+ * 使用 [Contact.uploadShortVideo], 将视频缩略图和视频[资源][ExternalResource] 上传以得到 [OfflineShortVideo].
+ *
+ * ## 使用 [OfflineShortVideo.Builder] 构建短视频
+ * [OfflineShortVideo] 提供 [Builder][OfflineShortVideo.Builder] 构建方式, 必须指定 [videoId], [filename], [fileMd5], [fileSize] 和 [fileFormat] 参数.
+ * 可选指定 [thumbnailMd5][OfflineShortVideo.Builder.thumbnailMd5] 和 [thumbnailSize][OfflineShortVideo.Builder.thumbnailSize]. 若不提供, 可能会影响服务器判断缓存.
+ *
+ * ## 从服务器接收
+ * 通过监听消息接收的短视频消息可直接转换为 [OnlineShortVideo].
+ *
+ * kotlin 示例:
+ * ```kotlin
+ * val video: OnlineShortVideo = event.message[OnlineShortVideo]
+ * ```
+ *
+ * # 下载视频
+ * 通过 [OnlineShortVideo.urlForDownload] 获取下载链接.
+ * 该下载链接不包含短视频的文件信息, 可以使用 [videoId] 或 [filename] 作为文件名, [fileFormat] 作为文件拓展名.
+ *
+ * @since 2.16
+ */
+@NotStableForInheritance
+public interface ShortVideo : MessageContent, ConstrainSingle {
+    /**
+     * 视频 ID.
+     */
+    public val videoId: String
+
+    /**
+     * 视频文件 MD5. 16 bytes.
+     */
+    public val fileMd5: ByteArray
+
+    /*
+     * 视频大小
+     */
+    public val fileSize: Long
+
+    /**
+     * 视频文件类型(拓展名)
+     */
+    public val fileFormat: String
+
+    /*
+     * 视频文件名, 不包括拓展名
+     */
+    public val filename: String
+
+
+    @MiraiInternalApi
+    override fun <D, R> accept(visitor: MessageVisitor<D, R>, data: D): R {
+        return visitor.visitShortVideo(this, data)
+    }
+
+    override val key: MessageKey<*>
+        get() = Key
+
+
+    public companion object Key :
+        AbstractPolymorphicMessageKey<MessageContent, ShortVideo>(MessageContent, { it.safeCast() }) {
+
+    }
+}
+
+/**
+ * 在线短视频消息, 即从消息事件中接收到的视频消息.
+ *
+ * [OnlineShortVideo] 仅可以从事件中的[消息链][MessageChain]接收, 不可手动构造.
+ *
+ * ### 序列化支持
+ *
+ * [OnlineShortVideo] 支持序列化. 可使用 [MessageChain.serializeToJsonString] 以及 [MessageChain.deserializeFromJsonString].
+ * 也可以在 [MessageSerializers.serializersModule] 获取到 [OnlineShortVideo] 的 [KSerializer].
+ *
+ * 要获取更多有关序列化的信息, 参阅 [MessageSerializers].
+ *
+ * @since 2.16
+ */
+@NotStableForInheritance
+public interface OnlineShortVideo : ShortVideo {
+    /**
+     * 下载链接
+     */
+    public val urlForDownload: String
+
+    public companion object Key :
+        AbstractPolymorphicMessageKey<ShortVideo, OnlineShortVideo>(ShortVideo, { it.safeCast() }) {
+        public const val SERIAL_NAME: String = "OnlineShortVideo"
+    }
+}
+
+/**
+ * 离线短视频消息.
+ *
+ * [OfflineShortVideo] 拥有协议上必要的五个属性:
+ * - 视频 ID [videoId]
+ * - 视频文件名 [filename]
+ * - 视频 MD5 [fileMd5]
+ * - 视频大小 [fileSize]
+ * - 视频格式 [fileFormat]
+ *
+ * 和非必要属性:
+ * - 缩略图 MD5 `thumbnailMd5`
+ * - 缩略图大小 `thumbnailSize`
+ *
+ * [OfflineShortVideo] 可由本地 [ExternalResource] 经过 [AudioSupported.uploadShortVideo] 上传到服务器得到, 故无[下载链接][OnlineShortVideo.urlForDownload].
+ *
+ * [OfflineShortVideo] 支持使用 [OfflineShortVideo.Builder] 可通过上述七个必要参数和两个非必要参数构造 [OfflineShortVideo] 实例.
+ *
+ * ### 序列化支持
+ *
+ * [OfflineShortVideo] 支持序列化. 可使用 [MessageChain.serializeToJsonString] 以及 [MessageChain.deserializeFromJsonString].
+ * 也可以在 [MessageSerializers.serializersModule] 获取到 [OfflineShortVideo] 的 [KSerializer].
+ *
+ * 要获取更多有关序列化的信息, 参阅 [MessageSerializers].
+ * @since 2.16
+ */
+@NotStableForInheritance
+public interface OfflineShortVideo : ShortVideo {
+
+    public companion object Key :
+        AbstractPolymorphicMessageKey<ShortVideo, OfflineShortVideo>(ShortVideo, { it.safeCast() }) {
+        public const val SERIAL_NAME: String = "OfflineShortVideo"
+    }
+
+    public class Builder internal constructor(
+        public var videoId: String,
+        public var fileMd5: ByteArray,
+        public var fileSize: Long,
+        public var fileFormat: String,
+        public var fileName: String
+    ) {
+        /**
+         * 缩略图文件 MD5
+         *
+         * 传入此处的缩略图 MD5 应该仅有以下来源:
+         * * *已通过 [Contact.uploadShortVideo] 上传完成的*缩略图[资源][ExternalResource], 可由 [ExternalResource.md5] 获得.
+         */
+        public var thumbnailMd5: ByteArray = EMPTY_BYTE_ARRAY
+
+        /**
+         * 缩略图文件大小
+         *
+         * 传入此处的缩略图文件大小应该仅有以下来源:
+         * * *已通过 [Contact.uploadShortVideo] 上传完成的*缩略图[资源][ExternalResource], 可由 [ExternalResource.size] 获得.
+         */
+        public var thumbnailSize: Long = 0
+
+        public fun build(): OfflineShortVideo {
+
+            @OptIn(MiraiInternalApi::class)
+            return InternalShortVideoProtocol.instance.createOfflineShortVideo(
+                videoId, fileMd5, fileSize, fileFormat, fileName, thumbnailMd5, thumbnailSize
+            )
+        }
+
+        public companion object {
+            /**
+             * 创建一个 [OfflineShortVideo.Builder]
+             *
+             * 在 Kotlin 可以使用类构造器的函数 [OfflineShortVideo]: `OfflineShortVideo(...)`
+             */
+            @JvmStatic
+            public fun newBuilder(
+                videoId: String,
+                fileName: String,
+                fileFormat: String,
+                fileMd5: ByteArray,
+                fileSize: Long
+            ): Builder = Builder(videoId, fileMd5, fileSize, fileFormat, fileName)
+        }
+    }
+}
+
+/**
+ * 构造 [OfflineShortVideo]. 有关参数的含义, 参考 [ShortVideo].
+ * @since 2.16
+ */
+@Suppress("NOTHING_TO_INLINE")
+@JvmSynthetic
+public inline fun OfflineShortVideo(
+    videoId: String,
+    fileName: String,
+    fileFormat: String,
+    fileMd5: ByteArray,
+    fileSize: Long,
+    thumbnailMd5: ByteArray = byteArrayOf(),
+    thumbnailSize: Long = 0,
+): OfflineShortVideo = OfflineShortVideo.Builder.newBuilder(videoId, fileName, fileFormat, fileMd5, fileSize).apply {
+    this@apply.thumbnailMd5 = thumbnailMd5
+    this@apply.thumbnailSize = thumbnailSize
+}.build()
+
+/**
+ * 内部短视频协议实现, 请不要使用此接口
+ * @since 2.16.0
+ */
+@MiraiInternalApi
+public interface InternalShortVideoProtocol {
+    public fun createOfflineShortVideo(
+        videoId: String,
+        fileMd5: ByteArray,
+        fileSize: Long,
+        fileFormat: String,
+        fileName: String,
+        thumbnailMd5: ByteArray,
+        thumbnailSize: Long
+    ): OfflineShortVideo
+
+    @MiraiInternalApi
+    public companion object {
+        public val instance: InternalShortVideoProtocol by lazy {
+            Mirai // initialize MiraiImpl first
+            loadService(
+                InternalShortVideoProtocol::class,
+                "net.mamoe.mirai.internal.message.InternalShortVideoProtocolImpl"
+            )
+        }
+    }
+}

+ 6 - 0
mirai-core-api/src/commonMain/kotlin/message/data/visitor/MessageVisitor.kt

@@ -41,6 +41,8 @@ public interface MessageVisitor<in D, out R> {
     public fun visitVoice(message: net.mamoe.mirai.message.data.Voice, data: D): R
     public fun visitAudio(message: Audio, data: D): R
 
+    public fun visitShortVideo(message: ShortVideo, data: D): R
+
     // region HummerMessage
     public fun visitHummerMessage(message: HummerMessage, data: D): R
     public fun visitFlashImage(message: FlashImage, data: D): R
@@ -164,6 +166,10 @@ public abstract class AbstractMessageVisitor<in D, out R> : MessageVisitor<D, R>
         return visitMessageContent(message, data)
     }
 
+    override fun visitShortVideo(message: ShortVideo, data: D): R {
+        return visitMessageContent(message, data)
+    }
+
     public override fun visitHummerMessage(message: HummerMessage, data: D): R {
         return visitMessageContent(message, data)
     }

+ 3 - 1
mirai-core-api/src/commonMain/kotlin/utils/ExternalResource.kt

@@ -161,7 +161,9 @@ public interface ExternalResource : java.io.Closeable {
      * 文件格式,如 "png", "amr". 当无法自动识别格式时为 [DEFAULT_FORMAT_NAME].
      *
      * 默认会从文件头识别, 支持的文件类型:
-     * png, jpg, gif, tif, bmp, amr, silk
+     * * 图片类型: png, jpg, gif, tif, bmp
+     * * 语音类型: amr, silk
+     * * 视频类类型: mp4, mkv
      *
      * @see net.mamoe.mirai.utils.getFileType
      * @see net.mamoe.mirai.utils.FILE_TYPES

+ 9 - 4
mirai-core-mock/src/internal/contact/AbstractMockContact.kt

@@ -16,10 +16,7 @@ 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.message.data.*
 import net.mamoe.mirai.mock.MockBot
 import net.mamoe.mirai.mock.contact.MockContact
 import net.mamoe.mirai.utils.*
@@ -61,6 +58,14 @@ internal abstract class AbstractMockContact(
         return bot.uploadMockImage(resource)
     }
 
+    override suspend fun uploadShortVideo(
+        thumbnail: ExternalResource,
+        video: ExternalResource,
+        fileName: String?
+    ): ShortVideo {
+        TODO("mock upload short video")
+    }
+
     override fun toString(): String {
         return "$id"
     }

+ 21 - 13
mirai-core-utils/src/commonMain/kotlin/Files.kt

@@ -15,27 +15,35 @@ package net.mamoe.mirai.utils
 import kotlin.jvm.JvmMultifileClass
 import kotlin.jvm.JvmName
 
+private class FileType(
+    signature: String,
+    val requiredHeaderSize: Int,
+    val formatName: String
+) {
+    val signatureRegex = Regex(signature, RegexOption.IGNORE_CASE)
+}
+
 /**
  * 文件头和文件类型列表
  */
-public val FILE_TYPES: MutableMap<String, String> = mutableMapOf(
-    "FFD8FF" to "jpg",
-    "89504E47" to "png",
-    "47494638" to "gif",
+private val FILE_TYPES: List<FileType> = listOf(
+    FileType("^FFD8FF", 3, "jpg"),
+    FileType("^89504E47", 4, "png"),
+    FileType("^47494638", 4, "gif"),
+    FileType("^424D", 3, "bmp"),
+    FileType("^2321414D52", 5, "amr"),
+    FileType("^02232153494C4B5F5633", 10, "silk"),
+    FileType("^([a-zA-Z0-9]{8})66747970", 8, "mp4"),
+
     //"49492A00" to "tif", // client doesn't support
-    "424D" to "bmp",
     //"52494646" to "webp", // pc client doesn't support
-
     // "57415645" to "wav", // server doesn't support
-
-    "2321414D52" to "amr",
-    "02232153494C4B5F5633" to "silk",
 )
 
 /**
  * 在 [getFileType] 需要的 [ByteArray] 长度
  */
-public val COUNT_BYTES_USED_FOR_DETECTING_FILE_TYPE: Int get() = FILE_TYPES.maxOf { it.key.length / 2 }
+public val COUNT_BYTES_USED_FOR_DETECTING_FILE_TYPE: Int by lazy { FILE_TYPES.maxOf { it.requiredHeaderSize } }
 
 /*
 
@@ -53,9 +61,9 @@ public fun getFileType(fileHeader: ByteArray): String? {
         "",
         length = COUNT_BYTES_USED_FOR_DETECTING_FILE_TYPE.coerceAtMost(fileHeader.size)
     )
-    FILE_TYPES.forEach { (k, v) ->
-        if (hex.startsWith(k)) {
-            return v
+    FILE_TYPES.forEach { t ->
+        if (hex.contains(t.signatureRegex)) {
+            return t.formatName
         }
     }
     return null

+ 1 - 3
mirai-core/src/commonMain/kotlin/MiraiImpl.kt

@@ -723,9 +723,7 @@ internal open class MiraiImpl : IMirai, LowLevelApiAccessor {
             it.fileName to it.buffer.loadAs(MsgTransmit.PbMultiMsgNew.serializer())
         }
         val main = pbs["MultiMsg"] ?: return this.msg.map { it.toNode(bot, EmptyRefineContext) }
-        val context = SimpleRefineContext(mutableMapOf())
-        context[ForwardMessageInternal.MsgTransmits] = pbs
-        return main.toForwardMessageNodes(bot, context)
+        return main.toForwardMessageNodes(bot, SimpleRefineContext(ForwardMessageInternal.MsgTransmits to pbs))
     }
 
     private suspend fun MsgComm.Msg.toNode(bot: Bot, refineContext: RefineContext): ForwardMessage.Node {

+ 129 - 1
mirai-core/src/commonMain/kotlin/contact/AbstractContact.kt

@@ -9,10 +9,26 @@
 
 package net.mamoe.mirai.internal.contact
 
+import io.ktor.utils.io.core.*
 import net.mamoe.mirai.Bot
 import net.mamoe.mirai.contact.*
+import net.mamoe.mirai.event.broadcast
+import net.mamoe.mirai.event.events.BeforeShortVideoUploadEvent
+import net.mamoe.mirai.event.events.EventCancelledException
+import net.mamoe.mirai.event.events.ShortVideoUploadEvent
 import net.mamoe.mirai.internal.QQAndroidBot
-import net.mamoe.mirai.utils.childScopeContext
+import net.mamoe.mirai.internal.message.data.OfflineShortVideoImpl
+import net.mamoe.mirai.internal.message.data.ShortVideoThumbnail
+import net.mamoe.mirai.internal.message.image.calculateImageInfo
+import net.mamoe.mirai.internal.network.highway.Highway
+import net.mamoe.mirai.internal.network.highway.ResourceKind
+import net.mamoe.mirai.internal.network.protocol.data.proto.PttShortVideo
+import net.mamoe.mirai.internal.network.protocol.packet.chat.video.PttCenterSvr
+import net.mamoe.mirai.internal.utils.io.serialization.loadAs
+import net.mamoe.mirai.internal.utils.io.serialization.writeProtoBuf
+import net.mamoe.mirai.message.data.ShortVideo
+import net.mamoe.mirai.internal.utils.CombinedExternalResource
+import net.mamoe.mirai.utils.*
 import kotlin.contracts.contract
 import kotlin.coroutines.CoroutineContext
 
@@ -21,6 +37,118 @@ internal abstract class AbstractContact(
     parentCoroutineContext: CoroutineContext,
 ) : Contact {
     final override val coroutineContext: CoroutineContext = parentCoroutineContext.childScopeContext()
+
+    override suspend fun uploadShortVideo(
+        thumbnail: ExternalResource,
+        video: ExternalResource,
+        fileName: String?
+    ): ShortVideo = thumbnail.withAutoClose {
+        video.withAutoClose {
+            if (this !is Group && this !is Friend) {
+                throw UnsupportedOperationException("short video can only upload to friend or group.")
+            }
+
+            if (video.formatName != "mp4") {
+                throw UnsupportedOperationException("video format ${video.formatName} is not supported.")
+            }
+
+            if (BeforeShortVideoUploadEvent(this, thumbnail, video).broadcast().isCancelled) {
+                throw EventCancelledException("cancelled by BeforeShortVideoUploadEvent")
+            }
+
+            // local uploaded offline short video uses video file md5 as its file name by default
+            val videoName = fileName ?: video.md5.toUHexString("")
+
+            val uploadResp = bot.network.sendAndExpect(
+                PttCenterSvr.GroupShortVideoUpReq(
+                    client = bot.client,
+                    contact = this,
+                    thumbnailFileMd5 = thumbnail.md5,
+                    thumbnailFileSize = thumbnail.size,
+                    videoFileName = videoName,
+                    videoFileMd5 = video.md5,
+                    videoFileSize = video.size,
+                    videoFileFormat = video.formatName
+                )
+            )
+
+            // get thumbnail image width and height
+            val thumbnailInfo = thumbnail.calculateImageInfo()
+
+            // fast path
+            if (uploadResp is PttCenterSvr.GroupShortVideoUpReq.Response.FileExists) {
+                return OfflineShortVideoImpl(
+                    uploadResp.fileId,
+                    videoName,
+                    video.md5,
+                    video.size,
+                    video.formatName,
+                    ShortVideoThumbnail(
+                        thumbnail.md5,
+                        thumbnail.size,
+                        thumbnailInfo.width,
+                        thumbnailInfo.height
+                    )
+                ).also {
+                    ShortVideoUploadEvent.Succeed(this, thumbnail, video, it).broadcast()
+                }
+            }
+
+            val highwayRespExt = CombinedExternalResource(thumbnail, video).use { resource ->
+                Highway.uploadResourceBdh(
+                    bot = bot,
+                    resource = resource,
+                    kind = ResourceKind.SHORT_VIDEO,
+                    commandId = 25,
+                    extendInfo = buildPacket {
+                        writeProtoBuf(
+                            PttShortVideo.PttShortVideoUploadReq.serializer(),
+                            PttCenterSvr.GroupShortVideoUpReq.buildShortVideoFileInfo(
+                                client = bot.client,
+                                contact = this@AbstractContact,
+                                thumbnailFileMd5 = thumbnail.md5,
+                                thumbnailFileSize = thumbnail.size,
+                                videoFileName = videoName,
+                                videoFileMd5 = video.md5,
+                                videoFileSize = video.size,
+                                videoFileFormat = video.formatName
+                            )
+                        )
+                    }.readBytes(),
+                    encrypt = true
+                ).extendInfo
+            }
+
+            if (highwayRespExt == null) {
+                ShortVideoUploadEvent.Failed(
+                    this,
+                    thumbnail,
+                    video,
+                    -1,
+                    "highway upload short video failed, extendInfo is null."
+                ).broadcast()
+                error("highway upload short video failed, extendInfo is null.")
+            }
+
+            val highwayUploadResp = highwayRespExt.loadAs(PttShortVideo.PttShortVideoUploadResp.serializer())
+
+            OfflineShortVideoImpl(
+                highwayUploadResp.fileid,
+                videoName,
+                video.md5,
+                video.size,
+                video.formatName,
+                ShortVideoThumbnail(
+                    thumbnail.md5,
+                    thumbnail.size,
+                    thumbnailInfo.width,
+                    thumbnailInfo.height
+                )
+            ).also {
+                ShortVideoUploadEvent.Succeed(this, thumbnail, video, it).broadcast()
+            }
+        }
+    }
 }
 
 internal val Contact.userIdOrNull: Long? get() = if (this is User) this.id else null

+ 14 - 1
mirai-core/src/commonMain/kotlin/contact/roaming/RoamingMessagesImplGroup.kt

@@ -12,6 +12,8 @@ package net.mamoe.mirai.internal.contact.roaming
 import kotlinx.coroutines.flow.*
 import net.mamoe.mirai.contact.roaming.RoamingMessageFilter
 import net.mamoe.mirai.internal.contact.CommonGroupImpl
+import net.mamoe.mirai.internal.message.RefineContextKey
+import net.mamoe.mirai.internal.message.SimpleRefineContext
 import net.mamoe.mirai.internal.message.toMessageChainOnline
 import net.mamoe.mirai.internal.network.protocol.data.proto.MsgComm
 import net.mamoe.mirai.internal.network.protocol.packet.chat.TroopManagement
@@ -65,7 +67,18 @@ internal class RoamingMessagesImplGroup(
                         .sortedByDescending { it.msgHead.msgSeq } // Ensure caller receives newer messages first
                         .filter { filter.apply(it) } // Call filter after sort
                         .asFlow()
-                        .map { listOf(it).toMessageChainOnline(bot, contact.id, MessageSourceKind.GROUP) }
+                        .map {
+                            listOf(it).toMessageChainOnline(
+                                bot,
+                                contact.id,
+                                MessageSourceKind.GROUP,
+                                SimpleRefineContext(
+                                    RefineContextKey.MessageSourceKind to MessageSourceKind.GROUP,
+                                    RefineContextKey.FromId to it.msgHead.fromUin,
+                                    RefineContextKey.GroupIdOrZero to contact.uin,
+                                )
+                            )
+                        }
                 )
 
                 currentSeq = resp.msgElem.first().msgHead.msgSeq

+ 9 - 2
mirai-core/src/commonMain/kotlin/contact/roaming/TimeBasedRoamingMessagesImpl.kt

@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.isActive
 import net.mamoe.mirai.contact.roaming.RoamingMessageFilter
+import net.mamoe.mirai.internal.message.RefineContextKey
+import net.mamoe.mirai.internal.message.SimpleRefineContext
 import net.mamoe.mirai.internal.message.toMessageChainOnline
 import net.mamoe.mirai.internal.network.protocol.packet.chat.receive.MessageSvcPbGetRoamMsgReq
 import net.mamoe.mirai.message.data.MessageChain
@@ -32,13 +34,18 @@ internal sealed class TimeBasedRoamingMessagesImpl : AbstractRoamingMessages() {
             while (currentCoroutineContext().isActive) {
                 val resp = requestRoamMsg(timeStart, lastMessageTime, random)
                 val messages = resp.messages ?: break
+
                 if (filter == null || filter === RoamingMessageFilter.ANY) {
                     // fast path
-                    messages.forEach { emit(it.toMessageChainOnline(contact.bot)) }
+                    messages.forEach { msg ->
+                        val context = SimpleRefineContext(RefineContextKey.FromId to msg.msgHead.fromUin)
+                        emit(msg.toMessageChainOnline(contact.bot, context))
+                    }
                 } else {
                     for (message in messages) {
                         if (filter.invoke(createRoamingMessage(message, messages))) {
-                            emit(message.toMessageChainOnline(contact.bot))
+                            val context = SimpleRefineContext(RefineContextKey.FromId to message.msgHead.fromUin)
+                            emit(message.toMessageChainOnline(contact.bot, context))
                         }
                     }
                 }

+ 32 - 5
mirai-core/src/commonMain/kotlin/message/ReceiveMessageHandler.kt

@@ -24,8 +24,10 @@ import net.mamoe.mirai.internal.message.source.*
 import net.mamoe.mirai.internal.network.protocol.data.proto.ImMsgBody
 import net.mamoe.mirai.internal.network.protocol.data.proto.MsgComm
 import net.mamoe.mirai.message.data.*
+import net.mamoe.mirai.utils.castOrNull
 import net.mamoe.mirai.utils.structureToString
 import net.mamoe.mirai.utils.toLongUnsigned
+import net.mamoe.mirai.utils.warning
 
 /**
  * 只在手动构造 [OfflineMessageSource] 时调用
@@ -78,7 +80,17 @@ internal suspend fun MsgComm.Msg.toMessageChainOnline(
         MessageSourceKind.GROUP -> msgHead.groupInfo?.groupCode ?: 0
         else -> 0
     }
-    return listOf(this).toMessageChainOnline(bot, groupId, kind, refineContext, facade)
+
+    return listOf(this).toMessageChainOnline(
+        bot,
+        groupId,
+        kind,
+        refineContext.merge(SimpleRefineContext(
+            RefineContextKey.MessageSourceKind to kind,
+            RefineContextKey.GroupIdOrZero to groupId
+        ), false),
+        facade
+    )
 }
 
 //internal fun List<MsgComm.Msg>.toMessageChainOffline(
@@ -129,13 +141,28 @@ private fun List<MsgComm.Msg>.toMessageChainImpl(
 
     val builder = MessageChainBuilder(messageList.sumOf { it.msgBody.richText.elems.size })
 
-    if (onlineSource != null) {
-        builder.add(ReceiveMessageTransformer.createMessageSource(bot, onlineSource, messageSourceKind, messageList))
-    }
+    val source = if (onlineSource != null) {
+        ReceiveMessageTransformer.createMessageSource(bot, onlineSource, messageSourceKind, messageList)
+    } else null
+    if (source != null) builder.add(source)
 
+    val fromId = source?.fromId ?: firstOrNull()?.msgHead?.fromUin
+    if (fromId == null) {
+        bot.logger.warning {
+            "Cannot determine fromId from message source and msg elements, " +
+                    "source: $source, elements: ${this.joinToString(", ")}"
+        }
+    }
 
     messageList.forEach { msg ->
-        facade.decode(msg.msgBody.richText.elems, groupIdOrZero, messageSourceKind, bot, builder, msg)
+        facade.decode(
+            msg.msgBody.richText.elems,
+            groupIdOrZero,
+            messageSourceKind,
+            bot,
+            builder,
+            msg
+        )
     }
 
     for (msg in messageList) {

+ 43 - 0
mirai-core/src/commonMain/kotlin/message/RefinableMessage.kt

@@ -16,6 +16,7 @@ import net.mamoe.mirai.internal.message.LightMessageRefiner.refineMessageSource
 import net.mamoe.mirai.internal.message.flags.InternalFlagOnlyMessage
 import net.mamoe.mirai.internal.message.source.IncomingMessageSourceInternal
 import net.mamoe.mirai.message.data.*
+import net.mamoe.mirai.utils.cast
 import net.mamoe.mirai.utils.safeCast
 
 /**
@@ -99,6 +100,12 @@ internal class RefineContextKey<T : Any>(
             append(')')
         }
     }
+
+    internal companion object {
+        val MessageSourceKind = RefineContextKey<MessageSourceKind>("MessageSourceKind")
+        val FromId = RefineContextKey<Long>("FromId")
+        val GroupIdOrZero = RefineContextKey<Long>("GroupIdOrZero")
+    }
 }
 
 /**
@@ -108,6 +115,8 @@ internal interface RefineContext {
     operator fun contains(key: RefineContextKey<*>): Boolean
     operator fun <T : Any> get(key: RefineContextKey<T>): T?
     fun <T : Any> getNotNull(key: RefineContextKey<T>): T = get(key) ?: error("No such value of `$key`")
+    fun merge(other: RefineContext, override: Boolean): RefineContext
+    fun entries(): Set<Pair<RefineContextKey<*>, Any>>
 }
 
 internal interface MutableRefineContext : RefineContext {
@@ -118,9 +127,19 @@ internal interface MutableRefineContext : RefineContext {
 internal object EmptyRefineContext : RefineContext {
     override fun contains(key: RefineContextKey<*>): Boolean = false
     override fun <T : Any> get(key: RefineContextKey<T>): T? = null
+    override fun merge(other: RefineContext, override: Boolean): RefineContext {
+        return other
+    }
+    override fun entries(): Set<Pair<RefineContextKey<*>, Any>> {
+        return emptySet()
+    }
     override fun toString(): String {
         return "EmptyRefineContext"
     }
+
+    override fun equals(other: Any?): Boolean {
+        return other === EmptyRefineContext
+    }
 }
 
 @Suppress("UNCHECKED_CAST")
@@ -140,8 +159,32 @@ internal class SimpleRefineContext(
     override fun remove(key: RefineContextKey<*>) {
         delegate.remove(key)
     }
+
+    override fun entries(): Set<Pair<RefineContextKey<*>, Any>> {
+        return delegate.entries.map { (k, v) -> k to v }.toSet()
+    }
+
+    override fun merge(other: RefineContext, override: Boolean): RefineContext {
+        val new = SimpleRefineContext(*entries().toTypedArray())
+        other.entries().forEach { (key, value) ->
+            if (new[key] == null || override) {
+                new[key as RefineContextKey<Any>] = value
+            }
+        }
+        return new
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (other !is RefineContext) return false
+        if (other === this) return true
+
+        return other.entries() == entries()
+    }
 }
 
+internal fun SimpleRefineContext(vararg elements: Pair<RefineContextKey<*>, Any>): SimpleRefineContext =
+    SimpleRefineContext(elements.toMap().toMutableMap())
+
 /**
  * 执行不需要 `suspend` 的 refine. 用于 [MessageSource.originalMessage].
  */

+ 243 - 0
mirai-core/src/commonMain/kotlin/message/data/shortVideo.kt

@@ -0,0 +1,243 @@
+/*
+ * 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.message.data
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.contact.Contact
+import net.mamoe.mirai.contact.getMember
+import net.mamoe.mirai.internal.asQQAndroidBot
+import net.mamoe.mirai.internal.message.RefinableMessage
+import net.mamoe.mirai.internal.message.RefineContext
+import net.mamoe.mirai.internal.message.RefineContextKey
+import net.mamoe.mirai.internal.message.protocol.impl.ShortVideoProtocol
+import net.mamoe.mirai.internal.network.protocol.data.proto.ImMsgBody
+import net.mamoe.mirai.internal.network.protocol.packet.chat.video.PttCenterSvr
+import net.mamoe.mirai.message.data.*
+import net.mamoe.mirai.utils.ExternalResource
+import net.mamoe.mirai.utils.toUHexString
+
+/**
+ * receive from pipeline and refine to [OnlineShortVideoImpl]
+ */
+internal class OnlineShortVideoMsgInternal(
+    private val videoFile: ImMsgBody.VideoFile
+) : RefinableMessage {
+
+    override fun tryRefine(bot: Bot, context: MessageChain, refineContext: RefineContext): Message? {
+        return null
+    }
+
+    override suspend fun refine(bot: Bot, context: MessageChain, refineContext: RefineContext): Message? {
+        bot.asQQAndroidBot()
+
+        val sourceKind = refineContext[RefineContextKey.MessageSourceKind] ?: return null
+        val fromId = refineContext[RefineContextKey.FromId] ?: return null
+        val groupId = refineContext[RefineContextKey.GroupIdOrZero] ?: return null
+
+        val contact = when (sourceKind) {
+            net.mamoe.mirai.message.data.MessageSourceKind.FRIEND -> bot.getFriend(fromId)
+            net.mamoe.mirai.message.data.MessageSourceKind.GROUP -> bot.getGroup(groupId)
+            else -> return null // TODO: ignore processing stranger's video message
+        } as Contact
+        val sender = when (sourceKind) {
+            net.mamoe.mirai.message.data.MessageSourceKind.FRIEND ->
+                bot.getFriend(fromId) ?: error("Cannot find friend $fromId.")
+            net.mamoe.mirai.message.data.MessageSourceKind.GROUP -> {
+                val group = bot.getGroup(groupId) ?: error("Cannot find group $groupId.")
+                group.getMember(fromId) ?: error("Cannot find member $fromId of group $groupId.")
+            }
+            else -> return null // TODO: ignore processing stranger's video message
+        }
+
+        val shortVideoDownloadReq = bot.network.sendAndExpect(
+            PttCenterSvr.ShortVideoDownReq(
+                bot.client,
+                contact,
+                sender,
+                videoFile.fileUuid.decodeToString(),
+                videoFile.fileMd5
+            )
+        )
+
+        if (shortVideoDownloadReq !is PttCenterSvr.ShortVideoDownReq.Response.Success)
+            throw IllegalStateException("Failed to query short video download attributes.")
+
+        if (!shortVideoDownloadReq.fileMd5.contentEquals(videoFile.fileMd5))
+            throw IllegalStateException(
+                "Queried short video download attributes doesn't match the requests. " +
+                        "message provides: ${videoFile.fileMd5.toUHexString("")}, " +
+                        "queried result: ${shortVideoDownloadReq.fileMd5.toUHexString("")}"
+            )
+
+        val format = ShortVideoProtocol.FORMAT
+            .firstOrNull { it.second == videoFile.fileFormat }?.first
+            ?: ExternalResource.DEFAULT_FORMAT_NAME
+
+        return OnlineShortVideoImpl(
+            videoFile.fileUuid.decodeToString(),
+            shortVideoDownloadReq.fileMd5,
+            videoFile.fileName.decodeToString(),
+            videoFile.fileSize.toLong(),
+            format,
+            shortVideoDownloadReq.urlV4,
+            ShortVideoThumbnail(
+                videoFile.thumbFileMd5,
+                videoFile.thumbFileSize.toLong(),
+                videoFile.thumbWidth,
+                videoFile.thumbHeight
+            )
+        )
+    }
+
+
+    override fun toString(): String {
+        return "OnlineShortVideoMsgInternal(videoElem=$videoFile)"
+    }
+
+    override fun contentToString(): String {
+        return "[视频元数据]"
+    }
+}
+
+@Serializable
+internal data class ShortVideoThumbnail(
+    val md5: ByteArray,
+    val size: Long,
+    val width: Int?,
+    val height: Int?,
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as ShortVideoThumbnail
+
+        if (!md5.contentEquals(other.md5)) return false
+        if (size != other.size) return false
+        if (width != other.width) return false
+        if (height != other.height) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = md5.contentHashCode()
+        result = 31 * result + size.hashCode()
+        result = 31 * result + (width ?: 0)
+        result = 31 * result + (height ?: 0)
+        return result
+    }
+}
+
+internal abstract class AbstractShortVideoWithThumbnail : ShortVideo {
+    abstract val thumbnail: ShortVideoThumbnail
+}
+
+@Suppress("DuplicatedCode")
+@SerialName(OnlineShortVideo.SERIAL_NAME)
+@Serializable
+internal class OnlineShortVideoImpl(
+    override val videoId: String,
+    override val fileMd5: ByteArray,
+    override val filename: String,
+    override val fileSize: Long,
+    override val fileFormat: String,
+    override val urlForDownload: String,
+    override val thumbnail: ShortVideoThumbnail
+) : OnlineShortVideo, AbstractShortVideoWithThumbnail() {
+
+    override fun toString(): String {
+        return "[mirai:shortvideo:$videoId, videoName=$filename.$fileFormat, videoMd5=${fileMd5.toUHexString("")}, " +
+                "videoSize=${fileSize}, thumbnailMd5=${thumbnail.md5.toUHexString("")}, thumbnailSize=${thumbnail.size}]"
+    }
+
+    override fun contentToString(): String {
+        return "[视频]"
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as OnlineShortVideoImpl
+
+        if (videoId != other.videoId) return false
+        if (!fileMd5.contentEquals(other.fileMd5)) return false
+        if (filename != other.filename) return false
+        if (fileSize != other.fileSize) return false
+        if (fileFormat != other.fileFormat) return false
+        if (urlForDownload != other.urlForDownload) return false
+        if (thumbnail != other.thumbnail) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = videoId.hashCode()
+        result = 31 * result + fileMd5.contentHashCode()
+        result = 31 * result + filename.hashCode()
+        result = 31 * result + fileSize.hashCode()
+        result = 31 * result + fileFormat.hashCode()
+        result = 31 * result + urlForDownload.hashCode()
+        result = 31 * result + thumbnail.hashCode()
+        return result
+    }
+}
+
+@Serializable
+internal class OfflineShortVideoImpl(
+    override val videoId: String,
+    override val filename: String,
+    override val fileMd5: ByteArray,
+    override val fileSize: Long,
+    override val fileFormat: String,
+    override val thumbnail: ShortVideoThumbnail
+) : OfflineShortVideo, AbstractShortVideoWithThumbnail() {
+
+    /**
+     * offline short video uses
+     */
+    override fun toString(): String {
+        return "[mirai:shortvideo:$videoId, videoName=$filename.$fileFormat, videoMd5=${fileMd5.toUHexString("")}, " +
+                "videoSize=${fileSize}, thumbnailMd5=${thumbnail.md5.toUHexString("")}, thumbnailSize=${thumbnail.size}]"
+    }
+
+    override fun contentToString(): String {
+        return "[视频]"
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as OfflineShortVideoImpl
+
+        if (videoId != other.videoId) return false
+        if (filename != other.filename) return false
+        if (!fileMd5.contentEquals(other.fileMd5)) return false
+        if (fileSize != other.fileSize) return false
+        if (fileFormat != other.fileFormat) return false
+        if (thumbnail != other.thumbnail) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = videoId.hashCode()
+        result = 31 * result + filename.hashCode()
+        result = 31 * result + fileMd5.contentHashCode()
+        result = 31 * result + fileSize.hashCode()
+        result = 31 * result + fileFormat.hashCode()
+        result = 31 * result + thumbnail.hashCode()
+        return result
+    }
+}

+ 41 - 0
mirai-core/src/commonMain/kotlin/message/image/InternalShortVideoProtocolImpl.kt

@@ -0,0 +1,41 @@
+/*
+ * 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.message.image
+
+import net.mamoe.mirai.internal.message.data.OfflineShortVideoImpl
+import net.mamoe.mirai.internal.message.data.ShortVideoThumbnail
+import net.mamoe.mirai.message.data.InternalShortVideoProtocol
+import net.mamoe.mirai.message.data.OfflineShortVideo
+
+internal class InternalShortVideoProtocolImpl : InternalShortVideoProtocol {
+    override fun createOfflineShortVideo(
+        videoId: String,
+        fileMd5: ByteArray,
+        fileSize: Long,
+        fileFormat: String,
+        fileName: String,
+        thumbnailMd5: ByteArray,
+        thumbnailSize: Long
+    ): OfflineShortVideo {
+        return OfflineShortVideoImpl(
+            videoId,
+            fileName,
+            fileMd5,
+            fileSize,
+            fileFormat,
+            ShortVideoThumbnail(
+                thumbnailMd5,
+                thumbnailSize,
+                0,
+                0
+            )
+        )
+    }
+}

+ 5 - 1
mirai-core/src/commonMain/kotlin/message/protocol/MessageProtocolFacade.kt

@@ -135,7 +135,9 @@ internal interface MessageProtocolFacade {
         groupIdOrZero: Long,
         messageSourceKind: MessageSourceKind,
         bot: Bot,
-    ): MessageChain = buildMessageChain { decode(elements, groupIdOrZero, messageSourceKind, bot, this, null) }
+    ): MessageChain = buildMessageChain {
+        decode(elements, groupIdOrZero, messageSourceKind, bot, this, null)
+    }
 
 
     fun createSerializersModule(): SerializersModule = SerializersModule {
@@ -336,6 +338,7 @@ internal class MessageProtocolFacadeImpl(
 
         return getSingleReceipt(result, message)
     }
+
     override suspend fun <C : AbstractContact> preprocessAndSendOutgoing(
         target: C,
         message: Message,
@@ -378,6 +381,7 @@ internal class MessageProtocolFacadeImpl(
                 "Internal error: no MessageReceipt was returned from OutgoingMessagePipeline for message",
                 forDebug = message.structureToString()
             )
+
             1 -> return result.single().castUp()
             else -> throw contextualBugReportException(
                 "Internal error: multiple MessageReceipts were returned from OutgoingMessagePipeline: $result",

+ 1 - 0
mirai-core/src/commonMain/kotlin/message/protocol/decode/MessageDecoderPipeline.kt

@@ -29,6 +29,7 @@ internal interface MessageDecoderContext : ProcessorPipelineContext<ImMsgBody.El
         val MESSAGE_SOURCE_KIND = TypeKey<MessageSourceKind>("messageSourceKind")
         val GROUP_ID = TypeKey<Long>("groupId") // zero if not group
         val CONTAINING_MSG = TypeKey<MsgComm.Msg?>("containingMsg")
+        val FROM_ID = TypeKey<Long>("fromId") // group/temp = sender, friend/stranger = this
     }
 }
 

+ 98 - 0
mirai-core/src/commonMain/kotlin/message/protocol/impl/ShortVideoProtocol.kt

@@ -0,0 +1,98 @@
+/*
+ * 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.message.protocol.impl
+
+import net.mamoe.mirai.internal.message.data.AbstractShortVideoWithThumbnail
+import net.mamoe.mirai.internal.message.data.OfflineShortVideoImpl
+import net.mamoe.mirai.internal.message.data.OnlineShortVideoImpl
+import net.mamoe.mirai.internal.message.data.OnlineShortVideoMsgInternal
+import net.mamoe.mirai.internal.message.protocol.MessageProtocol
+import net.mamoe.mirai.internal.message.protocol.ProcessorCollector
+import net.mamoe.mirai.internal.message.protocol.decode.MessageDecoder
+import net.mamoe.mirai.internal.message.protocol.decode.MessageDecoderContext
+import net.mamoe.mirai.internal.message.protocol.encode.MessageEncoder
+import net.mamoe.mirai.internal.message.protocol.encode.MessageEncoderContext
+import net.mamoe.mirai.internal.message.protocol.serialization.MessageSerializer
+import net.mamoe.mirai.internal.network.protocol.data.proto.ImMsgBody
+import net.mamoe.mirai.message.data.MessageContent
+import net.mamoe.mirai.message.data.ShortVideo
+import net.mamoe.mirai.message.data.SingleMessage
+
+internal class ShortVideoProtocol : MessageProtocol() {
+    override fun ProcessorCollector.collectProcessorsImpl() {
+        add(Decoder())
+        add(Encoder())
+
+        MessageSerializer.superclassesScope(ShortVideo::class, MessageContent::class, SingleMessage::class) {
+            add(MessageSerializer(OfflineShortVideoImpl::class, OfflineShortVideoImpl.serializer()))
+            add(MessageSerializer(OnlineShortVideoImpl::class, OnlineShortVideoImpl.serializer()))
+        }
+    }
+
+    private class Decoder : MessageDecoder {
+        override suspend fun MessageDecoderContext.process(data: ImMsgBody.Elem) {
+            val videoFile = data.videoFile ?: return
+            markAsConsumed()
+
+            collect(OnlineShortVideoMsgInternal(videoFile))
+        }
+    }
+
+    private class Encoder : MessageEncoder<AbstractShortVideoWithThumbnail> {
+        override suspend fun MessageEncoderContext.process(data: AbstractShortVideoWithThumbnail) {
+            markAsConsumed()
+
+            collect(ImMsgBody.Elem(text = ImMsgBody.Text("你的 QQ 暂不支持查看视频短片,请期待后续版本。")))
+
+            val thumbWidth = if (data.thumbnail.width == null || data.thumbnail.width == 0) 1280 else data.thumbnail.width!!
+            val thumbHeight = if (data.thumbnail.height == null || data.thumbnail.height == 0) 720 else data.thumbnail.height!!
+
+            collect(
+                ImMsgBody.Elem(
+                    videoFile = ImMsgBody.VideoFile(
+                        fileUuid = data.videoId.encodeToByteArray(),
+                        fileMd5 = data.fileMd5,
+                        fileName = data.filename.encodeToByteArray(),
+                        fileFormat = FORMAT.firstOrNull { it.first == data.fileFormat }?.second ?: 3,
+                        fileTime = 10,
+                        fileSize = data.fileSize.toInt(),
+                        thumbWidth = thumbWidth,
+                        thumbHeight = thumbHeight,
+                        thumbFileMd5 = data.thumbnail.md5,
+                        thumbFileSize = data.thumbnail.size.toInt(),
+                        busiType = 0,
+                        fromChatType = -1,
+                        toChatType = -1,
+                        boolSupportProgressive = true,
+                        fileWidth = thumbWidth,
+                        fileHeight = thumbHeight
+                    )
+                )
+            )
+        }
+
+    }
+
+     internal companion object {
+         internal val FORMAT: List<Pair<String, Int>> = listOf(
+             "ts" to 1,
+             "avi" to 2,
+             "mp4" to 3,
+             "wmv" to 4,
+             "mkv" to 5,
+             "rmvb" to 6,
+             "rm" to 7,
+             "afs" to 8,
+             "mov" to 9,
+             "mod" to 10,
+             "mts" to 11
+         )
+     }
+}

+ 7 - 1
mirai-core/src/commonMain/kotlin/message/source/offlineSourceImpl.kt

@@ -16,6 +16,8 @@ import kotlinx.serialization.Serializable
 import kotlinx.serialization.Transient
 import net.mamoe.mirai.Bot
 import net.mamoe.mirai.internal.message.MessageSourceSerializerImpl
+import net.mamoe.mirai.internal.message.RefineContextKey
+import net.mamoe.mirai.internal.message.SimpleRefineContext
 import net.mamoe.mirai.internal.message.toMessageChainNoSource
 import net.mamoe.mirai.internal.network.protocol.data.proto.ImMsgBody
 import net.mamoe.mirai.internal.network.protocol.data.proto.MsgComm
@@ -183,7 +185,10 @@ internal fun OfflineMessageSourceImplData(
         internalIds = delegate.pbReserve.loadAs(SourceMsg.ResvAttr.serializer())
             .origUids?.mapToIntArray { it.toInt() } ?: intArrayOf(),
         time = delegate.time,
-        originalMessageLazy = lazy { delegate.toMessageChainNoSource(bot, messageSourceKind, groupIdOrZero) },
+        originalMessageLazy = lazy {
+            val context = SimpleRefineContext(RefineContextKey.FromId to delegate.senderUin)
+            delegate.toMessageChainNoSource(bot, messageSourceKind, groupIdOrZero, context)
+        },
         fromId = delegate.senderUin,
         targetId = when {
             groupIdOrZero != 0L -> groupIdOrZero
@@ -191,6 +196,7 @@ internal fun OfflineMessageSourceImplData(
             delegate.srcMsg != null -> runCatching {
                 delegate.srcMsg.loadAs(MsgComm.Msg.serializer()).msgHead.toUin
             }.getOrElse { 0L }
+
             else -> 0/*error("cannot find targetId. delegate=${delegate._miraiContentToString()}, delegate.srcMsg=${
             kotlin.runCatching { delegate.srcMsg?.loadAs(MsgComm.Msg.serializer())?._miraiContentToString() }
                 .fold(

+ 2 - 0
mirai-core/src/commonMain/kotlin/network/highway/Highway.kt

@@ -126,6 +126,8 @@ internal enum class ResourceKind(
     FORWARD_MESSAGE("forward message"),
 
     ANNOUNCEMENT_IMAGE("announcement image"),
+
+    SHORT_VIDEO("short video")
     ;
 
     override fun toString(): String = display

+ 26 - 2
mirai-core/src/commonMain/kotlin/network/notice/group/GroupMessageProcessor.kt

@@ -20,6 +20,8 @@ import net.mamoe.mirai.event.events.MemberCardChangeEvent
 import net.mamoe.mirai.event.events.MemberSpecialTitleChangeEvent
 import net.mamoe.mirai.internal.contact.*
 import net.mamoe.mirai.internal.contact.info.MemberInfoImpl
+import net.mamoe.mirai.internal.message.RefineContextKey
+import net.mamoe.mirai.internal.message.SimpleRefineContext
 import net.mamoe.mirai.internal.message.toMessageChainOnline
 import net.mamoe.mirai.internal.network.Packet
 import net.mamoe.mirai.internal.network.components.NoticePipelineContext
@@ -159,7 +161,18 @@ internal class GroupMessageProcessor(
                 GroupMessageSyncEvent(
                     client = bot.otherClients.find { it.appId == msgHead.fromInstid }
                         ?: return, // don't compare with dstAppId. diff.
-                    message = msgs.map { it.msg }.toMessageChainOnline(bot, group.id, MessageSourceKind.GROUP),
+                    message = msgs.map { it.msg }.toMessageChainOnline(
+                        bot,
+                        group.id,
+                        MessageSourceKind.GROUP,
+                        SimpleRefineContext(
+                            mutableMapOf(
+                                RefineContextKey.MessageSourceKind to MessageSourceKind.GROUP,
+                                RefineContextKey.FromId to sender.uin,
+                                RefineContextKey.GroupIdOrZero to group.uin,
+                            )
+                        )
+                    ),
                     time = msgHead.msgTime,
                     group = group,
                     sender = sender,
@@ -174,7 +187,18 @@ internal class GroupMessageProcessor(
                 GroupMessageEvent(
                     senderName = nameCard.nick,
                     sender = sender,
-                    message = msgs.map { it.msg }.toMessageChainOnline(bot, group.id, MessageSourceKind.GROUP),
+                    message = msgs.map { it.msg }.toMessageChainOnline(
+                        bot,
+                        group.id,
+                        MessageSourceKind.GROUP,
+                        SimpleRefineContext(
+                            mutableMapOf(
+                                RefineContextKey.MessageSourceKind to MessageSourceKind.GROUP,
+                                RefineContextKey.FromId to sender.uin,
+                                RefineContextKey.GroupIdOrZero to group.uin,
+                            )
+                        )
+                    ),
                     permission = sender.permission,
                     time = msgHead.msgTime,
                 ),

+ 14 - 1
mirai-core/src/commonMain/kotlin/network/notice/priv/PrivateMessageProcessor.kt

@@ -15,6 +15,8 @@ import net.mamoe.mirai.event.Event
 import net.mamoe.mirai.event.events.*
 import net.mamoe.mirai.internal.contact.*
 import net.mamoe.mirai.internal.getGroupByUinOrCode
+import net.mamoe.mirai.internal.message.RefineContextKey
+import net.mamoe.mirai.internal.message.SimpleRefineContext
 import net.mamoe.mirai.internal.message.toMessageChainOnline
 import net.mamoe.mirai.internal.network.Packet
 import net.mamoe.mirai.internal.network.components.NoticePipelineContext
@@ -25,6 +27,7 @@ import net.mamoe.mirai.internal.network.components.SsoProcessor
 import net.mamoe.mirai.internal.network.notice.group.GroupMessageProcessor
 import net.mamoe.mirai.internal.network.protocol.data.proto.MsgComm
 import net.mamoe.mirai.internal.network.protocol.packet.chat.voice.PttStore
+import net.mamoe.mirai.message.data.MessageSourceKind
 import net.mamoe.mirai.utils.assertUnreachable
 import net.mamoe.mirai.utils.context
 
@@ -114,6 +117,7 @@ internal class PrivateMessageProcessor : SimpleNoticeProcessor<MsgComm.Msg>(type
                 val group = bot.getGroupByUinOrCode(tmpHead.groupUin) ?: return
                 handlePrivateMessage(data, group[senderUin] ?: return)
             }
+
             else -> markNotConsumed()
         }
 
@@ -129,7 +133,16 @@ internal class PrivateMessageProcessor : SimpleNoticeProcessor<MsgComm.Msg>(type
         val msgs = user.fragmentedMessageMerger.tryMerge(this)
         if (msgs.isEmpty()) return
 
-        val chain = msgs.toMessageChainOnline(bot, 0, user.correspondingMessageSourceKind)
+        val chain = msgs.toMessageChainOnline(
+            bot,
+            0,
+            user.correspondingMessageSourceKind,
+            SimpleRefineContext(
+                RefineContextKey.MessageSourceKind to MessageSourceKind.FRIEND,
+                RefineContextKey.FromId to user.uin,
+                RefineContextKey.GroupIdOrZero to 0L,
+            )
+        )
         val time = msgHead.msgTime
 
         collected += if (fromSync) {

+ 237 - 0
mirai-core/src/commonMain/kotlin/network/protocol/data/proto/PttShortVideo.kt

@@ -0,0 +1,237 @@
+/*
+ * 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.network.protocol.data.proto
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.protobuf.ProtoNumber
+import net.mamoe.mirai.internal.utils.io.ProtoBuf
+import net.mamoe.mirai.utils.EMPTY_BYTE_ARRAY
+
+@Serializable
+internal class PttShortVideo : ProtoBuf {
+    @Serializable
+    internal class ServerListInfo(
+        @JvmField @ProtoNumber(1) val upIp: Int = 0,
+        @JvmField @ProtoNumber(2) val upPort: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class CodecConfigReq(
+        @JvmField @ProtoNumber(1) val platformChipinfo: String = "",
+        @JvmField @ProtoNumber(2) val osVersion: String = "",
+        @JvmField @ProtoNumber(3) val deviceName: String = ""
+    ) : ProtoBuf
+
+    @Serializable
+    internal class DataHole(
+        @JvmField @ProtoNumber(1) val begin: Long = 0L,
+        @JvmField @ProtoNumber(2) val end: Long = 0L
+    ) : ProtoBuf
+
+    @Serializable
+    internal class ExtensionReq(
+        @JvmField @ProtoNumber(1) val subBusiType: Int = 0,
+        @JvmField @ProtoNumber(2) val userCnt: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class PttShortVideoAddr(
+        @JvmField @ProtoNumber(1) val hostType: Int = 0,
+        @JvmField @ProtoNumber(10) val strHost: List<String> = emptyList(),
+        @JvmField @ProtoNumber(11) val urlArgs: String = "",
+        @JvmField @ProtoNumber(21) val strHostIpv6: List<String> = emptyList(),
+        @JvmField @ProtoNumber(22) val strDomain: List<String> = emptyList()
+    ) : ProtoBuf
+
+    @Serializable
+    internal class PttShortVideoDeleteReq(
+        @JvmField @ProtoNumber(1) val fromuin: Long = 0L,
+        @JvmField @ProtoNumber(2) val touin: Long = 0L,
+        @JvmField @ProtoNumber(3) val chatType: Int = 0,
+        @JvmField @ProtoNumber(4) val clientType: Int = 0,
+        @JvmField @ProtoNumber(5) val fileid: String = "",
+        @JvmField @ProtoNumber(6) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(7) val agentType: Int = 0,
+        @JvmField @ProtoNumber(8) val fileMd5: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(9) val businessType: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class PttShortVideoDeleteResp(
+        @JvmField @ProtoNumber(1) val int32RetCode: Int = 0,
+        @JvmField @ProtoNumber(2) val retMsg: String = ""
+    ) : ProtoBuf
+
+    @Serializable
+    internal class PttShortVideoDownloadReq(
+        @JvmField @ProtoNumber(1) val fromuin: Long = 0L,
+        @JvmField @ProtoNumber(2) val touin: Long = 0L,
+        @JvmField @ProtoNumber(3) val chatType: Int = 0,
+        @JvmField @ProtoNumber(4) val clientType: Int = 0,
+        @JvmField @ProtoNumber(5) val fileid: String = "",
+        @JvmField @ProtoNumber(6) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(7) val agentType: Int = 0,
+        @JvmField @ProtoNumber(8) val fileMd5: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(9) val businessType: Int = 0,
+        @JvmField @ProtoNumber(10) val fileType: Int = 0,
+        @JvmField @ProtoNumber(11) val downType: Int = 0,
+        @JvmField @ProtoNumber(12) val sceneType: Int = 0,
+        @JvmField @ProtoNumber(13) val needInnerAddr: Int = 0,
+        @JvmField @ProtoNumber(14) val reqTransferType: Int = 0,
+        @JvmField @ProtoNumber(15) val reqHostType: Int = 0,
+        @JvmField @ProtoNumber(20) val flagSupportLargeSize: Int = 0,
+        @JvmField @ProtoNumber(30) val flagClientQuicProtoEnable: Int = 0,
+        @JvmField @ProtoNumber(31) val targetCodecFormat: Int = 0,
+        @JvmField @ProtoNumber(32) val msgCodecConfig: CodecConfigReq? = null,
+        @JvmField @ProtoNumber(33) val sourceCodecFormat: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class PttShortVideoDownloadResp(
+        @JvmField @ProtoNumber(1) val int32RetCode: Int = 0,
+        @JvmField @ProtoNumber(2) val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val sameAreaOutAddr: List<PttShortVideoIpList> = emptyList(),
+        @JvmField @ProtoNumber(4) val diffAreaOutAddr: List<PttShortVideoIpList> = emptyList(),
+        @JvmField @ProtoNumber(5) val downloadkey: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(6) val fileMd5: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(7) val sameAreaInnerAddr: List<PttShortVideoIpList> = emptyList(),
+        @JvmField @ProtoNumber(8) val diffAreaInnerAddr: List<PttShortVideoIpList> = emptyList(),
+        @JvmField @ProtoNumber(9) val msgDownloadAddr: PttShortVideoAddr? = null,
+        @JvmField @ProtoNumber(10) val encryptKey: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(30) val flagServerQuicProtoEnable: Int = 0,
+        @JvmField @ProtoNumber(31) val serverQuicPara: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(32) val codecFormat: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class PttShortVideoFileInfo(
+        @JvmField @ProtoNumber(1) val fileName: String = "",
+        @JvmField @ProtoNumber(2) val fileMd5: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(3) val thumbFileMd5: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(4) val fileSize: Long = 0L,
+        @JvmField @ProtoNumber(5) val fileResLength: Int = 0,
+        @JvmField @ProtoNumber(6) val fileResWidth: Int = 0,
+        @JvmField @ProtoNumber(7) val fileFormat: Int = 0,
+        @JvmField @ProtoNumber(8) val fileTime: Int = 0,
+        @JvmField @ProtoNumber(9) val thumbFileSize: Long = 0L,
+        @JvmField @ProtoNumber(10) val decryptVideoMd5: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(11) val decryptFileSize: Long = 0L,
+        @JvmField @ProtoNumber(12) val decryptThumbMd5: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(13) val decryptThumbSize: Long = 0L,
+        @JvmField @ProtoNumber(14) val extend: ByteArray = EMPTY_BYTE_ARRAY
+    ) : ProtoBuf
+
+    @Serializable
+    internal class PttShortVideoFileInfoExtend(
+        @JvmField @ProtoNumber(1) val bitRate: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class PttShortVideoIpList(
+        @JvmField @ProtoNumber(1) val ip: Int = 0,
+        @JvmField @ProtoNumber(2) val port: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class PttShortVideoRetweetReq(
+        @JvmField @ProtoNumber(1) val fromUin: Long = 0L,
+        @JvmField @ProtoNumber(2) val toUin: Long = 0L,
+        @JvmField @ProtoNumber(3) val fromChatType: Int = 0,
+        @JvmField @ProtoNumber(4) val toChatType: Int = 0,
+        @JvmField @ProtoNumber(5) val fromBusiType: Int = 0,
+        @JvmField @ProtoNumber(6) val toBusiType: Int = 0,
+        @JvmField @ProtoNumber(7) val clientType: Int = 0,
+        @JvmField @ProtoNumber(8) val msgPttShortVideoFileInfo: PttShortVideoFileInfo? = null,
+        @JvmField @ProtoNumber(9) val agentType: Int = 0,
+        @JvmField @ProtoNumber(10) val fileid: String = "",
+        @JvmField @ProtoNumber(11) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(20) val flagSupportLargeSize: Int = 0,
+        @JvmField @ProtoNumber(21) val codecFormat: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class PttShortVideoRetweetResp(
+        @JvmField @ProtoNumber(1) val int32RetCode: Int = 0,
+        @JvmField @ProtoNumber(2) val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val sameAreaOutAddr: List<PttShortVideoIpList> = emptyList(),
+        @JvmField @ProtoNumber(4) val diffAreaOutAddr: List<PttShortVideoIpList> = emptyList(),
+        @JvmField @ProtoNumber(5) val fileid: String = "",
+        @JvmField @ProtoNumber(6) val ukey: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(7) val fileExist: Int = 0,
+        @JvmField @ProtoNumber(8) val sameAreaInnerAddr: List<PttShortVideoIpList> = emptyList(),
+        @JvmField @ProtoNumber(9) val diffAreaInnerAddr: List<PttShortVideoIpList> = emptyList(),
+        @JvmField @ProtoNumber(10) val dataHole: List<DataHole> = emptyList(),
+        @JvmField @ProtoNumber(11) val isHotFile: Int = 0,
+        @JvmField @ProtoNumber(12) val longVideoCarryWatchPointType: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class PttShortVideoUploadReq(
+        @JvmField @ProtoNumber(1) val fromuin: Long = 0L,
+        @JvmField @ProtoNumber(2) val touin: Long = 0L,
+        @JvmField @ProtoNumber(3) val chatType: Int = 0,
+        @JvmField @ProtoNumber(4) val clientType: Int = 0,
+        @JvmField @ProtoNumber(5) val msgPttShortVideoFileInfo: PttShortVideoFileInfo? = null,
+        @JvmField @ProtoNumber(6) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(7) val agentType: Int = 0,
+        @JvmField @ProtoNumber(8) val businessType: Int = 0,
+        @JvmField @ProtoNumber(9) val encryptKey: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(10) val subBusinessType: Int = 0,
+        @JvmField @ProtoNumber(20) val flagSupportLargeSize: Int = 0,
+        @JvmField @ProtoNumber(21) val codecFormat: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class PttShortVideoUploadResp(
+        @JvmField @ProtoNumber(1) val int32RetCode: Int = 0,
+        @JvmField @ProtoNumber(2) val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val sameAreaOutAddr: List<PttShortVideoIpList> = emptyList(),
+        @JvmField @ProtoNumber(4) val diffAreaOutAddr: List<PttShortVideoIpList> = emptyList(),
+        @JvmField @ProtoNumber(5) val fileid: String = "",
+        @JvmField @ProtoNumber(6) val ukey: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(7) val fileExist: Int = 0,
+        @JvmField @ProtoNumber(8) val sameAreaInnerAddr: List<PttShortVideoIpList> = emptyList(),
+        @JvmField @ProtoNumber(9) val diffAreaInnerAddr: List<PttShortVideoIpList> = emptyList(),
+        @JvmField @ProtoNumber(10) val dataHole: List<DataHole> = emptyList(),
+        @JvmField @ProtoNumber(11) val encryptKey: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(12) val isHotFile: Int = 0,
+        @JvmField @ProtoNumber(13) val longVideoCarryWatchPointType: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class QuicParameter(
+        @JvmField @ProtoNumber(1) val enableQuic: Int = 0,
+        @JvmField @ProtoNumber(2) val encryptionVer: Int = 1,
+        @JvmField @ProtoNumber(3) val fecVer: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class ReqBody(
+        @JvmField @ProtoNumber(1) val cmd: Int = 0,
+        @JvmField @ProtoNumber(2) val seq: Int = 0,
+        @JvmField @ProtoNumber(3) val msgPttShortVideoUploadReq: PttShortVideoUploadReq? = null,
+        @JvmField @ProtoNumber(4) val msgPttShortVideoDownloadReq: PttShortVideoDownloadReq? = null,
+        @JvmField @ProtoNumber(5) val msgShortVideoRetweetReq: List<PttShortVideoRetweetReq> = emptyList(),
+        @JvmField @ProtoNumber(6) val msgShortVideoDeleteReq: List<PttShortVideoDeleteReq> = emptyList(),
+        @JvmField @ProtoNumber(100) val msgExtensionReq: List<ExtensionReq> = emptyList()
+    ) : ProtoBuf
+
+    @Serializable
+    internal class RspBody(
+        @JvmField @ProtoNumber(1) val cmd: Int = 0,
+        @JvmField @ProtoNumber(2) val seq: Int = 0,
+        @JvmField @ProtoNumber(3) val msgPttShortVideoUploadResp: PttShortVideoUploadResp? = null,
+        @JvmField @ProtoNumber(4) val msgPttShortVideoDownloadResp: PttShortVideoDownloadResp? = null,
+        @JvmField @ProtoNumber(5) val msgShortVideoRetweetResp: List<PttShortVideoRetweetResp> = emptyList(),
+        @JvmField @ProtoNumber(6) val msgShortVideoDeleteResp: List<PttShortVideoDeleteResp> = emptyList(),
+        @JvmField @ProtoNumber(100) val changeChannel: Int = 0,
+        @JvmField @ProtoNumber(101) val allowRetry: Int = 0
+    ) : ProtoBuf
+}

+ 3 - 0
mirai-core/src/commonMain/kotlin/network/protocol/packet/PacketFactory.kt

@@ -18,6 +18,7 @@ import net.mamoe.mirai.internal.network.protocol.packet.chat.*
 import net.mamoe.mirai.internal.network.protocol.packet.chat.image.ImgStore
 import net.mamoe.mirai.internal.network.protocol.packet.chat.image.LongConn
 import net.mamoe.mirai.internal.network.protocol.packet.chat.receive.*
+import net.mamoe.mirai.internal.network.protocol.packet.chat.video.PttCenterSvr
 import net.mamoe.mirai.internal.network.protocol.packet.chat.voice.PttStore
 import net.mamoe.mirai.internal.network.protocol.packet.list.FriendList
 import net.mamoe.mirai.internal.network.protocol.packet.list.ProfileService
@@ -151,6 +152,8 @@ internal object KnownPacketFactories {
         PttStore.GroupPttUp,
         PttStore.GroupPttDown,
         PttStore.C2CPttDown,
+        PttCenterSvr.GroupShortVideoUpReq,
+        PttCenterSvr.ShortVideoDownReq,
         LongConn.OffPicUp,
 //        LongConn.OffPicDown,
         TroopManagement.EditSpecialTitle,

+ 187 - 0
mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/shortvideo/PttCenterSvr.kt

@@ -0,0 +1,187 @@
+/*
+ * 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.network.protocol.packet.chat.video
+
+import io.ktor.utils.io.core.*
+import net.mamoe.mirai.contact.Contact
+import net.mamoe.mirai.contact.Friend
+import net.mamoe.mirai.contact.Group
+import net.mamoe.mirai.contact.User
+import net.mamoe.mirai.internal.QQAndroidBot
+import net.mamoe.mirai.internal.contact.uin
+import net.mamoe.mirai.internal.network.Packet
+import net.mamoe.mirai.internal.network.QQAndroidClient
+import net.mamoe.mirai.internal.network.protocol.data.proto.PttShortVideo
+import net.mamoe.mirai.internal.network.protocol.packet.OutgoingPacketFactory
+import net.mamoe.mirai.internal.network.protocol.packet.buildOutgoingUniPacket
+import net.mamoe.mirai.internal.utils.io.serialization.readProtoBuf
+import net.mamoe.mirai.internal.utils.io.serialization.writeProtoBuf
+
+internal class PttCenterSvr {
+    object GroupShortVideoUpReq :
+        OutgoingPacketFactory<GroupShortVideoUpReq.Response>("PttCenterSvr.GroupShortVideoUpReq") {
+        sealed class Response : Packet {
+            class FileExists(val fileId: String) : Response() {
+                override fun toString(): String {
+                    return "PttCenterSvr.GroupShortVideoUpReq.Response.FileExists(fileId=${fileId})"
+                }
+            }
+
+            object RequireUpload : Response() {
+                override fun toString(): String {
+                    return "PttCenterSvr.GroupShortVideoUpReq.Response.RequireUpload"
+                }
+            }
+        }
+
+        override suspend fun ByteReadPacket.decode(bot: QQAndroidBot): Response {
+            val resp = readProtoBuf(PttShortVideo.RspBody.serializer())
+            val upResp = resp.msgPttShortVideoUploadResp ?: return Response.RequireUpload
+
+            return if (upResp.fileExist == 1) {
+                Response.FileExists(upResp.fileid)
+            } else {
+                Response.RequireUpload
+            }
+        }
+
+        operator fun invoke(
+            client: QQAndroidClient,
+            contact: Contact,
+            thumbnailFileMd5: ByteArray,
+            thumbnailFileSize: Long,
+            videoFileName: String,
+            videoFileMd5: ByteArray,
+            videoFileSize: Long,
+            videoFileFormat: String
+        ) = buildOutgoingUniPacket(client) { sequenceId ->
+            writeProtoBuf(
+                PttShortVideo.ReqBody.serializer(),
+                PttShortVideo.ReqBody(
+                    cmd = 300,
+                    seq = sequenceId,
+                    msgPttShortVideoUploadReq = buildShortVideoFileInfo(
+                        client,
+                        contact,
+                        thumbnailFileMd5,
+                        thumbnailFileSize,
+                        videoFileName,
+                        videoFileMd5,
+                        videoFileSize,
+                        videoFileFormat
+                    ),
+                    msgExtensionReq = listOf(
+                        PttShortVideo.ExtensionReq(
+                            subBusiType = 0,
+                            userCnt = 1
+                        )
+                    )
+                )
+            )
+        }
+
+        internal fun buildShortVideoFileInfo(
+            client: QQAndroidClient,
+            contact: Contact,
+            thumbnailFileMd5: ByteArray,
+            thumbnailFileSize: Long,
+            videoFileName: String,
+            videoFileMd5: ByteArray,
+            videoFileSize: Long,
+            videoFileFormat: String
+        ) = PttShortVideo.PttShortVideoUploadReq(
+            fromuin = client.uin,
+            touin = contact.uin,
+            chatType = 1, // guild channel = 4, others = 1
+            clientType = 2,
+            msgPttShortVideoFileInfo = PttShortVideo.PttShortVideoFileInfo(
+                fileName = videoFileName + videoFileFormat,
+                fileMd5 = videoFileMd5,
+                fileSize = videoFileSize,
+                fileResLength = 1280,
+                fileResWidth = 720,
+                // Lcom/tencent/mobileqq/transfile/ShortVideoUploadProcessor;getFormat(Ljava/lang/String;)I
+                fileFormat = 3,
+                fileTime = 120,
+                thumbFileMd5 = thumbnailFileMd5,
+                thumbFileSize = thumbnailFileSize
+            ),
+            groupCode = if (contact is Group) contact.uin else 0,
+            flagSupportLargeSize = 1
+        )
+    }
+
+    object ShortVideoDownReq : OutgoingPacketFactory<ShortVideoDownReq.Response>("PttCenterSvr.ShortVideoDownReq") {
+        sealed class Response : Packet {
+            class Success(val fileMd5: ByteArray, val urlV4: String, val urlV6: String?) : Response() {
+                override fun toString(): String {
+                    return "PttCenterSvr.ShortVideoDownReq.Response.Success(" +
+                            "urlV4=$urlV4, urlV6=$urlV6)"
+                }
+            }
+
+            object Failed : Response() {
+                override fun toString(): String {
+                    return "PttCenterSvr.ShortVideoDownReq.Response.Failed"
+                }
+            }
+        }
+
+        override suspend fun ByteReadPacket.decode(bot: QQAndroidBot): Response {
+            val resp = readProtoBuf(PttShortVideo.RspBody.serializer())
+
+            val shortVideoDownloadResp = resp.msgPttShortVideoDownloadResp ?: return Response.Failed
+            val attr = shortVideoDownloadResp.msgDownloadAddr ?: return Response.Failed
+
+            val fileMd5 = shortVideoDownloadResp.fileMd5
+            val urlV4 = attr.strHost.first() + attr.urlArgs
+            val urlV6 = attr.strHostIpv6.firstOrNull()?.plus(attr.urlArgs)
+
+            return Response.Success(fileMd5, urlV4, urlV6)
+        }
+
+        // Lcom/tencent/mobileqq/transfile/protohandler/ShortVideoDownHandler;constructReqBody(Ljava/util/List;)[B
+        operator fun invoke(
+            client: QQAndroidClient,
+            contact: Contact,
+            sender: User,
+            videoFIleId: String,
+            videoFileMd5: ByteArray,
+        ) = buildOutgoingUniPacket(client) { sequenceId ->
+            writeProtoBuf(
+                PttShortVideo.ReqBody.serializer(),
+                PttShortVideo.ReqBody(
+                    cmd = 400,
+                    seq = sequenceId,
+                    msgPttShortVideoDownloadReq = PttShortVideo.PttShortVideoDownloadReq(
+                        fromuin = sender.uin,
+                        touin = client.uin,
+                        chatType = if (sender is Friend) 0 else 1,
+                        clientType = 7,
+                        fileid = videoFIleId,
+                        groupCode = if (contact is Group) contact.uin else 0L,
+                        fileMd5 = videoFileMd5,
+                        businessType = 1,
+                        flagSupportLargeSize = 1,
+                        flagClientQuicProtoEnable = 1,
+                        fileType = 2, // maybe 1 = newly uploaded video, unverified
+                        downType = 2,
+                        sceneType = 2, // hooked 0 and 1, but unknown
+                        reqTransferType = 1,
+                        reqHostType = 11,
+                    ),
+                    msgExtensionReq = listOf(
+                        PttShortVideo.ExtensionReq(subBusiType = 0)
+                    )
+                )
+            )
+        }
+    }
+}

+ 15 - 0
mirai-core/src/commonMain/kotlin/utils/ExternalResourceImpl.kt

@@ -0,0 +1,15 @@
+/*
+ * 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.utils
+
+import net.mamoe.mirai.utils.ExternalResource
+
+@Suppress("FunctionName")
+internal expect fun CombinedExternalResource(vararg resources: ExternalResource): ExternalResource

+ 4 - 0
mirai-core/src/commonMain/kotlin/utils/MiraiCoreServices.kt

@@ -81,6 +81,10 @@ internal object MiraiCoreServices {
             msgProtocol,
             "net.mamoe.mirai.internal.message.protocol.impl.RichMessageProtocol"
         ) { net.mamoe.mirai.internal.message.protocol.impl.RichMessageProtocol() }
+        Services.register(
+            msgProtocol,
+            "net.mamoe.mirai.internal.message.protocol.impl.ShortVideoProtocol"
+        ) { net.mamoe.mirai.internal.message.protocol.impl.ShortVideoProtocol() }
         Services.register(
             msgProtocol,
             "net.mamoe.mirai.internal.message.protocol.impl.TextProtocol"

+ 1 - 0
mirai-core/src/commonMain/resources/META-INF/services/net.mamoe.mirai.internal.message.protocol.MessageProtocol

@@ -20,6 +20,7 @@ net.mamoe.mirai.internal.message.protocol.impl.PokeMessageProtocol
 net.mamoe.mirai.internal.message.protocol.impl.PttMessageProtocol
 net.mamoe.mirai.internal.message.protocol.impl.QuoteReplyProtocol
 net.mamoe.mirai.internal.message.protocol.impl.RichMessageProtocol
+net.mamoe.mirai.internal.message.protocol.impl.ShortVideoProtocol
 net.mamoe.mirai.internal.message.protocol.impl.TextProtocol
 net.mamoe.mirai.internal.message.protocol.impl.VipFaceProtocol
 net.mamoe.mirai.internal.message.protocol.impl.ForwardMessageProtocol

+ 10 - 0
mirai-core/src/commonMain/resources/META-INF/services/net.mamoe.mirai.message.data.InternalShortVideoProtocol

@@ -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
+#
+
+net.mamoe.mirai.internal.message.image.InternalShortVideoProtocolImpl

+ 55 - 0
mirai-core/src/commonTest/kotlin/message/RefineContextTest.kt

@@ -0,0 +1,55 @@
+/*
+ * 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.message
+
+import net.mamoe.mirai.internal.test.AbstractTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+internal class RefineContextTest : AbstractTest() {
+    @Test
+    fun `merge test`() {
+        val Key1 = RefineContextKey<Int>("KeyInt")
+        val Key2 = RefineContextKey<Double>("KeyDouble")
+        val Key3 = RefineContextKey<String>("KeyString")
+        val Key4 = RefineContextKey<ByteArray>("KeyBytes")
+
+        val context1 = SimpleRefineContext(
+            Key1 to 114514,
+            Key2 to 1919.810,
+            Key3 to "sodayo"
+        )
+
+        val context2 = SimpleRefineContext(
+            Key2 to 1919.811,
+            Key3 to "yarimasune",
+            Key4 to byteArrayOf(11, 45, 14)
+        )
+
+        val combinedOverride = context1.merge(context2, override = true)
+        val combinedNotOverride = context1.merge(context2, override = false)
+
+        val context3 = SimpleRefineContext(
+            Key2 to 1919.811,
+            Key3 to "yarimasune"
+        )
+
+        assertEquals(context1, context1.merge(context3, false))
+        assertTrue(combinedOverride != combinedNotOverride)
+
+        assertEquals(4, combinedOverride.entries().size)
+        assertEquals(1919.811, combinedOverride[Key2])
+        assertEquals(1919.810, combinedNotOverride[Key2])
+        assertEquals("sodayo", combinedNotOverride[Key3])
+        assertTrue(byteArrayOf(11, 45, 14).contentEquals(combinedNotOverride[Key4]))
+    }
+}

+ 8 - 1
mirai-core/src/commonTest/kotlin/message/data/MessageRefineTest.kt

@@ -293,7 +293,10 @@ internal class MessageRefineTest : AbstractTestWithMiraiImpl() {
                     1234567890, 1617378549, "群垃圾,时不时来被gc", PlainText("5")
                 ),
                 ForwardMessage.Node(
-                    1234567890, 1617382639, "群垃圾,时不时来被gc", redefined[2].messageChain[QuoteReply]!! + PlainText("aseff")
+                    1234567890,
+                    1617382639,
+                    "群垃圾,时不时来被gc",
+                    redefined[2].messageChain[QuoteReply]!! + PlainText("aseff")
                 ),
             ),
             redefined,
@@ -370,10 +373,12 @@ private fun assertMessageChainEquals(expected: MessageChain, actual: MessageChai
                     if (a !is QuoteReply) return false
                     if (!compare(e.source.originalMessage, a.source.originalMessage)) return false
                 }
+
                 is MessageSource -> {
                     if (a !is MessageSource) return false
                     if (!compare(e.originalMessage, a.originalMessage)) return false
                 }
+
                 is ForwardMessage -> {
                     if (a !is ForwardMessage) return false
                     if (e.brief != a.brief) return false
@@ -383,10 +388,12 @@ private fun assertMessageChainEquals(expected: MessageChain, actual: MessageChai
                     if (e.preview != a.preview) return false
                     assertNodesEquals(e.nodeList, a.nodeList)
                 }
+
                 is Image -> {
                     if (a !is Image) return false
                     if (e.imageId != a.imageId) return false
                 }
+
                 else -> {
                     if (e != a) return false
                 }

+ 1 - 0
mirai-core/src/commonTest/kotlin/message/protocol/MessageProtocolFacadeTest.kt

@@ -32,6 +32,7 @@ internal class MessageProtocolFacadeTest : AbstractTest() {
                 PokeMessageProtocol
                 PttMessageProtocol
                 RichMessageProtocol
+                ShortVideoProtocol
                 TextProtocol
                 VipFaceProtocol
                 ForwardMessageProtocol

+ 8 - 1
mirai-core/src/commonTest/kotlin/message/protocol/impl/AbstractMessageProtocolTest.kt

@@ -236,7 +236,12 @@ internal abstract class AbstractMessageProtocolTest : AbstractMockNetworkHandler
     protected open fun Deferred<ChecksConfiguration>.doDecoderChecks() {
         val config = this.getCompleted()
         doDecoderChecks(config.messageChain, protocols) {
-            decodeAndRefineLight(config.elems, config.groupIdOrZero, config.messageSourceKind, bot)
+            decodeAndRefineLight(
+                config.elems,
+                config.groupIdOrZero,
+                config.messageSourceKind,
+                bot
+            )
         }
     }
 
@@ -280,6 +285,7 @@ internal abstract class AbstractMessageProtocolTest : AbstractMockNetworkHandler
                     sender = bot,
                     target = defaultTarget
                 )
+
                 is Friend -> OnlineMessageSourceToFriendImpl(
                     sequenceIds = intArrayOf(1),
                     internalIds = intArrayOf(1),
@@ -288,6 +294,7 @@ internal abstract class AbstractMessageProtocolTest : AbstractMockNetworkHandler
                     sender = bot,
                     target = defaultTarget
                 )
+
                 else -> error("Unexpected target: $defaultTarget")
             }
         }

+ 59 - 0
mirai-core/src/jvmBaseMain/kotlin/utils/ExternalResourceImpl.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.internal.utils
+
+import io.ktor.utils.io.core.*
+import io.ktor.utils.io.streams.*
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Deferred
+import net.mamoe.mirai.utils.ExternalResource
+import net.mamoe.mirai.utils.MiraiInternalApi
+import net.mamoe.mirai.utils.md5
+import net.mamoe.mirai.utils.sha1
+import java.io.InputStream
+import java.io.SequenceInputStream
+import java.util.Collections
+
+@Suppress("FunctionName")
+internal actual fun CombinedExternalResource(vararg resources: ExternalResource): ExternalResource {
+    return CombinedExternalResource(resources.toList())
+}
+
+/**
+ * it is caller's responsibility to guarantee the immutability of the stream.
+ */
+internal class CombinedExternalResource(
+    private val inputs: Collection<ExternalResource>
+) : ExternalResource {
+    override val isAutoClose: Boolean = true
+
+    override val size: Long = inputs.sumOf { it.size }
+    override val md5: ByteArray by lazy { combine().md5() }
+    override val sha1: ByteArray by lazy { combine().sha1() }
+
+    override val formatName: String = ""
+
+    private val _closed = CompletableDeferred<Unit>()
+    override val closed: Deferred<Unit>
+        get() = _closed
+
+    override fun close() {
+        _closed.complete(Unit)
+    }
+
+    override fun inputStream(): InputStream = combine()
+
+    @MiraiInternalApi
+    override fun input(): Input = inputStream().asInput()
+
+    private fun combine(): InputStream {
+        return SequenceInputStream(Collections.enumeration(inputs.map { it.inputStream() }))
+    }
+}

+ 45 - 0
mirai-core/src/jvmBaseTest/kotlin/utils/CombinedExternalResourceTest.kt

@@ -0,0 +1,45 @@
+/*
+ * 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.utils
+
+import io.ktor.utils.io.core.*
+import net.mamoe.mirai.internal.test.AbstractTest
+import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlin.text.toByteArray
+
+class CombinedExternalResourceTest : AbstractTest() {
+    @Test
+    fun `work`() {
+        val res1 = STRING_1.toByteArray().toExternalResource()
+        val res2 = STRING_2.toByteArray().toExternalResource()
+
+        val combined1 = buildPacket {
+            res1.input().use { it.copyTo(this) }
+            res2.input().use { it.copyTo(this) }
+        }.readBytes().toExternalResource()
+
+        val combined2 = CombinedExternalResource(res1, res2)
+
+        assertEquals(combined1.size, combined2.size)
+        assertTrue { combined1.md5.contentEquals(combined2.md5) }
+        assertTrue { combined1.sha1.contentEquals(combined2.sha1) }
+    }
+
+
+    private val STRING_1 = """
+        b4FNDvv49gMInP29t82fPJuWQ4ArG1k1YVeCN3UReWXplm4H2S4Rp7zTpt8WXRQEtTL7VemlTIytPbwUkus7qgPVsyUCFreRR1vB3QhRznXqcT06fDkXJQJKyyBGEdwddNWZAkqZcdrOk679sG14kKK5GexaQUmdfTivT5VPO8w1yoWPcUHPfpjB0shCEzjkHI84LJbWNRCVjoZhy0jZAKZxLrsi1sGhl30QcXCFnHpPhWbED8Er9c8gVbjYsG8ejaUlbeNNdKW3GoOpgjFLbwZoQI4QZZgvP5jhBWUPiMG3MCcPlYRSgTf70JpDVTE0YOLhXdJJxz87S8MR4M7rU0WO7ZRkoFOQpFHdmfMmJxbiATHHkOyHVhu1mvA0L72MNtDQP5GcKlDbDcdJL7om4FmekAVVnh7R
+    """.trimIndent()
+    private val STRING_2 = """
+        FdDoAZt2hJkKAfEWBNWO44R0tJRmApqIwHDD05oW0jyLVVPOdcPaFjY1muYM1qa6jbhZppWYm1oOmgbpFgdPZRYDgzznR0kSapdqXeSSevV4ww4E1U71ELDMsq4f0a1Y8K6UxIOpQl1n20eoe80fHuXKkfN6kbhROBXcwGbiFRpPg5k8G5hCerQQunQyNoeEZrbKacq2OYkOEJV57LuSbBTF4FMZYxCEp1a8omnK1EUHC1Go5pGy0dovz78KpCshPr7MHNMnRu0FiuJ1WYT8ri8iXWsTx3AMxHRjCYfJgrtqc86L3HW0V6Wr8FqFMJLtFl4PgXj5etfRSaaqRJFIZ3nWiRqW48JMRqdGRvLTUWs1Zoa8H11bych18MVypUQJOyxghLLJw0ZP4CvSNUeJOEMitxFxyzjC
+    """.trimIndent()
+}