mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
Compare commits
59 Commits
v4.7.33
...
protobuf-d
Author | SHA1 | Date | |
---|---|---|---|
![]() |
c50359c504 | ||
![]() |
364500e16e | ||
![]() |
22144010ec | ||
![]() |
6421bb4f5c | ||
![]() |
3919743885 | ||
![]() |
a5a57b9e20 | ||
![]() |
e31d2810ad | ||
![]() |
140e62fdcd | ||
![]() |
014b4deb87 | ||
![]() |
956b6cd172 | ||
![]() |
bbaca3f044 | ||
![]() |
bb8a44b918 | ||
![]() |
b5574d5999 | ||
![]() |
06dde072da | ||
![]() |
8e92a81bb9 | ||
![]() |
2c7345ae88 | ||
![]() |
33d4696155 | ||
![]() |
7d2dcc10e5 | ||
![]() |
e82687454c | ||
![]() |
84382caebc | ||
![]() |
662530e507 | ||
![]() |
edf81d0a2e | ||
![]() |
7cbae86941 | ||
![]() |
8ff7420a5e | ||
![]() |
7ae59b1419 | ||
![]() |
41036f8ee8 | ||
![]() |
380777ca04 | ||
![]() |
c658cd1096 | ||
![]() |
c7b9946d2f | ||
![]() |
0caca473d6 | ||
![]() |
3e5d35957d | ||
![]() |
6b8b14aba2 | ||
![]() |
5db7a90a24 | ||
![]() |
88b86611a3 | ||
![]() |
886fe2052e | ||
![]() |
e4dd194d4a | ||
![]() |
a47af60f58 | ||
![]() |
35f24eb806 | ||
![]() |
8403b1c9c1 | ||
![]() |
26593d593c | ||
![]() |
4d26ec737b | ||
![]() |
31fdffde36 | ||
![]() |
bccf5894cd | ||
![]() |
2754165ae5 | ||
![]() |
b9a9438bf0 | ||
![]() |
d8b2ebc01e | ||
![]() |
a979a07d3d | ||
![]() |
d4d94e43d8 | ||
![]() |
3deb7788ae | ||
![]() |
a790303ebe | ||
![]() |
99bfe69752 | ||
![]() |
1c8cf9538d | ||
![]() |
faf390bb18 | ||
![]() |
be2e2e86f0 | ||
![]() |
941b30847b | ||
![]() |
4c5a26698e | ||
![]() |
d14a1dd948 | ||
![]() |
1c0b434f47 | ||
![]() |
573451bade |
@@ -40,8 +40,11 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
| Docs | [](https://napneko.pages.dev/) | [](https://napcat.cyou/) | [](https://www.napcat.wiki) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| Contact | [](https://qm.qq.com/q/I6LU87a0Yq) | [](https://qm.qq.com/q/HaRcfrHpUk) | [](https://t.me/MelodicMoonlight) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
| QQ Group | [](https://qm.qq.com/q/CMmPbGw0jA) | [](https://qm.qq.com/q/8zJMLjqy2Y) | [](https://qm.qq.com/q/HaRcfrHpUk) | [](https://qm.qq.com/q/I6LU87a0Yq) |
|
||||
|:-:|:-:|:-:|:-:|:-:|
|
||||
|
||||
| Telegram | [](https://t.me/MelodicMoonlight) |
|
||||
|:-:|:-:|
|
||||
|
||||
## Thanks
|
||||
|
||||
|
BIN
external/fonts/AaCute.ttf
vendored
Normal file
BIN
external/fonts/AaCute.ttf
vendored
Normal file
Binary file not shown.
BIN
external/fonts/JetBrainsMono.ttf
vendored
Normal file
BIN
external/fonts/JetBrainsMono.ttf
vendored
Normal file
Binary file not shown.
BIN
external/fonts/post.jpg
vendored
Normal file
BIN
external/fonts/post.jpg
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 695 KiB |
@@ -4,7 +4,7 @@
|
||||
"name": "NapCatQQ",
|
||||
"slug": "NapCat.Framework",
|
||||
"description": "高性能的 OneBot 11 协议实现",
|
||||
"version": "4.7.32",
|
||||
"version": "4.7.45",
|
||||
"icon": "./logo.png",
|
||||
"authors": [
|
||||
{
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"name": "napcat",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "4.7.32",
|
||||
"version": "4.7.45",
|
||||
"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,6 +35,7 @@
|
||||
"@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",
|
||||
@@ -63,8 +64,12 @@
|
||||
"compressing": "^1.10.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^0.1.67",
|
||||
"@node-rs/jieba": "^2.0.1",
|
||||
"express": "^5.0.0",
|
||||
"napcat.protobuf": "^1.1.2",
|
||||
"silk-wasm": "^3.6.1",
|
||||
"wordcloud": "^1.2.3",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
|
@@ -115,7 +115,7 @@ async function tryDownload(options: string | HttpDownloadOptions, useReferer: bo
|
||||
if (useReferer && !headers['Referer']) {
|
||||
headers['Referer'] = url;
|
||||
}
|
||||
const fetchRes = await fetch(url, { headers }).catch((err) => {
|
||||
const fetchRes = await fetch(url, { headers, redirect: 'follow' }).catch((err) => {
|
||||
if (err.cause) {
|
||||
throw err.cause;
|
||||
}
|
||||
@@ -145,8 +145,8 @@ export enum FileUriType {
|
||||
|
||||
export async function checkUriType(Uri: string) {
|
||||
const LocalFileRet = await solveProblem((uri: string) => {
|
||||
if (fs.existsSync(uri)) {
|
||||
return { Uri: uri, Type: FileUriType.Local };
|
||||
if (fs.existsSync(path.normalize(uri))) {
|
||||
return { Uri: path.normalize(uri), Type: FileUriType.Local };
|
||||
}
|
||||
return undefined;
|
||||
}, Uri);
|
||||
|
@@ -109,7 +109,6 @@ 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);
|
||||
|
@@ -1 +1 @@
|
||||
export const napCatVersion = '4.7.32';
|
||||
export const napCatVersion = '4.7.45';
|
||||
|
@@ -345,6 +345,7 @@ export class NTQQFileApi {
|
||||
'NodeIKernelMsgListener/onRichMediaDownloadComplete',
|
||||
[{
|
||||
fileModelId: '0',
|
||||
downSourceType: 0,
|
||||
downloadSourceType: 0,
|
||||
triggerType: 1,
|
||||
msgId: msgId,
|
||||
|
@@ -86,4 +86,31 @@ export class NTQQFriendApi {
|
||||
accept,
|
||||
});
|
||||
}
|
||||
async handleDoubtFriendRequest(friendUid: string, str1: string = '', str2: string = '') {
|
||||
this.context.session.getBuddyService().approvalDoubtBuddyReq(friendUid, str1, str2);
|
||||
}
|
||||
async getDoubtFriendRequest(count: number) {
|
||||
let date = Date.now().toString();
|
||||
const [, ret] = await this.core.eventWrapper.callNormalEventV2(
|
||||
'NodeIKernelBuddyService/getDoubtBuddyReq',
|
||||
'NodeIKernelBuddyListener/onDoubtBuddyReqChange',
|
||||
[date, count, ''],
|
||||
() => true,
|
||||
(data) => data.reqId === date
|
||||
);
|
||||
let requests = Promise.all(ret.doubtList.map(async (item) => {
|
||||
return {
|
||||
flag: item.uid, //注意强制String 非isNumeric 不遵守则不符合设计
|
||||
uin: await this.core.apis.UserApi.getUinByUidV2(item.uid) ?? 0,// 信息字段
|
||||
nick: item.nick, // 信息字段 这个不是nickname 可能是来源的群内的昵称
|
||||
source: item.source, // 信息字段
|
||||
reason: item.reason, // 信息字段
|
||||
msg: item.msg, // 信息字段
|
||||
group_code: item.groupCode, // 信息字段
|
||||
time: item.reqTime, // 信息字段
|
||||
type: 'doubt' //保留字段
|
||||
};
|
||||
}))
|
||||
return requests;
|
||||
}
|
||||
}
|
||||
|
@@ -17,6 +17,24 @@ 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);
|
||||
@@ -137,7 +155,6 @@ 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: [],
|
||||
@@ -149,7 +166,30 @@ 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);
|
||||
}
|
||||
|
304
src/core/external/appid.json
vendored
304
src/core/external/appid.json
vendored
@@ -1,4 +1,76 @@
|
||||
{
|
||||
"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"
|
||||
@@ -258,5 +330,237 @@
|
||||
"3.2.17-34467": {
|
||||
"appid": 537282292,
|
||||
"qua": "V1_LNX_NQ_3.2.17_34467_GW_B"
|
||||
},
|
||||
"9.9.19-34566": {
|
||||
"appid": 537282307,
|
||||
"qua": "V1_WIN_NQ_9.9.19_34566_GW_B"
|
||||
},
|
||||
"3.2.17-34566": {
|
||||
"appid": 537282343,
|
||||
"qua": "V1_LNX_NQ_3.2.17_34566_GW_B"
|
||||
},
|
||||
"3.2.17-34606": {
|
||||
"appid": 537282343,
|
||||
"qua": "V1_LNX_NQ_3.2.17_34606_GW_B"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
22
src/core/external/offset.json
vendored
22
src/core/external/offset.json
vendored
@@ -327,12 +327,32 @@
|
||||
"send": "770CDC0",
|
||||
"recv": "77106F0"
|
||||
},
|
||||
"9.9.19-34362-x64":{
|
||||
"9.9.19-34362-x64": {
|
||||
"send": "3BD80D0",
|
||||
"recv": "3BDC8D0"
|
||||
},
|
||||
"9.9.19-34467-x64": {
|
||||
"send": "3BD8690",
|
||||
"recv": "3BDCE90"
|
||||
},
|
||||
"9.9.19-34566-x64": {
|
||||
"send": "3BDA110",
|
||||
"recv": "3BDE910"
|
||||
},
|
||||
"9.9.19-34606-x64": {
|
||||
"send": "3BDA110",
|
||||
"recv": "3BDE910"
|
||||
},
|
||||
"3.2.17-34606-x64": {
|
||||
"send": "AD7DC60",
|
||||
"recv": "AD81680"
|
||||
},
|
||||
"3.2.17-34606-arm64": {
|
||||
"send": "7711270",
|
||||
"recv": "7714BA0"
|
||||
},
|
||||
"9.9.19-34740-x64": {
|
||||
"send": "3BDD8D0",
|
||||
"recv": "3BE20D0"
|
||||
}
|
||||
}
|
@@ -40,12 +40,30 @@ export class NodeIKernelBuddyListener {
|
||||
}
|
||||
|
||||
onDelBatchBuddyInfos(arg: unknown): any {
|
||||
console.log('onDelBatchBuddyInfos not implemented', ...arguments);
|
||||
}
|
||||
|
||||
onDoubtBuddyReqChange(arg: unknown): any {
|
||||
onDoubtBuddyReqChange(_arg:
|
||||
{
|
||||
reqId: string;
|
||||
cookie: string;
|
||||
doubtList: Array<{
|
||||
uid: string;
|
||||
nick: string;
|
||||
age: number,
|
||||
sex: number;
|
||||
commFriendNum: number;
|
||||
reqTime: string;
|
||||
msg: string;
|
||||
source: string;
|
||||
reason: string;
|
||||
groupCode: string;
|
||||
nameMore?: null;
|
||||
}>;
|
||||
}): void | Promise<void> {
|
||||
}
|
||||
|
||||
onDoubtBuddyReqUnreadNumChange(arg: unknown): any {
|
||||
onDoubtBuddyReqUnreadNumChange(_num: number): void | Promise<void> {
|
||||
}
|
||||
|
||||
onNickUpdated(arg: unknown): any {
|
||||
|
@@ -21,7 +21,8 @@ export interface OnRichMediaDownloadCompleteParams {
|
||||
clientMsg: string,
|
||||
businessId: number,
|
||||
userTotalSpacePerDay: unknown,
|
||||
userUsedSpacePerDay: unknown
|
||||
userUsedSpacePerDay: unknown,
|
||||
chatType: number,
|
||||
}
|
||||
|
||||
export interface GroupFileInfoUpdateParamType {
|
||||
@@ -97,112 +98,112 @@ export interface TempOnRecvParams {
|
||||
}
|
||||
|
||||
export class NodeIKernelMsgListener {
|
||||
onAddSendMsg(msgRecord: RawMessage): any {
|
||||
onAddSendMsg(_msgRecord: RawMessage): any {
|
||||
|
||||
}
|
||||
|
||||
onBroadcastHelperDownloadComplete(broadcastHelperTransNotifyInfo: unknown): any {
|
||||
onBroadcastHelperDownloadComplete(_broadcastHelperTransNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onBroadcastHelperProgressUpdate(broadcastHelperTransNotifyInfo: unknown): any {
|
||||
onBroadcastHelperProgressUpdate(_broadcastHelperTransNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onChannelFreqLimitInfoUpdate(contact: unknown, z: unknown, freqLimitInfo: unknown): any {
|
||||
onChannelFreqLimitInfoUpdate(_contact: unknown, _z: unknown, _freqLimitInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onContactUnreadCntUpdate(hashMap: unknown): any {
|
||||
onContactUnreadCntUpdate(_hashMap: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onCustomWithdrawConfigUpdate(customWithdrawConfig: unknown): any {
|
||||
onCustomWithdrawConfigUpdate(_customWithdrawConfig: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onDraftUpdate(contact: unknown, arrayList: unknown, j2: unknown): any {
|
||||
onDraftUpdate(_contact: unknown, _arrayList: unknown, _j2: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onEmojiDownloadComplete(emojiNotifyInfo: unknown): any {
|
||||
onEmojiDownloadComplete(_emojiNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onEmojiResourceUpdate(emojiResourceInfo: unknown): any {
|
||||
onEmojiResourceUpdate(_emojiResourceInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onFeedEventUpdate(firstViewDirectMsgNotifyInfo: unknown): any {
|
||||
onFeedEventUpdate(_firstViewDirectMsgNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onFileMsgCome(arrayList: unknown): any {
|
||||
onFileMsgCome(_arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onFirstViewDirectMsgUpdate(firstViewDirectMsgNotifyInfo: unknown): any {
|
||||
onFirstViewDirectMsgUpdate(_firstViewDirectMsgNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onFirstViewGroupGuildMapping(arrayList: unknown): any {
|
||||
onFirstViewGroupGuildMapping(_arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onGrabPasswordRedBag(i2: unknown, str: unknown, i3: unknown, recvdOrder: unknown, msgRecord: unknown): any {
|
||||
onGrabPasswordRedBag(_i2: unknown, _str: unknown, _i3: unknown, _recvdOrder: unknown, _msgRecord: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onGroupFileInfoAdd(groupItem: unknown): any {
|
||||
onGroupFileInfoAdd(_groupItem: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onGroupFileInfoUpdate(groupFileListResult: GroupFileInfoUpdateParamType): any {
|
||||
onGroupFileInfoUpdate(_groupFileListResult: GroupFileInfoUpdateParamType): any {
|
||||
|
||||
}
|
||||
|
||||
onGroupGuildUpdate(groupGuildNotifyInfo: unknown): any {
|
||||
onGroupGuildUpdate(_groupGuildNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
|
||||
onGroupTransferInfoAdd(groupItem: unknown): any {
|
||||
onGroupTransferInfoAdd(_groupItem: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onGroupTransferInfoUpdate(groupFileListResult: unknown): any {
|
||||
onGroupTransferInfoUpdate(_groupFileListResult: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onGuildInteractiveUpdate(guildInteractiveNotificationItem: unknown): any {
|
||||
onGuildInteractiveUpdate(_guildInteractiveNotificationItem: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onGuildMsgAbFlagChanged(guildMsgAbFlag: unknown): any {
|
||||
onGuildMsgAbFlagChanged(_guildMsgAbFlag: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onGuildNotificationAbstractUpdate(guildNotificationAbstractInfo: unknown): any {
|
||||
onGuildNotificationAbstractUpdate(_guildNotificationAbstractInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onHitCsRelatedEmojiResult(downloadRelateEmojiResultInfo: unknown): any {
|
||||
onHitCsRelatedEmojiResult(_downloadRelateEmojiResultInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onHitEmojiKeywordResult(hitRelatedEmojiWordsResult: unknown): any {
|
||||
onHitEmojiKeywordResult(_hitRelatedEmojiWordsResult: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onHitRelatedEmojiResult(relatedWordEmojiInfo: unknown): any {
|
||||
onHitRelatedEmojiResult(_relatedWordEmojiInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onImportOldDbProgressUpdate(importOldDbMsgNotifyInfo: unknown): any {
|
||||
onImportOldDbProgressUpdate(_importOldDbMsgNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onInputStatusPush(inputStatusInfo: {
|
||||
onInputStatusPush(_inputStatusInfo: {
|
||||
chatType: number;
|
||||
eventType: number;
|
||||
fromUin: string;
|
||||
@@ -215,55 +216,55 @@ export class NodeIKernelMsgListener {
|
||||
|
||||
}
|
||||
|
||||
onKickedOffLine(kickedInfo: KickedOffLineInfo): any {
|
||||
onKickedOffLine(_kickedInfo: KickedOffLineInfo): any {
|
||||
|
||||
}
|
||||
|
||||
onLineDev(arrayList: unknown): any {
|
||||
onLineDev(_arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onLogLevelChanged(j2: unknown): any {
|
||||
onLogLevelChanged(_j2: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgAbstractUpdate(arrayList: unknown): any {
|
||||
onMsgAbstractUpdate(_arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgBoxChanged(arrayList: unknown): any {
|
||||
onMsgBoxChanged(_arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgDelete(contact: unknown, arrayList: unknown): any {
|
||||
onMsgDelete(_contact: unknown, _arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgEventListUpdate(hashMap: unknown): any {
|
||||
onMsgEventListUpdate(_hashMap: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgInfoListAdd(arrayList: unknown): any {
|
||||
onMsgInfoListAdd(_arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgInfoListUpdate(msgList: RawMessage[]): any {
|
||||
onMsgInfoListUpdate(_msgList: RawMessage[]): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgQRCodeStatusChanged(i2: unknown): any {
|
||||
onMsgQRCodeStatusChanged(_i2: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgRecall(chatType: ChatType, uid: string, msgSeq: string): any {
|
||||
onMsgRecall(_chatType: ChatType, _uid: string, _msgSeq: string): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgSecurityNotify(msgRecord: unknown): any {
|
||||
onMsgSecurityNotify(_msgRecord: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgSettingUpdate(msgSetting: unknown): any {
|
||||
onMsgSettingUpdate(_msgSetting: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
@@ -279,108 +280,108 @@ export class NodeIKernelMsgListener {
|
||||
|
||||
}
|
||||
|
||||
onReadFeedEventUpdate(firstViewDirectMsgNotifyInfo: unknown): any {
|
||||
onReadFeedEventUpdate(_firstViewDirectMsgNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onRecvGroupGuildFlag(i2: unknown): any {
|
||||
onRecvGroupGuildFlag(_i2: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onRecvMsg(arrayList: RawMessage[]): any {
|
||||
onRecvMsg(_arrayList: RawMessage[]): any {
|
||||
|
||||
}
|
||||
|
||||
onRecvMsgSvrRspTransInfo(j2: unknown, contact: unknown, i2: unknown, i3: unknown, str: unknown, bArr: unknown): any {
|
||||
onRecvMsgSvrRspTransInfo(_j2: unknown, _contact: unknown, _i2: unknown, _i3: unknown, _str: unknown, _bArr: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onRecvOnlineFileMsg(arrayList: unknown): any {
|
||||
onRecvOnlineFileMsg(_arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onRecvS2CMsg(arrayList: unknown): any {
|
||||
onRecvS2CMsg(_arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onRecvSysMsg(arrayList: Array<number>): any {
|
||||
onRecvSysMsg(_arrayList: Array<number>): any {
|
||||
|
||||
}
|
||||
|
||||
onRecvUDCFlag(i2: unknown): any {
|
||||
onRecvUDCFlag(_i2: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onRichMediaDownloadComplete(fileTransNotifyInfo: OnRichMediaDownloadCompleteParams): any {
|
||||
onRichMediaDownloadComplete(_fileTransNotifyInfo: OnRichMediaDownloadCompleteParams): any {
|
||||
}
|
||||
|
||||
onRichMediaProgerssUpdate(fileTransNotifyInfo: unknown): any {
|
||||
onRichMediaProgerssUpdate(_fileTransNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onRichMediaUploadComplete(fileTransNotifyInfo: unknown): any {
|
||||
onRichMediaUploadComplete(_fileTransNotifyInfo: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onSearchGroupFileInfoUpdate(searchGroupFileResult: unknown): any {
|
||||
onSearchGroupFileInfoUpdate(_searchGroupFileResult: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onSendMsgError(j2: unknown, contact: unknown, i2: unknown, str: unknown): any {
|
||||
onSendMsgError(_j2: unknown, _contact: unknown, _i2: unknown, _str: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onSysMsgNotification(i2: unknown, j2: unknown, j3: unknown, arrayList: unknown): any {
|
||||
onSysMsgNotification(_i2: unknown, _j2: unknown, _j3: unknown, _arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onTempChatInfoUpdate(tempChatInfo: TempOnRecvParams): any {
|
||||
onTempChatInfoUpdate(_tempChatInfo: TempOnRecvParams): any {
|
||||
|
||||
}
|
||||
|
||||
onUnreadCntAfterFirstView(hashMap: unknown): any {
|
||||
onUnreadCntAfterFirstView(_hashMap: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onUnreadCntUpdate(hashMap: unknown): any {
|
||||
onUnreadCntUpdate(_hashMap: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onUserChannelTabStatusChanged(z: unknown): any {
|
||||
onUserChannelTabStatusChanged(_z: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onUserOnlineStatusChanged(z: unknown): any {
|
||||
onUserOnlineStatusChanged(_z: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onUserTabStatusChanged(arrayList: unknown): any {
|
||||
onUserTabStatusChanged(_arrayList: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onlineStatusBigIconDownloadPush(i2: unknown, j2: unknown, str: unknown): any {
|
||||
onlineStatusBigIconDownloadPush(_i2: unknown, _j2: unknown, _str: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
onlineStatusSmallIconDownloadPush(i2: unknown, j2: unknown, str: unknown): any {
|
||||
onlineStatusSmallIconDownloadPush(_i2: unknown, _j2: unknown, _str: unknown): any {
|
||||
|
||||
}
|
||||
|
||||
// 第一次发现于Linux
|
||||
onUserSecQualityChanged(...args: unknown[]): any {
|
||||
onUserSecQualityChanged(..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onMsgWithRichLinkInfoUpdate(...args: unknown[]): any {
|
||||
onMsgWithRichLinkInfoUpdate(..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
onRedTouchChanged(...args: unknown[]): any {
|
||||
onRedTouchChanged(..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
|
||||
// 第一次发现于Win 9.9.9-23159
|
||||
onBroadcastHelperProgerssUpdate(...args: unknown[]): any {
|
||||
onBroadcastHelperProgerssUpdate(..._args: unknown[]): any {
|
||||
|
||||
}
|
||||
}
|
||||
|
@@ -96,7 +96,7 @@ export interface NodeIKernelSearchListener {
|
||||
}): any;
|
||||
|
||||
onSearchMsgKeywordsResult(params: {
|
||||
searchId: string,
|
||||
searchId: number,
|
||||
hasMore: boolean,
|
||||
resultItems: Array<{
|
||||
msgId: string,
|
||||
|
@@ -8,7 +8,8 @@ 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;
|
||||
@@ -56,6 +57,19 @@ 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;
|
||||
}
|
||||
|
@@ -6,13 +6,14 @@ import {
|
||||
PacketMsgFileElement,
|
||||
PacketMsgPicElement,
|
||||
PacketMsgPttElement,
|
||||
PacketMsgVideoElement
|
||||
PacketMsgReplyElement,
|
||||
PacketMsgVideoElement,
|
||||
} from '@/core/packet/message/element';
|
||||
import { ChatType, MsgSourceType, NTMsgType, RawMessage } from '@/core';
|
||||
import { MiniAppRawData, MiniAppReqParams } from '@/core/packet/entities/miniApp';
|
||||
import { AIVoiceChatType } from '@/core/packet/entities/aiChat';
|
||||
import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core';
|
||||
import { IndexNode, LongMsgResult, MsgInfo } from '@/core/packet/transformer/proto';
|
||||
import { IndexNode, LongMsgResult, MsgInfo, PushMsgBody } from '@/core/packet/transformer/proto';
|
||||
import { OidbPacket } from '@/core/packet/transformer/base';
|
||||
import { ImageOcrResult } from '@/core/packet/entities/ocrResult';
|
||||
import { gunzipSync } from 'zlib';
|
||||
@@ -76,22 +77,24 @@ 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}`);
|
||||
@@ -100,10 +103,13 @@ 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');
|
||||
@@ -137,24 +143,66 @@ 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;
|
||||
}
|
||||
|
||||
async UploadForwardMsg(msg: PacketMsg[], groupUin: number = 0) {
|
||||
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);
|
||||
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);
|
||||
@@ -203,12 +251,17 @@ 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);
|
||||
@@ -236,6 +289,7 @@ 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) {
|
||||
@@ -251,12 +305,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;
|
||||
@@ -269,7 +323,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),
|
||||
|
@@ -24,12 +24,15 @@ export class PacketMsgBuilder {
|
||||
}
|
||||
return {
|
||||
responseHead: {
|
||||
fromUid: '',
|
||||
fromUin: node.senderUin,
|
||||
toUid: node.groupId ? undefined : selfUid,
|
||||
type: 0,
|
||||
sigMap: 0,
|
||||
toUin: 0,
|
||||
fromUid: '',
|
||||
forward: node.groupId ? undefined : {
|
||||
friendName: node.senderName,
|
||||
},
|
||||
toUid: node.groupId ? undefined : selfUid,
|
||||
grp: node.groupId ? {
|
||||
groupUin: node.groupId,
|
||||
memberName: node.senderName,
|
||||
@@ -40,16 +43,13 @@ export class PacketMsgBuilder {
|
||||
type: node.groupId ? 82 : 9,
|
||||
subType: node.groupId ? undefined : 4,
|
||||
divSeq: node.groupId ? undefined : 4,
|
||||
msgId: crypto.randomBytes(4).readUInt32LE(0),
|
||||
autoReply: 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 ? 0 : 2,
|
||||
field3: node.groupId ? 1 : 2,
|
||||
unknownBase64: avatar,
|
||||
avatar: avatar
|
||||
}
|
||||
|
@@ -10,6 +10,7 @@ import {
|
||||
MsgInfo,
|
||||
NotOnlineImage,
|
||||
OidbSvcTrpcTcp0XE37_800Response,
|
||||
PushMsgBody,
|
||||
QBigFaceExtra,
|
||||
QSmallFaceExtra,
|
||||
} from '@/core/packet/transformer/proto';
|
||||
@@ -29,7 +30,8 @@ import {
|
||||
SendReplyElement,
|
||||
SendMultiForwardMsgElement,
|
||||
SendTextElement,
|
||||
SendVideoElement
|
||||
SendVideoElement,
|
||||
Peer
|
||||
} from '@/core';
|
||||
import {ForwardMsgBuilder} from '@/common/forward-msg-builder';
|
||||
import {PacketMsg, PacketSendMsgElement} from '@/core/packet/message/message';
|
||||
@@ -146,41 +148,40 @@ export class PacketMsgAtElement extends PacketMsgTextElement {
|
||||
}
|
||||
|
||||
export class PacketMsgReplyElement extends IPacketMsgElement<SendReplyElement> {
|
||||
messageId: bigint;
|
||||
messageSeq: number;
|
||||
messageClientSeq: number;
|
||||
time: number;
|
||||
targetMessageId: bigint;
|
||||
targetMessageSeq: number;
|
||||
targetMessageClientSeq: number;
|
||||
targetUin: number;
|
||||
targetUid: string;
|
||||
time: number;
|
||||
elems: PacketMsg[];
|
||||
targetElems?: NapProtoEncodeStructType<typeof Elem>[];
|
||||
targetSourceMsg?: NapProtoEncodeStructType<typeof PushMsgBody>;
|
||||
targetPeer?: Peer;
|
||||
|
||||
constructor(element: SendReplyElement) {
|
||||
super(element);
|
||||
this.messageId = BigInt(element.replyElement.replayMsgId ?? 0);
|
||||
this.messageSeq = +(element.replyElement.replayMsgSeq ?? 0);
|
||||
this.messageClientSeq = +(element.replyElement.replyMsgClientSeq ?? 0);
|
||||
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.targetUin = +(element.replyElement.senderUin ?? 0);
|
||||
this.targetUid = element.replyElement.senderUidStr ?? '';
|
||||
this.time = +(element.replyElement.replyMsgTime ?? 0);
|
||||
this.elems = []; // TODO: in replyElement.sourceMsgTextElems
|
||||
this.targetPeer = element.replyElement._replyMsgPeer;
|
||||
}
|
||||
|
||||
get isGroupReply(): boolean {
|
||||
return this.messageClientSeq === 0;
|
||||
return this.targetMessageClientSeq === 0;
|
||||
}
|
||||
|
||||
override buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
|
||||
return [{
|
||||
srcMsg: {
|
||||
origSeqs: [this.isGroupReply ? this.messageClientSeq : this.messageSeq],
|
||||
origSeqs: [this.isGroupReply ? this.targetMessageSeq : this.targetMessageClientSeq],
|
||||
senderUin: BigInt(this.targetUin),
|
||||
time: this.time,
|
||||
elems: [], // TODO: in replyElement.sourceMsgTextElems
|
||||
pbReserve: {
|
||||
messageId: this.messageId,
|
||||
},
|
||||
toUin: BigInt(this.targetUin),
|
||||
type: 1,
|
||||
elems: this.targetElems ?? [],
|
||||
sourceMsg: new NapProtoMsg(PushMsgBody).encode(this.targetSourceMsg ?? {}),
|
||||
toUin: BigInt(0),
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
27
src/core/packet/transformer/message/FetchC2CMessage.ts
Normal file
27
src/core/packet/transformer/message/FetchC2CMessage.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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();
|
30
src/core/packet/transformer/message/FetchGroupMessage.ts
Normal file
30
src/core/packet/transformer/message/FetchGroupMessage.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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();
|
@@ -1,2 +1,4 @@
|
||||
export { default as UploadForwardMsg } from './UploadForwardMsg';
|
||||
export { default as DownloadForwardMsg } from './DownloadForwardMsg';
|
||||
export { default as FetchGroupMessage } from './FetchGroupMessage';
|
||||
export { default as FetchC2CMessage } from './FetchC2CMessage';
|
||||
export { default as DownloadForwardMsg } from './DownloadForwardMsg';
|
||||
|
@@ -13,13 +13,15 @@ import {
|
||||
export const ContentHead = {
|
||||
type: ProtoField(1, ScalarType.UINT32),
|
||||
subType: ProtoField(2, ScalarType.UINT32, true),
|
||||
divSeq: ProtoField(3, ScalarType.UINT32, true),
|
||||
msgId: ProtoField(4, ScalarType.UINT32, true),
|
||||
c2cCmd: ProtoField(3, ScalarType.UINT32, true),
|
||||
ranDom: ProtoField(4, ScalarType.UINT32, true),
|
||||
sequence: ProtoField(5, ScalarType.UINT32, true),
|
||||
timeStamp: ProtoField(6, ScalarType.UINT32, true),
|
||||
field7: ProtoField(7, ScalarType.UINT64, true),
|
||||
field8: ProtoField(8, ScalarType.UINT32, true),
|
||||
field9: ProtoField(9, 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),
|
||||
newId: ProtoField(12, ScalarType.UINT64, true),
|
||||
forward: ProtoField(15, () => ForwardHead, true),
|
||||
};
|
||||
|
@@ -106,15 +106,15 @@ export interface NodeIKernelBuddyService {
|
||||
|
||||
getAddMeSetting(): unknown;
|
||||
|
||||
getDoubtBuddyReq(): unknown;
|
||||
getDoubtBuddyReq(reqId: string, num: number,uk:string): Promise<GeneralCallResult>;
|
||||
|
||||
getDoubtBuddyUnreadNum(): number;
|
||||
|
||||
approvalDoubtBuddyReq(uid: number, isAgree: boolean): void;
|
||||
approvalDoubtBuddyReq(uid: string, str1: string, str2: string): void;
|
||||
|
||||
delDoubtBuddyReq(uid: number): void;
|
||||
|
||||
delAllDoubtBuddyReq(): void;
|
||||
delAllDoubtBuddyReq(): Promise<GeneralCallResult>;
|
||||
|
||||
reportDoubtBuddyReqUnread(): void;
|
||||
|
||||
|
@@ -425,7 +425,20 @@ export interface NodeIKernelMsgService {
|
||||
|
||||
switchToOfflineGetRichMediaElement(...args: unknown[]): unknown;
|
||||
|
||||
downloadRichMedia(...args: unknown[]): unknown;
|
||||
downloadRichMedia(args: {
|
||||
fileModelId: string,
|
||||
downSourceType: number,
|
||||
triggerType: number,
|
||||
msgId: string,
|
||||
chatType: number,
|
||||
peerUid: string,
|
||||
elementId: string,
|
||||
thumbSize: number,
|
||||
downloadType: number,
|
||||
filePath: string
|
||||
} & {
|
||||
downloadSourceType: number, //33800左右一下的老版本 新版34606已经完全上面格式
|
||||
}): unknown;
|
||||
|
||||
getFirstUnreadMsgSeq(args: {
|
||||
peerUid: string
|
||||
|
@@ -54,7 +54,7 @@ export interface NodeIKernelSearchService {
|
||||
|
||||
cancelSearchChatMsgs(...args: unknown[]): unknown;// needs 3 arguments
|
||||
|
||||
searchMsgWithKeywords(keyWords: string[], param: Peer & { searchFields: number, pageLimit: number }): Promise<GeneralCallResult>;
|
||||
searchMsgWithKeywords(keyWords: string[], param: Peer & { searchFields: number, pageLimit: number }): number;
|
||||
|
||||
searchMoreMsgWithKeywords(...args: unknown[]): unknown;// needs 1 arguments
|
||||
|
||||
|
@@ -1,4 +1,15 @@
|
||||
import { ElementType, MessageElement, NTGrayTipElementSubTypeV2, PicSubType, PicType, TipAioOpGrayTipElement, TipGroupElement, NTVideoType, FaceType } from './msg';
|
||||
import {
|
||||
ElementType,
|
||||
MessageElement,
|
||||
NTGrayTipElementSubTypeV2,
|
||||
PicSubType,
|
||||
PicType,
|
||||
TipAioOpGrayTipElement,
|
||||
TipGroupElement,
|
||||
NTVideoType,
|
||||
FaceType,
|
||||
Peer
|
||||
} from './msg';
|
||||
|
||||
type ElementFullBase = Omit<MessageElement, 'elementType' | 'elementId' | 'extBufForUI'>;
|
||||
|
||||
@@ -213,6 +224,9 @@ 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 {
|
||||
|
@@ -403,7 +403,7 @@ export interface NTGroupGrayMember {
|
||||
}
|
||||
/**
|
||||
* 群灰色提示邀请者和被邀请者接口
|
||||
*
|
||||
*
|
||||
* */
|
||||
export interface NTGroupGrayInviterAndInvite {
|
||||
invited: NTGroupGrayMember;
|
||||
@@ -501,6 +501,7 @@ export interface RawMessage {
|
||||
elements: MessageElement[];// 消息元素
|
||||
sourceType: MsgSourceType;// 消息来源类型
|
||||
isOnlineMsg: boolean;// 是否为在线消息
|
||||
clientSeq?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -565,4 +566,4 @@ export enum FaceType {
|
||||
AniSticke = 3, // 动画贴纸
|
||||
Lottie = 4,// 新格式表情
|
||||
Poke = 5 // 可变Poke
|
||||
}
|
||||
}
|
||||
|
@@ -3,7 +3,7 @@ import { ActionName } from '@/onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
model: Type.String(),
|
||||
model: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
@@ -38,6 +38,7 @@ export default class GoCQHTTPUploadGroupFile extends OneBotAction<Payload, null>
|
||||
deleteAfterSentFiles: []
|
||||
};
|
||||
const sendFileEle = await this.core.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name, payload.folder ?? payload.folder_id);
|
||||
msgContext.deleteAfterSentFiles.push(downloadResult.path);
|
||||
await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(peer, [sendFileEle], msgContext.deleteAfterSentFiles);
|
||||
return null;
|
||||
}
|
||||
|
@@ -23,7 +23,7 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, nul
|
||||
if (payload.user_id) {
|
||||
const peerUid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id.toString());
|
||||
if (!peerUid) {
|
||||
throw new Error( `私聊${payload.user_id}不存在`);
|
||||
throw new Error(`私聊${payload.user_id}不存在`);
|
||||
}
|
||||
const isBuddy = await this.core.apis.FriendApi.isBuddy(peerUid);
|
||||
return { chatType: isBuddy ? ChatType.KCHATTYPEC2C : ChatType.KCHATTYPETEMPC2CFROMGROUP, peerUid };
|
||||
@@ -48,6 +48,7 @@ export default class GoCQHTTPUploadPrivateFile extends OneBotAction<Payload, nul
|
||||
deleteAfterSentFiles: []
|
||||
};
|
||||
const sendFileEle: SendFileElement = await this.core.apis.FileApi.createValidSendFileElement(msgContext, downloadResult.path, payload.name);
|
||||
msgContext.deleteAfterSentFiles.push(downloadResult.path);
|
||||
await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(await this.getPeer(payload), [sendFileEle], msgContext.deleteAfterSentFiles);
|
||||
return null;
|
||||
}
|
||||
|
@@ -115,10 +115,16 @@ import { RenameGroupFile } from './extends/RenameGroupFile';
|
||||
import { GetRkeyServer } from './packet/GetRkeyServer';
|
||||
import { GetRkeyEx } from './packet/GetRkeyEx';
|
||||
import { CleanCache } from './system/CleanCache';
|
||||
import SetFriendRemark from './user/SetFriendRemark';
|
||||
import { SetDoubtFriendsAddRequest } from './new/SetDoubtFriendsAddRequest';
|
||||
import { GetDoubtFriendsAddRequest } from './new/GetDoubtFriendsAddRequest';
|
||||
|
||||
export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
|
||||
|
||||
const actionHandlers = [
|
||||
new SetDoubtFriendsAddRequest(obContext, core),
|
||||
new GetDoubtFriendsAddRequest(obContext, core),
|
||||
new SetFriendRemark(obContext, core),
|
||||
new GetRkeyEx(obContext, core),
|
||||
new GetRkeyServer(obContext, core),
|
||||
new SetGroupRemark(obContext, core),
|
||||
|
18
src/onebot/action/new/GetDoubtFriendsAddRequest.ts
Normal file
18
src/onebot/action/new/GetDoubtFriendsAddRequest.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { OneBotAction } from '@/onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
count: Type.Number({ default: 50 }),
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class GetDoubtFriendsAddRequest extends OneBotAction<Payload, unknown> {
|
||||
override actionName = ActionName.GetDoubtFriendsAddRequest;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
async _handle(payload: Payload) {
|
||||
return await this.core.apis.FriendApi.getDoubtFriendRequest(payload.count);
|
||||
}
|
||||
}
|
21
src/onebot/action/new/SetDoubtFriendsAddRequest.ts
Normal file
21
src/onebot/action/new/SetDoubtFriendsAddRequest.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { OneBotAction } from '@/onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
flag: Type.String(),
|
||||
//注意强制String 非isNumeric 不遵守则不符合设计
|
||||
approve: Type.Boolean({ default: true }),
|
||||
//该字段没有语义 仅做保留 强制为True
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export class SetDoubtFriendsAddRequest extends OneBotAction<Payload, unknown> {
|
||||
override actionName = ActionName.SetDoubtFriendsAddRequest;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
async _handle(payload: Payload) {
|
||||
return await this.core.apis.FriendApi.handleDoubtFriendRequest(payload.flag);
|
||||
}
|
||||
}
|
@@ -10,6 +10,10 @@ export interface InvalidCheckResult {
|
||||
}
|
||||
|
||||
export const ActionName = {
|
||||
// new extends 完全差异OneBot类别
|
||||
GetDoubtFriendsAddRequest: 'get_doubt_friends_add_request',
|
||||
SetDoubtFriendsAddRequest: 'set_doubt_friends_add_request',
|
||||
// napcat
|
||||
GetRkeyEx: 'get_rkey',
|
||||
GetRkeyServer: 'get_rkey_server',
|
||||
SetGroupRemark: 'set_group_remark',
|
||||
@@ -35,6 +39,7 @@ export const ActionName = {
|
||||
SetGroupLeave: 'set_group_leave',
|
||||
SetSpecialTitle: 'set_group_special_title',
|
||||
SetFriendAddRequest: 'set_friend_add_request',
|
||||
SetFriendRemark: 'set_friend_remark',
|
||||
SetGroupAddRequest: 'set_group_add_request',
|
||||
GetLoginInfo: 'get_login_info',
|
||||
GoCQHTTP_GetStrangerInfo: 'get_stranger_info',
|
||||
|
25
src/onebot/action/user/SetFriendRemark.ts
Normal file
25
src/onebot/action/user/SetFriendRemark.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { OneBotAction } from '@/onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/onebot/action/router';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
|
||||
const SchemaData = Type.Object({
|
||||
user_id: Type.String(),
|
||||
remark: Type.String()
|
||||
});
|
||||
|
||||
type Payload = Static<typeof SchemaData>;
|
||||
|
||||
export default class SetFriendRemark extends OneBotAction<Payload, null> {
|
||||
override actionName = ActionName.SetFriendRemark;
|
||||
override payloadSchema = SchemaData;
|
||||
|
||||
async _handle(payload: Payload): Promise<null> {
|
||||
let friendUid = await this.core.apis.UserApi.getUidByUinV2(payload.user_id);
|
||||
let is_friend = await this.core.apis.FriendApi.isBuddy(friendUid);
|
||||
if (!is_friend) {
|
||||
throw new Error(`用户 ${payload.user_id} 不是好友`);
|
||||
}
|
||||
await this.core.apis.FriendApi.setBuddyRemark(friendUid, payload.remark);
|
||||
return null;
|
||||
}
|
||||
}
|
@@ -250,7 +250,34 @@ export class OneBotGroupApi {
|
||||
'invite'
|
||||
);
|
||||
}
|
||||
|
||||
async parse51TypeEvent(msg: RawMessage, grayTipElement: GrayTipElement) {
|
||||
// 神经腾讯 没了妈妈想出来的
|
||||
// Warn 下面存在高并发危险
|
||||
if (grayTipElement.jsonGrayTipElement.jsonStr) {
|
||||
const json: {
|
||||
align: string,
|
||||
items: Array<{ txt: string, type: string }>
|
||||
} = JSON.parse(grayTipElement.jsonGrayTipElement.jsonStr);
|
||||
if (json.items.length === 1 && json.items[0]?.txt.endsWith('加入群')) {
|
||||
let old_members = structuredClone(this.core.apis.GroupApi.groupMemberCache.get(msg.peerUid));
|
||||
if (!old_members) return;
|
||||
let new_members_map = await this.core.apis.GroupApi.refreshGroupMemberCache(msg.peerUid, true);
|
||||
if (!new_members_map) return;
|
||||
let new_members = Array.from(new_members_map.values());
|
||||
// 对比members查找新成员
|
||||
let new_member = new_members.find((member) => old_members.get(member.uid) == undefined);
|
||||
if (!new_member) return;
|
||||
return new OB11GroupIncreaseEvent(
|
||||
this.core,
|
||||
+msg.peerUid,
|
||||
+new_member.uin,
|
||||
0,
|
||||
'invite',
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
async parseGrayTipElement(msg: RawMessage, grayTipElement: GrayTipElement) {
|
||||
if (grayTipElement.subElementType === NTGrayTipElementSubTypeV2.GRAYTIP_ELEMENT_SUBTYPE_GROUP) {
|
||||
// 解析群组事件 由sysmsg解析
|
||||
@@ -282,6 +309,9 @@ export class OneBotGroupApi {
|
||||
return await this.parsePaiYiPai(msg, grayTipElement.jsonGrayTipElement.jsonStr);
|
||||
} else if (grayTipElement.jsonGrayTipElement.busiId == JsonGrayBusiId.AIO_GROUP_ESSENCE_MSG_TIP) {
|
||||
return await this.parseEssenceMsg(msg, grayTipElement.jsonGrayTipElement.jsonStr);
|
||||
} else if (+(grayTipElement.jsonGrayTipElement.busiId ?? 0) == 51) {
|
||||
// 51是什么?{"align":"center","items":[{"txt":"下一秒起床通过王者荣耀加入群","type":"nor"}]
|
||||
return await this.parse51TypeEvent(msg, grayTipElement);
|
||||
} else {
|
||||
return await this.parseOtherJsonEvent(msg, grayTipElement.jsonGrayTipElement.jsonStr, this.core.context);
|
||||
}
|
||||
|
@@ -467,6 +467,8 @@ export class OneBotMsgApi {
|
||||
replayMsgId: replyMsg.msgId, // raw.msgId
|
||||
senderUin: replyMsg.senderUin,
|
||||
senderUinStr: replyMsg.senderUin,
|
||||
replyMsgClientSeq: replyMsg.clientSeq,
|
||||
_replyMsgPeer: replyMsgM.Peer
|
||||
},
|
||||
} :
|
||||
undefined;
|
||||
@@ -907,10 +909,10 @@ export class OneBotMsgApi {
|
||||
const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUin, msg.senderUin);
|
||||
resMsg.group_id = parseInt(ret.tmpChatInfo!.groupCode);
|
||||
resMsg.sender.nickname = member?.nick ?? member?.cardName ?? '临时会话';
|
||||
resMsg.temp_source = resMsg.group_id;
|
||||
resMsg.temp_source = 0;
|
||||
} else {
|
||||
resMsg.group_id = 284840486;
|
||||
resMsg.temp_source = resMsg.group_id;
|
||||
resMsg.temp_source = 0;
|
||||
resMsg.sender.nickname = '临时会话';
|
||||
}
|
||||
}
|
||||
@@ -1105,6 +1107,8 @@ export class OneBotMsgApi {
|
||||
return 'kick';
|
||||
case 3:
|
||||
return 'kick_me';
|
||||
case 129:
|
||||
return 'disband';
|
||||
default:
|
||||
return 'kick';
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { OB11GroupNoticeEvent } from './OB11GroupNoticeEvent';
|
||||
import { NapCatCore } from '@/core';
|
||||
|
||||
export type GroupDecreaseSubType = 'leave' | 'kick' | 'kick_me';
|
||||
export type GroupDecreaseSubType = 'leave' | 'kick' | 'kick_me' | 'disband';
|
||||
|
||||
export class OB11GroupDecreaseEvent extends OB11GroupNoticeEvent {
|
||||
notice_type = 'group_decrease';
|
||||
@@ -11,7 +11,7 @@ export class OB11GroupDecreaseEvent extends OB11GroupNoticeEvent {
|
||||
constructor(core: NapCatCore, groupId: number, userId: number, operatorId: number, subType: GroupDecreaseSubType = 'leave') {
|
||||
super(core, groupId, userId);
|
||||
this.group_id = groupId;
|
||||
this.operator_id = operatorId;
|
||||
this.operator_id = operatorId;
|
||||
this.user_id = userId;
|
||||
this.sub_type = subType;
|
||||
}
|
||||
|
@@ -50,6 +50,7 @@ import {
|
||||
import { OB11Message } from './types';
|
||||
import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
|
||||
import { OB11HttpSSEServerAdapter } from './network/http-server-sse';
|
||||
import { OB11PluginAdapter } from './network/plugin';
|
||||
|
||||
//OneBot实现类
|
||||
export class NapCatOneBot11Adapter {
|
||||
@@ -113,9 +114,9 @@ export class NapCatOneBot11Adapter {
|
||||
//创建NetWork服务
|
||||
|
||||
// 注册Plugin 如果需要基于NapCat进行快速开发
|
||||
// this.networkManager.registerAdapter(
|
||||
// new OB11PluginAdapter('myPlugin', this.core, this,this.actions)
|
||||
// );
|
||||
this.networkManager.registerAdapter(
|
||||
new OB11PluginAdapter('myPlugin', this.core, this, this.actions)
|
||||
);
|
||||
for (const key of ob11Config.network.httpServers) {
|
||||
if (key.enable) {
|
||||
this.networkManager.registerAdapter(
|
||||
|
@@ -15,14 +15,14 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
messagePostFormat: 'array',
|
||||
reportSelfMessage: false,
|
||||
enable: true,
|
||||
debug: false,
|
||||
debug: true,
|
||||
};
|
||||
super(name, config, core, obContext, actions);
|
||||
}
|
||||
|
||||
onEvent<T extends OB11EmitEventContent>(event: T) {
|
||||
if (event.post_type === 'message') {
|
||||
plugin_onmessage(this.config.name, this.core, this.obContext, event as OB11Message, this.actions, this).then().catch();
|
||||
plugin_onmessage(this.config.name, this.core, this.obContext, event as OB11Message, this.actions, this).then().catch(console.log);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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;
|
||||
|
4
src/plugin/data.ts
Normal file
4
src/plugin/data.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
export let current_path = dirname(fileURLToPath(import.meta.url));
|
319
src/plugin/drawTime.ts
Normal file
319
src/plugin/drawTime.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
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');
|
||||
}
|
1819
src/plugin/index.ts
1819
src/plugin/index.ts
File diff suppressed because it is too large
Load Diff
432
src/plugin/network.ts
Normal file
432
src/plugin/network.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
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');
|
||||
}
|
952
src/plugin/wordcloud.ts
Normal file
952
src/plugin/wordcloud.ts
Normal file
@@ -0,0 +1,952 @@
|
||||
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');
|
||||
}
|
79
src/shell/drawJson.ts
Normal file
79
src/shell/drawJson.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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');
|
||||
}
|
@@ -1,2 +1,10 @@
|
||||
|
||||
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();
|
@@ -7,7 +7,10 @@ import { builtinModules } from 'module';
|
||||
const external = [
|
||||
'silk-wasm',
|
||||
'ws',
|
||||
'express'
|
||||
'express',
|
||||
'@napi-rs/canvas',
|
||||
'@node-rs/jieba',
|
||||
'@node-rs/jieba/dict.js',
|
||||
];
|
||||
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
|
||||
|
||||
@@ -23,6 +26,7 @@ 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 },
|
||||
@@ -46,6 +50,7 @@ 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 },
|
||||
@@ -65,6 +70,7 @@ 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 },
|
||||
|
Reference in New Issue
Block a user