mirror of
https://github.com/LLOneBot/LLOneBot.git
synced 2024-11-22 01:56:33 +00:00
feat: satori protocol
This commit is contained in:
@@ -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: '',
|
||||
|
@@ -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 {
|
||||
|
@@ -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!
|
||||
})
|
||||
}
|
||||
|
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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 => {
|
||||
|
@@ -3,3 +3,4 @@ export * from './item'
|
||||
export * from './button'
|
||||
export * from './switch'
|
||||
export * from './select'
|
||||
export * from './input'
|
||||
|
20
src/renderer/components/input.ts
Normal file
20
src/renderer/components/input.ts
Normal 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>
|
||||
`
|
||||
}
|
@@ -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
192
src/satori/adapter.ts
Normal 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
|
11
src/satori/api/channel/delete.ts
Normal file
11
src/satori/api/channel/delete.ts
Normal 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 {}
|
||||
}
|
15
src/satori/api/channel/get.ts
Normal file
15
src/satori/api/channel/get.ts
Normal 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
|
||||
}
|
||||
}
|
18
src/satori/api/channel/list.ts
Normal file
18
src/satori/api/channel/list.ts
Normal 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
|
||||
}]
|
||||
}
|
||||
}
|
12
src/satori/api/channel/mute.ts
Normal file
12
src/satori/api/channel/mute.ts
Normal 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 {}
|
||||
}
|
16
src/satori/api/channel/update.ts
Normal file
16
src/satori/api/channel/update.ts
Normal 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 {}
|
||||
}
|
14
src/satori/api/channel/user/create.ts
Normal file
14
src/satori/api/channel/user/create.ts
Normal 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
|
||||
}
|
||||
}
|
18
src/satori/api/guild/approve.ts
Normal file
18
src/satori/api/guild/approve.ts
Normal 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 {}
|
||||
}
|
12
src/satori/api/guild/get.ts
Normal file
12
src/satori/api/guild/get.ts
Normal 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)
|
||||
}
|
14
src/satori/api/guild/list.ts
Normal file
14
src/satori/api/guild/list.ts
Normal 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
73
src/satori/api/index.ts
Normal 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,
|
||||
}
|
24
src/satori/api/login/get.ts
Normal file
24
src/satori/api/login/get.ts
Normal 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: []
|
||||
}
|
||||
}
|
18
src/satori/api/member/approve.ts
Normal file
18
src/satori/api/member/approve.ts
Normal 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 {}
|
||||
}
|
18
src/satori/api/member/get.ts
Normal file
18
src/satori/api/member/get.ts
Normal 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)
|
||||
}
|
15
src/satori/api/member/kick.ts
Normal file
15
src/satori/api/member/kick.ts
Normal 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 {}
|
||||
}
|
19
src/satori/api/member/list.ts
Normal file
19
src/satori/api/member/list.ts
Normal 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)
|
||||
}
|
||||
}
|
17
src/satori/api/member/mute.ts
Normal file
17
src/satori/api/member/mute.ts
Normal 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 {}
|
||||
}
|
13
src/satori/api/message/create.ts
Normal file
13
src/satori/api/message/create.ts
Normal 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)
|
||||
}
|
18
src/satori/api/message/delete.ts
Normal file
18
src/satori/api/message/delete.ts
Normal 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 {}
|
||||
}
|
18
src/satori/api/message/get.ts
Normal file
18
src/satori/api/message/get.ts
Normal 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
|
||||
}
|
30
src/satori/api/message/list.ts
Normal file
30
src/satori/api/message/list.ts
Normal 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
|
||||
}
|
||||
}
|
19
src/satori/api/reaction/create.ts
Normal file
19
src/satori/api/reaction/create.ts
Normal 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 {}
|
||||
}
|
20
src/satori/api/reaction/delete.ts
Normal file
20
src/satori/api/reaction/delete.ts
Normal 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 {}
|
||||
}
|
27
src/satori/api/reaction/list.ts
Normal file
27
src/satori/api/reaction/list.ts
Normal 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))
|
||||
}
|
||||
}
|
26
src/satori/api/role/list.ts
Normal file
26
src/satori/api/role/list.ts
Normal 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'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
20
src/satori/api/role/member/set.ts
Normal file
20
src/satori/api/role/member/set.ts
Normal 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 {}
|
||||
}
|
25
src/satori/api/user/approve.ts
Normal file
25
src/satori/api/user/approve.ts
Normal 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 {}
|
||||
}
|
19
src/satori/api/user/get.ts
Normal file
19
src/satori/api/user/get.ts
Normal 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)
|
||||
}
|
||||
}
|
22
src/satori/api/user/list.ts
Normal file
22
src/satori/api/user/list.ts
Normal 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
32
src/satori/event/guild.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
60
src/satori/event/member.ts
Normal file
60
src/satori/event/member.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
35
src/satori/event/message.ts
Normal file
35
src/satori/event/message.ts
Normal 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
16
src/satori/event/user.ts
Normal 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
307
src/satori/message.ts
Normal 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
143
src/satori/server.ts
Normal 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
218
src/satori/utils.ts
Normal 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
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user