From bd041e43d13fd214b4cbddc198387047231bead8 Mon Sep 17 00:00:00 2001
From: Him188 <Him188@mamoe.net>
Date: Tue, 16 Nov 2021 10:50:17 +0000
Subject: [PATCH] Support `Image.isUploaded`, (#1671)

* Support `Image.isUploaded`,
add member `Image.md5`,
add `Image.calculateImageMd5ByImageId`,
close #1401

* Update docs
---
 ...binary-compatibility-validator-android.api |  13 +-
 .../api/binary-compatibility-validator.api    |  13 +-
 .../commonMain/kotlin/message/data/Image.kt   | 121 +++++++++++--
 .../commonMain/kotlin/message/data/impl.kt    |  16 --
 .../kotlin/message.data/ImageTest.kt          |  14 +-
 .../message/InternalImageProtocolImpl.kt      | 159 ++++++++++++++++++
 ...e.mirai.message.data.InternalImageProtocol |  10 ++
 .../message/InternalImageProtocolImplTest.kt  |  27 +++
 8 files changed, 332 insertions(+), 41 deletions(-)
 create mode 100644 mirai-core/src/commonMain/kotlin/message/InternalImageProtocolImpl.kt
 create mode 100644 mirai-core/src/commonMain/resources/META-INF/services/net.mamoe.mirai.message.data.InternalImageProtocol
 create mode 100644 mirai-core/src/commonTest/kotlin/message/InternalImageProtocolImplTest.kt

diff --git a/binary-compatibility-validator/android/api/binary-compatibility-validator-android.api b/binary-compatibility-validator/android/api/binary-compatibility-validator-android.api
index d6f64b16c..1518f082d 100644
--- a/binary-compatibility-validator/android/api/binary-compatibility-validator-android.api
+++ b/binary-compatibility-validator/android/api/binary-compatibility-validator-android.api
@@ -4445,9 +4445,14 @@ public abstract interface class net/mamoe/mirai/message/data/Image : net/mamoe/m
 	public static fun getImageResourceIdRegex1 ()Lkotlin/text/Regex;
 	public static fun getImageResourceIdRegex2 ()Lkotlin/text/Regex;
 	public abstract fun getImageType ()Lnet/mamoe/mirai/message/data/ImageType;
+	public fun getMd5 ()[B
 	public abstract fun getSize ()J
 	public abstract fun getWidth ()I
 	public fun isEmoji ()Z
+	public static fun isUploaded (Lnet/mamoe/mirai/Bot;[BJ)Z
+	public static fun isUploaded (Lnet/mamoe/mirai/Bot;[BJLkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static fun isUploaded (Lnet/mamoe/mirai/message/data/Image;Lnet/mamoe/mirai/Bot;)Z
+	public static fun isUploaded (Lnet/mamoe/mirai/message/data/Image;Lnet/mamoe/mirai/Bot;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public static fun queryUrl (Lnet/mamoe/mirai/message/data/Image;)Ljava/lang/String;
 	public static fun queryUrl (Lnet/mamoe/mirai/message/data/Image;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
@@ -4463,10 +4468,15 @@ public final class net/mamoe/mirai/message/data/Image$AsStringSerializer : kotli
 
 public final class net/mamoe/mirai/message/data/Image$Key : net/mamoe/mirai/message/data/AbstractMessageKey {
 	public static final field SERIAL_NAME Ljava/lang/String;
+	public final fun calculateImageMd5ByImageId (Ljava/lang/String;)[B
 	public final fun fromId (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/Image;
 	public final fun getImageIdRegex ()Lkotlin/text/Regex;
 	public final fun getImageResourceIdRegex1 ()Lkotlin/text/Regex;
 	public final fun getImageResourceIdRegex2 ()Lkotlin/text/Regex;
+	public final fun isUploaded (Lnet/mamoe/mirai/Bot;[BJ)Z
+	public final fun isUploaded (Lnet/mamoe/mirai/Bot;[BJLkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public final fun isUploaded (Lnet/mamoe/mirai/message/data/Image;Lnet/mamoe/mirai/Bot;)Z
+	public final fun isUploaded (Lnet/mamoe/mirai/message/data/Image;Lnet/mamoe/mirai/Bot;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public final fun queryUrl (Lnet/mamoe/mirai/message/data/Image;)Ljava/lang/String;
 	public final fun queryUrl (Lnet/mamoe/mirai/message/data/Image;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
@@ -4488,6 +4498,7 @@ public final class net/mamoe/mirai/message/data/ImageType : java/lang/Enum {
 	public static final field JPG Lnet/mamoe/mirai/message/data/ImageType;
 	public static final field PNG Lnet/mamoe/mirai/message/data/ImageType;
 	public static final field UNKNOWN Lnet/mamoe/mirai/message/data/ImageType;
+	public final fun getFormatName ()Ljava/lang/String;
 	public static final fun match (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/ImageType;
 	public static final fun matchOrNull (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/ImageType;
 	public static fun valueOf (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/ImageType;
@@ -4853,7 +4864,7 @@ public final class net/mamoe/mirai/message/data/MessageUtils {
 	public static final fun buildMessageChain (Lkotlin/jvm/functions/Function1;)Lnet/mamoe/mirai/message/data/MessageChain;
 	public static final synthetic fun buildMessageSource (Lnet/mamoe/mirai/Bot;Lnet/mamoe/mirai/message/data/MessageSourceKind;Lkotlin/jvm/functions/Function1;)Lnet/mamoe/mirai/message/data/OfflineMessageSource;
 	public static final synthetic fun buildMessageSource (Lnet/mamoe/mirai/IMirai;JLnet/mamoe/mirai/message/data/MessageSourceKind;Lkotlin/jvm/functions/Function1;)Lnet/mamoe/mirai/message/data/OfflineMessageSource;
-	public static final fun calculateImageMd5 (Lnet/mamoe/mirai/message/data/Image;)[B
+	public static final synthetic fun calculateImageMd5 (Lnet/mamoe/mirai/message/data/Image;)[B
 	public static final fun contentsList (Lnet/mamoe/mirai/message/data/MessageChain;)Ljava/util/List;
 	public static final synthetic fun contentsSequence (Lnet/mamoe/mirai/message/data/MessageChain;)Lkotlin/sequences/Sequence;
 	public static final fun copySource (Lnet/mamoe/mirai/message/data/MessageSource;Lkotlin/jvm/functions/Function1;)Lnet/mamoe/mirai/message/data/OfflineMessageSource;
diff --git a/binary-compatibility-validator/api/binary-compatibility-validator.api b/binary-compatibility-validator/api/binary-compatibility-validator.api
index 371a7128f..ed909966c 100644
--- a/binary-compatibility-validator/api/binary-compatibility-validator.api
+++ b/binary-compatibility-validator/api/binary-compatibility-validator.api
@@ -4445,9 +4445,14 @@ public abstract interface class net/mamoe/mirai/message/data/Image : net/mamoe/m
 	public static fun getImageResourceIdRegex1 ()Lkotlin/text/Regex;
 	public static fun getImageResourceIdRegex2 ()Lkotlin/text/Regex;
 	public abstract fun getImageType ()Lnet/mamoe/mirai/message/data/ImageType;
+	public fun getMd5 ()[B
 	public abstract fun getSize ()J
 	public abstract fun getWidth ()I
 	public fun isEmoji ()Z
+	public static fun isUploaded (Lnet/mamoe/mirai/Bot;[BJ)Z
+	public static fun isUploaded (Lnet/mamoe/mirai/Bot;[BJLkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public static fun isUploaded (Lnet/mamoe/mirai/message/data/Image;Lnet/mamoe/mirai/Bot;)Z
+	public static fun isUploaded (Lnet/mamoe/mirai/message/data/Image;Lnet/mamoe/mirai/Bot;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public static fun queryUrl (Lnet/mamoe/mirai/message/data/Image;)Ljava/lang/String;
 	public static fun queryUrl (Lnet/mamoe/mirai/message/data/Image;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
@@ -4463,10 +4468,15 @@ public final class net/mamoe/mirai/message/data/Image$AsStringSerializer : kotli
 
 public final class net/mamoe/mirai/message/data/Image$Key : net/mamoe/mirai/message/data/AbstractMessageKey {
 	public static final field SERIAL_NAME Ljava/lang/String;
+	public final fun calculateImageMd5ByImageId (Ljava/lang/String;)[B
 	public final fun fromId (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/Image;
 	public final fun getImageIdRegex ()Lkotlin/text/Regex;
 	public final fun getImageResourceIdRegex1 ()Lkotlin/text/Regex;
 	public final fun getImageResourceIdRegex2 ()Lkotlin/text/Regex;
+	public final fun isUploaded (Lnet/mamoe/mirai/Bot;[BJ)Z
+	public final fun isUploaded (Lnet/mamoe/mirai/Bot;[BJLkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public final fun isUploaded (Lnet/mamoe/mirai/message/data/Image;Lnet/mamoe/mirai/Bot;)Z
+	public final fun isUploaded (Lnet/mamoe/mirai/message/data/Image;Lnet/mamoe/mirai/Bot;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public final fun queryUrl (Lnet/mamoe/mirai/message/data/Image;)Ljava/lang/String;
 	public final fun queryUrl (Lnet/mamoe/mirai/message/data/Image;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
@@ -4488,6 +4498,7 @@ public final class net/mamoe/mirai/message/data/ImageType : java/lang/Enum {
 	public static final field JPG Lnet/mamoe/mirai/message/data/ImageType;
 	public static final field PNG Lnet/mamoe/mirai/message/data/ImageType;
 	public static final field UNKNOWN Lnet/mamoe/mirai/message/data/ImageType;
+	public final fun getFormatName ()Ljava/lang/String;
 	public static final fun match (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/ImageType;
 	public static final fun matchOrNull (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/ImageType;
 	public static fun valueOf (Ljava/lang/String;)Lnet/mamoe/mirai/message/data/ImageType;
@@ -4853,7 +4864,7 @@ public final class net/mamoe/mirai/message/data/MessageUtils {
 	public static final fun buildMessageChain (Lkotlin/jvm/functions/Function1;)Lnet/mamoe/mirai/message/data/MessageChain;
 	public static final synthetic fun buildMessageSource (Lnet/mamoe/mirai/Bot;Lnet/mamoe/mirai/message/data/MessageSourceKind;Lkotlin/jvm/functions/Function1;)Lnet/mamoe/mirai/message/data/OfflineMessageSource;
 	public static final synthetic fun buildMessageSource (Lnet/mamoe/mirai/IMirai;JLnet/mamoe/mirai/message/data/MessageSourceKind;Lkotlin/jvm/functions/Function1;)Lnet/mamoe/mirai/message/data/OfflineMessageSource;
-	public static final fun calculateImageMd5 (Lnet/mamoe/mirai/message/data/Image;)[B
+	public static final synthetic fun calculateImageMd5 (Lnet/mamoe/mirai/message/data/Image;)[B
 	public static final fun contentsList (Lnet/mamoe/mirai/message/data/MessageChain;)Ljava/util/List;
 	public static final synthetic fun contentsSequence (Lnet/mamoe/mirai/message/data/MessageChain;)Lkotlin/sequences/Sequence;
 	public static final fun copySource (Lnet/mamoe/mirai/message/data/MessageSource;Lkotlin/jvm/functions/Function1;)Lnet/mamoe/mirai/message/data/OfflineMessageSource;
diff --git a/mirai-core-api/src/commonMain/kotlin/message/data/Image.kt b/mirai-core-api/src/commonMain/kotlin/message/data/Image.kt
index 928761ef2..2725ae560 100644
--- a/mirai-core-api/src/commonMain/kotlin/message/data/Image.kt
+++ b/mirai-core-api/src/commonMain/kotlin/message/data/Image.kt
@@ -100,7 +100,7 @@ public interface Image : Message, MessageContent, CodableMessage {
     public val height: Int
 
     /**
-     * 图片的大小(字节), 当无法获取时为 0
+     * 图片的大小(字节), 当无法获取时为 0. 可用于 [isUploaded].
      *
      * @since 2.8.0
      */
@@ -122,6 +122,15 @@ public interface Image : Message, MessageContent, CodableMessage {
      */
     public val isEmoji: Boolean get() = false
 
+    /**
+     * 图片文件 MD5. 可用于 [isUploaded].
+     *
+     * @return 16 bytes
+     * @see isUploaded
+     * @since 2.9.0
+     */ // was an extension on Image before 2.9.0-M1.
+    public val md5: ByteArray get() = calculateImageMd5ByImageId(imageId)
+
     public object AsStringSerializer : KSerializer<Image> by String.serializer().mapPrimitive(
         SERIAL_NAME,
         serialize = { imageId },
@@ -143,6 +152,7 @@ public interface Image : Message, MessageContent, CodableMessage {
         )
     }
 
+    @JvmBlockingBridge
     public companion object Key : AbstractMessageKey<Image>({ it.safeCast() }) {
         public const val SERIAL_NAME: String = "Image"
 
@@ -166,12 +176,56 @@ public interface Image : Message, MessageContent, CodableMessage {
          * @throws IllegalStateException 当无任何 [Bot] 在线时抛出 (因为无法获取相关协议)
          */
         @JvmStatic
-        @JvmBlockingBridge
         public suspend fun Image.queryUrl(): String {
             val bot = Bot.instancesSequence.firstOrNull() ?: error("No Bot available to query image url")
             return Mirai.queryImageUrl(bot, this)
         }
 
+        /**
+         * 当图片在服务器上存在时返回 `true`, 这意味着图片可以直接发送给 [contact].
+         *
+         * 若返回 `false`, 则图片需要用 [ExternalResource] 重新上传 ([Contact.uploadImage]).
+         *
+         * @since 2.9.0
+         */
+        @JvmStatic
+        public suspend fun Image.isUploaded(bot: Bot): Boolean =
+            InternalImageProtocol.instance.isUploaded(bot, md5, size, null, imageType, width, height)
+
+        /**
+         * 当图片在服务器上存在时返回 `true`, 这意味着图片可以直接发送给 [contact].
+         *
+         * 若返回 `false`, 则图片需要用 [ExternalResource] 重新上传 ([Contact.uploadImage]).
+         *
+         * @param md5 图片文件 MD5. 可通过 [Image.md5] 获得.
+         * @param size 图片文件大小.
+         *
+         * @since 2.9.0
+         */
+        @JvmStatic
+        public suspend fun isUploaded(
+            bot: Bot,
+            md5: ByteArray,
+            size: Long,
+        ): Boolean = InternalImageProtocol.instance.isUploaded(bot, md5, size, null)
+
+        /**
+         * 由 [Image.imageId] 计算 [Image.md5].
+         *
+         * @since 2.9.0
+         */
+        public fun calculateImageMd5ByImageId(imageId: String): ByteArray {
+            @Suppress("DEPRECATION")
+            return when {
+                imageId matches IMAGE_ID_REGEX -> imageId.imageIdToMd5(1)
+                imageId matches IMAGE_RESOURCE_ID_REGEX_2 -> imageId.imageIdToMd5(imageId.skipToSecondHyphen() + 1)
+                imageId matches IMAGE_RESOURCE_ID_REGEX_1 -> imageId.imageIdToMd5(1)
+
+                else -> throw IllegalArgumentException(
+                    "Illegal imageId: '$imageId'. $ILLEGAL_IMAGE_ID_EXCEPTION_MESSAGE"
+                )
+            }
+        }
 
         /**
          * 统一 ID 正则表达式
@@ -222,17 +276,24 @@ public interface Image : Message, MessageContent, CodableMessage {
 @JvmSynthetic
 public inline fun Image(imageId: String): Image = Image.fromId(imageId)
 
-public enum class ImageType {
-    PNG,
-    BMP,
-    JPG,
-    GIF,
+public enum class ImageType(
+    /**
+     * @since 2.9.0
+     */
+    @MiraiInternalApi public val formatName: String,
+) {
+    PNG("png"),
+    BMP("bmp"),
+    JPG("jpg"),
+    GIF("gif"),
+
     //WEBP, //Unsupported by pc client
-    APNG,
-    UNKNOWN;
+    APNG("png"),
+    UNKNOWN("gif"); // bad design, should use `null` to represent unknown, but we cannot change it anymore.
 
     public companion object {
         private val IMAGE_TYPE_ENUM_LIST = values()
+
         @JvmStatic
         public fun match(str: String): ImageType {
             return matchOrNull(str) ?: UNKNOWN
@@ -250,15 +311,12 @@ public enum class ImageType {
 // Internals
 ///////////////////////////////////////////////////////////////////////////
 
-/**
- * 计算图片的 md5 校验值.
- *
- * 在 Java 使用: `MessageUtils.calculateImageMd5(image)`
- */
+@Deprecated("Use member function", level = DeprecationLevel.HIDDEN) // safe since it was internal
+@Suppress("EXTENSION_SHADOWED_BY_MEMBER")
 @MiraiInternalApi
 @get:JvmName("calculateImageMd5")
 public val Image.md5: ByteArray
-    get() = calculateImageMd5ByImageId(imageId)
+    get() = Image.calculateImageMd5ByImageId(imageId)
 
 
 /**
@@ -320,4 +378,35 @@ public abstract class FriendImage @MiraiInternalApi public constructor() :
 public abstract class GroupImage @MiraiInternalApi public constructor() :
     AbstractImage() { // change to sealed in the future.
     public companion object
-}
\ No newline at end of file
+}
+
+
+/**
+ * 内部图片协议实现
+ * @since 2.9.0-M1
+ */
+@MiraiInternalApi
+public interface InternalImageProtocol { // naming it Internal* to assign it a lower priority when resolving Image*
+    /**
+     * @param context 用于检查的 [Contact]. 群图片与好友图片是两个通道, 建议使用欲发送到的 [Contact] 对象作为 [contact] 参数, 但目前不提供此参数时也可以检查.
+     */
+    public suspend fun isUploaded(
+        bot: Bot,
+        md5: ByteArray,
+        size: Long,
+        context: Contact? = null,
+        type: ImageType = ImageType.UNKNOWN,
+        width: Int = 0,
+        height: Int = 0
+    ): Boolean
+
+    @MiraiInternalApi
+    public companion object {
+        public val instance: InternalImageProtocol by lazy {
+            loadService(
+                InternalImageProtocol::class,
+                "net.mamoe.mirai.internal.message.InternalImageProtocolImpl"
+            )
+        }
+    }
+}
diff --git a/mirai-core-api/src/commonMain/kotlin/message/data/impl.kt b/mirai-core-api/src/commonMain/kotlin/message/data/impl.kt
index 6ba0510a6..d852cc37f 100644
--- a/mirai-core-api/src/commonMain/kotlin/message/data/impl.kt
+++ b/mirai-core-api/src/commonMain/kotlin/message/data/impl.kt
@@ -20,7 +20,6 @@ import net.mamoe.mirai.message.data.Image.Key.IMAGE_RESOURCE_ID_REGEX_2
 import net.mamoe.mirai.utils.MiraiExperimentalApi
 import net.mamoe.mirai.utils.asImmutable
 import net.mamoe.mirai.utils.replaceAllKotlin
-import kotlin.native.concurrent.SharedImmutable
 
 // region image
 
@@ -201,7 +200,6 @@ internal fun MessageChainImplBySequence(
 //////////////////////
 
 
-@SharedImmutable
 @get:JvmSynthetic
 internal val EMPTY_BYTE_ARRAY = ByteArray(0)
 
@@ -247,20 +245,6 @@ internal fun String.imageIdToMd5(offset: Int): ByteArray {
     error("Internal error: failed imageIdToMd5, no enough chars. Input=$this, offset=$offset")
 }
 
-@OptIn(ExperimentalStdlibApi::class)
-internal fun calculateImageMd5ByImageId(imageId: String): ByteArray {
-    @Suppress("DEPRECATION")
-    return when {
-        imageId matches IMAGE_ID_REGEX -> imageId.imageIdToMd5(1)
-        imageId matches IMAGE_RESOURCE_ID_REGEX_2 -> imageId.imageIdToMd5(imageId.skipToSecondHyphen() + 1)
-        imageId matches IMAGE_RESOURCE_ID_REGEX_1 -> imageId.imageIdToMd5(1)
-
-        else -> error(
-            "illegal imageId: $imageId. $ILLEGAL_IMAGE_ID_EXCEPTION_MESSAGE"
-        )
-    }
-}
-
 internal val ILLEGAL_IMAGE_ID_EXCEPTION_MESSAGE: String =
     "ImageId must match Regex `${IMAGE_RESOURCE_ID_REGEX_1.pattern}`, " +
             "`${IMAGE_RESOURCE_ID_REGEX_2.pattern}` or " +
diff --git a/mirai-core-api/src/commonTest/kotlin/message.data/ImageTest.kt b/mirai-core-api/src/commonTest/kotlin/message.data/ImageTest.kt
index 084a71cca..14921ad40 100644
--- a/mirai-core-api/src/commonTest/kotlin/message.data/ImageTest.kt
+++ b/mirai-core-api/src/commonTest/kotlin/message.data/ImageTest.kt
@@ -1,10 +1,10 @@
 /*
- * Copyright 2019-2020 Mamoe Technologies and contributors.
+ * Copyright 2019-2021 Mamoe Technologies and contributors.
  *
- *  此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
- *  Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
  *
- *  https://github.com/mamoe/mirai/blob/master/LICENSE
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
  */
 @file:Suppress("EXPERIMENTAL_API_USAGE")
 
@@ -35,17 +35,17 @@ internal class ImageTest {
     fun testCalculateImageMd5ByImageId() {
         assertEquals(
             "01E9451B-70ED-EAE3-B37C-101F1EEBF5B5".filterNot { it == '-' }.autoHexToBytes().contentToString(),
-            calculateImageMd5ByImageId("{01E9451B-70ED-EAE3-B37C-101F1EEBF5B5}.mirai").contentToString()
+            Image.calculateImageMd5ByImageId("{01E9451B-70ED-EAE3-B37C-101F1EEBF5B5}.mirai").contentToString()
         )
 
         assertEquals(
             "f8f1ab55-bf8e-4236-b55e-955848d7069f".filterNot { it == '-' }.autoHexToBytes().contentToString(),
-            calculateImageMd5ByImageId("/f8f1ab55-bf8e-4236-b55e-955848d7069f").contentToString()
+            Image.calculateImageMd5ByImageId("/f8f1ab55-bf8e-4236-b55e-955848d7069f").contentToString()
         )
 
         assertEquals(
             "BFB7027B9354B8F899A062061D74E206".filterNot { it == '-' }.autoHexToBytes().contentToString(),
-            calculateImageMd5ByImageId("/000000000-3814297509-BFB7027B9354B8F899A062061D74E206").contentToString()
+            Image.calculateImageMd5ByImageId("/000000000-3814297509-BFB7027B9354B8F899A062061D74E206").contentToString()
         )
 
     }
diff --git a/mirai-core/src/commonMain/kotlin/message/InternalImageProtocolImpl.kt b/mirai-core/src/commonMain/kotlin/message/InternalImageProtocolImpl.kt
new file mode 100644
index 000000000..373e41e49
--- /dev/null
+++ b/mirai-core/src/commonMain/kotlin/message/InternalImageProtocolImpl.kt
@@ -0,0 +1,159 @@
+/*
+ * 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.message
+
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.contact.Contact
+import net.mamoe.mirai.contact.Group
+import net.mamoe.mirai.contact.User
+import net.mamoe.mirai.internal.QQAndroidBot
+import net.mamoe.mirai.internal.asQQAndroidBot
+import net.mamoe.mirai.internal.network.protocol.data.proto.Cmd0x352
+import net.mamoe.mirai.internal.network.protocol.packet.chat.image.ImgStore
+import net.mamoe.mirai.internal.network.protocol.packet.chat.image.LongConn
+import net.mamoe.mirai.internal.network.protocol.packet.sendAndExpect
+import net.mamoe.mirai.message.data.ImageType
+import net.mamoe.mirai.message.data.InternalImageProtocol
+import net.mamoe.mirai.utils.cast
+import net.mamoe.mirai.utils.toUHexString
+
+internal class InternalImageProtocolImpl : InternalImageProtocol {
+
+    /**
+     * Test Notes:
+     *
+     * - 查图片只需要 md5 和 size
+     * - 上传给群的图片可以通过 GroupPicUp(groupCode=user.id) 或 OffPicUp(dstUin=user.id) 查询
+     * - 上传给好友的图片可以通过 GroupPicUp(groupCode=group.id) 或 OffPicUp(dstUin=group.id) 查询
+     */
+    fun interface ImageUploadedChecker<C : Contact?> {
+        suspend fun isUploaded(
+            bot: QQAndroidBot,
+            context: C,
+            md5: ByteArray,
+            type: ImageType,
+            size: Long,
+            width: Int,
+            height: Int
+        ): Boolean
+
+        companion object {
+            val checkers = mapOf(
+                Group::class to ImageUploadedCheckerGroup(),
+                User::class to ImageUploadedCheckerUser(),
+                null to ImageUploadedCheckerFallback()
+            )
+        }
+    }
+
+    class ImageUploadedCheckerGroup : ImageUploadedChecker<Group> {
+        override suspend fun isUploaded(
+            bot: QQAndroidBot,
+            context: Group,
+            md5: ByteArray,
+            type: ImageType,
+            size: Long,
+            width: Int,
+            height: Int
+        ): Boolean {
+            val response: ImgStore.GroupPicUp.Response = ImgStore.GroupPicUp(
+                bot.client,
+                uin = bot.id,
+                groupCode = context.id,
+                md5 = md5,
+                size = size,
+                filename = "${md5.toUHexString("")}.${type.formatName}",
+                picWidth = width,
+                picHeight = height,
+                picType = getIdByImageType(type),
+                originalPic = 1
+            ).sendAndExpect(bot)
+
+            return response is ImgStore.GroupPicUp.Response.FileExists
+        }
+    }
+
+    class ImageUploadedCheckerUser : ImageUploadedChecker<User> {
+        override suspend fun isUploaded(
+            bot: QQAndroidBot,
+            context: User,
+            md5: ByteArray,
+            type: ImageType,
+            size: Long,
+            width: Int,
+            height: Int
+        ): Boolean {
+            val resp = LongConn.OffPicUp(
+                bot.client,
+                Cmd0x352.TryUpImgReq(
+                    buType = 1,
+                    srcUin = bot.id,
+                    dstUin = context.id,
+                    fileMd5 = md5,
+                    fileSize = size,
+                    imgWidth = width,
+                    imgHeight = height,
+                    imgType = getIdByImageType(type),
+                    fileName = "${md5.toUHexString("")}.${type.formatName}",
+                    imgOriginal = true,
+                    buildVer = bot.client.buildVer,
+                ),
+            ).sendAndExpect<LongConn.OffPicUp.Response>(bot)
+
+            return resp is LongConn.OffPicUp.Response.FileExists
+        }
+    }
+
+    class ImageUploadedCheckerFallback : ImageUploadedChecker<Nothing?> {
+        override suspend fun isUploaded(
+            bot: QQAndroidBot,
+            context: Nothing?,
+            md5: ByteArray,
+            type: ImageType,
+            size: Long,
+            width: Int,
+            height: Int
+        ): Boolean {
+            val response: ImgStore.GroupPicUp.Response = ImgStore.GroupPicUp(
+                bot.client,
+                uin = bot.id,
+                groupCode = 1,
+                md5 = md5,
+                size = size,
+                filename = "${md5.toUHexString("")}.${type.formatName}",
+                picWidth = width,
+                picHeight = height,
+                picType = getIdByImageType(type),
+                originalPic = 1
+            ).sendAndExpect(bot)
+
+            return response is ImgStore.GroupPicUp.Response.FileExists
+        }
+    }
+
+    override suspend fun isUploaded(
+        bot: Bot,
+        md5: ByteArray,
+        size: Long,
+        context: Contact?,
+        type: ImageType,
+        width: Int,
+        height: Int
+    ): Boolean {
+        val checker = findChecker(context) ?: return false
+        checker.cast<ImageUploadedChecker<Contact?>>()
+        return checker.isUploaded(bot.asQQAndroidBot(), context.cast<Contact?>(), md5, type, size, width, height)
+    }
+
+    fun findChecker(context: Contact?) = ImageUploadedChecker.checkers.asSequence()
+        .find { bothNull(it.key, context) || it.key?.isInstance(context) == true }?.value
+
+    private fun bothNull(a: Any?, b: Any?) = a == null && b == null
+}
\ No newline at end of file
diff --git a/mirai-core/src/commonMain/resources/META-INF/services/net.mamoe.mirai.message.data.InternalImageProtocol b/mirai-core/src/commonMain/resources/META-INF/services/net.mamoe.mirai.message.data.InternalImageProtocol
new file mode 100644
index 000000000..7d468c002
--- /dev/null
+++ b/mirai-core/src/commonMain/resources/META-INF/services/net.mamoe.mirai.message.data.InternalImageProtocol
@@ -0,0 +1,10 @@
+#
+# 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
+#
+
+net.mamoe.mirai.internal.message.InternalImageProtocolImpl
\ No newline at end of file
diff --git a/mirai-core/src/commonTest/kotlin/message/InternalImageProtocolImplTest.kt b/mirai-core/src/commonTest/kotlin/message/InternalImageProtocolImplTest.kt
new file mode 100644
index 000000000..c05fccfcb
--- /dev/null
+++ b/mirai-core/src/commonTest/kotlin/message/InternalImageProtocolImplTest.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.message
+
+import net.mamoe.mirai.internal.notice.processors.AbstractNoticeProcessorTest
+import kotlin.test.Test
+import kotlin.test.assertIs
+
+internal class InternalImageProtocolImplTest : AbstractNoticeProcessorTest() { // borrow Bot testkit
+    val instance = InternalImageProtocolImpl()
+
+    @Test
+    fun testFindChecker() {
+        assertIs<InternalImageProtocolImpl.ImageUploadedCheckerGroup>(instance.findChecker(setBot(1).addGroup(2, 3)))
+        assertIs<InternalImageProtocolImpl.ImageUploadedCheckerUser>(instance.findChecker(setBot(1).addFriend(2)))
+        assertIs<InternalImageProtocolImpl.ImageUploadedCheckerFallback>(instance.findChecker(null))
+
+        // these 3 tests are complete -- no need to add more when adding more checkers.
+    }
+}
\ No newline at end of file