Compare commits

...

37 Commits

Author SHA1 Message Date
手瓜一十雪
644060ca25 feat: init 2025-02-26 10:45:45 +08:00
手瓜一十雪
02038483d4 fix 2025-02-25 14:47:51 +08:00
手瓜一十雪
417c025dbf Merge branch 'main' into ai-chat 2025-02-25 14:47:42 +08:00
手瓜一十雪
2852996f18 x 2025-02-25 13:52:05 +08:00
手瓜一十雪
1a257e03fc x 2025-02-24 19:42:53 +08:00
手瓜一十雪
7b89e1fcb0 x 2025-02-24 17:36:22 +08:00
手瓜一十雪
c126820d40 x 2025-02-24 17:31:08 +08:00
手瓜一十雪
e316657cd7 x 2025-02-24 17:07:03 +08:00
手瓜一十雪
d8a9413627 x 2025-02-24 16:57:00 +08:00
手瓜一十雪
5c98623f2b x 2025-02-24 16:49:55 +08:00
手瓜一十雪
d6485b220e x 2025-02-24 16:05:36 +08:00
手瓜一十雪
c19c106266 llm 2025-02-24 13:16:12 +08:00
手瓜一十雪
ea3d069e49 feat: vsc build dev体验增强 2025-02-23 17:54:19 +08:00
Mlikiowa
3e6024f183 release: v4.6.0 2025-02-23 09:31:55 +00:00
Mlikiowa
337871693a release: v4.5.24 2025-02-23 09:31:19 +00:00
手瓜一十雪
2d921c4577 feat: sisi的妙妙rkey 2025-02-23 17:30:01 +08:00
手瓜一十雪
9accff7323 fix: ts warning 2025-02-23 17:28:30 +08:00
手瓜一十雪
88b1ee8c31 docs: todo #819 2025-02-23 17:17:52 +08:00
手瓜一十雪
3ac618bb4e fix: #822 2025-02-23 17:01:00 +08:00
手瓜一十雪
0051df3741 fix: #824 2025-02-23 16:57:55 +08:00
手瓜一十雪
7eb4e010b0 Merge pull request #823 from NapNeko/refactor-worker
refactor: 即刻起逐出piscina
2025-02-23 14:31:33 +08:00
手瓜一十雪
33cc23ada3 refactor: 即刻起逐出piscina 2025-02-23 14:29:26 +08:00
手瓜一十雪
e5aee372e3 fix: 调整依赖 2025-02-23 13:40:47 +08:00
手瓜一十雪
6b6ce4a761 fix: 依赖迁移到dev 2025-02-22 12:59:37 +08:00
手瓜一十雪
8c4ea7f8f2 fix: 异常代码 2025-02-22 11:57:48 +08:00
手瓜一十雪
c8b268b806 fix: #791 2025-02-22 11:50:54 +08:00
手瓜一十雪
cf5e0e0f14 Merge branch 'main' of https://github.com/NapNeko/NapCatQQ 2025-02-18 17:08:26 +08:00
手瓜一十雪
7b79f9cc17 fix: 日志显示 2025-02-18 17:08:24 +08:00
Mlikiowa
708d599966 release: v4.5.23 2025-02-18 08:56:25 +00:00
手瓜一十雪
1ecd5b78e6 feat: 文件移除path字段增强部分能力 2025-02-18 16:55:43 +08:00
手瓜一十雪
fca2e3c51a style: remove debug 2025-02-18 16:52:30 +08:00
手瓜一十雪
95ea761b2d feat: get_private_file_url 2025-02-18 16:51:51 +08:00
手瓜一十雪
6b3bfa1ee9 fix #810 2025-02-18 13:24:37 +08:00
手瓜一十雪
cea900ca2a publish 2025-02-17 22:49:20 +08:00
bietiaop
df3e302a9d fix: #802 2025-02-14 21:26:16 +08:00
pk5ls20
c88a68c9a8 fix: typo x2 2025-02-14 20:52:31 +08:00
Mlikiowa
92d01b9cdd release: v4.5.22 2025-02-14 10:36:03 +00:00
36 changed files with 765 additions and 120 deletions

115
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,115 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "dev:shell",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev:shell"
]
},
{
"type": "node",
"request": "launch",
"name": "build:shell",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"build:shell"
]
},
{
"type": "node",
"request": "launch",
"name": "build:universal",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"build:universal"
]
},
{
"type": "node",
"request": "launch",
"name": "build:framework",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"build:framework"
]
},
{
"type": "node",
"request": "launch",
"name": "build:webui",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"build:webui"
]
},
{
"type": "node",
"request": "launch",
"name": "dev:universal",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev:universal"
]
},
{
"type": "node",
"request": "launch",
"name": "dev:framework",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev:framework"
]
},
{
"type": "node",
"request": "launch",
"name": "dev:webui",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev:webui"
]
},
{
"type": "node",
"request": "launch",
"name": "lint",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"lint"
]
},
{
"type": "node",
"request": "launch",
"name": "depend",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"depend"
]
},
{
"type": "node",
"request": "launch",
"name": "dev:depend",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev:depend"
]
}
]
}

View File

