Bladeren bron

Support file operations (#1069)

* Proto structs for group file

* RemoteFile fundamental abstraction and proto structs

* Configure JVM target for mirai-console-intellij

* Add Group.filesRoot

* Fix build

* Implement a FileSystem for RemoteFile resolution

* Fix RemoteFile FileSystem and implement resolve and listFiles

* Implement file info query and file download

* Support uploading group file

* Support file feeds

* 2.5-M2-dev-1

* Fix tests

* 2.5-M2-dev-2

* Add uuid-based resolving, support getting file details

* Support FileMessage receive

* Support sending FileMessage

* 2.5-M2-dev-3

* Add DownloadInfo.id

* Improve RemoteFile.delete

* Support move, delete, rename, mkdir. Simplify listFiles

* - Rename RemoteFile.write to .upload.
- Prefer id matching
- Improve move

* Add permission checks

* Improve permission checks

* Rearrange functions and add constant ROOT_PATH

* Introduce FileSupported, add extensions

* Introduce ProgressionCallback

* Fix docs and uploadFileAndSend

* Remove empty FileHighway.kt

* Add test testNormalize

* Add RemoteFile.contact, change RemoteFile.uploadAndSend return type to MessageReceipt

* Move @JvmBlockingBridge to file

* Change FileMessage.toRemoteFile parameter type Group to FileSupported

* Add impl notes #1082
Him188 4 jaren geleden
bovenliggende
commit
e256ec06d3
43 gewijzigde bestanden met toevoegingen van 3000 en 215 verwijderingen
  1. 143 1
      binary-compatibility-validator/android/api/binary-compatibility-validator-android.api
  2. 143 1
      binary-compatibility-validator/api/binary-compatibility-validator.api
  3. 1 1
      buildSrc/src/main/kotlin/Versions.kt
  4. 1 1
      mirai-core-api/build.gradle.kts
  5. 29 0
      mirai-core-api/src/commonMain/kotlin/contact/FileSupported.kt
  6. 2 1
      mirai-core-api/src/commonMain/kotlin/contact/Group.kt
  7. 4 4
      mirai-core-api/src/commonMain/kotlin/internal/utils/ExternalImageImpls.kt
  8. 50 0
      mirai-core-api/src/commonMain/kotlin/message/data/FileMessage.kt
  9. 35 1
      mirai-core-api/src/commonMain/kotlin/utils/ExternalResource.kt
  10. 390 0
      mirai-core-api/src/commonMain/kotlin/utils/RemoteFile.kt
  11. 5 1
      mirai-core-utils/src/androidMain/kotlin/Actuals.kt
  12. 2 1
      mirai-core-utils/src/commonMain/kotlin/Bytes.kt
  13. 17 1
      mirai-core-utils/src/commonMain/kotlin/MiraiPlatformUtils.kt
  14. 5 1
      mirai-core-utils/src/jvmMain/kotlin/Actuals.kt
  15. 1 0
      mirai-core/src/commonMain/kotlin/MiraiImpl.kt
  16. 3 0
      mirai-core/src/commonMain/kotlin/contact/GroupImpl.kt
  17. 36 24
      mirai-core/src/commonMain/kotlin/contact/SendMessageHandler.kt
  18. 0 118
      mirai-core/src/commonMain/kotlin/contact/util.kt
  19. 55 0
      mirai-core/src/commonMain/kotlin/message/FileMessageImpl.kt
  20. 57 5
      mirai-core/src/commonMain/kotlin/message/ReceiveMessageHandler.kt
  21. 2 2
      mirai-core/src/commonMain/kotlin/message/incomingSourceImpl.kt
  22. 3 1
      mirai-core/src/commonMain/kotlin/network/highway/ChunkedFlowSession.kt
  23. 78 16
      mirai-core/src/commonMain/kotlin/network/highway/Highway.kt
  24. 81 0
      mirai-core/src/commonMain/kotlin/network/protocol/data/proto/Exciting.kt
  25. 104 0
      mirai-core/src/commonMain/kotlin/network/protocol/data/proto/GroupFileCommon.kt
  26. 6 6
      mirai-core/src/commonMain/kotlin/network/protocol/data/proto/Highway.kt
  27. 2 3
      mirai-core/src/commonMain/kotlin/network/protocol/data/proto/HummerResv21.kt
  28. 3 3
      mirai-core/src/commonMain/kotlin/network/protocol/data/proto/Msg.kt
  29. 1 1
      mirai-core/src/commonMain/kotlin/network/protocol/data/proto/OIDB.kt
  30. 179 0
      mirai-core/src/commonMain/kotlin/network/protocol/data/proto/Oidb0x6d6.kt
  31. 99 0
      mirai-core/src/commonMain/kotlin/network/protocol/data/proto/Oidb0x6d7.kt
  32. 168 0
      mirai-core/src/commonMain/kotlin/network/protocol/data/proto/Oidb0x6d8.kt
  33. 120 0
      mirai-core/src/commonMain/kotlin/network/protocol/data/proto/Oidb0x6d9.kt
  34. 1 0
      mirai-core/src/commonMain/kotlin/network/protocol/packet/PacketFactory.kt
  35. 502 0
      mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/GroupFile.kt
  36. 2 1
      mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/TroopManagement.kt
  37. 13 14
      mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/receive/MessageSvc.PbSendMsg.kt
  38. 4 2
      mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/receive/OnlinePush.PbPushGroupMsg.kt
  39. 4 3
      mirai-core/src/commonMain/kotlin/utils/PlatformSocket.kt
  40. 537 0
      mirai-core/src/commonMain/kotlin/utils/RemoteFileImpl.kt
  41. 65 0
      mirai-core/src/commonMain/kotlin/utils/io/serialization/utils.kt
  42. 7 2
      mirai-core/src/commonMain/kotlin/utils/retryWithServers.kt
  43. 40 0
      mirai-core/src/commonTest/kotlin/internal/utils/FileSystemTest.kt

+ 143 - 1
binary-compatibility-validator/android/api/binary-compatibility-validator-android.api

@@ -341,6 +341,10 @@ public final class net/mamoe/mirai/contact/ExceptionsKt {
 	public static final fun getBotMuteRemaining (Lnet/mamoe/mirai/contact/BotIsBeingMutedException;)I
 }
 
+public abstract interface class net/mamoe/mirai/contact/FileSupported : net/mamoe/mirai/contact/Contact {
+	public abstract fun getFilesRoot ()Lnet/mamoe/mirai/utils/RemoteFile;
+}
+
 public abstract interface class net/mamoe/mirai/contact/Friend : kotlinx/coroutines/CoroutineScope, net/mamoe/mirai/contact/User {
 	public synthetic fun delete ()Lkotlin/Unit;
 	public fun delete ()V
@@ -357,7 +361,7 @@ public abstract interface class net/mamoe/mirai/contact/Friend : kotlinx/corouti
 	public abstract fun sendMessage (Lnet/mamoe/mirai/message/data/Message;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
 
-public abstract interface class net/mamoe/mirai/contact/Group : kotlinx/coroutines/CoroutineScope, net/mamoe/mirai/contact/Contact {
+public abstract interface class net/mamoe/mirai/contact/Group : kotlinx/coroutines/CoroutineScope, net/mamoe/mirai/contact/Contact, net/mamoe/mirai/contact/FileSupported {
 	public static final field Companion Lnet/mamoe/mirai/contact/Group$Companion;
 	public abstract fun contains (J)Z
 	public fun contains (Lnet/mamoe/mirai/contact/NormalMember;)Z
@@ -4038,6 +4042,23 @@ public final class net/mamoe/mirai/message/data/Face$Companion {
 	public final fun serializer ()Lkotlinx/serialization/KSerializer;
 }
 
+public abstract interface class net/mamoe/mirai/message/data/FileMessage : net/mamoe/mirai/message/data/ConstrainSingle, net/mamoe/mirai/message/data/MessageContent {
+	public static final field Key Lnet/mamoe/mirai/message/data/FileMessage$Key;
+	public static final field SERIAL_NAME Ljava/lang/String;
+	public fun contentToString ()Ljava/lang/String;
+	public abstract fun getId ()Ljava/lang/String;
+	public fun getKey ()Lnet/mamoe/mirai/message/data/FileMessage$Key;
+	public synthetic fun getKey ()Lnet/mamoe/mirai/message/data/MessageKey;
+	public abstract fun getName ()Ljava/lang/String;
+	public abstract fun getSize ()J
+	public fun toRemoteFile (Lnet/mamoe/mirai/contact/FileSupported;)Lnet/mamoe/mirai/utils/RemoteFile;
+	public fun toRemoteFile (Lnet/mamoe/mirai/contact/FileSupported;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
+public final class net/mamoe/mirai/message/data/FileMessage$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
+	public static final field SERIAL_NAME Ljava/lang/String;
+}
+
 public final class net/mamoe/mirai/message/data/FlashImage : net/mamoe/mirai/message/code/CodableMessage, net/mamoe/mirai/message/data/ConstrainSingle, net/mamoe/mirai/message/data/HummerMessage, net/mamoe/mirai/message/data/MessageContent {
 	public static final field Key Lnet/mamoe/mirai/message/data/FlashImage$Key;
 	public static final field SERIAL_NAME Ljava/lang/String;
@@ -5611,6 +5632,7 @@ public abstract interface class net/mamoe/mirai/utils/ExternalResource : java/io
 	public abstract fun getClosed ()Lkotlinx/coroutines/Deferred;
 	public abstract fun getFormatName ()Ljava/lang/String;
 	public abstract fun getMd5 ()[B
+	public fun getSha1 ()[B
 	public abstract fun getSize ()J
 	public abstract fun inputStream ()Ljava/io/InputStream;
 	public static fun sendAsImage (Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;)Lnet/mamoe/mirai/message/MessageReceipt;
@@ -5625,6 +5647,8 @@ public abstract interface class net/mamoe/mirai/utils/ExternalResource : java/io
 	public static fun sendAsImage (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public static synthetic fun sendAsImage$default (Lnet/mamoe/mirai/utils/ExternalResource$Companion;Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;ILjava/lang/Object;)Lnet/mamoe/mirai/message/MessageReceipt;
 	public static synthetic fun sendAsImage$default (Lnet/mamoe/mirai/utils/ExternalResource$Companion;Ljava/io/InputStream;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;ILjava/lang/Object;)Lnet/mamoe/mirai/message/MessageReceipt;
+	public static fun uploadAsFileTo (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;)Lnet/mamoe/mirai/message/data/FileMessage;
+	public static fun uploadAsFileTo (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public static fun uploadAsImage (Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;)Lnet/mamoe/mirai/message/data/Image;
 	public static fun uploadAsImage (Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;)Lnet/mamoe/mirai/message/data/Image;
 	public static fun uploadAsImage (Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
@@ -5639,6 +5663,8 @@ public abstract interface class net/mamoe/mirai/utils/ExternalResource : java/io
 	public static synthetic fun uploadAsImage$default (Lnet/mamoe/mirai/utils/ExternalResource$Companion;Ljava/io/InputStream;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;ILjava/lang/Object;)Lnet/mamoe/mirai/message/data/Image;
 	public static fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;)Lnet/mamoe/mirai/message/data/Voice;
 	public static fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;)Lnet/mamoe/mirai/message/data/FileMessage;
+	public static fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
 
 public final class net/mamoe/mirai/utils/ExternalResource$Companion {
@@ -5668,6 +5694,8 @@ public final class net/mamoe/mirai/utils/ExternalResource$Companion {
 	public final fun sendAsImage (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public static synthetic fun sendAsImage$default (Lnet/mamoe/mirai/utils/ExternalResource$Companion;Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
 	public static synthetic fun sendAsImage$default (Lnet/mamoe/mirai/utils/ExternalResource$Companion;Ljava/io/InputStream;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
+	public final fun uploadAsFileTo (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;)Lnet/mamoe/mirai/message/data/FileMessage;
+	public final fun uploadAsFileTo (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public final fun uploadAsImage (Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;)Lnet/mamoe/mirai/message/data/Image;
 	public final fun uploadAsImage (Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;)Lnet/mamoe/mirai/message/data/Image;
 	public final fun uploadAsImage (Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
@@ -5682,6 +5710,8 @@ public final class net/mamoe/mirai/utils/ExternalResource$Companion {
 	public static synthetic fun uploadAsImage$default (Lnet/mamoe/mirai/utils/ExternalResource$Companion;Ljava/io/InputStream;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
 	public final fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;)Lnet/mamoe/mirai/message/data/Voice;
 	public final fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public final fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;)Lnet/mamoe/mirai/message/data/FileMessage;
+	public final fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
 
 public abstract interface class net/mamoe/mirai/utils/FileCacheStrategy {
@@ -5838,6 +5868,118 @@ public class net/mamoe/mirai/utils/PlatformLogger : net/mamoe/mirai/utils/MiraiL
 	public fun warning0 (Ljava/lang/String;Ljava/lang/Throwable;)V
 }
 
+public abstract interface class net/mamoe/mirai/utils/RemoteFile {
+	public static final field Companion Lnet/mamoe/mirai/utils/RemoteFile$Companion;
+	public static final field ROOT_PATH Ljava/lang/String;
+	public fun delete ()Z
+	public abstract fun delete (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun exists ()Z
+	public abstract fun exists (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public abstract fun getContact ()Lnet/mamoe/mirai/contact/FileSupported;
+	public fun getDownloadInfo ()Lnet/mamoe/mirai/utils/RemoteFile$DownloadInfo;
+	public abstract fun getDownloadInfo (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public abstract fun getId ()Ljava/lang/String;
+	public fun getInfo ()Lnet/mamoe/mirai/utils/RemoteFile$FileInfo;
+	public abstract fun getInfo (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public abstract fun getName ()Ljava/lang/String;
+	public abstract fun getParent ()Lnet/mamoe/mirai/utils/RemoteFile;
+	public abstract fun getPath ()Ljava/lang/String;
+	public fun isDirectory ()Z
+	public fun isDirectory (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun isFile ()Z
+	public abstract fun isFile (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun length ()J
+	public abstract fun length (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun listFiles ()Lkotlinx/coroutines/flow/Flow;
+	public abstract fun listFiles (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun listFilesIterator (Z)Ljava/util/Iterator;
+	public abstract fun listFilesIterator (ZLkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun mkdir ()Z
+	public abstract fun mkdir (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun moveTo (Ljava/lang/String;)Z
+	public abstract fun moveTo (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun moveTo (Lnet/mamoe/mirai/utils/RemoteFile;)Z
+	public abstract fun moveTo (Lnet/mamoe/mirai/utils/RemoteFile;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun renameTo (Ljava/lang/String;)Z
+	public abstract fun renameTo (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public abstract fun resolve (Ljava/lang/String;)Lnet/mamoe/mirai/utils/RemoteFile;
+	public abstract fun resolve (Lnet/mamoe/mirai/utils/RemoteFile;)Lnet/mamoe/mirai/utils/RemoteFile;
+	public fun resolveById (Ljava/lang/String;)Lnet/mamoe/mirai/utils/RemoteFile;
+	public fun resolveById (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun resolveById (Ljava/lang/String;Z)Lnet/mamoe/mirai/utils/RemoteFile;
+	public abstract fun resolveById (Ljava/lang/String;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static synthetic fun resolveById$default (Lnet/mamoe/mirai/utils/RemoteFile;Ljava/lang/String;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
+	public abstract fun resolveSibling (Ljava/lang/String;)Lnet/mamoe/mirai/utils/RemoteFile;
+	public abstract fun resolveSibling (Lnet/mamoe/mirai/utils/RemoteFile;)Lnet/mamoe/mirai/utils/RemoteFile;
+	public fun toMessage ()Lnet/mamoe/mirai/message/data/FileMessage;
+	public abstract fun toMessage (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public abstract fun toString ()Ljava/lang/String;
+	public fun upload (Ljava/io/File;)Z
+	public fun upload (Ljava/io/File;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun upload (Lnet/mamoe/mirai/utils/ExternalResource;)Z
+	public fun upload (Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun upload (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback;)Z
+	public abstract fun upload (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static synthetic fun upload$default (Lnet/mamoe/mirai/utils/RemoteFile;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
+	public fun uploadAndSend (Ljava/io/File;)Lnet/mamoe/mirai/message/MessageReceipt;
+	public fun uploadAndSend (Ljava/io/File;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun uploadAndSend (Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/message/MessageReceipt;
+	public abstract fun uploadAndSend (Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static fun uploadFile (Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/message/data/FileMessage;
+	public static fun uploadFile (Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static fun uploadFileAndSend (Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/message/MessageReceipt;
+	public static fun uploadFileAndSend (Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
+public final class net/mamoe/mirai/utils/RemoteFile$Companion {
+	public static final field ROOT_PATH Ljava/lang/String;
+	public final fun uploadFile (Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/message/data/FileMessage;
+	public final fun uploadFile (Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public final fun uploadFileAndSend (Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/message/MessageReceipt;
+	public final fun uploadFileAndSend (Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
+public final class net/mamoe/mirai/utils/RemoteFile$DownloadInfo {
+	public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[B[B)V
+	public final fun getFilename ()Ljava/lang/String;
+	public final fun getId ()Ljava/lang/String;
+	public final fun getMd5 ()[B
+	public final fun getPath ()Ljava/lang/String;
+	public final fun getSha1 ()[B
+	public final fun getUrl ()Ljava/lang/String;
+	public fun toString ()Ljava/lang/String;
+}
+
+public final class net/mamoe/mirai/utils/RemoteFile$FileInfo {
+	public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JIJJJ[B[B)V
+	public final fun getDownloadTimes ()I
+	public final fun getId ()Ljava/lang/String;
+	public final fun getLastModifyTime ()J
+	public final fun getLength ()J
+	public final fun getMd5 ()[B
+	public final fun getName ()Ljava/lang/String;
+	public final fun getPath ()Ljava/lang/String;
+	public final fun getSha1 ()[B
+	public final fun getUploadTime ()J
+	public final fun getUploaderId ()J
+	public final fun resolveToFile (Lnet/mamoe/mirai/contact/Group;)Lnet/mamoe/mirai/utils/RemoteFile;
+	public final fun resolveToFile (Lnet/mamoe/mirai/contact/Group;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
+public abstract interface class net/mamoe/mirai/utils/RemoteFile$ProgressionCallback {
+	public static final field Companion Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback$Companion;
+	public static fun asProgressionCallback (Lkotlinx/coroutines/channels/SendChannel;Z)Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback;
+	public fun onBegin (Lnet/mamoe/mirai/utils/RemoteFile;Lnet/mamoe/mirai/utils/ExternalResource;)V
+	public fun onFailure (Lnet/mamoe/mirai/utils/RemoteFile;Lnet/mamoe/mirai/utils/ExternalResource;Ljava/lang/Throwable;)V
+	public fun onProgression (Lnet/mamoe/mirai/utils/RemoteFile;Lnet/mamoe/mirai/utils/ExternalResource;J)V
+	public fun onSuccess (Lnet/mamoe/mirai/utils/RemoteFile;Lnet/mamoe/mirai/utils/ExternalResource;)V
+}
+
+public final class net/mamoe/mirai/utils/RemoteFile$ProgressionCallback$Companion {
+	public final fun asProgressionCallback (Lkotlinx/coroutines/channels/SendChannel;Z)Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback;
+	public static synthetic fun asProgressionCallback$default (Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback$Companion;Lkotlinx/coroutines/channels/SendChannel;ZILjava/lang/Object;)Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback;
+}
+
 public final class net/mamoe/mirai/utils/SilentLogger : net/mamoe/mirai/utils/PlatformLogger {
 	public static final field INSTANCE Lnet/mamoe/mirai/utils/SilentLogger;
 	public fun debug0 (Ljava/lang/String;)V

+ 143 - 1
binary-compatibility-validator/api/binary-compatibility-validator.api

@@ -341,6 +341,10 @@ public final class net/mamoe/mirai/contact/ExceptionsKt {
 	public static final fun getBotMuteRemaining (Lnet/mamoe/mirai/contact/BotIsBeingMutedException;)I
 }
 
+public abstract interface class net/mamoe/mirai/contact/FileSupported : net/mamoe/mirai/contact/Contact {
+	public abstract fun getFilesRoot ()Lnet/mamoe/mirai/utils/RemoteFile;
+}
+
 public abstract interface class net/mamoe/mirai/contact/Friend : kotlinx/coroutines/CoroutineScope, net/mamoe/mirai/contact/User {
 	public synthetic fun delete ()Lkotlin/Unit;
 	public fun delete ()V
@@ -357,7 +361,7 @@ public abstract interface class net/mamoe/mirai/contact/Friend : kotlinx/corouti
 	public abstract fun sendMessage (Lnet/mamoe/mirai/message/data/Message;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
 
-public abstract interface class net/mamoe/mirai/contact/Group : kotlinx/coroutines/CoroutineScope, net/mamoe/mirai/contact/Contact {
+public abstract interface class net/mamoe/mirai/contact/Group : kotlinx/coroutines/CoroutineScope, net/mamoe/mirai/contact/Contact, net/mamoe/mirai/contact/FileSupported {
 	public static final field Companion Lnet/mamoe/mirai/contact/Group$Companion;
 	public abstract fun contains (J)Z
 	public fun contains (Lnet/mamoe/mirai/contact/NormalMember;)Z
@@ -4038,6 +4042,23 @@ public final class net/mamoe/mirai/message/data/Face$Companion {
 	public final fun serializer ()Lkotlinx/serialization/KSerializer;
 }
 
+public abstract interface class net/mamoe/mirai/message/data/FileMessage : net/mamoe/mirai/message/data/ConstrainSingle, net/mamoe/mirai/message/data/MessageContent {
+	public static final field Key Lnet/mamoe/mirai/message/data/FileMessage$Key;
+	public static final field SERIAL_NAME Ljava/lang/String;
+	public fun contentToString ()Ljava/lang/String;
+	public abstract fun getId ()Ljava/lang/String;
+	public fun getKey ()Lnet/mamoe/mirai/message/data/FileMessage$Key;
+	public synthetic fun getKey ()Lnet/mamoe/mirai/message/data/MessageKey;
+	public abstract fun getName ()Ljava/lang/String;
+	public abstract fun getSize ()J
+	public fun toRemoteFile (Lnet/mamoe/mirai/contact/FileSupported;)Lnet/mamoe/mirai/utils/RemoteFile;
+	public fun toRemoteFile (Lnet/mamoe/mirai/contact/FileSupported;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
+public final class net/mamoe/mirai/message/data/FileMessage$Key : net/mamoe/mirai/message/data/AbstractPolymorphicMessageKey {
+	public static final field SERIAL_NAME Ljava/lang/String;
+}
+
 public final class net/mamoe/mirai/message/data/FlashImage : net/mamoe/mirai/message/code/CodableMessage, net/mamoe/mirai/message/data/ConstrainSingle, net/mamoe/mirai/message/data/HummerMessage, net/mamoe/mirai/message/data/MessageContent {
 	public static final field Key Lnet/mamoe/mirai/message/data/FlashImage$Key;
 	public static final field SERIAL_NAME Ljava/lang/String;
@@ -5611,6 +5632,7 @@ public abstract interface class net/mamoe/mirai/utils/ExternalResource : java/io
 	public abstract fun getClosed ()Lkotlinx/coroutines/Deferred;
 	public abstract fun getFormatName ()Ljava/lang/String;
 	public abstract fun getMd5 ()[B
+	public fun getSha1 ()[B
 	public abstract fun getSize ()J
 	public abstract fun inputStream ()Ljava/io/InputStream;
 	public static fun sendAsImage (Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;)Lnet/mamoe/mirai/message/MessageReceipt;
@@ -5625,6 +5647,8 @@ public abstract interface class net/mamoe/mirai/utils/ExternalResource : java/io
 	public static fun sendAsImage (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public static synthetic fun sendAsImage$default (Lnet/mamoe/mirai/utils/ExternalResource$Companion;Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;ILjava/lang/Object;)Lnet/mamoe/mirai/message/MessageReceipt;
 	public static synthetic fun sendAsImage$default (Lnet/mamoe/mirai/utils/ExternalResource$Companion;Ljava/io/InputStream;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;ILjava/lang/Object;)Lnet/mamoe/mirai/message/MessageReceipt;
+	public static fun uploadAsFileTo (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;)Lnet/mamoe/mirai/message/data/FileMessage;
+	public static fun uploadAsFileTo (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public static fun uploadAsImage (Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;)Lnet/mamoe/mirai/message/data/Image;
 	public static fun uploadAsImage (Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;)Lnet/mamoe/mirai/message/data/Image;
 	public static fun uploadAsImage (Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
@@ -5639,6 +5663,8 @@ public abstract interface class net/mamoe/mirai/utils/ExternalResource : java/io
 	public static synthetic fun uploadAsImage$default (Lnet/mamoe/mirai/utils/ExternalResource$Companion;Ljava/io/InputStream;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;ILjava/lang/Object;)Lnet/mamoe/mirai/message/data/Image;
 	public static fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;)Lnet/mamoe/mirai/message/data/Voice;
 	public static fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;)Lnet/mamoe/mirai/message/data/FileMessage;
+	public static fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
 
 public final class net/mamoe/mirai/utils/ExternalResource$Companion {
@@ -5668,6 +5694,8 @@ public final class net/mamoe/mirai/utils/ExternalResource$Companion {
 	public final fun sendAsImage (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public static synthetic fun sendAsImage$default (Lnet/mamoe/mirai/utils/ExternalResource$Companion;Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
 	public static synthetic fun sendAsImage$default (Lnet/mamoe/mirai/utils/ExternalResource$Companion;Ljava/io/InputStream;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
+	public final fun uploadAsFileTo (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;)Lnet/mamoe/mirai/message/data/FileMessage;
+	public final fun uploadAsFileTo (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public final fun uploadAsImage (Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;)Lnet/mamoe/mirai/message/data/Image;
 	public final fun uploadAsImage (Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;)Lnet/mamoe/mirai/message/data/Image;
 	public final fun uploadAsImage (Ljava/io/File;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
@@ -5682,6 +5710,8 @@ public final class net/mamoe/mirai/utils/ExternalResource$Companion {
 	public static synthetic fun uploadAsImage$default (Lnet/mamoe/mirai/utils/ExternalResource$Companion;Ljava/io/InputStream;Lnet/mamoe/mirai/contact/Contact;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
 	public final fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;)Lnet/mamoe/mirai/message/data/Voice;
 	public final fun uploadAsVoice (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/contact/Contact;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public final fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;)Lnet/mamoe/mirai/message/data/FileMessage;
+	public final fun uploadTo (Ljava/io/File;Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
 
 public abstract interface class net/mamoe/mirai/utils/FileCacheStrategy {
@@ -5867,6 +5897,118 @@ protected final class net/mamoe/mirai/utils/PlatformLogger$Color : java/lang/Enu
 	public static fun values ()[Lnet/mamoe/mirai/utils/PlatformLogger$Color;
 }
 
+public abstract interface class net/mamoe/mirai/utils/RemoteFile {
+	public static final field Companion Lnet/mamoe/mirai/utils/RemoteFile$Companion;
+	public static final field ROOT_PATH Ljava/lang/String;
+	public fun delete ()Z
+	public abstract fun delete (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun exists ()Z
+	public abstract fun exists (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public abstract fun getContact ()Lnet/mamoe/mirai/contact/FileSupported;
+	public fun getDownloadInfo ()Lnet/mamoe/mirai/utils/RemoteFile$DownloadInfo;
+	public abstract fun getDownloadInfo (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public abstract fun getId ()Ljava/lang/String;
+	public fun getInfo ()Lnet/mamoe/mirai/utils/RemoteFile$FileInfo;
+	public abstract fun getInfo (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public abstract fun getName ()Ljava/lang/String;
+	public abstract fun getParent ()Lnet/mamoe/mirai/utils/RemoteFile;
+	public abstract fun getPath ()Ljava/lang/String;
+	public fun isDirectory ()Z
+	public fun isDirectory (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun isFile ()Z
+	public abstract fun isFile (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun length ()J
+	public abstract fun length (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun listFiles ()Lkotlinx/coroutines/flow/Flow;
+	public abstract fun listFiles (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun listFilesIterator (Z)Ljava/util/Iterator;
+	public abstract fun listFilesIterator (ZLkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun mkdir ()Z
+	public abstract fun mkdir (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun moveTo (Ljava/lang/String;)Z
+	public abstract fun moveTo (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun moveTo (Lnet/mamoe/mirai/utils/RemoteFile;)Z
+	public abstract fun moveTo (Lnet/mamoe/mirai/utils/RemoteFile;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun renameTo (Ljava/lang/String;)Z
+	public abstract fun renameTo (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public abstract fun resolve (Ljava/lang/String;)Lnet/mamoe/mirai/utils/RemoteFile;
+	public abstract fun resolve (Lnet/mamoe/mirai/utils/RemoteFile;)Lnet/mamoe/mirai/utils/RemoteFile;
+	public fun resolveById (Ljava/lang/String;)Lnet/mamoe/mirai/utils/RemoteFile;
+	public fun resolveById (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun resolveById (Ljava/lang/String;Z)Lnet/mamoe/mirai/utils/RemoteFile;
+	public abstract fun resolveById (Ljava/lang/String;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static synthetic fun resolveById$default (Lnet/mamoe/mirai/utils/RemoteFile;Ljava/lang/String;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
+	public abstract fun resolveSibling (Ljava/lang/String;)Lnet/mamoe/mirai/utils/RemoteFile;
+	public abstract fun resolveSibling (Lnet/mamoe/mirai/utils/RemoteFile;)Lnet/mamoe/mirai/utils/RemoteFile;
+	public fun toMessage ()Lnet/mamoe/mirai/message/data/FileMessage;
+	public abstract fun toMessage (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public abstract fun toString ()Ljava/lang/String;
+	public fun upload (Ljava/io/File;)Z
+	public fun upload (Ljava/io/File;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun upload (Lnet/mamoe/mirai/utils/ExternalResource;)Z
+	public fun upload (Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun upload (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback;)Z
+	public abstract fun upload (Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static synthetic fun upload$default (Lnet/mamoe/mirai/utils/RemoteFile;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
+	public fun uploadAndSend (Ljava/io/File;)Lnet/mamoe/mirai/message/MessageReceipt;
+	public fun uploadAndSend (Ljava/io/File;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun uploadAndSend (Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/message/MessageReceipt;
+	public abstract fun uploadAndSend (Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static fun uploadFile (Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/message/data/FileMessage;
+	public static fun uploadFile (Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static fun uploadFileAndSend (Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/message/MessageReceipt;
+	public static fun uploadFileAndSend (Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
+public final class net/mamoe/mirai/utils/RemoteFile$Companion {
+	public static final field ROOT_PATH Ljava/lang/String;
+	public final fun uploadFile (Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/message/data/FileMessage;
+	public final fun uploadFile (Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public final fun uploadFileAndSend (Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/message/MessageReceipt;
+	public final fun uploadFileAndSend (Lnet/mamoe/mirai/contact/FileSupported;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
+public final class net/mamoe/mirai/utils/RemoteFile$DownloadInfo {
+	public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[B[B)V
+	public final fun getFilename ()Ljava/lang/String;
+	public final fun getId ()Ljava/lang/String;
+	public final fun getMd5 ()[B
+	public final fun getPath ()Ljava/lang/String;
+	public final fun getSha1 ()[B
+	public final fun getUrl ()Ljava/lang/String;
+	public fun toString ()Ljava/lang/String;
+}
+
+public final class net/mamoe/mirai/utils/RemoteFile$FileInfo {
+	public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JIJJJ[B[B)V
+	public final fun getDownloadTimes ()I
+	public final fun getId ()Ljava/lang/String;
+	public final fun getLastModifyTime ()J
+	public final fun getLength ()J
+	public final fun getMd5 ()[B
+	public final fun getName ()Ljava/lang/String;
+	public final fun getPath ()Ljava/lang/String;
+	public final fun getSha1 ()[B
+	public final fun getUploadTime ()J
+	public final fun getUploaderId ()J
+	public final fun resolveToFile (Lnet/mamoe/mirai/contact/Group;)Lnet/mamoe/mirai/utils/RemoteFile;
+	public final fun resolveToFile (Lnet/mamoe/mirai/contact/Group;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
+public abstract interface class net/mamoe/mirai/utils/RemoteFile$ProgressionCallback {
+	public static final field Companion Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback$Companion;
+	public static fun asProgressionCallback (Lkotlinx/coroutines/channels/SendChannel;Z)Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback;
+	public fun onBegin (Lnet/mamoe/mirai/utils/RemoteFile;Lnet/mamoe/mirai/utils/ExternalResource;)V
+	public fun onFailure (Lnet/mamoe/mirai/utils/RemoteFile;Lnet/mamoe/mirai/utils/ExternalResource;Ljava/lang/Throwable;)V
+	public fun onProgression (Lnet/mamoe/mirai/utils/RemoteFile;Lnet/mamoe/mirai/utils/ExternalResource;J)V
+	public fun onSuccess (Lnet/mamoe/mirai/utils/RemoteFile;Lnet/mamoe/mirai/utils/ExternalResource;)V
+}
+
+public final class net/mamoe/mirai/utils/RemoteFile$ProgressionCallback$Companion {
+	public final fun asProgressionCallback (Lkotlinx/coroutines/channels/SendChannel;Z)Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback;
+	public static synthetic fun asProgressionCallback$default (Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback$Companion;Lkotlinx/coroutines/channels/SendChannel;ZILjava/lang/Object;)Lnet/mamoe/mirai/utils/RemoteFile$ProgressionCallback;
+}
+
 public final class net/mamoe/mirai/utils/SilentLogger : net/mamoe/mirai/utils/PlatformLogger {
 	public static final field INSTANCE Lnet/mamoe/mirai/utils/SilentLogger;
 	public fun debug0 (Ljava/lang/String;)V

+ 1 - 1
buildSrc/src/main/kotlin/Versions.kt

@@ -12,7 +12,7 @@
 import org.gradle.api.attributes.Attribute
 
 object Versions {
-    const val project = "2.5-M1"
+    const val project = "2.5-M2-dev-3"
 
     const val core = project
     const val console = project

+ 1 - 1
mirai-core-api/build.gradle.kts

@@ -60,7 +60,7 @@ kotlin {
                 api(`kotlinx-serialization-core`)
                 api(`kotlinx-serialization-json`)
                 implementation(`kotlinx-serialization-protobuf`)
-                api(`kotlinx-coroutines-core`)
+                api(`kotlinx-coroutines-jdk8`)
                 implementation(`jetbrains-annotations`)
                 // api(`kotlinx-coroutines-jdk8`)
 

+ 29 - 0
mirai-core-api/src/commonMain/kotlin/contact/FileSupported.kt

@@ -0,0 +1,29 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ *  此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ *  Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ *  https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+
+package net.mamoe.mirai.contact
+
+import net.mamoe.mirai.utils.MiraiExperimentalApi
+import net.mamoe.mirai.utils.RemoteFile
+
+/**
+ * 支持文件操作的 [Contact]. 目前仅 [Group]
+ * @since 2.5
+ */
+@MiraiExperimentalApi
+public interface FileSupported : Contact {
+    /**
+     * 文件根目录. 可通过 [RemoteFile.listFiles] 获取目录下文件列表.
+     *
+     * @since 2.5
+     */
+    @MiraiExperimentalApi
+    public val filesRoot: RemoteFile
+}

+ 2 - 1
mirai-core-api/src/commonMain/kotlin/contact/Group.kt

@@ -26,7 +26,7 @@ import net.mamoe.mirai.utils.OverFileSizeMaxException
  * 群.
  */
 @JvmBlockingBridge
-public interface Group : Contact, CoroutineScope {
+public interface Group : Contact, CoroutineScope, FileSupported {
     /**
      * 群名称.
      *
@@ -83,6 +83,7 @@ public interface Group : Contact, CoroutineScope {
     public override val avatarUrl: String
         get() = "https://p.qlogo.cn/gh/$id/${id}/640"
 
+
     /**
      * 群成员列表, 不含机器人自己, 含群主.
      *

+ 4 - 4
mirai-core-api/src/commonMain/kotlin/internal/utils/ExternalImageImpls.kt

@@ -10,10 +10,7 @@
 package net.mamoe.mirai.internal.utils
 
 import kotlinx.coroutines.CompletableDeferred
-import net.mamoe.mirai.utils.COUNT_BYTES_USED_FOR_DETECTING_FILE_TYPE
-import net.mamoe.mirai.utils.ExternalResource
-import net.mamoe.mirai.utils.getFileType
-import net.mamoe.mirai.utils.md5
+import net.mamoe.mirai.utils.*
 import java.io.InputStream
 import java.io.RandomAccessFile
 
@@ -31,6 +28,7 @@ internal class ExternalResourceImplByFileWithMd5(
     override val md5: ByteArray,
     formatName: String?
 ) : ExternalResource {
+    override val sha1: ByteArray by lazy { inputStream().sha1() }
     override val size: Long = file.length()
     override val formatName: String by lazy {
         formatName ?: inputStream().detectFileTypeAndClose() ?: ExternalResource.DEFAULT_FORMAT_NAME
@@ -59,6 +57,7 @@ internal class ExternalResourceImplByFile(
 ) : ExternalResource {
     override val size: Long = file.length()
     override val md5: ByteArray by lazy { inputStream().md5() }
+    override val sha1: ByteArray by lazy { inputStream().sha1() }
     override val formatName: String by lazy {
         formatName ?: inputStream().detectFileTypeAndClose() ?: ExternalResource.DEFAULT_FORMAT_NAME
     }
@@ -84,6 +83,7 @@ internal class ExternalResourceImplByByteArray(
 ) : ExternalResource {
     override val size: Long = data.size.toLong()
     override val md5: ByteArray by lazy { data.md5() }
+    override val sha1: ByteArray by lazy { data.sha1() }
     override val formatName: String by lazy {
         formatName ?: getFileType(data.copyOf(COUNT_BYTES_USED_FOR_DETECTING_FILE_TYPE))
         ?: ExternalResource.DEFAULT_FORMAT_NAME

+ 50 - 0
mirai-core-api/src/commonMain/kotlin/message/data/FileMessage.kt

@@ -0,0 +1,50 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ *  此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ *  Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ *  https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+
+package net.mamoe.mirai.message.data
+
+import kotlinx.serialization.SerialName
+import net.mamoe.kjbb.JvmBlockingBridge
+import net.mamoe.mirai.contact.FileSupported
+import net.mamoe.mirai.utils.MiraiExperimentalApi
+import net.mamoe.mirai.utils.RemoteFile
+import net.mamoe.mirai.utils.safeCast
+
+/**
+ * 文件消息.
+ *
+ * @since 2.5
+ * @suppress 文件消息不稳定, 可能在未来版本有不兼容变更.
+ */
+@SerialName(FileMessage.SERIAL_NAME)
+@MiraiExperimentalApi
+public interface FileMessage : MessageContent, ConstrainSingle {
+    public val name: String
+    public val id: String
+    public val size: Long
+
+    override fun contentToString(): String = "[文件]$name" // orthodox
+
+    /**
+     * 获取一个对应的 [RemoteFile]. 当目标群或好友不存在这个文件时返回 `null`.
+     */
+    @MiraiExperimentalApi
+    @JvmBlockingBridge
+    public suspend fun toRemoteFile(contact: FileSupported): RemoteFile? {
+        return contact.filesRoot.resolveById(id)
+    }
+
+    override val key: Key get() = Key
+
+    public companion object Key :
+        AbstractPolymorphicMessageKey<MessageContent, ForwardMessage>(MessageContent, { it.safeCast() }) {
+        public const val SERIAL_NAME: String = "FileMessage"
+    }
+}

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

@@ -18,16 +18,19 @@ import net.mamoe.mirai.Mirai
 import net.mamoe.mirai.contact.Contact
 import net.mamoe.mirai.contact.Contact.Companion.sendImage
 import net.mamoe.mirai.contact.Contact.Companion.uploadImage
+import net.mamoe.mirai.contact.FileSupported
 import net.mamoe.mirai.contact.Group
 import net.mamoe.mirai.internal.utils.ExternalResourceImplByByteArray
 import net.mamoe.mirai.internal.utils.ExternalResourceImplByFile
 import net.mamoe.mirai.message.MessageReceipt
+import net.mamoe.mirai.message.data.FileMessage
 import net.mamoe.mirai.message.data.Image
 import net.mamoe.mirai.message.data.Voice
 import net.mamoe.mirai.message.data.sendTo
 import net.mamoe.mirai.utils.ExternalResource.Companion.sendAsImageTo
 import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
 import net.mamoe.mirai.utils.ExternalResource.Companion.uploadAsImage
+import net.mamoe.mirai.utils.RemoteFile.Companion.uploadFile
 import java.io.*
 
 
@@ -44,7 +47,7 @@ import java.io.*
  *
  * ## 释放
  *
- * 当 [ExternalResource] 创建时就可能会打开个文件 (如使用 [File.toExternalResource]).
+ * 当 [ExternalResource] 创建时就可能会打开个文件 (如使用 [File.toExternalResource]).
  * 类似于 [InputStream], [ExternalResource] 需要被 [关闭][close].
  *
  * @see ExternalResource.uploadAsImage 将资源作为图片上传, 得到 [Image]
@@ -61,6 +64,14 @@ public interface ExternalResource : Closeable {
      */
     public val md5: ByteArray
 
+    /**
+     * 文件内容 SHA1. 16 bytes
+     * @since 2.5
+     */
+    public val sha1: ByteArray
+        get() =
+            throw UnsupportedOperationException("ExternalResource.sha1 is not implemented by ${this::class.simpleName}")
+
     /**
      * 文件格式,如 "png", "amr". 当无法自动识别格式时为 [DEFAULT_FORMAT_NAME].
      *
@@ -252,6 +263,29 @@ public interface ExternalResource : Closeable {
         public suspend fun File.uploadAsImage(contact: Contact, formatName: String? = null): Image =
             toExternalResource(formatName).withUse { uploadAsImage(contact) }
 
+        /**
+         * 上传文件并获取文件消息.
+         * @param path 远程路径. 起始字符为 '/'. 如 '/foo/bar.txt'
+         * @since 2.5
+         * @see RemoteFile.path
+         * @see RemoteFile.upload
+         */
+        @JvmStatic
+        @JvmBlockingBridge
+        public suspend fun File.uploadTo(contact: FileSupported, path: String): FileMessage =
+            toExternalResource().use { contact.uploadFile(path, it) }
+
+        /**
+         * 上传文件并获取文件消息. 无论上传是否成功, 本函数都不会关闭资源.
+         * @param path 远程路径. 起始字符为 '/'. 如 '/foo/bar.txt'
+         * @since 2.5
+         * @see RemoteFile.path
+         * @see RemoteFile.upload
+         */
+        @JvmStatic
+        @JvmBlockingBridge
+        public suspend fun ExternalResource.uploadAsFileTo(contact: FileSupported, path: String): FileMessage =
+            contact.uploadFile(path, this)
 
         /**
          * 将文件作为语音上传后构造 [Voice].

+ 390 - 0
mirai-core-api/src/commonMain/kotlin/utils/RemoteFile.kt

@@ -0,0 +1,390 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ *  此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ *  Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ *  https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+@file:Suppress("unused")
+@file:JvmBlockingBridge
+
+package net.mamoe.mirai.utils
+
+import kotlinx.coroutines.channels.SendChannel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import net.mamoe.kjbb.JvmBlockingBridge
+import net.mamoe.mirai.contact.Contact
+import net.mamoe.mirai.contact.FileSupported
+import net.mamoe.mirai.contact.Group
+import net.mamoe.mirai.message.MessageReceipt
+import net.mamoe.mirai.message.data.FileMessage
+import net.mamoe.mirai.message.data.sendTo
+import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
+import java.io.File
+
+/**
+ * 表示一个远程文件或目录.
+ *
+ * @since 2.5
+ */
+@MiraiExperimentalApi
+public interface RemoteFile {
+    /**
+     * 文件名或目录名.
+     */
+    public val name: String
+
+    /**
+     * 文件的 ID. 群文件允许重名, ID 非空时用来区分重名.
+     */
+    public val id: String?
+
+    /**
+     * 标准的绝对路径, 起始字符为 '/'. 如 `/foo/bar.txt`.
+     *
+     * 根目录路径为 [ROOT_PATH]
+     */
+    public val path: String
+
+    /**
+     * 获取父目录, 当 [RemoteFile] 表示根目录时返回 `null`
+     */
+    public val parent: RemoteFile?
+
+    /**
+     * 此文件所属的群或好友
+     */
+    @MiraiExperimentalApi
+    public val contact: FileSupported
+
+    /**
+     * 当 [RemoteFile] 表示一个文件时返回 `true`.
+     */
+    public suspend fun isFile(): Boolean
+
+    /**
+     * 当 [RemoteFile] 表示一个目录时返回 `true`.
+     */
+    public suspend fun isDirectory(): Boolean = !isFile()
+
+    /**
+     * 获取文件长度. 当 [RemoteFile] 表示一个目录时行为不确定.
+     */
+    public suspend fun length(): Long
+
+    public class FileInfo @MiraiInternalApi constructor(
+        /**
+         * 文件或目录名.
+         */
+        public val name: String,
+        /**
+         * 唯一识别标识.
+         */
+        public val id: String,
+        /**
+         * 标准绝对路径.
+         */
+        public val path: String,
+        /**
+         * 文件长度 (大小) bytes, 目录的 [length] 不确定.
+         */
+        public val length: Long,
+        /**
+         * 下载次数. 目录没有下载次数, 此属性总是 `0`.
+         */
+        public val downloadTimes: Int,
+        /**
+         * 上传者 ID. 目录没有上传者, 此属性总是 `0`.
+         */
+        public val uploaderId: Long,
+        /**
+         * 上传的时间. 目录没有上传时间, 此属性总是 `0`.
+         */
+        public val uploadTime: Long,
+        /**
+         * 上次修改时间.
+         */
+        public val lastModifyTime: Long,
+        public val sha1: ByteArray,
+        public val md5: ByteArray,
+    ) {
+        /**
+         * 根据 [FileInfo.id] 或 [FileInfo.path] 获取到对应的 [RemoteFile].
+         */
+        public suspend fun resolveToFile(group: Group): RemoteFile =
+            group.filesRoot.resolveById(id) ?: group.filesRoot.resolve(path)
+    }
+
+    /**
+     * 获取这个文件或目录**此时**的详细信息. 当文件或目录不存在时返回 `null`.
+     */
+    public suspend fun getInfo(): FileInfo?
+
+    /**
+     * 当文件或目录存在时返回 `true`.
+     */
+    public suspend fun exists(): Boolean
+
+    /**
+     * @return [path]
+     */
+    public override fun toString(): String
+
+    ///////////////////////////////////////////////////////////////////////////
+    // resolve
+    ///////////////////////////////////////////////////////////////////////////
+
+    /**
+     * 获取该目录的子文件. 不会检查 [RemoteFile] 是否表示一个目录.
+     *
+     * @param relative 当初始字符为 '/' 时将作为绝对路径解析
+     * @see File.resolve stdlib 内的类似函数
+     */
+    public fun resolve(relative: String): RemoteFile
+
+    /**
+     * 获取该目录的子文件. 不会检查 [RemoteFile] 是否表示一个目录. 返回的 [RemoteFile.id] 将会与 `relative.id` 相同.
+     *
+     * @param relative 当 [RemoteFile.path] 初始字符为 '/' 时将作为绝对路径解析
+     * @see File.resolve stdlib 内的类似函数
+     */
+    public fun resolve(relative: RemoteFile): RemoteFile
+
+    /**
+     * 获取该目录下的 ID 为 [id] 的文件, 当 [deep] 为 `true` 时还会进入子目录继续寻找这样的文件. 在不存在时返回 `null`.
+     * @see resolve
+     */
+    public suspend fun resolveById(id: String, deep: Boolean = true): RemoteFile?
+
+    /**
+     * 获取该目录或子目录下的 ID 为 [id] 的文件, 在不存在时返回 `null`
+     * @see resolve
+     */
+    public suspend fun resolveById(id: String): RemoteFile? = resolveById(id, deep = true)
+
+    /**
+     * 获取父目录的子文件. 如 `RemoteFile("/foo/bar").resolveSibling("gav")` 为 `RemoteFile("/foo/gav")`.
+     * 不会检查 [RemoteFile] 是否表示一个目录.
+     *
+     * @param relative 当初始字符为 '/' 时将作为绝对路径解析
+     * @see File.resolveSibling stdlib 内的类似函数
+     */
+    public fun resolveSibling(relative: String): RemoteFile
+
+    /**
+     * 获取父目录的子文件. 如 `RemoteFile("/foo/bar").resolveSibling("gav")` 为 `RemoteFile("/foo/gav")`.
+     * 不会检查 [RemoteFile] 是否表示一个目录. 返回的 [RemoteFile.id] 将会与 `relative.id` 相同.
+     *
+     * @param relative 当 [RemoteFile.path] 初始字符为 '/' 时将作为绝对路径解析
+     * @see File.resolveSibling stdlib 内的类似函数
+     */
+    public fun resolveSibling(relative: RemoteFile): RemoteFile
+
+    ///////////////////////////////////////////////////////////////////////////
+    // operations
+    ///////////////////////////////////////////////////////////////////////////
+
+    /**
+     * 删除这个文件或目录. 若目录非空, 则会删除目录中的所有文件. 操作目录或非 Bot 自己上传的文件时需要管理员权限, 无管理员权限时返回 `false`.
+     */
+    public suspend fun delete(): Boolean
+
+    /**
+     * 重命名这个文件或目录, 将会更改 [RemoteFile.name] 属性值.
+     * 操作非 Bot 自己上传的文件时需要管理员权限.
+     */
+    public suspend fun renameTo(name: String): Boolean
+
+    /**
+     * 将这个目录或文件移动到另一个位置. 操作目录或非 Bot 自己上传的文件时需要管理员权限, 无管理员权限时返回 `false`.
+     */
+    public suspend fun moveTo(target: RemoteFile): Boolean
+
+    /**
+     * 将这个目录或文件移动到另一个位置. 操作目录或非 Bot 自己上传的文件时需要管理员权限, 无管理员权限时返回 `false`.
+     */
+    public suspend fun moveTo(path: String): Boolean
+
+    /**
+     * 创建目录. 目录已经存在或无管理员权限时返回 `false`.
+     */
+    public suspend fun mkdir(): Boolean
+
+    /**
+     * 获取该目录下所有文件, 返回的 [RemoteFile] 都拥有 [RemoteFile.id] 用于区分重名文件或目录. 当 [RemoteFile] 表示一个文件时返回 [emptyFlow].
+     */
+    public suspend fun listFiles(): Flow<RemoteFile>
+
+    /**
+     * 获取该目录下所有文件, 返回的 [RemoteFile] 都拥有 [RemoteFile.id] 用于区分重名文件或目录. 当 [RemoteFile] 表示一个文件时返回空迭代器.
+     * @param lazy 为 `true` 时惰性获取, 为 `false` 时立即获取全部文件列表.
+     */
+    @JavaFriendlyAPI
+    public suspend fun listFilesIterator(lazy: Boolean): Iterator<RemoteFile>
+
+    /**
+     * 得到相应文件消息, 可以发送. 当 [RemoteFile] 表示一个目录或文件不存在时返回 `null`.
+     */
+    public suspend fun toMessage(): FileMessage?
+
+    ///////////////////////////////////////////////////////////////////////////
+    // upload & download
+    ///////////////////////////////////////////////////////////////////////////
+
+    /**
+     * 上传进度回调
+     */
+    public interface ProgressionCallback {
+        public fun onBegin(file: RemoteFile, resource: ExternalResource) {}
+        public fun onProgression(file: RemoteFile, resource: ExternalResource, downloadedSize: Long) {}
+        public fun onSuccess(file: RemoteFile, resource: ExternalResource) {}
+        public fun onFailure(file: RemoteFile, resource: ExternalResource, exception: Throwable) {}
+
+        public companion object {
+            @JvmStatic
+            @MiraiExperimentalApi
+            public fun SendChannel<Long>.asProgressionCallback(closeOnFinish: Boolean = true): ProgressionCallback {
+                return object : ProgressionCallback {
+                    override fun onProgression(file: RemoteFile, resource: ExternalResource, downloadedSize: Long) {
+                        offer(downloadedSize)
+                    }
+
+                    override fun onSuccess(file: RemoteFile, resource: ExternalResource) {
+                        if (closeOnFinish) this@asProgressionCallback.close()
+                    }
+
+                    override fun onFailure(file: RemoteFile, resource: ExternalResource, exception: Throwable) {
+                        if (closeOnFinish) this@asProgressionCallback.close(exception)
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * 上传文件到 [RemoteFile] 表示的路径, 上传过程中调用 [callback] 传递进度. 当无权上传或其他原因失败时返回 `false`.
+     *
+     * 上传后不会发送文件消息, 即官方客户端只能在 "群文件" 中查看文件.
+     * 可通过 [toMessage] 获取到文件消息并通过 [Group.sendMessage] 发送, 或使用 [uploadAndSend].
+     *
+     * 若 [RemoteFile.id] 存在且旧文件存在, 将会覆盖旧文件.
+     * 即使用 [resolve] 或 [resolveSibling] 获取到的 [RemoteFile] 的 [upload] 总是上传一个新文件,
+     * 而使用 [resolveById] 或 [listFiles] 获取到的总是覆盖旧文件, 当旧文件已在远程删除时上传一个新文件.
+     *
+     * @param resource 需要上传的文件资源. 无论上传是否成功, 本函数都不会关闭 [resource].
+     */
+    public suspend fun upload(
+        resource: ExternalResource,
+        callback: ProgressionCallback? = null
+    ): Boolean
+
+    /**
+     * 上传文件到 [RemoteFile] 表示的路径. 当无权上传或其他原因失败时返回 `false`.
+     *
+     * 上传后不会发送文件消息, 即官方客户端只能在 "群文件" 中查看文件.
+     * 可通过 [toMessage] 获取到文件消息并通过 [Group.sendMessage] 发送, 或使用 [uploadAndSend].
+     *
+     * 若 [RemoteFile.id] 存在且旧文件存在, 将会覆盖旧文件.
+     * 即使用 [resolve] 或 [resolveSibling] 获取到的 [RemoteFile] 的 [upload] 总是上传一个新文件,
+     * 而使用 [resolveById] 或 [listFiles] 获取到的总是覆盖旧文件, 当旧文件已在远程删除时上传一个新文件.
+     *
+     * @param resource 需要上传的文件资源. 无论上传是否成功, 本函数都不会关闭 [resource].
+     * @see upload
+     */
+    public suspend fun upload(resource: ExternalResource): Boolean = upload(resource, null)
+
+    /**
+     * 上传文件.
+     * @see upload
+     */
+    public suspend fun upload(file: File): Boolean = file.toExternalResource().use { upload(it) }
+
+    /**
+     * 上传文件并发送文件消息.
+     * @param resource 需要上传的文件资源. 无论上传是否成功, 本函数都不会关闭 [resource].
+     * @see upload
+     */
+    @MiraiExperimentalApi
+    public suspend fun uploadAndSend(resource: ExternalResource): MessageReceipt<Contact>
+
+    /**
+     * 上传文件并发送文件消息.
+     * @see uploadAndSend
+     */
+    @MiraiExperimentalApi
+    public suspend fun uploadAndSend(file: File): MessageReceipt<Contact> =
+        file.toExternalResource().use { uploadAndSend(it) }
+
+    /**
+     * 获取文件下载链接, 当文件不存在或 [RemoteFile] 表示一个目录时返回 `null`
+     */
+    public suspend fun getDownloadInfo(): DownloadInfo?
+
+    public class DownloadInfo @MiraiInternalApi constructor(
+        /**
+         * @see RemoteFile.name
+         */
+        public val filename: String,
+        /**
+         * @see RemoteFile.id
+         */
+        public val id: String,
+        /**
+         * 标准绝对路径
+         * @see RemoteFile.path
+         */
+        public val path: String,
+        /**
+         * HTTP or HTTPS URL
+         */
+        public val url: String,
+        public val sha1: ByteArray,
+        public val md5: ByteArray,
+    ) {
+        override fun toString(): String {
+            return "DownloadInfo(filename='$filename', path='$path', url='$url', sha1=${sha1.toUHexString("")}, " +
+                    "md5=${md5.toUHexString("")})"
+        }
+    }
+
+    public companion object {
+        /**
+         * 根目录路径
+         * @see RemoteFile.path
+         */
+        public const val ROOT_PATH: String = "/"
+
+        /**
+         * 上传文件并获取文件消息.
+         * @param path 远程路径. 起始字符为 '/'. 如 '/foo/bar.txt'
+         * @param resource 需要上传的文件资源. 无论上传是否成功, 本函数都不会关闭 [resource].
+         * @see RemoteFile.upload
+         */
+        @JvmStatic
+        public suspend fun FileSupported.uploadFile(path: String, resource: ExternalResource): FileMessage {
+            val file = this.filesRoot.resolve(path)
+            if (!file.upload(resource)) error("Failed to upload file")
+            return file.toMessage() ?: error("Failed to create FileMessage.")
+        }
+
+        /**
+         * 上传文件并获取文件消息.
+         * @param resource 需要上传的文件资源. 无论上传是否成功, 本函数都不会关闭 [resource].
+         * @see RemoteFile.upload
+         */
+        @JvmStatic
+        public suspend fun <C : FileSupported> C.uploadFileAndSend(
+            path: String,
+            resource: ExternalResource
+        ): MessageReceipt<C> {
+            val file = this.filesRoot.resolve(path)
+            if (!file.upload(resource)) {
+                error("Failed to upload file")
+            }
+            return file.toMessage()?.sendTo(this) ?: error("Failed to create FileMessage.")
+        }
+    }
+}

+ 5 - 1
mirai-core-utils/src/androidMain/kotlin/Actuals.kt

@@ -15,6 +15,10 @@ package net.mamoe.mirai.utils
 import android.util.Base64
 
 
-public actual fun ByteArray.encodeToBase64(): String {
+public actual fun ByteArray.encodeBase64(): String {
     return Base64.encodeToString(this, Base64.DEFAULT)
+}
+
+public actual fun String.decodeBase64(): ByteArray {
+    return Base64.decode(this, Base64.DEFAULT)
 }

+ 2 - 1
mirai-core-utils/src/commonMain/kotlin/Bytes.kt

@@ -152,7 +152,8 @@ public fun UByteArray.toUHexString(separator: String = " ", offset: Int = 0, len
 public inline fun ByteArray.encodeToString(offset: Int = 0, charset: Charset = Charsets.UTF_8): String =
     kotlinx.io.core.String(this, charset = charset, offset = offset, length = this.size - offset)
 
-public expect fun ByteArray.encodeToBase64(): String
+public expect fun ByteArray.encodeBase64(): String
+public expect fun String.decodeBase64(): ByteArray
 
 public inline fun ByteArray.toReadPacket(offset: Int = 0, length: Int = this.size - offset): ByteReadPacket =
     ByteReadPacket(this, offset = offset, length = length)

+ 17 - 1
mirai-core-utils/src/commonMain/kotlin/MiraiPlatformUtils.kt

@@ -47,7 +47,11 @@ public fun ByteArray.unzip(offset: Int = 0, length: Int = size - offset): ByteAr
 }
 
 public fun InputStream.md5(): ByteArray {
-    val digest = MessageDigest.getInstance("md5")
+    return digest("md5")
+}
+
+public fun InputStream.digest(algorithm: String): ByteArray {
+    val digest = MessageDigest.getInstance(algorithm)
     digest.reset()
     use { input ->
         object : OutputStream() {
@@ -65,6 +69,10 @@ public fun InputStream.md5(): ByteArray {
     return digest.digest()
 }
 
+public fun InputStream.sha1(): ByteArray {
+    return digest("SHA-1")
+}
+
 /**
  * Localhost 解析
  */
@@ -80,6 +88,14 @@ public fun ByteArray.md5(offset: Int = 0, length: Int = size - offset): ByteArra
     return MessageDigest.getInstance("MD5").apply { update(this@md5, offset, length) }.digest()
 }
 
+public fun String.sha1(): ByteArray = toByteArray().sha1()
+
+@JvmOverloads
+public fun ByteArray.sha1(offset: Int = 0, length: Int = size - offset): ByteArray {
+    checkOffsetAndLength(offset, length)
+    return MessageDigest.getInstance("SHA-1").apply { update(this@sha1, offset, length) }.digest()
+}
+
 @JvmOverloads
 public fun ByteArray.ungzip(offset: Int = 0, length: Int = size - offset): ByteArray {
     return GZIPInputStream(inputStream(offset, length)).use { it.readBytes() }

+ 5 - 1
mirai-core-utils/src/jvmMain/kotlin/Actuals.kt

@@ -15,6 +15,10 @@ package net.mamoe.mirai.utils
 import java.util.*
 
 
-public actual fun ByteArray.encodeToBase64(): String {
+public actual fun ByteArray.encodeBase64(): String {
     return Base64.getEncoder().encodeToString(this)
+}
+
+public actual fun String.decodeBase64(): ByteArray {
+    return Base64.getDecoder().decode(this)
 }

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

@@ -65,6 +65,7 @@ internal open class MiraiImpl : IMirai, LowLevelApiAccessor {
             MessageSerializers.registerSerializer(OnlineGroupImageImpl::class, OnlineGroupImageImpl.serializer())
 
             MessageSerializers.registerSerializer(MarketFaceImpl::class, MarketFaceImpl.serializer())
+            MessageSerializers.registerSerializer(FileMessageImpl::class, FileMessageImpl.serializer())
 
             // MessageSource
 

+ 3 - 0
mirai-core/src/commonMain/kotlin/contact/GroupImpl.kt

@@ -35,6 +35,7 @@ import net.mamoe.mirai.internal.network.protocol.packet.chat.voice.PttStore
 import net.mamoe.mirai.internal.network.protocol.packet.chat.voice.voiceCodec
 import net.mamoe.mirai.internal.network.protocol.packet.list.ProfileService
 import net.mamoe.mirai.internal.utils.GroupPkgMsgParsingCache
+import net.mamoe.mirai.internal.utils.RemoteFileImpl
 import net.mamoe.mirai.internal.utils.io.serialization.toByteArray
 import net.mamoe.mirai.message.MessageReceipt
 import net.mamoe.mirai.message.data.*
@@ -72,6 +73,8 @@ internal class GroupImpl(
     override lateinit var owner: NormalMember
     override lateinit var botAsMember: NormalMember
 
+    override val filesRoot: RemoteFile by lazy { RemoteFileImpl(this, "/") }
+
     override val members: ContactList<NormalMember> = ContactList(members.mapNotNullTo(ConcurrentLinkedQueue()) {
         if (it.uin == bot.id) {
             botAsMember = newMember(it).cast()

+ 36 - 24
mirai-core/src/commonMain/kotlin/contact/SendMessageHandler.kt

@@ -10,6 +10,9 @@
 package net.mamoe.mirai.internal.contact
 
 import contact.StrangerImpl
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.async
 import net.mamoe.mirai.contact.*
 import net.mamoe.mirai.event.nextEventOrNull
 import net.mamoe.mirai.internal.MiraiImpl
@@ -21,6 +24,7 @@ import net.mamoe.mirai.internal.network.Packet
 import net.mamoe.mirai.internal.network.QQAndroidClient
 import net.mamoe.mirai.internal.network.protocol.data.proto.MsgComm
 import net.mamoe.mirai.internal.network.protocol.packet.OutgoingPacket
+import net.mamoe.mirai.internal.network.protocol.packet.chat.FileManagement
 import net.mamoe.mirai.internal.network.protocol.packet.chat.MusicSharePacket
 import net.mamoe.mirai.internal.network.protocol.packet.chat.image.ImgStore
 import net.mamoe.mirai.internal.network.protocol.packet.chat.receive.*
@@ -120,7 +124,7 @@ internal abstract class SendMessageHandler<C : Contact> {
 
         val group = contact
 
-        var source: OnlineMessageSource.Outgoing? = null
+        var source: Deferred<OnlineMessageSource.Outgoing>? = null
 
         bot.network.run {
             sendMessageMultiProtocol(
@@ -162,34 +166,37 @@ internal abstract class SendMessageHandler<C : Contact> {
                     is MusicSharePacket.Response -> {
                         resp.pkg.checkSuccess("send music share")
 
-                        source = constructSourceFromMusicShareResponse(finalMessage, resp)
+                        source = CompletableDeferred(constructSourceForSpecialMessage(finalMessage, 3116))
                     }
+//                    is CommonOidbResponse<*> -> {
+//                        when (resp.toResult("send message").getOrThrow()) {
+//                            is Oidb0x6d9.FeedsRspBody -> {
+//                            }
+//                        }
+//                    }
                 }
             }
 
-            check(source != null) {
-                "Internal error: source is not initialized"
-            }
+            val sourceAwait = source?.await() ?: error("Internal error: source is not initialized")
 
             try {
-                source!!.ensureSequenceIdAvailable()
+                sourceAwait.ensureSequenceIdAvailable()
             } catch (e: Exception) {
                 bot.network.logger.warning(
                     "Timeout awaiting sequenceId for message(${finalMessage.content.take(10)}). Some features may not work properly",
                     e
-
                 )
             }
 
-            return MessageReceipt(source!!, contact)
+            return MessageReceipt(sourceAwait, contact)
         }
     }
 
-    private fun sendMessageMultiProtocol(
+    private suspend fun sendMessageMultiProtocol(
         client: QQAndroidClient,
         message: MessageChain,
         fragmented: Boolean,
-        sourceCallback: (OnlineMessageSource.Outgoing) -> Unit
+        sourceCallback: (Deferred<OnlineMessageSource.Outgoing>) -> Unit
     ): List<OutgoingPacket> {
         message.takeSingleContent<MusicShare>()?.let { musicShare ->
             return listOf(
@@ -200,6 +207,12 @@ internal abstract class SendMessageHandler<C : Contact> {
             )
         }
 
+        message.takeSingleContent<FileMessage>()?.let { file ->
+            file.checkIsImpl()
+            sourceCallback(contact.async { constructSourceForSpecialMessage(message, 2021) })
+            return listOf(FileManagement.Feed(client, contact.id, file.busId, file.id))
+        }
+
         return messageSvcSendMessage(client, contact, message, fragmented, sourceCallback)
     }
 
@@ -208,12 +221,12 @@ internal abstract class SendMessageHandler<C : Contact> {
         contact: C,
         message: MessageChain,
         fragmented: Boolean,
-        sourceCallback: (OnlineMessageSource.Outgoing) -> Unit,
+        sourceCallback: (Deferred<OnlineMessageSource.Outgoing>) -> Unit,
     ) -> List<OutgoingPacket>
 
-    abstract suspend fun constructSourceFromMusicShareResponse(
+    abstract suspend fun constructSourceForSpecialMessage(
         finalMessage: MessageChain,
-        response: MusicSharePacket.Response
+        fromAppId: Int,
     ): OnlineMessageSource.Outgoing
 
     open suspend fun uploadLongMessageHighway(
@@ -321,39 +334,39 @@ internal sealed class UserSendMessageHandler<C : AbstractUser>(
 ) : SendMessageHandler<C>() {
     override val senderName: String get() = bot.nick
 
-    override suspend fun constructSourceFromMusicShareResponse(
+    override suspend fun constructSourceForSpecialMessage(
         finalMessage: MessageChain,
-        response: MusicSharePacket.Response
+        fromAppId: Int
     ): OnlineMessageSource.Outgoing {
-        throw UnsupportedOperationException("Sending MusicShare to user is not yet supported")
+        throw UnsupportedOperationException("Sending MusicShare or FileMessage to User is not yet supported")
     }
 }
 
 internal class FriendSendMessageHandler(
     contact: FriendImpl,
 ) : UserSendMessageHandler<FriendImpl>(contact) {
-    override val messageSvcSendMessage: (client: QQAndroidClient, contact: FriendImpl, message: MessageChain, fragmented: Boolean, sourceCallback: (OnlineMessageSource.Outgoing) -> Unit) -> List<OutgoingPacket> =
+    override val messageSvcSendMessage: (client: QQAndroidClient, contact: FriendImpl, message: MessageChain, fragmented: Boolean, sourceCallback: (Deferred<OnlineMessageSource.Outgoing>) -> Unit) -> List<OutgoingPacket> =
         MessageSvcPbSendMsg::createToFriend
 }
 
 internal class StrangerSendMessageHandler(
     contact: StrangerImpl,
 ) : UserSendMessageHandler<StrangerImpl>(contact) {
-    override val messageSvcSendMessage: (client: QQAndroidClient, contact: StrangerImpl, message: MessageChain, fragmented: Boolean, sourceCallback: (OnlineMessageSource.Outgoing) -> Unit) -> List<OutgoingPacket> =
+    override val messageSvcSendMessage: (client: QQAndroidClient, contact: StrangerImpl, message: MessageChain, fragmented: Boolean, sourceCallback: (Deferred<OnlineMessageSource.Outgoing>) -> Unit) -> List<OutgoingPacket> =
         MessageSvcPbSendMsg::createToStranger
 }
 
 internal class GroupTempSendMessageHandler(
     contact: NormalMemberImpl,
 ) : UserSendMessageHandler<NormalMemberImpl>(contact) {
-    override val messageSvcSendMessage: (client: QQAndroidClient, contact: NormalMemberImpl, message: MessageChain, fragmented: Boolean, sourceCallback: (OnlineMessageSource.Outgoing) -> Unit) -> List<OutgoingPacket> =
+    override val messageSvcSendMessage: (client: QQAndroidClient, contact: NormalMemberImpl, message: MessageChain, fragmented: Boolean, sourceCallback: (Deferred<OnlineMessageSource.Outgoing>) -> Unit) -> List<OutgoingPacket> =
         MessageSvcPbSendMsg::createToTemp
 }
 
 internal class GroupSendMessageHandler(
     override val contact: GroupImpl,
 ) : SendMessageHandler<GroupImpl>() {
-    override val messageSvcSendMessage: (client: QQAndroidClient, contact: GroupImpl, message: MessageChain, fragmented: Boolean, sourceCallback: (OnlineMessageSource.Outgoing) -> Unit) -> List<OutgoingPacket> =
+    override val messageSvcSendMessage: (client: QQAndroidClient, contact: GroupImpl, message: MessageChain, fragmented: Boolean, sourceCallback: (Deferred<OnlineMessageSource.Outgoing>) -> Unit) -> List<OutgoingPacket> =
         MessageSvcPbSendMsg::createToGroup
     override val senderName: String
         get() = contact.botAsMember.nameCardOrNick
@@ -371,14 +384,13 @@ internal class GroupSendMessageHandler(
         }
     }.toMessageChain()
 
-
-    override suspend fun constructSourceFromMusicShareResponse(
+    override suspend fun constructSourceForSpecialMessage(
         finalMessage: MessageChain,
-        response: MusicSharePacket.Response
+        fromAppId: Int
     ): OnlineMessageSource.Outgoing {
 
         val receipt: OnlinePushPbPushGroupMsg.SendGroupMessageReceipt =
-            nextEventOrNull(3000) { it.fromAppId == 3116 }
+            nextEventOrNull(3000) { it.fromAppId == fromAppId }
                 ?: OnlinePushPbPushGroupMsg.SendGroupMessageReceipt.EMPTY
 
         return OnlineMessageSourceToGroupImpl(

+ 0 - 118
mirai-core/src/commonMain/kotlin/contact/util.kt

@@ -13,138 +13,20 @@ package net.mamoe.mirai.internal.contact
 
 import net.mamoe.mirai.Bot
 import net.mamoe.mirai.contact.*
-import net.mamoe.mirai.event.broadcast
 import net.mamoe.mirai.event.events.*
-import net.mamoe.mirai.internal.asQQAndroidBot
 import net.mamoe.mirai.internal.message.LongMessageInternal
-import net.mamoe.mirai.internal.message.OnlineMessageSourceToFriendImpl
-import net.mamoe.mirai.internal.message.OnlineMessageSourceToStrangerImpl
-import net.mamoe.mirai.internal.message.ensureSequenceIdAvailable
-import net.mamoe.mirai.internal.network.protocol.packet.chat.receive.MessageSvcPbSendMsg
-import net.mamoe.mirai.internal.network.protocol.packet.chat.receive.createToFriend
-import net.mamoe.mirai.internal.network.protocol.packet.chat.receive.createToStranger
 import net.mamoe.mirai.internal.utils.estimateLength
 import net.mamoe.mirai.message.*
 import net.mamoe.mirai.message.data.*
 import net.mamoe.mirai.utils.cast
 import net.mamoe.mirai.utils.castOrNull
 import net.mamoe.mirai.utils.verbose
-import kotlin.contracts.InvocationKind
-import kotlin.contracts.contract
 
 internal inline val Group.uin: Long get() = this.cast<GroupImpl>().uin
 internal inline val Group.groupCode: Long get() = this.id
 internal inline val User.uin: Long get() = this.id
 internal inline val Bot.uin: Long get() = this.id
 
-internal suspend fun <T : User> Friend.sendMessageImpl(
-    message: Message,
-    friendReceiptConstructor: (OnlineMessageSourceToFriendImpl) -> MessageReceipt<Friend>,
-    tReceiptConstructor: (OnlineMessageSourceToFriendImpl) -> MessageReceipt<T>
-): MessageReceipt<T> {
-    contract { callsInPlace(friendReceiptConstructor, InvocationKind.EXACTLY_ONCE) }
-    val bot = bot.asQQAndroidBot()
-
-    val chain = kotlin.runCatching {
-        FriendMessagePreSendEvent(this, message).broadcast()
-    }.onSuccess {
-        check(!it.isCancelled) {
-            throw EventCancelledException("cancelled by FriendMessagePreSendEvent")
-        }
-    }.getOrElse {
-        throw EventCancelledException("exception thrown when broadcasting FriendMessagePreSendEvent", it)
-    }.message.toMessageChain()
-    chain.verityLength(message, this)
-
-    chain.firstIsInstanceOrNull<QuoteReply>()?.source?.ensureSequenceIdAvailable()
-
-
-    lateinit var source: OnlineMessageSourceToFriendImpl
-    val result = bot.network.runCatching {
-        MessageSvcPbSendMsg.createToFriend(
-            bot.client,
-            this@sendMessageImpl,
-            chain,
-            false
-        ) {
-            source = it
-        }.forEach { packet ->
-            packet.sendAndExpect<MessageSvcPbSendMsg.Response>().let {
-                check(it is MessageSvcPbSendMsg.Response.SUCCESS) {
-                    "Send friend message failed: $it"
-                }
-            }
-        }
-        friendReceiptConstructor(source)
-    }
-
-    result.fold(
-        onSuccess = {
-            FriendMessagePostSendEvent(this, chain, null, it)
-        },
-        onFailure = {
-            FriendMessagePostSendEvent(this, chain, it, null)
-        }
-    ).broadcast()
-
-    result.getOrThrow()
-    return tReceiptConstructor(source)
-}
-
-internal suspend fun <T : User> Stranger.sendMessageImpl(
-    message: Message,
-    strangerReceiptConstructor: (OnlineMessageSourceToStrangerImpl) -> MessageReceipt<Stranger>,
-    tReceiptConstructor: (OnlineMessageSourceToStrangerImpl) -> MessageReceipt<T>
-): MessageReceipt<T> {
-    contract { callsInPlace(strangerReceiptConstructor, InvocationKind.EXACTLY_ONCE) }
-    val bot = bot.asQQAndroidBot()
-
-    val chain = kotlin.runCatching {
-        StrangerMessagePreSendEvent(this, message).broadcast()
-    }.onSuccess {
-        check(!it.isCancelled) {
-            throw EventCancelledException("cancelled by StrangerMessagePreSendEvent")
-        }
-    }.getOrElse {
-        throw EventCancelledException("exception thrown when broadcasting StrangerMessagePreSendEvent", it)
-    }.message.toMessageChain()
-    chain.verityLength(message, this)
-
-    chain.firstIsInstanceOrNull<QuoteReply>()?.source?.ensureSequenceIdAvailable()
-
-    lateinit var source: OnlineMessageSourceToStrangerImpl
-    val result = bot.network.runCatching {
-        MessageSvcPbSendMsg.createToStranger(
-            bot.client,
-            this@sendMessageImpl,
-            chain,
-            false,
-        ) {
-            source = it
-        }.forEach { pk ->
-            pk.sendAndExpect<net.mamoe.mirai.internal.network.protocol.packet.chat.receive.MessageSvcPbSendMsg.Response>()
-                .let {
-                    kotlin.check(it is net.mamoe.mirai.internal.network.protocol.packet.chat.receive.MessageSvcPbSendMsg.Response.SUCCESS) {
-                        "Send temp message failed: $it"
-                    }
-                }
-        }
-        strangerReceiptConstructor(source)
-    }
-
-    result.fold(
-        onSuccess = {
-            StrangerMessagePostSendEvent(this, chain, null, it)
-        },
-        onFailure = {
-            StrangerMessagePostSendEvent(this, chain, it, null)
-        }
-    ).broadcast()
-
-    result.getOrThrow()
-    return tReceiptConstructor(source)
-}
-
 internal fun Contact.logMessageSent(message: Message) {
     if (message !is LongMessageInternal) {
         bot.logger.verbose("$this <- $message".replaceMagicCodes())

+ 55 - 0
mirai-core/src/commonMain/kotlin/message/FileMessageImpl.kt

@@ -0,0 +1,55 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ *  此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ *  Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ *  https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+
+package net.mamoe.mirai.internal.message
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import net.mamoe.mirai.message.data.FileMessage
+import kotlin.contracts.contract
+
+internal fun FileMessage.checkIsImpl(): FileMessageImpl {
+    contract { returns() implies (this@checkIsImpl is FileMessageImpl) }
+    return this as? FileMessageImpl ?: error("FileMessage must not be implemented manually.")
+}
+
+@Serializable
+@SerialName(FileMessage.SERIAL_NAME)
+internal class FileMessageImpl(
+    override val name: String,
+    override val id: String,
+    override val size: Long,
+    val busId: Int // internal // TODO: 2021/3/8 introduce OnlineFileMessage and OfflineFileMessage to eliminate property `busId`.
+) : FileMessage {
+    override fun toString(): String = "[mirai:file:$name,$id]"
+
+    @Suppress("DuplicatedCode")
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as FileMessageImpl
+
+        if (name != other.name) return false
+        if (id != other.id) return false
+        if (size != other.size) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = name.hashCode()
+        result = 31 * result + id.hashCode()
+        result = 31 * result + size.hashCode()
+        result = 31 * result + busId.hashCode()
+        return result
+    }
+
+}

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

@@ -9,19 +9,20 @@
 
 package net.mamoe.mirai.internal.message
 
+import io.ktor.util.*
 import kotlinx.io.core.discardExact
 import kotlinx.io.core.readUInt
+import kotlinx.io.core.readUShort
+import kotlinx.serialization.json.Json
 import net.mamoe.mirai.Bot
 import net.mamoe.mirai.contact.Contact
 import net.mamoe.mirai.internal.asQQAndroidBot
 import net.mamoe.mirai.internal.message.ReceiveMessageTransformer.cleanupRubbishMessageElements
 import net.mamoe.mirai.internal.message.ReceiveMessageTransformer.joinToMessageChain
 import net.mamoe.mirai.internal.message.ReceiveMessageTransformer.toVoice
-import net.mamoe.mirai.internal.network.protocol.data.proto.CustomFace
-import net.mamoe.mirai.internal.network.protocol.data.proto.HummerCommelem
-import net.mamoe.mirai.internal.network.protocol.data.proto.ImMsgBody
-import net.mamoe.mirai.internal.network.protocol.data.proto.MsgComm
+import net.mamoe.mirai.internal.network.protocol.data.proto.*
 import net.mamoe.mirai.internal.utils.io.serialization.loadAs
+import net.mamoe.mirai.internal.utils.io.serialization.readProtoBuf
 import net.mamoe.mirai.message.data.*
 import net.mamoe.mirai.utils.*
 
@@ -144,10 +145,12 @@ private object ReceiveMessageTransformer {
             element.lightApp != null -> decodeLightApp(element.lightApp, builder)
             element.customElem != null -> decodeCustomElem(element.customElem, builder)
             element.commonElem != null -> decodeCommonElem(element.commonElem, builder)
+            element.transElemInfo != null -> decodeTransElem(element.transElemInfo, builder)
 
             element.elemFlags2 != null
                     || element.extraInfo != null
-                    || element.generalFlags != null -> {
+                    || element.generalFlags != null
+            -> {
                 // ignore
             }
             else -> {
@@ -342,6 +345,55 @@ private object ReceiveMessageTransformer {
         }
     }
 
+    private fun decodeTransElem(
+        transElement: ImMsgBody.TransElem,
+        list: MessageChainBuilder
+    ) {
+        // file
+        // type=24
+        when (transElement.elemType) {
+            24 -> transElement.elemValue.read {
+                // group file feed
+                // 01 00 77 08 06 12 0A 61 61 61 61 61 61 2E 74 78 74 1A 06 31 35 42 79 74 65 3A 5F 12 5D 08 66 12 25 2F 64 37 34 62 62 66 33 61 2D 37 62 32 35 2D 31 31 65 62 2D 38 34 66 38 2D 35 34 35 32 30 30 37 62 35 64 39 66 18 0F 22 0A 61 61 61 61 61 61 2E 74 78 74 28 00 3A 00 42 20 61 33 32 35 66 36 33 34 33 30 65 37 61 30 31 31 66 37 64 30 38 37 66 63 33 32 34 37 35 34 39 63
+//                fun getFileRsrvAttr(file: ObjMsg.MsgContentInfo.MsgFile): HummerResv21.ResvAttr? {
+//                    if (file.ext.isEmpty()) return null
+//                    val element = kotlin.runCatching {
+//                        jsonForFileDecode.parseToJsonElement(file.ext) as? JsonObject
+//                    }.getOrNull() ?: return null
+//                    val extInfo = element["ExtInfo"]?.toString()?.decodeBase64() ?: return null
+//                    return extInfo.loadAs(HummerResv21.ResvAttr.serializer())
+//                }
+
+                val var7 = readByte()
+                if (var7 == 1.toByte()) {
+                    while (remaining > 2) {
+                        val proto = readProtoBuf(ObjMsg.ObjMsg.serializer(), readUShort().toInt())
+                        // proto.msgType=6
+
+                        val file = proto.msgContentInfo.firstOrNull()?.msgFile ?: continue // officially get(0) only.
+//                        val attr = getFileRsrvAttr(file) ?: continue
+//                        val info = attr.forwardExtFileInfo ?: continue
+
+                        list.add(
+                            FileMessageImpl(
+                                name = file.fileName,
+                                id = file.filePath, // path i.e. /a99e95fa-7b2d-11eb-adae-5452007b698a
+                                size = file.fileSize,
+                                busId = file.busId
+                            )
+                        )
+                    }
+                }
+            }
+        }
+
+    }
+
+    private val jsonForFileDecode = Json {
+        isLenient = true
+        coerceInputValues = true
+    }
+
     private fun decodeCommonElem(
         commonElem: ImMsgBody.CommonElem,
         list: MessageChainBuilder

+ 2 - 2
mirai-core/src/commonMain/kotlin/message/incomingSourceImpl.kt

@@ -28,7 +28,7 @@ import net.mamoe.mirai.internal.utils.io.serialization.toByteArray
 import net.mamoe.mirai.message.data.MessageChain
 import net.mamoe.mirai.message.data.MessageSourceKind
 import net.mamoe.mirai.message.data.OnlineMessageSource
-import net.mamoe.mirai.utils.encodeToBase64
+import net.mamoe.mirai.utils.encodeBase64
 import net.mamoe.mirai.utils.encodeToString
 import net.mamoe.mirai.utils.mapToIntArray
 import java.util.concurrent.atomic.AtomicBoolean
@@ -174,7 +174,7 @@ internal class OnlineMessageSourceFromGroupImpl(
             ?: error("cannot find member for OnlineMessageSourceFromGroupImpl. msg=${msg._miraiContentToString()}")
 
         anonymousInfo.run {
-            group.newAnonymous(anonGroupMsg!!.anonNick.encodeToString(), anonGroupMsg.anonId.encodeToBase64())
+            group.newAnonymous(anonGroupMsg!!.anonNick.encodeToString(), anonGroupMsg.anonId.encodeBase64())
         }
     }
 

+ 3 - 1
mirai-core/src/commonMain/kotlin/network/highway/ChunkedFlowSession.kt

@@ -23,7 +23,8 @@ import kotlin.contracts.contract
 internal class ChunkedFlowSession<T>(
     private val input: InputStream,
     private val buffer: ByteArray,
-    private val mapper: (buffer: ByteArray, size: Int, offset: Long) -> T
+    private val callback: Highway.ProgressionCallback? = null,
+    private val mapper: (buffer: ByteArray, size: Int, offset: Long) -> T,
 ) : Closeable {
     override fun close() {
         input.close()
@@ -38,6 +39,7 @@ internal class ChunkedFlowSession<T>(
                 val size = runBIO { input.read(buffer) }
                 if (size == -1) return
                 block(mapper(buffer, size, offset.getAndAdd(size.toLongUnsigned())))
+                callback?.onProgression(offset.get())
             }
         }
     }

+ 78 - 16
mirai-core/src/commonMain/kotlin/network/highway/Highway.kt

@@ -46,6 +46,10 @@ internal object Highway {
         var extendInfo: ByteArray? = null,
     )
 
+    fun interface ProgressionCallback {
+        fun onProgression(size: Long)
+    }
+
     suspend fun uploadResourceBdh(
         bot: QQAndroidBot,
         resource: ExternalResource,
@@ -53,10 +57,17 @@ internal object Highway {
         commandId: Int,  // group image=2, friend image=1, groupPtt=29
         extendInfo: ByteArray = EMPTY_BYTE_ARRAY,
         encrypt: Boolean = false,
-        initialTicket: ByteArray? = null,
+        initialTicket: ByteArray? = null, // null then use sig session
         tryOnce: Boolean = false,
         noBdhAwait: Boolean = false,
-        fallbackSession: (Throwable) -> BdhSession = { throw IllegalStateException("Failed to get bdh session", it) }
+        fallbackSession: (Throwable) -> BdhSession = { throw IllegalStateException("Failed to get bdh session", it) },
+        resultChecker: (CSDataHighwayHead.RspDataHighwayHead) -> Boolean = { it.errorCode == 0 },
+        createConnection: suspend (ip: String, port: Int) -> HighwayProtocolChannel = { ip, port ->
+            PlatformSocket.connect(ip, port)
+        },
+        callback: ProgressionCallback? = null,
+        dataFlag: Int = 4096,
+        localeId: Int = 2052,
     ): BdhUploadResponse {
         val bdhSession = kotlin.runCatching {
             val deferred = bot.bdhSyncer.bdhSession
@@ -83,11 +94,15 @@ internal object Highway {
                 commandId = commandId,
                 initialTicket = initialTicket ?: bdhSession.sigSession,
                 data = resource,
+                dataFlag = dataFlag,
+                localeId = localeId,
                 fileMd5 = md5,
-                extendInfo = if (encrypt) TEA.encrypt(extendInfo, bdhSession.sessionKey) else extendInfo
+                extendInfo = if (encrypt) TEA.encrypt(extendInfo, bdhSession.sessionKey) else extendInfo,
+                callback = callback
             ).sendConcurrently(
-                createConnection = { PlatformSocket.connect(ip, port) },
-                coroutines = bot.configuration.highwayUploadCoroutineCount
+                createConnection = { createConnection(ip, port) },
+                coroutines = bot.configuration.highwayUploadCoroutineCount,
+                resultChecker = resultChecker,
             ) { head ->
                 if (head.rspExtendinfo.isNotEmpty()) {
                     resp.extendInfo = head.rspExtendinfo
@@ -106,6 +121,8 @@ internal enum class ResourceKind(
     PRIVATE_VOICE("private voice"),
     GROUP_VOICE("group voice"),
 
+    GROUP_FILE("group file"),
+
     LONG_MESSAGE("long message"),
     FORWARD_MESSAGE("forward message"),
     ;
@@ -123,9 +140,9 @@ internal enum class ChannelKind(
     override fun toString(): String = display
 }
 
-internal suspend inline fun <reified R> tryServersUpload(
+internal suspend inline fun <reified R, reified IP> tryServersUpload(
     bot: QQAndroidBot,
-    servers: Collection<Pair<Int, Int>>,
+    servers: Collection<Pair<IP, Int>>,
     resourceSize: Long,
     resourceKind: ResourceKind,
     channelKind: ChannelKind,
@@ -250,10 +267,51 @@ private fun <T> Flow<T>.produceIn0(coroutineScope: CoroutineScope): ReceiveChann
     }
 }
 
+internal interface HighwayProtocolChannel {
+    suspend fun send(packet: ByteReadPacket)
+    suspend fun read(): ByteReadPacket
+}
+
+
+// backup
+
+//            createConnection = { ip, port ->
+//                SynchronousHighwayProtocolChannel { packet ->
+//                    val http = Mirai.Http
+//                    http.post("http://$ip:$port/cgi-bin/httpconn?htcmd=0x6FF0087&uin=${bot.id}") {
+//                        userAgent("QQClient")
+//                        val bytes = packet.readBytes()
+//                        body = object : OutgoingContent.WriteChannelContent() {
+//                            override val contentLength: Long get() = bytes.size.toLongUnsigned()
+//                            override val contentType: ContentType get() = ContentType.Any
+//                            override suspend fun writeTo(channel: ByteWriteChannel) {
+//                                channel.writeFully(bytes)
+//                            }
+//                        }
+//                    }
+//                }
+//            }
+
+internal class SynchronousHighwayProtocolChannel(
+    val action: suspend (ByteReadPacket) -> ByteArray
+) : HighwayProtocolChannel {
+    @Volatile
+    var result: ByteArray? = null
+
+    override suspend fun send(packet: ByteReadPacket) {
+        result = action(packet)
+    }
+
+    override suspend fun read(): ByteReadPacket {
+        return result?.toReadPacket() ?: error("result is null")
+    }
+}
+
 internal suspend fun ChunkedFlowSession<ByteReadPacket>.sendConcurrently(
-    createConnection: suspend () -> PlatformSocket,
+    createConnection: suspend () -> HighwayProtocolChannel,
     coroutines: Int = 5,
-    respCallback: (resp: CSDataHighwayHead.RspDataHighwayHead) -> Unit = {}
+    resultChecker: (CSDataHighwayHead.RspDataHighwayHead) -> Boolean,
+    respCallback: (resp: CSDataHighwayHead.RspDataHighwayHead) -> Unit = {},
 ) = coroutineScope {
     val channel = asFlow().produceIn0(this)
     // 'single thread' producer emits chunks to channel
@@ -264,7 +322,7 @@ internal suspend fun ChunkedFlowSession<ByteReadPacket>.sendConcurrently(
             while (isActive) {
                 val next = channel.tryReceive() ?: break // concurrent-safe receive
                 val result = next.withUse {
-                    socket.sendReceiveHighway(next)
+                    socket.sendReceiveHighway(next, resultChecker)
                 }
                 respCallback(result)
             }
@@ -282,8 +340,9 @@ private suspend fun <E : Any> ReceiveChannel<E>.tryReceive(): E? {
     }.getOrNull()
 }
 
-private suspend fun PlatformSocket.sendReceiveHighway(
+private suspend fun HighwayProtocolChannel.sendReceiveHighway(
     it: ByteReadPacket,
+    resultChecker: (CSDataHighwayHead.RspDataHighwayHead) -> Boolean,
 ): CSDataHighwayHead.RspDataHighwayHead {
     send(it)
     //0A 3C 08 01 12 0A 31 39 39 34 37 30 31 30 32 31 1A 0C 50 69 63 55 70 2E 44 61 74 61 55 70 20 E9 A7 05 28 00 30 BD DB 8B 80 02 38 80 20 40 02 4A 0A 38 2E 32 2E 30 2E 31 32 39 36 50 84 10 12 3D 08 00 10 FD 08 18 00 20 FD 08 28 C6 01 38 00 42 10 D4 1D 8C D9 8F 00 B2 04 E9 80 09 98 EC F8 42 7E 4A 10 D4 1D 8C D9 8F 00 B2 04 E9 80 09 98 EC F8 42 7E 50 89 92 A2 FB 06 58 00 60 00 18 53 20 01 28 00 30 04 3A 00 40 E6 B7 F7 D9 80 2E 48 00 50 00
@@ -293,7 +352,9 @@ private suspend fun PlatformSocket.sendReceiveHighway(
         val headLength = readInt()
         discardExact(4)
         val proto = readProtoBuf(CSDataHighwayHead.RspDataHighwayHead.serializer(), length = headLength)
-        check(proto.errorCode == 0) { "highway transfer failed, error ${proto.errorCode}" }
+        check(resultChecker(proto)) { "highway transfer failed, error ${proto.errorCode}" }
+        // error 70: 某属性有误
+        // error 79: 没有 body (可能)
         return proto
     }
 }
@@ -305,19 +366,20 @@ internal fun highwayPacketSession(
     appId: Int,
     dataFlag: Int = 4096,
     commandId: Int,
-    localId: Int = 2052,
+    localeId: Int = 2052,
     initialTicket: ByteArray,
     data: ExternalResource,
     fileMd5: ByteArray,
     sizePerPacket: Int = ByteArrayPool.BUFFER_SIZE,
     extendInfo: ByteArray = EMPTY_BYTE_ARRAY,
+    callback: Highway.ProgressionCallback? = null,
 ): ChunkedFlowSession<ByteReadPacket> {
     ByteArrayPool.checkBufferSize(sizePerPacket)
     //   require(ticket.size == 128) { "bad uKey. Required size=128, got ${ticket.size}" }
 
     val ticket = AtomicReference(initialTicket)
 
-    return ChunkedFlowSession(data.inputStream(), ByteArray(sizePerPacket)) { buffer, size, offset ->
+    return ChunkedFlowSession(data.inputStream(), ByteArray(sizePerPacket), callback) { buffer, size, offset ->
         val head = CSDataHighwayHead.ReqDataHighwayHead(
             msgBasehead = CSDataHighwayHead.DataHighwayHead(
                 version = 1,
@@ -328,13 +390,13 @@ internal fun highwayPacketSession(
                     1 -> client.nextHighwayDataTransSequenceIdForFriend()
                     27 -> client.nextHighwayDataTransSequenceIdForApplyUp()
                     29 -> client.nextHighwayDataTransSequenceIdForGroup()
-                    else -> error("illegal commandId: $commandId")
+                    else -> client.nextHighwayDataTransSequenceIdForGroup()
                 },
                 retryTimes = 0,
                 appid = appId,
                 dataflag = dataFlag,
                 commandId = commandId,
-                localeId = localId
+                localeId = localeId
             ),
             msgSeghead = CSDataHighwayHead.SegHead(
                 //   cacheAddr = 812157193,

+ 81 - 0
mirai-core/src/commonMain/kotlin/network/protocol/data/proto/Exciting.kt

@@ -0,0 +1,81 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ *  此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ *  Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ *  https://github.com/mamoe/mirai/blob/master/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
+
+@Serializable
+internal class GroupFileUploadExt(
+    @JvmField @ProtoNumber(1) val u1: Int,
+    @JvmField @ProtoNumber(2) val u2: Int,
+    @JvmField @ProtoNumber(100) val entry: GroupFileUploadEntry,
+    @JvmField @ProtoNumber(3) val u3: Int,
+) : ProtoBuf
+
+@Serializable
+internal class GroupFileUploadEntry(
+    @JvmField @ProtoNumber(100) val business: ExcitingBusiInfo,
+    @JvmField @ProtoNumber(200) val fileEntry: ExcitingFileEntry,
+    @JvmField @ProtoNumber(300) val clientInfo: ExcitingClientInfo,
+    @JvmField @ProtoNumber(400) val fileNameInfo: ExcitingFileNameInfo,
+    @JvmField @ProtoNumber(500) val host: ExcitingHostConfig,
+) : ProtoBuf
+
+@Serializable
+internal class ExcitingBusiInfo(
+    @JvmField @ProtoNumber(1) val busId: Int,
+    @JvmField @ProtoNumber(100) val senderUin: Long,
+    @JvmField @ProtoNumber(200) val receiverUin: Long, // maybe
+    @JvmField @ProtoNumber(400) val groupCode: Long, // maybe
+) : ProtoBuf
+
+@Serializable
+internal class ExcitingFileEntry(
+    @JvmField @ProtoNumber(100) val fileSize: Long,
+    @JvmField @ProtoNumber(200) val md5: ByteArray,
+    @JvmField @ProtoNumber(300) val sha1: ByteArray,
+    @JvmField @ProtoNumber(600) val fileId: ByteArray,
+    @JvmField @ProtoNumber(700) val uploadKey: ByteArray,
+) : ProtoBuf
+
+
+@Serializable
+internal class ExcitingClientInfo(
+    @JvmField @ProtoNumber(100) val clientType: Int, // maybe
+    @JvmField @ProtoNumber(200) val appId: String,
+    @JvmField @ProtoNumber(300) val terminalType: Int,
+    @JvmField @ProtoNumber(400) val clientVer: String,
+    @JvmField @ProtoNumber(600) val unknown: Int,
+) : ProtoBuf
+
+@Serializable
+internal class ExcitingFileNameInfo(
+    @JvmField @ProtoNumber(100) val filename: String,
+) : ProtoBuf
+
+@Serializable
+internal class ExcitingHostConfig(
+    @JvmField @ProtoNumber(200) val hosts: List<ExcitingHostInfo>,
+) : ProtoBuf
+
+@Serializable
+internal class ExcitingHostInfo(
+    @JvmField @ProtoNumber(1) val url: ExcitingUrlInfo,
+    @JvmField @ProtoNumber(2) val port: Int,
+) : ProtoBuf
+
+@Serializable
+internal class ExcitingUrlInfo(
+    @JvmField @ProtoNumber(1) val unknown: Int,
+    @JvmField @ProtoNumber(2) val host: String,
+) : ProtoBuf

+ 104 - 0
mirai-core/src/commonMain/kotlin/network/protocol/data/proto/GroupFileCommon.kt

@@ -0,0 +1,104 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ *  此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ *  Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ *  https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+@file:Suppress("unused", "SpellCheckingInspection")
+
+package net.mamoe.mirai.internal.network.protocol.data.proto
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.protobuf.ProtoNumber
+import net.mamoe.mirai.internal.network.protocol.packet.EMPTY_BYTE_ARRAY
+import net.mamoe.mirai.internal.utils.io.ProtoBuf
+
+internal class GroupFileCommon : ProtoBuf {
+    @Serializable
+    internal class FeedsInfo(
+        @JvmField @ProtoNumber(1) val busId: Int = 0,
+        @JvmField @ProtoNumber(2) val fileId: String = "",
+        @JvmField @ProtoNumber(3) val msgRandom: Int = 0,
+        @JvmField @ProtoNumber(4) val ext: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(5) val feedFlag: Int = 0,
+        @JvmField @ProtoNumber(6) val msgCtrl: MsgCtrl.MsgCtrl? = null
+    ) : ProtoBuf
+
+    @Serializable
+    internal class FeedsResult(
+        @JvmField @ProtoNumber(1) val int32RetCode: Int = 0,
+        @JvmField @ProtoNumber(2) val detail: String = "",
+        @JvmField @ProtoNumber(3) val fileId: String = "",
+        @JvmField @ProtoNumber(4) val busId: Int = 0,
+        @JvmField @ProtoNumber(5) val deadTime: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class FileInfo(
+        @JvmField @ProtoNumber(1) val fileId: String = "",
+        @JvmField @ProtoNumber(2) val fileName: String = "",
+        @JvmField @ProtoNumber(3) val fileSize: Long = 0L,
+        @JvmField @ProtoNumber(4) val busId: Int = 0,
+        @JvmField @ProtoNumber(5) val uploadedSize: Long = 0L,
+        @JvmField @ProtoNumber(6) val uploadTime: Int = 0,
+        @JvmField @ProtoNumber(7) val deadTime: Int = 0,
+        @JvmField @ProtoNumber(8) val modifyTime: Int = 0,
+        @JvmField @ProtoNumber(9) val downloadTimes: Int = 0,
+        @JvmField @ProtoNumber(10) val sha: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(11) val sha3: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(12) val md5: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(13) val localPath: String = "",
+        @JvmField @ProtoNumber(14) val uploaderName: String = "",
+        @JvmField @ProtoNumber(15) val uploaderUin: Long = 0L,
+        @JvmField @ProtoNumber(16) val parentFolderId: String = "",
+        @JvmField @ProtoNumber(17) val safeType: Int = 0,
+        @JvmField @ProtoNumber(20) val fileBlobExt: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(21) val ownerUin: Long = 0L,
+        @JvmField @ProtoNumber(22) val feedId: String = "",
+        @JvmField @ProtoNumber(23) val reservedField: ByteArray = EMPTY_BYTE_ARRAY
+    ) : ProtoBuf
+
+    @Serializable
+    internal class FileInfoTmem(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val files: List<FileInfo> = emptyList()
+    ) : ProtoBuf
+
+    @Serializable
+    internal class FileItem(
+        @JvmField @ProtoNumber(1) val type: Int = 0,
+        @JvmField @ProtoNumber(2) val folderInfo: FolderInfo? = null,
+        @JvmField @ProtoNumber(3) val fileInfo: FileInfo? = null
+    ) : ProtoBuf
+
+    @Serializable
+    internal class FolderInfo(
+        @JvmField @ProtoNumber(1) val folderId: String = "", // uuid
+        @JvmField @ProtoNumber(2) val parentFolderId: String = "",
+        @JvmField @ProtoNumber(3) val folderName: String = "",
+        @JvmField @ProtoNumber(4) val createTime: Int = 0,
+        @JvmField @ProtoNumber(5) val modifyTime: Int = 0,
+        @JvmField @ProtoNumber(6) val createUin: Long = 0L,
+        @JvmField @ProtoNumber(7) val creatorName: String = "",
+        @JvmField @ProtoNumber(8) val totalFileCount: Int = 0,
+        @JvmField @ProtoNumber(9) val modifyUin: Long = 0L,
+        @JvmField @ProtoNumber(10) val modifyName: String = "",
+        @JvmField @ProtoNumber(11) val usedSpace: Long = 0L
+    ) : ProtoBuf
+
+    @Serializable
+    internal class FolderInfoTmem(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val folders: List<FolderInfo> = emptyList()
+    ) : ProtoBuf
+
+    @Serializable
+    internal class OverwriteInfo(
+        @JvmField @ProtoNumber(1) val fileId: String = "",
+        @JvmField @ProtoNumber(2) val downloadTimes: Int = 0
+    ) : ProtoBuf
+}
+        

+ 6 - 6
mirai-core/src/commonMain/kotlin/network/protocol/data/proto/Highway.kt

@@ -160,10 +160,10 @@ internal class CSDataHighwayHead : ProtoBuf {
         @JvmField @ProtoNumber(2) val uin: String = "",
         @JvmField @ProtoNumber(3) val command: String = "",
         @JvmField @ProtoNumber(4) val seq: Int = 0,
-        @JvmField @ProtoNumber(5) val retryTimes: Int,// = 0,
-        @JvmField @ProtoNumber(6) val appid: Int,// = 0,
-        @JvmField @ProtoNumber(7) val dataflag: Int,// = 0,
-        @JvmField @ProtoNumber(8) val commandId: Int,// = 0,
+        @JvmField @ProtoNumber(5) val retryTimes: Int? = null,// = 0,
+        @JvmField @ProtoNumber(6) val appid: Int? = null,// = 0,
+        @JvmField @ProtoNumber(7) val dataflag: Int? = null,// = 0,
+        @JvmField @ProtoNumber(8) val commandId: Int? = null,// = 0,
         @JvmField @ProtoNumber(9) val buildVer: String = "",
         @JvmField @ProtoNumber(10) val localeId: Int = 0,
         @JvmField @ProtoNumber(11) val envId: Int = 0
@@ -279,9 +279,9 @@ internal class CSDataHighwayHead : ProtoBuf {
         @JvmField @ProtoNumber(2) val filesize: Long = 0L,
         @JvmField @ProtoNumber(3) val dataoffset: Long = 0L,
         @JvmField @ProtoNumber(4) val datalength: Int = 0,
-        @JvmField @ProtoNumber(5) val rtcode: Int, // = 0,
+        @JvmField @ProtoNumber(5) val rtcode: Int? = null, // = 0,
         @JvmField @ProtoNumber(6) val serviceticket: ByteArray = EMPTY_BYTE_ARRAY,
-        @JvmField @ProtoNumber(7) val flag: Int, // = 0,
+        @JvmField @ProtoNumber(7) val flag: Int? = null, // = 0,
         @JvmField @ProtoNumber(8) val md5: ByteArray = EMPTY_BYTE_ARRAY,
         @JvmField @ProtoNumber(9) val fileMd5: ByteArray = EMPTY_BYTE_ARRAY,
         @JvmField @ProtoNumber(10) val cacheAddr: Int = 0,

+ 2 - 3
mirai-core/src/commonMain/kotlin/network/protocol/data/proto/HummerResv21.kt

@@ -1,5 +1,5 @@
 /*
- * Copyright 2019-2020 Mamoe Technologies and contributors.
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
  *
  *  此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
  *  Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
@@ -16,7 +16,6 @@ import kotlinx.serialization.protobuf.ProtoNumber
 import net.mamoe.mirai.internal.network.protocol.packet.EMPTY_BYTE_ARRAY
 import net.mamoe.mirai.internal.utils.io.ProtoBuf
 
-@Serializable
 internal class HummerResv21 : ProtoBuf {
     @Serializable
     internal class FileImgInfo(
@@ -29,7 +28,7 @@ internal class HummerResv21 : ProtoBuf {
         @JvmField @ProtoNumber(1) val fileType: Int = 0,
         @JvmField @ProtoNumber(2) val senderUin: Long = 0L,
         @JvmField @ProtoNumber(3) val receiverUin: Long = 0L,
-        @JvmField @ProtoNumber(4) val fileUuid: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(4) val fileUuid: String = "",
         @JvmField @ProtoNumber(5) val fileName: String = "",
         @JvmField @ProtoNumber(6) val fileSize: Long = 0L,
         @JvmField @ProtoNumber(7) val fileSha1: ByteArray = EMPTY_BYTE_ARRAY,

+ 3 - 3
mirai-core/src/commonMain/kotlin/network/protocol/data/proto/Msg.kt

@@ -1192,12 +1192,12 @@ internal class ObjMsg : ProtoBuf {
         @Serializable
         internal class MsgFile(
             @ProtoNumber(1) @JvmField val busId: Int = 0,
-            @ProtoNumber(2) @JvmField val filePath: ByteArray = EMPTY_BYTE_ARRAY,
+            @ProtoNumber(2) @JvmField val filePath: String = "", // actually uuid
             @ProtoNumber(3) @JvmField val fileSize: Long = 0L,
             @ProtoNumber(4) @JvmField val fileName: String = "",
             @ProtoNumber(5) @JvmField val int64DeadTime: Long = 0L,
-            @ProtoNumber(6) @JvmField val fileSha1: ByteArray = EMPTY_BYTE_ARRAY,
-            @ProtoNumber(7) @JvmField val ext: ByteArray = EMPTY_BYTE_ARRAY
+            @ProtoNumber(6) @JvmField val fileSha1: ByteArray = EMPTY_BYTE_ARRAY, // empty
+            @ProtoNumber(7) @JvmField val ext: String = "", // originally bytes
         ) : ProtoBuf
     }
 

+ 1 - 1
mirai-core/src/commonMain/kotlin/network/protocol/data/proto/OIDB.kt

@@ -958,7 +958,7 @@ internal class OidbSso : ProtoBuf {
     @Serializable
     internal class OIDBSSOPkg(
         @ProtoNumber(1) @JvmField val command: Int = 0,
-        @ProtoNumber(2) @JvmField val serviceType: Int = 0,
+        @ProtoNumber(2) @JvmField val serviceType: Int,
         @ProtoNumber(3) @JvmField val result: Int = 0,
         @ProtoNumber(4) @JvmField val bodybuffer: ByteArray = EMPTY_BYTE_ARRAY,
         @ProtoNumber(5) @JvmField val errorMsg: String = "",

+ 179 - 0
mirai-core/src/commonMain/kotlin/network/protocol/data/proto/Oidb0x6d6.kt

@@ -0,0 +1,179 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ *  此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ *  Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ *  https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+@file:Suppress("unused", "SpellCheckingInspection")
+
+package net.mamoe.mirai.internal.network.protocol.data.proto
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.protobuf.ProtoNumber
+import net.mamoe.mirai.internal.network.protocol.packet.EMPTY_BYTE_ARRAY
+import net.mamoe.mirai.internal.network.protocol.packet.chat.CheckableStruct
+import net.mamoe.mirai.internal.utils.io.ProtoBuf
+
+internal class Oidb0x6d6 : ProtoBuf {
+    @Serializable
+    internal class DeleteFileReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0,
+        @JvmField @ProtoNumber(3) val busId: Int = 0,
+        @JvmField @ProtoNumber(4) val parentFolderId: String = "",
+        @JvmField @ProtoNumber(5) val fileId: String = "",
+        @JvmField @ProtoNumber(6) val msgdbSeq: Int = 0,
+        @JvmField @ProtoNumber(7) val msgRand: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class DeleteFileRspBody(
+        /**
+         * -103: file not exist
+         */
+        @ProtoNumber(1) override val int32RetCode: Int = 0,
+        @ProtoNumber(2) override val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = ""
+    ) : ProtoBuf, CheckableStruct
+
+    @Serializable
+    internal class DownloadFileReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0,
+        @JvmField @ProtoNumber(3) val busId: Int = 0,
+        @JvmField @ProtoNumber(4) val fileId: String = "",
+        @JvmField @ProtoNumber(5) val boolThumbnailReq: Boolean = false,
+        @JvmField @ProtoNumber(6) val urlType: Int = 0,
+        @JvmField @ProtoNumber(7) val boolPreviewReq: Boolean = false,
+        @JvmField @ProtoNumber(8) val src: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class DownloadFileRspBody(
+        @ProtoNumber(1) override val int32RetCode: Int = 0,
+        @ProtoNumber(2) override val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = "",
+        @JvmField @ProtoNumber(4) val downloadIp: String = "",
+        @JvmField @ProtoNumber(5) val downloadDns: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(6) val downloadUrl: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(7) val sha: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(8) val sha3: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(9) val md5: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(10) val cookieVal: String = "",
+        @JvmField @ProtoNumber(11) val saveFileName: String = "",
+        @JvmField @ProtoNumber(12) val previewPort: Int = 0,
+        @JvmField @ProtoNumber(13) val downloadDnsHttps: String = "",
+        @JvmField @ProtoNumber(14) val previewPortHttps: Int = 0
+    ) : ProtoBuf, CheckableStruct
+
+    @Serializable
+    internal class MoveFileReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0,
+        @JvmField @ProtoNumber(3) val busId: Int = 0,
+        @JvmField @ProtoNumber(4) val fileId: String = "",
+        @JvmField @ProtoNumber(5) val parentFolderId: String = "",
+        @JvmField @ProtoNumber(6) val destFolderId: String = ""
+    ) : ProtoBuf
+
+    @Serializable
+    internal class MoveFileRspBody(
+        @ProtoNumber(1) override val int32RetCode: Int = 0,
+        @ProtoNumber(2) override val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = "",
+        @JvmField @ProtoNumber(4) val parentFolderId: String = ""
+    ) : ProtoBuf, CheckableStruct
+
+    @Serializable
+    internal class RenameFileReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0,
+        @JvmField @ProtoNumber(3) val busId: Int = 0,
+        @JvmField @ProtoNumber(4) val fileId: String = "",
+        @JvmField @ProtoNumber(5) val parentFolderId: String = "",
+        @JvmField @ProtoNumber(6) val newFileName: String = ""
+    ) : ProtoBuf
+
+    @Serializable
+    internal class RenameFileRspBody(
+        @ProtoNumber(1) override val int32RetCode: Int = 0,
+        @ProtoNumber(2) override val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = ""
+    ) : ProtoBuf, CheckableStruct
+
+    @Serializable
+    internal class ReqBody(
+        @JvmField @ProtoNumber(1) val uploadFileReq: UploadFileReqBody? = null,
+        @JvmField @ProtoNumber(2) val resendFileReq: ResendReqBody? = null,
+        @JvmField @ProtoNumber(3) val downloadFileReq: DownloadFileReqBody? = null,
+        @JvmField @ProtoNumber(4) val deleteFileReq: DeleteFileReqBody? = null,
+        @JvmField @ProtoNumber(5) val renameFileReq: RenameFileReqBody? = null,
+        @JvmField @ProtoNumber(6) val moveFileReq: MoveFileReqBody? = null
+    ) : ProtoBuf
+
+    @Serializable
+    internal class ResendReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0,
+        @JvmField @ProtoNumber(3) val busId: Int = 0,
+        @JvmField @ProtoNumber(4) val fileId: String = "",
+        @JvmField @ProtoNumber(5) val sha: ByteArray = EMPTY_BYTE_ARRAY
+    ) : ProtoBuf
+
+    @Serializable
+    internal class ResendRspBody(
+        @ProtoNumber(1) override val int32RetCode: Int = 0,
+        @ProtoNumber(2) override val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = "",
+        @JvmField @ProtoNumber(4) val uploadIp: String = "",
+        @JvmField @ProtoNumber(5) val fileKey: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(6) val checkKey: ByteArray = EMPTY_BYTE_ARRAY
+    ) : ProtoBuf, CheckableStruct
+
+    @Serializable
+    internal class RspBody(
+        @JvmField @ProtoNumber(1) val uploadFileRsp: UploadFileRspBody? = null,
+        @JvmField @ProtoNumber(2) val resendFileRsp: ResendRspBody? = null,
+        @JvmField @ProtoNumber(3) val downloadFileRsp: DownloadFileRspBody? = null,
+        @JvmField @ProtoNumber(4) val deleteFileRsp: DeleteFileRspBody? = null,
+        @JvmField @ProtoNumber(5) val renameFileRsp: RenameFileRspBody? = null,
+        @JvmField @ProtoNumber(6) val moveFileRsp: MoveFileRspBody? = null
+    ) : ProtoBuf
+
+    @Serializable
+    internal class UploadFileReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0,
+        @JvmField @ProtoNumber(3) val busId: Int = 0,
+        @JvmField @ProtoNumber(4) val entrance: Int = 0,
+        @JvmField @ProtoNumber(5) val parentFolderId: String = "",
+        @JvmField @ProtoNumber(6) val fileName: String = "",
+        @JvmField @ProtoNumber(7) val localPath: String = "",
+        @JvmField @ProtoNumber(8) val fileSize: Long = 0L,
+        @JvmField @ProtoNumber(9) val sha: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(10) val sha3: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(11) val md5: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(15) val boolSupportMultiUpload: Boolean = false
+    ) : ProtoBuf
+
+    @Serializable
+    internal class UploadFileRspBody(
+        @ProtoNumber(1) override val int32RetCode: Int = 0,
+        @ProtoNumber(2) override val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = "",
+        @JvmField @ProtoNumber(4) val uploadIp: String = "",
+        @JvmField @ProtoNumber(5) val serverDns: String = "",
+        @JvmField @ProtoNumber(6) val busId: Int = 0,
+        @JvmField @ProtoNumber(7) val fileId: String = "",
+        @JvmField @ProtoNumber(8) val fileKey: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(9) val checkKey: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(10) val boolFileExist: Boolean = false,
+        @JvmField @ProtoNumber(12) val uploadIpLanV4: List<String> = emptyList(),
+        @JvmField @ProtoNumber(13) val uploadIpLanV6: List<String> = emptyList(),
+        @JvmField @ProtoNumber(14) val uploadPort: Int = 0
+    ) : ProtoBuf, CheckableStruct
+}
+        

+ 99 - 0
mirai-core/src/commonMain/kotlin/network/protocol/data/proto/Oidb0x6d7.kt

@@ -0,0 +1,99 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ *  此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ *  Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ *  https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+@file:Suppress("unused", "SpellCheckingInspection")
+
+package net.mamoe.mirai.internal.network.protocol.data.proto
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.protobuf.ProtoNumber
+import net.mamoe.mirai.internal.network.protocol.packet.chat.CheckableStruct
+import net.mamoe.mirai.internal.utils.io.ProtoBuf
+
+internal class Oidb0x6d7 : ProtoBuf {
+    @Serializable
+    internal class CreateFolderReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0,
+        @JvmField @ProtoNumber(3) val parentFolderId: String = "",
+        @JvmField @ProtoNumber(4) val folderName: String = ""
+    ) : ProtoBuf
+
+    @Serializable
+    internal class CreateFolderRspBody(
+        @ProtoNumber(1) override val int32RetCode: Int = 0,
+        @ProtoNumber(2) override val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = "",
+        @JvmField @ProtoNumber(4) val folderInfo: GroupFileCommon.FolderInfo? = null
+    ) : ProtoBuf, CheckableStruct
+
+    @Serializable
+    internal class DeleteFolderReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0,
+        @JvmField @ProtoNumber(3) val folderId: String = ""
+    ) : ProtoBuf
+
+    @Serializable
+    internal class DeleteFolderRspBody(
+        @ProtoNumber(1) override val int32RetCode: Int = 0,
+        @ProtoNumber(2) override val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = ""
+    ) : ProtoBuf, CheckableStruct
+
+    @Serializable
+    internal class MoveFolderReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0,
+        @JvmField @ProtoNumber(3) val folderId: String = "",
+        @JvmField @ProtoNumber(4) val parentFolderId: String = "",
+        @JvmField @ProtoNumber(5) val destFolderId: String = ""
+    ) : ProtoBuf
+
+    @Serializable
+    internal class MoveFolderRspBody(
+        @ProtoNumber(1) override val int32RetCode: Int = 0,
+        @ProtoNumber(2) override val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = "",
+        @JvmField @ProtoNumber(4) val folderInfo: GroupFileCommon.FolderInfo? = null
+    ) : ProtoBuf, CheckableStruct
+
+    @Serializable
+    internal class RenameFolderReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0,
+        @JvmField @ProtoNumber(3) val folderId: String = "",
+        @JvmField @ProtoNumber(4) val newFolderName: String = ""
+    ) : ProtoBuf
+
+    @Serializable
+    internal class RenameFolderRspBody(
+        @ProtoNumber(1) override val int32RetCode: Int = 0,
+        @ProtoNumber(2) override val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = "",
+        @JvmField @ProtoNumber(4) val folderInfo: GroupFileCommon.FolderInfo? = null
+    ) : ProtoBuf, CheckableStruct
+
+    @Serializable
+    internal class ReqBody(
+        @JvmField @ProtoNumber(1) val createFolderReq: CreateFolderReqBody? = null,
+        @JvmField @ProtoNumber(2) val deleteFolderReq: DeleteFolderReqBody? = null,
+        @JvmField @ProtoNumber(3) val renameFolderReq: RenameFolderReqBody? = null,
+        @JvmField @ProtoNumber(4) val moveFolderReq: MoveFolderReqBody? = null
+    ) : ProtoBuf
+
+    @Serializable
+    internal class RspBody(
+        @JvmField @ProtoNumber(1) val createFolderRsp: CreateFolderRspBody? = null,
+        @JvmField @ProtoNumber(2) val deleteFolderRsp: DeleteFolderRspBody? = null,
+        @JvmField @ProtoNumber(3) val renameFolderRsp: RenameFolderRspBody? = null,
+        @JvmField @ProtoNumber(4) val moveFolderRsp: MoveFolderRspBody? = null
+    ) : ProtoBuf
+}
+        

+ 168 - 0
mirai-core/src/commonMain/kotlin/network/protocol/data/proto/Oidb0x6d8.kt

@@ -0,0 +1,168 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ *  此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ *  Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ *  https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+@file:Suppress("unused", "SpellCheckingInspection")
+
+package net.mamoe.mirai.internal.network.protocol.data.proto
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.protobuf.ProtoNumber
+import net.mamoe.mirai.internal.network.protocol.packet.EMPTY_BYTE_ARRAY
+import net.mamoe.mirai.internal.utils.io.ProtoBuf
+
+internal class Oidb0x6d8 : ProtoBuf {
+    @Serializable
+    internal class FileTimeStamp(
+        @JvmField @ProtoNumber(1) val uploadTime: Int = 0,
+        @JvmField @ProtoNumber(2) val fileId: String = ""
+    ) : ProtoBuf
+
+    @Serializable
+    internal class GetFileCountReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0,
+        @JvmField @ProtoNumber(3) val busId: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class GetFileCountRspBody(
+        @JvmField @ProtoNumber(1) val int32RetCode: Int = 0,
+        @JvmField @ProtoNumber(2) val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = "",
+        @JvmField @ProtoNumber(4) val allFileCount: Int = 0,
+        @JvmField @ProtoNumber(5) val boolFileTooMany: Boolean = false,
+        @JvmField @ProtoNumber(6) val limitCount: Int = 0,
+        @JvmField @ProtoNumber(7) val boolIsFull: Boolean = false
+    ) : ProtoBuf
+
+    @Serializable
+    internal class GetFileInfoReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0,
+        @JvmField @ProtoNumber(3) val busId: Int = 0,
+        @JvmField @ProtoNumber(4) val fileId: String = "",
+        @JvmField @ProtoNumber(5) val fieldFlag: Int = 16777215
+    ) : ProtoBuf
+
+    @Serializable
+    internal class GetFileInfoRspBody(
+        @JvmField @ProtoNumber(1) val int32RetCode: Int = 0,
+        @JvmField @ProtoNumber(2) val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = "",
+        @JvmField @ProtoNumber(4) val fileInfo: GroupFileCommon.FileInfo? = null
+    ) : ProtoBuf
+
+    @Serializable
+    internal class GetFileListReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0,
+        @JvmField @ProtoNumber(3) val folderId: String = "",
+        @JvmField @ProtoNumber(4) val startTimestamp: FileTimeStamp? = null,
+        @JvmField @ProtoNumber(5) val fileCount: Int = 0,
+        @JvmField @ProtoNumber(6) val maxTimestamp: FileTimeStamp? = null,
+        @JvmField @ProtoNumber(7) val allFileCount: Int = 0,
+        @JvmField @ProtoNumber(8) val reqFrom: Int = 0,
+        @JvmField @ProtoNumber(9) val sortBy: Int = 0,
+        @JvmField @ProtoNumber(10) val filterCode: Int = 0,
+        @JvmField @ProtoNumber(11) val uin: Long = 0L,
+        @JvmField @ProtoNumber(12) val fieldFlag: Int = 16777215,
+        @JvmField @ProtoNumber(13) val startIndex: Int = 0,
+        @JvmField @ProtoNumber(14) val context: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(15) val clientVersion: Int = 0,
+        @JvmField @ProtoNumber(16) val whiteList: Int = 0,
+        @JvmField @ProtoNumber(17) val sortOrder: Int = 0,
+        @JvmField @ProtoNumber(18) val showOnlinedocFolder: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class GetFileListRspBody(
+        @JvmField @ProtoNumber(1) val int32RetCode: Int = 0,
+        @JvmField @ProtoNumber(2) val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = "",
+        @JvmField @ProtoNumber(4) val boolIsEnd: Boolean = false,
+        @JvmField @ProtoNumber(5) val itemList: List<Item> = emptyList(),
+        @JvmField @ProtoNumber(6) val msgMaxTimestamp: FileTimeStamp? = null,
+        @JvmField @ProtoNumber(7) val allFileCount: Int = 0,
+        @JvmField @ProtoNumber(8) val filterCode: Int = 0,
+        @JvmField @ProtoNumber(11) val boolSafeCheckFlag: Boolean = false,
+        @JvmField @ProtoNumber(12) val safeCheckRes: Int = 0,
+        @JvmField @ProtoNumber(13) val nextIndex: Int = 0,
+        @JvmField @ProtoNumber(14) val context: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(15) val role: Int = 0,
+        @JvmField @ProtoNumber(16) val openFlag: Int = 0
+    ) : ProtoBuf {
+        @Serializable
+        internal class Item(
+            @JvmField @ProtoNumber(1) val type: Int = 0, // folder=2,
+            @JvmField @ProtoNumber(2) val folderInfo: GroupFileCommon.FolderInfo? = null,
+            @JvmField @ProtoNumber(3) val fileInfo: GroupFileCommon.FileInfo? = null
+        ) : ProtoBuf {
+            val id get() = fileInfo?.fileId ?: folderInfo?.folderId
+            val name get() = fileInfo?.fileName ?: folderInfo?.folderName
+        }
+    }
+
+    @Serializable
+    internal class GetFilePreviewReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0,
+        @JvmField @ProtoNumber(3) val busId: Int = 0,
+        @JvmField @ProtoNumber(4) val fileId: String = ""
+    ) : ProtoBuf
+
+    @Serializable
+    internal class GetFilePreviewRspBody(
+        @JvmField @ProtoNumber(1) val int32RetCode: Int = 0,
+        @JvmField @ProtoNumber(2) val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = "",
+        @JvmField @ProtoNumber(4) val int32ServerIp: Int = 0,
+        @JvmField @ProtoNumber(5) val int32ServerPort: Int = 0,
+        @JvmField @ProtoNumber(6) val downloadDns: String = "",
+        @JvmField @ProtoNumber(7) val downloadUrl: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(8) val cookieVal: String = "",
+        @JvmField @ProtoNumber(9) val reservedField: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(10) val downloadDnsHttps: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(11) val previewPortHttps: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class GetSpaceReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class GetSpaceRspBody(
+        @JvmField @ProtoNumber(1) val int32RetCode: Int = 0,
+        @JvmField @ProtoNumber(2) val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = "",
+        @JvmField @ProtoNumber(4) val totalSpace: Long = 0L,
+        @JvmField @ProtoNumber(5) val usedSpace: Long = 0L,
+        @JvmField @ProtoNumber(6) val boolAllUpload: Boolean = false
+    ) : ProtoBuf
+
+    @Serializable
+    internal class ReqBody(
+        @JvmField @ProtoNumber(1) val fileInfoReq: GetFileInfoReqBody? = null,
+        @JvmField @ProtoNumber(2) val fileListInfoReq: GetFileListReqBody? = null,
+        @JvmField @ProtoNumber(3) val groupFileCntReq: GetFileCountReqBody? = null,
+        @JvmField @ProtoNumber(4) val groupSpaceReq: GetSpaceReqBody? = null,
+        @JvmField @ProtoNumber(5) val filePreviewReq: GetFilePreviewReqBody? = null
+    ) : ProtoBuf
+
+    @Serializable
+    internal class RspBody(
+        @JvmField @ProtoNumber(1) val fileInfoRsp: GetFileInfoRspBody? = null,
+        @JvmField @ProtoNumber(2) val fileListInfoRsp: GetFileListRspBody? = null,
+        @JvmField @ProtoNumber(3) val groupFileCntRsp: GetFileCountRspBody? = null,
+        @JvmField @ProtoNumber(4) val groupSpaceRsp: GetSpaceRspBody? = null,
+        @JvmField @ProtoNumber(5) val filePreviewRsp: GetFilePreviewRspBody? = null
+    ) : ProtoBuf
+}
+        

+ 120 - 0
mirai-core/src/commonMain/kotlin/network/protocol/data/proto/Oidb0x6d9.kt

@@ -0,0 +1,120 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ *  此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ *  Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ *  https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+@file:Suppress("unused", "SpellCheckingInspection")
+
+package net.mamoe.mirai.internal.network.protocol.data.proto
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.protobuf.ProtoNumber
+import net.mamoe.mirai.internal.network.protocol.packet.EMPTY_BYTE_ARRAY
+import net.mamoe.mirai.internal.utils.io.ProtoBuf
+
+internal class Oidb0x6d9 : ProtoBuf {
+    @Serializable
+    internal class CopyFromReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0,
+        @JvmField @ProtoNumber(3) val srcBusId: Int = 0,
+        @JvmField @ProtoNumber(4) val srcParentFolder: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(5) val srcFilePath: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(6) val dstBusId: Int = 0,
+        @JvmField @ProtoNumber(7) val dstFolderId: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(8) val fileSize: Long = 0L,
+        @JvmField @ProtoNumber(9) val localPath: String = "",
+        @JvmField @ProtoNumber(10) val fileName: String = "",
+        @JvmField @ProtoNumber(11) val srcUin: Long = 0L,
+        @JvmField @ProtoNumber(12) val md5: ByteArray = EMPTY_BYTE_ARRAY
+    ) : ProtoBuf
+
+    @Serializable
+    internal class CopyFromRspBody(
+        @JvmField @ProtoNumber(1) val int32RetCode: Int = 0,
+        @JvmField @ProtoNumber(2) val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = "",
+        @JvmField @ProtoNumber(4) val saveFilePath: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(5) val busId: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class CopyToReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0,
+        @JvmField @ProtoNumber(3) val srcBusId: Int = 0,
+        @JvmField @ProtoNumber(4) val srcFileId: String = "",
+        @JvmField @ProtoNumber(5) val dstBusId: Int = 0,
+        @JvmField @ProtoNumber(6) val dstUin: Long = 0L,
+        @JvmField @ProtoNumber(40) val newFileName: String = "",
+        @JvmField @ProtoNumber(100) val timCloudPdirKey: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(101) val timCloudPpdirKey: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(102) val timCloudExtensionInfo: ByteArray = EMPTY_BYTE_ARRAY,
+        @JvmField @ProtoNumber(103) val timFileExistOption: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class CopyToRspBody(
+        @JvmField @ProtoNumber(1) val int32RetCode: Int = 0,
+        @JvmField @ProtoNumber(2) val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = "",
+        @JvmField @ProtoNumber(4) val saveFilePath: String = "",
+        @JvmField @ProtoNumber(5) val busId: Int = 0,
+        @JvmField @ProtoNumber(40) val fileName: String = ""
+    ) : ProtoBuf
+
+    @Serializable
+    internal class FeedsReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0,
+        @JvmField @ProtoNumber(3) val feedsInfoList: List<GroupFileCommon.FeedsInfo> = emptyList(),
+        @JvmField @ProtoNumber(4) val multiSendSeq: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class FeedsRspBody(
+        @JvmField @ProtoNumber(1) val int32RetCode: Int = 0,
+        @JvmField @ProtoNumber(2) val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = "",
+        @JvmField @ProtoNumber(4) val feedsResultList: List<GroupFileCommon.FeedsResult> = emptyList(),
+        @JvmField @ProtoNumber(5) val svrbusyWaitTime: Int = 0
+    ) : ProtoBuf
+
+    @Serializable
+    internal class ReqBody(
+        @JvmField @ProtoNumber(1) val transFileReq: TransFileReqBody? = null,
+        @JvmField @ProtoNumber(2) val copyFromReq: CopyFromReqBody? = null,
+        @JvmField @ProtoNumber(3) val copyToReq: CopyToReqBody? = null,
+        @JvmField @ProtoNumber(5) val feedsInfoReq: FeedsReqBody? = null
+    ) : ProtoBuf
+
+    @Serializable
+    internal class RspBody(
+        @JvmField @ProtoNumber(1) val transFileRsp: TransFileRspBody? = null,
+        @JvmField @ProtoNumber(2) val copyFromRsp: CopyFromRspBody? = null,
+        @JvmField @ProtoNumber(3) val copyToRsp: CopyToRspBody? = null,
+        @JvmField @ProtoNumber(5) val feedsInfoRsp: FeedsRspBody? = null
+    ) : ProtoBuf
+
+    @Serializable
+    internal class TransFileReqBody(
+        @JvmField @ProtoNumber(1) val groupCode: Long = 0L,
+        @JvmField @ProtoNumber(2) val appId: Int = 0,
+        @JvmField @ProtoNumber(3) val busId: Int = 0,
+        @JvmField @ProtoNumber(4) val fileId: String = ""
+    ) : ProtoBuf
+
+    @Serializable
+    internal class TransFileRspBody(
+        @JvmField @ProtoNumber(1) val int32RetCode: Int = 0,
+        @JvmField @ProtoNumber(2) val retMsg: String = "",
+        @JvmField @ProtoNumber(3) val clientWording: String = "",
+        @JvmField @ProtoNumber(4) val saveBusId: Int = 0,
+        @JvmField @ProtoNumber(5) val saveFilePath: String = ""
+    ) : ProtoBuf
+}
+        

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

@@ -161,6 +161,7 @@ internal object KnownPacketFactories {
         StrangerList.DelStranger,
         SummaryCard.ReqSummaryCard,
         MusicSharePacket,
+        *FileManagement.factories
     )
 
     object IncomingFactories : List<IncomingPacketFactory<*>> by mutableListOf(

+ 502 - 0
mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/GroupFile.kt

@@ -0,0 +1,502 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ *  此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ *  Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ *  https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+@file:Suppress("NOTHING_TO_INLINE")
+
+package net.mamoe.mirai.internal.network.protocol.packet.chat
+
+import kotlinx.io.core.ByteReadPacket
+import kotlinx.serialization.DeserializationStrategy
+import net.mamoe.mirai.internal.QQAndroidBot
+import net.mamoe.mirai.internal.network.Packet
+import net.mamoe.mirai.internal.network.QQAndroidClient
+import net.mamoe.mirai.internal.network.protocol.data.proto.*
+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.ProtoBuf
+import net.mamoe.mirai.internal.utils.io.serialization.readOidbSsoPkg
+import net.mamoe.mirai.internal.utils.io.serialization.writeOidb
+import net.mamoe.mirai.utils.ExternalResource
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
+import kotlin.math.absoluteValue
+import kotlin.random.Random
+
+internal sealed class CommonOidbResponse<T> : Packet {
+    data class Failure<T>(
+        val result: Int,
+        val msg: String,
+        val e: Throwable?,
+    ) : CommonOidbResponse<T>() {
+        inline fun createException(actionName: String): IllegalStateException {
+            return IllegalStateException("Failed $actionName, result=$result, msg=$msg", e)
+        }
+
+        override fun toString(): String {
+            return "CommonOidbResponse.Failure(result=$result, msg=$msg, e=$e)"
+        }
+    }
+
+    class Success<T>(
+        val resp: T
+    ) : CommonOidbResponse<T>() {
+        override fun toString(): String {
+            return "CommonOidbResponse.Success"
+        }
+    }
+}
+
+internal interface CheckableStruct {
+    val int32RetCode: Int
+    val retMsg: String
+}
+
+@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE", "RESULT_CLASS_IN_RETURN_TYPE")
+@kotlin.internal.InlineOnly
+internal inline fun <T> CommonOidbResponse<T>.toResult(actionName: String, checkResp: Boolean = true): Result<T> {
+    return if (this is CommonOidbResponse.Failure) {
+        Result.failure(this.createException(actionName))
+    } else {
+        this as CommonOidbResponse.Success<T>
+        if (!checkResp) return Result.success(this.resp)
+        val result = this.resp
+        if (result is CheckableStruct) {
+            if (result.int32RetCode != 0) return Result.failure(IllegalStateException("Failed $actionName, result=${result.int32RetCode}, msg=${result.retMsg}"))
+        }
+        Result.success(this.resp)
+    }
+}
+
+/**
+ * @param respMapper may throw any exception, which will be wrapped to CommonOidbResponse.Failure
+ */
+internal inline fun <T : ProtoBuf, R> ByteReadPacket.readOidbRespCommon(
+    bodyBufferDeserializer: DeserializationStrategy<T>,
+    respMapper: (T) -> R
+): CommonOidbResponse<R> {
+    contract { callsInPlace(respMapper, InvocationKind.AT_MOST_ONCE) }
+    val oidb = readOidbSsoPkg(bodyBufferDeserializer)
+    return oidb.fold(
+        onSuccess = {
+            CommonOidbResponse.Success(kotlin.runCatching {
+                respMapper(this)
+            }.getOrElse {
+                return CommonOidbResponse.Failure(0, it.message ?: "", it)
+            })
+        },
+        onFailure = {
+            CommonOidbResponse.Failure(result, errorMsg, null)
+        }
+    )
+}
+
+internal object FileManagement {
+    val factories = arrayOf(
+        GetFileList,
+        GetFileInfo,
+        RequestDownload,
+        RequestUpload,
+        DeleteFile,
+        MoveFile,
+        RenameFile,
+        TransferFile,
+        Feed,
+        RenameFolder,
+//        MoveFolder,
+        DeleteFolder,
+        CreateFolder,
+    )
+
+    object GetFileList : OutgoingPacketFactory<CommonOidbResponse<Oidb0x6d8.GetFileListRspBody>>("OidbSvc.0x6d8_1") {
+        operator fun invoke(
+            client: QQAndroidClient,
+            groupCode: Long,
+            folderId: String,
+            startIndex: Int,
+        ) = buildOutgoingUniPacket(client) {
+            writeOidb(
+                1752,
+                1,
+                Oidb0x6d8.ReqBody.serializer(),
+                Oidb0x6d8.ReqBody(
+                    fileListInfoReq = Oidb0x6d8.GetFileListReqBody(
+                        groupCode = groupCode,
+                        appId = 3,
+                        folderId = folderId,
+                        fileCount = 20,
+                        reqFrom = 3,
+                        sortBy = 1,
+                        startIndex = startIndex
+                    )
+                )
+            )
+        }
+
+        override suspend fun ByteReadPacket.decode(bot: QQAndroidBot): CommonOidbResponse<Oidb0x6d8.GetFileListRspBody> {
+            return readOidbRespCommon(Oidb0x6d8.RspBody.serializer()) { it.fileListInfoRsp!! }
+        }
+    }
+
+    object GetFileInfo : OutgoingPacketFactory<CommonOidbResponse<Oidb0x6d8.GetFileInfoRspBody>>("OidbSvc.0x6d8_0") {
+        operator fun invoke(
+            client: QQAndroidClient,
+            groupCode: Long,
+            fileId: String,
+            busId: Int,
+        ) = buildOutgoingUniPacket(client) {
+            writeOidb(
+                1752,
+                0,
+                Oidb0x6d8.ReqBody.serializer(),
+                Oidb0x6d8.ReqBody(
+                    fileInfoReq = Oidb0x6d8.GetFileInfoReqBody(
+                        groupCode = groupCode,
+                        appId = 3,
+                        fileId = fileId,
+                        busId = busId
+                    )
+                )
+            )
+        }
+
+        override suspend fun ByteReadPacket.decode(bot: QQAndroidBot): CommonOidbResponse<Oidb0x6d8.GetFileInfoRspBody> {
+            return readOidbRespCommon(Oidb0x6d8.RspBody.serializer()) { it.fileInfoRsp!! }
+        }
+    }
+
+    object RequestUpload : OutgoingPacketFactory<CommonOidbResponse<Oidb0x6d6.UploadFileRspBody>>("OidbSvc.0x6d6_0") {
+        operator fun invoke(
+            client: QQAndroidClient,
+            groupCode: Long,
+            folderId: String,
+            resource: ExternalResource,
+            filename: String,
+        ) = buildOutgoingUniPacket(client) {
+            resource.sha1 // check supported
+
+            writeOidb(
+                command = 1750,
+                serviceType = 0,
+                Oidb0x6d6.ReqBody.serializer(),
+                Oidb0x6d6.ReqBody(
+                    uploadFileReq = Oidb0x6d6.UploadFileReqBody(
+                        groupCode = groupCode,
+                        appId = 3,
+                        busId = 102,
+                        entrance = 5,
+                        parentFolderId = folderId,
+                        fileName = filename,
+                        localPath = "/storage/emulated/0/Pictures/files/s/$filename",
+                        fileSize = resource.size,
+                        sha = resource.sha1,
+                        md5 = resource.md5,
+                        boolSupportMultiUpload = true,
+                    )
+                )
+            )
+        }
+
+        override suspend fun ByteReadPacket.decode(bot: QQAndroidBot): CommonOidbResponse<Oidb0x6d6.UploadFileRspBody> {
+            return readOidbRespCommon(Oidb0x6d6.RspBody.serializer()) { it.uploadFileRsp!! }
+        }
+    }
+
+    object RequestDownload :
+        OutgoingPacketFactory<CommonOidbResponse<Oidb0x6d6.DownloadFileRspBody>>("OidbSvc.0x6d6_2") {
+        operator fun invoke(
+            client: QQAndroidClient,
+            groupCode: Long,
+            busId: Int,
+            fileId: String,
+        ) = buildOutgoingUniPacket(client) {
+            writeOidb(
+                command = 1750,
+                serviceType = 2,
+                Oidb0x6d6.ReqBody.serializer(),
+                Oidb0x6d6.ReqBody(
+                    downloadFileReq = Oidb0x6d6.DownloadFileReqBody(
+                        groupCode = groupCode,
+                        appId = 3,
+                        busId = busId,
+                        fileId = fileId,
+                    )
+                )
+            )
+        }
+
+        override suspend fun ByteReadPacket.decode(bot: QQAndroidBot): CommonOidbResponse<Oidb0x6d6.DownloadFileRspBody> {
+            return readOidbRespCommon(Oidb0x6d6.RspBody.serializer()) { it.downloadFileRsp!! }
+        }
+    }
+
+    object MoveFile : OutgoingPacketFactory<CommonOidbResponse<Oidb0x6d6.MoveFileRspBody>>("OidbSvc.0x6d6_5") {
+        operator fun invoke(
+            client: QQAndroidClient,
+            groupCode: Long,
+            busId: Int,
+            fileId: String,
+            parentFolderId: String,
+            destFolderId: String,
+        ) = buildOutgoingUniPacket(client) {
+            writeOidb(
+                command = 1750,
+                serviceType = 5,
+                Oidb0x6d6.ReqBody.serializer(),
+                Oidb0x6d6.ReqBody(
+                    moveFileReq = Oidb0x6d6.MoveFileReqBody(
+                        groupCode = groupCode,
+                        appId = 3,
+                        busId = busId,
+                        fileId = fileId,
+                        parentFolderId = parentFolderId,
+                        destFolderId = destFolderId
+                    )
+                )
+            )
+        }
+
+        override suspend fun ByteReadPacket.decode(bot: QQAndroidBot): CommonOidbResponse<Oidb0x6d6.MoveFileRspBody> {
+            return readOidbRespCommon(Oidb0x6d6.RspBody.serializer()) { it.moveFileRsp!! }
+        }
+    }
+
+
+    // 转发
+    object TransferFile : OutgoingPacketFactory<CommonOidbResponse<Oidb0x6d9.TransFileRspBody>>("OidbSvc.0x6d9_0") {
+        operator fun invoke(
+            client: QQAndroidClient,
+            groupCode: Long,
+            busId: Int,
+            fileId: String,
+        ) = buildOutgoingUniPacket(client) {
+            writeOidb(
+                command = 1753,
+                serviceType = 0,
+                Oidb0x6d9.ReqBody.serializer(),
+                Oidb0x6d9.ReqBody(
+                    transFileReq = Oidb0x6d9.TransFileReqBody(
+                        groupCode = groupCode,
+                        appId = 3,
+                        busId = busId,
+                        fileId = fileId,
+                    )
+                )
+            )
+        }
+
+        override suspend fun ByteReadPacket.decode(bot: QQAndroidBot): CommonOidbResponse<Oidb0x6d9.TransFileRspBody> {
+            return readOidbRespCommon(Oidb0x6d9.RspBody.serializer()) { it.transFileRsp!! }
+        }
+    }
+
+
+    object RenameFile : OutgoingPacketFactory<CommonOidbResponse<Oidb0x6d6.RenameFileRspBody>>("OidbSvc.0x6d6_4") {
+        operator fun invoke(
+            client: QQAndroidClient,
+            groupCode: Long,
+            busId: Int,
+            fileId: String,
+            parentFolderId: String,
+            newName: String,
+        ) = buildOutgoingUniPacket(client) {
+            writeOidb(
+                command = 1750,
+                serviceType = 4,
+                Oidb0x6d6.ReqBody.serializer(),
+                Oidb0x6d6.ReqBody(
+                    renameFileReq = Oidb0x6d6.RenameFileReqBody(
+                        groupCode = groupCode,
+                        appId = 3,
+                        busId = busId,
+                        fileId = fileId,
+                        parentFolderId = parentFolderId,
+                        newFileName = newName,
+                    )
+                )
+            )
+        }
+
+        override suspend fun ByteReadPacket.decode(bot: QQAndroidBot): CommonOidbResponse<Oidb0x6d6.RenameFileRspBody> {
+            return readOidbRespCommon(Oidb0x6d6.RspBody.serializer()) { it.renameFileRsp!! }
+        }
+    }
+
+    object DeleteFile : OutgoingPacketFactory<CommonOidbResponse<Oidb0x6d6.DeleteFileRspBody>>("OidbSvc.0x6d6_3") {
+        operator fun invoke(
+            client: QQAndroidClient,
+            groupCode: Long,
+            busId: Int,
+            fileId: String,
+            parentFolderId: String,
+        ) = buildOutgoingUniPacket(client) {
+            writeOidb(
+                command = 1750,
+                serviceType = 3,
+                Oidb0x6d6.ReqBody.serializer(),
+                Oidb0x6d6.ReqBody(
+                    deleteFileReq = Oidb0x6d6.DeleteFileReqBody(
+                        groupCode = groupCode,
+                        appId = 3,
+                        busId = busId,
+                        fileId = fileId,
+                        parentFolderId = parentFolderId,
+                    )
+                )
+            )
+        }
+
+        override suspend fun ByteReadPacket.decode(bot: QQAndroidBot): CommonOidbResponse<Oidb0x6d6.DeleteFileRspBody> {
+            return readOidbRespCommon(Oidb0x6d6.RspBody.serializer()) { it.deleteFileRsp!! }
+        }
+    }
+
+    object Feed : OutgoingPacketFactory<CommonOidbResponse<Oidb0x6d9.FeedsRspBody>>("OidbSvc.0x6d9_4") {
+        operator fun invoke(
+            client: QQAndroidClient,
+            groupCode: Long,
+            busId: Int,
+            fileId: String,
+            random: Int = Random.nextInt().absoluteValue,
+        ) = buildOutgoingUniPacket(client) {
+            writeOidb(
+                command = 1753,
+                serviceType = 4,
+                Oidb0x6d9.ReqBody.serializer(),
+                Oidb0x6d9.ReqBody(
+                    feedsInfoReq = Oidb0x6d9.FeedsReqBody(
+                        groupCode = groupCode,
+                        appId = 3,
+                        feedsInfoList = listOf(
+                            GroupFileCommon.FeedsInfo(
+                                busId = busId,
+                                fileId = fileId,
+                                feedFlag = 1,
+                                msgRandom = random,
+                            )
+                        )
+                    )
+                )
+            )
+        }
+
+        override suspend fun ByteReadPacket.decode(bot: QQAndroidBot): CommonOidbResponse<Oidb0x6d9.FeedsRspBody> {
+            return readOidbRespCommon(Oidb0x6d9.RspBody.serializer()) { it.feedsInfoRsp!! }
+        }
+    }
+
+
+    object RenameFolder : OutgoingPacketFactory<CommonOidbResponse<Oidb0x6d7.RenameFolderRspBody>>("OidbSvc.0x6d7_2") {
+        operator fun invoke(
+            client: QQAndroidClient,
+            groupCode: Long,
+            folderId: String,
+            newName: String
+        ) = buildOutgoingUniPacket(client) {
+            writeOidb(
+                command = 1751,
+                serviceType = 2,
+                Oidb0x6d7.ReqBody.serializer(),
+                Oidb0x6d7.ReqBody(
+                    renameFolderReq = Oidb0x6d7.RenameFolderReqBody(
+                        groupCode = groupCode,
+                        appId = 3,
+                        folderId = folderId,
+                        newFolderName = newName,
+                    )
+                )
+            )
+        }
+
+        override suspend fun ByteReadPacket.decode(bot: QQAndroidBot): CommonOidbResponse<Oidb0x6d7.RenameFolderRspBody> {
+            return readOidbRespCommon(Oidb0x6d7.RspBody.serializer()) { it.renameFolderRsp!! }
+        }
+    }
+
+    // qq doesn't support
+//    object MoveFolder : OutgoingPacketFactory<CommonOidbResponse<Oidb0x6d7.MoveFolderRspBody>>("OidbSvc.0x6d7_3") {
+//        operator fun invoke(
+//            client: QQAndroidClient,
+//            groupCode: Long,
+//            folderId: String,
+//            parentFolderId: String,
+//            newParentFolderId: String,
+//        ) = buildOutgoingUniPacket(client) {
+//            writeOidb(
+//                command = 1751,
+//                serviceType = 3,
+//                Oidb0x6d7.ReqBody.serializer(),
+//                Oidb0x6d7.ReqBody(
+//                    moveFolderReq = Oidb0x6d7.MoveFolderReqBody(
+//                        groupCode = groupCode,
+//                        appId = 3,
+//                        folderId = folderId,
+//                        parentFolderId = parentFolderId,
+//                        destFolderId = newParentFolderId,
+//                    )
+//                )
+//            )
+//        }
+//
+//        override suspend fun ByteReadPacket.decode(bot: QQAndroidBot): CommonOidbResponse<Oidb0x6d7.MoveFolderRspBody> {
+//            return readOidbRespCommon(Oidb0x6d7.RspBody.serializer()) { it.moveFolderRsp!! }
+//        }
+//    }
+
+    object DeleteFolder : OutgoingPacketFactory<CommonOidbResponse<Oidb0x6d7.DeleteFolderRspBody>>("OidbSvc.0x6d7_1") {
+        operator fun invoke(
+            client: QQAndroidClient,
+            groupCode: Long,
+            folderId: String,
+        ) = buildOutgoingUniPacket(client) {
+            writeOidb(
+                command = 1751,
+                serviceType = 1,
+                Oidb0x6d7.ReqBody.serializer(),
+                Oidb0x6d7.ReqBody(
+                    deleteFolderReq = Oidb0x6d7.DeleteFolderReqBody(
+                        groupCode = groupCode,
+                        appId = 3,
+                        folderId = folderId,
+                    )
+                )
+            )
+        }
+
+        override suspend fun ByteReadPacket.decode(bot: QQAndroidBot): CommonOidbResponse<Oidb0x6d7.DeleteFolderRspBody> {
+            return readOidbRespCommon(Oidb0x6d7.RspBody.serializer()) { it.deleteFolderRsp!! }
+        }
+    }
+
+    object CreateFolder : OutgoingPacketFactory<CommonOidbResponse<Oidb0x6d7.CreateFolderRspBody>>("OidbSvc.0x6d7_0") {
+        operator fun invoke(
+            client: QQAndroidClient,
+            groupCode: Long,
+            parentFolderId: String,
+            name: String
+        ) = buildOutgoingUniPacket(client) {
+            writeOidb(
+                command = 1751,
+                serviceType = 0,
+                Oidb0x6d7.ReqBody.serializer(),
+                Oidb0x6d7.ReqBody(
+                    createFolderReq = Oidb0x6d7.CreateFolderReqBody(
+                        groupCode = groupCode,
+                        appId = 3,
+                        parentFolderId = parentFolderId,
+                        folderName = name,
+                    )
+                )
+            )
+        }
+
+        override suspend fun ByteReadPacket.decode(bot: QQAndroidBot): CommonOidbResponse<Oidb0x6d7.CreateFolderRspBody> {
+            return readOidbRespCommon(Oidb0x6d7.RspBody.serializer()) { it.createFolderRsp!! }
+        }
+    }
+}

+ 2 - 1
mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/TroopManagement.kt

@@ -238,10 +238,11 @@ internal class TroopManagement {
                     OidbSso.OIDBSSOPkg.serializer(),
                     OidbSso.OIDBSSOPkg(
                         command = 2202,
+                        serviceType = 0,
                         bodybuffer = Oidb0x89a.ReqBody(
                             groupCode = groupCode,
                             stGroupInfo = Oidb0x89a.Groupinfo().apply(info)
-                        ).toByteArray(Oidb0x89a.ReqBody.serializer())
+                        ).toByteArray(Oidb0x89a.ReqBody.serializer()),
                     )
                 )
             }

+ 13 - 14
mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/receive/MessageSvc.PbSendMsg.kt

@@ -9,6 +9,8 @@
 
 package net.mamoe.mirai.internal.network.protocol.packet.chat.receive
 
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Deferred
 import kotlinx.io.core.ByteReadPacket
 import kotlinx.io.core.toByteArray
 import net.mamoe.mirai.contact.Friend
@@ -469,7 +471,7 @@ internal inline fun MessageSvcPbSendMsg.createToTemp(
     member: Member,
     message: MessageChain,
     fragmented: Boolean,
-    crossinline sourceCallback: (OnlineMessageSourceToTempImpl) -> Unit
+    crossinline sourceCallback: (Deferred<OnlineMessageSourceToTempImpl>) -> Unit
 ): List<OutgoingPacket> {
     contract {
         callsInPlace(sourceCallback, InvocationKind.EXACTLY_ONCE)
@@ -482,7 +484,7 @@ internal inline fun MessageSvcPbSendMsg.createToTemp(
         sequenceIds = intArrayOf(client.atomicNextMessageSequenceId()),
         originalMessage = message
     )
-    sourceCallback(source)
+    sourceCallback(CompletableDeferred(source))
     return createToTempImpl(
         client,
         member,
@@ -497,7 +499,7 @@ internal inline fun MessageSvcPbSendMsg.createToStranger(
     stranger: Stranger,
     message: MessageChain,
     fragmented: Boolean,
-    crossinline sourceCallback: (OnlineMessageSourceToStrangerImpl) -> Unit
+    crossinline sourceCallback: (Deferred<OnlineMessageSourceToStrangerImpl>) -> Unit
 ): List<OutgoingPacket> {
     contract {
         callsInPlace(sourceCallback, InvocationKind.EXACTLY_ONCE)
@@ -506,9 +508,8 @@ internal inline fun MessageSvcPbSendMsg.createToStranger(
         client,
         stranger,
         message,
-        fragmented,
-        sourceCallback
-    )
+        fragmented
+    ) { sourceCallback(CompletableDeferred(it)) }
 }
 
 internal inline fun MessageSvcPbSendMsg.createToFriend(
@@ -516,7 +517,7 @@ internal inline fun MessageSvcPbSendMsg.createToFriend(
     qq: Friend,
     message: MessageChain,
     fragmented: Boolean,
-    crossinline sourceCallback: (OnlineMessageSourceToFriendImpl) -> Unit
+    crossinline sourceCallback: (Deferred<OnlineMessageSourceToFriendImpl>) -> Unit
 ): List<OutgoingPacket> {
     contract {
         callsInPlace(sourceCallback, InvocationKind.EXACTLY_ONCE)
@@ -525,9 +526,8 @@ internal inline fun MessageSvcPbSendMsg.createToFriend(
         client,
         qq,
         message,
-        fragmented,
-        sourceCallback
-    )
+        fragmented
+    ) { sourceCallback(CompletableDeferred(it)) }
 }
 
 
@@ -536,7 +536,7 @@ internal inline fun MessageSvcPbSendMsg.createToGroup(
     group: Group,
     message: MessageChain,
     fragmented: Boolean,
-    crossinline sourceCallback: (OnlineMessageSourceToGroupImpl) -> Unit
+    crossinline sourceCallback: (Deferred<OnlineMessageSourceToGroupImpl>) -> Unit
 ): List<OutgoingPacket> {
     contract {
         callsInPlace(sourceCallback, InvocationKind.EXACTLY_ONCE)
@@ -545,7 +545,6 @@ internal inline fun MessageSvcPbSendMsg.createToGroup(
         client,
         group,
         message,
-        fragmented,
-        sourceCallback
-    )
+        fragmented
+    ) { sourceCallback(CompletableDeferred(it)) }
 }

+ 4 - 2
mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/receive/OnlinePush.PbPushGroupMsg.kt

@@ -67,7 +67,9 @@ internal object OnlinePushPbPushGroupMsg : IncomingPacketFactory<Packet?>("Onlin
             val messageRandom = pbPushMsg.msg.msgBody.richText.attr?.random ?: return null
 
             if (bot.client.syncingController.pendingGroupMessageReceiptCacheList.contains { it.messageRandom == messageRandom }
-                || msgHead.fromAppid == 3116) {
+                || msgHead.fromAppid == 3116 || msgHead.fromAppid == 2021) {
+                // 3116=group music share
+                // 2021=group file
                 // message sent by bot
                 return SendGroupMessageReceipt(
                     messageRandom,
@@ -103,7 +105,7 @@ internal object OnlinePushPbPushGroupMsg : IncomingPacketFactory<Packet?>("Onlin
         val name: String
 
         if (anonymous != null) { // anonymous member
-            sender = group.newAnonymous(anonymous.anonNick.encodeToString(), anonymous.anonId.encodeToBase64())
+            sender = group.newAnonymous(anonymous.anonNick.encodeToString(), anonymous.anonId.encodeBase64())
             name = sender.nameCard
         } else { // normal member chat
             sender = group[msgHead.fromUin] as NormalMemberImpl? ?: kotlin.run {

+ 4 - 3
mirai-core/src/commonMain/kotlin/utils/PlatformSocket.kt

@@ -16,6 +16,7 @@ import kotlinx.io.core.ByteReadPacket
 import kotlinx.io.core.Closeable
 import kotlinx.io.streams.readPacketAtMost
 import kotlinx.io.streams.writePacket
+import net.mamoe.mirai.internal.network.highway.HighwayProtocolChannel
 import net.mamoe.mirai.utils.withUse
 import java.io.BufferedInputStream
 import java.io.BufferedOutputStream
@@ -30,7 +31,7 @@ import kotlin.contracts.contract
 /**
  * TCP Socket.
  */
-internal class PlatformSocket : Closeable {
+internal class PlatformSocket : Closeable, HighwayProtocolChannel {
     private lateinit var socket: Socket
 
     val isOpen: Boolean
@@ -64,7 +65,7 @@ internal class PlatformSocket : Closeable {
     /**
      * @throws SendPacketInternalException
      */
-    suspend fun send(packet: ByteReadPacket) {
+    override suspend fun send(packet: ByteReadPacket) {
         runInterruptible(Dispatchers.IO) {
             try {
                 writeChannel.writePacket(packet)
@@ -80,7 +81,7 @@ internal class PlatformSocket : Closeable {
     /**
      * @throws ReadPacketInternalException
      */
-    suspend fun read(): ByteReadPacket = suspendCancellableCoroutine { cont ->
+    override suspend fun read(): ByteReadPacket = suspendCancellableCoroutine { cont ->
         val task = thread.submit {
             kotlin.runCatching {
                 readChannel.readPacketAtMost(Long.MAX_VALUE)

+ 537 - 0
mirai-core/src/commonMain/kotlin/utils/RemoteFileImpl.kt

@@ -0,0 +1,537 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ *  此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ *  Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ *  https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+package net.mamoe.mirai.internal.utils
+
+import io.ktor.client.request.*
+import io.ktor.http.*
+import io.ktor.http.content.*
+import io.ktor.utils.io.*
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.runBlocking
+import net.mamoe.mirai.contact.Contact
+import net.mamoe.mirai.contact.Group
+import net.mamoe.mirai.contact.isOperator
+import net.mamoe.mirai.internal.EMPTY_BYTE_ARRAY
+import net.mamoe.mirai.internal.asQQAndroidBot
+import net.mamoe.mirai.internal.contact.groupCode
+import net.mamoe.mirai.internal.message.FileMessageImpl
+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.*
+import net.mamoe.mirai.internal.network.protocol.packet.chat.FileManagement
+import net.mamoe.mirai.internal.network.protocol.packet.chat.toResult
+import net.mamoe.mirai.internal.network.protocol.packet.sendAndExpect
+import net.mamoe.mirai.internal.utils.io.serialization.toByteArray
+import net.mamoe.mirai.message.MessageReceipt
+import net.mamoe.mirai.message.data.FileMessage
+import net.mamoe.mirai.message.data.sendTo
+import net.mamoe.mirai.utils.*
+import net.mamoe.mirai.utils.RemoteFile.Companion.ROOT_PATH
+import java.util.*
+import kotlin.contracts.contract
+
+private val fs = FileSystem
+
+// internal for tests
+internal object FileSystem {
+    fun checkLegitimacy(path: String) {
+        val char = path.firstOrNull { it in """:*?"<>|""" }
+        if (char != null) {
+            throw IllegalArgumentException("""Chars ':*?"<>|' are not allowed in path. RemoteFile path contains illegal char: '$char'. path='$path'""")
+        }
+    }
+
+    fun normalize(path: String): String {
+        checkLegitimacy(path)
+        return path.replace('\\', '/')
+    }
+
+    // net.mamoe.mirai.internal.utils.internal.utils.FileSystemTest
+
+    fun normalize(parent: String, name: String): String {
+        var nName = normalize(name)
+        if (nName.startsWith('/')) return nName // absolute path then ignore parent
+        nName = nName.removeSuffix("/")
+
+        var nParent = normalize(parent)
+        if (nParent == "/") return "/$nName"
+        if (!nParent.startsWith('/')) nParent = "/$nParent"
+
+        val slash = nName.indexOf('/')
+        if (slash != -1) {
+            nParent += '/' + nName.substring(0, slash)
+            nName = nName.substring(slash + 1)
+        }
+
+        return "$nParent/$nName"
+    }
+}
+
+internal class RemoteFileInfo(
+    val id: String, // fileId or folderId
+    val isFile: Boolean,
+    val path: String,
+    val name: String,
+    val parentFolderId: String,
+    val size: Long,
+    val busId: Int, // for file only
+    val creatorId: Long, //ownerUin, createUin
+    val createTime: Long, // uploadTime, createTime
+    val modifyTime: Long,
+    val downloadTimes: Int,
+    val sha: ByteArray, // for file only
+    val md5: ByteArray, // for file only
+) {
+    companion object {
+        val root = RemoteFileInfo(
+            "", false, "/", "/", "", 0, 0, 0, 0, 0, 0, EMPTY_BYTE_ARRAY, EMPTY_BYTE_ARRAY
+        )
+    }
+}
+
+internal fun RemoteFile.checkIsImpl(): RemoteFileImpl {
+    contract { returns() implies (this@checkIsImpl is RemoteFileImpl) }
+    return this as? RemoteFileImpl ?: error("RemoteFile must not be implemented manually.")
+}
+
+internal class RemoteFileImpl(
+    contact: Group,
+    override val path: String, // absolute
+) : RemoteFile {
+    private val contactRef by contact.weakRef()
+    override val contact get() = contactRef ?: error("RemoteFile is closed due to Contact closed.")
+
+    constructor(contact: Group, parent: String, name: String) : this(contact, fs.normalize(parent, name))
+
+    override var id: String? = null
+
+    override val name: String
+        get() = path.substringAfterLast('/')
+
+    private val bot get() = contact.bot.asQQAndroidBot()
+    private val client get() = bot.client
+
+    override val parent: RemoteFileImpl?
+        get() {
+            if (path == ROOT_PATH) return null
+            val s = path.substringBeforeLast('/')
+            return RemoteFileImpl(contact, if (s.isEmpty()) ROOT_PATH else s)
+        }
+
+    /**
+     * Prefer id matching.
+     */
+    private suspend fun Flow<Oidb0x6d8.GetFileListRspBody.Item>.findMatching(): Oidb0x6d8.GetFileListRspBody.Item? {
+        var nameMatching: Oidb0x6d8.GetFileListRspBody.Item? = null
+
+        val idMatching = firstOrNull {
+            if (it.name == this@RemoteFileImpl.name) {
+                nameMatching = it
+            }
+            it.id == this@RemoteFileImpl.id
+        }
+
+        return idMatching ?: nameMatching
+    }
+
+    private suspend fun getFileFolderInfo(): RemoteFileInfo? {
+        val parent = parent ?: return RemoteFileInfo.root
+        val info = parent.getFilesFlow()
+            .filter { it.name == this.name }
+            .findMatching()
+            ?: return null
+        return when {
+            info.folderInfo != null -> info.folderInfo.run {
+                RemoteFileInfo(
+                    id = folderId,
+                    isFile = false,
+                    path = path,
+                    name = folderName,
+                    parentFolderId = parentFolderId,
+                    size = 0,
+                    busId = 0,
+                    creatorId = createUin,
+                    createTime = createTime.toLongUnsigned(),
+                    modifyTime = modifyTime.toLongUnsigned(),
+                    downloadTimes = 0,
+                    sha = EMPTY_BYTE_ARRAY,
+                    md5 = EMPTY_BYTE_ARRAY,
+                )
+            }
+            info.fileInfo != null -> info.fileInfo.run {
+                RemoteFileInfo(
+                    id = fileId,
+                    isFile = true,
+                    path = path,
+                    name = fileName,
+                    parentFolderId = parentFolderId,
+                    size = fileSize,
+                    busId = busId,
+                    creatorId = uploaderUin,
+                    createTime = uploadTime.toLongUnsigned(),
+                    modifyTime = modifyTime.toLongUnsigned(),
+                    downloadTimes = downloadTimes,
+                    sha = sha,
+                    md5 = md5,
+                )
+            }
+            else -> null
+        }
+    }
+
+    private fun RemoteFileInfo?.checkExists(thisPath: String, kind: String = "Remote path"): RemoteFileInfo {
+        if (this == null) throw IllegalStateException("$kind '$thisPath' does not exist.")
+        return this
+    }
+
+    override suspend fun isFile(): Boolean = this.getFileFolderInfo().checkExists(this.path).isFile
+    override suspend fun length(): Long = this.getFileFolderInfo().checkExists(this.path).size
+    override suspend fun exists(): Boolean = this.getFileFolderInfo() != null
+    override suspend fun getInfo(): RemoteFile.FileInfo? {
+        return getFileFolderInfo()?.run {
+            RemoteFile.FileInfo(
+                name = name,
+                id = id,
+                path = path,
+                length = size,
+                downloadTimes = downloadTimes,
+                uploaderId = creatorId,
+                uploadTime = createTime,
+                lastModifyTime = modifyTime,
+                sha1 = sha,
+                md5 = md5,
+            )
+        }
+    }
+
+    private fun getFilesFlow(): Flow<Oidb0x6d8.GetFileListRspBody.Item> {
+        return flow {
+            var index = 0
+            while (true) {
+                val list = FileManagement.GetFileList(
+                    client,
+                    groupCode = contact.id,
+                    folderId = path,
+                    startIndex = index
+                ).sendAndExpect(bot).toResult("RemoteFile.listFiles").getOrThrow()
+                index += list.itemList.size
+
+                if (list.int32RetCode != 0) return@flow
+                if (list.itemList.isEmpty()) return@flow
+
+                emitAll(list.itemList.asFlow())
+            }
+        }
+    }
+
+    private fun Oidb0x6d8.GetFileListRspBody.Item.resolveToFile(): RemoteFile? {
+        val item = this
+        return when {
+            item.fileInfo != null -> {
+                resolve(item.fileInfo.fileName)
+            }
+            item.folderInfo != null -> {
+                resolve(item.folderInfo.folderName)
+            }
+            else -> null
+        }?.also {
+            it.id = item.id
+        }
+    }
+
+    override suspend fun listFiles(): Flow<RemoteFile> {
+        return getFilesFlow().mapNotNull { item ->
+            item.resolveToFile()
+        }
+    }
+
+    @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
+    @OptIn(JavaFriendlyAPI::class)
+    override suspend fun listFilesIterator(lazy: Boolean): Iterator<RemoteFile> {
+        if (!lazy) return listFiles().toList().iterator()
+
+        return object : Iterator<RemoteFile> {
+            private val queue = ArrayDeque<Oidb0x6d8.GetFileListRspBody.Item>(1)
+
+            @Volatile
+            private var index = 0
+            private var ended = false
+
+            private suspend fun updateItems() {
+                val list = FileManagement.GetFileList(
+                    client,
+                    groupCode = contact.id,
+                    folderId = path,
+                    startIndex = index
+                ).sendAndExpect(bot).toResult("RemoteFile.listFiles").getOrThrow()
+                if (list.int32RetCode != 0 || list.itemList.isEmpty()) {
+                    ended = true
+                    return
+                }
+                index += list.itemList.size
+                for (item in list.itemList) {
+                    if (item.fileInfo != null || item.folderInfo != null) queue.add(item)
+                }
+            }
+
+            override fun hasNext(): Boolean {
+                if (queue.isEmpty() && !ended) runBlocking { updateItems() }
+                return queue.isNotEmpty()
+            }
+
+            override fun next(): RemoteFile {
+                return queue.removeFirst().resolveToFile()!!
+            }
+        }
+    }
+
+    override fun resolve(relative: String) = RemoteFileImpl(contact, this.path, relative)
+    override fun resolve(relative: RemoteFile): RemoteFileImpl {
+        if (relative.checkIsImpl().contact !== this.contact) error("`relative` must be obtained from the same Group as `this`.")
+
+        return resolve(relative.path).also { it.id = relative.id }
+    }
+
+    override suspend fun resolveById(id: String, deep: Boolean): RemoteFile? {
+        return getFilesFlow().filter { it.id == id }.firstOrNull()?.resolveToFile()
+    }
+
+    override fun resolveSibling(relative: String): RemoteFileImpl {
+        val parent = this.parent
+        if (parent == null) {
+            if (fs.normalize(relative) == ROOT_PATH) error("Root path does not have sibling paths.")
+            return RemoteFileImpl(contact, ROOT_PATH)
+        }
+        return RemoteFileImpl(contact, parent.path, relative)
+    }
+
+    override fun resolveSibling(relative: RemoteFile): RemoteFileImpl {
+        if (relative.checkIsImpl().contact !== this.contact) error("`relative` must be obtained from the same Group as `this`.")
+
+        return resolveSibling(relative.path).also { it.id = relative.id }
+    }
+
+    private fun RemoteFileInfo.isOperable(): Boolean =
+        creatorId == bot.id || contact.botPermission.isOperator()
+
+    private fun isBotOperator(): Boolean = contact.botPermission.isOperator()
+
+    override suspend fun delete(): Boolean {
+        val info = getFileFolderInfo() ?: return false
+        if (!info.isOperable()) return false
+        return when {
+            info.isFile -> {
+                FileManagement.DeleteFile(
+                    client,
+                    groupCode = contact.id,
+                    busId = info.busId,
+                    fileId = info.id,
+                    parentFolderId = info.parentFolderId,
+                ).sendAndExpect(bot).toResult("RemoteFile.delete", checkResp = false).getOrThrow().int32RetCode == 0
+            }
+//            recursively -> {
+//                this.listFiles().collect { child ->
+//                    child.delete()
+//                }
+//                this.delete()
+//            }
+            else -> {
+                // natively 'recursive'
+                FileManagement.DeleteFolder(
+                    client, contact.id, info.id
+                ).sendAndExpect(bot).toResult("RemoteFile.delete").getOrThrow().int32RetCode == 0
+            }
+        }
+    }
+
+    override suspend fun renameTo(name: String): Boolean {
+        if (path == ROOT_PATH && name != ROOT_PATH) return false
+
+        val normalized = fs.normalize(name)
+        if (normalized.contains('/')) throw IllegalArgumentException("'/' is not allowed in file or directory names. Given: '$name'.")
+
+        val info = getFileFolderInfo() ?: return false
+        if (!info.isOperable()) return false
+        return if (info.isFile) {
+            FileManagement.RenameFile(client, contact.id, info.busId, info.id, info.parentFolderId, normalized)
+        } else {
+            FileManagement.RenameFolder(client, contact.id, info.id, normalized)
+        }.sendAndExpect(bot).toResult("RemoteFile.renameTo", checkResp = false).getOrThrow().int32RetCode == 0
+    }
+
+    /**
+     * null means not exist
+     */
+    private suspend fun getIdSmart(): String? {
+        if (path == ROOT_PATH) return ROOT_PATH
+        return this.id ?: this.getFileFolderInfo()?.id
+    }
+
+    override suspend fun moveTo(target: RemoteFile): Boolean {
+        if (target.checkIsImpl().contact != this.contact) {
+            // TODO: 2021/3/4 cross-group file move
+
+            //                target.mkdir()
+//                val targetFolderId = target.getIdSmart() ?: return false
+//                this.listFiles().mapNotNull { it.checkIsImpl().getFileFolderInfo() }.collect {
+//                    FileManagement.MoveFile(client, contact.id, it.busId, it.id, it.parentFolderId, targetFolderId)
+//                        .sendAndExpect(bot).toResult("RemoteFile.moveTo", checkResp = false).getOrThrow()
+//
+//                    // TODO: 2021/3/3 batch packets
+//                }
+//                this.delete() // it is now empty
+
+            error("Cross-group file operation is not yet supported.")
+        }
+        if (target.path == this.path) return true
+        if (target.parent?.path == this.path) return false
+        val info = getFileFolderInfo() ?: return false
+        if (!info.isOperable()) return false
+        return if (info.isFile) {
+            val newParentId = target.parent?.checkIsImpl()?.getIdSmart() ?: return false
+            FileManagement.MoveFile(client, contact.id, info.busId, info.id, info.parentFolderId, newParentId)
+                .sendAndExpect(bot).toResult("RemoteFile.moveTo", checkResp = false).getOrThrow().int32RetCode == 0
+        } else {
+            return FileManagement.RenameFolder(client, contact.id, info.id, target.name).sendAndExpect(bot)
+                .toResult("RemoteFile.moveTo", checkResp = false).getOrThrow().int32RetCode == 0
+        }
+    }
+
+
+    override suspend fun moveTo(path: String): Boolean = moveTo(resolve(path))
+    override suspend fun mkdir(): Boolean {
+        if (path == ROOT_PATH) return false
+        if (!isBotOperator()) return false
+
+        val parentFolderId: String = parent?.getIdSmart() ?: return false
+
+        return FileManagement.CreateFolder(client, contact.id, parentFolderId, this.name)
+            .sendAndExpect(bot).toResult("RemoteFile.mkdir", checkResp = false).getOrThrow().int32RetCode == 0
+    }
+
+    override suspend fun upload(resource: ExternalResource, callback: RemoteFile.ProgressionCallback?): Boolean {
+        val parent = parent ?: return false
+        val parentInfo = parent.getFileFolderInfo() ?: return false
+        val resp = FileManagement.RequestUpload(
+            client,
+            groupCode = contact.id,
+            folderId = parentInfo.id,
+            resource = resource,
+            filename = this.name
+        ).sendAndExpect(bot).toResult("RemoteFile.upload").getOrThrow()
+        if (resp.boolFileExist) {
+            return true
+        }
+
+        val ext = GroupFileUploadExt(
+            u1 = 100,
+            u2 = 1,
+            entry = GroupFileUploadEntry(
+                business = ExcitingBusiInfo(
+                    busId = resp.busId,
+                    senderUin = bot.id,
+                    receiverUin = contact.groupCode, // TODO: 2021/3/1 code or uin?
+                    groupCode = contact.groupCode,
+                ),
+                fileEntry = ExcitingFileEntry(
+                    fileSize = resource.size,
+                    md5 = resource.md5,
+                    sha1 = resource.sha1,
+                    fileId = resp.fileId.toByteArray(),
+                    uploadKey = resp.checkKey,
+                ),
+                clientInfo = ExcitingClientInfo(
+                    clientType = 2,
+                    appId = client.protocol.id.toString(),
+                    terminalType = 2,
+                    clientVer = "9e9c09dc",
+                    unknown = 4,
+                ),
+                fileNameInfo = ExcitingFileNameInfo(this.name),
+                host = ExcitingHostConfig(
+                    hosts = listOf(
+                        ExcitingHostInfo(
+                            url = ExcitingUrlInfo(
+                                unknown = 1,
+                                host = resp.uploadIpLanV4.firstOrNull()
+                                    ?: resp.uploadIpLanV6.firstOrNull()
+                                    ?: resp.uploadIp,
+                            ),
+                            port = resp.uploadPort,
+                        ),
+                    ),
+                ),
+            ),
+            u3 = 0,
+        ).toByteArray(GroupFileUploadExt.serializer())
+
+        callback?.onBegin(this, resource)
+
+        kotlin.runCatching {
+            Highway.uploadResourceBdh(
+                bot = bot,
+                resource = resource,
+                kind = ResourceKind.GROUP_FILE,
+                commandId = 71,
+                extendInfo = ext,
+                dataFlag = 0,
+                callback = if (callback == null) null else fun(it: Long) {
+                    callback.onProgression(this, resource, it)
+                }
+            )
+        }.fold(
+            onSuccess = {
+                callback?.onSuccess(this, resource)
+            },
+            onFailure = {
+                callback?.onFailure(this, resource, it)
+            }
+        )
+
+        return true
+    }
+
+    override suspend fun uploadAndSend(resource: ExternalResource): MessageReceipt<Contact> {
+        if (!upload(resource)) error("Failed to upload file.")
+        return toMessage()?.sendTo(contact) ?: error("Failed to create FileMessage")
+    }
+
+//    override suspend fun writeSession(resource: ExternalResource): FileUploadSession {
+//    }
+
+    override suspend fun getDownloadInfo(): RemoteFile.DownloadInfo? {
+        val info = getFileFolderInfo() ?: return null
+        if (!info.isFile) return null
+        val resp = FileManagement.RequestDownload(
+            client,
+            groupCode = contact.id,
+            busId = info.busId,
+            fileId = info.id
+        ).sendAndExpect(bot).toResult("RemoteFile.getDownloadInfo").getOrThrow()
+
+        return RemoteFile.DownloadInfo(
+            filename = name,
+            id = info.id,
+            path = path,
+            url = "http://${resp.downloadIp}/ftn_handler/${resp.downloadUrl.toUHexString("")}/?fname=" +
+                    info.id.toByteArray().toUHexString(""),
+            sha1 = info.sha,
+            md5 = info.md5
+        )
+    }
+
+    override fun toString(): String = path
+
+    override suspend fun toMessage(): FileMessage? {
+        val info = getFileFolderInfo() ?: return null
+        if (!info.isFile) return null
+        return FileMessageImpl(name, info.id, info.size, info.busId)
+    }
+}

+ 65 - 0
mirai-core/src/commonMain/kotlin/utils/io/serialization/utils.kt

@@ -9,6 +9,7 @@
 
 @file:JvmName("SerializationUtils")
 @file:JvmMultifileClass
+@file:Suppress("NOTHING_TO_INLINE")
 
 package net.mamoe.mirai.internal.utils.io.serialization
 
@@ -127,6 +128,24 @@ internal fun <T : ProtoBuf> BytePacketBuilder.writeProtoBuf(serializer: Serializ
     this.writeFully(v.toByteArray(serializer))
 }
 
+internal fun <T : ProtoBuf> BytePacketBuilder.writeOidb(
+    command: Int = 0,
+    serviceType: Int = 0,
+    serializer: SerializationStrategy<T>,
+    v: T,
+    clientVersion: String = "android 8.4.8",
+) {
+    return this.writeProtoBuf(
+        OidbSso.OIDBSSOPkg.serializer(),
+        OidbSso.OIDBSSOPkg(
+            command = command,
+            serviceType = serviceType,
+            clientVersion = clientVersion,
+            bodybuffer = v.toByteArray(serializer)
+        )
+    )
+}
+
 /**
  * dump
  */
@@ -157,6 +176,52 @@ internal fun <T : ProtoBuf> ByteReadPacket.readProtoBuf(
     length: Int = this.remaining.toInt()
 ): T = KtProtoBuf.decodeFromByteArray(serializer, this.readBytes(length))
 
+@Suppress("NON_PUBLIC_PRIMARY_CONSTRUCTOR_OF_INLINE_CLASS")
+internal inline class OidbBodyOrFailure<T : ProtoBuf> private constructor(
+    private val v: Any
+) {
+    internal class Failure(
+        val oidb: OidbSso.OIDBSSOPkg
+    )
+
+    inline fun <R> fold(
+        onSuccess: T.(T) -> R,
+        onFailure: OidbSso.OIDBSSOPkg.(OidbSso.OIDBSSOPkg) -> R,
+    ): R {
+        contract {
+            callsInPlace(onSuccess, InvocationKind.AT_MOST_ONCE)
+            callsInPlace(onFailure, InvocationKind.AT_MOST_ONCE)
+        }
+        @Suppress("UNCHECKED_CAST")
+        return if (v is Failure) {
+            onFailure(v.oidb, v.oidb)
+        } else {
+            val t = v as T
+            onSuccess(t, t)
+        }
+    }
+
+    companion object {
+        fun <T : ProtoBuf> success(t: T): OidbBodyOrFailure<T> = OidbBodyOrFailure(t)
+        fun <T : ProtoBuf> failure(oidb: OidbSso.OIDBSSOPkg): OidbBodyOrFailure<T> = OidbBodyOrFailure(Failure(oidb))
+    }
+}
+
+/**
+ * load
+ */
+internal inline fun <T : ProtoBuf> ByteReadPacket.readOidbSsoPkg(
+    serializer: DeserializationStrategy<T>,
+    length: Int = this.remaining.toInt()
+): OidbBodyOrFailure<T> {
+    val oidb = readBytes(length).loadAs(OidbSso.OIDBSSOPkg.serializer())
+    return if (oidb.result == 0) {
+        OidbBodyOrFailure.success(oidb.bodybuffer.loadAs(serializer))
+    } else {
+        OidbBodyOrFailure.failure(oidb)
+    }
+}
+
 /**
  * 构造 [RequestPacket] 的 [RequestPacket.sBuffer]
  */

+ 7 - 2
mirai-core/src/commonMain/kotlin/utils/retryWithServers.kt

@@ -10,10 +10,11 @@
 package net.mamoe.mirai.internal.utils
 
 import kotlinx.coroutines.withTimeoutOrNull
+import net.mamoe.mirai.utils.cast
 import kotlin.math.roundToInt
 
 
-internal suspend inline fun <R> Collection<Pair<Int, Int>>.retryWithServers(
+internal suspend inline fun <R, reified IP> Collection<Pair<IP, Int>>.retryWithServers(
     timeoutMillis: Long,
     onFail: (exception: Throwable?) -> Nothing,
     crossinline block: suspend (ip: String, port: Int) -> R
@@ -24,7 +25,11 @@ internal suspend inline fun <R> Collection<Pair<Int, Int>>.retryWithServers(
     for (pair in this) {
         return kotlin.runCatching {
             withTimeoutOrNull(timeoutMillis) {
-                block(pair.first.toIpV4AddressString(), pair.second)
+                if (IP::class == Int::class) {
+                    block(pair.first.cast<Int>().toIpV4AddressString(), pair.second)
+                } else {
+                    block(pair.first.toString(), pair.second)
+                }
             }
         }.recover {
             if (exception != null) {

+ 40 - 0
mirai-core/src/commonTest/kotlin/internal/utils/FileSystemTest.kt

@@ -0,0 +1,40 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ *  此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ *  Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ *  https://github.com/mamoe/mirai/blob/master/LICENSE
+ */
+
+
+package net.mamoe.mirai.internal.utils
+
+import org.junit.jupiter.api.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+internal class FileSystemTest {
+
+    private val fs = FileSystem
+
+    @Test
+    fun testLegitimacy() {
+        fs.checkLegitimacy("a")
+        assertFailsWith<IllegalArgumentException> { fs.checkLegitimacy("a:") }
+        assertFailsWith<IllegalArgumentException> { fs.checkLegitimacy("?a") }
+    }
+
+    @Test
+    fun testNormalize() {
+        assertEquals("/", fs.normalize("/"))
+        assertEquals("/", fs.normalize("\\"))
+        assertEquals("/foo", fs.normalize("/foo"))
+        assertEquals("/foo", fs.normalize("\\foo"))
+        assertEquals("foo", fs.normalize("foo"))
+        assertEquals("foo/", fs.normalize("foo/"))
+
+        assertEquals("/bar", fs.normalize("\\foo", "/bar"))
+        assertEquals("/foo/bar", fs.normalize("\\foo", "bar"))
+    }
+}