Compare commits

..

18 Commits

Author SHA1 Message Date
手瓜一十雪
c50359c504 Update appid.json 2025-05-03 11:28:57 +08:00
手瓜一十雪
364500e16e Update package.json 2025-05-02 21:31:47 +08:00
手瓜一十雪
22144010ec Merge branch 'main' into protobuf-decode 2025-05-02 21:31:40 +08:00
手瓜一十雪
8403b1c9c1 fix 2025-03-26 20:50:08 +08:00
手瓜一十雪
26593d593c fix: 移除不必要的 2025-03-26 12:38:39 +08:00
手瓜一十雪
4d26ec737b fix 2025-03-26 12:32:54 +08:00
手瓜一十雪
31fdffde36 fix 2025-03-26 12:27:43 +08:00
手瓜一十雪
bccf5894cd fix 2025-03-26 12:04:42 +08:00
手瓜一十雪
2754165ae5 fix 2025-03-25 16:53:48 +08:00
手瓜一十雪
b9a9438bf0 x 2025-03-23 16:53:06 +08:00
手瓜一十雪
d8b2ebc01e x 2025-03-23 11:49:49 +08:00
手瓜一十雪
a979a07d3d Merge branch 'main' into protobuf-decode 2025-03-23 11:45:37 +08:00
手瓜一十雪
d4d94e43d8 Merge branch 'main' into protobuf-decode 2025-03-20 12:25:54 +08:00
手瓜一十雪
3deb7788ae Merge branch 'main' into protobuf-decode 2025-03-19 12:13:26 +08:00
手瓜一十雪
a790303ebe x 2025-03-19 11:53:53 +08:00
手瓜一十雪
99bfe69752 Merge branch 'main' into protobuf-decode 2025-03-19 11:10:23 +08:00
手瓜一十雪
1c8cf9538d Merge branch 'main' into protobuf-decode 2025-03-19 10:56:29 +08:00
手瓜一十雪
be2e2e86f0 napcat.protobuf test 2025-02-21 21:12:46 +08:00
98 changed files with 4224 additions and 26472 deletions

View File

@@ -150,15 +150,3 @@ jobs:
NapCat.Shell.zip NapCat.Shell.zip
NapCat.Framework.Windows.Once.zip NapCat.Framework.Windows.Once.zip
draft: true draft: true
build-docker:
needs: release-napcat
runs-on: ubuntu-latest
steps:
- name: Dispatch Docker Build
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.NAPCAT_BUILD }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/NapNeko/NapCat-Docker/actions/workflows/docker-publish.yml/dispatches \
-d '{"ref": "main"}'

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Develop # Develop
node_modules/ node_modules/
package-lock.json
pnpm-lock.yaml pnpm-lock.yaml
out/ out/
dist/ dist/

View File

