Shamrock: 精华消息支持

Signed-off-by: 白池 <whitechi73@outlook.com>
This commit is contained in:
白池 2024-03-18 11:49:38 +08:00
parent 6c9b282c6a
commit ee5fcc3403
23 changed files with 1536 additions and 20 deletions

View File

@ -1,7 +1,11 @@
package kritor.service
import kotlin.reflect.KClass
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.FUNCTION)
annotation class Grpc(
val serviceName: String,
val funcName: String
val funcName: String,
)

View File

@ -118,9 +118,7 @@ private fun APIInfoCard(
text = rpcAddress,
hint = "请输入回调地址",
error = "输入的地址不合法",
checker = {
it.isEmpty() || it.contains(":")
},
checker = { true },
confirm = {
ShamrockConfig[ctx, RPCAddress] = rpcAddress.value
AppRuntime.log("设置回调RPC地址为[${rpcAddress.value}]。")

View File

@ -0,0 +1,96 @@
@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 {
private val subPackage = arrayOf("contact", "core", "file", "friend", "group", "message", "web")
override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver.getSymbolsWithAnnotation(Grpc::class.qualifiedName!!)
val actions = (symbols as Sequence<KSFunctionDeclaration>).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.*")
.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()
}
}

View File

@ -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
)
}
}

View File

@ -34,7 +34,7 @@ active_ticket=
enable_self_message=false
# 旧BDH兼容开关
enable_old_bdh=false
enable_old_bdh=true
# 反JVM调用栈跟踪
anti_jvm_trace=true

View File

@ -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<Response>()
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()
}
}

View File

@ -0,0 +1,6 @@
package kritor.handlers
internal object GrpcHandlers {
}

View File

@ -26,6 +26,8 @@ class KritorServer(
.addService(GroupFileService)
.addService(MessageService)
.addService(EventService)
.addService(ForwardMessageService)
.addService(WebService)
.build()!!
fun start(block: Boolean = false) {

View File

@ -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
}
}
}

View File

@ -118,6 +118,7 @@ internal object GroupFileService: GroupFileServiceGrpcKt.GroupFileServiceCorouti
return renameFolderResponse { }
}
@Grpc("GroupFileService", "GetFileSystemInfo")
override suspend fun getFileSystemInfo(request: GetFileSystemInfoRequest): GetFileSystemInfoResponse {
return getGroupFileSystemInfo(request.groupId)
}

View File

@ -61,7 +61,6 @@ 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
import moe.fuqiuluo.shamrock.helper.TroopHonorHelper.decodeHonor
import moe.fuqiuluo.shamrock.tools.ifNullOrEmpty
import qq.service.contact.ContactHelper
@ -89,7 +88,7 @@ internal object GroupService: GroupServiceGrpcKt.GroupServiceCoroutineImplBase()
}
}
@Grpc("GroupService", "PokeMember")
@Grpc("GroupService", "PokeMember", )
override suspend fun pokeMember(request: PokeMemberRequest): PokeMemberResponse {
GroupHelper.pokeMember(request.groupId, when(request.targetCase!!) {
PokeMemberRequest.TargetCase.TARGET_UIN -> request.targetUin

View File

@ -10,6 +10,8 @@ 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
@ -18,6 +20,7 @@ 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
@ -25,6 +28,7 @@ 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
@ -118,4 +122,15 @@ object KritorService: KritorServiceGrpcKt.KritorServiceCoroutineImplBase() {
}
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
}
}
}
}

View File

