feat: satori protocol

This commit is contained in:
idranme
2024-10-06 10:37:06 +08:00
parent 8c0cc8beba
commit 4cd9adde1d
45 changed files with 1712 additions and 72 deletions

View File

@@ -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: '',

View File

@@ -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 {

View File

@@ -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!
})
}

View File

@@ -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)
})
}

View File

@@ -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)
}

View File

@@ -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 => {

View File

@@ -3,3 +3,4 @@ export * from './item'
export * from './button'
export * from './switch'
export * from './select'
export * from './input'

View File

@@ -0,0 +1,20 @@
export const SettingInput = (
key: string,
type: 'port' | 'text',
value: string | number,
placeholder: string | number,
style = ''
) => {
if (type === 'text') {
return `
<div class="q-input" style="${style}">
<input class="q-input__inner" data-config-key="${key}" type="text" value="${value}" placeholder="${placeholder}" />
</div>
`
}
return `
<div class="q-input" style="${style}">
<input class="q-input__inner" data-config-key="${key}" type="number" min="1" max="65534" value="${value}" placeholder="${placeholder}" />
</div>
`
}

View File

@@ -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,
`<div class="q-input"><input class="q-input__inner" data-config-key="ob11.httpPort" type="number" min="1" max="65534" value="${config.ob11.httpPort}" placeholder="${config.ob11.httpPort}" /></div>`,
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) {
<div id="config-ob11-wsHosts-list"></div>
</div>`,
SettingItem(
' WebSocket 服务心跳间隔',
'WebSocket 服务心跳间隔',
'控制每隔多久发送一个心跳包,单位为毫秒',
`<div class="q-input"><input class="q-input__inner" data-config-key="heartInterval" type="number" min="1000" value="${config.heartInterval}" placeholder="${config.heartInterval}" /></div>`,
),
SettingItem(
'Access token',
null,
`<div class="q-input" style="width:210px;"><input class="q-input__inner" data-config-key="token" type="text" value="${config.token}" placeholder="未设置" /></div>`,
`<div class="q-input" style="width:170px;"><input class="q-input__inner" data-config-key="token" type="text" value="${config.token}" placeholder="未设置" /></div>`,
),
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 路径,发送语音、视频需要',
`<a href="javascript:LiteLoader.api.openExternal(\'https://llonebot.github.io/zh-CN/guide/ffmpeg\');">可点此下载</a>, 路径: <span id="config-ffmpeg-path-text">${!isEmpty(config.ffmpeg) ? config.ffmpeg : '未指定'
@@ -160,25 +192,6 @@ async function onSettingWindowCreated(view: Element) {
`<div class="q-input" style="width:210px;"><input class="q-input__inner" data-config-key="musicSignUrl" type="text" value="${config.musicSignUrl}" placeholder="未设置" /></div>`,
'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<HTMLSpanElement>('#llonebot-update-title')!
const buttonDom = view.querySelector<HTMLButtonElement>('#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'

192
src/satori/adapter.ts Normal file
View File

@@ -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<Event>>): ObjectToSnake<Event> {
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

View File

@@ -0,0 +1,11 @@
import { Handler } from '../index'
import { Dict } from 'cosmokit'
interface Payload {
channel_id: string
}
export const deleteChannel: Handler<Dict<never>, Payload> = async (ctx, payload) => {
await ctx.ntGroupApi.quitGroup(payload.channel_id)
return {}
}

View File

@@ -0,0 +1,15 @@
import { Channel } from '@satorijs/protocol'
import { Handler } from '../index'
interface Payload {
channel_id: string
}
export const getChannel: Handler<Channel, Payload> = 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
}
}

View File

@@ -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<List<Channel>, 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
}]
}
}

View File

@@ -0,0 +1,12 @@
import { Handler } from '../index'
import { Dict } from 'cosmokit'
interface Payload {
channel_id: string
duration: number
}
export const muteChannel: Handler<Dict<never>, Payload> = async (ctx, payload) => {
await ctx.ntGroupApi.banGroup(payload.channel_id, payload.duration !== 0)
return {}
}

