mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
Compare commits
12 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
7557b71869 | ||
![]() |
d07187bd5d | ||
![]() |
2c6a6ba440 | ||
![]() |
4592bf7817 | ||
![]() |
afd6d450a0 | ||
![]() |
b134849dcf | ||
![]() |
e7d0f6d6da | ||
![]() |
16a29b0127 | ||
![]() |
1f5596ef16 | ||
![]() |
bef05432d0 | ||
![]() |
67533d7743 | ||
![]() |
0cc86c6348 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,14 +1,12 @@
|
||||
# Develop
|
||||
node_modules/
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
out/
|
||||
dist/
|
||||
/src/core.lib/common/
|
||||
/localdebug/
|
||||
|
||||
# Editor
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/*
|
||||
|
||||
|
9
.vscode/settings.json
vendored
Normal file
9
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.expand": false,
|
||||
"explorer.fileNesting.patterns": {
|
||||
".env.universal": ".env.*",
|
||||
"tsconfig.json": "tsconfig.*.json, env.d.ts, vite.config.ts",
|
||||
"package.json": "package-lock.json, eslint*, .prettier*, .editorconfig, manifest.json, logo.png, .gitignore, LICENSE"
|
||||
}
|
||||
}
|
@@ -32,7 +32,7 @@ NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
|
||||
|
||||
[Server.Other](https://docs.napcat.cyou/)
|
||||
|
||||
[Qbot.News](https://neko.qbot.news)
|
||||
[NapCat.Wiki](https://www.napcat.wiki)
|
||||
|
||||
## 回家旅途
|
||||
[QQ Group#1](https://qm.qq.com/q/I6LU87a0Yq)
|
||||
@@ -46,7 +46,7 @@ NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
|
||||
## 性能设计/协议标准
|
||||
NapCat 已实现90%+的 OneBot / GoCQ 标准接口,并提供兼容性保留接口,其设计理念遵守 无数据库/异步优先/后台刷新 的性能思想。
|
||||
|
||||
由此设计带来一系列好处,在开发中,获取群员列表通常小于50Ms,单条文本消息发送在320Ms以内,在1k+的群聊流畅运行,同时带来一些副作用,上报数据中大量使用Magic生成字段,消息Id无法持久,无法上报撤回消息原始内容。
|
||||
由此设计带来一系列好处,在开发中,获取群员列表通常小于50Ms,单条文本消息发送在320Ms以内,在1k+的群聊流畅运行,同时带来一些副作用,消息Id无法持久,无法上报撤回消息原始内容。
|
||||
|
||||
NapCat 在设计理念下遵守 OneBot 规范大多数要求并且积极改进,任何合理的标准化 Issue 与 Pr 将被接收。
|
||||
|
||||
|
@@ -4,7 +4,7 @@
|
||||
"name": "NapCatQQ",
|
||||
"slug": "NapCat.Framework",
|
||||
"description": "高性能的 OneBot 11 协议实现",
|
||||
"version": "4.3.4",
|
||||
"version": "4.3.7",
|
||||
"icon": "./logo.png",
|
||||
"authors": [
|
||||
{
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"name": "napcat",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "4.3.4",
|
||||
"version": "4.3.7",
|
||||
"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",
|
||||
|
@@ -1,6 +1,72 @@
|
||||
import { Peer } from '@/core';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
class TimeBasedCache<K, V> {
|
||||
private cache = new Map<K, { value: V, timestamp: number, frequency: number }>();
|
||||
private keyList = new Set<K>();
|
||||
private operationCount = 0;
|
||||
|
||||
constructor(private maxCapacity: number, private ttl: number = 30 * 1000 * 60, private cleanupCount: number = 10) {}
|
||||
|
||||
public put(key: K, value: V): void {
|
||||
const timestamp = Date.now();
|
||||
const cacheEntry = { value, timestamp, frequency: 1 };
|
||||
this.cache.set(key, cacheEntry);
|
||||
this.keyList.add(key);
|
||||
this.operationCount++;
|
||||
if (this.keyList.size > this.maxCapacity) this.evict();
|
||||
if (this.operationCount >= this.cleanupCount) this.cleanup(this.cleanupCount);
|
||||
}
|
||||
|
||||
public get(key: K): V | undefined {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry && Date.now() - entry.timestamp < this.ttl) {
|
||||
entry.timestamp = Date.now();
|
||||
entry.frequency++;
|
||||
this.operationCount++;
|
||||
if (this.operationCount >= this.cleanupCount) this.cleanup(this.cleanupCount);
|
||||
return entry.value;
|
||||
} else {
|
||||
this.deleteKey(key);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private cleanup(count: number): void {
|
||||
const currentTime = Date.now();
|
||||
let cleaned = 0;
|
||||
for (const key of this.keyList) {
|
||||
if (cleaned >= count) break;
|
||||
const entry = this.cache.get(key);
|
||||
if (entry && currentTime - entry.timestamp >= this.ttl) {
|
||||
this.deleteKey(key);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
this.operationCount = 0; // 重置操作计数器
|
||||
}
|
||||
|
||||
private deleteKey(key: K): void {
|
||||
this.cache.delete(key);
|
||||
this.keyList.delete(key);
|
||||
}
|
||||
|
||||
private evict(): void {
|
||||
while (this.keyList.size > this.maxCapacity) {
|
||||
let oldestKey: K | undefined;
|
||||
let minFrequency = Infinity;
|
||||
for (const key of this.keyList) {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry && entry.frequency < minFrequency) {
|
||||
minFrequency = entry.frequency;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
if (oldestKey !== undefined) this.deleteKey(oldestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface FileUUIDData {
|
||||
peer: Peer;
|
||||
modelId?: string;
|
||||
@@ -10,49 +76,11 @@ interface FileUUIDData {
|
||||
fileUUID?: string;
|
||||
}
|
||||
|
||||
class TimeBasedCache<K, V> {
|
||||
private cache: Map<K, { value: V, timestamp: number }>;
|
||||
private ttl: number;
|
||||
|
||||
constructor(ttl: number) {
|
||||
this.cache = new Map();
|
||||
this.ttl = ttl;
|
||||
}
|
||||
|
||||
public put(key: K, value: V): void {
|
||||
const timestamp = Date.now();
|
||||
this.cache.set(key, { value, timestamp });
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
public get(key: K): V | undefined {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry) {
|
||||
const currentTime = Date.now();
|
||||
if (currentTime - entry.timestamp < this.ttl) {
|
||||
return entry.value;
|
||||
} else {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const currentTime = Date.now();
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (currentTime - entry.timestamp >= this.ttl) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FileUUIDManager {
|
||||
private cache: TimeBasedCache<string, FileUUIDData>;
|
||||
|
||||
constructor(ttl: number) {
|
||||
this.cache = new TimeBasedCache<string, FileUUIDData>(ttl);
|
||||
this.cache = new TimeBasedCache<string, FileUUIDData>(5000, ttl);
|
||||
}
|
||||
|
||||
public encode(data: FileUUIDData, endString: string = "", customUUID?: string): string {
|
||||
|
@@ -1 +1 @@
|
||||
export const napCatVersion = '4.3.4';
|
||||
export const napCatVersion = '4.3.7';
|
||||
|
@@ -462,7 +462,7 @@ export class NTQQFileApi {
|
||||
rkeyData.private_rkey = tempRkeyData.private_rkey;
|
||||
rkeyData.online_rkey = tempRkeyData.expired_time > Date.now() / 1000;
|
||||
} catch (e) {
|
||||
this.context.logger.logError('获取rkey失败 Fallback Old Mode', e);
|
||||
this.context.logger.logDebug('获取rkey失败 Fallback Old Mode', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -15,6 +15,10 @@ export class RkeyManager {
|
||||
private_rkey: '',
|
||||
expired_time: 0,
|
||||
};
|
||||
private failureCount: number = 0;
|
||||
private lastFailureTimestamp: number = 0;
|
||||
private readonly FAILURE_LIMIT: number = 8;
|
||||
private readonly ONE_DAY: number = 24 * 60 * 60 * 1000;
|
||||
|
||||
constructor(serverUrl: string[], logger: LogWrapper) {
|
||||
this.logger = logger;
|
||||
@@ -22,11 +26,21 @@ export class RkeyManager {
|
||||
}
|
||||
|
||||
async getRkey() {
|
||||
const now = new Date().getTime();
|
||||
if (now - this.lastFailureTimestamp > this.ONE_DAY) {
|
||||
this.failureCount = 0; // 重置失败计数器
|
||||
}
|
||||
|
||||
if (this.failureCount >= this.FAILURE_LIMIT) {
|
||||
this.logger.logError(`[Rkey] 服务存在异常, 图片使用FallBack机制`);
|
||||
throw new Error('获取rkey失败次数过多,请稍后再试');
|
||||
}
|
||||
|
||||
if (this.isExpired()) {
|
||||
try {
|
||||
await this.refreshRkey();
|
||||
} catch (e) {
|
||||
throw new Error(`获取rkey失败: ${e}`);//外抛
|
||||
throw new Error(`${e}`);//外抛
|
||||
}
|
||||
}
|
||||
return this.rkeyData;
|
||||
@@ -34,7 +48,6 @@ export class RkeyManager {
|
||||
|
||||
isExpired(): boolean {
|
||||
const now = new Date().getTime() / 1000;
|
||||
// console.log(`now: ${now}, expired_time: ${this.rkeyData.expired_time}`);
|
||||
return now > this.rkeyData.expired_time;
|
||||
}
|
||||
|
||||
@@ -48,14 +61,17 @@ export class RkeyManager {
|
||||
private_rkey: temp.private_rkey.slice(6),
|
||||
expired_time: temp.expired_time
|
||||
};
|
||||
this.failureCount = 0;
|
||||
return;
|
||||
} catch (e) {
|
||||
this.logger.logError(`[Rkey] Get Rkey ${url} Error `, e);
|
||||
this.logger.logError(`[Rkey] 异常服务 ${url} 异常 / `, e);
|
||||
this.failureCount++;
|
||||
this.lastFailureTimestamp = new Date().getTime();
|
||||
//是否为最后一个url
|
||||
if (url === this.serverUrl[this.serverUrl.length - 1]) {
|
||||
throw new Error(`获取rkey失败: ${e}`);//外抛
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
@@ -18,7 +18,7 @@ export interface BuddyCategoryType {
|
||||
export interface CoreInfo {
|
||||
uid: string;
|
||||
uin: string;
|
||||
nick: string;
|
||||
nick?: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
|
@@ -0,0 +1 @@
|
||||
import '@/universal/napcat';
|
@@ -20,7 +20,7 @@ import {
|
||||
GroupNotify,
|
||||
} from '@/core';
|
||||
import faceConfig from '@/core/external/face_config.json';
|
||||
import { NapCatOneBot11Adapter, OB11Message, OB11MessageData, OB11MessageDataType, OB11MessageFileBase, OB11MessageForward, } from '@/onebot';
|
||||
import { NapCatOneBot11Adapter, OB11Message, OB11MessageData, OB11MessageDataType, OB11MessageFileBase, OB11MessageForward, OB11MessageImage, OB11MessageVideo, } from '@/onebot';
|
||||
import { OB11Construct } from '@/onebot/helper/data';
|
||||
import { EventType } from '@/onebot/event/OneBotEvent';
|
||||
import { encodeCQCode } from '@/onebot/helper/cqcode';
|
||||
@@ -906,16 +906,16 @@ export class OneBotMsgApi {
|
||||
const calculateTotalSize = async (elements: SendMessageElement[]): Promise<number> => {
|
||||
const sizePromises = elements.map(async element => {
|
||||
switch (element.elementType) {
|
||||
case ElementType.PTT:
|
||||
return (await fsPromise.stat(element.pttElement.filePath)).size;
|
||||
case ElementType.FILE:
|
||||
return (await fsPromise.stat(element.fileElement.filePath)).size;
|
||||
case ElementType.VIDEO:
|
||||
return (await fsPromise.stat(element.videoElement.filePath)).size;
|
||||
case ElementType.PIC:
|
||||
return (await fsPromise.stat(element.picElement.sourcePath)).size;
|
||||
default:
|
||||
return 0;
|
||||
case ElementType.PTT:
|
||||
return (await fsPromise.stat(element.pttElement.filePath)).size;
|
||||
case ElementType.FILE:
|
||||
return (await fsPromise.stat(element.fileElement.filePath)).size;
|
||||
case ElementType.VIDEO:
|
||||
return (await fsPromise.stat(element.videoElement.filePath)).size;
|
||||
case ElementType.PIC:
|
||||
return (await fsPromise.stat(element.picElement.sourcePath)).size;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
const sizes = await Promise.all(sizePromises);
|
||||
@@ -956,40 +956,66 @@ export class OneBotMsgApi {
|
||||
|
||||
private async handleOb11FileLikeMessage(
|
||||
{ data: inputdata }: OB11MessageFileBase,
|
||||
{ deleteAfterSentFiles }: SendMessageContext,
|
||||
{ deleteAfterSentFiles }: SendMessageContext
|
||||
) {
|
||||
const realUri = inputdata.url ?? inputdata.file ?? inputdata.path ?? '';
|
||||
if (realUri.length === 0) {
|
||||
let realUri = [inputdata.url, inputdata.file, inputdata.path].find(uri => uri && uri.trim()) ?? '';
|
||||
if (!realUri) {
|
||||
this.core.context.logger.logError('文件消息缺少参数', inputdata);
|
||||
throw Error('文件消息缺少参数');
|
||||
}
|
||||
const {
|
||||
path,
|
||||
fileName,
|
||||
errMsg,
|
||||
success,
|
||||
} = (await uriToLocalFile(this.core.NapCatTempPath, realUri));
|
||||
|
||||
if (!success) {
|
||||
this.core.context.logger.logError('文件下载失败', errMsg);
|
||||
throw Error('文件下载失败' + errMsg);
|
||||
throw new Error('文件消息缺少参数');
|
||||
}
|
||||
|
||||
deleteAfterSentFiles.push(path);
|
||||
|
||||
return { path, fileName: inputdata.name ?? fileName };
|
||||
const downloadFile = async (uri: string) => {
|
||||
const { path, fileName, errMsg, success } = await uriToLocalFile(this.core.NapCatTempPath, uri);
|
||||
if (!success) {
|
||||
this.core.context.logger.logError('文件下载失败', errMsg);
|
||||
throw new Error('文件下载失败: ' + errMsg);
|
||||
}
|
||||
return { path, fileName };
|
||||
};
|
||||
try {
|
||||
const { path, fileName } = await downloadFile(realUri);
|
||||
deleteAfterSentFiles.push(path);
|
||||
return { path, fileName: inputdata.name ?? fileName };
|
||||
} catch {
|
||||
realUri = await this.handleObfuckName(realUri);
|
||||
const { path, fileName } = await downloadFile(realUri);
|
||||
deleteAfterSentFiles.push(path);
|
||||
return { path, fileName: inputdata.name ?? fileName };
|
||||
}
|
||||
}
|
||||
async handleObfuckName(name: string) {
|
||||
const contextMsgFile = FileNapCatOneBotUUID.decode(name);
|
||||
if (contextMsgFile && contextMsgFile.msgId && contextMsgFile.elementId) {
|
||||
const { peer, msgId, elementId } = contextMsgFile;
|
||||
const rawMessage = (await this.core.apis.MsgApi.getMsgsByMsgId(peer, [msgId]))?.msgList.find(msg => msg.msgId === msgId);
|
||||
const mixElement = rawMessage?.elements.find(e => e.elementId === elementId);
|
||||
const mixElementInner = mixElement?.videoElement ?? mixElement?.fileElement ?? mixElement?.pttElement ?? mixElement?.picElement;
|
||||
if (!mixElementInner) throw new Error('element not found');
|
||||
let url = '';
|
||||
if (mixElement?.picElement && rawMessage) {
|
||||
const tempData =
|
||||
await this.obContext.apis.MsgApi.rawToOb11Converters.picElement?.(mixElement?.picElement, rawMessage, mixElement, { parseMultMsg: false }) as OB11MessageImage | undefined;
|
||||
url = tempData?.data.url ?? '';
|
||||
}
|
||||
if (mixElement?.videoElement && rawMessage) {
|
||||
const tempData =
|
||||
await this.obContext.apis.MsgApi.rawToOb11Converters.videoElement?.(mixElement?.videoElement, rawMessage, mixElement, { parseMultMsg: false }) as OB11MessageVideo | undefined;
|
||||
url = tempData?.data.url ?? '';
|
||||
}
|
||||
return url !== '' ? url : await this.core.apis.FileApi.downloadMedia(msgId, peer.chatType, peer.peerUid, elementId, '', '');
|
||||
}
|
||||
throw new Error('文件名解析失败');
|
||||
}
|
||||
|
||||
groupChangDecreseType2String(type: number): GroupDecreaseSubType {
|
||||
switch (type) {
|
||||
case 130:
|
||||
return 'leave';
|
||||
case 131:
|
||||
return 'kick';
|
||||
case 3:
|
||||
return 'kick_me';
|
||||
default:
|
||||
return 'kick';
|
||||
case 130:
|
||||
return 'leave';
|
||||
case 131:
|
||||
return 'kick';
|
||||
case 3:
|
||||
return 'kick_me';
|
||||
default:
|
||||
return 'kick';
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -139,13 +139,14 @@ export const defaultOneBotConfigs = createDefaultConfig<OneBotConfig>({
|
||||
});
|
||||
|
||||
export const mergeNetworkDefaultConfig = {
|
||||
httpSseServers: httpSseServerDefaultConfigs,
|
||||
httpServers: httpServerDefaultConfigs,
|
||||
httpClients: httpClientDefaultConfigs,
|
||||
websocketServers: websocketServerDefaultConfigs,
|
||||
websocketClients: websocketClientDefaultConfigs,
|
||||
} as const;
|
||||
|
||||
export type NetworkConfigAdapter = HttpServerConfig | HttpClientConfig | WebsocketServerConfig | WebsocketClientConfig | PluginConfig;
|
||||
export type NetworkConfigAdapter = HttpServerConfig | HttpSseServerConfig | HttpClientConfig | WebsocketServerConfig | WebsocketClientConfig | PluginConfig;
|
||||
type NetworkConfigKeys = keyof typeof mergeNetworkDefaultConfig;
|
||||
|
||||
export function mergeOneBotConfigs(
|
||||
|
@@ -23,7 +23,7 @@ export class OB11Construct {
|
||||
...rawFriend.baseInfo,
|
||||
...rawFriend.coreInfo,
|
||||
user_id: parseInt(rawFriend.coreInfo.uin),
|
||||
nickname: rawFriend.coreInfo.nick,
|
||||
nickname: rawFriend.coreInfo.nick ?? "",
|
||||
remark: rawFriend.coreInfo.remark ?? rawFriend.coreInfo.nick,
|
||||
sex: this.sex(rawFriend.baseInfo.sex),
|
||||
level: 0,
|
||||
|
6
src/universal/LiteLoader.d.ts
vendored
Normal file
6
src/universal/LiteLoader.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare global {
|
||||
namespace globalThis {
|
||||
var LiteLoader: Symbol;
|
||||
}
|
||||
}
|
||||
export {}
|
@@ -1,7 +1,6 @@
|
||||
import { NCoreInitShell } from "@/shell/base";
|
||||
|
||||
export * from "@/framework/napcat";
|
||||
export * from "@/shell/base";
|
||||
if ((global as any).LiteLoader == undefined) {
|
||||
if (global.LiteLoader == undefined) {
|
||||
NCoreInitShell();
|
||||
}
|
Reference in New Issue
Block a user