diff --git a/src/common/config.ts b/src/common/config.ts
index 8539af0..c764361 100644
--- a/src/common/config.ts
+++ b/src/common/config.ts
@@ -1,6 +1,6 @@
import fs from 'node:fs'
import path from 'node:path'
-import { Config, OB11Config } from './types'
+import { Config, OB11Config, SatoriConfig } from './types'
import { selfInfo, DATA_DIR } from './globalVars'
import { mergeNewProperties } from './utils/misc'
@@ -22,6 +22,7 @@ export class ConfigUtil {
reloadConfig(): Config {
const ob11Default: OB11Config = {
+ enable: true,
httpPort: 3000,
httpHosts: [],
httpSecret: '',
@@ -35,8 +36,14 @@ export class ConfigUtil {
enableHttpHeart: false,
listenLocalhost: false
}
+ const satoriDefault: SatoriConfig = {
+ enable: true,
+ port: 5600,
+ listen: '0.0.0.0',
+ token: ''
+ }
const defaultConfig: Config = {
- enableLLOB: true,
+ satori: satoriDefault,
ob11: ob11Default,
heartInterval: 60000,
token: '',
diff --git a/src/common/types.ts b/src/common/types.ts
index 14baf48..f586fba 100644
--- a/src/common/types.ts
+++ b/src/common/types.ts
@@ -1,4 +1,5 @@
export interface OB11Config {
+ enable: boolean
httpPort: number
httpHosts: string[]
httpSecret?: string
@@ -18,13 +19,15 @@ export interface OB11Config {
listenLocalhost: boolean
}
-export interface CheckVersion {
- result: boolean
- version: string
+export interface SatoriConfig {
+ enable: boolean
+ listen: string
+ port: number
+ token: string
}
export interface Config {
- enableLLOB: boolean
+ satori: SatoriConfig
ob11: OB11Config
token?: string
heartInterval: number // ms
@@ -45,6 +48,13 @@ export interface Config {
hosts?: string[]
/** @deprecated */
wsPort?: string
+ /** @deprecated */
+ enableLLOB?: boolean
+}
+
+export interface CheckVersion {
+ result: boolean
+ version: string
}
export interface LLOneBotError {
diff --git a/src/main/log.ts b/src/main/log.ts
index 6347564..af94098 100644
--- a/src/main/log.ts
+++ b/src/main/log.ts
@@ -28,7 +28,7 @@ export default class Log {
},
}
Logger.targets.push(target)
- ctx.on('llonebot/config-updated', input => {
+ ctx.on('llob/config-updated', input => {
enable = input.log!
})
}
diff --git a/src/main/main.ts b/src/main/main.ts
index 3dd509c..ffec856 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -2,6 +2,7 @@ import path from 'node:path'
import Log from './log'
import Core from '../ntqqapi/core'
import OneBot11Adapter from '../onebot11/adapter'
+import SatoriAdapter from '../satori/adapter'
import Database from 'minato'
import SQLiteDriver from '@minatojs/driver-sqlite'
import Store from './store'
@@ -41,7 +42,7 @@ import { existsSync, mkdirSync } from 'node:fs'
declare module 'cordis' {
interface Events {
- 'llonebot/config-updated': (input: LLOBConfig) => void
+ 'llob/config-updated': (input: LLOBConfig) => void
}
}
@@ -150,11 +151,6 @@ function onLoad() {
async function start() {
log('process pid', process.pid)
const config = getConfigUtil().getConfig()
- if (!config.enableLLOB) {
- llonebotError.otherError = 'LLOneBot 未启动'
- log('LLOneBot 开关设置为关闭,不启动LLOneBot')
- return
- }
if (!existsSync(TEMP_DIR)) {
await mkdir(TEMP_DIR)
}
@@ -183,20 +179,28 @@ function onLoad() {
ctx.plugin(Store, {
msgCacheExpire: config.msgCacheExpire! * 1000
})
- ctx.plugin(OneBot11Adapter, {
- ...config.ob11,
- heartInterval: config.heartInterval,
- token: config.token!,
- debug: config.debug!,
- reportSelfMessage: config.reportSelfMessage!,
- musicSignUrl: config.musicSignUrl,
- enableLocalFile2Url: config.enableLocalFile2Url!,
- ffmpeg: config.ffmpeg,
- })
+ if (config.ob11.enable) {
+ ctx.plugin(OneBot11Adapter, {
+ ...config.ob11,
+ heartInterval: config.heartInterval,
+ token: config.token!,
+ debug: config.debug!,
+ reportSelfMessage: config.reportSelfMessage!,
+ musicSignUrl: config.musicSignUrl,
+ enableLocalFile2Url: config.enableLocalFile2Url!,
+ ffmpeg: config.ffmpeg,
+ })
+ }
+ if (config.satori.enable) {
+ ctx.plugin(SatoriAdapter, {
+ ...config.satori,
+ ffmpeg: config.ffmpeg,
+ })
+ }
ctx.start()
llonebotError.otherError = ''
ipcMain.on(CHANNEL_SET_CONFIG_CONFIRMED, (event, config: LLOBConfig) => {
- ctx.parallel('llonebot/config-updated', config)
+ ctx.parallel('llob/config-updated', config)
})
}
diff --git a/src/ntqqapi/core.ts b/src/ntqqapi/core.ts
index a5496fa..7f0e0be 100644
--- a/src/ntqqapi/core.ts
+++ b/src/ntqqapi/core.ts
@@ -13,9 +13,10 @@ import {
CategoryFriend,
SimpleInfo,
ChatType,
- BuddyReqType
+ BuddyReqType,
+ GrayTipElementSubType
} from './types'
-import { selfInfo } from '../common/globalVars'
+import { selfInfo, llonebotError } from '../common/globalVars'
import { version } from '../version'
import { invoke } from './ntcall'
@@ -43,10 +44,15 @@ class Core extends Service {
}
public start() {
+ if (!this.config.ob11.enable && !this.config.satori.enable) {
+ llonebotError.otherError = 'LLOneBot 未启动'
+ this.ctx.logger.info('LLOneBot 开关设置为关闭,不启动 LLOneBot')
+ return
+ }
this.startTime = Date.now()
this.registerListener()
this.ctx.logger.info(`LLOneBot/${version}`)
- this.ctx.on('llonebot/config-updated', input => {
+ this.ctx.on('llob/config-updated', input => {
Object.assign(this.config, input)
})
}
@@ -118,16 +124,13 @@ class Core extends Service {
activatedPeerUids.push(contact.id)
const peer = { peerUid: contact.id, chatType: contact.chatType }
if (contact.chatType === ChatType.TempC2CFromGroup) {
- this.ctx.ntMsgApi.activateChatAndGetHistory(peer).then(() => {
- this.ctx.ntMsgApi.getMsgHistory(peer, '', 20).then(({ msgList }) => {
- const lastTempMsg = msgList.at(-1)
- if (Date.now() / 1000 - Number(lastTempMsg?.msgTime) < 5) {
- this.ctx.parallel('nt/message-created', lastTempMsg!)
- }
- })
+ this.ctx.ntMsgApi.activateChatAndGetHistory(peer, 1).then(res => {
+ const lastTempMsg = res.msgList[0]
+ if (Date.now() / 1000 - Number(lastTempMsg?.msgTime) < 5) {
+ this.ctx.parallel('nt/message-created', lastTempMsg!)
+ }
})
- }
- else {
+ } else {
this.ctx.ntMsgApi.activateChat(peer)
}
}
@@ -179,7 +182,14 @@ class Core extends Service {
registerReceiveHook<{ msgList: RawMessage[] }>([ReceiveCmdS.UPDATE_MSG], payload => {
for (const msg of payload.msgList) {
- if (msg.recallTime !== '0' && !recallMsgIds.includes(msg.msgId)) {
+ if (
+ msg.recallTime !== '0' &&
+ msg.msgType === 5 &&
+ msg.subMsgType === 4 &&
+ msg.elements[0]?.grayTipElement?.subElementType === GrayTipElementSubType.Revoke &&
+ !recallMsgIds.includes(msg.msgId)
+ ) {
+ recallMsgIds.shift()
recallMsgIds.push(msg.msgId)
this.ctx.parallel('nt/message-deleted', msg)
} else if (sentMsgIds.get(msg.msgId)) {
@@ -205,7 +215,7 @@ class Core extends Service {
if (payload.unreadCount) {
let notifies: GroupNotify[]
try {
- notifies = (await this.ctx.ntGroupApi.getSingleScreenNotifies(14)).slice(0, payload.unreadCount)
+ notifies = await this.ctx.ntGroupApi.getSingleScreenNotifies(payload.unreadCount)
} catch (e) {
return
}
@@ -215,6 +225,7 @@ class Core extends Service {
if (groupNotifyFlags.includes(flag) || notifyTime < this.startTime) {
continue
}
+ groupNotifyFlags.shift()
groupNotifyFlags.push(flag)
this.ctx.parallel('nt/group-notify', notify)
}
diff --git a/src/onebot11/adapter.ts b/src/onebot11/adapter.ts
index 45d6dce..ee3a61a 100644
--- a/src/onebot11/adapter.ts
+++ b/src/onebot11/adapter.ts
@@ -331,7 +331,7 @@ class OneBot11Adapter extends Service {
if (this.config.enableHttpPost) {
this.ob11HttpPost.start()
}
- this.ctx.on('llonebot/config-updated', input => {
+ this.ctx.on('llob/config-updated', input => {
this.handleConfigUpdated(input)
})
this.ctx.on('nt/message-created', input => {
diff --git a/src/renderer/components/index.ts b/src/renderer/components/index.ts
index fd4f228..c5b0465 100644
--- a/src/renderer/components/index.ts
+++ b/src/renderer/components/index.ts
@@ -3,3 +3,4 @@ export * from './item'
export * from './button'
export * from './switch'
export * from './select'
+export * from './input'
diff --git a/src/renderer/components/input.ts b/src/renderer/components/input.ts
new file mode 100644
index 0000000..7c8e152
--- /dev/null
+++ b/src/renderer/components/input.ts
@@ -0,0 +1,20 @@
+export const SettingInput = (
+ key: string,
+ type: 'port' | 'text',
+ value: string | number,
+ placeholder: string | number,
+ style = ''
+) => {
+ if (type === 'text') {
+ return `
+
+
+
+ `
+ }
+ return `
+
+
+
+ `
+}
diff --git a/src/renderer/index.ts b/src/renderer/index.ts
index 660e703..7eecf45 100644
--- a/src/renderer/index.ts
+++ b/src/renderer/index.ts
@@ -1,5 +1,5 @@
import { CheckVersion, Config } from '../common/types'
-import { SettingButton, SettingItem, SettingList, SettingSwitch, SettingSelect } from './components'
+import { SettingButton, SettingItem, SettingList, SettingSwitch, SettingSelect, SettingInput } from './components'
import { version } from '../version'
// @ts-expect-error: Unreachable code error
import StyleRaw from './style.css?raw'
@@ -11,7 +11,6 @@ function isEmpty(value: unknown) {
}
async function onSettingWindowCreated(view: Element) {
- //window.llonebot.log('setting window created')
const config = await window.llonebot.getConfig()
const ob11Config = { ...config.ob11 }
@@ -49,12 +48,28 @@ async function onSettingWindowCreated(view: Element) {
]),
SettingList([
SettingItem(
- '是否启用 LLOneBot, 重启 QQ 后生效',
+ '是否启用 Satori 协议',
+ '重启 QQ 后生效',
+ SettingSwitch('satori.enable', config.satori.enable),
+ ),
+ SettingItem(
+ '服务端口',
null,
- SettingSwitch('enableLLOB', config.enableLLOB, { 'control-display-id': 'config-enableLLOB' }),
- )]
- ),
+ SettingInput('satori.port', 'port', config.satori.port, config.satori.port),
+ ),
+ SettingItem(
+ '服务令牌',
+ null,
+ SettingInput('satori.token', 'text', config.satori.token, '未设置', 'width:170px;'),
+ ),
+ SettingItem('', null, SettingButton('保存', 'config-ob11-save', 'primary')),
+ ]),
SettingList([
+ SettingItem(
+ '是否启用 OneBot 协议',
+ '重启 QQ 后生效',
+ SettingSwitch('ob11.enable', config.ob11.enable),
+ ),
SettingItem(
'启用 HTTP 服务',
null,
@@ -63,7 +78,7 @@ async function onSettingWindowCreated(view: Element) {
SettingItem(
'HTTP 服务监听端口',
null,
- ``,
+ SettingInput('ob11.httpPort', 'port', config.ob11.httpPort, config.ob11.httpPort),
'config-ob11-httpPort',
config.ob11.enableHttp,
),
@@ -127,14 +142,14 @@ async function onSettingWindowCreated(view: Element) {
`,
SettingItem(
- ' WebSocket 服务心跳间隔',
+ 'WebSocket 服务心跳间隔',
'控制每隔多久发送一个心跳包,单位为毫秒',
``,
),
SettingItem(
'Access token',
null,
- ``,
+ ``,
),
SettingItem(
'新消息上报格式',
@@ -148,6 +163,23 @@ async function onSettingWindowCreated(view: Element) {
config.ob11.messagePostFormat,
),
),
+ SettingItem(
+ 'HTTP、正向 WebSocket 服务仅监听 127.0.0.1',
+ '而不是 0.0.0.0',
+ SettingSwitch('ob11.listenLocalhost', config.ob11.listenLocalhost),
+ ),
+ SettingItem(
+ '上报 Bot 自身发送的消息',
+ '上报 event 为 message_sent',
+ SettingSwitch('reportSelfMessage', config.reportSelfMessage),
+ ),
+ SettingItem(
+ '使用 Base64 编码获取文件',
+ '调用 /get_image、/get_record、/get_file 时,没有 url 时添加 Base64 字段',
+ SettingSwitch('enableLocalFile2Url', config.enableLocalFile2Url),
+ ),
+ ]),
+ SettingList([
SettingItem(
'FFmpeg 路径,发送语音、视频需要',
`可点此下载, 路径: ${!isEmpty(config.ffmpeg) ? config.ffmpeg : '未指定'
@@ -160,25 +192,6 @@ async function onSettingWindowCreated(view: Element) {
``,
'config-musicSignUrl',
),
- SettingItem(
- 'HTTP、正向 WebSocket 服务仅监听 127.0.0.1',
- '而不是 0.0.0.0',
- SettingSwitch('ob11.listenLocalhost', config.ob11.listenLocalhost),
- ),
- SettingItem('', null, SettingButton('保存', 'config-ob11-save', 'primary')),
- ]),
- SettingList([
- SettingItem(
- '使用 Base64 编码获取文件',
- '调用 /get_image、/get_record、/get_file 时,没有 url 时添加 Base64 字段',
- SettingSwitch('enableLocalFile2Url', config.enableLocalFile2Url),
- ),
- SettingItem('调试模式', '开启后上报信息会添加 raw 字段以附带原始信息', SettingSwitch('debug', config.debug)),
- SettingItem(
- '上报 Bot 自身发送的消息',
- '上报 event 为 message_sent',
- SettingSwitch('reportSelfMessage', config.reportSelfMessage),
- ),
SettingItem(
'自动删除收到的文件',
'在收到文件后的指定时间内删除该文件',
@@ -386,22 +399,22 @@ async function onSettingWindowCreated(view: Element) {
view.appendChild(node)
})
// 更新逻辑
- async function checkVersionFunc(ResultVersion: CheckVersion) {
+ async function checkVersionFunc(info: CheckVersion) {
const titleDom = view.querySelector('#llonebot-update-title')!
const buttonDom = view.querySelector('#llonebot-update-button')!
- if (ResultVersion.version === '') {
+ if (info.version === '') {
titleDom.innerHTML = `当前版本为 v${version},检查更新失败`
buttonDom.innerHTML = '点击重试'
buttonDom.addEventListener('click', async () => {
window.llonebot.checkVersion().then(checkVersionFunc)
- })
- } else if (!ResultVersion.result) {
+ }, { once: true })
+ } else if (!info.result) {
titleDom.innerHTML = '当前已是最新版本 v' + version
buttonDom.innerHTML = '无需更新'
} else {
- titleDom.innerHTML = `当前版本为 v${version},最新版本为 v${ResultVersion.version}`
+ titleDom.innerHTML = `当前版本为 v${version},最新版本为 v${info.version}`
buttonDom.innerHTML = '点击更新'
buttonDom.dataset.type = 'primary'
diff --git a/src/satori/adapter.ts b/src/satori/adapter.ts
new file mode 100644
index 0000000..34ed0ba
--- /dev/null
+++ b/src/satori/adapter.ts
@@ -0,0 +1,192 @@
+import * as NT from '@/ntqqapi/types'
+import { omit } from 'cosmokit'
+import { Event } from '@satorijs/protocol'
+import { Service, Context } from 'cordis'
+import { SatoriConfig } from '@/common/types'
+import { SatoriServer } from './server'
+import { selfInfo } from '@/common/globalVars'
+import { ObjectToSnake } from 'ts-case-convert'
+import { isDeepStrictEqual } from 'node:util'
+import { parseMessageCreated, parseMessageDeleted } from './event/message'
+import { parseGuildAdded, parseGuildRemoved, parseGuildRequest } from './event/guild'
+import { parseGuildMemberAdded, parseGuildMemberRemoved, parseGuildMemberRequest } from './event/member'
+import { parseFriendRequest } from './event/user'
+
+declare module 'cordis' {
+ interface Context {
+ satori: SatoriAdapter
+ }
+}
+
+class SatoriAdapter extends Service {
+ static inject = [
+ 'ntMsgApi', 'ntFileApi', 'ntFileCacheApi',
+ 'ntFriendApi', 'ntGroupApi', 'ntUserApi',
+ 'ntWindowApi', 'ntWebApi', 'store',
+ ]
+ private counter: number
+ private selfId: string
+ private server: SatoriServer
+
+ constructor(public ctx: Context, public config: SatoriAdapter.Config) {
+ super(ctx, 'satori', true)
+ this.counter = 0
+ this.selfId = selfInfo.uin
+ this.server = new SatoriServer(ctx, config)
+ }
+
+ async handleMessage(input: NT.RawMessage) {
+ if (
+ input.msgType === 5 &&
+ input.subMsgType === 8 &&
+ input.elements[0]?.grayTipElement?.groupElement?.type === 1 &&
+ input.elements[0].grayTipElement.groupElement.memberUid === selfInfo.uid
+ ) {
+ // 自身主动申请
+ return await parseGuildAdded(this, input)
+ }
+ else if (
+ input.msgType === 5 &&
+ input.subMsgType === 12 &&
+ input.elements[0]?.grayTipElement?.xmlElement?.templId === '10179' &&
+ input.elements[0].grayTipElement.xmlElement.templParam.get('invitee') === selfInfo.uin
+ ) {
+ // 自身被邀请
+ return await parseGuildAdded(this, input)
+ }
+ else if (
+ input.msgType === 5 &&
+ input.subMsgType === 8 &&
+ input.elements[0]?.grayTipElement?.groupElement?.type === 3
+ ) {
+ // 自身被踢出
+ return await parseGuildRemoved(this, input)
+ }
+ else if (
+ input.msgType === 5 &&
+ input.subMsgType === 8 &&
+ input.elements[0]?.grayTipElement?.groupElement?.type === 1
+ ) {
+ // 他人主动申请
+ return await parseGuildMemberAdded(this, input)
+ }
+ else if (
+ input.msgType === 5 &&
+ input.subMsgType === 12 &&
+ input.elements[0]?.grayTipElement?.xmlElement?.templId === '10179'
+ ) {
+ // 他人被邀请
+ return await parseGuildMemberAdded(this, input)
+ }
+ else if (
+ input.msgType === 5 &&
+ input.subMsgType === 12 &&
+ input.elements[0]?.grayTipElement?.jsonGrayTipElement?.busiId === '19217'
+ ) {
+ // 机器人被邀请
+ return await parseGuildMemberAdded(this, input, true)
+ }
+ else if (
+ input.msgType === 5 &&
+ input.subMsgType === 12 &&
+ input.elements[0]?.grayTipElement?.xmlElement?.templId === '10382'
+ ) {
+
+ }
+ else {
+ // 普通的消息
+ return await parseMessageCreated(this, input)
+ }
+ }
+
+ async handleGroupNotify(input: NT.GroupNotify) {
+ if (
+ input.type === NT.GroupNotifyType.InvitedByMember &&
+ input.status === NT.GroupNotifyStatus.Unhandle
+ ) {
+ // 自身被邀请,需自身同意
+ return await parseGuildRequest(this, input)
+ }
+ else if (
+ input.type === NT.GroupNotifyType.MemberLeaveNotifyAdmin ||
+ input.type === NT.GroupNotifyType.KickMemberNotifyAdmin
+ ) {
+ // 他人主动退出或被踢
+ return await parseGuildMemberRemoved(this, input)
+ }
+ else if (
+ input.type === NT.GroupNotifyType.RequestJoinNeedAdminiStratorPass &&
+ input.status === NT.GroupNotifyStatus.Unhandle
+ ) {
+ // 他人主动申请,需管理员同意
+ return await parseGuildMemberRequest(this, input)
+ }
+ else if (
+ input.type === NT.GroupNotifyType.InvitedNeedAdminiStratorPass &&
+ input.status === NT.GroupNotifyStatus.Unhandle
+ ) {
+ // 他人被邀请,需管理员同意
+ return await parseGuildMemberRequest(this, input)
+ }
+ }
+
+ start() {
+ this.server.start()
+
+ this.ctx.on('nt/message-created', async input => {
+ const event = await this.handleMessage(input)
+ .catch(e => this.ctx.logger.error(e))
+ event && this.server.dispatch(event)
+ })
+
+ this.ctx.on('nt/group-notify', async input => {
+ const event = await this.handleGroupNotify(input)
+ .catch(e => this.ctx.logger.error(e))
+ event && this.server.dispatch(event)
+ })
+
+ this.ctx.on('nt/message-deleted', async input => {
+ const event = await parseMessageDeleted(this, input)
+ .catch(e => this.ctx.logger.error(e))
+ event && this.server.dispatch(event)
+ })
+
+ this.ctx.on('nt/friend-request', async input => {
+ const event = await parseFriendRequest(this, input)
+ .catch(e => this.ctx.logger.error(e))
+ event && this.server.dispatch(event)
+ })
+
+ this.ctx.on('llob/config-updated', async input => {
+ const old = omit(this.config, ['ffmpeg'])
+ if (!isDeepStrictEqual(old, input.satori)) {
+ await this.server.stop()
+ this.server.updateConfig(input.satori)
+ this.server.start()
+ }
+ Object.assign(this.config, {
+ ...input.satori,
+ ffmpeg: input.ffmpeg
+ })
+ })
+ }
+
+ event(type: string, data: Partial>): ObjectToSnake {
+ return {
+ id: ++this.counter,
+ type,
+ self_id: this.selfId,
+ platform: 'llonebot',
+ timestamp: Date.now(),
+ ...data
+ }
+ }
+}
+
+namespace SatoriAdapter {
+ export interface Config extends SatoriConfig {
+ ffmpeg?: string
+ }
+}
+
+export default SatoriAdapter
diff --git a/src/satori/api/channel/delete.ts b/src/satori/api/channel/delete.ts
new file mode 100644
index 0000000..948f539
--- /dev/null
+++ b/src/satori/api/channel/delete.ts
@@ -0,0 +1,11 @@
+import { Handler } from '../index'
+import { Dict } from 'cosmokit'
+
+interface Payload {
+ channel_id: string
+}
+
+export const deleteChannel: Handler, Payload> = async (ctx, payload) => {
+ await ctx.ntGroupApi.quitGroup(payload.channel_id)
+ return {}
+}
diff --git a/src/satori/api/channel/get.ts b/src/satori/api/channel/get.ts
new file mode 100644
index 0000000..8e04fa6
--- /dev/null
+++ b/src/satori/api/channel/get.ts
@@ -0,0 +1,15 @@
+import { Channel } from '@satorijs/protocol'
+import { Handler } from '../index'
+
+interface Payload {
+ channel_id: string
+}
+
+export const getChannel: Handler = async (ctx, payload) => {
+ const info = await ctx.ntGroupApi.getGroupAllInfo(payload.channel_id)
+ return {
+ id: payload.channel_id,
+ type: Channel.Type.TEXT,
+ name: info.groupAll.groupName
+ }
+}
diff --git a/src/satori/api/channel/list.ts b/src/satori/api/channel/list.ts
new file mode 100644
index 0000000..d5013ae
--- /dev/null
+++ b/src/satori/api/channel/list.ts
@@ -0,0 +1,18 @@
+import { Channel, List } from '@satorijs/protocol'
+import { Handler } from '../index'
+
+interface Payload {
+ guild_id: string
+ next?: string
+}
+
+export const getChannelList: Handler, Payload> = async (ctx, payload) => {
+ const info = await ctx.ntGroupApi.getGroupAllInfo(payload.guild_id)
+ return {
+ data: [{
+ id: payload.guild_id,
+ type: Channel.Type.TEXT,
+ name: info.groupAll.groupName
+ }]
+ }
+}
diff --git a/src/satori/api/channel/mute.ts b/src/satori/api/channel/mute.ts
new file mode 100644
index 0000000..83ae4bb
--- /dev/null
+++ b/src/satori/api/channel/mute.ts
@@ -0,0 +1,12 @@
+import { Handler } from '../index'
+import { Dict } from 'cosmokit'
+
+interface Payload {
+ channel_id: string
+ duration: number
+}
+
+export const muteChannel: Handler, Payload> = async (ctx, payload) => {
+ await ctx.ntGroupApi.banGroup(payload.channel_id, payload.duration !== 0)
+ return {}
+}
diff --git a/src/satori/api/channel/update.ts b/src/satori/api/channel/update.ts
new file mode 100644
index 0000000..8ac6710
--- /dev/null
+++ b/src/satori/api/channel/update.ts
@@ -0,0 +1,16 @@
+import { Channel } from '@satorijs/protocol'
+import { Handler } from '../index'
+import { ObjectToSnake } from 'ts-case-convert'
+import { Dict } from 'cosmokit'
+
+interface Payload {
+ channel_id: string
+ data: ObjectToSnake
+}
+
+export const updateChannel: Handler, Payload> = async (ctx, payload) => {
+ if (payload.data.name) {
+ await ctx.ntGroupApi.setGroupName(payload.channel_id, payload.data.name)
+ }
+ return {}
+}
diff --git a/src/satori/api/channel/user/create.ts b/src/satori/api/channel/user/create.ts
new file mode 100644
index 0000000..47f00aa
--- /dev/null
+++ b/src/satori/api/channel/user/create.ts
@@ -0,0 +1,14 @@
+import { Channel } from '@satorijs/protocol'
+import { Handler } from '../../index'
+
+interface Payload {
+ user_id: string
+ guild_id?: string
+}
+
+export const createDirectChannel: Handler = async (ctx, payload) => {
+ return {
+ id: 'private:' + payload.user_id,
+ type: Channel.Type.DIRECT
+ }
+}
diff --git a/src/satori/api/guild/approve.ts b/src/satori/api/guild/approve.ts
new file mode 100644
index 0000000..70aad30
--- /dev/null
+++ b/src/satori/api/guild/approve.ts
@@ -0,0 +1,18 @@
+import { Handler } from '../index'
+import { GroupRequestOperateTypes } from '@/ntqqapi/types'
+import { Dict } from 'cosmokit'
+
+interface Payload {
+ message_id: string
+ approve: boolean
+ comment: string
+}
+
+export const handleGuildRequest: Handler, Payload> = async (ctx, payload) => {
+ await ctx.ntGroupApi.handleGroupRequest(
+ payload.message_id,
+ payload.approve ? GroupRequestOperateTypes.approve : GroupRequestOperateTypes.reject,
+ payload.comment
+ )
+ return {}
+}
diff --git a/src/satori/api/guild/get.ts b/src/satori/api/guild/get.ts
new file mode 100644
index 0000000..58e1626
--- /dev/null
+++ b/src/satori/api/guild/get.ts
@@ -0,0 +1,12 @@
+import { Guild } from '@satorijs/protocol'
+import { Handler } from '../index'
+import { decodeGuild } from '../../utils'
+
+interface Payload {
+ guild_id: string
+}
+
+export const getGuild: Handler = async (ctx, payload) => {
+ const info = await ctx.ntGroupApi.getGroupAllInfo(payload.guild_id)
+ return decodeGuild(info.groupAll)
+}
diff --git a/src/satori/api/guild/list.ts b/src/satori/api/guild/list.ts
new file mode 100644
index 0000000..5f4f5e1
--- /dev/null
+++ b/src/satori/api/guild/list.ts
@@ -0,0 +1,14 @@
+import { Guild, List } from '@satorijs/protocol'
+import { Handler } from '../index'
+import { decodeGuild } from '../../utils'
+
+interface Payload {
+ next?: string
+}
+
+export const getGuildList: Handler, Payload> = async (ctx) => {
+ const groups = await ctx.ntGroupApi.getGroups()
+ return {
+ data: groups.map(decodeGuild)
+ }
+}
diff --git a/src/satori/api/index.ts b/src/satori/api/index.ts
new file mode 100644
index 0000000..1ba4416
--- /dev/null
+++ b/src/satori/api/index.ts
@@ -0,0 +1,73 @@
+import { Context } from 'cordis'
+import { Awaitable, Dict } from 'cosmokit'
+import { ObjectToSnake } from 'ts-case-convert'
+import { getChannel } from './channel/get'
+import { getChannelList } from './channel/list'
+import { updateChannel } from './channel/update'
+import { deleteChannel } from './channel/delete'
+import { muteChannel } from './channel/mute'
+import { createDirectChannel } from './channel/user/create'
+import { getGuild } from './guild/get'
+import { getGuildList } from './guild/list'
+import { handleGuildRequest } from './guild/approve'
+import { getLogin } from './login/get'
+import { getGuildMember } from './member/get'
+import { getGuildMemberList } from './member/list'
+import { kickGuildMember } from './member/kick'
+import { muteGuildMember } from './member/mute'
+import { handleGuildMemberRequest } from './member/approve'
+import { createMessage } from './message/create'
+import { getMessage } from './message/get'
+import { deleteMessage } from './message/delete'
+import { getMessageList } from './message/list'
+import { createReaction } from './reaction/create'
+import { deleteReaction } from './reaction/delete'
+import { getReactionList } from './reaction/list'
+import { setGuildMemberRole } from './role/member/set'
+import { getGuildRoleList } from './role/list'
+import { getUser } from './user/get'
+import { getFriendList } from './user/list'
+import { handleFriendRequest } from './user/approve'
+
+export type Handler<
+ R extends Dict = Dict,
+ P extends Dict = any
+> = (ctx: Context, payload: P) => Awaitable>
+
+export const handlers: Record = {
+ // 频道 (Channel)
+ getChannel,
+ getChannelList,
+ updateChannel,
+ deleteChannel,
+ muteChannel,
+ createDirectChannel,
+ // 群组 (Guild)
+ getGuild,
+ getGuildList,
+ handleGuildRequest,
+ // 登录信息 (Login)
+ getLogin,
+ // 群组成员 (GuildMember)
+ getGuildMember,
+ getGuildMemberList,
+ kickGuildMember,
+ muteGuildMember,
+ handleGuildMemberRequest,
+ // 消息 (Message)
+ createMessage,
+ getMessage,
+ deleteMessage,
+ getMessageList,
+ // 表态 (Reaction)
+ createReaction,
+ deleteReaction,
+ getReactionList,
+ // 群组角色 (GuildRole)
+ setGuildMemberRole,
+ getGuildRoleList,
+ // 用户 (User)
+ getUser,
+ getFriendList,
+ handleFriendRequest,
+}
diff --git a/src/satori/api/login/get.ts b/src/satori/api/login/get.ts
new file mode 100644
index 0000000..51fc376
--- /dev/null
+++ b/src/satori/api/login/get.ts
@@ -0,0 +1,24 @@
+import { Login, Status, Methods } from '@satorijs/protocol'
+import { decodeUser } from '../../utils'
+import { selfInfo } from '@/common/globalVars'
+import { Handler } from '../index'
+import { handlers } from '../index'
+
+export const getLogin: Handler = async (ctx) => {
+ const features: string[] = []
+ for (const [feature, info] of Object.entries(Methods)) {
+ if (info.name in handlers) {
+ features.push(feature)
+ }
+ }
+ features.push('guild.plain')
+ await ctx.ntUserApi.getSelfNick()
+ return {
+ user: decodeUser(selfInfo),
+ adapter: 'llonebot',
+ platform: 'llonebot',
+ status: selfInfo.online ? Status.ONLINE : Status.OFFLINE,
+ features,
+ proxy_urls: []
+ }
+}
diff --git a/src/satori/api/member/approve.ts b/src/satori/api/member/approve.ts
new file mode 100644
index 0000000..9efd1df
--- /dev/null
+++ b/src/satori/api/member/approve.ts
@@ -0,0 +1,18 @@
+import { Handler } from '../index'
+import { GroupRequestOperateTypes } from '@/ntqqapi/types'
+import { Dict } from 'cosmokit'
+
+interface Payload {
+ message_id: string
+ approve: boolean
+ comment?: string
+}
+
+export const handleGuildMemberRequest: Handler, Payload> = async (ctx, payload) => {
+ await ctx.ntGroupApi.handleGroupRequest(
+ payload.message_id,
+ payload.approve ? GroupRequestOperateTypes.approve : GroupRequestOperateTypes.reject,
+ payload.comment
+ )
+ return {}
+}
diff --git a/src/satori/api/member/get.ts b/src/satori/api/member/get.ts
new file mode 100644
index 0000000..67d9d6f
--- /dev/null
+++ b/src/satori/api/member/get.ts
@@ -0,0 +1,18 @@
+import { GuildMember } from '@satorijs/protocol'
+import { Handler } from '../index'
+import { decodeGuildMember } from '../../utils'
+
+interface Payload {
+ guild_id: string
+ user_id: string
+}
+
+export const getGuildMember: Handler = async (ctx, payload) => {
+ const uid = await ctx.ntUserApi.getUidByUin(payload.user_id)
+ if (!uid) throw new Error('无法获取用户信息')
+ const info = await ctx.ntGroupApi.getGroupMember(payload.guild_id, uid)
+ if (!info) {
+ throw new Error(`群成员${payload.user_id}不存在`)
+ }
+ return decodeGuildMember(info)
+}
diff --git a/src/satori/api/member/kick.ts b/src/satori/api/member/kick.ts
new file mode 100644
index 0000000..a647e7b
--- /dev/null
+++ b/src/satori/api/member/kick.ts
@@ -0,0 +1,15 @@
+import { Handler } from '../index'
+import { Dict } from 'cosmokit'
+
+interface Payload {
+ guild_id: string
+ user_id: string
+ permanent?: boolean
+}
+
+export const kickGuildMember: Handler, Payload> = async (ctx, payload) => {
+ const uid = await ctx.ntUserApi.getUidByUin(payload.user_id, payload.guild_id)
+ if (!uid) throw new Error('无法获取用户信息')
+ await ctx.ntGroupApi.kickMember(payload.guild_id, [uid], Boolean(payload.permanent))
+ return {}
+}
diff --git a/src/satori/api/member/list.ts b/src/satori/api/member/list.ts
new file mode 100644
index 0000000..69bd821
--- /dev/null
+++ b/src/satori/api/member/list.ts
@@ -0,0 +1,19 @@
+import { GuildMember, List } from '@satorijs/protocol'
+import { Handler } from '../index'
+import { decodeGuildMember } from '../../utils'
+
+interface Payload {
+ guild_id: string
+ next?: string
+}
+
+export const getGuildMemberList: Handler, Payload> = async (ctx, payload) => {
+ let members = await ctx.ntGroupApi.getGroupMembers(payload.guild_id)
+ if (members.size === 0) {
+ await ctx.sleep(100)
+ members = await ctx.ntGroupApi.getGroupMembers(payload.guild_id)
+ }
+ return {
+ data: Array.from(members.values()).map(decodeGuildMember)
+ }
+}
diff --git a/src/satori/api/member/mute.ts b/src/satori/api/member/mute.ts
new file mode 100644
index 0000000..7329a7e
--- /dev/null
+++ b/src/satori/api/member/mute.ts
@@ -0,0 +1,17 @@
+import { Handler } from '../index'
+import { Dict } from 'cosmokit'
+
+interface Payload {
+ guild_id: string
+ user_id: string
+ duration: number //毫秒
+}
+
+export const muteGuildMember: Handler, Payload> = async (ctx, payload) => {
+ const uid = await ctx.ntUserApi.getUidByUin(payload.user_id, payload.guild_id)
+ if (!uid) throw new Error('无法获取用户信息')
+ await ctx.ntGroupApi.banMember(payload.guild_id, [
+ { uid, timeStamp: payload.duration / 1000 }
+ ])
+ return {}
+}
diff --git a/src/satori/api/message/create.ts b/src/satori/api/message/create.ts
new file mode 100644
index 0000000..6976732
--- /dev/null
+++ b/src/satori/api/message/create.ts
@@ -0,0 +1,13 @@
+import { Message } from '@satorijs/protocol'
+import { MessageEncoder } from '../../message'
+import { Handler } from '../index'
+
+interface Payload {
+ channel_id: string
+ content: string
+}
+
+export const createMessage: Handler = (ctx, payload) => {
+ const encoder = new MessageEncoder(ctx, payload.channel_id)
+ return encoder.send(payload.content)
+}
diff --git a/src/satori/api/message/delete.ts b/src/satori/api/message/delete.ts
new file mode 100644
index 0000000..428c0c1
--- /dev/null
+++ b/src/satori/api/message/delete.ts
@@ -0,0 +1,18 @@
+import { Handler } from '../index'
+import { Dict } from 'cosmokit'
+import { getPeer } from '../../utils'
+
+interface Payload {
+ channel_id: string
+ message_id: string
+}
+
+export const deleteMessage: Handler, Payload> = async (ctx, payload) => {
+ const peer = await getPeer(ctx, payload.channel_id)
+ const data = await ctx.ntMsgApi.recallMsg(peer, [payload.message_id])
+ if (data.result !== 0) {
+ ctx.logger.error('message.delete', payload.message_id, data)
+ throw new Error(`消息撤回失败`)
+ }
+ return {}
+}
diff --git a/src/satori/api/message/get.ts b/src/satori/api/message/get.ts
new file mode 100644
index 0000000..04178fb
--- /dev/null
+++ b/src/satori/api/message/get.ts
@@ -0,0 +1,18 @@
+import { Message } from '@satorijs/protocol'
+import { Handler } from '../index'
+import { decodeMessage, getPeer } from '../../utils'
+
+interface Payload {
+ channel_id: string
+ message_id: string
+}
+
+export const getMessage: Handler = async (ctx, payload) => {
+ const peer = await getPeer(ctx, payload.channel_id)
+ const raw = ctx.store.getMsgCache(payload.message_id) ?? (await ctx.ntMsgApi.getMsgsByMsgId(peer, [payload.message_id])).msgList[0]
+ const result = await decodeMessage(ctx, raw)
+ if (!result) {
+ throw new Error('消息为空')
+ }
+ return result
+}
diff --git a/src/satori/api/message/list.ts b/src/satori/api/message/list.ts
new file mode 100644
index 0000000..c32ebad
--- /dev/null
+++ b/src/satori/api/message/list.ts
@@ -0,0 +1,30 @@
+import { Direction, Message, Order, TwoWayList } from '@satorijs/protocol'
+import { Handler } from '../index'
+import { decodeMessage, getPeer } from '../../utils'
+import { RawMessage } from '@/ntqqapi/types'
+import { filterNullable } from '@/common/utils/misc'
+
+interface Payload {
+ channel_id: string
+ next?: string
+ direction?: Direction
+ limit?: number
+ order?: Order
+}
+
+export const getMessageList: Handler, Payload> = async (ctx, payload) => {
+ const count = payload.limit ?? 50
+ const peer = await getPeer(ctx, payload.channel_id)
+ let msgList: RawMessage[]
+ if (!payload.next) {
+ msgList = (await ctx.ntMsgApi.getAioFirstViewLatestMsgs(peer, count)).msgList
+ } else {
+ msgList = (await ctx.ntMsgApi.getMsgHistory(peer, payload.next, count)).msgList
+ }
+ const data = filterNullable(await Promise.all(msgList.map(e => decodeMessage(ctx, e))))
+ if (payload.order === 'desc') data.reverse()
+ return {
+ data,
+ next: msgList.at(-1)?.msgId
+ }
+}
diff --git a/src/satori/api/reaction/create.ts b/src/satori/api/reaction/create.ts
new file mode 100644
index 0000000..c37e9a8
--- /dev/null
+++ b/src/satori/api/reaction/create.ts
@@ -0,0 +1,19 @@
+import { Handler } from '../index'
+import { Dict } from 'cosmokit'
+import { getPeer } from '../../utils'
+
+interface Payload {
+ channel_id: string
+ message_id: string
+ emoji: string
+}
+
+export const createReaction: Handler, Payload> = async (ctx, payload) => {
+ const peer = await getPeer(ctx, payload.channel_id)
+ const { msgList } = await ctx.ntMsgApi.getMsgsByMsgId(peer, [payload.message_id])
+ if (!msgList.length || !msgList[0].msgSeq) {
+ throw new Error('无法获取该消息')
+ }
+ await ctx.ntMsgApi.setEmojiLike(peer, msgList[0].msgSeq, payload.emoji, true)
+ return {}
+}
diff --git a/src/satori/api/reaction/delete.ts b/src/satori/api/reaction/delete.ts
new file mode 100644
index 0000000..0bfd833
--- /dev/null
+++ b/src/satori/api/reaction/delete.ts
@@ -0,0 +1,20 @@
+import { Handler } from '../index'
+import { Dict } from 'cosmokit'
+import { getPeer } from '../../utils'
+
+interface Payload {
+ channel_id: string
+ message_id: string
+ emoji: string
+ user_id?: string
+}
+
+export const deleteReaction: Handler, Payload> = async (ctx, payload) => {
+ const peer = await getPeer(ctx, payload.channel_id)
+ const { msgList } = await ctx.ntMsgApi.getMsgsByMsgId(peer, [payload.message_id])
+ if (!msgList.length || !msgList[0].msgSeq) {
+ throw new Error('无法获取该消息')
+ }
+ await ctx.ntMsgApi.setEmojiLike(peer, msgList[0].msgSeq, payload.emoji, false)
+ return {}
+}
diff --git a/src/satori/api/reaction/list.ts b/src/satori/api/reaction/list.ts
new file mode 100644
index 0000000..f0decaf
--- /dev/null
+++ b/src/satori/api/reaction/list.ts
@@ -0,0 +1,27 @@
+import { List, User } from '@satorijs/protocol'
+import { Handler } from '../index'
+import { decodeUser, getPeer } from '../../utils'
+import { filterNullable } from '@/common/utils/misc'
+
+interface Payload {
+ channel_id: string
+ message_id: string
+ emoji: string
+ next?: string
+}
+
+export const getReactionList: Handler, Payload> = async (ctx, payload) => {
+ const peer = await getPeer(ctx, payload.channel_id)
+ const { msgList } = await ctx.ntMsgApi.getMsgsByMsgId(peer, [payload.message_id])
+ if (!msgList.length || !msgList[0].msgSeq) {
+ throw new Error('无法获取该消息')
+ }
+ const emojiType = payload.emoji.length > 3 ? '2' : '1'
+ const count = msgList[0].emojiLikesList.find(e => e.emojiId === payload.emoji)?.likesCnt ?? '50'
+ const data = await ctx.ntMsgApi.getMsgEmojiLikesList(peer, msgList[0].msgSeq, payload.emoji, emojiType, +count)
+ const uids = await Promise.all(data.emojiLikesList.map(e => ctx.ntUserApi.getUidByUin(e.tinyId, peer.peerUid)))
+ const raw = await ctx.ntUserApi.getCoreAndBaseInfo(filterNullable(uids))
+ return {
+ data: Array.from(raw.values()).map(e => decodeUser(e.coreInfo))
+ }
+}
diff --git a/src/satori/api/role/list.ts b/src/satori/api/role/list.ts
new file mode 100644
index 0000000..81c4ea9
--- /dev/null
+++ b/src/satori/api/role/list.ts
@@ -0,0 +1,26 @@
+import { GuildRole, List } from '@satorijs/protocol'
+import { Handler } from '../index'
+
+interface Payload {
+ guild_id: string
+ next?: string
+}
+
+export const getGuildRoleList: Handler>, Payload> = () => {
+ return {
+ data: [
+ {
+ id: '4',
+ name: 'owner'
+ },
+ {
+ id: '3',
+ name: 'admin'
+ },
+ {
+ id: '2',
+ name: 'member'
+ }
+ ]
+ }
+}
diff --git a/src/satori/api/role/member/set.ts b/src/satori/api/role/member/set.ts
new file mode 100644
index 0000000..f5d628d
--- /dev/null
+++ b/src/satori/api/role/member/set.ts
@@ -0,0 +1,20 @@
+import { Handler } from '../../index'
+import { Dict } from 'cosmokit'
+
+interface Payload {
+ guild_id: string
+ user_id: string
+ role_id: string
+}
+
+export const setGuildMemberRole: Handler, Payload> = async (ctx, payload) => {
+ const uid = await ctx.ntUserApi.getUidByUin(payload.user_id, payload.guild_id)
+ if (!uid) {
+ throw new Error('无法获取用户信息')
+ }
+ if (payload.role_id !== '2' && payload.role_id !== '3') {
+ throw new Error('role_id 仅可以为 2 或 3')
+ }
+ await ctx.ntGroupApi.setMemberRole(payload.guild_id, uid, +payload.role_id)
+ return {}
+}
diff --git a/src/satori/api/user/approve.ts b/src/satori/api/user/approve.ts
new file mode 100644
index 0000000..b663fe2
--- /dev/null
+++ b/src/satori/api/user/approve.ts
@@ -0,0 +1,25 @@
+import { Handler } from '../index'
+import { Dict } from 'cosmokit'
+import { ChatType } from '@/ntqqapi/types'
+
+interface Payload {
+ message_id: string
+ approve: boolean
+ comment?: string
+}
+
+export const handleFriendRequest: Handler, Payload> = async (ctx, payload) => {
+ const data = payload.message_id.split('|')
+ if (data.length < 2) {
+ throw new Error('无效的 message_id')
+ }
+ const uid = data[0]
+ const reqTime = data[1]
+ await ctx.ntFriendApi.handleFriendRequest(uid, reqTime, payload.approve)
+ await ctx.ntMsgApi.activateChat({
+ peerUid: uid,
+ chatType: ChatType.C2C,
+ guildId: ''
+ })
+ return {}
+}
diff --git a/src/satori/api/user/get.ts b/src/satori/api/user/get.ts
new file mode 100644
index 0000000..184607b
--- /dev/null
+++ b/src/satori/api/user/get.ts
@@ -0,0 +1,19 @@
+import { User } from '@satorijs/protocol'
+import { Handler } from '../index'
+import { decodeUser } from '../../utils'
+
+interface Payload {
+ user_id: string
+}
+
+export const getUser: Handler = async (ctx, payload) => {
+ const uin = payload.user_id
+ const uid = await ctx.ntUserApi.getUidByUin(uin)
+ if (!uid) throw new Error('无法获取用户信息')
+ const data = await ctx.ntUserApi.getUserSimpleInfo(uid)
+ const ranges = await ctx.ntUserApi.getRobotUinRange()
+ return {
+ ...decodeUser(data.coreInfo),
+ is_bot: ranges.some(e => uin >= e.minUin && uin <= e.maxUin)
+ }
+}
diff --git a/src/satori/api/user/list.ts b/src/satori/api/user/list.ts
new file mode 100644
index 0000000..421cfc4
--- /dev/null
+++ b/src/satori/api/user/list.ts
@@ -0,0 +1,22 @@
+import { User, List } from '@satorijs/protocol'
+import { Handler } from '../index'
+import { decodeUser } from '../../utils'
+import { getBuildVersion } from '@/common/utils/misc'
+
+interface Payload {
+ next?: string
+}
+
+export const getFriendList: Handler, Payload> = async (ctx) => {
+ if (getBuildVersion() >= 26702) {
+ const friends = await ctx.ntFriendApi.getBuddyV2()
+ return {
+ data: friends.map(e => decodeUser(e.coreInfo))
+ }
+ } else {
+ const friends = await ctx.ntFriendApi.getFriends()
+ return {
+ data: friends.map(e => decodeUser(e))
+ }
+ }
+}
diff --git a/src/satori/event/guild.ts b/src/satori/event/guild.ts
new file mode 100644
index 0000000..3287cfc
--- /dev/null
+++ b/src/satori/event/guild.ts
@@ -0,0 +1,32 @@
+import SatoriAdapter from '../adapter'
+import { RawMessage, GroupNotify } from '@/ntqqapi/types'
+import { decodeGuild } from '../utils'
+
+export async function parseGuildAdded(bot: SatoriAdapter, input: RawMessage) {
+ const { groupAll } = await bot.ctx.ntGroupApi.getGroupAllInfo(input.peerUid)
+
+ return bot.event('guild-added', {
+ guild: decodeGuild(groupAll)
+ })
+}
+
+export async function parseGuildRemoved(bot: SatoriAdapter, input: RawMessage) {
+ const { groupAll } = await bot.ctx.ntGroupApi.getGroupAllInfo(input.peerUid)
+
+ return bot.event('guild-removed', {
+ guild: decodeGuild(groupAll)
+ })
+}
+
+export async function parseGuildRequest(bot: SatoriAdapter, notify: GroupNotify) {
+ const groupCode = notify.group.groupCode
+ const flag = groupCode + '|' + notify.seq + '|' + notify.type
+
+ return bot.event('guild-request', {
+ guild: decodeGuild(notify.group),
+ message: {
+ id: flag,
+ content: notify.postscript
+ }
+ })
+}
diff --git a/src/satori/event/member.ts b/src/satori/event/member.ts
new file mode 100644
index 0000000..19842e2
--- /dev/null
+++ b/src/satori/event/member.ts
@@ -0,0 +1,60 @@
+import SatoriAdapter from '../adapter'
+import { RawMessage, GroupNotify } from '@/ntqqapi/types'
+import { decodeGuild, decodeUser } from '../utils'
+
+export async function parseGuildMemberAdded(bot: SatoriAdapter, input: RawMessage, isBot = false) {
+ const { groupAll } = await bot.ctx.ntGroupApi.getGroupAllInfo(input.peerUid)
+
+ let memberUid: string | undefined
+ if (input.elements[0].grayTipElement?.groupElement) {
+ memberUid = input.elements[0].grayTipElement.groupElement.memberUid
+ } else if (input.elements[0].grayTipElement?.jsonGrayTipElement) {
+ const json = JSON.parse(input.elements[0].grayTipElement.jsonGrayTipElement.jsonStr)
+ const uin = new URL(json.items[2].jp).searchParams.get('robot_uin')
+ if (!uin) return
+ memberUid = await bot.ctx.ntUserApi.getUidByUin(uin)
+ } else {
+ const iterator = input.elements[0].grayTipElement?.xmlElement?.members.keys()
+ iterator?.next()
+ memberUid = iterator?.next().value
+ }
+ if (!memberUid) return
+
+ const user = decodeUser((await bot.ctx.ntUserApi.getUserSimpleInfo(memberUid)).coreInfo)
+ user.is_bot = isBot
+
+ return bot.event('guild-member-added', {
+ guild: decodeGuild(groupAll),
+ user,
+ member: {
+ user,
+ nick: user.name
+ }
+ })
+}
+
+export async function parseGuildMemberRemoved(bot: SatoriAdapter, input: GroupNotify) {
+ const user = decodeUser((await bot.ctx.ntUserApi.getUserSimpleInfo(input.user1.uid)).coreInfo)
+
+ return bot.event('guild-member-removed', {
+ guild: decodeGuild(input.group),
+ user,
+ member: {
+ user,
+ nick: user.name
+ }
+ })
+}
+
+export async function parseGuildMemberRequest(bot: SatoriAdapter, input: GroupNotify) {
+ const groupCode = input.group.groupCode
+ const flag = groupCode + '|' + input.seq + '|' + input.type
+
+ return bot.event('guild-member-request', {
+ guild: decodeGuild(input.group),
+ message: {
+ id: flag,
+ content: input.postscript
+ }
+ })
+}
diff --git a/src/satori/event/message.ts b/src/satori/event/message.ts
new file mode 100644
index 0000000..14cf6be
--- /dev/null
+++ b/src/satori/event/message.ts
@@ -0,0 +1,35 @@
+import SatoriAdapter from '../adapter'
+import { RawMessage } from '@/ntqqapi/types'
+import { decodeMessage, decodeUser } from '../utils'
+import { omit } from 'cosmokit'
+
+export async function parseMessageCreated(bot: SatoriAdapter, input: RawMessage) {
+ const message = await decodeMessage(bot.ctx, input)
+ if (!message) return
+
+ return bot.event('message-created', {
+ message: omit(message, ['member', 'user', 'channel', 'guild']),
+ member: message.member,
+ user: message.user,
+ channel: message.channel,
+ guild: message.guild
+ })
+}
+
+export async function parseMessageDeleted(bot: SatoriAdapter, input: RawMessage) {
+ const origin = bot.ctx.store.getMsgCache(input.msgId)
+ if (!origin) return
+ const message = await decodeMessage(bot.ctx, origin)
+ if (!message) return
+ const operatorUid = input.elements[0].grayTipElement!.revokeElement!.operatorUid
+ const user = await bot.ctx.ntUserApi.getUserSimpleInfo(operatorUid)
+
+ return bot.event('message-deleted', {
+ message: omit(message, ['member', 'user', 'channel', 'guild']),
+ member: message.member,
+ user: message.user,
+ channel: message.channel,
+ guild: message.guild,
+ operator: omit(decodeUser(user.coreInfo), ['is_bot'])
+ })
+}
diff --git a/src/satori/event/user.ts b/src/satori/event/user.ts
new file mode 100644
index 0000000..9869673
--- /dev/null
+++ b/src/satori/event/user.ts
@@ -0,0 +1,16 @@
+import SatoriAdapter from '../adapter'
+import { FriendRequest } from '@/ntqqapi/types'
+import { decodeUser } from '../utils'
+
+export async function parseFriendRequest(bot: SatoriAdapter, input: FriendRequest) {
+ const flag = input.friendUid + '|' + input.reqTime
+ const user = await bot.ctx.ntUserApi.getUserSimpleInfo(input.friendUid)
+
+ return bot.event('friend-request', {
+ user: decodeUser(user.coreInfo),
+ message: {
+ id: flag,
+ content: input.extWords
+ }
+ })
+}
diff --git a/src/satori/message.ts b/src/satori/message.ts
new file mode 100644
index 0000000..3474fb0
--- /dev/null
+++ b/src/satori/message.ts
@@ -0,0 +1,307 @@
+import h from '@satorijs/element'
+import pathLib from 'node:path'
+import * as NT from '@/ntqqapi/types'
+import { Context } from 'cordis'
+import { Message } from '@satorijs/protocol'
+import { SendElement } from '@/ntqqapi/entities'
+import { decodeMessage, getPeer } from './utils'
+import { ObjectToSnake } from 'ts-case-convert'
+import { uri2local } from '@/common/utils'
+import { unlink } from 'node:fs/promises'
+import { selfInfo } from '@/common/globalVars'
+
+class State {
+ children: (NT.SendMessageElement | string)[] = []
+
+ constructor(public type: 'message' | 'multiForward') { }
+}
+
+export class MessageEncoder {
+ public errors: Error[] = []
+ public results: ObjectToSnake[] = []
+ private elements: NT.SendMessageElement[] = []
+ private deleteAfterSentFiles: string[] = []
+ private stack: State[] = [new State('message')]
+ private peer?: NT.Peer
+
+ constructor(private ctx: Context, private channelId: string) { }
+
+ async flush() {
+ if (this.elements.length === 0) return
+
+ if (this.stack[0].type === 'multiForward') {
+ this.stack[0].children.push(...this.elements)
+ this.elements = []
+ return
+ }
+
+ this.peer ??= await getPeer(this.ctx, this.channelId)
+ const sent = await this.ctx.ntMsgApi.sendMsg(this.peer, this.elements)
+ if (sent) {
+ this.ctx.logger.info('消息发送', this.peer)
+ const result = await decodeMessage(this.ctx, sent)
+ result && this.results.push(result)
+ }
+ this.deleteAfterSentFiles.forEach(path => unlink(path))
+ this.deleteAfterSentFiles = []
+ this.elements = []
+ }
+
+ private async fetchFile(url: string) {
+ const res = await uri2local(this.ctx, url)
+ if (!res.success) {
+ this.ctx.logger.error(res.errMsg)
+ throw Error(res.errMsg)
+ }
+ if (!res.isLocal) {
+ this.deleteAfterSentFiles.push(res.path)
+ }
+ return res.path
+ }
+
+ private async getPeerFromMsgId(msgId: string): Promise {
+ this.peer ??= await getPeer(this.ctx, this.channelId)
+ const msg = (await this.ctx.ntMsgApi.getMsgsByMsgId(this.peer, [msgId])).msgList
+ if (msg.length > 0) {
+ return this.peer
+ } else {
+ const cacheMsg = this.ctx.store.getMsgCache(msgId)
+ if (cacheMsg) {
+ return {
+ peerUid: cacheMsg.peerUid,
+ chatType: cacheMsg.chatType
+ }
+ }
+ const c2cMsg = await this.ctx.ntMsgApi.queryMsgsById(NT.ChatType.C2C, msgId)
+ if (c2cMsg.msgList.length) {
+ return {
+ peerUid: c2cMsg.msgList[0].peerUid,
+ chatType: c2cMsg.msgList[0].chatType
+ }
+ }
+ const groupMsg = await this.ctx.ntMsgApi.queryMsgsById(NT.ChatType.Group, msgId)
+ if (groupMsg.msgList.length) {
+ return {
+ peerUid: groupMsg.msgList[0].peerUid,
+ chatType: groupMsg.msgList[0].chatType
+ }
+ }
+ }
+ }
+
+ private async forward(msgId: string, srcPeer: NT.Peer, destPeer: NT.Peer) {
+ const list = await this.ctx.ntMsgApi.forwardMsg(srcPeer, destPeer, [msgId])
+ return list[0]
+ }
+
+ private async multiForward() {
+ if (!this.stack[0].children.length) return
+
+ const selfPeer = {
+ chatType: NT.ChatType.C2C,
+ peerUid: selfInfo.uid,
+ }
+ const nodeMsgIds: { msgId: string, peer: NT.Peer }[] = []
+ for (const node of this.stack[0].children) {
+ if (typeof node === 'string') {
+ if (node.length !== 19) {
+ this.ctx.logger.warn('转发消息失败,消息 ID 不合法', node)
+ continue
+ }
+ const peer = await this.getPeerFromMsgId(node)
+ if (!peer) {
+ this.ctx.logger.warn('转发消息失败,未找到消息', node)
+ continue
+ }
+ nodeMsgIds.push({ msgId: node, peer })
+ } else {
+ try {
+ const sent = await this.ctx.ntMsgApi.sendMsg(selfPeer, [node])
+ if (!sent) {
+ this.ctx.logger.warn('转发节点生成失败', node)
+ continue
+ }
+ nodeMsgIds.push({ msgId: sent.msgId, peer: selfPeer })
+ await this.ctx.sleep(100)
+ } catch (e) {
+ this.ctx.logger.error('生成转发消息节点失败', e)
+ }
+ }
+ }
+
+ let srcPeer: NT.Peer
+ let needSendSelf = false
+ for (const { peer } of nodeMsgIds) {
+ srcPeer ??= { chatType: peer.chatType, peerUid: peer.peerUid }
+ if (srcPeer.peerUid !== peer.peerUid) {
+ needSendSelf = true
+ break
+ }
+ }
+ let retMsgIds: string[] = []
+ if (needSendSelf) {
+ for (const { msgId, peer } of nodeMsgIds) {
+ const srcPeer = {
+ peerUid: peer.peerUid,
+ chatType: peer.chatType
+ }
+ const clonedMsg = await this.forward(msgId, srcPeer, selfPeer)
+ if (clonedMsg) {
+ retMsgIds.push(clonedMsg.msgId)
+ }
+ await this.ctx.sleep(100)
+ }
+ srcPeer = selfPeer
+ } else {
+ retMsgIds = nodeMsgIds.map(e => e.msgId)
+ }
+ if (retMsgIds.length === 0) {
+ throw Error('转发消息失败,节点为空')
+ }
+
+ if (this.stack[1].type === 'multiForward') {
+ this.peer ??= await getPeer(this.ctx, this.channelId)
+ const msg = await this.ctx.ntMsgApi.multiForwardMsg(srcPeer!, selfPeer, retMsgIds)
+ this.stack[1].children.push(...msg.elements as NT.SendMessageElement[])
+ } else {
+ this.peer ??= await getPeer(this.ctx, this.channelId)
+ await this.ctx.ntMsgApi.multiForwardMsg(srcPeer!, this.peer, retMsgIds)
+ this.ctx.logger.info('消息发送', this.peer)
+ }
+ }
+
+ async visit(element: h) {
+ const { type, attrs, children } = element
+ if (type === 'text') {
+ this.elements.push(SendElement.text(attrs.content))
+ } else if (type === 'at') {
+ if (attrs.type === 'all') {
+ this.elements.push(SendElement.at('', '', NT.AtType.All, '@全体成员'))
+ } else {
+ const uid = await this.ctx.ntUserApi.getUidByUin(attrs.id) ?? ''
+ const display = attrs.name ? '@' + attrs.name : ''
+ this.elements.push(SendElement.at(attrs.id, uid, NT.AtType.One, display))
+ }
+ } else if (type === 'a') {
+ await this.render(children)
+ const prev = this.elements.at(-1)
+ if (prev?.elementType === 1 && prev.textElement.atType === 0) {
+ prev.textElement.content += ` ( ${attrs.href} )`
+ }
+ } else if (type === 'img' || type === 'image') {
+ const url = attrs.src ?? attrs.url
+ const path = await this.fetchFile(url)
+ const element = await SendElement.pic(this.ctx, path)
+ this.deleteAfterSentFiles.push(element.picElement.sourcePath!)
+ this.elements.push(element)
+ } else if (type === 'audio') {
+ await this.flush()
+ const url = attrs.src ?? attrs.url
+ const path = await this.fetchFile(url)
+ this.elements.push(await SendElement.ptt(this.ctx, path))
+ await this.flush()
+ } else if (type === 'video') {
+ await this.flush()
+ const url = attrs.src ?? attrs.url
+ const path = await this.fetchFile(url)
+ let thumb: string | undefined
+ if (attrs.poster) {
+ thumb = await this.fetchFile(attrs.poster)
+ }
+ const element = await SendElement.video(this.ctx, path, undefined, thumb)
+ this.deleteAfterSentFiles.push(element.videoElement.filePath)
+ this.elements.push(element)
+ await this.flush()
+ } else if (type === 'file') {
+ await this.flush()
+ const url = attrs.src ?? attrs.url
+ const path = await this.fetchFile(url)
+ const fileName = attrs.title ?? pathLib.basename(path)
+ this.elements.push(await SendElement.file(this.ctx, path, fileName))
+ await this.flush()
+ } else if (type === 'br') {
+ this.elements.push(SendElement.text('\n'))
+ } else if (type === 'p') {
+ const prev = this.elements.at(-1)
+ if (prev?.elementType === 1 && prev.textElement.atType === 0) {
+ if (!prev.textElement.content.endsWith('\n')) {
+ prev.textElement.content += '\n'
+ }
+ } else if (prev) {
+ this.elements.push(SendElement.text('\n'))
+ }
+ await this.render(children)
+ const last = this.elements.at(-1)
+ if (last?.elementType === 1 && last.textElement.atType === 0) {
+ if (!last.textElement.content.endsWith('\n')) {
+ last.textElement.content += '\n'
+ }
+ } else {
+ this.elements.push(SendElement.text('\n'))
+ }
+ } else if (type === 'message') {
+ if (attrs.id && attrs.forward) {
+ await this.flush()
+ const srcPeer = await this.getPeerFromMsgId(attrs.id)
+ if (srcPeer) {
+ this.peer ??= await getPeer(this.ctx, this.channelId)
+ const sent = await this.forward(attrs.id, srcPeer, this.peer)
+ if (sent) {
+ this.ctx.logger.info('消息发送', this.peer)
+ const result = await decodeMessage(this.ctx, sent)
+ result && this.results.push(result)
+ }
+ }
+ } else if (attrs.forward) {
+ await this.flush()
+ this.stack.unshift(new State('multiForward'))
+ await this.render(children)
+ await this.flush()
+ await this.multiForward()
+ this.stack.shift()
+ } else if (attrs.id && this.stack[0].type === 'multiForward') {
+ this.stack[0].children.push(attrs.id)
+ } else {
+ await this.render(children)
+ await this.flush()
+ }
+ } else if (type === 'quote') {
+ this.peer ??= await getPeer(this.ctx, this.channelId)
+ const source = (await this.ctx.ntMsgApi.getMsgsByMsgId(this.peer, [attrs.id])).msgList[0]
+ if (source) {
+ this.elements.push(SendElement.reply(source.msgSeq, source.msgId, source.senderUin))
+ }
+ } else if (type === 'face') {
+ this.elements.push(SendElement.face(+attrs.id, +attrs.type))
+ } else if (type === 'mface') {
+ this.elements.push(SendElement.mface(
+ +attrs.emojiPackageId,
+ attrs.emojiId,
+ attrs.key,
+ attrs.summary
+ ))
+ } else {
+ await this.render(children)
+ }
+ }
+
+ async render(elements: h[], flush?: boolean) {
+ for (const element of elements) {
+ await this.visit(element)
+ }
+ if (flush) {
+ await this.flush()
+ }
+ }
+
+ async send(content: h.Fragment) {
+ const elements = h.normalize(content)
+ await this.render(elements)
+ await this.flush()
+ if (this.errors.length) {
+ throw new AggregateError(this.errors)
+ } else {
+ return this.results
+ }
+ }
+}
diff --git a/src/satori/server.ts b/src/satori/server.ts
new file mode 100644
index 0000000..290748b
--- /dev/null
+++ b/src/satori/server.ts
@@ -0,0 +1,143 @@
+import * as Universal from '@satorijs/protocol'
+import express, { Express, Request, Response } from 'express'
+import { Server } from 'node:http'
+import { Context } from 'cordis'
+import { handlers } from './api'
+import { WebSocket, WebSocketServer } from 'ws'
+import { promisify } from 'node:util'
+import { ObjectToSnake } from 'ts-case-convert'
+import { selfInfo } from '@/common/globalVars'
+
+export class SatoriServer {
+ private express: Express
+ private httpServer?: Server
+ private wsServer?: WebSocketServer
+ private wsClients: WebSocket[] = []
+
+ constructor(private ctx: Context, private config: SatoriServer.Config) {
+ this.express = express()
+ this.express.use(express.json({ limit: '50mb' }))
+ }
+
+ public start() {
+ this.express.get('/v1/:name', async (req, res) => {
+ res.status(405).send('Please use POST method to send requests.')
+ })
+
+ this.express.post('/v1/:name', async (req, res) => {
+ const method = Universal.Methods[req.params.name]
+ if (!method) {
+ res.status(404).send('method not found')
+ return
+ }
+
+ if (this.checkAuth(req, res)) return
+
+ const selfId = req.headers['satori-user-id'] ?? req.headers['x-self-id']
+ const platform = req.headers['satori-platform'] ?? req.headers['x-platform']
+ if (selfId !== selfInfo.uin || !platform) {
+ res.status(403).send('login not found')
+ return
+ }
+
+ const handle = handlers[method.name]
+ if (!handle) {
+ res.status(404).send('method not found')
+ return
+ }
+ try {
+ const result = await handle(this.ctx, req.body)
+ res.json(result)
+ } catch (e) {
+ this.ctx.logger.error(e)
+ throw e
+ }
+ })
+
+ const { listen, port } = this.config
+ this.httpServer = this.express.listen(port, listen, () => {
+ this.ctx.logger.info(`HTTP server started ${listen}:${port}`)
+ })
+ this.wsServer = new WebSocketServer({
+ server: this.httpServer
+ })
+
+ this.wsServer.on('connection', (socket, req) => {
+ const url = req.url?.split('?').shift()
+ if (!['/v1/events', '/v1/events/'].includes(url!)) {
+ return socket.close()
+ }
+
+ socket.addEventListener('message', async (event) => {
+ let payload: Universal.ClientPayload
+ try {
+ payload = JSON.parse(event.data.toString())
+ } catch (error) {
+ return socket.close(4000, 'invalid message')
+ }
+
+ if (payload.op === Universal.Opcode.IDENTIFY) {
+ if (this.config.token && payload.body?.token !== this.config.token) {
+ return socket.close(4004, 'invalid token')
+ }
+ this.ctx.logger.info('ws connect', url)
+ socket.send(JSON.stringify({
+ op: Universal.Opcode.READY,
+ body: {
+ logins: [await handlers.getLogin(this.ctx, {})]
+ }
+ } as Universal.ServerPayload))
+ this.wsClients.push(socket)
+ } else if (payload.op === Universal.Opcode.PING) {
+ socket.send(JSON.stringify({
+ op: Universal.Opcode.PONG,
+ body: {},
+ } as Universal.ServerPayload))
+ }
+ })
+ })
+ }
+
+ public async stop() {
+ if (this.wsServer) {
+ const close = promisify(this.wsServer.close)
+ await close.call(this.wsServer)
+ }
+ if (this.httpServer) {
+ const close = promisify(this.httpServer.close)
+ await close.call(this.httpServer)
+ }
+ }
+
+ private checkAuth(req: Request, res: Response) {
+ if (!this.config.token) return
+ if (req.headers.authorization !== `Bearer ${this.config.token}`) {
+ res.status(403).send('invalid token')
+ return true
+ }
+ }
+
+ public async dispatch(body: ObjectToSnake) {
+ this.wsClients.forEach(socket => {
+ if (socket.readyState === WebSocket.OPEN) {
+ socket.send(JSON.stringify({
+ op: Universal.Opcode.EVENT,
+ body
+ } as ObjectToSnake))
+ this.ctx.logger.info('WebSocket 事件上报', socket.url ?? '', body.type)
+ }
+ })
+ }
+
+ public updateConfig(config: SatoriServer.Config) {
+ Object.assign(this.config, config)
+ }
+}
+
+namespace SatoriServer {
+ export interface Config {
+ port: number
+ listen: string
+ token: string
+ }
+}
diff --git a/src/satori/utils.ts b/src/satori/utils.ts
new file mode 100644
index 0000000..564c6da
--- /dev/null
+++ b/src/satori/utils.ts
@@ -0,0 +1,218 @@
+import h from '@satorijs/element'
+import * as NT from '@/ntqqapi/types'
+import * as Universal from '@satorijs/protocol'
+import { Context } from 'cordis'
+import { ObjectToSnake } from 'ts-case-convert'
+import { pick } from 'cosmokit'
+import { pathToFileURL } from 'node:url'
+
+export function decodeUser(user: NT.User): ObjectToSnake {
+ return {
+ id: user.uin,
+ name: user.nick,
+ nick: user.remark || user.nick,
+ avatar: `http://q.qlogo.cn/headimg_dl?dst_uin=${user.uin}&spec=640`,
+ is_bot: false
+ }
+}
+
+function decodeGuildChannelId(data: NT.RawMessage) {
+ if (data.chatType === NT.ChatType.Group) {
+ return [data.peerUin, data.peerUin]
+ } else {
+ return [undefined, 'private:' + data.peerUin]
+ }
+}
+
+function decodeMessageUser(data: NT.RawMessage) {
+ return {
+ id: data.senderUin,
+ name: data.sendNickName,
+ nick: data.sendRemarkName || data.sendNickName,
+ avatar: `http://q.qlogo.cn/headimg_dl?dst_uin=${data.senderUin}&spec=640`
+ }
+}
+
+function decodeMessageMember(user: Universal.User, data: NT.RawMessage) {
+ return {
+ user: user,
+ nick: data.sendMemberName || data.sendNickName
+ }
+}
+
+async function decodeElement(ctx: Context, data: NT.RawMessage, quoted = false) {
+ const buffer: h[] = []
+ for (const v of data.elements) {
+ if (v.textElement && v.textElement.atType !== NT.AtType.Unknown) {
+ // at
+ const { atNtUid, atUid, atType, content } = v.textElement
+ if (atType === NT.AtType.All) {
+ buffer.push(h.at(undefined, { type: 'all' }))
+ } else if (atType === NT.AtType.One) {
+ let id: string
+ if (atUid && atUid !== '0') {
+ id = atUid
+ } else {
+ id = await ctx.ntUserApi.getUinByUid(atNtUid)
+ }
+ buffer.push(h.at(id, { name: content.replace('@', '') }))
+ }
+ } else if (v.textElement && v.textElement.content) {
+ // text
+ buffer.push(h.text(v.textElement.content))
+ } else if (v.replyElement && !quoted) {
+ // quote
+ const peer = {
+ chatType: data.chatType,
+ peerUid: data.peerUid,
+ guildId: ''
+ }
+ const { replayMsgSeq, replyMsgTime, sourceMsgIdInRecords } = v.replyElement
+ const records = data.records.find(msgRecord => msgRecord.msgId === sourceMsgIdInRecords)
+ const senderUid = v.replyElement.senderUidStr || records?.senderUid
+ if (!records || !replyMsgTime || !senderUid) {
+ ctx.logger.error('找不到引用消息', v.replyElement)
+ continue
+ }
+ if (data.multiTransInfo) {
+ buffer.push(h.quote(records.msgId))
+ continue
+ }
+
+ try {
+ const { msgList } = await ctx.ntMsgApi.queryMsgsWithFilterExBySeq(peer, replayMsgSeq, replyMsgTime, [senderUid])
+ let replyMsg: NT.RawMessage | undefined
+ if (records.msgRandom !== '0') {
+ replyMsg = msgList.find(msg => msg.msgRandom === records.msgRandom)
+ } else {
+ ctx.logger.info('msgRandom is missing', v.replyElement, records)
+ replyMsg = msgList[0]
+ }
+ if (!replyMsg) {
+ ctx.logger.info('queryMsgs', msgList.map(e => pick(e, ['msgSeq', 'msgRandom'])), records.msgRandom)
+ throw new Error('回复消息验证失败')
+ }
+ const elements = await decodeElement(ctx, replyMsg, true)
+ buffer.push(h('quote', { id: replyMsg.msgId }, elements))
+ } catch (e) {
+ ctx.logger.error('获取不到引用的消息', v.replyElement, (e as Error).stack)
+ }
+ } else if (v.picElement) {
+ // img
+ const src = await ctx.ntFileApi.getImageUrl(v.picElement)
+ buffer.push(h.img(src, {
+ width: v.picElement.picWidth,
+ height: v.picElement.picHeight,
+ subType: v.picElement.picSubType
+ }))
+ } else if (v.pttElement) {
+ // audio
+ const src = pathToFileURL(v.pttElement.filePath).href
+ buffer.push(h.audio(src, { duration: v.pttElement.duration }))
+ } else if (v.videoElement) {
+ // video
+ const src = (await ctx.ntFileApi.getVideoUrl({
+ chatType: data.chatType,
+ peerUid: data.peerUid,
+ }, data.msgId, v.elementId)) || pathToFileURL(v.videoElement.filePath).href
+ buffer.push(h.video(src))
+ } else if (v.marketFaceElement) {
+ // mface
+ const { emojiId, supportSize } = v.marketFaceElement
+ const { width = 300, height = 300 } = supportSize?.[0] ?? {}
+ const dir = emojiId.substring(0, 2)
+ const src = `https://gxh.vip.qq.com/club/item/parcel/item/${dir}/${emojiId}/raw${width}.gif`
+ buffer.push(h('mface', {
+ emojiPackageId: v.marketFaceElement.emojiPackageId,
+ emojiId,
+ key: v.marketFaceElement.key,
+ summary: v.marketFaceElement.faceName
+ }, [h.image(src, { width, height })]))
+ } else if (v.faceElement) {
+ // face
+ const { faceIndex, faceType } = v.faceElement
+ buffer.push(h('face', {
+ id: String(faceIndex),
+ type: String(faceType),
+ platform: 'llonebot'
+ }))
+ }
+ }
+ return buffer
+}
+
+export async function decodeMessage(
+ ctx: Context,
+ data: NT.RawMessage,
+ message: ObjectToSnake = {}
+) {
+ if (!data.senderUin || data.senderUin === '0') return //跳过空消息
+
+ const [guildId, channelId] = decodeGuildChannelId(data)
+ const elements = await decodeElement(ctx, data)
+
+ if (elements.length === 0) return
+
+ message.id = data.msgId
+ message.content = elements.join('')
+ message.channel = {
+ id: channelId!,
+ name: data.peerName,
+ type: guildId ? Universal.Channel.Type.TEXT : Universal.Channel.Type.DIRECT
+ }
+ message.user = decodeMessageUser(data)
+ message.created_at = +data.msgTime * 1000
+ if (message.channel.type === Universal.Channel.Type.DIRECT) {
+ const info = (await ctx.ntUserApi.getUserSimpleInfo(data.senderUid)).coreInfo
+ message.channel.name = info.nick
+ message.user.name = info.nick
+ message.user.nick = info.remark || info.nick
+ }
+ if (guildId) {
+ message.guild = {
+ id: guildId,
+ name: data.peerName,
+ avatar: `https://p.qlogo.cn/gh/${guildId}/${guildId}/640`
+ }
+ message.member = decodeMessageMember(message.user, data)
+ }
+
+ return message
+}
+
+export function decodeGuildMember(data: NT.GroupMember): ObjectToSnake {
+ return {
+ user: {
+ ...decodeUser(data),
+ is_bot: data.isRobot
+ },
+ nick: data.cardName || data.nick,
+ avatar: `http://q.qlogo.cn/headimg_dl?dst_uin=${data.uin}&spec=640`,
+ joined_at: data.joinTime * 1000
+ }
+}
+
+export function decodeGuild(data: Record<'groupCode' | 'groupName', string>): ObjectToSnake {
+ return {
+ id: data.groupCode,
+ name: data.groupName,
+ avatar: `https://p.qlogo.cn/gh/${data.groupCode}/${data.groupCode}/640`
+ }
+}
+
+export async function getPeer(ctx: Context, channelId: string): Promise {
+ let peerUid = channelId
+ let chatType: NT.ChatType = NT.ChatType.Group
+ if (peerUid.includes('private:')) {
+ const uin = channelId.replace('private:', '')
+ const uid = await ctx.ntUserApi.getUidByUin(uin)
+ if (!uid) throw new Error('无法获取用户信息')
+ const isBuddy = await ctx.ntFriendApi.isBuddy(uid)
+ chatType = isBuddy ? NT.ChatType.C2C : NT.ChatType.TempC2CFromGroup
+ peerUid = uid
+ }
+ return {
+ chatType,
+ peerUid
+ }
+}