Compare commits

..

4 Commits

Author SHA1 Message Date
手瓜一十雪
7b638dac6b Merge branch 'main' into 1999 2025-04-27 19:22:08 +08:00
手瓜一十雪
6db79cef86 feat: 1999初版 2025-04-22 14:26:39 +08:00
手瓜一十雪
0454e8d869 feat 2025-04-22 12:34:24 +08:00
手瓜一十雪
0aa1afee7a feat 2025-04-22 12:14:19 +08:00
45 changed files with 1366 additions and 4159 deletions

View File

@@ -40,11 +40,8 @@ _Modern protocol-side framework implemented based on NTQQ._
| Docs | [![Cloudflare.Pages](https://img.shields.io/badge/docs%20on-Cloudflare.Pages-blue)](https://napneko.pages.dev/) | [![Server.Other](https://img.shields.io/badge/docs%20on-Server.Other-green)](https://napcat.cyou/) | [![NapCat.Wiki](https://img.shields.io/badge/docs%20on-NapCat.Wiki-red)](https://www.napcat.wiki) |
|:-:|:-:|:-:|:-:|
| QQ Group | [![QQ Group#4](https://img.shields.io/badge/QQ%20Group%234-Join-blue)](https://qm.qq.com/q/CMmPbGw0jA) | [![QQ Group#3](https://img.shields.io/badge/QQ%20Group%233-Join-blue)](https://qm.qq.com/q/8zJMLjqy2Y) | [![QQ Group#2](https://img.shields.io/badge/QQ%20Group%232-Join-blue)](https://qm.qq.com/q/HaRcfrHpUk) | [![QQ Group#1](https://img.shields.io/badge/QQ%20Group%231-Join-blue)](https://qm.qq.com/q/I6LU87a0Yq) |
|:-:|:-:|:-:|:-:|:-:|
| Telegram | [![Telegram](https://img.shields.io/badge/Telegram-MelodicMoonlight-blue)](https://t.me/MelodicMoonlight) |
|:-:|:-:|
| Contact | [![QQ Group#1](https://img.shields.io/badge/QQ%20Group%231-Join-blue)](https://qm.qq.com/q/I6LU87a0Yq) | [![QQ Group#2](https://img.shields.io/badge/QQ%20Group%232-Join-blue)](https://qm.qq.com/q/HaRcfrHpUk) | [![Telegram](https://img.shields.io/badge/Telegram-MelodicMoonlight-blue)](https://t.me/MelodicMoonlight) |
|:-:|:-:|:-:|:-:|
## Thanks

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 695 KiB

View File

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

View File

@@ -2,7 +2,7 @@
"name": "napcat",
"private": true,
"type": "module",
"version": "4.7.45",
"version": "4.7.43",
"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",
@@ -35,13 +35,13 @@
"@types/qrcode-terminal": "^0.12.2",
"@types/react-color": "^3.0.13",
"@types/type-is": "^1.6.7",
"@types/wordcloud": "^1.2.2",
"@types/ws": "^8.5.12",
"@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0",
"ajv": "^8.13.0",
"async-mutex": "^0.5.0",
"commander": "^13.0.0",
"compressing": "^1.10.1",
"cors": "^2.8.5",
"esbuild": "0.25.0",
"eslint": "^9.14.0",
@@ -54,22 +54,18 @@
"image-size": "^1.1.1",
"json5": "^2.2.3",
"multer": "^1.4.5-lts.1",
"napcat.protobuf": "^1.1.4",
"typescript": "^5.3.3",
"typescript-eslint": "^8.13.0",
"vite": "^6.0.1",
"vite-plugin-cp": "^6.0.0",
"vite-tsconfig-paths": "^5.1.0",
"napcat.protobuf": "^1.1.4",
"winston": "^3.17.0",
"compressing": "^1.10.1"
"winston": "^3.17.0"
},
"dependencies": {
"@napi-rs/canvas": "^0.1.67",
"@node-rs/jieba": "^2.0.1",
"@napi-rs/canvas": "^0.1.69",
"express": "^5.0.0",
"napcat.protobuf": "^1.1.2",
"silk-wasm": "^3.6.1",
"wordcloud": "^1.2.3",
"ws": "^8.18.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

@@ -145,8 +145,8 @@ export enum FileUriType {
export async function checkUriType(Uri: string) {
const LocalFileRet = await solveProblem((uri: string) => {
if (fs.existsSync(path.normalize(uri))) {
return { Uri: path.normalize(uri), Type: FileUriType.Local };
if (fs.existsSync(uri)) {
return { Uri: uri, Type: FileUriType.Local };
}
return undefined;
}, Uri);

View File

@@ -109,6 +109,7 @@ export class RequestUtil {
req.end();
});
}
// 请求返回都是原始内容
static async HttpGetText(url: string, method: string = 'GET', data?: any, headers: { [key: string]: string } = {}) {
return this.HttpGetJson<string>(url, method, data, headers, false, false);

View File

@@ -1 +1 @@
export const napCatVersion = '4.7.45';
export const napCatVersion = '4.7.43';

View File

@@ -17,24 +17,6 @@ export class NTQQMsgApi {
return this.context.session.getMsgService().clickInlineKeyboardButton(...params);
}
async searchMsgWithKeywords(keyWords: string[], param: Peer & { searchFields: number, pageLimit: number }) {
let outputSearchId = 0;
return this.core.eventWrapper.callNormalEventV2(
'NodeIKernelSearchService/searchMsgWithKeywords',
'NodeIKernelSearchListener/onSearchMsgKeywordsResult',
[keyWords, param],
(searchId) => {
outputSearchId = searchId;
return true;
},
(event) => {
return event.searchId == outputSearchId;
},
1,
5000
);
}
getMsgByClientSeqAndTime(peer: Peer, replyMsgClientSeq: string, replyMsgTime: string) {
// https://bot.q.qq.com/wiki/develop/api-v2/openapi/emoji/model.html#EmojiType 可以用过特殊方式拉取
return this.context.session.getMsgService().getMsgByClientSeqAndTime(peer, replyMsgClientSeq, replyMsgTime);
@@ -155,6 +137,7 @@ 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: [],
@@ -166,30 +149,7 @@ export class NTQQMsgApi {
pageLimit: 20000,
});
}
async queryFirstMsgBySenderTime(peer: Peer, SendersUid: string[], filterMsgFromTime: string, filterMsgToTime: string) {
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', {
chatInfo: peer,
filterMsgType: [],
filterSendersUid: SendersUid,
filterMsgToTime: filterMsgToTime,
filterMsgFromTime: filterMsgFromTime,
isReverseOrder: true,
isIncludeCurrent: true,
pageLimit: 20000,
});
}
async queryFirstMsgByTime(peer: Peer, filterMsgFromTime: string, filterMsgToTime: string) {
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', {
chatInfo: peer,
filterMsgType: [],
filterSendersUid: [],
filterMsgToTime: filterMsgToTime,
filterMsgFromTime: filterMsgFromTime,
isReverseOrder: true,
isIncludeCurrent: true,
pageLimit: 20000,
});
}
async setMsgRead(peer: Peer) {
return this.context.session.getMsgService().setMsgRead(peer);
}

View File

@@ -1,76 +1,4 @@
{
"3.1.2-13107": {
"appid": 537146866,
"qua": "V1_LNX_NQ_3.1.2-13107_RDM_B"
},
"3.2.10-25765": {
"appid": 537234773,
"qua": "V1_LNX_NQ_3.2.10_25765_GW_B"
},
"3.2.12-26702": {
"appid": 537237950,
"qua": "V1_LNX_NQ_3.2.12_26702_GW_B"
},
"3.2.12-26740": {
"appid": 537237950,
"qua": "V1_LNX_NQ_3.2.12_26740_GW_B"
},
"3.2.12-26909": {
"appid": 537237923,
"qua": "V1_LNX_NQ_3.2.12_26909_GW_B"
},
"3.2.12-27187": {
"appid": 537240645,
"qua": "V1_LNX_NQ_3.2.12_27187_GW_B"
},
"3.2.12-27206": {
"appid": 537240645,
"qua": "V1_LNX_NQ_3.2.12_27206_GW_B"
},
"3.2.12-27254": {
"appid": 537240795,
"qua": "V1_LNX_NQ_3.2.12_27254_GW_B"
},
"9.9.11-24815": {
"appid": 537226656,
"qua": "V1_WIN_NQ_9.9.11_24815_GW_B"
},
"9.9.12-25493": {
"appid": 537231759,
"qua": "V1_WIN_NQ_9.9.12_25493_GW_B"
},
"9.9.12-25765": {
"appid": 537234702,
"qua": "V1_WIN_NQ_9.9.12_25765_GW_B"
},
"9.9.12-26299": {
"appid": 537234826,
"qua": "V1_WIN_NQ_9.9.12_26299_GW_B"
},
"9.9.12-26339": {
"appid": 537234826,
"qua": "V1_WIN_NQ_9.9.12_26339_GW_B"
},
"9.9.12-26466": {
"appid": 537234826,
"qua": "V1_WIN_NQ_9.9.12_26466_GW_B"
},
"9.9.15-26702": {
"appid": 537237765,
"qua": "V1_WIN_NQ_9.9.15_26702_GW_B"
},
"9.9.15-26740": {
"appid": 537237765,
"qua": "V1_WIN_NQ_9.9.15_26740_GW_B"
},
"3.2.12-27556": {
"appid": 537243600,
"qua": "V1_LNX_NQ_3.2.12_27556_GW_B"
},
"3.2.12-27597": {
"appid": 537243600,
"qua": "V1_LNX_NQ_3.2.12_27597_GW_B"
},
"9.9.15-28060": {
"appid": 537246092,
"qua": "V1_WIN_NQ_9.9.15_28060_GW_B"
@@ -346,221 +274,5 @@
"9.9.19-34606": {
"appid": 537282307,
"qua": "V1_WIN_NQ_9.9.19_34606_GW_B"
},
"9.9.19-34740": {
"appid": 537290691,
"qua": "V1_WIN_NQ_9.9.19_34740_GW_B"
},
"3.2.17-34740": {
"appid": 537290727,
"qua": "V1_LNX_NQ_3.2.17_34740_GW_B"
},
"8.9.50-Android": {
"appid": 537155551,
"qua": "V1_AND_SQ_8.9.50_3898_YYB_D"
},
"9.0.1-Watch": {
"appid": 537214131,
"qua": "V1_AND_SQ_8.9.68_0_RDM_B"
},
"6.8.2-21241": {
"appid": 537128930,
"qua": "V1_IOS_SQ_6.8.2_21241_YYB_D"
},
"8.8.88-Android": {
"appid": 537118044,
"qua": "V1_IOS_SQ_8.8.88_2770_YYB_D"
},
"8.9.90-Android": {
"appid": 537185007,
"qua": "V1_AND_SQ_8.9.90_4938_YYB_D"
},
"8.9.33-Android": {
"appid": 537151682,
"qua": "V1_AND_SQ_8.9.33_3898_YYB_D"
},
"3.5.1-Tim": {
"appid": 537150355,
"qua": "V1_AND_SQ_8.3.9_351_TIM_D"
},
"8.9.58-Android": {
"appid": 537163194,
"qua": "V1_AND_SQ_8.9.58_4108_YYB_D"
},
"8.9.63-Android": {
"appid": 537163194,
"qua": "V1_AND_SQ_8.9.63_4194_YYB_D"
},
"8.9.68-Android": {
"appid": 537168313,
"qua": "V1_AND_SQ_8.9.68_4264_YYB_D"
},
"8.9.70-Android": {
"appid": 537169928,
"qua": "V1_AND_SQ_8.9.70_4330_YYB_D"
},
"8.9.71-Android": {
"appid": 537170024,
"qua": "V1_AND_SQ_8.9.71_4332_YYB_D"
},
"8.9.73-Android": {
"appid": 537171689,
"qua": "V1_AND_SQ_8.9.73_4416_YYB_D"
},
"8.9.75-Android": {
"appid": 537173381,
"qua": "V1_AND_SQ_8.9.75_4482_YYB_D"
},
"8.9.76-Android": {
"appid": 537173477,
"qua": "V1_AND_SQ_8.9.76_4484_YYB_D"
},
"8.9.78-Android": {
"appid": 537175315,
"qua": "V1_AND_SQ_8.9.78_4548_YYB_D"
},
"8.9.80-Android": {
"appid": 537176863,
"qua": "V1_AND_SQ_8.9.80_4614_YYB_D"
},
"8.9.83-Android": {
"appid": 537178646,
"qua": "V1_AND_SQ_8.9.83_4680_YYB_D"
},
"8.9.85-Android": {
"appid": 537180568,
"qua": "V1_AND_SQ_8.9.85_4766_YYB_D"
},
"8.9.88-Android": {
"appid": 537182769,
"qua": "V1_AND_SQ_8.9.88_4852_YYB_D"
},
"8.9.93-Android": {
"appid": 537187398,
"qua": "V1_AND_SQ_8.9.93_5028_YYB_D"
},
"9.0.0-Android": {
"appid": 537194351,
"qua": "V1_AND_SQ_9.0.0_5282_YYB_D"
},
"9.0.8-Android": {
"appid": 537200218,
"qua": "V1_AND_SQ_9.0.8_5540_YYB_D"
},
"9.0.17-Android": {
"appid": 537204056,
"qua": "V1_AND_SQ_9.0.17_5712_YYB_D"
},
"9.0.25-Android": {
"appid": 537210084,
"qua": "V1_AND_SQ_9.0.25_5942_YYB_D"
},
"9.0.35-Android": {
"appid": 537215475,
"qua": "V1_AND_SQ_9.0.35_6150_YYB_D"
},
"9.0.50-Android": {
"appid": 537217916,
"qua": "V1_AND_SQ_9.0.50_6258_YYB_D"
},
"9.0.56-Android": {
"appid": 537220323,
"qua": "V1_AND_SQ_9.0.56_6372_YYB_D"
},
"9.0.60-Android": {
"appid": 537222797,
"qua": "V1_AND_SQ_9.0.60_6478_YYB_D"
},
"9.0.65-Android": {
"appid": 537225139,
"qua": "V1_AND_SQ_9.0.65_6588_YYB_D"
},
"9.0.70-Android": {
"appid": 537228487,
"qua": "V1_AND_SQ_9.0.70_6698_YYB_D"
},
"9.0.81-Android": {
"appid": 537233527,
"qua": "V1_AND_SQ_9.0.81_6922_YYB_D"
},
"9.0.85-Android": {
"appid": 537236316,
"qua": "V1_AND_SQ_9.0.85_7068_YYB_D"
},
"9.0.90-Android":{
"appid": 537239255,
"qua": "V1_AND_SQ_9.0.90_7218_YYB_D"
},
"9.0.95-Android": {
"appid": 537242075,
"qua": "V1_AND_SQ_9.0.95_7368_YYB_D"
},
"9.1.0-Android": {
"appid": 537244893,
"qua": "V1_AND_SQ_9.1.0_7518_YYB_D"
},
"9.1.20-Android": {
"appid": 537257414,
"qua": "V1_AND_SQ_9.1.20_8198_YYB_D"
},
"9.1.25-Android": {
"appid": 537260030,
"qua": "V1_AND_SQ_9.1.25_8368_YYB_D"
},
"9.1.67-Android": {
"appid": 537284101,
"qua": "V1_AND_SQ_9.1.67_9728_YYB_D"
},
"9.1.70-Android": {
"appid": 537285947,
"qua": "V1_AND_SQ_9.1.70_9898_YYB_D"
},
"9.1.65-Android": {
"appid": 537278302,
"qua": "V1_AND_SQ_9.1.65_9558_YYB_D"
},
"9.1.60-Android": {
"appid": 537275636,
"qua": "V1_AND_SQ_9.1.60_9388_YYB_D"
},
"9.1.55-Android": {
"appid": 537272835,
"qua": "V1_AND_SQ_9.1.55_9218_YYB_D"
},
"9.1.52-Android": {
"appid": 537270265,
"qua": "V1_AND_SQ_9.1.52_9054_YYB_D"
},
"9.1.50-Android": {
"appid": 537270031,
"qua": "V1_AND_SQ_9.1.50_9048_YYB_D"
},
"9.1.5-Android": {
"appid": 537247779,
"qua": "V1_AND_SQ_9.1.5_7688_YYB_D"
},
"9.1.35-Android": {
"appid": 537265576,
"qua": "V1_AND_SQ_9.1.35_8708_YYB_D"
},
"9.1.31-Android": {
"appid": 537262715,
"qua": "V1_AND_SQ_9.1.31_8542_YYB_D"
},
"9.1.30-Android": {
"appid": 537262559,
"qua": "V1_AND_SQ_9.1.30_8538_YYB_D"
},
"9.1.16-Android": {
"appid": 537254305,
"qua": "V1_AND_SQ_9.1.16_8032_YYB_D"
},
"9.1.15-Android": {
"appid": 537254149,
"qua": "V1_AND_SQ_9.1.15_8028_YYB_D"
},
"9.1.10-Android": {
"appid": 537251380,
"qua": "V1_AND_SQ_9.1.10_7858_YYB_D"
}
}

View File

@@ -350,9 +350,5 @@
"3.2.17-34606-arm64": {
"send": "7711270",
"recv": "7714BA0"
},
"9.9.19-34740-x64": {
"send": "3BDD8D0",
"recv": "3BE20D0"
}
}

View File

@@ -96,7 +96,7 @@ export interface NodeIKernelSearchListener {
}): any;
onSearchMsgKeywordsResult(params: {
searchId: number,
searchId: string,
hasMore: boolean,
resultItems: Array<{
msgId: string,

View File

@@ -8,8 +8,7 @@ import { LRUCache } from '@/common/lru-cache';
import { LogStack } from '@/core/packet/context/clientContext';
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
import { PacketLogger } from '@/core/packet/context/loggerContext';
import { ProtoBufDecode } from 'napcat.protobuf';
export const MsgData = new LRUCache<string, string>(5000);
// 0 send 1 recv
export interface NativePacketExportType {
InitHook?: (send: string, recv: string, callback: (type: number, uin: string, cmd: string, seq: number, hex_data: string) => void, o3_hook: boolean) => boolean;
@@ -57,19 +56,6 @@ export class NativePacketClient extends IPacketClient {
// console.log('callback:', callback, trace_id);
callback?.({ seq, cmd, hex_data });
}
if (cmd === 'trpc.msg.olpush.OlPushService.MsgPush') {
try {
let msg_info = ProtoBufDecode(Buffer.from(hex_data, 'hex')) as any;
let group_id = (msg_info['1']['1']['8']['1'] as number).toString()
let msg_seq = (msg_info['1']['2']['5'] as number).toString()
let msg_id = group_id + '_' + msg_seq;
MsgData.put(msg_id, hex_data);
console.log('add msgid:', msg_id);
} catch (error) {
console.log('error:', error);
}
}
}, this.napcore.config.o3HookMode == 1);
this.available = true;
}

View File

@@ -6,14 +6,13 @@ import {
PacketMsgFileElement,
PacketMsgPicElement,
PacketMsgPttElement,
PacketMsgReplyElement,
PacketMsgVideoElement,
PacketMsgVideoElement
} from '@/core/packet/message/element';
import { ChatType, MsgSourceType, NTMsgType, RawMessage } from '@/core';
import { MiniAppRawData, MiniAppReqParams } from '@/core/packet/entities/miniApp';
import { AIVoiceChatType } from '@/core/packet/entities/aiChat';
import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core';
import { IndexNode, LongMsgResult, MsgInfo, PushMsgBody } from '@/core/packet/transformer/proto';
import { IndexNode, LongMsgResult, MsgInfo } from '@/core/packet/transformer/proto';
import { OidbPacket } from '@/core/packet/transformer/base';
import { ImageOcrResult } from '@/core/packet/entities/ocrResult';
import { gunzipSync } from 'zlib';
@@ -77,24 +76,22 @@ export class PacketOperationContext {
async UploadResources(msg: PacketMsg[], groupUin: number = 0) {
const chatType = groupUin ? ChatType.KCHATTYPEGROUP : ChatType.KCHATTYPEC2C;
const peerUid = groupUin ? String(groupUin) : this.context.napcore.basicInfo.uid;
const reqList = msg.flatMap((m) =>
m.msg
.map((e) => {
if (e instanceof PacketMsgPicElement) {
return this.context.highway.uploadImage({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgVideoElement) {
return this.context.highway.uploadVideo({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgPttElement) {
return this.context.highway.uploadPtt({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgFileElement) {
return this.context.highway.uploadFile({ chatType, peerUid }, e);
}
return null;
})
.filter(Boolean)
const reqList = msg.flatMap(m =>
m.msg.map(e => {
if (e instanceof PacketMsgPicElement) {
return this.context.highway.uploadImage({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgVideoElement) {
return this.context.highway.uploadVideo({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgPttElement) {
return this.context.highway.uploadPtt({ chatType, peerUid }, e);
} else if (e instanceof PacketMsgFileElement) {
return this.context.highway.uploadFile({ chatType, peerUid }, e);
}
return null;
}).filter(Boolean)
);
const res = await Promise.allSettled(reqList);
this.context.logger.info(`上传资源${res.length}个,失败${res.filter((r) => r.status === 'rejected').length}`);
this.context.logger.info(`上传资源${res.length}个,失败${res.filter(r => r.status === 'rejected').length}`);
res.forEach((result, index) => {
if (result.status === 'rejected') {
this.context.logger.error(`上传第${index + 1}个资源失败:${result.reason.stack}`);
@@ -103,13 +100,10 @@ export class PacketOperationContext {
}
async UploadImage(img: PacketMsgPicElement) {
await this.context.highway.uploadImage(
{
chatType: ChatType.KCHATTYPEC2C,
peerUid: this.context.napcore.basicInfo.uid,
},
img
);
await this.context.highway.uploadImage({
chatType: ChatType.KCHATTYPEC2C,
peerUid: this.context.napcore.basicInfo.uid
}, img);
const index = img.msgInfo?.msgInfoBody?.at(0)?.index;
if (!index) {
throw new Error('img.msgInfo?.msgInfoBody![0].index! is undefined');
@@ -143,66 +137,24 @@ export class PacketOperationContext {
coordinates: item.polygon.coordinates.map((c) => {
return {
x: c.x,
y: c.y,
y: c.y
};
}),
};
}),
language: res.ocrRspBody.language,
language: res.ocrRspBody.language
} as ImageOcrResult;
}
private async SendPreprocess(msg: PacketMsg[], groupUin: number = 0) {
const ps = msg.map((m) => {
return m.msg.map(async(e) => {
if (e instanceof PacketMsgReplyElement && !e.targetElems) {
this.context.logger.debug(`Cannot find reply element's targetElems, prepare to fetch it...`);
if (!e.targetPeer?.peerUid) {
this.context.logger.error(`targetPeer is undefined!`);
}
let targetMsg: NapProtoEncodeStructType<typeof PushMsgBody>[] | undefined;
if (e.isGroupReply) {
targetMsg = await this.FetchGroupMessage(+(e.targetPeer?.peerUid ?? 0), e.targetMessageSeq, e.targetMessageSeq);
} else {
targetMsg = await this.FetchC2CMessage(await this.context.napcore.basicInfo.uin2uid(e.targetUin), e.targetMessageSeq, e.targetMessageSeq);
}
e.targetElems = targetMsg.at(0)?.body?.richText?.elems;
e.targetSourceMsg = targetMsg.at(0);
}
});
}).flat();
await Promise.all(ps)
await this.UploadResources(msg, groupUin);
}
async FetchGroupMessage(groupUin: number, startSeq: number, endSeq: number): Promise<NapProtoDecodeStructType<typeof PushMsgBody>[]> {
const req = trans.FetchGroupMessage.build(groupUin, startSeq, endSeq);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.FetchGroupMessage.parse(resp);
return res.body.messages
}
async FetchC2CMessage(targetUid: string, startSeq: number, endSeq: number): Promise<NapProtoDecodeStructType<typeof PushMsgBody>[]> {
const req = trans.FetchC2CMessage.build(targetUid, startSeq, endSeq);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.FetchC2CMessage.parse(resp);
return res.messages
}
async UploadForwardMsg(msg: PacketMsg[], groupUin: number = 0) {
await this.SendPreprocess(msg, groupUin);
await this.UploadResources(msg, groupUin);
const req = trans.UploadForwardMsg.build(this.context.napcore.basicInfo.uid, msg, groupUin);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.UploadForwardMsg.parse(resp);
return res.result.resId;
}
async MoveGroupFile(
groupUin: number,
fileUUID: string,
currentParentDirectory: string,
targetParentDirectory: string
) {
async MoveGroupFile(groupUin: number, fileUUID: string, currentParentDirectory: string, targetParentDirectory: string) {
const req = trans.MoveGroupFile.build(groupUin, fileUUID, currentParentDirectory, targetParentDirectory);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.MoveGroupFile.parse(resp);
@@ -251,17 +203,12 @@ export class PacketOperationContext {
return res.content.map((item) => {
return {
category: item.category,
voices: item.voices,
voices: item.voices
};
});
}
async GetAiVoice(
groupUin: number,
voiceId: string,
text: string,
chatType: AIVoiceChatType
): Promise<NapProtoDecodeStructType<typeof MsgInfo>> {
async GetAiVoice(groupUin: number, voiceId: string, text: string, chatType: AIVoiceChatType): Promise<NapProtoDecodeStructType<typeof MsgInfo>> {
let reqTime = 0;
const reqMaxTime = 30;
const sessionId = crypto.randomBytes(4).readUInt32BE(0);
@@ -289,7 +236,6 @@ export class PacketOperationContext {
if (!main?.actionData.msgBody) {
throw new Error('msgBody is empty');
}
this.context.logger.debug('rawChains ', inflate.toString('hex'));
const messagesPromises = main.actionData.msgBody.map(async (msg) => {
if (!msg?.body?.richText?.elems) {
@@ -305,12 +251,12 @@ export class PacketOperationContext {
const groupUin = msg?.responseHead.grp?.groupUin ?? 0;
element.picElement = {
...element.picElement,
originImageUrl: await this.GetGroupImageUrl(groupUin, index!),
originImageUrl: await this.GetGroupImageUrl(groupUin, index!)
};
} else {
element.picElement = {
...element.picElement,
originImageUrl: await this.GetImageUrl(this.context.napcore.basicInfo.uid, index!),
originImageUrl: await this.GetImageUrl(this.context.napcore.basicInfo.uid, index!)
};
}
return element;
@@ -323,7 +269,7 @@ export class PacketOperationContext {
elements: elements,
guildId: '',
isOnlineMsg: false,
msgId: '7467703692092974645', // TODO: no necessary
msgId: '7467703692092974645', // TODO: no necessary
msgRandom: '0',
msgSeq: String(msg.contentHead.sequence ?? 0),
msgTime: String(msg.contentHead.timeStamp ?? 0),

View File

@@ -24,15 +24,12 @@ export class PacketMsgBuilder {
}
return {
responseHead: {
fromUin: node.senderUin,
type: 0,
sigMap: 0,
toUin: 0,
fromUid: '',
fromUin: node.senderUin,
toUid: node.groupId ? undefined : selfUid,
forward: node.groupId ? undefined : {
friendName: node.senderName,
},
toUid: node.groupId ? undefined : selfUid,
grp: node.groupId ? {
groupUin: node.groupId,
memberName: node.senderName,
@@ -43,13 +40,16 @@ export class PacketMsgBuilder {
type: node.groupId ? 82 : 9,
subType: node.groupId ? undefined : 4,
divSeq: node.groupId ? undefined : 4,
autoReply: 0,
msgId: crypto.randomBytes(4).readUInt32LE(0),
sequence: crypto.randomBytes(4).readUInt32LE(0),
timeStamp: +node.time.toString().substring(0, 10),
field7: BigInt(1),
field8: 0,
field9: 0,
forward: {
field1: 0,
field2: 0,
field3: node.groupId ? 1 : 2,
field3: node.groupId ? 0 : 2,
unknownBase64: avatar,
avatar: avatar
}

View File

@@ -10,7 +10,6 @@ import {
MsgInfo,
NotOnlineImage,
OidbSvcTrpcTcp0XE37_800Response,
PushMsgBody,
QBigFaceExtra,
QSmallFaceExtra,
} from '@/core/packet/transformer/proto';
@@ -30,8 +29,7 @@ import {
SendReplyElement,
SendMultiForwardMsgElement,
SendTextElement,
SendVideoElement,
Peer
SendVideoElement
} from '@/core';
import {ForwardMsgBuilder} from '@/common/forward-msg-builder';
import {PacketMsg, PacketSendMsgElement} from '@/core/packet/message/message';
@@ -148,40 +146,41 @@ export class PacketMsgAtElement extends PacketMsgTextElement {
}
export class PacketMsgReplyElement extends IPacketMsgElement<SendReplyElement> {
time: number;
targetMessageId: bigint;
targetMessageSeq: number;
targetMessageClientSeq: number;
messageId: bigint;
messageSeq: number;
messageClientSeq: number;
targetUin: number;
targetUid: string;
targetElems?: NapProtoEncodeStructType<typeof Elem>[];
targetSourceMsg?: NapProtoEncodeStructType<typeof PushMsgBody>;
targetPeer?: Peer;
time: number;
elems: PacketMsg[];
constructor(element: SendReplyElement) {
super(element);
this.time = +(element.replyElement.replyMsgTime ?? Math.floor(Date.now() / 1000));
this.targetMessageId = BigInt(element.replyElement.replayMsgId ?? 0);
this.targetMessageSeq = +(element.replyElement.replayMsgSeq ?? 0);
this.targetMessageClientSeq = +(element.replyElement.replyMsgClientSeq ?? 0);
this.messageId = BigInt(element.replyElement.replayMsgId ?? 0);
this.messageSeq = +(element.replyElement.replayMsgSeq ?? 0);
this.messageClientSeq = +(element.replyElement.replyMsgClientSeq ?? 0);
this.targetUin = +(element.replyElement.senderUin ?? 0);
this.targetUid = element.replyElement.senderUidStr ?? '';
this.targetPeer = element.replyElement._replyMsgPeer;
this.time = +(element.replyElement.replyMsgTime ?? 0);
this.elems = []; // TODO: in replyElement.sourceMsgTextElems
}
get isGroupReply(): boolean {
return this.targetMessageClientSeq === 0;
return this.messageClientSeq === 0;
}
override buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
return [{
srcMsg: {
origSeqs: [this.isGroupReply ? this.targetMessageSeq : this.targetMessageClientSeq],
origSeqs: [this.isGroupReply ? this.messageClientSeq : this.messageSeq],
senderUin: BigInt(this.targetUin),
time: this.time,
elems: this.targetElems ?? [],
sourceMsg: new NapProtoMsg(PushMsgBody).encode(this.targetSourceMsg ?? {}),
toUin: BigInt(0),
elems: [], // TODO: in replyElement.sourceMsgTextElems
pbReserve: {
messageId: this.messageId,
},
toUin: BigInt(this.targetUin),
type: 1,
}
}];
}

View File

@@ -1,27 +0,0 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
class FetchC2CMessage extends PacketTransformer<typeof proto.SsoGetC2cMsgResponse> {
constructor() {
super();
}
build(targetUid: string, startSeq: number, endSeq: number): OidbPacket {
const req = new NapProtoMsg(proto.SsoGetC2cMsg).encode({
friendUid: targetUid,
startSequence: startSeq,
endSequence: endSeq,
});
return {
cmd: 'trpc.msg.register_proxy.RegisterProxy.SsoGetC2cMsg',
data: PacketHexStrBuilder(req)
};
}
parse(data: Buffer) {
return new NapProtoMsg(proto.SsoGetC2cMsgResponse).decode(data);
}
}
export default new FetchC2CMessage();

View File

@@ -1,30 +0,0 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
class FetchGroupMessage extends PacketTransformer<typeof proto.SsoGetGroupMsgResponse> {
constructor() {
super();
}
build(groupUin: number, startSeq: number, endSeq: number): OidbPacket {
const req = new NapProtoMsg(proto.SsoGetGroupMsg).encode({
info: {
groupUin: groupUin,
startSequence: startSeq,
endSequence: endSeq
},
direction: true
});
return {
cmd: 'trpc.msg.register_proxy.RegisterProxy.SsoGetGroupMsg',
data: PacketHexStrBuilder(req)
};
}
parse(data: Buffer) {
return new NapProtoMsg(proto.SsoGetGroupMsgResponse).decode(data);
}
}
export default new FetchGroupMessage();

View File

@@ -1,4 +1,2 @@
export { default as UploadForwardMsg } from './UploadForwardMsg';
export { default as FetchGroupMessage } from './FetchGroupMessage';
export { default as FetchC2CMessage } from './FetchC2CMessage';
export { default as DownloadForwardMsg } from './DownloadForwardMsg';
export { default as DownloadForwardMsg } from './DownloadForwardMsg';

View File

@@ -13,15 +13,13 @@ import {
export const ContentHead = {
type: ProtoField(1, ScalarType.UINT32),
subType: ProtoField(2, ScalarType.UINT32, true),
c2cCmd: ProtoField(3, ScalarType.UINT32, true),
ranDom: ProtoField(4, ScalarType.UINT32, true),
divSeq: ProtoField(3, ScalarType.UINT32, true),
msgId: ProtoField(4, ScalarType.UINT32, true),
sequence: ProtoField(5, ScalarType.UINT32, true),
timeStamp: ProtoField(6, ScalarType.UINT32, true),
pkgNum: ProtoField(7, ScalarType.UINT64, true),
pkgIndex: ProtoField(8, ScalarType.UINT32, true),
divSeq: ProtoField(9, ScalarType.UINT32, true),
autoReply: ProtoField(10, ScalarType.UINT32),
ntMsgSeq: ProtoField(10, ScalarType.UINT32, true),
field7: ProtoField(7, ScalarType.UINT64, true),
field8: ProtoField(8, ScalarType.UINT32, true),
field9: ProtoField(9, ScalarType.UINT32, true),
newId: ProtoField(12, ScalarType.UINT64, true),
forward: ProtoField(15, () => ForwardHead, true),
};

View File

@@ -54,7 +54,7 @@ export interface NodeIKernelSearchService {
cancelSearchChatMsgs(...args: unknown[]): unknown;// needs 3 arguments
searchMsgWithKeywords(keyWords: string[], param: Peer & { searchFields: number, pageLimit: number }): number;
searchMsgWithKeywords(keyWords: string[], param: Peer & { searchFields: number, pageLimit: number }): Promise<GeneralCallResult>;
searchMoreMsgWithKeywords(...args: unknown[]): unknown;// needs 1 arguments

View File

@@ -1,15 +1,4 @@
import {
ElementType,
MessageElement,
NTGrayTipElementSubTypeV2,
PicSubType,
PicType,
TipAioOpGrayTipElement,
TipGroupElement,
NTVideoType,
FaceType,
Peer
} from './msg';
import { ElementType, MessageElement, NTGrayTipElementSubTypeV2, PicSubType, PicType, TipAioOpGrayTipElement, TipGroupElement, NTVideoType, FaceType } from './msg';
type ElementFullBase = Omit<MessageElement, 'elementType' | 'elementId' | 'extBufForUI'>;
@@ -224,9 +213,6 @@ export interface ReplyElement {
senderUidStr?: string;
replyMsgTime?: string;
replyMsgClientSeq?: string;
// HACK: Attributes that were not originally available,
// but were added due to NTQQ and NapCat's internal implementation, are used to supplement NapCat
_replyMsgPeer?: Peer;
}
export interface CalendarElement {

View File

@@ -403,7 +403,7 @@ export interface NTGroupGrayMember {
}
/**
* 群灰色提示邀请者和被邀请者接口
*
*
* */
export interface NTGroupGrayInviterAndInvite {
invited: NTGroupGrayMember;
@@ -501,7 +501,6 @@ export interface RawMessage {
elements: MessageElement[];// 消息元素
sourceType: MsgSourceType;// 消息来源类型
isOnlineMsg: boolean;// 是否为在线消息
clientSeq?: string;
}
/**
@@ -566,4 +565,4 @@ export enum FaceType {
AniSticke = 3, // 动画贴纸
Lottie = 4,// 新格式表情
Poke = 5 // 可变Poke
}
}

View File

@@ -467,8 +467,6 @@ export class OneBotMsgApi {
replayMsgId: replyMsg.msgId, // raw.msgId
senderUin: replyMsg.senderUin,
senderUinStr: replyMsg.senderUin,
replyMsgClientSeq: replyMsg.clientSeq,
_replyMsgPeer: replyMsgM.Peer
},
} :
undefined;

View File

@@ -115,7 +115,7 @@ export class NapCatOneBot11Adapter {
// 注册Plugin 如果需要基于NapCat进行快速开发
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) {
if (key.enable) {

View File

@@ -15,7 +15,7 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
messagePostFormat: 'array',
reportSelfMessage: false,
enable: true,
debug: true,
debug: false,
};
super(name, config, core, obContext, actions);
}

View File

@@ -180,7 +180,7 @@ export interface OB11MessageNode {
id?: string;
user_id?: number | string; // number
uin?: number | string; // number, compatible with go-cqhttp
nickname?: string;
nickname: string;
name?: string; // compatible with go-cqhttp
content: OB11MessageMixType;
source?: string;

View File

@@ -0,0 +1,203 @@
/**
* TapTap账号管理类
*/
import * as fs from 'fs';
import * as path from 'path';
export class TapAccountManager {
private userBindTapMap = new Map<string, {
default: string;
list: Array<{
id: string;
name: string;
}>;
}>();
private dataFilePath: string;
constructor(dataDir: string = './data') {
// 确保数据目录存在
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
this.dataFilePath = path.join(dataDir, 'tap_accounts.json');
this.loadData();
}
saveData() {
try {
// 将 Map 转换为可序列化的对象
const dataObj: Record<string, any> = {};
this.userBindTapMap.forEach((value, key) => {
dataObj[key] = value;
});
fs.writeFileSync(
this.dataFilePath,
JSON.stringify(dataObj, null, 2),
'utf-8'
);
} catch (error) {
console.error('保存 TapTap 账号数据失败:', error);
}
}
loadData() {
try {
if (fs.existsSync(this.dataFilePath)) {
const fileContent = fs.readFileSync(this.dataFilePath, 'utf-8');
const dataObj = JSON.parse(fileContent);
// 清空当前数据并加载新数据
this.userBindTapMap.clear();
for (const [key, value] of Object.entries(dataObj)) {
this.userBindTapMap.set(key, value as any);
}
console.log('TapTap 账号数据已加载');
} else {
console.log('未找到 TapTap 账号数据文件,将创建新的数据存储');
}
} catch (error) {
console.error('加载 TapTap 账号数据失败:', error);
}
}
/**
* 绑定TapTap ID
*/
async bindAccount(userId: string, tapId: string, characterName: string): Promise<boolean> {
// 用户首次绑定账号
if (!this.userBindTapMap.has(userId)) {
this.userBindTapMap.set(userId, {
default: tapId,
list: [{
id: tapId,
name: characterName
}]
});
await this.saveData();
return true;
}
// 用户已有账号,追加新账号
const userData = this.userBindTapMap.get(userId)!;
// 检查是否已经绑定过该ID
if (!userData.list.some(item => item.id === tapId)) {
userData.list.push({
id: tapId,
name: characterName
});
userData.default = tapId; // 新绑定的账号自动设为默认
await this.saveData();
return true;
}
// 账号已存在
return false;
}
/**
* 切换默认TapTap ID
*/
async switchAccount(userId: string, tapId: string): Promise<boolean> {
const userData = this.userBindTapMap.get(userId);
if (!userData) return false;
const accountItem = userData.list.find(item => item.id === tapId);
if (!accountItem) return false;
userData.default = tapId;
await this.saveData();
return true;
}
/**
* 删除绑定的TapTap ID
* @returns 删除结果、剩余默认账号信息(如果有)
*/
async deleteAccount(userId: string, tapId: string): Promise<{
success: boolean;
deletedAccount?: { id: string; name: string; };
defaultAccount?: { id: string; name: string; };
isEmpty: boolean;
}> {
const userData = this.userBindTapMap.get(userId);
if (!userData) {
return { success: false, isEmpty: true };
}
const index = userData.list.findIndex(item => item.id === tapId);
if (index === -1) {
return { success: false, isEmpty: false };
}
const deletedAccount = userData.list[index];
userData.list.splice(index, 1);
// 如果删除的是当前默认账号,则需要重新设置默认账号
if (userData.default === tapId) {
userData.default = userData.list.length > 0 ? userData.list[0]?.id ?? '' : '';
}
// 如果没有绑定账号了,则删除该用户的记录
let result;
if (userData.list.length === 0) {
this.userBindTapMap.delete(userId);
result = {
success: true,
deletedAccount,
isEmpty: true
};
} else {
const defaultAccount = userData.list.find(item => item.id === userData.default);
result = {
success: true,
deletedAccount,
defaultAccount,
isEmpty: false
};
}
await this.saveData();
return result;
}
/**
* 获取用户账号列表
*/
getAccountList(userId: string): {
hasAccounts: boolean;
accounts?: Array<{
id: string;
name: string;
isDefault: boolean;
}>;
} {
const userData = this.userBindTapMap.get(userId);
if (!userData || userData.list.length === 0) {
return { hasAccounts: false };
}
const accounts = userData.list.map(item => ({
id: item.id,
name: item.name,
isDefault: item.id === userData.default
}));
return { hasAccounts: true, accounts };
}
/**
* 获取用户当前默认账号ID
*/
getDefaultAccount(userId: string): {
hasDefault: boolean;
tapId?: string;
} {
const userData = this.userBindTapMap.get(userId);
if (!userData || !userData.default) {
return { hasDefault: false };
}
return { hasDefault: true, tapId: userData.default };
}
}

255
src/plugin/api.ts Normal file
View File

@@ -0,0 +1,255 @@
export interface GameUserDetail {
data: Data;
now: number;
success: boolean;
[property: string]: any;
}
export interface Data {
config: Config;
external_url: string;
is_bind: boolean;
list: DataList[];
next_page: string;
prev_page: string;
role_id: string;
sharing: Sharing;
show_bind_button: boolean;
[property: string]: any;
}
export interface Config {
app_icon: AppIcon;
banner: Banner;
font_class: string;
font_color: string;
label: Label;
show_bind_expired_alert: boolean;
tint: number;
title: string;
[property: string]: any;
}
export interface AppIcon {
color: string;
height: number;
medium_url: string;
original_format: string;
original_size: number;
original_url: string;
small_url: string;
url: string;
width: number;
[property: string]: any;
}
export interface Banner {
color: string;
height: number;
medium_url: string;
original_format: string;
original_size: number;
original_url: string;
small_url: string;
url: string;
width: number;
[property: string]: any;
}
export interface Label {
color: string;
medium_url: string;
original_format: string;
original_url: string;
small_url: string;
url: string;
[property: string]: any;
}
export interface DataList {
basic_module?: BasicModule;
character_module?: CharacterModule;
episode_module?: EpisodeModule;
is_sharing: boolean;
item_progress?: ItemProgress;
module_type: number;
weapon_module?: WeaponModule;
[property: string]: any;
}
export interface BasicModule {
avatar: BasicModuleAvatar;
custom_items: BasicModuleCustomItem[];
custom_title: string;
info: Info[];
name: string;
role_id: string;
subtitle: string;
[property: string]: any;
}
export interface BasicModuleAvatar {
color: string;
medium_url: string;
original_format: string;
original_url: string;
small_url: string;
url: string;
[property: string]: any;
}
export interface BasicModuleCustomItem {
is_main: boolean;
key: string;
value: string;
[property: string]: any;
}
export interface Info {
main_value: string;
name: string;
sub_value: string;
value: string;
[property: string]: any;
}
export interface CharacterModule {
custom_title: string;
list: CharacterModuleList[];
total: number;
[property: string]: any;
}
export interface CharacterModuleList {
grade: string;
image: PurpleImage;
level: number;
name: string;
talent_level: number;
[property: string]: any;
}
export interface PurpleImage {
color: string;
medium_url: string;
original_format: string;
original_url: string;
small_url: string;
url: string;
[property: string]: any;
}
export interface EpisodeModule {
custom_items: EpisodeModuleCustomItem[];
custom_title: string;
[property: string]: any;
}
export interface EpisodeModuleCustomItem {
is_main: boolean;
key: string;
value: string;
[property: string]: any;
}
export interface ItemProgress {
custom_title: string;
list: ItemProgressList[];
table_tabs: string[];
total: number;
[property: string]: any;
}
export interface ItemProgressList {
avatar: ListAvatar;
name: string;
progress: Progress;
sort: Sort;
[property: string]: any;
}
export interface ListAvatar {
color: string;
medium_url: string;
original_format: string;
original_url: string;
small_url: string;
url: string;
[property: string]: any;
}
export interface Progress {
current: number;
max: number;
[property: string]: any;
}
export interface Sort {
icon: Icon;
value: string;
[property: string]: any;
}
export interface Icon {
color: string;
medium_url: string;
original_format: string;
original_url: string;
small_url: string;
url: string;
[property: string]: any;
}
export interface WeaponModule {
custom_title: string;
list: WeaponModuleList[];
total: number;
[property: string]: any;
}
export interface WeaponModuleList {
grade: string;
image: FluffyImage;
level: number;
name: string;
props: string[];
rarity: Rarity;
[property: string]: any;
}
export interface FluffyImage {
color: string;
medium_url: string;
original_format: string;
original_url: string;
small_url: string;
url: string;
[property: string]: any;
}
export interface Rarity {
color: string;
medium_url: string;
original_format: string;
original_url: string;
small_url: string;
url: string;
[property: string]: any;
}
export interface Sharing {
description: string;
image: null;
moment_params: MomentParams;
qr_code: string;
title: string;
url: string;
[property: string]: any;
}
export interface MomentParams {
app_id: number;
group_label_id: number;
hashtag_ids: number[];
[property: string]: any;
}

195
src/plugin/canvas.ts Normal file
View File

@@ -0,0 +1,195 @@
import { createCanvas, loadImage, GlobalFonts } from '@napi-rs/canvas';
interface MenuCommand {
command: string;
description: string;
highlight?: boolean;
}
/**
* 生成REVERSE.1999帮助菜单图片 (自适应美化版)
* @returns 生成的图片的base64编码
*/
export async function generate1999HelpMenu(): Promise<string> {
try {
// 字体注册
const fontsToTry = [
{ path: 'C:\\Windows\\Fonts\\msyh.ttc', name: 'Microsoft YaHei' },
{ path: 'C:\\Windows\\Fonts\\msyhbd.ttc', name: 'Microsoft YaHei Bold' },
{ path: 'C:\\Windows\\Fonts\\simhei.ttf', name: 'SimHei' }
];
let fontFamily = 'sans-serif';
let fontFamilyBold = 'sans-serif';
for (const font of fontsToTry) {
try {
if (!GlobalFonts.has(font.name)) {
GlobalFonts.registerFromPath(font.path, font.name);
}
if (font.name.includes('Bold')) {
fontFamilyBold = font.name;
} else if (fontFamily === 'sans-serif') {
fontFamily = font.name;
}
} catch { }
}
if (fontFamilyBold === 'sans-serif') fontFamilyBold = fontFamily;
// 画布设置
const width = 2560;
const height = 1440;
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
// 背景处理
const backgroundPath = "E:\\NewDevelop\\NapCatQQ\\src\\canvas\\image\\normal\\01.jpg";
try {
const backgroundImage = await loadImage(backgroundPath);
const scale = Math.max(width / backgroundImage.width, height / backgroundImage.height);
const scaledWidth = backgroundImage.width * scale;
const scaledHeight = backgroundImage.height * scale;
const x = (width - scaledWidth) / 2;
const y = (height - scaledHeight) / 2;
ctx.drawImage(backgroundImage, x, y, scaledWidth, scaledHeight);
ctx.save();
ctx.filter = 'blur(20px) brightness(0.75)';
ctx.drawImage(backgroundImage, x, y, scaledWidth, scaledHeight);
ctx.restore();
} catch {
const gradient = ctx.createLinearGradient(0, 0, width, height);
gradient.addColorStop(0, '#2a2a40');
gradient.addColorStop(1, '#4a3a60');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
}
// 半透明叠加层
ctx.fillStyle = 'rgba(15, 15, 25, 0.65)';
ctx.fillRect(0, 0, width, height);
// 命令列表
const commands: MenuCommand[] = [
{ command: '#1999 绑定 <TapTap ID>', description: '将您的 TapTap ID 与机器人绑定', highlight: true },
{ command: '#1999 切换 <TapTap ID>', description: '切换当前操作的 TapTap ID' },
{ command: '#1999 删除 <TapTap ID>', description: '解除指定的 TapTap ID 绑定' },
{ command: '#1999 账号', description: '查看所有已绑定的 TapTap 账号', highlight: true },
{ command: '#1999 信息', description: '查询当前选中账号的游戏信息', highlight: true },
{ command: '#1999 心相', description: '浏览当前账号拥有的心相详情' },
{ command: '#1999 角色', description: '浏览当前账号拥有的角色详情' },
{ command: '#1999 帮助', description: '显示此帮助菜单' }
];
// 动态计算卡片宽度
ctx.font = `bold 40px ${fontFamilyBold}`;
let maxCommandWidth = 0;
let maxDescWidth = 0;
for (const cmd of commands) {
maxCommandWidth = Math.max(maxCommandWidth, ctx.measureText(cmd.command).width);
ctx.font = `32px ${fontFamily}`;
maxDescWidth = Math.max(maxDescWidth, ctx.measureText(cmd.description).width);
ctx.font = `bold 40px ${fontFamilyBold}`;
}
const baseCardWidth = Math.max(maxCommandWidth, maxDescWidth) + 120;
const minCardWidth = 420;
const maxCardWidth = Math.min(baseCardWidth, width * 0.38);
const cardWidth = Math.max(minCardWidth, Math.min(maxCardWidth, baseCardWidth));
// 卡片参数
const cardHeight = Math.floor(height * 0.07) + 44;
const cardBorderRadius = 24;
const cardShadow = 'rgba(60, 40, 120, 0.18)';
const textPaddingLeft = 38;
// 自适应间距,最大不超过指定值
const maxColGap = 44;
const maxRowGap = 32;
const minColGap = 24;
const minRowGap = 18;
// 动态计算列数,保证整体不空旷且不挤
let cols = Math.min(commands.length, Math.floor((width - 160) / (cardWidth + minColGap)));
cols = Math.max(1, cols);
let colGap = Math.floor((width - cols * cardWidth) / (cols + 1));
colGap = Math.max(minColGap, Math.min(colGap, maxColGap));
const rows = Math.ceil(commands.length / cols);
let rowGap = Math.floor((height - rows * cardHeight - 120) / (rows + 1));
rowGap = Math.max(minRowGap, Math.min(rowGap, maxRowGap));
// 计算整体卡片区尺寸,实现居中
const cardsAreaWidth = cols * cardWidth + (cols - 1) * colGap;
const cardsAreaHeight = rows * cardHeight + (rows - 1) * rowGap;
const cardsStartX = Math.floor((width - cardsAreaWidth) / 2);
const cardsStartY = Math.floor((height - cardsAreaHeight) / 2);
// 绘制命令卡片
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i];
if (!cmd) continue;
const col = i % cols;
const row = Math.floor(i / cols);
const x = cardsStartX + col * (cardWidth + colGap);
const y = cardsStartY + row * (cardHeight + rowGap);
// 卡片阴影
ctx.save();
ctx.shadowColor = cardShadow;
ctx.shadowBlur = 16;
ctx.shadowOffsetY = 6;
// 卡片背景
ctx.beginPath();
ctx.roundRect(x, y, cardWidth, cardHeight, cardBorderRadius);
ctx.closePath();
if (cmd.highlight) {
const cardGradient = ctx.createLinearGradient(x, y, x + cardWidth, y);
cardGradient.addColorStop(0, 'rgba(110, 80, 250, 0.60)');
cardGradient.addColorStop(1, 'rgba(150, 110, 255, 0.72)');
ctx.fillStyle = cardGradient;
} else {
ctx.fillStyle = 'rgba(45, 45, 65, 0.89)';
}
ctx.fill();
ctx.restore();
// 左侧高亮条
if (cmd.highlight) {
ctx.save();
ctx.beginPath();
ctx.roundRect(x, y, cardWidth, cardHeight, cardBorderRadius);
ctx.clip();
ctx.fillStyle = '#c5a8ff';
ctx.fillRect(x, y, 12, cardHeight);
ctx.restore();
}
// 文本
const commandTextX = x + textPaddingLeft;
const commandTextY = y + cardHeight * 0.38;
const descriptionTextY = y + cardHeight * 0.74;
ctx.fillStyle = cmd.highlight ? '#f0e8ff' : '#c0d4ff';
ctx.font = `bold 40px ${fontFamilyBold}`;
ctx.fillText(cmd.command, commandTextX, commandTextY);
ctx.fillStyle = cmd.highlight ? 'rgba(255,255,255,1)' : 'rgba(225,230,245,0.92)';
ctx.font = `32px ${fontFamily}`;
ctx.fillText(cmd.description, commandTextX, descriptionTextY);
}
// 底部 NapCat & Plugin
const bottomTextY = height - 36;
ctx.font = `23px ${fontFamily}`;
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText('NapCat & Plugin', width / 2, bottomTextY);
// 转换为base64
const buffer = canvas.toBuffer('image/png');
const base64Image = `base64://${buffer.toString('base64')}`;
return base64Image;
} catch (error) {
console.error('生成菜单时发生错误:', error);
throw error;
}
}

View File

@@ -1,4 +0,0 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
export let current_path = dirname(fileURLToPath(import.meta.url));

View File

@@ -1,319 +0,0 @@
import { createCanvas, loadImage } from "@napi-rs/canvas";
import path from "path";
import { current_path } from "./data";
/**
* 绘制时间模式匹配的可视化图表
* @param data 需要绘制的数据和配置
* @returns Base64编码的图片
*/
export async function drawTimePattern(data: {
targetUser: string,
matchedUsers: Array<{
username: string,
similarity: number,
pattern: Map<string, number>
}>,
targetPattern: Map<string, number>,
timeRange: string
}) {
// 计算需要的画布高度,根据匹配用户数量可能需要更多空间
const legendRowHeight = 30; // 每行图例的高度,增加一点空间
const legendRows = Math.ceil(data.matchedUsers.length / 2) + 1; // 目标用户一行,其他匹配用户每两个一行
// 画布基础配置
const padding = 50;
const titleHeight = 80;
const hourChartHeight = 250;
const weekdayChartHeight = 180;
const legendTitleHeight = 40;
// 计算图例总高度,确保足够空间
const legendHeight = legendRows * legendRowHeight + legendTitleHeight;
// 计算所需的总高度
const requiredHeight = titleHeight + hourChartHeight + 60 + weekdayChartHeight + 40 + legendHeight + padding;
// 设置画布尺寸,确保足够显示所有内容
const width = 1000;
const height = requiredHeight + padding; // 确保底部有足够的padding
// 创建画布
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
// 加载背景图
const backgroundImage = await loadImage(path.join(current_path,'./fonts/post.jpg'));
const pattern = ctx.createPattern(backgroundImage, 'repeat');
ctx.fillStyle = pattern;
ctx.fillRect(0, 0, width, height);
// 应用模糊效果
ctx.filter = 'blur(5px)';
ctx.drawImage(canvas, 0, 0);
ctx.filter = 'none';
// 绘制半透明白色背景卡片
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
const radius = 20;
const cardWidth = width - padding * 2;
const cardHeight = height - padding * 2;
const cardX = padding;
const cardY = padding;
// 绘制圆角矩形
ctx.beginPath();
ctx.moveTo(cardX + radius, cardY);
ctx.lineTo(cardX + cardWidth - radius, cardY);
ctx.quadraticCurveTo(cardX + cardWidth, cardY, cardX + cardWidth, cardY + radius);
ctx.lineTo(cardX + cardWidth, cardY + cardHeight - radius);
ctx.quadraticCurveTo(cardX + cardWidth, cardY + cardHeight, cardX + cardWidth - radius, cardY + cardHeight);
ctx.lineTo(cardX + radius, cardY + cardHeight);
ctx.quadraticCurveTo(cardX, cardY + cardHeight, cardX, cardY + cardHeight - radius);
ctx.lineTo(cardX, cardY + radius);
ctx.quadraticCurveTo(cardX, cardY, cardX + radius, cardY);
ctx.closePath();
ctx.fill();
// 设置标题
ctx.fillStyle = '#333';
ctx.font = 'bold 24px "Aa偷吃可爱长大的"';
ctx.textAlign = 'center';
ctx.fillText(`${data.targetUser} ${data.timeRange}聊天时间匹配分析`, width / 2, cardY + 35);
// 绘制小时分布图表
const hourChartTop = cardY + titleHeight;
const hourChartBottom = hourChartTop + hourChartHeight;
const hourChartLeft = cardX + 40;
const hourChartRight = cardX + cardWidth - 40;
const hourChartWidth = hourChartRight - hourChartLeft;
// 绘制小时图表标题
ctx.font = 'bold 18px "Aa偷吃可爱长大的"';
ctx.fillText('每日小时活跃度分布', width / 2, hourChartTop - 10);
// 绘制小时图表横坐标
ctx.fillStyle = '#666';
ctx.font = '14px "JetBrains Mono"';
ctx.textAlign = 'center';
for (let hour = 0; hour < 24; hour += 2) {
const x = hourChartLeft + (hour / 24) * hourChartWidth;
ctx.fillText(`${hour}`, x, hourChartBottom + 20);
}
ctx.textAlign = 'left';
ctx.font = '14px "Aa偷吃可爱长大的"';
ctx.fillText('时间(小时)', hourChartLeft, hourChartBottom + 40);
ctx.font = '14px "JetBrains Mono"';
// 绘制小时图表网格线
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 0.5;
for (let hour = 0; hour < 24; hour += 2) {
const x = hourChartLeft + (hour / 24) * hourChartWidth;
ctx.beginPath();
ctx.moveTo(x, hourChartTop);
ctx.lineTo(x, hourChartBottom);
ctx.stroke();
}
// 确定最大活跃度值,用于缩放
let maxHourValue = 0;
for (let hour = 0; hour < 24; hour++) {
const targetValue = data.targetPattern.get(`hour_${hour}`) || 0;
if (targetValue > maxHourValue) maxHourValue = targetValue;
for (const match of data.matchedUsers) {
const matchValue = match.pattern.get(`hour_${hour}`) || 0;
if (matchValue > maxHourValue) maxHourValue = matchValue;
}
}
// 为了美观,确保最大值不会让图表太扁
maxHourValue = Math.max(maxHourValue, 0.15);
// 绘制目标用户小时分布曲线
ctx.strokeStyle = '#e74c3c';
ctx.lineWidth = 3;
ctx.beginPath();
for (let hour = 0; hour < 24; hour++) {
const x = hourChartLeft + (hour / 24) * hourChartWidth;
const value = data.targetPattern.get(`hour_${hour}`) || 0;
const y = hourChartBottom - (value / maxHourValue) * (hourChartHeight - 30);
if (hour === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
// 绘制匹配用户小时分布曲线
const colors = ['#3498db', '#2ecc71', '#9b59b6', '#f1c40f', '#1abc9c'];
data.matchedUsers.forEach((match, index) => {
const colorIndex = index % colors.length;
ctx.strokeStyle = colors[colorIndex]!;
ctx.lineWidth = 2;
ctx.beginPath();
for (let hour = 0; hour < 24; hour++) {
const x = hourChartLeft + (hour / 24) * hourChartWidth;
const value = match.pattern.get(`hour_${hour}`) || 0;
const y = hourChartBottom - (value / maxHourValue) * (hourChartHeight - 30);
if (hour === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
});
// 绘制星期分布图表
const weekChartTop = hourChartBottom + 60;
const weekChartBottom = weekChartTop + weekdayChartHeight;
const weekChartLeft = hourChartLeft;
const weekChartRight = hourChartRight;
const weekChartWidth = weekChartRight - weekChartLeft;
// 绘制星期图表标题
ctx.fillStyle = '#333';
ctx.font = 'bold 18px "Aa偷吃可爱长大的"';
ctx.textAlign = 'center';
ctx.fillText('星期活跃度分布', width / 2, weekChartTop - 10);
// 绘制星期图表横坐标
ctx.fillStyle = '#666';
ctx.font = '14px "Aa偷吃可爱长大的"';
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
for (let day = 0; day < 7; day++) {
const x = weekChartLeft + (day / 7) * weekChartWidth + (weekChartWidth / 14);
ctx.fillText(weekdays[day]!, x, weekChartBottom + 20);
}
// 绘制星期图表网格线
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 0.5;
for (let day = 0; day <= 7; day++) {
const x = weekChartLeft + (day / 7) * weekChartWidth;
ctx.beginPath();
ctx.moveTo(x, weekChartTop);
ctx.lineTo(x, weekChartBottom);
ctx.stroke();
}
// 确定最大活跃度值,用于缩放
let maxDayValue = 0;
for (let day = 0; day < 7; day++) {
const targetValue = data.targetPattern.get(`day_${day}`) || 0;
if (targetValue > maxDayValue) maxDayValue = targetValue;
for (const match of data.matchedUsers) {
const matchValue = match.pattern.get(`day_${day}`) || 0;
if (matchValue > maxDayValue) maxDayValue = matchValue;
}
}
// 为了美观,确保最大值不会让图表太扁
maxDayValue = Math.max(maxDayValue, 0.3);
// 改进柱状图绘制逻辑,避免重叠
const totalUsers = data.matchedUsers.length + 1; // 包括目标用户
const dayWidth = weekChartWidth / 7; // 每天的总宽度
// 计算单个柱状图宽度,确保有足够间距
const barWidth = dayWidth * 0.7 / totalUsers; // 每个柱子的宽度
const groupPadding = dayWidth * 0.15; // 不同天之间的组间距
const barPadding = dayWidth * 0.15 / (totalUsers + 1); // 同一天内柱子之间的间距
// 绘制所有用户的星期分布(包括目标用户和匹配用户)
const allUsers = [
{ username: data.targetUser, pattern: data.targetPattern, color: '#e74c3c', isTarget: true }
];
data.matchedUsers.forEach((match, idx) => {
allUsers.push({
username: match.username,
pattern: match.pattern,
color: colors[idx % colors.length] || '#3498db',
isTarget: false
});
});
// 统一绘制所有用户的柱状图
allUsers.forEach((user, userIndex) => {
ctx.fillStyle = user.color;
for (let day = 0; day < 7; day++) {
const value = user.pattern.get(`day_${day}`) || 0;
const barHeight = (value / maxDayValue) * (weekdayChartHeight - 30);
// 计算柱子的位置,确保均匀分布
const startX = weekChartLeft + day * dayWidth + groupPadding / 2;
const x = startX + barPadding * (userIndex + 1) + barWidth * userIndex;
const y = weekChartBottom - barHeight;
// 绘制柱子
ctx.fillRect(x, y, barWidth, barHeight);
}
});
// 绘制图例
let legendTop = weekChartBottom + 50; // 增加与上方图表的间距
ctx.textAlign = 'left';
ctx.font = '14px "Aa偷吃可爱长大的"';
// 绘制图例标题
ctx.fillStyle = '#333';
ctx.font = 'bold 18px "Aa偷吃可爱长大的"';
ctx.textAlign = 'center';
ctx.fillText('图例说明', width / 2, legendTop);
ctx.font = '14px "Aa偷吃可爱长大的"';
ctx.textAlign = 'left';
// 计算图例开始位置和每列宽度
const legendStartX = hourChartLeft;
const legendColumnWidth = Math.min(450, (cardWidth - 80) / 2); // 确保在宽度有限时也能正常显示
const legendsPerRow = 2; // 每行最多2个图例
// 目标用户图例 - 单独一行
legendTop += 25; // 图例标题与第一个图例的间距
ctx.fillStyle = '#e74c3c';
ctx.fillRect(legendStartX, legendTop, 20, 10);
ctx.fillStyle = '#333';
ctx.fillText(data.targetUser + " (目标用户)", legendStartX + 30, legendTop + 10);
// 匹配用户图例 - 每行最多2个用户
legendTop += legendRowHeight; // 进入下一行
data.matchedUsers.forEach((match, index) => {
const colorIndex = index % colors.length;
const row = Math.floor(index / legendsPerRow);
const col = index % legendsPerRow;
const x = legendStartX + col * legendColumnWidth;
const y = legendTop + row * legendRowHeight;
// 确保没有超出画布范围
if (y + 20 <= cardY + cardHeight - padding / 2) {
ctx.fillStyle = colors[colorIndex]!;
ctx.fillRect(x, y, 20, 10);
ctx.fillStyle = '#333';
const similarity = (match.similarity * 100).toFixed(1);
// 测量文本长度,确保不超出列宽
const text = `${match.username} (${similarity}% 匹配)`;
const metrics = ctx.measureText(text);
if (metrics.width > legendColumnWidth - 40) {
// 如果文本过长,缩短显示
const shortUsername = match.username.length > 10 ?
match.username.substring(0, 10) + "..." :
match.username;
ctx.fillText(`${shortUsername} (${similarity}% 匹配)`, x + 30, y + 10);
} else {
ctx.fillText(text, x + 30, y + 10);
}
}
});
// 保存图像
const buffer = canvas.toBuffer('image/png');
return "base64://" + buffer.toString('base64');
}

25
src/plugin/get_user.ts Normal file
View File

@@ -0,0 +1,25 @@
import { RequestUtil } from '@/common/request';
import { GameUserDetail } from './api';
/**
* 获取用户游戏记录信息
* @param tap_id TapTap用户ID
* @returns 用户游戏记录信息
*/
export async function get_user(tap_id: string): Promise<GameUserDetail> {
try {
const params = new URLSearchParams({
'app_id': '221062',
'user_id': tap_id,
'X-UA': 'V=1&PN=WebApp&LANG=zh_CN&VN_CODE=102&VN=0.1.0&LOC=CN&PLT=Android&DS=Android&UID=00e000ee-00e0-0e0e-ee00-f0c95d8ca115&VID=444444444&OS=Android&OSV=14.0.1'
});
const url = `https://www.taptap.cn/webapiv2/game-record/v1/detail-by-user?${params.toString()}`;
return await RequestUtil.HttpGetJson(url, 'GET');
} catch (error) {
console.error('获取用户游戏记录失败:', error);
throw error;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,432 +0,0 @@
import { createCanvas, loadImage } from "@napi-rs/canvas";
import path from "path";
import { current_path } from "./data";
interface NetworkNode {
id: string;
label: string;
value: number;
}
interface NetworkEdge {
from: string;
to: string;
value: number;
}
interface NetworkData {
nodes: NetworkNode[];
edges: NetworkEdge[];
title: string;
}
export async function drawWordNetwork(data: NetworkData): Promise<string> {
// 根据节点数量动态调整画布尺寸
const nodeCount = data.nodes.length;
const baseWidth = 1000;
const baseHeight = 800;
// 根据节点数量计算合适的尺寸
const width = Math.max(baseWidth, Math.min(2000, baseWidth + (nodeCount - 10) * 30));
const height = Math.max(baseHeight, Math.min(1500, baseHeight + (nodeCount - 10) * 25));
// 根据画布大小调整边距
const padding = Math.max(60, Math.min(100, 60 + nodeCount / 20));
const centerX = width / 2;
const centerY = height / 2;
// 创建画布
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
// 绘制背景
try {
const backgroundImage = await loadImage(path.join(current_path, '.\\fonts\\post.jpg'));
const pattern = ctx.createPattern(backgroundImage, 'repeat');
if (pattern) {
ctx.fillStyle = pattern;
ctx.fillRect(0, 0, width, height);
// 添加模糊效果
ctx.filter = 'blur(5px)';
ctx.drawImage(canvas, 0, 0);
ctx.filter = 'none';
}
} catch (e) {
// 如果背景图加载失败,使用纯色背景
ctx.fillStyle = '#f0f0f0';
ctx.fillRect(0, 0, width, height);
}
// 绘制半透明卡片背景
const cardWidth = width - padding * 2;
const cardHeight = height - padding * 2;
const cardX = padding;
const cardY = padding;
const radius = 20;
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
ctx.beginPath();
ctx.moveTo(cardX + radius, cardY);
ctx.lineTo(cardX + cardWidth - radius, cardY);
ctx.quadraticCurveTo(cardX + cardWidth, cardY, cardX + cardWidth, cardY + radius);
ctx.lineTo(cardX + cardWidth, cardY + cardHeight - radius);
ctx.quadraticCurveTo(cardX + cardWidth, cardY + cardHeight, cardX + cardWidth - radius, cardY + cardHeight);
ctx.lineTo(cardX + radius, cardY + cardHeight);
ctx.quadraticCurveTo(cardX, cardY + cardHeight, cardX, cardY + cardHeight - radius);
ctx.lineTo(cardX, cardY + radius);
ctx.quadraticCurveTo(cardX, cardY, cardX + radius, cardY);
ctx.closePath();
ctx.fill();
// 绘制标题
ctx.fillStyle = '#333';
ctx.font = '28px "Aa偷吃可爱长大的"';
ctx.textAlign = 'center';
ctx.fillText(data.title, centerX, cardY + 40);
// 计算节点位置 (使用简化版力导向算法)
const nodePositions = new Map<string, { x: number, y: number }>();
const radiusScale = 15; // 基础节点半径
const maxRadius = 40; // 最大节点半径
// 找出最大频率值用于缩放
const maxValue = Math.max(...data.nodes.map(n => n.value));
// 计算每个节点的实际半径
const nodeRadiusMap = new Map<string, number>();
for (const node of data.nodes) {
const radius = Math.min(maxRadius, radiusScale + (node.value / maxValue) * 25);
nodeRadiusMap.set(node.id, radius);
}
// 节点重叠标记系统 - 跟踪哪些节点存在重叠
const overlapTracker = new Map<string, Set<string>>();
for (const node of data.nodes) {
overlapTracker.set(node.id, new Set<string>());
}
// 根据画布尺寸调整初始分布范围 - 增加分布范围
const initialRadius = Math.min(cardWidth, cardHeight) * 0.4;
// 对节点按大小排序,确保大节点先放置
const sortedNodes = [...data.nodes].sort((a, b) => b.value - a.value);
// 初始化随机位置 - 改进的空间分配策略
for (let i = 0; i < sortedNodes.length; i++) {
const node = sortedNodes[i];
if (!node) continue; // 防止空节点
const nodeRadius = nodeRadiusMap.get(node.id)!;
// 使用黄金角度法进行更均匀的分布
const goldenAngle = Math.PI * (3 - Math.sqrt(5)); // 黄金角
const angle = i * goldenAngle;
// 根据节点大小调整距离
const sizeFactor = 1 + (nodeRadius / maxRadius) * 0.5; // 大节点获得更远的初始距离
const distance = initialRadius * (0.4 + 0.6 * Math.random()) * sizeFactor;
nodePositions.set(node.id, {
x: centerX + Math.cos(angle) * distance,
y: centerY + Math.sin(angle) * distance
});
}
// 根据节点数量调整迭代次数 - 增加迭代次数确保充分布局
const iterations = Math.max(40, Math.min(80, 40 + nodeCount));
// 模拟物理力学
for (let iteration = 0; iteration < iterations; iteration++) {
// 冷却因子 - 调整冷却曲线以减缓冷却速度
const temperatureFactor = 1 - Math.pow(iteration / iterations, 1.5) * 0.8;
// 清除重叠标记
for (const nodeId of overlapTracker.keys()) {
overlapTracker.get(nodeId)!.clear();
}
// 斥力 (所有节点相互排斥)
for (let i = 0; i < data.nodes.length; i++) {
const node1 = data.nodes[i];
if (!node1) continue; // 防止空节点
const pos1 = nodePositions.get(node1.id)!;
const radius1 = nodeRadiusMap.get(node1.id)!;
for (let j = i + 1; j < data.nodes.length; j++) {
const node2 = data.nodes[j];
if (!node2) continue; // 防止空节点
const pos2 = nodePositions.get(node2.id)!;
const radius2 = nodeRadiusMap.get(node2.id)!;
const dx = pos2.x - pos1.x;
const dy = pos2.y - pos1.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
// 根据节点实际大小计算最小距离
const minDistance = radius1 + radius2 + 40; // 增加最小间隙
// 检测并标记重叠
if (distance < minDistance) {
overlapTracker.get(node1.id)!.add(node2.id);
overlapTracker.get(node2.id)!.add(node1.id);
}
// 对所有节点应用基础斥力
const repulsionStrength = 1200 * temperatureFactor; // 增强基础斥力
if (distance > 0) {
// 使用反比平方斥力,但对近距离节点增加额外斥力
const proximityFactor = Math.pow(Math.max(0, 1 - distance / (minDistance * 2)), 2) * 3 + 1;
const force = Math.min(8, repulsionStrength / (distance * distance)) * proximityFactor;
// 根据节点大小调整斥力
const sizeFactor = (radius1 + radius2) / (radiusScale * 2);
const adjustedForce = force * Math.sqrt(sizeFactor);
const moveX = (dx / distance) * adjustedForce;
const moveY = (dy / distance) * adjustedForce;
pos1.x -= moveX;
pos1.y -= moveY;
pos2.x += moveX;
pos2.y += moveY;
}
// 如果距离小于最小距离,增加强制分离力
if (distance < minDistance) {
// 计算重叠度
const overlapRatio = (minDistance - distance) / minDistance;
// 计算分离力 - 重叠程度越高,力越大
// 在迭代后期增加分离力
const lateStageFactor = 1 + Math.max(0, (iteration - iterations * 0.6) / (iterations * 0.4)) * 2;
const separationForce = overlapRatio * 0.8 * temperatureFactor * lateStageFactor;
pos1.x -= dx * separationForce;
pos1.y -= dy * separationForce;
pos2.x += dx * separationForce;
pos2.y += dy * separationForce;
// 额外的扭矩力,帮助节点绕过彼此
if (overlapRatio > 0.5 && iteration > iterations * 0.3) {
// 计算垂直于连线的方向
const perpX = -dy / distance;
const perpY = dx / distance;
// 随机选择扭矩方向
const sign = Math.random() > 0.5 ? 1 : -1;
const torqueFactor = 0.2 * overlapRatio * temperatureFactor;
pos1.x += perpX * torqueFactor * sign;
pos1.y += perpY * torqueFactor * sign;
pos2.x -= perpX * torqueFactor * sign;
pos2.y -= perpY * torqueFactor * sign;
}
}
}
}
// 引力 (有连接的节点相互吸引) - 优化以避免过度聚集
for (const edge of data.edges) {
const pos1 = nodePositions.get(edge.from)!;
const pos2 = nodePositions.get(edge.to)!;
const radius1 = nodeRadiusMap.get(edge.from)!;
const radius2 = nodeRadiusMap.get(edge.to)!;
const dx = pos2.x - pos1.x;
const dy = pos2.y - pos1.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
// 根据边权值和节点大小调整引力
const baseStrength = Math.min(0.015, edge.value / 200);
const strength = baseStrength * temperatureFactor;
// 根据节点大小动态调整最佳距离
const minNodeDistance = radius1 + radius2 + 40;
const optimalDistance = minNodeDistance + 60 + edge.value * 0.5;
if (distance > optimalDistance) {
// 如果节点距离过远,应用引力
const attractionForce = strength * Math.min(1, (distance - optimalDistance) / optimalDistance);
pos1.x += dx * attractionForce;
pos1.y += dy * attractionForce;
pos2.x -= dx * attractionForce;
pos2.y -= dy * attractionForce;
} else if (distance < minNodeDistance) {
// 如果节点距离过近,应用斥力
const repulsionForce = 0.05 * temperatureFactor * (minNodeDistance - distance) / minNodeDistance;
pos1.x -= dx * repulsionForce;
pos1.y -= dy * repulsionForce;
pos2.x += dx * repulsionForce;
pos2.y += dy * repulsionForce;
}
}
// 中心引力 - 防止节点飞得太远
const availableArea = Math.min(cardWidth, cardHeight) * 0.45; // 增加有效区域
for (const node of data.nodes) {
const pos = nodePositions.get(node.id)!;
const dx = centerX - pos.x;
const dy = centerY - pos.y;
const distanceFromCenter = Math.sqrt(dx * dx + dy * dy);
// 根据与中心距离施加引力
if (distanceFromCenter > availableArea) {
const centerForce = 0.01 * temperatureFactor *
Math.pow((distanceFromCenter - availableArea) / availableArea, 1.2);
pos.x += dx * centerForce;
pos.y += dy * centerForce;
}
}
// 确保节点不会超出边界
for (const node of data.nodes) {
const pos = nodePositions.get(node.id)!;
const radius = nodeRadiusMap.get(node.id)!;
const margin = radius + 20; // 考虑节点实际大小的边距
pos.x = Math.max(cardX + margin, Math.min(cardX + cardWidth - margin, pos.x));
pos.y = Math.max(cardY + margin, Math.min(cardY + cardHeight - margin, pos.y));
}
// 重叠度计算 - 统计当前总重叠数量
let totalOverlaps = 0;
for (const overlaps of overlapTracker.values()) {
totalOverlaps += overlaps.size;
}
// 如果迭代已进行了3/4以上且没有重叠可以提前结束
if (iteration > iterations * 0.75 && totalOverlaps === 0) {
break;
}
}
// 最终重叠消除阶段 - 专门解决残余重叠问题
for (let i = 0; i < 15; i++) {
let overlapsFixed = 0;
for (let j = 0; j < data.nodes.length; j++) {
const node1 = data.nodes[j];
if (!node1) continue; // 防止空节点
const pos1 = nodePositions.get(node1.id)!;
const radius1 = nodeRadiusMap.get(node1.id)!;
for (let k = j + 1; k < data.nodes.length; k++) {
const node2 = data.nodes[k];
if (!node2) continue; // 防止空节点
const pos2 = nodePositions.get(node2.id)!;
const radius2 = nodeRadiusMap.get(node2.id)!;
const dx = pos2.x - pos1.x;
const dy = pos2.y - pos1.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
const minDistance = radius1 + radius2 + 40;
if (distance < minDistance) {
// 计算需要移动的距离
const moveDistance = (minDistance - distance) / 2 + 1;
const moveX = (dx / distance) * moveDistance;
const moveY = (dy / distance) * moveDistance;
// 应用移动
pos1.x -= moveX;
pos1.y -= moveY;
pos2.x += moveX;
pos2.y += moveY;
// 施加小的随机扰动以打破对称性
const jitter = 1;
pos1.x += (Math.random() - 0.5) * jitter;
pos1.y += (Math.random() - 0.5) * jitter;
pos2.x += (Math.random() - 0.5) * jitter;
pos2.y += (Math.random() - 0.5) * jitter;
overlapsFixed++;
}
}
// 确保节点不会超出边界
const radius = nodeRadiusMap.get(node1.id)!;
const margin = radius + 20;
pos1.x = Math.max(cardX + margin, Math.min(cardX + cardWidth - margin, pos1.x));
pos1.y = Math.max(cardY + margin, Math.min(cardY + cardHeight - margin, pos1.y));
}
// 如果没有重叠了,提前退出
if (overlapsFixed === 0) break;
}
// 绘制边 - 改进边的视觉效果
for (const edge of data.edges) {
const pos1 = nodePositions.get(edge.from)!;
const pos2 = nodePositions.get(edge.to)!;
const radius1 = nodeRadiusMap.get(edge.from)!;
const radius2 = nodeRadiusMap.get(edge.to)!;
// 计算边的实际起止点,从节点边缘开始而不是中心
const dx = pos2.x - pos1.x;
const dy = pos2.y - pos1.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
// 计算实际的起点和终点,从节点边缘开始
const startX = pos1.x + (dx / distance) * radius1;
const startY = pos1.y + (dy / distance) * radius1;
const endX = pos2.x - (dx / distance) * radius2;
const endY = pos2.y - (dy / distance) * radius2;
// 根据权重确定线宽
const lineWidth = Math.max(1, Math.min(5, edge.value / 10));
// 计算透明度 (权重越高透明度越低)
const alpha = Math.min(0.7, 0.2 + edge.value / 20);
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
ctx.strokeStyle = `rgba(100, 100, 255, ${alpha})`;
ctx.lineWidth = lineWidth;
ctx.stroke();
}
// 绘制节点
for (const node of data.nodes) {
const pos = nodePositions.get(node.id)!;
// 使用预计算的节点半径
const radius = nodeRadiusMap.get(node.id)!;
// 绘制节点圆形
ctx.beginPath();
ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255, 100, 100, 0.8)`;
ctx.fill();
// 绘制边框
ctx.strokeStyle = '#800000';
ctx.lineWidth = 2;
ctx.stroke();
// 根据节点大小调整字体大小
const fontSize = Math.max(14, Math.min(18, 14 + (node.value / maxValue) * 6));
// 绘制文本
ctx.fillStyle = '#000';
ctx.font = `${fontSize}px "Aa偷吃可爱长大的"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(node.label, pos.x, pos.y);
}
// 绘制图例
ctx.fillStyle = '#333';
ctx.font = '18px "Aa偷吃可爱长大的"';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText('词频越高,节点越大', cardX + 20, cardY + 20);
ctx.fillText('关联越强,连线越粗', cardX + 20, cardY + 50);
// 保存图像
const buffer = canvas.toBuffer('image/png');
return "base64://" + buffer.toString('base64');
}

387
src/plugin/new.ts Normal file
View File

@@ -0,0 +1,387 @@
import { createCanvas, loadImage, GlobalFonts } from '@napi-rs/canvas';
import { GameUserDetail } from './api';
// 字体注册工具
function getFontFamily() {
const fontsToTry = [
{ path: 'C:\\Windows\\Fonts\\msyh.ttc', name: 'Microsoft YaHei' },
{ path: 'C:\\Windows\\Fonts\\msyhbd.ttc', name: 'Microsoft YaHei Bold' },
{ path: 'C:\\Windows\\Fonts\\simhei.ttf', name: 'SimHei' }
];
let fontFamily = 'sans-serif';
let fontFamilyBold = 'sans-serif';
for (const font of fontsToTry) {
try {
if (!GlobalFonts.has(font.name)) {
GlobalFonts.registerFromPath(font.path, font.name);
}
if (font.name.includes('Bold')) {
fontFamilyBold = font.name;
} else if (fontFamily === 'sans-serif') {
fontFamily = font.name;
}
} catch { }
}
if (fontFamilyBold === 'sans-serif') fontFamilyBold = fontFamily;
return { fontFamily, fontFamilyBold };
}
// 背景绘制工具
async function drawBackground(ctx: any, width: number, height: number) {
const backgroundPath = "E:\\NewDevelop\\NapCatQQ\\src\\canvas\\image\\normal\\01.jpg";
try {
const backgroundImage = await loadImage(backgroundPath);
const scale = Math.max(width / backgroundImage.width, height / backgroundImage.height);
const scaledWidth = backgroundImage.width * scale;
const scaledHeight = backgroundImage.height * scale;
const x = (width - scaledWidth) / 2;
const y = (height - scaledHeight) / 2;
ctx.drawImage(backgroundImage, x, y, scaledWidth, scaledHeight);
ctx.save();
ctx.filter = 'blur(20px) brightness(0.75)';
ctx.drawImage(backgroundImage, x, y, scaledWidth, scaledHeight);
ctx.restore();
} catch {
const gradient = ctx.createLinearGradient(0, 0, width, height);
gradient.addColorStop(0, '#2a2a40');
gradient.addColorStop(1, '#4a3a60');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
}
ctx.fillStyle = 'rgba(15, 15, 25, 0.65)';
ctx.fillRect(0, 0, width, height);
}
// 标题美化工具
function drawTitle(ctx: any, text: string, width: number, y: number, fontFamilyBold: string) {
// 小标题条
ctx.save();
ctx.globalAlpha = 0.32;
ctx.fillStyle = '#c5a8ff';
ctx.fillRect(width / 2 - 220, y - 38, 440, 54);
ctx.restore();
// 标题
ctx.font = `bold 44px ${fontFamilyBold}`;
ctx.fillStyle = '#f0e8ff';
ctx.textAlign = 'center';
ctx.fillText(text, width / 2, y);
// 下划线
ctx.save();
ctx.strokeStyle = '#c5a8ff';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(width / 2 - 80, y + 18);
ctx.lineTo(width / 2 + 80, y + 18);
ctx.stroke();
ctx.restore();
}
// 信息行绘制
function drawInfoLine(ctx: any, text: string, x: number, y: number, font: string, color: string | CanvasGradient | CanvasPattern) {
ctx.font = font;
// 兼容 CanvasGradient/CanvasPattern 和 string
if (typeof color === 'string' || color instanceof CanvasGradient || color instanceof CanvasPattern) {
ctx.fillStyle = color;
} else {
ctx.fillStyle = '#c5a8ff';
}
ctx.textAlign = 'left';
ctx.fillText(text, x, y);
}
// 卡片小块绘制
function drawMiniCard(ctx: any, x: number, y: number, w: number, h: number, radius: number, shadow = true, color?: string, shadowColor?: string) {
ctx.save();
if (shadow) {
ctx.shadowColor = shadowColor ?? 'rgba(60, 40, 120, 0.13)';
ctx.shadowBlur = 12;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, radius);
ctx.closePath();
ctx.fillStyle = color ?? 'rgba(45, 45, 65, 0.89)';
ctx.fill();
ctx.restore();
}
// 底部标识
function drawFooter(ctx: any, width: number, height: number, fontFamily: string) {
ctx.font = `22px ${fontFamily}`;
ctx.fillStyle = 'rgba(255,255,255,0.62)';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText('NapCat & Plugin', width / 2, height - 24);
}
/**
* 生成REVERSE.1999 信息图片
*/
export async function generate1999InfoImage(userInfo: any): Promise<string> {
const { fontFamily, fontFamilyBold } = getFontFamily();
const width = 2560, height = 1440;
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
await drawBackground(ctx, width, height);
// 标题
drawTitle(ctx, '账号信息', width, 120, fontFamilyBold);
// 信息内容
const startX = width / 2 - 350, startY = 220, lineH = 62;
const infoList = [
`昵称: ${userInfo.data.list[0]?.basic_module?.name ?? '-'}`,
`角色ID: ${userInfo.data.list[0]?.basic_module?.role_id ?? '-'}`,
`角色数量: ${userInfo.data.list[0]?.basic_module?.custom_items[0]?.value ?? '-'}`,
`登录天数: ${userInfo.data.list[0]?.basic_module?.custom_items[1]?.value ?? '-'}`,
`雨滴数量: ${userInfo.data.list[0]?.basic_module?.custom_items[2]?.value ?? '-'}`,
`你何时睁眼看这个世界: ${userInfo.data.list[1]?.episode_module?.custom_items[0]?.value ?? '-'}`,
`你在哪一幕: ${userInfo.data.list[1]?.episode_module?.custom_items[1]?.value ?? '-'}`,
`人工梦游: ${userInfo.data.list[1]?.episode_module?.custom_items[2]?.value ?? '-'}`,
];
ctx.font = `bold 36px ${fontFamilyBold}`;
ctx.fillStyle = '#c5a8ff';
infoList.forEach((txt, i) => {
drawInfoLine(ctx, txt, startX, startY + i * lineH, ctx.font, ctx.fillStyle);
});
drawFooter(ctx, width, height, fontFamily);
const buffer = canvas.toBuffer('image/png');
return `base64://${buffer.toString('base64')}`;
}
/**
* 生成REVERSE.1999 心相图片
*/
export async function generate1999WeaponImage(userInfo: GameUserDetail): Promise<string> {
const { fontFamily, fontFamilyBold } = getFontFamily();
const width = 2560, height = 1440;
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
await drawBackground(ctx, width, height);
drawTitle(ctx, '心相', width, 120, fontFamilyBold);
ctx.font = `bold 34px ${fontFamilyBold}`;
ctx.fillStyle = '#c5a8ff';
drawInfoLine(ctx, `昵称: ${userInfo.data.list[0]?.basic_module?.name ?? '-'}`, width / 2 - 350, 180, ctx.font, ctx.fillStyle);
drawInfoLine(ctx, `角色ID: ${userInfo.data.list[0]?.basic_module?.role_id ?? '-'}`, width / 2 - 350, 230, ctx.font, ctx.fillStyle);
const weaponList = userInfo.data.list[3]?.weapon_module?.list ?? [];
const cardW = 420, cardH = 110, gapX = 38, gapY = 32;
const imgSize = 90;
const textLeft = 36 + imgSize + 18; // 缩略图+间隔
const cols = Math.min(5, Math.floor((width - 2 * gapX) / (cardW + gapX)));
const areaW = cols * cardW + (cols - 1) * gapX;
const startX = (width - areaW) / 2;
let y = 280;
ctx.font = `bold 32px ${fontFamilyBold}`;
for (let idx = 0; idx < weaponList.length; idx++) {
const item = weaponList[idx];
const col = idx % cols, row = Math.floor(idx / cols);
const x = startX + col * (cardW + gapX);
const cy = y + row * (cardH + gapY);
drawMiniCard(ctx, x, cy, cardW, cardH, 22);
if (!item) continue; // 跳过空值
// 绘制缩略图
if (item.image?.small_url) {
try {
const img = await loadImage(item.image.small_url);
const imgX = x + 18;
const imgY = cy + (cardH - imgSize) / 2;
ctx.save();
ctx.beginPath();
ctx.arc(imgX + imgSize / 2, imgY + imgSize / 2, imgSize / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
ctx.drawImage(img, imgX, imgY, imgSize, imgSize);
ctx.restore();
} catch { }
}
ctx.fillStyle = '#f0e8ff';
ctx.font = `bold 32px ${fontFamilyBold}`;
ctx.fillText(item.name ?? '-', x + textLeft, cy + cardH / 2 - 12);
ctx.font = `28px ${fontFamily}`;
ctx.fillStyle = '#c5a8ff';
ctx.fillText(`LV.${item.level ?? '-'}`, x + textLeft, cy + cardH / 2 + 32);
}
drawFooter(ctx, width, height, fontFamily);
const buffer = canvas.toBuffer('image/png');
return `base64://${buffer.toString('base64')}`;
}
/**
* 生成REVERSE.1999 角色图片
*/
export async function generate1999CharacterImage(userInfo: GameUserDetail): Promise<string> {
const { fontFamily, fontFamilyBold } = getFontFamily();
const width = 2560, height = 1440;
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
await drawBackground(ctx, width, height);
drawTitle(ctx, '角色', width, 120, fontFamilyBold);
ctx.font = `bold 34px ${fontFamilyBold}`;
ctx.fillStyle = '#c5a8ff';
drawInfoLine(ctx, `昵称: ${userInfo.data.list[0]?.basic_module?.name ?? '-'}`, width / 2 - 350, 180, ctx.font, ctx.fillStyle);
drawInfoLine(ctx, `角色ID: ${userInfo.data.list[0]?.basic_module?.role_id ?? '-'}`, width / 2 - 350, 230, ctx.font, ctx.fillStyle);
const charList = userInfo.data.list[2]?.character_module?.list ?? [];
const cardW = 420, cardH = 110, gapX = 38, gapY = 32;
const imgSize = 90;
const textLeft = 36 + imgSize + 18;
const cols = Math.min(5, Math.floor((width - 2 * gapX) / (cardW + gapX)));
const areaW = cols * cardW + (cols - 1) * gapX;
const startX = (width - areaW) / 2;
let y = 280;
ctx.font = `bold 32px ${fontFamilyBold}`;
for (let idx = 0; idx < charList.length; idx++) {
const item = charList[idx];
const col = idx % cols, row = Math.floor(idx / cols);
const x = startX + col * (cardW + gapX);
const cy = y + row * (cardH + gapY);
drawMiniCard(ctx, x, cy, cardW, cardH, 22);
// 绘制缩略图
if (!item) continue; // 跳过空值
if (item.image?.small_url) {
try {
const img = await loadImage(item.image.small_url);
const imgX = x + 18;
const imgY = cy + (cardH - imgSize) / 2;
ctx.save();
ctx.beginPath();
ctx.arc(imgX + imgSize / 2, imgY + imgSize / 2, imgSize / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
ctx.drawImage(img, imgX, imgY, imgSize, imgSize);
ctx.restore();
} catch { }
}
ctx.fillStyle = '#f0e8ff';
ctx.font = `bold 32px ${fontFamilyBold}`;
ctx.fillText(item.name ?? '-', x + textLeft, cy + cardH / 2 - 12);
ctx.font = `28px ${fontFamily}`;
ctx.fillStyle = '#c5a8ff';
ctx.fillText(`LV.${item.level ?? '-'}`, x + textLeft, cy + cardH / 2 + 32);
}
drawFooter(ctx, width, height, fontFamily);
const buffer = canvas.toBuffer('image/png');
return `base64://${buffer.toString('base64')}`;
}
/**
* 生成REVERSE.1999 绑定/切换结果图片
*/
async function generateSimpleResultImage(title: string, tapId: string, characterName: string): Promise<string> {
const { fontFamily, fontFamilyBold } = getFontFamily();
const width = 900, height = 340;
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
await drawBackground(ctx, width, height);
// 标题
drawTitle(ctx, title, width, 80, fontFamilyBold);
// 内容卡片
const cardW = 700, cardH = 120, cardX = (width - cardW) / 2, cardY = 120;
drawMiniCard(
ctx,
cardX,
cardY,
cardW,
cardH,
20,
true,
'rgba(197,168,255,0.18)',
'rgba(197,168,255,0.13)'
);
// TapTap ID
ctx.font = `bold 28px ${fontFamilyBold}`;
ctx.fillStyle = '#c5a8ff';
ctx.textAlign = 'left';
ctx.fillText(`TapTap ID:`, cardX + 36, cardY + 48);
ctx.font = `bold 28px ${fontFamilyBold}`;
ctx.fillStyle = '#fffbe6';
ctx.fillText(tapId, cardX + 180, cardY + 48);
// 角色名称
ctx.font = `bold 28px ${fontFamilyBold}`;
ctx.fillStyle = '#c5a8ff';
ctx.fillText(`角色名称:`, cardX + 36, cardY + 88);
ctx.font = `bold 28px ${fontFamilyBold}`;
ctx.fillStyle = '#ffe066';
ctx.fillText(characterName, cardX + 180, cardY + 88);
drawFooter(ctx, width, height, fontFamily);
const buffer = canvas.toBuffer('image/png');
return `base64://${buffer.toString('base64')}`;
}
export async function generate1999BindImage(tapId: string, characterName: string): Promise<string> {
return generateSimpleResultImage('绑定成功', tapId, characterName);
}
export async function generate1999SwitchImage(tapId: string, characterName: string): Promise<string> {
return generateSimpleResultImage('切换成功', tapId, characterName);
}
/**
* 生成REVERSE.1999 账号列表图片
*/
export async function generate1999AccountListImage(accountList: { id: string, name: string, isDefault?: boolean }[]): Promise<string> {
const { fontFamily, fontFamilyBold } = getFontFamily();
const width = 900;
const cardW = 700, cardH = 56, gapY = 18;
const listH = accountList.length * cardH + (accountList.length - 1) * gapY;
const height = Math.max(340, 120 + listH + 60);
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
await drawBackground(ctx, width, height);
// 标题
drawTitle(ctx, '已绑定账号列表', width, 80, fontFamilyBold);
// 列表区域起始Y垂直居中
const startY = Math.max(140, (height - listH) / 2);
for (let i = 0; i < accountList.length; i++) {
const item = accountList[i];
if (!item) continue; // 跳过空值
const x = (width - cardW) / 2;
const y = startY + i * (cardH + gapY);
// 卡片颜色
const cardColor = item.isDefault ? 'rgba(255, 224, 102, 0.22)' : 'rgba(197, 168, 255, 0.18)';
const shadowColor = item.isDefault ? 'rgba(255, 224, 102, 0.18)' : 'rgba(197, 168, 255, 0.13)';
drawMiniCard(ctx, x, y, cardW, cardH, 16, true, cardColor, shadowColor);
// 账号ID小号灰色
ctx.font = `22px ${fontFamily}`;
ctx.fillStyle = '#b0b0c8';
ctx.textAlign = 'left';
ctx.fillText(`ID: ${item.id}`, x + 28, y + 26);
// 账号名(大号,主色)
ctx.font = `bold 26px ${fontFamilyBold}`;
ctx.fillStyle = item.isDefault ? '#ffe066' : '#c5a8ff';
ctx.fillText(item.name, x + 28, y + 48);
// 当前使用标识
if (item.isDefault) {
ctx.font = `bold 18px ${fontFamilyBold}`;
ctx.fillStyle = '#fffbe6';
ctx.fillText('(当前使用)', x + 28 + ctx.measureText(item.name).width + 12, y + 48);
}
}
drawFooter(ctx, width, height, fontFamily);
const buffer = canvas.toBuffer('image/png');
return `base64://${buffer.toString('base64')}`;
}

23
src/plugin/sendMessage.ts Normal file
View File

@@ -0,0 +1,23 @@
import { OB11Message, OB11MessageData } from '@/onebot';
import { ActionMap } from '@/onebot/action';
/**
* 发送消息工具函数
*/
export async function sendMessage<T extends OB11MessageData[] | string>(
message: OB11Message,
action: ActionMap,
content: T
) {
if (message.message_type === 'private') {
await action.get('send_msg')?._handle({
user_id: message.user_id.toString(),
message: content,
});
} else {
await action.get('send_msg')?._handle({
group_id: message.group_id?.toString(),
message: content,
});
}
};

View File

@@ -1,952 +0,0 @@
import { createCanvas } from '@napi-rs/canvas';
interface WordFrequency {
word: string;
frequency: number;
}
interface Position {
x: number;
y: number;
width: number;
height: number;
rotation: number;
fontSize: number;
}
/**
* 根据词频生成词云图片
* @param wordFrequencies 词频数组,包含单词和对应的频率
* @param initialWidth 初始画布宽度(最终会自动调整)
* @param initialHeight 初始画布高度(最终会自动调整)
* @param options 词云配置选项
* @returns 图片的base64编码字符串
*/
export async function generateWordCloud(
wordFrequencies: WordFrequency[],
initialWidth = 1000,
initialHeight = 800,
options = {
backgroundColor: 'white',
enableRotation: true,
maxAttempts: 60, // 每个词的最大尝试次数
minFontSize: 20, // 最小字体大小
maxFontSize: 100, // 最大字体大小
padding: 20, // 降低内边距提高紧凑度
horizontalWeight: 0.6, // 提高横排权重增强可读性
rotationVariance: 10, // 减少旋转角度变化
safetyMargin: 6, // 减小安全距离以提高密度
fontSizeRatio: 2.0, // 字体大小差异
overlapThreshold: 0.10, // 允许10%的重叠
maxExpansionAttempts: 10, // 最大画布扩展次数
expansionRatio: 1.15 // 降低每次扩展比例
}
): Promise<string> {
// 空数组检查
if (wordFrequencies.length === 0) {
const emptyCanvas = createCanvas(initialWidth, initialHeight);
const ctx = emptyCanvas.getContext('2d');
ctx.fillStyle = options.backgroundColor;
ctx.fillRect(0, 0, initialWidth, initialHeight);
return "base64://" + emptyCanvas.toBuffer('image/png').toString('base64');
}
// 过滤不可渲染字符 - 增强过滤能力
const filteredWordFrequencies = wordFrequencies.map(item => ({
...item,
word: filterUnrenderableChars(item.word)
})).filter(item => item.word.length > 0);
// 再次检查过滤后是否为空
if (filteredWordFrequencies.length === 0) {
const emptyCanvas = createCanvas(initialWidth, initialHeight);
const ctx = emptyCanvas.getContext('2d');
ctx.fillStyle = options.backgroundColor;
ctx.fillRect(0, 0, initialWidth, initialHeight);
return "base64://" + emptyCanvas.toBuffer('image/png').toString('base64');
}
// 对词频进行排序,频率高的先绘制
const sortedWords = [...filteredWordFrequencies].sort((a, b) => b.frequency - a.frequency);
// 计算最小和最大频率,用于字体大小缩放
const maxFreq = sortedWords[0]?.frequency || 1;
const minFreq = sortedWords[sortedWords.length - 1]?.frequency || 1;
const freqRange = Math.max(1, maxFreq - minFreq); // 避免除以零
// 检查字符类型
const isChineseChar = (char: string): boolean => /[\u4e00-\u9fa5]/.test(char);
const isEnglishChar = (char: string): boolean => /[a-zA-Z0-9]/.test(char);
// 判断单词类型(中文、英文或混合)
const getWordType = (word: string): 'chinese' | 'english' | 'mixed' => {
let hasChinese = false;
let hasEnglish = false;
for (const char of word) {
if (isChineseChar(char)) hasChinese = true;
else if (isEnglishChar(char)) hasEnglish = true;
if (hasChinese && hasEnglish) return 'mixed';
}
return hasChinese ? 'chinese' : 'english';
};
// 获取适合单词的字体
const getFontFamily = (word: string): string => {
const wordType = getWordType(word);
if (wordType === 'chinese') return '"Aa偷吃可爱长大的", sans-serif';
if (wordType === 'english') return '"JetBrains Mono", monospace';
return '"Aa偷吃可爱长大的", "JetBrains Mono", sans-serif'; // 混合类型
};
// 增强的字体大小计算函数,保持高频词更大但减小差距
const calculateFontSize = (frequency: number, index: number): number => {
// 基本的频率比例
const frequencyRatio = (frequency - minFreq) / freqRange;
// 根据词云大小调整差异系数
const smallCloudFactor = sortedWords.length < 15 ? 1.5 : 1.0; // 小词云时增大差异
// 应用非线性映射,使高频词更大但差距不过大
let sizeRatio;
if (index === 0) {
// 最高频词
sizeRatio = Math.pow(frequencyRatio, 0.3) * 2.2 * smallCloudFactor;
} else if (index < sortedWords.length * 0.05) {
// 前5%的高频词
sizeRatio = Math.pow(frequencyRatio, 0.4) * 1.8 * smallCloudFactor;
} else if (index < sortedWords.length * 0.15) {
// 前15%的高频词
sizeRatio = Math.pow(frequencyRatio, 0.5) * 1.5 * smallCloudFactor;
} else if (index < sortedWords.length * 0.3) {
// 前30%的高频词
sizeRatio = Math.pow(frequencyRatio, 0.6) * 1.3 * smallCloudFactor;
} else {
// 其余的词
sizeRatio = Math.pow(frequencyRatio, 0.7) * 1.0 * smallCloudFactor;
}
// 应用配置的字体大小比例系数
sizeRatio *= options.fontSizeRatio;
// 计算最终字体大小
return Math.max(
options.minFontSize,
Math.min(
options.maxFontSize,
Math.floor(options.minFontSize + sizeRatio * (options.maxFontSize - options.minFontSize))
)
);
};
// 获取基于词频的颜色
const getColorFromFrequency = (frequency: number, index: number): string => {
// 使用词频和索引生成不同的色相值
const hue = (index * 137.5) % 360; // 黄金角分布
// 重要词使用更醒目的颜色
let saturation, lightness;
if (index === 0) {
// 最高频词
saturation = 95;
lightness = 45;
} else if (index < sortedWords.length * 0.1) {
// 前10%的高频词
saturation = 90;
lightness = 45;
} else {
// 降低其他词的饱和度,增加整体和谐性
saturation = 75 + (Math.max(0.3, frequency / maxFreq) * 15);
lightness = 40 + (Math.max(0.3, frequency / maxFreq) * 15);
}
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
};
// 临时画布用于测量文本
let tempCanvas = createCanvas(initialWidth, initialHeight);
let tempCtx = tempCanvas.getContext('2d');
// 已确定位置的单词数组
const placedWords: Position[] = [];
// 根据旋转角度计算包围盒(用于碰撞检测)
const getRotatedBoundingBox = (x: number, y: number, width: number, height: number, rotation: number) => {
// 转换角度为弧度
const rad = rotation * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
// 计算四个角的坐标
const corners = [
{ x: -width / 2, y: -height / 2 },
{ x: width / 2, y: -height / 2 },
{ x: width / 2, y: height / 2 },
{ x: -width / 2, y: height / 2 }
].map(pt => {
return {
x: x + width / 2 + (pt.x * cos - pt.y * sin),
y: y + height / 2 + (pt.x * sin + pt.y * cos)
};
});
// 计算包围盒
const boxMinX = Math.min(...corners.map(c => c.x));
const boxMaxX = Math.max(...corners.map(c => c.x));
const boxMinY = Math.min(...corners.map(c => c.y));
const boxMaxY = Math.max(...corners.map(c => c.y));
return { minX: boxMinX, maxX: boxMaxX, minY: boxMinY, maxY: boxMaxY };
};
// 精确重叠检测 - 允许适度重叠,并考虑词的重要性
const isOverlapping = (x: number, y: number, width: number, height: number, rotation: number, index: number): boolean => {
// 获取当前词的包围盒
const currentBox = getRotatedBoundingBox(x, y, width, height, rotation);
// 为边缘增加安全距离,根据重要性调整
const safetyMargin = index < sortedWords.length * 0.05 ?
options.safetyMargin * 1.2 : options.safetyMargin * 0.9;
const safetyBox = {
minX: currentBox.minX - safetyMargin,
maxX: currentBox.maxX + safetyMargin,
minY: currentBox.minY - safetyMargin,
maxY: currentBox.maxY + safetyMargin
};
// 计算当前单词的面积
const currentArea = (safetyBox.maxX - safetyBox.minX) * (safetyBox.maxY - safetyBox.minY);
// 为高频词设置更严格的重叠阈值
const overlapThreshold = index < sortedWords.length * 0.1 ?
options.overlapThreshold * 0.6 : options.overlapThreshold;
// 检查是否与已放置的词重叠超过阈值
for (const pos of placedWords) {
const posBox = getRotatedBoundingBox(
pos.x, pos.y, pos.width, pos.height, pos.rotation
);
// 计算重叠区域
const overlapX = Math.max(0, Math.min(safetyBox.maxX, posBox.maxX) - Math.max(safetyBox.minX, posBox.minX));
const overlapY = Math.max(0, Math.min(safetyBox.maxY, posBox.maxY) - Math.max(safetyBox.minY, posBox.minY));
const overlapArea = overlapX * overlapY;
// 计算重叠率
const overlapRatio = overlapArea / currentArea;
// 如果重叠率超过阈值,则认为重叠
if (overlapRatio > overlapThreshold) {
return true;
}
}
return false;
};
// 获取当前词云形状信息 - 改进密度计算
const getCloudShape = () => {
if (placedWords.length === 0) {
return {
width: initialWidth,
height: initialHeight,
ratio: initialWidth / initialHeight,
density: 0
};
}
// 计算已放置区域的边界
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
let totalArea = 0;
placedWords.forEach(pos => {
const box = getRotatedBoundingBox(pos.x, pos.y, pos.width, pos.height, pos.rotation);
minX = Math.min(minX, box.minX);
maxX = Math.max(maxX, box.maxX);
minY = Math.min(minY, box.minY);
maxY = Math.max(maxY, box.maxY);
// 累计词的面积
totalArea += pos.width * pos.height;
});
const width = Math.max(1, maxX - minX);
const height = Math.max(1, maxY - minY);
const area = width * height;
// 计算密度 (已用面积 / 总面积)
const density = totalArea / area;
return {
width,
height,
ratio: width / height,
density
};
};
// 自适应旋转角度决策 - 基于可用空间和单词特性
const getOptimalRotation = (word: string, textWidth: number, textHeight: number, index: number) => {
if (!options.enableRotation) return 0;
const wordType = getWordType(word);
const isHighFrequencyWord = index < sortedWords.length * 0.15; // 前15%的高频词
// 单字或短词偏好水平排列
if (word.length === 1 || (wordType === 'english' && word.length <= 3)) {
return 0;
}
// 中文单字不旋转
if (wordType === 'chinese' && word.length === 1) {
return 0;
}
// 高频词优先水平排列
if (isHighFrequencyWord) {
// 最高频词不旋转
if (index === 0) return 0;
// 其他高频词轻微旋转
return (Math.random() * 2 - 1) * 3;
}
// 获取当前词云形状与密度
const cloudShape = getCloudShape();
// 特别定制:根据词的宽高比决定旋转
// 细长的词在排版上更灵活
const isLongWord = textWidth / textHeight > 3;
// 宽高比例调整旋转策略
if (cloudShape.ratio > 1.3) {
// 宽大于高,优先考虑竖排
if (isLongWord) {
// 细长词适合90度旋转
return 90;
} else {
// 其他词随机选择,但偏向竖排
return Math.random() < 0.7 ?
90 + (Math.random() * 2 - 1) * 5 : // 竖排
(Math.random() * 2 - 1) * 5; // 横排
}
} else if (cloudShape.ratio < 0.7) {
// 高大于宽,优先考虑横排
return (Math.random() * 2 - 1) * 5;
}
// 根据词的类型进一步决定倾向
let horizontalBias = options.horizontalWeight;
if (wordType === 'chinese' && word.length > 1) {
// 中文词组更适合横排
horizontalBias += 0.2;
} else if (wordType === 'english' && word.length > 5) {
// 长英文单词可增加竖排几率
horizontalBias -= 0.1;
}
// 根据词的长宽比进一步微调
const aspectRatio = textWidth / textHeight;
if (aspectRatio > 4) {
// 极细长的词更适合竖排
horizontalBias -= 0.25;
} else if (aspectRatio < 1.5) {
// 近方形的词更适合横排
horizontalBias += 0.15;
}
// 最终决定旋转
return Math.random() < horizontalBias ?
(Math.random() * 2 - 1) * options.rotationVariance / 3 : // 横排,减小角度变化
90 + (Math.random() * 2 - 1) * options.rotationVariance / 3; // 竖排,减小角度变化
};
// 增强的过滤不可渲染字符函数
function filterUnrenderableChars(text: string): string {
// 过滤掉控制字符、特殊Unicode和一些可能导致渲染问题的字符
return text
.replace(/[\u0000-\u001F\u007F-\u009F\uFFFD\uFFFE\uFFFF]/g, '') // 控制字符和特殊字符
.replace(/[\u2000-\u200F\u2028-\u202F]/g, '') // 一些特殊空白和控制字符
.replace(/[\u0080-\u00A0]/g, '') // 一些Latin-1补充字符
.replace(/[^\p{L}\p{N}\p{P}\p{Z}]/gu, '') //
.trim();
}
// ===== 优化: 添加新的位置策略函数 =====
// 改进的螺旋布局 - 更紧凑的布局策略
const getSpiralPosition = (
textWidth: number,
textHeight: number,
attempt: number,
canvasShape: { width: number, height: number, ratio: number, density: number }
) => {
// 根据词数量调整螺旋参数 - 词少时更紧凑
const wordCountFactor = Math.min(1, placedWords.length / 20); // 少于20个词时更紧凑
// 使用已放置词的中心点,而非固定画布中心
let centerX = initialWidth / 2;
let centerY = initialHeight / 2;
// 如果已经有足够的词,使用它们的质心作为新的中心点
if (placedWords.length >= 3) {
let sumX = 0, sumY = 0, weightSum = 0;
for (const pos of placedWords) {
// 较大的词有更大的权重影响中心点
const weight = Math.sqrt(pos.width * pos.height);
sumX += (pos.x + pos.width / 2) * weight;
sumY += (pos.y + pos.height / 2) * weight;
weightSum += weight;
}
centerX = sumX / weightSum;
centerY = sumY / weightSum;
}
// 动态调整螺旋参数,词数少时更紧凑
const baseA = Math.min(initialWidth, initialHeight) / (35 + (1 - wordCountFactor) * 15);
const densityFactor = Math.max(0.7, Math.min(1.4, 0.7 + canvasShape.density * 1.2));
const a = baseA / densityFactor; // 反比例,密度高时参数更小,螺旋更紧凑
// 词数量少时使用更小的角度增量,产生更紧凑的螺旋
const angleIncrement = 0.1 + wordCountFactor * 0.25;
const angle = angleIncrement * attempt;
// 非线性距离增长,词数少时增长更慢
const distanceMultiplier = wordCountFactor * (
attempt < 8 ?
0.2 + Math.pow(attempt / 8, 1.5) : // 前几次更靠近中心,呈幂次增长
0.7 + Math.pow((attempt - 8) / 25, 0.7) // 之后缓慢增长
) + (1 - wordCountFactor) * (0.1 + Math.pow(attempt / 20, 1.2)); // 词少时增长更慢
// 根据画布形状自适应调整螺旋方向
let dx, dy;
if (canvasShape.ratio > 1.2) { // 宽大于高
// 水平方向拉伸,但减少拉伸强度
dx = a * angle * Math.cos(angle) * distanceMultiplier * 1.1;
dy = a * angle * Math.sin(angle) * distanceMultiplier * 0.9;
} else if (canvasShape.ratio < 0.8) { // 高大于宽
// 垂直方向拉伸,但减少拉伸强度
dx = a * angle * Math.cos(angle) * distanceMultiplier * 0.9;
dy = a * angle * Math.sin(angle) * distanceMultiplier * 1.1;
} else {
// 更均衡的螺旋
dx = a * angle * Math.cos(angle) * distanceMultiplier;
dy = a * angle * Math.sin(angle) * distanceMultiplier;
}
// 添加少量随机抖动以打破规则性
dx += (Math.random() - 0.5) * a * 0.5;
dy += (Math.random() - 0.5) * a * 0.5;
const x = centerX + dx - textWidth / 2;
const y = centerY + dy - textHeight / 2;
return {
x: Math.max(options.safetyMargin, Math.min(initialWidth - textWidth - options.safetyMargin, x)),
y: Math.max(textHeight / 2 + options.safetyMargin, Math.min(initialHeight - textHeight / 2 - options.safetyMargin, y))
};
};
// 新增: 空白区域填充策略
const findGapPosition = (
textWidth: number,
textHeight: number,
canvasShape: { width: number, height: number, ratio: number, density: number }
) => {
// 如果词太少,直接返回螺旋位置
if (placedWords.length < 5) {
return getSpiralPosition(textWidth, textHeight, Math.floor(Math.random() * 10), canvasShape);
}
// 计算当前词云的边界
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const pos of placedWords) {
const box = getRotatedBoundingBox(pos.x, pos.y, pos.width, pos.height, pos.rotation);
minX = Math.min(minX, box.minX);
maxX = Math.max(maxX, box.maxX);
minY = Math.min(minY, box.minY);
maxY = Math.max(maxY, box.maxY);
}
// 定义搜索区域,适当扩大范围
const searchMargin = Math.max(textWidth, textHeight) * 0.5;
const searchMinX = Math.max(0, minX - searchMargin);
const searchMaxX = Math.min(initialWidth, maxX + searchMargin);
const searchMinY = Math.max(0, minY - searchMargin);
const searchMaxY = Math.min(initialHeight, maxY + searchMargin);
// 搜索区域宽高
const searchWidth = searchMaxX - searchMinX;
const searchHeight = searchMaxY - searchMinY;
// 网格尺寸,较小的网格可以更精确地找到空白区域
const gridSize = Math.min(textWidth, textHeight) / 2;
const gridRows = Math.max(3, Math.ceil(searchHeight / gridSize));
const gridCols = Math.max(3, Math.ceil(searchWidth / gridSize));
// 初始化网格密度
const gridDensity = Array(gridRows).fill(0).map(() => Array(gridCols).fill(0));
// 计算每个网格的密度
for (const pos of placedWords) {
const box = getRotatedBoundingBox(pos.x, pos.y, pos.width, pos.height, pos.rotation);
// 计算此词覆盖的网格范围
const startRow = Math.max(0, Math.floor((box.minY - searchMinY) / gridSize));
const endRow = Math.min(gridRows - 1, Math.floor((box.maxY - searchMinY) / gridSize));
const startCol = Math.max(0, Math.floor((box.minX - searchMinX) / gridSize));
const endCol = Math.min(gridCols - 1, Math.floor((box.maxX - searchMinX) / gridSize));
// 增加网格密度值
for (let r = startRow; r <= endRow; r++) {
for (let c = startCol; c <= endCol; c++) {
if (r >= 0 && r < gridRows && c >= 0 && c < gridCols) {
gridDensity[r]![c] += 1;
}
}
}
}
// 找出能容纳当前词的最低密度区域
let bestDensity = Infinity;
let bestRow = 0, bestCol = 0;
// 需要的网格数量
const needRows = Math.ceil(textHeight / gridSize);
const needCols = Math.ceil(textWidth / gridSize);
// 搜索最优位置
for (let r = 0; r <= gridRows - needRows; r++) {
for (let c = 0; c <= gridCols - needCols; c++) {
let totalDensity = 0;
let isValid = true;
// 计算区域总密度
for (let nr = 0; nr < needRows && isValid; nr++) {
for (let nc = 0; nc < needCols && isValid; nc++) {
if (r + nr < gridRows && c + nc < gridCols) {
totalDensity += gridDensity[r + nr]![c + nc];
// 如果单个网格密度过高,直接判定无效
if (gridDensity[r + nr]![c + nc] > 3) {
isValid = false;
}
}
}
}
// 更新最佳位置
if (isValid && totalDensity < bestDensity) {
bestDensity = totalDensity;
bestRow = r;
bestCol = c;
}
}
}
// 添加随机抖动避免太规则
const jitterX = (Math.random() - 0.5) * gridSize * 0.6;
const jitterY = (Math.random() - 0.5) * gridSize * 0.6;
// 计算最终位置
const x = searchMinX + bestCol * gridSize + jitterX;
const y = searchMinY + bestRow * gridSize + jitterY;
return {
x: Math.max(options.safetyMargin, Math.min(initialWidth - textWidth - options.safetyMargin, x)),
y: Math.max(textHeight + options.safetyMargin, Math.min(initialHeight - options.safetyMargin, y))
};
};
// 新增: 边缘扩展策略
const getEdgeExtendPosition = (
textWidth: number,
textHeight: number,
attempt: number,
canvasShape: { width: number, height: number, ratio: number, density: number }
) => {
// 如果无已放置词,回退到螺旋
if (placedWords.length === 0) {
return getSpiralPosition(textWidth, textHeight, attempt, canvasShape);
}
// 随机选择一个已放置的词作为参考点
const referenceIndex = Math.floor(Math.random() * placedWords.length);
const reference = placedWords[referenceIndex];
// 随机选择方向 (0=右, 1=下, 2=左, 3=上4-7=对角线)
const direction = Math.floor(Math.random() * 8);
// 基础位置
let baseX = reference!.x;
let baseY = reference!.y;
// 获取参考词的旋转后边界框
const refBox = getRotatedBoundingBox(
reference!.x, reference!.y, reference!.width, reference!.height, reference!.rotation
);
// 根据方向计算新位置
const margin = options.safetyMargin * 0.5; // 减小边距,增加紧凑度
switch (direction) {
case 0: // 右
baseX = refBox.maxX + margin;
baseY = refBox.minY + (refBox.maxY - refBox.minY) / 2 - textHeight / 2;
break;
case 1: // 下
baseX = refBox.minX + (refBox.maxX - refBox.minX) / 2 - textWidth / 2;
baseY = refBox.maxY + margin;
break;
case 2: // 左
baseX = refBox.minX - textWidth - margin;
baseY = refBox.minY + (refBox.maxY - refBox.minY) / 2 - textHeight / 2;
break;
case 3: // 上
baseX = refBox.minX + (refBox.maxX - refBox.minX) / 2 - textWidth / 2;
baseY = refBox.minY - textHeight - margin;
break;
case 4: // 右上
baseX = refBox.maxX + margin;
baseY = refBox.minY - textHeight - margin;
break;
case 5: // 右下
baseX = refBox.maxX + margin;
baseY = refBox.maxY + margin;
break;
case 6: // 左下
baseX = refBox.minX - textWidth - margin;
baseY = refBox.maxY + margin;
break;
case 7: // 左上
baseX = refBox.minX - textWidth - margin;
baseY = refBox.minY - textHeight - margin;
break;
}
// 添加少量随机抖动
baseX += (Math.random() - 0.5) * margin * 2;
baseY += (Math.random() - 0.5) * margin * 2;
return {
x: Math.max(options.safetyMargin, Math.min(initialWidth - textWidth - options.safetyMargin, baseX)),
y: Math.max(textHeight + options.safetyMargin, Math.min(initialHeight - options.safetyMargin, baseY))
};
};
// 新增: 多策略选择函数,根据情况选择最佳策略
const getPositionWithStrategy = (
textWidth: number,
textHeight: number,
attempt: number,
canvasShape: { width: number, height: number, ratio: number, density: number },
index: number
) => {
// 检测是否为小词云
const isSmallWordCloud = sortedWords.length < 15;
// 第一个词或前几个高频词仍然放在中心,小词云时范围更大
if (placedWords.length === 0 || (index < 3 && attempt < 5) || (isSmallWordCloud && index < Math.min(5, sortedWords.length / 2))) {
// 添加小偏移以避免完全重叠
const offset = isSmallWordCloud ? index * 8 : 0;
return {
x: initialWidth / 2 - textWidth / 2 + (Math.random() - 0.5) * offset,
y: initialHeight / 2 - textHeight / 2 + (Math.random() - 0.5) * offset
};
}
// 根据尝试次数选择不同策略
const attemptProgress = attempt / options.maxAttempts; // 0到1的进度值
// 小词云优先使用紧凑布局策略
if (isSmallWordCloud) {
if (attemptProgress < 0.6) {
return getSpiralPosition(textWidth, textHeight, attempt / 2, canvasShape); // 减少螺旋步长,更紧凑
} else {
return Math.random() < 0.7 ?
findGapPosition(textWidth, textHeight, canvasShape) :
getEdgeExtendPosition(textWidth, textHeight, attempt / 2, canvasShape);
}
}
// 高频词优先使用螺旋或中心布局
if (index < sortedWords.length * 0.1) {
if (attemptProgress < 0.5) {
return getSpiralPosition(textWidth, textHeight, attempt, canvasShape);
} else {
return Math.random() < 0.7 ?
findGapPosition(textWidth, textHeight, canvasShape) :
getEdgeExtendPosition(textWidth, textHeight, attempt, canvasShape);
}
}
// 不同阶段使用不同策略
if (attemptProgress < 0.3) {
// 前30%尝试: 主要使用改进的螺旋
return getSpiralPosition(textWidth, textHeight, attempt, canvasShape);
} else if (attemptProgress < 0.7) {
// 中间40%尝试: 主要寻找空白区域
return Math.random() < 0.8 ?
findGapPosition(textWidth, textHeight, canvasShape) :
getSpiralPosition(textWidth, textHeight, attempt, canvasShape);
} else {
// 后30%尝试: 主要使用边缘扩展和随机策略
const r = Math.random();
if (r < 0.6) {
return getEdgeExtendPosition(textWidth, textHeight, attempt, canvasShape);
} else if (r < 0.8) {
return findGapPosition(textWidth, textHeight, canvasShape);
} else {
return getSpiralPosition(textWidth, textHeight, attempt * 2, canvasShape); // 双倍螺旋步进,迅速扩展
}
}
};
// 记录所有单词的边界以自动调整画布大小
let minX = initialWidth;
let maxX = 0;
let minY = initialHeight;
let maxY = 0;
// 记录原始中心点,用于居中重定位
let originalCenterX = initialWidth / 2;
let originalCenterY = initialHeight / 2;
// 动态画布扩展计数
let canvasExpansionCount = 0;
// 第一轮:计算每个单词的位置并追踪边界
for (let i = 0; i < sortedWords.length; i++) {
const { word, frequency } = sortedWords[i]!;
// 安全检查 - 过滤不可渲染字符
const safeWord = filterUnrenderableChars(word);
if (!safeWord) continue;
// 使用增强的字体大小计算函数
const fontSize = calculateFontSize(frequency, i);
// 获取合适的字体
const fontFamily = getFontFamily(safeWord);
// 设置字体和测量文本
tempCtx.font = `bold ${fontSize}px ${fontFamily}`;
const metrics = tempCtx.measureText(safeWord);
// 更精确地计算文本高度
const textHeight = fontSize;
const textWidth = metrics.width;
// 获取当前云形状与密度
const cloudShape = getCloudShape();
// 获取最佳旋转角度
const rotation = getOptimalRotation(safeWord, textWidth, textHeight, i);
// 尝试定位
let positioned = false;
let finalX = 0, finalY = 0;
// 尝试放置单词,如果失败可能会扩展画布
for (let attempt = 0; attempt < options.maxAttempts && !positioned; attempt++) {
// 使用多策略获取位置,而不是仅用螺旋布局
const { x, y } = getPositionWithStrategy(textWidth, textHeight, attempt, cloudShape, i);
if (!isOverlapping(x, y, textWidth, textHeight, rotation, i)) {
finalX = x;
finalY = y;
positioned = true;
// 获取此单词旋转后的包围盒
const box = getRotatedBoundingBox(x, y, textWidth, textHeight, rotation);
// 更新整体边界
minX = Math.min(minX, box.minX);
maxX = Math.max(maxX, box.maxX);
minY = Math.min(minY, box.minY);
maxY = Math.max(maxY, box.maxY);
// 记录位置,保存字体大小
placedWords.push({
x: finalX,
y: finalY,
width: textWidth,
height: textHeight,
rotation,
fontSize
});
} else if (attempt === options.maxAttempts - 1 && canvasExpansionCount < options.maxExpansionAttempts) {
// 如果所有尝试都失败,并且还有扩展余量,则扩展画布
canvasExpansionCount++;
// 计算当前中心点
const currentCenterX = (maxX + minX) / 2;
const currentCenterY = (maxY + minY) / 2;
// 保存原始画布尺寸
const oldWidth = initialWidth;
const oldHeight = initialHeight;
// 扩展画布尺寸 - 使用更小的扩展比例
initialWidth = Math.ceil(initialWidth * options.expansionRatio);
initialHeight = Math.ceil(initialHeight * options.expansionRatio);
// 计算扩展量
const widthIncrease = initialWidth - oldWidth;
const heightIncrease = initialHeight - oldHeight;
// 调整所有已放置单词的位置,使它们保持居中
placedWords.forEach(pos => {
// 相对于原中心的偏移
const offsetX = pos.x - originalCenterX;
const offsetY = pos.y - originalCenterY;
// 计算新位置,保持相对于中心的偏移不变
pos.x = originalCenterX + widthIncrease / 2 + offsetX;
pos.y = originalCenterY + heightIncrease / 2 + offsetY;
});
// 更新坐标系中心点
originalCenterX = originalCenterX + widthIncrease / 2;
originalCenterY = originalCenterY + heightIncrease / 2;
// 更新边界信息
minX += widthIncrease / 2;
maxX += widthIncrease / 2;
minY += heightIncrease / 2;
maxY += heightIncrease / 2;
// 重新创建临时画布
tempCanvas = createCanvas(initialWidth, initialHeight);
tempCtx = tempCanvas.getContext('2d');
// 重置尝试计数,在新的扩展画布上再次尝试
attempt = -1; // 会在循环中+1变成0
}
}
// 如果仍然无法放置,尝试增加重叠容忍度
if (!positioned) {
const maxOverlapThreshold = options.overlapThreshold * 2.0; // 允许更多重叠
for (let attempt = 0; attempt < options.maxAttempts && !positioned; attempt++) {
// 再次使用多策略获取位置
const { x, y } = getPositionWithStrategy(textWidth, textHeight, attempt, cloudShape, i);
// 获取当前词的包围盒
const currentBox = getRotatedBoundingBox(x, y, textWidth, textHeight, rotation);
// 计算当前单词的面积
const currentArea = (currentBox.maxX - currentBox.minX) * (currentBox.maxY - currentBox.minY);
// 计算最大重叠面积
let maxOverlapArea = 0;
for (const pos of placedWords) {
const posBox = getRotatedBoundingBox(
pos.x, pos.y, pos.width, pos.height, pos.rotation
);
// 计算重叠区域
const overlapX = Math.max(0, Math.min(currentBox.maxX, posBox.maxX) - Math.max(currentBox.minX, posBox.minX));
const overlapY = Math.max(0, Math.min(currentBox.maxY, posBox.maxY) - Math.max(currentBox.minY, posBox.minY));
const overlapArea = overlapX * overlapY;
maxOverlapArea = Math.max(maxOverlapArea, overlapArea);
}
// 计算重叠率
const overlapRatio = maxOverlapArea / currentArea;
// 如果重叠率在允许范围内,则放置
if (overlapRatio <= maxOverlapThreshold) {
finalX = x;
finalY = y;
positioned = true;
// 获取此单词旋转后的包围盒
const box = getRotatedBoundingBox(x, y, textWidth, textHeight, rotation);
// 更新整体边界(即使是增加重叠度放置的单词也计入边界)
minX = Math.min(minX, box.minX);
maxX = Math.max(maxX, box.maxX);
minY = Math.min(minY, box.minY);
maxY = Math.max(maxY, box.maxY);
// 记录位置
placedWords.push({
x: finalX,
y: finalY,
width: textWidth,
height: textHeight,
rotation,
fontSize
});
}
}
// 如果仍然无法放置,则跳过该词
if (!positioned) {
console.log(`无法放置单词: ${safeWord}`);
continue;
}
}
}
// 第二阶段:确定最终画布大小并绘制
// 添加内边距
minX = Math.max(0, minX - options.padding);
minY = Math.max(0, minY - options.padding);
maxX = maxX + options.padding;
maxY = maxY + options.padding;
// 计算最终画布尺寸
const finalWidth = Math.ceil(maxX - minX);
const finalHeight = Math.ceil(maxY - minY);
// 创建最终画布
const canvas = createCanvas(finalWidth, finalHeight);
const ctx = canvas.getContext('2d');
// 设置背景
ctx.fillStyle = options.backgroundColor;
ctx.fillRect(0, 0, finalWidth, finalHeight);
for (let i = 0; i < sortedWords.length; i++) {
if (i >= placedWords.length) continue;
const { word, frequency } = sortedWords[i]!;
const position = placedWords[i];
if (!position) continue;
const safeWord = filterUnrenderableChars(word);
if (!safeWord) continue;
const fontFamily = getFontFamily(safeWord);
ctx.font = `bold ${position.fontSize}px ${fontFamily}`;
ctx.fillStyle = getColorFromFrequency(frequency, i);
const adjustedX = position.x - minX;
const adjustedY = position.y - minY;
ctx.save();
ctx.translate(
adjustedX + position.width / 2,
adjustedY + position.height / 2
);
ctx.rotate(position.rotation * Math.PI / 180);
ctx.fillText(safeWord, -position.width / 2, position.height / 2);
ctx.restore();
}
const buffer = canvas.toBuffer('image/png');
return "base64://" + buffer.toString('base64');
}

View File

@@ -1,79 +0,0 @@
import { current_path } from "@/plugin/data";
import { createCanvas, loadImage } from "@napi-rs/canvas";
import path from "path";
export async function drawJsonContent(jsonContent: string) {
const lines = jsonContent.split('\n');
const padding = 40;
const lineHeight = 30;
const canvas = createCanvas(1, 1);
const ctx = canvas.getContext('2d');
const chineseRegex = /[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef]/;
let maxLineWidth = 0;
for (const line of lines) {
let lineWidth = 0;
for (const char of line) {
const isChinese = chineseRegex.test(char);
ctx.font = isChinese ? '20px "Aa偷吃可爱长大的"' : '20px "JetBrains Mono"';
lineWidth += ctx.measureText(char).width;
}
if (lineWidth > maxLineWidth) {
maxLineWidth = lineWidth;
}
}
const width = maxLineWidth + padding * 2;
const height = lines.length * lineHeight + padding * 2;
const finalCanvas = createCanvas(width, height);
const finalCtx = finalCanvas.getContext('2d');
const backgroundImage = await loadImage(path.join(current_path,'./fonts/post.jpg'));
const pattern = finalCtx.createPattern(backgroundImage, 'repeat');
finalCtx.fillStyle = pattern;
finalCtx.fillRect(0, 0, width, height);
finalCtx.filter = 'blur(5px)';
finalCtx.drawImage(finalCanvas, 0, 0);
finalCtx.filter = 'none';
const cardWidth = width - padding;
const cardHeight = height - padding;
const cardX = padding / 2;
const cardY = padding / 2;
const radius = 20;
finalCtx.fillStyle = 'rgba(255, 255, 255, 0.8)';
finalCtx.beginPath();
finalCtx.moveTo(cardX + radius, cardY);
finalCtx.lineTo(cardX + cardWidth - radius, cardY);
finalCtx.quadraticCurveTo(cardX + cardWidth, cardY, cardX + cardWidth, cardY + radius);
finalCtx.lineTo(cardX + cardWidth, cardY + cardHeight - radius);
finalCtx.quadraticCurveTo(cardX + cardWidth, cardY + cardHeight, cardX + cardWidth - radius, cardY + cardHeight);
finalCtx.lineTo(cardX + radius, cardY + cardHeight);
finalCtx.quadraticCurveTo(cardX, cardY + cardHeight, cardX, cardY + cardHeight - radius);
finalCtx.lineTo(cardX, cardY + radius);
finalCtx.quadraticCurveTo(cardX, cardY, cardX + radius, cardY);
finalCtx.closePath();
finalCtx.fill();
// 绘制 JSON 内容
finalCtx.fillStyle = 'black';
let textY = cardY + 40;
for (const line of lines) {
let x = cardX + 20;
for (const char of line) {
const isChinese = /[\u4e00-\u9fa5]/.test(char);
finalCtx.font = isChinese ? '20px "Aa偷吃可爱长大的"' : '20px "JetBrains Mono"';
finalCtx.fillText(char, x, textY);
x += finalCtx.measureText(char).width;
}
textY += 30;
}
// 保存图像
const buffer = finalCanvas.toBuffer('image/png');
return "base64://" + buffer.toString('base64');
}

View File

@@ -1,10 +1,2 @@
import { NCoreInitShell } from './base';
import { GlobalFonts } from '@napi-rs/canvas';
import { current_path } from '@/plugin/data';
import path from 'path';
GlobalFonts.registerFromPath(path.join(current_path, './fonts/JetBrainsMono.ttf'), 'JetBrains Mono');
GlobalFonts.registerFromPath(path.join(current_path, './fonts/AaCute.ttf'), 'Aa偷吃可爱长大的');
console.log('字体注册完成');
NCoreInitShell();

View File

@@ -8,9 +8,7 @@ const external = [
'silk-wasm',
'ws',
'express',
'@napi-rs/canvas',
'@node-rs/jieba',
'@node-rs/jieba/dict.js',
'@napi-rs/canvas'
];
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
@@ -26,7 +24,6 @@ if (process.env.NAPCAT_BUILDSYS == 'linux') {
const UniversalBaseConfigPlugin: PluginOption[] = [
cp({
targets: [
{ src: './external/fonts', dest: 'dist/fonts' },
{ src: './manifest.json', dest: 'dist' },
{ src: './src/core/external/napcat.json', dest: 'dist/config/' },
{ src: './src/native/packet', dest: 'dist/moehoo', flatten: false },
@@ -50,7 +47,6 @@ const UniversalBaseConfigPlugin: PluginOption[] = [
const FrameworkBaseConfigPlugin: PluginOption[] = [
cp({
targets: [
{ src: './external/fonts', dest: 'dist/fonts' },
{ src: './manifest.json', dest: 'dist' },
{ src: './src/core/external/napcat.json', dest: 'dist/config/' },
{ src: './src/native/packet', dest: 'dist/moehoo', flatten: false },
@@ -70,7 +66,6 @@ const FrameworkBaseConfigPlugin: PluginOption[] = [
const ShellBaseConfigPlugin: PluginOption[] = [
cp({
targets: [
{ src: './external/fonts', dest: 'dist/fonts' },
{ src: './src/native/packet', dest: 'dist/moehoo', flatten: false },
{ src: './src/native/pty', dest: 'dist/pty', flatten: false },
{ src: './napcat.webui/dist/', dest: 'dist/static/', flatten: false },