Compare commits

...

18 Commits

Author SHA1 Message Date
linyuchen
1dd468e2ff fix: #290 2024-07-13 16:25:00 +08:00
linyuchen
2a1aa8c649 feat: image subType 2024-07-13 14:26:23 +08:00
linyuchen
1633734e08 Merge branch 'dev' 2024-07-13 14:09:45 +08:00
linyuchen
dff92e6f27 chore: version 3.27.0
feat: support poke
feat: LLOneBot global switch
2024-07-13 14:09:03 +08:00
linyuchen
dba5e30d5d doc: plugin description 2024-07-10 13:48:05 +08:00
linyuchen
2d04ab2e72 fix: crychic crash 2024-07-10 13:47:44 +08:00
linyuchen
1a015ac8d3 Merge pull request #262 from LLOneBot/dev
get_record 支持 out_format 进行转码,和其他小修复
2024-06-21 17:39:53 +08:00
linyuchen
6390620ddd chore: version 3.26.7 2024-06-21 17:33:48 +08:00
linyuchen
0d19005dc3 refactor: remove duplicate import 2024-06-21 17:28:17 +08:00
linyuchen
c6479dd2c4 Merge remote-tracking branch 'origin/dev' into dev 2024-06-21 16:21:15 +08:00
linyuchen
8871331b7c 🐛 fix: ws echo #261 2024-06-21 16:20:59 +08:00
linyuchen
e01148b86a 🐛 fix: ws echo 2024-06-21 16:20:26 +08:00
linyuchen
2f87e3818e Merge pull request #260 from idranme/main
perf: audio
2024-06-21 10:36:29 +08:00
linyuchen
2c8a594c38 Merge branch 'dev' into main 2024-06-21 10:36:14 +08:00
idranme
1508dab7fe perf: audio 2024-06-18 19:15:56 +00:00
linyuchen
958b21e47e fix: wait get_file download complete 2024-06-17 17:41:23 +08:00
linyuchen
781c3311ae fix: get_file cache not found 2024-06-17 16:20:37 +08:00
linyuchen
52850d172e feat: decode silk 2024-06-17 16:05:38 +08:00
15 changed files with 123 additions and 39 deletions

View File

