diff --git a/kritor b/kritor new file mode 160000 index 0000000..e4aac65 --- /dev/null +++ b/kritor @@ -0,0 +1 @@ +Subproject commit e4aac653e14249cbb6f27567ffd463165f6deebe diff --git a/processor/build.gradle.kts b/processor/build.gradle.kts index fc61b86..22df2e4 100644 --- a/processor/build.gradle.kts +++ b/processor/build.gradle.kts @@ -15,7 +15,7 @@ dependencies { implementation("com.google.devtools.ksp:symbol-processing-api:1.9.21-1.0.15") implementation("com.squareup:kotlinpoet:1.14.2") - implementation(DEPENDENCY_PROTOBUF) + //implementation(DEPENDENCY_PROTOBUF) implementation(kotlinx("serialization-protobuf", "1.6.2")) ksp("dev.zacsweers.autoservice:auto-service-ksp:1.1.0") diff --git a/processor/src/main/java/moe/fuqiuluo/ksp/impl/GrpcProcessor.kt b/processor/src/main/java/moe/fuqiuluo/ksp/impl/GrpcProcessor.kt new file mode 100644 index 0000000..bc80b18 --- /dev/null +++ b/processor/src/main/java/moe/fuqiuluo/ksp/impl/GrpcProcessor.kt @@ -0,0 +1,95 @@ +@file:Suppress("UNCHECKED_CAST") +@file:OptIn(KspExperimental::class) + +package moe.fuqiuluo.ksp.impl + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getAnnotationsByType +import com.google.devtools.ksp.getClassDeclarationByName +import com.google.devtools.ksp.getJavaClassByName +import com.google.devtools.ksp.getKotlinClassByName +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.KSTypeParameter +import com.google.devtools.ksp.symbol.Modifier +import com.google.devtools.ksp.validate +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import kritor.service.Grpc + +class GrpcProcessor( + private val codeGenerator: CodeGenerator, + private val logger: KSPLogger +): SymbolProcessor { + override fun process(resolver: Resolver): List { + val symbols = resolver.getSymbolsWithAnnotation(Grpc::class.qualifiedName!!) + val actions = (symbols as Sequence).toList() + + if (actions.isEmpty()) return emptyList() + + // 怎么返回nullable的结果 + val packageName = "kritor.handlers" + val funcBuilder = FunSpec.builder("handleGrpc") + .addModifiers(KModifier.SUSPEND) + .addParameter("cmd", String::class) + .addParameter("data", ByteArray::class) + .returns(ByteArray::class) + val fileSpec = FileSpec.scriptBuilder("AutoGrpcHandlers", packageName) + + logger.warn("Found ${actions.size} grpc-actions") + + //logger.error(resolver.getClassDeclarationByName("io.kritor.AuthReq").toString()) + //logger.error(resolver.getJavaClassByName("io.kritor.AuthReq").toString()) + //logger.error(resolver.getKotlinClassByName("io.kritor.AuthReq").toString()) + + actions.forEach { action -> + val methodName = action.qualifiedName?.asString()!! + val grpcMethod = action.getAnnotationsByType(Grpc::class).first() + val service = grpcMethod.serviceName + val funcName = grpcMethod.funcName + funcBuilder.addStatement("if (cmd == \"${service}.${funcName}\") {\t") + + val reqType = action.parameters[0].type.toString() + val rspType = action.returnType.toString() + funcBuilder.addStatement("val resp: $rspType = $methodName($reqType.parseFrom(data))") + funcBuilder.addStatement("return resp.toByteArray()") + + funcBuilder.addStatement("}") + } + funcBuilder.addStatement("return EMPTY_BYTE_ARRAY") + fileSpec + .addStatement("import io.kritor.*") + .addStatement("import io.kritor.core.*") + .addStatement("import io.kritor.contact.*") + .addStatement("import io.kritor.group.*") + .addStatement("import io.kritor.friend.*") + .addStatement("import io.kritor.file.*") + .addStatement("import io.kritor.message.*") + .addStatement("import io.kritor.web.*") + .addStatement("import io.kritor.developer.*") + .addFunction(funcBuilder.build()) + .addImport("moe.fuqiuluo.symbols", "EMPTY_BYTE_ARRAY") + runCatching { + codeGenerator.createNewFile( + dependencies = Dependencies(aggregating = false), + packageName = packageName, + fileName = fileSpec.name + ).use { outputStream -> + outputStream.writer().use { + fileSpec.build().writeTo(it) + } + } + } + + return emptyList() + } +} \ No newline at end of file diff --git a/processor/src/main/java/moe/fuqiuluo/ksp/impl/ProtobufProcessor.kt b/processor/src/main/java/moe/fuqiuluo/ksp/impl/ProtobufProcessor.kt index 7cf97dc..db503a3 100644 --- a/processor/src/main/java/moe/fuqiuluo/ksp/impl/ProtobufProcessor.kt +++ b/processor/src/main/java/moe/fuqiuluo/ksp/impl/ProtobufProcessor.kt @@ -32,7 +32,7 @@ class ProtobufProcessor( }.toList() if (actions.isNotEmpty()) { - actions.forEachIndexed { index, clz -> + actions.forEachIndexed { _, clz -> if (clz.isInternal()) return@forEachIndexed if (clz.isPrivate()) return@forEachIndexed diff --git a/processor/src/main/java/moe/fuqiuluo/ksp/impl/XposedHookProcessor.kt b/processor/src/main/java/moe/fuqiuluo/ksp/impl/XposedHookProcessor.kt index cea2db4..201d89e 100644 --- a/processor/src/main/java/moe/fuqiuluo/ksp/impl/XposedHookProcessor.kt +++ b/processor/src/main/java/moe/fuqiuluo/ksp/impl/XposedHookProcessor.kt @@ -1,5 +1,5 @@ @file:Suppress("UNCHECKED_CAST", "LocalVariableName", "PrivatePropertyName") -@file:OptIn(KspExperimental::class) +@file:OptIn(KspExperimental::class, KspExperimental::class) package moe.fuqiuluo.ksp.impl @@ -27,10 +27,14 @@ class XposedHookProcessor( private val logger: KSPLogger ): SymbolProcessor { override fun process(resolver: Resolver): List { - val symbols = resolver.getSymbolsWithAnnotation(XposedHook::class.qualifiedName!!) + val symbols = resolver.getSymbolsWithAnnotation( + annotationName = XposedHook::class.qualifiedName!!, + inDepth = true + ) + logger.warn("Found ${symbols.count()} classes annotated with XposedHook") val unableToProcess = symbols.filterNot { it.validate() } val actions = (symbols.filter { - it is KSClassDeclaration && it.validate() && it.classKind == ClassKind.CLASS + it is KSClassDeclaration && it.classKind == ClassKind.CLASS } as Sequence).toList() if (actions.isNotEmpty()) { @@ -46,7 +50,7 @@ class XposedHookProcessor( } val context = ClassName("android.content", "Context") - val packageName = "moe.fuqiuluo.shamrock.xposed.hooks" + val packageName = "moe.fuqiuluo.shamrock.xposed.actions" val fileSpec = FileSpec.builder(packageName, "AutoActionLoader").addFunction(FunSpec.builder("runFirstActions") .addParameter("ctx", context) .apply { @@ -96,16 +100,6 @@ class XposedHookProcessor( } } } - return unableToProcess.toList() } - - inner class ActionLoaderVisitor( - private val firstActions: List, - private val serviceActions: List, - ): KSVisitorVoid() { - override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) { - - } - } } \ No newline at end of file diff --git a/processor/src/main/java/moe/fuqiuluo/ksp/providers/GrpcProvider.kt b/processor/src/main/java/moe/fuqiuluo/ksp/providers/GrpcProvider.kt new file mode 100644 index 0000000..008110c --- /dev/null +++ b/processor/src/main/java/moe/fuqiuluo/ksp/providers/GrpcProvider.kt @@ -0,0 +1,17 @@ +package moe.fuqiuluo.ksp.providers + +import com.google.auto.service.AutoService +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import moe.fuqiuluo.ksp.impl.GrpcProcessor + +@AutoService(SymbolProcessorProvider::class) +class GrpcProvider: SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + return GrpcProcessor( + environment.codeGenerator, + environment.logger + ) + } +} \ No newline at end of file diff --git a/protobuf/build.gradle.kts b/protobuf/build.gradle.kts index 06cedfb..db8dae5 100644 --- a/protobuf/build.gradle.kts +++ b/protobuf/build.gradle.kts @@ -37,7 +37,7 @@ android { } dependencies { - implementation(DEPENDENCY_PROTOBUF) + //implementation(DEPENDENCY_PROTOBUF) implementation(kotlinx("serialization-protobuf", "1.6.2")) implementation(kotlinx("serialization-json", "1.6.2")) @@ -47,5 +47,5 @@ dependencies { } tasks.withType().configureEach { - kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn" + kotlinOptions.freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" } \ No newline at end of file diff --git a/protobuf/src/main/java/protobuf/oidb/cmd0x11c5/NtV2RichMediaRsp.kt b/protobuf/src/main/java/protobuf/oidb/cmd0x11c5/NtV2RichMediaRsp.kt index bf5fd13..e12c009 100644 --- a/protobuf/src/main/java/protobuf/oidb/cmd0x11c5/NtV2RichMediaRsp.kt +++ b/protobuf/src/main/java/protobuf/oidb/cmd0x11c5/NtV2RichMediaRsp.kt @@ -1,7 +1,7 @@ @file:OptIn(ExperimentalSerializationApi::class) package protobuf.oidb.cmd0x11c5 -import com.google.protobuf.Internal.EMPTY_BYTE_ARRAY +import moe.fuqiuluo.symbols.EMPTY_BYTE_ARRAY import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.protobuf.ProtoNumber @@ -9,7 +9,7 @@ import moe.fuqiuluo.symbols.Protobuf @Serializable data class NtV2RichMediaRsp( - @ProtoNumber(1) val head: RspHead, + @ProtoNumber(1) val head: RspHead?, @ProtoNumber(2) val upload: UploadRsp?, @ProtoNumber(3) val download: DownloadRsp?, @ProtoNumber(4) val downloadRkeyRsp: DownloadRkeyRsp?, diff --git a/protobuf/src/main/java/protobuf/oidb/cmd0x388/Cmd0x388Req.kt b/protobuf/src/main/java/protobuf/oidb/cmd0x388/Cmd0x388Req.kt index ea1f666..b7882fe 100644 --- a/protobuf/src/main/java/protobuf/oidb/cmd0x388/Cmd0x388Req.kt +++ b/protobuf/src/main/java/protobuf/oidb/cmd0x388/Cmd0x388Req.kt @@ -1,6 +1,6 @@ package protobuf.oidb.cmd0x388 -import com.google.protobuf.Internal.EMPTY_BYTE_ARRAY +import moe.fuqiuluo.symbols.EMPTY_BYTE_ARRAY import kotlinx.serialization.Serializable import kotlinx.serialization.protobuf.ProtoNumber import moe.fuqiuluo.symbols.Protobuf diff --git a/protobuf/src/main/java/protobuf/oidb/cmd0x388/Cmd0x388Rsp.kt b/protobuf/src/main/java/protobuf/oidb/cmd0x388/Cmd0x388Rsp.kt index 8d7f107..75f441e 100644 --- a/protobuf/src/main/java/protobuf/oidb/cmd0x388/Cmd0x388Rsp.kt +++ b/protobuf/src/main/java/protobuf/oidb/cmd0x388/Cmd0x388Rsp.kt @@ -2,7 +2,7 @@ package protobuf.oidb.cmd0x388 -import com.google.protobuf.Internal.EMPTY_BYTE_ARRAY +import moe.fuqiuluo.symbols.EMPTY_BYTE_ARRAY import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.protobuf.ProtoNumber diff --git a/qqinterface/src/main/java/com/tencent/common/app/AppInterface.java b/qqinterface/src/main/java/com/tencent/common/app/AppInterface.java index 694bd70..80a3d99 100644 --- a/qqinterface/src/main/java/com/tencent/common/app/AppInterface.java +++ b/qqinterface/src/main/java/com/tencent/common/app/AppInterface.java @@ -6,9 +6,13 @@ import com.tencent.mobileqq.app.BusinessObserver; import com.tencent.mobileqq.app.MessageHandler; import com.tencent.qphone.base.remote.ToServiceMsg; +import java.util.concurrent.ConcurrentHashMap; + import mqq.app.AppRuntime; public abstract class AppInterface extends AppRuntime { + private final ConcurrentHashMap allHandler = new ConcurrentHashMap<>(); + public String getCurrentNickname() { return ""; } diff --git a/qqinterface/src/main/java/com/tencent/mobileqq/app/BaseBusinessHandler.java b/qqinterface/src/main/java/com/tencent/mobileqq/app/BaseBusinessHandler.java index 4287a5a..a5292b0 100644 --- a/qqinterface/src/main/java/com/tencent/mobileqq/app/BaseBusinessHandler.java +++ b/qqinterface/src/main/java/com/tencent/mobileqq/app/BaseBusinessHandler.java @@ -13,6 +13,10 @@ public abstract class BaseBusinessHandler extends OidbWrapper { return null; } + public void addBusinessObserver(ToServiceMsg toServiceMsg, BusinessObserver businessObserver, boolean z) { + + } + public final T decodePacket(byte[] data, String name, T obj) { UniPacket uniPacket = new UniPacket(true); try { @@ -24,6 +28,10 @@ public abstract class BaseBusinessHandler extends OidbWrapper { } } + public boolean msgCmdFilter(String str) { + return false; + } + protected abstract Set getCommandList(); protected abstract Set getPushCommandList(); diff --git a/qqinterface/src/main/java/com/tencent/mobileqq/app/BusinessHandler.java b/qqinterface/src/main/java/com/tencent/mobileqq/app/BusinessHandler.java index 970dcec..74cde56 100644 --- a/qqinterface/src/main/java/com/tencent/mobileqq/app/BusinessHandler.java +++ b/qqinterface/src/main/java/com/tencent/mobileqq/app/BusinessHandler.java @@ -8,6 +8,8 @@ public abstract class BusinessHandler extends BaseBusinessHandler { public BusinessHandler(AppInterface appInterface) { } + protected abstract Class observerClass(); + @Override public Set getCommandList() { return null; diff --git a/qqinterface/src/main/java/com/tencent/mobileqq/msf/sdk/MsfMessagePair.java b/qqinterface/src/main/java/com/tencent/mobileqq/msf/sdk/MsfMessagePair.java new file mode 100644 index 0000000..e6c82db --- /dev/null +++ b/qqinterface/src/main/java/com/tencent/mobileqq/msf/sdk/MsfMessagePair.java @@ -0,0 +1,18 @@ +package com.tencent.mobileqq.msf.sdk; + +import com.tencent.qphone.base.remote.FromServiceMsg; +import com.tencent.qphone.base.remote.ToServiceMsg; + +public class MsfMessagePair { + public FromServiceMsg fromServiceMsg; + public String sendProcess; + public ToServiceMsg toServiceMsg; + + public MsfMessagePair(String str, ToServiceMsg toServiceMsg, FromServiceMsg fromServiceMsg) { + + } + + public MsfMessagePair(ToServiceMsg toServiceMsg, FromServiceMsg fromServiceMsg) { + + } +} \ No newline at end of file diff --git a/qqinterface/src/main/java/com/tencent/qqnt/kernel/api/impl/MsgService.java b/qqinterface/src/main/java/com/tencent/qqnt/kernel/api/impl/MsgService.java index 3fc02e1..4fa64c0 100644 --- a/qqinterface/src/main/java/com/tencent/qqnt/kernel/api/impl/MsgService.java +++ b/qqinterface/src/main/java/com/tencent/qqnt/kernel/api/impl/MsgService.java @@ -18,6 +18,11 @@ public class MsgService { public void addMsgListener(IKernelMsgListener listener) { } + public void removeMsgListener(@NotNull IKernelMsgListener iKernelMsgListener) { + + } + + public String getRichMediaFilePathForGuild(@NotNull RichMediaFilePathInfo richMediaFilePathInfo) { return null; } diff --git a/qqinterface/src/main/java/mqq/app/AppRuntime.java b/qqinterface/src/main/java/mqq/app/AppRuntime.java index 5562807..1693142 100644 --- a/qqinterface/src/main/java/mqq/app/AppRuntime.java +++ b/qqinterface/src/main/java/mqq/app/AppRuntime.java @@ -66,6 +66,13 @@ public abstract class AppRuntime { } } + public MobileQQ getApplication() { + return null; + } + + public void startServlet(NewIntent newIntent) { + } + public T getRuntimeService(Class cls, String namespace) { throw new UnsupportedOperationException(); } diff --git a/qqinterface/src/main/java/mqq/app/NewIntent.java b/qqinterface/src/main/java/mqq/app/NewIntent.java new file mode 100644 index 0000000..91f8577 --- /dev/null +++ b/qqinterface/src/main/java/mqq/app/NewIntent.java @@ -0,0 +1,29 @@ +package mqq.app; + +import android.content.Context; +import android.content.Intent; + +import com.tencent.mobileqq.app.BusinessObserver; + +public class NewIntent extends Intent { + public boolean runNow; + + public NewIntent(Context context, Class cls) { + super(context, cls); + } + + public BusinessObserver getObserver() { + return null; + } + + public boolean isWithouLogin() { + return false; + } + + public void setObserver(BusinessObserver businessObserver) { + + } + + public void setWithouLogin(boolean z) { + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 9693397..e5f6130 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,3 +39,6 @@ include( include(":protobuf") include(":processor") include(":annotations") +include(":kritor") + +project(":kritor").projectDir = file("kritor/protos") \ No newline at end of file diff --git a/xposed/build.gradle.kts b/xposed/build.gradle.kts index e517625..8048a78 100644 --- a/xposed/build.gradle.kts +++ b/xposed/build.gradle.kts @@ -5,6 +5,7 @@ plugins { id("org.jetbrains.kotlin.android") id("kotlin-kapt") id("com.google.devtools.ksp") version "1.9.22-1.0.17" + id("com.google.protobuf") version "0.9.4" kotlin("plugin.serialization") version "1.9.22" } @@ -63,6 +64,8 @@ dependencies { compileOnly ("de.robv.android.xposed:api:82") compileOnly (project(":qqinterface")) + protobuf(project(":kritor")) + implementation(project(":protobuf")) implementation(project(":annotations")) ksp(project(":processor")) @@ -72,9 +75,7 @@ dependencies { DEPENDENCY_ANDROIDX.forEach { implementation(it) } - implementation(DEPENDENCY_JAVA_WEBSOCKET) - implementation(DEPENDENCY_PROTOBUF) - implementation(DEPENDENCY_JSON5K) + //implementation(DEPENDENCY_PROTOBUF) implementation(room("runtime")) kapt(room("compiler")) @@ -83,16 +84,15 @@ dependencies { implementation(kotlinx("io-jvm", "0.1.16")) implementation(kotlinx("serialization-protobuf", "1.6.2")) - implementation(ktor("server", "core")) - implementation(ktor("server", "host-common")) - implementation(ktor("server", "status-pages")) - implementation(ktor("server", "netty")) - implementation(ktor("server", "content-negotiation")) implementation(ktor("client", "core")) - implementation(ktor("client", "content-negotiation")) - implementation(ktor("client", "cio")) + implementation(ktor("client", "okhttp")) implementation(ktor("serialization", "kotlinx-json")) - implementation(ktor("network", "tls-certificates")) + + implementation("io.grpc:grpc-stub:1.62.2") + implementation("io.grpc:grpc-protobuf-lite:1.62.2") + implementation("com.google.protobuf:protobuf-kotlin-lite:3.25.3") + implementation("io.grpc:grpc-kotlin-stub:1.4.1") + implementation("io.grpc:grpc-okhttp:1.62.2") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") @@ -101,6 +101,45 @@ dependencies { androidTestImplementation("androidx.compose.ui:ui-test-junit4") } -tasks.withType().configureEach { - kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn" -} \ No newline at end of file +tasks.withType().all { + kotlinOptions { + freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn") + } +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:3.25.3" + } + plugins { + create("java") { + artifact = "io.grpc:protoc-gen-grpc-java:1.62.2" + } + create("grpc") { + artifact = "io.grpc:protoc-gen-grpc-java:1.62.2" + } + create("grpckt") { + artifact = "io.grpc:protoc-gen-grpc-kotlin:1.4.1:jdk8@jar" + } + } + generateProtoTasks { + all().forEach { + it.plugins { + create("java") { + option("lite") + } + create("grpc") { + option("lite") + } + create("grpckt") { + option("lite") + } + } + it.builtins { + create("kotlin") { + option("lite") + } + } + } + } +} diff --git a/xposed/src/main/assets/config.properties b/xposed/src/main/assets/config.properties new file mode 100644 index 0000000..b715d29 --- /dev/null +++ b/xposed/src/main/assets/config.properties @@ -0,0 +1,43 @@ +# Shamrock Config + +# 资源上传群组 +resource_group=883536416 + +# 强制使用平板模式 +force_tablet=false + +# 被动(反向)RPC开关 +passive_rpc=false +# 被动(反向)RPC地址 +rpc_address= +# 第一个被动RPC鉴权token +rpc_address.ticket= +# 如果有多个请使用 +# 我是第二个地址 +#rpc_address.1= +# 第二个被动RPC鉴权token +#rpc_address.1.ticket= + +# 主动(正向)RPC开关 +active_rpc=false +# 主动(正向)RPC端口 +rpc_port=5700 +# 主动RPC鉴权token +active_ticket= +# 多鉴权token支持 +# 第二个主动RPC鉴权token +#active_ticket.1= + +# 自回复开关 +#alive_reply=false +# 自回复消息 +enable_self_message=false + +# 旧BDH兼容开关 +enable_old_bdh=true + +# 反JVM调用栈跟踪 +anti_jvm_trace=true + +# 调试模式 +#debug=false \ No newline at end of file diff --git a/xposed/src/main/cpp/clover.cpp b/xposed/src/main/cpp/clover.cpp index 7eb50fa..2604690 100644 --- a/xposed/src/main/cpp/clover.cpp +++ b/xposed/src/main/cpp/clover.cpp @@ -173,7 +173,7 @@ NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) { extern "C" JNIEXPORT jboolean JNICALL -Java_moe_fuqiuluo_shamrock_xposed_hooks_AntiDetection_antiNativeDetections(JNIEnv *env, +Java_moe_fuqiuluo_shamrock_xposed_actions_AntiDetection_antiNativeDetections(JNIEnv *env, jobject thiz) { if (hook_function == nullptr) return false; hook_function((void*) __system_property_get, (void *)fake_system_property_get, (void **) &backup_system_property_get); diff --git a/xposed/src/main/java/kritor/auth/AuthInterceptor.kt b/xposed/src/main/java/kritor/auth/AuthInterceptor.kt new file mode 100644 index 0000000..b195794 --- /dev/null +++ b/xposed/src/main/java/kritor/auth/AuthInterceptor.kt @@ -0,0 +1,64 @@ +package kritor.auth + +import io.grpc.ForwardingServerCallListener +import io.grpc.Metadata +import io.grpc.ServerCall +import io.grpc.ServerCallHandler +import io.grpc.ServerInterceptor +import moe.fuqiuluo.shamrock.config.ActiveTicket +import moe.fuqiuluo.shamrock.config.ShamrockConfig + +object AuthInterceptor: ServerInterceptor { + /** + * Intercept [ServerCall] dispatch by the `next` [ServerCallHandler]. General + * semantics of [ServerCallHandler.startCall] apply and the returned + * [io.grpc.ServerCall.Listener] must not be `null`. + * + * + * If the implementation throws an exception, `call` will be closed with an error. + * Implementations must not throw an exception if they started processing that may use `call` on another thread. + * + * @param call object to receive response messages + * @param headers which can contain extra call metadata from [ClientCall.start], + * e.g. authentication credentials. + * @param next next processor in the interceptor chain + * @return listener for processing incoming messages for `call`, never `null`. + */ + override fun interceptCall( + call: ServerCall, + headers: Metadata?, + next: ServerCallHandler + ): ServerCall.Listener { + val methodName = call.methodDescriptor.fullMethodName + val ticket = getAllTicket() + if (ticket.isNotEmpty() && !methodName.startsWith("Auth")) { + val ticketHeader = headers?.get(Metadata.Key.of("ticket", Metadata.ASCII_STRING_MARSHALLER)) + if (ticketHeader == null || ticketHeader !in ticket) { + call.close(io.grpc.Status.UNAUTHENTICATED.withDescription("Invalid ticket"), Metadata()) + return object: ServerCall.Listener() {} + } + } + return object: ForwardingServerCallListener.SimpleForwardingServerCallListener(next.startCall(call, headers)) { + } + } + + fun getAllTicket(): List { + val result = arrayListOf() + val activeTicketName = ActiveTicket.name() + var index = 0 + while (true) { + val ticket = ShamrockConfig.getProperty(activeTicketName + if (index == 0) "" else ".$index", null) + if (ticket.isNullOrEmpty()) { + if (index == 0) { + return result + } else { + break + } + } else { + result.add(ticket) + } + index++ + } + return result + } +} \ No newline at end of file diff --git a/xposed/src/main/java/kritor/client/KritorClient.kt b/xposed/src/main/java/kritor/client/KritorClient.kt new file mode 100644 index 0000000..c31a0f1 --- /dev/null +++ b/xposed/src/main/java/kritor/client/KritorClient.kt @@ -0,0 +1,142 @@ +@file:OptIn(DelicateCoroutinesApi::class) +package kritor.client + +import com.google.protobuf.ByteString +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.kritor.ReverseServiceGrpcKt +import io.kritor.event.EventServiceGrpcKt +import io.kritor.event.EventType +import io.kritor.event.eventStructure +import io.kritor.event.messageEvent +import io.kritor.reverse.ReqCode +import io.kritor.reverse.Request +import io.kritor.reverse.Response +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.launch +import kritor.handlers.handleGrpc +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.internals.GlobalEventTransmitter +import kotlin.time.Duration.Companion.seconds + +internal class KritorClient( + val host: String, + val port: Int +) { + private lateinit var channel: ManagedChannel + + private lateinit var channelJob: Job + private val senderChannel = MutableSharedFlow() + + fun start() { + runCatching { + if (::channel.isInitialized && isActive()){ + channel.shutdown() + } + channel = ManagedChannelBuilder + .forAddress(host, port) + .usePlaintext() + .enableRetry() // 允许尝试 + .executor(Dispatchers.IO.asExecutor()) // 使用协程的调度器 + .build() + }.onFailure { + LogCenter.log("KritorClient start failed: ${it.stackTraceToString()}", Level.ERROR) + } + } + + fun listen(retryCnt: Int = -1) { + if (::channelJob.isInitialized && channelJob.isActive) { + channelJob.cancel() + } + channelJob = GlobalScope.launch(Dispatchers.IO) { + runCatching { + val stub = ReverseServiceGrpcKt.ReverseServiceCoroutineStub(channel) + registerEvent(EventType.EVENT_TYPE_MESSAGE) + registerEvent(EventType.EVENT_TYPE_CORE_EVENT) + registerEvent(EventType.EVENT_TYPE_REQUEST) + registerEvent(EventType.EVENT_TYPE_NOTICE) + stub.reverseStream(channelFlow { + senderChannel.collect { send(it) } + }).collect { + onReceive(it) + } + }.onFailure { + LogCenter.log("KritorClient listen failed, retry after 15s: ${it.stackTraceToString()}", Level.WARN) + } + delay(15.seconds) + LogCenter.log("KritorClient listen retrying, retryCnt = $retryCnt", Level.WARN) + if (retryCnt != 0) listen(retryCnt - 1) + } + } + + fun registerEvent(eventType: EventType) { + GlobalScope.launch(Dispatchers.IO) { + runCatching { + EventServiceGrpcKt.EventServiceCoroutineStub(channel).registerPassiveListener(channelFlow { + when(eventType) { + EventType.EVENT_TYPE_MESSAGE -> GlobalEventTransmitter.onMessageEvent { + send(eventStructure { + this.type = EventType.EVENT_TYPE_MESSAGE + this.message = it.second + }) + } + EventType.EVENT_TYPE_CORE_EVENT -> {} + EventType.EVENT_TYPE_NOTICE -> GlobalEventTransmitter.onNoticeEvent { + send(eventStructure { + this.type = EventType.EVENT_TYPE_NOTICE + this.notice = it + }) + } + EventType.EVENT_TYPE_REQUEST -> GlobalEventTransmitter.onRequestEvent { + send(eventStructure { + this.type = EventType.EVENT_TYPE_REQUEST + this.request = it + }) + } + EventType.UNRECOGNIZED -> {} + } + }) + }.onFailure { + LogCenter.log("KritorClient registerEvent failed: ${it.stackTraceToString()}", Level.ERROR) + } + } + } + + private suspend fun onReceive(request: Request) = GlobalScope.launch { + //LogCenter.log("KritorClient onReceive: $request") + runCatching { + val rsp = handleGrpc(request.cmd, request.buf.toByteArray()) + senderChannel.emit(Response.newBuilder() + .setCmd(request.cmd) + .setCode(ReqCode.SUCCESS) + .setMsg("success") + .setSeq(request.seq) + .setBuf(ByteString.copyFrom(rsp)) + .build()) + }.onFailure { + senderChannel.emit(Response.newBuilder() + .setCmd(request.cmd) + .setCode(ReqCode.INTERNAL) + .setMsg(it.stackTraceToString()) + .setSeq(request.seq) + .setBuf(ByteString.EMPTY) + .build()) + } + } + + fun isActive(): Boolean { + return !channel.isShutdown + } + + fun close() { + channel.shutdown() + } +} \ No newline at end of file diff --git a/xposed/src/main/java/kritor/handlers/GrpcHandlers.kt b/xposed/src/main/java/kritor/handlers/GrpcHandlers.kt new file mode 100644 index 0000000..9bd3aa8 --- /dev/null +++ b/xposed/src/main/java/kritor/handlers/GrpcHandlers.kt @@ -0,0 +1,6 @@ +package kritor.handlers + +internal object GrpcHandlers { + + +} \ No newline at end of file diff --git a/xposed/src/main/java/kritor/server/KritorServer.kt b/xposed/src/main/java/kritor/server/KritorServer.kt new file mode 100644 index 0000000..990c7d4 --- /dev/null +++ b/xposed/src/main/java/kritor/server/KritorServer.kt @@ -0,0 +1,45 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) +package kritor.server + +import io.grpc.Grpc +import io.grpc.InsecureServerCredentials +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asExecutor +import kritor.auth.AuthInterceptor +import kritor.service.* +import moe.fuqiuluo.shamrock.helper.LogCenter +import kotlin.coroutines.CoroutineContext + +class KritorServer( + private val port: Int +): CoroutineScope { + private val server = Grpc.newServerBuilderForPort(port, InsecureServerCredentials.create()) + .executor(Dispatchers.IO.asExecutor()) + .intercept(AuthInterceptor) + .addService(Authentication) + .addService(ContactService) + .addService(KritorService) + .addService(FriendService) + .addService(GroupService) + .addService(GroupFileService) + .addService(MessageService) + .addService(EventService) + .addService(ForwardMessageService) + .addService(WebService) + .addService(DeveloperService) + .build()!! + + fun start(block: Boolean = false) { + LogCenter.log("KritorServer started at port $port.") + server.start() + + if (block) { + server.awaitTermination() + } + } + + override val coroutineContext: CoroutineContext = + Dispatchers.IO.limitedParallelism(12) +} \ No newline at end of file diff --git a/xposed/src/main/java/kritor/service/Authentication.kt b/xposed/src/main/java/kritor/service/Authentication.kt new file mode 100644 index 0000000..20aac7a --- /dev/null +++ b/xposed/src/main/java/kritor/service/Authentication.kt @@ -0,0 +1,66 @@ +package kritor.service + +import io.grpc.Status +import io.grpc.StatusRuntimeException +import io.kritor.AuthCode +import io.kritor.AuthReq +import io.kritor.AuthRsp +import io.kritor.AuthenticationGrpcKt +import io.kritor.GetAuthStateReq +import io.kritor.GetAuthStateRsp +import io.kritor.authRsp +import io.kritor.getAuthStateRsp +import kritor.auth.AuthInterceptor +import moe.fuqiuluo.shamrock.config.ActiveTicket +import moe.fuqiuluo.shamrock.config.ShamrockConfig +import qq.service.QQInterfaces + +internal object Authentication: AuthenticationGrpcKt.AuthenticationCoroutineImplBase() { + @Grpc("Authentication", "Auth") + override suspend fun auth(request: AuthReq): AuthRsp { + if (QQInterfaces.app.account != request.account) { + return authRsp { + code = AuthCode.NO_ACCOUNT + msg = "No such account" + } + } + + val activeTicketName = ActiveTicket.name() + var index = 0 + while (true) { + val ticket = ShamrockConfig.getProperty(activeTicketName + if (index == 0) "" else ".$index", null) + if (ticket.isNullOrEmpty()) { + if (index == 0) { + return authRsp { + code = AuthCode.OK + msg = "OK" + } + } else { + break + } + } else if (ticket == request.ticket) { + return authRsp { + code = AuthCode.OK + msg = "OK" + } + } + index++ + } + + return authRsp { + code = AuthCode.NO_TICKET + msg = "Invalid ticket" + } + } + + @Grpc("Authentication", "GetAuthState") + override suspend fun getAuthState(request: GetAuthStateReq): GetAuthStateRsp { + if (request.account != QQInterfaces.app.account) { + throw StatusRuntimeException(Status.CANCELLED.withDescription("No such account")) + } + + return getAuthStateRsp { + isRequiredAuth = AuthInterceptor.getAllTicket().isNotEmpty() + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/kritor/service/ContactService.kt b/xposed/src/main/java/kritor/service/ContactService.kt new file mode 100644 index 0000000..8dc8de7 --- /dev/null +++ b/xposed/src/main/java/kritor/service/ContactService.kt @@ -0,0 +1,183 @@ +package kritor.service + +import android.os.Bundle +import com.tencent.mobileqq.profilecard.api.IProfileCardBlacklistApi +import com.tencent.mobileqq.profilecard.api.IProfileProtocolConst.* +import com.tencent.mobileqq.profilecard.api.IProfileProtocolService +import com.tencent.mobileqq.qroute.QRoute +import io.grpc.Status +import io.grpc.StatusRuntimeException +import io.kritor.contact.ContactServiceGrpcKt +import io.kritor.contact.GetUidRequest +import io.kritor.contact.GetUidResponse +import io.kritor.contact.GetUinByUidRequest +import io.kritor.contact.GetUinByUidResponse +import io.kritor.contact.IsBlackListUserRequest +import io.kritor.contact.IsBlackListUserResponse +import io.kritor.contact.ProfileCard +import io.kritor.contact.ProfileCardRequest +import io.kritor.contact.SetProfileCardRequest +import io.kritor.contact.SetProfileCardResponse +import io.kritor.contact.StrangerExt +import io.kritor.contact.StrangerInfo +import io.kritor.contact.StrangerInfoRequest +import io.kritor.contact.VoteUserRequest +import io.kritor.contact.VoteUserResponse +import io.kritor.contact.profileCard +import io.kritor.contact.strangerInfo +import io.kritor.contact.voteUserResponse +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import qq.service.QQInterfaces +import qq.service.contact.ContactHelper +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +internal object ContactService: ContactServiceGrpcKt.ContactServiceCoroutineImplBase() { + @Grpc("ContactService", "VoteUser") + override suspend fun voteUser(request: VoteUserRequest): VoteUserResponse { + ContactHelper.voteUser(when(request.accountCase!!) { + VoteUserRequest.AccountCase.ACCOUNT_UIN -> request.accountUin + VoteUserRequest.AccountCase.ACCOUNT_UID -> ContactHelper.getUinByUidAsync(request.accountUid).toLong() + VoteUserRequest.AccountCase.ACCOUNT_NOT_SET -> throw StatusRuntimeException(Status.INVALID_ARGUMENT + .withDescription("account not set") + ) + }, request.voteCount).onFailure { + throw StatusRuntimeException(Status.INTERNAL + .withDescription(it.stackTraceToString()) + ) + } + return voteUserResponse { } + } + + @Grpc("ContactService", "GetProfileCard") + override suspend fun getProfileCard(request: ProfileCardRequest): ProfileCard { + val uin = when (request.accountCase!!) { + ProfileCardRequest.AccountCase.ACCOUNT_UIN -> request.accountUin + ProfileCardRequest.AccountCase.ACCOUNT_UID -> ContactHelper.getUinByUidAsync(request.accountUid).toLong() + ProfileCardRequest.AccountCase.ACCOUNT_NOT_SET -> throw StatusRuntimeException(Status.INVALID_ARGUMENT + .withDescription("account not set") + ) + } + + val contact = ContactHelper.getProfileCard(uin) + + contact.onFailure { + throw StatusRuntimeException(Status.INTERNAL + .withDescription(it.stackTraceToString()) + ) + } + + contact.onSuccess { + return profileCard { + this.uin = it.uin.toLong() + this.uid = if (request.hasAccountUid()) request.accountUid + else ContactHelper.getUidByUinAsync(it.uin.toLong()) + this.name = it.strNick ?: "" + this.remark = it.strReMark ?: "" + this.level = it.iQQLevel + this.birthday = it.lBirthday + this.loginDay = it.lLoginDays.toInt() + this.voteCnt = it.lVoteCount.toInt() + this.qid = it.qid ?: "" + this.isSchoolVerified = it.schoolVerifiedFlag + } + } + + throw StatusRuntimeException(Status.INTERNAL + .withDescription("logic failed") + ) + } + + @Grpc("ContactService", "GetStrangerInfo") + override suspend fun getStrangerInfo(request: StrangerInfoRequest): StrangerInfo { + val userId = request.uin + val info = ContactHelper.refreshAndGetProfileCard(userId).onFailure { + throw StatusRuntimeException(Status.INTERNAL + .withCause(it) + .withDescription("Unable to fetch stranger info") + ) + }.getOrThrow() + + return strangerInfo { + this.uid = ContactHelper.getUidByUinAsync(userId) + this.uin = (info.uin ?: "0").toLong() + this.name = info.strNick ?: "" + this.level = info.iQQLevel + this.loginDay = info.lLoginDays.toInt() + this.voteCnt = info.lVoteCount.toInt() + this.qid = info.qid ?: "" + this.isSchoolVerified = info.schoolVerifiedFlag + this.ext = StrangerExt.newBuilder() + .setBigVip(info.bBigClubVipOpen == 1.toByte()) + .setHollywoodVip(info.bHollywoodVipOpen == 1.toByte()) + .setQqVip(info.bQQVipOpen == 1.toByte()) + .setSuperVip(info.bSuperQQOpen == 1.toByte()) + .setVoted(info.bVoted == 1.toByte()) + .build().toByteString() + } + } + + @Grpc("ContactService", "GetUid") + override suspend fun getUid(request: GetUidRequest): GetUidResponse { + return GetUidResponse.newBuilder().apply { + request.uinList.forEach { + putUid(it, ContactHelper.getUidByUinAsync(it)) + } + }.build() + } + + @Grpc("ContactService", "GetUinByUid") + override suspend fun getUinByUid(request: GetUinByUidRequest): GetUinByUidResponse { + return GetUinByUidResponse.newBuilder().apply { + request.uidList.forEach { + putUin(it, ContactHelper.getUinByUidAsync(it).toLong()) + } + }.build() + } + + @Grpc("ContactService", "SetProfileCard") + override suspend fun setProfileCard(request: SetProfileCardRequest): SetProfileCardResponse { + val bundle = Bundle() + val service = QQInterfaces.app + .getRuntimeService(IProfileProtocolService::class.java, "all") + if (request.hasNickName()) { + bundle.putString(KEY_NICK, request.nickName) + } + if (request.hasCompany()) { + bundle.putString(KEY_COMPANY, request.company) + } + if (request.hasEmail()) { + bundle.putString(KEY_EMAIL, request.email) + } + if (request.hasCollege()) { + bundle.putString(KEY_COLLEGE, request.college) + } + if (request.hasPersonalNote()) { + bundle.putString(KEY_PERSONAL_NOTE, request.personalNote) + } + + if (request.hasBirthday()) { + bundle.putInt(KEY_BIRTHDAY, request.birthday) + } + if (request.hasAge()) { + bundle.putInt(KEY_AGE, request.age) + } + + service.setProfileDetail(bundle) + return super.setProfileCard(request) + } + + @Grpc("ContactService", "IsBlackListUser") + override suspend fun isBlackListUser(request: IsBlackListUserRequest): IsBlackListUserResponse { + val blacklistApi = QRoute.api(IProfileCardBlacklistApi::class.java) + val isBlack = withTimeoutOrNull(5000) { + suspendCancellableCoroutine { continuation -> + blacklistApi.isBlackOrBlackedUin(request.uin.toString()) { + continuation.resume(it) + } + } + } ?: false + return IsBlackListUserResponse.newBuilder().setIsBlackListUser(isBlack).build() + } +} \ No newline at end of file diff --git a/xposed/src/main/java/kritor/service/DeveloperService.kt b/xposed/src/main/java/kritor/service/DeveloperService.kt new file mode 100644 index 0000000..4577fc6 --- /dev/null +++ b/xposed/src/main/java/kritor/service/DeveloperService.kt @@ -0,0 +1,50 @@ +package kritor.service + +import com.google.protobuf.ByteString +import com.tencent.mobileqq.fe.FEKit +import com.tencent.mobileqq.qsec.qsecdandelionsdk.Dandelion +import io.kritor.developer.DeveloperServiceGrpcKt +import io.kritor.developer.EnergyRequest +import io.kritor.developer.EnergyResponse +import io.kritor.developer.SendPacketRequest +import io.kritor.developer.SendPacketResponse +import io.kritor.developer.SignRequest +import io.kritor.developer.SignResponse +import io.kritor.developer.energyResponse +import io.kritor.developer.sendPacketResponse +import io.kritor.developer.signResponse +import qq.service.QQInterfaces + +internal object DeveloperService: DeveloperServiceGrpcKt.DeveloperServiceCoroutineImplBase() { + @Grpc("DeveloperService", "Sign") + override suspend fun sign(request: SignRequest): SignResponse { + return signResponse { + val result = FEKit.getInstance().getSign(request.command, request.buffer.toByteArray(), request.seq, request.uin) + this.sign = ByteString.copyFrom(result.sign) + this.token = ByteString.copyFrom(result.token) + this.extra = ByteString.copyFrom(result.extra) + } + } + + @Grpc("DeveloperService", "Energy") + override suspend fun energy(request: EnergyRequest): EnergyResponse { + return energyResponse { + this.result = ByteString.copyFrom(Dandelion.getInstance().fly(request.data, request.salt.toByteArray())) + } + } + + + @Grpc("DeveloperService", "SendPacket") + override suspend fun sendPacket(request: SendPacketRequest): SendPacketResponse { + return sendPacketResponse { + val fromServiceMsg = QQInterfaces.sendBufferAW(request.command, request.isProtobuf, request.requestBuffer.toByteArray()) + if (fromServiceMsg?.wupBuffer == null) { + this.isSuccess = false + } else { + this.isSuccess = true + this.responseBuffer = ByteString.copyFrom(fromServiceMsg.wupBuffer) + } + } + } + +} \ No newline at end of file diff --git a/xposed/src/main/java/kritor/service/EventService.kt b/xposed/src/main/java/kritor/service/EventService.kt new file mode 100644 index 0000000..bededa2 --- /dev/null +++ b/xposed/src/main/java/kritor/service/EventService.kt @@ -0,0 +1,42 @@ +package kritor.service + +import io.grpc.Status +import io.grpc.StatusRuntimeException +import io.kritor.event.EventRequest +import io.kritor.event.EventServiceGrpcKt +import io.kritor.event.EventStructure +import io.kritor.event.EventType +import io.kritor.event.RequestPushEvent +import io.kritor.event.eventStructure +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import moe.fuqiuluo.shamrock.internals.GlobalEventTransmitter + +internal object EventService: EventServiceGrpcKt.EventServiceCoroutineImplBase() { + override fun registerActiveListener(request: RequestPushEvent): Flow { + return channelFlow { + when(request.type!!) { + EventType.EVENT_TYPE_CORE_EVENT -> {} + EventType.EVENT_TYPE_MESSAGE -> GlobalEventTransmitter.onMessageEvent { + send(eventStructure { + this.type = EventType.EVENT_TYPE_MESSAGE + this.message = it.second + }) + } + EventType.EVENT_TYPE_NOTICE -> GlobalEventTransmitter.onRequestEvent { + send(eventStructure { + this.type = EventType.EVENT_TYPE_NOTICE + this.request = it + }) + } + EventType.EVENT_TYPE_REQUEST -> GlobalEventTransmitter.onNoticeEvent { + send(eventStructure { + this.type = EventType.EVENT_TYPE_NOTICE + this.notice = it + }) + } + EventType.UNRECOGNIZED -> throw StatusRuntimeException(Status.INVALID_ARGUMENT) + } + } + } +} diff --git a/xposed/src/main/java/kritor/service/ForwardMessageService.kt b/xposed/src/main/java/kritor/service/ForwardMessageService.kt new file mode 100644 index 0000000..a6b91f6 --- /dev/null +++ b/xposed/src/main/java/kritor/service/ForwardMessageService.kt @@ -0,0 +1,50 @@ +package kritor.service + +import com.tencent.qqnt.kernel.nativeinterface.MsgConstant +import com.tencent.qqnt.kernel.nativeinterface.MsgElement +import io.grpc.Status +import io.grpc.StatusRuntimeException +import io.kritor.message.Element +import io.kritor.message.ElementType +import io.kritor.message.ForwardMessageRequest +import io.kritor.message.ForwardMessageResponse +import io.kritor.message.ForwardMessageServiceGrpcKt +import io.kritor.message.Scene +import io.kritor.message.element +import io.kritor.message.forwardMessageResponse +import qq.service.contact.longPeer +import qq.service.msg.ForwardMessageHelper +import qq.service.msg.MessageHelper +import qq.service.msg.NtMsgConvertor + +internal object ForwardMessageService: ForwardMessageServiceGrpcKt.ForwardMessageServiceCoroutineImplBase() { + @Grpc("ForwardMessageService", "ForwardMessage") + override suspend fun forwardMessage(request: ForwardMessageRequest): ForwardMessageResponse { + val contact = request.contact.let { + MessageHelper.generateContact(when(it.scene!!) { + Scene.GROUP -> MsgConstant.KCHATTYPEGROUP + Scene.FRIEND -> MsgConstant.KCHATTYPEC2C + Scene.GUILD -> MsgConstant.KCHATTYPEGUILD + Scene.STRANGER_FROM_GROUP -> MsgConstant.KCHATTYPETEMPC2CFROMGROUP + Scene.NEARBY -> MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN + Scene.STRANGER -> MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN + Scene.UNRECOGNIZED -> throw StatusRuntimeException(Status.INVALID_ARGUMENT.withDescription("Unrecognized scene")) + }, it.peer, it.subPeer) + } + + val forwardMessage = ForwardMessageHelper.uploadMultiMsg(contact.chatType, contact.longPeer().toString(), contact.guildId, request.messagesList).onFailure { + throw StatusRuntimeException(Status.INTERNAL.withCause(it)) + }.getOrThrow() + + val uniseq = MessageHelper.generateMsgId(contact.chatType) + return forwardMessageResponse { + this.messageId = MessageHelper.sendMessage(contact, NtMsgConvertor.convertToNtMsgs(contact, uniseq, arrayListOf(element { + this.type = ElementType.FORWARD + this.forward = forwardMessage + })), request.retryCount, uniseq).onFailure { + throw StatusRuntimeException(Status.INTERNAL.withCause(it)) + }.getOrThrow() + this.resId = forwardMessage.id + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/kritor/service/FriendService.kt b/xposed/src/main/java/kritor/service/FriendService.kt new file mode 100644 index 0000000..bd45e4b --- /dev/null +++ b/xposed/src/main/java/kritor/service/FriendService.kt @@ -0,0 +1,41 @@ +package kritor.service + +import io.grpc.Status +import io.grpc.StatusRuntimeException +import io.kritor.friend.FriendServiceGrpcKt +import io.kritor.friend.GetFriendListRequest +import io.kritor.friend.GetFriendListResponse +import io.kritor.friend.friendData +import io.kritor.friend.friendExt +import io.kritor.friend.getFriendListResponse +import qq.service.contact.ContactHelper +import qq.service.friend.FriendHelper + +internal object FriendService: FriendServiceGrpcKt.FriendServiceCoroutineImplBase() { + @Grpc("FriendService", "GetFriendList") + override suspend fun getFriendList(request: GetFriendListRequest): GetFriendListResponse { + val friendList = FriendHelper.getFriendList(if(request.hasRefresh()) request.refresh else false).onFailure { + throw StatusRuntimeException(Status.INTERNAL + .withDescription(it.stackTraceToString()) + ) + }.getOrThrow() + + return getFriendListResponse { + friendList.forEach { + this.friendList.add(friendData { + uin = it.uin.toLong() + uid = ContactHelper.getUidByUinAsync(uin) + qid = "" + nick = it.name ?: "" + remark = it.remark ?: "" + age = it.age + level = 0 + gender = it.gender.toInt() + groupId = it.groupid + ext = friendExt {}.toByteString() + }) + } + } + } + +} \ No newline at end of file diff --git a/xposed/src/main/java/kritor/service/GroupFileService.kt b/xposed/src/main/java/kritor/service/GroupFileService.kt new file mode 100644 index 0000000..b67685a --- /dev/null +++ b/xposed/src/main/java/kritor/service/GroupFileService.kt @@ -0,0 +1,139 @@ +package kritor.service + +import io.grpc.Status +import io.grpc.StatusRuntimeException +import io.kritor.file.* +import moe.fuqiuluo.shamrock.tools.slice +import moe.fuqiuluo.symbols.decodeProtobuf +import protobuf.auto.toByteArray +import protobuf.oidb.cmd0x6d7.CreateFolderReq +import protobuf.oidb.cmd0x6d7.DeleteFolderReq +import protobuf.oidb.cmd0x6d7.Oidb0x6d7ReqBody +import protobuf.oidb.cmd0x6d7.Oidb0x6d7RespBody +import protobuf.oidb.cmd0x6d7.RenameFolderReq +import qq.service.QQInterfaces +import qq.service.file.GroupFileHelper +import qq.service.file.GroupFileHelper.getGroupFileSystemInfo +import tencent.im.oidb.cmd0x6d6.oidb_0x6d6 +import tencent.im.oidb.cmd0x6d8.oidb_0x6d8 +import tencent.im.oidb.oidb_sso + +internal object GroupFileService: GroupFileServiceGrpcKt.GroupFileServiceCoroutineImplBase() { + @Grpc("GroupFileService", "CreateFolder") + override suspend fun createFolder(request: CreateFolderRequest): CreateFolderResponse { + val data = Oidb0x6d7ReqBody( + createFolder = CreateFolderReq( + groupCode = request.groupId.toULong(), + appId = 3u, + parentFolderId = "/", + folderName = request.name + ) + ).toByteArray() + val fromServiceMsg = QQInterfaces.sendOidbAW("OidbSvc.0x6d7_0", 1751, 0, data) + ?: throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to send oidb request")) + if (fromServiceMsg.wupBuffer == null) { + throw StatusRuntimeException(Status.INTERNAL.withDescription("oidb request failed")) + } + val oidbPkg = oidb_sso.OIDBSSOPkg() + oidbPkg.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + val rsp = oidbPkg.bytes_bodybuffer.get() + .toByteArray() + .decodeProtobuf() + if (rsp.createFolder?.retCode != 0) { + throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to create folder: ${rsp.createFolder?.retCode}")) + } + return createFolderResponse { + this.id = rsp.createFolder?.folderInfo?.folderId ?: "" + this.usedSpace = 0 + } + } + + @Grpc("GroupFileService", "DeleteFolder") + override suspend fun deleteFolder(request: DeleteFolderRequest): DeleteFolderResponse { + val fromServiceMsg = QQInterfaces.sendOidbAW("OidbSvc.0x6d7_1", 1751, 1, Oidb0x6d7ReqBody( + deleteFolder = DeleteFolderReq( + groupCode = request.groupId.toULong(), + appId = 3u, + folderId = request.folderId + ) + ).toByteArray()) ?: throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to send oidb request")) + if (fromServiceMsg.wupBuffer == null) { + throw StatusRuntimeException(Status.INTERNAL.withDescription("oidb request failed")) + } + val oidbPkg = oidb_sso.OIDBSSOPkg() + oidbPkg.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + val rsp = oidbPkg.bytes_bodybuffer.get().toByteArray().decodeProtobuf() + if (rsp.deleteFolder?.retCode != 0) { + throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to delete folder: ${rsp.deleteFolder?.retCode}")) + } + return deleteFolderResponse { } + } + + @Grpc("GroupFileService", "DeleteFile") + override suspend fun deleteFile(request: DeleteFileRequest): DeleteFileResponse { + val oidb0x6d6ReqBody = oidb_0x6d6.ReqBody().apply { + delete_file_req.set(oidb_0x6d6.DeleteFileReqBody().apply { + uint64_group_code.set(request.groupId) + uint32_app_id.set(3) + uint32_bus_id.set(request.busId) + str_parent_folder_id.set("/") + str_file_id.set(request.fileId) + }) + } + val fromServiceMsg = QQInterfaces.sendOidbAW("OidbSvc.0x6d6_3", 1750, 3, oidb0x6d6ReqBody.toByteArray()) + ?: throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to send oidb request")) + if (fromServiceMsg.wupBuffer == null) { + throw StatusRuntimeException(Status.INTERNAL.withDescription("oidb request failed")) + } + val oidbPkg = oidb_sso.OIDBSSOPkg() + oidbPkg.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + val rsp = oidb_0x6d6.RspBody().apply { + mergeFrom(oidbPkg.bytes_bodybuffer.get().toByteArray()) + } + if (rsp.delete_file_rsp.int32_ret_code.get() != 0) { + throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to delete file: ${rsp.delete_file_rsp.int32_ret_code.get()}")) + } + return deleteFileResponse { } + } + + @Grpc("GroupFileService", "RenameFolder") + override suspend fun renameFolder(request: RenameFolderRequest): RenameFolderResponse { + val fromServiceMsg = QQInterfaces.sendOidbAW("OidbSvc.0x6d7_3", 1751, 3, Oidb0x6d7ReqBody( + renameFolder = RenameFolderReq( + groupCode = request.groupId.toULong(), + appId = 3u, + folderId = request.folderId, + folderName = request.name + ) + ).toByteArray()) ?: throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to send oidb request")) + if (fromServiceMsg.wupBuffer == null) { + throw StatusRuntimeException(Status.INTERNAL.withDescription("oidb request failed")) + } + val oidbPkg = oidb_sso.OIDBSSOPkg() + oidbPkg.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + val rsp = oidbPkg.bytes_bodybuffer.get().toByteArray().decodeProtobuf() + if (rsp.renameFolder?.retCode != 0) { + throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to rename folder: ${rsp.renameFolder?.retCode}")) + } + return renameFolderResponse { } + } + + @Grpc("GroupFileService", "GetFileSystemInfo") + override suspend fun getFileSystemInfo(request: GetFileSystemInfoRequest): GetFileSystemInfoResponse { + return getGroupFileSystemInfo(request.groupId) + } + + @Grpc("GroupFileService", "GetRootFiles") + override suspend fun getRootFiles(request: GetRootFilesRequest): GetRootFilesResponse { + return getRootFilesResponse { + val response = GroupFileHelper.getGroupFiles(request.groupId) + this.files.addAll(response.filesList) + this.folders.addAll(response.foldersList) + } + } + + @Grpc("GroupFileService", "GetFiles") + override suspend fun getFiles(request: GetFilesRequest): GetFilesResponse { + return GroupFileHelper.getGroupFiles(request.groupId, request.folderId) + } +} \ No newline at end of file diff --git a/xposed/src/main/java/kritor/service/GroupService.kt b/xposed/src/main/java/kritor/service/GroupService.kt new file mode 100644 index 0000000..8f98627 --- /dev/null +++ b/xposed/src/main/java/kritor/service/GroupService.kt @@ -0,0 +1,405 @@ +package kritor.service + +import io.grpc.Status +import io.grpc.StatusRuntimeException +import io.kritor.group.BanMemberRequest +import io.kritor.group.BanMemberResponse +import io.kritor.group.GetGroupHonorRequest +import io.kritor.group.GetGroupHonorResponse +import io.kritor.group.GetGroupInfoRequest +import io.kritor.group.GetGroupInfoResponse +import io.kritor.group.GetGroupListRequest +import io.kritor.group.GetGroupListResponse +import io.kritor.group.GetGroupMemberInfoRequest +import io.kritor.group.GetGroupMemberInfoResponse +import io.kritor.group.GetGroupMemberListRequest +import io.kritor.group.GetGroupMemberListResponse +import io.kritor.group.GetNotJoinedGroupInfoRequest +import io.kritor.group.GetNotJoinedGroupInfoResponse +import io.kritor.group.GetProhibitedUserListRequest +import io.kritor.group.GetProhibitedUserListResponse +import io.kritor.group.GetRemainCountAtAllRequest +import io.kritor.group.GetRemainCountAtAllResponse +import io.kritor.group.GroupServiceGrpcKt +import io.kritor.group.KickMemberRequest +import io.kritor.group.KickMemberResponse +import io.kritor.group.LeaveGroupRequest +import io.kritor.group.LeaveGroupResponse +import io.kritor.group.ModifyGroupNameRequest +import io.kritor.group.ModifyGroupNameResponse +import io.kritor.group.ModifyGroupRemarkRequest +import io.kritor.group.ModifyGroupRemarkResponse +import io.kritor.group.ModifyMemberCardRequest +import io.kritor.group.ModifyMemberCardResponse +import io.kritor.group.PokeMemberRequest +import io.kritor.group.PokeMemberResponse +import io.kritor.group.SetGroupAdminRequest +import io.kritor.group.SetGroupAdminResponse +import io.kritor.group.SetGroupUniqueTitleRequest +import io.kritor.group.SetGroupUniqueTitleResponse +import io.kritor.group.SetGroupWholeBanRequest +import io.kritor.group.SetGroupWholeBanResponse +import io.kritor.group.banMemberResponse +import io.kritor.group.getGroupHonorResponse +import io.kritor.group.getGroupInfoResponse +import io.kritor.group.getGroupListResponse +import io.kritor.group.getGroupMemberInfoResponse +import io.kritor.group.getGroupMemberListResponse +import io.kritor.group.getNotJoinedGroupInfoResponse +import io.kritor.group.getProhibitedUserListResponse +import io.kritor.group.getRemainCountAtAllResponse +import io.kritor.group.groupHonorInfo +import io.kritor.group.groupMemberInfo +import io.kritor.group.kickMemberResponse +import io.kritor.group.leaveGroupResponse +import io.kritor.group.modifyGroupNameResponse +import io.kritor.group.modifyGroupRemarkResponse +import io.kritor.group.modifyMemberCardResponse +import io.kritor.group.notJoinedGroupInfo +import io.kritor.group.pokeMemberResponse +import io.kritor.group.prohibitedUserInfo +import io.kritor.group.setGroupAdminResponse +import io.kritor.group.setGroupUniqueTitleResponse +import io.kritor.group.setGroupWholeBanResponse +import moe.fuqiuluo.shamrock.helper.TroopHonorHelper.decodeHonor +import moe.fuqiuluo.shamrock.tools.ifNullOrEmpty +import qq.service.contact.ContactHelper +import qq.service.group.GroupHelper + +internal object GroupService: GroupServiceGrpcKt.GroupServiceCoroutineImplBase() { + @Grpc("GroupService", "BanMember") + override suspend fun banMember(request: BanMemberRequest): BanMemberResponse { + if (!GroupHelper.isAdmin(request.groupId.toString())) { + throw StatusRuntimeException(Status.PERMISSION_DENIED + .withDescription("You are not admin of this group") + ) + } + + GroupHelper.banMember(request.groupId, when(request.targetCase!!) { + BanMemberRequest.TargetCase.TARGET_UIN -> request.targetUin + BanMemberRequest.TargetCase.TARGET_UID -> ContactHelper.getUinByUidAsync(request.targetUid).toLong() + else -> throw StatusRuntimeException(Status.INVALID_ARGUMENT + .withDescription("target not set") + ) + }, request.duration) + + return banMemberResponse { + groupId = request.groupId + } + } + + @Grpc("GroupService", "PokeMember", ) + override suspend fun pokeMember(request: PokeMemberRequest): PokeMemberResponse { + GroupHelper.pokeMember(request.groupId, when(request.targetCase!!) { + PokeMemberRequest.TargetCase.TARGET_UIN -> request.targetUin + PokeMemberRequest.TargetCase.TARGET_UID -> ContactHelper.getUinByUidAsync(request.targetUid).toLong() + else -> throw StatusRuntimeException(Status.INVALID_ARGUMENT + .withDescription("target not set") + ) + }) + return pokeMemberResponse { } + } + + @Grpc("GroupService", "KickMember") + override suspend fun kickMember(request: KickMemberRequest): KickMemberResponse { + if (!GroupHelper.isAdmin(request.groupId.toString())) { + throw StatusRuntimeException(Status.PERMISSION_DENIED + .withDescription("You are not admin of this group") + ) + } + GroupHelper.kickMember(request.groupId, request.rejectAddRequest, if (request.hasKickReason()) request.kickReason else "", when(request.targetCase!!) { + KickMemberRequest.TargetCase.TARGET_UIN -> request.targetUin + KickMemberRequest.TargetCase.TARGET_UID -> ContactHelper.getUinByUidAsync(request.targetUid).toLong() + else -> throw StatusRuntimeException(Status.INVALID_ARGUMENT + .withDescription("target not set") + ) + }) + return kickMemberResponse { } + } + + @Grpc("GroupService", "LeaveGroup") + override suspend fun leaveGroup(request: LeaveGroupRequest): LeaveGroupResponse { + GroupHelper.resignTroop(request.groupId.toString()) + return leaveGroupResponse { } + } + + @Grpc("GroupService", "ModifyMemberCard") + override suspend fun modifyMemberCard(request: ModifyMemberCardRequest): ModifyMemberCardResponse { + if (!GroupHelper.isAdmin(request.groupId.toString())) { + throw StatusRuntimeException(Status.PERMISSION_DENIED + .withDescription("You are not admin of this group") + ) + } + GroupHelper.modifyGroupMemberCard(request.groupId, when(request.targetCase!!) { + ModifyMemberCardRequest.TargetCase.TARGET_UIN -> request.targetUin + ModifyMemberCardRequest.TargetCase.TARGET_UID -> ContactHelper.getUinByUidAsync(request.targetUid).toLong() + else -> throw StatusRuntimeException(Status.INVALID_ARGUMENT + .withDescription("target not set") + ) + }, request.card) + return modifyMemberCardResponse { } + } + + @Grpc("GroupService", "ModifyGroupName") + override suspend fun modifyGroupName(request: ModifyGroupNameRequest): ModifyGroupNameResponse { + if (!GroupHelper.isAdmin(request.groupId.toString())) { + throw StatusRuntimeException(Status.PERMISSION_DENIED + .withDescription("You are not admin of this group") + ) + } + + GroupHelper.modifyTroopName(request.groupId.toString(), request.groupName) + + return modifyGroupNameResponse { } + } + + @Grpc("GroupService", "ModifyGroupRemark") + override suspend fun modifyGroupRemark(request: ModifyGroupRemarkRequest): ModifyGroupRemarkResponse { + GroupHelper.modifyGroupRemark(request.groupId, request.remark) + + return modifyGroupRemarkResponse { } + } + + @Grpc("GroupService", "SetGroupAdmin") + override suspend fun setGroupAdmin(request: SetGroupAdminRequest): SetGroupAdminResponse { + if (!GroupHelper.isOwner(request.groupId.toString())) { + throw StatusRuntimeException(Status.PERMISSION_DENIED + .withDescription("You are not admin of this group") + ) + } + + GroupHelper.setGroupAdmin(request.groupId, when(request.targetCase!!) { + SetGroupAdminRequest.TargetCase.TARGET_UIN -> request.targetUin + SetGroupAdminRequest.TargetCase.TARGET_UID -> ContactHelper.getUinByUidAsync(request.targetUid).toLong() + else -> throw StatusRuntimeException(Status.INVALID_ARGUMENT + .withDescription("target not set") + ) + }, request.isAdmin) + + return setGroupAdminResponse { } + } + + @Grpc("GroupService", "SetGroupUniqueTitle") + override suspend fun setGroupUniqueTitle(request: SetGroupUniqueTitleRequest): SetGroupUniqueTitleResponse { + if (!GroupHelper.isAdmin(request.groupId.toString())) { + throw StatusRuntimeException(Status.PERMISSION_DENIED + .withDescription("You are not admin of this group") + ) + } + + GroupHelper.setGroupUniqueTitle(request.groupId.toString(), when(request.targetCase!!) { + SetGroupUniqueTitleRequest.TargetCase.TARGET_UIN -> request.targetUin + SetGroupUniqueTitleRequest.TargetCase.TARGET_UID -> ContactHelper.getUinByUidAsync(request.targetUid).toLong() + else -> throw StatusRuntimeException(Status.INVALID_ARGUMENT + .withDescription("target not set") + ) + }.toString(), request.uniqueTitle) + + return setGroupUniqueTitleResponse { } + } + + @Grpc("GroupService", "SetGroupWholeBan") + override suspend fun setGroupWholeBan(request: SetGroupWholeBanRequest): SetGroupWholeBanResponse { + if (!GroupHelper.isAdmin(request.groupId.toString())) { + throw StatusRuntimeException(Status.PERMISSION_DENIED + .withDescription("You are not admin of this group") + ) + } + + GroupHelper.setGroupWholeBan(request.groupId, request.isBan) + return setGroupWholeBanResponse { } + } + + @Grpc("GroupService", "GetGroupInfo") + override suspend fun getGroupInfo(request: GetGroupInfoRequest): GetGroupInfoResponse { + val groupInfo = GroupHelper.getGroupInfo(request.groupId.toString(), true).onFailure { + throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to get group info").withCause(it)) + }.getOrThrow() + return getGroupInfoResponse { + this.groupInfo = io.kritor.group.groupInfo { + groupId = groupInfo.troopcode.toLong() + groupName = groupInfo.troopname.ifNullOrEmpty { groupInfo.troopRemark }.ifNullOrEmpty { groupInfo.newTroopName } ?: "" + groupRemark = groupInfo.troopRemark ?: "" + owner = groupInfo.troopowneruin?.toLong() ?: 0 + admins.addAll(GroupHelper.getAdminList(groupId)) + maxMemberCount = groupInfo.wMemberMax + memberCount = groupInfo.wMemberNum + groupUin = groupInfo.troopuin?.toLong() ?: 0 + } + } + } + + @Grpc("GroupService", "GetGroupList") + override suspend fun getGroupList(request: GetGroupListRequest): GetGroupListResponse { + val groupList = GroupHelper.getGroupList(if (request.hasRefresh()) request.refresh else false).onFailure { + throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to get group list").withCause(it)) + }.getOrThrow() + return getGroupListResponse { + groupList.forEach { groupInfo -> + this.groupInfo.add(io.kritor.group.groupInfo { + groupId = groupInfo.troopcode.toLong() + groupName = groupInfo.troopname.ifNullOrEmpty { groupInfo.troopRemark }.ifNullOrEmpty { groupInfo.newTroopName } ?: "" + groupRemark = groupInfo.troopRemark ?: "" + owner = groupInfo.troopowneruin?.toLong() ?: 0 + admins.addAll(GroupHelper.getAdminList(groupId)) + maxMemberCount = groupInfo.wMemberMax + memberCount = groupInfo.wMemberNum + groupUin = groupInfo.troopuin?.toLong() ?: 0 + }) + } + } + } + + @Grpc("GroupService", "GetGroupMemberInfo") + override suspend fun getGroupMemberInfo(request: GetGroupMemberInfoRequest): GetGroupMemberInfoResponse { + val memberInfo = GroupHelper.getTroopMemberInfoByUin(request.groupId.toString(), when(request.targetCase!!) { + GetGroupMemberInfoRequest.TargetCase.UIN -> request.uin + GetGroupMemberInfoRequest.TargetCase.UID -> ContactHelper.getUinByUidAsync(request.uid).toLong() + else -> throw StatusRuntimeException(Status.INVALID_ARGUMENT + .withDescription("target not set") + ) + }.toString()).onFailure { + throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to get group member info").withCause(it)) + }.getOrThrow() + return getGroupMemberInfoResponse { + groupMemberInfo = groupMemberInfo { + uid = if (request.targetCase == GetGroupMemberInfoRequest.TargetCase.UID) request.uid else ContactHelper.getUidByUinAsync(request.uin) + uin = memberInfo.memberuin?.toLong() ?: 0 + nick = memberInfo.troopnick + .ifNullOrEmpty { memberInfo.hwName } + .ifNullOrEmpty { memberInfo.troopColorNick } + .ifNullOrEmpty { memberInfo.friendnick } ?: "" + age = memberInfo.age.toInt() + uniqueTitle = memberInfo.mUniqueTitle ?: "" + uniqueTitleExpireTime = memberInfo.mUniqueTitleExpire + card = memberInfo.troopnick.ifNullOrEmpty { memberInfo.friendnick } ?: "" + joinTime = memberInfo.join_time + lastActiveTime = memberInfo.last_active_time + level = memberInfo.level + shutUpTimestamp = memberInfo.gagTimeStamp + + distance = memberInfo.distance + honor.addAll((memberInfo.honorList ?: "") + .split("|") + .filter { it.isNotBlank() } + .map { it.toInt() }) + unfriendly = false + cardChangeable = GroupHelper.isAdmin(request.groupId.toString()) + } + } + } + + @Grpc("GroupService", "GetGroupMemberList") + override suspend fun getGroupMemberList(request: GetGroupMemberListRequest): GetGroupMemberListResponse { + val memberList = GroupHelper.getGroupMemberList(request.groupId.toString(), if (request.hasRefresh()) request.refresh else false).onFailure { + throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to get group member list").withCause(it)) + }.getOrThrow() + return getGroupMemberListResponse { + memberList.forEach { memberInfo -> + this.groupMemberInfo.add(groupMemberInfo { + uid = ContactHelper.getUidByUinAsync(memberInfo.memberuin?.toLong() ?: 0) + uin = memberInfo.memberuin?.toLong() ?: 0 + nick = memberInfo.troopnick + .ifNullOrEmpty { memberInfo.hwName } + .ifNullOrEmpty { memberInfo.troopColorNick } + .ifNullOrEmpty { memberInfo.friendnick } ?: "" + age = memberInfo.age.toInt() + uniqueTitle = memberInfo.mUniqueTitle ?: "" + uniqueTitleExpireTime = memberInfo.mUniqueTitleExpire + card = memberInfo.troopnick.ifNullOrEmpty { memberInfo.friendnick } ?: "" + joinTime = memberInfo.join_time + lastActiveTime = memberInfo.last_active_time + level = memberInfo.level + shutUpTimestamp = memberInfo.gagTimeStamp + + distance = memberInfo.distance + honor.addAll((memberInfo.honorList ?: "") + .split("|") + .filter { it.isNotBlank() } + .map { it.toInt() }) + unfriendly = false + cardChangeable = GroupHelper.isAdmin(request.groupId.toString()) + }) + } + } + } + + @Grpc("GroupService", "GetProhibitedUserList") + override suspend fun getProhibitedUserList(request: GetProhibitedUserListRequest): GetProhibitedUserListResponse { + val prohibitedList = GroupHelper.getProhibitedMemberList(request.groupId).onFailure { + throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to get prohibited user list").withCause(it)) + }.getOrThrow() + return getProhibitedUserListResponse { + prohibitedList.forEach { + this.prohibitedUserInfo.add(prohibitedUserInfo { + uid = ContactHelper.getUidByUinAsync(it.memberUin) + uin = it.memberUin + prohibitedTime = it.shutuptimestap + }) + } + } + } + + @Grpc("GroupService", "GetRemainCountAtAll") + override suspend fun getRemainCountAtAll(request: GetRemainCountAtAllRequest): GetRemainCountAtAllResponse { + val remainAtAllRsp = GroupHelper.getGroupRemainAtAllRemain(request.groupId).onFailure { + throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to get remain count").withCause(it)) + }.getOrThrow() + return getRemainCountAtAllResponse { + accessAtAll = remainAtAllRsp.bool_can_at_all.get() + remainCountForGroup = remainAtAllRsp.uint32_remain_at_all_count_for_group.get() + remainCountForSelf = remainAtAllRsp.uint32_remain_at_all_count_for_uin.get() + } + } + + @Grpc("GroupService", "GetNotJoinedGroupInfo") + override suspend fun getNotJoinedGroupInfo(request: GetNotJoinedGroupInfoRequest): GetNotJoinedGroupInfoResponse { + val groupInfo = GroupHelper.getNotJoinedGroupInfo(request.groupId).onFailure { + throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to get not joined group info").withCause(it)) + }.getOrThrow() + return getNotJoinedGroupInfoResponse { + this.groupInfo = notJoinedGroupInfo { + groupId = groupInfo.groupId + groupName = groupInfo.groupName + owner = groupInfo.owner + maxMemberCount = groupInfo.maxMember + memberCount = groupInfo.memberCount + groupDesc = groupInfo.groupDesc + createTime = groupInfo.createTime.toInt() + groupFlag = groupInfo.groupFlag + groupFlagExt = groupInfo.groupFlagExt + } + } + } + + @Grpc("GroupService", "GetGroupHonor") + override suspend fun getGroupHonor(request: GetGroupHonorRequest): GetGroupHonorResponse { + return getGroupHonorResponse { + GroupHelper.getGroupMemberList(request.groupId.toString(), true).onFailure { + throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to get group member list").withCause(it)) + }.onSuccess { memberList -> + memberList.forEach { member -> + (member.honorList ?: "").split("|") + .filter { it.isNotBlank() } + .map { it.toInt() }.forEach { + val honor = decodeHonor(member.memberuin.toLong(), it, member.mHonorRichFlag) + if (honor != null) { + groupHonorInfo.add(groupHonorInfo { + uid = ContactHelper.getUidByUinAsync(member.memberuin.toLong()) + uin = member.memberuin.toLong() + nick = member.troopnick + .ifNullOrEmpty { member.hwName } + .ifNullOrEmpty { member.troopColorNick } + .ifNullOrEmpty { member.friendnick } ?: "" + honorName = honor.honorName + avatar = honor.honorIconUrl + id = honor.honorId + description = honor.honorUrl + }) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/kritor/service/KritorService.kt b/xposed/src/main/java/kritor/service/KritorService.kt new file mode 100644 index 0000000..185bc6a --- /dev/null +++ b/xposed/src/main/java/kritor/service/KritorService.kt @@ -0,0 +1,136 @@ +package kritor.service + +import android.util.Base64 +import com.tencent.mobileqq.app.QQAppInterface +import io.grpc.Status +import io.grpc.StatusRuntimeException +import io.kritor.core.ClearCacheRequest +import io.kritor.core.ClearCacheResponse +import io.kritor.core.DownloadFileRequest +import io.kritor.core.DownloadFileResponse +import io.kritor.core.GetCurrentAccountRequest +import io.kritor.core.GetCurrentAccountResponse +import io.kritor.core.GetDeviceBatteryRequest +import io.kritor.core.GetDeviceBatteryResponse +import io.kritor.core.GetVersionRequest +import io.kritor.core.GetVersionResponse +import io.kritor.core.KritorServiceGrpcKt +import io.kritor.core.SwitchAccountRequest +import io.kritor.core.SwitchAccountResponse +import io.kritor.core.clearCacheResponse +import io.kritor.core.downloadFileResponse +import io.kritor.core.getCurrentAccountResponse +import io.kritor.core.getDeviceBatteryResponse +import io.kritor.core.getVersionResponse +import io.kritor.core.switchAccountResponse +import moe.fuqiuluo.shamrock.tools.ShamrockVersion +import moe.fuqiuluo.shamrock.utils.DownloadUtils +import moe.fuqiuluo.shamrock.utils.FileUtils +import moe.fuqiuluo.shamrock.utils.MD5 +import moe.fuqiuluo.shamrock.utils.MMKVFetcher +import moe.fuqiuluo.shamrock.utils.PlatformUtils +import mqq.app.MobileQQ +import qq.service.QQInterfaces +import qq.service.QQInterfaces.Companion.app +import qq.service.contact.ContactHelper +import java.io.File + +internal object KritorService: KritorServiceGrpcKt.KritorServiceCoroutineImplBase() { + @Grpc("KritorService", "GetVersion") + override suspend fun getVersion(request: GetVersionRequest): GetVersionResponse { + return getVersionResponse { + this.version = ShamrockVersion + this.appName = "Shamrock" + } + } + + @Grpc("KritorService", "ClearCache") + override suspend fun clearCache(request: ClearCacheRequest): ClearCacheResponse { + FileUtils.clearCache() + MMKVFetcher.mmkvWithId("audio2silk") + .clear() + return clearCacheResponse {} + } + + @Grpc("KritorService", "GetCurrentAccount") + override suspend fun getCurrentAccount(request: GetCurrentAccountRequest): GetCurrentAccountResponse { + return getCurrentAccountResponse { + this.accountName = if (app is QQAppInterface) app.currentNickname else "unknown" + this.accountUid = app.currentUid ?: "" + this.accountUin = (app.currentUin ?: "0").toLong() + } + } + + @Grpc("KritorService", "DownloadFile") + override suspend fun downloadFile(request: DownloadFileRequest): DownloadFileResponse { + val headerMap = mutableMapOf( + "User-Agent" to "Shamrock" + ) + if (request.hasHeaders()) { + request.headers.split("[\r\n]").forEach { + val pair = it.split("=") + if (pair.size >= 2) { + val (k, v) = pair + headerMap[k] = v + } + } + } + + var tmp = FileUtils.getTmpFile("cache") + if (request.hasBase64()) { + val bytes = Base64.decode(request.base64, Base64.DEFAULT) + tmp.writeBytes(bytes) + } else if(request.hasUrl()) { + if(!DownloadUtils.download( + urlAdr = request.url, + dest = tmp, + headers = headerMap, + threadCount = if (request.hasThreadCnt()) request.threadCnt else 3 + )) { + throw StatusRuntimeException(Status.INTERNAL.withDescription("download failed")) + } + } + tmp = if (!request.hasFileName()) FileUtils.renameByMd5(tmp) + else tmp.parentFile!!.resolve(request.fileName).also { + tmp.renameTo(it) + } + if (request.hasRootPath()) { + tmp = File(request.rootPath).resolve(tmp.name).also { + tmp.renameTo(it) + } + } + + return downloadFileResponse { + this.fileMd5 = MD5.genFileMd5Hex(tmp.absolutePath) + this.fileAbsolutePath = tmp.absolutePath + } + } + + @Grpc("KritorService", "SwitchAccount") + override suspend fun switchAccount(request: SwitchAccountRequest): SwitchAccountResponse { + val uin = when(request.accountCase!!) { + SwitchAccountRequest.AccountCase.ACCOUNT_UID -> ContactHelper.getUinByUidAsync(request.accountUid) + SwitchAccountRequest.AccountCase.ACCOUNT_UIN -> request.accountUin.toString() + SwitchAccountRequest.AccountCase.ACCOUNT_NOT_SET -> throw StatusRuntimeException(Status.INVALID_ARGUMENT.withDescription("account not found")) + } + val account = MobileQQ.getMobileQQ().allAccounts.firstOrNull { it.uin == uin } + ?: throw StatusRuntimeException(Status.NOT_FOUND.withDescription("account not found")) + runCatching { + app.switchAccount(account, null) + }.onFailure { + throw StatusRuntimeException(Status.INTERNAL.withCause(it).withDescription("failed to switch account")) + } + return switchAccountResponse { } + } + + @Grpc("KritorService", "GetDeviceBattery") + override suspend fun getDeviceBattery(request: GetDeviceBatteryRequest): GetDeviceBatteryResponse { + return getDeviceBatteryResponse { + PlatformUtils.getDeviceBattery().let { + this.battery = it.battery + this.scale = it.scale + this.status = it.status + } + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/kritor/service/MessageService.kt b/xposed/src/main/java/kritor/service/MessageService.kt new file mode 100644 index 0000000..0d2ad7b --- /dev/null +++ b/xposed/src/main/java/kritor/service/MessageService.kt @@ -0,0 +1,469 @@ +package kritor.service + +import com.tencent.mobileqq.qroute.QRoute +import com.tencent.qqnt.kernel.nativeinterface.Contact +import com.tencent.qqnt.kernel.nativeinterface.MsgConstant +import com.tencent.qqnt.kernel.nativeinterface.MsgRecord +import com.tencent.qqnt.msg.api.IMsgService +import io.grpc.Status +import io.grpc.StatusRuntimeException +import io.kritor.message.ClearMessagesRequest +import io.kritor.message.ClearMessagesResponse +import io.kritor.message.DeleteEssenceMsgRequest +import io.kritor.message.DeleteEssenceMsgResponse +import io.kritor.message.GetEssenceMessagesRequest +import io.kritor.message.GetEssenceMessagesResponse +import io.kritor.message.GetForwardMessagesRequest +import io.kritor.message.GetForwardMessagesResponse +import io.kritor.message.GetHistoryMessageRequest +import io.kritor.message.GetHistoryMessageResponse +import io.kritor.message.GetMessageBySeqRequest +import io.kritor.message.GetMessageBySeqResponse +import io.kritor.message.GetMessageRequest +import io.kritor.message.GetMessageResponse +import io.kritor.message.MessageServiceGrpcKt +import io.kritor.message.RecallMessageRequest +import io.kritor.message.RecallMessageResponse +import io.kritor.message.Scene +import io.kritor.message.SendMessageByResIdRequest +import io.kritor.message.SendMessageByResIdResponse +import io.kritor.message.SendMessageRequest +import io.kritor.message.SendMessageResponse +import io.kritor.message.SetEssenceMessageRequest +import io.kritor.message.SetEssenceMessageResponse +import io.kritor.message.SetMessageCommentEmojiRequest +import io.kritor.message.SetMessageCommentEmojiResponse +import io.kritor.message.clearMessagesResponse +import io.kritor.message.contact +import io.kritor.message.deleteEssenceMsgResponse +import io.kritor.message.essenceMessage +import io.kritor.message.getEssenceMessagesResponse +import io.kritor.message.getForwardMessagesResponse +import io.kritor.message.getHistoryMessageResponse +import io.kritor.message.getMessageBySeqResponse +import io.kritor.message.getMessageResponse +import io.kritor.message.messageBody +import io.kritor.message.recallMessageResponse +import io.kritor.message.sendMessageByResIdResponse +import io.kritor.message.sendMessageResponse +import io.kritor.message.sender +import io.kritor.message.setEssenceMessageResponse +import io.kritor.message.setMessageCommentEmojiResponse +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import protobuf.auto.toByteArray +import protobuf.message.ContentHead +import protobuf.message.Elem +import protobuf.message.MsgBody +import protobuf.message.PbSendMsgReq +import protobuf.message.RichText +import protobuf.message.RoutingHead +import protobuf.message.element.GeneralFlags +import protobuf.message.routing.C2C +import protobuf.message.routing.Grp +import qq.service.QQInterfaces +import qq.service.contact.longPeer +import qq.service.internals.NTServiceFetcher +import qq.service.msg.MessageHelper +import qq.service.msg.NtMsgConvertor +import qq.service.msg.toKritorEventMessages +import qq.service.msg.toKritorReqMessages +import qq.service.msg.toKritorResponseMessages +import kotlin.coroutines.resume +import kotlin.random.Random +import kotlin.random.nextUInt + +internal object MessageService: MessageServiceGrpcKt.MessageServiceCoroutineImplBase() { + @Grpc("MessageService", "SendMessage") + override suspend fun sendMessage(request: SendMessageRequest): SendMessageResponse { + val contact = request.contact.let { + MessageHelper.generateContact(when(it.scene!!) { + Scene.GROUP -> MsgConstant.KCHATTYPEGROUP + Scene.FRIEND -> MsgConstant.KCHATTYPEC2C + Scene.GUILD -> MsgConstant.KCHATTYPEGUILD + Scene.STRANGER_FROM_GROUP -> MsgConstant.KCHATTYPETEMPC2CFROMGROUP + Scene.NEARBY -> MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN + Scene.STRANGER -> MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN + Scene.UNRECOGNIZED -> throw StatusRuntimeException(Status.INVALID_ARGUMENT.withDescription("Unrecognized scene")) + }, it.peer, it.subPeer) + } + + val uniseq = MessageHelper.generateMsgId(contact.chatType) + return sendMessageResponse { + this.messageId = MessageHelper.sendMessage(contact, NtMsgConvertor.convertToNtMsgs(contact, uniseq, request.elementsList), request.retryCount, uniseq).onFailure { + throw StatusRuntimeException(Status.INTERNAL.withCause(it)) + }.getOrThrow() + } + } + + @Grpc("MessageService", "SendMessageByResId") + override suspend fun sendMessageByResId(request: SendMessageByResIdRequest): SendMessageByResIdResponse { + val contact = request.contact + val req = PbSendMsgReq( + routingHead = when (request.contact.scene) { + Scene.GROUP -> RoutingHead(grp = Grp(contact.longPeer().toUInt())) + Scene.FRIEND -> RoutingHead(c2c = C2C(contact.longPeer().toUInt())) + else -> RoutingHead(grp = Grp(contact.longPeer().toUInt())) + }, + contentHead = ContentHead(1, 0, 0, 0), + msgBody = MsgBody( + richText = RichText( + elements = arrayListOf( + Elem( + generalFlags = GeneralFlags( + longTextFlag = 1u, + longTextResid = request.resId + ) + ) + ) + ) + ), + msgSeq = Random.nextUInt(), + msgRand = Random.nextUInt(), + msgVia = 0u + ) + QQInterfaces.sendBuffer("MessageSvc.PbSendMsg", true, req.toByteArray()) + return sendMessageByResIdResponse { } + } + + @Grpc("MessageService", "ClearMessages") + override suspend fun clearMessages(request: ClearMessagesRequest): ClearMessagesResponse { + val contact = request.contact + val kernelService = NTServiceFetcher.kernelService + val sessionService = kernelService.wrapperSession + val service = sessionService.msgService + val chatType = when(contact.scene!!) { + Scene.GROUP -> MsgConstant.KCHATTYPEGROUP + Scene.FRIEND -> MsgConstant.KCHATTYPEC2C + Scene.GUILD -> MsgConstant.KCHATTYPEGUILD + Scene.STRANGER_FROM_GROUP -> MsgConstant.KCHATTYPETEMPC2CFROMGROUP + Scene.NEARBY -> MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN + Scene.STRANGER -> MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN + Scene.UNRECOGNIZED -> throw StatusRuntimeException(Status.INVALID_ARGUMENT.withDescription("Unrecognized scene")) + } + service.clearMsgRecords(Contact(chatType, contact.peer, contact.subPeer), null) + return clearMessagesResponse { } + } + + @Grpc("MessageService", "RecallMessage") + override suspend fun recallMessage(request: RecallMessageRequest): RecallMessageResponse { + val contact = request.contact.let { + MessageHelper.generateContact(when(it.scene!!) { + Scene.GROUP -> MsgConstant.KCHATTYPEGROUP + Scene.FRIEND -> MsgConstant.KCHATTYPEC2C + Scene.GUILD -> MsgConstant.KCHATTYPEGUILD + Scene.STRANGER_FROM_GROUP -> MsgConstant.KCHATTYPETEMPC2CFROMGROUP + Scene.NEARBY -> MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN + Scene.STRANGER -> MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN + Scene.UNRECOGNIZED -> throw StatusRuntimeException(Status.INVALID_ARGUMENT.withDescription("Unrecognized scene")) + }, it.peer, it.subPeer) + } + val kernelService = NTServiceFetcher.kernelService + val sessionService = kernelService.wrapperSession + val service = sessionService.msgService + service.recallMsg(contact, arrayListOf(request.messageId)) { code, msg -> + if (code != 0) { + LogCenter.log("消息撤回失败: $code:$msg", Level.WARN) + } + } + + return recallMessageResponse {} + } + + @Grpc("MessageService", "GetForwardMessages") + override suspend fun getForwardMessages(request: GetForwardMessagesRequest): GetForwardMessagesResponse { + return getForwardMessagesResponse { + MessageHelper.getForwardMsg(request.resId).onFailure { + throw StatusRuntimeException(Status.INTERNAL.withCause(it)) + }.getOrThrow().forEach { detail -> + messages.add(messageBody { + val peer = when (scene) { + Scene.GROUP -> detail.groupId.toString() + Scene.FRIEND -> detail.sender.userId.toString() + else -> detail.peerId.toString() + } + + this.time = detail.time + this.scene = when(detail.msgType) { + MsgConstant.KCHATTYPEC2C -> Scene.FRIEND + MsgConstant.KCHATTYPEGROUP -> Scene.GROUP + MsgConstant.KCHATTYPEGUILD -> Scene.GUILD + MsgConstant.KCHATTYPETEMPC2CFROMGROUP -> Scene.STRANGER_FROM_GROUP + MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN -> Scene.NEARBY + else -> Scene.STRANGER + } + this.messageId = detail.qqMsgId + this.messageSeq = detail.msgSeq + this.contact = contact { + this.scene = scene + this.peer = peer + } + this.sender = sender { + this.uin = detail.sender.userId + this.nick = detail.sender.nickName + this.uid = detail.sender.uid + } + detail.message?.elements?.toKritorResponseMessages(Contact(detail.msgType, peer, null))?.let { + this.elements.addAll(it) + } + }) + } + } + } + + @Grpc("MessageService", "GetMessage") + override suspend fun getMessage(request: GetMessageRequest): GetMessageResponse { + val contact = request.contact.let { + MessageHelper.generateContact(when(it.scene!!) { + Scene.GROUP -> MsgConstant.KCHATTYPEGROUP + Scene.FRIEND -> MsgConstant.KCHATTYPEC2C + Scene.GUILD -> MsgConstant.KCHATTYPEGUILD + Scene.STRANGER_FROM_GROUP -> MsgConstant.KCHATTYPETEMPC2CFROMGROUP + Scene.NEARBY -> MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN + Scene.STRANGER -> MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN + Scene.UNRECOGNIZED -> throw StatusRuntimeException(Status.INVALID_ARGUMENT.withDescription("Unrecognized scene")) + }, it.peer, it.subPeer) + } + val msg: MsgRecord = withTimeoutOrNull(5000) { + val service = QRoute.api(IMsgService::class.java) + suspendCancellableCoroutine { continuation -> + service.getMsgsByMsgId(contact, arrayListOf(request.messageId)) { code, _, msgRecords -> + if (code == 0 && msgRecords.isNotEmpty()) { + continuation.resume(msgRecords.first()) + } else { + continuation.resume(null) + } + } + continuation.invokeOnCancellation { + continuation.resume(null) + } + } + } ?: throw StatusRuntimeException(Status.NOT_FOUND.withDescription("Message not found")) + + return getMessageResponse { + this.message = messageBody { + this.messageId = msg.msgId + this.scene = request.contact.scene + this.contact = request.contact + this.sender = sender { + this.uin = msg.senderUin + this.nick = msg.sendNickName ?: "" + this.uid = msg.senderUid ?: "" + } + this.messageSeq = msg.msgSeq + this.elements.addAll(msg.elements.toKritorReqMessages(contact)) + } + } + } + + @Grpc("MessageService", "GetMessageBySeq") + override suspend fun getMessageBySeq(request: GetMessageBySeqRequest): GetMessageBySeqResponse { + val contact = request.contact.let { + MessageHelper.generateContact(when(it.scene!!) { + Scene.GROUP -> MsgConstant.KCHATTYPEGROUP + Scene.FRIEND -> MsgConstant.KCHATTYPEC2C + Scene.GUILD -> MsgConstant.KCHATTYPEGUILD + Scene.STRANGER_FROM_GROUP -> MsgConstant.KCHATTYPETEMPC2CFROMGROUP + Scene.NEARBY -> MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN + Scene.STRANGER -> MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN + Scene.UNRECOGNIZED -> throw StatusRuntimeException(Status.INVALID_ARGUMENT.withDescription("Unrecognized scene")) + }, it.peer, it.subPeer) + } + val msg: MsgRecord = withTimeoutOrNull(5000) { + val service = QRoute.api(IMsgService::class.java) + suspendCancellableCoroutine { continuation -> + service.getMsgsBySeqAndCount(contact, request.messageSeq, 1, true) { code, _, msgRecords -> + if (code == 0 && msgRecords.isNotEmpty()) { + continuation.resume(msgRecords.first()) + } else { + continuation.resume(null) + } + } + continuation.invokeOnCancellation { + continuation.resume(null) + } + } + } ?: throw StatusRuntimeException(Status.NOT_FOUND.withDescription("Message not found")) + + return getMessageBySeqResponse { + this.message = messageBody { + this.messageId = msg.msgId + this.scene = request.contact.scene + this.contact = request.contact + this.sender = sender { + this.uin = msg.senderUin + this.nick = msg.sendNickName ?: "" + this.uid = msg.senderUid ?: "" + } + this.messageSeq = msg.msgSeq + this.elements.addAll(msg.elements.toKritorReqMessages(contact)) + } + } + } + + @Grpc("MessageService", "GetHistoryMessage") + override suspend fun getHistoryMessage(request: GetHistoryMessageRequest): GetHistoryMessageResponse { + val contact = request.contact.let { + MessageHelper.generateContact(when(it.scene!!) { + Scene.GROUP -> MsgConstant.KCHATTYPEGROUP + Scene.FRIEND -> MsgConstant.KCHATTYPEC2C + Scene.GUILD -> MsgConstant.KCHATTYPEGUILD + Scene.STRANGER_FROM_GROUP -> MsgConstant.KCHATTYPETEMPC2CFROMGROUP + Scene.NEARBY -> MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN + Scene.STRANGER -> MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN + Scene.UNRECOGNIZED -> throw StatusRuntimeException(Status.INVALID_ARGUMENT.withDescription("Unrecognized scene")) + }, it.peer, it.subPeer) + } + val msgs: List = withTimeoutOrNull(5000) { + val service = QRoute.api(IMsgService::class.java) + suspendCancellableCoroutine { continuation -> + service.getMsgs(contact, request.startMessageId, request.count, true) { code, _, msgRecords -> + if (code == 0 && msgRecords.isNotEmpty()) { + continuation.resume(msgRecords) + } else { + continuation.resume(null) + } + } + continuation.invokeOnCancellation { + continuation.resume(null) + } + } + } ?: throw StatusRuntimeException(Status.NOT_FOUND.withDescription("Messages not found")) + + return getHistoryMessageResponse { + msgs.forEach { + messages.add(messageBody { + this.messageId = it.msgId + this.scene = request.contact.scene + this.contact = request.contact + this.sender = sender { + this.uin = it.senderUin + this.nick = it.sendNickName ?: "" + this.uid = it.senderUid ?: "" + } + this.messageSeq = it.msgSeq + this.elements.addAll(it.elements.toKritorReqMessages(contact)) + }) + } + } + } + + @Grpc("MessageService", "DeleteEssenceMsg") + override suspend fun deleteEssenceMsg(request: DeleteEssenceMsgRequest): DeleteEssenceMsgResponse { + val contact = MessageHelper.generateContact(MsgConstant.KCHATTYPEGROUP, request.groupId.toString()) + val msg: MsgRecord = withTimeoutOrNull(5000) { + val service = QRoute.api(IMsgService::class.java) + suspendCancellableCoroutine { continuation -> + service.getMsgsByMsgId(contact, arrayListOf(request.messageId)) { code, _, msgRecords -> + if (code == 0 && msgRecords.isNotEmpty()) { + continuation.resume(msgRecords.first()) + } else { + continuation.resume(null) + } + } + continuation.invokeOnCancellation { + continuation.resume(null) + } + } + } ?: throw StatusRuntimeException(Status.NOT_FOUND.withDescription("Message not found")) + if(MessageHelper.deleteEssenceMessage(request.groupId, msg.msgSeq, msg.msgRandom) == null) + throw StatusRuntimeException(Status.NOT_FOUND.withDescription("delete essence message failed")) + return deleteEssenceMsgResponse { } + } + + @Grpc("MessageService", "GetEssenceMessages") + override suspend fun getEssenceMessages(request: GetEssenceMessagesRequest): GetEssenceMessagesResponse { + val contact = MessageHelper.generateContact(MsgConstant.KCHATTYPEGROUP, request.groupId.toString()) + return getEssenceMessagesResponse { + MessageHelper.getEssenceMessageList(request.groupId, request.page, request.pageSize).onFailure { + throw StatusRuntimeException(Status.INTERNAL.withCause(it)) + }.getOrThrow().forEach { + essenceMessage.add(essenceMessage { + withTimeoutOrNull(5000) { + val service = QRoute.api(IMsgService::class.java) + suspendCancellableCoroutine { continuation -> + service.getMsgsBySeqAndCount(contact, it.messageSeq, 1, true) { code, _, msgRecords -> + if (code == 0 && msgRecords.isNotEmpty()) { + continuation.resume(msgRecords.first()) + } else { + continuation.resume(null) + } + } + continuation.invokeOnCancellation { + continuation.resume(null) + } + } + }?.let { + this.messageId = it.msgId + } + this.messageSeq = it.messageSeq + this.msgTime = it.senderTime.toInt() + this.senderNick = it.senderNick + this.senderUin = it.senderId + this.operationTime = it.operatorTime.toInt() + this.operatorNick = it.operatorNick + this.operatorUin = it.operatorId + this.jsonElements = it.messageContent.toString() + }) + } + } + } + + @Grpc("MessageService", "SetEssenceMessage") + override suspend fun setEssenceMessage(request: SetEssenceMessageRequest): SetEssenceMessageResponse { + val contact = MessageHelper.generateContact(MsgConstant.KCHATTYPEGROUP, request.groupId.toString()) + val msg: MsgRecord = withTimeoutOrNull(5000) { + val service = QRoute.api(IMsgService::class.java) + suspendCancellableCoroutine { continuation -> + service.getMsgsByMsgId(contact, arrayListOf(request.messageId)) { code, _, msgRecords -> + if (code == 0 && msgRecords.isNotEmpty()) { + continuation.resume(msgRecords.first()) + } else { + continuation.resume(null) + } + } + continuation.invokeOnCancellation { + continuation.resume(null) + } + } + } ?: throw StatusRuntimeException(Status.NOT_FOUND.withDescription("Message not found")) + if (MessageHelper.setEssenceMessage(request.groupId, msg.msgSeq, msg.msgRandom) == null) { + throw StatusRuntimeException(Status.NOT_FOUND.withDescription("set essence message failed")) + } + return setEssenceMessageResponse { } + } + + @Grpc("MessageService", "SetMessageCommentEmoji") + override suspend fun setMessageCommentEmoji(request: SetMessageCommentEmojiRequest): SetMessageCommentEmojiResponse { + val contact = request.contact.let { + MessageHelper.generateContact(when(it.scene!!) { + Scene.GROUP -> MsgConstant.KCHATTYPEGROUP + Scene.FRIEND -> MsgConstant.KCHATTYPEC2C + Scene.GUILD -> MsgConstant.KCHATTYPEGUILD + Scene.STRANGER_FROM_GROUP -> MsgConstant.KCHATTYPETEMPC2CFROMGROUP + Scene.NEARBY -> MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN + Scene.STRANGER -> MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN + Scene.UNRECOGNIZED -> throw StatusRuntimeException(Status.INVALID_ARGUMENT.withDescription("Unrecognized scene")) + }, it.peer, it.subPeer) + } + val msg: MsgRecord = withTimeoutOrNull(5000) { + val service = QRoute.api(IMsgService::class.java) + suspendCancellableCoroutine { continuation -> + service.getMsgsByMsgId(contact, arrayListOf(request.messageId)) { code, _, msgRecords -> + if (code == 0 && msgRecords.isNotEmpty()) { + continuation.resume(msgRecords.first()) + } else { + continuation.resume(null) + } + } + continuation.invokeOnCancellation { + continuation.resume(null) + } + } + } ?: throw StatusRuntimeException(Status.NOT_FOUND.withDescription("Message not found")) + MessageHelper.setGroupMessageCommentFace(request.contact.longPeer(), msg.msgSeq.toULong(), request.faceId.toString(), request.isComment) + return setMessageCommentEmojiResponse { } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/kritor/service/WebService.kt b/xposed/src/main/java/kritor/service/WebService.kt new file mode 100644 index 0000000..81e3def --- /dev/null +++ b/xposed/src/main/java/kritor/service/WebService.kt @@ -0,0 +1,72 @@ +package kritor.service + +import io.grpc.Status +import io.grpc.StatusRuntimeException +import io.kritor.web.GetCSRFTokenRequest +import io.kritor.web.GetCSRFTokenResponse +import io.kritor.web.GetCookiesRequest +import io.kritor.web.GetCookiesResponse +import io.kritor.web.GetCredentialsRequest +import io.kritor.web.GetCredentialsResponse +import io.kritor.web.GetHttpCookiesRequest +import io.kritor.web.GetHttpCookiesResponse +import io.kritor.web.WebServiceGrpcKt +import io.kritor.web.getCSRFTokenResponse +import io.kritor.web.getCookiesResponse +import io.kritor.web.getCredentialsResponse +import io.kritor.web.getHttpCookiesResponse +import qq.service.ticket.TicketHelper + +internal object WebService: WebServiceGrpcKt.WebServiceCoroutineImplBase() { + @Grpc("WebService", "GetCookies") + override suspend fun getCookies(request: GetCookiesRequest): GetCookiesResponse { + return getCookiesResponse { + if (request.domain.isNullOrEmpty()) { + this.cookie = TicketHelper.getCookie() + } else { + this.cookie = TicketHelper.getCookie(request.domain) + } + } + } + + @Grpc("WebService", "GetCredentials") + override suspend fun getCredentials(request: GetCredentialsRequest): GetCredentialsResponse { + return getCredentialsResponse { + if (request.domain.isNullOrEmpty()) { + val uin = TicketHelper.getUin() + val skey = TicketHelper.getRealSkey(uin) + val pskey = TicketHelper.getPSKey(uin) + this.cookie = "o_cookie=$uin; ied_qq=o$uin; pac_uid=1_$uin; uin=o$uin; skey=$skey; p_uin=o$uin; p_skey=$pskey;" + this.bkn = TicketHelper.getCSRF(pskey) + } else { + val uin = TicketHelper.getUin() + val skey = TicketHelper.getRealSkey(uin) + val pskey = TicketHelper.getPSKey(uin, request.domain) ?: "" + val pt4token = TicketHelper.getPt4Token(uin, request.domain) ?: "" + this.cookie = "o_cookie=$uin; ied_qq=o$uin; pac_uid=1_$uin; uin=o$uin; skey=$skey; p_uin=o$uin; p_skey=$pskey; pt4_token=$pt4token;" + this.bkn = TicketHelper.getCSRF(pskey) + } + } + } + + @Grpc("WebService", "GetCSRFToken") + override suspend fun getCSRFToken(request: GetCSRFTokenRequest): GetCSRFTokenResponse { + return getCSRFTokenResponse { + if (request.domain.isNullOrEmpty()) { + this.bkn = TicketHelper.getCSRF() + } else { + this.bkn = TicketHelper.getCSRF(TicketHelper.getUin(), request.domain) + } + } + } + + @Grpc("WebService", "GetHttpCookies") + override suspend fun getHttpCookies(request: GetHttpCookiesRequest): GetHttpCookiesResponse { + return getHttpCookiesResponse { + this.cookie = TicketHelper.getHttpCookies(request.appid, request.daid, request.jumpUrl) + ?: throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to get http cookies")) + } + } + + +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/config/ActiveRPC.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/ActiveRPC.kt new file mode 100644 index 0000000..282c5bf --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/ActiveRPC.kt @@ -0,0 +1,7 @@ +package moe.fuqiuluo.shamrock.config + +data object ActiveRPC: ConfigKey() { + override fun name(): String = "active_rpc" + + override fun default(): Boolean = false +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/config/ActiveTicket.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/ActiveTicket.kt new file mode 100644 index 0000000..4354770 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/ActiveTicket.kt @@ -0,0 +1,7 @@ +package moe.fuqiuluo.shamrock.config + +object ActiveTicket: ConfigKey() { + override fun name(): String = "active_ticket" + + override fun default(): String = "" +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/config/AliveReply.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/AliveReply.kt new file mode 100644 index 0000000..bebcac6 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/AliveReply.kt @@ -0,0 +1,6 @@ +package moe.fuqiuluo.shamrock.config + +object AliveReply: ConfigKey() { + override fun name() = "alive_reply" + override fun default() = false +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/config/AntiJvmTrace.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/AntiJvmTrace.kt new file mode 100644 index 0000000..a0398f1 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/AntiJvmTrace.kt @@ -0,0 +1,7 @@ +package moe.fuqiuluo.shamrock.config + +object AntiJvmTrace: ConfigKey() { + override fun default() = false + + override fun name() = "anti_jvm_trace" +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/config/B2Mode.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/B2Mode.kt new file mode 100644 index 0000000..77d789c --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/B2Mode.kt @@ -0,0 +1,6 @@ +package moe.fuqiuluo.shamrock.config + +object B2Mode: ConfigKey() { + override fun name() = "b2_mode" + override fun default() = false +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/config/ConfigKey.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/ConfigKey.kt new file mode 100644 index 0000000..0e92222 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/ConfigKey.kt @@ -0,0 +1,15 @@ +package moe.fuqiuluo.shamrock.config + +abstract class ConfigKey { + abstract fun name(): String + + abstract fun default(): T + + companion object { + + } +} + +internal inline fun > T.get(): Type { + return ShamrockConfig[this] +} diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/config/DebugMode.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/DebugMode.kt new file mode 100644 index 0000000..d7a1213 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/DebugMode.kt @@ -0,0 +1,7 @@ +package moe.fuqiuluo.shamrock.config + +object DebugMode: ConfigKey() { + override fun name(): String = "debug" + + override fun default(): Boolean = false +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/config/EnableOldBDH.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/EnableOldBDH.kt new file mode 100644 index 0000000..2af8dbc --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/EnableOldBDH.kt @@ -0,0 +1,6 @@ +package moe.fuqiuluo.shamrock.config + +object EnableOldBDH: ConfigKey() { + override fun name() = "enable_old_bdh" + override fun default() = true +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/config/EnableSelfMessage.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/EnableSelfMessage.kt new file mode 100644 index 0000000..447c5e5 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/EnableSelfMessage.kt @@ -0,0 +1,6 @@ +package moe.fuqiuluo.shamrock.config + +object EnableSelfMessage: ConfigKey() { + override fun name() = "enable_self_message" + override fun default() = false +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/config/ForceTablet.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/ForceTablet.kt new file mode 100644 index 0000000..3c7e42e --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/ForceTablet.kt @@ -0,0 +1,7 @@ +package moe.fuqiuluo.shamrock.config + +data object ForceTablet: ConfigKey() { + override fun name(): String = "force_tablet" + + override fun default(): Boolean = false +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/config/IsInit.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/IsInit.kt new file mode 100644 index 0000000..02adf33 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/IsInit.kt @@ -0,0 +1,7 @@ +package moe.fuqiuluo.shamrock.config + +object IsInit: ConfigKey() { + override fun name(): String = "is_init" + + override fun default(): Boolean = false +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/config/PassiveRPC.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/PassiveRPC.kt new file mode 100644 index 0000000..6465917 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/PassiveRPC.kt @@ -0,0 +1,7 @@ +package moe.fuqiuluo.shamrock.config + +data object PassiveRPC: ConfigKey() { + override fun name(): String = "passive_rpc" + + override fun default(): Boolean = false +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/config/RPCAddress.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/RPCAddress.kt new file mode 100644 index 0000000..79411a1 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/RPCAddress.kt @@ -0,0 +1,7 @@ +package moe.fuqiuluo.shamrock.config + +data object RPCAddress: ConfigKey() { + override fun name(): String = "rpc_address" + + override fun default(): String = "" +} diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/config/RPCPort.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/RPCPort.kt new file mode 100644 index 0000000..86c828f --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/RPCPort.kt @@ -0,0 +1,8 @@ +package moe.fuqiuluo.shamrock.config + +data object RPCPort: ConfigKey() { + override fun name(): String = "rpc_port" + + override fun default(): Int = 5700 +} + diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/config/ResourceGroup.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/ResourceGroup.kt new file mode 100644 index 0000000..6a329b3 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/ResourceGroup.kt @@ -0,0 +1,7 @@ +package moe.fuqiuluo.shamrock.config + +data object ResourceGroup: ConfigKey() { + override fun name(): String = "resource_group" + + override fun default(): String = "883536416" +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/config/ShamrockConfig.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/ShamrockConfig.kt new file mode 100644 index 0000000..ee27847 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/config/ShamrockConfig.kt @@ -0,0 +1,89 @@ +package moe.fuqiuluo.shamrock.config + +import android.content.Intent +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.xposed.loader.LuoClassloader.moduleLoader +import mqq.app.MobileQQ +import java.util.Properties + +private val configDir = MobileQQ.getContext().getExternalFilesDir(null)!! + .parentFile!!.resolve("Tencent/Shamrock").also { + if (!it.exists()) it.mkdirs() + } +private val configFile = configDir.resolve("config.prop") + +private val configKeys = setOf( + ActiveRPC, + AntiJvmTrace, + ForceTablet, + PassiveRPC, + ResourceGroup, + RPCAddress, + RPCPort, +) + +internal object ShamrockConfig: Properties() { + init { + if (!configFile.exists()) { + moduleLoader.getResourceAsStream("assets/config.properties")?.use { + configDir.resolve("default.prop").outputStream().use { output -> + it.copyTo(output) + } + } + moduleLoader.getResourceAsStream("assets/config.properties")?.use { + configFile.outputStream().use { output -> + it.copyTo(output) + } + } + } + if (configFile.exists()) configFile.inputStream().use { + load(it) + } + } + + internal inline operator fun get(key: ConfigKey): Type { + return when(Type::class) { + Int::class -> getProperty(key.name()).toInt() as Type ?: key.default() + Long::class -> getProperty(key.name()).toLong() as Type ?: key.default() + String::class -> getProperty(key.name()) as Type ?: key.default() + Boolean::class -> getProperty(key.name()).toBoolean() as Type ?: key.default() + else -> throw IllegalArgumentException("Unsupported type") + } + } + + fun updateConfig(intent: Intent? = null) { + intent?.let { + for (key in configKeys) { + when (key.default()) { + is String -> { + val value = intent.getStringExtra(key.name()) + if (value != null) setProperty(key.name(), value) + } + is Boolean -> { + val value = intent.getBooleanExtra(key.name(), key.default() as Boolean) + setProperty(key.name(), value.toString()) + } + is Int -> { + val value = intent.getIntExtra(key.name(), key.default() as Int) + setProperty(key.name(), value.toString()) + } + is Long -> { + val value = intent.getLongExtra(key.name(), key.default() as Long) + setProperty(key.name(), value.toString()) + } + } + } + + if (getProperty(ActiveTicket.name()).isNullOrEmpty()) { + setProperty(ActiveTicket.name(), "") // 初始化ticket + } + + setProperty(IsInit.name(), "true") + } + configFile.outputStream().use { + store(it, "Shamrock Config") + } + } + + private fun readResolve(): Any = ShamrockConfig +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/helper/Exceptions.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/helper/Exceptions.kt index f5fc24b..af2b2d7 100644 --- a/xposed/src/main/java/moe/fuqiuluo/shamrock/helper/Exceptions.kt +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/helper/Exceptions.kt @@ -6,10 +6,14 @@ internal class ParamsException(key: String): InternalMessageMakerError("Lack of internal class IllegalParamsException(key: String): InternalMessageMakerError("Illegal param $key") -internal object ActionMsgException: InternalMessageMakerError("action msg") +internal object ActionMsgException: InternalMessageMakerError("action msg") { + private fun readResolve(): Any = ActionMsgException +} internal class LogicException(why: String) : InternalMessageMakerError(why) -internal object ErrorTokenException : InternalMessageMakerError("access_token error") +internal object ErrorTokenException : InternalMessageMakerError("access_token error") { + private fun readResolve(): Any = ErrorTokenException +} internal class SendMsgException(why: String) : InternalMessageMakerError(why) diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/helper/LocalCacheHelper.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/helper/LocalCacheHelper.kt index 9783f55..5b86ba5 100644 --- a/xposed/src/main/java/moe/fuqiuluo/shamrock/helper/LocalCacheHelper.kt +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/helper/LocalCacheHelper.kt @@ -1,23 +1,2 @@ package moe.fuqiuluo.shamrock.helper -import moe.fuqiuluo.qqinterface.servlet.BaseSvc -import moe.fuqiuluo.shamrock.utils.FileUtils -import mqq.app.MobileQQ -import java.io.File - -internal object LocalCacheHelper: BaseSvc() { - // 获取外部储存data目录 - private val dataDir = MobileQQ.getContext().getExternalFilesDir(null)!! - .parentFile!!.resolve("Tencent") - - fun getCurrentPttPath(): File { - return dataDir.resolve("MobileQQ/${app.currentAccountUin}/ptt").also { - if (!it.exists()) it.mkdirs() - } - } - - fun getCachePttFile(md5: String): File { - val file = FileUtils.getFileByMd5(md5) - return if (file.exists()) file else getCurrentPttPath().resolve("$md5.amr") - } -} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/helper/LogCenter.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/helper/LogCenter.kt index 469bd9b..cd6f168 100644 --- a/xposed/src/main/java/moe/fuqiuluo/shamrock/helper/LogCenter.kt +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/helper/LogCenter.kt @@ -8,9 +8,10 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import moe.fuqiuluo.shamrock.remote.service.config.ShamrockConfig -import moe.fuqiuluo.shamrock.xposed.hooks.toast -import moe.fuqiuluo.shamrock.xposed.helper.internal.DataRequester +import moe.fuqiuluo.shamrock.config.DebugMode +import moe.fuqiuluo.shamrock.config.ShamrockConfig +import moe.fuqiuluo.shamrock.tools.toast +import moe.fuqiuluo.shamrock.xposed.helper.AppTalker import mqq.app.MobileQQ import java.io.File import java.util.Date @@ -51,19 +52,18 @@ internal object LogCenter { private val format = SimpleDateFormat("[HH:mm:ss] ") fun log(string: String, level: Level = Level.INFO, toast: Boolean = false) { - if (!ShamrockConfig.isDebug() && level == Level.DEBUG) { + if (!ShamrockConfig[DebugMode] && level == Level.DEBUG) { return } if (toast) { MobileQQ.getContext().toast(string) } - // 把日志广播到主进程 GlobalScope.launch(Dispatchers.Default) { - DataRequester.request("send_message", bodyBuilder = { + AppTalker.talk("send_message") { put("string", string) put("level", level.id) - }) + } } if (!LogFile.exists()) { @@ -79,7 +79,7 @@ internal object LogCenter { level: Level = Level.INFO, toast: Boolean = false ) { - if (!ShamrockConfig.isDebug() && level == Level.DEBUG) { + if (!ShamrockConfig[DebugMode] && level == Level.DEBUG) { return } @@ -89,10 +89,10 @@ internal object LogCenter { } // 把日志广播到主进程 GlobalScope.launch(Dispatchers.Default) { - DataRequester.request("send_message", bodyBuilder = { + AppTalker.talk("send_message") { put("string", log) put("level", level.id) - }) + } } if (!LogFile.exists()) { @@ -103,10 +103,6 @@ internal object LogCenter { LogFile.appendText(format) } -// fun getAllLog(): File { -// return LogFile -// } - fun getLogLines(start: Int, recent: Boolean = false): List { val logData = LogFile.readLines() val index = if(start > logData.size || start < 0) 0 else start diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/helper/TroopHonorHelper.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/helper/TroopHonorHelper.kt index 76f2c76..eb3622a 100644 --- a/xposed/src/main/java/moe/fuqiuluo/shamrock/helper/TroopHonorHelper.kt +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/helper/TroopHonorHelper.kt @@ -1,6 +1,5 @@ package moe.fuqiuluo.shamrock.helper -import moe.fuqiuluo.shamrock.remote.service.data.GroupMemberHonor object TroopHonorHelper { data class Honor( @@ -60,4 +59,13 @@ object TroopHonorHelper { else -> flag shr 4 } and 3 } + + data class GroupMemberHonor( + val uin: Long, + val honorUrl: String, + val honorIconUrl: String, + val honorLevel: Int, + val honorId: Int, + val honorName: String + ) } \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/internals/GlobalEventTransmitter.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/internals/GlobalEventTransmitter.kt new file mode 100644 index 0000000..a90a273 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/internals/GlobalEventTransmitter.kt @@ -0,0 +1,549 @@ +@file:OptIn(DelicateCoroutinesApi::class) + +package moe.fuqiuluo.shamrock.internals + +import com.tencent.qqnt.kernel.nativeinterface.MsgElement +import com.tencent.qqnt.kernel.nativeinterface.MsgRecord +import io.kritor.event.GroupApplyType +import io.kritor.event.GroupMemberBanType +import io.kritor.event.GroupMemberDecreasedType +import io.kritor.event.GroupMemberIncreasedType +import io.kritor.event.MessageEvent +import io.kritor.event.NoticeEvent +import io.kritor.event.NoticeType +import io.kritor.event.RequestType +import io.kritor.event.RequestsEvent +import io.kritor.event.Scene +import io.kritor.event.contact +import io.kritor.event.essenceMessageNotice +import io.kritor.event.friendApplyRequest +import io.kritor.event.friendFileComeNotice +import io.kritor.event.friendPokeNotice +import io.kritor.event.friendRecallNotice +import io.kritor.event.groupAdminChangedNotice +import io.kritor.event.groupApplyRequest +import io.kritor.event.groupFileComeNotice +import io.kritor.event.groupMemberBannedNotice +import io.kritor.event.groupMemberDecreasedNotice +import io.kritor.event.groupMemberIncreasedNotice +import io.kritor.event.groupPokeNotice +import io.kritor.event.groupRecallNotice +import io.kritor.event.groupSignNotice +import io.kritor.event.groupUniqueTitleChangedNotice +import io.kritor.event.groupWholeBanNotice +import io.kritor.event.messageEvent +import io.kritor.event.noticeEvent +import io.kritor.event.requestsEvent +import io.kritor.event.sender +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import qq.service.QQInterfaces +import qq.service.msg.toKritorEventMessages + +internal object GlobalEventTransmitter: QQInterfaces() { + private val messageEventFlow by lazy { + MutableSharedFlow>() + } + private val noticeEventFlow by lazy { + MutableSharedFlow() + } + private val requestEventFlow by lazy { + MutableSharedFlow() + } + + private suspend fun pushNotice(noticeEvent: NoticeEvent) = noticeEventFlow.emit(noticeEvent) + + private suspend fun pushRequest(requestEvent: RequestsEvent) = requestEventFlow.emit(requestEvent) + + private suspend fun transMessageEvent(record: MsgRecord, message: MessageEvent) = messageEventFlow.emit(record to message) + + object MessageTransmitter { + suspend fun transGroupMessage( + record: MsgRecord, + elements: ArrayList, + ): Boolean { + transMessageEvent(record, messageEvent { + this.time = record.msgTime.toInt() + this.scene = Scene.GROUP + this.messageId = record.msgId + this.messageSeq = record.msgSeq + this.contact = contact { + this.scene = scene + this.peer = record.peerUin.toString() + this.subPeer = record.peerUid + } + this.sender = sender { + this.uin = record.senderUin + this.uid = record.senderUid + this.nick = record.sendNickName + } + this.elements.addAll(elements.toKritorEventMessages(record)) + }) + return true + } + + suspend fun transPrivateMessage( + record: MsgRecord, + elements: ArrayList, + ): Boolean { + transMessageEvent(record, messageEvent { + this.time = record.msgTime.toInt() + this.scene = Scene.FRIEND + this.messageId = record.msgId + this.messageSeq = record.msgSeq + this.contact = contact { + this.scene = scene + this.peer = record.senderUin.toString() + this.subPeer = record.senderUid + } + this.sender = sender { + this.uin = record.senderUin + this.uid = record.senderUid + this.nick = record.sendNickName + } + this.elements.addAll(elements.toKritorEventMessages(record)) + }) + return true + } + + suspend fun transTempMessage( + record: MsgRecord, + elements: ArrayList, + groupCode: Long, + fromNick: String, + ): Boolean { + transMessageEvent(record, messageEvent { + this.time = record.msgTime.toInt() + this.scene = Scene.FRIEND + this.messageId = record.msgId + this.messageSeq = record.msgSeq + this.contact = contact { + this.scene = scene + this.peer = record.senderUin.toString() + this.subPeer = groupCode.toString() + } + this.sender = sender { + this.uin = record.senderUin + this.uid = record.senderUid + this.nick = record.sendNickName + } + this.elements.addAll(elements.toKritorEventMessages(record)) + }) + return true + } + + suspend fun transGuildMessage( + record: MsgRecord, + elements: ArrayList, + ): Boolean { + transMessageEvent(record, messageEvent { + this.time = record.msgTime.toInt() + this.scene = Scene.GUILD + this.messageId = record.msgId + this.messageSeq = record.msgSeq + this.contact = contact { + this.scene = scene + this.peer = record.guildId ?: "" + this.subPeer = record.channelId ?: "" + } + this.sender = sender { + this.uin = record.senderUin + this.uid = record.senderUid + this.nick = record.sendNickName + } + this.elements.addAll(elements.toKritorEventMessages(record)) + }) + return true + } + } + + /** + * 文件通知 通知器 + **/ + object FileNoticeTransmitter { + /** + * 推送私聊文件事件 + */ + suspend fun transPrivateFileEvent( + msgTime: Long, + userId: Long, + fileId: String, + fileSubId: String, + fileName: String, + fileSize: Long, + expireTime: Long, + url: String + ): Boolean { + pushNotice(noticeEvent { + this.type = NoticeType.FRIEND_FILE_COME + this.time = msgTime.toInt() + this.friendFileCome = friendFileComeNotice { + this.fileId = fileId + this.fileName = fileName + this.operator = userId + this.fileSize = fileSize + this.expireTime = expireTime.toInt() + this.fileSubId = fileSubId + this.url = url + } + }) + return true + } + + /** + * 推送私聊文件事件 + */ + suspend fun transGroupFileEvent( + msgTime: Long, + userId: Long, + groupId: Long, + uuid: String, + fileName: String, + fileSize: Long, + bizId: Int, + url: String + ): Boolean { + pushNotice(noticeEvent { + this.type = NoticeType.GROUP_FILE_COME + this.time = msgTime.toInt() + this.groupFileCome = groupFileComeNotice { + this.groupId = groupId + this.operator = userId + this.fileId = uuid + this.fileName = fileName + this.fileSize = fileSize + this.biz = bizId + this.url = url + } + }) + return true + } + } + + /** + * 群聊通知 通知器 + */ + object GroupNoticeTransmitter { + suspend fun transGroupSign(time: Long, target: Long, action: String?, rankImg: String?, groupCode: Long): Boolean { + pushNotice(noticeEvent { + this.type = NoticeType.GROUP_SIGN + this.time = time.toInt() + this.groupSign = groupSignNotice { + this.groupId = groupCode + this.targetUin = target + this.action = action ?: "" + this.suffix = "" + this.rankImage = rankImg ?: "" + } + }) + return true + } + + suspend fun transGroupPoke(time: Long, operator: Long, target: Long, action: String?, suffix: String?, actionImg: String?, groupCode: Long): Boolean { + pushNotice(noticeEvent { + this.type = NoticeType.GROUP_POKE + this.time = time.toInt() + this.groupPoke = groupPokeNotice { + this.action = action ?: "" + this.target = target + this.operator = operator + this.suffix = suffix ?: "" + this.actionImage = actionImg ?: "" + } + }) + return true + } + + suspend fun transGroupMemberNumIncreased( + time: Long, + target: Long, + targetUid: String, + groupCode: Long, + operator: Long, + operatorUid: String, + type: GroupMemberIncreasedType + ): Boolean { + pushNotice(noticeEvent { + this.type = NoticeType.GROUP_MEMBER_INCREASE + this.time = time.toInt() + this.groupMemberIncrease = groupMemberIncreasedNotice { + this.groupId = groupCode + this.operatorUid = operatorUid + this.operatorUin = operator + this.targetUid = targetUid + this.targetUin = target + this.type = type + } + }) + return true + } + + suspend fun transGroupMemberNumDecreased( + time: Long, + target: Long, + targetUid: String, + groupCode: Long, + operator: Long, + operatorUid: String, + type: GroupMemberDecreasedType + ): Boolean { + pushNotice(noticeEvent { + this.type = NoticeType.GROUP_MEMBER_INCREASE + this.time = time.toInt() + this.groupMemberDecrease = groupMemberDecreasedNotice { + this.groupId = groupCode + this.operatorUid = operatorUid + this.operatorUin = operator + this.targetUid = targetUid + this.targetUin = target + this.type = type + } + }) + return true + } + + suspend fun transGroupAdminChanged( + msgTime: Long, + target: Long, + targetUid: String, + groupCode: Long, + setAdmin: Boolean + ): Boolean { + pushNotice(noticeEvent { + this.type = NoticeType.GROUP_ADMIN_CHANGED + this.time = msgTime.toInt() + this.groupAdminChanged = groupAdminChangedNotice { + this.groupId = groupCode + this.targetUid = targetUid + this.targetUin = target + this.isAdmin = setAdmin + } + }) + return true + } + + suspend fun transGroupWholeBan( + msgTime: Long, + operator: Long, + groupCode: Long, + isOpen: Boolean + ): Boolean { + pushNotice(noticeEvent { + this.type = NoticeType.GROUP_WHOLE_BAN + this.time = msgTime.toInt() + this.groupWholeBan = groupWholeBanNotice { + this.groupId = groupCode + this.isWholeBan = isOpen + this.operator = operator + } + }) + return true + } + + suspend fun transGroupBan( + msgTime: Long, + operator: Long, + operatorUid: String, + target: Long, + targetUid: String, + groupCode: Long, + duration: Int + ): Boolean { + pushNotice(noticeEvent { + this.type = NoticeType.GROUP_MEMBER_BANNED + this.time = msgTime.toInt() + this.groupMemberBanned = groupMemberBannedNotice { + this.groupId = groupCode + this.operatorUid = operatorUid + this.operatorUin = operator + this.targetUid = targetUid + this.targetUin = target + this.duration = duration + this.type = if (duration > 0) GroupMemberBanType.BAN + else GroupMemberBanType.LIFT_BAN + } + }) + return true + } + + suspend fun transGroupMsgRecall( + time: Long, + operator: Long, + operatorUid: String, + target: Long, + targetUid: String, + groupCode: Long, + msgId: Long, + tipText: String + ): Boolean { + pushNotice(noticeEvent { + this.type = NoticeType.GROUP_RECALL + this.time = time.toInt() + this.groupRecall = groupRecallNotice { + this.groupId = groupCode + this.operatorUid = operatorUid + this.operatorUin = operator + this.targetUid = targetUid + this.targetUin = target + this.messageId = msgId + this.tipText = tipText + } + }) + return true + } + + suspend fun transCardChange( + time: Long, + targetId: Long, + oldCard: String, + newCard: String, + groupId: Long + ): Boolean { + + return true + } + + suspend fun transTitleChange( + time: Long, + targetId: Long, + title: String, + groupId: Long + ): Boolean { + pushNotice(noticeEvent { + this.type = NoticeType.GROUP_MEMBER_UNIQUE_TITLE_CHANGED + this.time = time.toInt() + this.groupMemberUniqueTitleChanged = groupUniqueTitleChangedNotice { + this.groupId = groupId + this.target = targetId + this.title = title + } + }) + return true + } + + suspend fun transEssenceChange( + time: Long, + senderUin: Long, + operatorUin: Long, + msgId: Long, + groupId: Long, + subType: UInt + ): Boolean { + pushNotice(noticeEvent { + this.type = NoticeType.GROUP_ESSENCE_CHANGED + this.time = time.toInt() + this.groupEssenceChanged = essenceMessageNotice { + this.groupId = groupId + this.messageId = msgId + this.sender = senderUin + this.operator = operatorUin + this.subType = subType.toInt() + } + }) + return true + } + } + + /** + * 私聊通知 通知器 + */ + object PrivateNoticeTransmitter { + suspend fun transPrivatePoke(msgTime: Long, operator: Long, target: Long, action: String?, suffix: String?, actionImg: String?): Boolean { + pushNotice(noticeEvent { + this.type = NoticeType.FRIEND_POKE + this.time = msgTime.toInt() + this.friendPoke = friendPokeNotice { + this.action = action ?: "" + this.target = target + this.operator = operator + this.suffix = suffix ?: "" + this.actionImage = actionImg ?: "" + } + }) + return true + } + + suspend fun transPrivateRecall(time: Long, operator: Long, msgId: Long, tipText: String): Boolean { + pushNotice(noticeEvent { + this.type = NoticeType.FRIEND_RECALL + this.time = time.toInt() + this.friendRecall = friendRecallNotice { + this.operator = operator + this.messageId = msgId + this.tipText = tipText + } + }) + return true + } + + } + + /** + * 请求 通知器 + */ + object RequestTransmitter { + suspend fun transFriendApp(time: Long, operator: Long, tipText: String, flag: String): Boolean { + pushRequest(requestsEvent { + this.type = RequestType.FRIEND_APPLY + this.time = time.toInt() + this.friendApply = friendApplyRequest { + this.applierUin = operator + this.message = tipText + this.flag = flag + } + }) + return true + } + + suspend fun transGroupApply( + time: Long, + applier: Long, + applierUid: String, + reason: String, + groupCode: Long, + flag: String, + type: GroupApplyType + ): Boolean { + pushRequest(requestsEvent { + this.type = RequestType.GROUP_APPLY + this.time = time.toInt() + this.groupApply = groupApplyRequest { + this.applierUid = applierUid + this.applierUin = applier + this.groupId = groupCode + this.reason = reason + this.flag = flag + this.type = type + } + }) + return true + } + } + + suspend inline fun onMessageEvent(collector: FlowCollector>) { + messageEventFlow.collect { + GlobalScope.launch { + collector.emit(it) + } + } + } + + suspend inline fun onNoticeEvent(collector: FlowCollector) { + noticeEventFlow.collect { + GlobalScope.launch { + collector.emit(it) + } + } + } + + suspend inline fun onRequestEvent(collector: FlowCollector) { + requestEventFlow.collect { + GlobalScope.launch { + collector.emit(it) + } + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/AndroidX.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/AndroidX.kt new file mode 100644 index 0000000..52d99ad --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/AndroidX.kt @@ -0,0 +1,16 @@ +package moe.fuqiuluo.shamrock.tools + +import android.content.Context +import android.os.Handler +import android.widget.Toast +import de.robv.android.xposed.XposedBridge + +lateinit var GlobalUi: Handler + +internal fun Context.toast(msg: String, flag: Int = Toast.LENGTH_SHORT) { + XposedBridge.log(msg) + if (!::GlobalUi.isInitialized) { + return + } + GlobalUi.post { Toast.makeText(this, msg, flag).show() } +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/Json.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/Json.kt index f04a36a..a44278b 100644 --- a/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/Json.kt +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/Json.kt @@ -1,6 +1,5 @@ package moe.fuqiuluo.shamrock.tools -import io.github.xn32.json5k.Json5 import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -27,15 +26,6 @@ val GlobalJson = Json { coerceInputValues = true // 强制输入值 } - -val GlobalJson5 = Json5 { - prettyPrint = true - indentationWidth = 2 - //useSingleQuotes = true - //quoteMemberNames = true - //encodeDefaults = true -} - val String.asJson: JsonElement get() = Json.parseToJsonElement(this) diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/Kotlinx.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/Kotlinx.kt index cb40366..2f5c3ce 100644 --- a/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/Kotlinx.kt +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/Kotlinx.kt @@ -47,8 +47,8 @@ fun ByteArray.slice(off: Int, length: Int = size - off): ByteArray { .let { s -> if (uppercase) s.lowercase(Locale.getDefault()) else s } } ?: "null" -fun String?.ifNullOrEmpty(defaultValue: String?): String? { - return if (this.isNullOrEmpty()) defaultValue else this +fun String?.ifNullOrEmpty(defaultValue: () -> String?): String? { + return if (this.isNullOrEmpty()) defaultValue() else this } @JvmOverloads fun String.hex2ByteArray(replace: Boolean = false): ByteArray { diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/KtorClient.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/KtorClient.kt index 5abeb84..f5f4d7d 100644 --- a/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/KtorClient.kt +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/KtorClient.kt @@ -1,43 +1,15 @@ package moe.fuqiuluo.shamrock.tools import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.serialization.kotlinx.json.json -import kotlinx.serialization.json.Json -val GlobalClient: HttpClient by lazy { - HttpClient { - //install(HttpCookies) +val GlobalClient by lazy { + HttpClient(OkHttp) { install(HttpTimeout) { requestTimeoutMillis = 15000 connectTimeoutMillis = 15000 socketTimeoutMillis = 15000 } - install(ContentNegotiation) { - json(Json { - prettyPrint = true - isLenient = true - }) - } - } -} - -val GlobalClientNoRedirect: HttpClient by lazy { - HttpClient { - //install(HttpCookies) - followRedirects = false - - install(HttpTimeout) { - requestTimeoutMillis = 15000 - connectTimeoutMillis = 15000 - socketTimeoutMillis = 15000 - } - install(ContentNegotiation) { - json(Json { - prettyPrint = true - isLenient = true - }) - } } } \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/Version.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/Version.kt index e5a1c38..8bc766b 100644 --- a/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/Version.kt +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/tools/Version.kt @@ -2,9 +2,7 @@ package moe.fuqiuluo.shamrock.tools import mqq.app.MobileQQ -private val context = MobileQQ.getContext() -private val packageManager = context.packageManager - -private fun getPackageInfo(packageName: String) = packageManager.getPackageInfo(packageName, 0) - -val ShamrockVersion: String = getPackageInfo("moe.fuqiuluo.shamrock.hided").versionName +val ShamrockVersion: String by lazy { + MobileQQ.getContext().packageManager + .getPackageInfo("moe.fuqiuluo.shamrock.hided", 0).versionName +} diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/utils/AudioUtils.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/utils/AudioUtils.kt index d7c07b1..0c512b6 100644 --- a/xposed/src/main/java/moe/fuqiuluo/shamrock/utils/AudioUtils.kt +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/utils/AudioUtils.kt @@ -8,7 +8,7 @@ import com.arthenica.ffmpegkit.FFmpegKit import com.arthenica.ffmpegkit.FFprobeKit import com.arthenica.ffmpegkit.ReturnCode import com.tencent.qqnt.kernel.nativeinterface.QQNTWrapperUtil -import moe.fuqiuluo.shamrock.helper.LocalCacheHelper +import qq.service.internals.LocalCacheHelper import moe.fuqiuluo.shamrock.helper.Level import moe.fuqiuluo.shamrock.helper.LogCenter import java.io.File diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/utils/DownloadUtils.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/utils/DownloadUtils.kt index 1cdaf5a..73d5ed7 100644 --- a/xposed/src/main/java/moe/fuqiuluo/shamrock/utils/DownloadUtils.kt +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/utils/DownloadUtils.kt @@ -33,7 +33,7 @@ object DownloadUtils { threadCount: Int = MAX_THREAD, headers: Map = mapOf() ): Boolean { - var threadCnt = if(threadCount == 0) MAX_THREAD else threadCount + var threadCnt = if(threadCount == 0 || threadCount < 0) MAX_THREAD else threadCount val url = URL(urlAdr) val connection = withContext(Dispatchers.IO) { url.openConnection() } as HttpURLConnection headers.forEach { (k, v) -> diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/XposedEntry.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/XposedEntry.kt index 6bd1b3c..f763f97 100644 --- a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/XposedEntry.kt +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/XposedEntry.kt @@ -1,32 +1,28 @@ package moe.fuqiuluo.shamrock.xposed import android.content.Context -import android.os.Process +import android.os.Build +import android.os.Handler import de.robv.android.xposed.IXposedHookLoadPackage import de.robv.android.xposed.XposedBridge import de.robv.android.xposed.callbacks.XC_LoadPackage import de.robv.android.xposed.XposedBridge.log -import moe.fuqiuluo.shamrock.helper.Level -import moe.fuqiuluo.shamrock.helper.LogCenter -import moe.fuqiuluo.shamrock.remote.service.config.ShamrockConfig import moe.fuqiuluo.shamrock.utils.MMKVFetcher -import moe.fuqiuluo.shamrock.xposed.loader.KeepAlive +import moe.fuqiuluo.shamrock.xposed.helper.KeepAlive import moe.fuqiuluo.shamrock.xposed.loader.LuoClassloader import moe.fuqiuluo.shamrock.tools.FuzzySearchClass +import moe.fuqiuluo.shamrock.tools.GlobalUi import moe.fuqiuluo.shamrock.tools.afterHook import moe.fuqiuluo.shamrock.utils.PlatformUtils -import moe.fuqiuluo.shamrock.xposed.hooks.runFirstActions import mqq.app.MobileQQ import java.lang.reflect.Field import java.lang.reflect.Modifier -import kotlin.system.exitProcess +import moe.fuqiuluo.shamrock.xposed.actions.runFirstActions private const val PACKAGE_NAME_QQ = "com.tencent.mobileqq" -private const val PACKAGE_NAME_QQ_INTERNATIONAL = "com.tencent.mobileqqi" -private const val PACKAGE_NAME_QQ_LITE = "com.tencent.qqlite" + private const val PACKAGE_NAME_TIM = "com.tencent.tim" -private val uselessProcess = listOf("peak", "tool", "mini", "qzone") internal class XposedEntry: IXposedHookLoadPackage { companion object { @@ -36,7 +32,6 @@ internal class XposedEntry: IXposedHookLoadPackage { var secStaticNativehookInited = false external fun injected(): Boolean - external fun hasEnv(): Boolean } @@ -140,22 +135,14 @@ internal class XposedEntry: IXposedHookLoadPackage { MMKVFetcher.initMMKV(ctx) } - runCatching { - if (ShamrockConfig.forbidUselessProcess()) { - if(uselessProcess.any { - processName.contains(it, ignoreCase = true) - }) { - log("[Shamrock] Useless process detected: $processName, exit.") - Process.killProcess(Process.myPid()) - exitProcess(0) - } - } else { - log("[Shamrock] Useless process detection is disabled.") - } - } - log("Process Name = $processName") + GlobalUi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + Handler.createAsync(ctx.mainLooper) + } else { + Handler(ctx.mainLooper) + } + runFirstActions(ctx) } } diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/AntiDetection.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/AntiDetection.kt new file mode 100644 index 0000000..e402c6f --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/AntiDetection.kt @@ -0,0 +1,265 @@ +@file:Suppress("UNCHECKED_CAST", "LocalVariableName") +package moe.fuqiuluo.shamrock.xposed.actions + +import android.content.ContentResolver +import android.content.Context +import android.content.pm.PackageManager +import android.content.pm.VersionedPackage +import android.os.Build +import android.os.Looper +import com.tencent.qphone.base.remote.ToServiceMsg +import de.robv.android.xposed.XC_MethodReplacement +import de.robv.android.xposed.XSharedPreferences +import de.robv.android.xposed.XposedHelpers +import moe.fuqiuluo.shamrock.config.AntiJvmTrace +import moe.fuqiuluo.shamrock.config.ShamrockConfig +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.tools.MethodHooker +import moe.fuqiuluo.shamrock.tools.hookMethod +import moe.fuqiuluo.shamrock.utils.PlatformUtils +import moe.fuqiuluo.shamrock.xposed.XposedEntry +import moe.fuqiuluo.shamrock.xposed.loader.LuoClassloader +import moe.fuqiuluo.shamrock.xposed.loader.NativeLoader +import moe.fuqiuluo.symbols.XposedHook +import mqq.app.MobileQQ +import qq.service.QQInterfaces + +@XposedHook(priority = 0) +class AntiDetection: IAction { + private external fun antiNativeDetections(): Boolean + + override fun invoke(ctx: Context) { + try { + antiFindPackage(ctx) + }catch(_:Throwable){ } //某个大聪明在外面隐藏了shamrock,导致这个代码抛出异常,俺不说是谁>_< + antiGetPackageGidsDetection(ctx) + antiProviderDetection() + antiNativeDetection() + if (ShamrockConfig[AntiJvmTrace]) + antiTrace() + antiMemoryWalking() + antiO3Report() + } + + private fun antiO3Report() { + QQInterfaces.app.javaClass.hookMethod("sendToService").before { + val toServiceMsg = it.args[0] as ToServiceMsg? + if (toServiceMsg != null && toServiceMsg.serviceCmd.startsWith("trpc.o3")) { + LogCenter.log("拦截trpc.o3环境上报包", Level.WARN) + it.result = null + } + } + } + + private fun antiGetPackageGidsDetection(ctx: Context) { + ctx.packageManager::class.java.hookMethod("getPackageGids").before { + val packageName = it.args[0] as String + if (packageName == "moe.fuqiuluo.shamrock") { + it.result = null + it.throwable = PackageManager.NameNotFoundException(packageName) + LogCenter.log("AntiDetection: 检测到对Shamrock的检测,欺骗GetPackageGids") + } + } + } + + private fun antiProviderDetection() { + ContentResolver::class.java.hookMethod("acquireContentProviderClient").before { + val uri = it.args[0] as String + if (uri == "moe.fuqiuluo.108.provider" || uri == "moe.fuqiuluo.xqbot.provider") { + it.result = null + LogCenter.log("AntiDetection: 检测到对Shamrock的检测,欺骗ContentResolver", Level.WARN) + } + } + } + + val isModuleStack = fun String.(): Boolean { + return contains("fuqiuluo") || contains("shamrock") || contains("whitechi") || contains("lsposed") || contains("xposed") + } + + private fun isModuleStack(): Boolean { + Thread.currentThread().stackTrace.forEach { + if (it.className.isModuleStack()) return true + } + return false + } + + private fun antiNativeDetection() { + try { + val pref = XSharedPreferences("moe.fuqiuluo.shamrock", "shared_config") + if (!pref.file.canRead()) { + LogCenter.log("[Shamrock] unable to load XSharedPreferences", Level.WARN) + return + } else if (!pref.getBoolean("super_anti", false)) { + return + } + NativeLoader.load("clover") + val env = XposedEntry.hasEnv() + val injected = XposedEntry.injected() + if (!env || !injected) { + LogCenter.log("[Shamrock] Shamrock反检测启动失败(env=$env, injected=$injected)", Level.ERROR) + } else { + XposedEntry.secStaticNativehookInited = true + if (PlatformUtils.isMainProcess()) { + LogCenter.log( + "[Shamrock] Shamrock反检测启动成功: ${antiNativeDetections()}", + Level.INFO + ) + } + } + } catch (e: Throwable) { + LogCenter.log("[Shamrock] Shamrock反检测启动失败,请检查LSPosed版本使用大于100: ${e.message}", Level.ERROR) + } + } + + private fun antiFindPackage(context: Context) { + if (isAntiFindPackage) return + + val packageManager = context.packageManager + val applicationInfo = packageManager.getApplicationInfo("moe.fuqiuluo.shamrock", 0) + val packageInfo = packageManager.getPackageInfo("moe.fuqiuluo.shamrock", 0) + + packageManager.javaClass.hookMethod("getApplicationInfo").before { + val packageName = it.args[0] as String + if(packageName == "moe.fuqiuluo.shamrock") { + LogCenter.log("AntiDetection: 检测到对Shamrock的检测,欺骗PackageManager(GA)", Level.WARN) + it.throwable = PackageManager.NameNotFoundException("Hided") + } else if (packageName == "moe.fuqiuluo.shamrock.hided") { + it.result = applicationInfo + } + } + + packageManager.javaClass.hookMethod("getPackageInfo").before { + when(val packageName = it.args[0]) { + is String -> { + if(packageName == "moe.fuqiuluo.shamrock") { + LogCenter.log("AntiDetection: 检测到对Shamrock的检测,欺骗PackageManager(GP)", Level.WARN) + it.throwable = PackageManager.NameNotFoundException() + } else if (packageName == "moe.fuqiuluo.shamrock.hided") { + it.result = packageInfo + } + } + else -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && packageName is VersionedPackage) { + if(packageName.packageName == "moe.fuqiuluo.shamrock") { + LogCenter.log("AntiDetection: 检测到对Shamrock的检测,欺骗PackageManager(GPV)", Level.WARN) + it.throwable = PackageManager.NameNotFoundException() + } + } + } + } + } + + isAntiFindPackage = true + } + + private fun antiMemoryWalking() { + val c = Class.forName("dalvik.system.VMDebug") + //val startMethodTracingMethod = c.getDeclaredMethod( + // "startMethodTracing", String::class.java, + // Integer.TYPE, Integer.TYPE, java.lang.Boolean.TYPE, Integer.TYPE + //) + //val stopMethodTracingMethod = c.getDeclaredMethod("stopMethodTracing") + //val getMethodTracingModeMethod = c.getDeclaredMethod("getMethodTracingMode") + //val getRuntimeStatMethod = c.getDeclaredMethod("getRuntimeStat", String::class.java) + //val getRuntimeStatsMethod = c.getDeclaredMethod("getRuntimeStats") + val VMClassLoader = LuoClassloader.load("java/lang/VMClassLoader") + if (VMClassLoader != null) { + // ... + } + + kotlin.runCatching { + XposedHelpers.findAndHookMethod(XposedHelpers.findClass("com.tencent.bugly.agent.CrashReport", LuoClassloader.hostClassLoader), + "initCrashReport", object: XC_MethodReplacement() { + override fun replaceHookedMethod(param: MethodHookParam): Any? { + return null + } + }) + } + + c.hookMethod("countInstancesOfClass").before { + val clz = it.args[0] as Class<*> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if(clz.packageName.isModuleStack()) { + it.result = 0L + } + } else { + if(clz.canonicalName?.isModuleStack() == true) { + it.result = 0L + } + } + } + + c.hookMethod("countInstancesOfClasses").before { + val clzs = it.args[0] as Array> + clzs.forEach { clz -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if(clz.packageName.isModuleStack()) { + it.result = 0L + return@forEach + } + } else { + if(clz.canonicalName?.isModuleStack() == true) { + it.result = 0L + return@forEach + } + } + } + } + + c.hookMethod("getInstancesOfClasses").after { + val clzs = it.args[0] as Array> + clzs.forEachIndexed { _, clz -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if(clz.packageName.isModuleStack()) { + it.result = Array(0) { } + } + } else { + if(clz.canonicalName?.isModuleStack() == true) { + it.result = Array(0) { } + } + } + } + } + } + + private fun antiTrace() { + val isModuleStack = fun StackTraceElement.(): Boolean { + return className.isModuleStack() + } + + val stackTraceHooker: MethodHooker = { + val result = it.result as Array + var zygote = false + val newResult = result.filter { + if (it.className == ZYGOTE_NAME) { + zygote = true + } + !it.isModuleStack() + }.toTypedArray() + if (!zygote && Thread.currentThread() == Looper.getMainLooper().thread) { + it.result = arrayListOf(StackTraceElement(ZYGOTE_NAME, "main", ZYGOTE_NAME, 0), *newResult) + } else { + it.result = newResult + } + } + + Thread::class.java.hookMethod("getName").after { + val result = it.result as String + if (result.contains("fuqiuluo") || result.contains("shamrock") || result.contains("whitechi")) { + it.result = "android" + } + } + + Thread::class.java.hookMethod("getStackTrace").after(stackTraceHooker) + Throwable::class.java.hookMethod("getStackTrace").after(stackTraceHooker) + Throwable::class.java.hookMethod("getOurStackTrace").after(stackTraceHooker) + } + + companion object { + @JvmStatic + var isAntiFindPackage = false + + const val ZYGOTE_NAME = "com.android.internal.os.ZygoteInit" + } +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/DynamicBroadcast.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/DynamicBroadcast.kt new file mode 100644 index 0000000..98e5ac8 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/DynamicBroadcast.kt @@ -0,0 +1,51 @@ +package moe.fuqiuluo.shamrock.xposed.actions + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import de.robv.android.xposed.XposedBridge +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.xposed.actions.interacts.SwitchStatus +import moe.fuqiuluo.shamrock.xposed.actions.interacts.Init +import moe.fuqiuluo.symbols.Process +import moe.fuqiuluo.symbols.XposedHook +import mqq.app.MobileQQ + +@XposedHook(priority = 1, process = Process.MAIN) +class DynamicBroadcast: IAction { + @SuppressLint("UnspecifiedRegisterReceiverFlag") + override fun invoke(ctx: Context) { + val intentFilter = IntentFilter() + intentFilter.addAction("moe.fuqiuluo.kritor.dynamic") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + MobileQQ.getMobileQQ().registerReceiver( + DynamicReceiver, intentFilter, + Context.RECEIVER_EXPORTED + ) + } else { + MobileQQ.getMobileQQ().registerReceiver(DynamicReceiver, intentFilter) + } + XposedBridge.log("Register Main::Broadcast successfully.") + } + + private object DynamicReceiver: BroadcastReceiver() { + private val handlers = mapOf( + "init" to Init, + "switch_status" to SwitchStatus + ) + + override fun onReceive(context: Context, intent: Intent) { + val cmd = intent.getStringExtra("__cmd") ?: "" + val handler = handlers[cmd] + if (handler == null) { + LogCenter.log("DynamicReceiver.onReceive: unknown cmd=$cmd", Level.ERROR) + } else { + handler(intent) + } + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/FetchService.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/FetchService.kt new file mode 100644 index 0000000..d84ee96 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/FetchService.kt @@ -0,0 +1,37 @@ +@file:OptIn(DelicateCoroutinesApi::class) +package moe.fuqiuluo.shamrock.xposed.actions + +import android.content.Context +import com.tencent.qqnt.kernel.api.IKernelService +import com.tencent.qqnt.kernel.api.impl.KernelServiceImpl +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import moe.fuqiuluo.shamrock.tools.hookMethod +import moe.fuqiuluo.shamrock.utils.PlatformUtils +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import qq.service.internals.NTServiceFetcher +import moe.fuqiuluo.shamrock.xposed.loader.NativeLoader +import moe.fuqiuluo.symbols.XposedHook + +@XposedHook(priority = 2) +internal class FetchService: IAction { + override fun invoke(ctx: Context) { + NativeLoader.load("shamrock") + + if (PlatformUtils.isMqq()) { + KernelServiceImpl::class.java.hookMethod("initService").after { + val service = it.thisObject as IKernelService + LogCenter.log("NTKernel try to init service: $service", Level.DEBUG) + GlobalScope.launch { + NTServiceFetcher.onFetch(service) + } + } + } else if (PlatformUtils.isTim()) { + // TIM 尚未进入 NTKernel + LogCenter.log("NTKernel init failed: tim not support NT", Level.ERROR) + } + + } +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/FixAudioLibraryLoader.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/FixAudioLibraryLoader.kt new file mode 100644 index 0000000..024021d --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/FixAudioLibraryLoader.kt @@ -0,0 +1,34 @@ +package moe.fuqiuluo.shamrock.xposed.actions + +import android.content.Context +import moe.fuqiuluo.shamrock.tools.hookMethod +import moe.fuqiuluo.shamrock.xposed.loader.NativeLoader +import moe.fuqiuluo.symbols.XposedHook + +@XposedHook(priority = 0) +internal class FixAudioLibraryLoader: IAction { + private val redirectedLibrary =arrayOf( + "ffmpegkit_abidetect", + "avutil", + "swscale", + "swresample", + "avcodec", + "avformat", + "avfilter", + "avdevice", + "ffmpegkit" + ) + + override fun invoke(ctx: Context) { + com.arthenica.ffmpegkit.NativeLoader::class.java.hookMethod("loadLibrary").before { + val name: String = it.args[0] as String + if (name in redirectedLibrary) { + redirectedLibrary.forEach { + NativeLoader.load(it) + } + NativeLoader.load(name) + } + it.result = null + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/ForceTablet.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/ForceTablet.kt new file mode 100644 index 0000000..0e49988 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/ForceTablet.kt @@ -0,0 +1,61 @@ +@file:Suppress("UNUSED_VARIABLE", "LocalVariableName") +package moe.fuqiuluo.shamrock.xposed.actions + +import android.content.Context +import com.tencent.common.config.pad.DeviceType +import com.tencent.qqnt.kernel.nativeinterface.InitSessionConfig +import de.robv.android.xposed.XposedBridge +import moe.fuqiuluo.shamrock.config.ForceTablet +import moe.fuqiuluo.shamrock.config.ShamrockConfig +import moe.fuqiuluo.shamrock.tools.FuzzySearchClass +import moe.fuqiuluo.shamrock.utils.PlatformUtils +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.tools.afterHook +import moe.fuqiuluo.shamrock.tools.hookMethod +import moe.fuqiuluo.shamrock.xposed.loader.LuoClassloader +import moe.fuqiuluo.symbols.XposedHook + +@XposedHook(priority = 0) +internal class ForceTablet: IAction { + override fun invoke(ctx: Context) { + if (ShamrockConfig[ForceTablet]) { + if (PlatformUtils.isMainProcess()) { + LogCenter.log("强制协议类型 (PAD)", toast = true) + } + + val returnTablet = afterHook { + it.result = DeviceType.TABLET + } + + FuzzySearchClass.findAllClassByMethod( + LuoClassloader.hostClassLoader, "com.tencent.common.config.pad" + ) { _, method -> + method.returnType == DeviceType::class.java + }.forEach { clazz -> + //log("Inject to tablet mode in ${clazz.name}") + val method = clazz.declaredMethods.first { it.returnType == DeviceType::class.java } + XposedBridge.hookMethod(method, returnTablet) + } + + val PadUtil = LuoClassloader.load("com.tencent.common.config.pad.PadUtil") + PadUtil?.declaredMethods?.filter { + it.returnType == DeviceType::class.java + }?.forEach { + XposedBridge.hookMethod(it, returnTablet) + } + + val deviceTypeField = InitSessionConfig::class.java.declaredFields.firstOrNull { + it.type == com.tencent.qqnt.kernel.nativeinterface.DeviceType::class.java + } + if (deviceTypeField != null) { + XposedBridge.hookAllConstructors(InitSessionConfig::class.java, afterHook { + if (!deviceTypeField.isAccessible) deviceTypeField.isAccessible = true + deviceTypeField.set(it.thisObject, com.tencent.qqnt.kernel.nativeinterface.DeviceType.KPAD) + }) + } + InitSessionConfig::class.java.hookMethod("getDeviceType").after { + it.result = com.tencent.qqnt.kernel.nativeinterface.DeviceType.KPAD + } + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/IAction.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/IAction.kt new file mode 100644 index 0000000..9cc39c4 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/IAction.kt @@ -0,0 +1,9 @@ +package moe.fuqiuluo.shamrock.xposed.actions + +import android.content.Context + +internal interface IAction { + + operator fun invoke(ctx: Context) + +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/InitRemoteService.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/InitRemoteService.kt new file mode 100644 index 0000000..8bce023 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/InitRemoteService.kt @@ -0,0 +1,60 @@ +@file:OptIn(DelicateCoroutinesApi::class) + +package moe.fuqiuluo.shamrock.xposed.actions + +import android.content.Context +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kritor.client.KritorClient +import kritor.server.KritorServer +import moe.fuqiuluo.shamrock.config.ActiveRPC +import moe.fuqiuluo.shamrock.config.PassiveRPC +import moe.fuqiuluo.shamrock.config.RPCAddress +import moe.fuqiuluo.shamrock.config.RPCPort +import moe.fuqiuluo.shamrock.config.ShamrockConfig +import moe.fuqiuluo.shamrock.config.get +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.symbols.Process +import moe.fuqiuluo.symbols.XposedHook + +private lateinit var server: KritorServer +private lateinit var client: KritorClient + +@XposedHook(Process.MAIN, priority = 10) +internal class InitRemoteService : IAction { + override fun invoke(ctx: Context) { + GlobalScope.launch { + runCatching { + if (ActiveRPC.get()) { + if (!::server.isInitialized) { + server = KritorServer(RPCPort.get()) + server.start() + } + } else { + LogCenter.log("ActiveRPC is disabled, KritorServer will not be started.") + } + + if (PassiveRPC.get()) { + if (!::client.isInitialized) { + val hostAndPort = RPCAddress.get().split(":").let { + it.first() to it.last().toInt() + } + LogCenter.log("Connect RPC to ${hostAndPort.first}:${hostAndPort.second}") + client = KritorClient(hostAndPort.first, hostAndPort.second) + client.start() + client.listen() + + } + } else { + LogCenter.log("PassiveRPC is disabled, KritorServer will not be started.") + } + + + }.onFailure { + LogCenter.log("Start RPC failed: ${it.message}", Level.ERROR) + } + } + } +} diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/ListenShamrockUpdate.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/ListenShamrockUpdate.kt new file mode 100644 index 0000000..f8b0180 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/ListenShamrockUpdate.kt @@ -0,0 +1,40 @@ +package moe.fuqiuluo.shamrock.xposed.actions + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Process +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.symbols.XposedHook +import kotlin.system.exitProcess + +@XposedHook(priority = 20) +internal class ListenShamrockUpdate: IAction { + override fun invoke(ctx: Context) { + val intent = IntentFilter() + intent.addAction("android.intent.action.PACKAGE_ADDED") + intent.addAction("android.intent.action.PACKAGE_REMOVED") + intent.addAction("android.intent.action.PACKAGE_REPLACED") + intent.addDataScheme("package") + + ctx.registerReceiver(Companion, intent) + } + + companion object: BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + "android.intent.action.PACKAGE_ADDED", + "android.intent.action.PACKAGE_REMOVED", + "android.intent.action.PACKAGE_REPLACED" -> { + val packageName = intent.data?.schemeSpecificPart + if (packageName == "moe.fuqiuluo.shamrock") { + LogCenter.log("Shamrock更新, QQ已经自我销毁。") + Process.killProcess(Process.myPid()) + exitProcess(0) + } + } + } + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/NoBackGround.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/NoBackGround.kt new file mode 100644 index 0000000..7ff90a9 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/NoBackGround.kt @@ -0,0 +1,34 @@ +package moe.fuqiuluo.shamrock.xposed.actions + +import android.content.Context +import de.robv.android.xposed.XposedHelpers +import moe.fuqiuluo.shamrock.tools.hookMethod +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.xposed.loader.LuoClassloader +import moe.fuqiuluo.symbols.XposedHook +import mqq.app.MobileQQ + +@XposedHook(priority = 10) +internal class NoBackGround: IAction { + override fun invoke(ctx: Context) { + kotlin.runCatching { + XposedHelpers.findClass("com.tencent.mobileqq.activity.miniaio.MiniMsgUser", LuoClassloader) + }.onSuccess { + it.hookMethod("onBackground").before { + it.result = null + } + }.onFailure { + LogCenter.log("Keeping MiniMsgUser alive failed: ${it.message}", Level.WARN) + } + + try { + val application = MobileQQ.getMobileQQ() + application.javaClass.hookMethod("onActivityFocusChanged").before { + it.args[1] = true + } + } catch (e: Throwable) { + LogCenter.log("Keeping MSF alive failed: ${e.message}", Level.WARN) + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/PatchMsfCore.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/PatchMsfCore.kt new file mode 100644 index 0000000..fcf823a --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/PatchMsfCore.kt @@ -0,0 +1,45 @@ +@file:Suppress("UNCHECKED_CAST", "UNUSED_VARIABLE", "LocalVariableName") +package moe.fuqiuluo.shamrock.xposed.actions + +import android.content.Context +import com.tencent.common.app.AppInterface +import com.tencent.mobileqq.msf.sdk.MsfMessagePair +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.tools.hookMethod +import moe.fuqiuluo.shamrock.xposed.loader.LuoClassloader +import moe.fuqiuluo.symbols.XposedHook +import qq.service.QQInterfaces +import qq.service.internals.MSFHandler.onPush +import qq.service.internals.MSFHandler.onResp + + +@XposedHook(priority = 10) +class PatchMsfCore: IAction { + override fun invoke(ctx: Context) { + val app = QQInterfaces.app + require(app is AppInterface) { "QQInterface.app must be AppInterface" } + + runCatching { + val MSFRespHandleTask = LuoClassloader.load("mqq.app.msghandle.MSFRespHandleTask") + if (MSFRespHandleTask == null) { + LogCenter.log("无法注入MSFRespHandleTask!", Level.ERROR) + } else { + val msfPair = MSFRespHandleTask.declaredFields.first { + it.type == MsfMessagePair::class.java + } + msfPair.isAccessible = true + MSFRespHandleTask.hookMethod("run").before { + val pair = msfPair.get(it.thisObject) as MsfMessagePair + if (pair.toServiceMsg == null) { + onPush(pair.fromServiceMsg) + } else { + onResp(pair.toServiceMsg, pair.fromServiceMsg) + } + } + } + }.onFailure { + LogCenter.log(it.stackTraceToString(), Level.ERROR) + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/PullConfig.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/PullConfig.kt new file mode 100644 index 0000000..34b0081 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/PullConfig.kt @@ -0,0 +1,38 @@ +@file:OptIn(DelicateCoroutinesApi::class) +package moe.fuqiuluo.shamrock.xposed.actions + +import android.content.Context +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import moe.fuqiuluo.shamrock.config.IsInit +import moe.fuqiuluo.shamrock.config.ShamrockConfig +import moe.fuqiuluo.shamrock.tools.toast +import moe.fuqiuluo.shamrock.utils.PlatformUtils +import moe.fuqiuluo.shamrock.xposed.helper.AppTalker +import moe.fuqiuluo.symbols.Process +import moe.fuqiuluo.symbols.XposedHook +import kotlin.system.exitProcess +import kotlin.time.Duration.Companion.seconds + +@XposedHook(Process.MAIN, priority = 1) +class PullConfig: IAction { + override fun invoke(ctx: Context) { + if (!PlatformUtils.isMainProcess()) return + + val isInit = ShamrockConfig[IsInit] + AppTalker.talk("init", onFailure = { + if (isInit) { + ctx.toast("Shamrock主进程未启动,将不会同步配置!") + } else { + ctx.toast("Shamrock主进程未启动,初始化失败!") + GlobalScope.launch { + delay(3.seconds) + exitProcess(1) + } + } + }) + ctx.toast("同步配置中...") + } +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/interacts/IInteract.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/interacts/IInteract.kt new file mode 100644 index 0000000..b9fadff --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/interacts/IInteract.kt @@ -0,0 +1,7 @@ +package moe.fuqiuluo.shamrock.xposed.actions.interacts + +import android.content.Intent + +interface IInteract { + operator fun invoke(intent: Intent) +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/interacts/Init.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/interacts/Init.kt new file mode 100644 index 0000000..c430558 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/interacts/Init.kt @@ -0,0 +1,24 @@ +package moe.fuqiuluo.shamrock.xposed.actions.interacts + +import android.content.Context +import android.content.Intent +import moe.fuqiuluo.shamrock.config.ShamrockConfig +import moe.fuqiuluo.shamrock.tools.toast +import moe.fuqiuluo.shamrock.xposed.actions.runServiceActions +import moe.fuqiuluo.shamrock.xposed.loader.NativeLoader +import mqq.app.MobileQQ + +internal object Init: IInteract { + private external fun testNativeLibrary(): String + + override fun invoke(intent: Intent) { + ShamrockConfig.updateConfig(intent) + initAppService(MobileQQ.getMobileQQ()) + } + + private fun initAppService(ctx: Context) { + NativeLoader.load("shamrock") + ctx.toast(testNativeLibrary()) + runServiceActions(ctx) + } +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/interacts/SwitchStatus.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/interacts/SwitchStatus.kt new file mode 100644 index 0000000..aee1936 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/actions/interacts/SwitchStatus.kt @@ -0,0 +1,19 @@ +package moe.fuqiuluo.shamrock.xposed.actions.interacts + +import android.content.Intent +import com.tencent.mobileqq.app.QQAppInterface +import moe.fuqiuluo.shamrock.tools.ShamrockVersion +import moe.fuqiuluo.shamrock.xposed.helper.AppTalker +import moe.fuqiuluo.shamrock.xposed.loader.NativeLoader +import qq.service.QQInterfaces + +object SwitchStatus: IInteract, QQInterfaces() { + override fun invoke(intent: Intent) { + AppTalker.talk("switch_status") { + put("account", app.currentAccountUin) + put("nickname", if (app is QQAppInterface) app.currentNickname else "unknown") + put("voice", NativeLoader.isVoiceLoaded) + put("core_version", ShamrockVersion) + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/helper/AppTalker.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/helper/AppTalker.kt index 09e2bfc..7803197 100644 --- a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/helper/AppTalker.kt +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/helper/AppTalker.kt @@ -3,11 +3,10 @@ package moe.fuqiuluo.shamrock.xposed.helper import android.content.ContentValues import android.net.Uri import mqq.app.MobileQQ -import kotlin.random.Random internal object AppTalker { - val uriName = "content://moe.fuqiuluo.108.provider" // 你是真的闲,这都上个检测 - val URI = Uri.parse(uriName) + private const val uriName = "content://moe.fuqiuluo.108.provider" // 你是真的闲,这都上个检测 + private val URI = Uri.parse(uriName) fun talk(values: ContentValues, onFailure: ((Throwable) -> Unit)? = null) { val ctx = MobileQQ.getContext() @@ -17,4 +16,20 @@ internal object AppTalker { onFailure?.invoke(e) } } + + fun talk(action: String, bodyBuilder: ContentValues.() -> Unit) { + val values = ContentValues() + values.put("__cmd", action) + values.put("__hash", 0) + bodyBuilder.invoke(values) + talk(values) + } + + fun talk(action: String, onFailure: ((Throwable) -> Unit)? = null, bodyBuilder: ContentValues.() -> Unit = {}) { + val values = ContentValues() + values.put("__cmd", action) + values.put("__hash", 0) + bodyBuilder.invoke(values) + talk(values, onFailure) + } } \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/helper/KeepAlive.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/helper/KeepAlive.kt new file mode 100644 index 0000000..6b4f477 --- /dev/null +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/helper/KeepAlive.kt @@ -0,0 +1,180 @@ +package moe.fuqiuluo.shamrock.xposed.helper + +import android.content.pm.ApplicationInfo +import android.os.Build +import de.robv.android.xposed.XSharedPreferences +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.XposedHelpers +import moe.fuqiuluo.shamrock.tools.hookMethod +import java.lang.reflect.Method + +internal object KeepAlive { + private val KeepPackage = arrayOf( + "com.tencent.mobileqq", "moe.fuqiuluo.shamrock" + ) + private val KeepRecords = arrayListOf() + private lateinit var KeepThread: Thread + + private lateinit var METHOD_IS_KILLED: Method + private var allowPersistent: Boolean = false + + operator fun invoke(loader: ClassLoader) { + val pref = XSharedPreferences("moe.fuqiuluo.shamrock", "shared_config") + hookAMS(pref, loader) + hookDoze(pref, loader) + } + + private fun hookDoze(pref: XSharedPreferences, loader: ClassLoader) { + if (pref.file.canRead() && pref.getBoolean("hook_doze", false)) { + val result = runCatching { + val DeviceIdleController = XposedHelpers.findClass( + "com.android.server.DeviceIdleController", + loader + ) + ?: return@runCatching -1 + val becomeActiveLocked = XposedHelpers.findMethodBestMatch( + DeviceIdleController, + "becomeActiveLocked", + String::class.java, + Integer.TYPE + ) + ?: return@runCatching -2 + if (!becomeActiveLocked.isAccessible) { + becomeActiveLocked.isAccessible = true + } + DeviceIdleController.hookMethod("onStart").after { + XposedBridge.log("[Shamrock] DeviceIdleController onStart") + } + DeviceIdleController.hookMethod("becomeInactiveIfAppropriateLocked").before { + XposedBridge.log("[Shamrock] DeviceIdleController becomeInactiveIfAppropriateLocked") + it.result = Unit + } + DeviceIdleController.hookMethod("stepIdleStateLocked").before { + XposedBridge.log("[Shamrock] DeviceIdleController stepIdleStateLocked") + it.result = Unit + } + return@runCatching 0 + }.getOrElse { -5 } + if(result < 0) { + XposedBridge.log("[Shamrock] Unable to hookDoze: $result") + } + } + } + + private fun hookAMS(pref: XSharedPreferences, loader: ClassLoader) { + kotlin.runCatching { + val ActivityManagerService = + XposedHelpers.findClass("com.android.server.am.ActivityManagerService", loader) + ActivityManagerService.hookMethod("newProcessRecordLocked").after { + increaseAdj(it.result) + } + }.onFailure { + XposedBridge.log("[Shamrock] Plan A failed: ${it.message}") + } + + if (pref.file.canRead()) { + allowPersistent = pref.getBoolean("persistent", false) + XposedBridge.log("[Shamrock] allowPersistent = $allowPersistent") + } else { + XposedBridge.log("[Shamrock] unable to load XSharedPreferences") + } + + kotlin.runCatching { + val ProcessList = XposedHelpers.findClass("com.android.server.am.ProcessList", loader) + ProcessList.hookMethod("newProcessRecordLocked").after { + increaseAdj(it.result) + } + }.onFailure { + XposedBridge.log("[Shamrock] Plan B failed: ${it.message}") + } + } + + private fun checkThread() { + if (!::KeepThread.isInitialized || !KeepThread.isAlive) { + KeepThread = Thread { + val deletedList = mutableSetOf() + while (true) { + Thread.sleep(100) + KeepRecords.forEach { + val isKilled = if (::METHOD_IS_KILLED.isInitialized) { + kotlin.runCatching { + METHOD_IS_KILLED.invoke(it) as Boolean + }.getOrElse { false } + } else false + if (isKilled) { + deletedList.add(it) + XposedBridge.log("Process Closed: $it") + } else { + keepByAdj(it) + } + } + if (deletedList.isNotEmpty()) { + KeepRecords.removeAll(deletedList) + deletedList.clear() + } + } + }.also { + it.isDaemon = true + it.start() + } + } + } + + private fun increaseAdj(record: Any) { + if (record.toString().contains("system", ignoreCase = true)) { + return + } + val applicationInfo = record.javaClass.getDeclaredField("info").also { + if (!it.isAccessible) it.isAccessible = true + }.get(record) as ApplicationInfo + if(applicationInfo.processName in KeepPackage) { + XposedBridge.log("[Shamrock] Process is keeping: $record") + KeepRecords.add(record) + keepByAdj(record) + // Error + if (allowPersistent) { + XposedBridge.log("[Shamrock] Open NoDied Mode!!!") + keepByPersistent(record) + } + checkThread() + } + } + + private fun keepByAdj(record: Any) { + val clazz = record.javaClass + if (!::METHOD_IS_KILLED.isInitialized) { + kotlin.runCatching { + METHOD_IS_KILLED = clazz.getDeclaredMethod("isKilled").also { + if (!it.isAccessible) it.isAccessible = true + } + } + } + kotlin.runCatching { + val newState = clazz.getDeclaredField("mState").also { + if (!it.isAccessible) it.isAccessible = true + }.get(record) + newState.javaClass.getDeclaredMethod("setMaxAdj", Int::class.java).also { + if (!it.isAccessible) it.isAccessible = true + }.invoke(newState, 1) + }.onFailure { + clazz.getDeclaredField("maxAdj").also { + if (!it.isAccessible) it.isAccessible = true + }.set(record, 1) + } + } + + private fun keepByPersistent(record: Any) { + val clazz = record.javaClass + kotlin.runCatching { + if (Build.VERSION.SDK_INT < 29) { + clazz.getDeclaredField("persistent").also { + if (!it.isAccessible) it.isAccessible = true + }.set(record, true) + } else { + clazz.getDeclaredMethod("setPersistent", Boolean::class.java).also { + if (!it.isAccessible) it.isAccessible = true + }.invoke(record, true) + } + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/loader/NativeLoader.kt b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/loader/NativeLoader.kt index 35eaf94..a2a9c68 100644 --- a/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/loader/NativeLoader.kt +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/xposed/loader/NativeLoader.kt @@ -34,8 +34,6 @@ internal object NativeLoader { XposedBridge.log("[Shamrock] 反射检测到 Android x86") true } else false - }.onFailure { - XposedBridge.log("[Shamrock] ${it.stackTraceToString()}") }.getOrElse { false } private fun getLibFilePath(name: String): String { diff --git a/xposed/src/main/java/qq/service/QQInterfaces.kt b/xposed/src/main/java/qq/service/QQInterfaces.kt new file mode 100644 index 0000000..22c3169 --- /dev/null +++ b/xposed/src/main/java/qq/service/QQInterfaces.kt @@ -0,0 +1,140 @@ +package qq.service + +import android.os.Bundle +import com.tencent.common.app.AppInterface +import com.tencent.mobileqq.pb.ByteStringMicro +import com.tencent.qphone.base.remote.FromServiceMsg +import com.tencent.qphone.base.remote.ToServiceMsg +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import moe.fuqiuluo.shamrock.utils.PlatformUtils +import mqq.app.MobileQQ +import protobuf.auto.toByteArray +import protobuf.oidb.TrpcOidb +import qq.service.internals.MSFHandler +import tencent.im.oidb.oidb_sso +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +abstract class QQInterfaces { + + companion object { + val app = (if (PlatformUtils.isMqqPackage()) + MobileQQ.getMobileQQ().waitAppRuntime() + else + MobileQQ.getMobileQQ().waitAppRuntime(null)) as AppInterface + + fun sendToServiceMsg(to: ToServiceMsg) { + app.sendToService(to) + } + + suspend fun sendToServiceMsgAW( + to: ToServiceMsg, + timeout: Duration = 30.seconds + ): FromServiceMsg? { + val seq = MSFHandler.nextSeq() + to.addAttribute("shamrock_uid", seq) + val resp: Pair? = withTimeoutOrNull(timeout) { + suspendCancellableCoroutine { continuation -> + GlobalScope.launch { + MSFHandler.registerResp(seq, continuation) + sendToServiceMsg(to) + } + } + } + if (resp == null) { + MSFHandler.unregisterResp(seq) + } + return resp?.second + } + + fun sendExtra(cmd: String, builder: (Bundle) -> Unit) { + val toServiceMsg = createToServiceMsg(cmd) + builder(toServiceMsg.extraData) + app.sendToService(toServiceMsg) + } + + fun createToServiceMsg(cmd: String): ToServiceMsg { + return ToServiceMsg("mobileqq.service", app.currentAccountUin, cmd) + } + + fun sendOidb(cmd: String, command: Int, service: Int, data: ByteArray, trpc: Boolean = false) { + val to = createToServiceMsg(cmd) + if (trpc) { + val oidb = TrpcOidb( + cmd = command, + service = service, + buffer = data, + flag = 1 + ) + to.putWupBuffer(oidb.toByteArray()) + } else { + val oidb = oidb_sso.OIDBSSOPkg() + oidb.uint32_command.set(command) + oidb.uint32_service_type.set(service) + oidb.bytes_bodybuffer.set(ByteStringMicro.copyFrom(data)) + oidb.str_client_version.set(PlatformUtils.getClientVersion(MobileQQ.getContext())) + to.putWupBuffer(oidb.toByteArray()) + } + to.addAttribute("req_pb_protocol_flag", true) + app.sendToService(to) + } + + fun sendBuffer( + cmd: String, + isProto: Boolean, + data: ByteArray, + ) { + val toServiceMsg = createToServiceMsg(cmd) + toServiceMsg.putWupBuffer(data) + toServiceMsg.addAttribute("req_pb_protocol_flag", isProto) + sendToServiceMsg(toServiceMsg) + } + + @DelicateCoroutinesApi + suspend fun sendBufferAW( + cmd: String, + isProto: Boolean, + data: ByteArray, + timeout: Duration = 30.seconds + ): FromServiceMsg? { + val toServiceMsg = createToServiceMsg(cmd) + toServiceMsg.putWupBuffer(data) + toServiceMsg.addAttribute("req_pb_protocol_flag", isProto) + return sendToServiceMsgAW(toServiceMsg, timeout) + } + + @DelicateCoroutinesApi + suspend fun sendOidbAW( + cmd: String, + command: Int, + service: Int, + data: ByteArray, + trpc: Boolean = false, + timeout: Duration = 30.seconds + ): FromServiceMsg? { + val to = createToServiceMsg(cmd) + if (trpc) { + val oidb = TrpcOidb( + cmd = command, + service = service, + buffer = data, + flag = 1 + ) + to.putWupBuffer(oidb.toByteArray()) + } else { + val oidb = oidb_sso.OIDBSSOPkg() + oidb.uint32_command.set(command) + oidb.uint32_service_type.set(service) + oidb.bytes_bodybuffer.set(ByteStringMicro.copyFrom(data)) + oidb.str_client_version.set(PlatformUtils.getClientVersion(MobileQQ.getContext())) + to.putWupBuffer(oidb.toByteArray()) + } + to.addAttribute("req_pb_protocol_flag", true) + return sendToServiceMsgAW(to, timeout) + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/bdh/FileTransfer.kt b/xposed/src/main/java/qq/service/bdh/FileTransfer.kt new file mode 100644 index 0000000..4e44bcb --- /dev/null +++ b/xposed/src/main/java/qq/service/bdh/FileTransfer.kt @@ -0,0 +1,190 @@ +@file:OptIn(DelicateCoroutinesApi::class) + +package qq.service.bdh + +import com.tencent.mobileqq.transfile.BaseTransProcessor +import com.tencent.mobileqq.transfile.FileMsg +import com.tencent.mobileqq.transfile.TransferRequest +import com.tencent.mobileqq.transfile.api.ITransFileController +import com.tencent.mobileqq.utils.httputils.IHttpCommunicatorListener +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.utils.MD5 +import mqq.app.AppRuntime +import qq.service.QQInterfaces +import java.io.File +import java.lang.Math.abs +import kotlin.coroutines.resume +import kotlin.random.Random + +internal abstract class FileTransfer { + suspend fun transC2CResource( + peerId: String, + file: File, + fileType: Int, busiType: Int, + wait: Boolean = true, + builder: (TransferRequest) -> Unit + ): Boolean { + val runtime = QQInterfaces.app + val transferRequest = TransferRequest() + transferRequest.needSendMsg = false + transferRequest.mSelfUin = runtime.account + transferRequest.mPeerUin = peerId + transferRequest.mSecondId = runtime.currentAccountUin + transferRequest.mUinType = FileMsg.UIN_BUDDY + transferRequest.mFileType = fileType + transferRequest.mUniseq = createMessageUniseq() + transferRequest.mIsUp = true + builder(transferRequest) + transferRequest.mBusiType = busiType + transferRequest.mMd5 = MD5.genFileMd5Hex(file.absolutePath) + transferRequest.mLocalPath = file.absolutePath + return transAndWait(runtime, transferRequest, wait) + } + + suspend fun transTroopResource( + groupId: String, + file: File, + fileType: Int, busiType: Int, + wait: Boolean = true, + builder: (TransferRequest) -> Unit + ): Boolean { + val runtime = QQInterfaces.app + val transferRequest = TransferRequest() + transferRequest.needSendMsg = false + transferRequest.mSelfUin = runtime.account + transferRequest.mPeerUin = groupId + transferRequest.mSecondId = runtime.currentAccountUin + transferRequest.mUinType = FileMsg.UIN_TROOP + transferRequest.mFileType = fileType + transferRequest.mUniseq = createMessageUniseq() + transferRequest.mIsUp = true + builder(transferRequest) + transferRequest.mBusiType = busiType + transferRequest.mMd5 = MD5.genFileMd5Hex(file.absolutePath) + transferRequest.mLocalPath = file.absolutePath + return transAndWait(runtime, transferRequest, wait) + } + + private suspend fun transAndWait( + runtime: AppRuntime, + transferRequest: TransferRequest, + wait: Boolean + ): Boolean { + return withTimeoutOrNull(60_000) { + val service = runtime.getRuntimeService(ITransFileController::class.java, "all") + if(service.transferAsync(transferRequest)) { + if (!wait) { // 如果无需等待直接返回 + return@withTimeoutOrNull true + } + suspendCancellableCoroutine { continuation -> + GlobalScope.launch { + lateinit var processor: IHttpCommunicatorListener + while ( + //service.findProcessor( + // transferRequest.keyForTransfer // uin + uniseq + //) != null + service.containsProcessor(runtime.currentAccountUin, transferRequest.mUniseq) + // 如果上传处理器依旧存在,说明没有上传成功 + && service.isWorking.get() + ) { + processor = service.findProcessor(runtime.currentAccountUin, transferRequest.mUniseq) + delay(100) + } + if (processor is BaseTransProcessor && processor.file != null) { + val fileMsg = processor.file + LogCenter.log("[OldBDH] 资源上传结束(fileId = ${fileMsg.fileID}, fileKey = ${fileMsg.fileKey}, path = ${fileMsg.filePath})") + } + continuation.resume(true) + } + // 实现取消上传器 + // 目前没什么用 + continuation.invokeOnCancellation { + continuation.resume(false) + } + } + } else true + } ?: false + } + + companion object { + const val SEND_MSG_BUSINESS_TYPE_AIO_ALBUM_PIC = 1031 + const val SEND_MSG_BUSINESS_TYPE_AIO_KEY_WORD_PIC = 1046 + const val SEND_MSG_BUSINESS_TYPE_AIO_QZONE_PIC = 1045 + const val SEND_MSG_BUSINESS_TYPE_ALBUM_PIC = 1007 + const val SEND_MSG_BUSINESS_TYPE_BLESS = 1056 + const val SEND_MSG_BUSINESS_TYPE_CAPTURE_PIC = 1008 + const val SEND_MSG_BUSINESS_TYPE_COMMEN_FALSH_PIC = 1040 + const val SEND_MSG_BUSINESS_TYPE_CUSTOM = 1006 + const val SEND_MSG_BUSINESS_TYPE_DOUTU_PIC = 1044 + const val SEND_MSG_BUSINESS_TYPE_FALSH_PIC = 1039 + const val SEND_MSG_BUSINESS_TYPE_FAST_IMAGE = 1037 + const val SEND_MSG_BUSINESS_TYPE_FORWARD_EDIT = 1048 + const val SEND_MSG_BUSINESS_TYPE_FORWARD_PIC = 1009 + const val SEND_MSG_BUSINESS_TYPE_FULL_SCREEN_ESSENCE = 1057 + const val SEND_MSG_BUSINESS_TYPE_GALEERY_PIC = 1041 + const val SEND_MSG_BUSINESS_TYPE_GAME_CENTER_STRATEGY = 1058 + const val SEND_MSG_BUSINESS_TYPE_HOT_PIC = 1042 + const val SEND_MSG_BUSINESS_TYPE_MIXED_PICS = 1043 + const val SEND_MSG_BUSINESS_TYPE_PIC_AIO_ALBUM = 1052 + const val SEND_MSG_BUSINESS_TYPE_PIC_CAMERA = 1050 + const val SEND_MSG_BUSINESS_TYPE_PIC_FAV = 1053 + const val SEND_MSG_BUSINESS_TYPE_PIC_SCREEN = 1027 + const val SEND_MSG_BUSINESS_TYPE_PIC_SHARE = 1030 + const val SEND_MSG_BUSINESS_TYPE_PIC_TAB_CAMERA = 1051 + const val SEND_MSG_BUSINESS_TYPE_QQPINYIN_SEND_PIC = 1038 + const val SEND_MSG_BUSINESS_TYPE_RECOMMENDED_STICKER = 1047 + const val SEND_MSG_BUSINESS_TYPE_RELATED_EMOTION = 1054 + const val SEND_MSG_BUSINESS_TYPE_SHOWLOVE = 1036 + const val SEND_MSG_BUSINESS_TYPE_SOGOU_SEND_PIC = 1034 + const val SEND_MSG_BUSINESS_TYPE_TROOP_BAR = 1035 + const val SEND_MSG_BUSINESS_TYPE_WLAN_RECV_NOTIFY = 1055 + const val SEND_MSG_BUSINESS_TYPE_ZHITU_PIC = 1049 + const val SEND_MSG_BUSINESS_TYPE_ZPLAN_EMOTICON_GIF = 1060 + const val SEND_MSG_BUSINESS_TYPE_ZPLAN_PIC = 1059 + + const val VIDEO_FORMAT_AFS = 7 + const val VIDEO_FORMAT_AVI = 1 + const val VIDEO_FORMAT_MKV = 4 + const val VIDEO_FORMAT_MOD = 9 + const val VIDEO_FORMAT_MOV = 8 + const val VIDEO_FORMAT_MP4 = 2 + const val VIDEO_FORMAT_MTS = 11 + const val VIDEO_FORMAT_RM = 6 + const val VIDEO_FORMAT_RMVB = 5 + const val VIDEO_FORMAT_TS = 10 + const val VIDEO_FORMAT_WMV = 3 + + const val BUSI_TYPE_GUILD_VIDEO = 4601 + const val BUSI_TYPE_MULTI_FORWARD_VIDEO = 1010 + const val BUSI_TYPE_PUBACCOUNT_PERM_VIDEO = 1009 + const val BUSI_TYPE_PUBACCOUNT_TEMP_VIDEO = 1007 + const val BUSI_TYPE_SHORT_VIDEO = 1 + const val BUSI_TYPE_SHORT_VIDEO_PTV = 2 + const val BUSI_TYPE_VIDEO = 0 + const val BUSI_TYPE_VIDEO_EMOTICON_PIC = 1022 + const val BUSI_TYPE_VIDEO_EMOTICON_VIDEO = 1021 + + const val TRANSFILE_TYPE_PIC = 1 + const val TRANSFILE_TYPE_PIC_EMO = 65538 + const val TRANSFILE_TYPE_PIC_THUMB = 65537 + const val TRANSFILE_TYPE_PISMA = 49 + const val TRANSFILE_TYPE_RAWPIC = 131075 + + const val TRANSFILE_TYPE_PROFILE_COVER = 35 + const val TRANSFILE_TYPE_PTT = 2 + const val TRANSFILE_TYPE_PTT_SLICE_TO_TEXT = 327696 + const val TRANSFILE_TYPE_QQHEAD_PIC = 131074 + + internal fun createMessageUniseq(time: Long = System.currentTimeMillis()): Long { + var uniseq = (time / 1000).toInt().toLong() + uniseq = uniseq shl 32 or kotlin.math.abs(Random.nextInt()).toLong() + return uniseq + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/bdh/NtV2RichMediaSvc.kt b/xposed/src/main/java/qq/service/bdh/NtV2RichMediaSvc.kt new file mode 100644 index 0000000..e25ffd7 --- /dev/null +++ b/xposed/src/main/java/qq/service/bdh/NtV2RichMediaSvc.kt @@ -0,0 +1,495 @@ +package qq.service.bdh + +import android.graphics.BitmapFactory +import androidx.exifinterface.media.ExifInterface +import com.tencent.mobileqq.qroute.QRoute +import com.tencent.qqnt.aio.adapter.api.IAIOPttApi +import com.tencent.qqnt.kernel.nativeinterface.CommonFileInfo +import com.tencent.qqnt.kernel.nativeinterface.Contact +import com.tencent.qqnt.kernel.nativeinterface.FileTransNotifyInfo +import com.tencent.qqnt.kernel.nativeinterface.MsgConstant +import com.tencent.qqnt.kernel.nativeinterface.MsgElement +import com.tencent.qqnt.kernel.nativeinterface.PicElement +import com.tencent.qqnt.kernel.nativeinterface.PttElement +import com.tencent.qqnt.kernel.nativeinterface.QQNTWrapperUtil +import com.tencent.qqnt.kernel.nativeinterface.RichMediaFilePathInfo +import com.tencent.qqnt.kernel.nativeinterface.VideoElement +import com.tencent.qqnt.msg.api.IMsgService +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import moe.fuqiuluo.shamrock.config.ResourceGroup +import moe.fuqiuluo.shamrock.config.ShamrockConfig +import moe.fuqiuluo.shamrock.tools.hex2ByteArray +import moe.fuqiuluo.shamrock.tools.ifNullOrEmpty +import moe.fuqiuluo.shamrock.tools.slice +import moe.fuqiuluo.shamrock.utils.AudioUtils +import moe.fuqiuluo.shamrock.utils.FileUtils +import moe.fuqiuluo.shamrock.utils.MediaType +import moe.fuqiuluo.symbols.decodeProtobuf +import protobuf.auto.toByteArray +import protobuf.oidb.TrpcOidb +import protobuf.oidb.cmd0x11c5.ClientMeta +import protobuf.oidb.cmd0x11c5.CodecConfigReq +import protobuf.oidb.cmd0x11c5.CommonHead +import protobuf.oidb.cmd0x11c5.DownloadExt +import protobuf.oidb.cmd0x11c5.DownloadReq +import protobuf.oidb.cmd0x11c5.FileInfo +import protobuf.oidb.cmd0x11c5.FileType +import protobuf.oidb.cmd0x11c5.IndexNode +import protobuf.oidb.cmd0x11c5.MultiMediaReqHead +import protobuf.oidb.cmd0x11c5.NtV2RichMediaReq +import protobuf.oidb.cmd0x11c5.NtV2RichMediaRsp +import protobuf.oidb.cmd0x11c5.SceneInfo +import protobuf.oidb.cmd0x11c5.UploadInfo +import protobuf.oidb.cmd0x11c5.UploadReq +import protobuf.oidb.cmd0x11c5.UploadRsp +import protobuf.oidb.cmd0x11c5.VideoDownloadExt +import protobuf.oidb.cmd0x388.Cmd0x388ReqBody +import protobuf.oidb.cmd0x388.Cmd0x388RspBody +import protobuf.oidb.cmd0x388.TryUpImgReq +import qq.service.QQInterfaces +import qq.service.internals.NTServiceFetcher +import qq.service.internals.msgService +import qq.service.kernel.SimpleKernelMsgListener +import qq.service.msg.MessageHelper +import java.io.File +import kotlin.coroutines.resume +import kotlin.math.roundToInt +import kotlin.random.Random +import kotlin.random.nextUInt +import kotlin.random.nextULong +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +internal object NtV2RichMediaSvc: QQInterfaces() { + private val requestIdSeq = atomic(1L) + + fun fetchGroupResUploadTo(): String { + return ShamrockConfig[ResourceGroup].ifNullOrEmpty { "100000000" }!! + } + + suspend fun tryUploadResourceByNt( + chatType: Int, + elementType: Int, + resources: ArrayList, + timeout: Duration, + retryCnt: Int = 5 + ): Result> { + return internalTryUploadResourceByNt(chatType, elementType, resources, timeout).onFailure { + if (retryCnt > 0) { + return tryUploadResourceByNt(chatType, elementType, resources, timeout, retryCnt - 1) + } + } + } + + /** + * 批量上传图片 + */ + private suspend fun internalTryUploadResourceByNt( + chatType: Int, + elementType: Int, + resources: ArrayList, + timeout: Duration + ): Result> { + require(resources.size in 1 .. 10) { "imageFiles.size() must be in 1 .. 10" } + + val messages = ArrayList(resources.map { file -> + val elem = MsgElement() + elem.elementType = elementType + when(elementType) { + MsgConstant.KELEMTYPEPIC -> { + val pic = PicElement() + pic.md5HexStr = QQNTWrapperUtil.CppProxy.genFileMd5Hex(file.absolutePath) + val msgService = NTServiceFetcher.kernelService.msgService!! + val originalPath = msgService.getRichMediaFilePathForMobileQQSend( + RichMediaFilePathInfo( + 2, 0, pic.md5HexStr, file.name, 1, 0, null, "", true + ) + ) + if (!QQNTWrapperUtil.CppProxy.fileIsExist(originalPath) || QQNTWrapperUtil.CppProxy.getFileSize( + originalPath + ) != file.length() + ) { + val thumbPath = msgService.getRichMediaFilePathForMobileQQSend( + RichMediaFilePathInfo( + 2, 0, pic.md5HexStr, file.name, 2, 720, null, "", true + ) + ) + QQNTWrapperUtil.CppProxy.copyFile(file.absolutePath, originalPath) + QQNTWrapperUtil.CppProxy.copyFile(file.absolutePath, thumbPath) + } + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + BitmapFactory.decodeFile(file.absolutePath, options) + val exifInterface = ExifInterface(file.absolutePath) + val orientation = exifInterface.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_UNDEFINED + ) + if (orientation != ExifInterface.ORIENTATION_ROTATE_90 && orientation != ExifInterface.ORIENTATION_ROTATE_270) { + pic.picWidth = options.outWidth + pic.picHeight = options.outHeight + } else { + pic.picWidth = options.outHeight + pic.picHeight = options.outWidth + } + pic.sourcePath = file.absolutePath + pic.fileSize = QQNTWrapperUtil.CppProxy.getFileSize(file.absolutePath) + pic.original = true + pic.picType = FileUtils.getPicType(file) + elem.picElement = pic + } + MsgConstant.KELEMTYPEPTT -> { + require(resources.size == 1) // 语音只能单个上传 + var pttFile = file + val ptt = PttElement() + when (AudioUtils.getMediaType(pttFile)) { + MediaType.Silk -> { + ptt.formatType = MsgConstant.KPTTFORMATTYPESILK + ptt.duration = QRoute.api(IAIOPttApi::class.java) + .getPttFileDuration(pttFile.absolutePath) + } + MediaType.Amr -> { + ptt.duration = AudioUtils.getDurationSec(pttFile) + ptt.formatType = MsgConstant.KPTTFORMATTYPEAMR + } + MediaType.Pcm -> { + val result = AudioUtils.pcmToSilk(pttFile) + ptt.duration = (result.second * 0.001).roundToInt() + pttFile = result.first + ptt.formatType = MsgConstant.KPTTFORMATTYPESILK + } + + else -> { + val result = AudioUtils.audioToSilk(pttFile) + ptt.duration = runCatching { + QRoute.api(IAIOPttApi::class.java) + .getPttFileDuration(result.second.absolutePath) + }.getOrElse { + result.first + } + pttFile = result.second + ptt.formatType = MsgConstant.KPTTFORMATTYPESILK + } + } + ptt.md5HexStr = QQNTWrapperUtil.CppProxy.genFileMd5Hex(pttFile.absolutePath) + val msgService = NTServiceFetcher.kernelService.msgService!! + val originalPath = msgService.getRichMediaFilePathForMobileQQSend( + RichMediaFilePathInfo( + MsgConstant.KELEMTYPEPTT, 0, ptt.md5HexStr, file.name, 1, 0, null, "", true + ) + ) + if (!QQNTWrapperUtil.CppProxy.fileIsExist(originalPath) || QQNTWrapperUtil.CppProxy.getFileSize(originalPath) != pttFile.length()) { + QQNTWrapperUtil.CppProxy.copyFile(pttFile.absolutePath, originalPath) + } + if (originalPath != null) { + ptt.filePath = originalPath + } else { + ptt.filePath = pttFile.absolutePath + } + ptt.canConvert2Text = true + ptt.fileId = 0 + ptt.fileUuid = "" + ptt.text = "" + ptt.voiceType = MsgConstant.KPTTVOICETYPESOUNDRECORD + ptt.voiceChangeType = MsgConstant.KPTTVOICECHANGETYPENONE + elem.pttElement = ptt + } + MsgConstant.KELEMTYPEVIDEO -> { + require(resources.size == 1) // 视频只能单个上传 + val video = VideoElement() + video.videoMd5 = QQNTWrapperUtil.CppProxy.genFileMd5Hex(file.absolutePath) + val msgService = NTServiceFetcher.kernelService.msgService!! + val originalPath = msgService.getRichMediaFilePathForMobileQQSend( + RichMediaFilePathInfo( + 5, 2, video.videoMd5, file.name, 1, 0, null, "", true + ) + ) + val thumbPath = msgService.getRichMediaFilePathForMobileQQSend( + RichMediaFilePathInfo( + 5, 1, video.videoMd5, file.name, 2, 0, null, "", true + ) + ) + if (!QQNTWrapperUtil.CppProxy.fileIsExist(originalPath) || QQNTWrapperUtil.CppProxy.getFileSize( + originalPath + ) != file.length() + ) { + QQNTWrapperUtil.CppProxy.copyFile(file.absolutePath, originalPath) + AudioUtils.obtainVideoCover(file.absolutePath, thumbPath!!) + } + video.fileTime = AudioUtils.getVideoTime(file) + video.fileSize = file.length() + video.fileName = file.name + video.fileFormat = FileTransfer.VIDEO_FORMAT_MP4 + video.thumbSize = QQNTWrapperUtil.CppProxy.getFileSize(thumbPath).toInt() + val options = BitmapFactory.Options() + BitmapFactory.decodeFile(thumbPath, options) + video.thumbWidth = options.outWidth + video.thumbHeight = options.outHeight + video.thumbMd5 = QQNTWrapperUtil.CppProxy.genFileMd5Hex(thumbPath) + video.thumbPath = hashMapOf(0 to thumbPath) + elem.videoElement = video + } + else -> throw IllegalArgumentException("unsupported elementType: $elementType") + } + return@map elem + }) + if (messages.isEmpty()) { + return Result.failure(Exception("no valid image files")) + } + val contact = when(chatType) { + MsgConstant.KCHATTYPEC2C -> MessageHelper.generateContact(chatType, app.currentAccountUin) + else -> Contact(chatType, fetchGroupResUploadTo(), null) + } + val result = mutableListOf() + val msgService = NTServiceFetcher.kernelService.msgService + ?: return Result.failure(Exception("kernelService.msgService is null")) + withTimeoutOrNull(timeout) { + val uniseq = MessageHelper.generateMsgId(chatType) + suspendCancellableCoroutine { + val listener = object: SimpleKernelMsgListener() { + override fun onRichMediaUploadComplete(fileTransNotifyInfo: FileTransNotifyInfo) { + if (fileTransNotifyInfo.msgId == uniseq) { + result.add(fileTransNotifyInfo.commonFileInfo) + } + if (result.size == resources.size) { + msgService.removeMsgListener(this) + it.resume(true) + } + } + } + msgService.addMsgListener(listener) + + QRoute.api(IMsgService::class.java).sendMsg(contact, uniseq, messages) { _, _ -> + if (contact.chatType == MsgConstant.KCHATTYPEGROUP && contact.peerUid == "100000000") { + val kernelService = NTServiceFetcher.kernelService + val sessionService = kernelService.wrapperSession + val service = sessionService.msgService + service.deleteMsg(contact, arrayListOf(uniseq), null) + } + } + + it.invokeOnCancellation { + msgService.removeMsgListener(listener) + } + } + } + + if (result.isEmpty()) { + return Result.failure(Exception("upload failed")) + } + + return Result.success(result) + } + + /** + * 获取NT图片的RKEY + */ + suspend fun getNtPicRKey( + fileId: String, + md5: String, + sha: String, + fileSize: ULong, + width: UInt, + height: UInt, + sceneBuilder: suspend SceneInfo.() -> Unit + ): Result { + runCatching { + val req = NtV2RichMediaReq( + head = MultiMediaReqHead( + commonHead = CommonHead( + requestId = requestIdSeq.incrementAndGet().toULong(), + cmd = 200u + ), + sceneInfo = SceneInfo( + requestType = 2u, + businessType = 1u, + ).apply { + sceneBuilder() + }, + clientMeta = ClientMeta(2u) + ), + download = DownloadReq( + IndexNode( + FileInfo( + fileSize = fileSize, + md5 = md5.lowercase(), + sha1 = sha.lowercase(), + name = "${md5}.jpg", + fileType = FileType( + fileType = 1u, + picFormat = 1000u, + videoFormat = 0u, + voiceFormat = 0u + ), + width = width, + height = height, + time = 0u, + original = 1u + ), + fileUuid = fileId, + storeId = 1u, + uploadTime = 0u, + ttl = 0u, + subType = 0u, + storeAppId = 0u + ), + DownloadExt( + video = VideoDownloadExt( + busiType = 0u, + subBusiType = 0u, + msgCodecConfig = CodecConfigReq( + platformChipinfo = "", + osVer = "", + deviceName = "" + ), + flag = 1u + ) + ) + ) + ).toByteArray() + val fromServiceMsg = sendOidbAW("OidbSvcTrpcTcp.0x11c5_200", 4549, 200, req, true) + if (fromServiceMsg == null || fromServiceMsg.wupBuffer == null) { + return Result.failure(Exception("unable to get multimedia pic info: ${fromServiceMsg?.wupBuffer}")) + } + fromServiceMsg.wupBuffer.slice(4).decodeProtobuf().buffer.decodeProtobuf().download?.rkeyParam?.let { + return Result.success(it) + } + }.onFailure { + return Result.failure(it) + } + return Result.failure(Exception("unable to get c2c nt pic")) + } + + suspend fun requestUploadNtPic( + file: File, + md5: String, + sha: String, + name: String, + width: UInt, + height: UInt, + retryCnt: Int, + sceneBuilder: suspend SceneInfo.() -> Unit + ): Result { + return runCatching { + requestUploadNtPic(file, md5, sha, name, width, height, sceneBuilder).getOrThrow() + }.onFailure { + if (retryCnt > 0) { + return requestUploadNtPic(file, md5, sha, name, width, height, retryCnt - 1, sceneBuilder) + } + } + } + + private suspend fun requestUploadNtPic( + file: File, + md5: String, + sha: String, + name: String, + width: UInt, + height: UInt, + sceneBuilder: suspend SceneInfo.() -> Unit + ): Result { + val req = NtV2RichMediaReq( + head = MultiMediaReqHead( + commonHead = CommonHead( + requestId = requestIdSeq.incrementAndGet().toULong(), + cmd = 100u + ), + sceneInfo = SceneInfo( + requestType = 2u, + businessType = 1u, + ).apply { + sceneBuilder() + }, + clientMeta = ClientMeta(2u) + ), + upload = UploadReq( + listOf(UploadInfo( + FileInfo( + fileSize = file.length().toULong(), + md5 = md5, + sha1 = sha, + name = name, + fileType = FileType( + fileType = 1u, + picFormat = 1000u, + videoFormat = 0u, + voiceFormat = 0u + ), + width = width, + height = height, + time = 0u, + original = 1u + ), + subFileType = 0u + )), + tryFastUploadCompleted = true, + srvSendMsg = false, + clientRandomId = Random.nextULong(), + compatQMsgSceneType = 1u, + clientSeq = Random.nextUInt(), + noNeedCompatMsg = false + ) + ).toByteArray() + val fromServiceMsg = sendOidbAW("OidbSvcTrpcTcp.0x11c5_100", 4549, 100, req, true, timeout = 3.seconds) + if (fromServiceMsg == null || fromServiceMsg.wupBuffer == null) { + return Result.failure(Exception("unable to request upload nt pic")) + } + val rspBuffer = fromServiceMsg.wupBuffer.slice(4).decodeProtobuf().buffer + val rsp = rspBuffer.decodeProtobuf() + if (rsp.upload == null) { + return Result.failure(Exception("unable to request upload nt pic: ${rsp.head}")) + } + return Result.success(rsp.upload!!) + } + + /** + * 使用OldBDH获取图片上传状态以及图片上传服务器 + */ + suspend fun requestUploadGroupPic( + groupId: ULong, + md5: String, + fileSize: ULong, + width: UInt, + height: UInt, + ): Result { + return runCatching { + val fromServiceMsg = sendBufferAW("ImgStore.GroupPicUp", true, Cmd0x388ReqBody( + netType = 3, + subCmd = 1, + msgTryUpImg = arrayListOf( + TryUpImgReq( + groupCode = groupId.toLong(), + srcUin = app.longAccountUin, + fileMd5 = md5.hex2ByteArray(), + fileSize = fileSize.toLong(), + fileName = "$md5.jpg", + srcTerm = 2, + platformType = 9, + buType = 212, + picWidth = width.toInt(), + picHeight = height.toInt(), + picType = 1000, + buildVer = "1.0.0", + originalPic = 1, + fileIndex = byteArrayOf(), + srvUpload = 0 + ) + ), + ).toByteArray()) + if (fromServiceMsg == null || fromServiceMsg.wupBuffer == null) { + return Result.failure(Exception("unable to request upload group pic")) + } + val rsp = fromServiceMsg.wupBuffer.slice(4).decodeProtobuf() + .msgTryUpImgRsp!!.first() + TryUpPicData( + uKey = rsp.ukey, + exist = rsp.fileExist, + fileId = rsp.fileId.toULong(), + upIp = rsp.upIp, + upPort = rsp.upPort + ) + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/bdh/ResourceData.kt b/xposed/src/main/java/qq/service/bdh/ResourceData.kt new file mode 100644 index 0000000..5b02d6c --- /dev/null +++ b/xposed/src/main/java/qq/service/bdh/ResourceData.kt @@ -0,0 +1,58 @@ +package qq.service.bdh + +import com.tencent.mobileqq.data.MessageRecord +import java.io.File + +internal enum class ContactType { + TROOP, + PRIVATE, +} + +internal interface TransTarget { + val id: String + val type: ContactType + + val mRec: MessageRecord? +} + +internal class Troop( + override val id: String, + override val mRec: MessageRecord? = null +): TransTarget { + override val type: ContactType = ContactType.TROOP +} + +internal class Private( + override val id: String, + override val mRec: MessageRecord? = null +): TransTarget { + override val type: ContactType = ContactType.PRIVATE +} + +internal enum class ResourceType { + Picture, + Video, + Voice +} + +internal interface Resource { + val type: ResourceType +} + +internal data class PictureResource( + val src: File +): Resource { + override val type = ResourceType.Picture +} + +internal data class VideoResource( + val src: File, val thumb: File +): Resource { + override val type = ResourceType.Video +} + +internal data class VoiceResource( + val src: File +): Resource { + override val type = ResourceType.Voice +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/bdh/RichProtoSvc.kt b/xposed/src/main/java/qq/service/bdh/RichProtoSvc.kt new file mode 100644 index 0000000..f8ac3a2 --- /dev/null +++ b/xposed/src/main/java/qq/service/bdh/RichProtoSvc.kt @@ -0,0 +1,429 @@ +@file:OptIn(ExperimentalSerializationApi::class) +package qq.service.bdh + +import com.tencent.mobileqq.pb.ByteStringMicro +import com.tencent.mobileqq.transfile.FileMsg +import com.tencent.mobileqq.transfile.api.IProtoReqManager +import com.tencent.mobileqq.transfile.protohandler.RichProto +import com.tencent.mobileqq.transfile.protohandler.RichProtoProc +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.ExperimentalSerializationApi +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.tools.slice +import moe.fuqiuluo.shamrock.tools.toHexString +import moe.fuqiuluo.shamrock.utils.PlatformUtils +import moe.fuqiuluo.symbols.decodeProtobuf +import mqq.app.MobileQQ +import protobuf.auto.toByteArray +import protobuf.oidb.cmd0x11c5.C2CUserInfo +import protobuf.oidb.cmd0x11c5.ChannelUserInfo +import protobuf.oidb.cmd0x11c5.GroupUserInfo +import protobuf.oidb.cmd0xfc2.Oidb0xfc2ChannelInfo +import protobuf.oidb.cmd0xfc2.Oidb0xfc2MsgApplyDownloadReq +import protobuf.oidb.cmd0xfc2.Oidb0xfc2ReqBody +import protobuf.oidb.cmd0xfc2.Oidb0xfc2RspBody +import qq.service.QQInterfaces +import qq.service.contact.ContactHelper +import tencent.im.cs.cmd0x346.cmd0x346 +import tencent.im.oidb.cmd0x6d6.oidb_0x6d6 +import tencent.im.oidb.cmd0xe37.cmd0xe37 +import tencent.im.oidb.oidb_sso +import kotlin.coroutines.resume + +private const val GPRO_PIC = "gchat.qpic.cn" +private const val MULTIMEDIA_DOMAIN = "multimedia.nt.qq.com.cn" +private const val C2C_PIC = "c2cpicdw.qpic.cn" + +internal object RichProtoSvc: QQInterfaces() { + suspend fun getGuildFileDownUrl(peerId: String, channelId: String, fileId: String, bizId: Int): String { + val fromServiceMsg = sendOidbAW("OidbSvcTrpcTcp.0xfc2_0", 4034, 0, Oidb0xfc2ReqBody( + msgCmd = 1200, + msgBusType = 4202, + msgChannelInfo = Oidb0xfc2ChannelInfo( + guildId = peerId.toULong(), + channelId = channelId.toULong() + ), + msgTerminalType = 2, + msgApplyDownloadReq = Oidb0xfc2MsgApplyDownloadReq( + fieldId = fileId, + supportEncrypt = 0 + ) + ).toByteArray()) + if (fromServiceMsg == null || fromServiceMsg.wupBuffer == null) { + return "" + } + val body = oidb_sso.OIDBSSOPkg() + body.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + body.bytes_bodybuffer + .get().toByteArray() + .decodeProtobuf() + .msgApplyDownloadRsp?.let { + it.msgDownloadInfo?.let { + return "https://${it.downloadDomain}${it.downloadUrl}&fname=$fileId&isthumb=0" + } + } + return "" + } + + suspend fun getGroupFileDownUrl( + peerId: Long, + fileId: String, + bizId: Int = 102 + ): String { + val fromServiceMsg = sendOidbAW("OidbSvcTrpcTcp.0x6d6_2", 1750, 2, oidb_0x6d6.ReqBody().apply { + download_file_req.set(oidb_0x6d6.DownloadFileReqBody().apply { + uint64_group_code.set(peerId) + uint32_app_id.set(3) + uint32_bus_id.set(bizId) + str_file_id.set(fileId) + }) + }.toByteArray()) + if (fromServiceMsg == null || fromServiceMsg.wupBuffer == null) { + return "" + } + val body = oidb_sso.OIDBSSOPkg() + body.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + val result = oidb_0x6d6.RspBody().mergeFrom(body.bytes_bodybuffer.get().toByteArray()) + if (body.uint32_result.get() != 0 + || result.download_file_rsp.int32_ret_code.get() != 0) { + return "" + } + + val domain = if (!result.download_file_rsp.str_download_dns.has()) + ("https://" + result.download_file_rsp.str_download_ip.get()) + else ("http://" + result.download_file_rsp.str_download_dns.get().toByteArray().decodeToString()) + val downloadUrl = result.download_file_rsp.bytes_download_url.get().toByteArray().toHexString() + val appId = MobileQQ.getMobileQQ().appId + val version = PlatformUtils.getQQVersion(MobileQQ.getContext()) + + return "$domain/ftn_handler/$downloadUrl/?fname=$fileId&client_proto=qq&client_appid=$appId&client_type=android&client_ver=$version&client_down_type=auto&client_aio_type=unk" + } + + suspend fun getC2CFileDownUrl( + fileId: String, + subId: String, + retryCnt: Int = 0 + ): String { + val fromServiceMsg = sendOidbAW("OidbSvc.0xe37_1200", 3639, 1200, cmd0xe37.Req0xe37().apply { + bytes_cmd_0x346_req_body.set(ByteStringMicro.copyFrom(cmd0x346.ReqBody().apply { + uint32_cmd.set(1200) + uint32_seq.set(1) + msg_apply_download_req.set(cmd0x346.ApplyDownloadReq().apply { + uint64_uin.set(app.longAccountUin) + bytes_uuid.set(ByteStringMicro.copyFrom(fileId.toByteArray())) + uint32_owner_type.set(2) + str_fileidcrc.set(subId) + + }) + uint32_business_id.set(3) + uint32_client_type.set(104) + uint32_flag_support_mediaplatform.set(1) + msg_extension_req.set(cmd0x346.ExtensionReq().apply { + uint32_download_url_type.set(1) + }) + }.toByteArray())) + }.toByteArray()) + if (fromServiceMsg == null || fromServiceMsg.wupBuffer == null) { + if (retryCnt < 5) { + return getC2CFileDownUrl(fileId, subId, retryCnt + 1) + } + return "" + } else { + val body = oidb_sso.OIDBSSOPkg() + body.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + val result = cmd0x346.RspBody().mergeFrom(cmd0xe37.Resp0xe37().mergeFrom( + body.bytes_bodybuffer.get().toByteArray() + ).bytes_cmd_0x346_rsp_body.get().toByteArray()) + if (body.uint32_result.get() != 0 || + result.msg_apply_download_rsp.int32_ret_code.has() && result.msg_apply_download_rsp.int32_ret_code.get() != 0) { + return "" + } + + val oldData = result.msg_apply_download_rsp.msg_download_info + //val newData = result[14, 40] NTQQ 文件信息 + + val domain = if (oldData.str_download_dns.has()) ("https://" + oldData.str_download_dns.get()) else ("http://" + oldData.rpt_str_downloadip_list.get().first()) + val params = oldData.str_download_url.get() + val appId = MobileQQ.getMobileQQ().appId + val version = PlatformUtils.getQQVersion(MobileQQ.getContext()) + + return "$domain$params&isthumb=0&client_proto=qq&client_appid=$appId&client_type=android&client_ver=$version&client_down_type=auto&client_aio_type=unk" + } + } + + suspend fun getGroupPicDownUrl( + originalUrl: String, + md5: String, + peer: String = "", + fileId: String = "", + sha: String = "", + fileSize: ULong = 0uL, + width: UInt = 0u, + height: UInt = 0u + ): String { + val isNtServer = originalUrl.startsWith("/download") + val domain = if (isNtServer) MULTIMEDIA_DOMAIN else GPRO_PIC + if (originalUrl.isNotEmpty()) { + if (isNtServer && !originalUrl.contains("rkey=")) { + NtV2RichMediaSvc.getNtPicRKey( + fileId = fileId, + md5 = md5, + sha = sha, + fileSize = fileSize, + width = width, + height = height + ) { + sceneType = 2u + grp = GroupUserInfo(peer.toULong()) + }.onSuccess { + return "https://$domain$originalUrl$it" + }.onFailure { + LogCenter.log("getGroupPicDownUrl: ${it.stackTraceToString()}", Level.WARN) + } + } + return "https://$domain$originalUrl" + } + return "https://$domain/gchatpic_new/0/0-0-${md5.uppercase()}/0?term=2" + } + + suspend fun getC2CPicDownUrl( + originalUrl: String, + md5: String, + peer: String = "", + fileId: String = "", + sha: String = "", + fileSize: ULong = 0uL, + width: UInt = 0u, + height: UInt = 0u, + storeId: Int = 0 + ): String { + val isNtServer = storeId == 1 || originalUrl.startsWith("/download") + val domain = if (isNtServer) MULTIMEDIA_DOMAIN else C2C_PIC + if (originalUrl.isNotEmpty()) { + if (fileId.isNotEmpty()) NtV2RichMediaSvc.getNtPicRKey( + fileId = fileId, + md5 = md5, + sha = sha, + fileSize = fileSize, + width = width, + height = height + ) { + sceneType = 1u + c2c = C2CUserInfo( + accountType = 2u, + uid = ContactHelper.getUidByUinAsync(peer.toLong()) + ) + }.onSuccess { + if (isNtServer && !originalUrl.contains("rkey=")) { + return "https://$domain$originalUrl$it" + } + }.onFailure { + LogCenter.log("getC2CPicDownUrl: ${it.stackTraceToString()}", Level.WARN) + } + if (isNtServer && !originalUrl.contains("rkey=")) { + return "https://$domain$originalUrl&rkey=" + } + return "https://$domain$originalUrl" + } + return "https://$domain/offpic_new/0/0-0-${md5}/0?term=2" + } + + suspend fun getGuildPicDownUrl( + originalUrl: String, + md5: String, + peer: String = "", + subPeer: String = "", + fileId: String = "", + sha: String = "", + fileSize: ULong = 0uL, + width: UInt = 0u, + height: UInt = 0u + ): String { + val isNtServer = originalUrl.startsWith("/download") + val domain = if (isNtServer) MULTIMEDIA_DOMAIN else GPRO_PIC + if (originalUrl.isNotEmpty()) { + if (isNtServer && !originalUrl.contains("rkey=")) { + NtV2RichMediaSvc.getNtPicRKey( + fileId = fileId, + md5 = md5, + sha = sha, + fileSize = fileSize, + width = width, + height = height + ) { + sceneType = 3u + channel = ChannelUserInfo(peer.toULong(), subPeer.toULong(), 1u) + }.onSuccess { + return "https://$domain$originalUrl$it" + }.onFailure { + LogCenter.log("getGuildPicDownUrl: ${it.stackTraceToString()}", Level.WARN) + } + return "https://$domain$originalUrl&rkey=" + } + return "https://$domain$originalUrl" + } + return "https://$domain/qmeetpic/0/0-0-${md5.uppercase()}/0?term=2" + } + + suspend fun getC2CVideoDownUrl( + peerId: String, + md5: ByteArray, + fileUUId: String + ): String { + return suspendCancellableCoroutine { + val richProtoReq = RichProto.RichProtoReq() + val downReq: RichProto.RichProtoReq.ShortVideoDownReq = RichProto.RichProtoReq.ShortVideoDownReq() + downReq.selfUin = app.currentAccountUin + downReq.peerUin = peerId + downReq.secondUin = peerId + downReq.uinType = FileMsg.UIN_BUDDY + downReq.agentType = 0 + downReq.chatType = 1 + downReq.troopUin = peerId + downReq.clientType = 2 + downReq.fileId = fileUUId + downReq.md5 = md5 + downReq.busiType = FileTransfer.BUSI_TYPE_SHORT_VIDEO + downReq.subBusiType = 0 + downReq.fileType = FileTransfer.VIDEO_FORMAT_MP4 + downReq.downType = 1 + downReq.sceneType = 1 + richProtoReq.callback = RichProtoProc.RichProtoCallback { _, resp -> + if (resp.resps.isEmpty() || resp.resps.first().errCode != 0) { + LogCenter.log("requestDownPrivateVideo: ${resp.resps.firstOrNull()?.errCode}", Level.WARN) + it.resume("") + } else { + val videoDownResp = resp.resps.first() as RichProto.RichProtoResp.ShortVideoDownResp + val url = StringBuilder() + url.append(videoDownResp.mIpList.random().getServerUrl("http://")) + url.append(videoDownResp.mUrl) + it.resume(url.toString()) + } + } + richProtoReq.protoKey = RichProtoProc.SHORT_VIDEO_DW + richProtoReq.reqs.add(downReq) + richProtoReq.protoReqMgr = app.getRuntimeService(IProtoReqManager::class.java, "all") + RichProtoProc.procRichProtoReq(richProtoReq) + } + } + + suspend fun getGroupVideoDownUrl( + peerId: String, + md5: ByteArray, + fileUUId: String + ): String { + return suspendCancellableCoroutine { + val richProtoReq = RichProto.RichProtoReq() + val downReq: RichProto.RichProtoReq.ShortVideoDownReq = RichProto.RichProtoReq.ShortVideoDownReq() + downReq.selfUin = app.currentAccountUin + downReq.peerUin = peerId + downReq.secondUin = peerId + downReq.uinType = FileMsg.UIN_TROOP + downReq.agentType = 0 + downReq.chatType = 1 + downReq.troopUin = peerId + downReq.clientType = 2 + downReq.fileId = fileUUId + downReq.md5 = md5 + downReq.busiType = FileTransfer.BUSI_TYPE_SHORT_VIDEO + downReq.subBusiType = 0 + downReq.fileType = FileTransfer.VIDEO_FORMAT_MP4 + downReq.downType = 1 + downReq.sceneType = 1 + richProtoReq.callback = RichProtoProc.RichProtoCallback { _, resp -> + if (resp.resps.isEmpty() || resp.resps.first().errCode != 0) { + LogCenter.log("requestDownGroupVideo: ${resp.resps.firstOrNull()?.errCode}", Level.WARN) + it.resume("") + } else { + val videoDownResp = resp.resps.first() as RichProto.RichProtoResp.ShortVideoDownResp + val url = StringBuilder() + url.append(videoDownResp.mIpList.random().getServerUrl("http://")) + url.append(videoDownResp.mUrl) + it.resume(url.toString()) + } + } + richProtoReq.protoKey = RichProtoProc.SHORT_VIDEO_DW + richProtoReq.reqs.add(downReq) + richProtoReq.protoReqMgr = app.getRuntimeService(IProtoReqManager::class.java, "all") + RichProtoProc.procRichProtoReq(richProtoReq) + } + } + + suspend fun getC2CPttDownUrl( + peerId: String, + fileUUId: String + ): String { + return suspendCancellableCoroutine { + val richProtoReq = RichProto.RichProtoReq() + val pttDownReq: RichProto.RichProtoReq.C2CPttDownReq = RichProto.RichProtoReq.C2CPttDownReq() + pttDownReq.selfUin = app.currentAccountUin + pttDownReq.peerUin = peerId + pttDownReq.secondUin = peerId + pttDownReq.uinType = FileMsg.UIN_BUDDY + pttDownReq.busiType = 1002 + pttDownReq.uuid = fileUUId + pttDownReq.storageSource = "pttcenter" + pttDownReq.isSelfSend = false + + pttDownReq.voiceType = 1 + pttDownReq.downType = 1 + richProtoReq.callback = RichProtoProc.RichProtoCallback { _, resp -> + if (resp.resps.isEmpty() || resp.resps.first().errCode != 0) { + LogCenter.log("requestDownPrivateVoice: ${resp.resps.firstOrNull()?.errCode}", Level.WARN) + it.resume("") + } else { + val pttDownResp = resp.resps.first() as RichProto.RichProtoResp.C2CPttDownResp + val url = StringBuilder() + url.append(pttDownResp.downloadUrl) + url.append("&client_proto=qq&client_appid=${MobileQQ.getMobileQQ().appId}&client_type=android&client_ver=${PlatformUtils.getQQVersion(MobileQQ.getContext())}&client_down_type=auto&client_aio_type=unk") + it.resume(url.toString()) + } + } + richProtoReq.protoKey = RichProtoProc.C2C_PTT_DW + richProtoReq.reqs.add(pttDownReq) + richProtoReq.protoReqMgr = app.getRuntimeService(IProtoReqManager::class.java, "all") + RichProtoProc.procRichProtoReq(richProtoReq) + } + } + + suspend fun getGroupPttDownUrl( + peerId: String, + md5: ByteArray, + groupFileKey: String + ): String { + return suspendCancellableCoroutine { + val richProtoReq = RichProto.RichProtoReq() + val groupPttDownReq: RichProto.RichProtoReq.GroupPttDownReq = RichProto.RichProtoReq.GroupPttDownReq() + groupPttDownReq.selfUin = app.currentAccountUin + groupPttDownReq.peerUin = peerId + groupPttDownReq.secondUin = peerId + groupPttDownReq.uinType = FileMsg.UIN_TROOP + groupPttDownReq.groupFileID = 0 + groupPttDownReq.groupFileKey = groupFileKey + groupPttDownReq.md5 = md5 + groupPttDownReq.voiceType = 1 + groupPttDownReq.downType = 1 + richProtoReq.callback = RichProtoProc.RichProtoCallback { _, resp -> + if (resp.resps.isEmpty() || resp.resps.first().errCode != 0) { + LogCenter.log("requestDownGroupVoice: ${resp.resps.firstOrNull()?.errCode}", Level.WARN) + it.resume("") + } else { + val pttDownResp = resp.resps.first() as RichProto.RichProtoResp.GroupPttDownResp + val url = StringBuilder() + url.append("http://") + url.append(pttDownResp.domainV4V6) + url.append(pttDownResp.urlPath) + url.append("&client_proto=qq&client_appid=${MobileQQ.getMobileQQ().appId}&client_type=android&client_ver=${ + PlatformUtils.getQQVersion( + MobileQQ.getContext())}&client_down_type=auto&client_aio_type=unk") + it.resume(url.toString()) + } + } + richProtoReq.protoKey = RichProtoProc.GRP_PTT_DW + richProtoReq.reqs.add(groupPttDownReq) + richProtoReq.protoReqMgr = app.getRuntimeService(IProtoReqManager::class.java, "all") + RichProtoProc.procRichProtoReq(richProtoReq) + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/bdh/Transfer.kt b/xposed/src/main/java/qq/service/bdh/Transfer.kt new file mode 100644 index 0000000..f3003cd --- /dev/null +++ b/xposed/src/main/java/qq/service/bdh/Transfer.kt @@ -0,0 +1,135 @@ +package qq.service.bdh + +import com.tencent.mobileqq.data.MessageForShortVideo +import com.tencent.mobileqq.data.MessageRecord +import com.tencent.mobileqq.transfile.FileMsg +import com.tencent.mobileqq.transfile.TransferRequest +import moe.fuqiuluo.shamrock.utils.MD5 +import qq.service.bdh.ResourceType.* +import java.io.File + +internal object Transfer: FileTransfer() { + private val ROUTE = mapOf Boolean>>( + ContactType.TROOP to mapOf( + Picture to { uploadGroupPic(id, (it as PictureResource).src, mRec) }, + Voice to { uploadGroupVoice(id, (it as VoiceResource).src) }, + Video to { uploadGroupVideo(id, (it as VideoResource).src, it.thumb) }, + + ), + ContactType.PRIVATE to mapOf( + Picture to { uploadC2CPic(id, (it as PictureResource).src, mRec) }, + Voice to { uploadC2CVoice(id, (it as VoiceResource).src) }, + Video to { uploadC2CVideo(id, (it as VideoResource).src, it.thumb) }, + ) + ) + + suspend fun uploadC2CVideo( + userId: String, + file: File, + thumb: File, + wait: Boolean = true + ): Boolean { + return transC2CResource(userId, file, FileMsg.TRANSFILE_TYPE_SHORT_VIDEO_C2C, BUSI_TYPE_SHORT_VIDEO, wait) { + it.mSourceVideoCodecFormat = VIDEO_FORMAT_MP4 + it.mRec = MessageForShortVideo().also { + it.busiType = BUSI_TYPE_SHORT_VIDEO + } + it.mThumbPath = thumb.absolutePath + it.mThumbMd5 = MD5.genFileMd5Hex(thumb.absolutePath) + } + } + + suspend fun uploadGroupVideo( + groupId: String, + file: File, + thumb: File, + wait: Boolean = true + ): Boolean { + return transTroopResource(groupId, file, FileMsg.TRANSFILE_TYPE_SHORT_VIDEO_TROOP, BUSI_TYPE_SHORT_VIDEO, wait) { + it.mSourceVideoCodecFormat = VIDEO_FORMAT_MP4 + it.mRec = MessageForShortVideo().also { + it.busiType = BUSI_TYPE_SHORT_VIDEO + } + it.mThumbPath = thumb.absolutePath + it.mThumbMd5 = MD5.genFileMd5Hex(thumb.absolutePath) + } + } + + suspend fun uploadC2CVoice( + userId: String, + file: File, + wait: Boolean = true + ): Boolean { + return transC2CResource(userId, file, FileMsg.TRANSFILE_TYPE_PTT, 1002, wait) { + it.mPttUploadPanel = 3 + it.mPttCompressFinish = true + it.mIsPttPreSend = true + } + } + + suspend fun uploadGroupVoice( + groupId: String, + file: File, + wait: Boolean = true + ): Boolean { + return transTroopResource(groupId, file, FileMsg.TRANSFILE_TYPE_PTT, 1002, wait) { + it.mPttUploadPanel = 3 + it.mPttCompressFinish = true + it.mIsPttPreSend = true + } + } + + suspend fun uploadC2CPic( + peerId: String, + file: File, + record: MessageRecord? = null, + wait: Boolean = true + ): Boolean { + return transC2CResource(peerId, file, FileMsg.TRANSFILE_TYPE_PIC, SEND_MSG_BUSINESS_TYPE_PIC_CAMERA, wait) { + val picUpExtraInfo = TransferRequest.PicUpExtraInfo() + picUpExtraInfo.mIsRaw = false + picUpExtraInfo.mUinType = FileMsg.UIN_BUDDY + it.mPicSendSource = 8 + it.mExtraObj = picUpExtraInfo + it.mIsPresend = true + it.delayShowProgressTimeInMs = 2000 + it.mRec = record + } + } + + suspend fun uploadGroupPic( + groupId: String, + file: File, + record: MessageRecord? = null, + wait: Boolean = true + ): Boolean { + return transTroopResource(groupId, file, FileMsg.TRANSFILE_TYPE_PIC, SEND_MSG_BUSINESS_TYPE_PIC_CAMERA, wait) { + val picUpExtraInfo = TransferRequest.PicUpExtraInfo() + picUpExtraInfo.mIsRaw = false + picUpExtraInfo.mUinType = FileMsg.UIN_TROOP + it.mPicSendSource = 8 + it.delayShowProgressTimeInMs = 2000 + it.mExtraObj = picUpExtraInfo + it.mRec = record + } + } + + operator fun get(contactType: ContactType, resourceType: ResourceType): suspend TransTarget.(Resource) -> Boolean { + return (ROUTE[contactType] ?: error("unsupported contact type: $contactType"))[resourceType] + ?: error("Unsupported resource type: $resourceType") + } +} + +internal suspend infix fun TransferTaskBuilder.trans(res: Resource): Boolean { + return Transfer[contact.type, res.type](contact, res) +} + +internal class TransferTaskBuilder { + lateinit var contact: TransTarget +} + +internal infix fun Transfer.with(contact: TransTarget): TransferTaskBuilder { + return TransferTaskBuilder().also { + it.contact = contact + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/bdh/TryUpPicData.kt b/xposed/src/main/java/qq/service/bdh/TryUpPicData.kt new file mode 100644 index 0000000..aa6a2c4 --- /dev/null +++ b/xposed/src/main/java/qq/service/bdh/TryUpPicData.kt @@ -0,0 +1,13 @@ +package qq.service.bdh + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TryUpPicData( + @SerialName("ukey") val uKey: ByteArray, + @SerialName("exist") val exist: Boolean, + @SerialName("file_id") val fileId: ULong, + @SerialName("up_ip") var upIp: ArrayList? = null, + @SerialName("up_port") var upPort: ArrayList? = null, +) \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/contact/ContactExt.kt b/xposed/src/main/java/qq/service/contact/ContactExt.kt new file mode 100644 index 0000000..b2b7962 --- /dev/null +++ b/xposed/src/main/java/qq/service/contact/ContactExt.kt @@ -0,0 +1,21 @@ +package qq.service.contact + +import com.tencent.qqnt.kernel.nativeinterface.Contact +import com.tencent.qqnt.kernel.nativeinterface.MsgConstant +import io.kritor.message.Scene + +suspend fun Contact.longPeer(): Long { + return when(this.chatType) { + MsgConstant.KCHATTYPEGROUP -> peerUid.toLong() + MsgConstant.KCHATTYPETEMPC2CFROMGROUP, MsgConstant.KCHATTYPEC2C -> if (peerUid.startsWith("u_")) ContactHelper.getUinByUidAsync(peerUid).toLong() else peerUid.toLong() + else -> 0L + } +} + +suspend fun io.kritor.message.Contact.longPeer(): Long { + return when(this.scene) { + Scene.GROUP -> peer.toLong() + Scene.FRIEND, Scene.STRANGER, Scene.STRANGER_FROM_GROUP -> if (peer.startsWith("u_")) ContactHelper.getUinByUidAsync(peer).toLong() else peer.toLong() + else -> 0L + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/contact/ContactHelper.kt b/xposed/src/main/java/qq/service/contact/ContactHelper.kt new file mode 100644 index 0000000..4bc7c74 --- /dev/null +++ b/xposed/src/main/java/qq/service/contact/ContactHelper.kt @@ -0,0 +1,211 @@ +package qq.service.contact + +import com.tencent.common.app.AppInterface +import com.tencent.mobileqq.data.Card +import com.tencent.mobileqq.profilecard.api.IProfileDataService +import com.tencent.mobileqq.profilecard.api.IProfileProtocolConst.PARAM_SELF_UIN +import com.tencent.mobileqq.profilecard.api.IProfileProtocolConst.PARAM_TARGET_UIN +import com.tencent.mobileqq.profilecard.api.IProfileProtocolService +import com.tencent.mobileqq.profilecard.observer.ProfileCardObserver +import com.tencent.protofile.join_group_link.join_group_link +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import moe.fuqiuluo.shamrock.tools.slice +import qq.service.internals.NTServiceFetcher +import qq.service.QQInterfaces +import tencent.im.oidb.cmd0x11b2.oidb_0x11b2 +import tencent.im.oidb.oidb_sso +import kotlin.coroutines.resume + +internal object ContactHelper: QQInterfaces() { + const val FROM_C2C_AIO = 2 + const val FROM_CONDITION_SEARCH = 9 + const val FROM_CONTACTS_TAB = 5 + const val FROM_FACE_2_FACE_ADD_FRIEND = 11 + const val FROM_MAYKNOW_FRIEND = 3 + const val FROM_QCIRCLE = 4 + const val FROM_QQ_TROOP = 1 + const val FROM_QZONE = 7 + const val FROM_SCAN = 6 + const val FROM_SEARCH = 8 + const val FROM_SETTING_ME = 12 + const val FROM_SHARE_CARD = 10 + + const val PROFILE_CARD_IS_BLACK = 2 + const val PROFILE_CARD_IS_BLACKED = 1 + const val PROFILE_CARD_NOT_BLACK = 3 + + const val SUB_FROM_C2C_AIO = 21 + const val SUB_FROM_C2C_INTERACTIVE_LOGO = 25 + const val SUB_FROM_C2C_LEFT_SLIDE = 23 + const val SUB_FROM_C2C_OTHER = 24 + const val SUB_FROM_C2C_SETTING = 22 + const val SUB_FROM_C2C_TOFU = 26 + const val SUB_FROM_CONDITION_SEARCH_OTHER = 99 + const val SUB_FROM_CONDITION_SEARCH_RESULT = 91 + const val SUB_FROM_CONTACTS_FRIEND_TAB = 51 + const val SUB_FROM_CONTACTS_TAB = 55 + const val SUB_FROM_FACE_2_FACE_ADD_FRIEND_RESULT_AVATAR = 111 + const val SUB_FROM_FACE_2_FACE_OTHER = 119 + const val SUB_FROM_FRIEND_APPLY = 56 + const val SUB_FROM_FRIEND_NOTIFY_MORE = 57 + const val SUB_FROM_FRIEND_NOTIFY_TAB = 54 + const val SUB_FROM_GROUPING_TAB = 52 + const val SUB_FROM_MAYKNOW_FRIEND_CONTACT_TAB = 31 + const val SUB_FROM_MAYKNOW_FRIEND_CONTACT_TAB_MORE = 37 + const val SUB_FROM_MAYKNOW_FRIEND_FIND_PEOPLE = 34 + const val SUB_FROM_MAYKNOW_FRIEND_FIND_PEOPLE_MORE = 39 + const val SUB_FROM_MAYKNOW_FRIEND_FIND_PEOPLE_SEARCH = 36 + const val SUB_FROM_MAYKNOW_FRIEND_NEW_FRIEND_PAGE = 32 + const val SUB_FROM_MAYKNOW_FRIEND_OTHER = 35 + const val SUB_FROM_MAYKNOW_FRIEND_SEARCH = 33 + const val SUB_FROM_MAYKNOW_FRIEND_SEARCH_MORE = 38 + const val SUB_FROM_PHONE_LIST_TAB = 53 + const val SUB_FROM_QCIRCLE_OTHER = 42 + const val SUB_FROM_QCIRCLE_PROFILE = 41 + const val SUB_FROM_QQ_TROOP_ACTIVE_MEMBER = 15 + const val SUB_FROM_QQ_TROOP_ADMIN = 16 + const val SUB_FROM_QQ_TROOP_AIO = 11 + const val SUB_FROM_QQ_TROOP_MEMBER = 12 + const val SUB_FROM_QQ_TROOP_OTHER = 14 + const val SUB_FROM_QQ_TROOP_SETTING_MEMBER_LIST = 17 + const val SUB_FROM_QQ_TROOP_TEMP_SESSION = 13 + const val SUB_FROM_QRCODE_SCAN_DRAWER = 64 + const val SUB_FROM_QRCODE_SCAN_NEW = 61 + const val SUB_FROM_QRCODE_SCAN_OLD = 62 + const val SUB_FROM_QRCODE_SCAN_OTHER = 69 + const val SUB_FROM_QRCODE_SCAN_PROFILE = 63 + const val SUB_FROM_QZONE_HOME = 71 + const val SUB_FROM_QZONE_OTHER = 79 + const val SUB_FROM_SEARCH_CONTACT_TAB_MORE_FIND_PROFILE = 83 + const val SUB_FROM_SEARCH_FIND_PROFILE_TAB = 82 + const val SUB_FROM_SEARCH_MESSAGE_TAB_MORE_FIND_PROFILE = 84 + const val SUB_FROM_SEARCH_NEW_FRIEND_MORE_FIND_PROFILE = 85 + const val SUB_FROM_SEARCH_OTHER = 89 + const val SUB_FROM_SEARCH_TAB = 81 + const val SUB_FROM_SETTING_ME_AVATAR = 121 + const val SUB_FROM_SETTING_ME_OTHER = 129 + const val SUB_FROM_SHARE_CARD_C2C = 101 + const val SUB_FROM_SHARE_CARD_OTHER = 109 + const val SUB_FROM_SHARE_CARD_TROOP = 102 + const val SUB_FROM_TYPE_DEFAULT = 0 + + private val refreshCardLock by lazy { Mutex() } + + suspend fun voteUser(target: Long, count: Int): Result { + if(count !in 1 .. 20) { + return Result.failure(IllegalArgumentException("vote count must be in 1 .. 20")) + } + val card = getProfileCard(target).onFailure { + return Result.failure(RuntimeException("unable to fetch contact info")) + }.getOrThrow() + sendExtra("VisitorSvc.ReqFavorite") { + it.putLong(PARAM_SELF_UIN, app.longAccountUin) + it.putLong(PARAM_TARGET_UIN, target) + it.putByteArray("vCookies", card.vCookies) + it.putBoolean("nearby_people", true) + it.putInt("favoriteSource", FROM_CONTACTS_TAB) + it.putInt("iCount", count) + it.putInt("from", FROM_CONTACTS_TAB) + } + return Result.success(Unit) + } + + suspend fun getProfileCard(uin: Long): Result { + return getProfileCardFromCache(uin).onFailure { + return refreshAndGetProfileCard(uin) + } + } + + fun getProfileCardFromCache(uin: Long): Result { + val profileDataService = app + .getRuntimeService(IProfileDataService::class.java, "all") + val card = profileDataService.getProfileCard(uin.toString(), true) + return if (card == null || card.strNick.isNullOrEmpty()) { + Result.failure(Exception("unable to fetch profile card")) + } else { + Result.success(card) + } + } + + suspend fun refreshAndGetProfileCard(uin: Long): Result { + require(app is AppInterface) + val dataService = app + .getRuntimeService(IProfileDataService::class.java, "all") + val card = refreshCardLock.withLock { + suspendCancellableCoroutine { + app.addObserver(object: ProfileCardObserver() { + override fun onGetProfileCard(success: Boolean, obj: Any) { + app.removeObserver(this) + if (!success || obj !is Card) { + it.resume(null) + } else { + dataService.saveProfileCard(obj) + it.resume(obj) + } + } + }) + app.getRuntimeService(IProfileProtocolService::class.java, "all") + .requestProfileCard(app.currentUin, uin.toString(), 12, 0L, 0.toByte(), 0L, 0L, null, "", 0L, 10004, null, 0.toByte()) + } + } + return if (card == null || card.strNick.isNullOrEmpty()) { + Result.failure(Exception("unable to fetch profile card")) + } else { + Result.success(card) + } + } + + suspend fun getUinByUidAsync(uid: String): String { + if (uid.isBlank() || uid == "0") { + return "0" + } + + val kernelService = NTServiceFetcher.kernelService + val sessionService = kernelService.wrapperSession + + return suspendCancellableCoroutine { continuation -> + sessionService.uixConvertService.getUin(hashSetOf(uid)) { + continuation.resume(it) + } + }[uid]?.toString() ?: "0" + } + + suspend fun getUidByUinAsync(peerId: Long): String { + val kernelService = NTServiceFetcher.kernelService + val sessionService = kernelService.wrapperSession + return suspendCancellableCoroutine { continuation -> + sessionService.uixConvertService.getUid(hashSetOf(peerId)) { + continuation.resume(it) + } + }[peerId]!! + } + + suspend fun getSharePrivateArkMsg(peerId: Long): String { + val reqBody = oidb_0x11b2.BusinessCardV3Req() + reqBody.uin.set(peerId) + reqBody.jump_url.set("mqqapi://card/show_pslcard?src_type=internal&source=sharecard&version=1&uin=$peerId") + + val fromServiceMsg = sendOidbAW("OidbSvcTrpcTcp.0x11ca_0", 4790, 0, reqBody.toByteArray()) + ?: error("unable to fetch contact ark_json_text") + + val body = oidb_sso.OIDBSSOPkg() + body.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + val rsp = oidb_0x11b2.BusinessCardV3Rsp() + rsp.mergeFrom(body.bytes_bodybuffer.get().toByteArray()) + return rsp.signed_ark_msg.get() + } + + suspend fun getShareTroopArkMsg(groupId: Long): String { + val reqBody = join_group_link.ReqBody() + reqBody.get_ark.set(true) + reqBody.type.set(1) + reqBody.group_code.set(groupId) + val fromServiceMsg = sendBufferAW("GroupSvc.JoinGroupLink", true, reqBody.toByteArray()) + ?: error("unable to fetch contact ark_json_text") + val body = join_group_link.RspBody() + body.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + return body.signed_ark.get().toStringUtf8() + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/file/GroupFileHelper.kt b/xposed/src/main/java/qq/service/file/GroupFileHelper.kt new file mode 100644 index 0000000..61076ce --- /dev/null +++ b/xposed/src/main/java/qq/service/file/GroupFileHelper.kt @@ -0,0 +1,162 @@ +@file:OptIn(ExperimentalStdlibApi::class) + +package qq.service.file + +import com.tencent.mobileqq.pb.ByteStringMicro +import io.grpc.Status +import io.grpc.StatusRuntimeException +import io.kritor.file.File +import io.kritor.file.Folder +import io.kritor.file.GetFileSystemInfoResponse +import io.kritor.file.GetFilesRequest +import io.kritor.file.GetFilesResponse +import io.kritor.file.folder +import io.kritor.file.getFileSystemInfoResponse +import io.kritor.file.getFilesRequest +import io.kritor.file.getFilesResponse +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.slice +import moe.fuqiuluo.shamrock.utils.DeflateTools +import qq.service.QQInterfaces +import tencent.im.oidb.cmd0x6d8.oidb_0x6d8 +import tencent.im.oidb.oidb_sso +import kotlin.time.Duration.Companion.seconds + +internal object GroupFileHelper: QQInterfaces() { + suspend fun getGroupFileSystemInfo(groupId: Long): GetFileSystemInfoResponse { + val fromServiceMsg = sendOidbAW("OidbSvc.0x6d8_1", 1752, 2, oidb_0x6d8.ReqBody().also { + it.group_file_cnt_req.set(oidb_0x6d8.GetFileCountReqBody().also { + it.uint64_group_code.set(groupId) + it.uint32_app_id.set(3) + it.uint32_bus_id.set(0) + }) + }.toByteArray()) ?: throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to send oidb request")) + if (fromServiceMsg.wupBuffer == null) { + throw StatusRuntimeException(Status.INTERNAL.withDescription("oidb request failed")) + } + val fileCnt: Int + val limitCnt: Int + if (fromServiceMsg.wupBuffer != null) { + oidb_0x6d8.RspBody().mergeFrom( + oidb_sso.OIDBSSOPkg() + .mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + .bytes_bodybuffer.get() + .toByteArray() + ).group_file_cnt_rsp.apply { + fileCnt = uint32_all_file_count.get() + limitCnt = uint32_limit_count.get() + } + } else { + throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to fetch oidb response")) + } + + val fromServiceMsg2 = sendOidbAW("OidbSvc.0x6d8_1", 1752, 3, oidb_0x6d8.ReqBody().also { + it.group_space_req.set(oidb_0x6d8.GetSpaceReqBody().apply { + uint64_group_code.set(groupId) + uint32_app_id.set(3) + }) + }.toByteArray()) ?: throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to send oidb request")) + val totalSpace: Long + val usedSpace: Long + if (fromServiceMsg2.isSuccess && fromServiceMsg2.wupBuffer != null) { + oidb_0x6d8.RspBody().mergeFrom( + oidb_sso.OIDBSSOPkg() + .mergeFrom(fromServiceMsg2.wupBuffer.slice(4)) + .bytes_bodybuffer.get() + .toByteArray()).group_space_rsp.apply { + totalSpace = uint64_total_space.get() + usedSpace = uint64_used_space.get() + } + } else { + throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to fetch oidb response x2")) + } + + return getFileSystemInfoResponse { + this.fileCount = fileCnt + this.totalCount = limitCnt + this.totalSpace = totalSpace.toInt() + this.usedSpace = usedSpace.toInt() + } + } + + suspend fun getGroupFiles(groupId: Long, folderId: String = "/"): GetFilesResponse { + val fileSystemInfo = getGroupFileSystemInfo(groupId) + val fromServiceMsg = sendOidbAW("OidbSvc.0x6d8_1", 1752, 1, oidb_0x6d8.ReqBody().also { + it.file_list_info_req.set(oidb_0x6d8.GetFileListReqBody().apply { + uint64_group_code.set(groupId) + uint32_app_id.set(3) + str_folder_id.set(folderId) + + uint32_file_count.set(fileSystemInfo.fileCount) + uint32_all_file_count.set(0) + uint32_req_from.set(3) + uint32_sort_by.set(oidb_0x6d8.GetFileListReqBody.SORT_BY_TIMESTAMP) + + uint32_filter_code.set(0) + uint64_uin.set(0) + + uint32_start_index.set(0) + + bytes_context.set(ByteStringMicro.copyFrom(EMPTY_BYTE_ARRAY)) + + uint32_show_onlinedoc_folder.set(0) + }) + }.toByteArray(), timeout = 15.seconds) ?: throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to send oidb request")) + if (fromServiceMsg.wupBuffer == null) { + throw StatusRuntimeException(Status.INTERNAL.withDescription("oidb request failed")) + } + val files = arrayListOf() + val dirs = arrayListOf() + if (fromServiceMsg.wupBuffer != null) { + val oidb = oidb_sso.OIDBSSOPkg().mergeFrom(fromServiceMsg.wupBuffer.slice(4).let { + if (it[0] == 0x78.toByte()) DeflateTools.uncompress(it) else it + }) + + oidb_0x6d8.RspBody().mergeFrom(oidb.bytes_bodybuffer.get().toByteArray()) + .file_list_info_rsp.apply { + rpt_item_list.get().forEach { file -> + if (file.uint32_type.get() == oidb_0x6d8.GetFileListRspBody.TYPE_FILE) { + val fileInfo = file.file_info + files.add(io.kritor.file.file { + this.fileId = fileInfo.str_file_id.get() + this.fileName = fileInfo.str_file_name.get() + this.fileSize = fileInfo.uint64_file_size.get() + this.busId = fileInfo.uint32_bus_id.get() + this.uploadTime = fileInfo.uint32_upload_time.get() + this.deadTime = fileInfo.uint32_dead_time.get() + this.modifyTime = fileInfo.uint32_modify_time.get() + this.downloadTimes = fileInfo.uint32_download_times.get() + this.uploader = fileInfo.uint64_uploader_uin.get() + this.uploaderName = fileInfo.str_uploader_name.get() + this.sha = fileInfo.bytes_sha.get().toByteArray().toHexString() + this.sha3 = fileInfo.bytes_sha3.get().toByteArray().toHexString() + this.md5 = fileInfo.bytes_md5.get().toByteArray().toHexString() + }) + } + else if (file.uint32_type.get() == oidb_0x6d8.GetFileListRspBody.TYPE_FOLDER) { + val folderInfo = file.folder_info + dirs.add(folder { + this.folderId = folderInfo.str_folder_id.get() + this.folderName = folderInfo.str_folder_name.get() + this.totalFileCount = folderInfo.uint32_total_file_count.get() + this.createTime = folderInfo.uint32_create_time.get() + this.creator = folderInfo.uint64_create_uin.get() + this.creatorName = folderInfo.str_creator_name.get() + }) + } else { + LogCenter.log("未知文件类型: ${file.uint32_type.get()}", Level.WARN) + } + } + } + } else { + throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to fetch oidb response")) + } + + return getFilesResponse { + this.files.addAll(files) + this.folders.addAll(folders) + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/friend/FriendHelper.kt b/xposed/src/main/java/qq/service/friend/FriendHelper.kt new file mode 100644 index 0000000..a71205b --- /dev/null +++ b/xposed/src/main/java/qq/service/friend/FriendHelper.kt @@ -0,0 +1,107 @@ +package qq.service.friend + +import com.tencent.mobileqq.data.Friends +import com.tencent.mobileqq.friend.api.IFriendDataService +import com.tencent.mobileqq.friend.api.IFriendHandlerService +import com.tencent.mobileqq.qroute.QRoute +import com.tencent.mobileqq.relation.api.IAddFriendTempApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import moe.fuqiuluo.shamrock.tools.slice +import qq.service.QQInterfaces +import tencent.mobileim.structmsg.structmsg +import kotlin.coroutines.resume + +internal object FriendHelper: QQInterfaces() { + suspend fun getFriendList(refresh: Boolean): Result> { + val service = app.getRuntimeService(IFriendDataService::class.java, "all") + if(refresh || !service.isInitFinished) { + if(!requestFriendList(service)) { + return Result.failure(Exception("获取好友列表失败")) + } + } + return Result.success(service.allFriends) + } + + // ProfileService.Pb.ReqSystemMsgAction.Friend + fun requestFriendRequest(msgSeq: Long, uin: Long, remark: String = "", approve: Boolean? = true, notSee: Boolean? = false) { + val service = QRoute.api(IAddFriendTempApi::class.java) + val action = structmsg.SystemMsgActionInfo() + action.type.set(if (approve != false) 2 else 3) + action.group_id.set(0) + action.remark.set(remark) + val snInfo = structmsg.AddFrdSNInfo() + snInfo.uint32_not_see_dynamic.set(if (notSee != false) 1 else 0) + snInfo.uint32_set_sn.set(0) + action.addFrdSNInfo.set(snInfo) + service.sendFriendSystemMsgAction(1, msgSeq, uin, 1, 2004, 11, 0, action, 0, structmsg.StructMsg(), false, app) + } + + suspend fun requestFriendSystemMsgNew(msgNum: Int, latestFriendSeq: Long = 0, latestGroupSeq: Long = 0, retryCnt: Int = 3): List? { + if (retryCnt < 0) { + return ArrayList() + } + val req = structmsg.ReqSystemMsgNew() + req.msg_num.set(msgNum) + req.latest_friend_seq.set(latestFriendSeq) + req.latest_group_seq.set(latestGroupSeq) + req.version.set(1000) + req.checktype.set(2) + val flag = structmsg.FlagInfo() +// flag.GrpMsg_Kick_Admin.set(1) +// flag.GrpMsg_HiddenGrp.set(1) +// flag.GrpMsg_WordingDown.set(1) + flag.FrdMsg_GetBusiCard.set(1) +// flag.GrpMsg_GetOfficialAccount.set(1) +// flag.GrpMsg_GetPayInGroup.set(1) + flag.FrdMsg_Discuss2ManyChat.set(1) +// flag.GrpMsg_NotAllowJoinGrp_InviteNotFrd.set(1) + flag.FrdMsg_NeedWaitingMsg.set(1) + flag.FrdMsg_uint32_need_all_unread_msg.set(1) +// flag.GrpMsg_NeedAutoAdminWording.set(1) +// flag.GrpMsg_get_transfer_group_msg_flag.set(1) +// flag.GrpMsg_get_quit_pay_group_msg_flag.set(1) +// flag.GrpMsg_support_invite_auto_join.set(1) +// flag.GrpMsg_mask_invite_auto_join.set(1) +// flag.GrpMsg_GetDisbandedByAdmin.set(1) + flag.GrpMsg_GetC2cInviteJoinGroup.set(1) + req.flag.set(flag) + req.is_get_frd_ribbon.set(false) + req.is_get_grp_ribbon.set(false) + req.friend_msg_type_flag.set(1) + req.uint32_req_msg_type.set(1) + req.uint32_need_uid.set(1) + val fromServiceMsg = sendBufferAW("ProfileService.Pb.ReqSystemMsgNew.Friend", true, req.toByteArray()) + return if (fromServiceMsg == null || fromServiceMsg.wupBuffer == null) { + ArrayList() + } else { + try { + val msg = structmsg.RspSystemMsgNew() + msg.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + return msg.friendmsgs.get() + } catch (err: Throwable) { + requestFriendSystemMsgNew(msgNum, latestFriendSeq, latestGroupSeq, retryCnt - 1) + } + + } + } + + private suspend fun requestFriendList(dataService: IFriendDataService): Boolean { + val service = app.getRuntimeService(IFriendHandlerService::class.java, "all") + service.requestFriendList(true, 0) + return suspendCancellableCoroutine { continuation -> + val waiter = GlobalScope.launch { + while (!dataService.isInitFinished) { + delay(200) + } + continuation.resume(true) + } + continuation.invokeOnCancellation { + waiter.cancel() + continuation.resume(false) + } + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/group/GroupHelper.kt b/xposed/src/main/java/qq/service/group/GroupHelper.kt new file mode 100644 index 0000000..95436fa --- /dev/null +++ b/xposed/src/main/java/qq/service/group/GroupHelper.kt @@ -0,0 +1,772 @@ +package qq.service.group + +import KQQ.RespBatchProcess +import com.qq.jce.wup.UniPacket +import com.tencent.mobileqq.app.BusinessHandlerFactory +import com.tencent.mobileqq.data.troop.TroopInfo +import com.tencent.mobileqq.data.troop.TroopMemberInfo +import com.tencent.mobileqq.pb.ByteStringMicro +import com.tencent.mobileqq.troop.api.ITroopInfoService +import com.tencent.mobileqq.troop.api.ITroopMemberInfoService +import com.tencent.qqnt.kernel.nativeinterface.MemberInfo +import friendlist.stUinInfo +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import qq.service.internals.NTServiceFetcher +import moe.fuqiuluo.shamrock.tools.ifNullOrEmpty +import moe.fuqiuluo.shamrock.tools.putBuf32Long +import moe.fuqiuluo.shamrock.tools.slice +import protobuf.auto.toByteArray +import protobuf.oidb.cmd0xf16.Oidb0xf16 +import protobuf.oidb.cmd0xf16.SetGroupRemarkReq +import qq.service.QQInterfaces +import tencent.im.group.group_member_info +import tencent.im.oidb.cmd0x88d.oidb_0x88d +import tencent.im.oidb.cmd0x899.oidb_0x899 +import tencent.im.oidb.cmd0x89a.oidb_0x89a +import tencent.im.oidb.cmd0x8a0.oidb_0x8a0 +import tencent.im.oidb.cmd0x8a7.cmd0x8a7 +import tencent.im.oidb.cmd0x8fc.Oidb_0x8fc +import tencent.im.oidb.cmd0xed3.oidb_cmd0xed3 +import tencent.im.oidb.oidb_sso +import tencent.im.troop.honor.troop_honor +import tencent.mobileim.structmsg.structmsg +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import java.nio.ByteBuffer +import kotlin.coroutines.resume +import kotlin.time.Duration.Companion.seconds + +internal object GroupHelper: QQInterfaces() { + private val RefreshTroopMemberInfoLock by lazy { Mutex() } + private val RefreshTroopMemberListLock by lazy { Mutex() } + + private lateinit var METHOD_REQ_MEMBER_INFO: Method + private lateinit var METHOD_REQ_MEMBER_INFO_V2: Method + private lateinit var METHOD_REQ_TROOP_LIST: Method + private lateinit var METHOD_REQ_TROOP_MEM_LIST: Method + private lateinit var METHOD_REQ_MODIFY_GROUP_NAME: Method + + fun getGroupInfo(groupId: String): TroopInfo { + val service = app + .getRuntimeService(ITroopInfoService::class.java, "all") + + return service.getTroopInfo(groupId) + } + + fun isAdmin(groupId: String): Boolean { + val groupInfo = getGroupInfo(groupId) + + return groupInfo.isAdmin || groupInfo.troopowneruin == app.account + } + + fun isOwner(groupId: String): Boolean { + val groupInfo = getGroupInfo(groupId) + return groupInfo.troopowneruin == app.account + } + + fun getAdminList( + groupId: Long, + withOwner: Boolean = false + ): List { + val groupInfo = getGroupInfo(groupId.toString()) + return (groupInfo.Administrator ?: "") + .split("|", ",") + .also { + if (withOwner && it is ArrayList) { + it.add(0, groupInfo.troopowneruin) + } + }.mapNotNull { it.ifNullOrEmpty { null }?.toLong() } + } + + suspend fun getGroupList(refresh: Boolean): Result> { + val service = app.getRuntimeService(ITroopInfoService::class.java, "all") + + var troopList = service.allTroopList + if(refresh || !service.isTroopCacheInited || troopList == null) { + if(!requestGroupInfo(service)) { + return Result.failure(Exception("获取群列表失败")) + } else { + troopList = service.allTroopList + } + } + return Result.success(troopList) + } + + suspend fun getTroopMemberInfoByUinV2( + groupId: String, + uin: String, + refresh: Boolean = false + ): Result { + val service = app.getRuntimeService(ITroopMemberInfoService::class.java, "all") + var info = service.getTroopMember(groupId, uin) + if (refresh || !service.isMemberInCache(groupId, uin) || info == null || info.troopnick == null) { + info = requestTroopMemberInfo(service, groupId, uin, timeout = 2000).getOrNull() + } + if (info == null) { + info = getTroopMemberInfoByUinViaNt(groupId, uin, timeout = 2000L).getOrNull()?.let { + TroopMemberInfo().apply { + troopnick = it.cardName + friendnick = it.nick + } + } + } + try { + if (info != null && (info.alias == null || info.alias.isBlank())) { + val req = group_member_info.ReqBody() + req.uint64_group_code.set(groupId.toLong()) + req.uint64_uin.set(uin.toLong()) + req.bool_new_client.set(true) + req.uint32_client_type.set(1) + req.uint32_rich_card_name_ver.set(1) + val fromServiceMsg = sendBufferAW("group_member_card.get_group_member_card_info", true, req.toByteArray(), timeout = 2.seconds) + if (fromServiceMsg != null && fromServiceMsg.wupBuffer != null) { + val rsp = group_member_info.RspBody() + rsp.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + if (rsp.msg_meminfo.str_location.has()) { + info.alias = rsp.msg_meminfo.str_location.get().toStringUtf8() + } + if (rsp.msg_meminfo.uint32_age.has()) { + info.age = rsp.msg_meminfo.uint32_age.get().toByte() + } + if (rsp.msg_meminfo.bytes_group_honor.has()) { + val honorBytes = rsp.msg_meminfo.bytes_group_honor.get().toByteArray() + val honor = troop_honor.GroupUserCardHonor() + honor.mergeFrom(honorBytes) + info.level = honor.level.get() + // 10315: medal_id not real group level + } + } + } + } catch (err: Throwable) { + LogCenter.log(err.stackTraceToString(), Level.WARN) + } + return if (info != null) { + Result.success(info) + } else { + Result.failure(Exception("获取群成员信息失败")) + } + } + + private suspend fun requestGroupInfo( + service: ITroopInfoService + ): Boolean { + refreshTroopList() + + return suspendCancellableCoroutine { continuation -> + val waiter = GlobalScope.launch { + do { + delay(1000) + } while ( + !service.isTroopCacheInited + ) + continuation.resume(true) + } + continuation.invokeOnCancellation { + waiter.cancel() + continuation.resume(false) + } + } + } + + fun banMember(groupId: Long, memberUin: Long, time: Int) { + val buffer = ByteBuffer.allocate(1 * 8 + 7) + buffer.putBuf32Long(groupId) + buffer.put(32.toByte()) + buffer.putShort(1) + buffer.putBuf32Long(memberUin) + buffer.putInt(time) + val array = buffer.array() + sendOidb("OidbSvc.0x570_8", 1392, 8, array) + } + + fun pokeMember(groupId: Long, memberUin: Long) { + val req = oidb_cmd0xed3.ReqBody().apply { + uint64_group_code.set(groupId) + uint64_to_uin.set(memberUin) + uint32_msg_seq.set(0) + } + sendOidb("OidbSvc.0xed3", 3795, 1, req.toByteArray()) + } + + fun kickMember(groupId: Long, rejectAddRequest: Boolean, kickMsg: String, vararg memberUin: Long) { + val reqBody = oidb_0x8a0.ReqBody() + reqBody.opt_uint64_group_code.set(groupId) + memberUin.forEach { + val memberInfo = oidb_0x8a0.KickMemberInfo() + memberInfo.opt_uint32_operate.set(5) + memberInfo.opt_uint64_member_uin.set(it) + memberInfo.opt_uint32_flag.set(if (rejectAddRequest) 1 else 0) + reqBody.rpt_msg_kick_list.add(memberInfo) + } + if (kickMsg.isNotEmpty()) { + reqBody.bytes_kick_msg.set(ByteStringMicro.copyFrom(kickMsg.toByteArray())) + } + sendOidb("OidbSvc.0x8a0_0", 2208, 0, reqBody.toByteArray()) + } + + fun resignTroop(groupId: String) { + sendExtra("ProfileService.GroupMngReq") { + it.putInt("groupreqtype", 2) + it.putString("troop_uin", groupId) + it.putString("uin", app.currentUin) + } + } + + fun modifyGroupMemberCard(groupId: Long, userId: Long, name: String): Boolean { + val createToServiceMsg = createToServiceMsg("friendlist.ModifyGroupCardReq") + createToServiceMsg.extraData.putLong("dwZero", 0L) + createToServiceMsg.extraData.putLong("dwGroupCode", groupId) + val info = stUinInfo() + info.cGender = -1 + info.dwuin = userId + info.sEmail = "" + info.sName = name + info.sPhone = "" + info.sRemark = "" + info.dwFlag = 1 + createToServiceMsg.extraData.putSerializable("vecUinInfo", arrayListOf(info)) + createToServiceMsg.extraData.putLong("dwNewSeq", 0L) + sendToServiceMsg(createToServiceMsg) + return true + } + + fun modifyTroopName(groupId: String, name: String) { + val businessHandler = app.getBusinessHandler(BusinessHandlerFactory.TROOP_MODIFY_HANDLER) + + if (!::METHOD_REQ_MODIFY_GROUP_NAME.isInitialized) { + METHOD_REQ_MODIFY_GROUP_NAME = businessHandler.javaClass.declaredMethods.first { + it.parameterCount == 3 + && it.parameterTypes[0] == String::class.java + && it.parameterTypes[1] == String::class.java + && it.parameterTypes[2] == Boolean::class.java + && !Modifier.isPrivate(it.modifiers) + } + } + + METHOD_REQ_MODIFY_GROUP_NAME.invoke(businessHandler, groupId, name, false) + } + + fun modifyGroupRemark(groupId: Long, remark: String): Boolean { + sendOidb("OidbSvc.0xf16_1", 3862, 1, Oidb0xf16( + setGroupRemarkReq = SetGroupRemarkReq( + groupCode = groupId.toULong(), + groupUin = groupCode2GroupUin(groupId).toULong(), + groupRemark = remark + ) + ).toByteArray()) + return true + } + + fun setGroupAdmin(groupId: Long, userId: Long, enable: Boolean) { + val buffer = ByteBuffer.allocate(9) + buffer.putBuf32Long(groupId) + buffer.putBuf32Long(userId) + buffer.put(if (enable) 1 else 0) + val array = buffer.array() + sendOidb("OidbSvc.0x55c_1", 1372, 1, array) + } + + // ProfileService.Pb.ReqSystemMsgAction.Group + suspend fun requestGroupRequest( + msgSeq: Long, + uin: Long, + gid: Long, + msg: String? = "", + approve: Boolean? = true, + notSee: Boolean? = false, + subType: String + ): Result{ + val req = structmsg.ReqSystemMsgAction().apply { + if (subType == "invite") { + msg_type.set(1) + src_id.set(3) + sub_src_id.set(10016) + group_msg_type.set(2) + } else { + msg_type.set(2) + src_id.set(2) + sub_src_id.set(30024) + group_msg_type.set(1) + } + msg_seq.set(msgSeq) + req_uin.set(uin) + sub_type.set(1) + action_info.set(structmsg.SystemMsgActionInfo().apply { + type.set(if (approve != false) 11 else 12) + group_code.set(gid) + if (subType == "add") { + this.msg.set(msg) + this.blacklist.set(notSee != false) + } + }) + language.set(1000) + } + val fromServiceMsg = sendBufferAW("ProfileService.Pb.ReqSystemMsgAction.Group", true, req.toByteArray()) + ?: return Result.failure(Exception("ReqSystemMsgAction.Group: No Response")) + if (fromServiceMsg.wupBuffer == null) { + return Result.failure(Exception("ReqSystemMsgAction.Group: No WupBuffer")) + } + val rsp = structmsg.RspSystemMsgAction().mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + return if (rsp.head.result.has()) { + if (rsp.head.result.get() == 0) { + Result.success(rsp.msg_detail.get()) + } else { + Result.failure(Exception(rsp.head.msg_fail.get())) + } + } else { + Result.failure(Exception("操作失败")) + } + } + + suspend fun requestGroupSystemMsgNew(msgNum: Int, reqMsgType: Int = 1, latestFriendSeq: Long = 0, latestGroupSeq: Long = 0, retryCnt: Int = 5): List { + if (retryCnt < 0) { + return ArrayList() + } + val req = structmsg.ReqSystemMsgNew() + req.msg_num.set(msgNum) + req.latest_friend_seq.set(latestFriendSeq) + req.latest_group_seq.set(latestGroupSeq) + req.version.set(1000) + req.checktype.set(3) + val flag = structmsg.FlagInfo() + flag.GrpMsg_Kick_Admin.set(1) + flag.GrpMsg_HiddenGrp.set(1) + flag.GrpMsg_WordingDown.set(1) +// flag.FrdMsg_GetBusiCard.set(1) + flag.GrpMsg_GetOfficialAccount.set(1) + flag.GrpMsg_GetPayInGroup.set(1) + flag.FrdMsg_Discuss2ManyChat.set(1) + flag.GrpMsg_NotAllowJoinGrp_InviteNotFrd.set(1) + flag.FrdMsg_NeedWaitingMsg.set(1) +// flag.FrdMsg_uint32_need_all_unread_msg.set(1) + flag.GrpMsg_NeedAutoAdminWording.set(1) + flag.GrpMsg_get_transfer_group_msg_flag.set(1) + flag.GrpMsg_get_quit_pay_group_msg_flag.set(1) + flag.GrpMsg_support_invite_auto_join.set(1) + flag.GrpMsg_mask_invite_auto_join.set(1) + flag.GrpMsg_GetDisbandedByAdmin.set(1) + flag.GrpMsg_GetC2cInviteJoinGroup.set(1) + req.flag.set(flag) + req.is_get_frd_ribbon.set(false) + req.is_get_grp_ribbon.set(false) + req.friend_msg_type_flag.set(1) + req.uint32_req_msg_type.set(reqMsgType) + req.uint32_need_uid.set(1) + val fromServiceMsg = sendBufferAW("ProfileService.Pb.ReqSystemMsgNew.Group", true, req.toByteArray()) + return if (fromServiceMsg == null || fromServiceMsg.wupBuffer == null) { + ArrayList() + } else { + try { + val msg = structmsg.RspSystemMsgNew() + msg.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + return msg.groupmsgs.get().orEmpty() + } catch (err: Throwable) { + requestGroupSystemMsgNew(msgNum, reqMsgType, latestFriendSeq, latestGroupSeq, retryCnt - 1) + } + } + } + + suspend fun setGroupUniqueTitle(groupId: String, userId: String, title: String) { + val localMemberInfo = getTroopMemberInfoByUin(groupId, userId, true).getOrThrow() + val req = Oidb_0x8fc.ReqBody() + req.uint64_group_code.set(groupId.toLong()) + val memberInfo = Oidb_0x8fc.MemberInfo() + memberInfo.uint64_uin.set(userId.toLong()) + memberInfo.bytes_uin_name.set(ByteStringMicro.copyFromUtf8(localMemberInfo.troopnick.ifEmpty { + localMemberInfo.troopremark.ifNullOrEmpty { "" } + })) + memberInfo.bytes_special_title.set(ByteStringMicro.copyFromUtf8(title)) + memberInfo.uint32_special_title_expire_time.set(-1) + req.rpt_mem_level_info.add(memberInfo) + sendOidb("OidbSvc.0x8fc_2", 2300, 2, req.toByteArray()) + } + + fun setGroupWholeBan(groupId: Long, enable: Boolean) { + val reqBody = oidb_0x89a.ReqBody() + reqBody.uint64_group_code.set(groupId) + reqBody.st_group_info.set(oidb_0x89a.groupinfo().apply { + uint32_shutup_time.set(if (enable) 268435455 else 0) + }) + sendOidb("OidbSvc.0x89a_0", 2202, 0, reqBody.toByteArray()) + } + + suspend fun getGroupMemberList(groupId: String, refresh: Boolean): Result> { + val service = app.getRuntimeService(ITroopMemberInfoService::class.java, "all") + var memberList = service.getAllTroopMembers(groupId) + if (refresh || memberList == null) { + memberList = requestTroopMemberInfo(service, groupId).onFailure { + return Result.failure(Exception("获取群成员列表失败")) + }.getOrThrow() + } + + getGroupInfo(groupId, true).onSuccess { + if(it.wMemberNum > memberList.size) { + return getGroupMemberList(groupId, true) + } + } + + return Result.success(memberList) + } + + suspend fun getProhibitedMemberList(groupId: Long): Result> { + val fromServiceMsg = sendOidbAW("OidbSvc.0x899_0", 2201, 0, oidb_0x899.ReqBody().apply { + uint64_group_code.set(groupId) + uint64_start_uin.set(0) + uint32_identify_flag.set(6) + memberlist_opt.set(oidb_0x899.memberlist().apply { + uint64_member_uin.set(0) + uint32_shutup_timestap.set(0) + }) + }.toByteArray()) ?: return Result.failure(RuntimeException("[oidb] timeout")) + if (fromServiceMsg.wupBuffer == null) { + return Result.failure(RuntimeException("[oidb] failed")) + } + val body = oidb_sso.OIDBSSOPkg() + body.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + if(body.uint32_result.get() != 0) { + return Result.failure(RuntimeException(body.str_error_msg.get())) + } + val resp = oidb_0x899.RspBody().mergeFrom(body.bytes_bodybuffer.get().toByteArray()) + return Result.success(resp.rpt_memberlist.get().map { + ProhibitedMemberInfo(it.uint64_member_uin.get(), it.uint32_shutup_timestap.get()) + }) + } + + suspend fun getGroupRemainAtAllRemain (groupId: Long): Result { + val fromServiceMsg = sendOidbAW("OidbSvcTrpcTcp.0x8a7_0", 2215, 0, cmd0x8a7.ReqBody().apply { + uint32_sub_cmd.set(1) + uint32_limit_interval_type_for_uin.set(2) + uint32_limit_interval_type_for_group.set(1) + uint64_uin.set(app.longAccountUin) + uint64_group_code.set(groupId) + }.toByteArray(), trpc = true) ?: return Result.failure(RuntimeException("[oidb] timeout")) + if (fromServiceMsg.wupBuffer == null) { + return Result.failure(RuntimeException("[oidb] failed")) + } + val body = oidb_sso.OIDBSSOPkg() + body.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + if(body.uint32_result.get() != 0) { + return Result.failure(RuntimeException(body.str_error_msg.get())) + } + + val resp = cmd0x8a7.RspBody().mergeFrom(body.bytes_bodybuffer.get().toByteArray()) + return Result.success(resp) + } + + suspend fun getNotJoinedGroupInfo(groupId: Long): Result { + return withTimeoutOrNull(5000) timeout@{ + val toServiceMsg = createToServiceMsg("ProfileService.ReqBatchProcess") + toServiceMsg.extraData.putLong("troop_code", groupId) + toServiceMsg.extraData.putBoolean("is_admin", false) + toServiceMsg.extraData.putInt("from", 0) + val fromServiceMsg = sendToServiceMsgAW(toServiceMsg) ?: return@timeout Result.failure(Exception("获取群信息超时")) + if (fromServiceMsg.wupBuffer == null) { + return@timeout Result.failure(Exception("获取群信息失败")) + } + val uniPacket = UniPacket(true) + uniPacket.encodeName = "utf-8" + uniPacket.decode(fromServiceMsg.wupBuffer) + val respBatchProcess = uniPacket.getByClass("RespBatchProcess", RespBatchProcess()) + val batchRespInfo = oidb_0x88d.RspBody().mergeFrom(oidb_sso.OIDBSSOPkg() + .mergeFrom(respBatchProcess.batch_response_list.first().buffer) + .bytes_bodybuffer.get().toByteArray()).stzrspgroupinfo.get().firstOrNull() + ?: return@timeout Result.failure(Exception("获取群信息失败")) + val info = batchRespInfo.stgroupinfo + Result.success(NotJoinedGroupInfo( + groupId = batchRespInfo.uint64_group_code.get(), + maxMember = info.uint32_group_member_max_num.get(), + memberCount = info.uint32_group_member_num.get(), + groupName = info.string_group_name.get().toStringUtf8(), + groupDesc = info.string_group_finger_memo.get().toStringUtf8(), + owner = info.uint64_group_owner.get(), + createTime = info.uint32_group_create_time.get().toLong(), + groupFlag = info.uint32_group_flag.get(), + groupFlagExt = info.uint32_group_flag_ext.get() + )) + } ?: Result.failure(Exception("获取群信息超时")) + } + + suspend fun getGroupInfo(groupId: String, refresh: Boolean): Result { + val service = app + .getRuntimeService(ITroopInfoService::class.java, "all") + + val groupInfo = getGroupInfo(groupId) + + return if(refresh || !service.isTroopCacheInited || groupInfo.troopuin.isNullOrBlank()) { + requestGroupInfo(service, groupId) + } else { + Result.success(groupInfo) + } + } + + private suspend fun requestGroupInfo(dataService: ITroopInfoService, uin: String): Result { + val info = withTimeoutOrNull(1000) { + var troopInfo: TroopInfo? + do { + troopInfo = dataService.getTroopInfo(uin) + delay(100) + } while (troopInfo == null || troopInfo.troopuin.isNullOrBlank()) + return@withTimeoutOrNull troopInfo + } + return if (info != null) { + Result.success(info) + } else { + Result.failure(Exception("获取群列表失败")) + } + } + + suspend fun getTroopMemberInfoByUin( + groupId: String, + uin: String, + refresh: Boolean = false + ): Result { + val service = app.getRuntimeService(ITroopMemberInfoService::class.java, "all") + var info = service.getTroopMember(groupId, uin) + if (refresh || !service.isMemberInCache(groupId, uin) || info == null || info.troopnick == null) { + info = requestTroopMemberInfo(service, groupId, uin).getOrNull() + } + if (info == null) { + info = getTroopMemberInfoByUinViaNt(groupId, uin).getOrNull()?.let { + TroopMemberInfo().apply { + troopnick = it.cardName + friendnick = it.nick + } + } + } + try { + if (info != null && (info.alias == null || info.alias.isBlank())) { + val req = group_member_info.ReqBody() + req.uint64_group_code.set(groupId.toLong()) + req.uint64_uin.set(uin.toLong()) + req.bool_new_client.set(true) + req.uint32_client_type.set(1) + req.uint32_rich_card_name_ver.set(1) + val fromServiceMsg = sendBufferAW("group_member_card.get_group_member_card_info", true, req.toByteArray()) + if (fromServiceMsg != null && fromServiceMsg.wupBuffer != null) { + val rsp = group_member_info.RspBody() + rsp.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + if (rsp.msg_meminfo.str_location.has()) { + info.alias = rsp.msg_meminfo.str_location.get().toStringUtf8() + } + if (rsp.msg_meminfo.uint32_age.has()) { + info.age = rsp.msg_meminfo.uint32_age.get().toByte() + } + if (rsp.msg_meminfo.bytes_group_honor.has()) { + val honorBytes = rsp.msg_meminfo.bytes_group_honor.get().toByteArray() + val honor = troop_honor.GroupUserCardHonor() + honor.mergeFrom(honorBytes) + info.level = honor.level.get() + // 10315: medal_id not real group level + } + } + } + } catch (err: Throwable) { + LogCenter.log("getTroopMemberInfoByUin: " + err.stackTraceToString(), Level.WARN) + } + return if (info != null) { + Result.success(info) + } else { + Result.failure(Exception("获取群成员信息失败")) + } + } + + suspend fun getTroopMemberInfoByUinViaNt( + groupId: String, + qq: String, + timeout: Long = 5000L + ): Result { + return runCatching { + val kernelService = NTServiceFetcher.kernelService + val sessionService = kernelService.wrapperSession + val groupService = sessionService.groupService + val info = withTimeoutOrNull(timeout) { + suspendCancellableCoroutine { + groupService.getTransferableMemberInfo(groupId.toLong()) { code, _, data -> + if (code != 0) { + it.resume(null) + return@getTransferableMemberInfo + } + data.forEach { (_, info) -> + if (info.uin == qq.toLong()) { + it.resume(info) + return@forEach + } + } + it.resume(null) + } + } + } + return if (info != null) { + Result.success(info) + } else { + Result.failure(Exception("获取群成员信息失败")) + } + } + } + + private suspend fun requestTroopMemberInfo(service: ITroopMemberInfoService, groupId: String, memberUin: String, timeout: Long = 10_000): Result { + val info = RefreshTroopMemberInfoLock.withLock { + service.deleteTroopMember(groupId, memberUin) + + requestMemberInfoV2(groupId, memberUin) + requestMemberInfo(groupId, memberUin) + + withTimeoutOrNull(timeout) { + while (!service.isMemberInCache(groupId, memberUin)) { + delay(200) + } + return@withTimeoutOrNull service.getTroopMember(groupId, memberUin) + } + } + return if (info != null) { + Result.success(info) + } else { + Result.failure(Exception("获取群成员信息失败")) + } + } + + private fun requestMemberInfo(groupId: String, memberUin: String) { + val businessHandler = app.getBusinessHandler(BusinessHandlerFactory.TROOP_MEMBER_CARD_HANDLER) + + if (!::METHOD_REQ_MEMBER_INFO.isInitialized) { + METHOD_REQ_MEMBER_INFO = businessHandler.javaClass.declaredMethods.first { + it.parameterCount == 2 && + it.parameterTypes[0] == Long::class.java && + it.parameterTypes[1] == Long::class.java && + !Modifier.isPrivate(it.modifiers) + } + } + + METHOD_REQ_MEMBER_INFO.invoke(businessHandler, groupId.toLong(), memberUin.toLong()) + } + + private fun requestMemberInfoV2(groupId: String, memberUin: String) { + val businessHandler = app.getBusinessHandler(BusinessHandlerFactory.TROOP_MEMBER_CARD_HANDLER) + + if (!::METHOD_REQ_MEMBER_INFO_V2.isInitialized) { + METHOD_REQ_MEMBER_INFO_V2 = businessHandler.javaClass.declaredMethods.first { + it.parameterCount == 3 && + it.parameterTypes[0] == String::class.java && + it.parameterTypes[1] == String::class.java && + !Modifier.isPrivate(it.modifiers) + } + } + + METHOD_REQ_MEMBER_INFO_V2.invoke(businessHandler, + groupId, groupUin2GroupCode(groupId.toLong()).toString(), arrayListOf(memberUin)) + } + + private suspend fun requestTroopMemberInfo(service: ITroopMemberInfoService, groupId: String): Result> { + val info = RefreshTroopMemberListLock.withLock { + service.deleteTroopMembers(groupId) + refreshTroopMemberList(groupId) + + withTimeoutOrNull(10000) { + var memberList: List? + do { + delay(100) + memberList = service.getAllTroopMembers(groupId) + } while (memberList.isNullOrEmpty()) + return@withTimeoutOrNull memberList + } + } + return if (info != null) { + Result.success(info) + } else { + Result.failure(Exception("获取群成员信息失败")) + } + } + + private fun refreshTroopMemberList(groupId: String) { + val businessHandler = app.getBusinessHandler(BusinessHandlerFactory.TROOP_MEMBER_LIST_HANDLER) + + // void C(boolean forceRefresh, String groupId, String troopcode, int reqType); // RequestedTroopList/refreshMemberListFromServer + if (!::METHOD_REQ_TROOP_MEM_LIST.isInitialized) { + METHOD_REQ_TROOP_MEM_LIST = businessHandler.javaClass.declaredMethods.first { + it.parameterCount == 4 + && it.parameterTypes[0] == Boolean::class.java + && it.parameterTypes[1] == String::class.java + && it.parameterTypes[2] == String::class.java + && it.parameterTypes[3] == Int::class.java + && !Modifier.isPrivate(it.modifiers) + } + } + + METHOD_REQ_TROOP_MEM_LIST.invoke(businessHandler, true, groupId, groupUin2GroupCode(groupId.toLong()).toString(), 5) + } + + private fun refreshTroopList() { + val businessHandler = app.getBusinessHandler(BusinessHandlerFactory.TROOP_LIST_HANDLER) + + if (!::METHOD_REQ_TROOP_LIST.isInitialized) { + METHOD_REQ_TROOP_LIST = businessHandler.javaClass.declaredMethods.first { + it.parameterCount == 0 && !Modifier.isPrivate(it.modifiers) && it.returnType == Void.TYPE + } + } + + METHOD_REQ_TROOP_LIST.invoke(businessHandler) + } + + fun groupUin2GroupCode(groupuin: Long): Long { + var calc = groupuin / 1000000L + while (true) { + calc -= if (calc >= 0 + 202 && calc + 202 <= 10) { + (202 - 0).toLong() + } else if (calc >= 11 + 480 && calc <= 19 + 480) { + (480 - 11).toLong() + } else if (calc >= 20 + 2100 && calc <= 66 + 2100) { + (2100 - 20).toLong() + } else if (calc >= 67 + 2010 && calc <= 156 + 2010) { + (2010 - 67).toLong() + } else if (calc >= 157 + 2147 && calc <= 209 + 2147) { + (2147 - 157).toLong() + } else if (calc >= 210 + 4100 && calc <= 309 + 4100) { + (4100 - 210).toLong() + } else if (calc >= 310 + 3800 && calc <= 499 + 3800) { + (3800 - 310).toLong() + } else { + break + } + } + return calc * 1000000L + groupuin % 1000000L + } + + fun groupCode2GroupUin(groupcode: Long): Long { + var calc = groupcode / 1000000L + loop@ while (true) calc += when (calc) { + in 0..10 -> { + (202 - 0).toLong() + } + in 11..19 -> { + (480 - 11).toLong() + } + in 20..66 -> { + (2100 - 20).toLong() + } + in 67..156 -> { + (2010 - 67).toLong() + } + in 157..209 -> { + (2147 - 157).toLong() + } + in 210..309 -> { + (4100 - 210).toLong() + } + in 310..499 -> { + (3800 - 310).toLong() + } + else -> { + break@loop + } + } + return calc * 1000000L + groupcode % 1000000L + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/group/NotJoinedGroupInfo.kt b/xposed/src/main/java/qq/service/group/NotJoinedGroupInfo.kt new file mode 100644 index 0000000..9959e8f --- /dev/null +++ b/xposed/src/main/java/qq/service/group/NotJoinedGroupInfo.kt @@ -0,0 +1,17 @@ +package qq.service.group + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class NotJoinedGroupInfo( + @SerialName("group_id") val groupId: Long, + @SerialName("max_member_cnt") val maxMember: Int, + @SerialName("member_count") val memberCount: Int, + @SerialName("group_name") val groupName: String, + @SerialName("group_desc") val groupDesc: String, + @SerialName("owner") val owner: Long, + @SerialName("create_time") val createTime: Long, + @SerialName("group_flag") val groupFlag: Int, + @SerialName("group_flag_ext") val groupFlagExt: Int, +) \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/group/ProhibitedMemberInfo.kt b/xposed/src/main/java/qq/service/group/ProhibitedMemberInfo.kt new file mode 100644 index 0000000..6611dd7 --- /dev/null +++ b/xposed/src/main/java/qq/service/group/ProhibitedMemberInfo.kt @@ -0,0 +1,10 @@ +package qq.service.group + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class ProhibitedMemberInfo( + @SerialName("user_id") val memberUin: Long, + @SerialName("time") val shutuptimestap: Int +) \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/internals/AioListener.kt b/xposed/src/main/java/qq/service/internals/AioListener.kt new file mode 100644 index 0000000..4412541 --- /dev/null +++ b/xposed/src/main/java/qq/service/internals/AioListener.kt @@ -0,0 +1,140 @@ +@file:OptIn(DelicateCoroutinesApi::class) + +package qq.service.internals + +import com.tencent.qqnt.kernel.nativeinterface.MsgConstant +import com.tencent.qqnt.kernel.nativeinterface.MsgRecord +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.internals.GlobalEventTransmitter +import qq.service.bdh.RichProtoSvc +import qq.service.kernel.SimpleKernelMsgListener +import qq.service.msg.MessageHelper + +object AioListener: SimpleKernelMsgListener() { + override fun onRecvMsg(records: ArrayList) { + records.forEach { + GlobalScope.launch { + try { + onMsg(it) + } catch (e: Exception) { + LogCenter.log("OnMessage: " + e.stackTraceToString(), Level.WARN) + } + } + } + } + + private suspend fun onMsg(record: MsgRecord) { + when (record.chatType) { + MsgConstant.KCHATTYPEGROUP -> { + if (record.senderUin == 0L) return + + LogCenter.log("群消息(group = ${record.peerName}(${record.peerUin}), uin = ${record.senderUin}, id = ${record.msgId})") + + if (!GlobalEventTransmitter.MessageTransmitter.transGroupMessage(record, record.elements)) { + LogCenter.log("群消息推送失败 -> 推送目标可能不存在", Level.WARN) + } + } + + MsgConstant.KCHATTYPEC2C -> { + LogCenter.log("私聊消息(private = ${record.senderUin}, id = [${record.msgId} | ${record.msgSeq}])") + + if (!GlobalEventTransmitter.MessageTransmitter.transPrivateMessage( + record, record.elements + ) + ) { + LogCenter.log("私聊消息推送失败 -> MessageTransmitter", Level.WARN) + } + } + + MsgConstant.KCHATTYPETEMPC2CFROMGROUP -> { + var groupCode = 0L + var fromNick = "" + MessageHelper.getTempChatInfo(record.chatType, record.senderUid).onSuccess { + groupCode = it.groupCode.toLong() + fromNick = it.fromNick + } + + LogCenter.log("私聊临时消息(private = ${record.senderUin}, groupId=$groupCode)") + + if (!GlobalEventTransmitter.MessageTransmitter.transTempMessage(record, record.elements, groupCode, fromNick) + ) { + LogCenter.log("私聊临时消息推送失败 -> MessageTransmitter", Level.WARN) + } + } + + MsgConstant.KCHATTYPEGUILD -> { + LogCenter.log("频道消息(guildId = ${record.guildId}, sender = ${record.senderUid}, id = [${record.msgId}])") + if (!GlobalEventTransmitter.MessageTransmitter + .transGuildMessage(record, record.elements) + ) { + LogCenter.log("频道消息推送失败 -> MessageTransmitter", Level.WARN) + } + } + + else -> LogCenter.log("不支持PUSH事件: ${record.chatType}") + } + } + + override fun onFileMsgCome(arrayList: ArrayList?) { + arrayList?.forEach { record -> + GlobalScope.launch { + when (record.chatType) { + MsgConstant.KCHATTYPEGROUP -> onGroupFileMsg(record) + MsgConstant.KCHATTYPEC2C -> onC2CFileMsg(record) + else -> LogCenter.log("不支持该来源的文件上传事件:${record}", Level.WARN) + } + } + } + } + + private suspend fun onC2CFileMsg(record: MsgRecord) { + val userId = record.senderUin + val fileMsg = record.elements.firstOrNull { + it.elementType == MsgConstant.KELEMTYPEFILE + }?.fileElement ?: kotlin.run { + LogCenter.log("消息为私聊文件消息但不包含文件消息,来自:${record.peerUin}", Level.WARN) + return + } + + val fileName = fileMsg.fileName + val fileSize = fileMsg.fileSize + val expireTime = fileMsg.expireTime ?: 0 + val fileId = fileMsg.fileUuid + val fileSubId = fileMsg.fileSubId ?: "" + val url = RichProtoSvc.getC2CFileDownUrl(fileId, fileSubId) + + if (!GlobalEventTransmitter.FileNoticeTransmitter + .transPrivateFileEvent(record.msgTime, userId, fileId, fileSubId, fileName, fileSize, expireTime, url) + ) { + LogCenter.log("私聊文件消息推送失败 -> FileNoticeTransmitter", Level.WARN) + } + } + + private suspend fun onGroupFileMsg(record: MsgRecord) { + val groupId = record.peerUin + val userId = record.senderUin + val fileMsg = record.elements.firstOrNull { + it.elementType == MsgConstant.KELEMTYPEFILE + }?.fileElement ?: kotlin.run { + LogCenter.log("消息为群聊文件消息但不包含文件消息,来自:${record.peerUin}", Level.WARN) + return + } + //val fileMd5 = fileMsg.fileMd5 + val fileName = fileMsg.fileName + val fileSize = fileMsg.fileSize + val uuid = fileMsg.fileUuid + val bizId = fileMsg.fileBizId + + val url = RichProtoSvc.getGroupFileDownUrl(record.peerUin, uuid, bizId) + + if (!GlobalEventTransmitter.FileNoticeTransmitter + .transGroupFileEvent(record.msgTime, userId, groupId, uuid, fileName, fileSize, bizId, url) + ) { + LogCenter.log("群聊文件消息推送失败 -> FileNoticeTransmitter", Level.WARN) + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/internals/LineDevListener.kt b/xposed/src/main/java/qq/service/internals/LineDevListener.kt new file mode 100644 index 0000000..41698c9 --- /dev/null +++ b/xposed/src/main/java/qq/service/internals/LineDevListener.kt @@ -0,0 +1,14 @@ +package qq.service.internals + +import com.tencent.qqnt.kernel.nativeinterface.DevInfo +import com.tencent.qqnt.kernel.nativeinterface.KickedInfo +import qq.service.kernel.SimpleKernelMsgListener +import java.util.ArrayList + +object LineDevListener: SimpleKernelMsgListener() { + override fun onKickedOffLine(kickedInfo: KickedInfo) { + } + + override fun onLineDev(devs: ArrayList) { + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/internals/LocalCacheHelper.kt b/xposed/src/main/java/qq/service/internals/LocalCacheHelper.kt new file mode 100644 index 0000000..30fefb9 --- /dev/null +++ b/xposed/src/main/java/qq/service/internals/LocalCacheHelper.kt @@ -0,0 +1,23 @@ +package qq.service.internals + +import moe.fuqiuluo.shamrock.utils.FileUtils +import mqq.app.MobileQQ +import qq.service.QQInterfaces +import java.io.File + +internal object LocalCacheHelper: QQInterfaces() { + // 获取外部储存data目录 + private val dataDir = MobileQQ.getContext().getExternalFilesDir(null)!! + .parentFile!!.resolve("Tencent") + + fun getCurrentPttPath(): File { + return dataDir.resolve("MobileQQ/${app.currentAccountUin}/ptt").also { + if (!it.exists()) it.mkdirs() + } + } + + fun getCachePttFile(md5: String): File { + val file = FileUtils.getFileByMd5(md5) + return if (file.exists()) file else getCurrentPttPath().resolve("$md5.amr") + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/internals/MSFHandler.kt b/xposed/src/main/java/qq/service/internals/MSFHandler.kt new file mode 100644 index 0000000..0cc4c13 --- /dev/null +++ b/xposed/src/main/java/qq/service/internals/MSFHandler.kt @@ -0,0 +1,73 @@ +package qq.service.internals + +import com.tencent.qphone.base.remote.FromServiceMsg +import com.tencent.qphone.base.remote.ToServiceMsg +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import kotlin.coroutines.resume + +typealias MsfPush = (FromServiceMsg) -> Unit +typealias MsfResp = CancellableContinuation> + +internal object MSFHandler { + private val mPushHandlers = hashMapOf() + private val mRespHandler = hashMapOf() + private val mPushLock = Mutex() + private val mRespLock = Mutex() + + private val seq = atomic(0) + + fun nextSeq(): Int { + seq.compareAndSet(0xFFFFFFF, 0) + return seq.incrementAndGet() + } + + suspend fun registerPush(cmd: String, push: MsfPush) { + mPushLock.withLock { + mPushHandlers[cmd] = push + } + } + + suspend fun unregisterPush(cmd: String) { + mPushLock.withLock { + mPushHandlers.remove(cmd) + } + } + + suspend fun registerResp(cmd: Int, resp: MsfResp) { + mRespLock.withLock { + mRespHandler[cmd] = resp + } + } + + suspend fun unregisterResp(cmd: Int) { + mRespLock.withLock { + mRespHandler.remove(cmd) + } + } + + fun onPush(fromServiceMsg: FromServiceMsg) { + val cmd = fromServiceMsg.serviceCmd + if (cmd == "trpc.msg.olpush.OlPushService.MsgPush") { + PrimitiveListener.onPush(fromServiceMsg) + } else { + val push = mPushHandlers[cmd] + push?.invoke(fromServiceMsg) + } + } + + fun onResp(toServiceMsg: ToServiceMsg, fromServiceMsg: FromServiceMsg) { + runCatching { + val cmd = toServiceMsg.getAttribute("shamrock_uid") as? Int? + ?: return@runCatching + val resp = mRespHandler[cmd] + resp?.resume(toServiceMsg to fromServiceMsg) + }.onFailure { + LogCenter.log("MSF.onResp failed: ${it.message}", Level.ERROR) + } + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/internals/NTServiceFetcher.kt b/xposed/src/main/java/qq/service/internals/NTServiceFetcher.kt new file mode 100644 index 0000000..8e28fd7 --- /dev/null +++ b/xposed/src/main/java/qq/service/internals/NTServiceFetcher.kt @@ -0,0 +1,101 @@ +package qq.service.internals + +import com.tencent.qqnt.kernel.api.IKernelService +import com.tencent.qqnt.kernel.api.impl.MsgService +import com.tencent.qqnt.kernel.nativeinterface.IOperateCallback +import com.tencent.qqnt.kernel.nativeinterface.IQQNTWrapperSession +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.tools.hookMethod +import moe.fuqiuluo.shamrock.utils.PlatformUtils + +internal object NTServiceFetcher { + private lateinit var iKernelService: IKernelService + private val lock = Mutex() + private var curKernelHash = 0 + + suspend fun onFetch(service: IKernelService) { + lock.withLock { + val msgService = service.msgService ?: return + val sessionService = service.wrapperSession ?: return + //val groupService = sessionService.groupService ?: return + + val curHash = service.hashCode() + msgService.hashCode() + if (isInitForNt(curHash)) return + + LogCenter.log("Fetch kernel service successfully: $curKernelHash,$curHash,${PlatformUtils.isMainProcess()}") + curKernelHash = curHash + this.iKernelService = service + + + initNTKernelListener(msgService) + antiBackgroundMode(sessionService) + } + } + + /* + private fun hookGuildListener(sessionService: IQQNTWrapperSession) { + val guildService = sessionService.guildService + XposedBridge.hookMethod(guildService::addKernelGuildListener.javaMethod, object: XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam?) { + val service = param?.thisObject as IKernelGuildService + service.addKernelGuildListener(KernelGuildListener) + LogCenter.log("Register Guild listener successfully.") + } + }) + } + */ + + private inline fun isInitForNt(hash: Int): Boolean { + return hash == curKernelHash + } + + private fun initNTKernelListener(msgService: MsgService) { + if (!PlatformUtils.isMainProcess()) return + + try { + LogCenter.log("Register MSG listener successfully.") + msgService.addMsgListener(AioListener) + msgService.addMsgListener(LineDevListener) + + // 接口缺失 暂不使用 + //groupService.addKernelGroupListener(GroupEventListener) + //LogCenter.log("Register Group listener successfully.") + + //PrimitiveListener.registerListener() + } catch (e: Throwable) { + LogCenter.log(e.stackTraceToString(), Level.WARN) + } + } + + private fun antiBackgroundMode(sessionService: IQQNTWrapperSession) { + try { + sessionService.javaClass.hookMethod("switchToBackGround").before { + LogCenter.log({ "阻止进入后台模式!" }, Level.DEBUG) + it.result = null + } + + val msgService = sessionService.msgService + msgService.javaClass.hookMethod("switchBackGroundForMqq").before { + LogCenter.log({ "阻止进入后台模式!" }, Level.DEBUG) + val cb = it.args[1] as IOperateCallback + cb.onResult(-1, "injected") + it.result = null + } + msgService.javaClass.hookMethod("switchBackGround").before { + LogCenter.log({ "阻止进入后台模式!" }, Level.DEBUG) + val cb = it.args[1] as IOperateCallback + cb.onResult(-1, "injected") + it.result = null + } + LogCenter.log({ "反后台模式注入成功!" }, Level.DEBUG) + } catch (e: Throwable) { + LogCenter.log("Keeping NT alive failed: ${e.message}", Level.WARN) + } + } + + val kernelService: IKernelService + get() = iKernelService +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/internals/NTServiceHelper.kt b/xposed/src/main/java/qq/service/internals/NTServiceHelper.kt new file mode 100644 index 0000000..3dbb389 --- /dev/null +++ b/xposed/src/main/java/qq/service/internals/NTServiceHelper.kt @@ -0,0 +1,19 @@ +package qq.service.internals + +import com.tencent.qqnt.kernel.api.IKernelService +import com.tencent.qqnt.kernel.api.impl.MsgService +import java.lang.reflect.Method + +internal object KernelServiceHelper { + private lateinit var M_GET_MSG_SERVICE: Method + + fun getMsgService(service: IKernelService): MsgService? { + if (!KernelServiceHelper::M_GET_MSG_SERVICE.isInitialized) { + M_GET_MSG_SERVICE = IKernelService::class.java.getMethod("getMsgService") + } + return M_GET_MSG_SERVICE.invoke(service) as? MsgService + } +} + +internal val IKernelService.msgService: MsgService? + get() = KernelServiceHelper.getMsgService(this) \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/internals/PrimitiveListener.kt b/xposed/src/main/java/qq/service/internals/PrimitiveListener.kt new file mode 100644 index 0000000..c2a9b91 --- /dev/null +++ b/xposed/src/main/java/qq/service/internals/PrimitiveListener.kt @@ -0,0 +1,668 @@ +@file:OptIn(DelicateCoroutinesApi::class) +package qq.service.internals + +import com.tencent.mobileqq.qroute.QRoute +import com.tencent.qphone.base.remote.FromServiceMsg +import com.tencent.qqnt.kernel.nativeinterface.MsgConstant +import com.tencent.qqnt.msg.api.IMsgService +import io.kritor.event.GroupApplyType +import io.kritor.event.GroupMemberDecreasedType +import io.kritor.event.GroupMemberIncreasedType +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.io.core.ByteReadPacket +import kotlinx.io.core.discardExact +import kotlinx.io.core.readBytes +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.internals.GlobalEventTransmitter +import moe.fuqiuluo.shamrock.tools.asJsonObject +import moe.fuqiuluo.shamrock.tools.asString +import moe.fuqiuluo.shamrock.tools.readBuf32Long +import moe.fuqiuluo.shamrock.tools.slice +import moe.fuqiuluo.symbols.decodeProtobuf +import protobuf.message.ContentHead +import protobuf.message.MsgBody +import protobuf.message.ResponseHead +import protobuf.push.C2CCommonTipsEvent +import protobuf.push.C2CRecallEvent +import protobuf.push.FriendApplyEvent +import protobuf.push.GroupAdminChangeEvent +import protobuf.push.GroupApplyEvent +import protobuf.push.GroupBanEvent +import protobuf.push.GroupCommonTipsEvent +import protobuf.push.GroupInviteEvent +import protobuf.push.GroupInvitedApplyEvent +import protobuf.push.GroupListChangeEvent +import protobuf.push.MessagePush +import protobuf.push.MessagePushClientInfo +import qq.service.QQInterfaces +import qq.service.contact.ContactHelper +import qq.service.friend.FriendHelper.requestFriendSystemMsgNew +import qq.service.group.GroupHelper +import qq.service.group.GroupHelper.requestGroupSystemMsgNew +import qq.service.msg.MessageHelper +import kotlin.coroutines.resume + +internal object PrimitiveListener { + + fun onPush(fromServiceMsg: FromServiceMsg) { + if (fromServiceMsg.wupBuffer == null) return + try { + val push = fromServiceMsg.wupBuffer.slice(4) + .decodeProtobuf() + GlobalScope.launch { + onMsgPush(push) + } + } catch (e: Exception) { + LogCenter.log(e.stackTraceToString(), Level.WARN) + } + } + + private suspend fun onMsgPush(push: MessagePush) { + if ( + push.msgBody == null || + push.msgBody!!.contentHead == null || + push.msgBody!!.body == null || + push.msgBody!!.contentHead!!.msgTime == null + ) return + val msgBody = push.msgBody!! + val contentHead = msgBody.contentHead!! + val msgType = contentHead.msgType + val subType = contentHead.msgSubType + val msgTime = contentHead.msgTime!! + val body = msgBody.body!! + try { + when (msgType) { + 33 -> onGroupMemIncreased(msgTime, body) + 34 -> onGroupMemberDecreased(msgTime, body) + 44 -> onGroupAdminChange(msgTime, body) + 82 -> onGroupMessage(msgTime, body) + 84 -> onGroupApply(msgTime, contentHead, body) + 87 -> onInviteGroup(msgTime, msgBody.msgHead!!, body) + 528 -> when (subType) { + 35 -> onFriendApply(msgTime, push.clientInfo!!, body) + 39 -> onCardChange(msgTime, body) + 68 -> onGroupApply(msgTime, contentHead, body) + 138 -> onC2CRecall(msgTime, body) + 290 -> onC2CPoke(msgTime, body) + } + + 732 -> when (subType) { + 12 -> onGroupBan(msgTime, body) + 16 -> onGroupUniqueTitleChange(msgTime, body) + 17 -> onGroupRecall(msgTime, body) + 20 -> onGroupCommonTips(msgTime, body) + 21 -> onEssenceMessage(msgTime, push.clientInfo, body) + } + } + } catch (e: Exception) { + LogCenter.log("onMsgPush(msgType: $msgType, subType: $subType): " + e.stackTraceToString(), Level.WARN) + } + } + + private fun onGroupMessage(msgTime: Long, body: MsgBody) { + + } + + private suspend fun onC2CPoke(msgTime: Long, body: MsgBody) { + val event = body.msgContent!!.decodeProtobuf() + if (event.params == null) return + + val params = event.params!!.associate { + it.key to it.value + } + + val target = params["uin_str2"] ?: return + val operation = params["uin_str1"] ?: return + val suffix = params["suffix_str"] ?: "" + val actionImg = params["action_img_url"] ?: "" + val action = params["alt_str1"] ?: "" + + LogCenter.log("私聊戳一戳: $operation $action $target $suffix") + + if (!GlobalEventTransmitter.PrivateNoticeTransmitter + .transPrivatePoke(msgTime, operation.toLong(), target.toLong(), action, suffix, actionImg) + ) { + LogCenter.log("私聊戳一戳推送失败!", Level.WARN) + } + } + + private suspend fun onFriendApply( + msgTime: Long, + clientInfo: MessagePushClientInfo, + body: MsgBody + ) { + val event = body.msgContent!!.decodeProtobuf() + if (event.head == null) return + val head = event.head!! + val applierUid = head.applierUid + val msg = head.applyMsg ?: "" + val source = head.source ?: "" + var applier = ContactHelper.getUinByUidAsync(applierUid).toLong() + if (applier == 0L) { + applier = clientInfo.liteHead?.sender?.toLong() ?: 0 + } + val src = head.srcId + val subSrc = head.subSrc + val flag: String = try { + val reqs = requestFriendSystemMsgNew(20, 0, 0) + val req = reqs?.first { + it.msg_time.get() == msgTime + } + val seq = req?.msg_seq?.get() + "$seq;$src;$subSrc;$applier" + } catch (err: Throwable) { + "$msgTime;$src;$subSrc;$applier" + } + LogCenter.log("来自$applier 的好友申请:$msg ($source)") + if (!GlobalEventTransmitter.RequestTransmitter + .transFriendApp(msgTime, applier, msg, flag) + ) { + LogCenter.log("好友申请推送失败!", Level.WARN) + } + } + + + private suspend fun onCardChange(msgTime: Long, body: MsgBody) { +// val event = runCatching { +// body.msgContent!!.decodeProtobuf() +// }.getOrElse { +// val readPacket = ByteReadPacket(body.msgContent!!) +// readPacket.readBuf32Long() +// readPacket.discardExact(1) +// +// readPacket.readBytes(readPacket.readShort().toInt()).also { +// readPacket.release() +// }.decodeProtobuf() +// } +// +// val targetId = detail[1, 13, 2].asUtf8String +// val newCardList = detail[1, 13, 3].asList +// var newCard = "" +// newCardList +// .value +// .forEach { +// if (it[1].asInt == 1) { +// newCard = it[2].asUtf8String +// } +// } +// val groupId = detail[1, 13, 4].asLong +// var oldCard = "" +// val targetQQ = ContactHelper.getUinByUidAsync(targetId).toLong() +// LogCenter.log("群组[$groupId]成员$targetQQ 群名片变动 -> $newCard") +// // oldCard暂时获取不到 +// if (!GlobalEventTransmitter.GroupNoticeTransmitter +// .transCardChange(msgTime, targetQQ, oldCard, newCard, groupId) +// ) { +// LogCenter.log("群名片变动推送失败!", Level.WARN) +// } + } + + private suspend fun onGroupUniqueTitleChange(msgTime: Long, body: MsgBody) { + val event = runCatching { + body.msgContent!!.decodeProtobuf() + }.getOrElse { + val readPacket = ByteReadPacket(body.msgContent!!) + readPacket.readBuf32Long() + readPacket.discardExact(1) + + readPacket.readBytes(readPacket.readShort().toInt()).also { + readPacket.release() + }.decodeProtobuf() + } + val groupId = event.groupCode.toLong() + val detail = event.uniqueTitleChangeDetail!!.first() + + //detail = if (detail[5] is ProtoList) { + // (detail[5] as ProtoList).value[0] + //} else { + // detail[5] + // } + + val targetUin = detail.targetUin.toLong() + + // 恭喜<{\"cmd\":5,\"data\":\"qq\",\"text}\":\"nickname\"}>获得群主授予的<{\"cmd\":1,\"data\":\"https://qun.qq.com/qqweb/m/qun/medal/detail.html?_wv=16777223&bid=2504&gc=gid&isnew=1&medal=302&uin=uin\",\"text\":\"title\",\"url\":\"https://qun.qq.com/qqweb/m/qun/medal/detail.html?_wv=16777223&bid=2504&gc=gid&isnew=1&medal=302&uin=uin\"}>头衔 + val titleChangeInfo = detail.wording + if (titleChangeInfo.indexOf("群主授予") == -1) { + return + } + val titleJson = titleChangeInfo.split("获得群主授予的<")[1].replace(">头衔", "") + val titleJsonObj = Json.decodeFromString(titleJson).asJsonObject + val title = titleJsonObj["text"].asString + + LogCenter.log("群组[$groupId]成员$targetUin 获得群头衔 -> $title") + + if (!GlobalEventTransmitter.GroupNoticeTransmitter + .transTitleChange(msgTime, targetUin, title, groupId) + ) { + LogCenter.log("群头衔变动推送失败!", Level.WARN) + } + } + + private suspend fun onEssenceMessage( + msgTime: Long, + clientInfo: MessagePushClientInfo?, + body: MsgBody + ) { + if (clientInfo == null) return + val event = runCatching { + body.msgContent!!.decodeProtobuf() + }.getOrElse { + val readPacket = ByteReadPacket(body.msgContent!!) + readPacket.readBuf32Long() + readPacket.discardExact(1) + + readPacket.readBytes(readPacket.readShort().toInt()).also { + readPacket.release() + }.decodeProtobuf() + } + val groupId = event.groupCode.toLong() + val detail = event.essenceMsgInfo!!.first() + + val msgSeq = event.msgSeq.toLong() + val senderUin = detail.sender.toLong() + val operatorUin = detail.operator.toLong() + + when (val type = detail.type) { + 1u -> { + LogCenter.log("群设精消息(groupId=$groupId, sender=$senderUin, msgSeq=$msgSeq, operator=$operatorUin)") + } + 2u -> { + LogCenter.log("群撤精消息(groupId=$groupId, sender=$senderUin, msgId=$msgSeq, operator=$operatorUin)") + } + else -> error("onEssenceMessage unknown type: $type") + } + + val contact = MessageHelper.generateContact(MsgConstant.KCHATTYPEGROUP, groupId.toString()) + val sourceRecord = withTimeoutOrNull(3000) { + suspendCancellableCoroutine { + QRoute.api(IMsgService::class.java).getMsgsBySeqAndCount(contact, msgSeq, 1, true) { _, _, records -> + it.resume(records) + } + } + }?.firstOrNull() + + if (sourceRecord == null) { + LogCenter.log("无法获取源消息记录,无法推送精华消息变动!", Level.WARN) + return + } + + val msgId = sourceRecord.msgId + if (!GlobalEventTransmitter.GroupNoticeTransmitter + .transEssenceChange(msgTime, senderUin, operatorUin, msgId, groupId, detail.type) + ) { + LogCenter.log("精华消息变动推送失败!", Level.WARN) + } + } + + + private suspend fun onGroupCommonTips(time: Long, body: MsgBody) { + val event = runCatching { + body.msgContent!!.decodeProtobuf() + }.getOrElse { + val readPacket = ByteReadPacket(body.msgContent!!) + readPacket.discardExact(4) + readPacket.discardExact(1) + + readPacket.readBytes(readPacket.readShort().toInt()).also { + readPacket.release() + }.decodeProtobuf() + } + val groupId = event.groupCode.toLong() + val detail = event.baseTips!!.first() + + val params = detail.params!!.associate { + it.key to it.value + } + + val target = params["uin_str2"] ?: params["mqq_uin"] ?: return + val operation = params["uin_str1"] ?: return + val suffix = params["suffix_str"] ?: "" + val actionImg = params["action_img_url"] ?: "" + val action = params["alt_str1"] + ?: params["action_str"] + ?: params["user_sign"] + ?: "" + val rankImg = params["rank_img"] ?: "" + + when (detail.type) { + 1061u -> { + LogCenter.log("群戳一戳($groupId): $operation $action $target $suffix") + if (!GlobalEventTransmitter.GroupNoticeTransmitter + .transGroupPoke(time, operation.toLong(), target.toLong(), action, suffix, actionImg, groupId) + ) { + LogCenter.log("群戳一戳推送失败!", Level.WARN) + } + } + + 1068u -> { + LogCenter.log("群打卡($groupId): $action $target") + if (!GlobalEventTransmitter.GroupNoticeTransmitter + .transGroupSign(time, target.toLong(), action, rankImg, groupId) + ) { + LogCenter.log("群打卡推送失败!", Level.WARN) + } + } + + else -> { + LogCenter.log("onGroupPokeAndGroupSign unknown type ${detail.type}", Level.WARN) + } + } + } + + private suspend fun onC2CRecall(time: Long, body: MsgBody) { + val event = body.msgContent!!.decodeProtobuf() + val head = event.head!! + + val operationUid = head.operator!! + val operator = ContactHelper.getUinByUidAsync(operationUid).toLong() + + val msgSeq = head.msgSeq + val tipText = head.wording?.wording ?: "" + + val contact = MessageHelper.generateContact(MsgConstant.KCHATTYPEGROUP, operationUid) + val sourceRecord = withTimeoutOrNull(3000) { + suspendCancellableCoroutine { + QRoute.api(IMsgService::class.java).getMsgsBySeqAndCount(contact, msgSeq, 1, true) { _, _, records -> + it.resume(records) + } + } + }?.firstOrNull() + + if (sourceRecord == null) { + LogCenter.log("无法获取源消息记录,无法推送撤回消息!", Level.WARN) + return + } + + val msgId = sourceRecord.msgId + + LogCenter.log("私聊消息撤回: $operator, seq = $msgSeq, msgId = ${msgId}, tip = $tipText") + + if (!GlobalEventTransmitter.PrivateNoticeTransmitter + .transPrivateRecall(time, operator, msgId, tipText) + ) { + LogCenter.log("私聊消息撤回推送失败!", Level.WARN) + } + } + + private suspend fun onGroupMemIncreased(time: Long, body: MsgBody) { + val event = body.msgContent!!.decodeProtobuf() + val groupCode = event.groupCode + val targetUid = event.memberUid + val type = event.type + + GroupHelper.getGroupMemberList(groupCode.toString(), true).onFailure { + LogCenter.log("新成员加入刷新群成员列表失败: $groupCode", Level.WARN) + }.onSuccess { + LogCenter.log("新成员加入刷新群成员列表成功,群成员数量: ${it.size}", Level.INFO) + } + + val operatorUid = event.operatorUid + val operator = ContactHelper.getUinByUidAsync(operatorUid).toLong() + val target = ContactHelper.getUinByUidAsync(targetUid).toLong() + LogCenter.log("群成员增加($groupCode): $target, type = $type") + + if (!GlobalEventTransmitter.GroupNoticeTransmitter + .transGroupMemberNumIncreased( + time, + target, + targetUid, + groupCode, + operator, + operatorUid, + when (type) { + 130 -> GroupMemberIncreasedType.APPROVE + 131 -> GroupMemberIncreasedType.INVITE + else -> GroupMemberIncreasedType.APPROVE + } + ) + ) { + LogCenter.log("群成员增加推送失败!", Level.WARN) + } + } + + private suspend fun onGroupMemberDecreased(time: Long, body: MsgBody) { + val event = body.msgContent!!.decodeProtobuf() + val groupCode = event.groupCode + val targetUid = event.memberUid + val type = event.type + val operatorUid = event.operatorUid + + GroupHelper.getGroupMemberList(groupCode.toString(), true).onFailure { + LogCenter.log("新成员加入刷新群成员列表失败: $groupCode", Level.WARN) + }.onSuccess { + LogCenter.log("新成员加入刷新群成员列表成功,群成员数量: ${it.size}", Level.INFO) + } + + val operator = ContactHelper.getUinByUidAsync(operatorUid).toLong() + val target = ContactHelper.getUinByUidAsync(targetUid).toLong() + val subtype = when (type) { + 130 -> GroupMemberDecreasedType.LEAVE + 131 -> GroupMemberDecreasedType.KICK + 3 -> GroupMemberDecreasedType.KICK_ME + else -> GroupMemberDecreasedType.KICK + } + + LogCenter.log("群成员减少($groupCode): $target, type = $subtype ($type)") + + if (!GlobalEventTransmitter.GroupNoticeTransmitter + .transGroupMemberNumDecreased( + time, + target, + targetUid, + groupCode, + operator, + operatorUid, + subtype + ) + ) { + LogCenter.log("群成员减少推送失败!", Level.WARN) + } + } + + private suspend fun onGroupAdminChange(msgTime: Long, body: MsgBody) { + val event = body.msgContent!!.decodeProtobuf() + val groupCode = event.groupCode + if (event.operation == null) return + val operation = event.operation!! + if (operation.setInfo == null && operation.unsetInfo == null) return + + val isSetAdmin: Boolean + val targetUid: String + if (operation.setInfo == null) { + isSetAdmin = false + targetUid = operation.unsetInfo!!.targetUid!! + } else { + isSetAdmin = true + targetUid = operation.setInfo!!.targetUid!! + } + + val target = ContactHelper.getUinByUidAsync(targetUid).toLong() + LogCenter.log("群管理员变动($groupCode): $target, isSetAdmin = $isSetAdmin") + + if (!GlobalEventTransmitter.GroupNoticeTransmitter + .transGroupAdminChanged(msgTime, target, targetUid, groupCode, isSetAdmin) + ) { + LogCenter.log("群管理员变动推送失败!", Level.WARN) + } + } + + private suspend fun onGroupBan(msgTime: Long, body: MsgBody) { + val event = body.msgContent!!.decodeProtobuf() + val groupCode = event.groupCode.toLong() + val operatorUid = event.operatorUid + val wholeBan = event.target?.target?.targetUid == null + val targetUid = event.target?.target?.targetUid ?: "" + val rawDuration = event.target?.target?.rawDuration?.toInt() ?: 0 + val operator = ContactHelper.getUinByUidAsync(operatorUid).toLong() + val duration = if (wholeBan) -1 else rawDuration + val target = if (wholeBan) 0 else ContactHelper.getUinByUidAsync(targetUid).toLong() + + if (wholeBan) { + LogCenter.log("群全员禁言($groupCode): $operator -> ${if (rawDuration != 0) "开启" else "关闭"}") + if (!GlobalEventTransmitter.GroupNoticeTransmitter + .transGroupWholeBan(msgTime, groupCode, operator, rawDuration != 0) + ) { + LogCenter.log("群禁言推送失败!", Level.WARN) + } + } else { + LogCenter.log("群禁言($groupCode): $operator -> $target, 时长 = ${duration}s") + if (!GlobalEventTransmitter.GroupNoticeTransmitter + .transGroupBan(msgTime, operator, operatorUid, target, targetUid, groupCode, duration) + ) { + LogCenter.log("群禁言推送失败!", Level.WARN) + } + } + } + + private suspend fun onGroupRecall(time: Long, body: MsgBody) { + val event = runCatching { + body.msgContent!!.decodeProtobuf() + }.getOrElse { + val readPacket = ByteReadPacket(body.msgContent!!) + readPacket.discardExact(4) + readPacket.discardExact(1) + readPacket.readBytes(readPacket.readShort().toInt()).also { + readPacket.release() + }.decodeProtobuf() + } + val groupCode = event.groupCode.toLong() + val detail = event.recallDetails!! + val operatorUid = detail.operatorUid + val targetUid = detail.msgInfo!!.senderUid + val msgSeq = detail.msgInfo!!.msgSeq.toLong() + val tipText = detail.wording?.wording ?: "" + + val contact = MessageHelper.generateContact(MsgConstant.KCHATTYPEGROUP, groupCode.toString()) + val sourceRecord = withTimeoutOrNull(3000) { + suspendCancellableCoroutine { + QRoute.api(IMsgService::class.java).getMsgsBySeqAndCount(contact, msgSeq, 1, true) { _, _, records -> + it.resume(records) + } + } + }?.firstOrNull() + + if (sourceRecord == null) { + LogCenter.log("无法获取源消息记录,无法推送撤回消息!", Level.WARN) + return + } + + val msgId = sourceRecord.msgId + + val operator = ContactHelper.getUinByUidAsync(operatorUid).toLong() + val target = ContactHelper.getUinByUidAsync(targetUid).toLong() + LogCenter.log("群消息撤回($groupCode): $operator -> $target, seq = $msgSeq, id = $msgId, tip = $tipText") + + if (!GlobalEventTransmitter.GroupNoticeTransmitter + .transGroupMsgRecall(time, operator, operatorUid, target, targetUid, groupCode, msgId, tipText) + ) { + LogCenter.log("群消息撤回推送失败!", Level.WARN) + } + } + + private suspend fun onGroupApply(time: Long, contentHead: ContentHead, body: MsgBody) { + when (contentHead.msgType) { + 84 -> { + val event = body.msgContent!!.decodeProtobuf() + val groupCode = event.groupCode + val applierUid = event.applierUid + val reason = event.applyMsg ?: "" + var applier = ContactHelper.getUinByUidAsync(applierUid).toLong() + if (applier == QQInterfaces.app.longAccountUin) { + return + } + val msgSeq = contentHead.msgSeq + val flag = try { + var reqs = requestGroupSystemMsgNew(10, 1) + val riskReqs = requestGroupSystemMsgNew(5, 2) + reqs = reqs + riskReqs + val req = reqs.firstOrNull { + it.msg_time.get() == time && it.msg?.group_code?.get() == groupCode + } + val seq = req?.msg_seq?.get() ?: time + if (applier == 0L) { + applier = req?.req_uin?.get() ?: 0L + } + "$seq;$groupCode;$applier" + } catch (err: Throwable) { + "$time;$groupCode;$applier" + } + LogCenter.log("入群申请($groupCode) $applier: \"$reason\", seq: $msgSeq") + if (!GlobalEventTransmitter.RequestTransmitter + .transGroupApply(time, applier, applierUid, reason, groupCode, flag, GroupApplyType.GROUP_APPLY_ADD) + ) { + LogCenter.log("入群申请推送失败!", Level.WARN) + } + } + + 528 -> { + val event = body.msgContent!!.decodeProtobuf() + val groupCode = event.applyInfo?.groupCode ?: return + val applierUid = event.applyInfo?.applierUid ?: return + var applier = ContactHelper.getUinByUidAsync(applierUid).toLong() + if (applier == QQInterfaces.app.longAccountUin) { + return + } + if ((event.applyInfo?.type ?: return) < 3) { + // todo + return + } + val flag = try { + var reqs = requestGroupSystemMsgNew(10, 1) + val riskReqs = requestGroupSystemMsgNew(5, 2) + reqs = reqs + riskReqs + val req = reqs.firstOrNull() { + it.msg_time.get() == time + } + val seq = req?.msg_seq?.get() ?: time + if (applier == 0L) { + applier = req?.req_uin?.get() ?: 0L + } + "$seq;$groupCode;$applier" + } catch (err: Throwable) { + "$time;$groupCode;$applier" + } + LogCenter.log("邀请入群申请($groupCode): $applier") + if (!GlobalEventTransmitter.RequestTransmitter + .transGroupApply(time, applier, applierUid, "", groupCode, flag, GroupApplyType.GROUP_APPLY_ADD) + ) { + LogCenter.log("邀请入群申请推送失败!", Level.WARN) + } + } + } + } + + private suspend fun onInviteGroup(time: Long, msgHead: ResponseHead, body: MsgBody) { + val event = body.msgContent!!.decodeProtobuf() + val groupCode = event.groupCode + val invitorUid = event.inviterUid + val invitor = ContactHelper.getUinByUidAsync(invitorUid).toLong() + val uin = msgHead.receiver + LogCenter.log("邀请入群: $groupCode, 邀请者: \"$invitor\"") + val flag = try { + var reqs = requestGroupSystemMsgNew(10, 1) + val riskReqs = requestGroupSystemMsgNew(10, 2) + reqs = reqs + riskReqs + val req = reqs.firstOrNull { + it.msg_time.get() == time + } + val seq = req?.msg_seq?.get() ?: time + "$seq;$groupCode;$uin" + } catch (err: Throwable) { + "$time;$groupCode;$uin" + } + if (!GlobalEventTransmitter.RequestTransmitter + .transGroupApply(time, invitor, invitorUid, "", groupCode, flag, GroupApplyType.GROUP_APPLY_INVITE) + ) { + LogCenter.log("邀请入群推送失败!", Level.WARN) + } + } + + +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/kernel/SimpleKernelMsgListener.kt b/xposed/src/main/java/qq/service/kernel/SimpleKernelMsgListener.kt new file mode 100644 index 0000000..d15ed47 --- /dev/null +++ b/xposed/src/main/java/qq/service/kernel/SimpleKernelMsgListener.kt @@ -0,0 +1,316 @@ +package qq.service.kernel + +import com.tencent.qqnt.kernel.nativeinterface.BroadcastHelperTransNotifyInfo +import com.tencent.qqnt.kernel.nativeinterface.Contact +import com.tencent.qqnt.kernel.nativeinterface.ContactMsgBoxInfo +import com.tencent.qqnt.kernel.nativeinterface.CustomWithdrawConfig +import com.tencent.qqnt.kernel.nativeinterface.DevInfo +import com.tencent.qqnt.kernel.nativeinterface.DownloadRelateEmojiResultInfo +import com.tencent.qqnt.kernel.nativeinterface.EmojiNotifyInfo +import com.tencent.qqnt.kernel.nativeinterface.EmojiResourceInfo +import com.tencent.qqnt.kernel.nativeinterface.FileTransNotifyInfo +import com.tencent.qqnt.kernel.nativeinterface.FirstViewDirectMsgNotifyInfo +import com.tencent.qqnt.kernel.nativeinterface.FirstViewGroupGuildInfo +import com.tencent.qqnt.kernel.nativeinterface.FreqLimitInfo +import com.tencent.qqnt.kernel.nativeinterface.GroupFileListResult +import com.tencent.qqnt.kernel.nativeinterface.GroupGuildNotifyInfo +import com.tencent.qqnt.kernel.nativeinterface.GroupItem +import com.tencent.qqnt.kernel.nativeinterface.GuildInteractiveNotificationItem +import com.tencent.qqnt.kernel.nativeinterface.GuildMsgAbFlag +import com.tencent.qqnt.kernel.nativeinterface.GuildNotificationAbstractInfo +import com.tencent.qqnt.kernel.nativeinterface.HitRelatedEmojiWordsResult +import com.tencent.qqnt.kernel.nativeinterface.IKernelMsgListener +import com.tencent.qqnt.kernel.nativeinterface.ImportOldDbMsgNotifyInfo +import com.tencent.qqnt.kernel.nativeinterface.InputStatusInfo +import com.tencent.qqnt.kernel.nativeinterface.KickedInfo +import com.tencent.qqnt.kernel.nativeinterface.MsgAbstract +import com.tencent.qqnt.kernel.nativeinterface.MsgElement +import com.tencent.qqnt.kernel.nativeinterface.MsgRecord +import com.tencent.qqnt.kernel.nativeinterface.MsgSetting +import com.tencent.qqnt.kernel.nativeinterface.RecvdOrder +import com.tencent.qqnt.kernel.nativeinterface.RelatedWordEmojiInfo +import com.tencent.qqnt.kernel.nativeinterface.SearchGroupFileResult +import com.tencent.qqnt.kernel.nativeinterface.TabStatusInfo +import com.tencent.qqnt.kernel.nativeinterface.TempChatInfo +import com.tencent.qqnt.kernel.nativeinterface.UnreadCntInfo +import java.util.ArrayList +import java.util.HashMap + +abstract class SimpleKernelMsgListener: IKernelMsgListener { + override fun onAddSendMsg(msgRecord: MsgRecord?) { + + } + + override fun onBroadcastHelperDownloadComplete(broadcastHelperTransNotifyInfo: BroadcastHelperTransNotifyInfo?) { + + } + + override fun onBroadcastHelperProgerssUpdate(broadcastHelperTransNotifyInfo: BroadcastHelperTransNotifyInfo?) { + + } + + override fun onChannelFreqLimitInfoUpdate( + contact: Contact?, + z: Boolean, + freqLimitInfo: FreqLimitInfo? + ) { + + } + + override fun onContactUnreadCntUpdate(hashMap: HashMap>?) { + + } + + override fun onCustomWithdrawConfigUpdate(customWithdrawConfig: CustomWithdrawConfig?) { + + } + + override fun onDraftUpdate(contact: Contact?, arrayList: ArrayList?, j2: Long) { + + } + + override fun onEmojiDownloadComplete(emojiNotifyInfo: EmojiNotifyInfo?) { + + } + + override fun onEmojiResourceUpdate(emojiResourceInfo: EmojiResourceInfo?) { + + } + + override fun onFeedEventUpdate(firstViewDirectMsgNotifyInfo: FirstViewDirectMsgNotifyInfo?) { + + } + + override fun onFileMsgCome(arrayList: ArrayList?) { + + } + + override fun onFirstViewDirectMsgUpdate(firstViewDirectMsgNotifyInfo: FirstViewDirectMsgNotifyInfo?) { + + } + + override fun onFirstViewGroupGuildMapping(arrayList: ArrayList?) { + + } + + override fun onGrabPasswordRedBag( + i2: Int, + str: String?, + i3: Int, + recvdOrder: RecvdOrder?, + msgRecord: MsgRecord? + ) { + + } + + override fun onGroupFileInfoAdd(groupItem: GroupItem?) { + + } + + override fun onGroupFileInfoUpdate(groupFileListResult: GroupFileListResult?) { + + } + + override fun onGroupGuildUpdate(groupGuildNotifyInfo: GroupGuildNotifyInfo?) { + + } + + override fun onGroupTransferInfoAdd(groupItem: GroupItem?) { + + } + + override fun onGroupTransferInfoUpdate(groupFileListResult: GroupFileListResult?) { + + } + + override fun onGuildInteractiveUpdate(guildInteractiveNotificationItem: GuildInteractiveNotificationItem?) { + + } + + override fun onGuildMsgAbFlagChanged(guildMsgAbFlag: GuildMsgAbFlag?) { + + } + + override fun onGuildNotificationAbstractUpdate(guildNotificationAbstractInfo: GuildNotificationAbstractInfo?) { + + } + + override fun onHitCsRelatedEmojiResult(downloadRelateEmojiResultInfo: DownloadRelateEmojiResultInfo?) { + + } + + override fun onHitEmojiKeywordResult(hitRelatedEmojiWordsResult: HitRelatedEmojiWordsResult?) { + + } + + override fun onHitRelatedEmojiResult(relatedWordEmojiInfo: RelatedWordEmojiInfo?) { + + } + + override fun onImportOldDbProgressUpdate(importOldDbMsgNotifyInfo: ImportOldDbMsgNotifyInfo?) { + + } + + override fun onInputStatusPush(inputStatusInfo: InputStatusInfo?) { + + } + + override fun onKickedOffLine(kickedInfo: KickedInfo) { + + } + + override fun onLineDev(devs: ArrayList) { + + } + + override fun onLogLevelChanged(j2: Long) { + + } + + override fun onMsgAbstractUpdate(arrayList: ArrayList?) { + + } + + override fun onMsgBoxChanged(arrayList: ArrayList?) { + + } + + override fun onMsgDelete(contact: Contact?, arrayList: ArrayList?) { + + } + + override fun onMsgEventListUpdate(hashMap: HashMap>?) { + + } + + override fun onMsgInfoListAdd(arrayList: ArrayList?) { + + } + + override fun onMsgInfoListUpdate(arrayList: ArrayList?) { + + } + + override fun onMsgQRCodeStatusChanged(i2: Int) { + + } + + override fun onMsgRecall(chatType: Int, tips: String?, msgId: Long) { + + } + + override fun onMsgSecurityNotify(msgRecord: MsgRecord?) { + + } + + override fun onMsgSettingUpdate(msgSetting: MsgSetting?) { + + } + + override fun onNtFirstViewMsgSyncEnd() { + + } + + override fun onNtMsgSyncEnd() { + + } + + override fun onNtMsgSyncStart() { + + } + + override fun onReadFeedEventUpdate(firstViewDirectMsgNotifyInfo: FirstViewDirectMsgNotifyInfo?) { + + } + + override fun onRecvGroupGuildFlag(i2: Int) { + + } + + override fun onRecvMsg(records: ArrayList) { + + } + + override fun onRecvMsgSvrRspTransInfo( + j2: Long, + contact: Contact?, + i2: Int, + i3: Int, + str: String?, + bArr: ByteArray? + ) { + + } + + override fun onRecvOnlineFileMsg(arrayList: ArrayList?) { + + } + + override fun onRecvS2CMsg(arrayList: ArrayList?) { + + } + + override fun onRecvSysMsg(arrayList: ArrayList?) { + + } + + override fun onRecvUDCFlag(i2: Int) { + + } + + override fun onRichMediaDownloadComplete(fileTransNotifyInfo: FileTransNotifyInfo?) { + + } + + override fun onRichMediaProgerssUpdate(fileTransNotifyInfo: FileTransNotifyInfo?) { + + } + + override fun onRichMediaUploadComplete(fileTransNotifyInfo: FileTransNotifyInfo) { + + } + + override fun onSearchGroupFileInfoUpdate(searchGroupFileResult: SearchGroupFileResult?) { + + } + + override fun onSendMsgError(j2: Long, contact: Contact?, i2: Int, str: String?) { + + } + + override fun onSysMsgNotification(i2: Int, j2: Long, j3: Long, arrayList: ArrayList?) { + + } + + override fun onTempChatInfoUpdate(tempChatInfo: TempChatInfo?) { + + } + + override fun onUnreadCntAfterFirstView(hashMap: HashMap>?) { + + } + + override fun onUnreadCntUpdate(hashMap: HashMap>?) { + + } + + override fun onUserChannelTabStatusChanged(z: Boolean) { + + } + + override fun onUserOnlineStatusChanged(z: Boolean) { + + } + + override fun onUserTabStatusChanged(arrayList: ArrayList?) { + + } + + override fun onlineStatusBigIconDownloadPush(i2: Int, j2: Long, str: String?) { + + } + + override fun onlineStatusSmallIconDownloadPush(i2: Int, j2: Long, str: String?) { + + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/lightapp/ArkAppInfo.kt b/xposed/src/main/java/qq/service/lightapp/ArkAppInfo.kt new file mode 100644 index 0000000..d69ae65 --- /dev/null +++ b/xposed/src/main/java/qq/service/lightapp/ArkAppInfo.kt @@ -0,0 +1,40 @@ +package qq.service.lightapp + +sealed class ArkAppInfo( + val appId: Long, + val version: String, + val packageName: String, + val signature: String, + val miniAppId: Long = 0, + val appName: String = "" +) { + data object QQMusic: ArkAppInfo( + appId = 100497308, + version = "0.0.0", + packageName = "com.tencent.qqmusic", + signature = "cbd27cd7c861227d013a25b2d10f0799" + ) + data object NetEaseMusic: ArkAppInfo( + appId = 100495085, + version = "0.0.0", + packageName = "com.netease.cloudmusic", + signature = "da6b069da1e2982db3e386233f68d76d" + ) + + data object DanMaKu: ArkAppInfo( + appId = 100951776, + version = "0.0.0", + packageName = "tv.danmaku.bili", + signature = "7194d531cbe7960a22007b9f6bdaa38b", + miniAppId = 1109937557, + appName = "哔哩哔哩" + ) + + data object Docs: ArkAppInfo( + appId = 0, + version = "0.0.0", + packageName = "", + signature = "f3da3147654d9a21f3237b88f20dce9c", + miniAppId = 1108338344 + ) +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/lightapp/ArkMsgHelper.kt b/xposed/src/main/java/qq/service/lightapp/ArkMsgHelper.kt new file mode 100644 index 0000000..1d185f1 --- /dev/null +++ b/xposed/src/main/java/qq/service/lightapp/ArkMsgHelper.kt @@ -0,0 +1,48 @@ +package qq.service.lightapp + +import com.tencent.qqnt.kernel.nativeinterface.Contact +import com.tencent.qqnt.kernel.nativeinterface.MsgConstant +import qq.service.QQInterfaces +import qq.service.contact.longPeer +import tencent.im.oidb.cmd0xb77.oidb_cmd0xb77 + +internal object ArkMsgHelper: QQInterfaces() { + suspend fun tryShareMusic( + contact: Contact, + msgId: Long, + arkAppInfo: ArkAppInfo, + title: String, + singer: String, + jumpUrl: String, + previewUrl: String, + musicUrl: String, + ) { + val req = oidb_cmd0xb77.ReqBody() + req.appid.set(arkAppInfo.appId) + req.app_type.set(1) + req.msg_style.set(4) + req.client_info.set(oidb_cmd0xb77.ClientInfo().also { + it.platform.set(1) + it.sdk_version.set(arkAppInfo.version) + it.android_package_name.set(arkAppInfo.packageName) + it.android_signature.set(arkAppInfo.signature) + }) + req.ext_info.set(oidb_cmd0xb77.ExtInfo().also { + it.msg_seq.set(msgId) + }) + req.recv_uin.set(contact.longPeer()) + req.rich_msg_body.set(oidb_cmd0xb77.RichMsgBody().also { + it.title.set(title) + it.summary.set(singer) + it.url.set(jumpUrl) + it.picture_url.set(previewUrl) + it.music_url.set(musicUrl) + }) + when (contact.chatType) { + MsgConstant.KCHATTYPEGROUP -> req.send_type.set(1) + MsgConstant.KCHATTYPEC2C -> req.send_type.set(0) + else -> error("不支持该聊天类型发送音乐分享: chatType: ${contact.chatType}") + } + sendOidb("OidbSvc.0xb77_9", 0xb77, 9, req.toByteArray()) + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/lightapp/LbsHelper.kt b/xposed/src/main/java/qq/service/lightapp/LbsHelper.kt new file mode 100644 index 0000000..2ac7abc --- /dev/null +++ b/xposed/src/main/java/qq/service/lightapp/LbsHelper.kt @@ -0,0 +1,58 @@ +package qq.service.lightapp + +import com.tencent.biz.map.trpcprotocol.LbsSendInfo +import com.tencent.proto.lbsshare.LBSShare +import com.tencent.qqnt.kernel.nativeinterface.Contact +import com.tencent.qqnt.kernel.nativeinterface.MsgConstant +import moe.fuqiuluo.shamrock.helper.IllegalParamsException +import moe.fuqiuluo.shamrock.tools.slice +import qq.service.QQInterfaces +import qq.service.contact.longPeer +import kotlin.math.roundToInt + +internal object LbsHelper: QQInterfaces() { + suspend fun tryShareLocation(contact: Contact, lat: Double, lon: Double): Result { + val req = LbsSendInfo.SendMessageReq() + req.uint64_peer_account.set(contact.longPeer()) + when (contact.chatType) { + MsgConstant.KCHATTYPEGROUP -> req.enum_relation_type.set(1) + MsgConstant.KCHATTYPEC2C -> req.enum_relation_type.set(0) + else -> error("Not supported chat type: $contact") + } + req.str_name.set("位置分享") + req.str_address.set(getAddressWithLonLat(lat, lon).onFailure { + return Result.failure(it) + }.getOrNull()) + req.str_lat.set(lat.toString()) + req.str_lng.set(lon.toString()) + sendBuffer("trpc.qq_lbs.qq_lbs_ark.LocationArk.SsoSendMessage", true, req.toByteArray()) + return Result.success(Unit) + } + + suspend fun getAddressWithLonLat(lat: Double, lon: Double): Result { + if (lat > 90 || lat < 0) { + return Result.failure(IllegalParamsException("纬度大小错误")) + } + if (lon > 180 || lon < 0) { + return Result.failure(IllegalParamsException("经度大小错误")) + } + val latO = (lat * 1000000).roundToInt() + val lngO = (lon * 1000000).roundToInt() + val req = LBSShare.LocationReq() + req.lat.set(latO) + req.lng.set(lngO) + req.coordinate.set(1) + req.keyword.set("") + req.category.set("") + req.page.set(0) + req.count.set(20) + req.requireMyLbs.set(1) + req.imei.set("") + val fromServiceMsg = sendBufferAW("LbsShareSvr.location", true, req.toByteArray()) + ?: return Result.failure(Exception("获取位置失败")) + val resp = LBSShare.LocationResp() + resp.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + val location = resp.mylbs + return Result.success(location.addr.get()) + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/lightapp/MusicHelper.kt b/xposed/src/main/java/qq/service/lightapp/MusicHelper.kt new file mode 100644 index 0000000..349915e --- /dev/null +++ b/xposed/src/main/java/qq/service/lightapp/MusicHelper.kt @@ -0,0 +1,96 @@ +package qq.service.lightapp + +import com.tencent.qqnt.kernel.nativeinterface.Contact +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import kotlinx.serialization.json.Json +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.tools.GlobalClient +import moe.fuqiuluo.shamrock.tools.asInt +import moe.fuqiuluo.shamrock.tools.asJsonArray +import moe.fuqiuluo.shamrock.tools.asJsonArrayOrNull +import moe.fuqiuluo.shamrock.tools.asJsonObject +import moe.fuqiuluo.shamrock.tools.asString +import moe.fuqiuluo.shamrock.tools.asStringOrNull +import moe.fuqiuluo.shamrock.utils.MD5 + +internal object MusicHelper { + suspend fun tryShare163MusicById(contact: Contact, msgId: Long, id: String): Boolean { + try { + val respond = GlobalClient.get("https://music.163.com/api/song/detail/?id=$id&ids=[$id]") + val songInfo = Json.parseToJsonElement(respond.bodyAsText()).asJsonObject["songs"].asJsonArray.first().asJsonObject + val name = songInfo["name"].asString + val title = songInfo["name"].asString + val singerName = songInfo["artists"].asJsonArray.first().asJsonObject["name"].asString + val previewUrl = songInfo["album"].asJsonObject["picUrl"].asString + val playUrl = "https://music.163.com/song/media/outer/url?id=$id.mp3" + val jumpUrl = "https://music.163.com/#/song?id=$id" + ArkMsgHelper.tryShareMusic( + contact, + msgId, + ArkAppInfo.NetEaseMusic, + title.ifBlank { name }, + singerName, + jumpUrl, + previewUrl, + playUrl + ) + return true + } catch (e: Throwable) { + LogCenter.log(e.stackTraceToString(), Level.ERROR) + } + return false + } + + suspend fun tryShareQQMusicById(contact: Contact, msgId: Long, id: String): Boolean { + try { + val respond = GlobalClient.get("https://u.y.qq.com/cgi-bin/musicu.fcg?format=json&inCharset=utf8&outCharset=utf-8¬ice=0&platform=yqq.json&needNewCode=0&data={%22comm%22:{%22ct%22:24,%22cv%22:0},%22songinfo%22:{%22method%22:%22get_song_detail_yqq%22,%22param%22:{%22song_type%22:0,%22song_mid%22:%22%22,%22song_id%22:$id},%22module%22:%22music.pf_song_detail_svr%22}}") + val songInfo = Json.parseToJsonElement(respond.bodyAsText()).asJsonObject["songinfo"].asJsonObject + if (songInfo["code"].asInt != 0) { + LogCenter.log("获取QQ音乐($id)的歌曲信息失败。") + return false + } else { + val data = songInfo["data"].asJsonObject + val trackInfo = data["track_info"].asJsonObject + val mid = trackInfo["mid"].asString + val previewMid = trackInfo["album"].asJsonObject["mid"].asString + val singerMid = trackInfo["singer"].asJsonArrayOrNull?.let { + it[0].asJsonObject["mid"].asStringOrNull + } ?: "" + val name = trackInfo["name"].asString + val title = trackInfo["title"].asString + val singerName = trackInfo["singer"].asJsonArray.first().asJsonObject["name"].asString + val vs = trackInfo["vs"].asJsonArrayOrNull?.let { + it[0].asStringOrNull + } ?: "" + val code = MD5.getMd5Hex("${mid}q;z(&l~sdf2!nK".toByteArray()).substring(0 .. 4).uppercase() + val playUrl = "http://c6.y.qq.com/rsc/fcgi-bin/fcg_pyq_play.fcg?songid=&songmid=$mid&songtype=1&fromtag=50&uin=&code=$code" + val previewUrl = if (vs.isNotEmpty()) { + "http://y.gtimg.cn/music/photo_new/T062R150x150M000$vs}.jpg" + } else if (previewMid.isNotEmpty()) { + "http://y.gtimg.cn/music/photo_new/T002R150x150M000$previewMid.jpg" + } else if (singerMid.isNotEmpty()){ + "http://y.gtimg.cn/music/photo_new/T001R150x150M000$singerMid.jpg" + } else { + "" + } + val jumpUrl = "https://i.y.qq.com/v8/playsong.html?platform=11&appshare=android_qq&appversion=10030010&hosteuin=oKnlNenz7i-s7c**&songmid=${mid}&type=0&appsongtype=1&_wv=1&source=qq&ADTAG=qfshare" + ArkMsgHelper.tryShareMusic( + contact, + msgId, + ArkAppInfo.QQMusic, + title.ifBlank { name }, + singerName, + jumpUrl, + previewUrl, + playUrl + ) + return true + } + } catch (e: Throwable) { + LogCenter.log(e.stackTraceToString(), Level.ERROR) + } + return false + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/lightapp/Region.kt b/xposed/src/main/java/qq/service/lightapp/Region.kt new file mode 100644 index 0000000..0e07776 --- /dev/null +++ b/xposed/src/main/java/qq/service/lightapp/Region.kt @@ -0,0 +1,10 @@ +package qq.service.lightapp + +import kotlinx.serialization.Serializable + +@Serializable +internal data class Region( + val adcode: Int, + val province: String?, + val city: String? +) \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/lightapp/WeatherHelper.kt b/xposed/src/main/java/qq/service/lightapp/WeatherHelper.kt new file mode 100644 index 0000000..161888a --- /dev/null +++ b/xposed/src/main/java/qq/service/lightapp/WeatherHelper.kt @@ -0,0 +1,78 @@ +package qq.service.lightapp + +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.url +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.http.encodeURLQueryComponent +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.tools.GlobalClient +import moe.fuqiuluo.shamrock.tools.GlobalJson +import moe.fuqiuluo.shamrock.tools.asInt +import moe.fuqiuluo.shamrock.tools.asJsonArray +import moe.fuqiuluo.shamrock.tools.asJsonObject +import moe.fuqiuluo.shamrock.tools.asStringOrNull +import qq.service.QQInterfaces +import qq.service.ticket.TicketHelper + +internal object WeatherHelper: QQInterfaces() { + suspend fun fetchWeatherCard(code: Int): Result { + val cookie = TicketHelper.getCookie("mp.qq.com") + val resp = GlobalClient.get("https://weather.mp.qq.com/page/poster?_wv=2&&_wwv=4&adcode=$code") { + header("Cookie", cookie) + } + + if (resp.status != HttpStatusCode.OK) { + LogCenter.log("fetchWeatherCard: error: ${resp.status}, cookie: $cookie", Level.ERROR) + return Result.failure(Exception("search city failed")) + } + + val textJson = resp.bodyAsText() + .replace("\n", "") + .split("window.__INITIAL_STATE__ =")[1] + .split("};")[0].trim() + "}" + + //LogCenter.log(textJson) + + return Result.success(Json.parseToJsonElement(textJson).asJsonObject) + } + + suspend fun searchCity(query: String): Result> { + val pskey = TicketHelper.getPSKey(app.currentAccountUin, "mp.qq.com") ?: "" + val cookie = TicketHelper.getCookie("mp.qq.com") + val gtk = TicketHelper.getCSRF(pskey) + val resp = GlobalClient.get { + url("https://weather.mp.qq.com/trpc/weather/SearchRegions?g_tk=$gtk&key=${query.encodeURLQueryComponent()}&offset=0&count=25") + header("Cookie", cookie) + } + + if (resp.status != HttpStatusCode.OK) { + LogCenter.log("GetWeatherCityCode: error: ${resp.status}, cookie: $cookie, bkn: $gtk", Level.ERROR) + return Result.failure(Exception("search city failed")) + } + + val json = GlobalJson.parseToJsonElement(resp.bodyAsText()).asJsonObject + + + val cnt = json["totalCount"].asInt + if (cnt == 0) { + return Result.success(emptyList()) + } + + val regions = json["regions"].asJsonArray.map { + val region = it.asJsonObject + Region( + region["adcode"].asInt, + region["province"].asStringOrNull, + region["city"].asStringOrNull + ) + } + + return Result.success(regions) + } + +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/msg/ForwardMessageHelper.kt b/xposed/src/main/java/qq/service/msg/ForwardMessageHelper.kt new file mode 100644 index 0000000..eb1c8a4 --- /dev/null +++ b/xposed/src/main/java/qq/service/msg/ForwardMessageHelper.kt @@ -0,0 +1,227 @@ +package qq.service.msg + +import com.tencent.mobileqq.qroute.QRoute +import com.tencent.qqnt.kernel.nativeinterface.MsgConstant +import com.tencent.qqnt.kernel.nativeinterface.MsgRecord +import com.tencent.qqnt.msg.api.IMsgService +import io.grpc.Status +import io.grpc.StatusRuntimeException +import io.kritor.message.Element +import io.kritor.message.ElementType +import io.kritor.message.ForwardElement +import io.kritor.message.ForwardMessageBody +import io.kritor.message.Scene +import io.kritor.message.forwardElement +import io.kritor.message.nodeOrNull +import io.kritor.message.senderOrNull +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.tools.slice +import moe.fuqiuluo.shamrock.utils.DeflateTools +import moe.fuqiuluo.symbols.decodeProtobuf +import protobuf.auto.toByteArray +import protobuf.message.* +import protobuf.message.longmsg.* +import qq.service.QQInterfaces +import qq.service.contact.ContactHelper +import qq.service.msg.MessageHelper.getMultiMsg +import qq.service.ticket.TicketHelper +import java.util.UUID +import kotlin.coroutines.resume +import kotlin.random.Random +import kotlin.time.Duration.Companion.seconds + +internal object ForwardMessageHelper: QQInterfaces() { + suspend fun uploadMultiMsg( + chatType: Int, + peerId: String, + fromId: String = peerId, + messages: List, + ): Result { + var i = -1 + val desc = MutableList(messages.size) { "" } + val forwardMsg = mutableMapOf() + + val msgs = messages.mapNotNull { msg -> + kotlin.runCatching { + val contact = msg.contact.let { + MessageHelper.generateContact(when(it.scene!!) { + Scene.GROUP -> MsgConstant.KCHATTYPEGROUP + Scene.FRIEND -> MsgConstant.KCHATTYPEC2C + Scene.GUILD -> MsgConstant.KCHATTYPEGUILD + Scene.STRANGER_FROM_GROUP -> MsgConstant.KCHATTYPETEMPC2CFROMGROUP + Scene.NEARBY -> MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN + Scene.STRANGER -> MsgConstant.KCHATTYPETEMPC2CFROMUNKNOWN + Scene.UNRECOGNIZED -> throw StatusRuntimeException(Status.INVALID_ARGUMENT.withDescription("Unrecognized scene")) + }, it.peer, it.subPeer) + } + val node = msg.elementsList.find { it.type == ElementType.NODE }?.nodeOrNull + if (node != null) { + val msgId = node.messageId + val record: MsgRecord = withTimeoutOrNull(5000) { + val service = QRoute.api(IMsgService::class.java) + suspendCancellableCoroutine { continuation -> + service.getMsgsByMsgId(contact, arrayListOf(msgId)) { code, _, msgRecords -> + if (code == 0 && msgRecords.isNotEmpty()) { + continuation.resume(msgRecords.first()) + } else { + continuation.resume(null) + } + } + continuation.invokeOnCancellation { + continuation.resume(null) + } + } + } ?: error("合并转发消息节点消息(id = $msgId)获取失败") + PushMsgBody( + msgHead = ResponseHead( + peerUid = record.senderUid, + receiverUid = record.peerUid, + forward = ResponseForward( + friendName = record.sendNickName + ), + responseGrp = if (record.chatType == MsgConstant.KCHATTYPEGROUP) ResponseGrp( + groupCode = record.peerUin.toULong(), + memberCard = record.sendMemberName, + u1 = 2 + ) else null + ), + contentHead = ContentHead( + msgType = when (record.chatType) { + MsgConstant.KCHATTYPEC2C -> 9 + MsgConstant.KCHATTYPEGROUP -> 82 + else -> throw UnsupportedOperationException("Unsupported chatType: $chatType") + }, + msgSubType = if (record.chatType == MsgConstant.KCHATTYPEC2C) 175 else null, + divSeq = if (record.chatType == MsgConstant.KCHATTYPEC2C) 175 else null, + msgViaRandom = record.msgId, + sequence = record.msgSeq, // idk what this is(i++) + msgTime = record.msgTime, + u2 = 1, + u6 = 0, + u7 = 0, + msgSeq = if (record.chatType == MsgConstant.KCHATTYPEC2C) record.msgSeq else null, // seq for dm + forwardHead = ForwardHead( + u1 = 0, + u2 = 0, + u3 = 0, + ub641 = "", + avatar = "" + ) + ), + body = MsgBody( + richText = record.elements.toKritorReqMessages(contact).toRichText(contact).onFailure { + error("消息合成失败: ${it.stackTraceToString()}") + }.onSuccess { + desc[++i] = record.sendMemberName.ifEmpty { record.sendNickName } + ": " + it.first + }.getOrThrow().second + ) + ) + } else { + PushMsgBody( + msgHead = ResponseHead( + peer = msg.senderOrNull?.uin ?: TicketHelper.getUin().toLong(), + peerUid = msg.senderOrNull?.uid ?: TicketHelper.getUid(), + receiverUid = TicketHelper.getUid(), + forward = ResponseForward( + friendName = msg.senderOrNull?.nick ?: TicketHelper.getNickname() + ) + ), + contentHead = ContentHead( + msgType = 9, + msgSubType = 175, + divSeq = 175, + msgViaRandom = Random.nextLong(), + sequence = msg.messageSeq.toLong(), + msgTime = msg.messageTime.toLong(), + u2 = 1, + u6 = 0, + u7 = 0, + msgSeq = msg.messageSeq.toLong(), + forwardHead = ForwardHead( + u1 = 0, + u2 = 0, + u3 = 2, + ub641 = "", + avatar = "" + ) + ), + body = MsgBody( + richText = msg.elementsList.toRichText(contact).onSuccess { + desc[++i] = (msg.senderOrNull?.nick ?: TicketHelper.getNickname()) + ": " + it.first + }.onFailure { + error("消息合成失败: ${it.stackTraceToString()}") + }.getOrThrow().second + ) + ) + } + }.onFailure { + LogCenter.log("消息节点解析失败:${it.stackTraceToString()}", Level.WARN) + }.getOrNull() + }.ifEmpty { + return Result.failure(Exception("消息节点为空")) + } + + val payload = LongMsgPayload( + action = mutableListOf( + LongMsgAction( + command = "MultiMsg", + data = LongMsgContent( + body = msgs + ) + ) + ).apply { + forwardMsg.map { msg -> + addAll(getMultiMsg(msg.value).getOrElse { return Result.failure(Exception("无法获取嵌套转发消息: $it")) } + .map { action -> + if (action.command == "MultiMsg") LongMsgAction( + command = msg.key, + data = action.data + ) else action + }) + } + } + ) + + val req = LongMsgReq( + sendInfo = when (chatType) { + MsgConstant.KCHATTYPEC2C -> SendLongMsgInfo( + type = 1, + uid = LongMsgUid(if(peerId.startsWith("u_")) peerId else ContactHelper.getUidByUinAsync(peerId.toLong()) ), + payload = DeflateTools.gzip(payload.toByteArray()) + ) + MsgConstant.KCHATTYPEGROUP -> SendLongMsgInfo( + type = 3, + uid = LongMsgUid(fromId), + groupUin = fromId.toULong(), + payload = DeflateTools.gzip(payload.toByteArray()) + ) + else -> throw UnsupportedOperationException("Unsupported chatType: $chatType") + }, + setting = LongMsgSettings( + field1 = 4, + field2 = 2, + field3 = 9, + field4 = 0 + ) + ).toByteArray() + + val fromServiceMsg = sendBufferAW("trpc.group.long_msg_interface.MsgService.SsoSendLongMsg", true, req, timeout = 60.seconds) + ?: return Result.failure(Exception("unable to upload multi message, response timeout")) + val rsp = runCatching { + fromServiceMsg.wupBuffer.slice(4).decodeProtobuf() + }.getOrElse { + fromServiceMsg.wupBuffer.decodeProtobuf() + } + val resId = rsp.sendResult?.resId ?: return Result.failure(Exception("unable to upload multi message")) + + return Result.success(forwardElement { + this.id = resId + this.summary = summary + this.uniseq = UUID.randomUUID().toString() + this.description = desc.slice(0..if (i < 3) i else 3).joinToString("\n") + }) + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/msg/MessageData.kt b/xposed/src/main/java/qq/service/msg/MessageData.kt new file mode 100644 index 0000000..1553530 --- /dev/null +++ b/xposed/src/main/java/qq/service/msg/MessageData.kt @@ -0,0 +1,69 @@ +package qq.service.msg + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import protobuf.message.RichText + +@Serializable +internal data class MessageResult( + @SerialName("message_id") val msgId: Int, + @SerialName("time") val time: Long +) + +@Serializable +internal data class UploadForwardMessageResult( + @SerialName("res_id") val resId: String, + @SerialName("filename") val filename: String, + @SerialName("summary") val summary: String, + @SerialName("desc") val desc: String, +) + +@Serializable +internal data class SendForwardMessageResult( + @SerialName("message_id") val msgId: Int, + @SerialName("res_id") val resId: String, + @SerialName("forward_id") val forwardId: String = resId +) + +@Serializable +internal data class MessageDetail( + @SerialName("time") val time: Int, + @SerialName("message_type") val msgType: Int, + @SerialName("message_id") val msgId: Int, + @SerialName("message_id_qq") val qqMsgId: Long, + @SerialName("message_seq") val msgSeq: Long, + @SerialName("real_id") val realId: Long, + @SerialName("sender") val sender: MessageSender, + @SerialName("message") val message: RichText?, + @SerialName("group_id") val groupId: Long = 0, + @SerialName("peer_id") val peerId: Long, + @SerialName("target_id") val targetId: Long = 0, +) + +@Serializable +internal data class GetForwardMsgResult( + @SerialName("messages") val msgs: List +) + +@Serializable +internal data class MessageSender( + @SerialName("user_id") val userId: Long, + @SerialName("nickname") val nickName: String, + @SerialName("sex") val sex: String, + @SerialName("age") val age: Int, + @SerialName("uid") val uid: String, + @SerialName("tiny_id") val tinyId: String, +) + +@Serializable +internal data class EssenceMessage( + @SerialName("sender_id") val senderId: Long, + @SerialName("sender_nick") val senderNick: String, + @SerialName("sender_time") val senderTime: Long, + @SerialName("operator_id") val operatorId: Long, + @SerialName("operator_nick") val operatorNick: String, + @SerialName("operator_time") val operatorTime: Long, + @SerialName("message_seq") val messageSeq: Long, + @SerialName("message_content") val messageContent: JsonElement, +) \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/msg/MessageHelper.kt b/xposed/src/main/java/qq/service/msg/MessageHelper.kt new file mode 100644 index 0000000..a70edb3 --- /dev/null +++ b/xposed/src/main/java/qq/service/msg/MessageHelper.kt @@ -0,0 +1,326 @@ +package qq.service.msg + +import com.tencent.mobileqq.qroute.QRoute +import com.tencent.mobileqq.troop.api.ITroopMemberNameService +import com.tencent.qqnt.kernel.api.IKernelService +import com.tencent.qqnt.kernel.nativeinterface.Contact +import com.tencent.qqnt.kernel.nativeinterface.MsgConstant +import com.tencent.qqnt.kernel.nativeinterface.MsgElement +import com.tencent.qqnt.kernel.nativeinterface.MsgRecord +import com.tencent.qqnt.kernel.nativeinterface.TempChatGameSession +import com.tencent.qqnt.kernel.nativeinterface.TempChatInfo +import com.tencent.qqnt.kernel.nativeinterface.TempChatPrepareInfo +import com.tencent.qqnt.msg.api.IMsgService +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.header +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.jsonObject +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.EmptyJsonArray +import moe.fuqiuluo.shamrock.tools.GlobalClient +import moe.fuqiuluo.shamrock.tools.asInt +import moe.fuqiuluo.shamrock.tools.asJsonArrayOrNull +import moe.fuqiuluo.shamrock.tools.asJsonObject +import moe.fuqiuluo.shamrock.tools.asLong +import moe.fuqiuluo.shamrock.tools.asString +import moe.fuqiuluo.shamrock.tools.asStringOrNull +import moe.fuqiuluo.shamrock.tools.slice +import moe.fuqiuluo.shamrock.tools.toHexString +import moe.fuqiuluo.shamrock.utils.DeflateTools +import moe.fuqiuluo.symbols.decodeProtobuf +import protobuf.auto.toByteArray +import protobuf.message.longmsg.LongMsgAction +import protobuf.message.longmsg.LongMsgPayload +import protobuf.message.longmsg.LongMsgReq +import protobuf.message.longmsg.LongMsgRsp +import protobuf.message.longmsg.LongMsgSettings +import protobuf.message.longmsg.LongMsgUid +import protobuf.message.longmsg.RecvLongMsgInfo +import protobuf.oidb.cmd0x9082.Oidb0x9082 +import qq.service.QQInterfaces +import qq.service.contact.ContactHelper +import qq.service.internals.msgService +import qq.service.ticket.TicketHelper +import tencent.im.oidb.cmd0xeac.oidb_0xeac +import tencent.im.oidb.oidb_sso +import kotlin.coroutines.resume + +typealias MessageId = Long + +internal object MessageHelper: QQInterfaces() { + suspend fun getEssenceMessageList(groupId: Long, page: Int = 0, pageSize: Int = 20): Result>{ + val cookie = TicketHelper.getCookie("qun.qq.com") + val bkn = TicketHelper.getBkn(TicketHelper.getRealSkey(TicketHelper.getUin())) + val url = "https://qun.qq.com/cgi-bin/group_digest/digest_list?bkn=${bkn}&group_code=${groupId}&page_start=${page}&page_limit=${pageSize}" + val response = GlobalClient.get(url) { + header("Cookie", cookie) + } + val body = Json.decodeFromStream(response.body()) + if (body.jsonObject["retcode"].asInt == 0) { + val data = body.jsonObject["data"].asJsonObject + val list = data["msg_list"].asJsonArrayOrNull + ?: // is_end + return Result.success(ArrayList()) + return Result.success(list.map { + val obj = it.jsonObject + val msgSeq = obj["msg_seq"].asLong + EssenceMessage( + senderId = obj["sender_uin"].asString.toLong(), + senderNick = obj["sender_nick"].asString, + senderTime = obj["sender_time"].asLong, + operatorId = obj["add_digest_uin"].asString.toLong(), + operatorNick = obj["add_digest_nick"].asString, + operatorTime = obj["add_digest_time"].asLong, + messageSeq = msgSeq, + messageContent = obj["msg_content"] ?: EmptyJsonArray + ) + }) + } else { + return Result.failure(Exception(body.jsonObject["retmsg"].asStringOrNull)) + } + } + + fun setGroupMessageCommentFace(peer: Long, msgSeq: ULong, faceIndex: String, isSet: Boolean) { + val serviceId = if (isSet) 1 else 2 + sendOidb("OidbSvcTrpcTcp.0x9082_$serviceId", 36994, serviceId, Oidb0x9082( + peer = peer.toULong(), + msgSeq = msgSeq, + faceIndex = faceIndex, + flag = 1u, + u1 = 0u, + u2 = 0u + ).toByteArray()) + } + + suspend fun setEssenceMessage(groupId: Long, seq: Long, rand: Long): String? { + val fromServiceMsg = sendOidbAW("OidbSvc.0xeac_1", 3756, 1, oidb_0xeac.ReqBody().apply { + group_code.set(groupId) + msg_seq.set(seq.toInt()) + msg_random.set(rand.toInt()) + }.toByteArray()) + if (fromServiceMsg?.wupBuffer == null) { + return "no response" + } + val body = oidb_sso.OIDBSSOPkg() + body.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + val result = oidb_0xeac.RspBody().mergeFrom(body.bytes_bodybuffer.get().toByteArray()) + return if (result.wording.has()) { + LogCenter.log("设置群精华失败: ${result.wording.get()}", Level.WARN) + "设置群精华失败: ${result.wording.get()}" + } else { + LogCenter.log("设置群精华 -> $groupId: $seq") + null + } + } + + suspend fun deleteEssenceMessage(groupId: Long, seq: Long, rand: Long): String? { + val fromServiceMsg = sendOidbAW("OidbSvc.0xeac_2", 3756, 2, oidb_0xeac.ReqBody().apply { + group_code.set(groupId) + msg_seq.set(seq.toInt()) + msg_random.set(rand.toInt()) + }.toByteArray()) + if (fromServiceMsg?.wupBuffer == null) { + return "no response" + } + val body = oidb_sso.OIDBSSOPkg() + body.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + val result = oidb_0xeac.RspBody().mergeFrom(body.bytes_bodybuffer.get().toByteArray()) + return if (result.wording.has()) { + LogCenter.log("移除群精华失败: ${result.wording.get()}", Level.WARN) + "移除群精华失败: ${result.wording.get()}" + } else { + LogCenter.log("移除群精华 -> $groupId: $seq") + null + } + } + + private suspend fun prepareTempChatFromGroup( + groupId: String, + peerId: String + ): Result { + LogCenter.log("主动临时消息,创建临时会话。", Level.INFO) + val msgService = app.getRuntimeService(IKernelService::class.java, "all").msgService + ?: return Result.failure(Exception("获取消息服务失败")) + msgService.prepareTempChat( + TempChatPrepareInfo( + MsgConstant.KCHATTYPETEMPC2CFROMGROUP, + ContactHelper.getUidByUinAsync(peerId = peerId.toLong()), + app.getRuntimeService(ITroopMemberNameService::class.java, "all") + .getTroopMemberNameRemarkFirst(groupId, peerId), + groupId, + EMPTY_BYTE_ARRAY, + app.currentUid, + "", + TempChatGameSession() + ) + ) { code, reason -> + if (code != 0) { + LogCenter.log("临时会话创建失败: $code, $reason", Level.ERROR) + } + } + return Result.success(Unit) + } + + suspend fun sendMessage(contact: Contact, msgs: ArrayList, retry: Int, uniseq: Long): Result { + if (contact.chatType == MsgConstant.KCHATTYPETEMPC2CFROMGROUP) { + prepareTempChatFromGroup(contact.guildId, contact.peerUid).getOrThrow() + } + return withTimeoutOrNull(5000) { + suspendCancellableCoroutine { + QRoute.api(IMsgService::class.java).sendMsg(contact, uniseq, msgs) { code: Int, msg: String -> + if (code == 0) { + it.resume(uniseq) + } else { + LogCenter.log("消息发送失败: $code:$msg", Level.WARN) + it.resume(null) + } + } + } + }?.let { Result.success(it) } ?: resendMsg(contact, uniseq, retry) + } + + private suspend fun resendMsg(contact: Contact, msgId: MessageId, retry: Int): Result { + if (retry > 0) { + return withTimeoutOrNull(5000) { + suspendCancellableCoroutine { + QRoute.api(IMsgService::class.java).resendMsg(contact, msgId) { code, msg -> + if (code == 0) { + it.resume(msgId) + } else { + LogCenter.log("消息重发失败: $code:$msg", Level.WARN) + it.resume(null) + } + } + } + }?.let { Result.success(it) } ?: resendMsg(contact, msgId, retry - 1) + } else { + return Result.failure(Exception("消息发送失败:重试已达上限")) + } + } + + suspend fun getTempChatInfo(chatType: Int, uid: String): Result { + val msgService = app.getRuntimeService(IKernelService::class.java, "all").msgService + ?: return Result.failure(Exception("获取消息服务失败")) + val info: TempChatInfo = withTimeoutOrNull(5000) { + suspendCancellableCoroutine { + msgService.getTempChatInfo(chatType, uid) { code, msg, tempChatInfo -> + if (code == 0) { + it.resume(tempChatInfo) + } else { + LogCenter.log("获取临时会话信息失败: $code:$msg", Level.ERROR) + it.resume(null) + } + } + } + } ?: return Result.failure(Exception("获取临时会话信息失败")) + return Result.success(info) + } + + suspend fun generateContact(record: MsgRecord): Contact { + val peerId = when (record.chatType) { + MsgConstant.KCHATTYPEC2C, MsgConstant.KCHATTYPETEMPC2CFROMGROUP -> record.senderUid + MsgConstant.KCHATTYPEGUILD -> record.channelId + else -> record.peerUin.toString() + } + return Contact(record.chatType, peerId, if (record.chatType == MsgConstant.KCHATTYPEGUILD) { + record.guildId + } else if(record.chatType == MsgConstant.KCHATTYPETEMPC2CFROMGROUP) { + val tempInfo = getTempChatInfo(record.chatType, peerId).getOrThrow() + tempInfo.groupCode + } else { + null + }) + } + + suspend fun generateContact(chatType: Int, id: String, subId: String = ""): Contact { + val peerId = when (chatType) { + MsgConstant.KCHATTYPEC2C, MsgConstant.KCHATTYPETEMPC2CFROMGROUP -> { + if (id.startsWith("u_")) id + else ContactHelper.getUidByUinAsync(id.toLong()) + } + else -> id + } + return if (chatType == MsgConstant.KCHATTYPEGUILD) { + Contact(chatType, subId, peerId) + } else { + Contact(chatType, peerId, subId) + } + } + + suspend fun getMultiMsg(resId: String): Result> { + val req = LongMsgReq( + recvInfo = RecvLongMsgInfo( + uid = LongMsgUid(app.currentUid), + resId = resId, + u1 = 3 + ), + setting = LongMsgSettings( + field1 = 2, + field2 = 2, + field3 = 9, + field4 = 0 + ) + ) + val fromServiceMsg = sendBufferAW( + "trpc.group.long_msg_interface.MsgService.SsoRecvLongMsg", + true, + req.toByteArray() + ) ?: return Result.failure(Exception("unable to get multi message")) + val rsp = fromServiceMsg.wupBuffer.slice(4).decodeProtobuf() + val zippedPayload = DeflateTools.ungzip( + rsp.recvResult?.payload ?: return Result.failure(Exception("payload is empty")) + ) + LogCenter.log(zippedPayload.toHexString(), Level.DEBUG) + return Result.success( + zippedPayload.decodeProtobuf().action + ?: return Result.failure(Exception("action is empty")) + ) + } + + suspend fun getForwardMsg(resId: String): Result> { + val result = getMultiMsg(resId).getOrElse { return Result.failure(it) } + result.forEach { + if (it.command == "MultiMsg") { + return Result.success(it.data?.body?.map { msg -> + val chatType = if (msg.contentHead!!.msgType == 82) MsgConstant.KCHATTYPEGROUP else MsgConstant.KCHATTYPEC2C + MessageDetail( + time = msg.contentHead?.msgTime?.toInt() ?: 0, + msgType = chatType, + msgId = 0, // msgViaRandom为空 tx不给 + qqMsgId = 0, + msgSeq = msg.contentHead!!.msgSeq ?: 0, + realId = msg.contentHead!!.msgSeq ?: 0, + sender = MessageSender( + msg.msgHead?.peer ?: 0, + msg.msgHead?.responseGrp?.memberCard ?: msg.msgHead?.forward?.friendName ?: "", + "unknown", + 0, + msg.msgHead?.peerUid ?: "", + msg.msgHead?.peerUid ?: "" + ), + message = msg.body?.richText, + peerId = msg.msgHead?.peer ?: 0, + groupId = if (chatType == MsgConstant.KCHATTYPEGROUP) msg.msgHead?.responseGrp?.groupCode?.toLong() + ?: 0 else 0, + targetId = if (chatType != MsgConstant.KCHATTYPEGROUP) msg.msgHead?.peer ?: 0 else 0 + ) + } ?: return Result.failure(Exception("Msg is empty"))) + } + } + return Result.failure(Exception("Can't find msg")) + } + + + fun generateMsgId(chatType: Int): Long { + return createMessageUniseq(chatType, System.currentTimeMillis()) + } + + external fun createMessageUniseq(chatType: Int, time: Long): Long +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/msg/MsgConvertor.kt b/xposed/src/main/java/qq/service/msg/MsgConvertor.kt new file mode 100644 index 0000000..aa2e277 --- /dev/null +++ b/xposed/src/main/java/qq/service/msg/MsgConvertor.kt @@ -0,0 +1,426 @@ +package qq.service.msg + +import com.tencent.mobileqq.qroute.QRoute +import com.tencent.qqnt.kernel.nativeinterface.MsgConstant +import com.tencent.qqnt.kernel.nativeinterface.MsgElement +import com.tencent.qqnt.kernel.nativeinterface.MsgRecord +import com.tencent.qqnt.msg.api.IMsgService +import io.kritor.event.Element +import io.kritor.event.ImageType +import io.kritor.event.Scene +import io.kritor.event.atElement +import io.kritor.event.basketballElement +import io.kritor.event.buttonAction +import io.kritor.event.buttonActionPermission +import io.kritor.event.buttonRender +import io.kritor.event.contactElement +import io.kritor.event.diceElement +import io.kritor.event.faceElement +import io.kritor.event.forwardElement +import io.kritor.event.imageElement +import io.kritor.event.jsonElement +import io.kritor.event.locationElement +import io.kritor.event.pokeElement +import io.kritor.event.replyElement +import io.kritor.event.rpsElement +import io.kritor.event.textElement +import io.kritor.event.videoElement +import io.kritor.event.voiceElement +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import moe.fuqiuluo.shamrock.helper.ActionMsgException +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.helper.db.ImageDB +import moe.fuqiuluo.shamrock.helper.db.ImageMapping +import moe.fuqiuluo.shamrock.tools.asJsonArray +import moe.fuqiuluo.shamrock.tools.asJsonObject +import moe.fuqiuluo.shamrock.tools.asString +import moe.fuqiuluo.shamrock.tools.hex2ByteArray +import moe.fuqiuluo.shamrock.tools.ifNullOrEmpty +import moe.fuqiuluo.shamrock.tools.toHexString +import moe.fuqiuluo.shamrock.utils.PlatformUtils +import moe.fuqiuluo.shamrock.utils.PlatformUtils.QQ_9_0_8_VER +import qq.service.bdh.RichProtoSvc +import qq.service.contact.ContactHelper +import kotlin.coroutines.resume + +/** + * 将NT消息(com.tencent.qqnt.*)转换为事件消息(io.kritor.event.*)推送 + */ + +typealias NtMessages = ArrayList +typealias Convertor = suspend (MsgRecord, MsgElement) -> Result + +private object MsgConvertor { + private val convertorMap = hashMapOf( + MsgConstant.KELEMTYPETEXT to ::convertText, + MsgConstant.KELEMTYPEFACE to ::convertFace, + MsgConstant.KELEMTYPEPIC to ::convertImage, + MsgConstant.KELEMTYPEPTT to ::convertVoice, + MsgConstant.KELEMTYPEVIDEO to ::convertVideo, + MsgConstant.KELEMTYPEMARKETFACE to ::convertMarketFace, + MsgConstant.KELEMTYPEARKSTRUCT to ::convertStructJson, + MsgConstant.KELEMTYPEREPLY to ::convertReply, + //MsgConstant.KELEMTYPEGRAYTIP to ::convertGrayTips, + MsgConstant.KELEMTYPEFILE to ::convertFile, + MsgConstant.KELEMTYPEMARKDOWN to ::convertMarkdown, + //MsgConstant.KELEMTYPEMULTIFORWARD to MsgElementConverter::convertXmlMultiMsgElem, + //MsgConstant.KELEMTYPESTRUCTLONGMSG to MsgElementConverter::convertXmlLongMsgElem, + MsgConstant.KELEMTYPEFACEBUBBLE to ::convertBubbleFace, + MsgConstant.KELEMTYPEINLINEKEYBOARD to ::convertInlineKeyboard + ) + + suspend fun convertText(record: MsgRecord, element: MsgElement): Result { + val text = element.textElement + val elem = Element.newBuilder() + if (text.atType != MsgConstant.ATTYPEUNKNOWN) { + elem.setAt(atElement { + this.uid = text.atNtUid + this.uin = ContactHelper.getUinByUidAsync(text.atNtUid).toLong() + }) + } else { + elem.setText(textElement { + this.text = text.content + }) + } + return Result.success(elem.build()) + } + + suspend fun convertFace(record: MsgRecord, element: MsgElement): Result { + val face = element.faceElement + val elem = Element.newBuilder() + if (face.faceType == 5) { + elem.setPoke(pokeElement { + this.id = face.vaspokeId + this.type = face.pokeType + this.strength = face.pokeStrength + }) + } else { + when(face.faceIndex) { + 114 -> elem.setBasketball(basketballElement { + this.id = face.resultId.ifNullOrEmpty { "0" }?.toInt() ?: 0 + }) + 358 -> elem.setDice(diceElement { + this.id = face.resultId.ifNullOrEmpty { "0" }?.toInt() ?: 0 + }) + 359 -> elem.setRps(rpsElement { + this.id = face.resultId.ifNullOrEmpty { "0" }?.toInt() ?: 0 + }) + 394 -> elem.setFace(faceElement { + this.id = face.faceIndex + this.isBig = face.faceType == 3 + this.result = face.resultId.ifNullOrEmpty { "1" }?.toInt() ?: 1 + }) + else -> elem.setFace(faceElement { + this.id = face.faceIndex + this.isBig = face.faceType == 3 + }) + } + } + return Result.success(elem.build()) + } + + suspend fun convertImage(record: MsgRecord, element: MsgElement): Result { + val image = element.picElement + val md5 = (image.md5HexStr ?: image.fileName + .replace("{", "") + .replace("}", "") + .replace("-", "").split(".")[0]) + .uppercase() + + var storeId = 0 + if (PlatformUtils.getQQVersionCode() > QQ_9_0_8_VER) { + storeId = image.storeID + } + + ImageDB.getInstance().imageMappingDao().insert( + ImageMapping( + fileName = md5, + md5 = md5, + chatType = record.chatType, + size = image.fileSize, + sha = "", + fileId = image.fileUuid, + storeId = storeId, + ) + ) + + val originalUrl = image.originImageUrl ?: "" + LogCenter.log({ "receive image: $image" }, Level.DEBUG) + + val elem = Element.newBuilder() + elem.setImage(imageElement { + this.file = md5 + this.url = when (record.chatType) { + MsgConstant.KCHATTYPEDISC, MsgConstant.KCHATTYPEGROUP -> RichProtoSvc.getGroupPicDownUrl( + originalUrl = originalUrl, + md5 = md5, + fileId = image.fileUuid, + width = image.picWidth.toUInt(), + height = image.picHeight.toUInt(), + sha = "", + fileSize = image.fileSize.toULong(), + peer = record.peerUin.toString() + ) + + MsgConstant.KCHATTYPEC2C -> RichProtoSvc.getC2CPicDownUrl( + originalUrl = originalUrl, + md5 = md5, + fileId = image.fileUuid, + width = image.picWidth.toUInt(), + height = image.picHeight.toUInt(), + sha = "", + fileSize = image.fileSize.toULong(), + peer = record.senderUin.toString(), + storeId = storeId + ) + + MsgConstant.KCHATTYPEGUILD -> RichProtoSvc.getGuildPicDownUrl( + originalUrl = originalUrl, + md5 = md5, + fileId = image.fileUuid, + width = image.picWidth.toUInt(), + height = image.picHeight.toUInt(), + sha = "", + fileSize = image.fileSize.toULong(), + peer = record.channelId.ifNullOrEmpty { record.peerUin.toString() } ?: "0", + subPeer = record.guildId ?: "0" + ) + + else -> throw UnsupportedOperationException("Not supported chat type: ${record.chatType}") + } + this.type = if (image.isFlashPic == true) ImageType.FLASH else if (image.original) ImageType.ORIGIN else ImageType.COMMON + this.subType = image.picSubType + }) + + return Result.success(elem.build()) + } + + suspend fun convertVoice(record: MsgRecord, element: MsgElement): Result { + val ptt = element.pttElement + val elem = Element.newBuilder() + + val md5 = if (ptt.fileName.startsWith("silk")) + ptt.fileName.substring(5) + else ptt.md5HexStr + + elem.setVoice(voiceElement { + this.url = when (record.chatType) { + MsgConstant.KCHATTYPEC2C -> RichProtoSvc.getC2CPttDownUrl("0", ptt.fileUuid) + MsgConstant.KCHATTYPEGROUP, MsgConstant.KCHATTYPEGUILD -> RichProtoSvc.getGroupPttDownUrl("0", md5.hex2ByteArray(), ptt.fileUuid) + + else -> throw UnsupportedOperationException("Not supported chat type: ${record.chatType}") + } + this.file = md5 + this.magic = ptt.voiceChangeType != MsgConstant.KPTTVOICECHANGETYPENONE + }) + + return Result.success(elem.build()) + } + + suspend fun convertVideo(record: MsgRecord, element: MsgElement): Result { + val video = element.videoElement + val elem = Element.newBuilder() + val md5 = if (video.fileName.contains("/")) { + video.videoMd5.takeIf { + !it.isNullOrEmpty() + }?.hex2ByteArray() ?: video.fileName.split("/").let { + it[it.size - 2].hex2ByteArray() + } + } else video.fileName.split(".")[0].hex2ByteArray() + elem.setVideo(videoElement { + this.file = md5.toHexString() + this.url = when (record.chatType) { + MsgConstant.KCHATTYPEGROUP -> RichProtoSvc.getGroupVideoDownUrl("0", md5, video.fileUuid) + MsgConstant.KCHATTYPEC2C -> RichProtoSvc.getC2CVideoDownUrl("0", md5, video.fileUuid) + MsgConstant.KCHATTYPEGUILD -> RichProtoSvc.getGroupVideoDownUrl("0", md5, video.fileUuid) + else -> throw UnsupportedOperationException("Not supported chat type: ${record.chatType}") + } + }) + return Result.success(elem.build()) + } + + suspend fun convertMarketFace(record: MsgRecord, element: MsgElement): Result { + val marketFace = element.marketFaceElement + val elem = Element.newBuilder() + elem.setMarketFace(io.kritor.event.marketFaceElement { + this.id = marketFace.emojiId.lowercase() + }) + return Result.success(elem.build()) + } + + suspend fun convertStructJson(record: MsgRecord, element: MsgElement): Result { + val data = element.arkElement.bytesData.asJsonObject + val elem = Element.newBuilder() + when (data["app"].asString) { + "com.tencent.multimsg" -> { + val info = data["meta"].asJsonObject["detail"].asJsonObject + elem.setForward(forwardElement { + this.id = info["resid"].asString + this.uniseq = info["uniseq"].asString + this.summary = info["summary"].asString + this.description = info["news"].asJsonArray.joinToString("\n") { + it.asJsonObject["text"].asString + } + }) + } + + "com.tencent.troopsharecard" -> { + val info = data["meta"].asJsonObject["contact"].asJsonObject + elem.setContact(contactElement { + this.scene = Scene.GROUP + this.peer = info["jumpUrl"].asString.split("group_code=")[1] + }) + } + + "com.tencent.contact.lua" -> { + val info = data["meta"].asJsonObject["contact"].asJsonObject + elem.setContact(contactElement { + this.scene = Scene.FRIEND + this.peer = info["jumpUrl"].asString.split("uin=")[1] + }) + } + + "com.tencent.map" -> { + val info = data["meta"].asJsonObject["Location.Search"].asJsonObject + elem.setLocation(locationElement { + this.lat = info["lat"].asString.toFloat() + this.lon = info["lng"].asString.toFloat() + this.address = info["address"].asString + this.title = info["name"].asString + }) + } + + else -> elem.setJson(jsonElement { + this.json = data.toString() + }) + } + return Result.success(elem.build()) + } + + suspend fun convertReply(record: MsgRecord, element: MsgElement): Result { + val reply = element.replyElement + val elem = Element.newBuilder() + elem.setReply(replyElement { + val msgSeq = reply.replayMsgSeq + val contact = MessageHelper.generateContact(record) + val sourceRecords = withTimeoutOrNull(3000) { + suspendCancellableCoroutine { + QRoute.api(IMsgService::class.java).getMsgsBySeqAndCount(contact, msgSeq, 1, true) { _, _, records -> + it.resume(records) + } + } + } + if (sourceRecords.isNullOrEmpty()) { + LogCenter.log("无法查询到回复的消息ID: seq = $msgSeq", Level.WARN) + this.messageId = reply.replayMsgId + } else { + this.messageId = sourceRecords.first().msgId + } + }) + return Result.success(elem.build()) + } + + suspend fun convertFile(record: MsgRecord, element: MsgElement): Result { + val fileMsg = element.fileElement + val fileName = fileMsg.fileName + val fileSize = fileMsg.fileSize + val expireTime = fileMsg.expireTime ?: 0 + val fileId = fileMsg.fileUuid + val bizId = fileMsg.fileBizId ?: 0 + val fileSubId = fileMsg.fileSubId ?: "" + val url = when (record.chatType) { + MsgConstant.KCHATTYPEC2C -> RichProtoSvc.getC2CFileDownUrl(fileId, fileSubId) + MsgConstant.KCHATTYPEGUILD -> RichProtoSvc.getGuildFileDownUrl(record.guildId, record.channelId, fileId, bizId) + else -> RichProtoSvc.getGroupFileDownUrl(record.peerUin, fileId, bizId) + } + val elem = Element.newBuilder() + elem.setFile(io.kritor.event.fileElement { + this.name = fileName + this.size = fileSize + this.url = url + this.expireTime = expireTime + this.id = fileId + this.subId = fileSubId + this.biz = bizId + }) + return Result.success(elem.build()) + } + + suspend fun convertMarkdown(record: MsgRecord, element: MsgElement): Result { + val markdown = element.markdownElement + val elem = Element.newBuilder() + elem.setMarkdown(io.kritor.event.markdownElement { + this.markdown = markdown.content + }) + return Result.success(elem.build()) + } + + suspend fun convertBubbleFace(record: MsgRecord, element: MsgElement): Result { + val bubbleFace = element.faceBubbleElement + val elem = Element.newBuilder() + elem.setBubbleFace(io.kritor.event.bubbleFaceElement { + this.id = bubbleFace.yellowFaceInfo.index + this.count = bubbleFace.faceCount ?: 1 + }) + return Result.success(elem.build()) + } + + suspend fun convertInlineKeyboard(record: MsgRecord, element: MsgElement): Result { + val inlineKeyboard = element.inlineKeyboardElement + val elem = Element.newBuilder() + elem.setButton(io.kritor.event.buttonElement { + inlineKeyboard.rows.forEach { row -> + this.rows.add(io.kritor.event.row { + row.buttons.forEach buttonsLoop@ { button -> + if (button == null) return@buttonsLoop + this.buttons.add(io.kritor.event.button { + this.id = button.id + this.action = buttonAction { + this.type = button.type + this.permission = buttonActionPermission { + this.type = button.permissionType + button.specifyRoleIds?.let { + this.roleIds.addAll(it) + } + button.specifyTinyids?.let { + this.userIds.addAll(it) + } + } + this.unsupportedTips = button.unsupportTips ?: "" + this.data = button.data ?: "" + this.reply = button.isReply + this.enter = button.enter + } + this.renderData = buttonRender { + this.label = button.label ?: "" + this.visitedLabel = button.visitedLabel ?: "" + this.style = button.style + } + }) + } + }) + } + }) + return Result.success(elem.build()) + } + + operator fun get(case: Int): Convertor? { + return convertorMap[case] + } +} + +suspend fun NtMessages.toKritorEventMessages(record: MsgRecord): ArrayList { + val result = arrayListOf() + forEach { + MsgConvertor[it.elementType]?.invoke(record, it)?.onSuccess { + result.add(it) + }?.onFailure { + if (it !is ActionMsgException) { + LogCenter.log("消息转换异常: " + it.stackTraceToString(), Level.WARN) + } + } + } + return result +} diff --git a/xposed/src/main/java/qq/service/msg/MultiConvertor.kt b/xposed/src/main/java/qq/service/msg/MultiConvertor.kt new file mode 100644 index 0000000..44b9bbd --- /dev/null +++ b/xposed/src/main/java/qq/service/msg/MultiConvertor.kt @@ -0,0 +1,275 @@ +@file:OptIn(ExperimentalUnsignedTypes::class) +package qq.service.msg + +import com.tencent.qqnt.kernel.nativeinterface.Contact +import com.tencent.qqnt.kernel.nativeinterface.MsgConstant +import io.kritor.message.Element +import io.kritor.message.ElementType +import io.kritor.message.ImageType +import io.kritor.message.Scene +import io.kritor.message.atElement +import io.kritor.message.buttonActionPermission +import io.kritor.message.buttonElement +import io.kritor.message.contactElement +import io.kritor.message.faceElement +import io.kritor.message.forwardElement +import io.kritor.message.imageElement +import io.kritor.message.jsonElement +import io.kritor.message.locationElement +import io.kritor.message.markdownElement +import io.kritor.message.replyElement +import io.kritor.message.textElement +import kotlinx.io.core.ByteReadPacket +import kotlinx.io.core.discardExact +import kotlinx.io.core.readUInt +import moe.fuqiuluo.shamrock.tools.asJsonArray +import moe.fuqiuluo.shamrock.tools.asJsonObject +import moe.fuqiuluo.shamrock.tools.asString +import moe.fuqiuluo.shamrock.tools.slice +import moe.fuqiuluo.shamrock.tools.toHexString +import moe.fuqiuluo.shamrock.utils.DeflateTools +import moe.fuqiuluo.symbols.decodeProtobuf +import protobuf.message.Elem +import protobuf.message.element.commelem.ButtonExtra +import protobuf.message.element.commelem.MarkdownExtra +import protobuf.message.element.commelem.QFaceExtra +import qq.service.bdh.RichProtoSvc + +/** + * 将合并转发PB(protobuf.message.*)转请求消息(io.kritor.message.*)发送 + */ + +suspend fun List.toKritorResponseMessages(contact: Contact): ArrayList { + val kritorMessages = ArrayList() + forEach { element -> + if (element.text != null) { + val text = element.text!! + if (text.attr6Buf != null) { + val at = ByteReadPacket(text.attr6Buf!!) + at.discardExact(7) + val uin = at.readUInt() + kritorMessages.add(io.kritor.message.element { + this.type = ElementType.AT + this.at = atElement { + this.uin = uin.toLong() + } + }) + } else { + kritorMessages.add(io.kritor.message.element { + this.type = ElementType.TEXT + this.text = textElement { + this.text = text.str ?: "" + } + }) + } + } else if (element.face != null) { + kritorMessages.add(io.kritor.message.element { + this.type = ElementType.FACE + this.face = faceElement { + this.id = element.face!!.index ?: 0 + } + }) + } else if (element.customFace != null) { + val customFace = element.customFace!! + val md5 = customFace.md5.toHexString() + val origUrl = customFace.origUrl!! + kritorMessages.add(io.kritor.message.element { + this.type = ElementType.IMAGE + this.image = imageElement { + this.fileName = md5 + this.type = if (customFace.origin == true) ImageType.ORIGIN else ImageType.COMMON + this.url = when (contact.chatType) { + MsgConstant.KCHATTYPEDISC, MsgConstant.KCHATTYPEGROUP -> RichProtoSvc.getGroupPicDownUrl(origUrl, md5) + MsgConstant.KCHATTYPEC2C -> RichProtoSvc.getC2CPicDownUrl(origUrl, md5) + MsgConstant.KCHATTYPEGUILD -> RichProtoSvc.getGuildPicDownUrl(origUrl, md5) + else -> throw UnsupportedOperationException("Not supported chat type: $contact") + } + } + }) + } else if (element.notOnlineImage != null) { + require(element.notOnlineImage != null) + val md5 = element.notOnlineImage!!.picMd5.toHexString() + val origUrl = element.notOnlineImage!!.origUrl!! + kritorMessages.add(io.kritor.message.element { + this.type = ElementType.IMAGE + this.image = imageElement { + this.fileName = md5 + this.type = if (element.notOnlineImage?.original == true) ImageType.ORIGIN else ImageType.COMMON + this.url = when (contact.chatType) { + MsgConstant.KCHATTYPEDISC, MsgConstant.KCHATTYPEGROUP -> RichProtoSvc.getGroupPicDownUrl(origUrl, md5) + MsgConstant.KCHATTYPEC2C -> RichProtoSvc.getC2CPicDownUrl(origUrl, md5) + MsgConstant.KCHATTYPEGUILD -> RichProtoSvc.getGuildPicDownUrl(origUrl, md5) + else -> throw UnsupportedOperationException("Not supported chat type: $contact") + } + } + }) + } else if (element.generalFlags != null) { + val generalFlags = element.generalFlags!! + if (generalFlags.longTextFlag == 1u) { + kritorMessages.add(io.kritor.message.element { + this.type = ElementType.FORWARD + this.forward = forwardElement { + this.id = generalFlags.longTextResid ?: "" + } + }) + } + } else if (element.srcMsg != null) { + val srcMsg = element.srcMsg!! + val msgId = srcMsg.pbReserve?.msgRand?.toLong() ?: 0 + kritorMessages.add(io.kritor.message.element { + this.type = ElementType.REPLY + this.reply = replyElement { + this.messageId = msgId + } + }) + } else if (element.lightApp != null) { + val data = element.lightApp!!.data!! + val jsonStr = (if (data[0].toInt() == 1) DeflateTools.uncompress(data.slice(1)) else data.slice(1)).decodeToString() + val json = jsonStr.asJsonObject + when (json["app"].asString) { + "com.tencent.multimsg" -> { + val info = json["meta"].asJsonObject["detail"].asJsonObject + kritorMessages.add(io.kritor.message.element { + this.type = ElementType.FORWARD + this.forward = forwardElement { + this.id = info["resid"].asString + this.uniseq = info["uniseq"].asString + this.summary = info["summary"].asString + this.description = info["news"].asJsonArray.joinToString("\n") { + it.asJsonObject["text"].asString + } + } + }) + } + + "com.tencent.troopsharecard" -> { + val info = json["meta"].asJsonObject["contact"].asJsonObject + kritorMessages.add(io.kritor.message.element { + this.type = ElementType.CONTACT + this.contact = contactElement { + this.scene = Scene.GROUP + this.peer = info["jumpUrl"].asString.split("group_code=")[1] + } + }) + + } + + "com.tencent.contact.lua" -> { + val info = json["meta"].asJsonObject["contact"].asJsonObject + kritorMessages.add(io.kritor.message.element { + this.type = ElementType.CONTACT + this.contact = contactElement { + this.scene = Scene.FRIEND + this.peer = info["jumpUrl"].asString.split("uin=")[1] + } + }) + } + + "com.tencent.map" -> { + val info = json["meta"].asJsonObject["Location.Search"].asJsonObject + kritorMessages.add(io.kritor.message.element { + this.type = ElementType.LOCATION + this.location = locationElement { + this.lat = info["lat"].asString.toFloat() + this.lon = info["lng"].asString.toFloat() + this.address = info["address"].asString + this.title = info["name"].asString + } + }) + } + else -> { + kritorMessages.add(io.kritor.message.element { + this.type = ElementType.JSON + this.json = jsonElement { + this.json = jsonStr + } + }) + } + } + } else if (element.commonElem != null) { + val commonElem = element.commonElem!! + when (commonElem.serviceType) { + 37 -> { + val qFaceExtra = commonElem.elem!!.decodeProtobuf() + when (qFaceExtra.faceId) { + 358 -> kritorMessages.add(io.kritor.message.element { + this.type = ElementType.DICE + this.dice = io.kritor.message.diceElement { + this.id = qFaceExtra.result!!.toInt() + } + }) + + 359 -> kritorMessages.add(io.kritor.message.element { + this.type = ElementType.RPS + this.rps = io.kritor.message.rpsElement { + this.id = qFaceExtra.result!!.toInt() + } + }) + + else -> kritorMessages.add(io.kritor.message.element { + this.type = ElementType.FACE + this.face = faceElement { + this.id = qFaceExtra.faceId ?: 0 + this.isBig = false + this.result = qFaceExtra.result?.toInt() ?: 0 + } + }) + } + } + + 45 -> { + val markdownExtra = commonElem.elem!!.decodeProtobuf() + kritorMessages.add(io.kritor.message.element { + this.type = ElementType.MARKDOWN + this.markdown = markdownElement { + this.markdown = markdownExtra.content!! + } + }) + } + + 46 -> { + val buttonExtra = commonElem.elem!!.decodeProtobuf() + kritorMessages.add(io.kritor.message.element { + this.type = ElementType.BUTTON + this.button = buttonElement { + buttonExtra.field1!!.rows?.forEach { row -> + this.rows.add(io.kritor.message.row { + row.buttons?.forEach { button -> + this.buttons.add(io.kritor.message.button { + val renderData = button.renderData + val action = button.action + val permission = action?.permission + this.id = button.id ?: "" + this.renderData = io.kritor.message.buttonRender { + this.label = renderData?.label ?: "" + this.visitedLabel = renderData?.visitedLabel ?: "" + this.style = renderData?.style ?: 0 + } + this.action = io.kritor.message.buttonAction { + this.type = action?.type ?: 0 + this.permission = buttonActionPermission { + this.type = permission?.type ?: 0 + this.roleIds.addAll( + permission?.specifyRoleIds ?: emptyList() + ) + this.userIds.addAll( + permission?.specifyUserIds ?: emptyList() + ) + } + this.unsupportedTips = action?.unsupportTips ?: "" + this.data = action?.data ?: "" + this.reply = action?.reply ?: false + this.enter = action?.enter ?: false + } + }) + } + }) + } + } + }) + } + } + } + } + return kritorMessages +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/msg/NtMsgConvertor.kt b/xposed/src/main/java/qq/service/msg/NtMsgConvertor.kt new file mode 100644 index 0000000..a943efe --- /dev/null +++ b/xposed/src/main/java/qq/service/msg/NtMsgConvertor.kt @@ -0,0 +1,894 @@ +package qq.service.msg + +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.exifinterface.media.ExifInterface +import com.tencent.mobileqq.emoticon.QQSysFaceUtil +import com.tencent.mobileqq.pb.ByteStringMicro +import com.tencent.mobileqq.qroute.QRoute +import com.tencent.qphone.base.remote.ToServiceMsg +import com.tencent.qqnt.aio.adapter.api.IAIOPttApi +import com.tencent.qqnt.kernel.nativeinterface.* +import com.tencent.qqnt.msg.api.IMsgService +import io.kritor.message.AtElement +import io.kritor.message.Button +import io.kritor.message.Element +import io.kritor.message.ElementType +import io.kritor.message.ElementType.* +import io.kritor.message.ImageElement +import io.kritor.message.ImageType +import io.kritor.message.MusicPlatform +import io.kritor.message.Scene +import io.kritor.message.VoiceElement +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import moe.fuqiuluo.shamrock.config.EnableOldBDH +import moe.fuqiuluo.shamrock.config.get +import moe.fuqiuluo.shamrock.helper.ActionMsgException +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.helper.LogicException +import moe.fuqiuluo.shamrock.tools.asJsonObject +import moe.fuqiuluo.shamrock.tools.ifNullOrEmpty +import moe.fuqiuluo.shamrock.tools.json +import moe.fuqiuluo.shamrock.utils.AudioUtils +import moe.fuqiuluo.shamrock.utils.DownloadUtils +import moe.fuqiuluo.shamrock.utils.FileUtils +import moe.fuqiuluo.shamrock.utils.MediaType +import moe.fuqiuluo.shamrock.utils.PlatformUtils +import mqq.app.MobileQQ +import qq.service.QQInterfaces.Companion.app +import qq.service.bdh.FileTransfer +import qq.service.bdh.PictureResource +import qq.service.bdh.Private +import qq.service.bdh.Transfer +import qq.service.bdh.Troop +import qq.service.bdh.VideoResource +import qq.service.bdh.VoiceResource +import qq.service.bdh.trans +import qq.service.bdh.with +import qq.service.contact.ContactHelper +import qq.service.contact.longPeer +import qq.service.group.GroupHelper +import qq.service.internals.NTServiceFetcher +import qq.service.internals.msgService +import qq.service.lightapp.ArkAppInfo +import qq.service.lightapp.ArkMsgHelper +import qq.service.lightapp.LbsHelper +import qq.service.lightapp.MusicHelper +import qq.service.lightapp.WeatherHelper +import tencent.im.oidb.cmd0xb77.oidb_cmd0xb77 +import tencent.im.oidb.cmd0xdc2.oidb_cmd0xdc2 +import tencent.im.oidb.oidb_sso +import java.io.ByteArrayInputStream +import java.io.File +import kotlin.coroutines.resume +import kotlin.math.roundToInt +import kotlin.random.Random +import kotlin.random.nextInt + +/** + * 将请求消息(io.kritor.message)转成NT消息(com.tencent.qqnt.*)发送 + */ + + +typealias Messages = Collection +private typealias NtConvertor = suspend (Contact, Long, Element) -> Result + +object NtMsgConvertor { + private val ntConvertors = mapOf( + TEXT to ::textConvertor, + AT to ::atConvertor, + FACE to ::faceConvertor, + BUBBLE_FACE to ::bubbleFaceConvertor, + REPLY to ::replyConvertor, + IMAGE to ::imageConvertor, + VOICE to ::voiceConvertor, + VIDEO to ::videoConvertor, + BASKETBALL to ::basketballConvertor, + DICE to ::diceConvertor, + RPS to ::rpsConvertor, + POKE to ::pokeConvertor, + MUSIC to ::musicConvertor, + WEATHER to ::weatherConvertor, + LOCATION to ::locationConvertor, + SHARE to ::shareConvertor, + CONTACT to ::contactConvertor, + JSON to ::jsonConvertor, + FORWARD to ::forwardConvertor, + MARKDOWN to ::markdownConvertor, + BUTTON to ::buttonConvertor, + ) + + suspend fun convertToNtMsgs(contact: Contact, msgId: Long, msgs: Messages): ArrayList { + val ntMsgs = ArrayList() + msgs.forEach { + val convertor = ntConvertors[it.type] + if (convertor == null) { + LogCenter.log("未知的消息类型: ${it.type}", Level.WARN) + } else { + try { + ntMsgs.add(convertor(contact, msgId, it).getOrThrow()) + } catch (e: Throwable) { + if (e !is ActionMsgException) { + LogCenter.log("消息转换失败: ${it.type}", Level.WARN) + } + } + } + } + return ntMsgs + } + + private suspend fun textConvertor(contact: Contact, msgId: Long, text: Element): Result { + val elem = MsgElement() + elem.elementType = MsgConstant.KELEMTYPETEXT + elem.textElement = TextElement() + elem.textElement.content = text.text.text + return Result.success(elem) + } + + private suspend fun atConvertor(contact: Contact, msgId: Long, sourceAt: Element): Result { + if (contact.chatType != MsgConstant.KCHATTYPEGROUP) { + LogCenter.log("暂不支持非群聊的@元素", Level.WARN) + return Result.failure(ActionMsgException) + } + + val elem = MsgElement() + val at = TextElement() + if (sourceAt.at.accountCase == AtElement.AccountCase.UIN) { + val uin = sourceAt.at.uin + if (uin == 0L) { + at.content = "@全体成员" + at.atType = MsgConstant.ATTYPEALL + at.atNtUid = "0" + } else { + val info = GroupHelper.getTroopMemberInfoByUinV2(contact.peerUid, uin.toString(), true).onFailure { + LogCenter.log("无法获取群成员信息: contact=$contact, id=${uin}", Level.WARN) + }.getOrNull() + at.content = "@${ + info?.troopnick.ifNullOrEmpty { info?.friendnick } + ?: uin.toString() + }" + at.atType = MsgConstant.ATTYPEONE + at.atNtUid = ContactHelper.getUidByUinAsync(uin) + } + } else { + val uid = sourceAt.at.uid + if (uid == "all" || uid == "0") { + at.content = "@全体成员" + at.atType = MsgConstant.ATTYPEALL + at.atNtUid = "0" + } else { + val uin = ContactHelper.getUinByUidAsync(uid) + val info = GroupHelper.getTroopMemberInfoByUinV2(contact.peerUid, uin, true).onFailure { + LogCenter.log("无法获取群成员信息: contact=$contact, id=${uin}", Level.WARN) + }.getOrNull() + at.content = "@${ + info?.troopnick.ifNullOrEmpty { info?.friendnick } + ?: uin + }" + at.atType = MsgConstant.ATTYPEONE + at.atNtUid = uid + } + } + elem.textElement = at + elem.elementType = MsgConstant.KELEMTYPETEXT + return Result.success(elem) + } + + private suspend fun faceConvertor(contact: Contact, msgId: Long, sourceFace: Element): Result { + val serverId = sourceFace.face.id + val big = sourceFace.face.isBig || serverId == 394 + + val elem = MsgElement() + elem.elementType = MsgConstant.KELEMTYPEFACE + val face = FaceElement() + + // 1 old face + // 2 normal face + // 3 super face + // 4 is market face + // 5 is vas poke + face.faceType = if (big) 3 else 2 + face.faceIndex = serverId + face.faceText = QQSysFaceUtil.getFaceDescription(QQSysFaceUtil.convertToLocal(serverId)) + if (serverId == 394) { + face.stickerId = "40" + face.packId = "1" + face.sourceType = 1 + face.stickerType = 3 + face.randomType = 1 + face.resultId = Random.nextInt(1..5).toString() + } else if (big) { + face.imageType = 0 + face.stickerId = "30" + face.packId = "1" + face.sourceType = 1 + face.stickerType = 1 + face.randomType = 1 + } else { + face.imageType = 0 + face.packId = "0" + } + elem.faceElement = face + + return Result.success(elem) + } + + private suspend fun bubbleFaceConvertor(contact: Contact, msgId: Long, sourceBubbleFace: Element): Result { + val faceId = sourceBubbleFace.bubbleFace.id + val local = QQSysFaceUtil.convertToLocal(faceId) + val name = QQSysFaceUtil.getFaceDescription(local) + val count = sourceBubbleFace.bubbleFace.count + val elem = MsgElement() + elem.elementType = MsgConstant.KELEMTYPEFACEBUBBLE + val face = FaceBubbleElement() + face.faceType = 13 + face.faceCount = count + face.faceSummary = QQSysFaceUtil.getPrueFaceDescription(name) + val smallYellowFaceInfo = SmallYellowFaceInfo() + smallYellowFaceInfo.index = faceId + smallYellowFaceInfo.compatibleText = face.faceSummary + smallYellowFaceInfo.text = face.faceSummary + face.yellowFaceInfo = smallYellowFaceInfo + face.faceFlag = 0 + face.content = "[${face.faceSummary}]x$count" + elem.faceBubbleElement = face + return Result.success(elem) + } + + private suspend fun replyConvertor(contact: Contact, msgId: Long, sourceReply: Element): Result { + val element = MsgElement() + element.elementType = MsgConstant.KELEMTYPEREPLY + val reply = ReplyElement() + + reply.replayMsgId = sourceReply.reply.messageId + reply.sourceMsgIdInRecords = reply.replayMsgId + + if (reply.replayMsgId == 0L) { + LogCenter.log("无法获取被回复消息", Level.ERROR) + } + + withTimeoutOrNull(3000) { + suspendCancellableCoroutine { + QRoute.api(IMsgService::class.java).getMsgsByMsgId(contact, arrayListOf(reply.replayMsgId)) { _, _, records -> + it.resume(records) + } + } + }?.firstOrNull()?.let { + reply.replayMsgSeq = it.msgSeq + //reply.sourceMsgText = it.elements.firstOrNull { it.elementType == MsgConstant.KELEMTYPETEXT }?.textElement?.content + reply.replyMsgTime = it.msgTime + reply.senderUidStr = it.senderUid + reply.senderUid = it.senderUin + } + + element.replyElement = reply + return Result.success(element) + } + + private suspend fun imageConvertor(contact: Contact, msgId: Long, sourceImage: Element): Result { + val isOriginal = sourceImage.image.type == ImageType.ORIGIN + val isFlash = sourceImage.image.type == ImageType.FLASH + val file = when(sourceImage.image.dataCase!!) { + ImageElement.DataCase.FILE_NAME -> { + val fileMd5 = sourceImage.image.fileName.replace(regex = "[{}\\-]".toRegex(), replacement = "").split(".")[0].lowercase() + FileUtils.getFileByMd5(fileMd5) + } + ImageElement.DataCase.FILE_PATH -> { + val filePath = sourceImage.image.filePath + File(filePath).inputStream().use { + FileUtils.saveFileToCache(it) + } + } + ImageElement.DataCase.FILE_BASE64 -> { + FileUtils.saveFileToCache(ByteArrayInputStream( + Base64.decode(sourceImage.image.fileBase64, Base64.DEFAULT) + )) + } + ImageElement.DataCase.URL -> { + val tmp = FileUtils.getTmpFile() + if(DownloadUtils.download(sourceImage.image.url, tmp)) { + tmp.inputStream().use { + FileUtils.saveFileToCache(it) + }.also { + tmp.delete() + } + } else { + tmp.delete() + return Result.failure(LogicException("图片资源下载失败: ${sourceImage.image.url}")) + } + } + ImageElement.DataCase.DATA_NOT_SET -> return Result.failure(IllegalArgumentException("ImageElement data is not set")) + } + + if (EnableOldBDH.get()) { + Transfer with when (contact.chatType) { + MsgConstant.KCHATTYPEGROUP -> Troop(contact.peerUid) + MsgConstant.KCHATTYPETEMPC2CFROMGROUP, MsgConstant.KCHATTYPEC2C -> Private(contact.longPeer().toString()) + MsgConstant.KCHATTYPEGUILD -> Troop(contact.peerUid) + else -> return Result.failure(Exception("Not supported chatType(${contact.chatType}) for PictureMsg")) + } trans PictureResource(file) + } + + val elem = MsgElement() + elem.elementType = MsgConstant.KELEMTYPEPIC + val pic = PicElement() + pic.md5HexStr = QQNTWrapperUtil.CppProxy.genFileMd5Hex(file.absolutePath) + + val msgService = NTServiceFetcher.kernelService.msgService!! + val originalPath = msgService.getRichMediaFilePathForMobileQQSend( + RichMediaFilePathInfo( + 2, 0, pic.md5HexStr, file.name, 1, 0, null, "", true + ) + ) + if (!QQNTWrapperUtil.CppProxy.fileIsExist(originalPath) || QQNTWrapperUtil.CppProxy.getFileSize(originalPath) != file.length()) { + val thumbPath = msgService.getRichMediaFilePathForMobileQQSend( + RichMediaFilePathInfo( + 2, 0, pic.md5HexStr, file.name, 2, 720, null, "", true + ) + ) + QQNTWrapperUtil.CppProxy.copyFile(file.absolutePath, originalPath) + QQNTWrapperUtil.CppProxy.copyFile(file.absolutePath, thumbPath) + } + + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + BitmapFactory.decodeFile(file.absolutePath, options) + val exifInterface = ExifInterface(file.absolutePath) + val orientation = exifInterface.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_UNDEFINED + ) + if (orientation != ExifInterface.ORIENTATION_ROTATE_90 && orientation != ExifInterface.ORIENTATION_ROTATE_270) { + pic.picWidth = options.outWidth + pic.picHeight = options.outHeight + } else { + pic.picWidth = options.outHeight + pic.picHeight = options.outWidth + } + pic.sourcePath = file.absolutePath + pic.fileSize = QQNTWrapperUtil.CppProxy.getFileSize(file.absolutePath) + pic.original = isOriginal + pic.picType = FileUtils.getPicType(file) + pic.picSubType = 0 + pic.isFlashPic = isFlash + + elem.picElement = pic + + return Result.success(elem) + } + + private suspend fun voiceConvertor(contact: Contact, msgId: Long, sourceVoice: Element): Result { + var file = when(sourceVoice.voice.dataCase!!) { + VoiceElement.DataCase.FILE_NAME -> { + val fileMd5 = sourceVoice.voice.fileName.replace(regex = "[{}\\-]".toRegex(), replacement = "").split(".")[0].lowercase() + FileUtils.getFileByMd5(fileMd5) + } + VoiceElement.DataCase.FILE_PATH -> { + val filePath = sourceVoice.voice.filePath + File(filePath).inputStream().use { + FileUtils.saveFileToCache(it) + } + } + VoiceElement.DataCase.FILE_BASE64 -> { + FileUtils.saveFileToCache(ByteArrayInputStream( + Base64.decode(sourceVoice.voice.fileBase64, Base64.DEFAULT) + )) + } + VoiceElement.DataCase.URL -> { + val tmp = FileUtils.getTmpFile() + if(DownloadUtils.download(sourceVoice.voice.url, tmp)) { + tmp.inputStream().use { + FileUtils.saveFileToCache(it) + }.also { + tmp.delete() + } + } else { + tmp.delete() + return Result.failure(LogicException("音频资源下载失败: ${sourceVoice.voice.url}")) + } + } + VoiceElement.DataCase.DATA_NOT_SET -> return Result.failure(IllegalArgumentException("VoiceElement data is not set")) + } + + val isMagic = sourceVoice.voice.magic + + val ptt = PttElement() + + when (AudioUtils.getMediaType(file)) { + MediaType.Silk -> { + LogCenter.log({ "Silk: $file" }, Level.DEBUG) + ptt.formatType = MsgConstant.KPTTFORMATTYPESILK + ptt.duration = QRoute.api(IAIOPttApi::class.java) + .getPttFileDuration(file.absolutePath) + } + + MediaType.Amr -> { + LogCenter.log({ "Amr: $file" }, Level.DEBUG) + ptt.duration = AudioUtils.getDurationSec(file) + ptt.formatType = MsgConstant.KPTTFORMATTYPEAMR + } + + MediaType.Pcm -> { + LogCenter.log({ "Pcm To Silk: $file" }, Level.DEBUG) + val result = AudioUtils.pcmToSilk(file) + ptt.duration = (result.second * 0.001).roundToInt() + file = result.first + ptt.formatType = MsgConstant.KPTTFORMATTYPESILK + } + + else -> { + LogCenter.log({ "Audio To SILK: $file" }, Level.DEBUG) + val result = AudioUtils.audioToSilk(file) + ptt.duration = runCatching { + QRoute.api(IAIOPttApi::class.java) + .getPttFileDuration(result.second.absolutePath) + }.getOrElse { + result.first + } + file = result.second + ptt.formatType = MsgConstant.KPTTFORMATTYPESILK + } + } + + val elem = MsgElement() + elem.elementType = MsgConstant.KELEMTYPEPTT + ptt.md5HexStr = QQNTWrapperUtil.CppProxy.genFileMd5Hex(file.absolutePath) + + if (EnableOldBDH.get()) { + if (!(Transfer with when (contact.chatType) { + MsgConstant.KCHATTYPEGROUP -> Troop(contact.peerUid) + MsgConstant.KCHATTYPETEMPC2CFROMGROUP, MsgConstant.KCHATTYPEC2C -> Private(contact.longPeer().toString()) + MsgConstant.KCHATTYPEGUILD -> Troop(contact.peerUid) + else -> return Result.failure(Exception("Not supported chatType(${contact.chatType}) for VoiceMsg")) + } trans VoiceResource(file)) + ) { + return Result.failure(RuntimeException("上传语音失败: $file")) + } + ptt.filePath = file.absolutePath + } else { + val msgService = NTServiceFetcher.kernelService.msgService!! + + val originalPath = msgService.getRichMediaFilePathForMobileQQSend( + RichMediaFilePathInfo( + MsgConstant.KELEMTYPEPTT, 0, ptt.md5HexStr, file.name, 1, 0, null, "", true + ) + ) + if (!QQNTWrapperUtil.CppProxy.fileIsExist(originalPath) || QQNTWrapperUtil.CppProxy.getFileSize(originalPath) != file.length()) { + QQNTWrapperUtil.CppProxy.copyFile(file.absolutePath, originalPath) + } + if (originalPath != null) { + ptt.filePath = originalPath + } else { + ptt.filePath = file.absolutePath + } + } + + ptt.canConvert2Text = true + ptt.fileId = 0 + ptt.fileUuid = "" + ptt.text = "" + + if (!isMagic) { + ptt.voiceType = MsgConstant.KPTTVOICETYPESOUNDRECORD + ptt.voiceChangeType = MsgConstant.KPTTVOICECHANGETYPENONE + } else { + ptt.voiceType = MsgConstant.KPTTVOICETYPEVOICECHANGE + ptt.voiceChangeType = MsgConstant.KPTTVOICECHANGETYPEECHO + } + + elem.pttElement = ptt + + return Result.success(elem) + } + + private suspend fun videoConvertor(contact: Contact, msgId: Long, sourceVideo: Element): Result { + val elem = MsgElement() + val video = VideoElement() + + val file = when(sourceVideo.video.dataCase!!) { + io.kritor.message.VideoElement.DataCase.FILE_NAME -> { + val fileMd5 = sourceVideo.video.fileName.replace(regex = "[{}\\-]".toRegex(), replacement = "").split(".")[0].lowercase() + FileUtils.getFileByMd5(fileMd5) + } + io.kritor.message.VideoElement.DataCase.FILE_PATH -> { + val filePath = sourceVideo.video.filePath + File(filePath).inputStream().use { + FileUtils.saveFileToCache(it) + } + } + io.kritor.message.VideoElement.DataCase.FILE_BASE64 -> { + FileUtils.saveFileToCache(ByteArrayInputStream( + Base64.decode(sourceVideo.video.fileBase64, Base64.DEFAULT) + )) + } + io.kritor.message.VideoElement.DataCase.URL -> { + val tmp = FileUtils.getTmpFile() + if(DownloadUtils.download(sourceVideo.video.url, tmp)) { + tmp.inputStream().use { + FileUtils.saveFileToCache(it) + }.also { + tmp.delete() + } + } else { + tmp.delete() + return Result.failure(LogicException("视频资源下载失败: ${sourceVideo.video.url}")) + } + } + io.kritor.message.VideoElement.DataCase.DATA_NOT_SET -> return Result.failure(IllegalArgumentException("VideoElement data is not set")) + } + + video.videoMd5 = QQNTWrapperUtil.CppProxy.genFileMd5Hex(file.absolutePath) + + val msgService = NTServiceFetcher.kernelService.msgService!! + val originalPath = msgService.getRichMediaFilePathForMobileQQSend( + RichMediaFilePathInfo( + 5, 2, video.videoMd5, file.name, 1, 0, null, "", true + ) + ) + val thumbPath = msgService.getRichMediaFilePathForMobileQQSend( + RichMediaFilePathInfo( + 5, 1, video.videoMd5, file.name, 2, 0, null, "", true + ) + ) + if (!QQNTWrapperUtil.CppProxy.fileIsExist(originalPath) || QQNTWrapperUtil.CppProxy.getFileSize( + originalPath + ) != file.length() + ) { + QQNTWrapperUtil.CppProxy.copyFile(file.absolutePath, originalPath) + AudioUtils.obtainVideoCover(file.absolutePath, thumbPath!!) + } + + if (EnableOldBDH.get()) { + Transfer with when (contact.chatType) { + MsgConstant.KCHATTYPEGROUP -> Troop(contact.peerUid) + MsgConstant.KCHATTYPETEMPC2CFROMGROUP, MsgConstant.KCHATTYPEC2C -> Private(contact.longPeer().toString()) + MsgConstant.KCHATTYPEGUILD -> Troop(contact.peerUid) + else -> return Result.failure(Exception("Not supported chatType(${contact.chatType}) for VideoMsg")) + } trans VideoResource(file, File(thumbPath.toString())) + } + + video.fileTime = AudioUtils.getVideoTime(file) + video.fileSize = file.length() + video.fileName = file.name + video.fileFormat = FileTransfer.VIDEO_FORMAT_MP4 + video.thumbSize = QQNTWrapperUtil.CppProxy.getFileSize(thumbPath).toInt() + val options = BitmapFactory.Options() + BitmapFactory.decodeFile(thumbPath, options) + video.thumbWidth = options.outWidth + video.thumbHeight = options.outHeight + video.thumbMd5 = QQNTWrapperUtil.CppProxy.genFileMd5Hex(thumbPath) + video.thumbPath = hashMapOf(0 to thumbPath) + + elem.videoElement = video + elem.elementType = MsgConstant.KELEMTYPEVIDEO + + return Result.success(elem) + } + + private suspend fun basketballConvertor(contact: Contact, msgId: Long, sourceBasketball: Element): Result { + val elem = MsgElement() + elem.elementType = MsgConstant.KELEMTYPEFACE + val face = FaceElement() + face.faceIndex = 114 + face.faceText = "/篮球" + face.faceType = 3 + face.packId = "1" + face.stickerId = "13" + face.sourceType = 1 + face.stickerType = 2 + face.resultId = Random.nextInt(1..5).toString() + face.surpriseId = "" + face.randomType = 1 + elem.faceElement = face + return Result.success(elem) + } + + private suspend fun diceConvertor(contact: Contact, msgId: Long, sourceDice: Element): Result { + val elem = MsgElement() + elem.elementType = MsgConstant.KELEMTYPEMARKETFACE + val market = MarketFaceElement( + 6, 1, 11464, 3, 0, 200, 200, + "[骰子]", "4823d3adb15df08014ce5d6796b76ee1", "409e2a69b16918f9", + null, null, 0, 0, 0, 1, 0, + null, null, null, // jumpurl + "", null, null, + null, null, arrayListOf(MarketFaceSupportSize(200, 200)), null + ) + elem.marketFaceElement = market + return Result.success(elem) + } + + private suspend fun rpsConvertor(contact: Contact, msgId: Long, sourceRps: Element): Result { + val elem = MsgElement() + elem.elementType = MsgConstant.KELEMTYPEMARKETFACE + val market = MarketFaceElement( + 6, 1, 11415, 3, 0, 200, 200, + "[猜拳]", "83C8A293AE65CA140F348120A77448EE", "7de39febcf45e6db", + null, null, 0, 0, 0, 1, 0, + null, null, null, + "", null, null, + null, null, arrayListOf(MarketFaceSupportSize(200, 200)), null + ) + elem.marketFaceElement = market + return Result.success(elem) + } + + private suspend fun pokeConvertor(contact: Contact, msgId: Long, sourcePoke: Element): Result { + val elem = MsgElement() + val face = FaceElement() + face.faceIndex = 0 + face.faceText = "" + face.faceType = 5 + face.packId = null + face.pokeType = sourcePoke.poke.type + face.spokeSummary = "" + face.doubleHit = 0 + face.vaspokeId = sourcePoke.poke.id + face.vaspokeName = "" + face.vaspokeMinver = "" + face.pokeStrength = sourcePoke.poke.strength + face.msgType = 0 + face.faceBubbleCount = 0 + face.oldVersionStr = "[截一戳]请使用最新版手机QQ体验新功能。" + face.pokeFlag = 0 + elem.elementType = MsgConstant.KELEMTYPEFACE + elem.faceElement = face + return Result.success(elem) + } + + private suspend fun musicConvertor(contact: Contact, msgId: Long, sourceMusic: Element): Result { + when (val type = sourceMusic.music.platform) { + MusicPlatform.QQ -> { + val id = sourceMusic.music.id + if (!MusicHelper.tryShareQQMusicById(contact, msgId, id)) { + LogCenter.log("无法发送QQ音乐分享", Level.ERROR) + } + } + + MusicPlatform.NetEase -> { + val id = sourceMusic.music.id + if (!MusicHelper.tryShare163MusicById(contact, msgId, id)) { + LogCenter.log("无法发送网易云音乐分享", Level.ERROR) + } + } + + MusicPlatform.Custom -> { + val data = sourceMusic.music.custom + ArkMsgHelper.tryShareMusic( + contact, + msgId, + ArkAppInfo.QQMusic, + data.title, + data.author, + data.url, + data.pic, + data.audio + ) + } + + else -> LogCenter.log("不支持的音乐分享类型: $type", Level.ERROR) + } + + return Result.failure(ActionMsgException) + } + + private suspend fun weatherConvertor(contact: Contact, msgId: Long, sourceWeather: Element): Result { + val code = if (sourceWeather.weather.code.isNullOrEmpty()) { + val city = sourceWeather.weather.city + WeatherHelper.searchCity(city).onFailure { + LogCenter.log("无法获取城市天气: $city", Level.ERROR) + }.getOrThrow().first().adcode + } else sourceWeather.weather.code.toInt() + WeatherHelper.fetchWeatherCard(code).onSuccess { + val element = MsgElement() + element.elementType = MsgConstant.KELEMTYPEARKSTRUCT + val share = it["weekStore"] + .asJsonObject["share"] + .asJsonObject["data"].toString() + element.arkElement = + ArkElement(share, null, MsgConstant.ARKSTRUCTELEMENTSUBTYPEUNKNOWN) + return Result.success(element) + }.onFailure { + return Result.failure(it) + } + return Result.failure(ActionMsgException) + } + + private suspend fun locationConvertor(contact: Contact, msgId: Long, sourceLocation: Element): Result { + LbsHelper.tryShareLocation(contact, sourceLocation.location.lat.toDouble(), sourceLocation.location.lon.toDouble()).onFailure { + LogCenter.log("无法发送位置分享", Level.ERROR) + } + return Result.failure(ActionMsgException) + } + + private suspend fun shareConvertor(contact: Contact, msgId: Long, sourceShare: Element): Result { + val url = sourceShare.share.url + val image = sourceShare.share.image.ifNullOrEmpty { + val startWithPrefix = url.startsWith("http://") || url.startsWith("https://") + val endWithPrefix = url.startsWith("/") + "http://" + url.split("/")[if (startWithPrefix) 2 else 0] + if (!endWithPrefix) { + "/favicon.ico" + } else { + "favicon.ico" + } + }!! + val title = sourceShare.share.title + val content = sourceShare.share.content + + val reqBody = oidb_cmd0xdc2.ReqBody() + val info = oidb_cmd0xb77.ReqBody() + info.appid.set(100446242L) + info.app_type.set(1) + info.msg_style.set(0) + info.recv_uin.set(contact.longPeer()) + val clientInfo = oidb_cmd0xb77.ClientInfo() + clientInfo.platform.set(1) + info.client_info.set(clientInfo) + val richMsgBody = oidb_cmd0xb77.RichMsgBody() + richMsgBody.using_ark.set(true) + richMsgBody.title.set(title) + richMsgBody.summary.set(content ?: url) + richMsgBody.brief.set("[分享] $title") + richMsgBody.url.set(url) + richMsgBody.picture_url.set(image) + info.ext_info.set(oidb_cmd0xb77.ExtInfo().also { + it.msg_seq.set(msgId) + }) + info.rich_msg_body.set(richMsgBody) + reqBody.msg_body.set(info) + val sendTo = oidb_cmd0xdc2.BatchSendReq() + when (contact.chatType) { + MsgConstant.KCHATTYPEGROUP -> sendTo.send_type.set(1) + MsgConstant.KCHATTYPEC2C -> sendTo.send_type.set(0) + else -> return Result.failure(Exception("Not supported chatType(${contact.chatType}) for ShareMsg")) + } + sendTo.recv_uin.set(contact.peerUid.toLong()) + reqBody.batch_send_req.add(sendTo) + val to = ToServiceMsg("mobileqq.service", app.currentAccountUin, "OidbSvc.0xdc2_34") + val oidb = oidb_sso.OIDBSSOPkg() + oidb.uint32_command.set(0xdc2) + oidb.uint32_service_type.set(34) + oidb.bytes_bodybuffer.set(ByteStringMicro.copyFrom(reqBody.toByteArray())) + oidb.str_client_version.set(PlatformUtils.getClientVersion(MobileQQ.getContext())) + to.putWupBuffer(oidb.toByteArray()) + to.addAttribute("req_pb_protocol_flag", true) + app.sendToService(to) + return Result.failure(ActionMsgException) + } + + private suspend fun contactConvertor(contact: Contact, msgId: Long, sourceContact: Element): Result { + val elem = MsgElement() + + when (val scene = sourceContact.contact.scene) { + Scene.FRIEND -> { + val ark = ArkElement(ContactHelper.getSharePrivateArkMsg(contact.longPeer()), null, null) + elem.arkElement = ark + } + + Scene.GROUP -> { + val ark = ArkElement(ContactHelper.getShareTroopArkMsg(contact.longPeer()), null, null) + elem.arkElement = ark + } + + else -> return Result.failure(LogicException("不支持的联系人分享类型: $scene")) + } + + elem.elementType = MsgConstant.KELEMTYPEARKSTRUCT + return Result.success(elem) + } + + private suspend fun jsonConvertor(contact: Contact, msgId: Long, sourceJson: Element): Result { + val elem = MsgElement() + elem.elementType = MsgConstant.KELEMTYPEARKSTRUCT + val ark = ArkElement(sourceJson.json.json, null, null) + elem.arkElement = ark + return Result.success(elem) + } + + private suspend fun markdownConvertor(contact: Contact, msgId: Long, sourceMarkdown: Element): Result { + val elem = MsgElement() + elem.elementType = MsgConstant.KELEMTYPEMARKDOWN + val markdownElement = MarkdownElement(sourceMarkdown.markdown.markdown) + elem.markdownElement = markdownElement + return Result.success(elem) + } + + private suspend fun buttonConvertor(contact: Contact, msgId: Long, sourceButton: Element): Result { + fun tryNewKeyboardButton(button: Button): InlineKeyboardButton { + val renderData = button.renderData + val action = button.action + val permission = action.permission + return runCatching { + InlineKeyboardButton(button.id, renderData.label, renderData.visitedLabel, renderData.style, + action.type, 0, + action.unsupportedTips, + action.data, false, + permission.type, + ArrayList(permission.roleIdsList), + ArrayList(permission.userIdsList), + false, 0, false, arrayListOf() + ) + }.getOrElse { + InlineKeyboardButton(button.id, renderData.label, renderData.visitedLabel, renderData.style, + action.type, 0, + action.unsupportedTips, + action.data, false, + permission.type, + ArrayList(permission.roleIdsList), + ArrayList(permission.userIdsList) + ) + } + } + + val elem = MsgElement() + elem.elementType = MsgConstant.KELEMTYPEINLINEKEYBOARD + val rows = arrayListOf() + + val keyboard = sourceButton.button + keyboard.rowsList.forEach { row -> + val buttons = arrayListOf() + row.buttonsList.forEach { button -> + buttons.add(tryNewKeyboardButton(button)) + } + rows.add(InlineKeyboardRow(buttons)) + } + elem.inlineKeyboardElement = InlineKeyboardElement(rows, 0) + return Result.success(elem) + } + + private suspend fun forwardConvertor(contact: Contact, msgId: Long, sourceForward: Element): Result { + val resId = sourceForward.forward.id + val filename = sourceForward.forward.uniseq + var summary = sourceForward.forward.summary + val descriptions = sourceForward.forward.description + var news = descriptions?.split("\n")?.map { "text" to it } + + if (news == null || summary == null) { + val forwardMsg = MessageHelper.getForwardMsg(resId).getOrElse { return Result.failure(it) } + if (news == null) { + news = forwardMsg.map { + "text" to it.sender.nickName + ": " + descriptions + } + } + if (summary == null) { + summary = "查看${forwardMsg.size}条转发消息" + } + } + + val json = mapOf( + "app" to "com.tencent.multimsg", + "config" to mapOf( + "autosize" to 1, + "forward" to 1, + "round" to 1, + "type" to "normal", + "width" to 300 + ), + "desc" to "[聊天记录]", + "extra" to mapOf( + "filename" to filename, + "tsum" to 2 + ).json.toString(), + "meta" to mapOf( + "detail" to mapOf( + "news" to news, + "resid" to resId, + "source" to "群聊的聊天记录", + "summary" to summary, + "uniseq" to filename + ) + ), + "prompt" to "[聊天记录]", + "ver" to "0.0.0.5", + "view" to "contact" + ) + + val elem = MsgElement() + elem.elementType = MsgConstant.KELEMTYPEARKSTRUCT + val ark = ArkElement(json.json.toString(), null, null) + elem.arkElement = ark + return Result.success(elem) + } +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/msg/ReqMessageConvertor.kt b/xposed/src/main/java/qq/service/msg/ReqMessageConvertor.kt new file mode 100644 index 0000000..8636a54 --- /dev/null +++ b/xposed/src/main/java/qq/service/msg/ReqMessageConvertor.kt @@ -0,0 +1,401 @@ +package qq.service.msg + +import com.tencent.mobileqq.qroute.QRoute +import com.tencent.qqnt.kernel.nativeinterface.* +import com.tencent.qqnt.kernel.nativeinterface.Contact +import com.tencent.qqnt.msg.api.IMsgService +import io.kritor.message.* +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import moe.fuqiuluo.shamrock.helper.ActionMsgException +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.helper.db.ImageDB +import moe.fuqiuluo.shamrock.helper.db.ImageMapping +import moe.fuqiuluo.shamrock.tools.asJsonArray +import moe.fuqiuluo.shamrock.tools.asJsonObject +import moe.fuqiuluo.shamrock.tools.asString +import moe.fuqiuluo.shamrock.tools.hex2ByteArray +import moe.fuqiuluo.shamrock.tools.ifNullOrEmpty +import moe.fuqiuluo.shamrock.tools.toHexString +import moe.fuqiuluo.shamrock.utils.PlatformUtils +import moe.fuqiuluo.shamrock.utils.PlatformUtils.QQ_9_0_8_VER +import qq.service.bdh.RichProtoSvc +import qq.service.contact.ContactHelper +import qq.service.contact.longPeer +import kotlin.coroutines.resume + +/** + * 将NT消息(com.tencent.qqnt.*)转换为请求消息(io.kritor.message.*)推送 + */ + +private typealias ReqConvertor = suspend (Contact, MsgElement) -> Result + +private object ReqMsgConvertor { + private val convertorMap = hashMapOf( + MsgConstant.KELEMTYPETEXT to ::convertText, + MsgConstant.KELEMTYPEFACE to ::convertFace, + MsgConstant.KELEMTYPEPIC to ::convertImage, + MsgConstant.KELEMTYPEPTT to ::convertVoice, + MsgConstant.KELEMTYPEVIDEO to ::convertVideo, + MsgConstant.KELEMTYPEMARKETFACE to ::convertMarketFace, + MsgConstant.KELEMTYPEARKSTRUCT to ::convertStructJson, + MsgConstant.KELEMTYPEREPLY to ::convertReply, + //MsgConstant.KELEMTYPEGRAYTIP to ::convertGrayTips, + MsgConstant.KELEMTYPEFILE to ::convertFile, + MsgConstant.KELEMTYPEMARKDOWN to ::convertMarkdown, + //MsgConstant.KELEMTYPEMULTIFORWARD to MsgElementConverter::convertXmlMultiMsgElem, + //MsgConstant.KELEMTYPESTRUCTLONGMSG to MsgElementConverter::convertXmlLongMsgElem, + MsgConstant.KELEMTYPEFACEBUBBLE to ::convertBubbleFace, + MsgConstant.KELEMTYPEINLINEKEYBOARD to ::convertInlineKeyboard + ) + + suspend fun convertText(contact: Contact, element: MsgElement): Result { + val text = element.textElement + val elem = Element.newBuilder() + if (text.atType != MsgConstant.ATTYPEUNKNOWN) { + elem.setAt(atElement { + this.uid = text.atNtUid + this.uin = ContactHelper.getUinByUidAsync(text.atNtUid).toLong() + }) + } else { + elem.setText(textElement { + this.text = text.content + }) + } + return Result.success(elem.build()) + } + + suspend fun convertFace(contact: Contact, element: MsgElement): Result { + val face = element.faceElement + val elem = Element.newBuilder() + if (face.faceType == 5) { + elem.setPoke(pokeElement { + this.id = face.vaspokeId + this.type = face.pokeType + this.strength = face.pokeStrength + }) + } else { + when(face.faceIndex) { + 114 -> elem.setBasketball(basketballElement { + this.id = face.resultId.ifNullOrEmpty { "0" }?.toInt() ?: 0 + }) + 358 -> elem.setDice(diceElement { + this.id = face.resultId.ifNullOrEmpty { "0" }?.toInt() ?: 0 + }) + 359 -> elem.setRps(rpsElement { + this.id = face.resultId.ifNullOrEmpty { "0" }?.toInt() ?: 0 + }) + 394 -> elem.setFace(faceElement { + this.id = face.faceIndex + this.isBig = face.faceType == 3 + this.result = face.resultId.ifNullOrEmpty { "1" }?.toInt() ?: 1 + }) + else -> elem.setFace(faceElement { + this.id = face.faceIndex + this.isBig = face.faceType == 3 + }) + } + } + return Result.success(elem.build()) + } + + suspend fun convertImage(contact: Contact, element: MsgElement): Result { + val image = element.picElement + val md5 = (image.md5HexStr ?: image.fileName + .replace("{", "") + .replace("}", "") + .replace("-", "").split(".")[0]) + .uppercase() + + var storeId = 0 + if (PlatformUtils.getQQVersionCode() > QQ_9_0_8_VER) { + storeId = image.storeID + } + + ImageDB.getInstance().imageMappingDao().insert( + ImageMapping( + fileName = md5, + md5 = md5, + chatType = contact.chatType, + size = image.fileSize, + sha = "", + fileId = image.fileUuid, + storeId = storeId, + ) + ) + + val originalUrl = image.originImageUrl ?: "" + LogCenter.log({ "receive image: $image" }, Level.DEBUG) + + val elem = Element.newBuilder() + elem.setImage(imageElement { + this.file = md5 + this.url = when (contact.chatType) { + MsgConstant.KCHATTYPEDISC, MsgConstant.KCHATTYPEGROUP -> RichProtoSvc.getGroupPicDownUrl( + originalUrl = originalUrl, + md5 = md5, + fileId = image.fileUuid, + width = image.picWidth.toUInt(), + height = image.picHeight.toUInt(), + sha = "", + fileSize = image.fileSize.toULong(), + peer = contact.longPeer().toString() + ) + + MsgConstant.KCHATTYPEC2C -> RichProtoSvc.getC2CPicDownUrl( + originalUrl = originalUrl, + md5 = md5, + fileId = image.fileUuid, + width = image.picWidth.toUInt(), + height = image.picHeight.toUInt(), + sha = "", + fileSize = image.fileSize.toULong(), + peer = contact.longPeer().toString(), + storeId = storeId + ) + + MsgConstant.KCHATTYPEGUILD -> RichProtoSvc.getGuildPicDownUrl( + originalUrl = originalUrl, + md5 = md5, + fileId = image.fileUuid, + width = image.picWidth.toUInt(), + height = image.picHeight.toUInt(), + sha = "", + fileSize = image.fileSize.toULong(), + peer = contact.longPeer().toString(), + subPeer ="0" + ) + + else -> throw UnsupportedOperationException("Not supported chat type: ${contact.chatType}") + } + this.type = if (image.isFlashPic == true) ImageType.FLASH else if (image.original) ImageType.ORIGIN else ImageType.COMMON + this.subType = image.picSubType + }) + + return Result.success(elem.build()) + } + + suspend fun convertVoice(contact: Contact, element: MsgElement): Result { + val ptt = element.pttElement + val elem = Element.newBuilder() + + val md5 = if (ptt.fileName.startsWith("silk")) + ptt.fileName.substring(5) + else ptt.md5HexStr + + elem.setVoice(voiceElement { + this.url = when (contact.chatType) { + MsgConstant.KCHATTYPEC2C -> RichProtoSvc.getC2CPttDownUrl("0", ptt.fileUuid) + MsgConstant.KCHATTYPEGROUP, MsgConstant.KCHATTYPEGUILD -> RichProtoSvc.getGroupPttDownUrl("0", md5.hex2ByteArray(), ptt.fileUuid) + + else -> throw UnsupportedOperationException("Not supported chat type: ${contact.chatType}") + } + this.file = md5 + this.magic = ptt.voiceChangeType != MsgConstant.KPTTVOICECHANGETYPENONE + }) + + return Result.success(elem.build()) + } + + suspend fun convertVideo(contact: Contact, element: MsgElement): Result { + val video = element.videoElement + val elem = Element.newBuilder() + val md5 = if (video.fileName.contains("/")) { + video.videoMd5.takeIf { + !it.isNullOrEmpty() + }?.hex2ByteArray() ?: video.fileName.split("/").let { + it[it.size - 2].hex2ByteArray() + } + } else video.fileName.split(".")[0].hex2ByteArray() + elem.setVideo(videoElement { + this.file = md5.toHexString() + this.url = when (contact.chatType) { + MsgConstant.KCHATTYPEGROUP -> RichProtoSvc.getGroupVideoDownUrl("0", md5, video.fileUuid) + MsgConstant.KCHATTYPEC2C -> RichProtoSvc.getC2CVideoDownUrl("0", md5, video.fileUuid) + MsgConstant.KCHATTYPEGUILD -> RichProtoSvc.getGroupVideoDownUrl("0", md5, video.fileUuid) + else -> throw UnsupportedOperationException("Not supported chat type: ${contact.chatType}") + } + }) + return Result.success(elem.build()) + } + + suspend fun convertMarketFace(contact: Contact, element: MsgElement): Result { + val marketFace = element.marketFaceElement + val elem = Element.newBuilder() + return Result.failure(ActionMsgException) + } + + suspend fun convertStructJson(contact: Contact, element: MsgElement): Result { + val data = element.arkElement.bytesData.asJsonObject + val elem = Element.newBuilder() + when (data["app"].asString) { + "com.tencent.multimsg" -> { + val info = data["meta"].asJsonObject["detail"].asJsonObject + elem.setForward(forwardElement { + this.id = info["resid"].asString + this.uniseq = info["uniseq"].asString + this.summary = info["summary"].asString + this.description = info["news"].asJsonArray.joinToString("\n") { + it.asJsonObject["text"].asString + } + }) + } + + "com.tencent.troopsharecard" -> { + val info = data["meta"].asJsonObject["contact"].asJsonObject + elem.setContact(contactElement { + this.scene = Scene.GROUP + this.peer = info["jumpUrl"].asString.split("group_code=")[1] + }) + } + + "com.tencent.contact.lua" -> { + val info = data["meta"].asJsonObject["contact"].asJsonObject + elem.setContact(contactElement { + this.scene = Scene.FRIEND + this.peer = info["jumpUrl"].asString.split("uin=")[1] + }) + } + + "com.tencent.map" -> { + val info = data["meta"].asJsonObject["Location.Search"].asJsonObject + elem.setLocation(locationElement { + this.lat = info["lat"].asString.toFloat() + this.lon = info["lng"].asString.toFloat() + this.address = info["address"].asString + this.title = info["name"].asString + }) + } + + else -> elem.setJson(jsonElement { + this.json = data.toString() + }) + } + return Result.success(elem.build()) + } + + suspend fun convertReply(contact: Contact, element: MsgElement): Result { + val reply = element.replyElement + val elem = Element.newBuilder() + elem.setReply(replyElement { + val msgSeq = reply.replayMsgSeq + val sourceRecords = withTimeoutOrNull(3000) { + suspendCancellableCoroutine { + QRoute.api(IMsgService::class.java).getMsgsBySeqAndCount(contact, msgSeq, 1, true) { _, _, records -> + it.resume(records) + } + } + } + if (sourceRecords.isNullOrEmpty()) { + LogCenter.log("无法查询到回复的消息ID: seq = $msgSeq", Level.WARN) + this.messageId = reply.replayMsgId + } else { + this.messageId = sourceRecords.first().msgId + } + }) + return Result.success(elem.build()) + } + + suspend fun convertFile(contact: Contact, element: MsgElement): Result { + val fileMsg = element.fileElement + val fileName = fileMsg.fileName + val fileSize = fileMsg.fileSize + val expireTime = fileMsg.expireTime ?: 0 + val fileId = fileMsg.fileUuid + val bizId = fileMsg.fileBizId ?: 0 + val fileSubId = fileMsg.fileSubId ?: "" + val url = when (contact.chatType) { + MsgConstant.KCHATTYPEC2C -> RichProtoSvc.getC2CFileDownUrl(fileId, fileSubId) + MsgConstant.KCHATTYPEGUILD -> RichProtoSvc.getGuildFileDownUrl(contact.guildId, contact.longPeer().toString(), fileId, bizId) + else -> RichProtoSvc.getGroupFileDownUrl(contact.longPeer(), fileId, bizId) + } + val elem = Element.newBuilder() + elem.setFile(fileElement { + this.name = fileName + this.size = fileSize + this.url = url + this.expireTime = expireTime + this.id = fileId + this.subId = fileSubId + this.biz = bizId + }) + return Result.success(elem.build()) + } + + suspend fun convertMarkdown(contact: Contact, element: MsgElement): Result { + val markdown = element.markdownElement + val elem = Element.newBuilder() + elem.setMarkdown(markdownElement { + this.markdown = markdown.content + }) + return Result.success(elem.build()) + } + + suspend fun convertBubbleFace(contact: Contact, element: MsgElement): Result { + val bubbleFace = element.faceBubbleElement + val elem = Element.newBuilder() + elem.setBubbleFace(bubbleFaceElement { + this.id = bubbleFace.yellowFaceInfo.index + this.count = bubbleFace.faceCount ?: 1 + }) + return Result.success(elem.build()) + } + + suspend fun convertInlineKeyboard(contact: Contact, element: MsgElement): Result { + val inlineKeyboard = element.inlineKeyboardElement + val elem = Element.newBuilder() + elem.setButton(buttonElement { + inlineKeyboard.rows.forEach { row -> + this.rows.add(row { + row.buttons.forEach buttonsLoop@ { button -> + if (button == null) return@buttonsLoop + this.buttons.add(button { + this.id = button.id + this.action = buttonAction { + this.type = button.type + this.permission = buttonActionPermission { + this.type = button.permissionType + button.specifyRoleIds?.let { + this.roleIds.addAll(it) + } + button.specifyTinyids?.let { + this.userIds.addAll(it) + } + } + this.unsupportedTips = button.unsupportTips ?: "" + this.data = button.data ?: "" + this.reply = button.isReply + this.enter = button.enter + } + this.renderData = buttonRender { + this.label = button.label ?: "" + this.visitedLabel = button.visitedLabel ?: "" + this.style = button.style + } + }) + } + }) + } + }) + return Result.success(elem.build()) + } + + operator fun get(case: Int): ReqConvertor? { + return convertorMap[case] + } +} + +suspend fun NtMessages.toKritorReqMessages(contact: Contact): ArrayList { + val result = arrayListOf() + forEach { + ReqMsgConvertor[it.elementType]?.invoke(contact, it)?.onSuccess { + result.add(it) + }?.onFailure { + if (it !is ActionMsgException) { + LogCenter.log("消息转换异常: " + it.stackTraceToString(), Level.WARN) + } + } + } + return result +} \ No newline at end of file diff --git a/xposed/src/main/java/qq/service/msg/ReqMultiConvertor.kt b/xposed/src/main/java/qq/service/msg/ReqMultiConvertor.kt new file mode 100644 index 0000000..0f2d833 --- /dev/null +++ b/xposed/src/main/java/qq/service/msg/ReqMultiConvertor.kt @@ -0,0 +1,575 @@ +package qq.service.msg + +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.exifinterface.media.ExifInterface +import com.tencent.mobileqq.qroute.QRoute +import com.tencent.qqnt.kernel.nativeinterface.Contact +import com.tencent.qqnt.kernel.nativeinterface.MsgConstant +import com.tencent.qqnt.msg.api.IMsgService +import io.kritor.message.AtElement +import io.kritor.message.Element +import io.kritor.message.ElementType +import io.kritor.message.ImageElement +import io.kritor.message.ImageType +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import moe.fuqiuluo.shamrock.helper.Level +import moe.fuqiuluo.shamrock.helper.LogCenter +import moe.fuqiuluo.shamrock.helper.LogicException +import moe.fuqiuluo.shamrock.tools.asJsonObject +import moe.fuqiuluo.shamrock.tools.asString +import moe.fuqiuluo.shamrock.tools.hex2ByteArray +import moe.fuqiuluo.shamrock.tools.ifNullOrEmpty +import moe.fuqiuluo.shamrock.tools.json +import moe.fuqiuluo.shamrock.tools.putBuf32Long +import moe.fuqiuluo.shamrock.utils.DeflateTools +import moe.fuqiuluo.shamrock.utils.DownloadUtils +import moe.fuqiuluo.shamrock.utils.FileUtils +import protobuf.auto.toByteArray +import protobuf.message.Elem +import protobuf.message.RichText +import protobuf.message.element.* +import protobuf.message.element.commelem.Action +import protobuf.message.element.commelem.Button +import protobuf.message.element.commelem.ButtonExtra +import protobuf.message.element.commelem.MarkdownExtra +import protobuf.message.element.commelem.Object1 +import protobuf.message.element.commelem.Permission +import protobuf.message.element.commelem.PokeExtra +import protobuf.message.element.commelem.QFaceExtra +import protobuf.message.element.commelem.RenderData +import protobuf.message.element.commelem.Row +import protobuf.oidb.cmd0x11c5.C2CUserInfo +import protobuf.oidb.cmd0x11c5.GroupUserInfo +import qq.service.QQInterfaces +import qq.service.bdh.NtV2RichMediaSvc +import qq.service.bdh.NtV2RichMediaSvc.fetchGroupResUploadTo +import qq.service.contact.ContactHelper +import qq.service.contact.longPeer +import qq.service.group.GroupHelper +import qq.service.lightapp.WeatherHelper +import java.io.ByteArrayInputStream +import java.io.File +import java.nio.ByteBuffer +import java.util.UUID +import kotlin.coroutines.resume +import kotlin.random.Random +import kotlin.random.nextULong +import kotlin.time.Duration.Companion.seconds + +/** + * 请求消息(io.kritor.message.*)转换合并转发消息 + */ + +suspend fun List.toRichText(contact: Contact): Result> { + val summary = StringBuilder() + val elems = ArrayList() + forEach { + try { + when(it.type!!) { + ElementType.TEXT -> { + val text = it.text.text + val elem = Elem( + text = TextMsg(text) + ) + elems.add(elem) + summary.append(text) + } + ElementType.AT -> { + when (contact.chatType) { + MsgConstant.KCHATTYPEGROUP -> { + val qq = when (it.at.accountCase) { + AtElement.AccountCase.UIN -> it.at.uin.toString() + else -> ContactHelper.getUinByUidAsync(it.at.uid) + } + val type: Int + val nick = if (it.at.uid == "all" || it.at.uin == 0L) { + type = 1 + "@全体成员" + } else { + type = 0 + "@" + (GroupHelper.getTroopMemberInfoByUinV2(contact.longPeer().toString(), qq, true).let { + val info = it.getOrNull() + if (info == null) + LogCenter.log("无法获取群成员信息: $qq", Level.ERROR) + else info.troopnick + .ifNullOrEmpty { info.friendnick } + .ifNullOrEmpty { qq } + }) + } + val attr6 = ByteBuffer.allocate(6) + attr6.put(byteArrayOf(0, 1, 0, 0, 0)) + attr6.put(nick.length.toByte()) + attr6.putChar(type.toChar()) + attr6.putBuf32Long(qq.toLong()) + attr6.put(byteArrayOf(0, 0)) + val elem = Elem( + text = TextMsg(str = nick, attr6Buf = attr6.array()) + ) + elems.add(elem) + summary.append(nick) + } + + MsgConstant.KCHATTYPEC2C -> { + val qq = when (it.at.accountCase) { + AtElement.AccountCase.UIN -> it.at.uin.toString() + else -> ContactHelper.getUinByUidAsync(it.at.uid) + } + val display = "@" + (ContactHelper.getProfileCard(qq.toLong()).onSuccess { + it.strNick.ifNullOrEmpty { qq } + }.onFailure { + LogCenter.log("无法获取QQ信息: $qq", Level.WARN) + }) + val elem = Elem( + text = TextMsg(str = display) + ) + elems.add(elem) + summary.append(display) + } + else -> throw UnsupportedOperationException("Unsupported chatType($contact) for AtMsg") + } + } + ElementType.FACE -> { + val faceId = it.face.id + val elem = if (it.face.isBig) { + Elem( + commonElem = CommonElem( + serviceType = 37, + elem = QFaceExtra( + packId = "1", + stickerId = "1", + faceId = faceId, + field4 = 1, + field5 = 1, + result = "", + faceText = "", //todo 表情名字 + field9 = 1 + ).toByteArray(), + businessType = 1 + ) + ) + } else { + Elem( + face = FaceMsg( + index = faceId + ) + ) + } + elems.add(elem) + summary.append("[表情]") + } + ElementType.BUBBLE_FACE -> throw UnsupportedOperationException("Unsupported ElementType.BUBBLE_FACE") + ElementType.REPLY -> { + val msgId = it.reply.messageId + withTimeoutOrNull(3000) { + suspendCancellableCoroutine { + QRoute.api(IMsgService::class.java).getMsgsByMsgId(contact, arrayListOf(msgId)) { _, _, records -> + it.resume(records) + } + } + }?.firstOrNull()?.let { + val sourceContact = MessageHelper.generateContact(it) + elems.add(Elem( + srcMsg = SourceMsg( + origSeqs = listOf(it.msgSeq.toInt()), + senderUin = it.senderUin.toULong(), + time = it.msgTime.toULong(), + flag = 1u, + elems = it.elements + .toKritorReqMessages(sourceContact) + .toRichText(contact).getOrThrow().second.elements, + type = 0u, + pbReserve = SourceMsg.Companion.PbReserve( + msgRand = Random.nextULong(), + senderUid = it.senderUid, + receiverUid = QQInterfaces.app.currentUid, + field8 = Random.nextInt(0, 10000) + ), + ) + )) + } + summary.append("[回复消息]") + } + ElementType.IMAGE -> { + val type = it.image.type + val isOriginal = type == ImageType.ORIGIN + val file = when(it.image.dataCase!!) { + ImageElement.DataCase.FILE_NAME -> { + val fileMd5 = it.image.fileName.replace(regex = "[{}\\-]".toRegex(), replacement = "").split(".")[0].lowercase() + FileUtils.getFileByMd5(fileMd5) + } + ImageElement.DataCase.FILE_PATH -> { + val filePath = it.image.filePath + File(filePath).inputStream().use { + FileUtils.saveFileToCache(it) + } + } + ImageElement.DataCase.FILE_BASE64 -> { + FileUtils.saveFileToCache( + ByteArrayInputStream( + Base64.decode(it.image.fileBase64, Base64.DEFAULT) + ) + ) + } + ImageElement.DataCase.URL -> { + val tmp = FileUtils.getTmpFile() + if(DownloadUtils.download(it.image.url, tmp)) { + tmp.inputStream().use { + FileUtils.saveFileToCache(it) + }.also { + tmp.delete() + } + } else { + tmp.delete() + throw LogicException("图片资源下载失败: ${it.image.url}") + } + } + ImageElement.DataCase.DATA_NOT_SET -> throw IllegalArgumentException("ImageElement data is not set") + } + + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + BitmapFactory.decodeFile(file.absolutePath, options) + val exifInterface = ExifInterface(file.absolutePath) + val orientation = exifInterface.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_UNDEFINED + ) + val picWidth: Int + val picHeight: Int + if (orientation != ExifInterface.ORIENTATION_ROTATE_90 && orientation != ExifInterface.ORIENTATION_ROTATE_270) { + picWidth = options.outWidth + picHeight = options.outHeight + } else { + picWidth = options.outHeight + picHeight = options.outWidth + } + + val fileInfo = NtV2RichMediaSvc.tryUploadResourceByNt( + chatType = contact.chatType, + elementType = MsgConstant.KELEMTYPEPIC, + resources = arrayListOf(file), + timeout = 30.seconds + ).getOrThrow().first() + + runCatching { + fileInfo.uuid.toUInt() + }.onFailure { + NtV2RichMediaSvc.requestUploadNtPic(file, fileInfo.md5, fileInfo.sha, fileInfo.fileName, picWidth.toUInt(), picHeight.toUInt(), 5) { + when(contact.chatType) { + MsgConstant.KCHATTYPEGROUP -> { + sceneType = 2u + grp = GroupUserInfo(fetchGroupResUploadTo().toULong()) + } + MsgConstant.KCHATTYPEC2C -> { + sceneType = 1u + c2c = C2CUserInfo( + accountType = 2u, + uid = contact.peerUid + ) + } + else -> error("不支持的合并转发图片类型") + } + }.onFailure { + LogCenter.log("获取MultiMedia图片信息失败: $it", Level.ERROR) + }.onSuccess { + //LogCenter.log({ "获取MultiMedia图片信息成功: ${it.hashCode()}" }, Level.INFO) + elems.add(Elem( + commonElem = CommonElem( + serviceType = 48, + businessType = 10, + elem = it.msgInfo!!.toByteArray() + ) + )) + } + }.onSuccess { uuid -> + elems.add(when (contact.chatType) { + MsgConstant.KCHATTYPEGROUP -> Elem( + customFace = CustomFace( + filePath = fileInfo.fileName, + fileId = uuid, + serverIp = 0u, + serverPort = 0u, + fileType = FileUtils.getPicType(file).toUInt(), + useful = 1u, + md5 = fileInfo.md5.hex2ByteArray(), + bizType = 0u, + imageType = FileUtils.getPicType(file).toUInt(), + width = picWidth.toUInt(), + height = picHeight.toUInt(), + size = fileInfo.fileSize.toUInt(), + origin = isOriginal, + thumbWidth = 0u, + thumbHeight = 0u, + pbReserve = CustomFace.Companion.PbReserve( + field1 = 0, + field3 = 0, + field4 = 0, + field10 = 0, + field21 = CustomFace.Companion.Object1( + field1 = 0, + field2 = "", + field3 = 0, + field4 = 0, + field5 = 0, + md5Str = fileInfo.md5 + ) + ) + ) + ) + MsgConstant.KCHATTYPEC2C -> Elem( + notOnlineImage = NotOnlineImage( + filePath = fileInfo.fileName, + fileLen = fileInfo.fileSize.toUInt(), + downloadPath = fileInfo.uuid, + imgType = FileUtils.getPicType(file).toUInt(), + picMd5 = fileInfo.md5.hex2ByteArray(), + picHeight = picWidth.toUInt(), + picWidth = picHeight.toUInt(), + resId = fileInfo.uuid, + original = isOriginal, // true + pbReserve = NotOnlineImage.Companion.PbReserve( + field1 = 0, + field3 = 0, + field4 = 0, + field10 = 0, + field20 = NotOnlineImage.Companion.Object1( + field1 = 0, + field2 = "", + field3 = 0, + field4 = 0, + field5 = 0, + field7 = "", + ), + md5Str = fileInfo.md5 + ) + ) + ) + else -> throw LogicException("Not supported chatType($contact) for PictureMsg") + }) + } + + summary.append("[图片]") + } + ElementType.VOICE -> throw UnsupportedOperationException("Unsupported ElementType.VOICE") + ElementType.VIDEO -> throw UnsupportedOperationException("Unsupported ElementType.VIDEO") + ElementType.BASKETBALL -> throw UnsupportedOperationException("Unsupported ElementType.BASKETBALL") + ElementType.DICE -> { + val elem = Elem( + commonElem = CommonElem( + serviceType = 37, + elem = QFaceExtra( + packId = "1", + stickerId = "33", + faceId = 358, + field4 = 1, + field5 = 2, + result = "", + faceText = "/骰子", + field9 = 1 + ).toByteArray(), + businessType = 2 + ) + ) + elems.add(elem) + summary .append( "[骰子]" ) + } + ElementType.RPS -> { + val elem = Elem( + commonElem = CommonElem( + serviceType = 37, + elem = QFaceExtra( + packId = "1", + stickerId = "34", + faceId = 359, + field4 = 1, + field5 = 2, + result = "", + faceText = "/包剪锤", + field9 = 1 + ).toByteArray(), + businessType = 1 + ) + ) + elems.add(elem) + summary .append( "[包剪锤]" ) + } + ElementType.POKE -> { + val elem = Elem( + commonElem = CommonElem( + serviceType = 2, + elem = PokeExtra( + type = it.poke.type, + field7 = 0, + field8 = 0 + ).toByteArray(), + businessType = it.poke.id + ) + ) + elems.add(elem) + summary .append( "[戳一戳]" ) + } + ElementType.MUSIC -> throw UnsupportedOperationException("Unsupported ElementType.MUSIC") + ElementType.WEATHER -> { + var code = it.weather.code.toIntOrNull() + if (code == null) { + val city = it.weather.city + WeatherHelper.searchCity(city).onFailure { + LogCenter.log("无法获取城市天气: $city", Level.ERROR) + }.getOrNull()?.firstOrNull()?.let { + code = it.adcode + } + } + + if (code != null) { + val weatherCard = WeatherHelper.fetchWeatherCard(code!!).getOrThrow() + val elem = Elem( + lightApp = LightAppElem( + data = byteArrayOf(1) + DeflateTools.compress( + weatherCard["weekStore"] + .asJsonObject["share"].asString.toByteArray() + ) + ) + ) + elems.add(elem) + summary .append( "[天气卡片]" ) + } else { + throw LogicException("无法获取城市天气") + } + } + ElementType.LOCATION -> throw UnsupportedOperationException("Unsupported ElementType.LOCATION") + ElementType.SHARE -> throw UnsupportedOperationException("Unsupported ElementType.SHARE") + ElementType.GIFT -> throw UnsupportedOperationException("Unsupported ElementType.GIFT") + ElementType.MARKET_FACE -> throw UnsupportedOperationException("Unsupported ElementType.MARKET_FACE") + ElementType.FORWARD -> { + val resId = it.forward.id + val filename = UUID.randomUUID().toString().uppercase() + var content = it.forward.summary + val descriptions = it.forward.description + var news = descriptions?.split("\n")?.map { "text" to it } + + if (news == null || content == null) { + val forwardMsg = MessageHelper.getForwardMsg(resId).getOrThrow() + if (news == null) { + news = forwardMsg.map { + "text" to it.sender.nickName + ": " + descriptions + } + } + if (content == null) { + content = "查看${forwardMsg.size}条转发消息" + } + } + + val json = mapOf( + "app" to "com.tencent.multimsg", + "config" to mapOf( + "autosize" to 1, + "forward" to 1, + "round" to 1, + "type" to "normal", + "width" to 300 + ), + "desc" to "[聊天记录]", + "extra" to mapOf( + "filename" to filename, + "tsum" to 2 + ).json.toString(), + "meta" to mapOf( + "detail" to mapOf( + "news" to news, + "resid" to resId, + "source" to "群聊的聊天记录", + "summary" to content, + "uniseq" to filename + ) + ), + "prompt" to "[聊天记录]", + "ver" to "0.0.0.5", + "view" to "contact" + ) + val elem = Elem( + lightApp = LightAppElem( + data = byteArrayOf(1) + DeflateTools.compress(json.json.toString().toByteArray()) + ) + ) + elems.add(elem) + summary.append( "[聊天记录]" ) + } + ElementType.CONTACT -> throw UnsupportedOperationException("Unsupported ElementType.CONTACT") + ElementType.JSON -> { + val elem = Elem( + lightApp = LightAppElem( + data = byteArrayOf(1) + DeflateTools.compress(it.json.json.toByteArray()) + ) + ) + elems.add(elem) + summary .append( "[Json消息]" ) + } + ElementType.XML -> throw UnsupportedOperationException("Unsupported ElementType.XML") + ElementType.FILE -> throw UnsupportedOperationException("Unsupported ElementType.FILE") + ElementType.MARKDOWN -> { + val elem = Elem( + commonElem = CommonElem( + serviceType = 45, + elem = MarkdownExtra(it.markdown.markdown).toByteArray(), + businessType = 1 + ) + ) + elems.add(elem) + summary.append("[Markdown消息]") + } + ElementType.BUTTON -> { + val elem = Elem( + commonElem = CommonElem( + serviceType = 46, + elem = ButtonExtra( + field1 = Object1( + rows = it.button.rowsList.map { row -> + Row(buttons = row.buttonsList.map { button -> + val renderData = button.renderData + val action = button.action + val permission = action.permission + Button( + id = button.id, + renderData = RenderData( + label = renderData.label, + visitedLabel = renderData.visitedLabel, + style = renderData.style + ), + action = Action( + type = action.type, + permission = Permission( + type = permission.type, + specifyRoleIds = permission.roleIdsList, + specifyUserIds = permission.userIdsList + ), + unsupportTips = action.unsupportedTips, + data = action.data, + reply = action.reply, + enter = action.enter + ) + ) + }) + }, + appid = 0 + ) + ).toByteArray(), + businessType = 1 + ) + ) + elems.add(elem) + summary.append("[Button消息]") + } + ElementType.NODE -> throw UnsupportedOperationException("Unsupported ElementType.NODE") + ElementType.UNRECOGNIZED -> throw UnsupportedOperationException("Unsupported ElementType.UNRECOGNIZED") + } + } catch (e: Throwable) { + LogCenter.log("转换消息失败(Multi): ${e.stackTraceToString()}", Level.ERROR) + } + } + return Result.success(summary.toString() to RichText( + elements = elems + )) +} + diff --git a/xposed/src/main/java/qq/service/ticket/TicketHelper.kt b/xposed/src/main/java/qq/service/ticket/TicketHelper.kt new file mode 100644 index 0000000..955d583 --- /dev/null +++ b/xposed/src/main/java/qq/service/ticket/TicketHelper.kt @@ -0,0 +1,195 @@ +package qq.service.ticket + +import com.tencent.guild.api.transfile.IGuildTransFileApi +import com.tencent.mobileqq.app.QQAppInterface +import com.tencent.mobileqq.pskey.oidb.cmd0x102a.oidb_cmd0x102a +import com.tencent.mobileqq.qroute.QRoute +import com.tencent.qqnt.kernel.nativeinterface.BigDataTicket +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.get +import io.ktor.client.request.header +import moe.fuqiuluo.shamrock.tools.GlobalClient +import moe.fuqiuluo.shamrock.tools.slice +import mqq.app.MobileQQ +import mqq.manager.TicketManager +import oicq.wlogin_sdk.request.Ticket +import qq.service.QQInterfaces +import tencent.im.oidb.oidb_sso + +internal object TicketHelper: QQInterfaces() { + object SigType { + const val WLOGIN_A5 = 2 + const val WLOGIN_RESERVED = 16 + const val WLOGIN_STWEB = 32 // TLV 103 + const val WLOGIN_A2 = 64 + const val WLOGIN_ST = 128 + const val WLOGIN_AQSIG = 2097152 + const val WLOGIN_D2 = 262144 + const val WLOGIN_DA2 = 33554432 + const val WLOGIN_LHSIG = 4194304 + const val WLOGIN_LSKEY = 512 + const val WLOGIN_OPENKEY = 16384 + const val WLOGIN_PAYTOKEN = 8388608 + const val WLOGIN_PF = 16777216 + const val WLOGIN_PSKEY = 1048576 + const val WLOGIN_PT4Token = 134217728 + const val WLOGIN_QRPUSH = 67108864 + const val WLOGIN_SID = 524288 + const val WLOGIN_SIG64 = 8192 + const val WLOGIN_SKEY = 4096 + const val WLOGIN_TOKEN = 32768 + const val WLOGIN_VKEY = 131072 + + val ALL_TICKET = arrayOf( + WLOGIN_A5, WLOGIN_RESERVED, WLOGIN_STWEB, WLOGIN_A2, WLOGIN_ST, WLOGIN_AQSIG, WLOGIN_D2, WLOGIN_DA2, + WLOGIN_LHSIG, WLOGIN_LSKEY, WLOGIN_OPENKEY, WLOGIN_PAYTOKEN, WLOGIN_PF, WLOGIN_PSKEY, WLOGIN_PT4Token, + WLOGIN_QRPUSH, WLOGIN_SID, WLOGIN_SIG64, WLOGIN_SKEY, WLOGIN_TOKEN, WLOGIN_VKEY + ) + } + + inline fun getUin(): String { + return app.currentUin.ifBlank { "0" } + } + + fun getUid(): String { + return app.currentUid.ifBlank { "u_" } + } + + inline fun getNickname(): String { + return app.currentNickname + } + + fun getCookie(): String { + val uin = getUin() + val skey = getRealSkey(uin) + val pskey = getPSKey(uin) + return "uin=o$uin; skey=$skey; p_uin=o$uin; p_skey=$pskey" + } + + suspend fun getCookie(domain: String): String { + val uin = getUin() + val skey = getRealSkey(uin) + val pskey = getPSKey(uin, domain) ?: "" + val pt4token = getPt4Token(uin, domain) ?: "" + return "uin=o$uin; skey=$skey; p_uin=o$uin; p_skey=$pskey; pt4_token=$pt4token" + } + + fun getBigdataTicket(): BigDataTicket? { + return runCatching { + QRoute.api(IGuildTransFileApi::class.java).bigDataTicket?.let { + BigDataTicket(it.getSessionKey(), it.getSessionSig()) + } + }.getOrNull() + } + + fun getCSRF(pskey: String = getPSKey(getUin())): String { + if (pskey.isEmpty()) { + return "0" + } + var v = 5381 + for (element in pskey) { + v += ((v shl 5) + element.code.toLong()).toInt() + } + return (v and Int.MAX_VALUE).toString() + } + + suspend fun getCSRF(uin: String, domain: String): String { + // 是不是要用Skey? + return getBkn(getPSKey(uin, domain) ?: getSKey(uin)) + } + + fun getBkn(arg: String): String { + var v: Long = 5381 + for (element in arg) { + v += (v shl 5 and 2147483647L) + element.code.toLong() + } + return (v and 2147483647L).toString() + } + + fun getTicket(uin: String, id: Int): Ticket? { + require(app is QQAppInterface) + return (app.getManager(QQAppInterface.TICKET_MANAGER) as TicketManager).getLocalTicket(uin, id) + } + + fun getStWeb(uin: String): String { + require(app is QQAppInterface) + return (app.getManager(QQAppInterface.TICKET_MANAGER) as TicketManager).getStweb(uin) + } + + fun getSKey(uin: String): String { + require(app is QQAppInterface) + return (app.getManager(QQAppInterface.TICKET_MANAGER) as TicketManager).getRealSkey(uin) + } + + fun getRealSkey(uin: String): String { + require(app is QQAppInterface) + return (app.getManager(QQAppInterface.TICKET_MANAGER) as TicketManager).getRealSkey(uin) + } + + fun getPSKey(uin: String): String { + require(app is QQAppInterface) + val manager = (app.getManager(QQAppInterface.TICKET_MANAGER) as TicketManager) + manager.reloadCache(MobileQQ.getContext()) + return manager.getSuperkey(uin) ?: "" + } + + suspend fun getLessPSKey(vararg domain: String): Result> { + val req = oidb_cmd0x102a.GetPSkeyRequest() + req.domains.set(domain.toList()) + val fromServiceMsg = sendOidbAW("OidbSvcTcp.0x102a", 4138, 0, req.toByteArray()) + ?: return Result.failure(Exception("getLessPSKey failed")) + if (fromServiceMsg.wupBuffer == null) return Result.failure(Exception("getLessPSKey failed: no response")) + val body = oidb_sso.OIDBSSOPkg() + body.mergeFrom(fromServiceMsg.wupBuffer.slice(4)) + val rsp = oidb_cmd0x102a.GetPSkeyResponse().mergeFrom(body.bytes_bodybuffer.get().toByteArray()) + return Result.success(rsp.private_keys.get()) + } + + suspend fun getPSKey(uin: String, domain: String): String? { + require(app is QQAppInterface) + return (app.getManager(QQAppInterface.TICKET_MANAGER) as TicketManager).getPskey(uin, domain).let { + if (it.isNullOrBlank()) + getLessPSKey(domain).getOrNull()?.firstOrNull()?.key?.get() + else it + } + } + + fun getPt4Token(uin: String, domain: String): String? { + require(app is QQAppInterface) + return (app.getManager(QQAppInterface.TICKET_MANAGER) as TicketManager).getPt4Token(uin, domain) + } + + suspend fun getHttpCookies(appid: String, daid: String, jumpurl: String): String? { + val client = HttpClient { + followRedirects = false + + install(HttpTimeout) { + requestTimeoutMillis = 15000 + connectTimeoutMillis = 15000 + socketTimeoutMillis = 15000 + } + } + val uin = getUin() + val clientkey = getStWeb(uin) + var url = "https://ui.ptlogin2.qq.com/cgi-bin/login?pt_hide_ad=1&style=9&appid=$appid&pt_no_auth=1&pt_wxtest=1&daid=$daid&s_url=$jumpurl" + var cookie = client.get(url).headers.getAll("Set-Cookie")?.joinToString(";") + url = "https://ssl.ptlogin2.qq.com/jump?u1=$jumpurl&pt_report=1&daid=$daid&style=9&keyindex=19&clientuin=$uin&clientkey=$clientkey" + client.get(url) { + header("Cookie", cookie) + }.let { + cookie = it.headers.getAll("Set-Cookie")?.joinToString(";") + url = it.headers["Location"].toString() + } + cookie = client.get(url).headers.getAll("Set-Cookie")?.joinToString(";") + val extractedCookie = StringBuilder() + val cookies = cookie?.split(";") + cookies?.filter { cookie -> + val cookiePair = cookie.trim().split("=") + cookiePair.size == 2 && cookiePair[1].isNotBlank() && cookiePair[0].trim() in listOf("uin", "skey", "p_uin", "p_skey", "pt4_token") + }?.forEach { + extractedCookie.append("$it; ") + } + return extractedCookie.toString().trim() + } +} \ No newline at end of file