@@ -1,9 +1,8 @@
<img src="https://napneko.github.io/assets/newnewlogo.png" width = "305" height = "411" alt="NapCat" align=right />
<div align="center"> <div align="center">
# NapCat # NapCat
![NapCatQQ](https://socialify.git.ci/NapNeko/NapCatQQ/image?font=Jost&logo=https%3A%2F%2Fnapneko.github.io%2Fassets%2Fnewlogo.png&name=1&owner=1&pattern=Diagonal+Stripes&stargazers=1&theme=Auto)
_Modern protocol-side framework implemented based on NTQQ._ _Modern protocol-side framework implemented based on NTQQ._
@@ -51,10 +50,6 @@ _Modern protocol-side framework implemented based on NTQQ._
+ [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权 + [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
+ [AstrBot](https://github.com/AstrBotDevs/AstrBot) 是完美适配本项目的LLM Bot框架 在此推荐一下
+ [MaiBot](https://github.com/MaiM-with-u/MaiBot) 一只赛博群友 麦麦 Bot框架 在此推荐一下
+ 不过最最重要的 还是需要感谢屏幕前的你哦~ + 不过最最重要的 还是需要感谢屏幕前的你哦~
--- ---
@@ -66,3 +61,7 @@ _Modern protocol-side framework implemented based on NTQQ._
2. 项目其余逻辑代码采用[本仓库开源许可](./LICENSE). 2. 项目其余逻辑代码采用[本仓库开源许可](./LICENSE).
**本仓库仅用于提高易用性,实现消息推送类功能,此外,禁止任何项目未经仓库主作者授权基于 NapCat 代码开发。使用请遵守当地法律法规,由此造成的问题由使用者和提供违规使用教程者负责。** **本仓库仅用于提高易用性,实现消息推送类功能,此外,禁止任何项目未经仓库主作者授权基于 NapCat 代码开发。使用请遵守当地法律法规,由此造成的问题由使用者和提供违规使用教程者负责。**
## Warnings
[某框架抄袭部分分析](https://napneko.github.io/other/about-copy)

Binary file not shown.

BIN
external/fonts/AaCute.ttf vendored Normal file

Binary file not shown.

BIN
external/fonts/JetBrainsMono.ttf vendored Normal file

Binary file not shown.

BIN
external/fonts/post.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 KiB

BIN
external/logo.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

View File

@@ -1,9 +1,9 @@
{ {
"name": "qq-chat", "name": "qq-chat",
"version": "9.9.19-34740", "version": "9.9.18-32869",
"verHash": "f31348f2", "verHash": "e735296c",
"linuxVersion": "3.2.17-34740", "linuxVersion": "3.2.16-32869",
"linuxVerHash": "5aa2d8d6", "linuxVerHash": "4c192ba9",
"private": true, "private": true,
"description": "QQ", "description": "QQ",
"productName": "QQ", "productName": "QQ",
@@ -16,10 +16,27 @@
"bin": { "bin": {
"qd": "externals/devtools/cli/index.js" "qd": "externals/devtools/cli/index.js"
}, },
"appid": {
"win32": "537258389",
"darwin": "537258412",
"linux": "537258424"
},
"main": "./loadNapCat.js", "main": "./loadNapCat.js",
"buildVersion": "34740", "peerDependenciesMeta": {
"*": {
"optional": true
}
},
"pnpm": {
"patchedDependencies": {
"@vue/runtime-dom@3.5.12": "patches/@vue__runtime-dom@3.5.12.patch",
"@swc/helpers@0.5.3": "patches/@swc__helpers@0.5.3.patch",
"vuex@4.1.0": "patches/vuex@4.1.0.patch"
}
},
"buildVersion": "32869",
"isPureShell": true, "isPureShell": true,
"isByteCodeShell": true, "isByteCodeShell": true,
"platform": "win32", "platform": "win32",
"eleArch": "x64" "eleArch": "x64"
} }

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -92,9 +92,7 @@ const MusicInsert = () => {
className="w-96" className="w-96"
fullWidth fullWidth
selectedKey={mode} selectedKey={mode}
onSelectionChange={(key) => { onSelectionChange={setMode}
if (key !== null) setMode(key)
}}
> >
<Tab title="主流平台" key="default" className="flex flex-col gap-2"> <Tab title="主流平台" key="default" className="flex flex-col gap-2">
<Select <Select

View File

@@ -26,7 +26,7 @@ const itemVariants = {
opacity: 1, opacity: 1,
scale: 1, scale: 1,
y: 0, y: 0,
transition: { type: 'spring' as const, stiffness: 300, damping: 20 } transition: { type: 'spring', stiffness: 300, damping: 20 }
} }
} }

View File

@@ -24,7 +24,9 @@ const oneBotHttpApiGroup = {
}, },
'/get_group_system_msg': { '/get_group_system_msg': {
description: '获取群系统消息', description: '获取群系统消息',
request: z.object({}), request: z.object({
group_id: z.union([z.string(), z.number()]).describe('群号')
}),
response: baseResponseSchema.extend({ response: baseResponseSchema.extend({
data: z.object({ data: z.object({
InvitedRequest: z InvitedRequest: z
@@ -35,7 +37,6 @@ const oneBotHttpApiGroup = {
invitor_uin: z.string().describe('邀请人 QQ 号'), invitor_uin: z.string().describe('邀请人 QQ 号'),
invitor_nick: z.string().describe('邀请人昵称'), invitor_nick: z.string().describe('邀请人昵称'),
group_id: z.string().describe('群号'), group_id: z.string().describe('群号'),
message: z.string().describe('入群回答'),
group_name: z.string().describe('群名称'), group_name: z.string().describe('群名称'),
checked: z.boolean().describe('是否已处理'), checked: z.boolean().describe('是否已处理'),
actor: z.string().describe('处理人 QQ 号') actor: z.string().describe('处理人 QQ 号')
@@ -49,7 +50,6 @@ const oneBotHttpApiGroup = {
requester_uin: z.string().describe('请求人 QQ 号'), requester_uin: z.string().describe('请求人 QQ 号'),
requester_nick: z.string().describe('请求人昵称'), requester_nick: z.string().describe('请求人昵称'),
group_id: z.string().describe('群号'), group_id: z.string().describe('群号'),
message: z.string().describe('入群回答'),
group_name: z.string().describe('群名称'), group_name: z.string().describe('群名称'),
checked: z.boolean().describe('是否已处理'), checked: z.boolean().describe('是否已处理'),
actor: z.string().describe('处理人 QQ 号') actor: z.string().describe('处理人 QQ 号')
@@ -604,7 +604,7 @@ const oneBotHttpApiGroup = {
response: baseResponseSchema.extend({ response: baseResponseSchema.extend({
data: z data: z
.object({ .object({
group_id: z.number().describe('群号'), group_id: z.string().describe('群号'),
current_talkative: z current_talkative: z
.object({ .object({
user_id: z.number().describe('QQ 号'), user_id: z.number().describe('QQ 号'),

View File

@@ -56,9 +56,9 @@ export default function TerminalPage() {
setTabs((prev) => [...prev, newTab]) setTabs((prev) => [...prev, newTab])
setSelectedTab(id) setSelectedTab(id)
} catch (error: unknown) { } catch (error) {
console.error('Failed to create terminal:', error) console.error('Failed to create terminal:', error)
toast.error((error as Error).message) toast.error('创建终端失败')
} }
} }

8363
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
"name": "napcat", "name": "napcat",
"private": true, "private": true,
"type": "module", "type": "module",
"version": "4.8.92", "version": "4.7.45",
"scripts": { "scripts": {
"build:universal": "npm run build:webui && vite build --mode universal || exit 1", "build:universal": "npm run build:webui && vite build --mode universal || exit 1",
"build:framework": "npm run build:webui && vite build --mode framework || exit 1", "build:framework": "npm run build:webui && vite build --mode framework || exit 1",
@@ -35,6 +35,7 @@
"@types/qrcode-terminal": "^0.12.2", "@types/qrcode-terminal": "^0.12.2",
"@types/react-color": "^3.0.13", "@types/react-color": "^3.0.13",
"@types/type-is": "^1.6.7", "@types/type-is": "^1.6.7",
"@types/wordcloud": "^1.2.2",
"@types/ws": "^8.5.12", "@types/ws": "^8.5.12",
"@typescript-eslint/eslint-plugin": "^8.3.0", "@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0", "@typescript-eslint/parser": "^8.3.0",
@@ -42,16 +43,17 @@
"async-mutex": "^0.5.0", "async-mutex": "^0.5.0",
"commander": "^13.0.0", "commander": "^13.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"esbuild": "0.25.5", "esbuild": "0.25.0",
"eslint": "^9.14.0", "eslint": "^9.14.0",
"eslint-import-resolver-typescript": "^4.0.0", "eslint-import-resolver-typescript": "^4.0.0",
"eslint-plugin-import": "^2.29.1", "eslint-plugin-import": "^2.29.1",
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"fast-xml-parser": "^4.3.6", "fast-xml-parser": "^4.3.6",
"file-type": "^21.0.0", "file-type": "^20.0.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"image-size": "^1.1.1",
"json5": "^2.2.3", "json5": "^2.2.3",
"multer": "^2.0.1", "multer": "^1.4.5-lts.1",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"typescript-eslint": "^8.13.0", "typescript-eslint": "^8.13.0",
"vite": "^6.0.1", "vite": "^6.0.1",
@@ -62,8 +64,12 @@
"compressing": "^1.10.1" "compressing": "^1.10.1"
}, },
"dependencies": { "dependencies": {
"@napi-rs/canvas": "^0.1.67",
"@node-rs/jieba": "^2.0.1",
"express": "^5.0.0", "express": "^5.0.0",
"napcat.protobuf": "^1.1.2",
"silk-wasm": "^3.6.1", "silk-wasm": "^3.6.1",
"wordcloud": "^1.2.3",
"ws": "^8.18.0" "ws": "^8.18.0"
} }
} }

View File

@@ -8,12 +8,11 @@ import { pipeline } from 'stream/promises';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { LogWrapper } from './log'; import { LogWrapper } from './log';
const downloadOri = "https://github.com/NapNeko/ffmpeg-build/releases/download/v1.0.0/ffmpeg-7.1.1-win64.zip" const downloadOri = "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2025-04-16-12-54/ffmpeg-n7.1.1-6-g48c0f071d4-win64-lgpl-7.1.zip"
const urls = [ const urls = [
"https://github.moeyy.xyz/" + downloadOri, "https://github.moeyy.xyz/" + downloadOri,
"https://ghp.ci/" + downloadOri, "https://ghp.ci/" + downloadOri,
"https://gh.api.99988866.xyz/" + downloadOri, "https://gh.api.99988866.xyz/" + downloadOri,
"https://gh.api.99988866.xyz/" + downloadOri,
downloadOri downloadOri
]; ];
@@ -337,16 +336,9 @@ export async function downloadFFmpegIfNotExists(log: LogWrapper) {
const ffprobe_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffprobe.exe')); const ffprobe_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffprobe.exe'));
if (!ffmpeg_exist || !ffprobe_exist) { if (!ffmpeg_exist || !ffprobe_exist) {
let url = await downloadFFmpeg(path.join(currentPath, 'ffmpeg'), path.join(currentPath, 'cache'), (percentage: number, message: string) => { await downloadFFmpeg(path.join(currentPath, 'ffmpeg'), path.join(currentPath, 'cache'), (percentage: number, message: string) => {
log.log(`[FFmpeg] [Download] ${percentage}% - ${message}`); log.log(`[FFmpeg] [Download] ${percentage}% - ${message}`);
}); });
if (!url) {
log.log('[FFmpeg] [Error] 下载FFmpeg失败');
return {
path: null,
reset: false
};
}
return { return {
path: path.join(currentPath, 'ffmpeg'), path: path.join(currentPath, 'ffmpeg'),
reset: true reset: true

View File

@@ -4,10 +4,10 @@ import { execFile } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import type { VideoInfo } from './video'; import type { VideoInfo } from './video';
import { fileTypeFromFile } from 'file-type'; import { fileTypeFromFile } from 'file-type';
import imageSize from 'image-size';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { platform } from 'node:os'; import { platform } from 'node:os';
import { LogWrapper } from './log'; import { LogWrapper } from './log';
import { imageSizeFallBack } from '@/image-size';
const currentPath = dirname(fileURLToPath(import.meta.url)); const currentPath = dirname(fileURLToPath(import.meta.url));
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const getFFmpegPath = (tool: string): string => { const getFFmpegPath = (tool: string): string => {
@@ -157,7 +157,7 @@ export class FFmpegService {
try { try {
await this.extractThumbnail(videoPath, thumbnailPath); await this.extractThumbnail(videoPath, thumbnailPath);
// 获取图片尺寸 // 获取图片尺寸
const dimensions = await imageSizeFallBack(thumbnailPath); const dimensions = imageSize(thumbnailPath);
return { return {
format: fileType?.ext ?? 'mp4', format: fileType?.ext ?? 'mp4',

View File

@@ -13,15 +13,11 @@ export class NapCatPathWrapper {
constructor(mainPath: string = dirname(fileURLToPath(import.meta.url))) { constructor(mainPath: string = dirname(fileURLToPath(import.meta.url))) {
this.binaryPath = mainPath; this.binaryPath = mainPath;
let writePath: string; let writePath: string;
if (os.platform() === 'darwin') {
if (process.env['NAPCAT_WORKDIR']) {
writePath = process.env['NAPCAT_WORKDIR'];
} else if (os.platform() === 'darwin') {
writePath = path.join(os.homedir(), 'Library', 'Application Support', 'QQ', 'NapCat'); writePath = path.join(os.homedir(), 'Library', 'Application Support', 'QQ', 'NapCat');
} else { } else {
writePath = this.binaryPath; writePath = this.binaryPath;
} }
this.logsPath = path.join(writePath, 'logs'); this.logsPath = path.join(writePath, 'logs');
this.configPath = path.join(writePath, 'config'); this.configPath = path.join(writePath, 'config');
this.cachePath = path.join(writePath, 'cache'); this.cachePath = path.join(writePath, 'cache');

View File

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

View File

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

View File

@@ -17,6 +17,8 @@ import fs from 'fs';
import fsPromises from 'fs/promises'; import fsPromises from 'fs/promises';
import { InstanceContext, NapCatCore, SearchResultItem } from '@/core'; import { InstanceContext, NapCatCore, SearchResultItem } from '@/core';
import { fileTypeFromFile } from 'file-type'; import { fileTypeFromFile } from 'file-type';
import imageSize from 'image-size';
import { ISizeCalculationResult } from 'image-size/dist/types/interface';
import { RkeyManager } from '@/core/helper/rkey'; import { RkeyManager } from '@/core/helper/rkey';
import { calculateFileMD5 } from '@/common/file'; import { calculateFileMD5 } from '@/common/file';
import pathLib from 'node:path'; import pathLib from 'node:path';
@@ -26,9 +28,6 @@ import { SendMessageContext } from '@/onebot/api';
import { getFileTypeForSendType } from '../helper/msg'; import { getFileTypeForSendType } from '../helper/msg';
import { FFmpegService } from '@/common/ffmpeg'; import { FFmpegService } from '@/common/ffmpeg';
import { rkeyDataType } from '../types/file'; import { rkeyDataType } from '../types/file';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { FileId } from '../packet/transformer/proto/misc/fileid';
import { imageSizeFallBack } from '@/image-size';
export class NTQQFileApi { export class NTQQFileApi {
context: InstanceContext; context: InstanceContext;
@@ -64,76 +63,6 @@ export class NTQQFileApi {
} }
} }
async getFileUrl(chatType: ChatType, peer: string, fileUUID?: string, file10MMd5?: string | undefined) {
if (this.core.apis.PacketApi.available) {
try {
if (chatType === ChatType.KCHATTYPEGROUP && fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetGroupFileUrl(+peer, fileUUID);
} else if (file10MMd5 && fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetPrivateFileUrl(peer, fileUUID, file10MMd5);
}
} catch (error) {
this.context.logger.logError('获取文件URL失败', (error as Error).message);
}
}
throw new Error('fileUUID or file10MMd5 is undefined');
}
async getPttUrl(peer: string, fileUUID?: string) {
if (this.core.apis.PacketApi.available && fileUUID) {
let appid = new NapProtoMsg(FileId).decode(Buffer.from(fileUUID.replaceAll('-', '+').replaceAll('_', '/'), 'base64')).appid;
try {
if (appid && appid === 1403) {
return this.core.apis.PacketApi.pkt.operation.GetGroupPttUrl(+peer, {
fileUuid: fileUUID,
storeId: 1,
uploadTime: 0,
ttl: 0,
subType: 0,
});
} else if (fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetPttUrl(peer, {
fileUuid: fileUUID,
storeId: 1,
uploadTime: 0,
ttl: 0,
subType: 0,
});
}
} catch (error) {
this.context.logger.logError('获取文件URL失败', (error as Error).message);
}
}
throw new Error('packet cant get ptt url');
}
async getVideoUrlPacket(peer: string, fileUUID?: string) {
if (this.core.apis.PacketApi.available && fileUUID) {
let appid = new NapProtoMsg(FileId).decode(Buffer.from(fileUUID.replaceAll('-', '+').replaceAll('_', '/'), 'base64')).appid;
try {
if (appid && appid === 1415) {
return this.core.apis.PacketApi.pkt.operation.GetGroupVideoUrl(+peer, {
fileUuid: fileUUID,
storeId: 1,
uploadTime: 0,
ttl: 0,
subType: 0,
});
} else if (fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetVideoUrl(peer, {
fileUuid: fileUUID,
storeId: 1,
uploadTime: 0,
ttl: 0,
subType: 0,
});
}
} catch (error) {
this.context.logger.logError('获取文件URL失败', (error as Error).message);
}
}
throw new Error('packet cant get video url');
}
async copyFile(filePath: string, destPath: string) { async copyFile(filePath: string, destPath: string) {
await this.core.util.copyFile(filePath, destPath); await this.core.util.copyFile(filePath, destPath);
@@ -208,7 +137,7 @@ export class NTQQFileApi {
if (fileSize === 0) { if (fileSize === 0) {
throw new Error('文件异常大小为0'); throw new Error('文件异常大小为0');
} }
const imageSize = await imageSizeFallBack(picPath); const imageSize = await this.core.apis.FileApi.getImageSize(picPath);
context.deleteAfterSentFiles.push(path); context.deleteAfterSentFiles.push(path);
return { return {
elementType: ElementType.PIC, elementType: ElementType.PIC,
@@ -396,7 +325,6 @@ export class NTQQFileApi {
} }
}); });
}); });
return res.flat();
} }
async downloadMedia(msgId: string, chatType: ChatType, peerUid: string, elementId: string, thumbPath: string, sourcePath: string, timeout = 1000 * 60 * 2, force: boolean = false) { async downloadMedia(msgId: string, chatType: ChatType, peerUid: string, elementId: string, thumbPath: string, sourcePath: string, timeout = 1000 * 60 * 2, force: boolean = false) {
@@ -436,6 +364,19 @@ export class NTQQFileApi {
return completeRetData.filePath; return completeRetData.filePath;
} }
async getImageSize(filePath: string): Promise<ISizeCalculationResult> {
return new Promise((resolve, reject) => {
imageSize(filePath, (err: Error | null, dimensions) => {
if (err) {
reject(new Error(err.message));
} else if (!dimensions) {
reject(new Error('获取图片尺寸失败'));
} else {
resolve(dimensions);
}
});
});
}
async searchForFile(keys: string[]): Promise<SearchResultItem | undefined> { async searchForFile(keys: string[]): Promise<SearchResultItem | undefined> {
const randomResultId = 100000 + Math.floor(Math.random() * 10000); const randomResultId = 100000 + Math.floor(Math.random() * 10000);

View File

@@ -10,14 +10,11 @@ import {
GroupNotify, GroupNotify,
GroupInfoSource, GroupInfoSource,
ShutUpGroupMember, ShutUpGroupMember,
Peer,
ChatType,
} from '@/core'; } from '@/core';
import { isNumeric, solveAsyncProblem } from '@/common/helper'; import { isNumeric, solveAsyncProblem } from '@/common/helper';
import { LimitedHashTable } from '@/common/message-unique'; import { LimitedHashTable } from '@/common/message-unique';
import { NTEventWrapper } from '@/common/event'; import { NTEventWrapper } from '@/common/event';
import { CancelableTask, TaskExecutor } from '@/common/cancel-task'; import { CancelableTask, TaskExecutor } from '@/common/cancel-task';
import { createGroupDetailInfoV2Param, createGroupExtFilter, createGroupExtInfo } from '../data';
export class NTQQGroupApi { export class NTQQGroupApi {
context: InstanceContext; context: InstanceContext;
@@ -50,22 +47,6 @@ export class NTQQGroupApi {
this.initCache().then().catch(e => this.context.logger.logError(e)); this.initCache().then().catch(e => this.context.logger.logError(e));
} }
async createGrayTip(groupCode: string, tip: string) {
return this.context.session.getMsgService().addLocalJsonGrayTipMsg(
{
chatType: ChatType.KCHATTYPEGROUP,
peerUid: groupCode,
} as Peer,
{
busiId: 2201,
jsonStr: JSON.stringify({ "align": "center", "items": [{ "txt": tip, "type": "nor" }] }),
recentAbstract: tip,
isServer: false
},
true,
true
)
}
async initCache() { async initCache() {
for (const group of await this.getGroups(true)) { for (const group of await this.getGroups(true)) {
this.refreshGroupMemberCache(group.groupCode, false).then().catch(e => this.context.logger.logError(e)); this.refreshGroupMemberCache(group.groupCode, false).then().catch(e => this.context.logger.logError(e));
@@ -114,58 +95,6 @@ export class NTQQGroupApi {
return this.context.session.getGroupService().setHeader(groupCode, filePath); return this.context.session.getGroupService().setHeader(groupCode, filePath);
} }
// 0 0 无需管理员审核
// 0 2 需要管理员审核
// 1 2 禁止Bot入群( 最好只传一个1 )
async setGroupRobotAddOption(groupCode: string, robotMemberSwitch?: number, robotMemberExamine?: number) {
let extInfo = createGroupExtInfo(groupCode);
let groupExtFilter = createGroupExtFilter();
if (robotMemberSwitch !== undefined) {
extInfo.extInfo.inviteRobotMemberSwitch = robotMemberSwitch;
groupExtFilter.inviteRobotMemberSwitch = 1;
}
if (robotMemberExamine !== undefined) {
extInfo.extInfo.inviteRobotMemberExamine = robotMemberExamine;
groupExtFilter.inviteRobotMemberExamine = 1;
}
return this.context.session.getGroupService().modifyGroupExtInfoV2(extInfo, groupExtFilter);
}
async setGroupAddOption(groupCode: string, option: {
addOption: number;
groupQuestion?: string;
groupAnswer?: string;
}) {
let param = createGroupDetailInfoV2Param(groupCode);
// 设置要修改的目标
param.filter.addOption = 1;
if (option.addOption == 4 || option.addOption == 5) {
// 4 问题进入答案 5 问题管理员批准
param.filter.groupQuestion = 1;
param.filter.groupAnswer = option.addOption == 4 ? 1 : 0;
param.modifyInfo.groupQuestion = option.groupQuestion || '';
param.modifyInfo.groupAnswer = option.addOption == 4 ? option.groupAnswer || '' : '';
}
param.modifyInfo.addOption = option.addOption;
return this.context.session.getGroupService().modifyGroupDetailInfoV2(param, 0);
}
async setGroupSearch(groupCode: string, option: {
noCodeFingerOpenFlag?: number;
noFingerOpenFlag?: number;
}) {
let param = createGroupDetailInfoV2Param(groupCode);
if (option.noCodeFingerOpenFlag) {
param.filter.noCodeFingerOpenFlag = 1;
param.modifyInfo.noCodeFingerOpenFlag = option.noCodeFingerOpenFlag;
}
if (option.noFingerOpenFlag) {
param.filter.noFingerOpenFlag = 1;
param.modifyInfo.noFingerOpenFlag = option.noFingerOpenFlag;
}
return this.context.session.getGroupService().modifyGroupDetailInfoV2(param, 0);
}
async getGroups(forced: boolean = false) { async getGroups(forced: boolean = false) {
const [, , groupList] = await this.core.eventWrapper.callNormalEventV2( const [, , groupList] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelGroupService/getGroupList', 'NodeIKernelGroupService/getGroupList',

View File

@@ -17,6 +17,24 @@ export class NTQQMsgApi {
return this.context.session.getMsgService().clickInlineKeyboardButton(...params); 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) { getMsgByClientSeqAndTime(peer: Peer, replyMsgClientSeq: string, replyMsgTime: string) {
// https://bot.q.qq.com/wiki/develop/api-v2/openapi/emoji/model.html#EmojiType 可以用过特殊方式拉取 // https://bot.q.qq.com/wiki/develop/api-v2/openapi/emoji/model.html#EmojiType 可以用过特殊方式拉取
return this.context.session.getMsgService().getMsgByClientSeqAndTime(peer, replyMsgClientSeq, replyMsgTime); return this.context.session.getMsgService().getMsgByClientSeqAndTime(peer, replyMsgClientSeq, replyMsgTime);
@@ -71,7 +89,6 @@ export class NTQQMsgApi {
async queryMsgsWithFilterExWithSeq(peer: Peer, msgSeq: string) { async queryMsgsWithFilterExWithSeq(peer: Peer, msgSeq: string) {
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, { return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
chatInfo: peer, chatInfo: peer,
//searchFields: 3,
filterMsgType: [], filterMsgType: [],
filterSendersUid: [], filterSendersUid: [],
filterMsgToTime: '0', filterMsgToTime: '0',
@@ -85,7 +102,6 @@ export class NTQQMsgApi {
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, { return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
chatInfo: peer, chatInfo: peer,
filterMsgType: [], filterMsgType: [],
//searchFields: 3,
filterSendersUid: SendersUid, filterSendersUid: SendersUid,
filterMsgToTime: MsgTime, filterMsgToTime: MsgTime,
filterMsgFromTime: MsgTime, filterMsgFromTime: MsgTime,
@@ -102,7 +118,6 @@ export class NTQQMsgApi {
filterMsgToTime: '0', filterMsgToTime: '0',
filterMsgFromTime: '0', filterMsgFromTime: '0',
isReverseOrder: false, isReverseOrder: false,
//searchFields: 3,
isIncludeCurrent: true, isIncludeCurrent: true,
pageLimit: 1, pageLimit: 1,
}); });
@@ -113,7 +128,6 @@ export class NTQQMsgApi {
filterMsgType: [], filterMsgType: [],
filterSendersUid: [], filterSendersUid: [],
filterMsgToTime: '0', filterMsgToTime: '0',
//searchFields: 3,
filterMsgFromTime: '0', filterMsgFromTime: '0',
isReverseOrder: true, isReverseOrder: true,
isIncludeCurrent: true, isIncludeCurrent: true,
@@ -132,7 +146,6 @@ export class NTQQMsgApi {
chatInfo: peer,//此处为Peer 为关键查询参数 没有啥也没有 by mlik iowa chatInfo: peer,//此处为Peer 为关键查询参数 没有啥也没有 by mlik iowa
filterMsgType: [], filterMsgType: [],
filterSendersUid: [], filterSendersUid: [],
//searchFields: 3,
filterMsgToTime: filterMsgToTime, filterMsgToTime: filterMsgToTime,
filterMsgFromTime: filterMsgFromTime, filterMsgFromTime: filterMsgFromTime,
isReverseOrder: false, isReverseOrder: false,
@@ -142,12 +155,10 @@ export class NTQQMsgApi {
} }
async queryFirstMsgBySender(peer: Peer, SendersUid: string[]) { async queryFirstMsgBySender(peer: Peer, SendersUid: string[]) {
console.log(peer, SendersUid);
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', { return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', {
chatInfo: peer, chatInfo: peer,
filterMsgType: [], filterMsgType: [],
filterSendersUid: SendersUid, filterSendersUid: SendersUid,
//searchFields: 3,
filterMsgToTime: '0', filterMsgToTime: '0',
filterMsgFromTime: '0', filterMsgFromTime: '0',
isReverseOrder: true, isReverseOrder: true,
@@ -155,7 +166,30 @@ export class NTQQMsgApi {
pageLimit: 20000, 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) { async setMsgRead(peer: Peer) {
return this.context.session.getMsgService().setMsgRead(peer); return this.context.session.getMsgService().setMsgRead(peer);
} }

View File

@@ -264,7 +264,7 @@ export class NTQQWebApi {
async getGroupHonorInfo(groupCode: string, getType: WebHonorType) { async getGroupHonorInfo(groupCode: string, getType: WebHonorType) {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com'); const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
let HonorInfo = { let HonorInfo = {
group_id: Number(groupCode), group_id: groupCode,
current_talkative: {}, current_talkative: {},
talkative_list: [], talkative_list: [],
performer_list: [], performer_list: [],

View File

@@ -1,245 +0,0 @@
import { GroupDetailInfoV2Param, GroupExtInfo, GroupExtFilter } from "../types";
export function createGroupDetailInfoV2Param(group_code: string): GroupDetailInfoV2Param {
return {
groupCode: group_code,
filter:
{
noCodeFingerOpenFlag: 0,
noFingerOpenFlag: 0,
groupName: 0,
classExt: 0,
classText: 0,
fingerMemo: 0,
richFingerMemo: 0,
tagRecord: 0,
groupGeoInfo:
{
ownerUid: 0,
setTime: 0,
cityId: 0,
longitude: 0,
latitude: 0,
geoContent: 0,
poiId: 0
},
groupExtAdminNum: 0,
flag: 0,
groupMemo: 0,
groupAioSkinUrl: 0,
groupBoardSkinUrl: 0,
groupCoverSkinUrl: 0,
groupGrade: 0,
activeMemberNum: 0,
certificationType: 0,
certificationText: 0,
groupNewGuideLines:
{
enabled: 0,
content: 0
},
groupFace: 0,
addOption: 0,
shutUpTime: 0,
groupTypeFlag: 0,
appPrivilegeFlag: 0,
appPrivilegeMask: 0,
groupExtOnly:
{
tribeId: 0,
moneyForAddGroup: 0
}, groupSecLevel: 0,
groupSecLevelInfo: 0,
subscriptionUin: 0,
subscriptionUid: "",
allowMemberInvite: 0,
groupQuestion: 0,
groupAnswer: 0,
groupFlagExt3: 0,
groupFlagExt3Mask: 0,
groupOpenAppid: 0,
rootId: 0,
msgLimitFrequency: 0,
hlGuildAppid: 0,
hlGuildSubType: 0,
hlGuildOrgId: 0,
groupFlagExt4: 0,
groupFlagExt4Mask: 0,
groupSchoolInfo: {
location: 0,
grade: 0,
school: 0
},
groupCardPrefix:
{
introduction: 0,
rptPrefix: 0
}, allianceId: 0,
groupFlagPro1: 0,
groupFlagPro1Mask: 0
},
modifyInfo: {
noCodeFingerOpenFlag: 0,
noFingerOpenFlag: 0,
groupName: "",
classExt: 0,
classText: "",
fingerMemo: "",
richFingerMemo: "",
tagRecord: [],
groupGeoInfo: {
ownerUid: "",
SetTime: 0,
CityId: 0,
Longitude: "",
Latitude: "",
GeoContent: "",
poiId: ""
},
groupExtAdminNum: 0,
flag: 0,
groupMemo: "",
groupAioSkinUrl: "",
groupBoardSkinUrl: "",
groupCoverSkinUrl: "",
groupGrade: 0,
activeMemberNum: 0,
certificationType: 0,
certificationText: "",
groupNewGuideLines: {
enabled: false,
content: ""
}, groupFace: 0,
addOption: 0,
shutUpTime: 0,
groupTypeFlag: 0,
appPrivilegeFlag: 0,
appPrivilegeMask: 0,
groupExtOnly: {
tribeId: 0,
moneyForAddGroup: 0
},
groupSecLevel: 0,
groupSecLevelInfo: 0,
subscriptionUin: "",
subscriptionUid: "",
allowMemberInvite: 0,
groupQuestion: "",
groupAnswer: "",
groupFlagExt3: 0,
groupFlagExt3Mask: 0,
groupOpenAppid: 0,
rootId: "",
msgLimitFrequency: 0,
hlGuildAppid: 0,
hlGuildSubType: 0,
hlGuildOrgId: 0,
groupFlagExt4: 0,
groupFlagExt4Mask: 0,
groupSchoolInfo: {
location: "",
grade: 0,
school: ""
},
groupCardPrefix:
{
introduction: "",
rptPrefix: []
},
allianceId: "",
groupFlagPro1: 0,
groupFlagPro1Mask: 0
}
}
}
export function createGroupExtInfo(group_code: string): GroupExtInfo {
return {
groupCode: group_code,
resultCode: 0,
extInfo: {
groupInfoExtSeq: 0,
reserve: 0,
luckyWordId: '',
lightCharNum: 0,
luckyWord: '',
starId: 0,
essentialMsgSwitch: 0,
todoSeq: 0,
blacklistExpireTime: 0,
isLimitGroupRtc: 0,
companyId: 0,
hasGroupCustomPortrait: 0,
bindGuildId: '',
groupOwnerId: {
memberUin: '',
memberUid: '',
memberQid: '',
},
essentialMsgPrivilege: 0,
msgEventSeq: '',
inviteRobotSwitch: 0,
gangUpId: '',
qqMusicMedalSwitch: 0,
showPlayTogetherSwitch: 0,
groupFlagPro1: '',
groupBindGuildIds: {
guildIds: [],
},
viewedMsgDisappearTime: '',
groupExtFlameData: {
switchState: 0,
state: 0,
dayNums: [],
version: 0,
updateTime: '',
isDisplayDayNum: false,
},
groupBindGuildSwitch: 0,
groupAioBindGuildId: '',
groupExcludeGuildIds: {
guildIds: [],
},
fullGroupExpansionSwitch: 0,
fullGroupExpansionSeq: '',
inviteRobotMemberSwitch: 0,
inviteRobotMemberExamine: 0,
groupSquareSwitch: 0,
}
}
}
export function createGroupExtFilter(): GroupExtFilter {
return {
groupInfoExtSeq: 0,
reserve: 0,
luckyWordId: 0,
lightCharNum: 0,
luckyWord: 0,
starId: 0,
essentialMsgSwitch: 0,
todoSeq: 0,
blacklistExpireTime: 0,
isLimitGroupRtc: 0,
companyId: 0,
hasGroupCustomPortrait: 0,
bindGuildId: 0,
groupOwnerId: 0,
essentialMsgPrivilege: 0,
msgEventSeq: 0,
inviteRobotSwitch: 0,
gangUpId: 0,
qqMusicMedalSwitch: 0,
showPlayTogetherSwitch: 0,
groupFlagPro1: 0,
groupBindGuildIds: 0,
viewedMsgDisappearTime: 0,
groupExtFlameData: 0,
groupBindGuildSwitch: 0,
groupAioBindGuildId: 0,
groupExcludeGuildIds: 0,
fullGroupExpansionSwitch: 0,
fullGroupExpansionSeq: 0,
inviteRobotMemberSwitch: 0,
inviteRobotMemberExamine: 0,
groupSquareSwitch: 0,
}
};

View File

@@ -1 +0,0 @@
export * from "./group";

View File

@@ -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": { "9.9.15-28060": {
"appid": 537246092, "appid": 537246092,
"qua": "V1_WIN_NQ_9.9.15_28060_GW_B" "qua": "V1_WIN_NQ_9.9.15_28060_GW_B"
@@ -283,60 +355,212 @@
"appid": 537290727, "appid": 537290727,
"qua": "V1_LNX_NQ_3.2.17_34740_GW_B" "qua": "V1_LNX_NQ_3.2.17_34740_GW_B"
}, },
"9.9.19-34958": { "8.9.50-Android": {
"appid": 537290742, "appid": 537155551,
"qua": "V1_WIN_NQ_9.9.19_34958_GW_B" "qua": "V1_AND_SQ_8.9.50_3898_YYB_D"
}, },
"3.2.17-35184": { "9.0.1-Watch": {
"appid": 537291084, "appid": 537214131,
"qua": "V1_LNX_NQ_3.2.17_35184_GW_B" "qua": "V1_AND_SQ_8.9.68_0_RDM_B"
}, },
"9.9.19-35184": { "6.8.2-21241": {
"appid": 537291048, "appid": 537128930,
"qua": "V1_WIN_NQ_9.9.19_35184_GW_B" "qua": "V1_IOS_SQ_6.8.2_21241_YYB_D"
}, },
"3.2.17-35341": { "8.8.88-Android": {
"appid": 537291383, "appid": 537118044,
"qua": "V1_LNX_NQ_3.2.17_35341_GW_B" "qua": "V1_IOS_SQ_8.8.88_2770_YYB_D"
}, },
"9.9.19-35341": { "8.9.90-Android": {
"appid": 537291347, "appid": 537185007,
"qua": "V1_WIN_NQ_9.9.19_35341_GW_B" "qua": "V1_AND_SQ_8.9.90_4938_YYB_D"
}, },
"9.9.19-35469": { "8.9.33-Android": {
"appid": 537291398, "appid": 537151682,
"qua": "V1_WIN_NQ_9.9.19_35469_GW_B" "qua": "V1_AND_SQ_8.9.33_3898_YYB_D"
}, },
"3.2.18-35951": { "3.5.1-Tim": {
"appid": 537296013, "appid": 537150355,
"qua": "V1_LNX_NQ_3.2.18_35951_GW_B" "qua": "V1_AND_SQ_8.3.9_351_TIM_D"
}, },
"9.9.20-35951": { "8.9.58-Android": {
"appid": 537295977, "appid": 537163194,
"qua": "V1_WIN_NQ_9.9.20_35951_GW_B" "qua": "V1_AND_SQ_8.9.58_4108_YYB_D"
}, },
"3.2.18-36580": { "8.9.63-Android": {
"appid": 537298509, "appid": 537163194,
"qua": "V1_LNX_NQ_3.2.18_36580_GW_B" "qua": "V1_AND_SQ_8.9.63_4194_YYB_D"
}, },
"9.9.20-36580": { "8.9.68-Android": {
"appid": 537298473, "appid": 537168313,
"qua": "V1_WIN_NQ_9.9.20_36580_GW_B" "qua": "V1_AND_SQ_8.9.68_4264_YYB_D"
}, },
"9.9.20-37012": { "8.9.70-Android": {
"appid": 537304071, "appid": 537169928,
"qua": "V1_WIN_NQ_9.9.20_37012_GW_B" "qua": "V1_AND_SQ_8.9.70_4330_YYB_D"
}, },
"3.2.18-37012": { "8.9.71-Android": {
"appid": 537304107, "appid": 537170024,
"qua": "V1_LNX_NQ_3.2.18_37012_GW_B" "qua": "V1_AND_SQ_8.9.71_4332_YYB_D"
}, },
"3.2.18-37051": { "8.9.73-Android": {
"appid": 537304158, "appid": 537171689,
"qua": "V1_LNX_NQ_3.2.18_37051_GW_B" "qua": "V1_AND_SQ_8.9.73_4416_YYB_D"
}, },
"9.9.20-37051": { "8.9.75-Android": {
"appid": 537304122, "appid": 537173381,
"qua": "V1_WIN_NQ_9.9.20_37051_GW_B" "qua": "V1_AND_SQ_8.9.75_4482_YYB_D"
},
"8.9.76-Android": {
"appid": 537173477,
"qua": "V1_AND_SQ_8.9.76_4484_YYB_D"
},
"8.9.78-Android": {
"appid": 537175315,
"qua": "V1_AND_SQ_8.9.78_4548_YYB_D"
},
"8.9.80-Android": {
"appid": 537176863,
"qua": "V1_AND_SQ_8.9.80_4614_YYB_D"
},
"8.9.83-Android": {
"appid": 537178646,
"qua": "V1_AND_SQ_8.9.83_4680_YYB_D"
},
"8.9.85-Android": {
"appid": 537180568,
"qua": "V1_AND_SQ_8.9.85_4766_YYB_D"
},
"8.9.88-Android": {
"appid": 537182769,
"qua": "V1_AND_SQ_8.9.88_4852_YYB_D"
},
"8.9.93-Android": {
"appid": 537187398,
"qua": "V1_AND_SQ_8.9.93_5028_YYB_D"
},
"9.0.0-Android": {
"appid": 537194351,
"qua": "V1_AND_SQ_9.0.0_5282_YYB_D"
},
"9.0.8-Android": {
"appid": 537200218,
"qua": "V1_AND_SQ_9.0.8_5540_YYB_D"
},
"9.0.17-Android": {
"appid": 537204056,
"qua": "V1_AND_SQ_9.0.17_5712_YYB_D"
},
"9.0.25-Android": {
"appid": 537210084,
"qua": "V1_AND_SQ_9.0.25_5942_YYB_D"
},
"9.0.35-Android": {
"appid": 537215475,
"qua": "V1_AND_SQ_9.0.35_6150_YYB_D"
},
"9.0.50-Android": {
"appid": 537217916,
"qua": "V1_AND_SQ_9.0.50_6258_YYB_D"
},
"9.0.56-Android": {
"appid": 537220323,
"qua": "V1_AND_SQ_9.0.56_6372_YYB_D"
},
"9.0.60-Android": {
"appid": 537222797,
"qua": "V1_AND_SQ_9.0.60_6478_YYB_D"
},
"9.0.65-Android": {
"appid": 537225139,
"qua": "V1_AND_SQ_9.0.65_6588_YYB_D"
},
"9.0.70-Android": {
"appid": 537228487,
"qua": "V1_AND_SQ_9.0.70_6698_YYB_D"
},
"9.0.81-Android": {
"appid": 537233527,
"qua": "V1_AND_SQ_9.0.81_6922_YYB_D"
},
"9.0.85-Android": {
"appid": 537236316,
"qua": "V1_AND_SQ_9.0.85_7068_YYB_D"
},
"9.0.90-Android":{
"appid": 537239255,
"qua": "V1_AND_SQ_9.0.90_7218_YYB_D"
},
"9.0.95-Android": {
"appid": 537242075,
"qua": "V1_AND_SQ_9.0.95_7368_YYB_D"
},
"9.1.0-Android": {
"appid": 537244893,
"qua": "V1_AND_SQ_9.1.0_7518_YYB_D"
},
"9.1.20-Android": {
"appid": 537257414,
"qua": "V1_AND_SQ_9.1.20_8198_YYB_D"
},
"9.1.25-Android": {
"appid": 537260030,
"qua": "V1_AND_SQ_9.1.25_8368_YYB_D"
},
"9.1.67-Android": {
"appid": 537284101,
"qua": "V1_AND_SQ_9.1.67_9728_YYB_D"
},
"9.1.70-Android": {
"appid": 537285947,
"qua": "V1_AND_SQ_9.1.70_9898_YYB_D"
},
"9.1.65-Android": {
"appid": 537278302,
"qua": "V1_AND_SQ_9.1.65_9558_YYB_D"
},
"9.1.60-Android": {
"appid": 537275636,
"qua": "V1_AND_SQ_9.1.60_9388_YYB_D"
},
"9.1.55-Android": {
"appid": 537272835,
"qua": "V1_AND_SQ_9.1.55_9218_YYB_D"
},
"9.1.52-Android": {
"appid": 537270265,
"qua": "V1_AND_SQ_9.1.52_9054_YYB_D"
},
"9.1.50-Android": {
"appid": 537270031,
"qua": "V1_AND_SQ_9.1.50_9048_YYB_D"
},
"9.1.5-Android": {
"appid": 537247779,
"qua": "V1_AND_SQ_9.1.5_7688_YYB_D"
},
"9.1.35-Android": {
"appid": 537265576,
"qua": "V1_AND_SQ_9.1.35_8708_YYB_D"
},
"9.1.31-Android": {
"appid": 537262715,
"qua": "V1_AND_SQ_9.1.31_8542_YYB_D"
},
"9.1.30-Android": {
"appid": 537262559,
"qua": "V1_AND_SQ_9.1.30_8538_YYB_D"
},
"9.1.16-Android": {
"appid": 537254305,
"qua": "V1_AND_SQ_9.1.16_8032_YYB_D"
},
"9.1.15-Android": {
"appid": 537254149,
"qua": "V1_AND_SQ_9.1.15_8028_YYB_D"
},
"9.1.10-Android": {
"appid": 537251380,
"qua": "V1_AND_SQ_9.1.10_7858_YYB_D"
} }
} }

View File

@@ -354,89 +354,5 @@
"9.9.19-34740-x64": { "9.9.19-34740-x64": {
"send": "3BDD8D0", "send": "3BDD8D0",
"recv": "3BE20D0" "recv": "3BE20D0"
},
"3.2.17-34740-x64": {
"send": "ADDF0A0",
"recv": "ADE2AC0"
},
"3.2.17-34740-arm64": {
"send": "7753BB8",
"recv": "77574E8"
},
"9.9.19-34958-x64": {
"send": "3BDD8D0",
"recv": "3BE20D0"
},
"3.2.17-35184-x64": {
"send": "AE0DDE0",
"recv": "AE11800"
},
"3.2.17-35184-arm64": {
"send": "7776028",
"recv": "7779958"
},
"9.9.19-35184-x64": {
"send": "3BE5A10",
"recv": "3BEA210"
},
"9.9.19-35341-x64": {
"send": "3BF1D50",
"recv": "3BF6550"
},
"9.9.19-35469-x64": {
"send": "3BF1D50",
"recv": "3BF6550"
},
"3.2.17-35341-x64": {
"send": "AE2F700",
"recv": "AE33120"
},
"3.2.17-35341-arm64": {
"send": "778D840",
"recv": "7791170"
},
"9.9.20-35951-x64": {
"send": "3034BAC",
"recv": "3038354"
},
"3.2.18-35951-x64": {
"send": "AFBBB00",
"recv": "AFBF520"
},
"9.9.20-36580-x64": {
"send": "30824B8",
"recv": "3085C5C"
},
"3.2.18-36580-x64": {
"send": "B0853E0",
"recv": "B088E60"
},
"3.2.18-36580-arm64": {
"send": "793DAC8",
"recv": "7941458"
},
"3.2.18-37012-x64": {
"send": "B20F960",
"recv": "B2133E0"
},
"3.2.18-37012-arm64": {
"send": "7A19E00",
"recv": "7A1D790"
},
"9.9.20-37012-x64": {
"send": "30CC958",
"recv": "30D00FC"
},
"3.2.18-37051-x64": {
"send": "B20F960",
"recv": "B2133E0"
},
"3.2.18-37051-arm64": {
"send": "7A19E00",
"recv": "7A1D790"
},
"9.9.20-37051-x64": {
"send": "30CC958",
"recv": "30D00FC"
} }
} }

View File

@@ -3,43 +3,43 @@ import { BuddyCategoryType, FriendRequestNotify } from '@/core/types';
export type OnBuddyChangeParams = BuddyCategoryType[]; export type OnBuddyChangeParams = BuddyCategoryType[];
export class NodeIKernelBuddyListener { export class NodeIKernelBuddyListener {
onBuddyListChangedV2(_arg: unknown): any { onBuddyListChangedV2(arg: unknown): any {
} }
onAddBuddyNeedVerify(_arg: unknown): any { onAddBuddyNeedVerify(arg: unknown): any {
} }
onAddMeSettingChanged(_arg: unknown): any { onAddMeSettingChanged(arg: unknown): any {
} }
onAvatarUrlUpdated(_arg: unknown): any { onAvatarUrlUpdated(arg: unknown): any {
} }
onBlockChanged(_arg: unknown): any { onBlockChanged(arg: unknown): any {
} }
onBuddyDetailInfoChange(_arg: unknown): any { onBuddyDetailInfoChange(arg: unknown): any {
} }
onBuddyInfoChange(_arg: unknown): any { onBuddyInfoChange(arg: unknown): any {
} }
onBuddyListChange(_arg: OnBuddyChangeParams): any { onBuddyListChange(arg: OnBuddyChangeParams): any {
} }
onBuddyRemarkUpdated(_arg: unknown): any { onBuddyRemarkUpdated(arg: unknown): any {
} }
onBuddyReqChange(_arg: FriendRequestNotify): any { onBuddyReqChange(arg: FriendRequestNotify): any {
} }
onBuddyReqUnreadCntChange(_arg: unknown): any { onBuddyReqUnreadCntChange(arg: unknown): any {
} }
onCheckBuddySettingResult(_arg: unknown): any { onCheckBuddySettingResult(arg: unknown): any {
} }
onDelBatchBuddyInfos(_arg: unknown): any { onDelBatchBuddyInfos(arg: unknown): any {
console.log('onDelBatchBuddyInfos not implemented', ...arguments); console.log('onDelBatchBuddyInfos not implemented', ...arguments);
} }
@@ -66,12 +66,12 @@ export class NodeIKernelBuddyListener {
onDoubtBuddyReqUnreadNumChange(_num: number): void | Promise<void> { onDoubtBuddyReqUnreadNumChange(_num: number): void | Promise<void> {
} }
onNickUpdated(_arg: unknown): any { onNickUpdated(arg: unknown): any {
} }
onSmartInfos(_arg: unknown): any { onSmartInfos(arg: unknown): any {
} }
onSpacePermissionInfos(_arg: unknown): any { onSpacePermissionInfos(arg: unknown): any {
} }
} }

View File

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

View File

@@ -8,7 +8,8 @@ import { LRUCache } from '@/common/lru-cache';
import { LogStack } from '@/core/packet/context/clientContext'; import { LogStack } from '@/core/packet/context/clientContext';
import { NapCoreContext } from '@/core/packet/context/napCoreContext'; import { NapCoreContext } from '@/core/packet/context/napCoreContext';
import { PacketLogger } from '@/core/packet/context/loggerContext'; import { PacketLogger } from '@/core/packet/context/loggerContext';
import { ProtoBufDecode } from 'napcat.protobuf';
export const MsgData = new LRUCache<string, string>(5000);
// 0 send 1 recv // 0 send 1 recv
export interface NativePacketExportType { 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; InitHook?: (send: string, recv: string, callback: (type: number, uin: string, cmd: string, seq: number, hex_data: string) => void, o3_hook: boolean) => boolean;
@@ -40,8 +41,7 @@ export class NativePacketClient extends IPacketClient {
async init(_pid: number, recv: string, send: string): Promise<void> { async init(_pid: number, recv: string, send: string): Promise<void> {
const platform = process.platform + '.' + process.arch; const platform = process.platform + '.' + process.arch;
const isNewQQ = this.napcore.basicInfo.requireMinNTQQBuild("36580"); const moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './moehoo/MoeHoo.' + platform + '.node');
const moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './moehoo/MoeHoo.' + platform + (isNewQQ ? '.new' : '') + '.node');
process.dlopen(this.MoeHooExport, moehoo_path, constants.dlopen.RTLD_LAZY); process.dlopen(this.MoeHooExport, moehoo_path, constants.dlopen.RTLD_LAZY);
this.MoeHooExport.exports.InitHook?.(send, recv, (type: number, _uin: string, cmd: string, seq: number, hex_data: string) => { this.MoeHooExport.exports.InitHook?.(send, recv, (type: number, _uin: string, cmd: string, seq: number, hex_data: string) => {
@@ -57,6 +57,19 @@ export class NativePacketClient extends IPacketClient {
// console.log('callback:', callback, trace_id); // console.log('callback:', callback, trace_id);
callback?.({ seq, cmd, hex_data }); 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.napcore.config.o3HookMode == 1);
this.available = true; this.available = true;
} }

View File

@@ -1,7 +1,6 @@
import { NapCatCore } from '@/core'; import { NapCatCore } from '@/core';
export interface NapCoreCompatBasicInfo { export interface NapCoreCompatBasicInfo {
readonly requireMinNTQQBuild: (buildVer: string) => boolean;
readonly uin: number; readonly uin: number;
readonly uid: string; readonly uid: string;
readonly uin2uid: (uin: number) => Promise<string>; readonly uin2uid: (uin: number) => Promise<string>;
@@ -22,7 +21,6 @@ export class NapCoreContext {
get basicInfo() { get basicInfo() {
return { return {
requireMinNTQQBuild: (buildVer: string) => this.core.context.basicInfoWrapper.requireMinNTQQBuild(buildVer),
uin: +this.core.selfInfo.uin, uin: +this.core.selfInfo.uin,
uid: this.core.selfInfo.uid, uid: this.core.selfInfo.uid,
uin2uid: (uin: number) => this.core.apis.UserApi.getUidByUinV2(String(uin)).then(res => res ?? ''), uin2uid: (uin: number) => this.core.apis.UserApi.getUidByUinV2(String(uin)).then(res => res ?? ''),

View File

@@ -30,8 +30,13 @@ export class PacketOperationContext {
return await this.context.client.sendOidbPacket(pkt, rsp); return await this.context.client.sendOidbPacket(pkt, rsp);
} }
async SendPoke(is_group: boolean, peer: number, target?: number) { async GroupPoke(groupUin: number, uin: number) {
const req = trans.SendPoke.build(is_group, peer, target ?? peer); const req = trans.SendPoke.build(uin, groupUin);
await this.context.client.sendOidbPacket(req);
}
async FriendPoke(uin: number) {
const req = trans.SendPoke.build(uin);
await this.context.client.sendOidbPacket(req); await this.context.client.sendOidbPacket(req);
} }
@@ -119,20 +124,6 @@ export class PacketOperationContext {
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`; return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
} }
async GetPttUrl(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>) {
const req = trans.DownloadPtt.build(selfUid, node);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.DownloadPtt.parse(resp);
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async GetVideoUrl(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>) {
const req = trans.DownloadVideo.build(selfUid, node);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.DownloadVideo.parse(resp);
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async GetGroupImageUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>) { async GetGroupImageUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>) {
const req = trans.DownloadGroupImage.build(groupUin, node); const req = trans.DownloadGroupImage.build(groupUin, node);
const resp = await this.context.client.sendOidbPacket(req, true); const resp = await this.context.client.sendOidbPacket(req, true);
@@ -140,21 +131,6 @@ export class PacketOperationContext {
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`; return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
} }
async GetGroupPttUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>) {
const req = trans.DownloadGroupPtt.build(groupUin, node);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.DownloadImage.parse(resp);
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async GetGroupVideoUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>) {
const req = trans.DownloadGroupVideo.build(groupUin, node);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.DownloadImage.parse(resp);
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async ImageOCR(imgUrl: string) { async ImageOCR(imgUrl: string) {
const req = trans.ImageOCR.build(imgUrl); const req = trans.ImageOCR.build(imgUrl);
const resp = await this.context.client.sendOidbPacket(req, true); const resp = await this.context.client.sendOidbPacket(req, true);
@@ -178,7 +154,7 @@ export class PacketOperationContext {
private async SendPreprocess(msg: PacketMsg[], groupUin: number = 0) { private async SendPreprocess(msg: PacketMsg[], groupUin: number = 0) {
const ps = msg.map((m) => { const ps = msg.map((m) => {
return m.msg.map(async (e) => { return m.msg.map(async(e) => {
if (e instanceof PacketMsgReplyElement && !e.targetElems) { if (e instanceof PacketMsgReplyElement && !e.targetElems) {
this.context.logger.debug(`Cannot find reply element's targetElems, prepare to fetch it...`); this.context.logger.debug(`Cannot find reply element's targetElems, prepare to fetch it...`);
if (!e.targetPeer?.peerUid) { if (!e.targetPeer?.peerUid) {
@@ -246,7 +222,6 @@ export class PacketOperationContext {
const res = trans.DownloadGroupFile.parse(resp); const res = trans.DownloadGroupFile.parse(resp);
return `https://${res.download.downloadDns}/ftn_handler/${Buffer.from(res.download.downloadUrl).toString('hex')}/?fname=`; return `https://${res.download.downloadDns}/ftn_handler/${Buffer.from(res.download.downloadUrl).toString('hex')}/?fname=`;
} }
async GetPrivateFileUrl(self_id: string, fileUUID: string, md5: string) { async GetPrivateFileUrl(self_id: string, fileUUID: string, md5: string) {
const req = trans.DownloadPrivateFile.build(self_id, fileUUID, md5); const req = trans.DownloadPrivateFile.build(self_id, fileUUID, md5);
const resp = await this.context.client.sendOidbPacket(req, true); const resp = await this.context.client.sendOidbPacket(req, true);
@@ -254,6 +229,13 @@ export class PacketOperationContext {
return `http://${res.body?.result?.server}:${res.body?.result?.port}${res.body?.result?.url?.slice(8)}&isthumb=0`; return `http://${res.body?.result?.server}:${res.body?.result?.port}${res.body?.result?.url?.slice(8)}&isthumb=0`;
} }
async GetGroupPttUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>) {
const req = trans.DownloadGroupPtt.build(groupUin, node);
const resp = await this.context.client.sendOidbPacket(req, true);
const res = trans.DownloadGroupPtt.parse(resp);
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
}
async GetMiniAppAdaptShareInfo(param: MiniAppReqParams) { async GetMiniAppAdaptShareInfo(param: MiniAppReqParams) {
const req = trans.GetMiniAppAdaptShareInfo.build(param); const req = trans.GetMiniAppAdaptShareInfo.build(param);
const resp = await this.context.client.sendOidbPacket(req, true); const resp = await this.context.client.sendOidbPacket(req, true);

View File

@@ -8,13 +8,13 @@ class SendPoke extends PacketTransformer<typeof proto.OidbSvcTrpcTcpBase> {
super(); super();
} }
build(is_group: boolean, peer: number, target: number): OidbPacket { build(peer: number, group?: number): OidbPacket {
const payload = { const data = new NapProtoMsg(proto.OidbSvcTrpcTcp0XED3_1).encode({
uin: target, uin: peer,
ext: 0, groupUin: group,
...(is_group ? { groupUin: peer } : { friendUin: peer }) friendUin: group ?? peer,
}; ext: 0
const data = new NapProtoMsg(proto.OidbSvcTrpcTcp0XED3_1).encode(payload); });
return OidbBase.build(0xED3, 1, data); return OidbBase.build(0xED3, 1, data);
} }

View File

@@ -1,50 +0,0 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketTransformer } from '@/core/packet/transformer/base';
import OidbBase from '@/core/packet/transformer/oidb/oidbBase';
import { IndexNode } from '@/core/packet/transformer/proto';
class DownloadGroupVideo extends PacketTransformer<typeof proto.NTV2RichMediaResp> {
constructor() {
super();
}
build(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>): OidbPacket {
const body = new NapProtoMsg(proto.NTV2RichMediaReq).encode({
reqHead: {
common: {
requestId: 1,
command: 200
},
scene: {
requestType: 2,
businessType: 2,
sceneType: 2,
group: {
groupUin: groupUin
}
},
client: {
agentType: 2,
}
},
download: {
node: node,
download: {
video: {
busiType: 0,
sceneType: 0
}
}
}
});
return OidbBase.build(0x11EA, 200, body, true, false);
}
parse(data: Buffer) {
const oidbBody = OidbBase.parse(data).body;
return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody);
}
}
export default new DownloadGroupVideo();

View File

@@ -1,51 +0,0 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketTransformer } from '@/core/packet/transformer/base';
import OidbBase from '@/core/packet/transformer/oidb/oidbBase';
import { IndexNode } from '@/core/packet/transformer/proto';
class DownloadPtt extends PacketTransformer<typeof proto.NTV2RichMediaResp> {
constructor() {
super();
}
build(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>): OidbPacket {
const body = new NapProtoMsg(proto.NTV2RichMediaReq).encode({
reqHead: {
common: {
requestId: 1,
command: 200
},
scene: {
requestType: 1,
businessType: 3,
sceneType: 1,
c2C: {
accountType: 2,
targetUid: selfUid
},
},
client: {
agentType: 2,
}
},
download: {
node: node,
download: {
video: {
busiType: 0,
sceneType: 0
}
}
}
});
return OidbBase.build(0x126D, 200, body, true, false);
}
parse(data: Buffer) {
const oidbBody = OidbBase.parse(data).body;
return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody);
}
}
export default new DownloadPtt();

View File

@@ -1,51 +0,0 @@
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketTransformer } from '@/core/packet/transformer/base';
import OidbBase from '@/core/packet/transformer/oidb/oidbBase';
import { IndexNode } from '@/core/packet/transformer/proto';
class DownloadVideo extends PacketTransformer<typeof proto.NTV2RichMediaResp> {
constructor() {
super();
}
build(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>): OidbPacket {
const body = new NapProtoMsg(proto.NTV2RichMediaReq).encode({
reqHead: {
common: {
requestId: 1,
command: 200
},
scene: {
requestType: 2,
businessType: 2,
sceneType: 1,
c2C: {
accountType: 2,
targetUid: selfUid
},
},
client: {
agentType: 2,
}
},
download: {
node: node,
download: {
video: {
busiType: 0,
sceneType: 0
}
}
}
});
return OidbBase.build(0x11E9, 200, body, true, false);
}
parse(data: Buffer) {
const oidbBody = OidbBase.parse(data).body;
return new NapProtoMsg(proto.NTV2RichMediaResp).decode(oidbBody);
}
}
export default new DownloadVideo();

View File

@@ -13,6 +13,3 @@ export { default as UploadPrivatePtt } from './UploadPrivatePtt';
export { default as UploadPrivateVideo } from './UploadPrivateVideo'; export { default as UploadPrivateVideo } from './UploadPrivateVideo';
export { default as DownloadImage } from './DownloadImage'; export { default as DownloadImage } from './DownloadImage';
export { default as DownloadGroupImage } from './DownloadGroupImage'; export { default as DownloadGroupImage } from './DownloadGroupImage';
export { default as DownloadVideo } from './DownloadVideo';
export { default as DownloadGroupVideo } from './DownloadGroupVideo';
export { default as DownloadPtt } from './DownloadPtt';

View File

@@ -7,7 +7,7 @@ class OidbBase extends PacketTransformer<typeof proto.OidbSvcTrpcTcpBase> {
super(); super();
} }
build(cmd: number, subCmd: number, body: Uint8Array, isUid: boolean = true, _isLafter: boolean = false): OidbPacket { build(cmd: number, subCmd: number, body: Uint8Array, isUid: boolean = true, isLafter: boolean = false): OidbPacket {
const data = new NapProtoMsg(proto.OidbSvcTrpcTcpBase).encode({ const data = new NapProtoMsg(proto.OidbSvcTrpcTcpBase).encode({
command: cmd, command: cmd,
subCommand: subCmd, subCommand: subCmd,

View File

@@ -1,6 +0,0 @@
import { ProtoField, ScalarType } from '@napneko/nap-proto-core';
export const FileId = {
appid: ProtoField(4, ScalarType.UINT32, true),
ttl: ProtoField(10, ScalarType.UINT32, true),
};

View File

@@ -8,22 +8,10 @@ import {
GroupNotifyMsgType, GroupNotifyMsgType,
NTGroupRequestOperateTypes, NTGroupRequestOperateTypes,
KickMemberV2Req, KickMemberV2Req,
GroupDetailInfoV2Param,
GroupExtInfo,
GroupExtFilter,
} from '@/core/types'; } from '@/core/types';
import { GeneralCallResult } from '@/core/services/common'; import { GeneralCallResult } from '@/core/services/common';
export interface NodeIKernelGroupService { export interface NodeIKernelGroupService {
modifyGroupExtInfoV2(groupExtInfo: GroupExtInfo, groupExtFilter: GroupExtFilter): Promise<GeneralCallResult &
{
result: {
groupCode: string,
result: number
}
}>;
// ---> // --->
// 待启用 For Next Version 3.2.0 // 待启用 For Next Version 3.2.0
// isTroopMember ? 0 : 111 // isTroopMember ? 0 : 111
@@ -181,9 +169,6 @@ export interface NodeIKernelGroupService {
modifyGroupDetailInfo(groupCode: string, arg: unknown): void; modifyGroupDetailInfo(groupCode: string, arg: unknown): void;
// 第二个参数在大多数情况为0 设置群成员权限 例如上传群文件权限和群成员付费/加入邀请加入时为8
modifyGroupDetailInfoV2(param: GroupDetailInfoV2Param, arg: number): Promise<GeneralCallResult>;
setGroupMsgMask(groupCode: string, arg: unknown): void; setGroupMsgMask(groupCode: string, arg: unknown): void;
changeGroupShieldSettingTemp(groupCode: string, arg: unknown): void; changeGroupShieldSettingTemp(groupCode: string, arg: unknown): void;
@@ -264,7 +249,7 @@ export interface NodeIKernelGroupService {
reqToJoinGroup(groupCode: string, arg: unknown): void; reqToJoinGroup(groupCode: string, arg: unknown): void;
setGroupShutUp(groupCode: string, shutUp: boolean): Promise<GeneralCallResult>; setGroupShutUp(groupCode: string, shutUp: boolean): void;
getGroupShutUpMemberList(groupCode: string): Promise<GeneralCallResult>; getGroupShutUpMemberList(groupCode: string): Promise<GeneralCallResult>;

View File

@@ -148,11 +148,10 @@ export interface NodeIKernelMsgService {
msgList: RawMessage[] msgList: RawMessage[]
}>; }>;
// getMsgService/getMsgs { chatType: 2, peerUid: '975206796', privilegeFlag: 336068800 } 0 20 true //@deprecated
getMsgs(peer: Peer & { privilegeFlag: number }, msgId: string, count: number, queryOrder: boolean): Promise<GeneralCallResult & { getMsgs(peer: Peer, msgId: string, count: unknown, queryOrder: boolean): Promise<unknown>;
msgList: RawMessage[]
}>;
//@deprecated
getMsgsIncludeSelf(peer: Peer, msgId: string, count: number, queryOrder: boolean): Promise<GeneralCallResult & { getMsgsIncludeSelf(peer: Peer, msgId: string, count: number, queryOrder: boolean): Promise<GeneralCallResult & {
msgList: RawMessage[] msgList: RawMessage[]
}>; }>;

View File

@@ -54,7 +54,7 @@ export interface NodeIKernelSearchService {
cancelSearchChatMsgs(...args: unknown[]): unknown;// needs 3 arguments 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 searchMoreMsgWithKeywords(...args: unknown[]): unknown;// needs 1 arguments

View File

@@ -58,7 +58,6 @@ export interface GrayTipRovokeElement {
operatorUid: string; operatorUid: string;
operatorNick: string; operatorNick: string;
operatorRemark: string; operatorRemark: string;
isSelfOperate: boolean; // 是否是自己撤回的
operatorMemRemark?: string; operatorMemRemark?: string;
wording: string; // 自定义的撤回提示语 wording: string; // 自定义的撤回提示语
} }

View File

@@ -1,97 +1,4 @@
import { QQLevel, NTSex } from './user'; import { QQLevel, NTSex } from './user';
export interface GroupExtInfo {
groupCode: string;
resultCode: number;
extInfo: EXTInfo;
}
export interface GroupExtFilter {
groupInfoExtSeq: number;
reserve: number;
luckyWordId: number;
lightCharNum: number;
luckyWord: number;
starId: number;
essentialMsgSwitch: number;
todoSeq: number;
blacklistExpireTime: number;
isLimitGroupRtc: number;
companyId: number;
hasGroupCustomPortrait: number;
bindGuildId: number;
groupOwnerId: number;
essentialMsgPrivilege: number;
msgEventSeq: number;
inviteRobotSwitch: number;
gangUpId: number;
qqMusicMedalSwitch: number;
showPlayTogetherSwitch: number;
groupFlagPro1: number;
groupBindGuildIds: number;
viewedMsgDisappearTime: number;
groupExtFlameData: number;
groupBindGuildSwitch: number;
groupAioBindGuildId: number;
groupExcludeGuildIds: number;
fullGroupExpansionSwitch: number;
fullGroupExpansionSeq: number;
inviteRobotMemberSwitch: number;
inviteRobotMemberExamine: number;
groupSquareSwitch: number;
};
export interface EXTInfo {
groupInfoExtSeq: number;
reserve: number;
luckyWordId: string;
lightCharNum: number;
luckyWord: string;
starId: number;
essentialMsgSwitch: number;
todoSeq: number;
blacklistExpireTime: number;
isLimitGroupRtc: number;
companyId: number;
hasGroupCustomPortrait: number;
bindGuildId: string;
groupOwnerId: GroupOwnerID;
essentialMsgPrivilege: number;
msgEventSeq: string;
inviteRobotSwitch: number;
gangUpId: string;
qqMusicMedalSwitch: number;
showPlayTogetherSwitch: number;
groupFlagPro1: string;
groupBindGuildIds: GroupGuildIDS;
viewedMsgDisappearTime: string;
groupExtFlameData: GroupEXTFlameData;
groupBindGuildSwitch: number;
groupAioBindGuildId: string;
groupExcludeGuildIds: GroupGuildIDS;
fullGroupExpansionSwitch: number;
fullGroupExpansionSeq: string;
inviteRobotMemberSwitch: number;
inviteRobotMemberExamine: number;
groupSquareSwitch: number;
}
export interface GroupGuildIDS {
guildIds: any[];
}
export interface GroupEXTFlameData {
switchState: number;
state: number;
dayNums: any[];
version: number;
updateTime: string;
isDisplayDayNum: boolean;
}
export interface GroupOwnerID {
memberUin: string;
memberUid: string;
memberQid: string;
}
export interface KickMemberInfo { export interface KickMemberInfo {
optFlag: number; optFlag: number;
@@ -100,185 +7,6 @@ export interface KickMemberInfo {
optBytesMsg: string; optBytesMsg: string;
} }
export interface GroupDetailInfoV2Param {
groupCode: string;
filter: Filter;
modifyInfo: ModifyInfo;
}
export interface Filter {
noCodeFingerOpenFlag: number;
noFingerOpenFlag: number;
groupName: number;
classExt: number;
classText: number;
fingerMemo: number;
richFingerMemo: number;
tagRecord: number;
groupGeoInfo: FilterGroupGeoInfo;
groupExtAdminNum: number;
flag: number;
groupMemo: number;
groupAioSkinUrl: number;
groupBoardSkinUrl: number;
groupCoverSkinUrl: number;
groupGrade: number;
activeMemberNum: number;
certificationType: number;
certificationText: number;
groupNewGuideLines: FilterGroupNewGuideLines;
groupFace: number;
addOption: number;
shutUpTime: number;
groupTypeFlag: number;
appPrivilegeFlag: number;
appPrivilegeMask: number;
groupExtOnly: GroupEXTOnly;
groupSecLevel: number;
groupSecLevelInfo: number;
subscriptionUin: number;
subscriptionUid: string;
allowMemberInvite: number;
groupQuestion: number;
groupAnswer: number;
groupFlagExt3: number;
groupFlagExt3Mask: number;
groupOpenAppid: number;
rootId: number;
msgLimitFrequency: number;
hlGuildAppid: number;
hlGuildSubType: number;
hlGuildOrgId: number;
groupFlagExt4: number;
groupFlagExt4Mask: number;
groupSchoolInfo: FilterGroupSchoolInfo;
groupCardPrefix: FilterGroupCardPrefix;
allianceId: number;
groupFlagPro1: number;
groupFlagPro1Mask: number;
}
export interface FilterGroupCardPrefix {
introduction: number;
rptPrefix: number;
}
export interface GroupEXTOnly {
tribeId: number;
moneyForAddGroup: number;
}
export interface FilterGroupGeoInfo {
ownerUid: number;
setTime: number;
cityId: number;
longitude: number;
latitude: number;
geoContent: number;
poiId: number;
}
export interface FilterGroupNewGuideLines {
enabled: number;
content: number;
}
export interface FilterGroupSchoolInfo {
location: number;
grade: number;
school: number;
}
export interface ModifyInfo {
noCodeFingerOpenFlag: number;
noFingerOpenFlag: number;
groupName: string;
classExt: number;
classText: string;
fingerMemo: string;
richFingerMemo: string;
tagRecord: any[];
groupGeoInfo: ModifyInfoGroupGeoInfo;
groupExtAdminNum: number;
flag: number;
groupMemo: string;
groupAioSkinUrl: string;
groupBoardSkinUrl: string;
groupCoverSkinUrl: string;
groupGrade: number;
activeMemberNum: number;
certificationType: number;
certificationText: string;
groupNewGuideLines: ModifyInfoGroupNewGuideLines;
groupFace: number;
addOption: number;// 0 空设置 1 任何人都可以进入 2 需要管理员批准 3 不允许任何人入群 4 问题进入答案 5 问题管理员批准
shutUpTime: number;
groupTypeFlag: number;
appPrivilegeFlag: number;
// 需要管理员审核
// 0000 0000 0000 0000 0000 0000 0000
// 无需审核入群
// 0000 0001 0000 0000 0000 0000 0000
// 成员数100内无审核
// 0100 0000 0000 0000 0000 0000 0000
// 禁用 群成员邀请好友
// 0100 0000 0000 0000 0000 0000 0000
appPrivilegeMask: number;
// 0110 0001 0000 0000 0000 0000 0000
// 101711872
groupExtOnly: GroupEXTOnly;
groupSecLevel: number;
groupSecLevelInfo: number;
subscriptionUin: string;
subscriptionUid: string;
allowMemberInvite: number;
groupQuestion: string;
groupAnswer: string;
groupFlagExt3: number;
groupFlagExt3Mask: number;
groupOpenAppid: number;
rootId: string;
msgLimitFrequency: number;
hlGuildAppid: number;
hlGuildSubType: number;
hlGuildOrgId: number;
groupFlagExt4: number;
groupFlagExt4Mask: number;
groupSchoolInfo: ModifyInfoGroupSchoolInfo;
groupCardPrefix: ModifyInfoGroupCardPrefix;
allianceId: string;
groupFlagPro1: number;
groupFlagPro1Mask: number;
}
export interface ModifyInfoGroupCardPrefix {
introduction: string;
rptPrefix: any[];
}
export interface ModifyInfoGroupGeoInfo {
ownerUid: string;
SetTime: number;
CityId: number;
Longitude: string;
Latitude: string;
GeoContent: string;
poiId: string;
}
export interface ModifyInfoGroupNewGuideLines {
enabled: boolean;
content: string;
}
export interface ModifyInfoGroupSchoolInfo {
location: string;
grade: number;
school: string;
}
// 获取群详细信息的来源类型 // 获取群详细信息的来源类型
export enum GroupInfoSource { export enum GroupInfoSource {
KUNSPECIFIED, KUNSPECIFIED,

View File

@@ -508,8 +508,7 @@ export interface RawMessage {
* 查询消息参数接口 * 查询消息参数接口
*/ */
export interface QueryMsgsParams { export interface QueryMsgsParams {
chatInfo: Peer & { privilegeFlag?: number }; chatInfo: Peer;
//searchFields: number;
filterMsgType: Array<{ type: NTMsgType, subType: Array<number> }>; filterMsgType: Array<{ type: NTMsgType, subType: Array<number> }>;
filterSendersUid: string[]; filterSendersUid: string[];
filterMsgFromTime: string; filterMsgFromTime: string;

View File

@@ -132,26 +132,18 @@ export enum BuddyReqType {
KMEINITIATORWAITPEERCONFIRM = 13 KMEINITIATORWAITPEERCONFIRM = 13
} }
// 其中 ? 代表新版本参数
export interface FriendRequest { export interface FriendRequest {
isBuddy?: boolean;
isInitiator?: boolean; isInitiator?: boolean;
isDecide: boolean; isDecide: boolean;
friendUid: string; friendUid: string;
reqType: BuddyReqType, reqType: BuddyReqType,
reqTime: string; // 时间戳 秒 reqTime: string; // 时间戳 秒
flag?: number; // 0
preGroupingId?: number; // 0
commFriendNum?: number; // 共同好友数
extWords: string; // 申请人填写的验证消息 extWords: string; // 申请人填写的验证消息
isUnread: boolean; isUnread: boolean;
isDoubt?: boolean; // 是否是可疑的好友请求
nameMore?: string;
friendNick: string; friendNick: string;
sourceId: number; sourceId: number;
groupCode: string; groupCode: string
isBuddy?: boolean;
isAgreed?: boolean;
relation?: number;
} }
export interface FriendRequestNotify { export interface FriendRequestNotify {

View File

@@ -48,12 +48,6 @@ export async function NCoreInitFramework(
}); });
} }
//直到登录成功后,执行下一步 //直到登录成功后,执行下一步
// const selfInfo = {
// uid: 'u_FUSS0_x06S_9Tf4na_WpUg',
// uin: '3684714082',
// nick: '',
// online: true
// }
const selfInfo = await new Promise<SelfInfo>((resolveSelfInfo) => { const selfInfo = await new Promise<SelfInfo>((resolveSelfInfo) => {
const loginListener = new NodeIKernelLoginListener(); const loginListener = new NodeIKernelLoginListener();
loginListener.onQRCodeLoginSucceed = async (loginResult) => { loginListener.onQRCodeLoginSucceed = async (loginResult) => {

View File

@@ -1,26 +0,0 @@
const fs = require('fs');
const path = require('path');
async function initializeNapCat(session, loginService, registerCallback) {
//const logFile = path.join(currentPath, 'napcat.log');
console.log('[NapCat] [Info] 开始初始化NapCat');
//fs.writeFileSync(logFile, '', { flag: 'w' });
//fs.writeFileSync(logFile, '[NapCat] [Info] NapCat 初始化成功\n', { flag: 'a' });
try {
const currentPath = path.dirname(__filename);
const { NCoreInitFramework } = await import('file://' + path.join(currentPath, './napcat.mjs'));
await NCoreInitFramework(session, loginService, (callback) => { registerCallback(callback) });
} catch (error) {
console.log('[NapCat] [Error] 初始化NapCat', error);
//fs.writeFileSync(logFile, `[NapCat] [Error] 初始化NapCat失败: ${error.message}\n`, { flag: 'a' });
}
}
module.exports = {
initializeNapCat: initializeNapCat
};

View File

@@ -1,426 +0,0 @@
import * as fs from 'fs';
import { ReadStream } from 'fs';
export interface ImageSize {
width: number;
height: number;
}
export enum ImageType {
JPEG = 'jpeg',
PNG = 'png',
BMP = 'bmp',
GIF = 'gif',
WEBP = 'webp',
UNKNOWN = 'unknown',
}
interface ImageParser {
readonly type: ImageType;
canParse(buffer: Buffer): boolean;
parseSize(stream: ReadStream): Promise<ImageSize | undefined>;
}
// 魔术匹配
function matchMagic(buffer: Buffer, magic: number[], offset = 0): boolean {
if (buffer.length < offset + magic.length) {
return false;
}
for (let i = 0; i < magic.length; i++) {
if (buffer[offset + i] !== magic[i]) {
return false;
}
}
return true;
}
// PNG解析器
class PngParser implements ImageParser {
readonly type = ImageType.PNG;
// PNG 魔术头89 50 4E 47 0D 0A 1A 0A
private readonly PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
canParse(buffer: Buffer): boolean {
return matchMagic(buffer, this.PNG_SIGNATURE);
}
async parseSize(stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
stream.once('error', reject);
stream.once('readable', () => {
const buf = stream.read(24) as Buffer;
if (!buf || buf.length < 24) {
return resolve(undefined);
}
if (this.canParse(buf)) {
const width = buf.readUInt32BE(16);
const height = buf.readUInt32BE(20);
resolve({ width, height });
} else {
resolve(undefined);
}
});
});
}
}
// JPEG解析器
class JpegParser implements ImageParser {
readonly type = ImageType.JPEG;
// JPEG 魔术头FF D8
private readonly JPEG_SIGNATURE = [0xFF, 0xD8];
// JPEG标记常量
private readonly SOF_MARKERS = {
SOF0: 0xC0, // 基线DCT
SOF1: 0xC1, // 扩展顺序DCT
SOF2: 0xC2, // 渐进式DCT
SOF3: 0xC3, // 无损
} as const;
// 非SOF标记
private readonly NON_SOF_MARKERS: number[] = [
0xC4, // DHT
0xC8, // JPEG扩展
0xCC, // DAC
] as const;
canParse(buffer: Buffer): boolean {
return matchMagic(buffer, this.JPEG_SIGNATURE);
}
isSOFMarker(marker: number): boolean {
return (
marker === this.SOF_MARKERS.SOF0 ||
marker === this.SOF_MARKERS.SOF1 ||
marker === this.SOF_MARKERS.SOF2 ||
marker === this.SOF_MARKERS.SOF3
);
}
isNonSOFMarker(marker: number): boolean {
return this.NON_SOF_MARKERS.includes(marker);
}
async parseSize(stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise<ImageSize | undefined>((resolve, reject) => {
const BUFFER_SIZE = 1024; // 读取块大小,可以根据需要调整
let buffer = Buffer.alloc(0);
let offset = 0;
let found = false;
// 处理错误
stream.on('error', (err) => {
stream.destroy();
reject(err);
});
// 处理数据块
stream.on('data', (chunk: Buffer | string) => {
// 追加新数据到缓冲区
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
buffer = Buffer.concat([buffer.subarray(offset), chunkBuffer]);
offset = 0;
// 保持缓冲区在合理大小内,只保留最后的部分用于跨块匹配
const bufferSize = buffer.length;
const MIN_REQUIRED_BYTES = 10; // SOF段最低字节数
// 从JPEG头部后开始扫描
while (offset < bufferSize - MIN_REQUIRED_BYTES) {
// 寻找FF标记
if (buffer[offset] === 0xFF && buffer[offset + 1]! >= 0xC0 && buffer[offset + 1]! <= 0xCF) {
const marker = buffer[offset + 1];
if (!marker) {
break;
}
// 跳过非SOF标记
if (this.isNonSOFMarker(marker)) {
offset += 2;
continue;
}
// 处理SOF标记 (包含尺寸信息)
if (this.isSOFMarker(marker)) {
// 确保缓冲区中有足够数据读取尺寸
if (offset + 9 < bufferSize) {
// 解析尺寸: FF XX YY YY PP HH HH WW WW ...
// XX = 标记, YY YY = 段长度, PP = 精度, HH HH = 高, WW WW = 宽
const height = buffer.readUInt16BE(offset + 5);
const width = buffer.readUInt16BE(offset + 7);
found = true;
stream.destroy();
resolve({ width, height });
return;
} else {
// 如果缓冲区内数据不够,保留当前位置等待更多数据
break;
}
}
}
offset++;
}
// 缓冲区管理: 如果处理了许多数据但没找到标记,
// 保留最后N字节用于跨块匹配丢弃之前的数据
if (offset > BUFFER_SIZE) {
const KEEP_BYTES = 20; // 保留足够数据以处理跨块边界的情况
if (offset > KEEP_BYTES) {
buffer = buffer.subarray(offset - KEEP_BYTES);
offset = KEEP_BYTES;
}
}
});
// 处理流结束
stream.on('end', () => {
if (!found) {
resolve(undefined);
}
});
});
}
}
// BMP解析器
class BmpParser implements ImageParser {
readonly type = ImageType.BMP;
// BMP 魔术头42 4D (BM)
private readonly BMP_SIGNATURE = [0x42, 0x4D];
canParse(buffer: Buffer): boolean {
return matchMagic(buffer, this.BMP_SIGNATURE);
}
async parseSize(stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
stream.once('error', reject);
stream.once('readable', () => {
const buf = stream.read(26) as Buffer;
if (!buf || buf.length < 26) {
return resolve(undefined);
}
if (this.canParse(buf)) {
const width = buf.readUInt32LE(18);
const height = buf.readUInt32LE(22);
resolve({ width, height });
} else {
resolve(undefined);
}
});
});
}
}
// GIF解析器
class GifParser implements ImageParser {
readonly type = ImageType.GIF;
// GIF87a 魔术头47 49 46 38 37 61
private readonly GIF87A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61];
// GIF89a 魔术头47 49 46 38 39 61
private readonly GIF89A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
canParse(buffer: Buffer): boolean {
return (
matchMagic(buffer, this.GIF87A_SIGNATURE) ||
matchMagic(buffer, this.GIF89A_SIGNATURE)
);
}
async parseSize(stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
stream.once('error', reject);
stream.once('readable', () => {
const buf = stream.read(10) as Buffer;
if (!buf || buf.length < 10) {
return resolve(undefined);
}
if (this.canParse(buf)) {
const width = buf.readUInt16LE(6);
const height = buf.readUInt16LE(8);
resolve({ width, height });
} else {
resolve(undefined);
}
});
});
}
}
// WEBP解析器 - 完整支持VP8, VP8L, VP8X格式
class WebpParser implements ImageParser {
readonly type = ImageType.WEBP;
// WEBP RIFF 头52 49 46 46 (RIFF)
private readonly RIFF_SIGNATURE = [0x52, 0x49, 0x46, 0x46];
// WEBP 魔术头57 45 42 50 (WEBP)
private readonly WEBP_SIGNATURE = [0x57, 0x45, 0x42, 0x50];
// WEBP 块头
private readonly CHUNK_VP8 = [0x56, 0x50, 0x38, 0x20]; // "VP8 "
private readonly CHUNK_VP8L = [0x56, 0x50, 0x38, 0x4C]; // "VP8L"
private readonly CHUNK_VP8X = [0x56, 0x50, 0x38, 0x58]; // "VP8X"
canParse(buffer: Buffer): boolean {
return (
buffer.length >= 12 &&
matchMagic(buffer, this.RIFF_SIGNATURE, 0) &&
matchMagic(buffer, this.WEBP_SIGNATURE, 8)
);
}
isChunkType(buffer: Buffer, offset: number, chunkType: number[]): boolean {
return buffer.length >= offset + 4 && matchMagic(buffer, chunkType, offset);
}
async parseSize(stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
// 需要读取足够的字节来检测所有三种格式
const MAX_HEADER_SIZE = 32;
let totalBytes = 0;
let buffer = Buffer.alloc(0);
stream.on('error', reject);
stream.on('data', (chunk: Buffer | string) => {
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
buffer = Buffer.concat([buffer, chunkBuffer]);
totalBytes += chunk.length;
// 检查是否有足够的字节进行格式检测
if (totalBytes >= MAX_HEADER_SIZE) {
stream.destroy();
// 检查基本的WEBP签名
if (!this.canParse(buffer)) {
return resolve(undefined);
}
// 检查chunk头部位于字节12-15
if (this.isChunkType(buffer, 12, this.CHUNK_VP8)) {
// VP8格式 - 标准WebP
// 宽度和高度在帧头中
const width = buffer.readUInt16LE(26) & 0x3FFF;
const height = buffer.readUInt16LE(28) & 0x3FFF;
return resolve({ width, height });
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8L)) {
// VP8L格式 - 无损WebP
// 1字节标记后是14位宽度和14位高度
const bits = buffer.readUInt32LE(21);
const width = 1 + (bits & 0x3FFF);
const height = 1 + ((bits >> 14) & 0x3FFF);
return resolve({ width, height });
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8X)) {
// VP8X格式 - 扩展WebP
// 24位宽度和高度(减去1)
if (!buffer[24] || !buffer[25] || !buffer[26] || !buffer[27] || !buffer[28] || !buffer[29]) {
return resolve(undefined);
}
const width = 1 + ((buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) & 0xFFFFFF);
const height = 1 + ((buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) & 0xFFFFFF);
return resolve({ width, height });
} else {
// 未知的WebP子格式
return resolve(undefined);
}
}
});
stream.on('end', () => {
// 如果没有读到足够的字节
if (totalBytes < MAX_HEADER_SIZE) {
resolve(undefined);
}
});
});
}
}
const parsers: ReadonlyArray<ImageParser> = [
new PngParser(),
new JpegParser(),
new BmpParser(),
new GifParser(),
new WebpParser(),
];
export async function detectImageType(filePath: string): Promise<ImageType> {
return new Promise((resolve, reject) => {
const stream = fs.createReadStream(filePath, {
highWaterMark: 64, // 优化读取buffer大小
start: 0,
end: 63
});
let buffer: Buffer | null = null;
stream.once('error', (err) => {
stream.destroy();
reject(err);
});
stream.once('readable', () => {
buffer = stream.read(64) as Buffer;
stream.destroy();
if (!buffer) {
return resolve(ImageType.UNKNOWN);
}
for (const parser of parsers) {
if (parser.canParse(buffer)) {
return resolve(parser.type);
}
}
resolve(ImageType.UNKNOWN);
});
stream.once('end', () => {
if (!buffer) {
resolve(ImageType.UNKNOWN);
}
});
});
}
export async function imageSizeFromFile(filePath: string): Promise<ImageSize | undefined> {
try {
// 先检测类型
const type = await detectImageType(filePath);
const parser = parsers.find(p => p.type === type);
if (!parser) {
return undefined;
}
// 用流式方式解析尺寸
const stream = fs.createReadStream(filePath);
try {
return await parser.parseSize(stream);
} catch (err) {
console.error(`解析图片尺寸出错: ${err}`);
return undefined;
} finally {
if (!stream.destroyed) {
stream.destroy();
}
}
} catch (err) {
console.error(`检测图片类型出错: ${err}`);
return undefined;
}
}
export async function imageSizeFallBack(
filePath: string,
fallback: ImageSize = {
width: 1024,
height: 1024,
}
): Promise<ImageSize> {
return await imageSizeFromFile(filePath) ?? fallback;
}

View File

@@ -1,28 +0,0 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
group_id: Type.String(),
add_type: Type.Number(),
group_question: Type.Optional(Type.String()),
group_answer: Type.Optional(Type.String()),
});
type Payload = Static<typeof SchemaData>;
export default class SetGroupAddOption extends OneBotAction<Payload, null> {
override actionName = ActionName.SetGroupAddOption;
override payloadSchema = SchemaData;
async _handle(payload: Payload): Promise<null> {
let ret = await this.core.apis.GroupApi.setGroupAddOption(payload.group_id, {
addOption: payload.add_type,
groupQuestion: payload.group_question,
groupAnswer: payload.group_answer,
});
if (ret.result != 0) {
throw new Error(`设置群添加选项失败, ${ret.result}:${ret.errMsg}`);
}
return null;
}
}

View File

@@ -1,23 +0,0 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
group_id: Type.String(),
user_id: Type.Array(Type.String()),
reject_add_request: Type.Optional(Type.Union([Type.Boolean(), Type.String()])),
});
type Payload = Static<typeof SchemaData>;
export default class SetGroupKickMembers extends OneBotAction<Payload, null> {
override actionName = ActionName.SetGroupKickMembers;
override payloadSchema = SchemaData;
async _handle(payload: Payload): Promise<null> {
const rejectReq = payload.reject_add_request?.toString() == 'true';
const uids: string[] = await Promise.all(payload.user_id.map(async uin => await this.core.apis.UserApi.getUidByUinV2(uin)));
await this.core.apis.GroupApi.kickMember(payload.group_id.toString(), uids.filter(uid => !!uid), rejectReq);
return null;
}
}

View File

@@ -1,27 +0,0 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
group_id: Type.String(),
robot_member_switch: Type.Optional(Type.Number()),
robot_member_examine: Type.Optional(Type.Number()),
});
type Payload = Static<typeof SchemaData>;
export default class SetGroupRobotAddOption extends OneBotAction<Payload, null> {
override actionName = ActionName.SetGroupRobotAddOption;
override payloadSchema = SchemaData;
async _handle(payload: Payload): Promise<null> {
let ret = await this.core.apis.GroupApi.setGroupRobotAddOption(
payload.group_id,
payload.robot_member_switch,
payload.robot_member_examine,
);
if (ret.result != 0) {
throw new Error(`设置群机器人添加选项失败, ${ret.result}:${ret.errMsg}`);
}
return null;
}
}

View File

@@ -1,26 +0,0 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
group_id: Type.String(),
no_code_finger_open: Type.Optional(Type.Number()),
no_finger_open: Type.Optional(Type.Number()),
});
type Payload = Static<typeof SchemaData>;
export default class SetGroupSearch extends OneBotAction<Payload, null> {
override actionName = ActionName.SetGroupSearch;
override payloadSchema = SchemaData;
async _handle(payload: Payload): Promise<null> {
let ret = await this.core.apis.GroupApi.setGroupSearch(payload.group_id, {
noCodeFingerOpenFlag: payload.no_code_finger_open,
noFingerOpenFlag: payload.no_finger_open,
});
if (ret.result != 0) {
throw new Error(`设置群搜索失败, ${ret.result}:${ret.errMsg}`);
}
return null;
}
}

View File

@@ -4,10 +4,7 @@ import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({ const SchemaData = Type.Object({
group_id: Type.Union([Type.Number(), Type.String()]), group_id: Type.Union([Type.Number(), Type.String()]),
// 兼容gocq 与name二选一 folder_name: Type.String(),
folder_name: Type.Optional(Type.String()),
// 兼容gocq 与folder_name二选一
name: Type.Optional(Type.String()),
}); });
type Payload = Static<typeof SchemaData>; type Payload = Static<typeof SchemaData>;
@@ -19,7 +16,6 @@ export class CreateGroupFileFolder extends OneBotAction<Payload, ResponseType>
override actionName = ActionName.GoCQHTTP_CreateGroupFileFolder; override actionName = ActionName.GoCQHTTP_CreateGroupFileFolder;
override payloadSchema = SchemaData; override payloadSchema = SchemaData;
async _handle(payload: Payload) { async _handle(payload: Payload) {
const folderName = payload.folder_name || payload.name; return (await this.core.apis.GroupApi.creatGroupFileFolder(payload.group_id.toString(), payload.folder_name)).resultWithGroupItem;
return (await this.core.apis.GroupApi.creatGroupFileFolder(payload.group_id.toString(), folderName!)).resultWithGroupItem;
} }
} }

View File

@@ -1,4 +1,4 @@
import { ContextMode, normalize, ReturnDataType, SendMsgBase } from '@/onebot/action/msg/SendMsg'; import { normalize, SendMsgBase } from '@/onebot/action/msg/SendMsg';
import { OB11PostSendMsg } from '@/onebot/types'; import { OB11PostSendMsg } from '@/onebot/types';
import { ActionName } from '@/onebot/action/router'; import { ActionName } from '@/onebot/action/router';
@@ -19,14 +19,8 @@ export class GoCQHTTPSendForwardMsg extends GoCQHTTPSendForwardMsgBase {
} }
export class GoCQHTTPSendPrivateForwardMsg extends GoCQHTTPSendForwardMsgBase { export class GoCQHTTPSendPrivateForwardMsg extends GoCQHTTPSendForwardMsgBase {
override actionName = ActionName.GoCQHTTP_SendPrivateForwardMsg; override actionName = ActionName.GoCQHTTP_SendPrivateForwardMsg;
override async _handle(payload: OB11PostSendMsg): Promise<ReturnDataType> {
return this.base_handle(payload, ContextMode.Private);
}
} }
export class GoCQHTTPSendGroupForwardMsg extends GoCQHTTPSendForwardMsgBase { export class GoCQHTTPSendGroupForwardMsg extends GoCQHTTPSendForwardMsgBase {
override actionName = ActionName.GoCQHTTP_SendGroupForwardMsg; override actionName = ActionName.GoCQHTTP_SendGroupForwardMsg;
override async _handle(payload: OB11PostSendMsg): Promise<ReturnDataType> {
return this.base_handle(payload, ContextMode.Group);
}
} }

View File

@@ -1,27 +0,0 @@
import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
group_id: Type.Union([Type.Number(), Type.String()]),
});
type Payload = Static<typeof SchemaData>;
export class GetGroupDetailInfo extends OneBotAction<Payload, unknown> {
override actionName = ActionName.GetGroupDetailInfo;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
const data = await this.core.apis.GroupApi.fetchGroupDetail(payload.group_id.toString());
return {
...data,
group_all_shut: data.shutUpAllTimestamp > 0 ? -1 : 0,
group_remark: '',
group_id: +payload.group_id,
group_name: data.groupName,
member_count: data.memberNum,
max_member_count: data.maxMemberNum,
};
}
}

View File

@@ -4,7 +4,6 @@ import { ActionName } from '@/onebot/action/router';
import { Notify } from '@/onebot/types'; import { Notify } from '@/onebot/types';
interface RetData { interface RetData {
invited_requests: Notify[];
InvitedRequest: Notify[]; InvitedRequest: Notify[];
join_requests: Notify[]; join_requests: Notify[];
} }
@@ -14,7 +13,7 @@ export class GetGroupIgnoredNotifies extends OneBotAction<void, RetData> {
async _handle(): Promise<RetData> { async _handle(): Promise<RetData> {
const SingleScreenNotifies = await this.core.apis.GroupApi.getSingleScreenNotifies(false, 50); const SingleScreenNotifies = await this.core.apis.GroupApi.getSingleScreenNotifies(false, 50);
const retData: RetData = { invited_requests: [], InvitedRequest: [], join_requests: [] }; const retData: RetData = { InvitedRequest: [], join_requests: [] };
const notifyPromises = SingleScreenNotifies.map(async (SSNotify) => { const notifyPromises = SingleScreenNotifies.map(async (SSNotify) => {
const invitorUin = SSNotify.user1?.uid ? +await this.core.apis.UserApi.getUinByUidV2(SSNotify.user1.uid) : 0; const invitorUin = SSNotify.user1?.uid ? +await this.core.apis.UserApi.getUinByUidV2(SSNotify.user1.uid) : 0;
@@ -39,7 +38,7 @@ export class GetGroupIgnoredNotifies extends OneBotAction<void, RetData> {
}); });
await Promise.all(notifyPromises); await Promise.all(notifyPromises);
retData.invited_requests = retData.InvitedRequest;
return retData; return retData;
} }
} }

View File

@@ -8,16 +8,10 @@ interface GroupNotice {
notice_id: string; notice_id: string;
message: { message: {
text: string text: string
// 保持一段时间兼容性 防止以往版本出现问题 后续版本可考虑移除
image: Array<{ image: Array<{
height: string height: string
width: string width: string
id: string id: string
}>,
images: Array<{
height: string
width: string
id: string
}> }>
}; };
} }
@@ -46,18 +40,15 @@ export class GetGroupNotice extends OneBotAction<Payload, GroupNotice[]> {
continue; continue;
} }
const retApiNotice: WebApiGroupNoticeFeed = ret.feeds[key]; const retApiNotice: WebApiGroupNoticeFeed = ret.feeds[key];
const image = retApiNotice.msg.pics?.map((pic) => {
return { id: pic.id, height: pic.h, width: pic.w };
}) || [];
const retNotice: GroupNotice = { const retNotice: GroupNotice = {
notice_id: retApiNotice.fid, notice_id: retApiNotice.fid,
sender_id: retApiNotice.u, sender_id: retApiNotice.u,
publish_time: retApiNotice.pubt, publish_time: retApiNotice.pubt,
message: { message: {
text: retApiNotice.msg.text, text: retApiNotice.msg.text,
image, image: retApiNotice.msg.pics?.map((pic) => {
images: image, return { id: pic.id, height: pic.h, width: pic.w };
}) || [],
}, },
}; };
retNotices.push(retNotice); retNotices.push(retNotice);

View File

@@ -0,0 +1,19 @@
import { ActionName } from '@/onebot/action/router';
import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
group_id: Type.Union([Type.Number(), Type.String()]),
user_id: Type.Union([Type.Number(), Type.String()]),
});
type Payload = Static<typeof SchemaData>;
export class GroupPoke extends GetPacketStatusDepends<Payload, void> {
override actionName = ActionName.GroupPoke;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
await this.core.apis.PacketApi.pkt.operation.GroupPoke(+payload.group_id, +payload.user_id);
}
}

View File

@@ -1,19 +1,17 @@
import { ContextMode, ReturnDataType, SendMsgBase } from '@/onebot/action/msg/SendMsg'; import { ContextMode, SendMsgBase } from '@/onebot/action/msg/SendMsg';
import { ActionName, BaseCheckResult } from '@/onebot/action/router'; import { ActionName, BaseCheckResult } from '@/onebot/action/router';
import { OB11PostSendMsg } from '@/onebot/types'; import { OB11PostSendMsg } from '@/onebot/types';
// 未检测参数 // 未检测参数
class SendGroupMsg extends SendMsgBase { class SendGroupMsg extends SendMsgBase {
override actionName = ActionName.SendGroupMsg; override actionName = ActionName.SendGroupMsg;
override contextMode: ContextMode = ContextMode.Group;
protected override async check(payload: OB11PostSendMsg): Promise<BaseCheckResult> { protected override async check(payload: OB11PostSendMsg): Promise<BaseCheckResult> {
delete payload.user_id; delete payload.user_id;
payload.message_type = 'group'; payload.message_type = 'group';
return super.check(payload); return super.check(payload);
} }
override async _handle(payload: OB11PostSendMsg): Promise<ReturnDataType> {
return this.base_handle(payload, ContextMode.Group);
}
} }
export default SendGroupMsg; export default SendGroupMsg;

View File

@@ -15,10 +15,7 @@ export default class SetGroupWholeBan extends OneBotAction<Payload, null> {
async _handle(payload: Payload): Promise<null> { async _handle(payload: Payload): Promise<null> {
const enable = payload.enable?.toString() !== 'false'; const enable = payload.enable?.toString() !== 'false';
let res = await this.core.apis.GroupApi.banGroup(payload.group_id.toString(), enable); await this.core.apis.GroupApi.banGroup(payload.group_id.toString(), enable);
if (res.result !== 0) {
throw new Error(`SetGroupWholeBan failed: ${res.errMsg} ${res.result}`);
}
return null; return null;
} }
} }

View File

@@ -78,6 +78,7 @@ import { GetGroupFileSystemInfo } from '@/onebot/action/go-cqhttp/GetGroupFileSy
import { GetGroupRootFiles } from '@/onebot/action/go-cqhttp/GetGroupRootFiles'; import { GetGroupRootFiles } from '@/onebot/action/go-cqhttp/GetGroupRootFiles';
import { GetGroupFilesByFolder } from '@/onebot/action/go-cqhttp/GetGroupFilesByFolder'; import { GetGroupFilesByFolder } from '@/onebot/action/go-cqhttp/GetGroupFilesByFolder';
import { GetGroupSystemMsg } from './system/GetSystemMsg'; import { GetGroupSystemMsg } from './system/GetSystemMsg';
import { GroupPoke } from './group/GroupPoke';
import { GetUserStatus } from './extends/GetUserStatus'; import { GetUserStatus } from './extends/GetUserStatus';
import { GetRkey } from './extends/GetRkey'; import { GetRkey } from './extends/GetRkey';
import { SetSpecialTitle } from './extends/SetSpecialTitle'; import { SetSpecialTitle } from './extends/SetSpecialTitle';
@@ -85,6 +86,7 @@ import { GetGroupShutList } from './group/GetGroupShutList';
import { GetGroupMemberList } from './group/GetGroupMemberList'; import { GetGroupMemberList } from './group/GetGroupMemberList';
import { GetGroupFileUrl } from '@/onebot/action/file/GetGroupFileUrl'; import { GetGroupFileUrl } from '@/onebot/action/file/GetGroupFileUrl';
import { GetPacketStatus } from '@/onebot/action/packet/GetPacketStatus'; import { GetPacketStatus } from '@/onebot/action/packet/GetPacketStatus';
import { FriendPoke } from '@/onebot/action/user/FriendPoke';
import { GetCredentials } from './system/GetCredentials'; import { GetCredentials } from './system/GetCredentials';
import { SendGroupSign, SetGroupSign } from './extends/SetGroupSign'; import { SendGroupSign, SetGroupSign } from './extends/SetGroupSign';
import { GoCQHTTPGetGroupAtAllRemain } from './go-cqhttp/GetGroupAtAllRemain'; import { GoCQHTTPGetGroupAtAllRemain } from './go-cqhttp/GetGroupAtAllRemain';
@@ -100,7 +102,7 @@ import { GetGuildList } from './guild/GetGuildList';
import { GetGuildProfile } from './guild/GetGuildProfile'; import { GetGuildProfile } from './guild/GetGuildProfile';
import { GetClientkey } from './extends/GetClientkey'; import { GetClientkey } from './extends/GetClientkey';
import { SendPacket } from './extends/SendPacket'; import { SendPacket } from './extends/SendPacket';
import { FriendPoke, GroupPoke, SendPoke } from '@/onebot/action/packet/SendPoke'; import { SendPoke } from '@/onebot/action/packet/SendPoke';
import { SetDiyOnlineStatus } from './extends/SetDiyOnlineStatus'; import { SetDiyOnlineStatus } from './extends/SetDiyOnlineStatus';
import { BotExit } from './extends/BotExit'; import { BotExit } from './extends/BotExit';
import { ClickInlineKeyboardButton } from './extends/ClickInlineKeyboardButton'; import { ClickInlineKeyboardButton } from './extends/ClickInlineKeyboardButton';
@@ -116,22 +118,10 @@ import { CleanCache } from './system/CleanCache';
import SetFriendRemark from './user/SetFriendRemark'; import SetFriendRemark from './user/SetFriendRemark';
import { SetDoubtFriendsAddRequest } from './new/SetDoubtFriendsAddRequest'; import { SetDoubtFriendsAddRequest } from './new/SetDoubtFriendsAddRequest';
import { GetDoubtFriendsAddRequest } from './new/GetDoubtFriendsAddRequest'; import { GetDoubtFriendsAddRequest } from './new/GetDoubtFriendsAddRequest';
import SetGroupAddOption from './extends/SetGroupAddOption';
import SetGroupSearch from './extends/SetGroupSearch';
import SetGroupRobotAddOption from './extends/SetGroupRobotAddOption';
import SetGroupKickMembers from './extends/SetGroupKickMembers';
import { GetGroupDetailInfo } from './group/GetGroupDetailInfo';
import GetGroupAddRequest from './extends/GetGroupAddRequest';
import { GetCollectionList } from './extends/GetCollectionList';
export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCore) { export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
const actionHandlers = [ const actionHandlers = [
new GetGroupDetailInfo(obContext, core),
new SetGroupKickMembers(obContext, core),
new SetGroupAddOption(obContext, core),
new SetGroupRobotAddOption(obContext, core),
new SetGroupSearch(obContext, core),
new SetDoubtFriendsAddRequest(obContext, core), new SetDoubtFriendsAddRequest(obContext, core),
new GetDoubtFriendsAddRequest(obContext, core), new GetDoubtFriendsAddRequest(obContext, core),
new SetFriendRemark(obContext, core), new SetFriendRemark(obContext, core),
@@ -259,8 +249,6 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo
new GetPrivateFileUrl(obContext, core), new GetPrivateFileUrl(obContext, core),
new GetUnidirectionalFriendList(obContext, core), new GetUnidirectionalFriendList(obContext, core),
new CleanCache(obContext, core), new CleanCache(obContext, core),
new GetGroupAddRequest(obContext, core),
new GetCollectionList(obContext, core),
]; ];
type HandlerUnion = typeof actionHandlers[number]; type HandlerUnion = typeof actionHandlers[number];

View File

@@ -19,7 +19,6 @@ import { rawMsgWithSendMsg } from '@/core/packet/message/converter';
export interface ReturnDataType { export interface ReturnDataType {
message_id: number; message_id: number;
res_id?: string; res_id?: string;
forward_id?: string;
} }
export enum ContextMode { export enum ContextMode {
@@ -105,6 +104,8 @@ function getSpecialMsgNum(payload: OB11PostSendMsg, msgType: OB11MessageDataType
} }
export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> { export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> {
contextMode = ContextMode.Normal;
protected override async check(payload: OB11PostSendMsg): Promise<BaseCheckResult> { protected override async check(payload: OB11PostSendMsg): Promise<BaseCheckResult> {
const messages = normalize(payload.message); const messages = normalize(payload.message);
const nodeElementLength = getSpecialMsgNum(payload, OB11MessageDataType.node); const nodeElementLength = getSpecialMsgNum(payload, OB11MessageDataType.node);
@@ -116,13 +117,12 @@ export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> {
} }
return { valid: true }; return { valid: true };
} }
async _handle(payload: OB11PostSendMsg): Promise<ReturnDataType> { async _handle(payload: OB11PostSendMsg): Promise<ReturnDataType> {
return this.base_handle(payload); this.contextMode = ContextMode.Normal;
} if (payload.message_type === 'group') this.contextMode = ContextMode.Group;
async base_handle(payload: OB11PostSendMsg, contextMode: ContextMode = ContextMode.Normal): Promise<ReturnDataType> { if (payload.message_type === 'private') this.contextMode = ContextMode.Private;
if (payload.message_type === 'group') contextMode = ContextMode.Group; const peer = await createContext(this.core, payload, this.contextMode);
if (payload.message_type === 'private') contextMode = ContextMode.Private;
const peer = await createContext(this.core, payload, contextMode);
const messages = normalize( const messages = normalize(
payload.message, payload.message,
@@ -148,10 +148,7 @@ export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> {
peerUid: peer.peerUid, peerUid: peer.peerUid,
chatType: peer.chatType, chatType: peer.chatType,
}, (returnMsgAndResId.message).msgId); }, (returnMsgAndResId.message).msgId);
return { message_id: msgShortId!, res_id: returnMsgAndResId.res_id! };
// 对gocq的forward_id进行兼容
const resId = returnMsgAndResId.res_id!;
return { message_id: msgShortId!, res_id: resId, forward_id: resId };
} else if (returnMsgAndResId.res_id && !returnMsgAndResId.message) { } else if (returnMsgAndResId.res_id && !returnMsgAndResId.message) {
throw Error(`发送转发消息res_id${returnMsgAndResId.res_id} 失败`); throw Error(`发送转发消息res_id${returnMsgAndResId.res_id} 失败`);
} }
@@ -177,11 +174,9 @@ export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> {
nickname: string, nickname: string,
}, dp: number = 0): Promise<{ }, dp: number = 0): Promise<{
finallySendElements: SendArkElement, finallySendElements: SendArkElement,
res_id?: string, res_id?: string
deleteAfterSentFiles: string[],
} | null> { } | null> {
const packetMsg: PacketMsg[] = []; const packetMsg: PacketMsg[] = [];
let delFiles: string[] = [];
for (const node of messageNodes) { for (const node of messageNodes) {
if (dp >= 3) { if (dp >= 3) {
this.core.context.logger.logWarn('转发消息深度超过3层将停止解析'); this.core.context.logger.logWarn('转发消息深度超过3层将停止解析');
@@ -197,11 +192,9 @@ export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> {
nickname: (node.data.nickname || node.data.name) ?? parentMeta?.nickname ?? 'QQ用户', nickname: (node.data.nickname || node.data.name) ?? parentMeta?.nickname ?? 'QQ用户',
}, dp + 1); }, dp + 1);
sendElements = uploadReturnData?.finallySendElements ? [uploadReturnData.finallySendElements] : []; sendElements = uploadReturnData?.finallySendElements ? [uploadReturnData.finallySendElements] : [];
delFiles.push(...(uploadReturnData?.deleteAfterSentFiles || []));
} else { } else {
const sendElementsCreateReturn = await this.obContext.apis.MsgApi.createSendElements(OB11Data, msgPeer); const sendElementsCreateReturn = await this.obContext.apis.MsgApi.createSendElements(OB11Data, msgPeer);
sendElements = sendElementsCreateReturn.sendElements; sendElements = sendElementsCreateReturn.sendElements;
delFiles.push(...sendElementsCreateReturn.deleteAfterSentFiles);
} }
const packetMsgElements: rawMsgWithSendMsg = { const packetMsgElements: rawMsgWithSendMsg = {
@@ -225,8 +218,7 @@ export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> {
const msg = (await this.core.apis.MsgApi.getMsgsByMsgId(nodeMsg.Peer, [nodeMsg.MsgId])).msgList[0]; const msg = (await this.core.apis.MsgApi.getMsgsByMsgId(nodeMsg.Peer, [nodeMsg.MsgId])).msgList[0];
this.core.context.logger.logDebug(`handleForwardedNodesPacket[PureRaw] 开始转换 ${stringifyWithBigInt(msg)}`); this.core.context.logger.logDebug(`handleForwardedNodesPacket[PureRaw] 开始转换 ${stringifyWithBigInt(msg)}`);
if (msg) { if (msg) {
let msgCache = await this.core.apis.FileApi.downloadRawMsgMedia([msg]); await this.core.apis.FileApi.downloadRawMsgMedia([msg]);
delFiles.push(...msgCache);
const transformedMsg = this.core.apis.PacketApi.pkt.msgConverter.rawMsgToPacketMsg(msg, msgPeer); const transformedMsg = this.core.apis.PacketApi.pkt.msgConverter.rawMsgToPacketMsg(msg, msgPeer);
this.core.context.logger.logDebug(`handleForwardedNodesPacket[PureRaw] 转换为 ${stringifyWithBigInt(transformedMsg)}`); this.core.context.logger.logDebug(`handleForwardedNodesPacket[PureRaw] 转换为 ${stringifyWithBigInt(transformedMsg)}`);
packetMsg.push(transformedMsg); packetMsg.push(transformedMsg);
@@ -242,7 +234,6 @@ export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> {
const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsg(packetMsg, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0); const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsg(packetMsg, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0);
const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt); const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt);
return { return {
deleteAfterSentFiles: delFiles,
finallySendElements: { finallySendElements: {
elementType: ElementType.ARK, elementType: ElementType.ARK,
elementId: '', elementId: '',
@@ -264,7 +255,7 @@ export class SendMsgBase extends OneBotAction<OB11PostSendMsg, ReturnDataType> {
const res_id = uploadReturnData?.res_id; const res_id = uploadReturnData?.res_id;
const finallySendElements = uploadReturnData?.finallySendElements; const finallySendElements = uploadReturnData?.finallySendElements;
if (!finallySendElements) throw Error('转发消息失败,生成节点为空'); if (!finallySendElements) throw Error('转发消息失败,生成节点为空');
const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(msgPeer, [finallySendElements], uploadReturnData.deleteAfterSentFiles || []).catch(() => undefined); const returnMsg = await this.obContext.apis.MsgApi.sendMsgWithOb11UniqueId(msgPeer, [finallySendElements], []).catch(() => undefined);
return { message: returnMsg ?? null, res_id: res_id! }; return { message: returnMsg ?? null, res_id: res_id! };
} }

View File

@@ -1,18 +1,16 @@
import { ContextMode, ReturnDataType, SendMsgBase } from './SendMsg'; import { ContextMode, SendMsgBase } from './SendMsg';
import { ActionName, BaseCheckResult } from '@/onebot/action/router'; import { ActionName, BaseCheckResult } from '@/onebot/action/router';
import { OB11PostSendMsg } from '@/onebot/types'; import { OB11PostSendMsg } from '@/onebot/types';
// 未检测参数 // 未检测参数
class SendPrivateMsg extends SendMsgBase { class SendPrivateMsg extends SendMsgBase {
override actionName = ActionName.SendPrivateMsg; override actionName = ActionName.SendPrivateMsg;
override contextMode: ContextMode = ContextMode.Private;
protected override async check(payload: OB11PostSendMsg): Promise<BaseCheckResult> { protected override async check(payload: OB11PostSendMsg): Promise<BaseCheckResult> {
payload.message_type = 'private'; payload.message_type = 'private';
return super.check(payload); return super.check(payload);
} }
override async _handle(payload: OB11PostSendMsg): Promise<ReturnDataType> {
return this.base_handle(payload, ContextMode.Private);
}
} }
export default SendPrivateMsg; export default SendPrivateMsg;

View File

@@ -3,36 +3,21 @@ import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus';
import { Static, Type } from '@sinclair/typebox'; import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({ const SchemaData = Type.Object({
group_id: Type.Optional(Type.String()), group_id: Type.Optional(Type.Union([Type.Number(), Type.String()])),
user_id: Type.Optional(Type.String()), user_id: Type.Union([Type.Number(), Type.String()]),
target_id: Type.Optional(Type.String()),
}); });
type Payload = Static<typeof SchemaData>; type Payload = Static<typeof SchemaData>;
export class SendPokeBase extends GetPacketStatusDepends<Payload, void> {
export class SendPoke extends GetPacketStatusDepends<Payload, void> {
override actionName = ActionName.SendPoke;
override payloadSchema = SchemaData; override payloadSchema = SchemaData;
async _handle(payload: Payload) { async _handle(payload: Payload) {
// 这里的 !! 可以传入空字符串 忽略这些数据有利用接口统一接口 if (payload.group_id) {
const target_id = !!payload.target_id ? payload.target_id : payload.user_id; await this.core.apis.PacketApi.pkt.operation.GroupPoke(+payload.group_id, +payload.user_id);
const peer_id = !!payload.group_id ? payload.group_id : payload.user_id; } else {
await this.core.apis.PacketApi.pkt.operation.FriendPoke(+payload.user_id);
const is_group = !!payload.group_id;
if (!target_id || !peer_id) {
throw new Error('请检查参数,缺少 user_id 或 group_id');
} }
await this.core.apis.PacketApi.pkt.operation.SendPoke(is_group, +peer_id, +target_id);
} }
} }
export class SendPoke extends SendPokeBase {
override actionName = ActionName.SendPoke;
}
export class GroupPoke extends SendPokeBase {
override actionName = ActionName.GroupPoke;
}
export class FriendPoke extends SendPokeBase {
override actionName = ActionName.FriendPoke;
}

View File

@@ -10,10 +10,6 @@ export interface InvalidCheckResult {
} }
export const ActionName = { export const ActionName = {
SetGroupKickMembers: 'set_group_kick_members',
SetGroupRobotAddOption: 'set_group_robot_add_option',
SetGroupAddOption: 'set_group_add_option',
SetGroupSearch: 'set_group_search',
// new extends 完全差异OneBot类别 // new extends 完全差异OneBot类别
GetDoubtFriendsAddRequest: 'get_doubt_friends_add_request', GetDoubtFriendsAddRequest: 'get_doubt_friends_add_request',
SetDoubtFriendsAddRequest: 'set_doubt_friends_add_request', SetDoubtFriendsAddRequest: 'set_doubt_friends_add_request',
@@ -63,7 +59,7 @@ export const ActionName = {
GetStatus: 'get_status', GetStatus: 'get_status',
GetVersionInfo: 'get_version_info', GetVersionInfo: 'get_version_info',
// Reboot : 'set_restart', // Reboot : 'set_restart',
CleanCache: 'clean_cache', CleanCache : 'clean_cache',
Exit: 'bot_exit', Exit: 'bot_exit',
// go-cqhttp // go-cqhttp
SetQQProfile: 'set_qq_profile', SetQQProfile: 'set_qq_profile',
@@ -132,7 +128,6 @@ export const ActionName = {
FetchEmojiLike: 'fetch_emoji_like', FetchEmojiLike: 'fetch_emoji_like',
SetInputStatus: 'set_input_status', SetInputStatus: 'set_input_status',
GetGroupInfoEx: 'get_group_info_ex', GetGroupInfoEx: 'get_group_info_ex',
GetGroupDetailInfo: 'get_group_detail_info',
GetGroupIgnoreAddRequest: 'get_group_ignore_add_request', GetGroupIgnoreAddRequest: 'get_group_ignore_add_request',
DelGroupNotice: '_del_group_notice', DelGroupNotice: '_del_group_notice',
FriendPoke: 'friend_poke', FriendPoke: 'friend_poke',

View File

@@ -2,27 +2,18 @@ import { GroupNotifyMsgStatus } from '@/core';
import { OneBotAction } from '@/onebot/action/OneBotAction'; import { OneBotAction } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router'; import { ActionName } from '@/onebot/action/router';
import { Notify } from '@/onebot/types'; import { Notify } from '@/onebot/types';
import { Static, Type } from '@sinclair/typebox';
interface RetData { interface RetData {
invited_requests: Notify[];
InvitedRequest: Notify[]; InvitedRequest: Notify[];
join_requests: Notify[]; join_requests: Notify[];
} }
const SchemaData = Type.Object({ export class GetGroupSystemMsg extends OneBotAction<void, RetData> {
count: Type.Union([Type.Number(), Type.String()], { default: 50 }),
});
type Payload = Static<typeof SchemaData>;
export class GetGroupSystemMsg extends OneBotAction<Payload, RetData> {
override actionName = ActionName.GetGroupSystemMsg; override actionName = ActionName.GetGroupSystemMsg;
override payloadSchema = SchemaData;
async _handle(params: Payload): Promise<RetData> { async _handle(): Promise<RetData> {
const SingleScreenNotifies = await this.core.apis.GroupApi.getSingleScreenNotifies(false, +params.count); const SingleScreenNotifies = await this.core.apis.GroupApi.getSingleScreenNotifies(false, 50);
const retData: RetData = { invited_requests: [], InvitedRequest: [], join_requests: [] }; const retData: RetData = { InvitedRequest: [], join_requests: [] };
const notifyPromises = SingleScreenNotifies.map(async (SSNotify) => { const notifyPromises = SingleScreenNotifies.map(async (SSNotify) => {
const invitorUin = SSNotify.user1?.uid ? +await this.core.apis.UserApi.getUinByUidV2(SSNotify.user1.uid) : 0; const invitorUin = SSNotify.user1?.uid ? +await this.core.apis.UserApi.getUinByUidV2(SSNotify.user1.uid) : 0;
@@ -48,7 +39,6 @@ export class GetGroupSystemMsg extends OneBotAction<Payload, RetData> {
await Promise.all(notifyPromises); await Promise.all(notifyPromises);
retData.invited_requests = retData.InvitedRequest;
return retData; return retData;
} }
} }

View File

@@ -0,0 +1,18 @@
import { ActionName } from '@/onebot/action/router';
import { GetPacketStatusDepends } from '@/onebot/action/packet/GetPacketStatus';
import { Static, Type } from '@sinclair/typebox';
const SchemaData = Type.Object({
user_id: Type.Union([Type.Number(), Type.String()])
});
type Payload = Static<typeof SchemaData>;
export class FriendPoke extends GetPacketStatusDepends<Payload, void> {
override actionName = ActionName.FriendPoke;
override payloadSchema = SchemaData;
async _handle(payload: Payload) {
await this.core.apis.PacketApi.pkt.operation.FriendPoke(+payload.user_id);
}
}

View File

@@ -85,6 +85,9 @@ export class OneBotMsgApi {
textElement: async element => { textElement: async element => {
if (element.atType === NTMsgAtType.ATTYPEUNKNOWN) { if (element.atType === NTMsgAtType.ATTYPEUNKNOWN) {
let text = element.content; let text = element.content;
if (!text.trim()) {
return null;
}
// 兼容 9.7.x 换行符 // 兼容 9.7.x 换行符
if (text.indexOf('\n') === -1 && text.indexOf('\r\n') === -1) { if (text.indexOf('\n') === -1 && text.indexOf('\r\n') === -1) {
text = text.replace(/\r/g, '\n'); text = text.replace(/\r/g, '\n');
@@ -97,7 +100,7 @@ export class OneBotMsgApi {
let qq: string = 'all'; let qq: string = 'all';
if (element.atType !== NTMsgAtType.ATTYPEALL) { if (element.atType !== NTMsgAtType.ATTYPEALL) {
const { atNtUid, atUid } = element; const { atNtUid, atUid } = element;
qq = !atUid || atUid === '0' ? await this.core.apis.UserApi.getUinByUidV2(atNtUid) : String(Number(atUid) >>> 0); qq = !atUid || atUid === '0' ? await this.core.apis.UserApi.getUinByUidV2(atNtUid) : atUid;
} }
return { return {
type: OB11MessageDataType.at, type: OB11MessageDataType.at,
@@ -147,31 +150,12 @@ export class OneBotMsgApi {
}; };
FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileUuid); FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileUuid);
FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileName); FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileName);
if (this.core.apis.PacketApi.available) {
let url;
try {
url = await this.core.apis.FileApi.getFileUrl(msg.chatType, msg.peerUid, element.fileUuid, element.file10MMd5)
} catch (error) {
url = '';
}
if (url) {
return {
type: OB11MessageDataType.file,
data: {
file: element.fileName,
file_id: element.fileUuid,
file_size: element.fileSize,
url: url,
},
}
}
}
return { return {
type: OB11MessageDataType.file, type: OB11MessageDataType.file,
data: { data: {
file: element.fileName, file: element.fileName,
file_id: element.fileUuid, file_id: element.fileUuid,
file_size: element.fileSize file_size: element.fileSize,
}, },
}; };
}, },
@@ -241,13 +225,17 @@ export class OneBotMsgApi {
}, },
replyElement: async (element, msg) => { replyElement: async (element, msg) => {
const records = msg.records.find(msgRecord => msgRecord.msgId === element?.sourceMsgIdInRecords);
const peer = { const peer = {
chatType: msg.chatType, chatType: msg.chatType,
peerUid: msg.peerUid, peerUid: msg.peerUid,
guildId: '', guildId: '',
}; };
if (!records || !element.replyMsgTime || !element.senderUidStr) {
this.core.context.logger.logError('似乎是旧版客户端,获取不到引用的消息', element.replayMsgSeq);
return null;
}
// 创建回复数据的通用方法
const createReplyData = (msgId: string): OB11MessageData => ({ const createReplyData = (msgId: string): OB11MessageData => ({
type: OB11MessageDataType.reply, type: OB11MessageDataType.reply,
data: { data: {
@@ -255,96 +243,48 @@ export class OneBotMsgApi {
}, },
}); });
// 查找记录 if (records.peerUin === '284840486' || records.peerUin === '1094950020') {
const records = msg.records.find(msgRecord => msgRecord.msgId === element?.sourceMsgIdInRecords);
// 特定账号的特殊处理
if (records && (records.peerUin === '284840486' || records.peerUin === '1094950020')) {
return createReplyData(records.msgId); return createReplyData(records.msgId);
} }
let replyMsgList = (await this.core.apis.MsgApi.queryMsgsWithFilterExWithSeqV2(peer, element.replayMsgSeq, records.msgTime, [element.senderUidStr])).msgList;
let replyMsg = replyMsgList.find(msg => msg.msgRandom === records.msgRandom);
// 获取消息的通用方法组 if (!replyMsg || records.msgRandom !== replyMsg.msgRandom) {
const tryFetchMethods = async (msgSeq: string, senderUid?: string, msgTime?: string, msgRandom?: string): Promise<RawMessage | undefined> => { this.core.context.logger.logError(
try { '筛选结果,筛选消息失败,将使用Fallback-1 Seq: ',
// 方法1通过序号和时间筛选
if (senderUid && msgTime) {
const replyMsgList = (await this.core.apis.MsgApi.queryMsgsWithFilterExWithSeqV2(
peer, msgSeq, msgTime, [senderUid]
)).msgList;
const replyMsg = msgRandom
? replyMsgList.find(msg => msg.msgRandom === msgRandom)
: replyMsgList.find(msg => msg.msgSeq === msgSeq);
if (replyMsg) return replyMsg;
this.core.context.logger.logWarn(`方法1查询失败序号: ${msgSeq}, 消息数: ${replyMsgList.length}`);
}
// 方法2直接通过序号获取
const replyMsgList = (await this.core.apis.MsgApi.getMsgsBySeqAndCount(
peer, msgSeq, 1, true, true
)).msgList;
const replyMsg = msgRandom
? replyMsgList.find(msg => msg.msgRandom === msgRandom)
: replyMsgList.find(msg => msg.msgSeq === msgSeq);
if (replyMsg) return replyMsg;
this.core.context.logger.logWarn(`方法2查询失败序号: ${msgSeq}, 消息数: ${replyMsgList.length}`);
// 方法3另一种筛选方式
if (senderUid) {
const replyMsgList = (await this.core.apis.MsgApi.queryMsgsWithFilterExWithSeqV3(
peer, msgSeq, [senderUid]
)).msgList;
const replyMsg = msgRandom
? replyMsgList.find(msg => msg.msgRandom === msgRandom)
: replyMsgList.find(msg => msg.msgSeq === msgSeq);
if (replyMsg) return replyMsg;
this.core.context.logger.logWarn(`方法3查询失败序号: ${msgSeq}, 消息数: ${replyMsgList.length}`);
}
return undefined;
} catch (error) {
this.core.context.logger.logError('查询回复消息出错', error);
return undefined;
}
};
// 有记录情况下,使用完整信息查询
if (records && element.replyMsgTime && element.senderUidStr) {
const replyMsg = await tryFetchMethods(
element.replayMsgSeq, element.replayMsgSeq,
element.senderUidStr, ',消息长度:',
records.msgTime, replyMsgList.length
records.msgRandom
); );
replyMsgList = (await this.core.apis.MsgApi.getMsgsBySeqAndCount(peer, element.replayMsgSeq, 1, true, true)).msgList;
if (replyMsg) { replyMsg = replyMsgList.find(msg => msg.msgRandom === records.msgRandom);
return createReplyData(replyMsg.msgId);
}
this.core.context.logger.logError('所有查找方法均失败,获取不到带记录的引用消息', element.replayMsgSeq);
} else {
// 旧版客户端或不完整记录的情况,也尝试使用相同流程
this.core.context.logger.logWarn('似乎是旧版客户端,尝试仅通过序号获取引用消息', element.replayMsgSeq);
const replyMsg = await tryFetchMethods(element.replayMsgSeq);
if (replyMsg) {
return createReplyData(replyMsg.msgId);
}
this.core.context.logger.logError('所有查找方法均失败,获取不到旧客户端的引用消息', element.replayMsgSeq);
} }
return null; if (!replyMsg || records.msgRandom !== replyMsg.msgRandom) {
this.core.context.logger.logWarn(
'筛选消息失败,将使用Fallback-2 Seq:',
element.replayMsgSeq,
',消息长度:',
replyMsgList.length
);
replyMsgList = (await this.core.apis.MsgApi.queryMsgsWithFilterExWithSeqV3(peer, element.replayMsgSeq, [element.senderUidStr])).msgList;
replyMsg = replyMsgList.find(msg => msg.msgRandom === records.msgRandom);
}
// 丢弃该消息段
if (!replyMsg || records.msgRandom !== replyMsg.msgRandom) {
this.core.context.logger.logError(
'最终筛选结果,筛选消息失败,获取不到引用的消息 Seq: ',
element.replayMsgSeq,
',消息长度:',
replyMsgList.length
);
return null;
}
return createReplyData(replyMsg.msgId);
}, },
videoElement: async (element, msg, elementWrapper) => { videoElement: async (element, msg, elementWrapper) => {
const peer = { const peer = {
chatType: msg.chatType, chatType: msg.chatType,
@@ -391,17 +331,7 @@ export class OneBotMsgApi {
//开始兜底 //开始兜底
if (!videoDownUrl) { if (!videoDownUrl) {
if (this.core.apis.PacketApi.available) { videoDownUrl = element.filePath;
try {
videoDownUrl = await this.core.apis.FileApi.getVideoUrlPacket(msg.peerUid, element.fileUuid);
} catch (e) {
this.core.context.logger.logError('获取视频url失败', (e as Error).stack);
videoDownUrl = element.filePath;
}
} else {
videoDownUrl = element.filePath;
}
} }
const fileCode = FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileName); const fileCode = FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileName);
return { return {
@@ -421,28 +351,6 @@ export class OneBotMsgApi {
guildId: '', guildId: '',
}; };
const fileCode = FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, '', element.fileName); const fileCode = FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, '', element.fileName);
let pttUrl = '';
if (this.core.apis.PacketApi.available) {
try {
pttUrl = await this.core.apis.FileApi.getPttUrl(msg.peerUid, element.fileUuid);
} catch (e) {
this.core.context.logger.logError('获取语音url失败', (e as Error).stack);
pttUrl = element.filePath;
}
} else {
pttUrl = element.filePath;
}
if (pttUrl) {
return {
type: OB11MessageDataType.voice,
data: {
file: fileCode,
path: element.filePath,
url: pttUrl,
file_size: element.fileSize,
},
}
}
return { return {
type: OB11MessageDataType.voice, type: OB11MessageDataType.voice,
data: { data: {
@@ -1209,6 +1117,7 @@ export class OneBotMsgApi {
async waitGroupNotify(groupUin: string, memberUid?: string, operatorUid?: string) { async waitGroupNotify(groupUin: string, memberUid?: string, operatorUid?: string) {
const groupRole = this.core.apis.GroupApi.groupMemberCache.get(groupUin)?.get(this.core.selfInfo.uid.toString())?.role; const groupRole = this.core.apis.GroupApi.groupMemberCache.get(groupUin)?.get(this.core.selfInfo.uid.toString())?.role;
const isAdminOrOwner = groupRole === 3 || groupRole === 4; const isAdminOrOwner = groupRole === 3 || groupRole === 4;
if (isAdminOrOwner && !operatorUid) { if (isAdminOrOwner && !operatorUid) {
let dataNotify: GroupNotify | undefined; let dataNotify: GroupNotify | undefined;
await this.core.eventWrapper.registerListen('NodeIKernelGroupListener/onGroupNotifiesUpdated', await this.core.eventWrapper.registerListen('NodeIKernelGroupListener/onGroupNotifiesUpdated',
@@ -1238,7 +1147,7 @@ export class OneBotMsgApi {
const operatorUid = await this.waitGroupNotify( const operatorUid = await this.waitGroupNotify(
groupChange.groupUin.toString(), groupChange.groupUin.toString(),
groupChange.memberUid, groupChange.memberUid,
groupChange.operatorInfo ? new TextDecoder('utf-8').decode(groupChange.operatorInfo) : undefined groupChange.operatorInfo ? Buffer.from(groupChange.operatorInfo).toString() : ''
); );
return new OB11GroupIncreaseEvent( return new OB11GroupIncreaseEvent(
this.core, this.core,
@@ -1250,42 +1159,13 @@ export class OneBotMsgApi {
} else if (SysMessage.contentHead.type == 34 && SysMessage.body?.msgContent) { } else if (SysMessage.contentHead.type == 34 && SysMessage.body?.msgContent) {
const groupChange = new NapProtoMsg(GroupChange).decode(SysMessage.body.msgContent); const groupChange = new NapProtoMsg(GroupChange).decode(SysMessage.body.msgContent);
// 自身被踢出时operatorInfo会是一个protobuf 否则大多数情况为一个string
let operator_uid_parse: string | undefined = undefined;
if (groupChange.operatorInfo) {
// 先判断是否可能是protobuf自身被踢出或以0a开头
if (groupChange.decreaseType === 3 || Buffer.from(groupChange.operatorInfo).toString('hex').startsWith('0a')) {
// 可能是protobuf尝试解析
try {
operator_uid_parse = new NapProtoMsg(GroupChangeInfo).decode(groupChange.operatorInfo).operator?.operatorUid;
} catch (error) {
// protobuf解析失败fallback到字符串解析
try {
const decoded = new TextDecoder('utf-8').decode(groupChange.operatorInfo);
// 检查是否包含非ASCII字符如果包含则丢弃
const isAsciiOnly = [...decoded].every(char => char.charCodeAt(0) >= 32 && char.charCodeAt(0) <= 126);
operator_uid_parse = isAsciiOnly ? decoded : '';
} catch (e2) {
operator_uid_parse = '';
}
}
} else {
// 直接进行字符串解析
try {
const decoded = new TextDecoder('utf-8').decode(groupChange.operatorInfo);
// 检查是否包含非ASCII字符如果包含则丢弃
const isAsciiOnly = [...decoded].every(char => char.charCodeAt(0) >= 32 && char.charCodeAt(0) <= 126);
operator_uid_parse = isAsciiOnly ? decoded : '';
} catch (e) {
operator_uid_parse = '';
}
}
}
const operatorUid = await this.waitGroupNotify( const operatorUid = await this.waitGroupNotify(
groupChange.groupUin.toString(), groupChange.groupUin.toString(),
groupChange.memberUid, groupChange.memberUid,
operator_uid_parse groupChange.decreaseType === 3 && groupChange.operatorInfo ?
new NapProtoMsg(GroupChangeInfo).decode(groupChange.operatorInfo).operator?.operatorUid :
groupChange.operatorInfo?.toString()
); );
if (groupChange.memberUid === this.core.selfInfo.uid) { if (groupChange.memberUid === this.core.selfInfo.uid) {
setTimeout(() => { setTimeout(() => {

View File

@@ -2,5 +2,4 @@ import { EventType, OneBotEvent } from '@/onebot/event/OneBotEvent';
export abstract class OB11BaseNoticeEvent extends OneBotEvent { export abstract class OB11BaseNoticeEvent extends OneBotEvent {
post_type = EventType.NOTICE; post_type = EventType.NOTICE;
abstract notice_type: string; }
}

View File

@@ -1,6 +0,0 @@
import { EventType, OneBotEvent } from '@/onebot/event/OneBotEvent';
export abstract class OB11BaseRequestEvent extends OneBotEvent {
readonly post_type = EventType.REQUEST;
abstract request_type: string;
}

View File

@@ -1,8 +1,10 @@
import { OB11BaseNoticeEvent } from '@/onebot/event/notice/OB11BaseNoticeEvent';
import { EventType } from '@/onebot/event/OneBotEvent';
import { NapCatCore } from '@/core'; import { NapCatCore } from '@/core';
import { OB11BaseRequestEvent } from './OB11BaseRequestEvent';
export class OB11FriendRequestEvent extends OB11BaseRequestEvent { export class OB11FriendRequestEvent extends OB11BaseNoticeEvent {
override request_type = 'friend'; override post_type = EventType.REQUEST;
request_type = 'friend';
user_id: number; user_id: number;
comment: string; comment: string;

View File

@@ -1,18 +1,18 @@
import { OB11GroupNoticeEvent } from '@/onebot/event/notice/OB11GroupNoticeEvent';
import { EventType } from '@/onebot/event/OneBotEvent';
import { NapCatCore } from '@/core'; import { NapCatCore } from '@/core';
import { OB11BaseRequestEvent } from './OB11BaseRequestEvent';
export class OB11GroupRequestEvent extends OB11BaseRequestEvent { export class OB11GroupRequestEvent extends OB11GroupNoticeEvent {
override readonly request_type = 'group' as const; override post_type = EventType.REQUEST;
request_type = 'group';
group_id: number; override user_id: number;
user_id: number;
comment: string; comment: string;
flag: string; flag: string;
sub_type: string; sub_type: string;
constructor(core: NapCatCore, groupId: number, userId: number, sub_type: string, comment: string, flag: string) { constructor(core: NapCatCore, groupId: number, userId: number, sub_type: string, comment: string, flag: string) {
super(core); super(core, groupId, userId);
this.group_id = groupId;
this.user_id = userId; this.user_id = userId;
this.sub_type = sub_type; this.sub_type = sub_type;
this.comment = comment; this.comment = comment;

View File

@@ -50,6 +50,7 @@ import {
import { OB11Message } from './types'; import { OB11Message } from './types';
import { IOB11NetworkAdapter } from '@/onebot/network/adapter'; import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
import { OB11HttpSSEServerAdapter } from './network/http-server-sse'; import { OB11HttpSSEServerAdapter } from './network/http-server-sse';
import { OB11PluginAdapter } from './network/plugin';
//OneBot实现类 //OneBot实现类
export class NapCatOneBot11Adapter { export class NapCatOneBot11Adapter {
@@ -113,9 +114,9 @@ export class NapCatOneBot11Adapter {
//创建NetWork服务 //创建NetWork服务
// 注册Plugin 如果需要基于NapCat进行快速开发 // 注册Plugin 如果需要基于NapCat进行快速开发
// this.networkManager.registerAdapter( this.networkManager.registerAdapter(
// new OB11PluginAdapter('myPlugin', this.core, this,this.actions) new OB11PluginAdapter('myPlugin', this.core, this, this.actions)
// ); );
for (const key of ob11Config.network.httpServers) { for (const key of ob11Config.network.httpServers) {
if (key.enable) { if (key.enable) {
this.networkManager.registerAdapter( this.networkManager.registerAdapter(
@@ -270,6 +271,7 @@ export class NapCatOneBot11Adapter {
); );
} }
}; };
msgListener.onAddSendMsg = async (msg) => { msgListener.onAddSendMsg = async (msg) => {
try { try {
if (msg.sendStatus == SendStatusType.KSEND_STATUS_SENDING) { if (msg.sendStatus == SendStatusType.KSEND_STATUS_SENDING) {
@@ -281,8 +283,7 @@ export class NapCatOneBot11Adapter {
}, 1, 10 * 60 * 1000); }, 1, 10 * 60 * 1000);
// 10分钟 超时 // 10分钟 超时
const updatemsg = updatemsgs.find((e) => e.msgId === msg.msgId); const updatemsg = updatemsgs.find((e) => e.msgId === msg.msgId);
// updatemsg?.sendStatus == SendStatusType.KSEND_STATUS_SUCCESS_NOSEQ NOSEQ一般是服务器未下发SEQ 这意味着这条消息不应该推送network if (updatemsg?.sendStatus == SendStatusType.KSEND_STATUS_SUCCESS || updatemsg?.sendStatus == SendStatusType.KSEND_STATUS_SUCCESS_NOSEQ) {
if (updatemsg?.sendStatus == SendStatusType.KSEND_STATUS_SUCCESS) {
updatemsg.id = MessageUnique.createUniqueMsgId( updatemsg.id = MessageUnique.createUniqueMsgId(
{ {
chatType: updatemsg.chatType, chatType: updatemsg.chatType,
@@ -304,18 +305,8 @@ export class NapCatOneBot11Adapter {
peerUid: uid, peerUid: uid,
guildId: '' guildId: ''
}; };
let msg = (await this.core.apis.MsgApi.queryMsgsWithFilterExWithSeq(peer, msgSeq)).msgList.find(e => e.msgType == NTMsgType.KMSGTYPEGRAYTIPS); const msg = (await this.core.apis.MsgApi.queryMsgsWithFilterExWithSeq(peer, msgSeq)).msgList.find(e => e.msgType == NTMsgType.KMSGTYPEGRAYTIPS);
const element = msg?.elements.find(e => !!e.grayTipElement?.revokeElement); const element = msg?.elements.find(e => !!e.grayTipElement?.revokeElement);
if (element?.grayTipElement?.revokeElement.isSelfOperate && msg) {
await this.core.eventWrapper.registerListen('NodeIKernelMsgListener/onMsgRecall',
(chatType: ChatType, uid: string, msgSeq: string) => {
return chatType === msg?.chatType && uid === msg?.peerUid && msgSeq === msg?.msgSeq;
}
).catch(() => {
msg = undefined;
this.context.logger.logDebug('自操作消息撤回事件');
});
}
if (msg && element) { if (msg && element) {
const recallEvent = await this.emitRecallMsg(msg, element); const recallEvent = await this.emitRecallMsg(msg, element);
try { try {
@@ -326,7 +317,6 @@ export class NapCatOneBot11Adapter {
this.context.logger.logError('处理消息撤回失败', e); this.context.logger.logError('处理消息撤回失败', e);
} }
} }
}; };
msgListener.onKickedOffLine = async (kick) => { msgListener.onKickedOffLine = async (kick) => {
const event = new BotOfflineEvent(this.core, kick.tipsTitle, kick.tipsDesc); const event = new BotOfflineEvent(this.core, kick.tipsTitle, kick.tipsDesc);
@@ -345,7 +335,7 @@ export class NapCatOneBot11Adapter {
for (let i = 0; i < reqs.unreadNums; i++) { for (let i = 0; i < reqs.unreadNums; i++) {
const req = reqs.buddyReqs[i]; const req = reqs.buddyReqs[i];
if (!req) continue; if (!req) continue;
if (!!req.isInitiator || (req.isDecide && req.reqType !== BuddyReqType.KMEINITIATORWAITPEERCONFIRM) || !req.isUnread) { if (!!req.isInitiator || (req.isDecide && req.reqType !== BuddyReqType.KMEINITIATORWAITPEERCONFIRM)) {
continue; continue;
} }
try { try {
@@ -363,6 +353,7 @@ export class NapCatOneBot11Adapter {
} }
} }
}; };
this.context.session this.context.session
.getBuddyService() .getBuddyService()
.addKernelBuddyListener(proxiedListenerOf(buddyListener, this.context.logger)); .addKernelBuddyListener(proxiedListenerOf(buddyListener, this.context.logger));

View File

@@ -87,8 +87,8 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
this.app.use(async (req, res) => { this.app.use(async (req, res) => {
await this.handleRequest(req, res); await this.handleRequest(req, res);
}); });
this.server.listen(this.config.port, this.config.host, () => { this.server.listen(this.config.port, () => {
this.core.context.logger.log(`[OneBot] [HTTP Server Adapter] Start On ${this.config.host}:${this.config.port}`); this.core.context.logger.log(`[OneBot] [HTTP Server Adapter] Start On Port ${this.config.port}`);
}); });
} }

View File

@@ -15,14 +15,14 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
messagePostFormat: 'array', messagePostFormat: 'array',
reportSelfMessage: false, reportSelfMessage: false,
enable: true, enable: true,
debug: false, debug: true,
}; };
super(name, config, core, obContext, actions); super(name, config, core, obContext, actions);
} }
onEvent<T extends OB11EmitEventContent>(event: T) { onEvent<T extends OB11EmitEventContent>(event: T) {
if (event.post_type === 'message') { if (event.post_type === 'message') {
plugin_onmessage(this.config.name, this.core, this.obContext, event as OB11Message, this.actions, this).then().catch(); plugin_onmessage(this.config.name, this.core, this.obContext, event as OB11Message, this.actions, this).then().catch(console.log);
} }
} }

View File

@@ -39,11 +39,8 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
wsClient.close(); wsClient.close();
return; return;
} }
// 鉴权 close 不会立刻销毁 当前返回可避免挂载message事件 close 并未立刻关闭 而是存在timer操作后关闭 //鉴权
// 引发高危漏洞 this.authorize(this.config.token, wsClient, wsReq);
if (!this.authorize(this.config.token, wsClient, wsReq)) {
return;
}
const paramUrl = wsReq.url?.indexOf('?') !== -1 ? wsReq.url?.substring(0, wsReq.url?.indexOf('?')) : wsReq.url; const paramUrl = wsReq.url?.indexOf('?') !== -1 ? wsReq.url?.substring(0, wsReq.url?.indexOf('?')) : wsReq.url;
const isApiConnect = paramUrl === '/api' || paramUrl === '/api/'; const isApiConnect = paramUrl === '/api' || paramUrl === '/api/';
if (!isApiConnect) { if (!isApiConnect) {
@@ -148,16 +145,15 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
} }
private authorize(token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) { private authorize(token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) {
if (!token || token.length == 0) return true;//客户端未设置密钥 if (!token || token.length == 0) return;//客户端未设置密钥
const QueryClientToken = urlParse.parse(wsReq?.url || '', true).query['access_token']; const QueryClientToken = urlParse.parse(wsReq?.url || '', true).query['access_token'];
const HeaderClientToken = wsReq.headers.authorization?.split('Bearer ').pop() || ''; const HeaderClientToken = wsReq.headers.authorization?.split('Bearer ').pop() || '';
const ClientToken = typeof (QueryClientToken) === 'string' && QueryClientToken !== '' ? QueryClientToken : HeaderClientToken; const ClientToken = typeof (QueryClientToken) === 'string' && QueryClientToken !== '' ? QueryClientToken : HeaderClientToken;
if (ClientToken === token) { if (ClientToken === token) {
return true; return;
} }
wsClient.send(JSON.stringify(OB11Response.res(null, 'failed', 1403, 'token验证失败'))); wsClient.send(JSON.stringify(OB11Response.res(null, 'failed', 1403, 'token验证失败')));
wsClient.close(); wsClient.close();
return false;
} }
private checkStateAndReply<T>(data: T, wsClient: WebSocket) { private checkStateAndReply<T>(data: T, wsClient: WebSocket) {

View File

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

4
src/plugin/data.ts Normal file
View 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
View 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');
}

File diff suppressed because it is too large Load Diff

432
src/plugin/network.ts Normal file
View 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
View 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
View 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');
}

View File

@@ -1,2 +1,10 @@
import { NCoreInitShell } from './base'; 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(); NCoreInitShell();

View File

@@ -1,7 +1,6 @@
import { LogWrapper } from '@/common/log'; import { LogWrapper } from '@/common/log';
import * as net from 'net'; import * as net from 'net';
import * as process from 'process'; import * as process from 'process';
import { Writable } from 'stream';
/** /**
* 连接到命名管道并重定向stdout * 连接到命名管道并重定向stdout
@@ -12,6 +11,7 @@ import { Writable } from 'stream';
export function connectToNamedPipe(logger: LogWrapper, timeoutMs: number = 5000): Promise<{ disconnect: () => void }> { export function connectToNamedPipe(logger: LogWrapper, timeoutMs: number = 5000): Promise<{ disconnect: () => void }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
logger.log('只有Windows平台支持命名管道');
// 非Windows平台不reject而是返回一个空的disconnect函数 // 非Windows平台不reject而是返回一个空的disconnect函数
return resolve({ disconnect: () => { } }); return resolve({ disconnect: () => { } });
} }
@@ -25,50 +25,12 @@ export function connectToNamedPipe(logger: LogWrapper, timeoutMs: number = 5000)
}, timeoutMs); }, timeoutMs);
try { try {
const originalStdoutWrite = process.stdout.write.bind(process.stdout); let originalStdoutWrite = process.stdout.write.bind(process.stdout);
const pipeSocket = net.connect(pipePath, () => { const pipeSocket = net.connect(pipePath, () => {
// 清除超时 // 清除超时
clearTimeout(timeoutId); clearTimeout(timeoutId);
// 优化网络性能设置
pipeSocket.setNoDelay(true); // 减少延迟
// 设置更高的高水位线,允许更多数据缓冲
logger.log(`[StdOut] 已重定向到命名管道: ${pipePath}`); logger.log(`[StdOut] 已重定向到命名管道: ${pipePath}`);
// 创建拥有更优雅背压处理的 Writable 流
const pipeWritable = new Writable({
highWaterMark: 1024 * 64, // 64KB 高水位线
write(chunk, encoding, callback) {
if (!pipeSocket.writable) {
// 如果管道不可写退回到原始stdout
logger.log('[StdOut] 管道不可写,回退到控制台输出');
return originalStdoutWrite(chunk, encoding, callback);
}
// 尝试写入数据到管道
const canContinue = pipeSocket.write(chunk, encoding, () => {
// 数据已被发送或放入内部缓冲区
});
if (canContinue) {
// 如果返回true表示可以继续写入更多数据
// 立即通知写入流可以继续
process.nextTick(callback);
} else {
// 如果返回false表示内部缓冲区已满
// 等待drain事件再恢复写入
pipeSocket.once('drain', () => {
callback();
});
}
// 明确返回true表示写入已处理
return true;
}
});
// 重定向stdout
process.stdout.write = ( process.stdout.write = (
chunk: any, chunk: any,
encoding?: BufferEncoding | (() => void), encoding?: BufferEncoding | (() => void),
@@ -78,11 +40,8 @@ export function connectToNamedPipe(logger: LogWrapper, timeoutMs: number = 5000)
cb = encoding; cb = encoding;
encoding = undefined; encoding = undefined;
} }
return pipeSocket.write(chunk, encoding as BufferEncoding, cb);
// 使用优化的writable流处理写入
return pipeWritable.write(chunk, encoding as BufferEncoding, cb as () => void);
}; };
// 提供断开连接的方法 // 提供断开连接的方法
const disconnect = () => { const disconnect = () => {
process.stdout.write = originalStdoutWrite; process.stdout.write = originalStdoutWrite;
@@ -94,7 +53,6 @@ export function connectToNamedPipe(logger: LogWrapper, timeoutMs: number = 5000)
resolve({ disconnect }); resolve({ disconnect });
}); });
// 管道错误处理
pipeSocket.on('error', (err) => { pipeSocket.on('error', (err) => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
process.stdout.write = originalStdoutWrite; process.stdout.write = originalStdoutWrite;
@@ -102,18 +60,11 @@ export function connectToNamedPipe(logger: LogWrapper, timeoutMs: number = 5000)
reject(err); reject(err);
}); });
// 管道关闭处理
pipeSocket.on('end', () => { pipeSocket.on('end', () => {
process.stdout.write = originalStdoutWrite; process.stdout.write = originalStdoutWrite;
logger.log('命名管道连接已关闭'); logger.log('命名管道连接已关闭');
}); });
// 确保在连接意外关闭时恢复stdout
pipeSocket.on('close', () => {
process.stdout.write = originalStdoutWrite;
logger.log('命名管道连接已关闭');
});
} catch (error) { } catch (error) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
logger.log(`尝试连接命名管道 ${pipePath} 时发生异常:`, error); logger.log(`尝试连接命名管道 ${pipePath} 时发生异常:`, error);

View File

@@ -122,9 +122,7 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
// ------------挂载路由------------ // ------------挂载路由------------
// 挂载静态路由(前端),路径为 /webui // 挂载静态路由(前端),路径为 /webui
app.use('/webui', express.static(pathWrapper.staticPath, { app.use('/webui', express.static(pathWrapper.staticPath));
maxAge: '1d'
}));
// 初始化WebSocket服务器 // 初始化WebSocket服务器
const sslCerts = await checkCertificates(logger); const sslCerts = await checkCertificates(logger);
const isHttps = !!sslCerts; const isHttps = !!sslCerts;

View File

@@ -47,9 +47,6 @@ export const CreateTerminalHandler: RequestHandler = async (req, res) => {
if (isMacOS) { if (isMacOS) {
return sendError(res, 'MacOS不支持终端'); return sendError(res, 'MacOS不支持终端');
} }
if ((await WebUiConfig.GetWebUIConfig()).token === 'napcat') {
return sendError(res, '默认密码禁止创建终端');
}
try { try {
const { cols, rows } = req.body; const { cols, rows } = req.body;
const { id } = terminalManager.createTerminal(cols, rows); const { id } = terminalManager.createTerminal(cols, rows);

View File

@@ -9,7 +9,7 @@ Object.defineProperty(global, '__dirname', {
// 注意:堆栈格式可能不同,请根据实际环境调整索引及正则表达式 // 注意:堆栈格式可能不同,请根据实际环境调整索引及正则表达式
for (const line of stack) { for (const line of stack) {
const match = line.match(/\((.*):\d+:\d+\)/); const match = line.match(/\((.*):\d+:\d+\)/);
if (match?.[1]) { if (match) {
callerFile = match[1]; callerFile = match[1];
if (!callerFile.includes('init-dynamic-dirname.ts')) { if (!callerFile.includes('init-dynamic-dirname.ts')) {
break; break;

View File

@@ -1,13 +1,13 @@
import multer from 'multer'; import multer from 'multer';
import { WebUiConfigWrapper } from '../helper/config';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { WebUiConfig } from '@/webui';
export const webUIFontStorage = multer.diskStorage({ export const webUIFontStorage = multer.diskStorage({
destination: (_, __, cb) => { destination: (_, __, cb) => {
try { try {
const fontsPath = path.dirname(WebUiConfig.GetWebUIFontPath()); const fontsPath = path.dirname(WebUiConfigWrapper.GetWebUIFontPath());
// 确保字体目录存在 // 确保字体目录存在
fs.mkdirSync(fontsPath, { recursive: true }); fs.mkdirSync(fontsPath, { recursive: true });
cb(null, fontsPath); cb(null, fontsPath);

View File

@@ -7,7 +7,10 @@ import { builtinModules } from 'module';
const external = [ const external = [
'silk-wasm', 'silk-wasm',
'ws', 'ws',
'express' 'express',
'@napi-rs/canvas',
'@node-rs/jieba',
'@node-rs/jieba/dict.js',
]; ];
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat(); const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
@@ -23,6 +26,7 @@ if (process.env.NAPCAT_BUILDSYS == 'linux') {
const UniversalBaseConfigPlugin: PluginOption[] = [ const UniversalBaseConfigPlugin: PluginOption[] = [
cp({ cp({
targets: [ targets: [
{ src: './external/fonts', dest: 'dist/fonts' },
{ src: './manifest.json', dest: 'dist' }, { src: './manifest.json', dest: 'dist' },
{ src: './src/core/external/napcat.json', dest: 'dist/config/' }, { src: './src/core/external/napcat.json', dest: 'dist/config/' },
{ src: './src/native/packet', dest: 'dist/moehoo', flatten: false }, { src: './src/native/packet', dest: 'dist/moehoo', flatten: false },
@@ -46,6 +50,7 @@ const UniversalBaseConfigPlugin: PluginOption[] = [
const FrameworkBaseConfigPlugin: PluginOption[] = [ const FrameworkBaseConfigPlugin: PluginOption[] = [
cp({ cp({
targets: [ targets: [
{ src: './external/fonts', dest: 'dist/fonts' },
{ src: './manifest.json', dest: 'dist' }, { src: './manifest.json', dest: 'dist' },
{ src: './src/core/external/napcat.json', dest: 'dist/config/' }, { src: './src/core/external/napcat.json', dest: 'dist/config/' },
{ src: './src/native/packet', dest: 'dist/moehoo', flatten: false }, { src: './src/native/packet', dest: 'dist/moehoo', flatten: false },
@@ -53,7 +58,6 @@ const FrameworkBaseConfigPlugin: PluginOption[] = [
{ src: './napcat.webui/dist/', dest: 'dist/static/', flatten: false }, { src: './napcat.webui/dist/', dest: 'dist/static/', flatten: false },
{ src: './src/framework/liteloader.cjs', dest: 'dist' }, { src: './src/framework/liteloader.cjs', dest: 'dist' },
{ src: './src/framework/napcat.cjs', dest: 'dist' }, { src: './src/framework/napcat.cjs', dest: 'dist' },
{ src: './src/framework/nativeLoader.cjs', dest: 'dist' },
{ src: './src/framework/preload.cjs', dest: 'dist' }, { src: './src/framework/preload.cjs', dest: 'dist' },
{ src: './src/framework/renderer.js', dest: 'dist' }, { src: './src/framework/renderer.js', dest: 'dist' },
{ src: './package.json', dest: 'dist' }, { src: './package.json', dest: 'dist' },
@@ -66,6 +70,7 @@ const FrameworkBaseConfigPlugin: PluginOption[] = [
const ShellBaseConfigPlugin: PluginOption[] = [ const ShellBaseConfigPlugin: PluginOption[] = [
cp({ cp({
targets: [ targets: [
{ src: './external/fonts', dest: 'dist/fonts' },
{ src: './src/native/packet', dest: 'dist/moehoo', flatten: false }, { src: './src/native/packet', dest: 'dist/moehoo', flatten: false },
{ src: './src/native/pty', dest: 'dist/pty', flatten: false }, { src: './src/native/pty', dest: 'dist/pty', flatten: false },
{ src: './napcat.webui/dist/', dest: 'dist/static/', flatten: false }, { src: './napcat.webui/dist/', dest: 'dist/static/', flatten: false },