diff --git a/xposed/src/main/java/kritor/service/GroupService.kt b/xposed/src/main/java/kritor/service/GroupService.kt index 8e0d5b8d..9563b50f 100644 --- a/xposed/src/main/java/kritor/service/GroupService.kt +++ b/xposed/src/main/java/kritor/service/GroupService.kt @@ -188,13 +188,13 @@ internal object GroupService: GroupServiceGrpcKt.GroupServiceCoroutineImplBase() ) } - GroupHelper.setGroupUniqueTitle(request.groupId, when(request.targetCase!!) { + 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") ) - }, request.uniqueTitle) + }.toString(), request.uniqueTitle) return setGroupUniqueTitleResponse { } } @@ -253,13 +253,13 @@ internal object GroupService: GroupServiceGrpcKt.GroupServiceCoroutineImplBase() @Grpc("GroupService", "GetGroupMemberInfo") override suspend fun getGroupMemberInfo(request: GetGroupMemberInfoRequest): GetGroupMemberInfoResponse { - val memberInfo = GroupHelper.getTroopMemberInfoByUin(request.groupId, when(request.targetCase!!) { + 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") ) - }).onFailure { + }.toString()).onFailure { throw StatusRuntimeException(Status.INTERNAL.withDescription("unable to get group member info").withCause(it)) }.getOrThrow() return getGroupMemberInfoResponse { diff --git a/xposed/src/main/java/kritor/service/MessageService.kt b/xposed/src/main/java/kritor/service/MessageService.kt index 37769129..efb3e164 100644 --- a/xposed/src/main/java/kritor/service/MessageService.kt +++ b/xposed/src/main/java/kritor/service/MessageService.kt @@ -1,7 +1,331 @@ 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.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.clearMessagesResponse +import io.kritor.message.contact +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 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() { + 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() + } + } + + 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 { } + } + + 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 { } + } + + 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 {} + } + + 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) + } + }) + } + } + } + + 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)) + } + } + } + + 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)) + } + } + } + + 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)) + }) + } + } + } } \ 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 index 9c18f34a..a90a273a 100644 --- a/xposed/src/main/java/moe/fuqiuluo/shamrock/internals/GlobalEventTransmitter.kt +++ b/xposed/src/main/java/moe/fuqiuluo/shamrock/internals/GlobalEventTransmitter.kt @@ -41,7 +41,7 @@ import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import qq.service.QQInterfaces -import qq.service.msg.toKritorMessages +import qq.service.msg.toKritorEventMessages internal object GlobalEventTransmitter: QQInterfaces() { private val messageEventFlow by lazy { @@ -80,7 +80,7 @@ internal object GlobalEventTransmitter: QQInterfaces() { this.uid = record.senderUid this.nick = record.sendNickName } - this.elements.addAll(elements.toKritorMessages(record)) + this.elements.addAll(elements.toKritorEventMessages(record)) }) return true } @@ -104,7 +104,7 @@ internal object GlobalEventTransmitter: QQInterfaces() { this.uid = record.senderUid this.nick = record.sendNickName } - this.elements.addAll(elements.toKritorMessages(record)) + this.elements.addAll(elements.toKritorEventMessages(record)) }) return true } @@ -130,7 +130,7 @@ internal object GlobalEventTransmitter: QQInterfaces() { this.uid = record.senderUid this.nick = record.sendNickName } - this.elements.addAll(elements.toKritorMessages(record)) + this.elements.addAll(elements.toKritorEventMessages(record)) }) return true } @@ -146,15 +146,15 @@ internal object GlobalEventTransmitter: QQInterfaces() { this.messageSeq = record.msgSeq this.contact = contact { this.scene = scene - this.peer = record.channelId.toString() - this.subPeer = record.guildId + 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.toKritorMessages(record)) + this.elements.addAll(elements.toKritorEventMessages(record)) }) return true } diff --git a/xposed/src/main/java/qq/service/QQInterfaces.kt b/xposed/src/main/java/qq/service/QQInterfaces.kt index 2e6b0478..22c3169f 100644 --- a/xposed/src/main/java/qq/service/QQInterfaces.kt +++ b/xposed/src/main/java/qq/service/QQInterfaces.kt @@ -83,6 +83,17 @@ abstract class QQInterfaces { 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, 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 00000000..5b02d6c2 --- /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/Transfer.kt b/xposed/src/main/java/qq/service/bdh/Transfer.kt new file mode 100644 index 00000000..f3003cdb --- /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/contact/ContactExt.kt b/xposed/src/main/java/qq/service/contact/ContactExt.kt new file mode 100644 index 00000000..b2b79622 --- /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 index a516495e..4bc7c74b 100644 --- a/xposed/src/main/java/qq/service/contact/ContactHelper.kt +++ b/xposed/src/main/java/qq/service/contact/ContactHelper.kt @@ -7,11 +7,15 @@ 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() { @@ -177,4 +181,31 @@ internal object ContactHelper: QQInterfaces() { } }[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/group/GroupHelper.kt b/xposed/src/main/java/qq/service/group/GroupHelper.kt index 7062b6c7..95436fa4 100644 --- a/xposed/src/main/java/qq/service/group/GroupHelper.kt +++ b/xposed/src/main/java/qq/service/group/GroupHelper.kt @@ -42,6 +42,7 @@ 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() } @@ -99,6 +100,61 @@ internal object GroupHelper: QQInterfaces() { 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 { @@ -318,12 +374,12 @@ internal object GroupHelper: QQInterfaces() { } } - suspend fun setGroupUniqueTitle(groupId: Long, userId: Long, title: String) { + 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) + req.uint64_group_code.set(groupId.toLong()) val memberInfo = Oidb_0x8fc.MemberInfo() - memberInfo.uint64_uin.set(userId) + memberInfo.uint64_uin.set(userId.toLong()) memberInfo.bytes_uin_name.set(ByteStringMicro.copyFromUtf8(localMemberInfo.troopnick.ifEmpty { localMemberInfo.troopremark.ifNullOrEmpty { "" } })) @@ -468,13 +524,13 @@ internal object GroupHelper: QQInterfaces() { } suspend fun getTroopMemberInfoByUin( - groupId: Long, - uin: Long, + groupId: String, + uin: String, refresh: Boolean = false ): Result { val service = app.getRuntimeService(ITroopMemberInfoService::class.java, "all") - var info = service.getTroopMember(groupId.toString(), uin.toString()) - if (refresh || !service.isMemberInCache(groupId.toString(), uin.toString()) || info == null || info.troopnick == null) { + 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) { @@ -488,8 +544,8 @@ internal object GroupHelper: QQInterfaces() { try { if (info != null && (info.alias == null || info.alias.isBlank())) { val req = group_member_info.ReqBody() - req.uint64_group_code.set(groupId) - req.uint64_uin.set(uin) + 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) @@ -523,8 +579,8 @@ internal object GroupHelper: QQInterfaces() { } suspend fun getTroopMemberInfoByUinViaNt( - groupId: Long, - qq: Long, + groupId: String, + qq: String, timeout: Long = 5000L ): Result { return runCatching { @@ -533,13 +589,13 @@ internal object GroupHelper: QQInterfaces() { val groupService = sessionService.groupService val info = withTimeoutOrNull(timeout) { suspendCancellableCoroutine { - groupService.getTransferableMemberInfo(groupId) { code, _, data -> + groupService.getTransferableMemberInfo(groupId.toLong()) { code, _, data -> if (code != 0) { it.resume(null) return@getTransferableMemberInfo } data.forEach { (_, info) -> - if (info.uin == qq) { + if (info.uin == qq.toLong()) { it.resume(info) return@forEach } @@ -556,21 +612,18 @@ internal object GroupHelper: QQInterfaces() { } } - private suspend fun requestTroopMemberInfo(service: ITroopMemberInfoService, groupId: Long, memberUin: Long, timeout: Long = 10_000): Result { + private suspend fun requestTroopMemberInfo(service: ITroopMemberInfoService, groupId: String, memberUin: String, timeout: Long = 10_000): Result { val info = RefreshTroopMemberInfoLock.withLock { - val groupIdStr = groupId.toString() - val memberUinStr = memberUin.toString() - - service.deleteTroopMember(groupIdStr, memberUinStr) + service.deleteTroopMember(groupId, memberUin) requestMemberInfoV2(groupId, memberUin) requestMemberInfo(groupId, memberUin) withTimeoutOrNull(timeout) { - while (!service.isMemberInCache(groupIdStr, memberUinStr)) { + while (!service.isMemberInCache(groupId, memberUin)) { delay(200) } - return@withTimeoutOrNull service.getTroopMember(groupIdStr, memberUinStr) + return@withTimeoutOrNull service.getTroopMember(groupId, memberUin) } } return if (info != null) { @@ -580,7 +633,7 @@ internal object GroupHelper: QQInterfaces() { } } - private fun requestMemberInfo(groupId: Long, memberUin: Long) { + private fun requestMemberInfo(groupId: String, memberUin: String) { val businessHandler = app.getBusinessHandler(BusinessHandlerFactory.TROOP_MEMBER_CARD_HANDLER) if (!::METHOD_REQ_MEMBER_INFO.isInitialized) { @@ -592,10 +645,10 @@ internal object GroupHelper: QQInterfaces() { } } - METHOD_REQ_MEMBER_INFO.invoke(businessHandler, groupId, memberUin) + METHOD_REQ_MEMBER_INFO.invoke(businessHandler, groupId.toLong(), memberUin.toLong()) } - private fun requestMemberInfoV2(groupId: Long, memberUin: Long) { + private fun requestMemberInfoV2(groupId: String, memberUin: String) { val businessHandler = app.getBusinessHandler(BusinessHandlerFactory.TROOP_MEMBER_CARD_HANDLER) if (!::METHOD_REQ_MEMBER_INFO_V2.isInitialized) { @@ -607,7 +660,8 @@ internal object GroupHelper: QQInterfaces() { } } - METHOD_REQ_MEMBER_INFO_V2.invoke(businessHandler, groupId.toString(), groupUin2GroupCode(groupId).toString(), arrayListOf(memberUin.toString())) + METHOD_REQ_MEMBER_INFO_V2.invoke(businessHandler, + groupId, groupUin2GroupCode(groupId.toLong()).toString(), arrayListOf(memberUin)) } private suspend fun requestTroopMemberInfo(service: ITroopMemberInfoService, groupId: String): Result> { 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 00000000..41698c95 --- /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/NTServiceFetcher.kt b/xposed/src/main/java/qq/service/internals/NTServiceFetcher.kt index 5c6f8c8f..8e28fd74 100644 --- a/xposed/src/main/java/qq/service/internals/NTServiceFetcher.kt +++ b/xposed/src/main/java/qq/service/internals/NTServiceFetcher.kt @@ -58,6 +58,7 @@ internal object NTServiceFetcher { try { LogCenter.log("Register MSG listener successfully.") msgService.addMsgListener(AioListener) + msgService.addMsgListener(LineDevListener) // 接口缺失 暂不使用 //groupService.addKernelGroupListener(GroupEventListener) diff --git a/xposed/src/main/java/qq/service/kernel/SimpleKernelMsgListener.kt b/xposed/src/main/java/qq/service/kernel/SimpleKernelMsgListener.kt index d82a2924..d15ed47f 100644 --- a/xposed/src/main/java/qq/service/kernel/SimpleKernelMsgListener.kt +++ b/xposed/src/main/java/qq/service/kernel/SimpleKernelMsgListener.kt @@ -155,11 +155,11 @@ abstract class SimpleKernelMsgListener: IKernelMsgListener { } - override fun onKickedOffLine(kickedInfo: KickedInfo?) { + override fun onKickedOffLine(kickedInfo: KickedInfo) { } - override fun onLineDev(arrayList: ArrayList?) { + override fun onLineDev(devs: ArrayList) { } 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 00000000..d69ae658 --- /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 00000000..1d185f10 --- /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 00000000..2ac7abc2 --- /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 00000000..349915e1 --- /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 00000000..0e077767 --- /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 00000000..161888ae --- /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/MessageData.kt b/xposed/src/main/java/qq/service/msg/MessageData.kt new file mode 100644 index 00000000..6df7a32e --- /dev/null +++ b/xposed/src/main/java/qq/service/msg/MessageData.kt @@ -0,0 +1,71 @@ +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_id") var messageId: Int, + @SerialName("message_seq") val messageSeq: Int, + @SerialName("real_id") val realId: Int, + @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 index c66bb73e..8f994972 100644 --- a/xposed/src/main/java/qq/service/msg/MessageHelper.kt +++ b/xposed/src/main/java/qq/service/msg/MessageHelper.kt @@ -1,20 +1,105 @@ 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 kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull 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.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 qq.service.QQInterfaces import qq.service.contact.ContactHelper import qq.service.internals.msgService import kotlin.coroutines.resume +typealias MessageId = Long + internal object MessageHelper: QQInterfaces() { + 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("获取消息服务失败")) @@ -64,6 +149,70 @@ internal object MessageHelper: QQInterfaces() { } } + 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()) } diff --git a/xposed/src/main/java/qq/service/msg/MsgConvertor.kt b/xposed/src/main/java/qq/service/msg/MsgConvertor.kt index 9d38347b..aa2e277a 100644 --- a/xposed/src/main/java/qq/service/msg/MsgConvertor.kt +++ b/xposed/src/main/java/qq/service/msg/MsgConvertor.kt @@ -5,9 +5,7 @@ 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.AtElement import io.kritor.event.Element -import io.kritor.event.ElementKt import io.kritor.event.ImageType import io.kritor.event.Scene import io.kritor.event.atElement @@ -47,23 +45,13 @@ 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 -suspend fun NtMessages.toKritorMessages(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 -} - private object MsgConvertor { private val convertorMap = hashMapOf( MsgConstant.KELEMTYPETEXT to ::convertText, @@ -423,3 +411,16 @@ private object MsgConvertor { } } +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 00000000..44b9bbd9 --- /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 00000000..74e9581c --- /dev/null +++ b/xposed/src/main/java/qq/service/msg/NtMsgConvertor.kt @@ -0,0 +1,838 @@ +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.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, + 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) + } +} \ 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 00000000..8636a54f --- /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/ticket/TicketHelper.kt b/xposed/src/main/java/qq/service/ticket/TicketHelper.kt new file mode 100644 index 00000000..70bb8239 --- /dev/null +++ b/xposed/src/main/java/qq/service/ticket/TicketHelper.kt @@ -0,0 +1,199 @@ +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 + ) + } + + 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 { + 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).getSkey(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