@@ -1,10 +1,10 @@
{
"manifest_version": 4,
"type": "extension",
"name": "LLOneBot v3.26.6",
"name": "LLOneBot v3.27.1",
"slug": "LLOneBot",
"description": "使你的NTQQ支持OneBot11协议进行QQ机器人开发, 不支持商店在线更新",
"version": "3.26.6",
"description": "使你的NTQQ支持OneBot11协议进行QQ机器人开发",
"version": "3.27.1",
"icon": "./icon.jpg",
"authors": [
{

View File

@@ -23,7 +23,7 @@
"file-type": "^19.0.0",
"fluent-ffmpeg": "^2.1.2",
"level": "^8.0.1",
"silk-wasm": "^3.3.4",
"silk-wasm": "^3.6.0",
"utf-8-validate": "^6.0.3",
"uuid": "^9.0.1",
"ws": "^8.16.0"

View File

@@ -43,6 +43,7 @@ export class ConfigUtil {
enableQOAutoQuote: false
}
let defaultConfig: Config = {
enableLLOB: true,
ob11: ob11Default,
heartInterval: 60000,
token: '',

View File

@@ -17,6 +17,7 @@ export interface CheckVersion {
version: string
}
export interface Config {
enableLLOB: boolean
ob11: OB11Config
token?: string
heartInterval?: number // ms

View File

@@ -1,9 +1,9 @@
import fs from 'fs'
import { encode, getDuration, getWavFileInfo, isWav } from 'silk-wasm'
import fsPromise from 'fs/promises'
import { decode, encode, getDuration, getWavFileInfo, isWav, isSilk } from 'silk-wasm'
import { log } from './log'
import path from 'node:path'
import { DATA_DIR, TEMP_DIR } from './index'
import { TEMP_DIR } from './index'
import { v4 as uuidv4 } from 'uuid'
import { getConfigUtil } from '../config'
import { spawn } from 'node:child_process'
@@ -60,10 +60,11 @@ export async function encodeSilk(filePath: string) {
// }
try {
const file = await fsPromise.readFile(filePath)
const pttPath = path.join(TEMP_DIR, uuidv4())
if (getFileHeader(filePath) !== '02232153494c4b') {
if (!isSilk(file)) {
log(`语音文件${filePath}需要转换成silk`)
const _isWav = await isWavFile(filePath)
const _isWav = isWav(file)
const pcmPath = pttPath + '.pcm'
let sampleRate = 0
const convert = () => {
@@ -79,7 +80,8 @@ export async function encodeSilk(filePath: string) {
if (code == null || EXIT_CODES.includes(code)) {
sampleRate = 24000
const data = fs.readFileSync(pcmPath)
fs.unlink(pcmPath, (err) => {})
fs.unlink(pcmPath, (err) => {
})
return resolve(data)
}
log(`FFmpeg exit: code=${code ?? 'unknown'} sig=${signal ?? 'unknown'}`)
@@ -91,7 +93,7 @@ export async function encodeSilk(filePath: string) {
if (!_isWav) {
input = await convert()
} else {
input = fs.readFileSync(filePath)
input = file
const allowSampleRate = [8000, 12000, 16000, 24000, 32000, 44100, 48000]
const { fmt } = getWavFileInfo(input)
// log(`wav文件信息`, fmt)
@@ -108,7 +110,7 @@ export async function encodeSilk(filePath: string) {
duration: silk.duration / 1000,
}
} else {
const silk = fs.readFileSync(filePath)
const silk = file
let duration = 0
try {
duration = getDuration(silk) / 1000
@@ -128,3 +130,41 @@ export async function encodeSilk(filePath: string) {
return {}
}
}
export async function decodeSilk(inputFilePath: string, outFormat: 'mp3' | 'amr' | 'wma' | 'm4a' | 'spx' | 'ogg' | 'wav' | 'flac' = 'mp3') {
const silkArrayBuffer = await fsPromise.readFile(inputFilePath)
const data = (await decode(silkArrayBuffer, 24000)).data
const fileName = path.join(TEMP_DIR, path.basename(inputFilePath))
const outPCMPath = fileName + '.pcm'
const outFilePath = fileName + '.' + outFormat
await fsPromise.writeFile(outPCMPath, data)
const convert = () => {
return new Promise<string>((resolve, reject) => {
const ffmpegPath = getConfigUtil().getConfig().ffmpeg || process.env.FFMPEG_PATH || 'ffmpeg'
const cp = spawn(ffmpegPath, [
'-y',
'-f', 's16le', // PCM format
'-ar', '24000', // Sample rate
'-ac', '1', // Number of audio channels
'-i', outPCMPath,
outFilePath,
])
cp.on('error', (err) => {
log(`FFmpeg处理转换出错: `, err.message)
return reject(err)
})
cp.on('exit', (code, signal) => {
const EXIT_CODES = [0, 255]
if (code == null || EXIT_CODES.includes(code)) {
fs.unlink(outPCMPath, (err) => {
})
return resolve(outFilePath)
}
const exitErr = `FFmpeg exit: code=${code ?? 'unknown'} sig=${signal ?? 'unknown'}`
log(exitErr)
reject(Error(`FFmpeg处理转换失败,${exitErr}`))
})
})
}
return convert()
}

View File

@@ -13,7 +13,7 @@ import {
CHANNEL_UPDATE,
} from '../common/channels'
import { ob11WebsocketServer } from '../onebot11/server/ws/WebsocketServer'
import { DATA_DIR } from '../common/utils'
import { DATA_DIR, qqPkgInfo } from '../common/utils'
import {
friendRequests,
getFriend,
@@ -203,18 +203,23 @@ function onLoad() {
async function startReceiveHook() {
startHook().then()
if (getConfigUtil().getConfig().enablePoke) {
crychic.loadNode()
crychic.registerPokeHandler((id, isGroup) => {
log(`收到戳一戳消息了!是否群聊:${isGroup}id:${id}`)
let pokeEvent: OB11FriendPokeEvent | OB11GroupPokeEvent
if (isGroup) {
pokeEvent = new OB11GroupPokeEvent(parseInt(id))
}
else {
pokeEvent = new OB11FriendPokeEvent(parseInt(id))
}
postOb11Event(pokeEvent)
})
if ( qqPkgInfo.buildVersion > '23873'){
log(`当前版本${qqPkgInfo.buildVersion}不支持发送戳一戳模块`)
}
else {
crychic.loadNode()
crychic.registerPokeHandler((id, isGroup) => {
log(`收到戳一戳消息了!是否群聊:${isGroup}id:${id}`)
let pokeEvent: OB11FriendPokeEvent | OB11GroupPokeEvent
if (isGroup) {
pokeEvent = new OB11GroupPokeEvent(parseInt(id))
}
else {
pokeEvent = new OB11FriendPokeEvent(parseInt(id))
}
postOb11Event(pokeEvent)
})
}
}
registerReceiveHook<{
msgList: Array<RawMessage>
@@ -435,6 +440,11 @@ function onLoad() {
async function start() {
log('llonebot pid', process.pid)
const config = getConfigUtil().getConfig()
if (!config.enableLLOB){
log('LLOneBot 开关设置为关闭不启动LLOneBot')
return
}
llonebotError.otherError = ''
startTime = Date.now()
dbUtil.getReceivedTempUinMap().then((m) => {
@@ -469,7 +479,6 @@ function onLoad() {
}
const config = getConfigUtil().getConfig()
if (config.ob11.enableHttp) {
ob11HTTPServer.start(config.ob11.httpPort)
}

View File

@@ -188,6 +188,7 @@ export const IMAGE_HTTP_HOST = 'https://gchat.qpic.cn'
export const IMAGE_HTTP_HOST_NT = 'https://multimedia.nt.qq.com.cn'
export interface PicElement {
picSubType: PicSubType
picType: PicType // 有这玩意儿吗
originImageUrl: string // http url, 没有hosthost是https://gchat.qpic.cn/, 带download参数的是https://multimedia.nt.qq.com.cn
originImageMd5?: string
@@ -226,6 +227,7 @@ export interface GrayTipElement {
content: string
}
jsonGrayTipElement: {
busiId: number
jsonStr: string
}
}

View File

@@ -2,7 +2,7 @@ import BaseAction from '../BaseAction'
import fs from 'fs/promises'
import { dbUtil } from '@/common/db'
import { getConfigUtil } from '@/common/config'
import { log, sleep, uri2local } from '@/common/utils'
import { checkFileReceived, log, sleep, uri2local } from '@/common/utils'
import { NTQQFileApi } from '@/ntqqapi/api'
import { ActionName } from '../types'
import { FileElement, RawMessage, VideoElement } from '@/ntqqapi/types'
@@ -38,20 +38,21 @@ export class GetFileBase extends BaseAction<GetFilePayload, GetFileResponse> {
log('找到了文件 element', element)
// 构建下载函数
await NTQQFileApi.downloadMedia(msg.msgId, msg.chatType, msg.peerUid, cache.elementId, '', '', true)
await sleep(1000) // 这里延时是为何?
// 等待文件下载完成
msg = await dbUtil.getMsgByLongId(cache.msgId)
log('下载完成后的msg', msg)
cache.filePath = this.getElement(msg, cache.elementId).filePath
await checkFileReceived(cache.filePath, 10 * 1000)
dbUtil.addFileCache(file, cache).then()
}
}
}
protected async _handle(payload: GetFilePayload): Promise<GetFileResponse> {
const cache = await dbUtil.getFileCache(payload.file)
const { autoDeleteFile, enableLocalFile2Url, autoDeleteFileSecond } = getConfigUtil().getConfig()
let cache = await dbUtil.getFileCache(payload.file)
if (!cache) {
throw new Error('file not found')
}
const { autoDeleteFile, enableLocalFile2Url, autoDeleteFileSecond } = getConfigUtil().getConfig()
if (cache.downloadFunc) {
await cache.downloadFunc()
}

View File

@@ -1,5 +1,9 @@
import { GetFileBase, GetFilePayload, GetFileResponse } from './GetFile'
import { ActionName } from '../types'
import {decodeSilk} from "@/common/utils/audio";
import { getConfigUtil } from '@/common/config'
import path from 'node:path'
import fs from 'node:fs'
interface Payload extends GetFilePayload {
out_format: 'mp3' | 'amr' | 'wma' | 'm4a' | 'spx' | 'ogg' | 'wav' | 'flac'
@@ -9,7 +13,13 @@ export default class GetRecord extends GetFileBase {
actionName = ActionName.GetRecord
protected async _handle(payload: Payload): Promise<GetFileResponse> {
let res = super._handle(payload)
let res = await super._handle(payload)
res.file = await decodeSilk(res.file, payload.out_format)
res.file_name = path.basename(res.file)
res.file_size = fs.statSync(res.file).size.toString()
if (getConfigUtil().getConfig().enableLocalFile2Url){
res.base64 = fs.readFileSync(res.file, 'base64')
}
return res
}
}

View File

@@ -24,7 +24,7 @@ import {
User,
VideoElement,
} from '../ntqqapi/types'
import { deleteGroup, getFriend, getGroupMember, selfInfo, tempGroupCodeMap } from '../common/data'
import { deleteGroup, getFriend, getGroupMember, selfInfo, tempGroupCodeMap, uidMaps } from '../common/data'
import { EventType } from './event/OB11BaseEvent'
import { encodeCQCode } from './cqcode'
import { dbUtil } from '../common/db'
@@ -47,6 +47,7 @@ import { mFaceCache } from '../ntqqapi/constructor'
import { OB11FriendAddNoticeEvent } from './event/notice/OB11FriendAddNoticeEvent'
import { OB11FriendRecallNoticeEvent } from './event/notice/OB11FriendRecallNoticeEvent'
import { OB11GroupRecallNoticeEvent } from './event/notice/OB11GroupRecallNoticeEvent'
import { OB11GroupPokeEvent } from './event/notice/OB11PokeEvent'
let lastRKeyUpdateTime = 0
@@ -161,10 +162,12 @@ export class OB11Constructor {
// message_data["data"]["file"] = element.picElement.sourcePath
let fileName = element.picElement.fileName
const sourcePath = element.picElement.sourcePath
if (element.picElement.picType === PicType.gif && !fileName.endsWith('.gif')) {
const isGif = element.picElement.picType === PicType.gif
if (isGif && !fileName.endsWith('.gif')) {
fileName += '.gif'
}
message_data['data']['file'] = fileName
message_data['data']['subType'] = element.picElement.picSubType
// message_data["data"]["path"] = element.picElement.sourcePath
// let currentRKey = "CAQSKAB6JWENi5LMk0kc62l8Pm3Jn1dsLZHyRLAnNmHGoZ3y_gDZPqZt-64"
@@ -188,9 +191,7 @@ export class OB11Constructor {
element.picElement.sourcePath,
)
},
})
.then()
// 不在自动下载图片
}).then()
}
else if (element.videoElement || element.fileElement) {
const videoOrFileElement = element.videoElement || element.fileElement
@@ -297,7 +298,7 @@ export class OB11Constructor {
if (message_data.type !== 'unknown' && message_data.data) {
const cqCode = encodeCQCode(message_data)
if (messagePostFormat === 'string') {
;(resMsg.message as string) += cqCode
(resMsg.message as string) += cqCode
}
else (resMsg.message as OB11MessageData[]).push(message_data)
@@ -492,6 +493,16 @@ export class OB11Constructor {
}
* */
if (grayTipElement.jsonGrayTipElement.busiId == 1061) {
//判断业务类型
//Poke事件
let pokedetail: any[] = json.items;
//筛选item带有uid的元素
pokedetail = pokedetail.filter(item => item.uid);
if (pokedetail.length == 2) {
return new OB11GroupPokeEvent(parseInt(msg.peerUid), parseInt((uidMaps[pokedetail[0].uid])!), parseInt((uidMaps[pokedetail[1].uid])));
}
}
const memberUin = json.items[1].param[0]
const title = json.items[3].txt
log('收到群成员新头衔消息', json)

View File

@@ -50,6 +50,7 @@ export function encodeCQCode(data: OB11MessageData) {
}
const CQCodeEscape = (text: string) => {
text = text.toString()
return text.replace(/\&/g, '&amp;').replace(/\[/g, '&#91;').replace(/\]/g, '&#93;').replace(/,/g, '&#44;')
}

View File

@@ -21,10 +21,10 @@ export class OB11FriendPokeEvent extends OB11PokeEvent {
export class OB11GroupPokeEvent extends OB11PokeEvent {
group_id: number
constructor(group_id: number, user_id: number = 0) {
constructor(group_id: number, user_id: number = 0, target_id: number = 0) {
super()
this.group_id = group_id
this.target_id = user_id
this.target_id = target_id
this.user_id = user_id
}
}

View File

@@ -27,6 +27,7 @@ class OB11WebsocketServer extends WebsocketServerBase {
}
try {
let handleResult = await action.websocketHandle(params, echo)
handleResult.echo = echo
wsReply(wsClient, handleResult)
} catch (e) {
wsReply(wsClient, OB11Response.error(`api处理出错:${e.stack}`, 1200, echo))

View File

@@ -62,6 +62,13 @@ async function onSettingWindowCreated(view: Element) {
SettingButton('请稍候', 'llonebot-update-button', 'secondary'),
),
]),
SettingList([
SettingItem(
'是否启用 LLOneBot, 重启QQ后生效',
null,
SettingSwitch('enableLLOB', config.enableLLOB, { 'control-display-id': 'config-enableLLOB' }),
)]
),
SettingList([
SettingItem(
'启用 HTTP 服务',

View File

@@ -1 +1 @@
export const version = '3.26.6'
export const version = '3.27.1'