@ -9,6 +9,10 @@ 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
@ -25,8 +29,15 @@ 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
@ -36,6 +47,8 @@ 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
@ -63,6 +76,7 @@ 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!!) {
@ -84,6 +98,7 @@ internal object MessageService: MessageServiceGrpcKt.MessageServiceCoroutineImpl
}
}
@Grpc("MessageService", "SendMessageByResId")
override suspend fun sendMessageByResId(request: SendMessageByResIdRequest): SendMessageByResIdResponse {
val contact = request.contact
val req = PbSendMsgReq(
@ -113,6 +128,7 @@ internal object MessageService: MessageServiceGrpcKt.MessageServiceCoroutineImpl
return sendMessageByResIdResponse { }
}
@Grpc("MessageService", "ClearMessages")
override suspend fun clearMessages(request: ClearMessagesRequest): ClearMessagesResponse {
val contact = request.contact
val kernelService = NTServiceFetcher.kernelService
@ -131,6 +147,7 @@ internal object MessageService: MessageServiceGrpcKt.MessageServiceCoroutineImpl
return clearMessagesResponse { }
}
@Grpc("MessageService", "RecallMessage")
override suspend fun recallMessage(request: RecallMessageRequest): RecallMessageResponse {
val contact = request.contact.let {
MessageHelper.generateContact(when(it.scene!!) {
@ -155,6 +172,7 @@ internal object MessageService: MessageServiceGrpcKt.MessageServiceCoroutineImpl
return recallMessageResponse {}
}
@Grpc("MessageService", "GetForwardMessages")
override suspend fun getForwardMessages(request: GetForwardMessagesRequest): GetForwardMessagesResponse {
return getForwardMessagesResponse {
MessageHelper.getForwardMsg(request.resId).onFailure {
@ -195,6 +213,7 @@ internal object MessageService: MessageServiceGrpcKt.MessageServiceCoroutineImpl
}
}
@Grpc("MessageService", "GetMessage")
override suspend fun getMessage(request: GetMessageRequest): GetMessageResponse {
val contact = request.contact.let {
MessageHelper.generateContact(when(it.scene!!) {
@ -239,6 +258,7 @@ internal object MessageService: MessageServiceGrpcKt.MessageServiceCoroutineImpl
}
}
@Grpc("MessageService", "GetMessageBySeq")
override suspend fun getMessageBySeq(request: GetMessageBySeqRequest): GetMessageBySeqResponse {
val contact = request.contact.let {
MessageHelper.generateContact(when(it.scene!!) {
@ -283,6 +303,7 @@ internal object MessageService: MessageServiceGrpcKt.MessageServiceCoroutineImpl
}
}
@Grpc("MessageService", "GetHistoryMessage")
override suspend fun getHistoryMessage(request: GetHistoryMessageRequest): GetHistoryMessageResponse {
val contact = request.contact.let {
MessageHelper.generateContact(when(it.scene!!) {
@ -328,4 +349,121 @@ internal object MessageService: MessageServiceGrpcKt.MessageServiceCoroutineImpl
}
}
}
@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 { }
}
}

View File

@ -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"))
}
}
}

View File

@ -2,5 +2,5 @@ package moe.fuqiuluo.shamrock.config
object EnableOldBDH: ConfigKey<Boolean>() {
override fun name() = "enable_old_bdh"
override fun default() = false
override fun default() = true
}

View File

@ -6,8 +6,11 @@ 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
@ -17,6 +20,7 @@ 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 {
@ -32,6 +36,21 @@ internal class InitRemoteService : IAction {
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)

View File

@ -65,7 +65,7 @@ import kotlin.time.Duration.Companion.seconds
internal object NtV2RichMediaSvc: QQInterfaces() {
private val requestIdSeq = atomic(1L)
private fun fetchGroupResUploadTo(): String {
fun fetchGroupResUploadTo(): String {
return ShamrockConfig[ResourceGroup].ifNullOrEmpty { "100000000" }!!
}

View File

@ -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<ForwardMessageBody>,
): Result<ForwardElement> {
var i = -1
val desc = MutableList(messages.size) { "" }
val forwardMsg = mutableMapOf<String, String>()
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<LongMsgRsp>()
}.getOrElse {
fromServiceMsg.wupBuffer.decodeProtobuf<LongMsgRsp>()
}
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")
})
}
}

View File

@ -64,8 +64,6 @@ internal data class EssenceMessage(
@SerialName("operator_id") val operatorId: Long,
@SerialName("operator_nick") val operatorNick: String,
@SerialName("operator_time") val operatorTime: Long,
@SerialName("message_id") var messageId: Int,
@SerialName("message_seq") val messageSeq: Int,
@SerialName("real_id") val realId: Int,
@SerialName("message_seq") val messageSeq: Long,
@SerialName("message_content") val messageContent: JsonElement,
)

View File

@ -11,11 +11,26 @@ 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
@ -28,14 +43,104 @@ 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<List<EssenceMessage>>{
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<JsonElement>(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

View File

@ -30,6 +30,7 @@ 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
@ -94,6 +95,7 @@ object NtMsgConvertor {
SHARE to ::shareConvertor,
CONTACT to ::contactConvertor,
JSON to ::jsonConvertor,
FORWARD to ::forwardConvertor,
MARKDOWN to ::markdownConvertor,
BUTTON to ::buttonConvertor,
)
@ -835,4 +837,58 @@ object NtMsgConvertor {
elem.inlineKeyboardElement = InlineKeyboardElement(rows, 0)
return Result.success(elem)
}
private suspend fun forwardConvertor(contact: Contact, msgId: Long, sourceForward: Element): Result<MsgElement> {
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)
}
}

View File

@ -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<Element>.toRichText(contact: Contact): Result<Pair<String, RichText>> {
val summary = StringBuilder()
val elems = ArrayList<Elem>()
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
))
}

View File

@ -48,19 +48,15 @@ internal object TicketHelper: QQInterfaces() {
)
}
fun getUin(): String {
inline fun getUin(): String {
return app.currentUin.ifBlank { "0" }
}
fun getLongUin(): Long {
return app.longAccountUin
}
fun getUid(): String {
return app.currentUid.ifBlank { "u_" }
}
fun getNickname(): String {
inline fun getNickname(): String {
return app.currentNickname
}
@ -123,7 +119,7 @@ internal object TicketHelper: QQInterfaces() {
fun getSKey(uin: String): String {
require(app is QQAppInterface)
return (app.getManager(QQAppInterface.TICKET_MANAGER) as TicketManager).getSkey(uin)
return (app.getManager(QQAppInterface.TICKET_MANAGER) as TicketManager).getRealSkey(uin)
}
fun getRealSkey(uin: String): String {