瀏覽代碼

Redesign group files (#1589)

* Prototype new `RemoteFiles` design

* add `@JavaFriendlyAPI`

* remove `quietly`

* move `moveTo` to `AbsoluteFile`

* Add java friendly apis

* Remove `condoneMissing`

* Change `renameTo`

* Extract interface declarations

* update docs

* Add `AbsoluteFileFolder.exists`

* Add common ProgressionCallback

* Implement `RemoteFiles` and relevant `Absolute*`

* Implement `refresh` and `refreshed`

* Update docs

* Forbid blank paths

* Update docs

* Deprecate `RemoteFile` and implement `FileMessage.toAbsoluteFile`

* Change corresponding properties on operations

* Deprecate more old declarations

* Update docs

* Add check for permission

* Allow relative paths and fix upload

* fix absolutePath

* doc update

* api dump

* `Result<R>.onSuccessCatching`

* return null when file not exists

* Fix file uploading

* Fix folder.absolutePath

* add `resolveFileById`

* Implement toString

* Add `nameWithoutExtension` and `extension`

* Add `deep` to resolveFileById

* Implement permission check

* Remove notes

* Fix `resolveFileById`

* Fix `extension`

* add docs

* Improve docs

Co-authored-by: Karlatemp <karlatemp@vip.qq.com>
Him188 3 年之前
父節點
當前提交
9e151e7026
共有 22 個文件被更改,包括 1984 次插入4 次删除
  1. 124 0
      binary-compatibility-validator/android/api/binary-compatibility-validator-android.api
  2. 124 0
      binary-compatibility-validator/api/binary-compatibility-validator.api
  3. 13 1
      mirai-core-api/src/commonMain/kotlin/contact/FileSupported.kt
  4. 83 0
      mirai-core-api/src/commonMain/kotlin/contact/file/AbsoluteFile.kt
  5. 183 0
      mirai-core-api/src/commonMain/kotlin/contact/file/AbsoluteFileFolder.kt
  6. 196 0
      mirai-core-api/src/commonMain/kotlin/contact/file/AbsoluteFolder.kt
  7. 141 0
      mirai-core-api/src/commonMain/kotlin/contact/file/RemoteFiles.kt
  8. 12 1
      mirai-core-api/src/commonMain/kotlin/message/data/FileMessage.kt
  9. 10 0
      mirai-core-api/src/commonMain/kotlin/utils/ExternalResource.kt
  10. 103 0
      mirai-core-api/src/commonMain/kotlin/utils/ProgressionCallback.kt
  11. 16 1
      mirai-core-api/src/commonMain/kotlin/utils/RemoteFile.kt
  12. 10 1
      mirai-core-utils/src/commonMain/kotlin/ResultExtensions.kt
  13. 20 0
      mirai-core/src/commonMain/kotlin/contact/ContactAware.kt
  14. 5 0
      mirai-core/src/commonMain/kotlin/contact/GroupImpl.kt
  15. 172 0
      mirai-core/src/commonMain/kotlin/contact/file/AbsoluteFileImpl.kt
  16. 465 0
      mirai-core/src/commonMain/kotlin/contact/file/AbsoluteFolderImpl.kt
  17. 147 0
      mirai-core/src/commonMain/kotlin/contact/file/AbstractAbsoluteFileFolder.kt
  18. 31 0
      mirai-core/src/commonMain/kotlin/contact/file/FileProtocol.kt
  19. 48 0
      mirai-core/src/commonMain/kotlin/contact/file/RemoteFilesImpl.kt
  20. 53 0
      mirai-core/src/commonMain/kotlin/message/FileMessageImpl.kt
  21. 22 0
      mirai-core/src/commonMain/kotlin/network/protocol/packet/chat/GroupFile.kt
  22. 6 0
      mirai-core/src/commonMain/kotlin/utils/RemoteFileImpl.kt

+ 124 - 0
binary-compatibility-validator/android/api/binary-compatibility-validator-android.api

@@ -328,6 +328,7 @@ public final class net/mamoe/mirai/contact/ExceptionsKt {
 }
 
 public abstract interface class net/mamoe/mirai/contact/FileSupported : net/mamoe/mirai/contact/Contact {
+	public abstract fun getFiles ()Lnet/mamoe/mirai/contact/file/RemoteFiles;
 	public abstract fun getFilesRoot ()Lnet/mamoe/mirai/utils/RemoteFile;
 }
 
@@ -749,6 +750,112 @@ public final class net/mamoe/mirai/contact/announcement/OnlineAnnouncementKt {
 	public static final fun getBot (Lnet/mamoe/mirai/contact/announcement/OnlineAnnouncement;)Lnet/mamoe/mirai/Bot;
 }
 
+public abstract interface class net/mamoe/mirai/contact/file/AbsoluteFile : net/mamoe/mirai/contact/file/AbsoluteFileFolder {
+	public abstract fun getExpiryTime ()J
+	public abstract fun getMd5 ()[B
+	public abstract fun getSha1 ()[B
+	public abstract fun getSize ()J
+	public fun getUrl ()Ljava/lang/String;
+	public abstract fun getUrl (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun moveTo (Lnet/mamoe/mirai/contact/file/AbsoluteFolder;)Z
+	public abstract fun moveTo (Lnet/mamoe/mirai/contact/file/AbsoluteFolder;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun refreshed ()Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public abstract fun refreshed (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public abstract fun toMessage ()Lnet/mamoe/mirai/message/data/FileMessage;
+}
+
+public abstract interface class net/mamoe/mirai/contact/file/AbsoluteFileFolder {
+	public static final field Companion Lnet/mamoe/mirai/contact/file/AbsoluteFileFolder$Companion;
+	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 getAbsolutePath ()Ljava/lang/String;
+	public abstract fun getContact ()Lnet/mamoe/mirai/contact/FileSupported;
+	public static fun getExtension (Lnet/mamoe/mirai/contact/file/AbsoluteFileFolder;)Ljava/lang/String;
+	public abstract fun getId ()Ljava/lang/String;
+	public abstract fun getLastModifiedTime ()J
+	public abstract fun getName ()Ljava/lang/String;
+	public static fun getNameWithoutExtension (Lnet/mamoe/mirai/contact/file/AbsoluteFileFolder;)Ljava/lang/String;
+	public abstract fun getParent ()Lnet/mamoe/mirai/contact/file/AbsoluteFolder;
+	public abstract fun getUploadTime ()J
+	public abstract fun getUploaderId ()J
+	public abstract fun isFile ()Z
+	public abstract fun isFolder ()Z
+	public fun refresh ()Z
+	public abstract fun refresh (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun refreshed ()Lnet/mamoe/mirai/contact/file/AbsoluteFileFolder;
+	public abstract fun refreshed (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 toString ()Ljava/lang/String;
+}
+
+public final class net/mamoe/mirai/contact/file/AbsoluteFileFolder$Companion {
+	public final fun getExtension (Lnet/mamoe/mirai/contact/file/AbsoluteFileFolder;)Ljava/lang/String;
+	public final fun getNameWithoutExtension (Lnet/mamoe/mirai/contact/file/AbsoluteFileFolder;)Ljava/lang/String;
+}
+
+public abstract interface class net/mamoe/mirai/contact/file/AbsoluteFolder : net/mamoe/mirai/contact/file/AbsoluteFileFolder {
+	public static final field Companion Lnet/mamoe/mirai/contact/file/AbsoluteFolder$Companion;
+	public static final field ROOT_FOLDER_ID Ljava/lang/String;
+	public fun children ()Lkotlinx/coroutines/flow/Flow;
+	public abstract fun children (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun childrenStream ()Ljava/util/stream/Stream;
+	public abstract fun childrenStream (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun createFolder (Ljava/lang/String;)Lnet/mamoe/mirai/contact/file/AbsoluteFolder;
+	public abstract fun createFolder (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun files ()Lkotlinx/coroutines/flow/Flow;
+	public abstract fun files (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun filesStream ()Ljava/util/stream/Stream;
+	public abstract fun filesStream (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun folders ()Lkotlinx/coroutines/flow/Flow;
+	public abstract fun folders (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun foldersStream ()Ljava/util/stream/Stream;
+	public abstract fun foldersStream (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public abstract fun getContentsCount ()I
+	public fun isEmpty ()Z
+	public fun refreshed ()Lnet/mamoe/mirai/contact/file/AbsoluteFolder;
+	public abstract fun refreshed (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun resolveAll (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow;
+	public abstract fun resolveAll (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun resolveAllStream (Ljava/lang/String;)Ljava/util/stream/Stream;
+	public abstract fun resolveAllStream (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun resolveFileById (Ljava/lang/String;)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public fun resolveFileById (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun resolveFileById (Ljava/lang/String;Z)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public abstract fun resolveFileById (Ljava/lang/String;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static synthetic fun resolveFileById$default (Lnet/mamoe/mirai/contact/file/AbsoluteFolder;Ljava/lang/String;ZILjava/lang/Object;)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public static synthetic fun resolveFileById$default (Lnet/mamoe/mirai/contact/file/AbsoluteFolder;Ljava/lang/String;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
+	public fun resolveFiles (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow;
+	public abstract fun resolveFiles (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun resolveFilesStream (Ljava/lang/String;)Ljava/util/stream/Stream;
+	public abstract fun resolveFilesStream (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun resolveFolder (Ljava/lang/String;)Lnet/mamoe/mirai/contact/file/AbsoluteFolder;
+	public abstract fun resolveFolder (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun uploadNewFile (Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public fun uploadNewFile (Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun uploadNewFile (Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ProgressionCallback;)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public abstract fun uploadNewFile (Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ProgressionCallback;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static synthetic fun uploadNewFile$default (Lnet/mamoe/mirai/contact/file/AbsoluteFolder;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ProgressionCallback;ILjava/lang/Object;)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public static synthetic fun uploadNewFile$default (Lnet/mamoe/mirai/contact/file/AbsoluteFolder;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ProgressionCallback;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
+}
+
+public final class net/mamoe/mirai/contact/file/AbsoluteFolder$Companion {
+	public static final field ROOT_FOLDER_ID Ljava/lang/String;
+}
+
+public abstract interface class net/mamoe/mirai/contact/file/RemoteFiles {
+	public abstract fun getContact ()Lnet/mamoe/mirai/contact/FileSupported;
+	public abstract fun getRoot ()Lnet/mamoe/mirai/contact/file/AbsoluteFolder;
+	public fun uploadNewFile (Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public fun uploadNewFile (Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun uploadNewFile (Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ProgressionCallback;)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public fun uploadNewFile (Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ProgressionCallback;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static synthetic fun uploadNewFile$default (Lnet/mamoe/mirai/contact/file/RemoteFiles;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ProgressionCallback;ILjava/lang/Object;)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public static synthetic fun uploadNewFile$default (Lnet/mamoe/mirai/contact/file/RemoteFiles;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ProgressionCallback;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
+}
+
 public abstract interface class net/mamoe/mirai/contact/roaming/RoamingMessage {
 	public fun getBot ()Lnet/mamoe/mirai/Bot;
 	public abstract fun getContact ()Lnet/mamoe/mirai/contact/Contact;
@@ -4034,6 +4141,8 @@ public abstract interface class net/mamoe/mirai/message/data/FileMessage : net/m
 	public synthetic fun getKey ()Lnet/mamoe/mirai/message/data/MessageKey;
 	public abstract fun getName ()Ljava/lang/String;
 	public abstract fun getSize ()J
+	public fun toAbsoluteFile (Lnet/mamoe/mirai/contact/FileSupported;)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public abstract fun toAbsoluteFile (Lnet/mamoe/mirai/contact/FileSupported;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	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;
 }
@@ -5977,6 +6086,21 @@ public final class net/mamoe/mirai/utils/OverFileSizeMaxException : java/lang/Il
 	public fun <init> ()V
 }
 
+public abstract interface class net/mamoe/mirai/utils/ProgressionCallback {
+	public static final field Companion Lnet/mamoe/mirai/utils/ProgressionCallback$Companion;
+	public static fun asProgressionCallback (Lkotlinx/coroutines/channels/SendChannel;Z)Lnet/mamoe/mirai/utils/ProgressionCallback;
+	public fun onBegin (Ljava/lang/Object;Lnet/mamoe/mirai/utils/ExternalResource;)V
+	public fun onFailure (Ljava/lang/Object;Lnet/mamoe/mirai/utils/ExternalResource;Ljava/lang/Throwable;)V
+	public fun onFinished (Ljava/lang/Object;Lnet/mamoe/mirai/utils/ExternalResource;Ljava/lang/Object;)V
+	public fun onProgression (Ljava/lang/Object;Lnet/mamoe/mirai/utils/ExternalResource;Ljava/lang/Object;)V
+	public fun onSuccess (Ljava/lang/Object;Lnet/mamoe/mirai/utils/ExternalResource;Ljava/lang/Object;)V
+}
+
+public final class net/mamoe/mirai/utils/ProgressionCallback$Companion {
+	public final fun asProgressionCallback (Lkotlinx/coroutines/channels/SendChannel;Z)Lnet/mamoe/mirai/utils/ProgressionCallback;
+	public static synthetic fun asProgressionCallback$default (Lnet/mamoe/mirai/utils/ProgressionCallback$Companion;Lkotlinx/coroutines/channels/SendChannel;ZILjava/lang/Object;)Lnet/mamoe/mirai/utils/ProgressionCallback;
+}
+
 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;

+ 124 - 0
binary-compatibility-validator/api/binary-compatibility-validator.api

@@ -328,6 +328,7 @@ public final class net/mamoe/mirai/contact/ExceptionsKt {
 }
 
 public abstract interface class net/mamoe/mirai/contact/FileSupported : net/mamoe/mirai/contact/Contact {
+	public abstract fun getFiles ()Lnet/mamoe/mirai/contact/file/RemoteFiles;
 	public abstract fun getFilesRoot ()Lnet/mamoe/mirai/utils/RemoteFile;
 }
 
@@ -749,6 +750,112 @@ public final class net/mamoe/mirai/contact/announcement/OnlineAnnouncementKt {
 	public static final fun getBot (Lnet/mamoe/mirai/contact/announcement/OnlineAnnouncement;)Lnet/mamoe/mirai/Bot;
 }
 
+public abstract interface class net/mamoe/mirai/contact/file/AbsoluteFile : net/mamoe/mirai/contact/file/AbsoluteFileFolder {
+	public abstract fun getExpiryTime ()J
+	public abstract fun getMd5 ()[B
+	public abstract fun getSha1 ()[B
+	public abstract fun getSize ()J
+	public fun getUrl ()Ljava/lang/String;
+	public abstract fun getUrl (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun moveTo (Lnet/mamoe/mirai/contact/file/AbsoluteFolder;)Z
+	public abstract fun moveTo (Lnet/mamoe/mirai/contact/file/AbsoluteFolder;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun refreshed ()Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public abstract fun refreshed (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public abstract fun toMessage ()Lnet/mamoe/mirai/message/data/FileMessage;
+}
+
+public abstract interface class net/mamoe/mirai/contact/file/AbsoluteFileFolder {
+	public static final field Companion Lnet/mamoe/mirai/contact/file/AbsoluteFileFolder$Companion;
+	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 getAbsolutePath ()Ljava/lang/String;
+	public abstract fun getContact ()Lnet/mamoe/mirai/contact/FileSupported;
+	public static fun getExtension (Lnet/mamoe/mirai/contact/file/AbsoluteFileFolder;)Ljava/lang/String;
+	public abstract fun getId ()Ljava/lang/String;
+	public abstract fun getLastModifiedTime ()J
+	public abstract fun getName ()Ljava/lang/String;
+	public static fun getNameWithoutExtension (Lnet/mamoe/mirai/contact/file/AbsoluteFileFolder;)Ljava/lang/String;
+	public abstract fun getParent ()Lnet/mamoe/mirai/contact/file/AbsoluteFolder;
+	public abstract fun getUploadTime ()J
+	public abstract fun getUploaderId ()J
+	public abstract fun isFile ()Z
+	public abstract fun isFolder ()Z
+	public fun refresh ()Z
+	public abstract fun refresh (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun refreshed ()Lnet/mamoe/mirai/contact/file/AbsoluteFileFolder;
+	public abstract fun refreshed (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 toString ()Ljava/lang/String;
+}
+
+public final class net/mamoe/mirai/contact/file/AbsoluteFileFolder$Companion {
+	public final fun getExtension (Lnet/mamoe/mirai/contact/file/AbsoluteFileFolder;)Ljava/lang/String;
+	public final fun getNameWithoutExtension (Lnet/mamoe/mirai/contact/file/AbsoluteFileFolder;)Ljava/lang/String;
+}
+
+public abstract interface class net/mamoe/mirai/contact/file/AbsoluteFolder : net/mamoe/mirai/contact/file/AbsoluteFileFolder {
+	public static final field Companion Lnet/mamoe/mirai/contact/file/AbsoluteFolder$Companion;
+	public static final field ROOT_FOLDER_ID Ljava/lang/String;
+	public fun children ()Lkotlinx/coroutines/flow/Flow;
+	public abstract fun children (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun childrenStream ()Ljava/util/stream/Stream;
+	public abstract fun childrenStream (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun createFolder (Ljava/lang/String;)Lnet/mamoe/mirai/contact/file/AbsoluteFolder;
+	public abstract fun createFolder (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun files ()Lkotlinx/coroutines/flow/Flow;
+	public abstract fun files (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun filesStream ()Ljava/util/stream/Stream;
+	public abstract fun filesStream (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun folders ()Lkotlinx/coroutines/flow/Flow;
+	public abstract fun folders (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun foldersStream ()Ljava/util/stream/Stream;
+	public abstract fun foldersStream (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public abstract fun getContentsCount ()I
+	public fun isEmpty ()Z
+	public fun refreshed ()Lnet/mamoe/mirai/contact/file/AbsoluteFolder;
+	public abstract fun refreshed (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun resolveAll (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow;
+	public abstract fun resolveAll (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun resolveAllStream (Ljava/lang/String;)Ljava/util/stream/Stream;
+	public abstract fun resolveAllStream (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun resolveFileById (Ljava/lang/String;)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public fun resolveFileById (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun resolveFileById (Ljava/lang/String;Z)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public abstract fun resolveFileById (Ljava/lang/String;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static synthetic fun resolveFileById$default (Lnet/mamoe/mirai/contact/file/AbsoluteFolder;Ljava/lang/String;ZILjava/lang/Object;)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public static synthetic fun resolveFileById$default (Lnet/mamoe/mirai/contact/file/AbsoluteFolder;Ljava/lang/String;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
+	public fun resolveFiles (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow;
+	public abstract fun resolveFiles (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun resolveFilesStream (Ljava/lang/String;)Ljava/util/stream/Stream;
+	public abstract fun resolveFilesStream (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun resolveFolder (Ljava/lang/String;)Lnet/mamoe/mirai/contact/file/AbsoluteFolder;
+	public abstract fun resolveFolder (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun uploadNewFile (Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public fun uploadNewFile (Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun uploadNewFile (Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ProgressionCallback;)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public abstract fun uploadNewFile (Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ProgressionCallback;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static synthetic fun uploadNewFile$default (Lnet/mamoe/mirai/contact/file/AbsoluteFolder;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ProgressionCallback;ILjava/lang/Object;)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public static synthetic fun uploadNewFile$default (Lnet/mamoe/mirai/contact/file/AbsoluteFolder;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ProgressionCallback;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
+}
+
+public final class net/mamoe/mirai/contact/file/AbsoluteFolder$Companion {
+	public static final field ROOT_FOLDER_ID Ljava/lang/String;
+}
+
+public abstract interface class net/mamoe/mirai/contact/file/RemoteFiles {
+	public abstract fun getContact ()Lnet/mamoe/mirai/contact/FileSupported;
+	public abstract fun getRoot ()Lnet/mamoe/mirai/contact/file/AbsoluteFolder;
+	public fun uploadNewFile (Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public fun uploadNewFile (Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public fun uploadNewFile (Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ProgressionCallback;)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public fun uploadNewFile (Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ProgressionCallback;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static synthetic fun uploadNewFile$default (Lnet/mamoe/mirai/contact/file/RemoteFiles;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ProgressionCallback;ILjava/lang/Object;)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public static synthetic fun uploadNewFile$default (Lnet/mamoe/mirai/contact/file/RemoteFiles;Ljava/lang/String;Lnet/mamoe/mirai/utils/ExternalResource;Lnet/mamoe/mirai/utils/ProgressionCallback;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
+}
+
 public abstract interface class net/mamoe/mirai/contact/roaming/RoamingMessage {
 	public fun getBot ()Lnet/mamoe/mirai/Bot;
 	public abstract fun getContact ()Lnet/mamoe/mirai/contact/Contact;
@@ -4034,6 +4141,8 @@ public abstract interface class net/mamoe/mirai/message/data/FileMessage : net/m
 	public synthetic fun getKey ()Lnet/mamoe/mirai/message/data/MessageKey;
 	public abstract fun getName ()Ljava/lang/String;
 	public abstract fun getSize ()J
+	public fun toAbsoluteFile (Lnet/mamoe/mirai/contact/FileSupported;)Lnet/mamoe/mirai/contact/file/AbsoluteFile;
+	public abstract fun toAbsoluteFile (Lnet/mamoe/mirai/contact/FileSupported;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	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;
 }
@@ -5977,6 +6086,21 @@ public final class net/mamoe/mirai/utils/OverFileSizeMaxException : java/lang/Il
 	public fun <init> ()V
 }
 
+public abstract interface class net/mamoe/mirai/utils/ProgressionCallback {
+	public static final field Companion Lnet/mamoe/mirai/utils/ProgressionCallback$Companion;
+	public static fun asProgressionCallback (Lkotlinx/coroutines/channels/SendChannel;Z)Lnet/mamoe/mirai/utils/ProgressionCallback;
+	public fun onBegin (Ljava/lang/Object;Lnet/mamoe/mirai/utils/ExternalResource;)V
+	public fun onFailure (Ljava/lang/Object;Lnet/mamoe/mirai/utils/ExternalResource;Ljava/lang/Throwable;)V
+	public fun onFinished (Ljava/lang/Object;Lnet/mamoe/mirai/utils/ExternalResource;Ljava/lang/Object;)V
+	public fun onProgression (Ljava/lang/Object;Lnet/mamoe/mirai/utils/ExternalResource;Ljava/lang/Object;)V
+	public fun onSuccess (Ljava/lang/Object;Lnet/mamoe/mirai/utils/ExternalResource;Ljava/lang/Object;)V
+}
+
+public final class net/mamoe/mirai/utils/ProgressionCallback$Companion {
+	public final fun asProgressionCallback (Lkotlinx/coroutines/channels/SendChannel;Z)Lnet/mamoe/mirai/utils/ProgressionCallback;
+	public static synthetic fun asProgressionCallback$default (Lnet/mamoe/mirai/utils/ProgressionCallback$Companion;Lkotlinx/coroutines/channels/SendChannel;ZILjava/lang/Object;)Lnet/mamoe/mirai/utils/ProgressionCallback;
+}
+
 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;

+ 13 - 1
mirai-core-api/src/commonMain/kotlin/contact/FileSupported.kt

@@ -10,15 +10,18 @@
 
 package net.mamoe.mirai.contact
 
+import net.mamoe.mirai.contact.file.RemoteFiles
 import net.mamoe.mirai.utils.NotStableForInheritance
 import net.mamoe.mirai.utils.RemoteFile
 
 /**
  * 支持文件操作的 [Contact]. 目前仅 [Group].
  *
- * 获取文件操作相关示例: [RemoteFile]
+ * 获取文件操作相关示例: [RemoteFiles]
  *
  * @since 2.5
+ *
+ * @see RemoteFiles
  */
 @NotStableForInheritance
 public interface FileSupported : Contact {
@@ -27,5 +30,14 @@ public interface FileSupported : Contact {
      *
      * @since 2.5
      */
+    @Suppress("DEPRECATION")
+    @Deprecated("Please use files instead.", replaceWith = ReplaceWith("files.root")) // deprecated since 2.8.0-RC
     public val filesRoot: RemoteFile
+
+    /**
+     * 获取远程文件列表 (管理器).
+     *
+     * @since 2.8
+     */
+    public val files: RemoteFiles
 }

+ 83 - 0
mirai-core-api/src/commonMain/kotlin/contact/file/AbsoluteFile.kt

@@ -0,0 +1,83 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:JvmBlockingBridge
+@file:Suppress("OVERLOADS_INTERFACE")
+
+package net.mamoe.mirai.contact.file
+
+import kotlinx.io.errors.IOException
+import net.mamoe.kjbb.JvmBlockingBridge
+import net.mamoe.mirai.contact.PermissionDeniedException
+import net.mamoe.mirai.message.data.FileMessage
+import net.mamoe.mirai.utils.NotStableForInheritance
+
+/**
+ * 绝对文件标识. 精确表示一个远程文件. 不会受同名文件或目录的影响.
+ *
+ * @since 2.8
+ * @see RemoteFiles
+ * @see AbsoluteFolder
+ * @see AbsoluteFileFolder
+ */
+@NotStableForInheritance
+public interface AbsoluteFile : AbsoluteFileFolder {
+    /**
+     * 文件到期时间戳, 单位秒.
+     */
+    public val expiryTime: Long
+
+    /**
+     * 文件大小 (占用空间), 单位 byte.
+     */
+    public val size: Long
+
+    /**
+     * 文件内容 SHA-1.
+     */
+    public val sha1: ByteArray
+
+    /**
+     * 文件内容 MD5.
+     */
+    public val md5: ByteArray
+
+    /**
+     * 移动远程文件到 [folder] 目录下. 成功时返回 `true`, 当远程文件不存在时返回 `false`.
+     *
+     * 注意该操作有可能产生同名文件或目录 (当 [folder] 中已经存在一个名称为 [name] 的文件或目录时).
+     *
+     * @throws IOException 当发生网络错误时可能抛出
+     * @throws IllegalStateException 当发生已知的协议错误时抛出
+     * @throws PermissionDeniedException 当无管理员权限时抛出 (若群仅允许管理员上传)
+     */
+    public suspend fun moveTo(folder: AbsoluteFolder): Boolean
+
+    /**
+     * 获得下载链接 URL 字符串. 当远程文件不存在时返回 `null`.
+     */
+    public suspend fun getUrl(): String?
+
+    /**
+     * 得到表示远程文件的可以发送的 [FileMessage].
+     *
+     * 在 [上传文件][RemoteFiles.uploadNewFile] 时就已经发送了文件消息. [toMessage] 可供之后再次发送使用.
+     */
+    public fun toMessage(): FileMessage
+
+    /**
+     * 返回更新了文件或目录信息 ([lastModifiedTime] 等) 的, 指向相同文件的 [AbsoluteFileFolder].
+     * 不会更新当前 [AbsoluteFileFolder] 对象.
+     *
+     * 当远程文件或目录不存在时返回 `null`.
+     *
+     * 该函数会遍历上级目录的所有文件并匹配当前文件, 因此可能会非常慢, 请不要频繁使用.
+     */
+    override suspend fun refreshed(): AbsoluteFile?
+}

+ 183 - 0
mirai-core-api/src/commonMain/kotlin/contact/file/AbsoluteFileFolder.kt

@@ -0,0 +1,183 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:JvmBlockingBridge
+@file:Suppress("OVERLOADS_INTERFACE")
+
+package net.mamoe.mirai.contact.file
+
+import kotlinx.io.errors.IOException
+import net.mamoe.kjbb.JvmBlockingBridge
+import net.mamoe.mirai.contact.FileSupported
+import net.mamoe.mirai.contact.PermissionDeniedException
+import net.mamoe.mirai.utils.NotStableForInheritance
+import java.io.File
+
+/**
+ * 绝对文件或目录标识. 精确表示一个远程文件. 不会受同名文件或目录的影响.
+ *
+ * @since 2.8
+ * @see RemoteFiles
+ * @see AbsoluteFile
+ * @see AbsoluteFolder
+ */
+@NotStableForInheritance
+public sealed interface AbsoluteFileFolder {
+    /**
+     * 该对象所属 [FileSupported]
+     */
+    public val contact: FileSupported
+
+    /**
+     * 上级 [AbsoluteFileFolder].
+     *
+     * - 当该 [AbsoluteFileFolder] 表示一个目录中的文件时返回文件所属目录的 [AbsoluteFolder].
+     * - 当该 [AbsoluteFileFolder] 表示子目录时返回父目录的 [AbsoluteFolder].
+     *
+     * 特别地,
+     * - 当该 [AbsoluteFileFolder] 表示根目录下的一个文件时返回根目录的 [AbsoluteFolder].
+     * - 当该 [AbsoluteFileFolder] 表示根目录时返回 `null` (表示无上级).
+     *
+     * 也就是说, 若 [AbsoluteFileFolder.parent] 为 `null`, 那么该 [AbsoluteFileFolder] 就表示根目录.
+     */
+    public val parent: AbsoluteFolder?
+
+    /**
+     * 文件或目录的 ID, 即 `fileId` 或 `folderId`. 该属性由服务器维护, 通常唯一且持久.
+     */
+    public val id: String
+
+    /**
+     * 文件名或目录名.
+     *
+     * 注意, 当远程文件或目录被 (其他人) 改名时, [name] 不会变动.
+     * 只有在调用 [renameTo] 和 [refresh] 时才会更新.
+     *
+     * 不会包含 `:*?"<>|/\` 任一字符.
+     */
+    public val name: String
+
+    /**
+     * 绝对路径, 如 `/foo/bar.txt`.
+     *
+     * 注意, 当远程文件或目录被 (其他人) 移动到其他位置或其父目录名称改名时, [absolutePath] 不会变动.
+     * 只有在调用 [renameTo] 和 [refresh] 等时才会更新.
+     */
+    public val absolutePath: String
+
+    /**
+     * 表示远程文件时返回 `true`.
+     */
+    public val isFile: Boolean
+
+    /**
+     * 表示远程目录时返回 `true`.
+     */
+    public val isFolder: Boolean
+
+    /**
+     * 远程文件或目录的创建时间, 时间戳秒.
+     */
+    public val uploadTime: Long
+
+    /**
+     * 远程文件或目录的最后修改时间戳, 单位秒.
+     *
+     * 注意, 当远程文件或目录被 (其他人) 改动时, [lastModifiedTime] 不会变动.
+     * 只有在调用 [renameTo] 和 [refresh] 等时才会更新.
+     */
+    public val lastModifiedTime: Long
+
+    /**
+     * 上传者 ID.
+     */
+    public val uploaderId: Long
+
+
+    /**
+     * 查询该远程文件或目录是否还存在于服务器.
+     *
+     * 只会精确地按 [id] 检查, 而不会考虑同名文件或目录. 当文件或目录存在时返回 `true`.
+     *
+     * 该操作不会更新 [absolutePath] 等属性.
+     */
+    public suspend fun exists(): Boolean
+
+    /**
+     * 重命名远程文件或目录, **并且**修改当前(`this`) [AbsoluteFileFolder] 的 [name].
+     * 成功时返回 `true`, 当远程文件或目录不存在时返回 `false`.
+     *
+     * 注意该操作有可能产生同名文件或目录 (当服务器已经存在一个名称为 [newName] 的文件或目录时).
+     *
+     * @throws IOException 当发生网络错误时可能抛出
+     * @throws IllegalStateException 当发生已知的协议错误时抛出
+     * @throws PermissionDeniedException 当无管理员权限时抛出 (若群仅允许管理员上传)
+     */
+    public suspend fun renameTo(newName: String): Boolean
+
+    /**
+     * 删除远程文件或目录. 只会根据 [id] 精确地删除一个文件或目录, 不会删除其他同名文件或目录.
+     * 成功时返回 `true`, 当远程文件或目录不存在时返回 `false`.
+     *
+     * 若目录非空, 则会删除目录中的所有文件. 操作目录或非 Bot 自己上传的文件时需要管理员权限, 无管理员权限时抛出异常.
+     *
+     * @throws IOException 当发生网络错误时可能抛出
+     * @throws IllegalStateException 当发生已知的协议错误时抛出
+     * @throws PermissionDeniedException 当无管理员权限时抛出
+     */
+    public suspend fun delete(): Boolean
+
+    /**
+     * 更新当前 [AbsoluteFileFolder] 对象的文件或目录信息 ([lastModifiedTime], [absolutePath] 等).
+     * 成功时返回 `true`, 当远程文件或目录不存在时返回 `false`.
+     */
+    public suspend fun refresh(): Boolean
+
+    /**
+     * 返回更新了文件或目录信息 ([lastModifiedTime] 等) 的, 指向相同文件的 [AbsoluteFileFolder].
+     * 不会更新当前 [AbsoluteFileFolder] 对象.
+     *
+     * 当远程文件或目录不存在时返回 `null`.
+     *
+     * 该函数会遍历上级目录的所有文件并匹配当前文件, 因此可能会非常慢, 请不要频繁使用.
+     */
+    public suspend fun refreshed(): AbsoluteFileFolder?
+
+    public override fun toString(): String
+
+    public companion object {
+        /**
+         * 返回去掉文件后缀的文件名. 如 `foo.txt` 返回 `foo`.
+         *
+         * 注意, 当远程文件或目录被 (其他人) 改名时, [nameWithoutExtension] 不会变动.
+         * 只有在调用 [renameTo] 和 [refresh] 时才会更新.
+         *
+         * 不会包含 `:*?"<>|/\` 任一字符.
+         *
+         * @see File.nameWithoutExtension
+         */
+        @get:JvmStatic
+        public val AbsoluteFileFolder.nameWithoutExtension: String
+            get() = name.substringBeforeLast('.')
+
+        /**
+         * 返回文件的后缀名. 如 `foo.txt` 返回 `txt`.
+         *
+         * 注意, 当远程文件或目录被 (其他人) 改名时, [extension] 不会变动.
+         * 只有在调用 [renameTo] 和 [refresh] 时才会更新.
+         *
+         * 不会包含 `:*?"<>|/\` 任一字符.
+         *
+         * @see File.extension
+         */
+        @get:JvmStatic
+        public val AbsoluteFileFolder.extension: String
+            get() = name.substringAfterLast('.', "")
+    }
+}

+ 196 - 0
mirai-core-api/src/commonMain/kotlin/contact/file/AbsoluteFolder.kt

@@ -0,0 +1,196 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:JvmBlockingBridge
+@file:Suppress("OVERLOADS_INTERFACE")
+
+package net.mamoe.mirai.contact.file
+
+import kotlinx.coroutines.flow.Flow
+import net.mamoe.kjbb.JvmBlockingBridge
+import net.mamoe.mirai.contact.PermissionDeniedException
+import net.mamoe.mirai.utils.ExternalResource
+import net.mamoe.mirai.utils.JavaFriendlyAPI
+import net.mamoe.mirai.utils.NotStableForInheritance
+import net.mamoe.mirai.utils.ProgressionCallback
+import java.util.stream.Stream
+
+/**
+ * 绝对目录标识. 精确表示一个远程目录. 不会受同名文件或目录的影响.
+ *
+ * @since 2.8
+ * @see RemoteFiles
+ * @see AbsoluteFile
+ * @see AbsoluteFileFolder
+ */
+@NotStableForInheritance
+public interface AbsoluteFolder : AbsoluteFileFolder {
+    /**
+     * 当前快照中文件数量, 当有文件更新时(上传/删除文件) 该属性不会更新.
+     *
+     * 只可能通过 [refresh] 手动刷新
+     *
+     * 特别的, 若该目录表示根目录, [contentsCount] 返回 `0`. (无法快速获取)
+     */
+    public val contentsCount: Int
+
+    /**
+     * 当该目录为空时返回 `true`.
+     */
+    public fun isEmpty(): Boolean = contentsCount == 0
+
+    /**
+     * 返回更新了文件或目录信息 ([lastModifiedTime] 等) 的, 指向相同文件的 [AbsoluteFileFolder].
+     * 不会更新当前 [AbsoluteFileFolder] 对象.
+     *
+     * 当远程文件或目录不存在时返回 `null`.
+     *
+     * 该函数会遍历上级目录的所有文件并匹配当前文件, 因此可能会非常慢, 请不要频繁使用.
+     */
+    override suspend fun refreshed(): AbsoluteFolder?
+
+    ///////////////////////////////////////////////////////////////////////////
+    // list children
+    ///////////////////////////////////////////////////////////////////////////
+
+    /**
+     * 获取该目录下所有子目录列表.
+     */
+    public suspend fun folders(): Flow<AbsoluteFolder>
+
+    /**
+     * 获取该目录下所有子目录列表.
+     *
+     * 实现细节: 为了适合 Java 调用, 实现类似为阻塞式的 [folders], 因此不建议在 Kotlin 使用. 在 Kotlin 请使用 [folders].
+     */
+    @JavaFriendlyAPI
+    public suspend fun foldersStream(): Stream<AbsoluteFolder>
+
+
+    /**
+     * 获取该目录下所有文件列表.
+     */
+    public suspend fun files(): Flow<AbsoluteFile>
+
+    /**
+     * 获取该目录下所有文件列表.
+     *
+     * 实现细节: 为了适合 Java 调用, 实现类似为阻塞式的 [files], 因此不建议在 Kotlin 使用. 在 Kotlin 请使用 [files].
+     */
+    @JavaFriendlyAPI
+    public suspend fun filesStream(): Stream<AbsoluteFile>
+
+
+    /**
+     * 获取该目录下所有文件和子目录列表.
+     */
+    public suspend fun children(): Flow<AbsoluteFileFolder>
+
+    /**
+     * 获取该目录下所有文件和子目录列表.
+     *
+     * 实现细节: 为了适合 Java 调用, 实现类似为阻塞式的 [children], 因此不建议在 Kotlin 使用. 在 Kotlin 请使用 [children].
+     */
+    @JavaFriendlyAPI
+    public suspend fun childrenStream(): Stream<AbsoluteFileFolder>
+
+    ///////////////////////////////////////////////////////////////////////////
+    // resolve and upload
+    ///////////////////////////////////////////////////////////////////////////
+
+    /**
+     * 创建一个名称为 [name] 的子目录. 返回成功创建的或已有的子目录. 当目标目录已经存在时则直接返回该目录.
+     *
+     * @throws IllegalArgumentException 当 [name] 为空或包含非法字符 (`:*?"<>|`) 时抛出
+     * @throws PermissionDeniedException 当权限不足时抛出
+     */
+    public suspend fun createFolder(name: String): AbsoluteFolder
+
+    /**
+     * 获取一个已存在的名称为 [name] 的子目录. 当该名称的子目录不存在时返回 `null`.
+     *
+     * @throws IllegalArgumentException 当 [name] 为空或包含非法字符 (`:*?"<>|`) 时抛出
+     */
+    public suspend fun resolveFolder(name: String): AbsoluteFolder?
+
+    /**
+     * 精确获取 [AbsoluteFile.id] 为 [id] 的文件. 在目标文件不存在时返回 `null`. 当 [deep] 为 `true` 时还会深入子目录查找.
+     */
+    @JvmOverloads
+    public suspend fun resolveFileById(
+        id: String,
+        deep: Boolean = false
+    ): AbsoluteFile?
+
+    /**
+     * 根据路径获取指向的所有路径为 [path] 的文件列表. 同时支持相对路径和绝对路径. 支持获取子目录内的文件.
+     */
+    public suspend fun resolveFiles(
+        path: String
+    ): Flow<AbsoluteFile>
+
+    /**
+     * 根据路径获取指向的所有路径为 [path] 的文件列表. 同时支持相对路径和绝对路径. 支持获取子目录内的文件.
+     *
+     * 实现细节: 为了适合 Java 调用, 实现类似为阻塞式的 [resolveFiles], 因此不建议在 Kotlin 使用. 在 Kotlin 请使用 [resolveFiles].
+     */
+    @JavaFriendlyAPI
+    public suspend fun resolveFilesStream(
+        path: String
+    ): Stream<AbsoluteFile>
+
+    /**
+     * 根据路径获取指向的所有路径为 [path] 的文件和目录列表. 同时支持相对路径和绝对路径. 支持获取子目录内的文件和目录.
+     */
+    public suspend fun resolveAll(
+        path: String
+    ): Flow<AbsoluteFileFolder>
+
+    /**
+     * 根据路径获取指向的所有路径为 [path] 的文件和目录列表. 同时支持相对路径和绝对路径. 支持获取子目录内的文件和目录.
+     *
+     * 实现细节: 为了适合 Java 调用, 实现类似为阻塞式的 [resolveAll], 因此不建议在 Kotlin 使用. 在 Kotlin 请使用 [resolveAll].
+     */
+    @JavaFriendlyAPI
+    public suspend fun resolveAllStream(
+        path: String
+    ): Stream<AbsoluteFileFolder>
+
+    /**
+     * 上传一个文件到该目录, 返回上传成功的文件标识.
+     *
+     * 会在必要时尝试创建远程目录.
+     *
+     * ### [filepath]
+     *
+     * - 可以是 `foo.txt` 表示该目录下的文件 "foo.txt"
+     * - 也可以是 `sub/foo.txt` 表示该目录的子目录 "sub" 下的文件 "foo.txt".
+     * - 或是绝对路径 `/sub/foo.txt` 表示根目录的 "sub" 目录下的文件 "foo.txt"
+     *
+     * @param filepath 目标文件名
+     * @param content 文件内容
+     * @param callback 下载进度回调, 传递的 `progression` 是已下载字节数.
+     *
+     * @throws PermissionDeniedException 当无管理员权限时抛出 (若群仅允许管理员上传)
+     */
+    @JvmOverloads
+    public suspend fun uploadNewFile(
+        filepath: String,
+        content: ExternalResource,
+        callback: ProgressionCallback<AbsoluteFile, Long>? = null,
+    ): AbsoluteFile
+
+    public companion object {
+        /**
+         * 根目录 folder ID.
+         * @see id
+         */
+        public const val ROOT_FOLDER_ID: String = "/"
+    }
+}

+ 141 - 0
mirai-core-api/src/commonMain/kotlin/contact/file/RemoteFiles.kt

@@ -0,0 +1,141 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:JvmBlockingBridge
+@file:Suppress("OVERLOADS_INTERFACE")
+
+package net.mamoe.mirai.contact.file
+
+import kotlinx.coroutines.flow.Flow
+import net.mamoe.kjbb.JvmBlockingBridge
+import net.mamoe.mirai.contact.FileSupported
+import net.mamoe.mirai.contact.PermissionDeniedException
+import net.mamoe.mirai.utils.ExternalResource
+import net.mamoe.mirai.utils.NotStableForInheritance
+import net.mamoe.mirai.utils.ProgressionCallback
+import java.io.File
+import java.util.stream.Stream
+
+/**
+ * 表示远程文件列表 (管理器).
+ *
+ * [RemoteFiles] 包含一些协议接口,
+ *
+ * # 文件和目录操作
+ *
+ * 文件和目录的父类型是 [AbsoluteFileFolder].
+ *
+ * - [AbsoluteFile] 表示一个文件
+ * - [AbsoluteFolder] 表示一个目录
+ *
+ * 每个文件或目录都拥有一个唯一 ID: [AbsoluteFileFolder.id]. 该 ID 由服务器提供, 在重命名或移动时不会变化.
+ *
+ * 文件名可以通过 [AbsoluteFileFolder.name] 获得, 但注意文件名和其他属性都会随重命名或移动等操作更新.
+ *
+ * 除根目录 [root] 外, 每个文件或目录都拥有父目录 [AbsoluteFileFolder.parent].
+ *
+ * # 根目录
+ *
+ * 除了 [RemoteFiles] 中定义的捷径外, 一切文件目录操作都以获取根目录开始. 可通过 [RemoteFiles.root] 获取表示根目录的 [AbsoluteFolder].
+ *
+ * # 绝对路径与相对路径
+ *
+ * mirai 文件系统的绝对路径与相对路径与 Java [File] 实现的相同.
+ *
+ * 以 `/` 起始的路径表示绝对路径, 基于根目录 [root] 处理. 其他路径均表示相对路径.
+ *
+ * 可由 [AbsoluteFileFolder.absolutePath] 获取其绝对路径. 值得注意的是, 所有文件与目录对象都表示绝对路径下的目标, 因此它们都总是精确地表示一个目标, 而不受环境影响.
+ *
+ * 除重命名外, 所有文件和目录操作都默认同时支持上述两种路径.
+ *
+ * # 操作 [AbsoluteFileFolder]
+ *
+ * ## 重命名, 移动
+ *
+ * [AbsoluteFileFolder.renameTo], [AbsoluteFile.moveTo] 提供重命名和移动功能. 注意目录不支持移动.
+ *
+ * ## 获取目录中的子目录和文件列表
+ *
+ * 一个目录 ([AbsoluteFolder]) 可以包含多个子文件, 根目录还可以包含多个子目录 (详见下文 '目录结构限制').
+ *
+ * 使用 [AbsoluteFolder.children] 可以获得其内子目录和文件列表 [Flow]. [AbsoluteFolder.childrenStream] 提供适合 Java 的 [Stream] 实现.
+ * 使用 [AbsoluteFolder.folders] 或 [AbsoluteFolder.files] 可以特定地只获取子目录或文件列表. 这些函数也有其 `*Stream` 实现.
+ *
+ * 若要根据确定的文件或目录名称获取其 [AbsoluteFileFolder] 实例, 可使用 [AbsoluteFolder.resolveFiles] 或 [AbsoluteFolder.resolveFiles].
+ * 注意 [AbsoluteFolder.resolveFiles] 返回 [Flow] (其 Stream 版返回 [Stream]), 因为服务器允许多个文件有相同名称. (详见下文 '允许重名').
+ *
+ * 若已知文件 [AbsoluteFile.id], 可通过 [AbsoluteFolder.resolveFileById] 获得该文件.
+ *
+ * ## 上传新文件
+ * 可使用 [AbsoluteFolder.uploadNewFile] 上传新文件. 也可以通过 [RemoteFiles.uploadNewFile] 直接上传而跳过获取目录的步骤 (因为目录不允许同名).
+ *
+ * ## 覆盖一个旧文件
+ * 服务器不允许覆盖文件. 只能通过 [AbsoluteFile.delete] 删除文件后再上传新文件. 注意新旧文件的 [AbsoluteFile.id] 会不同.
+ *
+ * # 操作权限
+ * 操作一个目录时总是需要管理员权限. 若群设置 "允许任何人上传文件", 则上传文件和操作自己上传的文件时都不需要特殊权限. 注意, 操作他人的文件时总是需要管理员权限.
+ *
+ * # 服务器限制
+ *
+ * ## 目录结构限制
+ *
+ * 在 mirai 2.8.0 发布时, 服务器仅允许两层目录结构. 也就是说只允许根目录存在子目录, 子目录不能包含另一个子目录.
+ *
+ * 为了考虑将来服务器可能升级, mirai 没有做实现上的限制. mirai 所有操作都支持多层目录, 但进行这样的操作时将会得到服务器错误, 方法会抛出 [IllegalStateException].
+ *
+ * ## 允许重名
+ *
+ * 服务器允许同名目录和文件存在. 如下同名的三个文件与一个目录是允许的, 但它们的 [AbsoluteFileFolder.id] 都互不相同:
+ * ```
+ * foo
+ *  |- test (目录)
+ *  |- test (文件)
+ *  |- test (文件)
+ *  |- test (文件)
+ * ```
+ * 注意, 目录不允许同名.
+ *
+ * [AbsoluteFileFolder] 依据 [AbsoluteFileFolder.id] 定位文件, 而不是通过文件名. 因此 [AbsoluteFileFolder] 总是精确地代表一个文件或目录.
+ *
+ * @since 2.8
+ * @see FileSupported
+ */
+@NotStableForInheritance
+public interface RemoteFiles {
+    /**
+     * 获取表示根目录的 [AbsoluteFolder]
+     */
+    public val root: AbsoluteFolder
+
+    /**
+     * 该对象所属 [FileSupported]
+     */
+    public val contact: FileSupported
+
+    /**
+     * 上传一个文件到指定精确路径. 返回指代该远程文件的 [AbsoluteFile].
+     *
+     * 会在必要时尝试创建远程目录.
+     *
+     * 也可以使用 [AbsoluteFolder.uploadNewFile].
+     *
+     * @param filepath 文件路径, **包含目标文件名**. 如 `/foo/bar.txt`. 若是相对目录则基于 [根目录][root] 处理.
+     * @param content 文件内容
+     * @param callback 下载进度回调, 传递的 `progression` 是已下载字节数.
+     *
+     * @throws PermissionDeniedException 当无管理员权限时抛出 (若群仅允许管理员上传)
+     */
+    @JvmOverloads
+    public suspend fun uploadNewFile(
+        filepath: String,
+        content: ExternalResource,
+        callback: ProgressionCallback<AbsoluteFile, Long>? = null,
+    ): AbsoluteFile = root.uploadNewFile(filepath, content, callback)
+}
+

+ 12 - 1
mirai-core-api/src/commonMain/kotlin/message/data/FileMessage.kt

@@ -19,6 +19,7 @@ import kotlinx.serialization.Serializable
 import net.mamoe.kjbb.JvmBlockingBridge
 import net.mamoe.mirai.Mirai
 import net.mamoe.mirai.contact.FileSupported
+import net.mamoe.mirai.contact.file.AbsoluteFile
 import net.mamoe.mirai.event.events.MessageEvent
 import net.mamoe.mirai.message.code.CodableMessage
 import net.mamoe.mirai.message.code.internal.appendStringAsMiraiCode
@@ -40,6 +41,7 @@ import net.mamoe.mirai.utils.*
 @Serializable(FileMessage.Serializer::class)
 @SerialName(FileMessage.SERIAL_NAME)
 @NotStableForInheritance
+@JvmBlockingBridge
 public interface FileMessage : MessageContent, ConstrainSingle, CodableMessage {
     /**
      * 服务器需要的某种 ID.
@@ -74,11 +76,20 @@ public interface FileMessage : MessageContent, ConstrainSingle, CodableMessage {
     /**
      * 获取一个对应的 [RemoteFile]. 当目标群或好友不存在这个文件时返回 `null`.
      */
-    @JvmBlockingBridge
+    @Suppress("DEPRECATION")
+    @Deprecated("Please use toAbsoluteFile", ReplaceWith("this.toAbsoluteFile(contact)")) // deprecated since 2.8.0-RC
     public suspend fun toRemoteFile(contact: FileSupported): RemoteFile? {
+        @Suppress("DEPRECATION")
         return contact.filesRoot.resolveById(id)
     }
 
+    /**
+     * 获取一个对应的 [RemoteFile]. 当目标群或好友不存在这个文件时返回 `null`.
+     *
+     * @since 2.8
+     */
+    public suspend fun toAbsoluteFile(contact: FileSupported): AbsoluteFile?
+
     override val key: Key get() = Key
 
     /**

+ 10 - 0
mirai-core-api/src/commonMain/kotlin/utils/ExternalResource.kt

@@ -437,6 +437,11 @@ public interface ExternalResource : Closeable {
          * @see RemoteFile.path
          * @see RemoteFile.uploadAndSend
          */
+        @Suppress("DEPRECATION")
+        @Deprecated(
+            "Deprecated. Please use AbsoluteFolder.uploadNewFile",
+            ReplaceWith("contact.files.uploadNewFile(path, this, callback)")
+        ) // deprecated since 2.8.0-RC
         @JvmStatic
         @JvmBlockingBridge
         @JvmOverloads
@@ -456,6 +461,11 @@ public interface ExternalResource : Closeable {
          * @see RemoteFile.path
          * @see RemoteFile.uploadAndSend
          */
+        @Suppress("DEPRECATION")
+        @Deprecated(
+            "Deprecated. Please use AbsoluteFolder.uploadNewFile",
+            ReplaceWith("contact.files.uploadNewFile(path, this, callback)")
+        ) // deprecated since 2.8.0-RC
         @JvmStatic
         @JvmBlockingBridge
         @JvmName("sendAsFile")

+ 103 - 0
mirai-core-api/src/commonMain/kotlin/utils/ProgressionCallback.kt

@@ -0,0 +1,103 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.utils
+
+import kotlinx.coroutines.channels.SendChannel
+import net.mamoe.mirai.contact.file.AbsoluteFile
+import net.mamoe.mirai.utils.ProgressionCallback.Companion.asProgressionCallback
+
+
+/**
+ * 操作进度回调, 可供前端使用, 以提供进度显示.
+ *
+ * @param S subject, 操作对象, 如 [AbsoluteFile]
+ * @param P progression, 用于提示进度. 如当下载文件时为已下载文件大小字节数 [Long].
+ *
+ * @see asProgressionCallback
+ *
+ * @since 2.8
+ */
+public interface ProgressionCallback<in S, in P> {
+    /**
+     * 当操作开始时调用
+     */
+    public fun onBegin(subject: S, resource: ExternalResource) {}
+
+    /**
+     * 每当有进度更新时调用. 此方法可能会同时被多个线程调用.
+     */
+    public fun onProgression(subject: S, resource: ExternalResource, progression: P) {}
+
+    /**
+     * 当操作成功时调用.
+     *
+     * 在默认实现下只会由 [onFinished] 调用
+     */
+    public fun onSuccess(subject: S, resource: ExternalResource, progression: P) {}
+
+    /**
+     * 当操作以异常失败时调用.
+     *
+     * 在默认实现下只会由 [onFinished] 调用
+     */
+    public fun onFailure(subject: S, resource: ExternalResource, exception: Throwable) {}
+
+    /**
+     * 当操作完成时调用.
+     */
+    public fun onFinished(subject: S, resource: ExternalResource, result: Result<P>) {
+        result.fold(
+            onSuccess = { onSuccess(subject, resource, it) },
+            onFailure = { onFailure(subject, resource, it) },
+        )
+    }
+
+    public companion object {
+        /**
+         * 将一个 [SendChannel] 作为 [ProgressionCallback] 使用.
+         *
+         * ## 下载文件的使用示例
+         *
+         * 每当有进度更新, 已下载的字节数都会被[发送][SendChannel.offer]到 [SendChannel] 中.
+         * 进度的发送会通过 [offer][SendChannel.offer], 而不是通过 [send][SendChannel.send]. 意味着 [SendChannel] 通常要实现缓存.
+         *
+         * 若 [closeOnFinish] 为 `true`, 当下载完成 (无论是失败还是成功) 时会 [关闭][SendChannel.close] [SendChannel].
+         *
+         * 使用示例:
+         * ```
+         * val progress = Channel<Long>(Channel.BUFFERED)
+         *
+         * launch {
+         *   // 每 3 秒发送一次操作进度百分比
+         *   progress.receiveAsFlow().sample(Duration.seconds(3)).collect { bytes ->
+         *     group.sendMessage("File upload: ${(bytes.toDouble() / resource.size * 100).toInt() / 100}%.") // 保留 2 位小数
+         *   }
+         * }
+         *
+         * group.files.uploadNewFile("/foo.txt", resource, callback = progress.asProgressionCallback(true))
+         * group.sendMessage("File uploaded successfully.")
+         * ```
+         *
+         * 直接使用 [ProgressionCallback] 也可以实现示例这样的功能, [asProgressionCallback] 是为了简化操作.
+         */
+        @JvmStatic
+        public fun <S, P> SendChannel<P>.asProgressionCallback(closeOnFinish: Boolean = true): ProgressionCallback<S, P> {
+            return object : ProgressionCallback<S, P> {
+                override fun onProgression(subject: S, resource: ExternalResource, progression: P) {
+                    trySend(progression)
+                }
+
+                override fun onFinished(subject: S, resource: ExternalResource, result: Result<P>) {
+                    if (closeOnFinish) this@asProgressionCallback.close(result.exceptionOrNull())
+                }
+            }
+        }
+    }
+}

+ 16 - 1
mirai-core-api/src/commonMain/kotlin/utils/RemoteFile.kt

@@ -7,7 +7,7 @@
  * https://github.com/mamoe/mirai/blob/dev/LICENSE
  */
 
-@file:Suppress("unused")
+@file:Suppress("unused", "DEPRECATION")
 @file:JvmBlockingBridge
 
 package net.mamoe.mirai.utils
@@ -97,6 +97,7 @@ import java.io.File
  * @see FileSupported
  * @since 2.5
  */
+@Deprecated("Please use RemoteFiles and AbsoluteFileFolder form fileSupported.files") // deprecated since 2.8.0-RC
 @NotStableForInheritance
 public interface RemoteFile {
     /**
@@ -348,6 +349,10 @@ public interface RemoteFile {
      * 上传进度回调, 可供前端使用, 以提供进度显示.
      * @see asProgressionCallback
      */
+    @Deprecated(
+        "Deprecated without replacement. Please use AbsoluteFolder.uploadNewFile",
+        ReplaceWith("contact.files.uploadNewFile(path, this, callback)")
+    ) // deprecated since 2.8.0-RC
     public interface ProgressionCallback {
         /**
          * 当上传开始时调用
@@ -610,6 +615,11 @@ public interface RemoteFile {
          */
         @JvmStatic
         @JvmOverloads
+        @Deprecated(
+            "Deprecated. Please use AbsoluteFolder.uploadNewFile or RemoteFiles.uploadNewFile",
+            ReplaceWith("this.files.uploadNewFile(path, resource, callback)"),
+            level = DeprecationLevel.WARNING
+        ) // deprecated since 2.8.0-RC
         public suspend fun <C : FileSupported> C.sendFile(
             path: String,
             resource: ExternalResource,
@@ -624,6 +634,11 @@ public interface RemoteFile {
          */
         @JvmStatic
         @JvmOverloads
+        @Deprecated(
+            "Deprecated. Please use AbsoluteFolder.uploadNewFile or RemoteFiles.uploadNewFile",
+            ReplaceWith("file.toExternalResource().use { this.files.uploadNewFile(path, it, callback) }"),
+            level = DeprecationLevel.WARNING
+        ) // deprecated since 2.8.0-RC
         public suspend fun <C : FileSupported> C.sendFile(
             path: String,
             file: File,

+ 10 - 1
mirai-core-utils/src/commonMain/kotlin/ResultExtensions.kt

@@ -150,4 +150,13 @@ public inline fun <R> Result<R>.mapFailure(
     block: (Throwable) -> Throwable,
 ): Result<R> = onFailure {
     return Result.failure(block(it))
-}
+}
+
+public inline fun <R> Result<R>.onSuccessCatching(block: () -> Unit): Result<R> {
+    if (isSuccess) {
+        runCatching(block).onFailure {
+            return@onSuccessCatching Result.failure(it)
+        }
+    }
+    return this
+}

+ 20 - 0
mirai-core/src/commonMain/kotlin/contact/ContactAware.kt

@@ -0,0 +1,20 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.internal.contact
+
+import net.mamoe.mirai.contact.Contact
+import net.mamoe.mirai.internal.asQQAndroidBot
+
+internal interface ContactAware {
+    val contact: Contact
+
+    val bot get() = contact.bot.asQQAndroidBot()
+    val client get() = bot.client
+}

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

@@ -16,12 +16,14 @@ import net.mamoe.mirai.LowLevelApi
 import net.mamoe.mirai.Mirai
 import net.mamoe.mirai.contact.*
 import net.mamoe.mirai.contact.announcement.Announcements
+import net.mamoe.mirai.contact.file.RemoteFiles
 import net.mamoe.mirai.data.GroupInfo
 import net.mamoe.mirai.data.MemberInfo
 import net.mamoe.mirai.event.broadcast
 import net.mamoe.mirai.event.events.*
 import net.mamoe.mirai.internal.QQAndroidBot
 import net.mamoe.mirai.internal.contact.announcement.AnnouncementsImpl
+import net.mamoe.mirai.internal.contact.file.RemoteFilesImpl
 import net.mamoe.mirai.internal.contact.info.MemberInfoImpl
 import net.mamoe.mirai.internal.message.*
 import net.mamoe.mirai.internal.network.components.BdhSession
@@ -106,7 +108,10 @@ internal class GroupImpl constructor(
     override lateinit var owner: NormalMemberImpl
     override lateinit var botAsMember: NormalMemberImpl
 
+    @Suppress("DEPRECATION")
+    @Deprecated("Please use files instead.", replaceWith = ReplaceWith("files.root"))
     override val filesRoot: RemoteFile by lazy { RemoteFileImpl(this, "/") }
+    override val files: RemoteFiles by lazy { RemoteFilesImpl(this) }
 
 
     override val announcements: Announcements by lazy {

+ 172 - 0
mirai-core/src/commonMain/kotlin/contact/file/AbsoluteFileImpl.kt

@@ -0,0 +1,172 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.internal.contact.file
+
+import net.mamoe.mirai.contact.FileSupported
+import net.mamoe.mirai.contact.file.AbsoluteFile
+import net.mamoe.mirai.contact.file.AbsoluteFolder
+import net.mamoe.mirai.internal.message.FileMessageImpl
+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.message.data.FileMessage
+import net.mamoe.mirai.utils.toUHexString
+
+internal class AbsoluteFileImpl(
+    contact: FileSupported,
+    parent: AbsoluteFolder?,
+    id: String,
+    name: String,
+    uploadTime: Long,
+    lastModifiedTime: Long,
+    uploaderId: Long,
+
+    override var expiryTime: Long,
+    override val size: Long, // when file is changed, its id will also be changed, so no need to be var
+    override val sha1: ByteArray,
+    override val md5: ByteArray,
+
+    busId: Int,
+) : AbsoluteFile, AbstractAbsoluteFileFolder(
+    contact, parent, id, name, uploadTime, uploaderId, lastModifiedTime,
+    busId
+) {
+    override fun checkPermission(operationHint: String) {
+        // TODO: 30/10/2021  checkPermission: 群可以设置允许任何人上传而目前没有检测这个属性, 因此不能实现权限判定
+
+//        if (uploaderId == bot.id) return
+//        if (contact is GroupImpl && !contact.botPermission.isOperator()) throwPermissionDeniedException(operationHint)
+//        return
+    }
+
+    override val isFile: Boolean get() = true
+    override val isFolder: Boolean get() = false
+
+    override val absolutePath: String
+        get() {
+            val parent = parent
+            return when {
+                parent == null || parent.name == "/" -> "/$name"
+                else -> "${parent.absolutePath}/$name"
+            }
+        }
+
+    override suspend fun exists(): Boolean {
+        return FileManagement.GetFileInfo(
+            client,
+            groupCode = contact.id,
+            busId = busId,
+            fileId = id
+        ).sendAndExpect(bot)
+            .toResult("AbsoluteFileImpl.exists", checkResp = false)
+            .getOrThrow()
+            .fileInfo != null
+    }
+
+
+    override suspend fun moveTo(folder: AbsoluteFolder): Boolean {
+        if (folder.contact != this.contact) {
+            error("Cross-group file operation is not yet supported.")
+        }
+        if (folder.absolutePath == this.parentOrRoot.absolutePath) return true
+        checkPermission("moveTo")
+
+        val result = FileManagement.MoveFile(client, contact.id, busId, id, parent.idOrRoot, folder.idOrRoot)
+            .sendAndExpect(bot).toResult("AbsoluteFileImpl.moveTo", checkResp = false)
+            .getOrThrow()
+
+        return when (result.int32RetCode) {
+            -36 -> throwPermissionDeniedException("moveTo")
+            0 -> {
+                parent = folder
+                true
+            }
+            else -> {
+                false
+            }
+        }
+//        } else {
+//            return FileManagement.RenameFolder(client, contact.id, id, name).sendAndExpect(bot)
+//                .toResult("RemoteFile.moveTo", checkResp = false).getOrThrow().int32RetCode == 0
+//        }
+    }
+
+    override suspend fun getUrl(): String? {
+        // Known error
+        // java.lang.IllegalStateException: Failed AbsoluteFileImpl.getUrl, result=-303, msg=param error: bus_id
+        // java.lang.IllegalStateException: Failed AbsoluteFileImpl.getUrl, result=-103, msg=GetFileAttrAction file not exist
+
+        val resp = FileManagement.RequestDownload(
+            client,
+            groupCode = contact.id,
+            busId = busId,
+            fileId = id
+        ).sendAndExpect(bot)
+            .toResult("AbsoluteFileImpl.getUrl")
+            .getOrElse { return null }
+
+
+        return "http://${resp.downloadIp}/ftn_handler/${resp.downloadUrl.toUHexString("")}/?fname=" +
+                id.toByteArray().toUHexString("")
+    }
+
+    override fun toMessage(): FileMessage {
+        return FileMessageImpl(id, busId, name, size)
+    }
+
+    override suspend fun refresh(): Boolean {
+        val new = refreshed() ?: return false
+        this.parent = new.parent
+        this.expiryTime = new.expiryTime
+        this.name = new.name
+        this.lastModifiedTime = new.lastModifiedTime
+        return true
+    }
+
+    override fun toString(): String = "AbsoluteFile(name=$name, absolutePath=$absolutePath, id=$id)"
+
+    override suspend fun refreshed(): AbsoluteFile? {
+        val result = FileManagement.GetFileInfo(client, contact.id, id, busId)
+            .sendAndExpect(bot)
+            .toResult("AbsoluteFile.refreshed")
+            .getOrNull()?.fileInfo
+            ?: return null
+
+        return if (result.parentFolderId == this.parentOrRoot.id) {
+            this.parentOrRoot.impl().createChildFile(result)
+        } else {
+            null
+        }
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+        if (!super.equals(other)) return false
+
+        other as AbsoluteFileImpl
+
+        if (expiryTime != other.expiryTime) return false
+        if (size != other.size) return false
+        if (!sha1.contentEquals(other.sha1)) return false
+        if (!md5.contentEquals(other.md5)) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = super.hashCode()
+        result = 31 * result + expiryTime.hashCode()
+        result = 31 * result + size.hashCode()
+        result = 31 * result + sha1.contentHashCode()
+        result = 31 * result + md5.contentHashCode()
+        return result
+    }
+}

+ 465 - 0
mirai-core/src/commonMain/kotlin/contact/file/AbsoluteFolderImpl.kt

@@ -0,0 +1,465 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.internal.contact.file
+
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.runBlocking
+import net.mamoe.mirai.contact.FileSupported
+import net.mamoe.mirai.contact.file.AbsoluteFile
+import net.mamoe.mirai.contact.file.AbsoluteFileFolder
+import net.mamoe.mirai.contact.file.AbsoluteFolder
+import net.mamoe.mirai.contact.isOperator
+import net.mamoe.mirai.internal.contact.GroupImpl
+import net.mamoe.mirai.internal.contact.file.RemoteFilesImpl.Companion.findFileByPath
+import net.mamoe.mirai.internal.network.QQAndroidClient
+import net.mamoe.mirai.internal.network.components.ClockHolder.Companion.clock
+import net.mamoe.mirai.internal.network.highway.Highway
+import net.mamoe.mirai.internal.network.highway.ResourceKind
+import net.mamoe.mirai.internal.network.protocol
+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.FileSystem
+import net.mamoe.mirai.internal.utils.io.serialization.toByteArray
+import net.mamoe.mirai.utils.*
+import java.util.stream.Stream
+import kotlin.streams.asStream
+
+internal fun Oidb0x6d8.GetFileListRspBody.Item.resolved(parent: AbsoluteFolderImpl): AbsoluteFileFolder? {
+    val item = this
+    return when {
+        item.fileInfo != null -> {
+            parent.createChildFile(item.fileInfo)
+        }
+        item.folderInfo != null -> {
+            parent.createChildFolder(item.folderInfo)
+        }
+        else -> null
+    }
+}
+
+internal fun AbsoluteFolderImpl.createChildFolder(
+    folderInfo: GroupFileCommon.FolderInfo
+): AbsoluteFolderImpl = AbsoluteFolderImpl(
+    contact = contact,
+    parent = this,
+    id = folderInfo.folderId,
+    name = folderInfo.folderName,
+    uploadTime = folderInfo.createTime.toLongUnsigned(),
+    uploaderId = folderInfo.createUin,
+    lastModifiedTime = folderInfo.modifyTime.toLongUnsigned(),
+    contentsCount = folderInfo.totalFileCount
+)
+
+internal fun AbsoluteFolderImpl.createChildFile(
+    info: GroupFileCommon.FileInfo
+): AbsoluteFileImpl = AbsoluteFileImpl(
+    contact = contact,
+    parent = this,
+    id = info.fileId,
+    name = info.fileName,
+    uploadTime = info.uploadTime.toLongUnsigned(),
+    lastModifiedTime = info.modifyTime.toLongUnsigned(),
+    uploaderId = info.uploaderUin,
+    expiryTime = info.deadTime.toLongUnsigned(),
+    size = info.fileSize,
+    sha1 = info.sha,
+    md5 = info.md5,
+    busId = info.busId
+)
+
+internal class AbsoluteFolderImpl(
+    contact: FileSupported, parent: AbsoluteFolder?, id: String, name: String,
+    uploadTime: Long, uploaderId: Long, lastModifiedTime: Long,
+    override var contentsCount: Int,
+) : AbstractAbsoluteFileFolder(
+    contact,
+    parent, id, name, uploadTime, uploaderId, lastModifiedTime, 0
+), AbsoluteFolder {
+    override fun checkPermission(operationHint: String) {
+        // 目录权限不受 '允许任何人上传' 设置的影响
+        if (contact is GroupImpl && !contact.botPermission.isOperator()) throwPermissionDeniedException(operationHint)
+        return
+    }
+
+    override val isFile: Boolean get() = false
+    override val isFolder: Boolean get() = true
+
+    override val absolutePath: String
+        get() {
+            val parent = parent
+            return when {
+                parent == null || this.id == "/" -> "/"
+                parent.parent == null || parent.id == "/" -> "/$name"
+                else -> "${parent.absolutePath}/$name"
+            }
+        }
+
+    companion object {
+        suspend fun getItemsFlow(
+            client: QQAndroidClient,
+            contact: FileSupported,
+            folderId: String
+        ): Flow<Oidb0x6d8.GetFileListRspBody.Item> {
+            return flow {
+                var index = 0
+                while (true) {
+                    val list = FileManagement.GetFileList(
+                        client,
+                        groupCode = contact.id,
+                        folderId = folderId,
+                        startIndex = index
+                    ).sendAndExpect(client.bot).toResult("AbsoluteFolderImpl.getFilesFlow").getOrThrow()
+                    index += list.itemList.size
+
+                    if (list.int32RetCode != 0) return@flow
+                    if (list.itemList.isEmpty()) return@flow
+
+                    emitAll(list.itemList.asFlow())
+                }
+            }
+        }
+
+        suspend fun uploadNewFileImpl(
+            folder: AbsoluteFolderImpl,
+            filepath: String,
+            content: ExternalResource,
+            callback: ProgressionCallback<AbsoluteFile, Long>?
+        ): AbsoluteFile {
+            if (filepath.isBlank()) throw IllegalArgumentException("filename cannot be blank.")
+            // TODO: 12/10/2021 checkPermission for AbsoluteFolderImpl.upload
+
+            content.withAutoClose {
+                val resp = FileManagement.RequestUpload(
+                    folder.client,
+                    groupCode = folder.contact.id,
+                    folderId = folder.id,
+                    resource = content,
+                    filename = filepath
+                ).sendAndExpect(folder.bot).toResult("AbsoluteFolderImpl.upload").getOrThrow()
+
+                when (resp.int32RetCode) {
+                    -36 -> folder.throwPermissionDeniedException("uploadNewFile")
+                }
+
+                val file = AbsoluteFileImpl(
+                    contact = folder.contact,
+                    parent = folder,
+                    id = resp.fileId,
+                    name = filepath,
+                    uploadTime = folder.bot.clock.server.currentTimeSeconds(),
+                    lastModifiedTime = folder.bot.clock.server.currentTimeSeconds(),
+                    expiryTime = 0,
+                    uploaderId = folder.bot.id,
+                    size = content.size,
+                    sha1 = content.sha1,
+                    md5 = content.md5,
+                    busId = resp.busId
+                )
+
+                if (resp.boolFileExist) {
+                    // resp.boolFileExist:
+                    //      服务器是否存在相同的内容, 只是用来判断可不可以跳过上传
+                    //      当为 true 时跳过上传, 但仍然需要完成 `sendMessage(FileMessage)` 才是正常逻辑
+                    callback?.onBegin(file, content)
+                    val result = kotlin.runCatching {
+                        folder.contact.sendMessage(file.toMessage())
+                    }.map { content.size }
+                    callback?.onFinished(file, content, result)
+                    return file
+                }
+
+                val ext = GroupFileUploadExt(
+                    u1 = 100,
+                    u2 = 1,
+                    entry = GroupFileUploadEntry(
+                        business = ExcitingBusiInfo(
+                            busId = resp.busId,
+                            senderUin = folder.bot.id,
+                            receiverUin = folder.contact.id, // TODO: 2021/3/1 code or uin?
+                            groupCode = folder.contact.id,
+                        ),
+                        fileEntry = ExcitingFileEntry(
+                            fileSize = content.size,
+                            md5 = content.md5,
+                            sha1 = content.sha1,
+                            fileId = resp.fileId.toByteArray(),
+                            uploadKey = resp.checkKey,
+                        ),
+                        clientInfo = ExcitingClientInfo(
+                            clientType = 2,
+                            appId = folder.client.protocol.id.toString(),
+                            terminalType = 2,
+                            clientVer = "9e9c09dc",
+                            unknown = 4,
+                        ),
+                        fileNameInfo = ExcitingFileNameInfo(filepath),
+                        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(file, content)
+
+                kotlin.runCatching {
+                    Highway.uploadResourceBdh(
+                        bot = folder.bot,
+                        resource = content,
+                        kind = ResourceKind.GROUP_FILE,
+                        commandId = 71,
+                        extendInfo = ext,
+                        dataFlag = 0,
+                        callback = if (callback == null) null else fun(it: Long) {
+                            callback.onProgression(file, content, it)
+                        }
+                    )
+                }.let { result0 ->
+                    val result = result0.onSuccessCatching {
+                        folder.contact.sendMessage(file.toMessage())
+                    }
+                    callback?.onFinished(file, content, result.map { content.size })
+                }
+
+                return file
+            }
+        }
+    }
+
+    suspend fun getItemsFlow(): Flow<Oidb0x6d8.GetFileListRspBody.Item> = Companion.getItemsFlow(client, contact, id)
+
+    @JavaFriendlyAPI
+    private suspend fun getItemsSequence(): Sequence<Oidb0x6d8.GetFileListRspBody.Item> {
+        return sequence {
+            var index = 0
+            while (true) {
+                val list = runBlocking {
+                    FileManagement.GetFileList(
+                        client,
+                        groupCode = contact.id,
+                        folderId = id,
+                        startIndex = index
+                    ).sendAndExpect(bot)
+                }.toResult("AbsoluteFolderImpl.getFilesFlow").getOrThrow()
+                index += list.itemList.size
+
+                if (list.int32RetCode != 0) return@sequence
+                if (list.itemList.isEmpty()) return@sequence
+
+                yieldAll(list.itemList)
+            }
+        }
+    }
+
+    private fun Oidb0x6d8.GetFileListRspBody.Item.resolve(): AbsoluteFileFolder? = resolved(this@AbsoluteFolderImpl)
+
+    override suspend fun folders(): Flow<AbsoluteFolder> {
+        return getItemsFlow().filter { it.folderInfo != null }.map { it.resolve() as AbsoluteFolder }
+    }
+
+    @JavaFriendlyAPI
+    override suspend fun foldersStream(): Stream<AbsoluteFolder> {
+        return getItemsSequence().filter { it.folderInfo != null }.map { it.resolve() as AbsoluteFolder }.asStream()
+    }
+
+    override suspend fun files(): Flow<AbsoluteFile> {
+        return getItemsFlow().filter { it.fileInfo != null }.map { it.resolve() as AbsoluteFile }
+    }
+
+    @JavaFriendlyAPI
+    override suspend fun filesStream(): Stream<AbsoluteFile> {
+        return getItemsSequence().filter { it.fileInfo != null }.map { it.resolve() as AbsoluteFile }.asStream()
+    }
+
+    override suspend fun children(): Flow<AbsoluteFileFolder> {
+        return getItemsFlow().mapNotNull { it.resolve() }
+    }
+
+    @JavaFriendlyAPI
+    override suspend fun childrenStream(): Stream<AbsoluteFileFolder> {
+        return getItemsSequence().mapNotNull { it.resolve() }.asStream()
+    }
+
+    override suspend fun createFolder(name: String): AbsoluteFolder {
+        if (name.isBlank()) throw IllegalArgumentException("folder name cannot be blank.")
+        checkPermission("createFolder")
+        FileSystem.checkLegitimacy(name)
+
+        // server only support nesting depth level of 1 so we don't need to check the name
+
+        val result = FileManagement.CreateFolder(client, contact.id, this.id, name)
+            .sendAndExpect(bot).toResult("AbsoluteFolderImpl.mkdir", checkResp = false)
+            .getOrThrow() // throw protocol errors
+
+        /*
+        2021-10-30 13:06:33 D/soutv: unnamed = CreateFolderRspBody#-941698272 {
+            folderInfo=FolderInfo#1879610684 {
+                    createTime=0x617D3548(1635595592)
+                    createUin=xxx
+                    folderId=/49a18e46-cf24-4362-b0d0-13235c0e7862
+                    folderName=myFolder
+                    modifyTime=0x617D3548(1635595592)
+                    modifyUin=xxx
+                    parentFolderId=/
+                    usedSpace=0x0000000000000000(0)
+            }
+            retMsg=ok
+        }
+         */
+
+        /*
+        2021-10-30 13:03:44 D/soutv: unnamed = CreateFolderRspBody#-941698272 {
+            clientWording=只允许群主和管理员操作
+            int32RetCode=0xFFFFFFDC(-36)
+            retMsg=not group admin
+        }
+         */
+
+        /*
+        2021-10-30 13:10:32 D/soutv: unnamed = CreateFolderRspBody#-941698272 {
+            clientWording=同名文件夹已存在
+            int32RetCode=0xFFFFFEC7(-313)
+            retMsg=folder name has exist
+        }
+         */
+
+        return when (result.int32RetCode) {
+            -36 -> throwPermissionDeniedException("createFolder")
+            -313 -> this.resolveFolder(name) // already exists
+            0 -> {
+                if (result.folderInfo != null) {
+                    this.createChildFolder(result.folderInfo)
+                } else {
+                    this.resolveFolder(name)
+                }
+            }
+            else -> {
+                // unexpected errors
+                error("Failed to create folder '$name': ${result.int32RetCode} ${result.clientWording}.")
+            }
+        } ?: error("Failed to create folder '$name': server returned success but failed to find folder.")
+    }
+
+    override suspend fun resolveFolder(name: String): AbsoluteFolder? {
+        if (name.isBlank()) throw IllegalArgumentException("folder name cannot be blank.")
+        if (!FileSystem.isLegal(name)) return null
+        return getItemsFlow().firstOrNull { it.folderInfo?.folderName == name }?.resolve() as AbsoluteFolder?
+    }
+
+    override suspend fun resolveFileById(id: String, deep: Boolean): AbsoluteFile? {
+        if (id == "/" || id.isEmpty()) throw IllegalArgumentException("Illegal file id: $id")
+        getItemsFlow().filter { it.fileInfo?.fileId == id }.map { it.resolve() as AbsoluteFile }.firstOrNull()
+            ?.let { return it }
+
+        if (!deep) return null
+
+        return folders().map { it.resolveFileById(id, deep) }.firstOrNull()
+    }
+
+    override suspend fun resolveFiles(path: String): Flow<AbsoluteFile> {
+        if (path.isBlank()) throw IllegalArgumentException("path cannot be blank.")
+        if (!FileSystem.isLegal(path)) return emptyFlow()
+
+        if (!path.contains('/')) {
+            return getItemsFlow().filter { it.fileInfo?.fileName == path }.map { it.resolve() as AbsoluteFile }
+        }
+
+        return resolveFolder(path.substringBefore('/'))?.resolveFiles(path.substringAfter('/')) ?: emptyFlow()
+    }
+
+    @OptIn(JavaFriendlyAPI::class)
+    override suspend fun resolveFilesStream(path: String): Stream<AbsoluteFile> {
+        if (path.isBlank()) throw IllegalArgumentException("path cannot be blank.")
+        if (!FileSystem.isLegal(path)) return Stream.empty()
+
+        if (!path.contains('/')) {
+            return getItemsSequence().filter { it.fileInfo?.fileName == path }.map { it.resolve() as AbsoluteFile }
+                .asStream()
+        }
+
+        return resolveFolder(path.substringBefore('/'))?.resolveFilesStream(path.substringAfter('/')) ?: Stream.empty()
+    }
+
+    override suspend fun resolveAll(path: String): Flow<AbsoluteFileFolder> {
+        if (path.isBlank()) throw IllegalArgumentException("path cannot be blank.")
+        if (!FileSystem.isLegal(path)) return emptyFlow()
+        if (!path.contains('/')) {
+            return getItemsFlow().mapNotNull { it.resolve() }
+        }
+
+        return resolveFolder(path.substringBefore('/'))?.resolveAll(path.substringAfter('/')) ?: emptyFlow()
+    }
+
+    @JavaFriendlyAPI
+    override suspend fun resolveAllStream(path: String): Stream<AbsoluteFileFolder> {
+        if (path.isBlank()) throw IllegalArgumentException("path cannot be blank.")
+        if (!FileSystem.isLegal(path)) return Stream.empty()
+        if (!path.contains('/')) {
+            return getItemsSequence().mapNotNull { it.resolve() }.asStream()
+        }
+
+        return resolveFolder(path.substringBefore('/'))?.resolveAllStream(path.substringAfter('/')) ?: Stream.empty()
+    }
+
+    override suspend fun uploadNewFile(
+        filepath: String,
+        content: ExternalResource,
+        callback: ProgressionCallback<AbsoluteFile, Long>?
+    ): AbsoluteFile {
+        val (actualFolder, actualFilename) = findFileByPath(filepath)
+        return uploadNewFileImpl(actualFolder.impl(), actualFilename, content, callback)
+    }
+
+    override suspend fun exists(): Boolean {
+        return parentOrFail().folders().firstOrNull { it.id == this.id } != null
+    }
+
+    override suspend fun refresh(): Boolean {
+        val new = refreshed() ?: return false
+        this.name = new.name
+        this.lastModifiedTime = new.lastModifiedTime
+        this.contentsCount = new.contentsCount
+        return true
+    }
+
+    override fun toString(): String = "AbsoluteFolder(name=$name, absolutePath=$absolutePath, id=$id)"
+
+    override suspend fun refreshed(): AbsoluteFolder? = parentOrRoot.folders().firstOrNull { it.id == this.id }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+        if (!super.equals(other)) return false
+
+        other as AbsoluteFolderImpl
+
+        if (contentsCount != other.contentsCount) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = super.hashCode()
+        result = 31 * result + contentsCount.hashCode()
+        return result
+    }
+}

+ 147 - 0
mirai-core/src/commonMain/kotlin/contact/file/AbstractAbsoluteFileFolder.kt

@@ -0,0 +1,147 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+@file:Suppress("MemberVisibilityCanBePrivate")
+
+package net.mamoe.mirai.internal.contact.file
+
+import net.mamoe.mirai.contact.FileSupported
+import net.mamoe.mirai.contact.PermissionDeniedException
+import net.mamoe.mirai.contact.file.AbsoluteFile
+import net.mamoe.mirai.contact.file.AbsoluteFileFolder
+import net.mamoe.mirai.contact.file.AbsoluteFolder
+import net.mamoe.mirai.internal.asQQAndroidBot
+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.FileSystem
+import net.mamoe.mirai.utils.cast
+
+internal fun AbstractAbsoluteFileFolder.api(): AbsoluteFileFolder = this.cast()
+internal fun AbsoluteFileFolder.impl(): AbstractAbsoluteFileFolder = this.cast()
+internal fun AbsoluteFile.impl(): AbsoluteFileImpl = this.cast()
+internal fun AbsoluteFolder.impl(): AbsoluteFolderImpl = this.cast()
+
+internal val AbsoluteFolder?.idOrRoot get() = this?.id ?: AbsoluteFolder.ROOT_FOLDER_ID
+
+internal val AbstractAbsoluteFileFolder.parentOrRoot get() = parent ?: contact.files.root
+
+/**
+ * @see AbsoluteFileFolder
+ */
+internal abstract class AbstractAbsoluteFileFolder(
+    // overriding AbsFileFolder
+    val contact: FileSupported,
+    var parent: AbsoluteFolder?,
+    val id: String, // uuid-like
+    var name: String,
+    val uploadTime: Long,
+    val uploaderId: Long,
+    var lastModifiedTime: Long,
+    // end
+
+    val busId: Int, // protocol internal
+) {
+    protected inline val bot get() = contact.bot.asQQAndroidBot()
+    protected inline val client get() = bot.client
+
+    protected abstract fun checkPermission(operationHint: String)
+
+    fun throwPermissionDeniedException(operationHint: String): Nothing {
+        throw PermissionDeniedException("Permission denied: '$operationHint' on file '${this.api().absolutePath}' requires an operator permission.")
+    }
+
+    protected fun parentOrFail() = parent ?: error("Cannot rename the root folder.")
+
+    ///////////////////////////////////////////////////////////////////////////
+    // overriding AbsFileFolder
+    ///////////////////////////////////////////////////////////////////////////
+
+    protected abstract val isFile: Boolean
+    protected abstract val isFolder: Boolean
+
+    suspend fun renameTo(newName: String): Boolean {
+        FileSystem.checkLegitimacy(newName)
+        parentOrFail()
+        checkPermission("renameTo")
+
+        val result = if (isFile) {
+            FileManagement.RenameFile(client, contact.id, busId, id, parent.idOrRoot, newName)
+        } else {
+            FileManagement.RenameFolder(client, contact.id, id, newName)
+        }.sendAndExpect(bot)
+
+        result.toResult("AbstractAbsoluteFileFolder.renameTo") {
+            when (it) {
+                0 -> {
+                    name = newName
+                    return true
+                }
+                1 -> return false
+                else -> false
+            }
+        }.getOrThrow()
+
+        error("unreachable")
+    }
+
+    suspend fun delete(): Boolean {
+        checkPermission("delete")
+        val result = if (isFile) {
+            FileManagement.DeleteFile(client, contact.id, busId, id, parent.idOrRoot).sendAndExpect(bot)
+        } else {
+            // natively 'recursive'
+            FileManagement.DeleteFolder(client, contact.id, id).sendAndExpect(bot)
+        }.toResult("AbstractAbsoluteFileFolder.delete", checkResp = false).getOrThrow()
+
+        return when (result.int32RetCode) {
+            -36 -> throwPermissionDeniedException("delete")
+            0 -> true
+            else -> {
+                // files not exists or other errors.
+                false
+            }
+        }
+    }
+
+    @Suppress("DuplicatedCode")
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as AbstractAbsoluteFileFolder
+
+        if (contact != other.contact) return false
+        if (parent != other.parent) return false
+        if (id != other.id) return false
+        if (name != other.name) return false
+        if (uploadTime != other.uploadTime) return false
+        if (uploaderId != other.uploaderId) return false
+        if (lastModifiedTime != other.lastModifiedTime) return false
+        if (busId != other.busId) return false
+        if (isFile != other.isFile) return false
+        if (isFolder != other.isFolder) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = contact.hashCode()
+        result = 31 * result + (parent?.hashCode() ?: 0)
+        result = 31 * result + id.hashCode()
+        result = 31 * result + name.hashCode()
+        result = 31 * result + uploadTime.hashCode()
+        result = 31 * result + uploaderId.hashCode()
+        result = 31 * result + lastModifiedTime.hashCode()
+        result = 31 * result + busId
+        result = 31 * result + isFile.hashCode()
+        result = 31 * result + isFolder.hashCode()
+        return result
+    }
+}

+ 31 - 0
mirai-core/src/commonMain/kotlin/contact/file/FileProtocol.kt

@@ -0,0 +1,31 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.internal.contact.file
+
+import net.mamoe.mirai.internal.network.QQAndroidClient
+import net.mamoe.mirai.internal.network.protocol.data.proto.Oidb0x6d6
+import net.mamoe.mirai.internal.network.protocol.packet.chat.CommonOidbResponse
+import net.mamoe.mirai.internal.utils.FileSystem
+
+/**
+ * Abstract protocol bridge for file management.
+ */
+internal interface FileProtocol {
+    val fs: FileSystem get() = FileSystem
+
+    fun renameFile(
+        client: QQAndroidClient,
+        groupCode: Long,
+        busId: Int,
+        fileId: String,
+        parentFolderId: String,
+        newName: String,
+    ): CommonOidbResponse<Oidb0x6d6.RenameFileRspBody>
+}

+ 48 - 0
mirai-core/src/commonMain/kotlin/contact/file/RemoteFilesImpl.kt

@@ -0,0 +1,48 @@
+/*
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.internal.contact.file
+
+import net.mamoe.mirai.contact.FileSupported
+import net.mamoe.mirai.contact.file.AbsoluteFolder
+import net.mamoe.mirai.contact.file.RemoteFiles
+import net.mamoe.mirai.internal.contact.ContactAware
+import net.mamoe.mirai.internal.utils.FileSystem
+
+internal class RemoteFilesImpl(
+    override val contact: FileSupported,
+    override val root: AbsoluteFolder = AbsoluteFolderImpl(
+        contact,
+        null,
+        AbsoluteFolder.ROOT_FOLDER_ID,
+        "/",
+        0,
+        0,
+        0,
+        0
+    ),
+) : RemoteFiles, ContactAware {
+    companion object {
+        suspend fun AbsoluteFolder.findFileByPath(path: String): Pair<AbsoluteFolder, String> {
+            if (path.isBlank()) throw IllegalArgumentException("absolutePath cannot be blank.")
+            val normalized = FileSystem.normalize(path)
+//            if (!normalized.contains('/')) {
+//                throw IllegalArgumentException("Invalid absolutePath: '$path'. If you wanted to upload file to root directory, please add a leading '/'.")
+//            }
+            val folder = when (normalized.count { it == '/' }) {
+                0, 1 -> this
+                else -> this.createFolder(normalized.substringBeforeLast("/"))
+            }
+
+            val filename = normalized.substringAfterLast('/')
+            return folder to filename
+        }
+    }
+
+}

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

@@ -10,9 +10,26 @@
 
 package net.mamoe.mirai.internal.message
 
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.onEach
 import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
+import net.mamoe.mirai.contact.FileSupported
+import net.mamoe.mirai.contact.file.AbsoluteFile
+import net.mamoe.mirai.contact.file.AbsoluteFolder
+import net.mamoe.mirai.internal.QQAndroidBot
+import net.mamoe.mirai.internal.asQQAndroidBot
+import net.mamoe.mirai.internal.contact.file.AbsoluteFolderImpl
+import net.mamoe.mirai.internal.contact.file.createChildFile
+import net.mamoe.mirai.internal.contact.file.impl
+import net.mamoe.mirai.internal.contact.file.resolved
+import net.mamoe.mirai.internal.network.protocol.data.proto.Oidb0x6d8.GetFileListRspBody
+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.message.data.FileMessage
+import net.mamoe.mirai.utils.cast
 import kotlin.contracts.contract
 
 internal fun FileMessage.checkIsImpl(): FileMessageImpl {
@@ -31,5 +48,41 @@ internal data class FileMessageImpl(
     override val internalId: Int
         get() = busId
 
+    override suspend fun toAbsoluteFile(contact: FileSupported): AbsoluteFile? {
+        val result = FileManagement.GetFileInfo(contact.bot.asQQAndroidBot().client, contact.id, id, busId)
+            .sendAndExpect(contact.bot.asQQAndroidBot())
+            .toResult("FileMessage.toAbsoluteFile").getOrThrow()
+        if (result.fileInfo == null) return null
+
+        // Get its parent AbsoluteFolder
+        // This is necessary for properties like creationTime.
+        // Maybe we can optimize it in the future (i.e. make it lazy?)
+
+        val root = contact.files.root.impl()
+        val folder = if (result.fileInfo.parentFolderId == AbsoluteFolder.ROOT_FOLDER_ID) {
+            root
+        } else {
+            val folders = ArrayList<GetFileListRspBody.Item>()
+            root.impl().getItemsFlow()
+                .filter { it.folderInfo != null }
+                .onEach { folders.add(it) }
+                .firstOrNull { it.folderInfo?.folderId == result.fileInfo.parentFolderId }
+                ?.resolved(root) as AbsoluteFolderImpl?
+                ?: kotlin.run {
+                    for (folder in folders) {
+                        AbsoluteFolderImpl.getItemsFlow(
+                            (contact.bot as QQAndroidBot).client,
+                            contact,
+                            folder.folderInfo!!.folderId
+                        ).firstOrNull { it.folderInfo?.folderId == result.fileInfo.parentFolderId }
+                            ?.resolved(root)?.cast<AbsoluteFolderImpl?>()?.let { return@run it }
+                    }
+                    root
+                }
+        }
+
+        return folder.createChildFile(result.fileInfo)
+    }
+
     override fun toString(): String = "[mirai:file:$name,$id,$size,$busId]"
 }

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

@@ -73,6 +73,28 @@ internal inline fun <T> CommonOidbResponse<T>.toResult(actionName: String, check
     }
 }
 
+@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE", "RESULT_CLASS_IN_RETURN_TYPE")
+@kotlin.internal.InlineOnly
+internal inline fun <T> CommonOidbResponse<T>.toResult(
+    actionName: String,
+    checkResp: CheckableStruct.(Int) -> Boolean
+): Result<T> {
+    return if (this is CommonOidbResponse.Failure) {
+        Result.failure(this.createException(actionName))
+    } else {
+        this as CommonOidbResponse.Success<T>
+        val result = this.resp
+        if (result is CheckableStruct) {
+            if (!checkResp(
+                    result,
+                    result.int32RetCode
+                )
+            ) 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
  */

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

@@ -7,6 +7,8 @@
  * https://github.com/mamoe/mirai/blob/dev/LICENSE
  */
 
+@file:Suppress("DEPRECATION", "OverridingDeprecatedMember")
+
 package net.mamoe.mirai.internal.utils
 
 import kotlinx.coroutines.flow.*
@@ -46,6 +48,10 @@ internal object FileSystem {
         }
     }
 
+    fun isLegal(path: String): Boolean {
+        return path.firstOrNull { it in """:*?"<>|""" } == null
+    }
+
     fun normalize(path: String): String {
         checkLegitimacy(path)
         return path.replace('\\', '/')