2024-04-04 18:56:48 +08:00

457 lines
18 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<MsgElement>
typealias Convertor = suspend (MsgRecord, MsgElement) -> Result<Element>
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<Element> {
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<Element> {
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<Element> {
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<Element> {
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<Element> {
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<Element> {
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<Element> {
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<Element> {
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<Element> {
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<Element> {
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<Element> {
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<Element> {
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<Element> {
val result = arrayListOf<Element>()
forEach {
MsgConvertor[it.elementType]?.invoke(record, it)?.onSuccess {
result.add(it)
}?.onFailure {
if (it !is ActionMsgException) {
LogCenter.log("消息转换异常: " + it.stackTraceToString(), Level.WARN)
}
}
}
return result
}