diff --git a/README.md b/README.md index 4b57f38..07ef23a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # LLOneBot -LiteLoaderQQNT 插件,实现 OneBot 11 协议,用于 QQ 机器人开发 +LiteLoaderQQNT 插件,实现 OneBot 11 和 Satori 协议,用于 QQ 机器人开发 > [!CAUTION]\ > 请不要在 QQ 官方群聊和任何影响力较大的简中互联网平台(包括但不限于: 哔哩哔哩,微博,知乎,抖音等)发布和讨论任何与本插件存在相关性的信息 diff --git a/manifest.json b/manifest.json index 868515b..5163459 100644 --- a/manifest.json +++ b/manifest.json @@ -3,8 +3,8 @@ "type": "extension", "name": "LLOneBot", "slug": "LLOneBot", - "description": "实现 OneBot 11 协议,用于 QQ 机器人开发", - "version": "3.34.1", + "description": "实现 OneBot 11 和 Satori 协议,用于 QQ 机器人开发", + "version": "4.0.0", "icon": "./icon.webp", "authors": [ { diff --git a/package.json b/package.json index 7aee440..2fded7f 100644 --- a/package.json +++ b/package.json @@ -18,16 +18,19 @@ "license": "MIT", "dependencies": { "@minatojs/driver-sqlite": "^4.6.0", + "@satorijs/element": "^3.1.7", + "@satorijs/protocol": "^1.4.2", + "compare-versions": "^6.1.1", "cordis": "^3.18.1", "cors": "^2.8.5", - "cosmokit": "^1.6.2", + "cosmokit": "^1.6.3", "express": "^5.0.0", "fast-xml-parser": "^4.5.0", - "file-type": "^19.5.0", "fluent-ffmpeg": "^2.1.3", "minato": "^3.6.0", "protobufjs": "^7.4.0", "silk-wasm": "^3.6.1", + "ts-case-convert": "^2.1.0", "ws": "^8.18.0" }, "devDependencies": { diff --git a/scripts/gen-manifest.ts b/scripts/gen-manifest.ts index 7169fba..f90eb74 100644 --- a/scripts/gen-manifest.ts +++ b/scripts/gen-manifest.ts @@ -6,7 +6,7 @@ const manifest = { type: 'extension', name: 'LLOneBot', slug: 'LLOneBot', - description: '实现 OneBot 11 协议,用于 QQ 机器人开发', + description: '实现 OneBot 11 和 Satori 协议,用于 QQ 机器人开发', version, icon: './icon.webp', authors: [ 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/common/utils/file.ts b/src/common/utils/file.ts index b05593b..6bb9c9a 100644 --- a/src/common/utils/file.ts +++ b/src/common/utils/file.ts @@ -4,7 +4,7 @@ import path from 'node:path' import { TEMP_DIR } from '../globalVars' import { randomUUID, createHash } from 'node:crypto' import { fileURLToPath } from 'node:url' -import { fileTypeFromFile } from 'file-type' +import { Context } from 'cordis' // 定义一个异步函数来检查文件是否存在 export function checkFileReceived(path: string, timeout: number = 3000): Promise { @@ -118,7 +118,7 @@ type Uri2LocalRes = { isLocal: boolean } -export async function uri2local(uri: string, filename?: string, needExt?: boolean): Promise { +export async function uri2local(ctx: Context, uri: string, needExt?: boolean): Promise { const { type } = checkUriType(uri) if (type === FileUriType.FileURL) { @@ -136,15 +136,16 @@ export async function uri2local(uri: string, filename?: string, needExt?: boolea try { const res = await fetchFile(uri) const match = res.url.match(/.+\/([^/?]*)(?=\?)?/) + let filename: string if (match?.[1]) { - filename ??= match[1].replace(/[/\\:*?"<>|]/g, '_') + filename = match[1].replace(/[/\\:*?"<>|]/g, '_') } else { - filename ??= randomUUID() + filename = randomUUID() } let filePath = path.join(TEMP_DIR, filename) await fsPromise.writeFile(filePath, res.data) if (needExt && !path.extname(filePath)) { - const ext = (await fileTypeFromFile(filePath))?.ext + const ext = (await ctx.ntFileApi.getFileType(filePath)).ext filename += `.${ext}` await fsPromise.rename(filePath, `${filePath}.${ext}`) filePath = `${filePath}.${ext}` @@ -157,12 +158,12 @@ export async function uri2local(uri: string, filename?: string, needExt?: boolea } if (type === FileUriType.OneBotBase64) { - filename ??= randomUUID() + let filename = randomUUID() let filePath = path.join(TEMP_DIR, filename) const base64 = uri.replace(/^base64:\/\//, '') await fsPromise.writeFile(filePath, base64, 'base64') if (needExt) { - const ext = (await fileTypeFromFile(filePath))?.ext + const ext = (await ctx.ntFileApi.getFileType(filePath)).ext filename += `.${ext}` await fsPromise.rename(filePath, `${filePath}.${ext}`) filePath = `${filePath}.${ext}` @@ -174,12 +175,12 @@ export async function uri2local(uri: string, filename?: string, needExt?: boolea // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types const capture = /^data:([\w/.+-]+);base64,(.*)$/.exec(uri) if (capture) { - filename ??= randomUUID() + let filename = randomUUID() const [, _type, base64] = capture let filePath = path.join(TEMP_DIR, filename) await fsPromise.writeFile(filePath, base64, 'base64') if (needExt) { - const ext = (await fileTypeFromFile(filePath))?.ext + const ext = (await ctx.ntFileApi.getFileType(filePath)).ext filename += `.${ext}` await fsPromise.rename(filePath, `${filePath}.${ext}`) filePath = `${filePath}.${ext}` diff --git a/src/common/utils/legacyLog.ts b/src/common/utils/legacyLog.ts index 0010d2f..c779ffc 100644 --- a/src/common/utils/legacyLog.ts +++ b/src/common/utils/legacyLog.ts @@ -13,10 +13,15 @@ export function log(...msg: unknown[]) { let logMsg = '' for (const msgItem of msg) { if (typeof msgItem === 'object') { - logMsg += inspect(msgItem, { depth: 10, compact: true, breakLength: Infinity }) + ' ' - continue + logMsg += inspect(msgItem, { + depth: 10, + compact: true, + breakLength: Infinity, + maxArrayLength: 220 + }) + ' ' + } else { + logMsg += msgItem + ' ' } - logMsg += msgItem + ' ' } const currentDateTime = new Date().toLocaleString() logMsg = `${currentDateTime} ${logMsg}\n\n` diff --git a/src/common/utils/upgrade.ts b/src/common/utils/upgrade.ts index d625195..bdf10df 100644 --- a/src/common/utils/upgrade.ts +++ b/src/common/utils/upgrade.ts @@ -3,25 +3,19 @@ import { writeFile } from 'node:fs/promises' import { version } from '../../version' import { log, fetchFile } from '.' import { TEMP_DIR } from '../globalVars' +import { compare } from 'compare-versions' const downloadMirrorHosts = ['https://ghp.ci/'] const releasesMirrorHosts = ['https://kkgithub.com'] export async function checkNewVersion() { - const latestVersionText = await getRemoteVersion() - const latestVersion = latestVersionText.split('.') + const latestVersion = await getRemoteVersion() log('LLOneBot latest version', latestVersion) - const currentVersion = version.split('.') - //log('llonebot current version', currentVersion) - for (const k of [0, 1, 2]) { - const latest = parseInt(latestVersion[k]) - const current = parseInt(currentVersion[k]) - if (latest > current) { - log('') - return { result: true, version: latestVersionText } - } else if (latest < current) { - break - } + if (latestVersion === '') { + return { result: false, version: latestVersion } + } + if (compare(latestVersion, version, '>')) { + return { result: true, version: latestVersion } } return { result: false, version: version } } 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..5025ea4 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' @@ -17,12 +18,10 @@ import { CHANNEL_UPDATE, CHANNEL_SET_CONFIG_CONFIRMED } from '../common/channels' -import { getBuildVersion } from '../common/utils' import { hookNTQQApiCall, hookNTQQApiReceive } from '../ntqqapi/hook' import { checkNewVersion, upgradeLLOneBot } from '../common/utils/upgrade' import { getConfigUtil } from '../common/config' import { checkFfmpeg } from '../common/utils/video' -import { getSession } from '../ntqqapi/wrapper' import { Context } from 'cordis' import { llonebotError, selfInfo, LOG_DIR, DATA_DIR, TEMP_DIR } from '../common/globalVars' import { log, logFileName } from '../common/utils/legacyLog' @@ -41,7 +40,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 +149,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,32 +177,38 @@ 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) }) } - const buildVersion = getBuildVersion() - const intervalId = setInterval(() => { const self = Object.assign(selfInfo, { uin: globalThis.authData?.uin, uid: globalThis.authData?.uid, online: true }) - if (self.uin && (buildVersion >= 27187 || getSession())) { + if (self.uin) { clearInterval(intervalId) start() } diff --git a/src/ntqqapi/api/file.ts b/src/ntqqapi/api/file.ts index 5ec5d8e..67f48d9 100644 --- a/src/ntqqapi/api/file.ts +++ b/src/ntqqapi/api/file.ts @@ -18,7 +18,6 @@ import { ReceiveCmdS } from '../hook' import { RkeyManager } from '@/ntqqapi/helper/rkey' import { OnRichMediaDownloadCompleteParams, Peer } from '@/ntqqapi/types/msg' import { calculateFileMD5 } from '@/common/utils/file' -import { fileTypeFromFile } from 'file-type' import { copyFile, stat, unlink } from 'node:fs/promises' import { Time } from 'cosmokit' import { Service, Context } from 'cordis' @@ -56,7 +55,12 @@ export class NTQQFileApi extends Service { } async getFileType(filePath: string) { - return fileTypeFromFile(filePath) + return await invoke<{ + ext: string + mime: string + }>(NTMethod.FILE_TYPE, [filePath], { + className: NTClass.FS_API + }) } /** 上传文件到 QQ 的文件夹 */ @@ -111,23 +115,20 @@ export class NTQQFileApi extends Service { } const data = await invoke<{ notifyInfo: OnRichMediaDownloadCompleteParams }>( 'nodeIKernelMsgService/downloadRichMedia', - [ - { - getReq: { - fileModelId: '0', - downloadSourceType: 0, - triggerType: 1, - msgId: msgId, - chatType: chatType, - peerUid: peerUid, - elementId: elementId, - thumbSize: 0, - downloadType: 1, - filePath: thumbPath, - }, + [{ + getReq: { + fileModelId: '0', + downloadSourceType: 0, + triggerType: 1, + msgId: msgId, + chatType: chatType, + peerUid: peerUid, + elementId: elementId, + thumbSize: 0, + downloadType: 1, + filePath: thumbPath, }, - null, - ], + }], { cbCmd: ReceiveCmdS.MEDIA_DOWNLOAD_COMPLETE, cmdCB: payload => payload.notifyInfo.msgId === msgId, @@ -186,14 +187,11 @@ export class NTQQFileApi extends Service { async downloadFileForModelId(peer: Peer, fileModelId: string, timeout = 2 * Time.minute) { const data = await invoke<{ notifyInfo: OnRichMediaDownloadCompleteParams }>( 'nodeIKernelRichMediaService/downloadFileForModelId', - [ - { - peer, - fileModelIdList: [fileModelId], - save_path: '' - }, - null, - ], + [{ + peer, + fileModelIdList: [fileModelId], + save_path: '' + }], { cbCmd: ReceiveCmdS.MEDIA_DOWNLOAD_COMPLETE, cmdCB: payload => payload.notifyInfo.fileModelId === fileModelId, @@ -211,21 +209,19 @@ export class NTQQFileCacheApi extends Service { } async setCacheSilentScan(isSilent: boolean = true) { - return await invoke(NTMethod.CACHE_SET_SILENCE, [{ isSilent }, null]) + return await invoke(NTMethod.CACHE_SET_SILENCE, [{ isSilent }]) } getCacheSessionPathList() { - return invoke< - { - key: string - value: string - }[] - >(NTMethod.CACHE_PATH_SESSION, [], { className: NTClass.OS_API }) + return invoke>(NTMethod.CACHE_PATH_SESSION, [], { className: NTClass.OS_API }) } scanCache() { invoke(ReceiveCmdS.CACHE_SCAN_FINISH, [], { registerEvent: true }) - return invoke(NTMethod.CACHE_SCAN, [null, null], { timeout: 300 * Time.second }) + return invoke(NTMethod.CACHE_SCAN, [], { timeout: 300 * Time.second }) } getHotUpdateCachePath() { @@ -245,13 +241,13 @@ export class NTQQFileCacheApi extends Service { pageSize: pageSize, order: 1, lastRecord: _lastRecord, - }, null]) + }]) } async clearChatCache(chats: ChatCacheListItemBasic[] = [], fileKeys: string[] = []) { return await invoke(NTMethod.CACHE_CHAT_CLEAR, [{ chats, fileKeys, - }, null]) + }]) } } diff --git a/src/ntqqapi/api/friend.ts b/src/ntqqapi/api/friend.ts index 3c92ae3..75b1709 100644 --- a/src/ntqqapi/api/friend.ts +++ b/src/ntqqapi/api/friend.ts @@ -1,7 +1,6 @@ import { Friend, SimpleInfo, CategoryFriend } from '../types' import { ReceiveCmdS } from '../hook' import { invoke, NTMethod, NTClass } from '../ntcall' -import { getSession } from '@/ntqqapi/wrapper' import { Service, Context } from 'cordis' declare module 'cordis' { @@ -24,15 +23,11 @@ export class NTQQFriendApi extends Service { categroyMbCount: number buddyList: Friend[] }[] - }>( - 'getBuddyList', - [], - { - className: NTClass.NODE_STORE_API, - cbCmd: ReceiveCmdS.FRIENDS, - afterFirstCmd: false, - } - ) + }>('getBuddyList', [], { + className: NTClass.NODE_STORE_API, + cbCmd: ReceiveCmdS.FRIENDS, + afterFirstCmd: false + }) const _friends: Friend[] = [] for (const item of data.data) { _friends.push(...item.buddyList) @@ -41,22 +36,13 @@ export class NTQQFriendApi extends Service { } async handleFriendRequest(friendUid: string, reqTime: string, accept: boolean) { - const session = getSession() - if (session) { - return session.getBuddyService().approvalFriendRequest({ + return await invoke(NTMethod.HANDLE_FRIEND_REQUEST, [{ + approvalInfo: { friendUid, reqTime, - accept - }) - } else { - return await invoke(NTMethod.HANDLE_FRIEND_REQUEST, [{ - approvalInfo: { - friendUid, - reqTime, - accept, - }, - }]) - } + accept, + }, + }]) } async getBuddyV2(refresh = false): Promise { @@ -121,13 +107,13 @@ export class NTQQFriendApi extends Service { } async getBuddyRecommendContact(uin: string) { - const ret = await invoke('nodeIKernelBuddyService/getBuddyRecommendContactArkJson', [{ uin }, null]) + const ret = await invoke('nodeIKernelBuddyService/getBuddyRecommendContactArkJson', [{ uin }]) return ret.arkMsg } async setBuddyRemark(uid: string, remark: string) { return await invoke('nodeIKernelBuddyService/setBuddyRemark', [{ remarkParams: { uid, remark } - }, null]) + }]) } } diff --git a/src/ntqqapi/api/group.ts b/src/ntqqapi/api/group.ts index 9c4fd31..d6f6b83 100644 --- a/src/ntqqapi/api/group.ts +++ b/src/ntqqapi/api/group.ts @@ -14,10 +14,7 @@ import { import { invoke, NTClass, NTMethod } from '../ntcall' import { GeneralCallResult } from '../services' import { NTQQWindows } from './window' -import { getSession } from '../wrapper' -import { NodeIKernelGroupService } from '../services' import { Service, Context } from 'cordis' -import { isNumeric } from '@/common/utils/misc' declare module 'cordis' { interface Context { @@ -28,8 +25,6 @@ declare module 'cordis' { export class NTQQGroupApi extends Service { static inject = ['ntWindowApi'] - public groupMembers: Map> = new Map>() - constructor(protected ctx: Context) { super(ctx, 'ntGroupApi', true) } @@ -51,49 +46,37 @@ export class NTQQGroupApi extends Service { } async getGroupMembers(groupCode: string, num = 3000): Promise> { - const session = getSession() - let result: Awaited> - if (session) { - const groupService = session.getGroupService() - const sceneId = groupService.createMemberListScene(groupCode, 'groupMemberList_MainWindow') - result = await groupService.getNextMemberList(sceneId, undefined, num) - } else { - const sceneId = await invoke(NTMethod.GROUP_MEMBER_SCENE, [{ groupCode, scene: 'groupMemberList_MainWindow' }]) - result = await invoke(NTMethod.GROUP_MEMBERS, [{ sceneId, num }, null]) + const sceneId = await invoke(NTMethod.GROUP_MEMBER_SCENE, [{ groupCode, scene: 'groupMemberList_MainWindow' }]) + const data = await invoke(NTMethod.GROUP_MEMBERS, [{ sceneId, num }]) + if (data.errCode !== 0) { + throw new Error('获取群成员列表出错,' + data.errMsg) } - if (result.errCode !== 0) { - throw ('获取群成员列表出错,' + result.errMsg) - } - return result.result.infos + return data.result.infos } - async getGroupMember(groupCode: string, memberUinOrUid: string) { - if (!this.groupMembers.has(groupCode)) { - try { - // 更新群成员列表 - this.groupMembers.set(groupCode, await this.getGroupMembers(groupCode)) + async getGroupMember(groupCode: string, uid: string, forceUpdate = false) { + invoke('nodeIKernelGroupListener/onMemberInfoChange', [], { + registerEvent: true + }) + + const data = await invoke<{ + groupCode: string + members: Map + }>( + 'nodeIKernelGroupService/getMemberInfo', + [{ + groupCode, + uids: [uid], + forceUpdate + }], + { + cbCmd: 'nodeIKernelGroupListener/onMemberInfoChange', + afterFirstCmd: false, + cmdCB: payload => payload.members.has(uid), + timeout: 2000 } - catch (e) { - return - } - } - let members = this.groupMembers.get(groupCode)! - const getMember = () => { - let member: GroupMember | undefined = undefined - if (isNumeric(memberUinOrUid)) { - member = Array.from(members.values()).find(member => member.uin === memberUinOrUid) - } else { - member = members.get(memberUinOrUid) - } - return member - } - let member = getMember() - if (!member) { - this.groupMembers.set(groupCode, await this.getGroupMembers(groupCode)) - members = this.groupMembers.get(groupCode)! - member = getMember() - } - return member + ) + return data.members.get(uid)! } async getGroupIgnoreNotifies() { @@ -105,11 +88,12 @@ export class NTQQGroupApi extends Service { ) } - async getSingleScreenNotifies(num: number) { + async getSingleScreenNotifies(number: number, startSeq = '') { invoke(ReceiveCmdS.GROUP_NOTIFY, [], { registerEvent: true }) + return (await invoke( 'nodeIKernelGroupService/getSingleScreenNotifies', - [{ doubt: false, startSeq: '', number: num }, null], + [{ doubt: false, startSeq, number }], { cbCmd: ReceiveCmdS.GROUP_NOTIFY, afterFirstCmd: false, @@ -122,168 +106,97 @@ export class NTQQGroupApi extends Service { const groupCode = flagitem[0] const seq = flagitem[1] const type = parseInt(flagitem[2]) - const session = getSession() - if (session) { - return session.getGroupService().operateSysNotify(false, { - operateType, // 2 拒绝 + return await invoke(NTMethod.HANDLE_GROUP_REQUEST, [{ + doubt: false, + operateMsg: { + operateType, targetMsg: { - seq, // 通知序列号 + seq, type, groupCode, postscript: reason || ' ' // 仅传空值可能导致处理失败,故默认给个空格 - } - }) - } else { - return await invoke(NTMethod.HANDLE_GROUP_REQUEST, [{ - doubt: false, - operateMsg: { - operateType, - targetMsg: { - seq, - type, - groupCode, - postscript: reason || ' ' // 仅传空值可能导致处理失败,故默认给个空格 - }, }, - }, null]) - } + }, + }]) } async quitGroup(groupCode: string) { - const session = getSession() - if (session) { - return session.getGroupService().quitGroup(groupCode) - } else { - return await invoke(NTMethod.QUIT_GROUP, [{ groupCode }, null]) - } + return await invoke(NTMethod.QUIT_GROUP, [{ groupCode }]) } async kickMember(groupCode: string, kickUids: string[], refuseForever = false, kickReason = '') { - const session = getSession() - if (session) { - return session.getGroupService().kickMember(groupCode, kickUids, refuseForever, kickReason) - } else { - return await invoke(NTMethod.KICK_MEMBER, [{ groupCode, kickUids, refuseForever, kickReason }]) - } + return await invoke(NTMethod.KICK_MEMBER, [{ groupCode, kickUids, refuseForever, kickReason }]) } + /** timeStamp为秒数, 0为解除禁言 */ async banMember(groupCode: string, memList: Array<{ uid: string, timeStamp: number }>) { - // timeStamp为秒数, 0为解除禁言 - const session = getSession() - if (session) { - return session.getGroupService().setMemberShutUp(groupCode, memList) - } else { - return await invoke(NTMethod.MUTE_MEMBER, [{ groupCode, memList }]) - } + return await invoke(NTMethod.MUTE_MEMBER, [{ groupCode, memList }]) } async banGroup(groupCode: string, shutUp: boolean) { - const session = getSession() - if (session) { - return session.getGroupService().setGroupShutUp(groupCode, shutUp) - } else { - return await invoke(NTMethod.MUTE_GROUP, [{ groupCode, shutUp }, null]) - } + return await invoke(NTMethod.MUTE_GROUP, [{ groupCode, shutUp }]) } async setMemberCard(groupCode: string, memberUid: string, cardName: string) { - const session = getSession() - if (session) { - return session.getGroupService().modifyMemberCardName(groupCode, memberUid, cardName) - } else { - return await invoke(NTMethod.SET_MEMBER_CARD, [{ groupCode, uid: memberUid, cardName }, null]) - } + return await invoke(NTMethod.SET_MEMBER_CARD, [{ groupCode, uid: memberUid, cardName }]) } async setMemberRole(groupCode: string, memberUid: string, role: GroupMemberRole) { - const session = getSession() - if (session) { - return session.getGroupService().modifyMemberRole(groupCode, memberUid, role) - } else { - return await invoke(NTMethod.SET_MEMBER_ROLE, [{ groupCode, uid: memberUid, role }, null]) - } + return await invoke(NTMethod.SET_MEMBER_ROLE, [{ groupCode, uid: memberUid, role }]) } async setGroupName(groupCode: string, groupName: string) { - const session = getSession() - if (session) { - return session.getGroupService().modifyGroupName(groupCode, groupName, false) - } else { - return await invoke(NTMethod.SET_GROUP_NAME, [{ groupCode, groupName }, null]) - } + return await invoke(NTMethod.SET_GROUP_NAME, [{ groupCode, groupName }]) } async getGroupRemainAtTimes(groupCode: string) { - return await invoke(NTMethod.GROUP_AT_ALL_REMAIN_COUNT, [{ groupCode }, null]) + return await invoke(NTMethod.GROUP_AT_ALL_REMAIN_COUNT, [{ groupCode }]) } async removeGroupEssence(groupCode: string, msgId: string) { - const session = getSession() - if (session) { - const data = await session.getMsgService().getMsgsIncludeSelf({ chatType: 2, guildId: '', peerUid: groupCode }, msgId, 1, false) - return session.getGroupService().removeGroupEssence({ + const ntMsgApi = this.ctx.get('ntMsgApi')! + const data = await ntMsgApi.getMsgHistory({ chatType: 2, guildId: '', peerUid: groupCode }, msgId, 1, false) + return await invoke('nodeIKernelGroupService/removeGroupEssence', [{ + req: { groupCode: groupCode, msgRandom: Number(data?.msgList[0].msgRandom), msgSeq: Number(data?.msgList[0].msgSeq) - }) - } else { - const ntMsgApi = this.ctx.get('ntMsgApi')! - const data = await ntMsgApi.getMsgHistory({ chatType: 2, guildId: '', peerUid: groupCode }, msgId, 1, false) - return await invoke('nodeIKernelGroupService/removeGroupEssence', [{ - req: { - groupCode: groupCode, - msgRandom: Number(data?.msgList[0].msgRandom), - msgSeq: Number(data?.msgList[0].msgSeq) - } - }, null]) - } + } + }]) } async addGroupEssence(groupCode: string, msgId: string) { - const session = getSession() - if (session) { - const data = await session.getMsgService().getMsgsIncludeSelf({ chatType: 2, guildId: '', peerUid: groupCode }, msgId, 1, false) - return session.getGroupService().addGroupEssence({ + const ntMsgApi = this.ctx.get('ntMsgApi')! + const data = await ntMsgApi.getMsgHistory({ chatType: 2, guildId: '', peerUid: groupCode }, msgId, 1, false) + return await invoke('nodeIKernelGroupService/addGroupEssence', [{ + req: { groupCode: groupCode, msgRandom: Number(data?.msgList[0].msgRandom), msgSeq: Number(data?.msgList[0].msgSeq) - }) - } else { - const ntMsgApi = this.ctx.get('ntMsgApi')! - const data = await ntMsgApi.getMsgHistory({ chatType: 2, guildId: '', peerUid: groupCode }, msgId, 1, false) - return await invoke('nodeIKernelGroupService/addGroupEssence', [{ - req: { - groupCode: groupCode, - msgRandom: Number(data?.msgList[0].msgRandom), - msgSeq: Number(data?.msgList[0].msgSeq) - } - }, null]) - } + } + }]) } async createGroupFileFolder(groupId: string, folderName: string) { - return await invoke('nodeIKernelRichMediaService/createGroupFolder', [{ groupId, folderName }, null]) + return await invoke('nodeIKernelRichMediaService/createGroupFolder', [{ groupId, folderName }]) } async deleteGroupFileFolder(groupId: string, folderId: string) { - return await invoke('nodeIKernelRichMediaService/deleteGroupFolder', [{ groupId, folderId }, null]) + return await invoke('nodeIKernelRichMediaService/deleteGroupFolder', [{ groupId, folderId }]) } async deleteGroupFile(groupId: string, fileIdList: string[], busIdList: number[]) { - return await invoke('nodeIKernelRichMediaService/deleteGroupFile', [{ groupId, busIdList, fileIdList }, null]) + return await invoke('nodeIKernelRichMediaService/deleteGroupFile', [{ groupId, busIdList, fileIdList }]) } async getGroupFileList(groupId: string, fileListForm: GetFileListParam) { invoke('nodeIKernelMsgListener/onGroupFileInfoUpdate', [], { registerEvent: true }) const data = await invoke<{ fileInfo: GroupFileInfo }>( 'nodeIKernelRichMediaService/getGroupFileList', - [ - { - groupId, - fileListForm - }, - null, - ], + [{ + groupId, + fileListForm + }], { cbCmd: 'nodeIKernelMsgListener/onGroupFileInfoUpdate', afterFirstCmd: false, @@ -296,17 +209,17 @@ export class NTQQGroupApi extends Service { async publishGroupBulletin(groupCode: string, req: PublishGroupBulletinReq) { const ntUserApi = this.ctx.get('ntUserApi')! const psKey = (await ntUserApi.getPSkey(['qun.qq.com'])).domainPskeyMap.get('qun.qq.com')! - return await invoke('nodeIKernelGroupService/publishGroupBulletin', [{ groupCode, psKey, req }, null]) + return await invoke('nodeIKernelGroupService/publishGroupBulletin', [{ groupCode, psKey, req }]) } async uploadGroupBulletinPic(groupCode: string, path: string) { const ntUserApi = this.ctx.get('ntUserApi')! const psKey = (await ntUserApi.getPSkey(['qun.qq.com'])).domainPskeyMap.get('qun.qq.com')! - return await invoke('nodeIKernelGroupService/uploadGroupBulletinPic', [{ groupCode, psKey, path }, null]) + return await invoke('nodeIKernelGroupService/uploadGroupBulletinPic', [{ groupCode, psKey, path }]) } async getGroupRecommendContact(groupCode: string) { - const ret = await invoke('nodeIKernelGroupService/getGroupRecommendContactArkJson', [{ groupCode }, null]) + const ret = await invoke('nodeIKernelGroupService/getGroupRecommendContactArkJson', [{ groupCode }]) return ret.arkJson } @@ -317,7 +230,7 @@ export class NTQQGroupApi extends Service { msgSeq: +msgSeq, msgRandom: +msgRandom } - }, null]) + }]) } async getGroupHonorList(groupCode: string) { @@ -326,31 +239,33 @@ export class NTQQGroupApi extends Service { req: { groupCode: [+groupCode] } - }, null]) + }]) } - async getGroupAllInfo(groupCode: string, timeout = 1000) { - invoke('nodeIKernelGroupListener/onGroupAllInfoChange', [], { registerEvent: true }) + async getGroupAllInfo(groupCode: string) { + invoke('nodeIKernelGroupListener/onGroupAllInfoChange', [], { + registerEvent: true + }) + return await invoke<{ groupAll: GroupAllInfo }>( 'nodeIKernelGroupService/getGroupAllInfo', - [ - { - groupCode, - source: 4 - }, - null - ], + [{ + groupCode, + source: 4 + }], { cbCmd: 'nodeIKernelGroupListener/onGroupAllInfoChange', afterFirstCmd: false, - cmdCB: payload => payload.groupAll.groupCode === groupCode, - timeout + cmdCB: payload => payload.groupAll.groupCode === groupCode } ) } async getGroupBulletinList(groupCode: string) { - invoke('nodeIKernelGroupListener/onGetGroupBulletinListResult', [], { registerEvent: true }) + invoke('nodeIKernelGroupListener/onGetGroupBulletinListResult', [], { + registerEvent: true + }) + const ntUserApi = this.ctx.get('ntUserApi')! const psKey = (await ntUserApi.getPSkey(['qun.qq.com'])).domainPskeyMap.get('qun.qq.com')! return await invoke<{ @@ -377,4 +292,8 @@ export class NTQQGroupApi extends Service { } ) } + + async setGroupAvatar(groupCode: string, path: string) { + return await invoke('nodeIKernelGroupService/setHeader', [{ path, groupCode }]) + } } diff --git a/src/ntqqapi/api/msg.ts b/src/ntqqapi/api/msg.ts index 4207a10..9af3862 100644 --- a/src/ntqqapi/api/msg.ts +++ b/src/ntqqapi/api/msg.ts @@ -1,7 +1,5 @@ import { invoke, NTMethod } from '../ntcall' -import { GeneralCallResult } from '../services' import { RawMessage, SendMessageElement, Peer, ChatType } from '../types' -import { getSession } from '@/ntqqapi/wrapper' import { Service, Context } from 'cordis' import { selfInfo } from '@/common/globalVars' @@ -19,37 +17,27 @@ export class NTQQMsgApi extends Service { } async getTempChatInfo(chatType: ChatType, peerUid: string) { - const session = getSession() - if (session) { - return session.getMsgService().getTempChatInfo(chatType, peerUid) - } else { - return await invoke('nodeIKernelMsgService/getTempChatInfo', [{ chatType, peerUid }, null]) - } + return await invoke('nodeIKernelMsgService/getTempChatInfo', [{ chatType, peerUid }]) } async setEmojiLike(peer: Peer, msgSeq: string, emojiId: string, setEmoji: boolean) { - // nt_qq//global//nt_data//Emoji//emoji-resource//sysface_res/apng/ 下可以看到所有QQ表情预览 - // nt_qq\global\nt_data\Emoji\emoji-resource\face_config.json 里面有所有表情的id, 自带表情id是QSid, 标准emoji表情id是QCid + // nt_qq/global/nt_data/Emoji/emoji-resource/sysface_res/apng/ 下可以看到所有QQ表情预览 + // nt_qq/global/nt_data/Emoji/emoji-resource/face_config.json 里面有所有表情的id, 自带表情id是QSid, 标准emoji表情id是QCid // 其实以官方文档为准是最好的,https://bot.q.qq.com/wiki/develop/api-v2/openapi/emoji/model.html#EmojiType const emojiType = emojiId.length > 3 ? '2' : '1' return await invoke(NTMethod.EMOJI_LIKE, [{ peer, msgSeq, emojiId, emojiType, setEmoji }]) } async getMultiMsg(peer: Peer, rootMsgId: string, parentMsgId: string) { - const session = getSession() - if (session) { - return session.getMsgService().getMultiMsg(peer, rootMsgId, parentMsgId) - } else { - return await invoke(NTMethod.GET_MULTI_MSG, [{ peer, rootMsgId, parentMsgId }, null]) - } + return await invoke(NTMethod.GET_MULTI_MSG, [{ peer, rootMsgId, parentMsgId }]) } async activateChat(peer: Peer) { - return await invoke(NTMethod.ACTIVE_CHAT_PREVIEW, [{ peer, cnt: 1 }, null]) + return await invoke(NTMethod.ACTIVE_CHAT_PREVIEW, [{ peer, cnt: 1 }]) } - async activateChatAndGetHistory(peer: Peer) { - return await invoke(NTMethod.ACTIVE_CHAT_HISTORY, [{ peer, cnt: 20 }, null]) + async activateChatAndGetHistory(peer: Peer, cnt: number) { + return await invoke(NTMethod.ACTIVE_CHAT_HISTORY, [{ peer, cnt, msgId: '0', queryOrder: true }]) } async getAioFirstViewLatestMsgs(peer: Peer, cnt: number) { @@ -62,9 +50,9 @@ export class NTQQMsgApi extends Service { return await invoke('nodeIKernelMsgService/getMsgsByMsgId', [{ peer, msgIds }]) } - async getMsgHistory(peer: Peer, msgId: string, cnt: number, isReverseOrder: boolean = false) { - // 消息时间从旧到新 - return await invoke(NTMethod.HISTORY_MSG, [{ peer, msgId, cnt, queryOrder: isReverseOrder }]) + async getMsgHistory(peer: Peer, msgId: string, cnt: number, queryOrder = false) { + // 默认情况下消息时间从旧到新 + return await invoke(NTMethod.HISTORY_MSG, [{ peer, msgId, cnt, queryOrder }]) } async recallMsg(peer: Peer, msgIds: string[]) { @@ -96,6 +84,7 @@ export class NTQQMsgApi extends Service { timeout } ) + delete peer.guildId return data.msgList.find(msgRecord => msgRecord.guildId === uniqueId) } @@ -124,6 +113,7 @@ export class NTQQMsgApi extends Service { } } ) + delete destPeer.guildId return data.msgList.filter(msgRecord => msgRecord.guildId === uniqueId) } @@ -171,27 +161,8 @@ export class NTQQMsgApi extends Service { throw new Error('转发消息超时') } - async getMsgsBySeqAndCount(peer: Peer, msgSeq: string, count: number, desc: boolean, z: boolean) { - const session = getSession() - if (session) { - return await session.getMsgService().getMsgsBySeqAndCount(peer, msgSeq, count, desc, z) - } else { - return await invoke('nodeIKernelMsgService/getMsgsBySeqAndCount', [{ - peer, - cnt: count, - msgSeq, - queryOrder: desc - }, null]) - } - } - async getSingleMsg(peer: Peer, msgSeq: string) { - const session = getSession() - if (session) { - return await session.getMsgService().getSingleMsg(peer, msgSeq) - } else { - return await invoke('nodeIKernelMsgService/getSingleMsg', [{ peer, msgSeq }, null]) - } + return await invoke('nodeIKernelMsgService/getSingleMsg', [{ peer, msgSeq }]) } async queryFirstMsgBySeq(peer: Peer, msgSeq: string) { @@ -209,7 +180,7 @@ export class NTQQMsgApi extends Service { isIncludeCurrent: true, pageLimit: 1, } - }, null]) + }]) } async queryMsgsWithFilterExBySeq(peer: Peer, msgSeq: string, filterMsgTime: string, filterSendersUid: string[] = []) { @@ -231,7 +202,7 @@ export class NTQQMsgApi extends Service { } async setMsgRead(peer: Peer) { - return await invoke('nodeIKernelMsgService/setMsgRead', [{ peer }, null]) + return await invoke('nodeIKernelMsgService/setMsgRead', [{ peer }]) } async getMsgEmojiLikesList(peer: Peer, msgSeq: string, emojiId: string, emojiType: string, count: number) { @@ -250,7 +221,7 @@ export class NTQQMsgApi extends Service { count, backwardFetch: true, forceRefresh: true - }, null]) + }]) } async generateMsgUniqueId(chatType: number) { @@ -289,6 +260,6 @@ export class NTQQMsgApi extends Service { } async getServerTime() { - return await invoke('nodeIKernelMSFService/getServerTime', [null]) + return await invoke('nodeIKernelMSFService/getServerTime', []) } } diff --git a/src/ntqqapi/api/user.ts b/src/ntqqapi/api/user.ts index 22cce9f..5cb2d23 100644 --- a/src/ntqqapi/api/user.ts +++ b/src/ntqqapi/api/user.ts @@ -1,9 +1,8 @@ import { User, UserDetailInfoByUin, UserDetailInfoByUinV2, UserDetailInfo, UserDetailSource, ProfileBizType, SimpleInfo } from '../types' import { invoke } from '../ntcall' import { getBuildVersion } from '@/common/utils' -import { getSession } from '@/ntqqapi/wrapper' import { RequestUtil } from '@/common/utils/request' -import { isNullable, Time } from 'cosmokit' +import { isNullable, pick, Time } from 'cosmokit' import { Service, Context } from 'cordis' import { selfInfo } from '@/common/globalVars' @@ -20,15 +19,12 @@ export class NTQQUserApi extends Service { super(ctx, 'ntUserApi', true) } - async setQQAvatar(path: string) { + async setSelfAvatar(path: string) { return await invoke( 'nodeIKernelProfileService/setHeader', - [ - { path }, - null, - ], + [{ path }], { - timeout: 10 * Time.second, // 10秒不一定够 + timeout: 10 * Time.second // 10秒不一定够 } ) } @@ -92,49 +88,25 @@ export class NTQQUserApi extends Service { } async getPSkey(domains: string[]) { - return await invoke('nodeIKernelTipOffService/getPskey', [{ domains, isForNewPCQQ: true }, null]) + return await invoke('nodeIKernelTipOffService/getPskey', [{ domains, isForNewPCQQ: true }]) } async like(uid: string, count = 1) { - const session = getSession() - if (session) { - return session.getProfileLikeService().setBuddyProfileLike({ - friendUid: uid, - sourceId: 71, - doLikeCount: count, - doLikeTollCount: 0 - }) - } else { - return await invoke( - 'nodeIKernelProfileLikeService/setBuddyProfileLike', - [ - { - doLikeUserInfo: { - friendUid: uid, - sourceId: 71, - doLikeCount: count, - doLikeTollCount: 0 - } - }, - null, - ], - ) - } + return await invoke( + 'nodeIKernelProfileLikeService/setBuddyProfileLike', + [{ + doLikeUserInfo: { + friendUid: uid, + sourceId: 71, + doLikeCount: count, + doLikeTollCount: 0 + } + }] + ) } - async getUidByUinV1(uin: string) { + async getUidByUinV1(uin: string, groupCode?: string) { let uid = (await invoke('nodeIKernelUixConvertService/getUid', [{ uins: [uin] }])).uidInfo.get(uin) - if (!uid) { - for (const membersList of this.ctx.ntGroupApi.groupMembers.values()) { //从群友列表转 - for (const member of membersList.values()) { - if (member.uin === uin) { - uid = member.uid - break - } - } - if (uid) break - } - } if (!uid) { const unveifyUid = (await this.getUserDetailInfoByUin(uin)).info.uid //特殊转换 if (unveifyUid.indexOf('*') === -1) { @@ -145,29 +117,30 @@ export class NTQQUserApi extends Service { const friends = await this.ctx.ntFriendApi.getFriends() //从好友列表转 uid = friends.find(item => item.uin === uin)?.uid } + if (!uid && groupCode) { + const members = await this.ctx.ntGroupApi.getGroupMembers(groupCode) + uid = Array.from(members.values()).find(e => e.uin === uin)?.uid + } return uid } - async getUidByUinV2(uin: string, groupCode?: string) { - let uid = (await invoke('nodeIKernelGroupService/getUidByUins', [{ uin: [uin] }])).uids.get(uin) + async getUidByUinV2(uin: string) { + let uid = (await invoke('nodeIKernelGroupService/getUidByUins', [{ uinList: [uin] }])).uids.get(uin) if (uid) return uid uid = (await invoke('nodeIKernelProfileService/getUidByUin', [{ callFrom: 'FriendsServiceImpl', uin: [uin] }])).get(uin) if (uid) return uid uid = (await invoke('nodeIKernelUixConvertService/getUid', [{ uins: [uin] }])).uidInfo.get(uin) if (uid) return uid const unveifyUid = (await this.getUserDetailInfoByUinV2(uin)).detail.uid - if (!unveifyUid.includes('*')) return unveifyUid - if (groupCode) { - const member = await this.ctx.ntGroupApi.getGroupMember(groupCode, uin) - return member?.uid - } + //if (!unveifyUid.includes('*')) return unveifyUid + return unveifyUid } async getUidByUin(uin: string, groupCode?: string) { if (getBuildVersion() >= 26702) { - return this.getUidByUinV2(uin, groupCode) + return this.getUidByUinV2(uin) } - return this.getUidByUinV1(uin) + return this.getUidByUinV1(uin, groupCode) } async getUserDetailInfoByUinV2(uin: string) { @@ -194,7 +167,7 @@ export class NTQQUserApi extends Service { } async getUinByUidV2(uid: string) { - let uin = (await invoke('nodeIKernelGroupService/getUinByUids', [{ uid: [uid] }])).uins.get(uid) + let uin = (await invoke('nodeIKernelGroupService/getUinByUids', [{ uidList: [uid] }])).uins.get(uid) if (uin) return uin uin = (await invoke('nodeIKernelProfileService/getUinByUid', [{ callFrom: 'FriendsServiceImpl', uid: [uid] }])).get(uid) if (uin) return uin @@ -214,18 +187,13 @@ export class NTQQUserApi extends Service { } async forceFetchClientKey() { - const session = getSession() - if (session) { - return await session.getTicketService().forceFetchClientKey('') - } else { - return await invoke('nodeIKernelTicketService/forceFetchClientKey', [{ url: '' }, null]) - } + return await invoke('nodeIKernelTicketService/forceFetchClientKey', [{ url: '' }]) } async getSelfNick(refresh = true) { if ((refresh || !selfInfo.nick) && selfInfo.uid) { - const { profiles } = await this.getUserSimpleInfo(selfInfo.uid) - selfInfo.nick = profiles[selfInfo.uid].coreInfo.nick + const data = await this.getUserSimpleInfo(selfInfo.uid) + selfInfo.nick = data.nick } return selfInfo.nick } @@ -237,7 +205,7 @@ export class NTQQUserApi extends Service { extStatus, batteryStatus, } - }, null]) + }]) } async getProfileLike(uid: string) { @@ -252,11 +220,11 @@ export class NTQQUserApi extends Service { start: 0, limit: 20, } - }, null]) + }]) } - async getUserSimpleInfo(uid: string, force = true) { - return await invoke<{ profiles: Record }>( + async getUserSimpleInfoV2(uid: string, force = true) { + const data = await invoke<{ profiles: Record }>( 'nodeIKernelProfileService/getUserSimpleInfo', [{ uids: [uid], @@ -268,6 +236,27 @@ export class NTQQUserApi extends Service { cmdCB: payload => !isNullable(payload.profiles[uid]), } ) + return data.profiles[uid].coreInfo + } + + async getUserSimpleInfo(uid: string, force = true) { + if (getBuildVersion() >= 26702) { + return this.getUserSimpleInfoV2(uid, force) + } + const data = await invoke<{ profiles: Map }>( + 'nodeIKernelProfileService/getUserSimpleInfo', + [{ + uids: [uid], + force + }], + { + cbCmd: 'nodeIKernelProfileListener/onProfileSimpleChanged', + afterFirstCmd: false, + cmdCB: payload => payload.profiles.has(uid), + } + ) + const profile = data.profiles.get(uid)! + return pick(profile, ['nick', 'remark', 'uid', 'uin']) } async getCoreAndBaseInfo(uids: string[]) { diff --git a/src/ntqqapi/api/window.ts b/src/ntqqapi/api/window.ts index e63be71..2b485aa 100644 --- a/src/ntqqapi/api/window.ts +++ b/src/ntqqapi/api/window.ts @@ -35,7 +35,7 @@ export class NTQQWindowApi extends Service { super(ctx, 'ntWindowApi', true) } - // 打开窗口并获取对应的下发事件 + /** 打开窗口并获取对应的下发事件 */ async openWindow( ntQQWindow: NTQQWindow, args: unknown[], @@ -53,7 +53,6 @@ export class NTQQWindowApi extends Service { ) setTimeout(() => { for (const w of BrowserWindow.getAllWindows()) { - // log("close window", w.webContents.getURL()) if (w.webContents.getURL().indexOf(ntQQWindow.windowUrlHash) != -1) { w.close() } 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/ntqqapi/entities.ts b/src/ntqqapi/entities.ts index 64d0bd2..eafcea5 100644 --- a/src/ntqqapi/entities.ts +++ b/src/ntqqapi/entities.ts @@ -52,15 +52,15 @@ export namespace SendElement { } } - export function reply(msgSeq: string, msgId: string, senderUin: string, senderUinStr: string): SendReplyElement { + export function reply(msgSeq: string, msgId: string, senderUin: string): SendReplyElement { return { elementType: ElementType.Reply, elementId: '', replyElement: { - replayMsgSeq: msgSeq, // raw.msgSeq - replayMsgId: msgId, // raw.msgId + replayMsgSeq: msgSeq, + replayMsgId: msgId, senderUin: senderUin, - senderUinStr: senderUinStr, + senderUinStr: senderUin, }, } } @@ -251,19 +251,19 @@ export namespace SendElement { } } - export function face(faceId: number): SendFaceElement { + export function face(faceId: number, faceType?: number): SendFaceElement { // 从face_config.json中获取表情名称 const sysFaces = faceConfig.sysface - const emojiFaces = faceConfig.emoji - const face = sysFaces.find((face) => face.QSid === faceId.toString()) - faceId = parseInt(faceId.toString()) - // let faceType = parseInt(faceId.toString().substring(0, 1)); - let faceType = 1 - if (faceId >= 222) { - faceType = 2 - } - if (face?.AniStickerType) { - faceType = 3; + const face = sysFaces.find(face => face.QSid === String(faceId)) + if (!faceType) { + if (faceId < 222) { + faceType = 1 + } else { + faceType = 2 + } + if (face?.AniStickerType) { + faceType = 3 + } } return { elementType: ElementType.Face, diff --git a/src/ntqqapi/helper/face_config.json b/src/ntqqapi/helper/face_config.json index 84bb8fe..da7890a 100644 --- a/src/ntqqapi/helper/face_config.json +++ b/src/ntqqapi/helper/face_config.json @@ -1,5 +1,15 @@ { "sysface": [ + { + "QSid": "419", + "QDes": "/火车", + "IQLid": "419", + "AQLid": "419", + "EMCode": "10419", + "AniStickerType": 3, + "AniStickerPackId": "1", + "AniStickerId": "47" + }, { "QSid": "392", "QDes": "/龙年快乐", @@ -3662,4 +3672,4 @@ "EMCode": "401016" } ] -} \ No newline at end of file +} diff --git a/src/ntqqapi/services/NodeIKernelGroupService.ts b/src/ntqqapi/services/NodeIKernelGroupService.ts index 4247a4b..cf37bd9 100644 --- a/src/ntqqapi/services/NodeIKernelGroupService.ts +++ b/src/ntqqapi/services/NodeIKernelGroupService.ts @@ -123,4 +123,6 @@ export interface NodeIKernelGroupService { addGroupEssence(param: { groupCode: string, msgRandom: number, msgSeq: number }): Promise removeGroupEssence(param: { groupCode: string, msgRandom: number, msgSeq: number }): Promise + + setHeader(args: unknown[]): Promise } diff --git a/src/ntqqapi/services/NodeIKernelMsgService.ts b/src/ntqqapi/services/NodeIKernelMsgService.ts index af6276a..e1881eb 100644 --- a/src/ntqqapi/services/NodeIKernelMsgService.ts +++ b/src/ntqqapi/services/NodeIKernelMsgService.ts @@ -20,6 +20,10 @@ export interface NodeIKernelMsgService { getAioFirstViewLatestMsgs(peer: Peer, num: number): Promise + getAioFirstViewLatestMsgsAndAddActiveChat(...args: unknown[]): Promise + + getMsgsIncludeSelfAndAddActiveChat(...args: unknown[]): Promise + getMsgsIncludeSelf(peer: Peer, msgId: string, count: number, queryOrder: boolean): Promise getMsgsBySeqAndCount(peer: Peer, seq: string, count: number, desc: boolean, unknownArg: boolean): Promise diff --git a/src/ntqqapi/services/NodeIKernelProfileLikeService.ts b/src/ntqqapi/services/NodeIKernelProfileLikeService.ts index 5d98f8e..9551e9e 100644 --- a/src/ntqqapi/services/NodeIKernelProfileLikeService.ts +++ b/src/ntqqapi/services/NodeIKernelProfileLikeService.ts @@ -3,7 +3,7 @@ import { GeneralCallResult } from './common' import { Dict } from 'cosmokit' export interface NodeIKernelProfileLikeService { - setBuddyProfileLike(...args: unknown[]): { result: number, errMsg: string, succCounts: number } + setBuddyProfileLike(...args: unknown[]): GeneralCallResult & { succCounts: number } getBuddyProfileLike(req: BuddyProfileLikeReq): Promise + members: Map // uid -> remark } jsonGrayTipElement?: { busiId: string @@ -241,7 +297,6 @@ export interface GrayTipElement { } } - export enum FaceIndex { Dice = 358, RPS = 359, // 石头剪刀布 @@ -268,6 +323,10 @@ export interface MarketFaceElement { key: string imageWidth?: number imageHeight?: number + supportSize?: { + width: number + height: number + }[] } export interface VideoElement { @@ -326,58 +385,6 @@ export interface InlineKeyboardElement { ] } -export interface TipAioOpGrayTipElement { - // 这是什么提示来着? - operateType: number - peerUid: string - fromGrpCodeOfTmpChat: string -} - -export enum TipGroupElementType { - MemberIncrease = 1, - Kicked = 3, // 被移出群 - Ban = 8, -} - -export interface TipGroupElement { - type: TipGroupElementType // 1是表示有人加入群, 自己加入群也会收到这个 - role: 0 // 暂时不知 - groupName: string // 暂时获取不到 - memberUid: string - memberNick: string - memberRemark: string - adminUid: string - adminNick: string - adminRemark: string - createGroup: null - memberAdd?: { - showType: 1 - otherAdd: null - otherAddByOtherQRCode: null - otherAddByYourQRCode: null - youAddByOtherQRCode: null - otherInviteOther: null - otherInviteYou: null - youInviteOther: null - } - shutUp?: { - curTime: string - duration: string // 禁言时间,秒 - admin: { - uid: string - card: string - name: string - role: GroupMemberRole - } - member: { - uid: string - card: string - name: string - role: GroupMemberRole - } - } -} - export interface StructLongMsgElement { xmlContent: string resId: string @@ -409,11 +416,25 @@ export interface RawMessage { guildId: string sendNickName: string sendMemberName?: string // 发送者群名片 + sendRemarkName?: string // 发送者好友备注 chatType: ChatType sendStatus?: number // 消息状态,别人发的2是已撤回,自己发的2是已发送 recallTime: string // 撤回时间, "0"是没有撤回 records: RawMessage[] elements: MessageElement[] + peerName: string + multiTransInfo?: { + status: number + msgId: number + friendFlag: number + fromFaceUrl: string + } + emojiLikesList: { + emojiId: string + emojiType: string + likesCnt: string + isClicked: boolean + }[] } export interface Peer { diff --git a/src/ntqqapi/types/notify.ts b/src/ntqqapi/types/notify.ts index cdf5189..0f2d466 100644 --- a/src/ntqqapi/types/notify.ts +++ b/src/ntqqapi/types/notify.ts @@ -1,19 +1,19 @@ export enum GroupNotifyType { - INVITED_BY_MEMBER = 1, - REFUSE_INVITED, - REFUSED_BY_ADMINI_STRATOR, - AGREED_TOJOIN_DIRECT, // 有人接受了邀请入群 - INVITED_NEED_ADMINI_STRATOR_PASS, // 有人邀请了别人入群 - AGREED_TO_JOIN_BY_ADMINI_STRATOR, - REQUEST_JOIN_NEED_ADMINI_STRATOR_PASS, - SET_ADMIN, - KICK_MEMBER_NOTIFY_ADMIN, - KICK_MEMBER_NOTIFY_KICKED, - MEMBER_LEAVE_NOTIFY_ADMIN, // 主动退出 - CANCEL_ADMIN_NOTIFY_CANCELED, // 我被取消管理员 - CANCEL_ADMIN_NOTIFY_ADMIN, // 其他人取消管理员 - TRANSFER_GROUP_NOTIFY_OLDOWNER, - TRANSFER_GROUP_NOTIFY_ADMIN + InvitedByMember = 1, + RefuseInvited, + RefusedByAdminiStrator, + AgreedTojoinDirect, // 有人接受了邀请入群 + InvitedNeedAdminiStratorPass, // 有人邀请了别人入群 + AgreedToJoinByAdminiStrator, + RequestJoinNeedAdminiStratorPass, + SetAdmin, + KickMemberNotifyAdmin, + KickMemberNotifyKicked, + MemberLeaveNotifyAdmin, // 主动退出 + CancelAdminNotifyCanceled, // 我被取消管理员 + CancelAdminNotifyAdmin, // 其他人取消管理员 + TransferGroupNotifyOldowner, + TransferGroupNotifyAdmin } export interface GroupNotifies { @@ -23,11 +23,11 @@ export interface GroupNotifies { } export enum GroupNotifyStatus { - KINIT, // 初始化 - KUNHANDLE, // 未处理 - KAGREED, // 同意 - KREFUSED, // 拒绝 - KIGNORED // 忽略 + Init, // 初始化 + Unhandle, // 未处理 + Agreed, // 同意 + Refused, // 拒绝 + Ignored // 忽略 } export interface GroupNotify { @@ -79,41 +79,3 @@ export interface FriendRequestNotify { buddyReqs: FriendRequest[] } } - -export enum MemberExtSourceType { - DEFAULTTYPE = 0, - TITLETYPE = 1, - NEWGROUPTYPE = 2, -} - -export interface GroupExtParam { - groupCode: string - seq: string - beginUin: string - dataTime: string - uinList: Array - uinNum: string - groupType: string - richCardNameVer: string - sourceType: MemberExtSourceType - memberExtFilter: { - memberLevelInfoUin: number - memberLevelInfoPoint: number - memberLevelInfoActiveDay: number - memberLevelInfoLevel: number - memberLevelInfoName: number - levelName: number - dataTime: number - userShowFlag: number - sysShowFlag: number - timeToUpdate: number - nickName: number - specialTitle: number - levelNameNew: number - userShowFlagNew: number - msgNeedField: number - cmdUinFlagExt3Grocery: number - memberIcon: number - memberInfoSeq: number - } -} diff --git a/src/ntqqapi/types/user.ts b/src/ntqqapi/types/user.ts index 99f8d8d..9108a59 100644 --- a/src/ntqqapi/types/user.ts +++ b/src/ntqqapi/types/user.ts @@ -105,7 +105,7 @@ export interface BaseInfo { phoneNum: string categoryId: number richTime: number - richBuffer: string + richBuffer: Uint8Array } interface MusicInfo { diff --git a/src/ntqqapi/wrapper.ts b/src/ntqqapi/wrapper.ts deleted file mode 100644 index f101f1a..0000000 --- a/src/ntqqapi/wrapper.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - NodeIKernelBuddyService, - NodeIKernelGroupService, - NodeIKernelProfileService, - NodeIKernelProfileLikeService, - NodeIKernelMSFService, - NodeIKernelMsgService, - NodeIKernelUixConvertService, - NodeIKernelRichMediaService, - NodeIKernelTicketService, - NodeIKernelTipOffService -} from './services' -import { constants } from 'node:os' -import { Dict } from 'cosmokit' -const Process = require('node:process') - -export interface NodeIQQNTWrapperSession { - getBuddyService(): NodeIKernelBuddyService - getGroupService(): NodeIKernelGroupService - getProfileService(): NodeIKernelProfileService - getProfileLikeService(): NodeIKernelProfileLikeService - getMsgService(): NodeIKernelMsgService - getMSFService(): NodeIKernelMSFService - getUixConvertService(): NodeIKernelUixConvertService - getRichMediaService(): NodeIKernelRichMediaService - getTicketService(): NodeIKernelTicketService - getTipOffService(): NodeIKernelTipOffService -} - -export interface WrapperApi { - NodeIQQNTWrapperSession?: NodeIQQNTWrapperSession -} - -const wrapperApi: WrapperApi = {} - -Process.dlopenOrig = Process.dlopen - -Process.dlopen = function (module: Dict, filename: string, flags = constants.dlopen.RTLD_LAZY) { - const dlopenRet = this.dlopenOrig(module, filename, flags) - for (const export_name in module.exports) { - module.exports[export_name] = new Proxy(module.exports[export_name], { - construct: (target, args) => { - const ret = new target(...args) - if (export_name === 'NodeIQQNTWrapperSession') wrapperApi.NodeIQQNTWrapperSession = ret - return ret - } - }) - } - return dlopenRet -} - -export function getSession() { - return wrapperApi['NodeIQQNTWrapperSession'] -} diff --git a/src/onebot11/action/go-cqhttp/GetGroupFileUrl.ts b/src/onebot11/action/go-cqhttp/GetGroupFileUrl.ts index 78942f2..2a489b6 100644 --- a/src/onebot11/action/go-cqhttp/GetGroupFileUrl.ts +++ b/src/onebot11/action/go-cqhttp/GetGroupFileUrl.ts @@ -50,7 +50,7 @@ export class GetGroupFileUrl extends BaseAction { private async search(groupId: string, fileId: string, folderId?: string) { let modelId: string | undefined let nextIndex: number | undefined - let folders: GroupFileInfo['item'] = [] + const folders: GroupFileInfo['item'] = [] while (nextIndex !== 0) { const res = await this.ctx.ntGroupApi.getGroupFileList(groupId, { sortType: 1, diff --git a/src/onebot11/action/go-cqhttp/GetGroupSystemMsg.ts b/src/onebot11/action/go-cqhttp/GetGroupSystemMsg.ts index 6ccf040..e22d12c 100644 --- a/src/onebot11/action/go-cqhttp/GetGroupSystemMsg.ts +++ b/src/onebot11/action/go-cqhttp/GetGroupSystemMsg.ts @@ -38,7 +38,7 @@ export class GetGroupSystemMsg extends BaseAction { invitor_nick: notify.user1.nickName, group_id: +notify.group.groupCode, group_name: notify.group.groupName, - checked: notify.status !== GroupNotifyStatus.KUNHANDLE, + checked: notify.status !== GroupNotifyStatus.Unhandle, actor: notify.user2?.uid ? Number(await this.ctx.ntUserApi.getUinByUid(notify.user2.uid)) : 0 }) } else if (notify.type == 7) { @@ -49,7 +49,7 @@ export class GetGroupSystemMsg extends BaseAction { message: notify.postscript, group_id: +notify.group.groupCode, group_name: notify.group.groupName, - checked: notify.status !== GroupNotifyStatus.KUNHANDLE, + checked: notify.status !== GroupNotifyStatus.Unhandle, actor: notify.user2?.uid ? Number(await this.ctx.ntUserApi.getUinByUid(notify.user2.uid)) : 0 }) } diff --git a/src/onebot11/action/go-cqhttp/SendGroupNotice.ts b/src/onebot11/action/go-cqhttp/SendGroupNotice.ts index c1575f0..59a84fa 100644 --- a/src/onebot11/action/go-cqhttp/SendGroupNotice.ts +++ b/src/onebot11/action/go-cqhttp/SendGroupNotice.ts @@ -28,7 +28,7 @@ export class SendGroupNotice extends BaseAction { let picInfo: { id: string, width: number, height: number } | undefined if (payload.image) { - const { path, isLocal, success, errMsg } = await uri2local(payload.image, undefined, true) + const { path, isLocal, success, errMsg } = await uri2local(this.ctx, payload.image, true) if (!success) { throw new Error(`设置群公告失败, 错误信息: uri2local: ${errMsg}`) } diff --git a/src/onebot11/action/go-cqhttp/UploadGroupFile.ts b/src/onebot11/action/go-cqhttp/UploadGroupFile.ts index 72cc3ad..80c4a99 100644 --- a/src/onebot11/action/go-cqhttp/UploadGroupFile.ts +++ b/src/onebot11/action/go-cqhttp/UploadGroupFile.ts @@ -23,7 +23,7 @@ export class UploadGroupFile extends BaseAction { }) protected async _handle(payload: Payload): Promise { - const { success, errMsg, path, fileName } = await uri2local(payload.file) + const { success, errMsg, path, fileName } = await uri2local(this.ctx, payload.file) if (!success) { throw new Error(errMsg) } diff --git a/src/onebot11/action/go-cqhttp/UploadPrivateFile.ts b/src/onebot11/action/go-cqhttp/UploadPrivateFile.ts index 7c1c07c..79e4fa5 100644 --- a/src/onebot11/action/go-cqhttp/UploadPrivateFile.ts +++ b/src/onebot11/action/go-cqhttp/UploadPrivateFile.ts @@ -19,7 +19,7 @@ export class UploadPrivateFile extends BaseAction { - const { success, errMsg, path, fileName } = await uri2local(payload.file) + const { success, errMsg, path, fileName } = await uri2local(this.ctx, payload.file) if (!success) { throw new Error(errMsg) } diff --git a/src/onebot11/action/group/GetGroupMemberInfo.ts b/src/onebot11/action/group/GetGroupMemberInfo.ts index 8fe323c..211d5eb 100644 --- a/src/onebot11/action/group/GetGroupMemberInfo.ts +++ b/src/onebot11/action/group/GetGroupMemberInfo.ts @@ -18,7 +18,9 @@ class GetGroupMemberInfo extends BaseAction { protected async _handle(payload: Payload) { const groupCode = payload.group_id.toString() - const member = await this.ctx.ntGroupApi.getGroupMember(groupCode, payload.user_id.toString()) + const uid = await this.ctx.ntUserApi.getUidByUin(payload.user_id.toString()) + if (!uid) throw new Error('无法获取用户信息') + const member = await this.ctx.ntGroupApi.getGroupMember(groupCode, uid) if (member) { if (isNullable(member.sex)) { const info = await this.ctx.ntUserApi.getUserDetailInfo(member.uid) diff --git a/src/onebot11/action/llonebot/Debug.ts b/src/onebot11/action/llonebot/Debug.ts index 35ab94e..e0aa09b 100644 --- a/src/onebot11/action/llonebot/Debug.ts +++ b/src/onebot11/action/llonebot/Debug.ts @@ -16,10 +16,8 @@ export default class Debug extends BaseAction { for (const ntqqApiClass of ntqqApi) { const method = ntqqApiClass[payload.method as keyof typeof ntqqApiClass] if (method && method instanceof Function) { - const result = method.apply(ntqqApiClass, payload.args) - if (method.constructor.name === 'AsyncFunction') { - return await result - } + const result = await method.apply(ntqqApiClass, payload.args) + this.ctx.logger.info('debug', result) return result } } diff --git a/src/onebot11/action/llonebot/GetGroupAddRequest.ts b/src/onebot11/action/llonebot/GetGroupAddRequest.ts index 920cd39..07ccc8c 100644 --- a/src/onebot11/action/llonebot/GetGroupAddRequest.ts +++ b/src/onebot11/action/llonebot/GetGroupAddRequest.ts @@ -13,7 +13,7 @@ export default class GetGroupAddRequest extends BaseAction { const data = await this.ctx.ntGroupApi.getGroupIgnoreNotifies() - const notifies = data.notifies.filter(notify => notify.status === GroupNotifyStatus.KUNHANDLE) + const notifies = data.notifies.filter(notify => notify.status === GroupNotifyStatus.Unhandle) const returnData: OB11GroupRequestNotify[] = [] for (const notify of notifies) { const uin = await this.ctx.ntUserApi.getUinByUid(notify.user1.uid) diff --git a/src/onebot11/action/llonebot/SetQQAvatar.ts b/src/onebot11/action/llonebot/SetQQAvatar.ts index 0f8ed82..7c9351b 100644 --- a/src/onebot11/action/llonebot/SetQQAvatar.ts +++ b/src/onebot11/action/llonebot/SetQQAvatar.ts @@ -11,13 +11,13 @@ export default class SetAvatar extends BaseAction { actionName = ActionName.SetQQAvatar protected async _handle(payload: Payload): Promise { - const { path, isLocal, errMsg } = await uri2local(payload.file) + const { path, isLocal, errMsg } = await uri2local(this.ctx, payload.file) if (errMsg) { throw new Error(errMsg) } if (path) { await checkFileReceived(path, 5000) // 文件不存在QQ会崩溃,需要提前判断 - const ret = await this.ctx.ntUserApi.setQQAvatar(path) + const ret = await this.ctx.ntUserApi.setSelfAvatar(path) if (!isLocal) { unlink(path) } diff --git a/src/onebot11/adapter.ts b/src/onebot11/adapter.ts index 1c60dcd..ee3a61a 100644 --- a/src/onebot11/adapter.ts +++ b/src/onebot11/adapter.ts @@ -4,10 +4,7 @@ import { GroupNotify, GroupNotifyType, RawMessage, - BuddyReqType, FriendRequest, - GroupMember, - GroupMemberRole, GroupNotifyStatus } from '../ntqqapi/types' import { OB11GroupRequestEvent } from './event/request/OB11GroupRequest' @@ -23,7 +20,6 @@ import { OB11BaseMetaEvent } from './event/meta/OB11BaseMetaEvent' import { postHttpEvent } from './helper/eventForHttp' import { initActionMap } from './action' import { llonebotError } from '../common/globalVars' -import { OB11GroupCardEvent } from './event/notice/OB11GroupCardEvent' import { OB11GroupAdminNoticeEvent } from './event/notice/OB11GroupAdminNoticeEvent' import { OB11ProfileLikeEvent } from './event/notice/OB11ProfileLikeEvent' import { SysMsg } from '@/ntqqapi/proto/compiled' @@ -35,8 +31,10 @@ declare module 'cordis' { } class OneBot11Adapter extends Service { - static inject = ['ntMsgApi', 'ntFileApi', 'ntFileCacheApi', 'ntFriendApi', 'ntGroupApi', 'ntUserApi', 'ntWindowApi', 'ntWebApi', 'store'] - + static inject = [ + 'ntMsgApi', 'ntFileApi', 'ntFileCacheApi', 'ntFriendApi', + 'ntGroupApi', 'ntUserApi', 'ntWindowApi', 'ntWebApi', 'store' + ] private ob11WebSocket: OB11WebSocket private ob11WebSocketReverseManager: OB11WebSocketReverseManager private ob11Http: OB11Http @@ -91,7 +89,7 @@ class OneBot11Adapter extends Service { private async handleGroupNotify(notify: GroupNotify) { try { const flag = notify.group.groupCode + '|' + notify.seq + '|' + notify.type - if ([GroupNotifyType.MEMBER_LEAVE_NOTIFY_ADMIN, GroupNotifyType.KICK_MEMBER_NOTIFY_ADMIN].includes(notify.type)) { + if ([GroupNotifyType.MemberLeaveNotifyAdmin, GroupNotifyType.KickMemberNotifyAdmin].includes(notify.type)) { this.ctx.logger.info('有成员退出通知', notify) const member1Uin = await this.ctx.ntUserApi.getUinByUid(notify.user1.uid) let operatorId = member1Uin @@ -112,7 +110,7 @@ class OneBot11Adapter extends Service { ) this.dispatch(event) } - else if (notify.type === GroupNotifyType.REQUEST_JOIN_NEED_ADMINI_STRATOR_PASS && notify.status === GroupNotifyStatus.KUNHANDLE) { + else if (notify.type === GroupNotifyType.RequestJoinNeedAdminiStratorPass && notify.status === GroupNotifyStatus.Unhandle) { this.ctx.logger.info('有加群请求') const requestUin = await this.ctx.ntUserApi.getUinByUid(notify.user1.uid) const event = new OB11GroupRequestEvent( @@ -123,7 +121,7 @@ class OneBot11Adapter extends Service { ) this.dispatch(event) } - else if (notify.type === GroupNotifyType.INVITED_BY_MEMBER && notify.status === GroupNotifyStatus.KUNHANDLE) { + else if (notify.type === GroupNotifyType.InvitedByMember && notify.status === GroupNotifyStatus.Unhandle) { this.ctx.logger.info('收到邀请我加群通知') const userId = await this.ctx.ntUserApi.getUinByUid(notify.user2.uid) const event = new OB11GroupRequestEvent( @@ -136,7 +134,7 @@ class OneBot11Adapter extends Service { ) this.dispatch(event) } - else if (notify.type === GroupNotifyType.INVITED_NEED_ADMINI_STRATOR_PASS && notify.status === GroupNotifyStatus.KUNHANDLE) { + else if (notify.type === GroupNotifyType.InvitedNeedAdminiStratorPass && notify.status === GroupNotifyStatus.Unhandle) { this.ctx.logger.info('收到群员邀请加群通知') const userId = await this.ctx.ntUserApi.getUinByUid(notify.user1.uid) const event = new OB11GroupRequestEvent( @@ -147,6 +145,20 @@ class OneBot11Adapter extends Service { ) this.dispatch(event) } + else if ([ + GroupNotifyType.SetAdmin, + GroupNotifyType.CancelAdminNotifyCanceled, + GroupNotifyType.CancelAdminNotifyAdmin + ].includes(notify.type)) { + this.ctx.logger.info('收到管理员变动通知') + const uin = await this.ctx.ntUserApi.getUinByUid(notify.user1.uid) + const event = new OB11GroupAdminNoticeEvent( + notify.type === GroupNotifyType.SetAdmin ? 'set' : 'unset', + parseInt(notify.group.groupCode), + parseInt(uin), + ) + this.dispatch(event) + } } catch (e) { this.ctx.logger.error('解析群通知失败', (e as Error).stack) } @@ -306,29 +318,6 @@ class OneBot11Adapter extends Service { }) } - private async handleGroupMemberInfoUpdated(groupCode: string, members: GroupMember[]) { - for (const member of members) { - const existMember = await this.ctx.ntGroupApi.getGroupMember(groupCode, member.uin) - if (existMember) { - if (member.cardName !== existMember.cardName) { - this.ctx.logger.info('群成员名片变动', `${groupCode}: ${existMember.uin}`, existMember.cardName, '->', member.cardName) - this.dispatch( - new OB11GroupCardEvent(parseInt(groupCode), parseInt(member.uin), member.cardName, existMember.cardName), - ) - } else if (member.role !== existMember.role) { - this.ctx.logger.info('有管理员变动通知') - const groupAdminNoticeEvent = new OB11GroupAdminNoticeEvent( - member.role == GroupMemberRole.admin ? 'set' : 'unset', - parseInt(groupCode), - parseInt(member.uin) - ) - this.dispatch(groupAdminNoticeEvent) - } - Object.assign(existMember, member) - } - } - } - public start() { if (this.config.enableWs) { this.ob11WebSocket.start() @@ -342,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 => { @@ -360,9 +349,6 @@ class OneBot11Adapter extends Service { this.ctx.on('nt/friend-request', input => { this.handleFriendRequest(input) }) - this.ctx.on('nt/group-member-info-updated', input => { - this.handleGroupMemberInfoUpdated(input.groupCode, input.members) - }) this.ctx.on('nt/system-message-created', input => { const sysMsg = SysMsg.SystemMessage.decode(input) const { msgType, subType, subSubType } = sysMsg.msgSpec[0] ?? {} @@ -385,7 +371,6 @@ namespace OneBot11Adapter { token: string debug: boolean reportSelfMessage: boolean - msgCacheExpire: number musicSignUrl?: string enableLocalFile2Url: boolean ffmpeg?: string diff --git a/src/onebot11/connect/ws.ts b/src/onebot11/connect/ws.ts index 9ea9e82..e22699b 100644 --- a/src/onebot11/connect/ws.ts +++ b/src/onebot11/connect/ws.ts @@ -214,7 +214,7 @@ class OB11WebSocketReverse { let receive: { action: ActionName | null; params: unknown; echo?: unknown } = { action: null, params: {} } try { receive = JSON.parse(msg.toString()) - this.ctx.logger.info('收到反向Websocket消息', receive) + this.ctx.logger.info('收到反向 Websocket 消息', receive) } catch (e) { return this.reply(this.wsClient!, OB11Response.error('json解析失败,请检查数据格式', 1400, receive.echo)) } diff --git a/src/onebot11/entities.ts b/src/onebot11/entities.ts index 8912e8b..bc669b8 100644 --- a/src/onebot11/entities.ts +++ b/src/onebot11/entities.ts @@ -30,7 +30,6 @@ import { OB11GroupUploadNoticeEvent } from './event/notice/OB11GroupUploadNotice import { OB11GroupNoticeEvent } from './event/notice/OB11GroupNoticeEvent' import { calcQQLevel } from '../common/utils/misc' import { OB11GroupTitleEvent } from './event/notice/OB11GroupTitleEvent' -import { OB11GroupCardEvent } from './event/notice/OB11GroupCardEvent' import { OB11GroupDecreaseEvent } from './event/notice/OB11GroupDecreaseEvent' import { OB11GroupMsgEmojiLikeEvent } from './event/notice/OB11MsgEmojiLikeEvent' import { OB11FriendAddNoticeEvent } from './event/notice/OB11FriendAddNoticeEvent' @@ -39,7 +38,7 @@ import { OB11GroupRecallNoticeEvent } from './event/notice/OB11GroupRecallNotice import { OB11FriendPokeEvent, OB11GroupPokeEvent } from './event/notice/OB11PokeEvent' import { OB11BaseNoticeEvent } from './event/notice/OB11BaseNoticeEvent' import { OB11GroupEssenceEvent } from './event/notice/OB11GroupEssenceEvent' -import { omit, isNullable, pick, Dict } from 'cosmokit' +import { omit, pick, Dict } from 'cosmokit' import { Context } from 'cordis' import { selfInfo } from '@/common/globalVars' import { pathToFileURL } from 'node:url' @@ -80,7 +79,7 @@ export namespace OB11Entities { if (msg.chatType === ChatType.Group) { resMsg.sub_type = 'normal' resMsg.group_id = parseInt(msg.peerUin) - const member = await ctx.ntGroupApi.getGroupMember(msg.peerUin, msg.senderUin) + const member = await ctx.ntGroupApi.getGroupMember(msg.peerUin, msg.senderUid) if (member) { resMsg.sender.role = groupMemberRole(member.role) resMsg.sender.nickname = member.nick @@ -89,12 +88,12 @@ export namespace OB11Entities { } else if (msg.chatType === ChatType.C2C) { resMsg.sub_type = 'friend' - resMsg.sender.nickname = (await ctx.ntUserApi.getUserDetailInfo(msg.senderUid)).nick + resMsg.sender.nickname = (await ctx.ntUserApi.getUserSimpleInfo(msg.senderUid)).nick } else if (msg.chatType === ChatType.TempC2CFromGroup) { resMsg.sub_type = 'group' resMsg.temp_source = 0 //群聊 - resMsg.sender.nickname = (await ctx.ntUserApi.getUserDetailInfo(msg.senderUid)).nick + resMsg.sender.nickname = (await ctx.ntUserApi.getUserSimpleInfo(msg.senderUid)).nick const ret = await ctx.ntMsgApi.getTempChatInfo(ChatType.TempC2CFromGroup, msg.senderUid) if (ret?.result === 0) { resMsg.sender.group_id = Number(ret.tmpChatInfo?.groupCode) @@ -403,7 +402,7 @@ export namespace OB11Entities { if (msg.chatType !== ChatType.Group) { return } - if (msg.senderUin) { + /**if (msg.senderUin) { const member = await ctx.ntGroupApi.getGroupMember(msg.peerUid, msg.senderUin) if (member && member.cardName !== msg.sendMemberName) { const event = new OB11GroupCardEvent( @@ -415,24 +414,17 @@ export namespace OB11Entities { member.cardName = msg.sendMemberName! return event } - } + }*/ for (const element of msg.elements) { const grayTipElement = element.grayTipElement const groupElement = grayTipElement?.groupElement if (groupElement) { if (groupElement.type === TipGroupElementType.MemberIncrease) { ctx.logger.info('收到群成员增加消息', groupElement) - await ctx.sleep(1000) - const member = await ctx.ntGroupApi.getGroupMember(msg.peerUid, groupElement.memberUid) - let memberUin = member?.uin - if (!memberUin) { - memberUin = (await ctx.ntUserApi.getUserDetailInfo(groupElement.memberUid)).uin - } - const adminMember = await ctx.ntGroupApi.getGroupMember(msg.peerUid, groupElement.adminUid) - if (memberUin) { - const operatorUin = adminMember?.uin || memberUin - return new OB11GroupIncreaseEvent(parseInt(msg.peerUid), parseInt(memberUin), parseInt(operatorUin)) - } + const { memberUid, adminUid } = groupElement + const memberUin = await ctx.ntUserApi.getUinByUid(memberUid) + const operatorUin = adminUid ? await ctx.ntUserApi.getUinByUid(adminUid) : memberUin + return new OB11GroupIncreaseEvent(+msg.peerUid, +memberUin, +operatorUin) } else if (groupElement.type === TipGroupElementType.Ban) { ctx.logger.info('收到群成员禁言提示', groupElement) @@ -547,10 +539,9 @@ export namespace OB11Entities { while ((match = regex.exec(xmlElement.content)) !== null) { matches.push(match[1]) } - // log("新人进群匹配到的QQ号", matches) if (matches.length === 2) { - const [inviter, invitee] = matches - return new OB11GroupIncreaseEvent(parseInt(msg.peerUid), parseInt(invitee), parseInt(inviter), 'invite') + const [invitor, invitee] = matches + return new OB11GroupIncreaseEvent(+msg.peerUid, +invitee, +invitor, 'invite') } } } @@ -594,11 +585,6 @@ export namespace OB11Entities { const memberUin = json.items[1].param[0] const title = json.items[3].txt ctx.logger.info('收到群成员新头衔消息', json) - ctx.ntGroupApi.getGroupMember(msg.peerUid, memberUin).then(member => { - if (!isNullable(member)) { - member.memberSpecialTitle = title - } - }) return new OB11GroupTitleEvent(parseInt(msg.peerUid), parseInt(memberUin), title) } else if (grayTipElement.jsonGrayTipElement?.busiId === '19217') { ctx.logger.info('收到新人被邀请进群消息', grayTipElement) @@ -616,13 +602,7 @@ export namespace OB11Entities { msg: RawMessage, shortId: number ): Promise { - const msgElement = msg.elements.find( - (element) => element.grayTipElement?.subElementType === GrayTipElementSubType.Revoke, - ) - if (!msgElement) { - return - } - const revokeElement = msgElement.grayTipElement!.revokeElement + const revokeElement = msg.elements[0].grayTipElement?.revokeElement if (msg.chatType === ChatType.Group) { const operator = await ctx.ntGroupApi.getGroupMember(msg.peerUid, revokeElement!.operatorUid) return new OB11GroupRecallNoticeEvent( diff --git a/src/onebot11/helper/createMessage.ts b/src/onebot11/helper/createMessage.ts index 7bbb6a3..0a32d53 100644 --- a/src/onebot11/helper/createMessage.ts +++ b/src/onebot11/helper/createMessage.ts @@ -56,7 +56,7 @@ export async function createSendElements( remainAtAllCount = (await ctx.ntGroupApi.getGroupRemainAtTimes(groupCode)).atInfo .RemainAtAllCountForUin ctx.logger.info(`群${groupCode}剩余at全体次数`, remainAtAllCount) - const self = await ctx.ntGroupApi.getGroupMember(groupCode, selfInfo.uin) + const self = await ctx.ntGroupApi.getGroupMember(groupCode, selfInfo.uid) isAdmin = self?.role === GroupMemberRole.admin || self?.role === GroupMemberRole.owner } catch (e) { } @@ -66,44 +66,24 @@ export async function createSendElements( } } else if (peer.chatType === ChatType.Group) { - const atMember = await ctx.ntGroupApi.getGroupMember(peer.peerUid, atQQ) - if (atMember) { - const display = `@${atMember.cardName || atMember.nick}` - sendElements.push( - SendElement.at(atQQ, atMember.uid, AtType.One, display), - ) - } else { - const atNmae = sendMsg.data?.name - const uid = await ctx.ntUserApi.getUidByUin(atQQ) || '' - const display = atNmae ? `@${atNmae}` : '' - sendElements.push( - SendElement.at(atQQ, uid, AtType.One, display), - ) - } + const uid = await ctx.ntUserApi.getUidByUin(atQQ) ?? '' + const atNmae = sendMsg.data?.name + const display = atNmae ? `@${atNmae}` : '' + sendElements.push(SendElement.at(atQQ, uid, AtType.One, display)) } } } break case OB11MessageDataType.reply: { if (sendMsg.data?.id) { - const replyMsgId = await ctx.store.getMsgInfoByShortId(+sendMsg.data.id) - if (!replyMsgId) { - ctx.logger.warn('回复消息不存在', replyMsgId) + const info = await ctx.store.getMsgInfoByShortId(+sendMsg.data.id) + if (!info) { + ctx.logger.warn('回复消息不存在', info) continue } - const replyMsg = (await ctx.ntMsgApi.getMsgsByMsgId( - replyMsgId.peer, - [replyMsgId.msgId!] - )).msgList[0] - if (replyMsg) { - sendElements.push( - SendElement.reply( - replyMsg.msgSeq, - replyMsg.msgId, - replyMsg.senderUin!, - replyMsg.senderUin!, - ), - ) + const source = (await ctx.ntMsgApi.getMsgsByMsgId(info.peer, [info.msgId])).msgList[0] + if (source) { + sendElements.push(SendElement.reply(source.msgSeq, source.msgId, source.senderUin)) } } } @@ -147,7 +127,7 @@ export async function createSendElements( const { path, fileName } = await handleOb11FileLikeMessage(ctx, sendMsg, { deleteAfterSentFiles }) let thumb = sendMsg.data.thumb if (thumb) { - const uri2LocalRes = await uri2local(thumb) + const uri2LocalRes = await uri2local(ctx, thumb) if (uri2LocalRes.success) thumb = uri2LocalRes.path } const res = await SendElement.video(ctx, path, fileName, thumb) @@ -206,7 +186,7 @@ async function handleOb11FileLikeMessage( fileName, errMsg, success, - } = (await uri2local(inputdata?.url || inputdata.file)) + } = (await uri2local(ctx, inputdata.url || inputdata.file)) if (!success) { ctx.logger.error(errMsg) 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..2c20a92 --- /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), + 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..5140bcd --- /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)) + 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)) + + 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..ec5fff0 --- /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), ['is_bot']) + }) +} diff --git a/src/satori/event/user.ts b/src/satori/event/user.ts new file mode 100644 index 0000000..31f2397 --- /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), + 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..484a8c3 --- /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) + 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 + } +} diff --git a/src/version.ts b/src/version.ts index 066dfff..91acebf 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const version = '3.34.1' +export const version = '4.0.0'