Compare commits

..

25 Commits

Author SHA1 Message Date
idranme
e10a67ce05 Merge pull request #468 from LLOneBot/dev
release: 4.0.7
2024-10-13 01:30:01 +08:00
idranme
c8e897abdb chore: bump versions 2024-10-13 01:29:19 +08:00
idranme
e07c06f3e9 Merge pull request #467 from LLOneBot/dev
...
2024-10-13 01:17:06 +08:00
idranme
9c694a11b5 fix: poke 2024-10-13 01:12:12 +08:00
idranme
6e3bb7c9cf fix 2024-10-13 01:10:39 +08:00
idranme
0d8d3ac24f Merge pull request #466 from LLOneBot/dev
release: 4.0.6
2024-10-13 00:48:36 +08:00
idranme
c96d032820 chore: bump versions 2024-10-13 00:47:31 +08:00
idranme
837f48b63a fix: poke 2024-10-13 00:45:30 +08:00
idranme
9d0f9e7096 Merge pull request #465 from LLOneBot/dev
release: 4.0.5
2024-10-12 23:58:07 +08:00
idranme
801d79d79d chore: bump versions 2024-10-12 23:55:32 +08:00
idranme
0d5640046c feat: poke 2024-10-12 23:50:58 +08:00
idranme
e988908784 Merge pull request #463 from LLOneBot/dev
release: 4.0.4
2024-10-11 18:22:37 +08:00
idranme
1cfa736dd5 chore: bump versions 2024-10-11 18:18:51 +08:00
idranme
0081b0b124 refactor 2024-10-11 18:17:52 +08:00
idranme
ba565e7c38 feat(onebot): ocr_image API 2024-10-11 17:42:19 +08:00
idranme
abb468c3f8 optimize 2024-10-11 13:38:59 +08:00
idranme
433a175809 fix: at element 2024-10-11 12:47:12 +08:00
idranme
b40c81c5cb Merge pull request #462 from LLOneBot/dev
release: 4.0.3
2024-10-11 00:52:33 +08:00
idranme
ddf7ffcabe chore: bump versions 2024-10-11 00:50:56 +08:00
idranme
2b0aa6249b fix: at element 2024-10-11 00:50:06 +08:00
idranme
6bb4a8fe69 optimize 2024-10-11 00:31:11 +08:00
idranme
91d78f22f7 refactor 2024-10-09 02:55:12 +08:00
idranme
457ffc0922 Merge pull request #461 from LLOneBot/dev
release: 4.0.2
2024-10-08 21:26:39 +08:00
idranme
e3a2303e45 chore: bump versions 2024-10-08 21:25:23 +08:00
idranme
8465c47d41 fix 2024-10-08 21:22:04 +08:00
41 changed files with 479 additions and 297 deletions

7
.gitattributes vendored
View File

@@ -1,7 +0,0 @@
* text eol=lf
*.png -text
*.jpg -text
*.ico -text
*.gif -text
*.webp -text

View File

