mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
Compare commits
37 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
644060ca25 | ||
![]() |
02038483d4 | ||
![]() |
417c025dbf | ||
![]() |
2852996f18 | ||
![]() |
1a257e03fc | ||
![]() |
7b89e1fcb0 | ||
![]() |
c126820d40 | ||
![]() |
e316657cd7 | ||
![]() |
d8a9413627 | ||
![]() |
5c98623f2b | ||
![]() |
d6485b220e | ||
![]() |
c19c106266 | ||
![]() |
ea3d069e49 | ||
![]() |
3e6024f183 | ||
![]() |
337871693a | ||
![]() |
2d921c4577 | ||
![]() |
9accff7323 | ||
![]() |
88b1ee8c31 | ||
![]() |
3ac618bb4e | ||
![]() |
0051df3741 | ||
![]() |
7eb4e010b0 | ||
![]() |
33cc23ada3 | ||
![]() |
e5aee372e3 | ||
![]() |
6b6ce4a761 | ||
![]() |
8c4ea7f8f2 | ||
![]() |
c8b268b806 | ||
![]() |
cf5e0e0f14 | ||
![]() |
7b79f9cc17 | ||
![]() |
708d599966 | ||
![]() |
1ecd5b78e6 | ||
![]() |
fca2e3c51a | ||
![]() |
95ea761b2d | ||
![]() |
6b3bfa1ee9 | ||
![]() |
cea900ca2a | ||
![]() |
df3e302a9d | ||
![]() |
c88a68c9a8 | ||
![]() |
92d01b9cdd |
115
.vscode/launch.json
vendored
Normal file
115
.vscode/launch.json
vendored
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@@ -4,7 +4,7 @@
|
|||||||
"name": "NapCatQQ",
|
"name": "NapCatQQ",
|
||||||
"slug": "NapCat.Framework",
|
"slug": "NapCat.Framework",
|
||||||
"description": "高性能的 OneBot 11 协议实现",
|
"description": "高性能的 OneBot 11 协议实现",
|
||||||
"version": "4.5.21",
|
"version": "4.6.0",
|
||||||
"icon": "./logo.png",
|
"icon": "./logo.png",
|
||||||
"authors": [
|
"authors": [
|
||||||
{
|
{
|
||||||
|
BIN
napcat.webui/public/fonts/NotoSerifSC-VariableFont_wght.ttf
Normal file
BIN
napcat.webui/public/fonts/NotoSerifSC-VariableFont_wght.ttf
Normal file
Binary file not shown.
@@ -1,19 +1,21 @@
|
|||||||
import { PlayMode } from '@/const/enum'
|
import { PlayMode } from '@/const/enum'
|
||||||
|
|
||||||
|
import WebUIManager from '@/controllers/webui_manager'
|
||||||
import type {
|
import type {
|
||||||
FinalMusic,
|
FinalMusic,
|
||||||
Music163ListResponse,
|
Music163ListResponse,
|
||||||
Music163URLResponse
|
Music163URLResponse
|
||||||
} from '@/types/music'
|
} from '@/types/music'
|
||||||
|
|
||||||
import WebUIManager from '@/controllers/webui_manager'
|
|
||||||
/**
|
/**
|
||||||
* 获取网易云音乐歌单
|
* 获取网易云音乐歌单
|
||||||
* @param id 歌单id
|
* @param id 歌单id
|
||||||
* @returns 歌单信息
|
* @returns 歌单信息
|
||||||
*/
|
*/
|
||||||
export const get163MusicList = async (id: string) => {
|
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>(
|
// const res = await request.get<Music163ListResponse>(
|
||||||
// `https://wavesgame.top/playlist/track/all?id=${id}`
|
// `https://wavesgame.top/playlist/track/all?id=${id}`
|
||||||
// )
|
// )
|
||||||
@@ -71,7 +73,7 @@ export const get163MusicListSongs = async (id: string) => {
|
|||||||
if (songURL) {
|
if (songURL) {
|
||||||
finalMusic.push({
|
finalMusic.push({
|
||||||
id: song.id,
|
id: song.id,
|
||||||
url: songURL,
|
url: songURL.replace(/http:\/\//, '//').replace(/https:\/\//, '//'),
|
||||||
title: song.name,
|
title: song.name,
|
||||||
artist: song.ar.map((p) => p.name).join('/'),
|
artist: song.ar.map((p) => p.name).join('/'),
|
||||||
cover: song.al.picUrl
|
cover: song.al.picUrl
|
||||||
|
11
package.json
11
package.json
@@ -2,7 +2,7 @@
|
|||||||
"name": "napcat",
|
"name": "napcat",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "4.5.21",
|
"version": "4.6.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
|
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
|
||||||
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
|
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
|
||||||
@@ -42,7 +42,6 @@
|
|||||||
"ajv": "^8.13.0",
|
"ajv": "^8.13.0",
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
"commander": "^13.0.0",
|
"commander": "^13.0.0",
|
||||||
"cors": "^2.8.5",
|
|
||||||
"esbuild": "0.25.0",
|
"esbuild": "0.25.0",
|
||||||
"eslint": "^9.14.0",
|
"eslint": "^9.14.0",
|
||||||
"eslint-import-resolver-typescript": "^3.6.1",
|
"eslint-import-resolver-typescript": "^3.6.1",
|
||||||
@@ -59,13 +58,15 @@
|
|||||||
"vite": "^6.0.1",
|
"vite": "^6.0.1",
|
||||||
"vite-plugin-cp": "^4.0.8",
|
"vite-plugin-cp": "^4.0.8",
|
||||||
"vite-tsconfig-paths": "^5.1.0",
|
"vite-tsconfig-paths": "^5.1.0",
|
||||||
"winston": "^3.17.0"
|
"napcat.protobuf": "^1.1.3",
|
||||||
|
"winston": "^3.17.0",
|
||||||
|
"compressing": "^1.10.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ffmpeg.wasm/core-mt": "^0.13.2",
|
"@ffmpeg.wasm/core-mt": "^0.13.2",
|
||||||
"compressing": "^1.10.1",
|
"cors": "^2.8.5",
|
||||||
"express": "^5.0.0",
|
"express": "^5.0.0",
|
||||||
"piscina": "^4.7.0",
|
"openai": "^4.85.4",
|
||||||
"silk-wasm": "^3.6.1",
|
"silk-wasm": "^3.6.1",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,20 @@
|
|||||||
import { encode } from 'silk-wasm';
|
import { encode } from 'silk-wasm';
|
||||||
|
import { parentPort } from 'worker_threads';
|
||||||
|
|
||||||
export interface EncodeArgs {
|
export interface EncodeArgs {
|
||||||
input: ArrayBufferView | ArrayBuffer
|
input: ArrayBufferView | ArrayBuffer
|
||||||
sampleRate: number
|
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);
|
return await encode(input, sampleRate);
|
||||||
};
|
});
|
@@ -1,4 +1,3 @@
|
|||||||
import Piscina from 'piscina';
|
|
||||||
import fsPromise from 'fs/promises';
|
import fsPromise from 'fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
@@ -6,16 +5,16 @@ import { EncodeResult, getDuration, getWavFileInfo, isSilk, isWav } from 'silk-w
|
|||||||
import { LogWrapper } from '@/common/log';
|
import { LogWrapper } from '@/common/log';
|
||||||
import { EncodeArgs } from '@/common/audio-worker';
|
import { EncodeArgs } from '@/common/audio-worker';
|
||||||
import { FFmpegService } from '@/common/ffmpeg';
|
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];
|
const ALLOW_SAMPLE_RATE = [8000, 12000, 16000, 24000, 32000, 44100, 48000];
|
||||||
|
|
||||||
async function getWorkerPath() {
|
function getWorkerPath() {
|
||||||
return new URL(/* @vite-ignore */ './audio-worker.mjs', import.meta.url).href;
|
//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) {
|
async function guessDuration(pttPath: string, logger: LogWrapper) {
|
||||||
const pttFileInfo = await fsPromise.stat(pttPath);
|
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)
|
const { input, sampleRate } = isWav(file)
|
||||||
? await handleWavFile(file, filePath, pcmPath)
|
? await handleWavFile(file, filePath, pcmPath)
|
||||||
: { input: await FFmpegService.convert(filePath, pcmPath), sampleRate: 24000 };
|
: { 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));
|
fsPromise.unlink(pcmPath).catch((e) => logger.logError('删除临时文件失败', pcmPath, e));
|
||||||
await fsPromise.writeFile(pttPath, Buffer.from(silk.data));
|
await fsPromise.writeFile(pttPath, Buffer.from(silk.data));
|
||||||
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', silk.duration);
|
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', silk.duration);
|
||||||
|
@@ -5,6 +5,17 @@ import { readFileSync, statSync, writeFileSync } from 'fs';
|
|||||||
import type { VideoInfo } from './video';
|
import type { VideoInfo } from './video';
|
||||||
import { fileTypeFromFile } from 'file-type';
|
import { fileTypeFromFile } from 'file-type';
|
||||||
import imageSize from 'image-size';
|
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 {
|
class FFmpegService {
|
||||||
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
|
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
|
||||||
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
|
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> {
|
export default async function handleFFmpegTask({ method, args }: FFmpegTask): Promise<any> {
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case 'extractThumbnail':
|
case 'extractThumbnail':
|
||||||
return await FFmpegService.extractThumbnail(...args as [string, string]);
|
return await FFmpegService.extractThumbnail(...args as [string, string]);
|
||||||
case 'convertFile':
|
case 'convertFile':
|
||||||
return await FFmpegService.convertFile(...args as [string, string, string]);
|
return await FFmpegService.convertFile(...args as [string, string, string]);
|
||||||
case 'convert':
|
case 'convert':
|
||||||
return await FFmpegService.convert(...args as [string, string]);
|
return await FFmpegService.convert(...args as [string, string]);
|
||||||
case 'getVideoInfo':
|
case 'getVideoInfo':
|
||||||
return await FFmpegService.getVideoInfo(...args as [string, string]);
|
return await FFmpegService.getVideoInfo(...args as [string, string]);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown method: ${method}`);
|
throw new Error(`Unknown method: ${method}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
recvTask<FFmpegTask>(async ({ method, args }: FFmpegTask) => {
|
||||||
|
return await handleFFmpegTask({ method, args });
|
||||||
|
});
|
@@ -1,6 +1,8 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import Piscina from 'piscina';
|
|
||||||
import { VideoInfo } from './video';
|
import { VideoInfo } from './video';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { runTask } from './worker';
|
||||||
|
|
||||||
type EncodeArgs = {
|
type EncodeArgs = {
|
||||||
method: 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
|
method: 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
|
||||||
@@ -9,42 +11,26 @@ type EncodeArgs = {
|
|||||||
|
|
||||||
type EncodeResult = any;
|
type EncodeResult = any;
|
||||||
|
|
||||||
async function getWorkerPath() {
|
function getWorkerPath() {
|
||||||
return new URL(/* @vite-ignore */ './ffmpeg-worker.mjs', import.meta.url).href;
|
return path.join(path.dirname(fileURLToPath(import.meta.url)), './ffmpeg-worker.mjs');
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FFmpegService {
|
export class FFmpegService {
|
||||||
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
|
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
|
||||||
const piscina = new Piscina<EncodeArgs, EncodeResult>({
|
await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'extractThumbnail', args: [videoPath, thumbnailPath] });
|
||||||
filename: await getWorkerPath(),
|
|
||||||
});
|
|
||||||
await piscina.run({ method: 'extractThumbnail', args: [videoPath, thumbnailPath] });
|
|
||||||
await piscina.destroy();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
|
public static async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||||
const piscina = new Piscina<EncodeArgs, EncodeResult>({
|
await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'convertFile', args: [inputFile, outputFile, format] });
|
||||||
filename: await getWorkerPath(),
|
|
||||||
});
|
|
||||||
await piscina.run({ method: 'convertFile', args: [inputFile, outputFile, format] });
|
|
||||||
await piscina.destroy();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async convert(filePath: string, pcmPath: string): Promise<Buffer> {
|
public static async convert(filePath: string, pcmPath: string): Promise<Buffer> {
|
||||||
const piscina = new Piscina<EncodeArgs, EncodeResult>({
|
const result = await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'convert', args: [filePath, pcmPath] });
|
||||||
filename: await getWorkerPath(),
|
|
||||||
});
|
|
||||||
const result = await piscina.run({ method: 'convert', args: [filePath, pcmPath] });
|
|
||||||
await piscina.destroy();
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
|
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
|
||||||
const piscina = new Piscina<EncodeArgs, EncodeResult>({
|
const result = await await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { method: 'getVideoInfo', args: [videoPath, thumbnailPath] });
|
||||||
filename: await getWorkerPath(),
|
|
||||||
});
|
|
||||||
const result = await piscina.run({ method: 'getVideoInfo', args: [videoPath, thumbnailPath] });
|
|
||||||
await piscina.destroy();
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -232,7 +232,7 @@ export function rawMessageToText(msg: RawMessage, recursiveLevel = 0): string {
|
|||||||
tokens.push(`群聊 [${msg.peerName}(${msg.peerUin})]`);
|
tokens.push(`群聊 [${msg.peerName}(${msg.peerUin})]`);
|
||||||
}
|
}
|
||||||
if (msg.senderUin !== '0') {
|
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) {
|
} else if (msg.chatType == ChatType.KCHATTYPEDATALINE) {
|
||||||
tokens.push('移动设备');
|
tokens.push('移动设备');
|
||||||
|
@@ -1 +1 @@
|
|||||||
export const napCatVersion = '4.5.21';
|
export const napCatVersion = '4.6.0';
|
||||||
|
29
src/common/worker.ts
Normal file
29
src/common/worker.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@@ -41,7 +41,8 @@ export class NTQQFileApi {
|
|||||||
this.context = context;
|
this.context = context;
|
||||||
this.core = core;
|
this.core = core;
|
||||||
this.rkeyManager = new RkeyManager([
|
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
|
this.context.logger
|
||||||
);
|
);
|
||||||
|
@@ -165,7 +165,13 @@ export class NTQQGroupApi {
|
|||||||
|
|
||||||
return this.groupMemberCache.get(groupCode);
|
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) {
|
async getGroupMember(groupCode: string | number, memberUinOrUid: string | number) {
|
||||||
const groupCodeStr = groupCode.toString();
|
const groupCodeStr = groupCode.toString();
|
||||||
const memberUinOrUidStr = memberUinOrUid.toString();
|
const memberUinOrUidStr = memberUinOrUid.toString();
|
||||||
|
@@ -12,7 +12,7 @@ export class NTQQMsgApi {
|
|||||||
this.context = context;
|
this.context = context;
|
||||||
this.core = core;
|
this.core = core;
|
||||||
}
|
}
|
||||||
|
|
||||||
async clickInlineKeyboardButton(...params: Parameters<NodeIKernelMsgService['clickInlineKeyboardButton']>) {
|
async clickInlineKeyboardButton(...params: Parameters<NodeIKernelMsgService['clickInlineKeyboardButton']>) {
|
||||||
return this.context.session.getMsgService().clickInlineKeyboardButton(...params);
|
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) {
|
async setMsgRead(peer: Peer) {
|
||||||
return this.context.session.getMsgService().setMsgRead(peer);
|
return this.context.session.getMsgService().setMsgRead(peer);
|
||||||
}
|
}
|
||||||
|
2
src/core/external/offset.json
vendored
2
src/core/external/offset.json
vendored
@@ -175,7 +175,7 @@
|
|||||||
"send": "713A318",
|
"send": "713A318",
|
||||||
"recv": "713DB50"
|
"recv": "713DB50"
|
||||||
},
|
},
|
||||||
"6.9.63.30851-x64": {
|
"6.9.63-30851-x64": {
|
||||||
"send": "46C8040",
|
"send": "46C8040",
|
||||||
"recv": "46CA8AC"
|
"recv": "46CA8AC"
|
||||||
},
|
},
|
||||||
|
@@ -1,22 +1,22 @@
|
|||||||
import * as crypto from 'crypto';
|
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 * as trans from '@/core/packet/transformer';
|
||||||
import {PacketMsg} from '@/core/packet/message/message';
|
import { PacketMsg } from '@/core/packet/message/message';
|
||||||
import {
|
import {
|
||||||
PacketMsgFileElement,
|
PacketMsgFileElement,
|
||||||
PacketMsgPicElement,
|
PacketMsgPicElement,
|
||||||
PacketMsgPttElement,
|
PacketMsgPttElement,
|
||||||
PacketMsgVideoElement
|
PacketMsgVideoElement
|
||||||
} from '@/core/packet/message/element';
|
} from '@/core/packet/message/element';
|
||||||
import {ChatType, MsgSourceType, NTMsgType, RawMessage} from '@/core';
|
import { ChatType, MsgSourceType, NTMsgType, RawMessage } from '@/core';
|
||||||
import {MiniAppRawData, MiniAppReqParams} from '@/core/packet/entities/miniApp';
|
import { MiniAppRawData, MiniAppReqParams } from '@/core/packet/entities/miniApp';
|
||||||
import {AIVoiceChatType} from '@/core/packet/entities/aiChat';
|
import { AIVoiceChatType } from '@/core/packet/entities/aiChat';
|
||||||
import {NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg} from '@napneko/nap-proto-core';
|
import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core';
|
||||||
import {IndexNode, LongMsgResult, MsgInfo} from '@/core/packet/transformer/proto';
|
import { IndexNode, LongMsgResult, MsgInfo } from '@/core/packet/transformer/proto';
|
||||||
import {OidbPacket} from '@/core/packet/transformer/base';
|
import { OidbPacket } from '@/core/packet/transformer/base';
|
||||||
import {ImageOcrResult} from '@/core/packet/entities/ocrResult';
|
import { ImageOcrResult } from '@/core/packet/entities/ocrResult';
|
||||||
import {gunzipSync} from 'zlib';
|
import { gunzipSync } from 'zlib';
|
||||||
import {PacketMsgConverter} from '@/core/packet/message/converter';
|
import { PacketMsgConverter } from '@/core/packet/message/converter';
|
||||||
|
|
||||||
export class PacketOperationContext {
|
export class PacketOperationContext {
|
||||||
private readonly context: PacketContext;
|
private readonly context: PacketContext;
|
||||||
@@ -59,10 +59,10 @@ export class PacketOperationContext {
|
|||||||
const res = trans.GetStrangerInfo.parse(resp);
|
const res = trans.GetStrangerInfo.parse(resp);
|
||||||
const extBigInt = BigInt(res.data.status.value);
|
const extBigInt = BigInt(res.data.status.value);
|
||||||
if (extBigInt <= 10n) {
|
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));
|
status = Number((extBigInt & 0xff00n) + ((extBigInt >> 16n) & 0xffn));
|
||||||
return {status: 10, ext_status: status};
|
return { status: 10, ext_status: status };
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -79,13 +79,13 @@ export class PacketOperationContext {
|
|||||||
const reqList = msg.flatMap(m =>
|
const reqList = msg.flatMap(m =>
|
||||||
m.msg.map(e => {
|
m.msg.map(e => {
|
||||||
if (e instanceof PacketMsgPicElement) {
|
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) {
|
} 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) {
|
} 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) {
|
} else if (e instanceof PacketMsgFileElement) {
|
||||||
return this.context.highway.uploadFile({chatType, peerUid}, e);
|
return this.context.highway.uploadFile({ chatType, peerUid }, e);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}).filter(Boolean)
|
}).filter(Boolean)
|
||||||
@@ -160,6 +160,12 @@ export class PacketOperationContext {
|
|||||||
const res = trans.DownloadGroupFile.parse(resp);
|
const res = trans.DownloadGroupFile.parse(resp);
|
||||||
return `https://${res.download.downloadDns}/ftn_handler/${Buffer.from(res.download.downloadUrl).toString('hex')}/?fname=`;
|
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>) {
|
async GetGroupPttUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>) {
|
||||||
const req = trans.DownloadGroupPtt.build(groupUin, node);
|
const req = trans.DownloadGroupPtt.build(groupUin, node);
|
||||||
|
@@ -144,7 +144,7 @@ export class PacketHighwayContext {
|
|||||||
const ukey = preRespData.upload.uKey;
|
const ukey = preRespData.upload.uKey;
|
||||||
if (ukey && ukey != '') {
|
if (ukey && ukey != '') {
|
||||||
this.logger.debug(`[Highway] uploadGroupImageReq get upload ukey: ${ukey}, need upload!`);
|
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 sha1 = Buffer.from(index.info.fileSha1, 'hex');
|
||||||
const md5 = Buffer.from(index.info.fileHash, 'hex');
|
const md5 = Buffer.from(index.info.fileHash, 'hex');
|
||||||
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
||||||
@@ -181,7 +181,7 @@ export class PacketHighwayContext {
|
|||||||
const ukey = preRespData.upload.uKey;
|
const ukey = preRespData.upload.uKey;
|
||||||
if (ukey && ukey != '') {
|
if (ukey && ukey != '') {
|
||||||
this.logger.debug(`[Highway] uploadC2CImageReq get upload ukey: ${ukey}, need upload!`);
|
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 sha1 = Buffer.from(index.info.fileSha1, 'hex');
|
||||||
const md5 = Buffer.from(index.info.fileHash, 'hex');
|
const md5 = Buffer.from(index.info.fileHash, 'hex');
|
||||||
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
||||||
@@ -219,7 +219,7 @@ export class PacketHighwayContext {
|
|||||||
const ukey = preRespData.upload.uKey;
|
const ukey = preRespData.upload.uKey;
|
||||||
if (ukey && ukey != '') {
|
if (ukey && ukey != '') {
|
||||||
this.logger.debug(`[Highway] uploadGroupVideoReq get upload video ukey: ${ukey}, need upload!`);
|
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 md5 = Buffer.from(index.info.fileHash, 'hex');
|
||||||
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
||||||
fileUuid: index.fileUuid,
|
fileUuid: index.fileUuid,
|
||||||
@@ -244,16 +244,16 @@ export class PacketHighwayContext {
|
|||||||
this.logger.debug(`[Highway] uploadGroupVideoReq get upload invalid ukey ${ukey}, don't need upload!`);
|
this.logger.debug(`[Highway] uploadGroupVideoReq get upload invalid ukey ${ukey}, don't need upload!`);
|
||||||
}
|
}
|
||||||
const subFile = preRespData.upload.subFileInfos[0];
|
const subFile = preRespData.upload.subFileInfos[0];
|
||||||
if (subFile.uKey && subFile.uKey != '') {
|
if (subFile!.uKey && subFile!.uKey != '') {
|
||||||
this.logger.debug(`[Highway] uploadGroupVideoReq get upload video thumb ukey: ${subFile.uKey}, need upload!`);
|
this.logger.debug(`[Highway] uploadGroupVideoReq get upload video thumb ukey: ${subFile!.uKey}, need upload!`);
|
||||||
const index = preRespData.upload.msgInfo.msgInfoBody[1].index;
|
const index = preRespData.upload.msgInfo.msgInfoBody[1]!.index;
|
||||||
const md5 = Buffer.from(index.info.fileHash, 'hex');
|
const md5 = Buffer.from(index.info.fileHash, 'hex');
|
||||||
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
|
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
|
||||||
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
||||||
fileUuid: index.fileUuid,
|
fileUuid: index.fileUuid,
|
||||||
uKey: subFile.uKey,
|
uKey: subFile!.uKey,
|
||||||
network: {
|
network: {
|
||||||
ipv4S: oidbIpv4s2HighwayIpv4s(subFile.ipv4S)
|
ipv4S: oidbIpv4s2HighwayIpv4s(subFile!.ipv4S)
|
||||||
},
|
},
|
||||||
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
|
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
|
||||||
blockSize: BlockSize,
|
blockSize: BlockSize,
|
||||||
@@ -269,7 +269,7 @@ export class PacketHighwayContext {
|
|||||||
extend
|
extend
|
||||||
);
|
);
|
||||||
} else {
|
} 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;
|
video.msgInfo = preRespData.upload.msgInfo;
|
||||||
}
|
}
|
||||||
@@ -284,7 +284,7 @@ export class PacketHighwayContext {
|
|||||||
const ukey = preRespData.upload.uKey;
|
const ukey = preRespData.upload.uKey;
|
||||||
if (ukey && ukey != '') {
|
if (ukey && ukey != '') {
|
||||||
this.logger.debug(`[Highway] uploadC2CVideoReq get upload video ukey: ${ukey}, need upload!`);
|
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 md5 = Buffer.from(index.info.fileHash, 'hex');
|
||||||
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
||||||
fileUuid: index.fileUuid,
|
fileUuid: index.fileUuid,
|
||||||
@@ -309,16 +309,16 @@ export class PacketHighwayContext {
|
|||||||
this.logger.debug(`[Highway] uploadC2CVideoReq get upload invalid ukey ${ukey}, don't need upload!`);
|
this.logger.debug(`[Highway] uploadC2CVideoReq get upload invalid ukey ${ukey}, don't need upload!`);
|
||||||
}
|
}
|
||||||
const subFile = preRespData.upload.subFileInfos[0];
|
const subFile = preRespData.upload.subFileInfos[0];
|
||||||
if (subFile.uKey && subFile.uKey != '') {
|
if (subFile!.uKey && subFile!.uKey != '') {
|
||||||
this.logger.debug(`[Highway] uploadC2CVideoReq get upload video thumb ukey: ${subFile.uKey}, need upload!`);
|
this.logger.debug(`[Highway] uploadC2CVideoReq get upload video thumb ukey: ${subFile!.uKey}, need upload!`);
|
||||||
const index = preRespData.upload.msgInfo.msgInfoBody[1].index;
|
const index = preRespData.upload.msgInfo.msgInfoBody[1]!.index;
|
||||||
const md5 = Buffer.from(index.info.fileHash, 'hex');
|
const md5 = Buffer.from(index.info.fileHash, 'hex');
|
||||||
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
|
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
|
||||||
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
||||||
fileUuid: index.fileUuid,
|
fileUuid: index.fileUuid,
|
||||||
uKey: subFile.uKey,
|
uKey: subFile!.uKey,
|
||||||
network: {
|
network: {
|
||||||
ipv4S: oidbIpv4s2HighwayIpv4s(subFile.ipv4S)
|
ipv4S: oidbIpv4s2HighwayIpv4s(subFile!.ipv4S)
|
||||||
},
|
},
|
||||||
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
|
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
|
||||||
blockSize: BlockSize,
|
blockSize: BlockSize,
|
||||||
@@ -334,7 +334,7 @@ export class PacketHighwayContext {
|
|||||||
extend
|
extend
|
||||||
);
|
);
|
||||||
} else {
|
} 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;
|
video.msgInfo = preRespData.upload.msgInfo;
|
||||||
}
|
}
|
||||||
@@ -347,7 +347,7 @@ export class PacketHighwayContext {
|
|||||||
const ukey = preRespData.upload.uKey;
|
const ukey = preRespData.upload.uKey;
|
||||||
if (ukey && ukey != '') {
|
if (ukey && ukey != '') {
|
||||||
this.logger.debug(`[Highway] uploadGroupPttReq get upload ptt ukey: ${ukey}, need upload!`);
|
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 md5 = Buffer.from(index.info.fileHash, 'hex');
|
||||||
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
|
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
|
||||||
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
||||||
@@ -383,7 +383,7 @@ export class PacketHighwayContext {
|
|||||||
const ukey = preRespData.upload.uKey;
|
const ukey = preRespData.upload.uKey;
|
||||||
if (ukey && ukey != '') {
|
if (ukey && ukey != '') {
|
||||||
this.logger.debug(`[Highway] uploadC2CPttReq get upload ptt ukey: ${ukey}, need upload!`);
|
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 md5 = Buffer.from(index.info.fileHash, 'hex');
|
||||||
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
|
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
|
||||||
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
|
||||||
|
@@ -465,14 +465,14 @@ export interface NodeIKernelMsgService {
|
|||||||
setMsgEmojiLikesForRole(...args: unknown[]): unknown;
|
setMsgEmojiLikesForRole(...args: unknown[]): unknown;
|
||||||
|
|
||||||
clickInlineKeyboardButton(params: {
|
clickInlineKeyboardButton(params: {
|
||||||
guildId: string,
|
guildId?: string,
|
||||||
peerId: string,
|
peerId: string,
|
||||||
botAppid: string,
|
botAppid: string,
|
||||||
msgSeq: string,
|
msgSeq: string,
|
||||||
buttonId: string,
|
buttonId: string,
|
||||||
callback_data: string,
|
callback_data: string,
|
||||||
dmFlag: number,
|
dmFlag: number,
|
||||||
chatType: number
|
chatType: number // 1私聊 2群
|
||||||
}): Promise<GeneralCallResult & { status: number, promptText: string, promptType: number, promptIcon: number }>;
|
}): Promise<GeneralCallResult & { status: number, promptText: string, promptType: number, promptIcon: number }>;
|
||||||
|
|
||||||
setCurOnScreenMsg(...args: unknown[]): unknown;
|
setCurOnScreenMsg(...args: unknown[]): unknown;
|
||||||
|
@@ -7,6 +7,7 @@ const SchemaData = Type.Object({
|
|||||||
bot_appid: Type.String(),
|
bot_appid: Type.String(),
|
||||||
button_id: Type.String({ default: '' }),
|
button_id: Type.String({ default: '' }),
|
||||||
callback_data: Type.String({ default: '' }),
|
callback_data: Type.String({ default: '' }),
|
||||||
|
msg_seq: Type.String({ default: '10086' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
type Payload = Static<typeof SchemaData>;
|
type Payload = Static<typeof SchemaData>;
|
||||||
@@ -18,13 +19,12 @@ export class ClickInlineKeyboardButton extends OneBotAction<Payload, unknown> {
|
|||||||
async _handle(payload: Payload) {
|
async _handle(payload: Payload) {
|
||||||
return await this.core.apis.MsgApi.clickInlineKeyboardButton({
|
return await this.core.apis.MsgApi.clickInlineKeyboardButton({
|
||||||
buttonId: payload.button_id,
|
buttonId: payload.button_id,
|
||||||
guildId: '',// 频道使用
|
|
||||||
peerId: payload.group_id.toString(),
|
peerId: payload.group_id.toString(),
|
||||||
botAppid: payload.bot_appid,
|
botAppid: payload.bot_appid,
|
||||||
msgSeq: '10086',
|
msgSeq: payload.msg_seq,
|
||||||
callback_data: payload.callback_data,
|
callback_data: payload.callback_data,
|
||||||
dmFlag: 0,
|
dmFlag: 0,
|
||||||
chatType: 1
|
chatType: 2
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
56
src/onebot/action/extends/GetUnidirectionalFriendList.ts
Normal file
56
src/onebot/action/extends/GetUnidirectionalFriendList.ts
Normal 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()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
36
src/onebot/action/file/GetPrivateFileUrl.ts
Normal file
36
src/onebot/action/file/GetPrivateFileUrl.ts
Normal 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!');
|
||||||
|
}
|
||||||
|
}
|
@@ -106,6 +106,8 @@ import { SendPoke } from '@/onebot/action/packet/SendPoke';
|
|||||||
import { SetDiyOnlineStatus } from './extends/SetDiyOnlineStatus';
|
import { SetDiyOnlineStatus } from './extends/SetDiyOnlineStatus';
|
||||||
import { BotExit } from './extends/BotExit';
|
import { BotExit } from './extends/BotExit';
|
||||||
import { ClickInlineKeyboardButton } from './extends/ClickInlineKeyboardButton';
|
import { ClickInlineKeyboardButton } from './extends/ClickInlineKeyboardButton';
|
||||||
|
import { GetPrivateFileUrl } from './file/GetPrivateFileUrl';
|
||||||
|
import { GetUnidirectionalFriendList } from './extends/GetUnidirectionalFriendList';
|
||||||
|
|
||||||
export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
|
export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
|
||||||
|
|
||||||
@@ -225,6 +227,8 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo
|
|||||||
new GetGroupSystemMsg(obContext, core),
|
new GetGroupSystemMsg(obContext, core),
|
||||||
new BotExit(obContext, core),
|
new BotExit(obContext, core),
|
||||||
new ClickInlineKeyboardButton(obContext, core),
|
new ClickInlineKeyboardButton(obContext, core),
|
||||||
|
new GetPrivateFileUrl(obContext,core),
|
||||||
|
new GetUnidirectionalFriendList(obContext,core),
|
||||||
];
|
];
|
||||||
|
|
||||||
type HandlerUnion = typeof actionHandlers[number];
|
type HandlerUnion = typeof actionHandlers[number];
|
||||||
|
@@ -10,6 +10,7 @@ export interface InvalidCheckResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ActionName = {
|
export const ActionName = {
|
||||||
|
NapCat_GetPrivateFileUrl: 'get_private_file_url',
|
||||||
ClickInlineKeyboardButton: 'click_inline_keyboard_button',
|
ClickInlineKeyboardButton: 'click_inline_keyboard_button',
|
||||||
GetUnidirectionalFriendList: 'get_unidirectional_friend_list',
|
GetUnidirectionalFriendList: 'get_unidirectional_friend_list',
|
||||||
// onebot 11
|
// onebot 11
|
||||||
|
@@ -49,6 +49,7 @@ export class OneBotGroupApi {
|
|||||||
duration = -1;
|
duration = -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await this.core.apis.GroupApi.refreshGroupMemberCachePartial(GroupCode, memberUid);
|
||||||
const adminUin = (await this.core.apis.GroupApi.getGroupMember(GroupCode, adminUid))?.uin;
|
const adminUin = (await this.core.apis.GroupApi.getGroupMember(GroupCode, adminUid))?.uin;
|
||||||
if (memberUin && adminUin) {
|
if (memberUin && adminUin) {
|
||||||
return new OB11GroupBanEvent(
|
return new OB11GroupBanEvent(
|
||||||
@@ -113,12 +114,16 @@ export class OneBotGroupApi {
|
|||||||
async parseCardChangedEvent(msg: RawMessage) {
|
async parseCardChangedEvent(msg: RawMessage) {
|
||||||
if (msg.senderUin && msg.senderUin !== '0') {
|
if (msg.senderUin && msg.senderUin !== '0') {
|
||||||
const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUid, msg.senderUin);
|
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) {
|
if (member && member.cardName !== msg.sendMemberName) {
|
||||||
const newCardName = msg.sendMemberName ?? '';
|
const newCardName = msg.sendMemberName ?? '';
|
||||||
const event = new OB11GroupCardEvent(this.core, parseInt(msg.peerUid), parseInt(msg.senderUin), newCardName, member.cardName);
|
const event = new OB11GroupCardEvent(this.core, parseInt(msg.peerUid), parseInt(msg.senderUin), newCardName, member.cardName);
|
||||||
member.cardName = newCardName;
|
member.cardName = newCardName;
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
if (member && member.nick !== msg.sendNickName) {
|
||||||
|
await this.core.apis.GroupApi.refreshGroupMemberCachePartial(msg.peerUid, msg.senderUid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@@ -132,7 +132,6 @@ export class OneBotMsgApi {
|
|||||||
file: element.fileName,
|
file: element.fileName,
|
||||||
sub_type: element.picSubType,
|
sub_type: element.picSubType,
|
||||||
url: await this.core.apis.FileApi.getImageUrl(element),
|
url: await this.core.apis.FileApi.getImageUrl(element),
|
||||||
path: element.filePath,
|
|
||||||
file_size: element.fileSize,
|
file_size: element.fileSize,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -148,13 +147,13 @@ export class OneBotMsgApi {
|
|||||||
peerUid: msg.peerUid,
|
peerUid: msg.peerUid,
|
||||||
guildId: '',
|
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 {
|
return {
|
||||||
type: OB11MessageDataType.file,
|
type: OB11MessageDataType.file,
|
||||||
data: {
|
data: {
|
||||||
file: file,
|
file: element.fileName,
|
||||||
path: element.filePath,
|
file_id: element.fileUuid,
|
||||||
file_id: file,
|
|
||||||
file_size: element.fileSize,
|
file_size: element.fileSize,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -216,7 +215,6 @@ export class OneBotMsgApi {
|
|||||||
data: {
|
data: {
|
||||||
summary: _.faceName, // 商城表情名称
|
summary: _.faceName, // 商城表情名称
|
||||||
file: filename,
|
file: filename,
|
||||||
path: url,
|
|
||||||
url: url,
|
url: url,
|
||||||
key: _.key,
|
key: _.key,
|
||||||
emoji_id: _.emojiId,
|
emoji_id: _.emojiId,
|
||||||
@@ -339,7 +337,6 @@ export class OneBotMsgApi {
|
|||||||
type: OB11MessageDataType.video,
|
type: OB11MessageDataType.video,
|
||||||
data: {
|
data: {
|
||||||
file: fileCode,
|
file: fileCode,
|
||||||
path: videoDownUrl,
|
|
||||||
url: videoDownUrl,
|
url: videoDownUrl,
|
||||||
file_size: element.fileSize,
|
file_size: element.fileSize,
|
||||||
},
|
},
|
||||||
@@ -357,7 +354,6 @@ export class OneBotMsgApi {
|
|||||||
type: OB11MessageDataType.voice,
|
type: OB11MessageDataType.voice,
|
||||||
data: {
|
data: {
|
||||||
file: fileCode,
|
file: fileCode,
|
||||||
path: element.filePath,
|
|
||||||
file_size: element.fileSize,
|
file_size: element.fileSize,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -658,6 +654,19 @@ export class OneBotMsgApi {
|
|||||||
[OB11MessageDataType.node]: async () => undefined,
|
[OB11MessageDataType.node]: async () => undefined,
|
||||||
|
|
||||||
[OB11MessageDataType.forward]: async ({ data }, context) => {
|
[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);
|
const jsonData = ForwardMsgBuilder.fromResId(data.id);
|
||||||
return this.ob11ToRawConverters.json({
|
return this.ob11ToRawConverters.json({
|
||||||
data: { data: JSON.stringify(jsonData) },
|
data: { data: JSON.stringify(jsonData) },
|
||||||
|
@@ -3,7 +3,7 @@ import { NapCatCore } from '@/core';
|
|||||||
|
|
||||||
export class OB11GroupAdminNoticeEvent extends OB11GroupNoticeEvent {
|
export class OB11GroupAdminNoticeEvent extends OB11GroupNoticeEvent {
|
||||||
notice_type = 'group_admin';
|
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') {
|
constructor(core: NapCatCore, group_id: number, user_id: number, sub_type: 'set' | 'unset') {
|
||||||
super(core, group_id, user_id);
|
super(core, group_id, user_id);
|
||||||
|
@@ -50,6 +50,7 @@ import {
|
|||||||
import { OB11Message } from './types';
|
import { OB11Message } from './types';
|
||||||
import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
|
import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
|
||||||
import { OB11HttpSSEServerAdapter } from './network/http-server-sse';
|
import { OB11HttpSSEServerAdapter } from './network/http-server-sse';
|
||||||
|
import { OB11PluginAdapter } from './network/plugin';
|
||||||
|
|
||||||
//OneBot实现类
|
//OneBot实现类
|
||||||
export class NapCatOneBot11Adapter {
|
export class NapCatOneBot11Adapter {
|
||||||
@@ -113,9 +114,9 @@ export class NapCatOneBot11Adapter {
|
|||||||
//创建NetWork服务
|
//创建NetWork服务
|
||||||
|
|
||||||
// 注册Plugin 如果需要基于NapCat进行快速开发
|
// 注册Plugin 如果需要基于NapCat进行快速开发
|
||||||
// this.networkManager.registerAdapter(
|
this.networkManager.registerAdapter(
|
||||||
// new OB11PluginAdapter('myPlugin', this.core, this,this.actions)
|
new OB11PluginAdapter('myPlugin', this.core, this,this.actions)
|
||||||
// );
|
);
|
||||||
for (const key of ob11Config.network.httpServers) {
|
for (const key of ob11Config.network.httpServers) {
|
||||||
if (key.enable) {
|
if (key.enable) {
|
||||||
this.networkManager.registerAdapter(
|
this.networkManager.registerAdapter(
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
|
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
|
||||||
import { NapCatOneBot11Adapter, OB11Message } from '@/onebot';
|
import { NapCatOneBot11Adapter, OB11ArrayMessage } from '@/onebot';
|
||||||
import { NapCatCore } from '@/core';
|
import { NapCatCore } from '@/core';
|
||||||
import { PluginConfig } from '../config/config';
|
import { PluginConfig } from '../config/config';
|
||||||
import { plugin_onmessage } from '@/plugin';
|
import { plugin_onmessage } from '@/plugin';
|
||||||
@@ -15,14 +15,14 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
|||||||
messagePostFormat: 'array',
|
messagePostFormat: 'array',
|
||||||
reportSelfMessage: false,
|
reportSelfMessage: false,
|
||||||
enable: true,
|
enable: true,
|
||||||
debug: false,
|
debug: true,
|
||||||
};
|
};
|
||||||
super(name, config, core, obContext, actions);
|
super(name, config, core, obContext, actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
onEvent<T extends OB11EmitEventContent>(event: T) {
|
onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||||
if (event.post_type === 'message') {
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -30,6 +30,10 @@ export interface OB11Message {
|
|||||||
post_type?: EventType;
|
post_type?: EventType;
|
||||||
raw?: RawMessage;
|
raw?: RawMessage;
|
||||||
}
|
}
|
||||||
|
export interface OB11ArrayMessage extends OB11Message {
|
||||||
|
message_format: 'array';
|
||||||
|
message: OB11MessageData[];
|
||||||
|
}
|
||||||
|
|
||||||
// 合并转发消息接口定义
|
// 合并转发消息接口定义
|
||||||
export interface OB11ForwardMessage extends OB11Message {
|
export interface OB11ForwardMessage extends OB11Message {
|
||||||
|
57
src/plugin/chathot.ts
Normal file
57
src/plugin/chathot.ts
Normal 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
20
src/plugin/config.ts
Normal 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
9
src/plugin/helper.ts
Normal 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>;
|
||||||
|
}
|
@@ -1,11 +1,168 @@
|
|||||||
import { NapCatOneBot11Adapter, OB11Message } from '@/onebot';
|
import { NapCatOneBot11Adapter, OB11ArrayMessage, OB11MessageData } from '@/onebot';
|
||||||
import { NapCatCore } from '@/core';
|
import { NapCatCore } from '@/core';
|
||||||
import { ActionMap } from '@/onebot/action';
|
import { ActionMap } from '@/onebot/action';
|
||||||
import { OB11PluginAdapter } from '@/onebot/network/plugin';
|
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) => {
|
const client = new OpenAI({ apiKey: API_KEY, baseURL: BASE_URL });
|
||||||
if (message.raw_message === 'ping') {
|
const chatHotManager = new ChatHotManager();
|
||||||
const ret = await action.get('send_group_msg')?.handle({ group_id: String(message.group_id), message: 'pong' }, adapter, instance.config);
|
const memoryManager = new MemoryManager(mergeAndUpdateMemory);
|
||||||
console.log(ret);
|
|
||||||
|
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
102
src/plugin/memory.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -9,7 +9,7 @@ const external = [
|
|||||||
'ws',
|
'ws',
|
||||||
'express',
|
'express',
|
||||||
'@ffmpeg.wasm/core-mt',
|
'@ffmpeg.wasm/core-mt',
|
||||||
'piscina'
|
'openai'
|
||||||
];
|
];
|
||||||
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
|
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user