package qq.service.msg import com.tencent.mobileqq.qroute.QRoute import com.tencent.qqnt.kernel.nativeinterface.MsgConstant import com.tencent.qqnt.kernel.nativeinterface.MsgElement import com.tencent.qqnt.kernel.nativeinterface.MsgRecord import com.tencent.qqnt.msg.api.IMsgService import io.kritor.common.* import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull import moe.fuqiuluo.shamrock.helper.ActionMsgException import moe.fuqiuluo.shamrock.helper.Level import moe.fuqiuluo.shamrock.helper.LogCenter import moe.fuqiuluo.shamrock.helper.db.ImageDB import moe.fuqiuluo.shamrock.helper.db.ImageMapping import moe.fuqiuluo.shamrock.tools.asJsonArray import moe.fuqiuluo.shamrock.tools.asJsonObject import moe.fuqiuluo.shamrock.tools.asString import moe.fuqiuluo.shamrock.tools.hex2ByteArray import moe.fuqiuluo.shamrock.tools.ifNullOrEmpty import moe.fuqiuluo.shamrock.tools.toHexString import moe.fuqiuluo.shamrock.utils.PlatformUtils import moe.fuqiuluo.shamrock.utils.PlatformUtils.QQ_9_0_8_VER import qq.service.bdh.RichProtoSvc import qq.service.contact.ContactHelper import kotlin.coroutines.resume /** * 将NT消息(com.tencent.qqnt.*)转换为事件消息(io.kritor.event.*)推送 */ typealias NtMessages = ArrayList typealias Convertor = suspend (MsgRecord, MsgElement) -> Result private object MsgConvertor { private val convertorMap = hashMapOf( MsgConstant.KELEMTYPETEXT to ::convertText, MsgConstant.KELEMTYPEFACE to ::convertFace, MsgConstant.KELEMTYPEPIC to ::convertImage, MsgConstant.KELEMTYPEPTT to ::convertVoice, MsgConstant.KELEMTYPEVIDEO to ::convertVideo, MsgConstant.KELEMTYPEMARKETFACE to ::convertMarketFace, MsgConstant.KELEMTYPEARKSTRUCT to ::convertStructJson, MsgConstant.KELEMTYPEREPLY to ::convertReply, //MsgConstant.KELEMTYPEGRAYTIP to ::convertGrayTips, MsgConstant.KELEMTYPEFILE to ::convertFile, MsgConstant.KELEMTYPEMARKDOWN to ::convertMarkdown, //MsgConstant.KELEMTYPEMULTIFORWARD to MsgElementConverter::convertXmlMultiMsgElem, //MsgConstant.KELEMTYPESTRUCTLONGMSG to MsgElementConverter::convertXmlLongMsgElem, MsgConstant.KELEMTYPEFACEBUBBLE to ::convertBubbleFace, MsgConstant.KELEMTYPEINLINEKEYBOARD to ::convertInlineKeyboard ) suspend fun convertText(record: MsgRecord, element: MsgElement): Result { val text = element.textElement val elem = Element.newBuilder() if (text.atType != MsgConstant.ATTYPEUNKNOWN) { elem.type = ElementType.AT elem.setAt(AtElement.newBuilder().apply { this.uid = text.atNtUid this.uin = ContactHelper.getUinByUidAsync(text.atNtUid).toLong() }) } else { elem.type = ElementType.TEXT elem.setText(TextElement.newBuilder().apply { this.text = text.content }) } return Result.success(elem.build()) } suspend fun convertFace(record: MsgRecord, element: MsgElement): Result { val face = element.faceElement val elem = Element.newBuilder() if (face.faceType == 5) { elem.type = ElementType.POKE elem.setPoke(PokeElement.newBuilder().apply { this.id = face.vaspokeId this.type = face.pokeType this.strength = face.pokeStrength }) } else { when (face.faceIndex) { 114 -> { elem.type = ElementType.BASKETBALL elem.setBasketball(BasketballElement.newBuilder().apply { this.id = face.resultId.ifNullOrEmpty { "0" }?.toInt() ?: 0 }) } 358 -> { elem.type = ElementType.DICE elem.setDice(DiceElement.newBuilder().apply { this.id = face.resultId.ifNullOrEmpty { "0" }?.toInt() ?: 0 }) } 359 -> { elem.type = ElementType.RPS elem.setRps(RpsElement.newBuilder().apply { this.id = face.resultId.ifNullOrEmpty { "0" }?.toInt() ?: 0 }) } 394 -> { elem.type = ElementType.FACE elem.setFace(FaceElement.newBuilder().apply { this.id = face.faceIndex this.isBig = face.faceType == 3 this.result = face.resultId.ifNullOrEmpty { "1" }?.toInt() ?: 1 }) } else -> { elem.type = ElementType.FACE elem.setFace(FaceElement.newBuilder().apply { this.id = face.faceIndex this.isBig = face.faceType == 3 }) } } } return Result.success(elem.build()) } suspend fun convertImage(record: MsgRecord, element: MsgElement): Result { val image = element.picElement val md5 = (image.md5HexStr ?: image.fileName .replace("{", "") .replace("}", "") .replace("-", "").split(".")[0]) .uppercase() var storeId = 0 if (PlatformUtils.getQQVersionCode() > QQ_9_0_8_VER) { storeId = image.storeID } ImageDB.getInstance().imageMappingDao().insert( ImageMapping( fileName = md5, md5 = md5, chatType = record.chatType, size = image.fileSize, sha = "", fileId = image.fileUuid, storeId = storeId, ) ) val originalUrl = image.originImageUrl ?: "" LogCenter.log({ "receive image: $image" }, Level.DEBUG) val elem = Element.newBuilder() elem.type = ElementType.IMAGE elem.setImage(ImageElement.newBuilder().apply { this.file = md5 this.url = when (record.chatType) { MsgConstant.KCHATTYPEDISC, MsgConstant.KCHATTYPEGROUP -> RichProtoSvc.getGroupPicDownUrl( originalUrl = originalUrl, md5 = md5, fileId = image.fileUuid, width = image.picWidth.toUInt(), height = image.picHeight.toUInt(), sha = "", fileSize = image.fileSize.toULong(), peer = record.peerUin.toString() ) MsgConstant.KCHATTYPEC2C -> RichProtoSvc.getC2CPicDownUrl( originalUrl = originalUrl, md5 = md5, fileId = image.fileUuid, width = image.picWidth.toUInt(), height = image.picHeight.toUInt(), sha = "", fileSize = image.fileSize.toULong(), peer = record.senderUin.toString(), storeId = storeId ) MsgConstant.KCHATTYPEGUILD -> RichProtoSvc.getGuildPicDownUrl( originalUrl = originalUrl, md5 = md5, fileId = image.fileUuid, width = image.picWidth.toUInt(), height = image.picHeight.toUInt(), sha = "", fileSize = image.fileSize.toULong(), peer = record.channelId.ifNullOrEmpty { record.peerUin.toString() } ?: "0", subPeer = record.guildId ?: "0" ) else -> throw UnsupportedOperationException("Not supported chat type: ${record.chatType}") } this.type = if (image.isFlashPic == true) ImageType.FLASH else if (image.original) ImageType.ORIGIN else ImageType.COMMON this.subType = image.picSubType }) return Result.success(elem.build()) } suspend fun convertVoice(record: MsgRecord, element: MsgElement): Result { val ptt = element.pttElement val elem = Element.newBuilder() val md5 = if (ptt.fileName.startsWith("silk")) ptt.fileName.substring(5) else ptt.md5HexStr elem.type = ElementType.VOICE elem.setVoice(VoiceElement.newBuilder().apply { this.url = when (record.chatType) { MsgConstant.KCHATTYPEC2C -> RichProtoSvc.getC2CPttDownUrl("0", ptt.fileUuid) MsgConstant.KCHATTYPEGROUP, MsgConstant.KCHATTYPEGUILD -> RichProtoSvc.getGroupPttDownUrl( "0", md5.hex2ByteArray(), ptt.fileUuid ) else -> throw UnsupportedOperationException("Not supported chat type: ${record.chatType}") } this.file = md5 this.magic = ptt.voiceChangeType != MsgConstant.KPTTVOICECHANGETYPENONE }) return Result.success(elem.build()) } suspend fun convertVideo(record: MsgRecord, element: MsgElement): Result { val video = element.videoElement val elem = Element.newBuilder() val md5 = if (video.fileName.contains("/")) { video.videoMd5.takeIf { !it.isNullOrEmpty() }?.hex2ByteArray() ?: video.fileName.split("/").let { it[it.size - 2].hex2ByteArray() } } else video.fileName.split(".")[0].hex2ByteArray() elem.type = ElementType.VIDEO elem.setVideo(VideoElement.newBuilder().apply { this.file = md5.toHexString() this.url = when (record.chatType) { MsgConstant.KCHATTYPEGROUP -> RichProtoSvc.getGroupVideoDownUrl("0", md5, video.fileUuid) MsgConstant.KCHATTYPEC2C -> RichProtoSvc.getC2CVideoDownUrl("0", md5, video.fileUuid) MsgConstant.KCHATTYPEGUILD -> RichProtoSvc.getGroupVideoDownUrl("0", md5, video.fileUuid) else -> throw UnsupportedOperationException("Not supported chat type: ${record.chatType}") } }) return Result.success(elem.build()) } suspend fun convertMarketFace(record: MsgRecord, element: MsgElement): Result { val marketFace = element.marketFaceElement val elem = Element.newBuilder() elem.type = ElementType.MARKET_FACE elem.setMarketFace(MarketFaceElement.newBuilder().apply { this.id = marketFace.emojiId.lowercase() }) return Result.success(elem.build()) } suspend fun convertStructJson(record: MsgRecord, element: MsgElement): Result { val data = element.arkElement.bytesData.asJsonObject val elem = Element.newBuilder() when (data["app"].asString) { "com.tencent.multimsg" -> { val info = data["meta"].asJsonObject["detail"].asJsonObject elem.type = ElementType.FORWARD elem.setForward(ForwardElement.newBuilder().apply { this.resId = 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.type = ElementType.CONTACT elem.setContact(ContactElement.newBuilder().apply { 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.type = ElementType.CONTACT elem.setContact(ContactElement.newBuilder().apply { this.scene = Scene.FRIEND this.peer = info["jumpUrl"].asString.split("uin=")[1] }) } "com.tencent.map" -> { val info = data["meta"].asJsonObject["Location.Search"].asJsonObject elem.type = ElementType.LOCATION elem.setLocation(LocationElement.newBuilder().apply { this.lat = info["lat"].asString.toFloat() this.lon = info["lng"].asString.toFloat() this.address = info["address"].asString this.title = info["name"].asString }) } else -> { elem.type = ElementType.JSON elem.setJson(JsonElement.newBuilder().apply { this.json = data.toString() }) } } return Result.success(elem.build()) } suspend fun convertReply(record: MsgRecord, element: MsgElement): Result { val reply = element.replyElement val elem = Element.newBuilder() elem.type = ElementType.REPLY elem.setReply(ReplyElement.newBuilder().apply { val msgSeq = reply.replayMsgSeq val contact = MessageHelper.generateContact(record) val sourceRecords = withTimeoutOrNull(3000) { suspendCancellableCoroutine { QRoute.api(IMsgService::class.java) .getMsgsBySeqAndCount(contact, msgSeq, 1, true) { _, _, records -> it.resume(records) } } } if (sourceRecords.isNullOrEmpty()) { LogCenter.log("无法查询到回复的消息ID: seq = $msgSeq", Level.WARN) this.messageId = reply.replayMsgId } else { this.messageId = sourceRecords.first().msgId } }) return Result.success(elem.build()) } suspend fun convertFile(record: MsgRecord, element: MsgElement): Result { val fileMsg = element.fileElement val fileName = fileMsg.fileName val fileSize = fileMsg.fileSize val expireTime = fileMsg.expireTime ?: 0 val fileId = fileMsg.fileUuid val bizId = fileMsg.fileBizId ?: 0 val fileSubId = fileMsg.fileSubId ?: "" val url = when (record.chatType) { MsgConstant.KCHATTYPEC2C -> RichProtoSvc.getC2CFileDownUrl(fileId, fileSubId) MsgConstant.KCHATTYPEGUILD -> RichProtoSvc.getGuildFileDownUrl( record.guildId, record.channelId, fileId, bizId ) else -> RichProtoSvc.getGroupFileDownUrl(record.peerUin, fileId, bizId) } val elem = Element.newBuilder() elem.type = ElementType.FILE elem.setFile(FileElement.newBuilder().apply { this.name = fileName this.size = fileSize this.url = url this.expireTime = expireTime this.id = fileId this.subId = fileSubId this.biz = bizId }) return Result.success(elem.build()) } suspend fun convertMarkdown(record: MsgRecord, element: MsgElement): Result { val markdown = element.markdownElement val elem = Element.newBuilder() elem.type = ElementType.MARKDOWN elem.setMarkdown(MarkdownElement.newBuilder().apply { this.markdown = markdown.content }) return Result.success(elem.build()) } suspend fun convertBubbleFace(record: MsgRecord, element: MsgElement): Result { val bubbleFace = element.faceBubbleElement val elem = Element.newBuilder() elem.type = ElementType.BUBBLE_FACE elem.setBubbleFace(BubbleFaceElement.newBuilder().apply { this.id = bubbleFace.yellowFaceInfo.index this.count = bubbleFace.faceCount ?: 1 }) return Result.success(elem.build()) } suspend fun convertInlineKeyboard(record: MsgRecord, element: MsgElement): Result { val inlineKeyboard = element.inlineKeyboardElement val elem = Element.newBuilder() elem.type = ElementType.BUTTON elem.setButton(ButtonElement.newBuilder().apply { inlineKeyboard.rows.forEach { row -> this.addRows(ButtonRow.newBuilder().apply { row.buttons.forEach buttonsLoop@{ button -> if (button == null) return@buttonsLoop this.addButtons(Button.newBuilder().apply { this.id = button.id this.action = ButtonAction.newBuilder().apply { this.type = button.type this.permission = ButtonActionPermission.newBuilder().apply { this.type = button.permissionType button.specifyRoleIds?.let { this.addAllRoleIds(it) } button.specifyTinyids?.let { this.addAllUserIds(it) } }.build() this.unsupportedTips = button.unsupportTips ?: "" this.data = button.data ?: "" this.reply = button.isReply this.enter = button.enter }.build() this.renderData = ButtonRender.newBuilder().apply { this.label = button.label ?: "" this.visitedLabel = button.visitedLabel ?: "" this.style = button.style }.build() }) } }) } }) return Result.success(elem.build()) } operator fun get(case: Int): Convertor? { return convertorMap[case] } } suspend fun NtMessages.toKritorEventMessages(record: MsgRecord): ArrayList { val result = arrayListOf() forEach { MsgConvertor[it.elementType]?.invoke(record, it)?.onSuccess { result.add(it) }?.onFailure { if (it !is ActionMsgException) { LogCenter.log("消息转换异常: " + it.stackTraceToString(), Level.WARN) } } } return result }