Compare commits

..

12 Commits

Author SHA1 Message Date
手瓜一十雪
7557b71869 fix: httpSseServers DefaultConfig 2025-01-20 17:02:52 +08:00
Mlikiowa
d07187bd5d release: v4.3.7 2025-01-20 08:48:13 +00:00
手瓜一十雪
2c6a6ba440 fix: type 2025-01-20 16:47:06 +08:00
手瓜一十雪
4592bf7817 fix: nickname可能为null 2025-01-20 16:06:34 +08:00
Mlikiowa
afd6d450a0 release: v4.3.6 2025-01-20 06:51:29 +00:00
手瓜一十雪
b134849dcf feat: 支持文件名发送&兼容单空格问题 2025-01-20 14:51:08 +08:00
手瓜一十雪
e7d0f6d6da feat: 更合适的记录与rkey限制 2025-01-19 15:55:56 +08:00
手瓜一十雪
16a29b0127 feat: file 2025-01-19 15:47:09 +08:00
pk5ls20
1f5596ef16 Merge pull request #715 from FfmpegZZZ/main
chore:移除失效链接
2025-01-19 15:19:01 +08:00
Ffmpeg
bef05432d0 Update README.md 2025-01-19 15:10:18 +08:00
手瓜一十雪
67533d7743 docs: 已重写部分实现 2025-01-13 20:37:43 +08:00
Mlikiowa
0cc86c6348 release: v4.3.5 2025-01-13 12:36:23 +00:00
16 changed files with 179 additions and 95 deletions

2
.gitignore vendored
View File

