feat(event): add parser

This commit is contained in:
Il Harper 2024-03-05 19:28:39 +08:00
parent 54bcc15da6
commit 8478cf4437
No known key found for this signature in database
GPG Key ID: 4B71FCA698E7E8EC
2 changed files with 687 additions and 0 deletions

View File

@ -0,0 +1,546 @@
import type {
Channel,
ChronocatContext,
ChronocatLogCurrentConfig,
ChronocatSatoriEventsConfig,
Event,
Guild,
GuildMember,
RedMessage,
} from '@chronocat/shell'
import {
ChannelType,
ChatType,
FaceType,
MsgType,
SendType,
} from '@chronocat/shell'
import { l } from '@chronocat/shell/lib/services/logger'
import { PLATFORM } from '@chronocat/shell/lib/utils/consts'
import h from '@satorijs/element'
import { Buffer } from 'node:buffer'
import type { O } from 'ts-toolbelt'
import { parseMsgTypes } from './msgt'
export const buildParser =
(
ctx: ChronocatContext,
config: O.Intersect<ChronocatLogCurrentConfig, ChronocatSatoriEventsConfig>,
) =>
(message: RedMessage) =>
parseMessageRecv(ctx, config, message)
export const parseMessageRecv = async (
ctx: ChronocatContext,
config: O.Intersect<ChronocatLogCurrentConfig, ChronocatSatoriEventsConfig>,
message: RedMessage,
) => {
const parsed = await parseMessage(ctx, config, message)
if (!parsed) return undefined
const result: Event[] = []
for (const event of parsed) {
if (!event.message?.id && !event.user?.id) {
l.warn('satori: parser: 丢弃了一条消息', { code: 2127 })
continue
} else if (!event.message?.id)
l.warn(
`satori: parser: 来自 ${event.user?.name} (${event.user?.id}) 的消息不带有 messageId请注意。`,
{ code: 2128 },
)
else if (!event.user?.id)
l.warn(
`satori: parser: 消息 ${event.message.id} 不带有 userId请注意。`,
{ code: 2129 },
)
else if (!event.user?.name && !event.member?.nick)
l.warn(
`satori: parser: 消息 ${event.message.id} 不带有 userName请注意。`,
{
code: 2130,
},
)
result.push(event)
}
return result
}
export const parseMessage = async (
ctx: ChronocatContext,
config: O.Intersect<ChronocatLogCurrentConfig, ChronocatSatoriEventsConfig>,
message: RedMessage,
) => {
const event: Event = {
id: undefined as unknown as number,
type: undefined as unknown as string,
platform: undefined as unknown as string,
self_id: undefined as unknown as string,
timestamp: Number(message.msgTime) * 1000,
}
event.user = {
id: message.senderUin,
name: (message.sendNickName || undefined) as unknown as string,
avatar: `http://thirdqq.qlogo.cn/headimg_dl?dst_uin=${message.senderUin}&spec=640`,
}
const ntMsgTypes = parseMsgTypes(message)
// 无论哪种消息都有 Channel 和 User
event.channel = {} as Channel
// 判断消息来源
switch (ntMsgTypes.chatType) {
case ChatType.Private:
event.channel.type = ChannelType.DIRECT
event.channel.id = `private:${event.user.id}`
event.channel.name = event.user.name
break
case ChatType.Group:
// Guild 和 Member 只有群聊有
event.guild = {} as Guild
event.member = {} as GuildMember
if (message.sendMemberName) event.member.nick = message.sendMemberName
event.channel.type = ChannelType.TEXT
event.channel.id = event.guild.id = message.peerUid
event.channel.name = event.guild.name = message.peerName
event.guild.avatar = `https://p.qlogo.cn/gh/${message.peerUid}/${message.peerUid}/640`
break
}
if (
ntMsgTypes.msgType === MsgType.Ark &&
message.subMsgType === 0 &&
ntMsgTypes.sendType === SendType.Normal
)
// ARK 卡片消息elementType = 10
// 不处理
return undefined
else if (
ntMsgTypes.msgType === MsgType.Normal ||
ntMsgTypes.msgType === MsgType.Value3 ||
ntMsgTypes.msgType === MsgType.Ptt ||
ntMsgTypes.msgType === MsgType.Video ||
ntMsgTypes.msgType === MsgType.WithRecords ||
ntMsgTypes.msgType === MsgType.Vaule17
)
return parseChatMessage(ctx, config, event, message).then((x) => [
x[0],
...x[1],
])
// else if (event.__CHRONO_UNSAFE_NTMSGTYPES__.subMsgType.multiForward)
// // 合并转发消息
// // multiForwardMsgElementelementType = 16内并不带有合并转发的全部内容
// // 需要后续通过 API 再请求
// // 考虑到合并转发消息解析需求较少,不调度此 session
// return undefined
else if (
ntMsgTypes.msgType === MsgType.System && // 5
message.subMsgType === 8 && // 8
ntMsgTypes.sendType === SendType.System && // 3
message.elements[0]!.elementType === 8 && // 8
message.elements[0]!.grayTipElement!.subElementType === 4 && // 4
message.elements[0]!.grayTipElement!.groupElement!.type === 1 // 1
)
// 新人自行入群
return await parseGuildMemberAddedMessage(ctx, config, event, message)
else if (
ntMsgTypes.msgType === MsgType.System && // 5
message.subMsgType === 8 && // 8
ntMsgTypes.sendType === SendType.System && // 3
message.elements[0]!.elementType === 8 && // 8
message.elements[0]!.grayTipElement!.subElementType === 4 && // 4
message.elements[0]!.grayTipElement!.groupElement!.type === 8 // 8
)
// 他人被禁言
return await parseGuildMemberMuteMessage(ctx, config, event, message)
else if (
ntMsgTypes.msgType === MsgType.System && // 5
message.subMsgType === 8 && // 8
ntMsgTypes.sendType === SendType.System && // 3
message.elements[0]!.elementType === 8 && // 8
message.elements[0]!.grayTipElement!.subElementType === 4 && // 4
message.elements[0]!.grayTipElement!.groupElement!.type === 5 // 5
)
// 群名称变更
return undefined
else if (
ntMsgTypes.msgType === MsgType.System && // 5
message.subMsgType === 12 && // 12
ntMsgTypes.sendType === SendType.System && // 3
message.elements[0]!.elementType === 8 && // 8
message.elements[0]!.grayTipElement!.subElementType === 12 && // 12
message.elements[0]!.grayTipElement!.xmlElement!.busiType === '1' && // 1
message.elements[0]!.grayTipElement!.xmlElement!.busiId === '10145' // 10145
)
// 旧版群成员邀请新人入群
return await parseGuildMemberAddedLegacyInviteMessage(
ctx,
config,
event,
message,
)
else if (
ntMsgTypes.msgType === MsgType.System &&
message.subMsgType === 17 &&
ntMsgTypes.sendType === SendType.System
)
// 群主禁止群内临时通话
// 群主禁止群内发起新的群聊
return undefined
return undefined
}
/**
*
*
* @remarks
* 使
*/
async function parseChatMessage(
ctx: ChronocatContext,
config: O.Intersect<ChronocatLogCurrentConfig, ChronocatSatoriEventsConfig>,
event: Event,
message: RedMessage,
) {
const [elements, extraEvents] = await parseElements(ctx, config, message)
event.type = 'message-created'
event.message = {
id: message.msgId,
content: elements.join(''),
}
return [event, extraEvents] as const
}
/**
*
*
* @remarks
* `grayTipElement`elementType = 8
* `groupElement`subElementType = 4 QQ
*/
async function parseGuildMemberAddedMessage(
ctx: ChronocatContext,
config: O.Intersect<ChronocatLogCurrentConfig, ChronocatSatoriEventsConfig>,
event: Event,
message: RedMessage,
) {
const [event2, extraEvents] = await parseChatMessage(
ctx,
config,
event,
message,
)
event2.type = 'guild-member-added'
event2.operator = {
id: message.elements[0]!.grayTipElement!.groupElement!.adminUin!,
name: undefined as unknown as string,
}
event2.user = {
id: message.elements[0]!.grayTipElement!.groupElement!.memberUin!,
name: message.elements[0]!.grayTipElement!.groupElement!.memberNick!,
avatar: `http://thirdqq.qlogo.cn/headimg_dl?dst_uin=${
message.elements[0]!.grayTipElement!.groupElement!.memberUin
}&spec=640`,
}
if (event2.member) delete event2.member
return [event2, ...extraEvents]
}
/**
*
*/
async function parseGuildMemberMuteMessage(
ctx: ChronocatContext,
config: O.Intersect<ChronocatLogCurrentConfig, ChronocatSatoriEventsConfig>,
event: Event,
message: RedMessage,
) {
const [event2, extraEvents] = await parseChatMessage(
ctx,
config,
event,
message,
)
if (
Number(message.elements[0]!.grayTipElement!.groupElement!.shutUp!.duration)
)
event2.type = 'unsafe-guild-mute'
else event2.type = 'unsafe-guild-unmute'
event2.operator = {
id: message.elements[0]!.grayTipElement!.groupElement!.shutUp!.admin.uin,
name: undefined as unknown as string,
}
event2.user = {
id: message.elements[0]!.grayTipElement!.groupElement!.shutUp!.member.uin,
name: message.elements[0]!.grayTipElement!.groupElement!.shutUp!.member
.name,
avatar: `http://thirdqq.qlogo.cn/headimg_dl?dst_uin=${
message.elements[0]!.grayTipElement!.groupElement!.shutUp!.member.uin
}&spec=640`,
}
if (event2.member) delete event2.member
return [event2, ...extraEvents]
}
const regexGuildMemberAddedLegacyInviteMessage = /jp="(\d+)".*jp="(\d+)"/gim
/**
* 使 NT
*
*
* @remarks
* QQ
* `grayTipElement`elementType = 8
* `xmlElement`subElementType = 12 HTML QQ
*
* HTML
*
* ```html
* <gtip align="center">
* <qq uin="u_0gvBEjIEEOk5-EypJRjwxw" col="3" jp="1302744182" />
* <nor txt="邀请"/>
* <qq uin="u_ENIKFfFS74WSiKNoA6ERWg" col="3" jp="2953529126" />
* <nor txt="加入了群聊。"/>
* </gtip>
* ```
*
* 使 QQ 使 cheerio
*
*/
async function parseGuildMemberAddedLegacyInviteMessage(
ctx: ChronocatContext,
config: O.Intersect<ChronocatLogCurrentConfig, ChronocatSatoriEventsConfig>,
event: Event,
message: RedMessage,
) {
const [event2, extraEvents] = await parseChatMessage(
ctx,
config,
event,
message,
)
event2.type = 'guild-member-added'
const execArr = regexGuildMemberAddedLegacyInviteMessage.exec(
message.elements[0]!.grayTipElement!.xmlElement!.content!,
)
if (!Array.isArray(execArr) || execArr.length < 3) return undefined
const [_, operatorId, userId] = execArr
event2.operator = {
id: operatorId!,
name: undefined as unknown as string,
}
event2.user = {
id: userId!,
name: undefined as unknown as string,
avatar: `http://thirdqq.qlogo.cn/headimg_dl?dst_uin=${userId}&spec=640`,
}
if (event2.member) delete event2.member
return [event2, ...extraEvents]
}
/**
*
*/
async function parseElements(
ctx: ChronocatContext,
config: O.Intersect<ChronocatLogCurrentConfig, ChronocatSatoriEventsConfig>,
message: RedMessage,
) {
const elements: h[] = []
const extraEvents: Event[] = []
for (const m of message.elements) {
switch (m.elementType) {
case 1: {
// 文本消息
switch (m.textElement!.atType) {
case 0: {
// 纯文本消息
elements.push(h.text(m.textElement?.content))
break
}
case 2: {
// at 消息
const id = m.textElement!.atNtUin
const name = m.textElement!.content.slice(1)
if (!id) {
l.warn(
`satori: parser: at 目标 ${name} 不带有 id将跳过该元素。`,
{ code: 2131 },
)
break
}
elements.push(
h('at', {
id,
name,
}),
)
break
}
}
break
}
case 2: {
// 图片消息
elements.push(
h('img', {
src: `${config.self_url}/v1/assets/${Buffer.from(
JSON.stringify({
msgId: message.msgId,
chatType: message.chatType,
peerUid: message.peerUid,
elementId: m.elementId,
thumbSize: m.picElement!.thumbFileSize,
}),
).toString('base64url')}`,
}),
)
break
}
case 3: {
// 文件消息
elements.push(
h('file', {
src: `${config.self_url}/v1/assets/${Buffer.from(
JSON.stringify({
msgId: message.msgId,
chatType: message.chatType,
peerUid: message.peerUid,
elementId: m.elementId,
thumbSize: m.fileElement!.thumbFileSize,
}),
).toString('base64url')}`,
}),
)
break
}
case 4: {
// 语音消息
elements.push(
h('audio', {
src: `${config.self_url}/v1/assets/${Buffer.from(
JSON.stringify({
msgId: message.msgId,
chatType: message.chatType,
peerUid: message.peerUid,
elementId: m.elementId,
thumbSize: 0,
}),
).toString('base64url')}`,
}),
)
break
}
case 5: {
// 视频消息
elements.push(
h('video', {
src: `${config.self_url}/v1/assets/${Buffer.from(
JSON.stringify({
msgId: message.msgId,
chatType: message.chatType,
peerUid: message.peerUid,
elementId: m.elementId,
thumbSize: m.videoElement!.thumbSize,
}),
).toString('base64url')}`,
}),
)
break
}
case 6: {
// 表情
switch (m.faceElement!.faceType) {
case FaceType.PCPoke: {
elements.push(
h(`${PLATFORM}:pcpoke`, {
id: m.faceElement!.pokeType,
}),
)
break
}
case FaceType.Normal:
case FaceType.Super: {
elements.push(
h(`${PLATFORM}:face`, {
id: m.faceElement!.faceIndex,
name: `[${(await ctx.chronocat.api['chronocat.internal.qface.get'](`${m.faceElement!.faceIndex}`))!.QDes.slice(1)}]`,
platform: PLATFORM,
'unsafe-super': m.faceElement!.faceType === FaceType.Super,
'unsafe-result-id': m.faceElement!.resultId,
'unsafe-chain-count': m.faceElement!.chainCount,
}),
)
break
}
}
break
}
case 7: {
// 引用消息
const source = message.records.find(
(x) => x.msgId === m.replyElement!.sourceMsgIdInRecords,
)!
elements.push(
h(
'quote',
{
'chronocat:seq': m.replyElement!.replayMsgSeq,
},
[
await parseAuthor(source),
...(await parseElements(ctx, config, source))[0],
],
),
)
break
}
default:
break
}
}
return [elements, extraEvents] as const
}
async function parseAuthor(message: RedMessage) {
return h('author', {
id: message.senderUin,
name: message.sendMemberName || message.sendNickName,
avatar: `http://thirdqq.qlogo.cn/headimg_dl?dst_uin=${message.senderUin}&spec=640`,
})
}

