Shamrock: 支持添加QQ收藏(图片) /fav/add_image_msg

This commit is contained in:
WhiteChi 2023-12-22 14:19:56 +08:00
parent df25b0bc76
commit 88beaf8b6f
11 changed files with 384 additions and 29 deletions

View File

@ -37,6 +37,26 @@ Java_moe_fuqiuluo_shamrock_utils_MD5_genFileMd5Hex(JNIEnv *env, jobject thiz, js
return env->NewStringUTF(md5Hex.c_str());
}
extern "C"
JNIEXPORT jbyteArray JNICALL
Java_moe_fuqiuluo_shamrock_utils_MD5_genFileMd5(JNIEnv *env, jobject thiz, jstring file_path) {
auto cPathStr = env->GetStringUTFChars(file_path, nullptr);
std::filesystem::path filePath(cPathStr);
if (!std::filesystem::exists(filePath)) {
jclass exClass = env->FindClass("java/io/FileNotFoundException");
env->ThrowNew(exClass, "目标文件不存在");
env->DeleteLocalRef(exClass);
return nullptr;
}
auto file = std::ifstream(filePath.c_str(), std::ios::binary);
MD5 md5;
md5.update(file);
auto md5Bytes = md5.digest();
auto jByteArray = env->NewByteArray(16);
env->SetByteArrayRegion(jByteArray, 0, 16, reinterpret_cast<const jbyte*>(md5Bytes));
return jByteArray;
}
extern "C"
JNIEXPORT jstring JNICALL
Java_moe_fuqiuluo_shamrock_utils_MD5_getMd5Hex(JNIEnv *env, jobject thiz, jbyteArray bytes) {

View File

@ -0,0 +1,7 @@
package com.tencent.mobileqq.filemanager.api;
import com.tencent.mobileqq.qroute.QRouteApi;
public interface IFileManagerUtil extends QRouteApi {
byte[] getSHA(String str);
}

View File

@ -1,5 +1,6 @@
package moe.fuqiuluo.qqinterface.servlet
import android.graphics.BitmapFactory
import com.tencent.mobileqq.app.QQAppInterface
import com.tencent.mobileqq.transfile.HttpNetReq
import com.tencent.mobileqq.transfile.INetEngineListener
@ -8,18 +9,23 @@ import com.tencent.mobileqq.transfile.NetResp
import com.tencent.mobileqq.transfile.ServerAddr
import com.tencent.mobileqq.transfile.api.IHttpEngineService
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.io.core.BytePacketBuilder
import kotlinx.io.core.readBytes
import kotlinx.io.core.writeFully
import moe.fuqiuluo.proto.protobufMapOf
import moe.fuqiuluo.shamrock.helper.Level
import moe.fuqiuluo.shamrock.helper.LogCenter
import moe.fuqiuluo.shamrock.tools.EMPTY_BYTE_ARRAY
import moe.fuqiuluo.shamrock.tools.toInnerValuesString
import moe.fuqiuluo.shamrock.tools.hex2ByteArray
import moe.fuqiuluo.shamrock.tools.toHexString
import moe.fuqiuluo.shamrock.utils.DeflateTools
import moe.fuqiuluo.shamrock.utils.MD5
import moe.fuqiuluo.shamrock.xposed.helper.AppRuntimeFetcher
import mqq.manager.TicketManager
import oicq.wlogin_sdk.request.Ticket
import oicq.wlogin_sdk.request.WtTicketPromise
import oicq.wlogin_sdk.tools.ErrMsg
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.ByteBuffer
import kotlin.coroutines.resume
@ -28,12 +34,16 @@ import kotlin.coroutines.resume
* QQ收藏相关接口
*/
internal object QFavSvc: BaseSvc() {
private val SERVER_LIST = listOf(ServerAddr().also {
private val SERVER_LIST_COLLECTOR = listOf(ServerAddr().also {
it.isIpv6 = false
it.mIp = "collector.weiyun.com"
it.port = 80
})
private const val VT = 27
private val SERVER_LIST_PICUP = listOf(ServerAddr().also {
it.isIpv6 = false
it.mIp = "pic.pieceup.qq.com"
it.port = 80
})
private const val VERSION = 12820
private const val APPID = 30244
private const val SUB_APPID = 538116905
@ -41,6 +51,108 @@ internal object QFavSvc: BaseSvc() {
private const val MINOR_VERSION = 9
private var seq = 1
suspend fun addImageMsg(
uin: Long,
name: String,
groupId: Long = 0,
groupName: String = "",
picUrl: String,
pid: String,
width: Int, height: Int,
size: Long,
md5: String,
): Result<NetResp> {
val md5Bytes = md5.hex2ByteArray()
val data = protobufMapOf {
it[1] = mapOf(
20009 to mapOf(
1 to mapOf(
1 to 1, // bid
2 to 1, // category
3 to mapOf( // author
1 to if (groupId == 0L) 1 else 2, // type
2 to uin, // num_id
3 to name, // str_id
4 to groupId, // group_id
5 to groupName // group_name
),
4 to System.currentTimeMillis() - 2000, // create_time
5 to System.currentTimeMillis() - 1000, // sequence
7 to """{"recordAudioOnly":false,"audioOnly":false,"fileOnly":false}""",
9 to 0, // original_app_id
10 to 0 // custom_group_id
),
2 to mapOf(
1 to "",
3 to "[图片]",
4 to mapOf(
1 to picUrl,
2 to md5Bytes,
3 to md5,
6 to width,
7 to height,
8 to size,
9 to 0,
11 to pid
),
5 to 1
),
3 to mapOf(
2 to """<img src="$picUrl" />""",
4 to mapOf(
1 to picUrl,
2 to md5Bytes,
3 to md5,
6 to width,
7 to height,
8 to size,
9 to 0,
11 to pid
)
)
)
)
}.toByteArray()
return sendWeiyunReq(20009, data)
}
suspend fun applyUpImageMsg(
uin: Long,
name: String,
groupId: Long = 0,
groupName: String = "",
width: Int, height: Int,
image: File
): Result<NetResp> {
if (!image.exists()) {
return Result.failure(IllegalArgumentException("image file not exists"))
}
val md5 = MD5.genFileMd5(image.absolutePath)
val data = protobufMapOf {
it[1] = mapOf(
20010 to mapOf(
1 to mapOf(
2 to md5,
4 to md5.toHexString(),
10 to mapOf( // author
1 to if (groupId == 0L) 1 else 2, // type
2 to uin, // num_id
3 to name, // str_id
4 to groupId, // group_id
5 to groupName // group_name
),
6 to width, // width
7 to height,
8 to image.length(),
9 to 1, // type
11 to "/storage/emulated/0/DCIM/ShamrockUpload.jpeg" // pic_id
)
)
)
}.toByteArray()
return sendWeiyunReq(20010, data)
}
suspend fun addRichMediaMsg(
uin: Long,
name: String,
@ -111,7 +223,15 @@ internal object QFavSvc: BaseSvc() {
return sendWeiyunReq(20009, data)
}
suspend fun sendWeiyunReq(cmd: Int, body: ByteArray): Result<NetResp> {
suspend fun sendPicUpBlock(
fileSize: Long,
offset: Long,
block: ByteArray,
blockSize: Long,
sha: ByteArray,
pid: String,
outputStream: ByteArrayOutputStream = ByteArrayOutputStream(),
): Result<NetResp> {
return suspendCancellableCoroutine {
val httpNetReq = HttpNetReq()
httpNetReq.userData = null
@ -120,19 +240,73 @@ internal object QFavSvc: BaseSvc() {
if (netResp.mHttpCode != 200 && netResp.mResult != 0 && netResp.mErrDesc.isNullOrEmpty()) {
netResp.mErrDesc = netResp.mRespProperties["User-ErrMsg"]
}
netResp.mRespData = outputStream.toByteArray().copyOf()
it.resume(Result.success(netResp))
}
override fun onUpdateProgeress(netReq: NetReq, curr: Long, final: Long) {}
}
val vi = (app.getManager(QQAppInterface.TICKET_MANAGER) as TicketManager).getA2(app.currentAccountUin)
//LogCenter.log(pSKey)
httpNetReq.mHttpMethod = HttpNetReq.HTTP_POST
httpNetReq.mSendData = BytePacketBuilder().apply {
writeInt(-1412589450)
writeInt(10000)
writeInt(0)
writeInt(sha.size + 16 + blockSize.toInt())
writeShort(0)
writeShort(sha.size.toShort())
writeFully(sha)
writeInt(fileSize.toInt())
writeInt(offset.toInt())
writeInt(blockSize.toInt())
writeFully(block)
}.build().readBytes()
httpNetReq.mOutStream = outputStream
httpNetReq.mStartDownOffset = 0L
httpNetReq.mReqProperties["Shamrock"] = "true"
httpNetReq.mReqProperties["Cookie"] = String.format("uin=%s;vt=%d;vi=%s;pid=%s;appid=%d", app.currentAccountUin, 8, vi, pid, APPID)
httpNetReq.mReqProperties["host"] = "pic.pieceup.qq.com"
httpNetReq.mReqProperties["Range"] = "bytes=0-"
httpNetReq.mReqProperties["Content-Length"] = httpNetReq.mSendData.size.toString()
httpNetReq.mReqProperties["Accept-Encoding"] = "gzip"
httpNetReq.mReqProperties["Content-Encoding"] = "gzip"
httpNetReq.mPrioty = 1
httpNetReq.mReqUrl = "https://pic.pieceup.qq.com/"
httpNetReq.mServerList = SERVER_LIST_PICUP
val service = AppRuntimeFetcher.appRuntime
.getRuntimeService(IHttpEngineService::class.java, "qqfav")
service.sendReq(httpNetReq)
}
}
suspend fun sendWeiyunReq(
cmd: Int,
body: ByteArray,
outputStream: ByteArrayOutputStream = ByteArrayOutputStream(),
): Result<NetResp> {
return suspendCancellableCoroutine {
val httpNetReq = HttpNetReq()
httpNetReq.userData = null
httpNetReq.mCallback = object: INetEngineListener {
override fun onResp(netResp: NetResp) {
if (netResp.mHttpCode != 200 && netResp.mResult != 0 && netResp.mErrDesc.isNullOrEmpty()) {
netResp.mErrDesc = netResp.mRespProperties["User-ErrMsg"]
}
netResp.mRespData = outputStream.toByteArray().copyOf()
it.resume(Result.success(netResp))
}
override fun onUpdateProgeress(netReq: NetReq, curr: Long, final: Long) {}
}
val pSKey = getWeiYunPSKey()
LogCenter.log(pSKey)
//LogCenter.log(pSKey)
httpNetReq.mHttpMethod = HttpNetReq.HTTP_POST
httpNetReq.mSendData = DeflateTools.gzip(packData(packHead(cmd, pSKey), body))
httpNetReq.mOutStream = ByteArrayOutputStream()
httpNetReq.mOutStream = outputStream
httpNetReq.mStartDownOffset = 0L
httpNetReq.mReqProperties["Shamrock"] = "true"
httpNetReq.mReqProperties["Cookie"] = String.format("uin=%s;vt=%d;vi=%s;appid=%d", app.currentAccountUin, VT, pSKey, APPID)
httpNetReq.mReqProperties["Cookie"] = String.format("uin=%s;vt=%d;vi=%s;appid=%d", app.currentAccountUin, 27, pSKey, APPID)
httpNetReq.mReqProperties["host"] = "collector.weiyun.com"
httpNetReq.mReqProperties["Range"] = "bytes=0-"
httpNetReq.mReqProperties["Content-Length"] = httpNetReq.mSendData.size.toString()
@ -140,7 +314,7 @@ internal object QFavSvc: BaseSvc() {
httpNetReq.mReqProperties["Content-Encoding"] = "gzip"
httpNetReq.mPrioty = 1
httpNetReq.mReqUrl = "https://collector.weiyun.com/collector.fcg"
httpNetReq.mServerList = SERVER_LIST
httpNetReq.mServerList = SERVER_LIST_COLLECTOR
val service = AppRuntimeFetcher.appRuntime
.getRuntimeService(IHttpEngineService::class.java, "qqfav")
service.sendReq(httpNetReq)

View File

@ -52,7 +52,7 @@ internal object ActionManager {
GetWeatherCityCode, GetWeather,
// FAV
FavAddRichMediaMsg,
FavAddRichMediaMsg, FavAddImageMsg,
// OTHER
GetDeviceBattery, DownloadFile

View File

@ -0,0 +1,120 @@
package moe.fuqiuluo.shamrock.remote.action.handlers
import android.graphics.BitmapFactory
import kotlinx.io.core.ByteReadPacket
import kotlinx.io.core.discardExact
import kotlinx.serialization.json.JsonElement
import moe.fuqiuluo.proto.ProtoUtils
import moe.fuqiuluo.proto.asUtf8String
import moe.fuqiuluo.qqinterface.servlet.QFavSvc
import moe.fuqiuluo.shamrock.remote.action.ActionSession
import moe.fuqiuluo.shamrock.remote.action.IActionHandler
import moe.fuqiuluo.shamrock.tools.EmptyJsonString
import moe.fuqiuluo.shamrock.utils.CryptTools
import moe.fuqiuluo.shamrock.utils.DeflateTools
import moe.fuqiuluo.shamrock.utils.FileUtils
internal object FavAddImageMsg: IActionHandler() {
override suspend fun internalHandle(session: ActionSession): String {
val uin = session.getLong("user_id")
val nickName = session.getString("nick")
val groupName = session.getStringOrNull("group_name") ?: ""
val groupId = session.getLongOrNull("group_id") ?: 0L
val file = session.getString("file")
return invoke(uin, nickName, file, groupName, groupId, session.echo)
}
suspend operator fun invoke(
uin: Long,
nickName: String,
fileText: String,
groupName: String = "",
groupId: Long = 0L,
echo: JsonElement = EmptyJsonString
): String {
val image = fileText.let {
val md5 = it.replace(regex = "[{}\\-]".toRegex(), replacement = "").split(".")[0].lowercase()
if (md5.length == 32) {
FileUtils.getFile(it)
} else {
FileUtils.parseAndSave(it)
}
}
val options = BitmapFactory.Options()
BitmapFactory.decodeFile(image.absolutePath, options)
QFavSvc.applyUpImageMsg(uin, nickName,
image = image,
groupName = groupName,
groupId = groupId,
width = options.outWidth,
height = options.outHeight
).onSuccess {
return if (it.mHttpCode == 200 && it.mResult == 0) {
val readPacket = ByteReadPacket(DeflateTools.ungzip(it.mRespData))
readPacket.discardExact(6)
val allLength = readPacket.readInt()
val dataLength = readPacket.readInt()
val headLength = allLength - dataLength - 16
//LogCenter.log("上传图片请求成功: ${DeflateTools.ungzip(it.mRespData).toHexString()}")
//LogCenter.log("图片上传响应: allLength=$allLength, dataLength=$dataLength, headLength=$headLength")
readPacket.discardExact(2)
ByteArray(headLength).also {
readPacket.readFully(it, 0, it.size)
}
val data = ByteArray(dataLength).also {
readPacket.readFully(it, 0, it.size)
}
val pb = ProtoUtils.decodeFromByteArray(data)
val resp = pb[2, 20010, 1, 2]
val picUrl = resp[1].asUtf8String
val picId = resp[11].asUtf8String
val md5 = resp[4].asUtf8String
val sha = CryptTools
.getSHA1("/storage/emulated/0/Android/data/com.tencent.mobileqq/Tencent/QQ_Collection/pic/" + md5.uppercase() + "_0")
image.inputStream().use {
var offset = 0L
val block = ByteArray(131072)
var rest = image.length()
do {
val length = if (rest <= 131072) rest else 131072L
if(it.read(block, 0, length.toInt()) != -1) {
QFavSvc.sendPicUpBlock(
fileSize = image.length(),
offset = offset,
block = block,
blockSize = length,
pid = picId,
sha = sha
).onFailure {
return error(it.message ?: it.toString(), echo)
}
offset += length
rest -= length
} else {
rest = -1
}
} while (rest > 0)
}
QFavSvc.addImageMsg(
uin, nickName, groupId, groupName, picUrl, picId, options.outWidth, options.outHeight, image.length(), md5.uppercase()
).onFailure {
return error(it.message ?: it.toString(), echo)
}
ok(picUrl, echo)
} else {
logic(it.mErrDesc, echo)
}
}.onFailure {
return error(it.message ?: it.toString(), echo)
}
return ok("请求已提交", echo)
}
override fun path(): String = "fav.add_image_msg"
override val requiredParams: Array<String> = arrayOf("user_id", "nick", "file")
}

View File

@ -10,7 +10,7 @@ internal object FavAddRichMediaMsg: IActionHandler() {
override suspend fun internalHandle(session: ActionSession): String {
val uin = session.getLong("user_id")
val nickName = session.getString("nick")
val groupName = session.getStringOrNull("groupName") ?: ""
val groupName = session.getStringOrNull("group_name") ?: ""
val groupId = session.getLongOrNull("group_id") ?: 0L
val time = session.getLongOrNull("time") ?: System.currentTimeMillis()
val content = session.getString("content")

View File

@ -4,6 +4,7 @@ import io.ktor.http.ContentType
import io.ktor.server.application.call
import io.ktor.server.response.respondText
import io.ktor.server.routing.Routing
import moe.fuqiuluo.shamrock.remote.action.handlers.FavAddImageMsg
import moe.fuqiuluo.shamrock.remote.action.handlers.FavAddRichMediaMsg
import moe.fuqiuluo.shamrock.remote.action.handlers.GetFriendList
import moe.fuqiuluo.shamrock.remote.action.handlers.GetFriendSystemMsg
@ -21,8 +22,17 @@ fun Routing.fav() {
val nickName = call.fetchOrThrow("nick")
val time = call.fetchOrNull("time")?.toLong() ?: System.currentTimeMillis()
val content = call.fetchOrThrow("content")
val groupName = call.fetchOrNull("groupName") ?: ""
val groupName = call.fetchOrNull("group_name") ?: ""
val groupId = call.fetchOrNull("group_id")?.toLong() ?: 0L
call.respondText(FavAddRichMediaMsg(uin, nickName, time, content, groupName, groupId), ContentType.Application.Json)
}
getOrPost("/fav/add_image_msg") {
val uin = call.fetchOrThrow("user_id").toLong()
val nickName = call.fetchOrThrow("nick")
val file = call.fetchOrThrow("file")
val groupName = call.fetchOrNull("groupName") ?: ""
val groupId = call.fetchOrNull("group_id")?.toLong() ?: 0L
call.respondText(FavAddImageMsg(uin, nickName, file, groupName, groupId), ContentType.Application.Json)
}
}

View File

@ -0,0 +1,19 @@
package moe.fuqiuluo.shamrock.utils
object CryptTools {
fun getSHA1(string: String): ByteArray {
return getSHA1(string.toByteArray())
}
fun getSHA1(bytes: ByteArray): ByteArray {
return getDigest(bytes, "SHA-1")
}
private fun getDigest(bytes: ByteArray, algorithm: String): ByteArray {
val digest = java.security.MessageDigest.getInstance(algorithm)
digest.update(bytes)
return digest.digest()
}
}

View File

@ -4,9 +4,11 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.zip.Deflater
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
import java.util.zip.Inflater
object DeflateTools {
fun uncompress(inputByte: ByteArray?): ByteArray {
var len: Int
@ -79,4 +81,20 @@ object DeflateTools {
input.close()
}
}
fun ungzip(bytes: ByteArray): ByteArray {
val out = ByteArrayOutputStream()
val `in` = ByteArrayInputStream(bytes)
try {
val ungzip = GZIPInputStream(`in`)
val buffer = ByteArray(256)
var n: Int
while (ungzip.read(buffer).also { n = it } >= 0) {
out.write(buffer, 0, n)
}
} catch (e: java.lang.Exception) {
e.printStackTrace()
}
return out.toByteArray()
}
}

View File

@ -4,4 +4,6 @@ object MD5 {
external fun getMd5Hex(bytes: ByteArray): String
external fun genFileMd5Hex(filePath: String): String
external fun genFileMd5(filePath: String): ByteArray
}

View File

@ -16,17 +16,6 @@ import mqq.app.MobileQQ
internal class HookForDebug: IAction {
override fun invoke(ctx: Context) {
// MessageHelper.hookSendMessageOldChannel()
val oldHttpEngineProcessor = QRoute.api(IOldHttpEngineProcessor::class.java)
oldHttpEngineProcessor.javaClass.hookMethod("sendReq").before {
if (it.args[0] is HttpNetReq) {
LogCenter.log("已记录一个IOldHttpEngineProcessor请求")
val req = it.args[0] as HttpNetReq
if (req.mReqUrl.startsWith("https://")) {
req.mReqUrl = req.mReqUrl.replace("https://", "http://")
}
}
}
val httpEngineService = AppRuntimeFetcher.appRuntime
.getRuntimeService(IHttpEngineService::class.java, "all")
httpEngineService.javaClass.hookMethod("sendReq").before {
@ -36,12 +25,8 @@ internal class HookForDebug: IAction {
return@before
}
LogCenter.log("已记录一个IHttpEngineService请求")
if (req.mReqUrl == null || req.mReqUrl.isBlank()) {
val host = req.mReqProperties["host"] ?: "collector.weiyun.com"
req.mReqUrl = "http://$host"
} else if (req.mReqUrl.startsWith("https://")) {
req.mReqUrl = req.mReqUrl.replace("https://", "http://")
}
LogCenter.log("请求地址: ${req.mReqUrl}")
LogCenter.log("请求: ${req.toInnerValuesString(NetReq::class.java)}")
}
}