mirror of
https://github.com/mamoe/mirai.git
synced 2025-03-09 19:50:27 +08:00
Move StandardCharImageLoginSolver from common to jvm
This commit is contained in:
parent
d3583162a5
commit
ba61194fa4
@ -9,26 +9,11 @@
|
||||
|
||||
package net.mamoe.mirai.utils
|
||||
|
||||
import kotlinx.coroutines.CoroutineName
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.io.*
|
||||
import kotlinx.coroutines.io.jvm.nio.copyTo
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.mamoe.mirai.Bot
|
||||
import net.mamoe.mirai.network.LoginFailedException
|
||||
import net.mamoe.mirai.network.NoStandardInputForCaptchaException
|
||||
import net.mamoe.mirai.utils.DeviceInfo.Companion.loadAsDeviceInfo
|
||||
import net.mamoe.mirai.utils.LoginSolver.Companion.Default
|
||||
import net.mamoe.mirai.utils.StandardCharImageLoginSolver.Companion.createBlocking
|
||||
import java.awt.Image
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import javax.imageio.ImageIO
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
/**
|
||||
* 验证码, 设备锁解决器
|
||||
@ -81,11 +66,11 @@ public expect abstract class LoginSolver() {
|
||||
*
|
||||
* 检测策略:
|
||||
* 1. 若是 `mirai-core-api-android` 或 `android.util.Log` 存在, 返回 `null`.
|
||||
* 2. 检测 JVM 属性 `mirai.no-desktop`. 若存在, 返回 [StandardCharImageLoginSolver]
|
||||
* 2. 检测 JVM 属性 `mirai.no-desktop`. 若存在, 返回 `StandardCharImageLoginSolver`
|
||||
* 3. 检测 JVM 桌面环境, 若支持, 返回 `SwingSolver`
|
||||
* 4. 返回 [StandardCharImageLoginSolver]
|
||||
* 4. 返回 `StandardCharImageLoginSolver`
|
||||
*
|
||||
* @return `SwingSolver` 或 [StandardCharImageLoginSolver] 或 `null`
|
||||
* @return `SwingSolver` 或 `StandardCharImageLoginSolver` 或 `null`
|
||||
*/
|
||||
@JvmField
|
||||
public val Default: LoginSolver?
|
||||
@ -97,188 +82,8 @@ public expect abstract class LoginSolver() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI 环境 [LoginSolver]. 将验证码图片转为字符画并通过 `output` 输出, [input] 获取用户输入.
|
||||
*
|
||||
* 使用字符图片展示验证码, 使用 [input] 获取输入, 使用 [loggerSupplier] 输出
|
||||
*
|
||||
* @see createBlocking
|
||||
*/
|
||||
public class StandardCharImageLoginSolver @JvmOverloads constructor(
|
||||
input: suspend () -> String = { readLine() ?: throw NoStandardInputForCaptchaException() },
|
||||
/**
|
||||
* 为 `null` 时使用 [Bot.logger]
|
||||
*/
|
||||
private val loggerSupplier: (bot: Bot) -> MiraiLogger = { it.logger }
|
||||
) : LoginSolver() {
|
||||
public constructor(
|
||||
input: suspend () -> String = { readLine() ?: throw NoStandardInputForCaptchaException() },
|
||||
overrideLogger: MiraiLogger?
|
||||
) : this(input, { overrideLogger ?: it.logger })
|
||||
|
||||
private val input: suspend () -> String = suspend {
|
||||
withContext(Dispatchers.IO) { input() }
|
||||
}
|
||||
|
||||
override suspend fun onSolvePicCaptcha(bot: Bot, data: ByteArray): String? = loginSolverLock.withLock {
|
||||
val logger = loggerSupplier(bot)
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
withContext(Dispatchers.IO) {
|
||||
val tempFile: File = File.createTempFile("tmp", ".png").apply { deleteOnExit() }
|
||||
tempFile.createNewFile()
|
||||
logger.info { "[PicCaptcha] 需要图片验证码登录, 验证码为 4 字母" }
|
||||
logger.info { "[PicCaptcha] Picture captcha required. Captcha consists of 4 letters." }
|
||||
try {
|
||||
tempFile.writeChannel().apply { writeFully(data); close() }
|
||||
logger.info { "[PicCaptcha] 将会显示字符图片. 若看不清字符图片, 请查看文件 ${tempFile.absolutePath}" }
|
||||
logger.info { "[PicCaptcha] Displaying char-image. If not clear, view file ${tempFile.absolutePath}" }
|
||||
} catch (e: Exception) {
|
||||
logger.warning("[PicCaptcha] 无法写出验证码文件, 请尝试查看以上字符图片", e)
|
||||
logger.warning("[PicCaptcha] Failed to export captcha image. Please see the char-image.", e)
|
||||
}
|
||||
|
||||
tempFile.inputStream().use { stream ->
|
||||
try {
|
||||
val img = ImageIO.read(stream)
|
||||
if (img == null) {
|
||||
logger.warning { "[PicCaptcha] 无法创建字符图片. 请查看文件" }
|
||||
logger.warning { "[PicCaptcha] Failed to create char-image. Please see the file." }
|
||||
} else {
|
||||
logger.info { "[PicCaptcha] \n" + img.createCharImg() }
|
||||
}
|
||||
} catch (throwable: Throwable) {
|
||||
logger.warning("[PicCaptcha] 创建字符图片时出错. 请查看文件.", throwable)
|
||||
logger.warning("[PicCaptcha] Failed to create char-image. Please see the file.", throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info { "[PicCaptcha] 请输入 4 位字母验证码. 若要更换验证码, 请直接回车" }
|
||||
logger.info { "[PicCaptcha] Please type 4-letter captcha. Press Enter directly to refresh." }
|
||||
return input().takeUnless { it.isEmpty() || it.length != 4 }.also {
|
||||
logger.info { "[PicCaptcha] 正在提交 $it..." }
|
||||
logger.info { "[PicCaptcha] Submitting $it..." }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onSolveSliderCaptcha(bot: Bot, url: String): String = loginSolverLock.withLock {
|
||||
val logger = loggerSupplier(bot)
|
||||
logger.info { "[SliderCaptcha] 需要滑动验证码, 请在浏览器中打开以下链接并完成验证码, 完成后请输入提示 ticket." }
|
||||
logger.info { "[SliderCaptcha] Slider captcha required, please open the following link in any browser and solve the captcha. Type ticket here after completion." }
|
||||
logger.info { "[SliderCaptcha] @see https://github.com/project-mirai/mirai-login-solver-selenium#%E4%B8%8B%E8%BD%BD-chrome-%E6%89%A9%E5%B1%95%E6%8F%92%E4%BB%B6" }
|
||||
logger.info(url)
|
||||
return input().also {
|
||||
logger.info { "[SliderCaptcha] 正在提交中..." }
|
||||
logger.info { "[SliderCaptcha] Submitting..." }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onSolveUnsafeDeviceLoginVerify(bot: Bot, url: String): String = loginSolverLock.withLock {
|
||||
val logger = loggerSupplier(bot)
|
||||
logger.info { "[UnsafeLogin] 当前登录环境不安全,服务器要求账户认证。请在 QQ 浏览器打开 $url 并完成验证后输入任意字符。" }
|
||||
logger.info { "[UnsafeLogin] Account verification required by the server. Please open $url in QQ browser and complete challenge, then type anything here to submit." }
|
||||
return input().also {
|
||||
logger.info { "[UnsafeLogin] 正在提交中..." }
|
||||
logger.info { "[UnsafeLogin] Submitting..." }
|
||||
}
|
||||
}
|
||||
|
||||
public companion object {
|
||||
/**
|
||||
* 创建 Java 阻塞版 [input] 的 [StandardCharImageLoginSolver]
|
||||
*
|
||||
* @param input 将在协程 IO 池执行, 可以有阻塞调用
|
||||
*/
|
||||
@JvmStatic
|
||||
public fun createBlocking(input: () -> String, output: MiraiLogger?): StandardCharImageLoginSolver {
|
||||
return StandardCharImageLoginSolver({ withContext(Dispatchers.IO) { input() } }, output)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Java 阻塞版 [input] 的 [StandardCharImageLoginSolver]
|
||||
*
|
||||
* @param input 将在协程 IO 池执行, 可以有阻塞调用
|
||||
*/
|
||||
@JvmStatic
|
||||
public fun createBlocking(input: () -> String): StandardCharImageLoginSolver {
|
||||
return StandardCharImageLoginSolver({ withContext(Dispatchers.IO) { input() } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////
|
||||
//////////////// internal
|
||||
///////////////////////////////
|
||||
|
||||
internal fun BotConfiguration.getFileBasedDeviceInfoSupplier(file: () -> File): (Bot) -> DeviceInfo {
|
||||
return {
|
||||
file().loadAsDeviceInfo(json)
|
||||
}
|
||||
}
|
||||
|
||||
// Copied from Ktor CIO
|
||||
private fun File.writeChannel(
|
||||
coroutineContext: CoroutineContext = Dispatchers.IO
|
||||
): ByteWriteChannel = GlobalScope.reader(CoroutineName("file-writer") + coroutineContext, autoFlush = true) {
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
RandomAccessFile(this@writeChannel, "rw").use { file ->
|
||||
val copied = channel.copyTo(file.channel)
|
||||
file.setLength(copied) // truncate tail that could remain from the previously written data
|
||||
}
|
||||
}.channel
|
||||
|
||||
private val loginSolverLock = Mutex()
|
||||
|
||||
/**
|
||||
* @author NaturalHG
|
||||
*/
|
||||
private fun BufferedImage.createCharImg(outputWidth: Int = 100, ignoreRate: Double = 0.95): String {
|
||||
val newHeight = (this.height * (outputWidth.toDouble() / this.width)).toInt()
|
||||
val tmp = this.getScaledInstance(outputWidth, newHeight, Image.SCALE_SMOOTH)
|
||||
val image = BufferedImage(outputWidth, newHeight, BufferedImage.TYPE_INT_ARGB)
|
||||
val g2d = image.createGraphics()
|
||||
g2d.drawImage(tmp, 0, 0, null)
|
||||
fun gray(rgb: Int): Int {
|
||||
val r = rgb and 0xff0000 shr 16
|
||||
val g = rgb and 0x00ff00 shr 8
|
||||
val b = rgb and 0x0000ff
|
||||
return (r * 30 + g * 59 + b * 11 + 50) / 100
|
||||
}
|
||||
|
||||
fun grayCompare(g1: Int, g2: Int): Boolean =
|
||||
kotlin.math.min(g1, g2).toDouble() / kotlin.math.max(g1, g2) >= ignoreRate
|
||||
|
||||
val background = gray(image.getRGB(0, 0))
|
||||
|
||||
return buildString(capacity = height) {
|
||||
|
||||
val lines = mutableListOf<StringBuilder>()
|
||||
|
||||
var minXPos = outputWidth
|
||||
var maxXPos = 0
|
||||
|
||||
for (y in 0 until image.height) {
|
||||
val builderLine = StringBuilder()
|
||||
for (x in 0 until image.width) {
|
||||
val gray = gray(image.getRGB(x, y))
|
||||
if (grayCompare(gray, background)) {
|
||||
builderLine.append(" ")
|
||||
} else {
|
||||
builderLine.append("#")
|
||||
if (x < minXPos) {
|
||||
minXPos = x
|
||||
}
|
||||
if (x > maxXPos) {
|
||||
maxXPos = x
|
||||
}
|
||||
}
|
||||
}
|
||||
if (builderLine.toString().isBlank()) {
|
||||
continue
|
||||
}
|
||||
lines.add(builderLine)
|
||||
}
|
||||
for (line in lines) {
|
||||
append(line.substring(minXPos, maxXPos)).append("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -10,11 +10,30 @@
|
||||
|
||||
package net.mamoe.mirai.utils
|
||||
|
||||
import kotlinx.coroutines.CoroutineName
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.io.ByteWriteChannel
|
||||
import kotlinx.coroutines.io.close
|
||||
import kotlinx.coroutines.io.jvm.nio.copyTo
|
||||
import kotlinx.coroutines.io.reader
|
||||
import kotlinx.coroutines.io.writeFully
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.mamoe.mirai.Bot
|
||||
import net.mamoe.mirai.internal.utils.SeleniumLoginSolver
|
||||
import net.mamoe.mirai.internal.utils.isSliderCaptchaSupportKind
|
||||
import net.mamoe.mirai.network.LoginFailedException
|
||||
import net.mamoe.mirai.network.NoStandardInputForCaptchaException
|
||||
import net.mamoe.mirai.utils.LoginSolver.Companion.Default
|
||||
import net.mamoe.mirai.utils.StandardCharImageLoginSolver.Companion.createBlocking
|
||||
import java.awt.Image
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import javax.imageio.ImageIO
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
|
||||
/**
|
||||
@ -93,4 +112,181 @@ public actual abstract class LoginSolver public actual constructor() {
|
||||
?: error("LoginSolver is not provided by default on your platform. Please specify by BotConfiguration.loginSolver")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* CLI 环境 [LoginSolver]. 将验证码图片转为字符画并通过 `output` 输出, [input] 获取用户输入.
|
||||
*
|
||||
* 使用字符图片展示验证码, 使用 [input] 获取输入, 使用 [loggerSupplier] 输出
|
||||
*
|
||||
* @see createBlocking
|
||||
*/
|
||||
public class StandardCharImageLoginSolver @JvmOverloads constructor(
|
||||
input: suspend () -> String = { readLine() ?: throw NoStandardInputForCaptchaException() },
|
||||
/**
|
||||
* 为 `null` 时使用 [Bot.logger]
|
||||
*/
|
||||
private val loggerSupplier: (bot: Bot) -> MiraiLogger = { it.logger }
|
||||
) : LoginSolver() {
|
||||
public constructor(
|
||||
input: suspend () -> String = { readLine() ?: throw NoStandardInputForCaptchaException() },
|
||||
overrideLogger: MiraiLogger?
|
||||
) : this(input, { overrideLogger ?: it.logger })
|
||||
|
||||
private val input: suspend () -> String = suspend {
|
||||
withContext(Dispatchers.IO) { input() }
|
||||
}
|
||||
|
||||
override suspend fun onSolvePicCaptcha(bot: Bot, data: ByteArray): String? = loginSolverLock.withLock {
|
||||
val logger = loggerSupplier(bot)
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
(withContext(Dispatchers.IO) {
|
||||
val tempFile: File = File.createTempFile("tmp", ".png").apply { deleteOnExit() }
|
||||
tempFile.createNewFile()
|
||||
logger.info { "[PicCaptcha] 需要图片验证码登录, 验证码为 4 字母" }
|
||||
logger.info { "[PicCaptcha] Picture captcha required. Captcha consists of 4 letters." }
|
||||
try {
|
||||
tempFile.writeChannel().apply { writeFully(data); close() }
|
||||
logger.info { "[PicCaptcha] 将会显示字符图片. 若看不清字符图片, 请查看文件 ${tempFile.absolutePath}" }
|
||||
logger.info { "[PicCaptcha] Displaying char-image. If not clear, view file ${tempFile.absolutePath}" }
|
||||
} catch (e: Exception) {
|
||||
logger.warning("[PicCaptcha] 无法写出验证码文件, 请尝试查看以上字符图片", e)
|
||||
logger.warning("[PicCaptcha] Failed to export captcha image. Please see the char-image.", e)
|
||||
}
|
||||
|
||||
tempFile.inputStream().use { stream ->
|
||||
try {
|
||||
val img = ImageIO.read(stream)
|
||||
if (img == null) {
|
||||
logger.warning { "[PicCaptcha] 无法创建字符图片. 请查看文件" }
|
||||
logger.warning { "[PicCaptcha] Failed to create char-image. Please see the file." }
|
||||
} else {
|
||||
logger.info { "[PicCaptcha] \n" + img.createCharImg() }
|
||||
}
|
||||
} catch (throwable: Throwable) {
|
||||
logger.warning("[PicCaptcha] 创建字符图片时出错. 请查看文件.", throwable)
|
||||
logger.warning("[PicCaptcha] Failed to create char-image. Please see the file.", throwable)
|
||||
}
|
||||
}
|
||||
})
|
||||
logger.info { "[PicCaptcha] 请输入 4 位字母验证码. 若要更换验证码, 请直接回车" }
|
||||
logger.info { "[PicCaptcha] Please type 4-letter captcha. Press Enter directly to refresh." }
|
||||
return input().takeUnless { it.isEmpty() || it.length != 4 }.also {
|
||||
logger.info { "[PicCaptcha] 正在提交 $it..." }
|
||||
logger.info { "[PicCaptcha] Submitting $it..." }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onSolveSliderCaptcha(bot: Bot, url: String): String = loginSolverLock.withLock {
|
||||
val logger = loggerSupplier(bot)
|
||||
logger.info { "[SliderCaptcha] 需要滑动验证码, 请在浏览器中打开以下链接并完成验证码, 完成后请输入提示 ticket." }
|
||||
logger.info { "[SliderCaptcha] Slider captcha required, please open the following link in any browser and solve the captcha. Type ticket here after completion." }
|
||||
logger.info { "[SliderCaptcha] @see https://github.com/project-mirai/mirai-login-solver-selenium#%E4%B8%8B%E8%BD%BD-chrome-%E6%89%A9%E5%B1%95%E6%8F%92%E4%BB%B6" }
|
||||
logger.info(url)
|
||||
return input().also {
|
||||
logger.info { "[SliderCaptcha] 正在提交中..." }
|
||||
logger.info { "[SliderCaptcha] Submitting..." }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onSolveUnsafeDeviceLoginVerify(bot: Bot, url: String): String = loginSolverLock.withLock {
|
||||
val logger = loggerSupplier(bot)
|
||||
logger.info { "[UnsafeLogin] 当前登录环境不安全,服务器要求账户认证。请在 QQ 浏览器打开 $url 并完成验证后输入任意字符。" }
|
||||
logger.info { "[UnsafeLogin] Account verification required by the server. Please open $url in QQ browser and complete challenge, then type anything here to submit." }
|
||||
return input().also {
|
||||
logger.info { "[UnsafeLogin] 正在提交中..." }
|
||||
logger.info { "[UnsafeLogin] Submitting..." }
|
||||
}
|
||||
}
|
||||
|
||||
public companion object {
|
||||
/**
|
||||
* 创建 Java 阻塞版 [input] 的 [StandardCharImageLoginSolver]
|
||||
*
|
||||
* @param input 将在协程 IO 池执行, 可以有阻塞调用
|
||||
*/
|
||||
@JvmStatic
|
||||
public fun createBlocking(input: () -> String, output: MiraiLogger?): StandardCharImageLoginSolver {
|
||||
return StandardCharImageLoginSolver({ withContext(Dispatchers.IO) { input() } }, output)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Java 阻塞版 [input] 的 [StandardCharImageLoginSolver]
|
||||
*
|
||||
* @param input 将在协程 IO 池执行, 可以有阻塞调用
|
||||
*/
|
||||
@JvmStatic
|
||||
public fun createBlocking(input: () -> String): StandardCharImageLoginSolver {
|
||||
return StandardCharImageLoginSolver({ withContext(Dispatchers.IO) { input() } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copied from Ktor CIO
|
||||
private fun File.writeChannel(
|
||||
coroutineContext: CoroutineContext = Dispatchers.IO
|
||||
): ByteWriteChannel = GlobalScope.reader(CoroutineName("file-writer") + coroutineContext, autoFlush = true) {
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
RandomAccessFile(this@writeChannel, "rw").use { file ->
|
||||
val copied = channel.copyTo(file.channel)
|
||||
file.setLength(copied) // truncate tail that could remain from the previously written data
|
||||
}
|
||||
}.channel
|
||||
|
||||
private val loginSolverLock = Mutex()
|
||||
|
||||
/**
|
||||
* @author NaturalHG
|
||||
*/
|
||||
private fun BufferedImage.createCharImg(outputWidth: Int = 100, ignoreRate: Double = 0.95): String {
|
||||
val newHeight = (this.height * (outputWidth.toDouble() / this.width)).toInt()
|
||||
val tmp = this.getScaledInstance(outputWidth, newHeight, Image.SCALE_SMOOTH)
|
||||
val image = BufferedImage(outputWidth, newHeight, BufferedImage.TYPE_INT_ARGB)
|
||||
val g2d = image.createGraphics()
|
||||
g2d.drawImage(tmp, 0, 0, null)
|
||||
fun gray(rgb: Int): Int {
|
||||
val r = rgb and 0xff0000 shr 16
|
||||
val g = rgb and 0x00ff00 shr 8
|
||||
val b = rgb and 0x0000ff
|
||||
return (r * 30 + g * 59 + b * 11 + 50) / 100
|
||||
}
|
||||
|
||||
fun grayCompare(g1: Int, g2: Int): Boolean =
|
||||
kotlin.math.min(g1, g2).toDouble() / kotlin.math.max(g1, g2) >= ignoreRate
|
||||
|
||||
val background = gray(image.getRGB(0, 0))
|
||||
|
||||
return buildString(capacity = height) {
|
||||
|
||||
val lines = mutableListOf<StringBuilder>()
|
||||
|
||||
var minXPos = outputWidth
|
||||
var maxXPos = 0
|
||||
|
||||
for (y in 0 until image.height) {
|
||||
val builderLine = StringBuilder()
|
||||
for (x in 0 until image.width) {
|
||||
val gray = gray(image.getRGB(x, y))
|
||||
if (grayCompare(gray, background)) {
|
||||
builderLine.append(" ")
|
||||
} else {
|
||||
builderLine.append("#")
|
||||
if (x < minXPos) {
|
||||
minXPos = x
|
||||
}
|
||||
if (x > maxXPos) {
|
||||
maxXPos = x
|
||||
}
|
||||
}
|
||||
}
|
||||
if (builderLine.toString().isBlank()) {
|
||||
continue
|
||||
}
|
||||
lines.add(builderLine)
|
||||
}
|
||||
for (line in lines) {
|
||||
append(line.substring(minXPos, maxXPos)).append("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user