View File

@ -0,0 +1,141 @@
import type { ChatType, RedMessage, SendType } from '@chronocat/shell'
import { MsgType } from '@chronocat/shell'
/**
* QQNT
*
* @remarks
* #
*
* QQNT {@link RedMessage.chatType}{@link RedMessage.msgType}{@link RedMessage.subMsgType}
* {@link RedMessage.sendType}
*
* ## {@link RedMessage.chatType}
*
* {@link ChatType.Private} {@link ChatType.Group}
*
* ## {@link RedMessage.msgType}
*
*
*
* ## {@link RedMessage.subMsgType}
*
* Bitset 使 {@link adaptMsgTypes}
* {@link Boolean}
*
* ## {@link RedMessage.sendType}
*
*
*/
export interface MsgTypes {
chatType: ChatType
msgType: MsgType
subMsgType: {
/**
* at
*
* @remarks
* - Bit: `0000 0010 | 0000 0000 0000 0001` = `2-1`
* - Bit: `0000 1001 | 0000 0000 0000 0001` = `9-1`
* - `elementType`: `1`
*/
text: boolean
/**
*
*
* @remarks
* - Bit: `0000 0010 | 0000 0000 0000 0010` = `2-2`
* - `elementType`: `2`
*/
pic: boolean
/**
*
*
* @remarks
* - Bit: `0000 0010 | 0000 0000 0001 0000` = `2-16`
* - Bit: `0000 1001 | 0000 0000 0001 0000` = `9-16`
* - `elementType`: `6`
*/
face: boolean
/**
*
*
* @remarks
* - Bit: `0000 0010 | 0000 0000 1000 0000` = `2-128`
* - `elementType`: `1`
*/
link: boolean
/**
*
*
* @remarks
* - Bit: `0000 0010 | 0000 0000 0000 1000` = `2-8` (NT )
* - Bit: `0000 1000 | 0000 0000 0000 0000` = `8-0` ( Q )
* - `elementType`: `16`
*/
multiForward: boolean
/**
* Quote
*
* @remarks
* - Bit: `0000 1001 | 0000 0000 0010 0000` = `9-32`
* - `elementType`: `7`
*/
reply: boolean
/**
*
*
* @remarks
* - Bit: `0001 0001 | 0000 0000 0000 1000` = `17-8`
* - `elementType`: `11`
*/
marketFace: boolean
/**
*
*
* @remarks
* - Bit: `0000 0011 | 0000 0010 0000 0000` = `3-512`
* - `elementType`: `3`
*/
file: boolean
}
sendType: SendType
}
/**
* QQNT
*/
export const parseMsgTypes = (message: RedMessage): MsgTypes => ({
chatType: message.chatType,
msgType: message.msgType,
subMsgType: {
text: Boolean(message.subMsgType & (1 << 0)),
pic: Boolean(message.subMsgType & (1 << 1)),
face: Boolean(message.subMsgType & (1 << 4)),
link: Boolean(message.subMsgType & (1 << 7)),
multiForward: Boolean(message.subMsgType & (1 << 3)),
reply:
message.msgType === MsgType.WithRecords &&
Boolean(message.subMsgType & (1 << 5)),
marketFace:
message.msgType == MsgType.Vaule17 &&
Boolean(message.subMsgType & (1 << 3)),
file:
message.msgType === MsgType.Value3 &&
Boolean(message.subMsgType & (1 << 9)),
},
sendType: message.sendType,
})