Introduce FileCacheStrategy;

Rework `ExternalImage`, introduce `ReusableInput` for multiple attempts when uploading images;
Add `BotConfiguration.fileCacheStrategy`, defaults use cache system from host OS;
Introduce `DeferredReusableInput` for `*.toExternalImage` on JVM.
Deprecate `*.suspendToExternalImage` as no longer need to be suspend.
Open input only when required, close input after uploading files, fix #302
This commit is contained in:
Him188 2020-05-05 16:09:32 +08:00
parent 96a5825283
commit 2d9db234d7
17 changed files with 496 additions and 290 deletions

View File

@ -87,6 +87,10 @@ internal class FriendImpl(
@JvmSynthetic
@OptIn(MiraiInternalAPI::class, ExperimentalStdlibApi::class, ExperimentalTime::class)
override suspend fun uploadImage(image: ExternalImage): Image = try {
@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
if (image.input is net.mamoe.mirai.utils.internal.DeferredReusableInput) {
image.input.init(bot.configuration.fileCacheStrategy)
}
if (BeforeImageUploadEvent(this, image).broadcast().isCancelled) {
throw EventCancelledException("cancelled by BeforeImageUploadEvent.ToGroup")
}
@ -96,10 +100,10 @@ internal class FriendImpl(
srcUin = bot.id.toInt(),
dstUin = id.toInt(),
fileId = 0,
fileMd5 = image.md5,
fileMd5 = @Suppress("INVISIBLE_MEMBER") image.md5,
fileSize = @Suppress("INVISIBLE_MEMBER")
image.input.size.toInt(),
fileName = image.md5.toUHexString("") + "." + ExternalImage.defaultFormatName,
fileName = @Suppress("INVISIBLE_MEMBER") image.md5.toUHexString("") + "." + ExternalImage.defaultFormatName,
imgOriginal = 1
)
).sendAndExpect<LongConn.OffPicUp.Response>()

View File

@ -406,6 +406,10 @@ internal class GroupImpl(
@OptIn(ExperimentalTime::class)
@JvmSynthetic
override suspend fun uploadImage(image: ExternalImage): OfflineGroupImage = try {
@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
if (image.input is net.mamoe.mirai.utils.internal.DeferredReusableInput) {
image.input.init(bot.configuration.fileCacheStrategy)
}
if (BeforeImageUploadEvent(this, image).broadcast().isCancelled) {
throw EventCancelledException("cancelled by BeforeImageUploadEvent.ToGroup")
}

View File

@ -7,6 +7,8 @@
* https://github.com/mamoe/mirai/blob/master/LICENSE
*/
@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
package net.mamoe.mirai.qqandroid.network.highway
import io.ktor.client.HttpClient
@ -34,9 +36,9 @@ import net.mamoe.mirai.qqandroid.utils.addSuppressedMirai
import net.mamoe.mirai.qqandroid.utils.io.serialization.readProtoBuf
import net.mamoe.mirai.qqandroid.utils.io.withUse
import net.mamoe.mirai.qqandroid.utils.toIpV4AddressString
import net.mamoe.mirai.utils.ExternalImage
import net.mamoe.mirai.utils.MiraiExperimentalAPI
import net.mamoe.mirai.utils.MiraiInternalAPI
import net.mamoe.mirai.utils.internal.ReusableInput
import net.mamoe.mirai.utils.verbose
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.math.roundToInt
@ -44,12 +46,12 @@ import kotlin.time.ExperimentalTime
import kotlin.time.measureTime
@OptIn(MiraiInternalAPI::class, InternalSerializationApi::class)
@Suppress("SpellCheckingInspection", "INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
@Suppress("SpellCheckingInspection")
internal suspend fun HttpClient.postImage(
htcmd: String,
uin: Long,
groupcode: Long?,
imageInput: ExternalImage.ReusableInput, // Input from kotlinx.io, InputStream from kotlinx.io MPP, ByteReadChannel from ktor
imageInput: ReusableInput,
uKeyHex: String
): Boolean = post<HttpStatusCode> {
url {
@ -90,7 +92,7 @@ internal object HighwayHelper {
bot: QQAndroidBot,
servers: List<Pair<Int, Int>>,
uKey: ByteArray,
image: ExternalImage.ReusableInput,
image: ReusableInput,
kind: String,
commandId: Int
) = uploadImageToServers(bot, servers, uKey, image.md5, image, kind, commandId)
@ -102,7 +104,7 @@ internal object HighwayHelper {
servers: List<Pair<Int, Int>>,
uKey: ByteArray,
md5: ByteArray,
input: ExternalImage.ReusableInput,
input: ReusableInput,
kind: String,
commandId: Int
) = servers.retryWithServers(
@ -139,7 +141,7 @@ internal object HighwayHelper {
serverIp: String,
serverPort: Int,
ticket: ByteArray,
imageInput: ExternalImage.ReusableInput,
imageInput: ReusableInput,
fileMd5: ByteArray,
commandId: Int // group=2, friend=1
) {

View File

@ -21,10 +21,10 @@ import net.mamoe.mirai.qqandroid.network.protocol.packet.EMPTY_BYTE_ARRAY
import net.mamoe.mirai.qqandroid.utils.ByteArrayPool
import net.mamoe.mirai.qqandroid.utils.MiraiPlatformUtils
import net.mamoe.mirai.qqandroid.utils.io.serialization.toByteArray
import net.mamoe.mirai.utils.ExternalImage
import net.mamoe.mirai.utils.MiraiInternalAPI
import net.mamoe.mirai.utils.internal.ChunkedFlowSession
import net.mamoe.mirai.utils.internal.ChunkedInput
import net.mamoe.mirai.utils.internal.ReusableInput
import net.mamoe.mirai.utils.internal.map
@OptIn(MiraiInternalAPI::class, InternalSerializationApi::class)
@ -37,7 +37,7 @@ internal fun createImageDataPacketSequence(
commandId: Int,
localId: Int = 2052,
ticket: ByteArray,
data: ExternalImage.ReusableInput,
data: ReusableInput,
fileMd5: ByteArray,
sizePerPacket: Int = ByteArrayPool.BUFFER_SIZE
): ChunkedFlowSession<ByteReadPacket> {

View File

@ -1,46 +0,0 @@
/*
* Copyright 2020 Mamoe Technologies and contributors.
*
* 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
* Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
*
* https://github.com/mamoe/mirai/blob/master/LICENSE
*/
package net.mamoe.mirai
import io.ktor.utils.io.ByteReadChannel
import kotlinx.io.core.Input
import net.mamoe.mirai.utils.ExternalImage
import net.mamoe.mirai.utils.MiraiExperimentalAPI
import net.mamoe.mirai.utils.SinceMirai
import net.mamoe.mirai.utils.internal.InputStream
import kotlin.jvm.JvmStatic
/**
* Mirai 全局环境.
*/
@SinceMirai("1.0.0")
expect object Mirai {
@JvmStatic
var fileCacheStrategy: FileCacheStrategy
/**
* 缓存策略.
*
* 图片上传时默认使用文件缓存.
*/
interface FileCacheStrategy {
@MiraiExperimentalAPI
fun newImageCache(input: Input): ExternalImage
@MiraiExperimentalAPI
fun newImageCache(input: ByteReadChannel): ExternalImage
@MiraiExperimentalAPI
fun newImageCache(input: InputStream): ExternalImage
companion object Default : FileCacheStrategy
}
}

View File

@ -10,6 +10,7 @@
package net.mamoe.mirai.utils
import kotlinx.coroutines.Job
import net.mamoe.mirai.Bot
import net.mamoe.mirai.network.BotNetworkHandler
import kotlin.coroutines.CoroutineContext
@ -24,30 +25,20 @@ import kotlin.jvm.JvmStatic
*/
@Suppress("PropertyName")
open class BotConfiguration {
/**
* 日志记录器
*/
/** 日志记录器 */
var botLoggerSupplier: ((Bot) -> MiraiLogger) = { DefaultLogger("Bot(${it.id})") }
/**
* 网络层日志构造器
*/
/** 网络层日志构造器 */
@OptIn(MiraiInternalAPI::class)
var networkLoggerSupplier: ((BotNetworkHandler) -> MiraiLogger) = { DefaultLogger("Network(${it.bot.id})") }
/**
* 设备信息覆盖. 默认使用随机的设备信息.
*/
/** 设备信息覆盖. 默认使用随机的设备信息. */
var deviceInfo: ((Context) -> DeviceInfo)? = null
/**
* [CoroutineContext]
*/
/** 父 [CoroutineContext]. [Bot] 创建后会覆盖其 [Job], 但会将这个 [Job] 作为父 [Job] */
var parentCoroutineContext: CoroutineContext = EmptyCoroutineContext
/**
* 心跳周期. 过长会导致被服务器断开连接.
*/
/** 心跳周期. 过长会导致被服务器断开连接. */
var heartbeatPeriodMillis: Long = 60.secondsToMillis
/**
@ -56,31 +47,26 @@ open class BotConfiguration {
*/
var heartbeatTimeoutMillis: Long = 2.secondsToMillis
/**
* 心跳失败后的第一次重连前的等待时间.
*/
/** 心跳失败后的第一次重连前的等待时间. */
var firstReconnectDelayMillis: Long = 5.secondsToMillis
/**
* 重连失败后, 继续尝试的每次等待时间
*/
/** 重连失败后, 继续尝试的每次等待时间 */
var reconnectPeriodMillis: Long = 5.secondsToMillis
/**
* 最多尝试多少次重连
*/
/** 最多尝试多少次重连 */
var reconnectionRetryTimes: Int = Int.MAX_VALUE
/**
* 验证码处理器
*/
/** 验证码处理器 */
var loginSolver: LoginSolver = LoginSolver.Default
/**
* 使用协议类型
*/
/** 使用协议类型 */
@SinceMirai("1.0.0")
val protocol: MiraiProtocol = MiraiProtocol.ANDROID_PAD
var protocol: MiraiProtocol = MiraiProtocol.ANDROID_PAD
/** 缓存策略 */
@SinceMirai("1.0.0")
@MiraiExperimentalAPI
var fileCacheStrategy: FileCacheStrategy = FileCacheStrategy.PlatformDefault
@SinceMirai("1.0.0")
enum class MiraiProtocol(
@ -105,9 +91,7 @@ open class BotConfiguration {
}
companion object {
/**
* 默认的配置实例
*/
/** 默认的配置实例. 可以进行修改 */
@JvmStatic
val Default = BotConfiguration()
}
@ -144,11 +128,31 @@ open class BotConfiguration {
* ```
*/
@ConfigurationDsl
suspend fun inheritCoroutineContext() {
suspend inline fun inheritCoroutineContext() {
parentCoroutineContext = coroutineContext
}
@DslMarker
annotation class ConfigurationDsl
@SinceMirai("1.0.0")
fun copy(): BotConfiguration {
@OptIn(MiraiExperimentalAPI::class)
return BotConfiguration().also { new ->
new.botLoggerSupplier = botLoggerSupplier
new.networkLoggerSupplier = networkLoggerSupplier
new.deviceInfo = deviceInfo
new.parentCoroutineContext = parentCoroutineContext
new.heartbeatPeriodMillis = heartbeatPeriodMillis
new.heartbeatTimeoutMillis = heartbeatTimeoutMillis
new.firstReconnectDelayMillis = firstReconnectDelayMillis
new.reconnectPeriodMillis = reconnectPeriodMillis
new.reconnectionRetryTimes = reconnectionRetryTimes
new.loginSolver = loginSolver
new.protocol = protocol
new.fileCacheStrategy = fileCacheStrategy
}
}
}
@OptIn(ExperimentalMultiplatform::class)

View File

@ -11,15 +11,14 @@
package net.mamoe.mirai.utils
import io.ktor.utils.io.ByteWriteChannel
import net.mamoe.mirai.contact.Contact
import net.mamoe.mirai.contact.Group
import net.mamoe.mirai.contact.User
import net.mamoe.mirai.message.MessageReceipt
import net.mamoe.mirai.message.data.Image
import net.mamoe.mirai.message.data.sendTo
import net.mamoe.mirai.utils.internal.ChunkedFlowSession
import net.mamoe.mirai.utils.internal.ChunkedInput
import net.mamoe.mirai.utils.internal.DeferredReusableInput
import net.mamoe.mirai.utils.internal.ReusableInput
import kotlin.jvm.JvmField
import kotlin.jvm.JvmSynthetic
@ -31,24 +30,16 @@ import kotlin.jvm.JvmSynthetic
* @see ExternalImage.sendTo 上传图片并以纯图片消息发送给联系人
* @See ExternalImage.upload 上传图片并得到 [Image] 消息
*/
@OptIn(MiraiInternalAPI::class)
class ExternalImage internal constructor(
@JvmField
internal val input: ReusableInput // Input from kotlinx.io, InputStream from kotlinx.io MPP, ByteReadChannel from ktor
internal val input: ReusableInput
) {
val md5: ByteArray get() = this.input.md5
@SinceMirai("1.0.0")
internal interface ReusableInput {
val md5: ByteArray
val size: Long
fun chunkedFlow(sizePerPacket: Int): ChunkedFlowSession<ChunkedInput>
suspend fun writeTo(out: ByteWriteChannel): Long
}
internal val md5: ByteArray get() = this.input.md5
init {
require(input.size < 30L * 1024 * 1024) { "Image file is too big. Maximum is 30 MiB, but recommended to be 20 MiB" }
if (input !is DeferredReusableInput) {
require(input.size < 30L * 1024 * 1024) { "Image file is too big. Maximum is 30 MiB, but recommended to be 20 MiB" }
}
}
companion object {
@ -75,10 +66,16 @@ class ExternalImage internal constructor(
* SHARPP: 1004
*/
override fun toString(): String {
if (input is DeferredReusableInput) {
if (!input.initialized) {
return "ExternalImage(uninitialized)"
}
}
return "ExternalImage(${generateUUID(md5)})"
}
override fun toString(): String = "[ExternalImage(${generateUUID(md5)})]"
fun calculateImageResourceId(): String = generateImageId(md5)
internal fun calculateImageResourceId(): String = generateImageId(md5)
}
/**

View File

@ -0,0 +1,60 @@
package net.mamoe.mirai.utils
import kotlinx.io.core.Input
import kotlinx.io.errors.IOException
import net.mamoe.mirai.utils.internal.InputStream
/**
* 缓存策略.
*
* 图片上传时默认使用文件缓存.
*/
@MiraiExperimentalAPI
expect interface FileCacheStrategy {
/**
* [input] 缓存为 [ExternalImage].
* 此函数应 close 这个 [Input]
*/
@MiraiExperimentalAPI
@Throws(IOException::class)
fun newImageCache(input: Input): ExternalImage
/**
* [input] 缓存为 [ExternalImage].
* 此函数应 close 这个 [InputStream]
*/
@MiraiExperimentalAPI
@Throws(IOException::class)
fun newImageCache(input: InputStream): ExternalImage
/**
* [input] 缓存为 [ExternalImage].
* [input] 的内容应是不变的.
*/
@MiraiExperimentalAPI
@Throws(IOException::class)
fun newImageCache(input: ByteArray): ExternalImage
/**
* 默认的缓存方案. JVM 平台使用系统临时文件.
*/
@MiraiExperimentalAPI
object PlatformDefault : FileCacheStrategy
/**
* 使用内存直接存储所有图片文件.
*/
object MemoryCache : FileCacheStrategy {
@MiraiExperimentalAPI
@Throws(IOException::class)
override fun newImageCache(input: Input): ExternalImage
@MiraiExperimentalAPI
@Throws(IOException::class)
override fun newImageCache(input: InputStream): ExternalImage
@MiraiExperimentalAPI
@Throws(IOException::class)
override fun newImageCache(input: ByteArray): ExternalImage
}
}

View File

@ -0,0 +1,11 @@
package net.mamoe.mirai.utils.internal
import net.mamoe.mirai.utils.FileCacheStrategy
import net.mamoe.mirai.utils.MiraiExperimentalAPI
internal expect class DeferredReusableInput(input: Any, extraArg: Any?) : ReusableInput {
val initialized: Boolean
@OptIn(MiraiExperimentalAPI::class)
suspend fun init(strategy: FileCacheStrategy)
}

View File

@ -0,0 +1,13 @@
package net.mamoe.mirai.utils.internal
import io.ktor.utils.io.ByteWriteChannel
import net.mamoe.mirai.utils.SinceMirai
@SinceMirai("1.0.0")
internal interface ReusableInput {
val md5: ByteArray
val size: Long
fun chunkedFlow(sizePerPacket: Int): ChunkedFlowSession<ChunkedInput>
suspend fun writeTo(out: ByteWriteChannel): Long
}

View File

@ -9,9 +9,6 @@
package net.mamoe.mirai.utils.internal
import net.mamoe.mirai.utils.ExternalImage
internal expect fun ByteArray.asReusableInput(): ReusableInput
internal expect fun ByteArray.asReusableInput(): ExternalImage.ReusableInput
internal fun asReusableInput0(input: ByteArray): ExternalImage.ReusableInput = input.asReusableInput()
internal fun asReusableInput0(input: ByteArray): ReusableInput = input.asReusableInput()

View File

@ -1,46 +0,0 @@
package net.mamoe.mirai
import io.ktor.utils.io.ByteReadChannel
import kotlinx.io.core.Input
import net.mamoe.mirai.utils.ExternalImage
import net.mamoe.mirai.utils.MiraiExperimentalAPI
import net.mamoe.mirai.utils.internal.InputStream
/**
* Mirai 全局环境.
*/
actual object Mirai {
actual var fileCacheStrategy: FileCacheStrategy
get() = TODO("Not yet implemented")
set(value) {}
actual interface FileCacheStrategy {
@MiraiExperimentalAPI
actual fun newImageCache(input: Input): ExternalImage
@MiraiExperimentalAPI
actual fun newImageCache(input: ByteReadChannel): ExternalImage
@MiraiExperimentalAPI
actual fun newImageCache(input: InputStream): ExternalImage
actual companion object Default : FileCacheStrategy {
@MiraiExperimentalAPI
actual override fun newImageCache(input: Input): ExternalImage {
TODO("Not yet implemented")
}
@MiraiExperimentalAPI
actual override fun newImageCache(input: ByteReadChannel): ExternalImage {
TODO("Not yet implemented")
}
@MiraiExperimentalAPI
actual override fun newImageCache(input: InputStream): ExternalImage {
TODO("Not yet implemented")
}
}
}
}

View File

@ -37,7 +37,7 @@ import java.net.URL
*/
@Throws(OverFileSizeMaxException::class)
suspend fun <C : Contact> BufferedImage.sendTo(contact: C): MessageReceipt<C> =
withContext(Dispatchers.IO) { toExternalImage() }.sendTo(contact)
toExternalImage().sendTo(contact)
/**
* [Dispatchers.IO] 中下载 [URL] 到临时文件并将其作为图片发送到指定联系人
@ -45,7 +45,7 @@ suspend fun <C : Contact> BufferedImage.sendTo(contact: C): MessageReceipt<C> =
*/
@Throws(OverFileSizeMaxException::class)
suspend fun <C : Contact> URL.sendAsImageTo(contact: C): MessageReceipt<C> =
withContext(Dispatchers.IO) { toExternalImage() }.sendTo(contact)
toExternalImage().sendTo(contact)
/**
* [Dispatchers.IO] 中读取 [Input] 到临时文件并将其作为图片发送到指定联系人
@ -53,7 +53,7 @@ suspend fun <C : Contact> URL.sendAsImageTo(contact: C): MessageReceipt<C> =
*/
@Throws(OverFileSizeMaxException::class)
suspend fun <C : Contact> Input.sendAsImageTo(contact: C): MessageReceipt<C> =
withContext(Dispatchers.IO) { toExternalImage() }.sendTo(contact)
toExternalImage().sendTo(contact)
/**
* [Dispatchers.IO] 中读取 [InputStream] 到临时文件并将其作为图片发送到指定联系人
@ -61,7 +61,7 @@ suspend fun <C : Contact> Input.sendAsImageTo(contact: C): MessageReceipt<C> =
*/
@Throws(OverFileSizeMaxException::class)
suspend fun <C : Contact> InputStream.sendAsImageTo(contact: C): MessageReceipt<C> =
withContext(Dispatchers.IO) { toExternalImage() }.sendTo(contact)
toExternalImage().sendTo(contact)
/**
* [Dispatchers.IO] 中将文件作为图片发送到指定联系人
@ -70,7 +70,7 @@ suspend fun <C : Contact> InputStream.sendAsImageTo(contact: C): MessageReceipt<
@Throws(OverFileSizeMaxException::class)
suspend fun <C : Contact> File.sendAsImageTo(contact: C): MessageReceipt<C> {
require(this.exists() && this.canRead())
return withContext(Dispatchers.IO) { toExternalImage() }.sendTo(contact)
return toExternalImage().sendTo(contact)
}
// endregion
@ -84,7 +84,7 @@ suspend fun <C : Contact> File.sendAsImageTo(contact: C): MessageReceipt<C> {
@JvmSynthetic
@Throws(OverFileSizeMaxException::class)
suspend fun BufferedImage.upload(contact: Contact): Image =
withContext(Dispatchers.IO) { toExternalImage() }.upload(contact)
toExternalImage().upload(contact)
/**
* [Dispatchers.IO] 中下载 [URL] 到临时文件并将其作为图片上传后构造 [Image]
@ -92,7 +92,7 @@ suspend fun BufferedImage.upload(contact: Contact): Image =
*/
@Throws(OverFileSizeMaxException::class)
suspend fun URL.uploadAsImage(contact: Contact): Image =
withContext(Dispatchers.IO) { toExternalImage() }.upload(contact)
toExternalImage().upload(contact)
/**
* [Dispatchers.IO] 中读取 [Input] 到临时文件并将其作为图片上传后构造 [Image]
@ -100,7 +100,7 @@ suspend fun URL.uploadAsImage(contact: Contact): Image =
*/
@Throws(OverFileSizeMaxException::class)
suspend fun Input.uploadAsImage(contact: Contact): Image =
withContext(Dispatchers.IO) { toExternalImage() }.upload(contact)
toExternalImage().upload(contact)
/**
* [Dispatchers.IO] 中读取 [InputStream] 到临时文件并将其作为图片上传后构造 [Image]
@ -108,7 +108,7 @@ suspend fun Input.uploadAsImage(contact: Contact): Image =
*/
@Throws(OverFileSizeMaxException::class)
suspend fun InputStream.uploadAsImage(contact: Contact): Image =
withContext(Dispatchers.IO) { toExternalImage() }.upload(contact)
toExternalImage().upload(contact)
/**
* [Dispatchers.IO] 中将文件作为图片上传后构造 [Image]
@ -117,7 +117,7 @@ suspend fun InputStream.uploadAsImage(contact: Contact): Image =
@Throws(OverFileSizeMaxException::class)
suspend fun File.uploadAsImage(contact: Contact): Image {
require(this.isFile && this.exists() && this.canRead()) { "file ${this.path} is not readable" }
return withContext(Dispatchers.IO) { toExternalImage() }.upload(contact)
return toExternalImage().upload(contact)
}
// endregion

View File

@ -11,22 +11,14 @@
package net.mamoe.mirai.utils
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.io.ByteReadChannel
import kotlinx.coroutines.withContext
import kotlinx.io.core.Input
import kotlinx.io.core.copyTo
import kotlinx.io.errors.IOException
import kotlinx.io.streams.asOutput
import net.mamoe.mirai.Bot
import net.mamoe.mirai.utils.internal.DeferredReusableInput
import net.mamoe.mirai.utils.internal.asReusableInput
import net.mamoe.mirai.utils.internal.md5
import java.awt.image.BufferedImage
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.net.URL
import java.security.MessageDigest
import javax.imageio.ImageIO
/*
* 将各类型图片容器转为 [ExternalImage]
@ -34,126 +26,55 @@ import javax.imageio.ImageIO
/**
* [BufferedImage] 保存临时文件, 然后构造 [ExternalImage]
* [BufferedImage] 保存临时文件, 然后构造 [ExternalImage]
*/
@JvmOverloads
@Throws(IOException::class)
fun BufferedImage.toExternalImage(formatName: String = "png"): ExternalImage {
val file = createTempFile().apply { deleteOnExit() }
val digest = MessageDigest.getInstance("md5")
digest.reset()
file.outputStream().use { out ->
ImageIO.write(this@toExternalImage, formatName, object : OutputStream() {
override fun write(b: Int) {
out.write(b)
digest.update(b.toByte())
}
override fun write(b: ByteArray) {
out.write(b)
digest.update(b)
}
override fun write(b: ByteArray, off: Int, len: Int) {
out.write(b, off, len)
digest.update(b, off, len)
}
})
}
@Suppress("DEPRECATION_ERROR")
return ExternalImage(file.asReusableInput())
}
suspend inline fun BufferedImage.suspendToExternalImage(): ExternalImage = withContext(IO) { toExternalImage() }
fun BufferedImage.toExternalImage(formatName: String = "png"): ExternalImage =
ExternalImage(DeferredReusableInput(this, formatName))
/**
* 直接使用文件 [inputStream] 构造 [ExternalImage]
* 将文件作为 [ExternalImage] 使用. 只会在需要的时候打开文件并读取数据.
* @param deleteOnClose 若为 `true`, 图片发送后将会删除这个文件
*/
@OptIn(MiraiInternalAPI::class)
@Throws(IOException::class)
fun File.toExternalImage(): ExternalImage {
@Suppress("DEPRECATION_ERROR")
return ExternalImage(
input = this.asReusableInput()
)
}
@JvmOverloads
fun File.toExternalImage(deleteOnClose: Boolean = false): ExternalImage = ExternalImage(asReusableInput(deleteOnClose))
/**
* [IO] 中进行 [File.toExternalImage]
* [URL] 委托为 [ExternalImage].
* 只会在上传图片时才读取 [URL] 的内容. 具体行为取决于相关 [Bot] [FileCacheStrategy]
*/
suspend inline fun File.suspendToExternalImage(): ExternalImage = withContext(IO) { toExternalImage() }
fun URL.toExternalImage(): ExternalImage = ExternalImage(DeferredReusableInput(this, null))
/**
* 下载文件到临时目录然后调用 [File.toExternalImage]
* [InputStream] 委托为 [ExternalImage].
* 只会在上传图片时才读取 [InputStream] 的内容. 具体行为取决于相关 [Bot] [FileCacheStrategy]
*/
@Throws(IOException::class)
fun URL.toExternalImage(): ExternalImage {
val file = createTempFile().apply { deleteOnExit() }
file.outputStream().use { output ->
openStream().use { input ->
input.copyTo(output)
}
output.flush()
}
return file.toExternalImage()
}
@JvmName("toExternalImage")
fun InputStream.toExternalImage(): ExternalImage = ExternalImage(DeferredReusableInput(this, null))
/**
* [IO] 中进行 [URL.toExternalImage]
* [Input] 委托为 [ExternalImage].
* 只会在上传图片时才读取 [Input] 的内容. 具体行为取决于相关 [Bot] [FileCacheStrategy]
*/
suspend inline fun URL.suspendToExternalImage(): ExternalImage = withContext(IO) { toExternalImage() }
fun Input.toExternalImage(): ExternalImage = ExternalImage(DeferredReusableInput(this, null))
/**
* 保存为临时文件然后调用 [File.toExternalImage]
*/
@Throws(IOException::class)
fun InputStream.toExternalImage(): ExternalImage {
val file = createTempFile().apply { deleteOnExit() }
file.outputStream().use {
this.copyTo(it)
it.flush()
}
this.close()
return file.toExternalImage()
}
/**
* [IO] 中进行 [InputStream.toExternalImage]
*/
suspend inline fun InputStream.suspendToExternalImage(): ExternalImage = withContext(IO) { toExternalImage() }
@PlannedRemoval("1.2.0")
@Deprecated("no need", ReplaceWith("toExternalImage()"))
fun Input.suspendToExternalImage(): ExternalImage = toExternalImage()
/**
* 保存为临时文件然后调用 [File.toExternalImage].
*
* 需要函数调用者 close [this]
*/
@Throws(IOException::class)
fun Input.toExternalImage(): ExternalImage {
val file = createTempFile().apply { deleteOnExit() }
file.outputStream().asOutput().use {
this.copyTo(it)
it.flush()
}
return file.toExternalImage()
}
@PlannedRemoval("1.2.0")
@Deprecated("no need", ReplaceWith("toExternalImage()"))
fun InputStream.suspendToExternalImage(): ExternalImage = toExternalImage()
/**
* [IO] 中进行 [Input.toExternalImage]
*/
suspend inline fun Input.suspendToExternalImage(): ExternalImage = withContext(IO) { toExternalImage() }
@PlannedRemoval("1.2.0")
@Deprecated("no need", ReplaceWith("toExternalImage()"))
fun URL.suspendToExternalImage(): ExternalImage = toExternalImage()
/**
* 保存为临时文件然后调用 [File.toExternalImage].
*/
suspend fun ByteReadChannel.toExternalImage(): ExternalImage {
val file = createTempFile().apply { deleteOnExit() }
file.outputStream().use {
withContext(IO) { copyTo(it) }
it.flush()
}
@PlannedRemoval("1.2.0")
@Deprecated("no need", ReplaceWith("toExternalImage()"))
fun File.suspendToExternalImage(): ExternalImage = toExternalImage()
return file.suspendToExternalImage()
}
@PlannedRemoval("1.2.0")
@Deprecated("no need", ReplaceWith("toExternalImage()"))
fun BufferedImage.suspendToExternalImage(): ExternalImage = toExternalImage()

View File

@ -0,0 +1,213 @@
@file:Suppress("MemberVisibilityCanBePrivate")
package net.mamoe.mirai.utils
import kotlinx.io.core.Closeable
import kotlinx.io.core.Input
import kotlinx.io.core.readAvailable
import kotlinx.io.core.readBytes
import net.mamoe.mirai.Bot
import net.mamoe.mirai.utils.internal.InputStream
import net.mamoe.mirai.utils.internal.asReusableInput
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import java.io.OutputStream
import java.net.URL
import java.security.MessageDigest
import javax.imageio.ImageIO
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
/**
* 缓存策略.
*
* 图片上传时默认使用文件缓存.
*
* @see BotConfiguration.fileCacheStrategy [Bot] 指定缓存策略
*/
@MiraiExperimentalAPI
actual interface FileCacheStrategy {
/**
* [input] 缓存为 [ExternalImage].
* 此函数应 close 这个 [Input]
*/
@MiraiExperimentalAPI
@Throws(IOException::class)
actual fun newImageCache(input: Input): ExternalImage
/**
* [input] 缓存为 [ExternalImage].
* 此函数应 close 这个 [InputStream]
*/
@MiraiExperimentalAPI
@Throws(IOException::class)
actual fun newImageCache(input: InputStream): ExternalImage
/**
* [input] 缓存为 [ExternalImage].
* [input] 的内容应是不变的.
*/
@MiraiExperimentalAPI
@Throws(IOException::class)
actual fun newImageCache(input: ByteArray): ExternalImage
/**
* [input] 缓存为 [ExternalImage].
* [input] 的内容应是不变的.
*/
@MiraiExperimentalAPI
@Throws(IOException::class)
fun newImageCache(input: BufferedImage, format: String = "png"): ExternalImage
/**
* [input] 缓存为 [ExternalImage].
*/
@MiraiExperimentalAPI
@Throws(IOException::class)
fun newImageCache(input: URL, format: String = "png"): ExternalImage
/**
* 默认的缓存方案, 使用系统临时文件夹存储.
*/
@MiraiExperimentalAPI
actual object PlatformDefault : FileCacheStrategy by TempCache(null)
/**
* 使用内存直接存储所有图片文件.
*/
actual object MemoryCache : FileCacheStrategy {
@MiraiExperimentalAPI
@Throws(IOException::class)
actual override fun newImageCache(input: Input): ExternalImage {
return newImageCache(input.readBytes())
}
@MiraiExperimentalAPI
@Throws(IOException::class)
actual override fun newImageCache(input: InputStream): ExternalImage {
return newImageCache(input.readBytes())
}
@MiraiExperimentalAPI
@Throws(IOException::class)
actual override fun newImageCache(input: ByteArray): ExternalImage {
return ExternalImage(input.asReusableInput())
}
@MiraiExperimentalAPI
override fun newImageCache(input: BufferedImage, format: String): ExternalImage {
val out = ByteArrayOutputStream()
ImageIO.write(input, format, out)
return newImageCache(out.toByteArray())
}
@MiraiExperimentalAPI
override fun newImageCache(input: URL, format: String): ExternalImage {
val out = ByteArrayOutputStream()
input.openConnection().getInputStream().use { it.copyTo(out) }
return newImageCache(out.toByteArray())
}
}
/**
* 使用系统临时文件夹缓存图片文件. 在图片使用完毕后删除临时文件.
*/
@MiraiExperimentalAPI
class TempCache @JvmOverloads constructor(
/**
* 缓存图片存放位置
*/
val directory: File? = null
) : FileCacheStrategy {
@MiraiExperimentalAPI
@Throws(IOException::class)
override fun newImageCache(input: Input): ExternalImage {
return ExternalImage(createTempFile(directory = directory).apply {
deleteOnExit()
input.withOut(this.outputStream()) { copyTo(it) }
}.asReusableInput(true))
}
@MiraiExperimentalAPI
@Throws(IOException::class)
override fun newImageCache(input: InputStream): ExternalImage {
return ExternalImage(createTempFile(directory = directory).apply {
deleteOnExit()
input.withOut(this.outputStream()) { copyTo(it) }
}.asReusableInput(true))
}
@MiraiExperimentalAPI
@Throws(IOException::class)
override fun newImageCache(input: ByteArray): ExternalImage {
return ExternalImage(input.asReusableInput())
}
@MiraiExperimentalAPI
override fun newImageCache(input: BufferedImage, format: String): ExternalImage {
val file = createTempFile(directory = directory).apply { deleteOnExit() }
val digest = MessageDigest.getInstance("md5")
digest.reset()
file.outputStream().use { out ->
ImageIO.write(input, format, object : OutputStream() {
override fun write(b: Int) {
out.write(b)
digest.update(b.toByte())
}
override fun write(b: ByteArray) {
out.write(b)
digest.update(b)
}
override fun write(b: ByteArray, off: Int, len: Int) {
out.write(b, off, len)
digest.update(b, off, len)
}
})
}
@Suppress("DEPRECATION_ERROR")
return ExternalImage(file.asReusableInput(true, digest.digest()))
}
@MiraiExperimentalAPI
override fun newImageCache(input: URL, format: String): ExternalImage {
return ExternalImage(createTempFile(directory = directory).apply {
deleteOnExit()
input.openConnection().getInputStream().withOut(this.outputStream()) { copyTo(it) }
}.asReusableInput(true))
}
}
}
@OptIn(ExperimentalContracts::class)
internal inline fun <I : Closeable, O : Closeable, R> I.withOut(output: O, block: I.(output: O) -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return use { output.use { block(this, output) } }
}
/**
* Copies this stream to the given output stream, returning the number of bytes copied
*
* **Note** It is the caller's responsibility to close both of these resources.
*/
@Throws(IOException::class)
internal fun Input.copyTo(out: OutputStream, bufferSize: Int = DEFAULT_BUFFER_SIZE): Long {
var bytesCopied: Long = 0
val buffer = ByteArray(bufferSize)
var bytes = readAvailable(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
bytes = readAvailable(buffer)
}
return bytesCopied
}

View File

@ -0,0 +1,49 @@
package net.mamoe.mirai.utils.internal
import io.ktor.utils.io.ByteWriteChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.io.core.Input
import net.mamoe.mirai.utils.FileCacheStrategy
import net.mamoe.mirai.utils.MiraiExperimentalAPI
import java.awt.image.BufferedImage
import java.net.URL
internal actual class DeferredReusableInput actual constructor(
val input: Any,
val extraArg: Any?
) : ReusableInput {
@OptIn(MiraiExperimentalAPI::class)
actual suspend fun init(strategy: FileCacheStrategy) = withContext(Dispatchers.IO) {
if (delegate != null) {
return@withContext
}
delegate = when (input) {
is InputStream -> strategy.newImageCache(input)
is ByteArray -> strategy.newImageCache(input)
is Input -> strategy.newImageCache(input)
is BufferedImage -> strategy.newImageCache(input, extraArg as String)
is URL -> strategy.newImageCache(input)
else -> error("Internal error: unsupported DeferredReusableInput.input: ${input::class.qualifiedName}")
}.input
}
private var delegate: ReusableInput? = null
override val md5: ByteArray
get() = delegate?.md5 ?: error("DeferredReusableInput not yet initialized")
override val size: Long
get() = delegate?.size ?: error("DeferredReusableInput not yet initialized")
override fun chunkedFlow(sizePerPacket: Int): ChunkedFlowSession<ChunkedInput> {
return delegate?.chunkedFlow(sizePerPacket) ?: error("DeferredReusableInput not yet initialized")
}
override suspend fun writeTo(out: ByteWriteChannel): Long {
return delegate?.writeTo(out) ?: error("DeferredReusableInput not yet initialized")
}
actual val initialized: Boolean get() = delegate != null
}

View File

@ -5,12 +5,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import net.mamoe.mirai.message.data.toLongUnsigned
import net.mamoe.mirai.utils.ExternalImage
import java.io.File
import java.io.InputStream
internal actual fun ByteArray.asReusableInput(): ExternalImage.ReusableInput {
return object : ExternalImage.ReusableInput {
internal actual fun ByteArray.asReusableInput(): ReusableInput {
return object : ReusableInput {
override val md5: ByteArray = md5()
override val size: Long get() = this@asReusableInput.size.toLongUnsigned()
@ -32,8 +31,8 @@ internal actual fun ByteArray.asReusableInput(): ExternalImage.ReusableInput {
}
}
internal fun File.asReusableInput(): ExternalImage.ReusableInput {
return object : ExternalImage.ReusableInput {
internal fun File.asReusableInput(deleteOnClose: Boolean): ReusableInput {
return object : ReusableInput {
override val md5: ByteArray = inputStream().use { it.md5() }
override val size: Long get() = length()
@ -41,7 +40,10 @@ internal fun File.asReusableInput(): ExternalImage.ReusableInput {
val stream = inputStream()
return object : ChunkedFlowSession<ChunkedInput> {
override val flow: Flow<ChunkedInput> = stream.chunkedFlow(sizePerPacket)
override fun close() = stream.close()
override fun close() {
stream.close()
if (deleteOnClose) this@asReusableInput.delete()
}
}
}
@ -51,6 +53,27 @@ internal fun File.asReusableInput(): ExternalImage.ReusableInput {
}
}
internal fun File.asReusableInput(deleteOnClose: Boolean, md5: ByteArray): ReusableInput {
return object : ReusableInput {
override val md5: ByteArray get() = md5
override val size: Long get() = length()
override fun chunkedFlow(sizePerPacket: Int): ChunkedFlowSession<ChunkedInput> {
val stream = inputStream()
return object : ChunkedFlowSession<ChunkedInput> {
override val flow: Flow<ChunkedInput> = stream.chunkedFlow(sizePerPacket)
override fun close() {
stream.close()
if (deleteOnClose) this@asReusableInput.delete()
}
}
}
override suspend fun writeTo(out: ByteWriteChannel): Long {
return inputStream().use { it.copyTo(out) }
}
}
}
private suspend fun InputStream.copyTo(out: ByteWriteChannel): Long = withContext(Dispatchers.IO) {
var bytesCopied: Long = 0