@@ -4,7 +4,7 @@
"name": "LLOneBot",
"slug": "LLOneBot",
"description": "实现 OneBot 11 和 Satori 协议,用于 QQ 机器人开发",
"version": "4.0.1",
"version": "4.0.7",
"icon": "./icon.webp",
"authors": [
{

View File

@@ -24,7 +24,7 @@
"cordis": "^3.18.1",
"cors": "^2.8.5",
"cosmokit": "^1.6.3",
"express": "^5.0.0",
"express": "^5.0.1",
"fast-xml-parser": "^4.5.0",
"fluent-ffmpeg": "^2.1.3",
"minato": "^3.6.0",
@@ -42,7 +42,7 @@
"electron": "^31.4.0",
"electron-vite": "^2.3.0",
"protobufjs-cli": "^1.1.3",
"typescript": "^5.6.2",
"typescript": "^5.6.3",
"vite": "^5.4.8",
"vite-plugin-cp": "^4.0.8"
},

View File

@@ -34,7 +34,8 @@ export class ConfigUtil {
enableWsReverse: false,
messagePostFormat: 'array',
enableHttpHeart: false,
listenLocalhost: false
listenLocalhost: false,
reportSelfMessage: false
}
const satoriDefault: SatoriConfig = {
enable: true,
@@ -50,7 +51,6 @@ export class ConfigUtil {
enableLocalFile2Url: false,
debug: false,
log: false,
reportSelfMessage: false,
autoDeleteFile: false,
autoDeleteFileSecond: 60,
musicSignUrl: '',
@@ -73,6 +73,7 @@ export class ConfigUtil {
this.checkOldConfig(jsonData.ob11, jsonData, 'httpPort', 'http')
this.checkOldConfig(jsonData.ob11, jsonData, 'httpHosts', 'hosts')
this.checkOldConfig(jsonData.ob11, jsonData, 'wsPort', 'wsPort')
this.checkOldConfig(jsonData.ob11, jsonData, 'reportSelfMessage', 'reportSelfMessage')
this.config = jsonData
return this.config
}
@@ -86,8 +87,8 @@ export class ConfigUtil {
private checkOldConfig(
currentConfig: OB11Config,
oldConfig: Config,
currentKey: 'httpPort' | 'httpHosts' | 'wsPort',
oldKey: 'http' | 'hosts' | 'wsPort',
currentKey: 'httpPort' | 'httpHosts' | 'wsPort' | 'reportSelfMessage',
oldKey: 'http' | 'hosts' | 'wsPort' | 'reportSelfMessage',
) {
// 迁移旧的配置到新配置,避免用户重新填写配置
const oldValue = oldConfig[oldKey]

View File

@@ -17,6 +17,7 @@ export interface OB11Config {
*/
enableQOAutoQuote?: boolean
listenLocalhost: boolean
reportSelfMessage: boolean
}
export interface SatoriConfig {
@@ -33,7 +34,6 @@ export interface Config {
heartInterval: number // ms
enableLocalFile2Url?: boolean // 开启后本地文件路径图片会转成http链接, 语音会转成base64
debug?: boolean
reportSelfMessage?: boolean
log?: boolean
autoDeleteFile?: boolean
autoDeleteFileSecond?: number
@@ -50,6 +50,8 @@ export interface Config {
wsPort?: string
/** @deprecated */
enableLLOB?: boolean
/** @deprecated */
reportSelfMessage?: boolean
}
export interface CheckVersion {

View File

@@ -53,49 +53,44 @@ function convert(ctx: Context, input: Input, options: FFmpegOptions, outputPath?
}
export async function encodeSilk(ctx: Context, filePath: string) {
try {
const file = await fsPromise.readFile(filePath)
if (!isSilk(file)) {
ctx.logger.info(`语音文件${filePath}需要转换成silk`)
let result: EncodeResult
const allowSampleRate = [8000, 12000, 16000, 24000, 32000, 44100, 48000]
if (isWav(file) && allowSampleRate.includes(getWavFileInfo(file).fmt.sampleRate)) {
result = await encode(file, 0)
} else {
const input = await convert(ctx, filePath, {
output: [
'-ar 24000',
'-ac 1',
'-f s16le'
]
})
result = await encode(input, 24000)
}
const pttPath = path.join(TEMP_DIR, randomUUID())
await fsPromise.writeFile(pttPath, result.data)
ctx.logger.info(`语音文件${filePath}转换成功!`, pttPath, `时长:`, result.duration)
return {
converted: true,
path: pttPath,
duration: result.duration / 1000,
}
const file = await fsPromise.readFile(filePath)
if (!isSilk(file)) {
ctx.logger.info(`语音文件${filePath}需要转换成silk`)
let result: EncodeResult
const allowSampleRate = [8000, 12000, 16000, 24000, 32000, 44100, 48000]
if (isWav(file) && allowSampleRate.includes(getWavFileInfo(file).fmt.sampleRate)) {
result = await encode(file, 0)
} else {
const silk = file
let duration = 1
try {
duration = getDuration(silk) / 1000
} catch (e) {
ctx.logger.warn('获取语音文件时长失败, 默认为1秒', filePath, (e as Error).stack)
}
return {
converted: false,
path: filePath,
duration,
}
const input = await convert(ctx, filePath, {
output: [
'-ar 24000',
'-ac 1',
'-f s16le'
]
})
result = await encode(input, 24000)
}
const pttPath = path.join(TEMP_DIR, randomUUID())
await fsPromise.writeFile(pttPath, result.data)
ctx.logger.info(`语音文件${filePath}转换成功!`, pttPath, `时长:`, result.duration)
return {
converted: true,
path: pttPath,
duration: result.duration / 1000,
}
} else {
const silk = file
let duration = 1
try {
duration = getDuration(silk) / 1000
} catch (e) {
ctx.logger.warn('获取语音文件时长失败, 默认为1秒', filePath, (e as Error).stack)
}
return {
converted: false,
path: filePath,
duration,
}
} catch (err) {
ctx.logger.error('convert silk failed', (err as Error).stack)
return {}
}
}

View File

@@ -18,7 +18,7 @@ import {
CHANNEL_UPDATE,
CHANNEL_SET_CONFIG_CONFIRMED
} from '../common/channels'
import { hookNTQQApiCall, hookNTQQApiReceive } from '../ntqqapi/hook'
import { startHook } from '../ntqqapi/hook'
import { checkNewVersion, upgradeLLOneBot } from '../common/utils/upgrade'
import { getConfigUtil } from '../common/config'
import { checkFfmpeg } from '../common/utils/video'
@@ -183,7 +183,6 @@ function onLoad() {
heartInterval: config.heartInterval,
token: config.token!,
debug: config.debug!,
reportSelfMessage: config.reportSelfMessage!,
musicSignUrl: config.musicSignUrl,
enableLocalFile2Url: config.enableLocalFile2Url!,
ffmpeg: config.ffmpeg,
@@ -217,23 +216,11 @@ function onLoad() {
// 创建窗口时触发
function onBrowserWindowCreated(window: BrowserWindow) {
if (![2, 4, 6].includes(window.id)) {
return
}
if (window.id === 2) {
mainWindow = window
}
//log('window create', window.webContents.getURL().toString())
try {
hookNTQQApiCall(window, window.id !== 2)
hookNTQQApiReceive(window, window.id !== 2)
} catch (e) {
log('LLOneBot hook error: ', String(e))
}
}
try {
onLoad()
startHook()
} catch (e) {
console.log(e)
}

View File

@@ -201,6 +201,16 @@ export class NTQQFileApi extends Service {
)
return data.notifyInfo.filePath
}
async ocrImage(path: string) {
return await invoke(
'nodeIKernelNodeMiscService/wantWinScreenOCR',
[
{ url: path },
{ timeout: 5000 }
]
)
}
}
export class NTQQFileCacheApi extends Service {

View File

@@ -16,7 +16,7 @@ export class NTQQFriendApi extends Service {
/** 大于或等于 26702 应使用 getBuddyV2 */
async getFriends() {
const data = await invoke<{
const res = await invoke<{
data: {
categoryId: number
categroyName: string
@@ -28,11 +28,7 @@ export class NTQQFriendApi extends Service {
cbCmd: ReceiveCmdS.FRIENDS,
afterFirstCmd: false
})
const _friends: Friend[] = []
for (const item of data.data) {
_friends.push(...item.buddyList)
}
return _friends
return res.data.flatMap(e => e.buddyList)
}
async handleFriendRequest(friendUid: string, reqTime: string, accept: boolean) {

View File

@@ -55,7 +55,7 @@ export class NTQQGroupApi extends Service {
}
async getGroupMember(groupCode: string, uid: string, forceUpdate = false) {
invoke('nodeIKernelGroupListener/onMemberInfoChange', [], {
await invoke('nodeIKernelGroupListener/onMemberInfoChange', [], {
registerEvent: true
})
@@ -296,4 +296,30 @@ export class NTQQGroupApi extends Service {
async setGroupAvatar(groupCode: string, path: string) {
return await invoke('nodeIKernelGroupService/setHeader', [{ path, groupCode }])
}
async searchMember(groupCode: string, keyword: string) {
await invoke('nodeIKernelGroupListener/onSearchMemberChange', [], {
registerEvent: true
})
const sceneId = await invoke(NTMethod.GROUP_MEMBER_SCENE, [{
groupCode,
scene: 'groupMemberList_MainWindow'
}])
const data = await invoke<{
sceneId: string
keyword: string
infos: Map<string, GroupMember>
}>(
'nodeIKernelGroupService/searchMember',
[{ sceneId, keyword }],
{
cbCmd: 'nodeIKernelGroupListener/onSearchMemberChange',
cmdCB: payload => {
return payload.sceneId === sceneId && payload.keyword === keyword
},
afterFirstCmd: false
}
)
return data.infos
}
}

View File

@@ -263,4 +263,8 @@ export class NTQQMsgApi extends Service {
async getServerTime() {
return await invoke('nodeIKernelMSFService/getServerTime', [])
}
async fetchUnitedCommendConfig(groups: string[]) {
return await invoke('nodeIKernelUnitedConfigService/fetchUnitedCommendConfig', [{ groups }])
}
}

View File

@@ -108,18 +108,23 @@ export class NTQQUserApi extends Service {
async getUidByUinV1(uin: string, groupCode?: string) {
let uid = (await invoke('nodeIKernelUixConvertService/getUid', [{ uins: [uin] }])).uidInfo.get(uin)
if (!uid) {
const unveifyUid = (await this.getUserDetailInfoByUin(uin)).info.uid //特殊转换
if (unveifyUid.indexOf('*') === -1) {
uid = unveifyUid
}
}
if (!uid) {
const friends = await this.ctx.ntFriendApi.getFriends() //从好友列表转
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
let member = await this.ctx.ntGroupApi.searchMember(groupCode, uin)
if (member.size === 0) {
await this.ctx.ntGroupApi.getGroupMembers(groupCode, 1)
await this.ctx.sleep(30)
member = await this.ctx.ntGroupApi.searchMember(groupCode, uin)
}
uid = member.values().find(e => e.uin === uin)?.uid
}
if (!uid) {
const unveifyUid = (await this.getUserDetailInfoByUin(uin)).info.uid
if (!unveifyUid.includes('*')) {
uid = unveifyUid
}
}
return uid
}

View File

@@ -19,6 +19,7 @@ import {
import { selfInfo, llonebotError } from '../common/globalVars'
import { version } from '../version'
import { invoke } from './ntcall'
import { Native } from './native/index'
declare module 'cordis' {
interface Context {
@@ -38,9 +39,11 @@ declare module 'cordis' {
class Core extends Service {
static inject = ['ntMsgApi', 'ntFriendApi', 'ntGroupApi', 'store']
public startTime = 0
public native
constructor(protected ctx: Context, public config: Core.Config) {
super(ctx, 'app', true)
this.native = new Native(ctx)
}
public start() {
@@ -205,9 +208,6 @@ class Core extends Service {
})
registerReceiveHook<{ msgRecord: RawMessage }>(ReceiveCmdS.SELF_SEND_MSG, payload => {
if (!this.config.reportSelfMessage) {
return
}
sentMsgIds.set(payload.msgRecord.msgId, true)
})

View File

@@ -16,7 +16,7 @@ import {
SendTextElement,
SendVideoElement,
} from './types'
import { stat, writeFile, copyFile, unlink } from 'node:fs/promises'
import { stat, writeFile, copyFile, unlink, access } from 'node:fs/promises'
import { calculateFileMD5 } from '../common/utils/file'
import { defaultVideoThumb, getVideoInfo } from '../common/utils/video'
import { encodeSilk } from '../common/utils/audio'
@@ -115,25 +115,17 @@ export namespace SendElement {
}
export async function video(ctx: Context, filePath: string, fileName = '', diyThumbPath = ''): Promise<SendVideoElement> {
try {
await stat(filePath)
} catch (e) {
throw `文件${filePath}异常,不存在`
}
ctx.logger.info('复制视频到QQ目录', filePath)
await access(filePath)
const { fileName: _fileName, path, fileSize, md5 } = await ctx.ntFileApi.uploadFile(filePath, ElementType.Video)
ctx.logger.info('复制视频到QQ目录完成', path)
if (fileSize === 0) {
throw '文件异常大小为0'
throw new Error('文件异常,大小为 0')
}
const maxMB = 100;
const maxMB = 100
if (fileSize > 1024 * 1024 * maxMB) {
throw `视频过大,最大支持${maxMB}MB当前文件大小${fileSize}B`
throw new Error(`视频过大,最大支持${maxMB}MB当前文件大小${fileSize}B`)
}
let thumbDir = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`)
thumbDir = pathLib.dirname(thumbDir)
// log("thumb 目录", thumb)
const thumbDir = pathLib.dirname(path.replaceAll('\\', '/').replace(`/Ori/`, `/Thumb/`))
let videoInfo = {
width: 1920,
height: 1080,
@@ -194,7 +186,6 @@ export namespace SendElement {
const _thumbPath = await createThumb
ctx.logger.info('生成视频缩略图', _thumbPath)
const thumbSize = (await stat(_thumbPath)).size
// log("生成缩略图", _thumbPath)
thumbPath.set(0, _thumbPath)
const thumbMd5 = await calculateFileMD5(_thumbPath)
const element: SendVideoElement = {
@@ -219,13 +210,9 @@ export namespace SendElement {
export async function ptt(ctx: Context, pttPath: string): Promise<SendPttElement> {
const { converted, path: silkPath, duration } = await encodeSilk(ctx, pttPath)
if (!silkPath) {
throw '语音转换失败, 请检查语音文件是否正常'
}
// log("生成语音", silkPath, duration);
const { md5, fileName, path, fileSize } = await ctx.ntFileApi.uploadFile(silkPath, ElementType.Ptt)
if (fileSize === 0) {
throw '文件异常大小为0'
throw new Error('文件异常,大小为 0')
}
if (converted) {
unlink(silkPath)

View File

@@ -1,8 +1,7 @@
import type { BrowserWindow } from 'electron'
import { NTClass, NTMethod } from './ntcall'
import { NTMethod } from './ntcall'
import { log } from '@/common/utils'
import { randomUUID } from 'node:crypto'
import { Dict } from 'cosmokit'
import { ipcMain } from 'electron'
export const hookApiCallbacks: Record<string, (res: any) => void> = {}
@@ -28,19 +27,6 @@ export enum ReceiveCmdS {
MEDIA_UPLOAD_COMPLETE = 'nodeIKernelMsgListener/onRichMediaUploadComplete',
}
type NTReturnData = [
{
type: 'request'
eventName: NTClass
callbackId?: string
},
{
cmdName: ReceiveCmdS
cmdType: 'event'
payload: unknown
}[]
]
const logHook = false
const receiveHooks: Array<{
@@ -54,92 +40,64 @@ const callHooks: Array<{
hookFunc: (callParams: unknown[]) => void | Promise<void>
}> = []
export function hookNTQQApiReceive(window: BrowserWindow, onlyLog: boolean) {
window.webContents.send = new Proxy(window.webContents.send, {
apply(target, thisArg, args: [channel: string, ...args: NTReturnData]) {
try {
if (logHook && !args[1]?.eventName?.startsWith('ns-LoggerApi')) {
log('received ntqq api message', args)
}
} catch { }
if (!onlyLog) {
if (args[2] instanceof Array) {
for (const receiveData of args[2]) {
const ntMethodName = receiveData.cmdName
for (const hook of receiveHooks) {
if (hook.method.includes(ntMethodName)) {
Promise.resolve(hook.hookFunc(receiveData.payload))
export function startHook() {
const senderExclude = Symbol()
ipcMain.emit = new Proxy(ipcMain.emit, {
apply(target, thisArg, args: [eventName: string, ...args: any]) {
if (args[2]?.eventName.startsWith('ns-LoggerApi')) {
return target.apply(thisArg, args)
}
if (logHook) {
log('request', args)
}
const event = args[1]
if (event.sender && !event.sender[senderExclude]) {
event.sender[senderExclude] = true
event.sender.send = new Proxy(event.sender.send, {
apply(target, thisArg, args: any[]) {
if (args[1].eventName?.startsWith('ns-LoggerApi')) {
return target.apply(thisArg, args)
}
if (logHook) {
log('received', args)
}
const callbackId = args[1].callbackId
if (callbackId) {
if (hookApiCallbacks[callbackId]) {
Promise.resolve(hookApiCallbacks[callbackId](args[2]))
delete hookApiCallbacks[callbackId]
}
} else if (args[2]) {
for (const receiveData of args[2]) {
for (const hook of receiveHooks) {
if (hook.method.includes(receiveData.cmdName)) {
Promise.resolve(hook.hookFunc(receiveData.payload))
}
}
}
}
return target.apply(thisArg, args)
}
}
if (args[1]?.callbackId) {
const callbackId = args[1].callbackId
if (hookApiCallbacks[callbackId]) {
Promise.resolve(hookApiCallbacks[callbackId](args[2]))
delete hookApiCallbacks[callbackId]
})
}
if (args[3]?.length) {
const method = args[3][0]
const callParams = args[3].slice(1)
for (const hook of callHooks) {
if (hook.method.includes(method)) {
Promise.resolve(hook.hookFunc(callParams))
}
}
}
return target.apply(thisArg, args)
},
}
})
}
export function hookNTQQApiCall(window: BrowserWindow, onlyLog: boolean) {
const webContents = window.webContents as Dict
const ipc_message_proxy = webContents._events['-ipc-message']?.[0] || webContents._events['-ipc-message']
const proxyIpcMsg = new Proxy(ipc_message_proxy, {
apply(target, thisArg, args) {
const isLogger = args[3]?.[0]?.eventName?.startsWith('ns-LoggerApi')
if (!isLogger) {
try {
logHook && log('call NTQQ api', args)
} catch (e) { }
if (!onlyLog) {
try {
const _args: unknown[] = args[3][1]
const cmdName = _args[0] as NTMethod
const callParams = _args.slice(1)
callHooks.forEach((hook) => {
if (hook.method.includes(cmdName)) {
Promise.resolve(hook.hookFunc(callParams))
}
})
} catch { }
}
}
return target.apply(thisArg, args)
},
})
if (webContents._events['-ipc-message']?.[0]) {
webContents._events['-ipc-message'][0] = proxyIpcMsg
} else {
webContents._events['-ipc-message'] = proxyIpcMsg
}
/*const ipc_invoke_proxy = webContents._events['-ipc-invoke']?.[0] || webContents._events['-ipc-invoke']
const proxyIpcInvoke = new Proxy(ipc_invoke_proxy, {
apply(target, thisArg, args) {
//HOOK_LOG && log('call NTQQ invoke api', thisArg, args)
args[0]['_replyChannel']['sendReply'] = new Proxy(args[0]['_replyChannel']['sendReply'], {
apply(sendtarget, sendthisArg, sendargs) {
sendtarget.apply(sendthisArg, sendargs)
},
})
const ret = target.apply(thisArg, args)
//HOOK_LOG && log('call NTQQ invoke api return', ret)
return ret
},
})
if (webContents._events['-ipc-invoke']?.[0]) {
webContents._events['-ipc-invoke'][0] = proxyIpcInvoke
} else {
webContents._events['-ipc-invoke'] = proxyIpcInvoke
}*/
}
export function registerReceiveHook<PayloadType>(
method: string | string[],
hookFunc: (payload: PayloadType) => void,

Binary file not shown.

View File

@@ -0,0 +1,55 @@
import { Context } from 'cordis'
import { Dict } from 'cosmokit'
import { getBuildVersion } from '@/common/utils/misc'
// @ts-expect-error: Unreachable code error
import addon from './external/crychic-win32-x64.node?asset'
export class Native {
private crychic?: Dict
constructor(private ctx: Context) {
ctx.on('ready', () => {
this.start()
})
}
checkPlatform() {
return process.platform === 'win32' && process.arch === 'x64'
}
checkVersion() {
const version = getBuildVersion()
// 27187—27597
return version >= 27187 && version < 28060
}
start() {
if (this.crychic) {
return
}
if (!this.checkPlatform()) {
return
}
if (!this.checkVersion()) {
return
}
try {
this.crychic = require(addon)
this.crychic.init()
} catch (e) {
this.ctx.logger.warn('crychic 加载失败', e)
}
}
async sendFriendPoke(uin: number) {
if (!this.crychic) return
this.crychic.sendFriendPoke(uin)
await this.ctx.ntMsgApi.fetchUnitedCommendConfig(['100243'])
}
async sendGroupPoke(groupCode: number, memberUin: number) {
if (!this.crychic) return
this.crychic.sendGroupPoke(memberUin, groupCode)
await this.ctx.ntMsgApi.fetchUnitedCommendConfig(['100243'])
}
}

View File

@@ -14,7 +14,8 @@ import {
NodeIKernelRichMediaService,
NodeIKernelTicketService,
NodeIKernelTipOffService,
NodeIKernelRobotService
NodeIKernelRobotService,
NodeIKernelNodeMiscService
} from './services'
export enum NTClass {
@@ -94,6 +95,7 @@ interface NTService {
nodeIKernelTicketService: NodeIKernelTicketService
nodeIKernelTipOffService: NodeIKernelTipOffService
nodeIKernelRobotService: NodeIKernelRobotService
nodeIKernelNodeMiscService: NodeIKernelNodeMiscService
}
interface InvokeOptions<ReturnType> {
@@ -123,8 +125,8 @@ export function invoke<
const apiArgs = [method, ...args]
const callbackId = randomUUID()
const timeoutId = setTimeout(() => {
log(`ntqq api timeout ${channel}, ${eventName}, ${method}`, apiArgs)
reject(`ntqq api timeout ${channel}, ${eventName}, ${method}, ${apiArgs}`)
log(`ntqq api timeout ${channel}, ${eventName}, ${method}`, args)
reject(`ntqq api timeout ${channel}, ${eventName}, ${method}, ${JSON.stringify(args)}`)
}, timeout)
if (!options.cbCmd) {

View File

@@ -125,4 +125,6 @@ export interface NodeIKernelGroupService {
removeGroupEssence(param: { groupCode: string, msgRandom: number, msgSeq: number }): Promise<unknown>
setHeader(args: unknown[]): Promise<GeneralCallResult>
searchMember(sceneId: string, keyword: string): Promise<void>
}

View File

@@ -95,4 +95,6 @@ export interface NodeIKernelMsgService {
getMultiMsg(...args: unknown[]): Promise<GeneralCallResult & { msgList: RawMessage[] }>
getTempChatInfo(chatType: number, uid: string): Promise<TmpChatInfoApi>
sendSsoCmdReqByContend(ssoCmd: string, content: string): Promise<GeneralCallResult & { rsp: string }>
}

View File

@@ -0,0 +1,15 @@
export interface NodeIKernelNodeMiscService {
wantWinScreenOCR(...args: unknown[]): Promise<{
code: number
errMsg: string
result: {
text: string
[key: `pt${number}`]: {
x: string
y: string
}
charBox: unknown[]
score: ''
}[]
}>
}

View File

@@ -10,3 +10,4 @@ export * from './NodeIKernelRichMediaService'
export * from './NodeIKernelTicketService'
export * from './NodeIKernelTipOffService'
export * from './NodeIKernelRobotService'
export * from './NodeIKernelNodeMiscService'

View File

@@ -1,5 +1,3 @@
import { QQLevel, Sex } from './user'
export enum GroupListUpdateType {
REFRESHALL,
GETALL,
@@ -35,36 +33,54 @@ export interface Group {
memberUin: string
memberUid: string
}
members: GroupMember[] // 原始数据是没有这个的,为了方便自己加了这个字段
createTime: string
}
export enum GroupMemberRole {
normal = 2,
admin = 3,
owner = 4,
Normal = 2,
Admin = 3,
Owner = 4,
}
export interface GroupMember {
memberSpecialTitle?: string
avatarPath: string
cardName: string
cardType: number
isDelete: boolean
nick: string
uid: string
qid: string
uin: string
nick: string
remark: string
role: GroupMemberRole // 群主:4, 管理员:3群员:2
shutUpTime: number // 禁言时间,单位是什么暂时不清楚
uid: string // 加密的字符串
uin: string // QQ号
cardType: number
cardName: string
role: GroupMemberRole
avatarPath: string
shutUpTime: number
isDelete: boolean
isSpecialConcerned: boolean
isSpecialShield: boolean
isRobot: boolean
sex?: Sex
qqLevel?: QQLevel
isChangeRole: boolean
groupHonor: Uint8Array
memberRealLevel: number
memberLevel: number
globalGroupLevel: number
globalGroupPoint: number
memberTitleId: number
memberSpecialTitle: string
specialTitleExpireTime: string
userShowFlag: number
userShowFlagNew: number
richFlag: number
mssVipType: number
bigClubLevel: number
bigClubFlag: number
autoRemark: string
creditLevel: number
joinTime: number
lastSpeakTime: number
memberLevel: number
memberFlag: number
memberFlagExt: number
memberMobileFlag: number
memberFlagExt2: number
isSpecialShielded: boolean
cardNameId: number
}
export interface PublishGroupBulletinReq {

View File

@@ -67,6 +67,7 @@ export interface User {
recommendImgFlag?: number
disableEmojiShortCuts?: number
pendantId?: string
age?: number
}
export interface SelfInfo extends User {

View File

@@ -26,7 +26,7 @@ export class GetStrangerInfo extends BaseAction<Payload, OB11User> {
...extendData,
user_id: parseInt(extendData.info.uin) || 0,
nickname: extendData.info.nick,
sex: OB11UserSex.unknown,
sex: OB11UserSex.Unknown,
age: (extendData.info.birthday_year == 0) ? 0 : new Date().getFullYear() - extendData.info.birthday_year,
qid: extendData.info.qid,
level: extendData.info.qqLevel && calcQQLevel(extendData.info.qqLevel) || 0,
@@ -46,7 +46,7 @@ export class GetStrangerInfo extends BaseAction<Payload, OB11User> {
...extendData,
user_id: parseInt(extendData.detail.uin) || 0,
nickname: extendData.detail.simpleInfo.coreInfo.nick,
sex: OB11UserSex.unknown,
sex: OB11UserSex.Unknown,
age: 0,
level: extendData.detail.commonExt.qqLevel && calcQQLevel(extendData.detail.commonExt.qqLevel) || 0,
login_days: 0,

View File

@@ -0,0 +1,63 @@
import { BaseAction, Schema } from '../BaseAction'
import { ActionName } from '../types'
import { uri2local } from '@/common/utils/file'
import { access, unlink } from 'node:fs/promises'
interface Payload {
image: string
}
interface TextDetection {
text: string
confidence: number
coordinates: {
x: number //int32
y: number
}[]
}
interface Response {
texts: TextDetection[]
language: string
}
export class OCRImage extends BaseAction<Payload, Response> {
actionName = ActionName.GoCQHTTP_OCRImage
payloadSchema = Schema.object({
image: Schema.string().required()
})
protected async _handle(payload: Payload) {
const { errMsg, isLocal, path, success } = await uri2local(this.ctx, payload.image, true)
if (!success) {
throw new Error(errMsg)
}
await access(path)
const data = await this.ctx.ntFileApi.ocrImage(path)
if (!isLocal) {
unlink(path)
}
const texts = data.result.map(item => {
const ret: TextDetection = {
text: item.text,
confidence: 1,
coordinates: []
}
for (let i = 0; i < 4; i++) {
const pt = item[`pt${i + 1}`]
ret.coordinates.push({
x: parseInt(pt.x),
y: parseInt(pt.y)
})
}
return ret
})
return {
texts,
language: ''
}
}
}

View File

@@ -2,7 +2,7 @@ import { BaseAction, Schema } from '../BaseAction'
import { OB11GroupMember } from '../../types'
import { OB11Entities } from '../../entities'
import { ActionName } from '../types'
import { isNullable } from 'cosmokit'
import { calcQQLevel } from '@/common/utils/misc'
interface Payload {
group_id: number | string
@@ -22,14 +22,14 @@ class GetGroupMemberInfo extends BaseAction<Payload, OB11GroupMember> {
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)
Object.assign(member, info)
}
const ret = OB11Entities.groupMember(groupCode, member)
const date = Math.round(Date.now() / 1000)
ret.last_sent_time ??= date
ret.join_time ??= date
const info = await this.ctx.ntUserApi.getUserDetailInfo(member.uid)
ret.sex = OB11Entities.sex(info.sex!)
ret.qq_level = (info.qqLevel && calcQQLevel(info.qqLevel)) || 0
ret.age = info.age ?? 0
return ret
}
throw new Error(`群成员${payload.user_id}不存在`)

View File

@@ -25,7 +25,7 @@ export default class SetGroupAdmin extends BaseAction<Payload, null> {
await this.ctx.ntGroupApi.setMemberRole(
groupCode,
uid,
payload.enable ? GroupMemberRole.admin : GroupMemberRole.normal
payload.enable ? GroupMemberRole.Admin : GroupMemberRole.Normal
)
return null
}

View File

@@ -73,6 +73,9 @@ import { GetGroupFileUrl } from './go-cqhttp/GetGroupFileUrl'
import { GetGroupNotice } from './go-cqhttp/GetGroupNotice'
import { GetRobotUinRange } from './llonebot/GetRobotUinRange'
import { DeleteFriend } from './go-cqhttp/DeleteFriend'
import { OCRImage } from './go-cqhttp/OCRImage'
import { GroupPoke } from './llonebot/GroupPoke'
import { FriendPoke } from './llonebot/FriendPoke'
export function initActionMap(adapter: Adapter) {
const actionHandlers = [
@@ -91,6 +94,8 @@ export function initActionMap(adapter: Adapter) {
new FetchCustomFace(adapter),
new SetMsgEmojiLike(adapter),
new GetRobotUinRange(adapter),
new GroupPoke(adapter),
new FriendPoke(adapter),
// onebot11
new SendLike(adapter),
new GetMsg(adapter),
@@ -151,6 +156,7 @@ export function initActionMap(adapter: Adapter) {
new GetGroupFileUrl(adapter),
new GetGroupNotice(adapter),
new DeleteFriend(adapter),
new OCRImage(adapter),
]
const actionMap = new Map<string, BaseAction<any, unknown>>()
for (const action of actionHandlers) {

View File

@@ -0,0 +1,25 @@
import { BaseAction, Schema } from '../BaseAction'
import { ActionName } from '../types'
import { getBuildVersion } from '@/common/utils/misc'
interface Payload {
user_id: number | string
}
export class FriendPoke extends BaseAction<Payload, null> {
actionName = ActionName.FriendPoke
payloadSchema = Schema.object({
user_id: Schema.union([Number, String]).required()
})
async _handle(payload: Payload) {
if (!this.ctx.app.native.checkPlatform()) {
throw new Error('当前系统平台或架构不支持')
}
if (!this.ctx.app.native.checkVersion()) {
throw new Error(`当前 QQ 版本 ${getBuildVersion()} 不支持,可尝试其他版本 27187—27597`)
}
await this.ctx.app.native.sendFriendPoke(+payload.user_id)
return null
}
}

View File

@@ -0,0 +1,27 @@
import { BaseAction, Schema } from '../BaseAction'
import { ActionName } from '../types'
import { getBuildVersion } from '@/common/utils/misc'
interface Payload {
group_id: number | string
user_id: number | string
}
export class GroupPoke extends BaseAction<Payload, null> {
actionName = ActionName.GroupPoke
payloadSchema = Schema.object({
group_id: Schema.union([Number, String]).required(),
user_id: Schema.union([Number, String]).required()
})
async _handle(payload: Payload) {
if (!this.ctx.app.native.checkPlatform()) {
throw new Error('当前系统平台或架构不支持')
}
if (!this.ctx.app.native.checkVersion()) {
throw new Error(`当前 QQ 版本 ${getBuildVersion()} 不支持,可尝试其他版本 27187—27597`)
}
await this.ctx.app.native.sendGroupPoke(+payload.group_id, +payload.user_id)
return null
}
}

View File

@@ -27,6 +27,8 @@ export enum ActionName {
SendForwardMsg = 'send_forward_msg',
SetMsgEmojiLike = 'set_msg_emoji_like',
GetRobotUinRange = 'get_robot_uin_range',
GroupPoke = 'group_poke',
FriendPoke = 'friend_poke',
// onebot 11
SendLike = 'send_like',
GetLoginInfo = 'get_login_info',
@@ -86,4 +88,5 @@ export enum ActionName {
GoCQHTTP_GetGroupFileUrl = 'get_group_file_url',
GoCQHTTP_GetGroupNotice = '_get_group_notice',
GoCQHTTP_DeleteFriend = 'delete_friend',
GoCQHTTP_OCRImage = 'ocr_image',
}

View File

@@ -33,12 +33,13 @@ declare module 'cordis' {
class OneBot11Adapter extends Service {
static inject = [
'ntMsgApi', 'ntFileApi', 'ntFileCacheApi', 'ntFriendApi',
'ntGroupApi', 'ntUserApi', 'ntWindowApi', 'ntWebApi', 'store'
'ntGroupApi', 'ntUserApi', 'ntWindowApi', 'ntWebApi',
'store', 'app'
]
private ob11WebSocket: OB11WebSocket
private ob11WebSocketReverseManager: OB11WebSocketReverseManager
private ob11Http: OB11Http
private ob11HttpPost: OB11HttpPost
private ob11WebSocket
private ob11WebSocketReverseManager
private ob11Http
private ob11HttpPost
constructor(public ctx: Context, public config: OneBot11Adapter.Config) {
super(ctx, 'onebot', true)
@@ -173,26 +174,23 @@ class OneBot11Adapter extends Service {
return
}
const isSelfMsg = msg.user_id.toString() === selfInfo.uin
if (isSelfMsg && !this.config.reportSelfMessage) {
return
}
if (isSelfMsg) {
msg.target_id = parseInt(message.peerUin)
}
this.dispatch(msg)
}).catch(e => this.ctx.logger.error('constructMessage error: ', e.stack.toString()))
}).catch(e => this.ctx.logger.error('handling incoming messages', e))
OB11Entities.groupEvent(this.ctx, message).then(groupEvent => {
if (groupEvent) {
this.dispatch(groupEvent)
}
})
}).catch(e => this.ctx.logger.error('handling incoming group events', e))
OB11Entities.privateEvent(this.ctx, message).then(privateEvent => {
if (privateEvent) {
this.dispatch(privateEvent)
}
})
}).catch(e => this.ctx.logger.error('handling incoming buddy events', e))
}
private handleRecallMsg(message: RawMessage) {
@@ -310,7 +308,6 @@ class OneBot11Adapter extends Service {
heartInterval: config.heartInterval,
token: config.token,
debug: config.debug,
reportSelfMessage: config.reportSelfMessage,
msgCacheExpire: config.msgCacheExpire,
musicSignUrl: config.musicSignUrl,
enableLocalFile2Url: config.enableLocalFile2Url,
@@ -341,6 +338,9 @@ class OneBot11Adapter extends Service {
this.handleRecallMsg(input)
})
this.ctx.on('nt/message-sent', input => {
if (!this.config.reportSelfMessage) {
return
}
this.handleMsg(input)
})
this.ctx.on('nt/group-notify', input => {
@@ -370,7 +370,6 @@ namespace OneBot11Adapter {
heartInterval: number
token: string
debug: boolean
reportSelfMessage: boolean
musicSignUrl?: string
enableLocalFile2Url: boolean
ffmpeg?: string

View File

@@ -647,21 +647,21 @@ export namespace OB11Entities {
return raw.map(friendV2)
}
export function groupMemberRole(role: number): OB11GroupMemberRole | undefined {
export function groupMemberRole(role: number): OB11GroupMemberRole {
return {
4: OB11GroupMemberRole.owner,
3: OB11GroupMemberRole.admin,
2: OB11GroupMemberRole.member,
}[role]
4: OB11GroupMemberRole.Owner,
3: OB11GroupMemberRole.Admin,
2: OB11GroupMemberRole.Member,
}[role] ?? OB11GroupMemberRole.Member
}
export function sex(sex: Sex): OB11UserSex {
const sexMap = {
[Sex.male]: OB11UserSex.male,
[Sex.female]: OB11UserSex.female,
[Sex.unknown]: OB11UserSex.unknown,
[Sex.male]: OB11UserSex.Male,
[Sex.female]: OB11UserSex.Female,
[Sex.unknown]: OB11UserSex.Unknown,
}
return sexMap[sex] || OB11UserSex.unknown
return sexMap[sex] || OB11UserSex.Unknown
}
export function groupMember(group_id: string, member: GroupMember): OB11GroupMember {
@@ -670,20 +670,20 @@ export namespace OB11Entities {
user_id: parseInt(member.uin),
nickname: member.nick,
card: member.cardName,
sex: sex(member.sex!),
sex: OB11UserSex.Unknown,
age: 0,
area: '',
level: String(member.memberLevel ?? 0),
qq_level: (member.qqLevel && calcQQLevel(member.qqLevel)) || 0,
qq_level: 0,
join_time: member.joinTime,
last_sent_time: member.lastSpeakTime,
title_expire_time: 0,
title_expire_time: +member.specialTitleExpireTime,
unfriendly: false,
card_changeable: true,
is_robot: member.isRobot,
shut_up_timestamp: member.shutUpTime,
role: groupMemberRole(member.role),
title: member.memberSpecialTitle || '',
title: member.memberSpecialTitle,
}
}

View File

@@ -57,7 +57,7 @@ export async function createSendElements(
.RemainAtAllCountForUin
ctx.logger.info(`${groupCode}剩余at全体次数`, remainAtAllCount)
const self = await ctx.ntGroupApi.getGroupMember(groupCode, selfInfo.uid)
isAdmin = self?.role === GroupMemberRole.admin || self?.role === GroupMemberRole.owner
isAdmin = self?.role === GroupMemberRole.Admin || self?.role === GroupMemberRole.Owner
} catch (e) {
}
}
@@ -66,9 +66,11 @@ export async function createSendElements(
}
}
else if (peer.chatType === ChatType.Group) {
const uid = await ctx.ntUserApi.getUidByUin(atQQ) ?? ''
const atNmae = sendMsg.data?.name
const display = atNmae ? `@${atNmae}` : ''
const uid = await ctx.ntUserApi.getUidByUin(atQQ, peer.peerUid) ?? ''
let display = ''
if (sendMsg.data.name) {
display = `@${sendMsg.data.name}`
}
sendElements.push(SendElement.at(atQQ, uid, AtType.One, display))
}
}

View File

@@ -16,36 +16,36 @@ export interface OB11User {
}
export enum OB11UserSex {
male = 'male',
female = 'female',
unknown = 'unknown',
Male = 'male',
Female = 'female',
Unknown = 'unknown',
}
export enum OB11GroupMemberRole {
owner = 'owner',
admin = 'admin',
member = 'member',
Owner = 'owner',
Admin = 'admin',
Member = 'member',
}
export interface OB11GroupMember {
group_id: number
user_id: number
nickname: string
card?: string
sex?: OB11UserSex
age?: number
join_time?: number
last_sent_time?: number
level?: string
card: string
sex: OB11UserSex
age: number
join_time: number
last_sent_time: number
level: string
qq_level?: number
role?: OB11GroupMemberRole
title?: string
area?: string
unfriendly?: boolean
title_expire_time?: number
card_changeable?: boolean
role: OB11GroupMemberRole
title: string
area: string
unfriendly: boolean
title_expire_time: number
card_changeable: boolean
// 以下为gocq字段
shut_up_timestamp?: number
shut_up_timestamp: number
// 以下为扩展字段
is_robot?: boolean
qage?: number

View File

@@ -171,7 +171,7 @@ async function onSettingWindowCreated(view: Element) {
SettingItem(
'上报 Bot 自身发送的消息',
'上报 event 为 message_sent',
SettingSwitch('reportSelfMessage', config.reportSelfMessage),
SettingSwitch('ob11.reportSelfMessage', config.ob11.reportSelfMessage),
),
SettingItem(
'使用 Base64 编码获取文件',

View File

@@ -175,10 +175,14 @@ export class MessageEncoder {
if (type === 'text') {
this.elements.push(SendElement.text(attrs.content))
} else if (type === 'at') {
this.peer ??= await getPeer(this.ctx, this.channelId)
if (this.peer.chatType !== NT.ChatType.Group) {
return
}
if (attrs.type === 'all') {
this.elements.push(SendElement.at('', '', NT.AtType.All, '@全体成员'))
} else {
const uid = await this.ctx.ntUserApi.getUidByUin(attrs.id) ?? ''
const uid = await this.ctx.ntUserApi.getUidByUin(attrs.id, this.peer.peerUid) ?? ''
const display = attrs.name ? '@' + attrs.name : ''
this.elements.push(SendElement.at(attrs.id, uid, NT.AtType.One, display))
}

View File

@@ -56,7 +56,7 @@ export class SatoriServer {
const { listen, port } = this.config
this.httpServer = this.express.listen(port, listen, () => {
this.ctx.logger.info(`HTTP server started ${listen}:${port}`)
this.ctx.logger.info(`server started ${listen}:${port}`)
})
this.wsServer = new WebSocketServer({
server: this.httpServer
@@ -65,7 +65,7 @@ export class SatoriServer {
this.wsServer.on('connection', (socket, req) => {
const url = req.url?.split('?').shift()
if (!['/v1/events', '/v1/events/'].includes(url!)) {
return socket.close()
return socket.close(1008, 'invalid address')
}
socket.addEventListener('message', async (event) => {

View File

@@ -155,20 +155,15 @@ export async function decodeMessage(
}
message.user = decodeMessageUser(data)
message.created_at = +data.msgTime * 1000
if (message.channel.type === Universal.Channel.Type.DIRECT) {
if (!message.user.name) {
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 (message.channel.type === Universal.Channel.Type.DIRECT) {
message.channel.name = info.nick
}
}
if (guildId) {
let nick = data.sendMemberName || data.sendNickName
if (!data.sendNickName) {
const info = await ctx.ntGroupApi.getGroupMember(guildId, data.senderUid)
message.user.name = info.nick
message.user.nick = info.remark || info.nick
nick = info.cardName || info.nick
}
message.guild = {
id: guildId,
name: data.peerName,
@@ -176,7 +171,7 @@ export async function decodeMessage(
}
message.member = {
user: message.user,
nick
nick: data.sendMemberName || message.user.name
}
}

View File

@@ -1 +1 @@
export const version = '4.0.1'
export const version = '4.0.7'