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