@@ -4,7 +4,7 @@
"name": "NapCatQQ",
"slug": "NapCat.Framework",
"description": "高性能的 OneBot 11 协议实现",
"version": "4.5.21",
"version": "4.6.0",
"icon": "./logo.png",
"authors": [
{

View File

@@ -1,19 +1,21 @@
import { PlayMode } from '@/const/enum'
import WebUIManager from '@/controllers/webui_manager'
import type {
FinalMusic,
Music163ListResponse,
Music163URLResponse
} from '@/types/music'
import WebUIManager from '@/controllers/webui_manager'
/**
* 获取网易云音乐歌单
* @param id 歌单id
* @returns 歌单信息
*/
export const get163MusicList = async (id: string) => {
let res = await WebUIManager.proxy<Music163ListResponse>('https://wavesgame.top/playlist/track/all?id=' + id);
let res = await WebUIManager.proxy<Music163ListResponse>(
'https://wavesgame.top/playlist/track/all?id=' + id
)
// const res = await request.get<Music163ListResponse>(
// `https://wavesgame.top/playlist/track/all?id=${id}`
// )
@@ -71,7 +73,7 @@ export const get163MusicListSongs = async (id: string) => {
if (songURL) {
finalMusic.push({
id: song.id,
url: songURL,
url: songURL.replace(/http:\/\//, '//').replace(/https:\/\//, '//'),
title: song.name,
artist: song.ar.map((p) => p.name).join('/'),
cover: song.al.picUrl

View File

@@ -2,7 +2,7 @@
"name": "napcat",
"private": true,
"type": "module",
"version": "4.5.21",
"version": "4.6.0",
"scripts": {
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
@@ -42,7 +42,6 @@
"ajv": "^8.13.0",
"async-mutex": "^0.5.0",
"commander": "^13.0.0",
"cors": "^2.8.5",
"esbuild": "0.25.0",
"eslint": "^9.14.0",
"eslint-import-resolver-typescript": "^3.6.1",
@@ -59,13 +58,15 @@
"vite": "^6.0.1",
"vite-plugin-cp": "^4.0.8",
"vite-tsconfig-paths": "^5.1.0",
"winston": "^3.17.0"
"napcat.protobuf": "^1.1.3",
"winston": "^3.17.0",
"compressing": "^1.10.1"
},
"dependencies": {
"@ffmpeg.wasm/core-mt": "^0.13.2",
"compressing": "^1.10.1",
"cors": "^2.8.5",
"express": "^5.0.0",
"piscina": "^4.7.0",
"openai": "^4.85.4",
"silk-wasm": "^3.6.1",
"ws": "^8.18.0"
}

View File

@@ -1,9 +1,20 @@
import { encode } from 'silk-wasm';
import { parentPort } from 'worker_threads';
export interface EncodeArgs {
input: ArrayBufferView | ArrayBuffer
sampleRate: number
}
export default async ({ input, sampleRate }: EncodeArgs) => {
export function recvTask<T>(cb: (taskData: T) => Promise<unknown>) {
parentPort?.on('message', async (taskData: T) => {
try {
let ret = await cb(taskData);
parentPort?.postMessage(ret);
} catch (error: unknown) {
parentPort?.postMessage({ error: (error as Error).message });
}
});
}
recvTask<EncodeArgs>(async ({ input, sampleRate }) => {
return await encode(input, sampleRate);
};
});

View File

@@ -1,4 +1,3 @@
import Piscina from 'piscina';
import fsPromise from 'fs/promises';
import path from 'node:path';
import { randomUUID } from 'crypto';
@@ -6,16 +5,16 @@ import { EncodeResult, getDuration, getWavFileInfo, isSilk, isWav } from 'silk-w
import { LogWrapper } from '@/common/log';
import { EncodeArgs } from '@/common/audio-worker';
import { FFmpegService } from '@/common/ffmpeg';
import { runTask } from './worker';
import { fileURLToPath } from 'node:url';
const ALLOW_SAMPLE_RATE = [8000, 12000, 16000, 24000, 32000, 44100, 48000];
async function getWorkerPath() {
return new URL(/* @vite-ignore */ './audio-worker.mjs', import.meta.url).href;
function getWorkerPath() {
//return new URL(/* @vite-ignore */ './audio-worker.mjs', import.meta.url).href;
return path.join(path.dirname(fileURLToPath(import.meta.url)), 'audio-worker.mjs');
}
const piscina = new Piscina<EncodeArgs, EncodeResult>({
filename: await getWorkerPath(),
});
async function guessDuration(pttPath: string, logger: LogWrapper) {
const pttFileInfo = await fsPromise.stat(pttPath);
@@ -46,7 +45,7 @@ export async function encodeSilk(filePath: string, TEMP_DIR: string, logger: Log
const { input, sampleRate } = isWav(file)
? await handleWavFile(file, filePath, pcmPath)
: { input: await FFmpegService.convert(filePath, pcmPath), sampleRate: 24000 };
const silk = await piscina.run({ input: input, sampleRate: sampleRate });
const silk = await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { input: input, sampleRate: sampleRate });
fsPromise.unlink(pcmPath).catch((e) => logger.logError('删除临时文件失败', pcmPath, e));
await fsPromise.writeFile(pttPath, Buffer.from(silk.data));
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', silk.duration);

View File

@@ -5,6 +5,17 @@ import { readFileSync, statSync, writeFileSync } from 'fs';
import type { VideoInfo } from './video';
import { fileTypeFromFile } from 'file-type';
import imageSize from 'image-size';
import { parentPort } from 'worker_threads';
export function recvTask<T>(cb: (taskData: T) => Promise<unknown>) {
parentPort?.on('message', async (taskData: T) => {
try {
let ret = await cb(taskData);
parentPort?.postMessage(ret);
} catch (error: unknown) {
parentPort?.postMessage({ error: (error as Error).message });
}
});
}
class FFmpegService {
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
@@ -137,15 +148,18 @@ interface FFmpegTask {
}
export default async function handleFFmpegTask({ method, args }: FFmpegTask): Promise<any> {
switch (method) {
case 'extractThumbnail':
return await FFmpegService.extractThumbnail(...args as [string, string]);
case 'convertFile':
return await FFmpegService.convertFile(...args as [string, string, string]);
case 'convert':
return await FFmpegService.convert(...args as [string, string]);
case 'getVideoInfo':
return await FFmpegService.getVideoInfo(...args as [string, string]);
default:
throw new Error(`Unknown method: ${method}`);
case 'extractThumbnail':
return await FFmpegService.extractThumbnail(...args as [string, string]);
case 'convertFile':
return await FFmpegService.convertFile(...args as [string, string, string]);
case 'convert':
return await FFmpegService.convert(...args as [string, string]);
case 'getVideoInfo':
return await FFmpegService.getVideoInfo(...args as [string, string]);
default:
throw new Error(`Unknown method: ${method}`);
}
}
}
recvTask<FFmpegTask>(async ({ method, args }: FFmpegTask) => {
return await handleFFmpegTask({ method, args });
});

View File

@@ -1,6 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import Piscina from 'piscina';
import { VideoInfo } from './video';
import path from 'path';
import { fileURLToPath } from 'url';
import { runTask } from './worker';
type EncodeArgs = {
method: 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
@@ -9,42 +11,26 @@ type EncodeArgs = {
type EncodeResult = any;
async function getWorkerPath() {
return new URL(/* @vite-ignore */ './ffmpeg-worker.mjs', import.meta.url).href;
function getWorkerPath() {
return path.join(path.dirname(fileURLToPath(import.meta.url)), './ffmpeg-worker.mjs');
}
export class FFmpegService {
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
const piscina = new Piscina<EncodeArgs, EncodeResult>({
filename: await getWorkerPath(),
});
await piscina.run({ method: 'extractThumbnail', args: [videoPath, thumbnailPath] });
await piscina.destroy();
await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'extractThumbnail', args: [videoPath, thumbnailPath] });
}
public static async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
const piscina = new Piscina<EncodeArgs, EncodeResult>({
filename: await getWorkerPath(),
});
await piscina.run({ method: 'convertFile', args: [inputFile, outputFile, format] });
await piscina.destroy();
await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'convertFile', args: [inputFile, outputFile, format] });
}
public static async convert(filePath: string, pcmPath: string): Promise<Buffer> {
const piscina = new Piscina<EncodeArgs, EncodeResult>({
filename: await getWorkerPath(),
});
const result = await piscina.run({ method: 'convert', args: [filePath, pcmPath] });
await piscina.destroy();
const result = await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'convert', args: [filePath, pcmPath] });
return result;
}
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
const piscina = new Piscina<EncodeArgs, EncodeResult>({
filename: await getWorkerPath(),
});
const result = await piscina.run({ method: 'getVideoInfo', args: [videoPath, thumbnailPath] });
await piscina.destroy();
const result = await await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'getVideoInfo', args: [videoPath, thumbnailPath] });
return result;
}
}

View File

@@ -232,7 +232,7 @@ export function rawMessageToText(msg: RawMessage, recursiveLevel = 0): string {
tokens.push(`群聊 [${msg.peerName}(${msg.peerUin})]`);
}
if (msg.senderUin !== '0') {
tokens.push(`[${msg.sendMemberName ?? msg.sendRemarkName ?? msg.sendNickName}(${msg.senderUin})]`);
tokens.push(`[${msg.sendMemberName || msg.sendRemarkName || msg.sendNickName}(${msg.senderUin})]`);
}
} else if (msg.chatType == ChatType.KCHATTYPEDATALINE) {
tokens.push('移动设备');

View File

@@ -1 +1 @@
export const napCatVersion = '4.5.21';
export const napCatVersion = '4.6.0';

29
src/common/worker.ts Normal file
View File

@@ -0,0 +1,29 @@
import { Worker } from 'worker_threads';
export async function runTask<T, R>(workerScript: string, taskData: T): Promise<R> {
let worker = new Worker(workerScript);
try {
return await new Promise<R>((resolve, reject) => {
worker.on('message', (result: R) => {
resolve(result);
});
worker.on('error', (error) => {
reject(new Error(`Worker error: ${error.message}`));
});
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${code}`));
}
});
worker.postMessage(taskData);
});
} catch (error: unknown) {
throw new Error(`Failed to run task: ${(error as Error).message}`);
} finally {
// Ensure the worker is terminated after the promise is settled
worker.terminate();
}
}

View File

@@ -41,7 +41,8 @@ export class NTQQFileApi {
this.context = context;
this.core = core;
this.rkeyManager = new RkeyManager([
'https://rkey.napneko.icu/rkeys'
'https://ss.xingzhige.com/music_card/rkey', // 国内
'https://rkey.napneko.icu/rkeys' // Cloudflare
],
this.context.logger
);

View File

@@ -165,7 +165,13 @@ export class NTQQGroupApi {
return this.groupMemberCache.get(groupCode);
}
async refreshGroupMemberCachePartial(groupCode: string, uid: string) {
const member = await this.getGroupMemberEx(groupCode, uid, true);
if (member) {
this.groupMemberCache.get(groupCode)?.set(uid, member);
}
return member;
}
async getGroupMember(groupCode: string | number, memberUinOrUid: string | number) {
const groupCodeStr = groupCode.toString();
const memberUinOrUidStr = memberUinOrUid.toString();

View File

@@ -12,7 +12,7 @@ export class NTQQMsgApi {
this.context = context;
this.core = core;
}
async clickInlineKeyboardButton(...params: Parameters<NodeIKernelMsgService['clickInlineKeyboardButton']>) {
return this.context.session.getMsgService().clickInlineKeyboardButton(...params);
}
@@ -136,6 +136,20 @@ export class NTQQMsgApi {
});
}
async queryFirstMsgBySender(peer: Peer, SendersUid: string[]) {
console.log(peer, SendersUid);
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', {
chatInfo: peer,
filterMsgType: [],
filterSendersUid: SendersUid,
filterMsgToTime: '0',
filterMsgFromTime: '0',
isReverseOrder: true,
isIncludeCurrent: true,
pageLimit: 20000,
});
}
async setMsgRead(peer: Peer) {
return this.context.session.getMsgService().setMsgRead(peer);
}

View File

@@ -175,7 +175,7 @@
"send": "713A318",
"recv": "713DB50"
},
"6.9.63.30851-x64": {
"6.9.63-30851-x64": {
"send": "46C8040",
"recv": "46CA8AC"
},

View File

@@ -1,22 +1,22 @@
import * as crypto from 'crypto';
import {PacketContext} from '@/core/packet/context/packetContext';
import { PacketContext } from '@/core/packet/context/packetContext';
import * as trans from '@/core/packet/transformer';
import {PacketMsg} from '@/core/packet/message/message';
import { PacketMsg } from '@/core/packet/message/message';
import {
PacketMsgFileElement,
PacketMsgPicElement,
PacketMsgPttElement,
PacketMsgVideoElement
} from '@/core/packet/message/element';
import {ChatType, MsgSourceType, NTMsgType, RawMessage} from '@/core';
import {MiniAppRawData, MiniAppReqParams} from '@/core/packet/entities/miniApp';
import {AIVoiceChatType} from '@/core/packet/entities/aiChat';
import {NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg} from '@napneko/nap-proto-core';
import {IndexNode, LongMsgResult, MsgInfo} from '@/core/packet/transformer/proto';
import {OidbPacket} from '@/core/packet/transformer/base';
import {ImageOcrResult} from '@/core/packet/entities/ocrResult';
import {gunzipSync} from 'zlib';
import {PacketMsgConverter} from '@/core/packet/message/converter';
import { ChatType, MsgSourceType, NTMsgType, RawMessage } from '@/core';
import { MiniAppRawData, MiniAppReqParams } from '@/core/packet/entities/miniApp';
import { AIVoiceChatType } from '@/core/packet/entities/aiChat';
import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core';
import { IndexNode, LongMsgResult, MsgInfo } from '@/core/packet/transformer/proto';
import { OidbPacket } from '@/core/packet/transformer/base';
import { ImageOcrResult } from '@/core/packet/entities/ocrResult';
import { gunzipSync } from 'zlib';
import { PacketMsgConverter } from '@/core/packet/message/converter';
export class PacketOperationContext {
private readonly context: PacketContext;
@@ -59,10 +59,10 @@ export class PacketOperationContext {
const res = trans.GetStrangerInfo.parse(resp);
const extBigInt = BigInt(res.data.status.value);
if (extBigInt <= 10n) {
return {status: Number(extBigInt) * 10, ext_status: 0};
return { status: Number(extBigInt) * 10, ext_status: 0 };
}
status = Number((extBigInt & 0xff00n) + ((extBigInt >> 16n) & 0xffn));
return {status: 10, ext_status: status};
return { status: 10, ext_status: status };
} catch {
return undefined;
}
@@ -79,13 +79,13 @@ export class PacketOperationContext {
const reqList = msg.flatMap(m =>
m.msg.map(e => {
if (e instanceof PacketMsgPicElement) {
return this.context.highway.uploadImage({chatType, peerUid}, e);
return this.context.highway.uploadImage({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgVideoElement) {
return this.context.highway.uploadVideo({chatType, peerUid}, e);
return this.context.highway.uploadVideo({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgPttElement) {
return this.context.highway.uploadPtt({chatType, peerUid}, e);
return this.context.highway.uploadPtt({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgFileElement) {
return this.context.highway.uploadFile({chatType, peerUid}, e);
return this.context.highway.uploadFile({ chatType, peerUid }, e);
}
return null;
}).filter(Boolean)
@@ -160,6 +160,12 @@ export class PacketOperationContext {
const res = trans.DownloadGroupFile.parse(resp);
return `https://${res.download.downloadDns}/ftn_handler/${Buffer.from(res.download.downloadUrl).toString('hex')}/?fname=`;
}
async GetPrivateFileUrl(self_id: string, fileUUID: string, md5: string) {
const req = trans.DownloadPrivateFile.build(self_id, fileUUID, md5);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.DownloadPrivateFile.parse(resp);
return `http://${res.body?.result?.server}:${res.body?.result?.port}${res.body?.result?.url?.slice(8)}&isthumb=0`;
}
async GetGroupPttUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>) {
const req = trans.DownloadGroupPtt.build(groupUin, node);

View File

@@ -144,7 +144,7 @@ export class PacketHighwayContext {
const ukey = preRespData.upload.uKey;
if (ukey && ukey != '') {
this.logger.debug(`[Highway] uploadGroupImageReq get upload ukey: ${ukey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[0].index;
const index = preRespData.upload.msgInfo.msgInfoBody[0]!.index;
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
const md5 = Buffer.from(index.info.fileHash, 'hex');
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
@@ -181,7 +181,7 @@ export class PacketHighwayContext {
const ukey = preRespData.upload.uKey;
if (ukey && ukey != '') {
this.logger.debug(`[Highway] uploadC2CImageReq get upload ukey: ${ukey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[0].index;
const index = preRespData.upload.msgInfo.msgInfoBody[0]!.index;
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
const md5 = Buffer.from(index.info.fileHash, 'hex');
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
@@ -219,7 +219,7 @@ export class PacketHighwayContext {
const ukey = preRespData.upload.uKey;
if (ukey && ukey != '') {
this.logger.debug(`[Highway] uploadGroupVideoReq get upload video ukey: ${ukey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[0].index;
const index = preRespData.upload.msgInfo.msgInfoBody[0]!.index;
const md5 = Buffer.from(index.info.fileHash, 'hex');
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
fileUuid: index.fileUuid,
@@ -244,16 +244,16 @@ export class PacketHighwayContext {
this.logger.debug(`[Highway] uploadGroupVideoReq get upload invalid ukey ${ukey}, don't need upload!`);
}
const subFile = preRespData.upload.subFileInfos[0];
if (subFile.uKey && subFile.uKey != '') {
this.logger.debug(`[Highway] uploadGroupVideoReq get upload video thumb ukey: ${subFile.uKey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[1].index;
if (subFile!.uKey && subFile!.uKey != '') {
this.logger.debug(`[Highway] uploadGroupVideoReq get upload video thumb ukey: ${subFile!.uKey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[1]!.index;
const md5 = Buffer.from(index.info.fileHash, 'hex');
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
fileUuid: index.fileUuid,
uKey: subFile.uKey,
uKey: subFile!.uKey,
network: {
ipv4S: oidbIpv4s2HighwayIpv4s(subFile.ipv4S)
ipv4S: oidbIpv4s2HighwayIpv4s(subFile!.ipv4S)
},
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
blockSize: BlockSize,
@@ -269,7 +269,7 @@ export class PacketHighwayContext {
extend
);
} else {
this.logger.debug(`[Highway] uploadGroupVideoReq get upload invalid thumb ukey ${subFile.uKey}, don't need upload!`);
this.logger.debug(`[Highway] uploadGroupVideoReq get upload invalid thumb ukey ${subFile!.uKey}, don't need upload!`);
}
video.msgInfo = preRespData.upload.msgInfo;
}
@@ -284,7 +284,7 @@ export class PacketHighwayContext {
const ukey = preRespData.upload.uKey;
if (ukey && ukey != '') {
this.logger.debug(`[Highway] uploadC2CVideoReq get upload video ukey: ${ukey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[0].index;
const index = preRespData.upload.msgInfo.msgInfoBody[0]!.index;
const md5 = Buffer.from(index.info.fileHash, 'hex');
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
fileUuid: index.fileUuid,
@@ -309,16 +309,16 @@ export class PacketHighwayContext {
this.logger.debug(`[Highway] uploadC2CVideoReq get upload invalid ukey ${ukey}, don't need upload!`);
}
const subFile = preRespData.upload.subFileInfos[0];
if (subFile.uKey && subFile.uKey != '') {
this.logger.debug(`[Highway] uploadC2CVideoReq get upload video thumb ukey: ${subFile.uKey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[1].index;
if (subFile!.uKey && subFile!.uKey != '') {
this.logger.debug(`[Highway] uploadC2CVideoReq get upload video thumb ukey: ${subFile!.uKey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[1]!.index;
const md5 = Buffer.from(index.info.fileHash, 'hex');
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
fileUuid: index.fileUuid,
uKey: subFile.uKey,
uKey: subFile!.uKey,
network: {
ipv4S: oidbIpv4s2HighwayIpv4s(subFile.ipv4S)
ipv4S: oidbIpv4s2HighwayIpv4s(subFile!.ipv4S)
},
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
blockSize: BlockSize,
@@ -334,7 +334,7 @@ export class PacketHighwayContext {
extend
);
} else {
this.logger.debug(`[Highway] uploadC2CVideoReq get upload invalid thumb ukey ${subFile.uKey}, don't need upload!`);
this.logger.debug(`[Highway] uploadC2CVideoReq get upload invalid thumb ukey ${subFile!.uKey}, don't need upload!`);
}
video.msgInfo = preRespData.upload.msgInfo;
}
@@ -347,7 +347,7 @@ export class PacketHighwayContext {
const ukey = preRespData.upload.uKey;
if (ukey && ukey != '') {
this.logger.debug(`[Highway] uploadGroupPttReq get upload ptt ukey: ${ukey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[0].index;
const index = preRespData.upload.msgInfo.msgInfoBody[0]!.index;
const md5 = Buffer.from(index.info.fileHash, 'hex');
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
@@ -383,7 +383,7 @@ export class PacketHighwayContext {
const ukey = preRespData.upload.uKey;
if (ukey && ukey != '') {
this.logger.debug(`[Highway] uploadC2CPttReq get upload ptt ukey: ${ukey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[0].index;
const index = preRespData.upload.msgInfo.msgInfoBody[0]!.index;
const md5 = Buffer.from(index.info.fileHash, 'hex');
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({

View File

@@ -465,14 +465,14 @@ export interface NodeIKernelMsgService {
setMsgEmojiLikesForRole(...args: unknown[]): unknown;
clickInlineKeyboardButton(params: {
guildId: string,
guildId?: string,
peerId: string,
botAppid: string,
msgSeq: string,
buttonId: string,
callback_data: string,
dmFlag: number,
chatType: number
chatType: number // 1私聊 2群
}): Promise<GeneralCallResult & { status: number, promptText: string, promptType: number, promptIcon: number }>;
setCurOnScreenMsg(...args: unknown[]): unknown;

View File

@@ -7,6 +7,7 @@ const SchemaData = Type.Object({
bot_appid: Type.String(),
button_id: Type.String({ default: '' }),
callback_data: Type.String({ default: '' }),
msg_seq: Type.String({ default: '10086' }),
});
type Payload = Static<typeof SchemaData>;
@@ -18,13 +19,12 @@ export class ClickInlineKeyboardButton extends OneBotAction<Payload, unknown> {
async _handle(payload: Payload) {
return await this.core.apis.MsgApi.clickInlineKeyboardButton({
buttonId: payload.button_id,
guildId: '',// 频道使用
peerId: payload.group_id.toString(),
botAppid: payload.bot_appid,
msgSeq: '10086',
msgSeq: payload.msg_seq,
callback_data: payload.callback_data,
dmFlag: 0,
chatType: 1
chatType: 2
})
}
}

View File

@@ -0,0 +1,56 @@
import { PacketHexStr } from '@/core/packet/transformer/base';
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { ProtoBuf, ProtoBufBase, PBUint32, PBString } from 'napcat.protobuf';
interface Friend {
uin: number;
uid: string;
nick_name: string;
age: number;
source: string;
}
interface Block {
str_uid: string;
bytes_source: string;
uint32_sex: number;
uint32_age: number;
bytes_nick: string;
uint64_uin: number;
}
export class GetUnidirectionalFriendList extends OneBotAction<void, Friend[]> {
override actionName = ActionName.GetUnidirectionalFriendList;
async pack_data(data: string): Promise<Uint8Array> {
return ProtoBuf(class extends ProtoBufBase {
type = PBUint32(2, false, 0);
data = PBString(3, false, data);
}).encode();
}
async _handle(): Promise<Friend[]> {
const self_id = this.core.selfInfo.uin;
const req_json = {
uint64_uin: self_id,
uint64_top: 0,
uint32_req_num: 99,
bytes_cookies: ""
};
const packed_data = await this.pack_data(JSON.stringify(req_json));
const data = Buffer.from(packed_data).toString('hex');
const rsq = { cmd: 'MQUpdateSvc_com_qq_ti.web.OidbSvc.0xe17_0', data: data as PacketHexStr };
const rsp_data = await this.core.apis.PacketApi.pkt.operation.sendPacket(rsq, true);
const block_json = ProtoBuf(class extends ProtoBufBase { data = PBString(4); }).decode(rsp_data);
const block_list: Block[] = JSON.parse(block_json.data).rpt_block_list;
return block_list.map((block) => ({
uin: block.uint64_uin,
uid: block.str_uid,
nick_name: Buffer.from(block.bytes_nick, 'base64').toString(),
age: block.uint32_age,
source: Buffer.from(block.bytes_source, 'base64').toString()
}));
}
}

View File

@@ -0,0 +1,36 @@
import { ActionName } from '@/onebot/action/router';
import { FileNapCatOneBotUUID } from '@/common/file-uuid';
import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
file_id: Type.String(),
});
type Payload = Static<typeof SchemaData>;
interface GetPrivateFileUrlResponse {
url?: string;
}
export class GetPrivateFileUrl extends GetPacketStatusDepends<Payload, GetPrivateFileUrlResponse> {
override actionName = ActionName.NapCat_GetPrivateFileUrl;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
const contextMsgFile = FileNapCatOneBotUUID.decode(payload.file_id);
if (contextMsgFile?.fileUUID && contextMsgFile.msgId) {
let msg = await this.core.apis.MsgApi.getMsgsByMsgId(contextMsgFile.peer, [contextMsgFile.msgId]);
let self_id = this.core.selfInfo.uid;
let file_hash = msg.msgList[0]?.elements.map(ele => ele.fileElement?.file10MMd5)[0];
if (file_hash) {
return {
url: await this.core.apis.PacketApi.pkt.operation.GetPrivateFileUrl(self_id, contextMsgFile.fileUUID, file_hash)
};
}
}
throw new Error('real fileUUID not found!');
}
}

View File

@@ -106,6 +106,8 @@ import { SendPoke } from '@/onebot/action/packet/SendPoke';
import { SetDiyOnlineStatus } from './extends/SetDiyOnlineStatus';
import { BotExit } from './extends/BotExit';
import { ClickInlineKeyboardButton } from './extends/ClickInlineKeyboardButton';
import { GetPrivateFileUrl } from './file/GetPrivateFileUrl';
import { GetUnidirectionalFriendList } from './extends/GetUnidirectionalFriendList';
export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
@@ -225,6 +227,8 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo
new GetGroupSystemMsg(obContext, core),
new BotExit(obContext, core),
new ClickInlineKeyboardButton(obContext, core),
new GetPrivateFileUrl(obContext,core),
new GetUnidirectionalFriendList(obContext,core),
];
type HandlerUnion = typeof actionHandlers[number];

View File

@@ -10,6 +10,7 @@ export interface InvalidCheckResult {
}
export const ActionName = {
NapCat_GetPrivateFileUrl: 'get_private_file_url',
ClickInlineKeyboardButton: 'click_inline_keyboard_button',
GetUnidirectionalFriendList: 'get_unidirectional_friend_list',
// onebot 11

View File

@@ -49,6 +49,7 @@ export class OneBotGroupApi {
duration = -1;
}
}
await this.core.apis.GroupApi.refreshGroupMemberCachePartial(GroupCode, memberUid);
const adminUin = (await this.core.apis.GroupApi.getGroupMember(GroupCode, adminUid))?.uin;
if (memberUin && adminUin) {
return new OB11GroupBanEvent(
@@ -113,12 +114,16 @@ export class OneBotGroupApi {
async parseCardChangedEvent(msg: RawMessage) {
if (msg.senderUin && msg.senderUin !== '0') {
const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUid, msg.senderUin);
await this.core.apis.GroupApi.refreshGroupMemberCachePartial(msg.peerUid, msg.senderUid);
if (member && member.cardName !== msg.sendMemberName) {
const newCardName = msg.sendMemberName ?? '';
const event = new OB11GroupCardEvent(this.core, parseInt(msg.peerUid), parseInt(msg.senderUin), newCardName, member.cardName);
member.cardName = newCardName;
return event;
}
if (member && member.nick !== msg.sendNickName) {
await this.core.apis.GroupApi.refreshGroupMemberCachePartial(msg.peerUid, msg.senderUid);
}
}
return undefined;
}

View File

@@ -132,7 +132,6 @@ export class OneBotMsgApi {
file: element.fileName,
sub_type: element.picSubType,
url: await this.core.apis.FileApi.getImageUrl(element),
path: element.filePath,
file_size: element.fileSize,
},
};
@@ -148,13 +147,13 @@ export class OneBotMsgApi {
peerUid: msg.peerUid,
guildId: '',
};
const file = FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileName);
FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileUuid);
FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileName);
return {
type: OB11MessageDataType.file,
data: {
file: file,
path: element.filePath,
file_id: file,
file: element.fileName,
file_id: element.fileUuid,
file_size: element.fileSize,
},
};
@@ -216,7 +215,6 @@ export class OneBotMsgApi {
data: {
summary: _.faceName, // 商城表情名称
file: filename,
path: url,
url: url,
key: _.key,
emoji_id: _.emojiId,
@@ -339,7 +337,6 @@ export class OneBotMsgApi {
type: OB11MessageDataType.video,
data: {
file: fileCode,
path: videoDownUrl,
url: videoDownUrl,
file_size: element.fileSize,
},
@@ -357,7 +354,6 @@ export class OneBotMsgApi {
type: OB11MessageDataType.voice,
data: {
file: fileCode,
path: element.filePath,
file_size: element.fileSize,
},
};
@@ -658,6 +654,19 @@ export class OneBotMsgApi {
[OB11MessageDataType.node]: async () => undefined,
[OB11MessageDataType.forward]: async ({ data }, context) => {
// let id = data.id.toString();
// let peer: Peer | undefined = context.peer;
// if (isNumeric(id)) {
// let msgid = '';
// if (BigInt(data.id) > 2147483647n) {
// peer = MessageUnique.getPeerByMsgId(id)?.Peer;
// msgid = id;
// } else {
// let data = MessageUnique.getMsgIdAndPeerByShortId(parseInt(id));
// msgid = data?.MsgId ?? '';
// peer = data?.Peer;
// }
// }
const jsonData = ForwardMsgBuilder.fromResId(data.id);
return this.ob11ToRawConverters.json({
data: { data: JSON.stringify(jsonData) },

View File

@@ -3,7 +3,7 @@ import { NapCatCore } from '@/core';
export class OB11GroupAdminNoticeEvent extends OB11GroupNoticeEvent {
notice_type = 'group_admin';
sub_type: 'set' | 'unset';
sub_type: 'set' | 'unset';
constructor(core: NapCatCore, group_id: number, user_id: number, sub_type: 'set' | 'unset') {
super(core, group_id, user_id);

View File

@@ -50,6 +50,7 @@ import {
import { OB11Message } from './types';
import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
import { OB11HttpSSEServerAdapter } from './network/http-server-sse';
import { OB11PluginAdapter } from './network/plugin';
//OneBot实现类
export class NapCatOneBot11Adapter {
@@ -113,9 +114,9 @@ export class NapCatOneBot11Adapter {
//创建NetWork服务
// 注册Plugin 如果需要基于NapCat进行快速开发
// this.networkManager.registerAdapter(
// new OB11PluginAdapter('myPlugin', this.core, this,this.actions)
// );
this.networkManager.registerAdapter(
new OB11PluginAdapter('myPlugin', this.core, this,this.actions)
);
for (const key of ob11Config.network.httpServers) {
if (key.enable) {
this.networkManager.registerAdapter(

View File

@@ -1,5 +1,5 @@
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
import { NapCatOneBot11Adapter, OB11Message } from '@/onebot';
import { NapCatOneBot11Adapter, OB11ArrayMessage } from '@/onebot';
import { NapCatCore } from '@/core';
import { PluginConfig } from '../config/config';
import { plugin_onmessage } from '@/plugin';
@@ -15,14 +15,14 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
messagePostFormat: 'array',
reportSelfMessage: false,
enable: true,
debug: false,
debug: true,
};
super(name, config, core, obContext, actions);
}
onEvent<T extends OB11EmitEventContent>(event: T) {
if (event.post_type === 'message') {
plugin_onmessage(this.config.name, this.core, this.obContext, event as OB11Message, this.actions, this).then().catch();
plugin_onmessage(this.config.name, this.core, this.obContext, event as OB11ArrayMessage, this.actions, this).then().catch();
}
}

View File

@@ -30,6 +30,10 @@ export interface OB11Message {
post_type?: EventType;
raw?: RawMessage;
}
export interface OB11ArrayMessage extends OB11Message {
message_format: 'array';
message: OB11MessageData[];
}
// 合并转发消息接口定义
export interface OB11ForwardMessage extends OB11Message {

57
src/plugin/chathot.ts Normal file
View File

@@ -0,0 +1,57 @@
import { Mutex } from "async-mutex";
export class ChatHotManager {
// 存储群组的热度信息键为群组ID值为使用时间和使用计数
private chatHot: Map<string, { usetime: number, usecount: number }> = new Map();
// 互斥锁,确保热度信息的读写操作是安全的
private chatHotMutex = new Mutex();
/**
* 获取群组是否需要回复
* @param groupId 群组ID
* @returns 是否需要回复
*/
async getHot(groupId: string): Promise<boolean> {
return await this.chatHotMutex.runExclusive(async () => {
const chatHotData = this.chatHot.get(groupId);
const currentTime = Date.now();
if (chatHotData) {
console.log("原始热度", chatHotData?.usecount, currentTime - chatHotData.usetime > 30000);
if (currentTime - chatHotData.usetime > 30000) {
chatHotData.usetime = currentTime;
chatHotData.usecount = 0;
this.chatHot.set(groupId, chatHotData);
// 超出时间段重置计数
return false;
} else if (currentTime - chatHotData.usetime < 30000 && chatHotData.usecount > 0 && chatHotData.usecount < 2) {
// 在短时间内没请求,回复
return true;
}
// 在时间段内有请求,回复
return false;
}
// 初始化,不回复
this.chatHot.set(groupId, { usetime: currentTime, usecount: 0 });
return false;
});
}
/**
* 增加群组的热度计数
* @param groupId 群组ID
*/
async incrementHot(groupId: string) {
await this.chatHotMutex.runExclusive(() => {
const chatHotData = this.chatHot.get(groupId);
const currentTime = Date.now();
if (chatHotData) {
// 引用增加
chatHotData.usecount += 1;
this.chatHot.set(groupId, chatHotData);
} else {
// 初始化
this.chatHot.set(groupId, { usetime: currentTime, usecount: 1 });
}
});
}
}

20
src/plugin/config.ts Normal file
View File

@@ -0,0 +1,20 @@
export const PROMPT_MEMROY = `
你是合并、更新和组织记忆的专家。当提供现有记忆和新信息时,你的任务是合并和更新记忆列表,以反映最准确和最新的信息。你还会得到每个现有记忆与新信息的匹配分数。确保利用这些信息做出明智的决定,决定哪些记忆需要更新或合并。
指南:
- 消除重复的记忆,合并相关记忆,以确保列表简洁和更新。
- 记忆根据人物区分,同时不必每次重复人物账号,只需在记忆中提及一次即可。
- 如果一个记忆直接与新信息矛盾,请批判性地评估两条信息:
- 如果新记忆提供了更近期或更准确的更新,用新记忆替换旧记忆。
- 如果新记忆看起来不准确或细节较少,保留旧记忆并丢弃新记忆。
- 注意区分对应人物的记忆和印象, 不要产生混淆人物的印象和记忆。
- 在所有记忆中保持一致且清晰的风格,确保每个条目简洁而信息丰富。
- 如果新记忆是现有记忆的变体或扩展,更新现有记忆以反映新信息。
`;
export const API_KEY = 'sk-xxxx';//需要配置
export const BASE_URL = 'https://vip.bili2233.work/v1';
export const MODEL = 'gemini-2.0-flash-thinking-exp';
export const BOT_NAME = '千千';
export const BOT_ADMIN = '1627126029';
export const PROMPT = `你的名字叫千千`;
export const CQCODE = `增加一下能力通过不同昵称和QQ进行区分哦,注意理清回复消息的人物, At人直接发送 [CQ:at,qq=1234] 这样可以直接at某个人喵这 回复消息需要发送[CQ:reply,id=xxx]这种格式叫CQ码,发送图片等操作你可以从聊天记录中学习哦, 如果聊天记录的image CQ码 maface类型你可以直接复制使用`;
export const MEMORY_FILE = 'F:/Qian/memory.json';

9
src/plugin/helper.ts Normal file
View File

@@ -0,0 +1,9 @@
import { ChatCompletionContentPart, ChatCompletionMessageParam } from "openai/resources";
export async function toSingleRole(msg: Array<any>) {
let ret = { role: 'user', content: new Array<ChatCompletionContentPart>() };
for (const m of msg) {
ret.content.push(...m.content as any)
}
return [ret] as Array<ChatCompletionMessageParam>;
}

View File

@@ -1,11 +1,168 @@
import { NapCatOneBot11Adapter, OB11Message } from '@/onebot';
import { NapCatOneBot11Adapter, OB11ArrayMessage, OB11MessageData } from '@/onebot';
import { NapCatCore } from '@/core';
import { ActionMap } from '@/onebot/action';
import { OB11PluginAdapter } from '@/onebot/network/plugin';
import { OpenAI } from 'openai';
import { ChatCompletionContentPart, ChatCompletionMessageParam } from 'openai/resources';
import { MemoryManager } from './memory';
import { ChatHotManager } from './chathot';
import { API_KEY, BASE_URL, BOT_ADMIN, BOT_NAME, CQCODE, MODEL, PROMPT, PROMPT_MEMROY } from './config';
import { toSingleRole } from './helper';
export const plugin_onmessage = async (adapter: string, _core: NapCatCore, _obCtx: NapCatOneBot11Adapter, message: OB11Message, action: ActionMap, instance: OB11PluginAdapter) => {
if (message.raw_message === 'ping') {
const ret = await action.get('send_group_msg')?.handle({ group_id: String(message.group_id), message: 'pong' }, adapter, instance.config);
console.log(ret);
const client = new OpenAI({ apiKey: API_KEY, baseURL: BASE_URL });
const chatHotManager = new ChatHotManager();
const memoryManager = new MemoryManager(mergeAndUpdateMemory);
async function createChatCompletionWithRetry(params: any, retries: number = 5): Promise<any> {
for (let attempt = 0; attempt < retries; attempt++) {
try {
return await client.chat.completions.create(params);
} catch (error) {
console.error(`Ai会话 ${attempt + 1} failed:`, error);
if (attempt === retries - 1) throw error;
}
}
};
}
async function messageToOpenAi(adapter: string, msg: OB11MessageData[], groupId: string, action: ActionMap, plugin: OB11PluginAdapter, message: OB11ArrayMessage) {
const msgArray: Array<ChatCompletionContentPart> = [];
let ret = '';
for (const m of msg) {
if (m.type === 'reply') {
ret += `[CQ:reply,id=${m.data.id}]`;
} else if (m.type === 'text') {
ret += m.data.text;
} else if (m.type === 'at') {
const memberInfo = await action.get('get_group_member_info')
?.handle({ group_id: groupId, user_id: m.data.qq }, adapter, plugin.config);
ret += `[CQ:at=${m.data.qq},name=${memberInfo?.data?.nickname}]`;
} else if (m.type === 'image') {
ret += `[CQ:image,file=${m.data.url}]`;
msgArray.push({ type: 'image_url', image_url: { url: m.data.url?.replace('https://', 'http://') || '' } });
} else if (m.type === 'face') {
ret += '[CQ:face,id=' + m.data.id + ']';
}
}
msgArray.push({ type: 'text', text: `${message.sender.nickname}(${message.sender.user_id})发送了消息(消息id:${message.message_id}) :` + ret });
return msgArray.reverse();
}
async function mergeAndUpdateMemory(existingMemories: Array<ChatCompletionContentPart>[], newMemory: Array<ChatCompletionContentPart>[]): Promise<string> {
const completion = await createChatCompletionWithRetry({
messages: await toSingleRole([
{ role: 'user', content: [{ type: 'text', text: PROMPT_MEMROY }] },
{ role: 'user', content: [{ type: 'text', text: '接下来是旧记忆' }] },
...(existingMemories.map(msg => ({ role: 'user', content: msg.filter(e => e.type === 'text') }))),
{ role: 'user', content: [{ type: 'text', text: '接下来是新记忆' }] },
...(newMemory.map(msg => ({ role: 'user', content: msg.filter(e => e.type === 'text') })))]),
model: MODEL
});
return completion.choices[0]?.message.content || '';
}
async function generateChatCompletion(contentData: Array<ChatCompletionMessageParam>): Promise<string> {
const chatCompletion = await createChatCompletionWithRetry({ messages: contentData, model: MODEL });
return chatCompletion.choices[0]?.message.content || '';
}
async function handleClearMemoryCommand(groupId: string, type: 'short' | 'long', action: ActionMap, adapter: string, instance: OB11PluginAdapter) {
await memoryManager.clearMemory(groupId, type);
const message = type === 'short' ? '短期上下文已清理' : '长期上下文已清理';
await sendGroupMessage(groupId, message, action, adapter, instance);
}
async function sendGroupMessage(groupId: string, text: string, action: ActionMap, adapter: string, instance: OB11PluginAdapter) {
return await action.get('send_group_msg')?.handle({ group_id: String(groupId), message: text }, adapter, instance.config);
}
async function prepareContentData(message: OB11ArrayMessage, msgArray: Array<ChatCompletionContentPart>, prompt: string, reply?: Array<ChatCompletionContentPart>) {
const group_id = message.group_id?.toString()!;
const longTermMemoryList = memoryManager.getLongTermMemory(group_id);
let shortTermMemoryList = memoryManager.getShortTermMemory(group_id);
let data = shortTermMemoryList.map(msg => ({ role: 'user' as const, content: msg.filter(e => e.type === 'text') }));
return await toSingleRole([
{ role: 'user', content: [{ type: 'text', text: prompt }] },
{ role: 'user', content: [{ type: 'text', text: '接下来是长时间记忆' }] },
{ role: 'user', content: [{ type: 'text', text: longTermMemoryList }] },
{ role: 'user', content: [{ type: 'text', text: '接下来是短时间记忆' }] },
...data,
{ role: 'user', content: [{ type: 'text' as const, text: '接下来是本次引用消息' }] },
...(reply ? [{ role: 'user' as const, content: reply }] : []),
{ role: 'user', content: [{ type: 'text' as const, text: '接下来是当前对话' }] },
{ role: 'user', content: msgArray }
]);
}
async function handleChatResponse(message: OB11ArrayMessage, msgArray: Array<ChatCompletionContentPart>, adapter: string, action: ActionMap, instance: OB11PluginAdapter, _core: NapCatCore, reply?: Array<ChatCompletionContentPart>) {
const prompt = `请根据下面聊天内容,继续与 ${message?.sender?.card || message?.sender?.nickname} 进行对话。${CQCODE},注意回复内容只用输出内容,不要提及此段话,注意一定不要使用markdown,请采用纯文本回复。你的人设:${PROMPT}`;
const contentData = await prepareContentData(message, msgArray, prompt, reply);
const msgRet = await generateChatCompletion(contentData);
const sentMsg = await sendGroupMessage(message.group_id?.toString()!, msgRet, action, adapter, instance);
return { id: sentMsg?.data?.message_id, text: msgRet };
}
async function shouldRespond(message: OB11ArrayMessage, core: NapCatCore, oriMsg: any, currentHot: boolean, msgArray: Array<ChatCompletionContentPart>, reply?: Array<ChatCompletionContentPart>): Promise<boolean> {
if (
!message.raw_message.startsWith(BOT_NAME) &&
!message.message.find(e => e.type == 'at' && e.data.qq == core.selfInfo.uin) &&
oriMsg?.sender.user_id.toString() !== core.selfInfo.uin
) {
console.log("聊天热度", currentHot ? '热度高' : '热度低');
if (currentHot && msgArray.length > 0) {
const prompt = `请根据在群内聊天与 ${message.sender.card || message.sender?.nickname} 发送的聊天消息推测本次消息是否应该回复。在上下文关系并非强相关的话题和图片不要随意回复,根据上下文非常明显需要时才进行回复,否则不回复,注意尤其减少对图片消息的回应可能性, 注意回复内容只用输出2 - 3个字, 一定注意不想回复请输出不回复三个字即可, 想回复输出回复即可,一定不要给出现任何多余的字, 你的人设:${PROMPT}`;
const contentData = await prepareContentData(message, msgArray, prompt, reply);
const msgRet = await generateChatCompletion(contentData);
console.log('Ai回应判断:' + msgRet)
if (msgRet.indexOf('不回复') !== -1) {
return false;
}
} else {
return false;
}
}
return true;
}
async function handleClearMemory(message: OB11ArrayMessage, action: ActionMap, adapter: string, instance: OB11PluginAdapter) {
if (message.raw_message === '/清除短期上下文' && message.sender.user_id.toString() === BOT_ADMIN) {
await handleClearMemoryCommand(message.group_id?.toString()!, 'short', action, adapter, instance);
return true;
}
if (message.raw_message === '/清除长期上下文' && message.sender.user_id.toString() === BOT_ADMIN) {
await handleClearMemoryCommand(message.group_id?.toString()!, 'long', action, adapter, instance);
return true;
}
return false;
}
export const plugin_onmessage = async (
adapter: string,
core: NapCatCore,
_obCtx: NapCatOneBot11Adapter,
message: OB11ArrayMessage,
action: ActionMap,
instance: OB11PluginAdapter
) => {
const currentHot = await chatHotManager.getHot(message.group_id?.toString()!);
const oriMsgId = message.message.find(e => e.type == 'reply')?.data.id;
const oriMsg = (oriMsgId ? await action.get('get_msg')?._handle({ message_id: oriMsgId }, adapter, instance.config) : undefined) as OB11ArrayMessage | undefined;
const msgArray = await messageToOpenAi(adapter, message.message, message.group_id?.toString()!, action, instance, message);
if (!msgArray) return;
await memoryManager.updateMemory(message.group_id?.toString()!, [msgArray], core.selfInfo.uin);
if (await handleClearMemory(message, action, adapter, instance)) return;
const oriMsgOpenai = oriMsg ? await messageToOpenAi(adapter, oriMsg.message, oriMsg.group_id?.toString()!, action, instance, oriMsg) : undefined;
if (await shouldRespond(message, core, oriMsg, currentHot, msgArray, oriMsgOpenai)) {
const sentMsg = await handleChatResponse(message, msgArray, adapter, action, instance, core, oriMsgOpenai);
await memoryManager.updateMemory(message.group_id?.toString()!, [[{
type: 'text',
text: `我(群昵称: 乔千)(${core.selfInfo.uin})发送了消息(消息id: ${sentMsg.id}) : ` + sentMsg.text
}]], core.selfInfo.uin);
await chatHotManager.incrementHot(message.group_id?.toString()!);
}
};

102
src/plugin/memory.ts Normal file
View File

@@ -0,0 +1,102 @@
import { Mutex } from "async-mutex";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { ChatCompletionContentPart } from "openai/resources";
import { MEMORY_FILE } from "./config";
export class MemoryManager {
private longTermMemory: Map<string, string> = new Map();
private shortTermMemory: Map<string, Array<ChatCompletionContentPart>[]> = new Map();
private memoryCount: Map<string, number> = new Map();
private memMutex = new Mutex();
private SHORT_TERM_MEMORY_LIMIT = 100;
private mergeAndUpdateMemory: (currentMemory: Array<ChatCompletionContentPart>[], newMessages: Array<ChatCompletionContentPart>[]) => Promise<string>;
constructor(mergeAndUpdateMemory: (currentMemory: Array<ChatCompletionContentPart>[], newMessages: Array<ChatCompletionContentPart>[]) => Promise<string>) {
this.mergeAndUpdateMemory = mergeAndUpdateMemory;
this.loadFromJson(MEMORY_FILE);
setInterval(() => this.saveFromJson(MEMORY_FILE), 1000 * 60 * 5);
}
async updateMemory(
groupId: string,
newMessages: Array<ChatCompletionContentPart>[],
selfuin: string
) {
const currentMemory = this.shortTermMemory.get(groupId) || [];
const memCount = await this.incrementMemoryCount(groupId);
currentMemory.push(...newMessages);
if (memCount > this.SHORT_TERM_MEMORY_LIMIT) {
await this.handleMemoryOverflow(groupId, currentMemory, newMessages, selfuin);
}
this.shortTermMemory.set(groupId, currentMemory);
}
async incrementMemoryCount(groupId: string): Promise<number> {
return this.memMutex.runExclusive(() => {
const memCount = (this.memoryCount.get(groupId) || 0) + 1;
this.memoryCount.set(groupId, memCount);
return memCount;
});
}
async handleMemoryOverflow(
groupId: string,
currentMemory: Array<ChatCompletionContentPart>[],
newMessages: Array<ChatCompletionContentPart>[],
selfuin: string
) {
await this.memMutex.runExclusive(async () => {
const containsBotName = currentMemory.some(messages =>
messages.some(msg => msg.type === 'text' && msg.text.includes(selfuin))
);
if (containsBotName) {
const mergedMemory = await this.mergeAndUpdateMemory(currentMemory, newMessages);
this.longTermMemory.set(groupId, mergedMemory);
}
this.shortTermMemory.set(groupId, currentMemory.slice(-this.SHORT_TERM_MEMORY_LIMIT));
this.memoryCount.set(groupId, 0);
});
}
async clearMemory(groupId: string, type: 'short' | 'long') {
if (type === 'short') {
this.shortTermMemory.set(groupId, []);
} else {
this.longTermMemory.set(groupId, '');
}
}
getLongTermMemory(groupId: string): string {
return this.longTermMemory.get(groupId) || '';
}
getShortTermMemory(groupId: string): Array<ChatCompletionContentPart>[] {
return this.shortTermMemory.get(groupId) || [];
}
toJson() {
return {
longTermMemory: Array.from(this.longTermMemory.entries()),
shortTermMemory: Array.from(this.shortTermMemory.entries()),
memoryCount: Array.from(this.memoryCount.entries())
}
}
saveFromJson(file: string) {
let json = JSON.stringify(this.toJson(), null, 2);
writeFileSync(file, json);
}
loadFromJson(file: string) {
if (existsSync(file)) {
let json = readFileSync(file, { encoding: 'utf-8' });
let obj = JSON.parse(json);
this.longTermMemory = new Map(obj.longTermMemory);
this.shortTermMemory = new Map(obj.shortTermMemory);
this.memoryCount = new Map(obj.memoryCount);
}
}
}

View File

@@ -9,7 +9,7 @@ const external = [
'ws',
'express',
'@ffmpeg.wasm/core-mt',
'piscina'
'openai'
];
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();