@@ -1,14 +1,12 @@
# Develop # Develop
node_modules/ node_modules/
package-lock.json package-lock.json
pnpm-lock.yaml
out/ out/
dist/ dist/
/src/core.lib/common/ /src/core.lib/common/
/localdebug/ /localdebug/
# Editor # Editor
.vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.idea/* .idea/*

9
.vscode/settings.json vendored Normal file
View 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"
}
}

View File

@@ -32,7 +32,7 @@ NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
[Server.Other](https://docs.napcat.cyou/) [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) [QQ Group#1](https://qm.qq.com/q/I6LU87a0Yq)
@@ -46,7 +46,7 @@ NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
## 性能设计/协议标准 ## 性能设计/协议标准
NapCat 已实现90+的 OneBot / GoCQ 标准接口,并提供兼容性保留接口,其设计理念遵守 无数据库/异步优先/后台刷新 的性能思想。 NapCat 已实现90+的 OneBot / GoCQ 标准接口,并提供兼容性保留接口,其设计理念遵守 无数据库/异步优先/后台刷新 的性能思想。
由此设计带来一系列好处在开发中获取群员列表通常小于50Ms单条文本消息发送在320Ms以内在1k+的群聊流畅运行,同时带来一些副作用,上报数据中大量使用Magic生成字段消息Id无法持久无法上报撤回消息原始内容。 由此设计带来一系列好处在开发中获取群员列表通常小于50Ms单条文本消息发送在320Ms以内在1k+的群聊流畅运行同时带来一些副作用消息Id无法持久无法上报撤回消息原始内容。
NapCat 在设计理念下遵守 OneBot 规范大多数要求并且积极改进,任何合理的标准化 Issue 与 Pr 将被接收。 NapCat 在设计理念下遵守 OneBot 规范大多数要求并且积极改进,任何合理的标准化 Issue 与 Pr 将被接收。

View File

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

View File

@@ -2,7 +2,7 @@
"name": "napcat", "name": "napcat",
"private": true, "private": true,
"type": "module", "type": "module",
"version": "4.3.4", "version": "4.3.7",
"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",

View File

@@ -1,6 +1,72 @@
import { Peer } from '@/core'; import { Peer } from '@/core';
import { randomUUID } from 'crypto'; 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 { interface FileUUIDData {
peer: Peer; peer: Peer;
modelId?: string; modelId?: string;
@@ -10,49 +76,11 @@ interface FileUUIDData {
fileUUID?: string; 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 { class FileUUIDManager {
private cache: TimeBasedCache<string, FileUUIDData>; private cache: TimeBasedCache<string, FileUUIDData>;
constructor(ttl: number) { 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 { public encode(data: FileUUIDData, endString: string = "", customUUID?: string): string {

View File

@@ -1 +1 @@
export const napCatVersion = '4.3.4'; export const napCatVersion = '4.3.7';

View File

@@ -462,7 +462,7 @@ export class NTQQFileApi {
rkeyData.private_rkey = tempRkeyData.private_rkey; rkeyData.private_rkey = tempRkeyData.private_rkey;
rkeyData.online_rkey = tempRkeyData.expired_time > Date.now() / 1000; rkeyData.online_rkey = tempRkeyData.expired_time > Date.now() / 1000;
} catch (e) { } catch (e) {
this.context.logger.logError('获取rkey失败 Fallback Old Mode', e); this.context.logger.logDebug('获取rkey失败 Fallback Old Mode', e);
} }
} }

View File

@@ -15,6 +15,10 @@ export class RkeyManager {
private_rkey: '', private_rkey: '',
expired_time: 0, 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) { constructor(serverUrl: string[], logger: LogWrapper) {
this.logger = logger; this.logger = logger;
@@ -22,11 +26,21 @@ export class RkeyManager {
} }
async getRkey() { 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()) { if (this.isExpired()) {
try { try {
await this.refreshRkey(); await this.refreshRkey();
} catch (e) { } catch (e) {
throw new Error(`获取rkey失败: ${e}`);//外抛 throw new Error(`${e}`);//外抛
} }
} }
return this.rkeyData; return this.rkeyData;
@@ -34,7 +48,6 @@ export class RkeyManager {
isExpired(): boolean { isExpired(): boolean {
const now = new Date().getTime() / 1000; const now = new Date().getTime() / 1000;
// console.log(`now: ${now}, expired_time: ${this.rkeyData.expired_time}`);
return now > this.rkeyData.expired_time; return now > this.rkeyData.expired_time;
} }
@@ -48,14 +61,17 @@ export class RkeyManager {
private_rkey: temp.private_rkey.slice(6), private_rkey: temp.private_rkey.slice(6),
expired_time: temp.expired_time expired_time: temp.expired_time
}; };
this.failureCount = 0;
return;
} catch (e) { } 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 //是否为最后一个url
if (url === this.serverUrl[this.serverUrl.length - 1]) { if (url === this.serverUrl[this.serverUrl.length - 1]) {
throw new Error(`获取rkey失败: ${e}`);//外抛 throw new Error(`获取rkey失败: ${e}`);//外抛
} }
} }
} }
} }
} }

View File

@@ -18,7 +18,7 @@ export interface BuddyCategoryType {
export interface CoreInfo { export interface CoreInfo {
uid: string; uid: string;
uin: string; uin: string;
nick: string; nick?: string;
remark: string; remark: string;
} }

View File

@@ -0,0 +1 @@
import '@/universal/napcat';

View File

@@ -20,7 +20,7 @@ import {
GroupNotify, GroupNotify,
} from '@/core'; } from '@/core';
import faceConfig from '@/core/external/face_config.json'; 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 { OB11Construct } from '@/onebot/helper/data';
import { EventType } from '@/onebot/event/OneBotEvent'; import { EventType } from '@/onebot/event/OneBotEvent';
import { encodeCQCode } from '@/onebot/helper/cqcode'; import { encodeCQCode } from '@/onebot/helper/cqcode';
@@ -906,16 +906,16 @@ export class OneBotMsgApi {
const calculateTotalSize = async (elements: SendMessageElement[]): Promise<number> => { const calculateTotalSize = async (elements: SendMessageElement[]): Promise<number> => {
const sizePromises = elements.map(async element => { const sizePromises = elements.map(async element => {
switch (element.elementType) { switch (element.elementType) {
case ElementType.PTT: case ElementType.PTT:
return (await fsPromise.stat(element.pttElement.filePath)).size; return (await fsPromise.stat(element.pttElement.filePath)).size;
case ElementType.FILE: case ElementType.FILE:
return (await fsPromise.stat(element.fileElement.filePath)).size; return (await fsPromise.stat(element.fileElement.filePath)).size;
case ElementType.VIDEO: case ElementType.VIDEO:
return (await fsPromise.stat(element.videoElement.filePath)).size; return (await fsPromise.stat(element.videoElement.filePath)).size;
case ElementType.PIC: case ElementType.PIC:
return (await fsPromise.stat(element.picElement.sourcePath)).size; return (await fsPromise.stat(element.picElement.sourcePath)).size;
default: default:
return 0; return 0;
} }
}); });
const sizes = await Promise.all(sizePromises); const sizes = await Promise.all(sizePromises);
@@ -956,40 +956,66 @@ export class OneBotMsgApi {
private async handleOb11FileLikeMessage( private async handleOb11FileLikeMessage(
{ data: inputdata }: OB11MessageFileBase, { data: inputdata }: OB11MessageFileBase,
{ deleteAfterSentFiles }: SendMessageContext, { deleteAfterSentFiles }: SendMessageContext
) { ) {
const realUri = inputdata.url ?? inputdata.file ?? inputdata.path ?? ''; let realUri = [inputdata.url, inputdata.file, inputdata.path].find(uri => uri && uri.trim()) ?? '';
if (realUri.length === 0) { if (!realUri) {
this.core.context.logger.logError('文件消息缺少参数', inputdata); this.core.context.logger.logError('文件消息缺少参数', inputdata);
throw Error('文件消息缺少参数'); throw new Error('文件消息缺少参数');
}
const {
path,
fileName,
errMsg,
success,
} = (await uriToLocalFile(this.core.NapCatTempPath, realUri));
if (!success) {
this.core.context.logger.logError('文件下载失败', errMsg);
throw Error('文件下载失败' + errMsg);
} }
deleteAfterSentFiles.push(path); const downloadFile = async (uri: string) => {
const { path, fileName, errMsg, success } = await uriToLocalFile(this.core.NapCatTempPath, uri);
return { path, fileName: inputdata.name ?? fileName }; 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 { groupChangDecreseType2String(type: number): GroupDecreaseSubType {
switch (type) { switch (type) {
case 130: case 130:
return 'leave'; return 'leave';
case 131: case 131:
return 'kick'; return 'kick';
case 3: case 3:
return 'kick_me'; return 'kick_me';
default: default:
return 'kick'; return 'kick';
} }
} }

View File

@@ -139,13 +139,14 @@ export const defaultOneBotConfigs = createDefaultConfig<OneBotConfig>({
}); });
export const mergeNetworkDefaultConfig = { export const mergeNetworkDefaultConfig = {
httpSseServers: httpSseServerDefaultConfigs,
httpServers: httpServerDefaultConfigs, httpServers: httpServerDefaultConfigs,
httpClients: httpClientDefaultConfigs, httpClients: httpClientDefaultConfigs,
websocketServers: websocketServerDefaultConfigs, websocketServers: websocketServerDefaultConfigs,
websocketClients: websocketClientDefaultConfigs, websocketClients: websocketClientDefaultConfigs,
} as const; } as const;
export type NetworkConfigAdapter = HttpServerConfig | HttpClientConfig | WebsocketServerConfig | WebsocketClientConfig | PluginConfig; export type NetworkConfigAdapter = HttpServerConfig | HttpSseServerConfig | HttpClientConfig | WebsocketServerConfig | WebsocketClientConfig | PluginConfig;
type NetworkConfigKeys = keyof typeof mergeNetworkDefaultConfig; type NetworkConfigKeys = keyof typeof mergeNetworkDefaultConfig;
export function mergeOneBotConfigs( export function mergeOneBotConfigs(

View File

@@ -23,7 +23,7 @@ export class OB11Construct {
...rawFriend.baseInfo, ...rawFriend.baseInfo,
...rawFriend.coreInfo, ...rawFriend.coreInfo,
user_id: parseInt(rawFriend.coreInfo.uin), user_id: parseInt(rawFriend.coreInfo.uin),
nickname: rawFriend.coreInfo.nick, nickname: rawFriend.coreInfo.nick ?? "",
remark: rawFriend.coreInfo.remark ?? rawFriend.coreInfo.nick, remark: rawFriend.coreInfo.remark ?? rawFriend.coreInfo.nick,
sex: this.sex(rawFriend.baseInfo.sex), sex: this.sex(rawFriend.baseInfo.sex),
level: 0, level: 0,

6
src/universal/LiteLoader.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare global {
namespace globalThis {
var LiteLoader: Symbol;
}
}
export {}

View File

@@ -1,7 +1,6 @@
import { NCoreInitShell } from "@/shell/base"; import { NCoreInitShell } from "@/shell/base";
export * from "@/framework/napcat"; export * from "@/framework/napcat";
export * from "@/shell/base"; export * from "@/shell/base";
if ((global as any).LiteLoader == undefined) { if (global.LiteLoader == undefined) {
NCoreInitShell(); NCoreInitShell();
} }