View File

@@ -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<Channel>
}
export const updateChannel: Handler<Dict<never>, Payload> = async (ctx, payload) => {
if (payload.data.name) {
await ctx.ntGroupApi.setGroupName(payload.channel_id, payload.data.name)
}
return {}
}

View File

@@ -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<Channel, Payload> = async (ctx, payload) => {
return {
id: 'private:' + payload.user_id,
type: Channel.Type.DIRECT
}
}

View File

@@ -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<Dict<never>, Payload> = async (ctx, payload) => {
await ctx.ntGroupApi.handleGroupRequest(
payload.message_id,
payload.approve ? GroupRequestOperateTypes.approve : GroupRequestOperateTypes.reject,
payload.comment
)
return {}
}

View File

@@ -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<Guild, Payload> = async (ctx, payload) => {
const info = await ctx.ntGroupApi.getGroupAllInfo(payload.guild_id)
return decodeGuild(info.groupAll)
}

View File

@@ -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<List<Guild>, Payload> = async (ctx) => {
const groups = await ctx.ntGroupApi.getGroups()
return {
data: groups.map(decodeGuild)
}
}

73
src/satori/api/index.ts Normal file
View File

@@ -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<ObjectToSnake<R>>
export const handlers: Record<string, Handler> = {
// 频道 (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,
}

View File

@@ -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<Login> = 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: []
}
}

View File

@@ -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<Dict<never>, Payload> = async (ctx, payload) => {
await ctx.ntGroupApi.handleGroupRequest(
payload.message_id,
payload.approve ? GroupRequestOperateTypes.approve : GroupRequestOperateTypes.reject,
payload.comment
)
return {}
}

View File

@@ -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<GuildMember, Payload> = 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)
}

View File

@@ -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<Dict<never>, 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 {}
}

View File

@@ -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<List<GuildMember>, 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)
}
}

View File

@@ -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<Dict<never>, 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 {}
}

View File

@@ -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<Message[], Payload> = (ctx, payload) => {
const encoder = new MessageEncoder(ctx, payload.channel_id)
return encoder.send(payload.content)
}

View File

@@ -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<Dict<never>, 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 {}
}

View File

@@ -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<Message, Payload> = 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
}

View File

@@ -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<TwoWayList<Message>, 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
}
}

View File

@@ -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<Dict<never>, 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 {}
}

View File

@@ -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<Dict<never>, 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 {}
}

View File

@@ -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<List<User>, 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))
}
}

View File

@@ -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<List<Partial<GuildRole>>, Payload> = () => {
return {
data: [
{
id: '4',
name: 'owner'
},
{
id: '3',
name: 'admin'
},
{
id: '2',
name: 'member'
}
]
}
}

View File

@@ -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<Dict<never>, 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 {}
}

View File

@@ -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<Dict<never>, 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 {}
}

View File

@@ -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<User, Payload> = 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)
}
}

View File

@@ -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<List<User>, 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))
}
}
}

32
src/satori/event/guild.ts Normal file
View File

@@ -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
}
})
}

View File

@@ -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
}
})
}

View File

@@ -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'])
})
}

16
src/satori/event/user.ts Normal file
View File

@@ -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
}
})
}

307
src/satori/message.ts Normal file
View File

@@ -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<Message>[] = []
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<NT.Peer | undefined> {
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
}
}
}

143
src/satori/server.ts Normal file
View File

@@ -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<Universal.Event>) {
this.wsClients.forEach(socket => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
op: Universal.Opcode.EVENT,
body
} as ObjectToSnake<Universal.ServerPayload>))
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
}
}

218
src/satori/utils.ts Normal file
View File

@@ -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<Universal.User> {
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<Universal.Message> = {}
) {
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<Universal.GuildMember> {
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<Universal.Guild> {
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<NT.Peer> {
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
}
}