Compare commits

...

50 Commits

Author SHA1 Message Date
手瓜一十雪
197eec40ad fix 2025-02-03 15:05:12 +08:00
手瓜一十雪
b72156866d fix: broken pipe 2025-02-03 14:50:11 +08:00
手瓜一十雪
59a7d12a8c style: lint 2025-02-03 14:29:38 +08:00
手瓜一十雪
179351b23a Merge pull request #754 from NapNeko/qrcode-refactor
fix: thumb残留
2025-02-03 14:11:22 +08:00
手瓜一十雪
790809e8e5 fix: thumb残留 2025-02-03 14:09:51 +08:00
手瓜一十雪
1414a8a8c9 Merge pull request #753 from NapNeko/qrcode-refactor
refactor: qrcode to ts
2025-02-03 14:02:48 +08:00
bietiaop
9ab41734a5 fix: piscina src 2025-02-03 13:33:18 +08:00
手瓜一十雪
03cace2ea1 优化依赖 2025-02-03 13:07:05 +08:00
手瓜一十雪
c7371ab869 refactor: qrcode to ts 2025-02-03 12:51:50 +08:00
手瓜一十雪
b32d4b618c fix: 必须清理并回收 2025-02-03 11:40:58 +08:00
手瓜一十雪
3a27f37686 fix: pcm清理 2025-02-03 11:37:39 +08:00
手瓜一十雪
fe2d21979d Merge pull request #749 from NapNeko/type-force
style: Type force
2025-02-03 11:13:55 +08:00
手瓜一十雪
48b1f3d4f0 Merge branch 'main' into type-force 2025-02-03 11:13:46 +08:00
手瓜一十雪
93ed589ac7 Merge pull request #745 from NapNeko/dev-terminal
feat: 文件管理 & 系统终端
2025-02-03 11:13:14 +08:00
手瓜一十雪
96de9e2c16 style: 简化loader 避免全局error 2025-02-03 10:40:33 +08:00
手瓜一十雪
b25f9d3bec feat: 摇树生成&多平台统一改造 2025-02-03 10:33:10 +08:00
手瓜一十雪
15854c605b style: 强类型大法 2025-02-02 23:22:21 +08:00
手瓜一十雪
ac193cc94a style: lint 2025-02-02 20:17:28 +08:00
手瓜一十雪
d626f872e6 feat: 类型规范 2025-02-02 20:16:11 +08:00
bietiaop
3eb66fa34a fix: 关闭终端启动QQ 2025-02-02 15:34:53 +08:00
bietiaop
0fdd0175b7 fix: 重复关闭 2025-02-02 15:09:38 +08:00
pk5ls20
dec9b477e0 fix: #747 2025-02-02 14:51:36 +08:00
bietiaop
a0a4b0dd1d fix: 频率限制 2025-02-02 14:47:34 +08:00
bietiaop
8dc6da56a7 fix: node-pty 2025-02-02 14:33:39 +08:00
bietiaop
b4e07aacfe chore(terminal): 使用prebuild 2025-02-02 12:59:00 +08:00
bietiaop
19b47f0f42 fix: id生成使用uuid 2025-02-02 12:00:55 +08:00
bietiaop
f9ef3d63c7 fix: id生成使用uuid 2025-02-02 11:57:28 +08:00
bietiaop
2b574d33b5 fix: 更换重命名图标防止误解 2025-02-02 11:46:04 +08:00
bietiaop
6039e9bb46 feat: 文件管理 2025-02-02 11:37:58 +08:00
手瓜一十雪
adfd4b043f docs: fix 2025-02-02 11:02:54 +08:00
bietiaop
719189be55 feat: file manager 2025-02-01 22:47:51 +08:00
bietiaop
ef9907f4b6 style: 优化样式 2025-02-01 20:51:45 +08:00
bietiaop
16b7447df1 style: 优化样式 2025-02-01 20:47:54 +08:00
bietiaop
4157746478 feat: 系统终端 2025-02-01 20:35:01 +08:00
bietiaop
5120786708 Merge branch 'dev-terminal' of https://github.com/NapNeko/NapCatQQ into dev-terminal 2025-02-01 13:44:18 +08:00
bietiaop
0176fa75ef dev: terminal 2025-02-01 13:41:20 +08:00
bietiaop
e6968f2d80 fix(webui): name重复问题 2025-02-01 11:44:30 +08:00
bietiaop
c0dd8a53e8 chore(dep): 更新依赖,移除overrides(strtok3已经修复) 2025-01-31 19:05:40 +08:00
bietiaop
3cb3135235 feat: 登录状态机 2025-01-31 18:48:46 +08:00
bietiaop
28182cac64 chore(dep): 更新webui依赖 2025-01-31 12:07:57 +08:00
bietiaop
73b80d2482 release: v4.4.16 2025-01-30 10:42:46 +08:00
bietiaop
f22eb22409 release: v4.4.16 2025-01-30 10:12:35 +08:00
bietiaop
4a95b17a47 feat(webui): 修改token 2025-01-29 22:08:45 +08:00
bietiaop
f4a71159fd fix(dep): 尝试解决peek-readable依赖问题 2025-01-29 21:34:59 +08:00
bietiaop
c0431e3dc2 feat(webui_api): update token 2025-01-29 20:40:23 +08:00
Mlikiowa
7f87cee282 release: v4.4.15 2025-01-27 11:53:31 +00:00
手瓜一十雪
c24c704439 fix: clone object 2025-01-27 19:53:03 +08:00
Mlikiowa
232e5d55b8 release: v4.4.14 2025-01-27 11:31:15 +00:00
手瓜一十雪
da24ae7e1c fix: 空json5 2025-01-27 19:30:45 +08:00
Mlikiowa
8fc13f8a8f release: v4.4.13 2025-01-27 11:26:17 +00:00
364 changed files with 7741 additions and 2035 deletions

View File

@@ -12,7 +12,7 @@ insert_final_newline = true
# Set default charset
charset = utf-8
# 2 space indentation
# 4 space indentation
[*.{cjs,mjs,js,jsx,ts,tsx,css,scss,sass,html,json}]
indent_style = space
indent_size = 4
@@ -21,4 +21,4 @@ indent_size = 4
charset = latin1
# Unfortunately, EditorConfig doesn't support space configuration inside import braces directly.
# You'll need to rely on your linter/formatter like ESLint or Prettier for that.
# You'll need to rely on your linter/formatter like ESLint or Prettier for that.

3
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Develop
node_modules/
package-lock.json
pnpm-lock.yaml
out/
dist/
/src/core.lib/common/
@@ -13,4 +14,4 @@ devconfig/*
# Build
*.db
checkVersion.sh
bun.lockb
bun.lockb

View File

@@ -64,4 +64,4 @@ NapCat 在设计理念下遵守 OneBot 规范大多数要求并且积极改进
## 开源附加
任何使用本仓库代码的地方,都应当严格遵守[本仓库开源许可](./LICENSE)。**此外,禁止任何项目未经仓库主作者授权二次分发或基于 NapCat 代码开发。**
任何使用本仓库代码的地方,都应当严格遵守[本仓库开源许可](./LICENSE)。**本仓库仅用于提高IM易用性实现类似Hook推送此外,禁止任何项目未经仓库主作者授权二次分发或基于 NapCat 代码开发。使用请遵守当地法律法规,由此造成的问题由使用者和提供违规使用教程者负责。**

View File

@@ -1,70 +1,32 @@
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import _import from "eslint-plugin-import";
import { fixupPluginRules } from "@eslint/compat";
import eslint from '@eslint/js';
import tsEslintPlugin from '@typescript-eslint/eslint-plugin';
import tsEslintParser from '@typescript-eslint/parser';
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import path from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
const compat = new FlatCompat({
baseDirectory: dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
export default [{
ignores: ["src/core/proto/"],
}, ...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended"), {
plugins: {
"@typescript-eslint": typescriptEslint,
import: fixupPluginRules(_import),
},
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
parser: tsParser,
ecmaVersion: "latest",
sourceType: "module",
},
settings: {
"import/parsers": {
"@typescript-eslint/parser": [".ts"],
},
"import/resolver": {
typescript: {
alwaysTryTypes: true,
const customTsFlatConfig = [
{
name: 'typescript-eslint/base',
languageOptions: {
parser: tsEslintParser,
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
NodeJS: 'readonly', // 添加 NodeJS 全局变量
},
},
},
rules: {
indent: ["error", 4],
semi: ["error", "always"],
"no-unused-vars": "off",
"no-async-promise-executor": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-var-requires": "off",
"object-curly-spacing": ["error", "always"],
},
}, {
files: ["**/.eslintrc.{js,cjs}"],
languageOptions: {
globals: {
...globals.node,
files: ['**/*.{ts,tsx}'],
rules: {
...tsEslintPlugin.configs.recommended.rules,
'quotes': ['error', 'single'], // 使用单引号
'semi': ['error', 'always'], // 强制使用分号
'indent': ['error', 4], // 使用 4 空格缩进
},
ecmaVersion: 5,
sourceType: "commonjs",
plugins: {
'@typescript-eslint': tsEslintPlugin,
},
ignores: ['src/webui/**'], // 忽略 src/webui/ 目录所有文件
},
}];
];
export default [eslint.configs.recommended, ...customTsFlatConfig];

View File

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

View File

@@ -4,12 +4,15 @@
"version": "0.0.6",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --host=0.0.0.0",
"build": "tsc && vite build",
"lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@heroui/avatar": "2.2.7",
"@heroui/breadcrumbs": "2.2.7",
"@heroui/button": "2.2.10",
@@ -33,13 +36,14 @@
"@heroui/spinner": "2.2.7",
"@heroui/switch": "2.2.9",
"@heroui/system": "2.4.7",
"@heroui/table": "^2.2.9",
"@heroui/tabs": "2.2.8",
"@heroui/theme": "2.4.6",
"@heroui/tooltip": "2.2.8",
"@monaco-editor/loader": "^1.4.0",
"@monaco-editor/react": "4.7.0-rc.0",
"@react-aria/visually-hidden": "3.8.18",
"@reduxjs/toolkit": "^2.5.0",
"@react-aria/visually-hidden": "^3.8.19",
"@reduxjs/toolkit": "^2.5.1",
"@uidotdev/usehooks": "^2.4.1",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
@@ -47,17 +51,18 @@
"@xterm/xterm": "^5.5.0",
"ahooks": "^3.8.4",
"axios": "^1.7.9",
"clsx": "2.1.1",
"clsx": "^2.1.1",
"echarts": "^5.5.1",
"event-source-polyfill": "^1.0.31",
"framer-motion": "^11.15.0",
"framer-motion": "^12.0.6",
"monaco-editor": "^0.52.2",
"motion": "^11.15.0",
"motion": "^12.0.6",
"path-browserify": "^1.0.1",
"qface": "^1.4.1",
"qrcode.react": "^4.2.0",
"quill": "^2.0.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2",
"react-hot-toast": "^2.4.1",
@@ -65,41 +70,42 @@
"react-markdown": "^9.0.3",
"react-redux": "^9.2.0",
"react-responsive": "^10.0.0",
"react-router-dom": "7.1.0",
"react-router-dom": "^7.1.4",
"react-use-websocket": "^4.11.1",
"react-window": "^1.8.11",
"remark-gfm": "^4.0.0",
"tailwind-variants": "0.3.0",
"tailwindcss": "3.4.17",
"tailwind-variants": "^0.3.0",
"tailwindcss": "^3.4.17",
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@eslint/js": "^9.19.0",
"@react-types/shared": "^3.26.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/event-source-polyfill": "^1.0.5",
"@types/fabric": "^5.3.9",
"@types/node": "22.10.2",
"@types/react": "19.0.2",
"@types/react-dom": "19.0.2",
"@types/node": "^22.12.0",
"@types/path-browserify": "^1.0.3",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/react-window": "^1.8.8",
"@typescript-eslint/eslint-plugin": "8.18.1",
"@typescript-eslint/parser": "8.18.1",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "10.4.20",
"eslint": "^9.17.0",
"eslint-config-prettier": "9.1.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.19.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-prettier": "5.2.3",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-unused-imports": "4.1.4",
"eslint-plugin-unused-imports": "^4.1.4",
"globals": "^15.14.0",
"postcss": "8.4.49",
"prettier": "3.4.2",
"typescript": "5.7.2",
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"typescript": "^5.7.3",
"vite": "^6.0.5",
"vite-plugin-static-copy": "^2.2.0",
"vite-tsconfig-paths": "^5.1.4"

View File

@@ -5,7 +5,7 @@ import { IoMdRefresh } from 'react-icons/io'
export interface SaveButtonsProps {
onSubmit: () => void
reset: () => void
refresh: () => void
refresh?: () => void
isSubmitting: boolean
}
@@ -27,21 +27,23 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
</Button>
<Button
color="primary"
color="danger"
isLoading={isSubmitting}
onPress={() => onSubmit()}
>
</Button>
<Button
isIconOnly
color="secondary"
radius="full"
variant="flat"
onPress={() => refresh()}
>
<IoMdRefresh size={24} />
</Button>
{refresh && (
<Button
isIconOnly
color="secondary"
radius="full"
variant="flat"
onPress={() => refresh()}
>
<IoMdRefresh size={24} />
</Button>
)}
</div>
</div>
)

View File

@@ -0,0 +1,166 @@
import {
FaFile,
FaFileAudio,
FaFileCode,
FaFileCsv,
FaFileExcel,
FaFileImage,
FaFileLines,
FaFilePdf,
FaFilePowerpoint,
FaFileVideo,
FaFileWord,
FaFileZipper,
FaFolderClosed
} from 'react-icons/fa6'
export interface FileIconProps {
name?: string
isDirectory?: boolean
}
const FileIcon = (props: FileIconProps) => {
const { name, isDirectory = false } = props
if (isDirectory) {
return <FaFolderClosed className="text-yellow-500" />
}
const ext = name?.split('.').pop() || ''
if (ext) {
switch (ext.toLowerCase()) {
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'svg':
case 'bmp':
case 'ico':
case 'webp':
case 'tiff':
case 'tif':
case 'heic':
case 'heif':
case 'avif':
case 'apng':
case 'flif':
case 'ai':
case 'psd':
case 'xcf':
case 'sketch':
case 'fig':
case 'xd':
case 'svgz':
return <FaFileImage className="text-green-500" />
case 'pdf':
return <FaFilePdf className="text-red-500" />
case 'doc':
case 'docx':
return <FaFileWord className="text-blue-500" />
case 'xls':
case 'xlsx':
return <FaFileExcel className="text-green-500" />
case 'csv':
return <FaFileCsv className="text-green-500" />
case 'ppt':
case 'pptx':
return <FaFilePowerpoint className="text-red-500" />
case 'zip':
case 'rar':
case '7z':
case 'tar':
case 'gz':
case 'bz2':
case 'xz':
case 'lz':
case 'lzma':
case 'zst':
case 'zstd':
case 'z':
case 'taz':
case 'tz':
case 'tzo':
return <FaFileZipper className="text-green-500" />
case 'txt':
return <FaFileLines className="text-gray-500" />
case 'mp3':
case 'wav':
case 'flac':
return <FaFileAudio className="text-green-500" />
case 'mp4':
case 'avi':
case 'mov':
case 'wmv':
return <FaFileVideo className="text-red-500" />
case 'html':
case 'css':
case 'js':
case 'ts':
case 'jsx':
case 'tsx':
case 'json':
case 'xml':
case 'yaml':
case 'yml':
case 'md':
case 'sh':
case 'py':
case 'java':
case 'c':
case 'cpp':
case 'cs':
case 'go':
case 'php':
case 'rb':
case 'pl':
case 'swift':
case 'kt':
case 'rs':
case 'sql':
case 'r':
case 'scala':
case 'groovy':
case 'dart':
case 'lua':
case 'perl':
case 'h':
case 'm':
case 'mm':
case 'makefile':
case 'cmake':
case 'dockerfile':
case 'gradle':
case 'properties':
case 'ini':
case 'conf':
case 'env':
case 'bat':
case 'cmd':
case 'ps1':
case 'psm1':
case 'psd1':
case 'ps1xml':
case 'psc1':
case 'pssc':
case 'nuspec':
case 'resx':
case 'resw':
case 'csproj':
case 'vbproj':
case 'vcxproj':
case 'fsproj':
case 'sln':
case 'suo':
case 'user':
case 'userosscache':
case 'sln.docstates':
case 'dll':
return <FaFileCode className="text-blue-500" />
default:
return <FaFile className="text-gray-500" />
}
}
return <FaFile className="text-gray-500" />
}
export default FileIcon

View File

@@ -0,0 +1,64 @@
import { Button, ButtonGroup } from '@heroui/button'
import { Input } from '@heroui/input'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader
} from '@heroui/modal'
interface CreateFileModalProps {
isOpen: boolean
fileType: 'file' | 'directory'
newFileName: string
onTypeChange: (type: 'file' | 'directory') => void
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onClose: () => void
onCreate: () => void
}
export default function CreateFileModal({
isOpen,
fileType,
newFileName,
onTypeChange,
onNameChange,
onClose,
onCreate
}: CreateFileModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<div className="flex flex-col gap-4">
<ButtonGroup color="danger">
<Button
variant={fileType === 'file' ? 'solid' : 'flat'}
onPress={() => onTypeChange('file')}
>
</Button>
<Button
variant={fileType === 'directory' ? 'solid' : 'flat'}
onPress={() => onTypeChange('directory')}
>
</Button>
</ButtonGroup>
<Input label="名称" value={newFileName} onChange={onNameChange} />
</div>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="flat" onPress={onClose}>
</Button>
<Button color="danger" onPress={onCreate}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,94 @@
import { Button } from '@heroui/button'
import { Code } from '@heroui/code'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader
} from '@heroui/modal'
import CodeEditor from '@/components/code_editor'
interface FileEditModalProps {
isOpen: boolean
file: { path: string; content: string } | null
onClose: () => void
onSave: () => void
onContentChange: (newContent?: string) => void
}
export default function FileEditModal({
isOpen,
file,
onClose,
onSave,
onContentChange
}: FileEditModalProps) {
// 根据文件后缀返回对应语言
const getLanguage = (filePath: string) => {
if (filePath.endsWith('.js')) return 'javascript'
if (filePath.endsWith('.ts')) return 'typescript'
if (filePath.endsWith('.tsx')) return 'tsx'
if (filePath.endsWith('.jsx')) return 'jsx'
if (filePath.endsWith('.vue')) return 'vue'
if (filePath.endsWith('.svelte')) return 'svelte'
if (filePath.endsWith('.json')) return 'json'
if (filePath.endsWith('.html')) return 'html'
if (filePath.endsWith('.css')) return 'css'
if (filePath.endsWith('.scss')) return 'scss'
if (filePath.endsWith('.less')) return 'less'
if (filePath.endsWith('.md')) return 'markdown'
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) return 'yaml'
if (filePath.endsWith('.xml')) return 'xml'
if (filePath.endsWith('.sql')) return 'sql'
if (filePath.endsWith('.sh')) return 'shell'
if (filePath.endsWith('.bat')) return 'bat'
if (filePath.endsWith('.php')) return 'php'
if (filePath.endsWith('.java')) return 'java'
if (filePath.endsWith('.c')) return 'c'
if (filePath.endsWith('.cpp')) return 'cpp'
if (filePath.endsWith('.h')) return 'h'
if (filePath.endsWith('.hpp')) return 'hpp'
if (filePath.endsWith('.go')) return 'go'
if (filePath.endsWith('.py')) return 'python'
if (filePath.endsWith('.rb')) return 'ruby'
if (filePath.endsWith('.cs')) return 'csharp'
if (filePath.endsWith('.swift')) return 'swift'
if (filePath.endsWith('.vb')) return 'vb'
if (filePath.endsWith('.lua')) return 'lua'
if (filePath.endsWith('.pl')) return 'perl'
if (filePath.endsWith('.r')) return 'r'
return 'plaintext'
}
return (
<Modal size="full" isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader className="flex items-center gap-2 bg-content2 bg-opacity-50">
<span></span>
<Code className="text-xs">{file?.path}</Code>
</ModalHeader>
<ModalBody className="p-0">
<div className="h-full">
<CodeEditor
height="100%"
value={file?.content || ''}
onChange={onContentChange}
options={{ wordWrap: 'on' }}
language={file?.path ? getLanguage(file.path) : 'plaintext'}
/>
</div>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="flat" onPress={onClose}>
</Button>
<Button color="danger" onPress={onSave}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,159 @@
import { Button, ButtonGroup } from '@heroui/button'
import { Spinner } from '@heroui/spinner'
import {
type Selection,
type SortDescriptor,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow
} from '@heroui/table'
import { Tooltip } from '@heroui/tooltip'
import path from 'path-browserify'
import { BiRename } from 'react-icons/bi'
import { FiCopy, FiMove, FiTrash2 } from 'react-icons/fi'
import FileIcon from '@/components/file_icon'
import type { FileInfo } from '@/controllers/file_manager'
interface FileTableProps {
files: FileInfo[]
currentPath: string
loading: boolean
sortDescriptor: SortDescriptor
onSortChange: (descriptor: SortDescriptor) => void
selectedFiles: Selection
onSelectionChange: (selected: Selection) => void
onDirectoryClick: (dirPath: string) => void
onEdit: (filePath: string) => void
onRenameRequest: (name: string) => void
onMoveRequest: (name: string) => void
onCopyPath: (fileName: string) => void
onDelete: (filePath: string) => void
}
export default function FileTable({
files,
currentPath,
loading,
sortDescriptor,
onSortChange,
selectedFiles,
onSelectionChange,
onDirectoryClick,
onEdit,
onRenameRequest,
onMoveRequest,
onCopyPath,
onDelete
}: FileTableProps) {
return (
<Table
aria-label="文件列表"
sortDescriptor={sortDescriptor}
onSortChange={onSortChange}
onSelectionChange={onSelectionChange}
defaultSelectedKeys={[]}
selectedKeys={selectedFiles}
selectionMode="multiple"
>
<TableHeader>
<TableColumn key="name" allowsSorting>
</TableColumn>
<TableColumn key="type" allowsSorting>
</TableColumn>
<TableColumn key="size" allowsSorting>
</TableColumn>
<TableColumn key="mtime" allowsSorting>
</TableColumn>
<TableColumn key="actions"></TableColumn>
</TableHeader>
<TableBody
isLoading={loading}
loadingContent={
<div className="flex justify-center items-center h-full">
<Spinner />
</div>
}
items={files}
>
{(file: FileInfo) => (
<TableRow key={file.name}>
<TableCell>
<Button
variant="light"
onPress={() =>
file.isDirectory
? onDirectoryClick(file.name)
: onEdit(path.join(currentPath, file.name))
}
className="text-left justify-start"
startContent={
<FileIcon name={file.name} isDirectory={file.isDirectory} />
}
>
{file.name}
</Button>
</TableCell>
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
<TableCell>
{isNaN(file.size) || file.isDirectory ? '-' : `${file.size} 字节`}
</TableCell>
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
<TableCell>
<ButtonGroup size="sm">
<Tooltip content="重命名">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onRenameRequest(file.name)}
>
<BiRename />
</Button>
</Tooltip>
<Tooltip content="移动">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onMoveRequest(file.name)}
>
<FiMove />
</Button>
</Tooltip>
<Tooltip content="复制路径">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onCopyPath(file.name)}
>
<FiCopy />
</Button>
</Tooltip>
<Tooltip content="删除">
<Button
isIconOnly
color="danger"
variant="flat"
onPress={() => onDelete(path.join(currentPath, file.name))}
>
<FiTrash2 />
</Button>
</Tooltip>
</ButtonGroup>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)
}

View File

@@ -0,0 +1,168 @@
import { Button } from '@heroui/button'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader
} from '@heroui/modal'
import { Spinner } from '@heroui/spinner'
import clsx from 'clsx'
import path from 'path-browserify'
import { useState } from 'react'
import { IoAdd, IoRemove } from 'react-icons/io5'
import FileManager from '@/controllers/file_manager'
interface MoveModalProps {
isOpen: boolean
moveTargetPath: string
selectionInfo: string
onClose: () => void
onMove: () => void
onSelect: (dir: string) => void // 新增回调
}
// 将 DirectoryTree 改为递归组件
// 新增 selectedPath 属性,用于标识当前选中的目录
function DirectoryTree({
basePath,
onSelect,
selectedPath
}: {
basePath: string
onSelect: (dir: string) => void
selectedPath?: string
}) {
const [dirs, setDirs] = useState<string[]>([])
const [expanded, setExpanded] = useState(false)
// 新增loading状态
const [loading, setLoading] = useState(false)
const fetchDirectories = async () => {
try {
// 直接使用 basePath 调用接口,移除 process.platform 判断
const list = await FileManager.listDirectories(basePath)
setDirs(list.map((item) => item.name))
} catch (error) {
// ...error handling...
}
}
const handleToggle = async () => {
if (!expanded) {
setExpanded(true)
setLoading(true)
await fetchDirectories()
setLoading(false)
} else {
setExpanded(false)
}
}
const handleClick = () => {
onSelect(basePath)
handleToggle()
}
// 计算显示的名称
const getDisplayName = () => {
if (basePath === '/') return '/'
if (/^[A-Z]:$/i.test(basePath)) return basePath
return path.basename(basePath)
}
// 更新 Button 的 variant 逻辑
const isSeleted = selectedPath === basePath
const variant = isSeleted
? 'solid'
: selectedPath && path.dirname(selectedPath) === basePath
? 'flat'
: 'light'
return (
<div className="ml-4">
<Button
onPress={handleClick}
className="py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-md"
size="sm"
color="danger"
variant={variant}
startContent={
<div
className={clsx(
'rounded-md',
isSeleted ? 'bg-danger-600' : 'bg-danger-50'
)}
>
{expanded ? <IoRemove /> : <IoAdd />}
</div>
}
>
{getDisplayName()}
</Button>
{expanded && (
<div>
{loading ? (
<div className="flex py-1 px-8">
<Spinner size="sm" color="danger" />
</div>
) : (
dirs.map((dirName) => {
const childPath =
basePath === '/' && /^[A-Z]:$/i.test(dirName)
? dirName
: path.join(basePath, dirName)
return (
<DirectoryTree
key={childPath}
basePath={childPath}
onSelect={onSelect}
selectedPath={selectedPath}
/>
)
})
)}
</div>
)}
</div>
)
}
export default function MoveModal({
isOpen,
moveTargetPath,
selectionInfo,
onClose,
onMove,
onSelect
}: MoveModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<div className="rounded-md p-2 border border-default-300 overflow-auto max-h-60">
<DirectoryTree
basePath="/"
onSelect={onSelect}
selectedPath={moveTargetPath}
/>
</div>
<p className="text-sm text-default-500 mt-2">
{moveTargetPath || '未选择'}
</p>
<p className="text-sm text-default-500">{selectionInfo}</p>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="flat" onPress={onClose}>
</Button>
<Button color="danger" onPress={onMove}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,44 @@
import { Button } from '@heroui/button'
import { Input } from '@heroui/input'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader
} from '@heroui/modal'
interface RenameModalProps {
isOpen: boolean
newFileName: string
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onClose: () => void
onRename: () => void
}
export default function RenameModal({
isOpen,
newFileName,
onNameChange,
onClose,
onRename
}: RenameModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<Input label="新名称" value={newFileName} onChange={onNameChange} />
</ModalBody>
<ModalFooter>
<Button color="danger" variant="flat" onPress={onClose}>
</Button>
<Button color="danger" onPress={onRename}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,146 @@
import { motion, useMotionValue, useSpring } from 'motion/react'
import { useRef, useState } from 'react'
const springValues = {
damping: 30,
stiffness: 100,
mass: 2
}
export interface HoverTiltedCardProps {
imageSrc: string
altText?: string
captionText?: string
containerHeight?: string
containerWidth?: string
imageHeight?: string
imageWidth?: string
scaleOnHover?: number
rotateAmplitude?: number
showTooltip?: boolean
overlayContent?: React.ReactNode
displayOverlayContent?: boolean
}
export default function HoverTiltedCard({
imageSrc,
altText = 'NapCat',
captionText = 'NapCat',
containerHeight = '200px',
containerWidth = '100%',
imageHeight = '200px',
imageWidth = '200px',
scaleOnHover = 1.1,
rotateAmplitude = 14,
showTooltip = false,
overlayContent = (
<div className="text-center font-ubuntu mt-6 px-4 py-0.5 shadow-lg rounded-full bg-danger-600 text-default-100 bg-opacity-80">
NapCat
</div>
),
displayOverlayContent = true
}: HoverTiltedCardProps) {
const ref = useRef<HTMLDivElement>(null)
const x = useMotionValue(0)
const y = useMotionValue(0)
const rotateX = useSpring(useMotionValue(0), springValues)
const rotateY = useSpring(useMotionValue(0), springValues)
const scale = useSpring(1, springValues)
const opacity = useSpring(0)
const rotateFigcaption = useSpring(0, {
stiffness: 350,
damping: 30,
mass: 1
})
const [lastY, setLastY] = useState(0)
function handleMouse(e: React.MouseEvent) {
if (!ref.current) return
const rect = ref.current.getBoundingClientRect()
const offsetX = e.clientX - rect.left - rect.width / 2
const offsetY = e.clientY - rect.top - rect.height / 2
const rotationX = (offsetY / (rect.height / 2)) * -rotateAmplitude
const rotationY = (offsetX / (rect.width / 2)) * rotateAmplitude
rotateX.set(rotationX)
rotateY.set(rotationY)
x.set(e.clientX - rect.left)
y.set(e.clientY - rect.top)
const velocityY = offsetY - lastY
rotateFigcaption.set(-velocityY * 0.6)
setLastY(offsetY)
}
function handleMouseEnter() {
scale.set(scaleOnHover)
opacity.set(1)
}
function handleMouseLeave() {
opacity.set(0)
scale.set(1)
rotateX.set(0)
rotateY.set(0)
rotateFigcaption.set(0)
}
return (
<figure
ref={ref}
className="relative w-full h-full [perspective:800px] flex flex-col items-center justify-center"
style={{
height: containerHeight,
width: containerWidth
}}
onMouseMove={handleMouse}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<motion.div
className="relative [transform-style:preserve-3d]"
style={{
width: imageWidth,
height: imageHeight,
rotateX,
rotateY,
scale
}}
>
<motion.img
src={imageSrc}
alt={altText}
className="absolute top-0 left-0 object-cover rounded-md will-change-transform [transform:translateZ(0)] pointer-events-none select-none"
style={{
width: imageWidth,
height: imageHeight
}}
/>
{displayOverlayContent && overlayContent && (
<motion.div className="absolute top-0 left-0 right-0 z-10 flex justify-center will-change-transform [transform:translateZ(30px)]">
{overlayContent}
</motion.div>
)}
</motion.div>
{showTooltip && (
<motion.figcaption
className="pointer-events-none absolute left-0 top-0 rounded-md bg-white px-2 py-1 text-sm text-default-900 opacity-0 z-10 hidden sm:block"
style={{
x,
y,
opacity,
rotate: rotateFigcaption
}}
>
{captionText}
</motion.figcaption>
)}
</figure>
)
}

View File

@@ -1152,7 +1152,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="0ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1197,7 +1197,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="800ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1247,7 +1247,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="1600ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1297,7 +1297,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="2400ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1344,7 +1344,7 @@ export const WebUIIcon = (props: IconSvgProps) => (
begin="3200ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1399,7 +1399,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="0ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1446,7 +1446,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="600ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1496,7 +1496,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="1200ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1543,7 +1543,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="1800ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1590,7 +1590,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="2400ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1637,7 +1637,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="3000ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1684,7 +1684,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="3600ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1731,7 +1731,7 @@ export const BietiaopIcon = (props: IconSvgProps) => (
begin="4200ms"
></animate>
<animate
attributeName="fill-opacity"
attributeName="fillOpacity"
to="1"
dur="800ms"
calcMode="linear"
@@ -1744,3 +1744,224 @@ export const BietiaopIcon = (props: IconSvgProps) => (
</svg>
</>
)
export const FileIcon = (props: IconSvgProps) => (
<svg
version="1.1"
id="_x36_"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512"
xmlSpace="preserve"
{...props}
>
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g
id="SVGRepo_tracerCarrier"
strokeLinecap="round"
strokeLinejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
<g>
<path
style={{ fill: '#D4B476' }}
d="M441.853,393.794H70.147C31.566,393.794,0,362.228,0,323.647V106.969 c0-38.581,31.566-70.147,70.147-70.147h371.706c38.581,0,70.147,31.566,70.147,70.147v216.678 C512,362.228,480.434,393.794,441.853,393.794z"
></path>
<path
style={{ fill: '#D4B476' }}
d="M199.884,249.574H70.147C31.566,249.574,0,218.008,0,179.427V70.147C0,31.566,31.566,0,70.147,0 h129.737c38.581,0,70.147,31.566,70.147,70.147v109.28C270.031,218.008,238.465,249.574,199.884,249.574z"
></path>
<polygon
style={{ fill: '#F0EFEF' }}
points="485.439,329.388 87.357,347.774 78.653,130.095 476.734,111.709 "
></polygon>
<defs>
<filter
id="Adobe_OpacityMaskFilter"
filterUnits="userSpaceOnUse"
x="34.381"
y="60.216"
width="416.68"
height="259.557"
>
<feFlood
style={{
floodColor: 'white',
floodOpacity: 1
}}
result="back"
></feFlood>
<feBlend in="SourceGraphic" in2="back" mode="normal"></feBlend>
</filter>
</defs>
<mask
maskUnits="userSpaceOnUse"
x="34.381"
y="60.216"
width="416.68"
height="259.557"
id="SVGID_1_"
>
<g style={{ filter: 'url(#Adobe_OpacityMaskFilter)' }}>
<defs>
<filter
id="Adobe_OpacityMaskFilter_1_"
filterUnits="userSpaceOnUse"
x="34.381"
y="60.216"
width="416.68"
height="259.557"
>
<feFlood
style={{ floodColor: 'white', floodOpacity: 1 }}
result="back"
></feFlood>
<feBlend in="SourceGraphic" in2="back" mode="normal"></feBlend>
</filter>
</defs>
<mask
maskUnits="userSpaceOnUse"
x="34.381"
y="60.216"
width="416.68"
height="259.557"
id="SVGID_1_"
>
<g style={{ filter: 'url(#Adobe_OpacityMaskFilter_1_)' }}> </g>
</mask>
<linearGradient
id="SVGID_2_"
gradientUnits="userSpaceOnUse"
x1="34.3814"
y1="189.9944"
x2="451.061"
y2="189.9944"
>
<stop offset="0.57" style={{ stopColor: '#F6F6F6' }}></stop>
<stop offset="0.6039" style={{ stopColor: '#F6F6F6' }}></stop>
</linearGradient>
<polygon
style={{ mask: 'url(#SVGID_1_)', fill: 'url(#SVGID_2_)' }}
points="451.061,277.073 54.598,319.773 34.381,102.916 430.845,60.216 "
></polygon>
</g>
</mask>
<linearGradient
id="SVGID_3_"
gradientUnits="userSpaceOnUse"
x1="34.3814"
y1="189.9944"
x2="451.061"
y2="189.9944"
>
<stop offset="0.57" style={{ stopColor: '#FFFFFF' }}></stop>
<stop offset="0.6039" style={{ stopColor: '#F0F0F0' }}></stop>
</linearGradient>
<polygon
style={{ fill: 'url(#SVGID_3_)' }}
points="451.061,277.073 54.598,319.773 34.381,102.916 430.845,60.216 "
></polygon>
<path
style={{ fill: '#69A092' }}
d="M441.853,417.32H70.147C31.566,417.32,0,385.754,0,347.173V168.515h512v178.658 C512,385.754,480.434,417.32,441.853,417.32z"
></path>
<path
style={{ fill: '#D4B476' }}
d="M441.853,429.594H70.147C31.566,429.594,0,398.028,0,359.447V189.995h512v169.453 C512,398.028,480.434,429.594,441.853,429.594z"
></path>
<g>
<g>
<path
style={{ fill: '#CBBC89' }}
d="M41.051,330.321h28.918h7.581c0.686,0,1.357,0,2.012,0c0.655,0,1.171,0.126,1.545,0.375 c0.499,0.312,0.795,0.764,0.889,1.357c0.094,0.594,0.141,1.296,0.141,2.106c0,0.312,0.014,0.608,0.047,0.888 c0.031,0.281-0.016,0.547-0.14,0.796c-0.25,0.998-0.796,1.543-1.638,1.638c-0.843,0.094-1.888,0.14-3.136,0.14h-9.733H53.778 c-0.998,0-2.058-0.015-3.182-0.047c-1.122-0.03-1.965,0.173-2.526,0.608c-0.563,0.375-0.858,1.03-0.89,1.965 c-0.032,0.937-0.047,1.873-0.047,2.808v9.265c0,0.5-0.015,1.123-0.046,1.872c-0.033,0.749,0.014,1.373,0.139,1.871v0.748 c0.125,0.375,0.235,0.735,0.328,1.077c0.093,0.344,0.295,0.608,0.608,0.796c0.562,0.374,1.436,0.547,2.621,0.514 c1.184-0.03,2.214-0.047,3.088-0.047h16.845c0.561,0,1.171-0.014,1.825-0.046c0.655-0.031,1.31-0.031,1.965,0 c0.655,0.032,1.248,0.109,1.779,0.234c0.529,0.126,0.889,0.344,1.076,0.655c0.311,0.375,0.467,0.843,0.467,1.404 c0,0.56,0,1.186,0,1.871c0,0.375,0,0.719,0,1.03c0,0.313-0.062,0.594-0.186,0.842c-0.25,0.625-0.595,0.969-1.03,1.03 c-0.25,0.125-0.485,0.186-0.702,0.186c-0.219,0-0.483,0.033-0.796,0.094c-0.25,0.063-0.514,0.079-0.796,0.047 c-0.281-0.03-0.546-0.047-0.794-0.047h-3.277H54.433c-0.999,0-2.168-0.014-3.51-0.047c-1.342-0.03-2.292,0.172-2.853,0.609 c-0.314,0.25-0.547,0.64-0.702,1.169c-0.156,0.531-0.25,1.14-0.281,1.825c-0.033,0.687-0.033,1.389,0,2.106 c0.03,0.717,0.046,1.357,0.046,1.918v16.096c0,1.062,0.032,2.217,0.094,3.463c0.062,1.249-0.156,2.185-0.655,2.807 c-0.186,0.25-0.469,0.391-0.842,0.422c-0.374,0.032-0.749,0.109-1.123,0.234h-1.779c-0.875,0-1.684-0.03-2.433-0.093 c-0.749-0.062-1.279-0.375-1.59-0.937c-0.25-0.374-0.375-0.888-0.375-1.543c0-0.656,0-1.326,0-2.013v-7.488v-39.119v-10.107 c0-0.686-0.016-1.45-0.047-2.292c-0.032-0.842,0.107-1.512,0.422-2.012c0.249-0.374,0.715-0.685,1.404-0.935 c0.124-0.062,0.264-0.077,0.42-0.047C40.785,330.4,40.925,330.383,41.051,330.321z"
></path>
<path
style={{ fill: '#CBBC89' }}
d="M96.895,330.508c0.436,0,0.935-0.014,1.496-0.046c0.563-0.032,1.108-0.032,1.639,0 c0.529,0.032,1.013,0.109,1.45,0.234c0.437,0.125,0.749,0.312,0.937,0.561c0.374,0.438,0.576,1.046,0.608,1.825 c0.03,0.78,0.047,1.576,0.047,2.386v9.079v36.124v10.949c0,0.811,0,1.669,0,2.574c0,0.905-0.156,1.606-0.468,2.105 c-0.25,0.314-0.547,0.5-0.889,0.563c-0.344,0.062-0.765,0.156-1.264,0.28h-1.777c-0.874,0-1.684-0.034-2.433-0.094 c-0.749-0.061-1.28-0.374-1.592-0.935c-0.25-0.374-0.375-0.89-0.375-1.545c0-0.655,0-1.325,0-2.012v-7.487V345.95v-10.107 c0-0.686-0.015-1.451-0.045-2.292c-0.034-0.843,0.108-1.514,0.42-2.013c0.249-0.436,0.748-0.748,1.497-0.936 c0.125-0.061,0.25-0.077,0.375-0.047C96.645,330.587,96.769,330.571,96.895,330.508z"
></path>
<path
style={{ fill: '#CBBC89' }}
d="M126.093,330.321c0.436,0,0.935-0.015,1.498-0.047c0.561-0.03,1.107-0.03,1.638,0 c0.529,0.032,1.012,0.109,1.451,0.234c0.436,0.125,0.748,0.313,0.936,0.562c0.374,0.5,0.56,1.155,0.56,1.965 c0,0.811,0,1.654,0,2.528v9.826v30.042v8.609c0,0.687,0,1.436,0,2.245c0,0.813,0.094,1.468,0.281,1.967 c0.188,0.436,0.529,0.811,1.03,1.122c0.313,0.125,0.717,0.203,1.217,0.235c0.499,0.032,0.998,0.046,1.498,0.046h4.772h16.845 h5.24c0.436,0,0.89-0.014,1.359-0.046c0.467-0.032,0.888,0.016,1.263,0.139c0.81,0.25,1.34,0.594,1.591,1.03 c0.062,0.188,0.124,0.5,0.188,0.937c0.061,0.436,0.107,0.889,0.139,1.357c0.03,0.467,0.015,0.935-0.047,1.402 c-0.062,0.469-0.126,0.829-0.187,1.077c-0.126,0.437-0.406,0.782-0.843,1.029c-0.313,0.25-0.765,0.375-1.357,0.375 c-0.593,0-1.169,0-1.731,0h-6.177H133.58h-6.083c-0.874,0-1.623-0.047-2.246-0.139c-0.624-0.094-1.092-0.39-1.404-0.89 c-0.25-0.374-0.373-0.857-0.373-1.451c0-0.591,0-1.168,0-1.729v-6.74v-25.549v-20.308v-5.802c0-0.561,0.014-1.122,0.045-1.684 c0.032-0.561,0.141-0.998,0.328-1.31c0.249-0.374,0.717-0.685,1.404-0.935c0.124-0.062,0.265-0.077,0.422-0.047 C125.827,330.4,125.968,330.383,126.093,330.321z"
></path>
<path
style={{ fill: '#CBBC89' }}
d="M187.883,330.321h30.602h8.049c0.686,0,1.372-0.015,2.059-0.047 c0.685-0.03,1.248,0.109,1.684,0.422c0.188,0.125,0.343,0.281,0.468,0.467c0.124,0.188,0.249,0.375,0.374,0.563 c0.125,0.436,0.187,1.138,0.187,2.105c0,0.967-0.062,1.701-0.187,2.199c-0.25,0.874-0.78,1.357-1.592,1.451 c-0.811,0.094-1.777,0.14-2.9,0.14h-9.359h-15.722c-0.998,0-2.169-0.015-3.51-0.047c-1.343-0.03-2.325,0.139-2.949,0.514 c-0.624,0.374-0.951,1.03-0.982,1.966c-0.032,0.936-0.048,1.904-0.048,2.901v9.546c0,0.688-0.015,1.498-0.045,2.433 c-0.034,0.937,0.014,1.686,0.139,2.246c0.188,0.749,0.562,1.249,1.123,1.498c0.249,0.125,0.686,0.25,1.31,0.373h0.935 c0.249,0.063,0.515,0.08,0.796,0.047c0.281-0.03,0.577-0.047,0.89-0.047h3.181h18.343c0.561,0,1.184-0.014,1.872-0.046 c0.685-0.031,1.387-0.031,2.104,0c0.717,0.032,1.372,0.093,1.967,0.187c0.591,0.094,1.044,0.233,1.357,0.421 c0.186,0.126,0.341,0.407,0.467,0.842c0.125,0.438,0.218,0.905,0.282,1.404c0.061,0.5,0.077,1.016,0.045,1.545 c-0.031,0.531-0.109,0.983-0.233,1.357c-0.126,0.561-0.406,0.967-0.842,1.217c-0.127,0.126-0.297,0.203-0.515,0.235 c-0.22,0.032-0.422,0.079-0.608,0.139h-0.563c-0.312,0.063-0.623,0.079-0.935,0.047c-0.313-0.03-0.624-0.047-0.935-0.047h-3.463 h-19.747c-0.747,0-1.482-0.014-2.199-0.047c-0.719-0.03-1.39,0-2.012,0.094c-0.624,0.094-1.171,0.267-1.639,0.515 c-0.467,0.25-0.794,0.687-0.982,1.31c-0.126,0.375-0.173,0.811-0.139,1.31c0.03,0.5,0.045,0.999,0.045,1.498v5.239v8.892 c0,0.873,0.032,1.669,0.094,2.386c0.062,0.718,0.312,1.233,0.749,1.544c0.435,0.313,0.935,0.483,1.497,0.515 c0.561,0.032,1.185,0.046,1.871,0.046h5.99h17.314h5.334c0.499,0,0.998-0.014,1.497-0.046c0.498-0.032,0.936,0.016,1.31,0.139 c0.749,0.25,1.248,0.594,1.498,1.03c0.123,0.188,0.217,0.5,0.281,0.937c0.061,0.436,0.108,0.889,0.14,1.357 c0.03,0.467,0.015,0.935-0.047,1.402c-0.062,0.469-0.126,0.829-0.187,1.077c-0.25,0.5-0.531,0.842-0.842,1.029 c-0.375,0.25-0.858,0.375-1.45,0.375c-0.594,0-1.171,0-1.731,0h-6.552h-24.894h-6.551c-0.874,0-1.623-0.047-2.245-0.139 c-0.624-0.094-1.093-0.39-1.404-0.89c-0.25-0.374-0.374-0.857-0.374-1.451c0-0.591,0-1.168,0-1.729v-6.551v-25.082v-20.964 v-5.802c0-0.561,0-1.122,0-1.684c0-0.561,0.124-0.998,0.374-1.31c0.249-0.435,0.716-0.749,1.404-0.935 c0.125-0.062,0.248-0.077,0.375-0.047C187.632,330.4,187.756,330.383,187.883,330.321z"
></path>
</g>
<g>
<path
style={{ fill: '#98806E' }}
d="M41.051,330.321h28.918h7.581c0.686,0,1.357,0,2.012,0c0.655,0,1.171,0.126,1.545,0.375 c0.499,0.312,0.795,0.764,0.889,1.357c0.094,0.594,0.141,1.296,0.141,2.106c0,0.312,0.014,0.608,0.047,0.888 c0.031,0.281-0.016,0.547-0.14,0.796c-0.25,0.998-0.796,1.543-1.638,1.638c-0.843,0.094-1.888,0.14-3.136,0.14h-9.733H53.778 c-0.998,0-2.058-0.015-3.182-0.047c-1.122-0.03-1.965,0.173-2.526,0.608c-0.563,0.375-0.858,1.03-0.89,1.965 c-0.032,0.937-0.047,1.873-0.047,2.808v9.265c0,0.5-0.015,1.123-0.046,1.872c-0.033,0.749,0.014,1.373,0.139,1.871v0.748 c0.125,0.375,0.235,0.735,0.328,1.077c0.093,0.344,0.295,0.608,0.608,0.796c0.562,0.374,1.436,0.547,2.621,0.514 c1.184-0.03,2.214-0.047,3.088-0.047h16.845c0.561,0,1.171-0.014,1.825-0.046c0.655-0.031,1.31-0.031,1.965,0 c0.655,0.032,1.248,0.109,1.779,0.234c0.529,0.126,0.889,0.344,1.076,0.655c0.311,0.375,0.467,0.843,0.467,1.404 c0,0.56,0,1.186,0,1.871c0,0.375,0,0.719,0,1.03c0,0.313-0.062,0.594-0.186,0.842c-0.25,0.625-0.595,0.969-1.03,1.03 c-0.25,0.125-0.485,0.186-0.702,0.186c-0.219,0-0.483,0.033-0.796,0.094c-0.25,0.063-0.514,0.079-0.796,0.047 c-0.281-0.03-0.546-0.047-0.794-0.047h-3.277H54.433c-0.999,0-2.168-0.014-3.51-0.047c-1.342-0.03-2.292,0.172-2.853,0.609 c-0.314,0.25-0.547,0.64-0.702,1.169c-0.156,0.531-0.25,1.14-0.281,1.825c-0.033,0.687-0.033,1.389,0,2.106 c0.03,0.717,0.046,1.357,0.046,1.918v16.096c0,1.062,0.032,2.217,0.094,3.463c0.062,1.249-0.156,2.185-0.655,2.807 c-0.186,0.25-0.469,0.391-0.842,0.422c-0.374,0.032-0.749,0.109-1.123,0.234h-1.779c-0.875,0-1.684-0.03-2.433-0.093 c-0.749-0.062-1.279-0.375-1.59-0.937c-0.25-0.374-0.375-0.888-0.375-1.543c0-0.656,0-1.326,0-2.013v-7.488v-39.119v-10.107 c0-0.686-0.016-1.45-0.047-2.292c-0.032-0.842,0.107-1.512,0.422-2.012c0.249-0.374,0.715-0.685,1.404-0.935 c0.124-0.062,0.264-0.077,0.42-0.047C40.785,330.4,40.925,330.383,41.051,330.321z"
></path>
<path
style={{ fill: '#98806E' }}
d="M96.895,330.508c0.436,0,0.935-0.014,1.496-0.046c0.563-0.032,1.108-0.032,1.639,0 c0.529,0.032,1.013,0.109,1.45,0.234c0.437,0.125,0.749,0.312,0.937,0.561c0.374,0.438,0.576,1.046,0.608,1.825 c0.03,0.78,0.047,1.576,0.047,2.386v9.079v36.124v10.949c0,0.811,0,1.669,0,2.574c0,0.905-0.156,1.606-0.468,2.105 c-0.25,0.314-0.547,0.5-0.889,0.563c-0.344,0.062-0.765,0.156-1.264,0.28h-1.777c-0.874,0-1.684-0.034-2.433-0.094 c-0.749-0.061-1.28-0.374-1.592-0.935c-0.25-0.374-0.375-0.89-0.375-1.545c0-0.655,0-1.325,0-2.012v-7.487V345.95v-10.107 c0-0.686-0.015-1.451-0.045-2.292c-0.034-0.843,0.108-1.514,0.42-2.013c0.249-0.436,0.748-0.748,1.497-0.936 c0.125-0.061,0.25-0.077,0.375-0.047C96.645,330.587,96.769,330.571,96.895,330.508z"
></path>
<path
style={{ fill: '#98806E' }}
d="M126.093,330.321c0.436,0,0.935-0.015,1.498-0.047c0.561-0.03,1.107-0.03,1.638,0 c0.529,0.032,1.012,0.109,1.451,0.234c0.436,0.125,0.748,0.313,0.936,0.562c0.374,0.5,0.56,1.155,0.56,1.965 c0,0.811,0,1.654,0,2.528v9.826v30.042v8.609c0,0.687,0,1.436,0,2.245c0,0.813,0.094,1.468,0.281,1.967 c0.188,0.436,0.529,0.811,1.03,1.122c0.313,0.125,0.717,0.203,1.217,0.235c0.499,0.032,0.998,0.046,1.498,0.046h4.772h16.845 h5.24c0.436,0,0.89-0.014,1.359-0.046c0.467-0.032,0.888,0.016,1.263,0.139c0.81,0.25,1.34,0.594,1.591,1.03 c0.062,0.188,0.124,0.5,0.188,0.937c0.061,0.436,0.107,0.889,0.139,1.357c0.03,0.467,0.015,0.935-0.047,1.402 c-0.062,0.469-0.126,0.829-0.187,1.077c-0.126,0.437-0.406,0.782-0.843,1.029c-0.313,0.25-0.765,0.375-1.357,0.375 c-0.593,0-1.169,0-1.731,0h-6.177H133.58h-6.083c-0.874,0-1.623-0.047-2.246-0.139c-0.624-0.094-1.092-0.39-1.404-0.89 c-0.25-0.374-0.373-0.857-0.373-1.451c0-0.591,0-1.168,0-1.729v-6.74v-25.549v-20.308v-5.802c0-0.561,0.014-1.122,0.045-1.684 c0.032-0.561,0.141-0.998,0.328-1.31c0.249-0.374,0.717-0.685,1.404-0.935c0.124-0.062,0.265-0.077,0.422-0.047 C125.827,330.4,125.968,330.383,126.093,330.321z"
></path>
<path
style={{ fill: '#98806E' }}
d="M187.883,330.321h30.602h8.049c0.686,0,1.372-0.015,2.059-0.047 c0.685-0.03,1.248,0.109,1.684,0.422c0.188,0.125,0.343,0.281,0.468,0.467c0.124,0.188,0.249,0.375,0.374,0.563 c0.125,0.436,0.187,1.138,0.187,2.105c0,0.967-0.062,1.701-0.187,2.199c-0.25,0.874-0.78,1.357-1.592,1.451 c-0.811,0.094-1.777,0.14-2.9,0.14h-9.359h-15.722c-0.998,0-2.169-0.015-3.51-0.047c-1.343-0.03-2.325,0.139-2.949,0.514 c-0.624,0.374-0.951,1.03-0.982,1.966c-0.032,0.936-0.048,1.904-0.048,2.901v9.546c0,0.688-0.015,1.498-0.045,2.433 c-0.034,0.937,0.014,1.686,0.139,2.246c0.188,0.749,0.562,1.249,1.123,1.498c0.249,0.125,0.686,0.25,1.31,0.373h0.935 c0.249,0.063,0.515,0.08,0.796,0.047c0.281-0.03,0.577-0.047,0.89-0.047h3.181h18.343c0.561,0,1.184-0.014,1.872-0.046 c0.685-0.031,1.387-0.031,2.104,0c0.717,0.032,1.372,0.093,1.967,0.187c0.591,0.094,1.044,0.233,1.357,0.421 c0.186,0.126,0.341,0.407,0.467,0.842c0.125,0.438,0.218,0.905,0.282,1.404c0.061,0.5,0.077,1.016,0.045,1.545 c-0.031,0.531-0.109,0.983-0.233,1.357c-0.126,0.561-0.406,0.967-0.842,1.217c-0.127,0.126-0.297,0.203-0.515,0.235 c-0.22,0.032-0.422,0.079-0.608,0.139h-0.563c-0.312,0.063-0.623,0.079-0.935,0.047c-0.313-0.03-0.624-0.047-0.935-0.047h-3.463 h-19.747c-0.747,0-1.482-0.014-2.199-0.047c-0.719-0.03-1.39,0-2.012,0.094c-0.624,0.094-1.171,0.267-1.639,0.515 c-0.467,0.25-0.794,0.687-0.982,1.31c-0.126,0.375-0.173,0.811-0.139,1.31c0.03,0.5,0.045,0.999,0.045,1.498v5.239v8.892 c0,0.873,0.032,1.669,0.094,2.386c0.062,0.718,0.312,1.233,0.749,1.544c0.435,0.313,0.935,0.483,1.497,0.515 c0.561,0.032,1.185,0.046,1.871,0.046h5.99h17.314h5.334c0.499,0,0.998-0.014,1.497-0.046c0.498-0.032,0.936,0.016,1.31,0.139 c0.749,0.25,1.248,0.594,1.498,1.03c0.123,0.188,0.217,0.5,0.281,0.937c0.061,0.436,0.108,0.889,0.14,1.357 c0.03,0.467,0.015,0.935-0.047,1.402c-0.062,0.469-0.126,0.829-0.187,1.077c-0.25,0.5-0.531,0.842-0.842,1.029 c-0.375,0.25-0.858,0.375-1.45,0.375c-0.594,0-1.171,0-1.731,0h-6.552h-24.894h-6.551c-0.874,0-1.623-0.047-2.245-0.139 c-0.624-0.094-1.093-0.39-1.404-0.89c-0.25-0.374-0.374-0.857-0.374-1.451c0-0.591,0-1.168,0-1.729v-6.551v-25.082v-20.964 v-5.802c0-0.561,0-1.122,0-1.684c0-0.561,0.124-0.998,0.374-1.31c0.249-0.435,0.716-0.749,1.404-0.935 c0.125-0.062,0.248-0.077,0.375-0.047C187.632,330.4,187.756,330.383,187.883,330.321z"
></path>
</g>
</g>
<polygon
style={{ fill: '#BBAF98' }}
points="276.167,208.741 0,302.069 0,186.053 512,186.053 512,302.069 "
></polygon>
</g>
</g>
</svg>
)
export const LogIcon = (props: IconSvgProps) => (
<svg
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g
id="SVGRepo_tracerCarrier"
strokeLinecap="round"
strokeLinejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
<rect width="48" height="48" fill="white" fillOpacity="0.01"></rect>
<rect
x="13"
y="10"
width="28"
height="34"
fill="#2F88FF"
stroke="#000000"
strokeWidth="4"
strokeLinejoin="round"
></rect>
<path
d="M35 10V4H8C7.44772 4 7 4.44772 7 5V38H13"
stroke="#000000"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
></path>
<path
d="M21 22H33"
stroke="white"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
></path>
<path
d="M21 30H33"
stroke="white"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</g>
</svg>
)

View File

@@ -13,7 +13,6 @@ import { useTheme } from '@/hooks/use-theme'
import logo from '@/assets/images/logo.png'
import type { MenuItem } from '@/config/site'
import { title } from '../primitives'
import Menus from './menus'
interface SideBarProps {
@@ -49,18 +48,14 @@ const SideBar: React.FC<SideBarProps> = (props) => {
>
<motion.div className="w-64 flex flex-col items-stretch h-full transition-transform duration-300 ease-in-out z-30 relative float-right">
<div className="flex justify-center items-center mt-2 gap-2">
<Image height={40} src={logo} className="mb-2" />
<Image radius="none" height={40} src={logo} className="mb-2" />
<div
className={clsx(
'flex items-center hm-medium',
title({
shadow: true,
color: isDark ? 'violet' : 'pink'
}),
'!text-2xl'
'!text-2xl shiny-text'
)}
>
WebUI
NapCat
</div>
</div>
<div className="overflow-y-auto flex flex-col flex-1 px-4">

View File

@@ -0,0 +1,89 @@
import clsx from 'clsx'
import { type ReactNode, createContext, forwardRef, useContext } from 'react'
export interface TabsContextValue {
activeKey: string
onChange: (key: string) => void
}
const TabsContext = createContext<TabsContextValue>({
activeKey: '',
onChange: () => {}
})
export interface TabsProps {
activeKey: string
onChange: (key: string) => void
children: ReactNode
className?: string
}
export function Tabs({ activeKey, onChange, children, className }: TabsProps) {
return (
<TabsContext.Provider value={{ activeKey, onChange }}>
<div className={clsx('flex flex-col gap-2', className)}>{children}</div>
</TabsContext.Provider>
)
}
export interface TabListProps {
children: ReactNode
className?: string
}
export function TabList({ children, className }: TabListProps) {
return (
<div className={clsx('flex items-center gap-1', className)}>{children}</div>
)
}
export interface TabProps extends React.ButtonHTMLAttributes<HTMLDivElement> {
value: string
className?: string
children: ReactNode
isSelected?: boolean
}
export const Tab = forwardRef<HTMLDivElement, TabProps>(
({ className, isSelected, value, ...props }, ref) => {
const { onChange } = useContext(TabsContext)
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
onChange(value)
props.onClick?.(e)
}
return (
<div
ref={ref}
role="tab"
aria-selected={isSelected}
onClick={handleClick}
className={clsx(
'px-2 py-1 flex items-center gap-1 text-sm font-medium border-b-2 transition-colors',
isSelected
? 'border-danger text-danger'
: 'border-transparent hover:border-default',
className
)}
{...props}
/>
)
}
)
Tab.displayName = 'Tab'
export interface TabPanelProps {
value: string
children: ReactNode
className?: string
}
export function TabPanel({ value, children, className }: TabPanelProps) {
const { activeKey } = useContext(TabsContext)
if (value !== activeKey) return null
return <div className={clsx('flex-1', className)}>{children}</div>
}

View File

@@ -0,0 +1,38 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Tab } from '@/components/tabs'
import type { TabProps } from '@/components/tabs'
interface SortableTabProps extends TabProps {
id: string
}
export function SortableTab({ id, ...props }: SortableTabProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 1 : 0,
position: 'relative' as const,
touchAction: 'none'
}
return (
<Tab
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
{...props}
/>
)
}

View File

@@ -19,7 +19,7 @@ const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
p: ({ node, ...props }) => <p className="m-0" {...props} />,
a: ({ node, ...props }) => (
<a
className="text-blue-500 hover:underline"
className="text-primary-500 inline-block hover:underline"
target="_blank"
{...props}
/>
@@ -32,12 +32,12 @@ const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
),
blockquote: ({ node, ...props }) => (
<blockquote
className="border-l-4 border-gray-300 pl-4 italic"
className="border-l-4 border-default-300 pl-4 italic"
{...props}
/>
),
code: ({ node, ...props }) => (
<code className="bg-gray-100 p-1 rounded" {...props} />
<code className="bg-default-100 p-1 rounded text-xs" {...props} />
)
}}
>

View File

@@ -0,0 +1,38 @@
import { useEffect, useRef } from 'react'
import TerminalManager from '@/controllers/terminal_manager'
import XTerm, { XTermRef } from '../xterm'
interface TerminalInstanceProps {
id: string
}
export function TerminalInstance({ id }: TerminalInstanceProps) {
const termRef = useRef<XTermRef>(null)
useEffect(() => {
const handleData = (data: string) => {
try {
const parsed = JSON.parse(data)
if (parsed.data) {
termRef.current?.write(parsed.data)
}
} catch (e) {
termRef.current?.write(data)
}
}
TerminalManager.connectTerminal(id, handleData)
return () => {
TerminalManager.disconnectTerminal(id, handleData)
}
}, [id])
const handleInput = (data: string) => {
TerminalManager.sendInput(id, data)
}
return <XTerm ref={termRef} onInput={handleInput} className="w-full h-full" />
}

View File

@@ -0,0 +1,12 @@
export default function UnderConstruction() {
return (
<div className="flex flex-col items-center justify-center h-full pt-4">
<div className="flex flex-col items-center justify-center space-y-4">
<div className="text-6xl font-bold text-gray-500">🚧</div>
<div className="text-2xl font-bold text-gray-500">
Under Construction
</div>
</div>
</div>
)
}

View File

@@ -22,132 +22,146 @@ export type XTermRef = {
clear: () => void
}
const XTerm = forwardRef<XTermRef, React.HTMLAttributes<HTMLDivElement>>(
(props, ref) => {
const domRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
const { className, ...rest } = props
const { theme } = useTheme()
useEffect(() => {
if (!domRef.current) {
return
}
const terminal = new Terminal({
allowTransparency: true,
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace',
cursorInactiveStyle: 'outline',
drawBoldTextInBrightColors: false,
letterSpacing: 0,
lineHeight: 1.0
export interface XTermProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput'> {
onInput?: (data: string) => void
onKey?: (key: string, event: KeyboardEvent) => void
}
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
const domRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
const { className, onInput, onKey, ...rest } = props
const { theme } = useTheme()
useEffect(() => {
if (!domRef.current) {
return
}
const terminal = new Terminal({
allowTransparency: true,
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace',
cursorInactiveStyle: 'outline',
drawBoldTextInBrightColors: false
})
terminalRef.current = terminal
const fitAddon = new FitAddon()
terminal.loadAddon(
new WebLinksAddon((event, uri) => {
if (event.ctrlKey) {
window.open(uri, '_blank')
}
})
terminalRef.current = terminal
const fitAddon = new FitAddon()
terminal.loadAddon(
new WebLinksAddon((event, uri) => {
if (event.ctrlKey) {
window.open(uri, '_blank')
}
)
terminal.loadAddon(fitAddon)
terminal.loadAddon(new WebglAddon())
terminal.open(domRef.current)
terminal.writeln(
gradientText(
'Welcome to NapCat WebUI',
[255, 0, 0],
[0, 255, 0],
true,
true,
true
)
)
terminal.onData((data) => {
if (onInput) {
onInput(data)
}
})
terminal.onKey((event) => {
if (onKey) {
onKey(event.key, event.domEvent)
}
})
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit()
})
// 字体加载完成后重新调整终端大小
document.fonts.ready.then(() => {
fitAddon.fit()
resizeObserver.observe(domRef.current!)
})
return () => {
resizeObserver.disconnect()
setTimeout(() => {
terminal.dispose()
}, 0)
}
}, [])
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.options.theme = {
background: theme === 'dark' ? '#00000000' : '#ffffff00',
foreground: theme === 'dark' ? '#fff' : '#000',
selectionBackground:
theme === 'dark'
? 'rgba(179, 0, 0, 0.3)'
: 'rgba(255, 167, 167, 0.3)',
cursor: theme === 'dark' ? '#fff' : '#000',
cursorAccent: theme === 'dark' ? '#000' : '#fff',
black: theme === 'dark' ? '#fff' : '#000'
}
terminalRef.current.options.fontWeight =
theme === 'dark' ? 'normal' : '600'
terminalRef.current.options.fontWeightBold =
theme === 'dark' ? 'bold' : '900'
}
}, [theme])
useImperativeHandle(
ref,
() => ({
write: (...args) => {
return terminalRef.current?.write(...args)
},
writeAsync: async (data) => {
return new Promise((resolve) => {
terminalRef.current?.write(data, resolve)
})
)
terminal.loadAddon(fitAddon)
terminal.loadAddon(new WebglAddon())
terminal.open(domRef.current)
terminal.writeln(
gradientText(
'Welcome to NapCat WebUI',
[255, 0, 0],
[0, 255, 0],
true,
true,
true
)
)
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit()
})
// 字体加载完成后重新调整终端大小
document.fonts.ready.then(() => {
fitAddon.fit()
resizeObserver.observe(domRef.current!)
})
return () => {
resizeObserver.disconnect()
setTimeout(() => {
terminal.dispose()
}, 0)
},
writeln: (...args) => {
return terminalRef.current?.writeln(...args)
},
writelnAsync: async (data) => {
return new Promise((resolve) => {
terminalRef.current?.writeln(data, resolve)
})
},
clear: () => {
terminalRef.current?.clear()
}
}, [])
}),
[]
)
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.options.theme = {
background: theme === 'dark' ? '#00000000' : '#ffffff00',
foreground: theme === 'dark' ? '#fff' : '#000',
selectionBackground:
theme === 'dark'
? 'rgba(179, 0, 0, 0.3)'
: 'rgba(255, 167, 167, 0.3)',
cursor: theme === 'dark' ? '#fff' : '#000',
cursorAccent: theme === 'dark' ? '#000' : '#fff',
black: theme === 'dark' ? '#fff' : '#000'
}
terminalRef.current.options.fontWeight =
theme === 'dark' ? 'normal' : '600'
terminalRef.current.options.fontWeightBold =
theme === 'dark' ? 'bold' : '900'
}
}, [theme])
useImperativeHandle(
ref,
() => ({
write: (...args) => {
return terminalRef.current?.write(...args)
},
writeAsync: async (data) => {
return new Promise((resolve) => {
terminalRef.current?.write(data, resolve)
})
},
writeln: (...args) => {
return terminalRef.current?.writeln(...args)
},
writelnAsync: async (data) => {
return new Promise((resolve) => {
terminalRef.current?.writeln(data, resolve)
})
},
clear: () => {
terminalRef.current?.clear()
}
}),
[]
)
return (
return (
<div
className={clsx(
'p-2 rounded-md shadow-sm border border-default-200 w-full h-full overflow-hidden bg-opacity-50 backdrop-blur-sm',
theme === 'dark' ? 'bg-black' : 'bg-white',
className
)}
{...rest}
>
<div
className={clsx(
'p-2 rounded-md shadow-sm border border-default-200 w-full h-full overflow-hidden bg-opacity-50 backdrop-blur-sm',
theme === 'dark' ? 'bg-black' : 'bg-white',
className
)}
{...rest}
>
<div
style={{
width: '100%',
height: '100%'
}}
ref={domRef}
></div>
</div>
)
}
)
style={{
width: '100%',
height: '100%'
}}
ref={domRef}
></div>
</div>
)
})
export default XTerm

View File

@@ -1,6 +1,8 @@
import {
BugIcon2,
FileIcon,
InfoIcon,
LogIcon,
RouteIcon,
SettingsIcon,
SignalTowerIcon,
@@ -49,10 +51,10 @@ export const siteConfig = {
href: '/config'
},
{
label: '系统日志',
label: 'NapCat日志',
icon: (
<div className="w-5 h-5">
<TerminalIcon />
<LogIcon />
</div>
),
href: '/logs'
@@ -75,6 +77,24 @@ export const siteConfig = {
}
]
},
{
label: '文件管理',
icon: (
<div className="w-5 h-5">
<FileIcon />
</div>
),
href: '/file_manager'
},
{
label: '系统终端',
icon: (
<div className="w-5 h-5">
<TerminalIcon />
</div>
),
href: '/terminal'
},
{
label: '关于我们',
icon: (

View File

@@ -0,0 +1,98 @@
import { serverRequest } from '@/utils/request'
export interface FileInfo {
name: string
isDirectory: boolean
size: number
mtime: Date
}
export default class FileManager {
public static async listFiles(path: string = '/') {
const { data } = await serverRequest.get<ServerResponse<FileInfo[]>>(
`/File/list?path=${encodeURIComponent(path)}`
)
return data.data
}
// 新增:按目录获取
public static async listDirectories(path: string = '/') {
const { data } = await serverRequest.get<ServerResponse<FileInfo[]>>(
`/File/list?path=${encodeURIComponent(path)}&onlyDirectory=true`
)
return data.data
}
public static async createDirectory(path: string): Promise<boolean> {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/mkdir',
{ path }
)
return data.data
}
public static async delete(path: string) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/delete',
{ path }
)
return data.data
}
public static async readFile(path: string) {
const { data } = await serverRequest.get<ServerResponse<string>>(
`/File/read?path=${encodeURIComponent(path)}`
)
return data.data
}
public static async writeFile(path: string, content: string) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/write',
{ path, content }
)
return data.data
}
public static async createFile(path: string): Promise<boolean> {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/create',
{ path }
)
return data.data
}
public static async batchDelete(paths: string[]) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/batchDelete',
{ paths }
)
return data.data
}
public static async rename(oldPath: string, newPath: string) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/rename',
{ oldPath, newPath }
)
return data.data
}
public static async move(sourcePath: string, targetPath: string) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/move',
{ sourcePath, targetPath }
)
return data.data
}
public static async batchMove(
items: { sourcePath: string; targetPath: string }[]
) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/File/batchMove',
{ items }
)
return data.data
}
}

View File

@@ -0,0 +1,118 @@
import { serverRequest } from '@/utils/request'
type TerminalCallback = (data: string) => void
interface TerminalConnection {
ws: WebSocket
callbacks: Set<TerminalCallback>
isConnected: boolean
buffer: string[] // 添加缓存数组
}
export interface TerminalSession {
id: string
}
export interface TerminalInfo {
id: string
}
class TerminalManager {
private connections: Map<string, TerminalConnection> = new Map()
private readonly MAX_BUFFER_SIZE = 1000 // 限制缓存大小
async createTerminal(cols: number, rows: number): Promise<TerminalSession> {
const { data } = await serverRequest.post<ServerResponse<TerminalSession>>(
'/Log/terminal/create',
{ cols, rows }
)
return data.data
}
async closeTerminal(id: string): Promise<void> {
await serverRequest.post(`/Log/terminal/${id}/close`)
}
async getTerminalList(): Promise<TerminalInfo[]> {
const { data } =
await serverRequest.get<ServerResponse<TerminalInfo[]>>(
'/Log/terminal/list'
)
return data.data
}
connectTerminal(id: string, callback: TerminalCallback): WebSocket {
let conn = this.connections.get(id)
if (!conn) {
const url = new URL(window.location.href)
url.protocol = url.protocol.replace('http', 'ws')
url.pathname = `/api/ws/terminal`
url.searchParams.set('id', id)
const token = JSON.parse(localStorage.getItem('token') || '')
if (!token) {
throw new Error('No token found')
}
url.searchParams.set('token', token)
const ws = new WebSocket(url.toString())
conn = {
ws,
callbacks: new Set([callback]),
isConnected: false,
buffer: [] // 初始化缓存
}
ws.onmessage = (event) => {
const data = event.data
// 保存到缓存
conn?.buffer.push(data)
if ((conn?.buffer.length ?? 0) > this.MAX_BUFFER_SIZE) {
conn?.buffer.shift()
}
conn?.callbacks.forEach((cb) => cb(data))
}
ws.onopen = () => {
if (conn) conn.isConnected = true
}
ws.onclose = () => {
if (conn) conn.isConnected = false
}
this.connections.set(id, conn)
} else {
conn.callbacks.add(callback)
// 恢复历史内容
conn.buffer.forEach((data) => callback(data))
}
return conn.ws
}
disconnectTerminal(id: string, callback: TerminalCallback) {
const conn = this.connections.get(id)
if (!conn) return
conn.callbacks.delete(callback)
}
removeTerminal(id: string) {
const conn = this.connections.get(id)
if (conn?.ws.readyState === WebSocket.OPEN) {
conn.ws.close()
}
this.connections.delete(id)
}
sendInput(id: string, data: string) {
const conn = this.connections.get(id)
if (conn?.ws.readyState === WebSocket.OPEN) {
conn.ws.send(JSON.stringify({ type: 'input', data }))
}
}
}
const terminalManager = new TerminalManager()
export default terminalManager

View File

@@ -9,6 +9,14 @@ export interface Log {
message: string
}
export interface TerminalSession {
id: string
}
export interface TerminalInfo {
id: string
}
export default class WebUIManager {
public static async checkWebUiLogined() {
const { data } =
@@ -24,12 +32,20 @@ export default class WebUIManager {
return data.data.Credential
}
public static async changePassword(oldToken: string, newToken: string) {
const { data } = await serverRequest.post<ServerResponse<boolean>>(
'/auth/update_token',
{ oldToken, newToken }
)
return data.data
}
public static async proxy<T>(url = '') {
const data = await serverRequest.get<ServerResponse<string>>(
'/base/proxy?url=' + encodeURIComponent(url)
)
data.data.data = JSON.parse(data.data.data)
return data.data as ServerResponse<T>
return data.data as ServerResponse<T>
}
public static async getPackageInfo() {

View File

@@ -14,10 +14,12 @@ const useConfig = () => {
key: T,
value: OneBotConfig['network'][T][0]
) => {
if (
value.name &&
config.network[key].some((item) => item.name === value.name)
) {
const allNetworkNames = Object.keys(config.network).reduce((acc, key) => {
const _key = key as keyof OneBotConfig['network']
return acc.concat(config.network[_key].map((item) => item.name))
}, [] as string[])
if (value.name && allNetworkNames.includes(value.name)) {
throw new Error('已经存在相同的配置项名')
}

View File

@@ -4,28 +4,17 @@ import { Spinner } from '@heroui/spinner'
import { useRequest } from 'ahooks'
import clsx from 'clsx'
import { BietiaopIcon, WebUIIcon } from '@/components/icons'
import HoverTiltedCard from '@/components/hover_titled_card'
import NapCatRepoInfo from '@/components/napcat_repo_info'
import { title } from '@/components/primitives'
import logo from '@/assets/images/logo.png'
import WebUIManager from '@/controllers/webui_manager'
import packageJson from '../../../package.json'
function VersionInfo() {
const { data, loading, error } = useRequest(WebUIManager.getPackageInfo)
return (
<div className="flex items-center gap-2 mb-5">
<Chip
startContent={
<Chip color="danger" size="sm" className="-ml-0.5 select-none">
WebUI
</Chip>
}
>
{packageJson.version}
</Chip>
<Chip
startContent={
<Chip color="warning" size="sm" className="-ml-0.5 select-none">
@@ -51,21 +40,8 @@ export default function AboutPage() {
<title> NapCat WebUI</title>
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10">
<div className="max-w-full w-[1000px] px-5 flex flex-col items-center">
<div className="flex flex-col md:flex-row items-center">
<Image
alt="logo"
className="flex-shrink-0 w-52 md:w-48 mr-2"
src={logo}
/>
<div className="flex -mt-9 md:mt-0">
<WebUIIcon />
</div>
</div>
<div className="flex opacity-60 items-center gap-2 mb-2 font-ubuntu">
Created By
<div className="flex scale-80 -ml-5 -mr-5">
<BietiaopIcon />
</div>
<div className="flex flex-col md:flex-row items-center mb-6">
<HoverTiltedCard imageSrc={logo} />
</div>
<VersionInfo />
<div className="mb-6 flex flex-col items-center gap-4">

View File

@@ -0,0 +1,81 @@
import { Input } from '@heroui/input'
import { useLocalStorage } from '@uidotdev/usehooks'
import { Controller, useForm } from 'react-hook-form'
import toast from 'react-hot-toast'
import { useNavigate } from 'react-router-dom'
import key from '@/const/key'
import SaveButtons from '@/components/button/save_buttons'
import WebUIManager from '@/controllers/webui_manager'
const ChangePasswordCard = () => {
const {
control,
handleSubmit: handleWebuiSubmit,
formState: { isSubmitting },
reset
} = useForm<{
oldToken: string
newToken: string
}>({
defaultValues: {
oldToken: '',
newToken: ''
}
})
const navigate = useNavigate()
const [_, setToken] = useLocalStorage(key.token, '')
const onSubmit = handleWebuiSubmit(async (data) => {
try {
await WebUIManager.changePassword(data.oldToken, data.newToken)
toast.success('修改成功')
setToken('')
localStorage.removeItem(key.token)
navigate('/web_login')
} catch (error) {
const msg = (error as Error).message
toast.error(`修改失败: ${msg}`)
}
})
return (
<>
<title> - NapCat WebUI</title>
<Controller
control={control}
name="oldToken"
render={({ field }) => (
<Input
{...field}
label="旧密码"
placeholder="请输入旧密码"
type="password"
/>
)}
/>
<Controller
control={control}
name="newToken"
render={({ field }) => (
<Input
{...field}
label="新密码"
placeholder="请输入新密码"
type="password"
/>
)}
/>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
/>
</>
)
}
export default ChangePasswordCard

View File

@@ -1,111 +1,27 @@
import { Card, CardBody } from '@heroui/card'
import { Tab, Tabs } from '@heroui/tabs'
import { useLocalStorage } from '@uidotdev/usehooks'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import toast from 'react-hot-toast'
import { useMediaQuery } from 'react-responsive'
import key from '@/const/key'
import PageLoading from '@/components/page_loading'
import useConfig from '@/hooks/use-config'
import useMusic from '@/hooks/use-music'
import ChangePasswordCard from './change_password'
import OneBotConfigCard from './onebot'
import WebUIConfigCard from './webui'
export default function ConfigPage() {
const { config, saveConfigWithoutNetwork, refreshConfig } = useConfig()
const [loading, setLoading] = useState(false)
const {
control: onebotControl,
handleSubmit: handleOnebotSubmit,
formState: { isSubmitting: isOnebotSubmitting },
setValue: setOnebotValue
} = useForm<IConfig['onebot']>({
defaultValues: {
musicSignUrl: '',
enableLocalFile2Url: false,
parseMultMsg: false
}
})
export interface ConfigPageProps {
children?: React.ReactNode
}
const {
control: webuiControl,
handleSubmit: handleWebuiSubmit,
formState: { isSubmitting: isWebuiSubmitting },
setValue: setWebuiValue
} = useForm<IConfig['webui']>({
defaultValues: {
background: '',
musicListID: '',
customIcons: {}
}
})
const isMediumUp = useMediaQuery({ minWidth: 768 })
const [b64img, setB64img] = useLocalStorage(key.backgroundImage, '')
const [customIcons, setCustomIcons] = useLocalStorage<Record<string, string>>(
key.customIcons,
{}
const ConfingPageItem: React.FC<ConfigPageProps> = ({ children }) => {
return (
<Card className="bg-opacity-50 backdrop-blur-sm">
<CardBody className="items-center py-5">
<div className="w-96 max-w-full flex flex-col gap-2">{children}</div>
</CardBody>
</Card>
)
const { setListId, listId } = useMusic()
const resetOneBot = () => {
setOnebotValue('musicSignUrl', config.musicSignUrl)
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url)
setOnebotValue('parseMultMsg', config.parseMultMsg)
}
}
const resetWebUI = () => {
setWebuiValue('musicListID', listId)
setWebuiValue('customIcons', customIcons)
setWebuiValue('background', b64img)
}
const onOneBotSubmit = handleOnebotSubmit((data) => {
try {
saveConfigWithoutNetwork(data)
toast.success('保存成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`保存失败: ${msg}`)
}
})
const onWebuiSubmit = handleWebuiSubmit((data) => {
try {
setListId(data.musicListID)
setCustomIcons(data.customIcons)
setB64img(data.background)
toast.success('保存成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`保存失败: ${msg}`)
}
})
const onRefresh = async (shotTip = true) => {
try {
setLoading(true)
await refreshConfig()
if (shotTip) toast.success('刷新成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`刷新失败: ${msg}`)
} finally {
setLoading(false)
}
}
useEffect(() => {
resetOneBot()
resetWebUI()
}, [config])
useEffect(() => {
onRefresh(false)
}, [])
export default function ConfigPage() {
const isMediumUp = useMediaQuery({ minWidth: 768 })
return (
<section className="w-[1000px] max-w-full md:mx-auto gap-4 py-8 px-2 md:py-10">
@@ -122,23 +38,20 @@ export default function ConfigPage() {
}}
>
<Tab title="OneBot配置" key="onebot">
<PageLoading loading={loading} />
<OneBotConfigCard
isSubmitting={isOnebotSubmitting}
onRefresh={onRefresh}
onSubmit={onOneBotSubmit}
control={onebotControl}
reset={resetOneBot}
/>
<ConfingPageItem>
<OneBotConfigCard />
</ConfingPageItem>
</Tab>
<Tab title="WebUI配置" key="webui">
<WebUIConfigCard
isSubmitting={isWebuiSubmitting}
onRefresh={onRefresh}
onSubmit={onWebuiSubmit}
control={webuiControl}
reset={resetWebUI}
/>
<ConfingPageItem>
<WebUIConfigCard />
</ConfingPageItem>
</Tab>
<Tab title="修改密码" key="token">
<ConfingPageItem>
<ChangePasswordCard />
</ConfingPageItem>
</Tab>
</Tabs>
</section>

View File

@@ -1,68 +1,110 @@
import { Card, CardBody } from '@heroui/card'
import { Input } from '@heroui/input'
import { Controller } from 'react-hook-form'
import type { Control } from 'react-hook-form'
import { useEffect, useState } from 'react'
import { Controller, useForm } from 'react-hook-form'
import toast from 'react-hot-toast'
import SaveButtons from '@/components/button/save_buttons'
import PageLoading from '@/components/page_loading'
import SwitchCard from '@/components/switch_card'
export interface OneBotConfigCardProps {
control: Control<IConfig['onebot']>
onSubmit: () => void
reset: () => void
isSubmitting: boolean
onRefresh: () => void
}
const OneBotConfigCard: React.FC<OneBotConfigCardProps> = (props) => {
const { control, onSubmit, reset, isSubmitting, onRefresh } = props
import useConfig from '@/hooks/use-config'
const OneBotConfigCard = () => {
const { config, saveConfigWithoutNetwork, refreshConfig } = useConfig()
const [loading, setLoading] = useState(false)
const {
control,
handleSubmit: handleOnebotSubmit,
formState: { isSubmitting },
setValue: setOnebotValue
} = useForm<IConfig['onebot']>({
defaultValues: {
musicSignUrl: '',
enableLocalFile2Url: false,
parseMultMsg: false
}
})
const reset = () => {
setOnebotValue('musicSignUrl', config.musicSignUrl)
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url)
setOnebotValue('parseMultMsg', config.parseMultMsg)
}
const onSubmit = handleOnebotSubmit((data) => {
try {
saveConfigWithoutNetwork(data)
toast.success('保存成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`保存失败: ${msg}`)
}
})
const onRefresh = async (shotTip = true) => {
try {
setLoading(true)
await refreshConfig()
if (shotTip) toast.success('刷新成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`刷新失败: ${msg}`)
} finally {
setLoading(false)
}
}
useEffect(() => {
reset()
}, [config])
useEffect(() => {
onRefresh(false)
}, [])
if (loading) return <PageLoading loading={true} />
return (
<>
<title>OneBot配置 - NapCat WebUI</title>
<Card className="bg-opacity-50 backdrop-blur-sm">
<CardBody className="items-center py-5">
<div className="w-96 max-w-full flex flex-col gap-2">
<Controller
control={control}
name="musicSignUrl"
render={({ field }) => (
<Input
{...field}
label="音乐签名地址"
placeholder="请输入音乐签名地址"
/>
)}
/>
<Controller
control={control}
name="enableLocalFile2Url"
render={({ field }) => (
<SwitchCard
{...field}
description="启用本地文件到URL"
label="启用本地文件到URL"
/>
)}
/>
<Controller
control={control}
name="parseMultMsg"
render={({ field }) => (
<SwitchCard
{...field}
description="启用上报解析合并消息"
label="启用上报解析合并消息"
/>
)}
/>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
refresh={onRefresh}
/>
</div>
</CardBody>
</Card>
<Controller
control={control}
name="musicSignUrl"
render={({ field }) => (
<Input
{...field}
label="音乐签名地址"
placeholder="请输入音乐签名地址"
/>
)}
/>
<Controller
control={control}
name="enableLocalFile2Url"
render={({ field }) => (
<SwitchCard
{...field}
description="启用本地文件到URL"
label="启用本地文件到URL"
/>
)}
/>
<Controller
control={control}
name="parseMultMsg"
render={({ field }) => (
<SwitchCard
{...field}
description="启用上报解析合并消息"
label="启用上报解析合并消息"
/>
)}
/>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
refresh={onRefresh}
/>
</>
)
}

View File

@@ -1,69 +1,99 @@
import { Card, CardBody } from '@heroui/card'
import { Input } from '@heroui/input'
import { Controller } from 'react-hook-form'
import type { Control } from 'react-hook-form'
import { useLocalStorage } from '@uidotdev/usehooks'
import { useEffect } from 'react'
import { Controller, useForm } from 'react-hook-form'
import toast from 'react-hot-toast'
import key from '@/const/key'
import SaveButtons from '@/components/button/save_buttons'
import ImageInput from '@/components/input/image_input'
import useMusic from '@/hooks/use-music'
import { siteConfig } from '@/config/site'
export interface WebUIConfigCardProps {
control: Control<IConfig['webui']>
onSubmit: () => void
reset: () => void
isSubmitting: boolean
onRefresh: () => void
}
const WebUIConfigCard: React.FC<WebUIConfigCardProps> = (props) => {
const { control, onSubmit, reset, isSubmitting, onRefresh } = props
const WebUIConfigCard = () => {
const {
control,
handleSubmit: handleWebuiSubmit,
formState: { isSubmitting },
setValue: setWebuiValue
} = useForm<IConfig['webui']>({
defaultValues: {
background: '',
musicListID: '',
customIcons: {}
}
})
const [b64img, setB64img] = useLocalStorage(key.backgroundImage, '')
const [customIcons, setCustomIcons] = useLocalStorage<Record<string, string>>(
key.customIcons,
{}
)
const { setListId, listId } = useMusic()
const reset = () => {
setWebuiValue('musicListID', listId)
setWebuiValue('customIcons', customIcons)
setWebuiValue('background', b64img)
}
const onSubmit = handleWebuiSubmit((data) => {
try {
setListId(data.musicListID)
setCustomIcons(data.customIcons)
setB64img(data.background)
toast.success('保存成功')
} catch (error) {
const msg = (error as Error).message
toast.error(`保存失败: ${msg}`)
}
})
useEffect(() => {
reset()
}, [listId, customIcons, b64img])
return (
<>
<title>WebUI配置 - NapCat WebUI</title>
<Card className="bg-opacity-50 backdrop-blur-sm">
<CardBody className="items-center py-5">
<div className="w-96 max-w-full flex flex-col gap-2">
<Controller
control={control}
name="musicListID"
render={({ field }) => (
<Input
{...field}
label="网易云音乐歌单ID网页内音乐播放器"
placeholder="请输入歌单ID"
/>
)}
/>
<div className="flex flex-col gap-2">
<div className="flex-shrink-0 w-full"></div>
<Controller
control={control}
name="background"
render={({ field }) => <ImageInput {...field} />}
/>
</div>
<div className="flex flex-col gap-2">
<div></div>
{siteConfig.navItems.map((item) => (
<Controller
key={item.label}
control={control}
name={`customIcons.${item.label}`}
render={({ field }) => (
<ImageInput {...field} label={item.label} />
)}
/>
))}
</div>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
refresh={onRefresh}
/>
</div>
</CardBody>
</Card>
<Controller
control={control}
name="musicListID"
render={({ field }) => (
<Input
{...field}
label="网易云音乐歌单ID网页内音乐播放器"
placeholder="请输入歌单ID"
/>
)}
/>
<div className="flex flex-col gap-2">
<div className="flex-shrink-0 w-full"></div>
<Controller
control={control}
name="background"
render={({ field }) => <ImageInput {...field} />}
/>
</div>
<div className="flex flex-col gap-2">
<div></div>
{siteConfig.navItems.map((item) => (
<Controller
key={item.label}
control={control}
name={`customIcons.${item.label}`}
render={({ field }) => <ImageInput {...field} label={item.label} />}
/>
))}
</div>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
/>
</>
)
}

View File

@@ -0,0 +1,433 @@
import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs'
import { Button } from '@heroui/button'
import { Input } from '@heroui/input'
import type { Selection, SortDescriptor } from '@react-types/shared'
import path from 'path-browserify'
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { FiMove, FiPlus } from 'react-icons/fi'
import { MdRefresh } from 'react-icons/md'
import { TbTrash } from 'react-icons/tb'
import { TiArrowBack } from 'react-icons/ti'
import { useLocation, useNavigate } from 'react-router-dom'
import CreateFileModal from '@/components/file_manage/create_file_modal'
import FileEditModal from '@/components/file_manage/file_edit_modal'
import FileTable from '@/components/file_manage/file_table'
import MoveModal from '@/components/file_manage/move_modal'
import RenameModal from '@/components/file_manage/rename_modal'
import useDialog from '@/hooks/use-dialog'
import FileManager, { FileInfo } from '@/controllers/file_manager'
export default function FileManagerPage() {
const [files, setFiles] = useState<FileInfo[]>([])
const [loading, setLoading] = useState(false)
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
column: 'name',
direction: 'ascending'
})
const dialog = useDialog()
const location = useLocation()
const navigate = useNavigate()
// 修改 currentPath 初始化逻辑,去掉可能的前导斜杠
let currentPath = decodeURIComponent(location.hash.slice(1) || '/')
if (/^\/[A-Z]:$/i.test(currentPath)) {
currentPath = currentPath.slice(1)
}
const [editingFile, setEditingFile] = useState<{
path: string
content: string
} | null>(null)
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [newFileName, setNewFileName] = useState('')
const [fileType, setFileType] = useState<'file' | 'directory'>('file')
const [selectedFiles, setSelectedFiles] = useState<Selection>(new Set())
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false)
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false)
const [renamingFile, setRenamingFile] = useState<string>('')
const [moveTargetPath, setMoveTargetPath] = useState('')
const [jumpPath, setJumpPath] = useState('')
const sortFiles = (files: FileInfo[], descriptor: typeof sortDescriptor) => {
return [...files].sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
const direction = descriptor.direction === 'ascending' ? 1 : -1
switch (descriptor.column) {
case 'name':
return direction * a.name.localeCompare(b.name)
case 'type': {
const aType = a.isDirectory ? '目录' : '文件'
const bType = a.isDirectory ? '目录' : '文件'
return direction * aType.localeCompare(bType)
}
case 'size':
return direction * ((a.size || 0) - (b.size || 0))
case 'mtime':
return (
direction *
(new Date(a.mtime).getTime() - new Date(b.mtime).getTime())
)
default:
return 0
}
})
}
const loadFiles = async () => {
setLoading(true)
try {
const fileList = await FileManager.listFiles(currentPath)
setFiles(sortFiles(fileList, sortDescriptor))
} catch (error) {
toast.error('加载文件列表失败')
setFiles([])
}
setLoading(false)
}
useEffect(() => {
loadFiles()
}, [currentPath])
const handleSortChange = (descriptor: typeof sortDescriptor) => {
setSortDescriptor(descriptor)
setFiles((prev) => sortFiles(prev, descriptor))
}
const handleDirectoryClick = (dirPath: string) => {
if (dirPath === '..') {
if (/^[A-Z]:$/i.test(currentPath)) {
navigate('/file_manager#/')
return
}
const parentPath = path.dirname(currentPath)
navigate(
`/file_manager#${encodeURIComponent(parentPath === currentPath ? '/' : parentPath)}`
)
return
}
navigate(
`/file_manager#${encodeURIComponent(path.join(currentPath, dirPath))}`
)
}
const handleEdit = async (filePath: string) => {
try {
const content = await FileManager.readFile(filePath)
setEditingFile({ path: filePath, content })
} catch (error) {
toast.error('打开文件失败')
}
}
const handleSave = async () => {
if (!editingFile) return
try {
await FileManager.writeFile(editingFile.path, editingFile.content)
toast.success('保存成功')
setEditingFile(null)
loadFiles()
} catch (error) {
toast.error('保存失败')
}
}
const handleDelete = async (filePath: string) => {
dialog.confirm({
title: '删除文件',
content: <div> {filePath} </div>,
onConfirm: async () => {
try {
await FileManager.delete(filePath)
toast.success('删除成功')
loadFiles()
} catch (error) {
toast.error('删除失败')
}
}
})
}
const handleCreate = async () => {
if (!newFileName) return
const newPath = path.join(currentPath, newFileName)
try {
if (fileType === 'directory') {
if (!(await FileManager.createDirectory(newPath))) {
toast.error('目录已存在')
return
}
} else {
if (!(await FileManager.createFile(newPath))) {
toast.error('文件已存在')
return
}
}
toast.success('创建成功')
setIsCreateModalOpen(false)
setNewFileName('')
loadFiles()
} catch (error) {
toast.error((error as Error)?.message || '创建失败')
}
}
const handleBatchDelete = async () => {
const selectedArray =
selectedFiles instanceof Set
? Array.from(selectedFiles)
: files.map((f) => f.name)
if (selectedArray.length === 0) return
dialog.confirm({
title: '批量删除',
content: <div> {selectedArray.length} </div>,
onConfirm: async () => {
try {
const paths = selectedArray.map((key) =>
path.join(currentPath, key.toString())
)
await FileManager.batchDelete(paths)
toast.success('批量删除成功')
setSelectedFiles(new Set())
loadFiles()
} catch (error) {
toast.error('批量删除失败')
}
}
})
}
const handleRename = async () => {
if (!renamingFile || !newFileName) return
try {
await FileManager.rename(
path.join(currentPath, renamingFile),
path.join(currentPath, newFileName)
)
toast.success('重命名成功')
setIsRenameModalOpen(false)
setRenamingFile('')
setNewFileName('')
loadFiles()
} catch (error) {
toast.error('重命名失败')
}
}
const handleMove = async (sourceName: string) => {
if (!moveTargetPath) return
try {
await FileManager.move(
path.join(currentPath, sourceName),
path.join(moveTargetPath, sourceName)
)
toast.success('移动成功')
setIsMoveModalOpen(false)
setMoveTargetPath('')
loadFiles()
} catch (error) {
toast.error('移动失败')
}
}
const handleBatchMove = async () => {
if (!moveTargetPath) return
const selectedArray =
selectedFiles instanceof Set
? Array.from(selectedFiles)
: files.map((f) => f.name)
if (selectedArray.length === 0) return
try {
const items = selectedArray.map((name) => ({
sourcePath: path.join(currentPath, name.toString()),
targetPath: path.join(moveTargetPath, name.toString())
}))
await FileManager.batchMove(items)
toast.success('批量移动成功')
setIsMoveModalOpen(false)
setMoveTargetPath('')
setSelectedFiles(new Set())
loadFiles()
} catch (error) {
toast.error('批量移动失败')
}
}
const handleCopyPath = (fileName: string) => {
navigator.clipboard.writeText(path.join(currentPath, fileName))
toast.success('路径已复制')
}
const handleMoveClick = (fileName: string) => {
setRenamingFile(fileName)
setMoveTargetPath('')
setIsMoveModalOpen(true)
}
return (
<div className="p-4">
<div className="mb-4 flex items-center gap-4 sticky top-14 z-10 bg-content1 py-1">
<Button
color="danger"
size="sm"
isIconOnly
variant="flat"
onPress={() => handleDirectoryClick('..')}
className="text-lg"
>
<TiArrowBack />
</Button>
<Button
color="danger"
size="sm"
isIconOnly
variant="flat"
onPress={() => setIsCreateModalOpen(true)}
className="text-lg"
>
<FiPlus />
</Button>
<Button
color="danger"
isLoading={loading}
size="sm"
isIconOnly
variant="flat"
onPress={loadFiles}
className="text-lg"
>
<MdRefresh />
</Button>
{((selectedFiles instanceof Set && selectedFiles.size > 0) ||
selectedFiles === 'all') && (
<>
<Button
color="danger"
size="sm"
variant="flat"
onPress={handleBatchDelete}
className="text-sm"
startContent={<TbTrash className="text-lg" />}
>
(
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
)
</Button>
<Button
color="danger"
size="sm"
variant="flat"
onPress={() => {
setMoveTargetPath('')
setIsMoveModalOpen(true)
}}
className="text-sm"
startContent={<FiMove className="text-lg" />}
>
(
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
)
</Button>
</>
)}
<Breadcrumbs className="flex-1 shadow-small px-2 py-2 rounded-lg">
{currentPath.split('/').map((part, index, parts) => (
<BreadcrumbItem
key={part}
isCurrent={index === parts.length - 1}
onPress={() => {
const newPath = parts.slice(0, index + 1).join('/')
navigate(`/file_manager#${encodeURIComponent(newPath)}`)
}}
>
{part}
</BreadcrumbItem>
))}
</Breadcrumbs>
<Input
type="text"
placeholder="输入跳转路径"
value={jumpPath}
onChange={(e) => setJumpPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && jumpPath.trim() !== '') {
navigate(`/file_manager#${encodeURIComponent(jumpPath.trim())}`)
}
}}
className="ml-auto w-64"
/>
</div>
<FileTable
files={files}
currentPath={currentPath}
loading={loading}
sortDescriptor={sortDescriptor}
onSortChange={handleSortChange}
selectedFiles={selectedFiles}
onSelectionChange={setSelectedFiles}
onDirectoryClick={handleDirectoryClick}
onEdit={handleEdit}
onRenameRequest={(name) => {
setRenamingFile(name)
setNewFileName(name)
setIsRenameModalOpen(true)
}}
onMoveRequest={handleMoveClick}
onCopyPath={handleCopyPath}
onDelete={handleDelete}
/>
<FileEditModal
isOpen={!!editingFile}
file={editingFile}
onClose={() => setEditingFile(null)}
onSave={handleSave}
onContentChange={(newContent) =>
setEditingFile((prev) =>
prev ? { ...prev, content: newContent ?? '' } : null
)
}
/>
<CreateFileModal
isOpen={isCreateModalOpen}
fileType={fileType}
newFileName={newFileName}
onTypeChange={setFileType}
onNameChange={(e) => setNewFileName(e.target.value)}
onClose={() => setIsCreateModalOpen(false)}
onCreate={handleCreate}
/>
<RenameModal
isOpen={isRenameModalOpen}
newFileName={newFileName}
onNameChange={(e) => setNewFileName(e.target.value)}
onClose={() => setIsRenameModalOpen(false)}
onRename={handleRename}
/>
<MoveModal
isOpen={isMoveModalOpen}
moveTargetPath={moveTargetPath}
selectionInfo={
selectedFiles instanceof Set && selectedFiles.size > 0
? `${selectedFiles.size} 个项目`
: renamingFile
}
onClose={() => setIsMoveModalOpen(false)}
onMove={() =>
selectedFiles instanceof Set && selectedFiles.size > 0
? handleBatchMove()
: handleMove(renamingFile)
}
onSelect={(dir) => setMoveTargetPath(dir)} // 替换原有 onTargetChange
/>
</div>
)
}

View File

@@ -0,0 +1,171 @@
import {
DndContext,
DragEndEvent,
PointerSensor,
closestCenter,
useSensor,
useSensors
} from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
horizontalListSortingStrategy
} from '@dnd-kit/sortable'
import { Button } from '@heroui/button'
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { IoAdd, IoClose } from 'react-icons/io5'
import { TabList, TabPanel, Tabs } from '@/components/tabs'
import { SortableTab } from '@/components/tabs/sortable_tab.tsx'
import { TerminalInstance } from '@/components/terminal/terminal-instance'
import terminalManager from '@/controllers/terminal_manager'
interface TerminalTab {
id: string
title: string
}
export default function TerminalPage() {
const [tabs, setTabs] = useState<TerminalTab[]>([])
const [selectedTab, setSelectedTab] = useState<string>('')
useEffect(() => {
// 获取已存在的终端列表
terminalManager.getTerminalList().then((terminals) => {
if (terminals.length === 0) return
const newTabs = terminals.map((terminal) => ({
id: terminal.id,
title: terminal.id
}))
setTabs(newTabs)
setSelectedTab(newTabs[0].id)
})
}, [])
const createNewTerminal = async () => {
try {
const { id } = await terminalManager.createTerminal(80, 24)
const newTab = {
id,
title: id
}
setTabs((prev) => [...prev, newTab])
setSelectedTab(id)
} catch (error) {
console.error('Failed to create terminal:', error)
toast.error('创建终端失败')
}
}
const closeTerminal = async (id: string) => {
try {
await terminalManager.closeTerminal(id)
terminalManager.removeTerminal(id)
if (selectedTab === id) {
const previousIndex = tabs.findIndex((tab) => tab.id === id) - 1
if (previousIndex >= 0) {
setSelectedTab(tabs[previousIndex].id)
} else {
setSelectedTab(tabs[0]?.id || '')
}
}
setTabs((prev) => prev.filter((tab) => tab.id !== id))
} catch (error) {
toast.error('关闭终端失败')
}
}
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (active.id !== over?.id) {
setTabs((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id)
const newIndex = items.findIndex((item) => item.id === over?.id)
return arrayMove(items, oldIndex, newIndex)
})
}
}
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8
}
})
)
return (
<div className="flex flex-col gap-2 p-4">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<Tabs
activeKey={selectedTab}
onChange={setSelectedTab}
className="h-full overflow-hidden"
>
<div className="flex items-center gap-2 flex-shrink-0 flex-grow-0">
<TabList className="flex-1 !overflow-x-auto w-full hide-scrollbar">
<SortableContext
items={tabs}
strategy={horizontalListSortingStrategy}
>
{tabs.map((tab) => (
<SortableTab
key={tab.id}
id={tab.id}
value={tab.id}
isSelected={selectedTab === tab.id}
className="flex gap-2 items-center flex-shrink-0"
>
{tab.title}
<Button
isIconOnly
radius="full"
variant="flat"
size="sm"
className="min-w-0 w-4 h-4 flex-shrink-0"
onPress={() => closeTerminal(tab.id)}
color={selectedTab === tab.id ? 'danger' : 'default'}
>
<IoClose />
</Button>
</SortableTab>
))}
</SortableContext>
</TabList>
<Button
isIconOnly
color="danger"
size="sm"
variant="flat"
onPress={createNewTerminal}
startContent={<IoAdd />}
className="text-xl"
/>
</div>
<div className="flex-grow overflow-hidden">
{tabs.length === 0 && (
<div className="flex flex-col gap-2 items-center justify-center h-full text-gray-500 py-5">
<IoAdd className="text-4xl" />
<div className="text-sm"></div>
</div>
)}
{tabs.map((tab) => (
<TabPanel key={tab.id} value={tab.id} className="h-full">
<TerminalInstance id={tab.id} />
</TabPanel>
))}
</div>
</Tabs>
</DndContext>
</div>
)
}

View File

@@ -9,8 +9,10 @@ import ConfigPage from './dashboard/config'
import DebugPage from './dashboard/debug'
import HttpDebug from './dashboard/debug/http'
import WSDebug from './dashboard/debug/websocket'
import FileManagerPage from './dashboard/file_manager'
import LogsPage from './dashboard/logs'
import NetworkPage from './dashboard/network'
import TerminalPage from './dashboard/terminal'
export default function IndexPage() {
const location = useLocation()
@@ -33,6 +35,8 @@ export default function IndexPage() {
<Route path="ws" element={<WSDebug />} />
<Route path="http" element={<HttpDebug />} />
</Route>
<Route element={<FileManagerPage />} path="/file_manager" />
<Route element={<TerminalPage />} path="/terminal" />
<Route element={<AboutPage />} path="/about" />
</Routes>
</motion.div>

View File

@@ -1,15 +1,26 @@
@import url("./fonts.css");
@import url('./fonts.css');
@import url('./text.css');
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: PingFang SC,"Harmony",Helvetica Neue,Microsoft YaHei,sans-serif !important;
font-family:
PingFang SC,
'Harmony',
Helvetica Neue,
Microsoft YaHei,
sans-serif !important;
}
@layer components {
.hm-medium {
font-family: PingFang SC,"Harmony",Helvetica Neue,Microsoft YaHei,sans-serif !important;
font-family:
PingFang SC,
'Harmony',
Helvetica Neue,
Microsoft YaHei,
sans-serif !important;
@apply font-bold;
}
.font-ubuntu {
@@ -24,6 +35,20 @@ body {
.font-noto-serif {
font-family: 'Noto Serif SC', serif;
}
.hide-scrollbar::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
}
.hide-scrollbar::-webkit-scrollbar-thumb {
width: 0 !important;
height: 0 !important;
background-color: transparent !important;
}
.hide-scrollbar::-webkit-scrollbar-track {
width: 0 !important;
height: 0 !important;
background-color: transparent !important;
}
}
::-webkit-scrollbar {
@@ -51,7 +76,8 @@ body {
overflow: hidden !important;
}
.monaco-editor, .monaco-editor-background {
.monaco-editor,
.monaco-editor-background {
background-color: transparent !important;
}
@@ -77,7 +103,12 @@ body {
}
.context-view.monaco-menu-container * {
font-family: PingFang SC,"Harmony",Helvetica Neue,Microsoft YaHei,sans-serif !important;
font-family:
PingFang SC,
'Harmony',
Helvetica Neue,
Microsoft YaHei,
sans-serif !important;
}
.ql-hidden {
@@ -86,15 +117,3 @@ body {
.ql-editor img {
@apply inline-block;
}
/* input.ql-image {
@apply hidden;
}
.ql-image svg {
fill: none;
}
.ql-fill {
fill: currentColor;
}
.ql-stroke {
stroke: currentColor;
} */

View File

@@ -0,0 +1,34 @@
@layer base {
.shiny-text {
@apply text-pink-400 text-opacity-60;
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
animation: shine 5s linear infinite;
}
.shiny-text {
background-image: linear-gradient(
120deg,
rgba(255, 50, 50, 0) 40%,
rgba(255, 76, 76, 0.8) 50%,
rgba(255, 50, 50, 0) 60%
);
}
.dark .shiny-text {
background-image: linear-gradient(
120deg,
rgba(255, 255, 255, 0) 40%,
rgba(206, 21, 21, 0.8) 50%,
rgba(255, 255, 255, 0) 60%
);
}
@keyframes shine {
0% {
background-position: 100%;
}
100% {
background-position: -100%;
}
}
}

View File

@@ -164,7 +164,7 @@ interface CommonExt {
address: string
regTime: number
interest: string
labels: unknown[]
labels: string[]
qqLevel: QQLevel
}

View File

@@ -29,6 +29,11 @@ export default defineConfig(({ mode }) => {
base: '/webui/',
server: {
proxy: {
'/api/ws/terminal': {
target: backendDebugUrl,
ws: true,
changeOrigin: true
},
'/api': backendDebugUrl
}
},

View File

@@ -2,7 +2,7 @@
"name": "napcat",
"private": true,
"type": "module",
"version": "4.4.12",
"version": "4.4.16",
"scripts": {
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
@@ -17,18 +17,16 @@
"dev:depend": "npm i && cd napcat.webui && npm i"
},
"devDependencies": {
"json5": "^2.2.3",
"esbuild": "0.24.0",
"@babel/preset-typescript": "^7.24.7",
"@eslint/compat": "^1.2.2",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.14.0",
"@log4js-node/log4js-api": "^1.0.2",
"@napneko/nap-proto-core": "^0.0.4",
"@rollup/plugin-typescript": "^12.1.2",
"@rollup/plugin-node-resolve": "^16.0.0",
"@types/cors": "^2.8.17",
"@rollup/plugin-typescript": "^12.1.2",
"@sinclair/typebox": "^0.34.9",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.0.1",
"@types/qrcode-terminal": "^0.12.2",
@@ -39,6 +37,7 @@
"async-mutex": "^0.5.0",
"commander": "^13.0.0",
"cors": "^2.8.5",
"esbuild": "0.24.0",
"eslint": "^9.14.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.1",
@@ -46,19 +45,21 @@
"file-type": "^20.0.0",
"globals": "^15.12.0",
"image-size": "^1.1.1",
"json5": "^2.2.3",
"typescript": "^5.3.3",
"typescript-eslint": "^8.13.0",
"vite": "^6.0.1",
"vite-plugin-cp": "^4.0.8",
"vite-tsconfig-paths": "^5.1.0",
"winston": "^3.17.0"
"winston": "^3.17.0",
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5",
"@ffmpeg.wasm/main": "^0.13.1",
"express-rate-limit": "^7.5.0"
},
"dependencies": {
"@ffmpeg.wasm/core-mt": "^0.13.2",
"@ffmpeg.wasm/main": "^0.13.1",
"express": "^5.0.0",
"piscina": "^4.7.0",
"qrcode-terminal": "^0.12.0",
"@ffmpeg.wasm/core-mt": "^0.13.2",
"express": "^5.0.0",
"silk-wasm": "^3.6.1",
"ws": "^8.18.0"
}

View File

@@ -1,4 +1,4 @@
import { encode } from "silk-wasm";
import { encode } from 'silk-wasm';
export interface EncodeArgs {
input: ArrayBufferView | ArrayBuffer

View File

@@ -4,8 +4,8 @@ import path from 'node:path';
import { randomUUID } from 'crypto';
import { EncodeResult, getDuration, getWavFileInfo, isSilk, isWav } from 'silk-wasm';
import { LogWrapper } from '@/common/log';
import { EncodeArgs } from "@/common/audio-worker";
import { FFmpegService } from "@/common/ffmpeg";
import { EncodeArgs } from '@/common/audio-worker';
import { FFmpegService } from '@/common/ffmpeg';
const ALLOW_SAMPLE_RATE = [8000, 12000, 16000, 24000, 32000, 44100, 48000];
@@ -19,7 +19,7 @@ const piscina = new Piscina<EncodeArgs, EncodeResult>({
async function guessDuration(pttPath: string, logger: LogWrapper) {
const pttFileInfo = await fsPromise.stat(pttPath);
const duration = Math.max(1, Math.floor(pttFileInfo.size / 1024 / 3)); // 3kb/s
const duration = Math.max(1, Math.floor(pttFileInfo.size / 1024 / 3)); // 3kb/s
logger.log('通过文件大小估算语音的时长:', duration);
return duration;
}
@@ -27,12 +27,11 @@ async function guessDuration(pttPath: string, logger: LogWrapper) {
async function handleWavFile(
file: Buffer,
filePath: string,
pcmPath: string,
logger: LogWrapper
pcmPath: string
): Promise<{ input: Buffer; sampleRate: number }> {
const { fmt } = getWavFileInfo(file);
if (!ALLOW_SAMPLE_RATE.includes(fmt.sampleRate)) {
return { input: await FFmpegService.convert(filePath, pcmPath, logger), sampleRate: 24000 };
return { input: await FFmpegService.convert(filePath, pcmPath), sampleRate: 24000 };
}
return { input: file, sampleRate: fmt.sampleRate };
}
@@ -45,9 +44,10 @@ export async function encodeSilk(filePath: string, TEMP_DIR: string, logger: Log
logger.log(`语音文件${filePath}需要转换成silk`);
const pcmPath = `${pttPath}.pcm`;
const { input, sampleRate } = isWav(file)
? (await handleWavFile(file, filePath, pcmPath, logger))
: { input: await FFmpegService.convert(filePath, pcmPath, logger), sampleRate: 24000 };
? await handleWavFile(file, filePath, pcmPath)
: { input: await FFmpegService.convert(filePath, pcmPath), sampleRate: 24000 };
const silk = await piscina.run({ input: input, sampleRate: sampleRate });
fsPromise.unlink(pcmPath).catch((e) => logger.logError('删除临时文件失败', pcmPath, e));
await fsPromise.writeFile(pttPath, Buffer.from(silk.data));
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', silk.duration);
return {
@@ -59,8 +59,8 @@ export async function encodeSilk(filePath: string, TEMP_DIR: string, logger: Log
let duration = 0;
try {
duration = getDuration(file) / 1000;
} catch (e: any) {
logger.log('获取语音文件时长失败, 使用文件大小推测时长', filePath, e.stack);
} catch (e: unknown) {
logger.log('获取语音文件时长失败, 使用文件大小推测时长', filePath, (e as Error).stack);
duration = await guessDuration(filePath, logger);
}
return {
@@ -69,8 +69,8 @@ export async function encodeSilk(filePath: string, TEMP_DIR: string, logger: Log
duration,
};
}
} catch (error: any) {
logger.logError('convert silk failed', error.stack);
} catch (error: unknown) {
logger.logError('convert silk failed', (error as Error).stack);
return {};
}
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export type TaskExecutor<T> = (resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void, onCancel: (callback: () => void) => void) => void | Promise<void>;
export class CancelableTask<T> {
@@ -7,30 +8,34 @@ export class CancelableTask<T> {
private cancelListeners: Array<() => void> = [];
constructor(executor: TaskExecutor<T>) {
this.promise = new Promise<T>(async (resolve, reject) => {
this.promise = new Promise<T>((resolve, reject) => {
const onCancel = (callback: () => void) => {
this.cancelCallback = callback;
};
try {
await executor(
(value) => {
if (!this.isCanceled) {
resolve(value);
}
},
(reason) => {
if (!this.isCanceled) {
reject(reason);
}
},
onCancel
);
} catch (error) {
if (!this.isCanceled) {
reject(error);
const execute = async () => {
try {
await executor(
(value) => {
if (!this.isCanceled) {
resolve(value);
}
},
(reason) => {
if (!this.isCanceled) {
reject(reason);
}
},
onCancel
);
} catch (error) {
if (!this.isCanceled) {
reject(error);
}
}
}
};
execute();
});
}
@@ -72,41 +77,4 @@ export class CancelableTask<T> {
next: () => this.promise.then(value => ({ value, done: true })),
};
}
}
async function demoAwait() {
const executor: TaskExecutor<number> = async (resolve, reject, onCancel) => {
let count = 0;
const intervalId = setInterval(() => {
count++;
console.log(`Task is running... Count: ${count}`);
if (count === 5) {
clearInterval(intervalId);
resolve(count);
}
}, 1000);
onCancel(() => {
clearInterval(intervalId);
console.log('Task has been canceled.');
reject(new Error('Task was canceled'));
});
};
const task = new CancelableTask(executor);
task.onCancel(() => {
console.log('Cancel listener triggered.');
});
setTimeout(() => {
task.cancel(); // 取消任务
}, 6000);
try {
const result = await task;
console.log(`Task completed with result: ${result}`);
} catch (error) {
console.error('Task failed:', error);
}
}

View File

@@ -39,9 +39,9 @@ export abstract class ConfigBase<T> {
if (!fs.existsSync(configPath) && copy_default) {
try {
fs.writeFileSync(configPath, fs.readFileSync(this.getConfigPath(undefined), 'utf-8'));
this.core.context.logger.log(`[Core] [Config] 配置文件创建成功!\n`);
} catch (e: any) {
this.core.context.logger.logError(`[Core] [Config] 创建配置文件时发生错误:`, e.message);
this.core.context.logger.log('[Core] [Config] 配置文件创建成功!\n');
} catch (e: unknown) {
this.core.context.logger.logError('[Core] [Config] 创建配置文件时发生错误:', (e as Error).message);
}
} else if (!fs.existsSync(configPath) && !copy_default) {
fs.writeFileSync(configPath, '{}');
@@ -50,11 +50,11 @@ export abstract class ConfigBase<T> {
this.configData = json5.parse(fs.readFileSync(configPath, 'utf-8'));
this.core.context.logger.logDebug(`[Core] [Config] 配置文件${configPath}加载`, this.configData);
return this.configData;
} catch (e: any) {
} catch (e: unknown) {
if (e instanceof SyntaxError) {
this.core.context.logger.logError(`[Core] [Config] 配置文件格式错误,请检查配置文件:`, e.message);
this.core.context.logger.logError('[Core] [Config] 配置文件格式错误,请检查配置文件:', e.message);
} else {
this.core.context.logger.logError(`[Core] [Config] 读取配置文件时发生错误:`, e.message);
this.core.context.logger.logError('[Core] [Config] 读取配置文件时发生错误:', (e as Error).message);
}
return {} as T;
}
@@ -67,8 +67,8 @@ export abstract class ConfigBase<T> {
const configPath = this.getConfigPath(selfInfo.uin);
try {
fs.writeFileSync(configPath, JSON.stringify(newConfigData, this.getKeys(), 2));
} catch (e: any) {
this.core.context.logger.logError(`保存配置文件 ${configPath} 时发生错误:`, e.message);
} catch (e: unknown) {
this.core.context.logger.logError(`保存配置文件 ${configPath} 时发生错误:`, (e as Error).message);
}
}
}

View File

@@ -1,22 +0,0 @@
// decoratorAsyncMethod(this,function,wrapper)
async function decoratorMethod<T, R>(
target: T,
method: () => Promise<R>,
wrapper: (result: R) => Promise<any>,
executeImmediately: boolean = true
): Promise<any> {
const execute = async () => {
try {
const result = await method.call(target);
return wrapper(result);
} catch (error) {
return Promise.reject(error instanceof Error ? error : new Error(String(error)));
}
};
if (executeImmediately) {
return execute();
} else {
return execute;
}
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NodeIQQNTWrapperSession } from '@/core/wrapper';
import { randomUUID } from 'crypto';
import { ListenerNamingMapping, ServiceNamingMapping } from '@/core';
@@ -60,17 +61,22 @@ export class NTEventWrapper {
[key: string]: () => { [key: string]: (...params: Parameters<T>) => Promise<ReturnType<T>> };
};
if (eventNameArr.length > 1) {
const serviceName = 'get' + eventNameArr[0].replace('NodeIKernel', '');
const serviceName = 'get' + (eventNameArr[0]?.replace('NodeIKernel', '') ?? '');
const eventName = eventNameArr[1];
const services = (this.WrapperSession as unknown as eventType)[serviceName]();
const services = (this.WrapperSession as unknown as eventType)[serviceName]?.();
if (!services || !eventName) {
return undefined;
}
let event = services[eventName];
//重新绑定this
event = event.bind(services);
event = event?.bind(services);
if (event) {
return event as T;
}
return undefined;
}
return undefined;
}
createListenerFunction<T>(listenerMainName: string, uniqueCode: string = ''): T {
@@ -126,8 +132,8 @@ export class NTEventWrapper {
) {
return new Promise<Parameters<ListenerType>>((resolve, reject) => {
const ListenerNameList = listenerAndMethod.split('/');
const ListenerMainName = ListenerNameList[0];
const ListenerSubName = ListenerNameList[1];
const ListenerMainName = ListenerNameList[0] ?? '';
const ListenerSubName = ListenerNameList[1] ?? '';
const id = randomUUID();
let complete = 0;
let retData: Parameters<ListenerType> | undefined = undefined;
@@ -205,8 +211,8 @@ export class NTEventWrapper {
}
const ListenerNameList = listenerAndMethod.split('/');
const ListenerMainName = ListenerNameList[0];
const ListenerSubName = ListenerNameList[1];
const ListenerMainName = ListenerNameList[0]??'';
const ListenerSubName = ListenerNameList[1]??'';
return new Promise<[EventRet: Awaited<ReturnType<EventType>>, ...Parameters<ListenerType>]>(
(resolve, reject) => {

View File

@@ -1,18 +1,18 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { FFmpeg } from '@ffmpeg.wasm/main';
import { randomUUID } from 'crypto';
import { readFileSync, statSync, writeFileSync } from 'fs';
import type { LogWrapper } from './log';
import type { VideoInfo } from './video';
import { fileTypeFromFile } from 'file-type';
import imageSize from 'image-size';
class FFmpegService {
class FFmpegService {
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
const videoFileName = `${randomUUID()}.mp4`;
const outputFileName = `${randomUUID()}.jpg`;
try {
ffmpegInstance.fs.writeFile(videoFileName, readFileSync(videoPath));
let code = await ffmpegInstance.run('-i', videoFileName, '-ss', '00:00:01.000', '-vframes', '1', outputFileName);
const code = await ffmpegInstance.run('-i', videoFileName, '-ss', '00:00:01.000', '-vframes', '1', outputFileName);
if (code !== 0) {
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
}
@@ -44,7 +44,7 @@ import imageSize from 'image-size';
const params = format === 'amr'
? ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, '-ar', '8000', '-b:a', '12.2k', outputFileName]
: ['-f', 's16le', '-ar', '24000', '-ac', '1', '-i', inputFileName, outputFileName];
let code = await ffmpegInstance.run(...params);
const code = await ffmpegInstance.run(...params);
if (code !== 0) {
throw new Error('Error extracting thumbnail: FFmpeg process exited with code ' + code);
}
@@ -67,14 +67,14 @@ import imageSize from 'image-size';
}
}
public static async convert(filePath: string, pcmPath: string, logger: LogWrapper): Promise<Buffer> {
public static async convert(filePath: string, pcmPath: string): Promise<Buffer> {
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
const inputFileName = `${randomUUID()}.input`;
const outputFileName = `${randomUUID()}.pcm`;
try {
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(filePath));
const params = ['-y', '-i', inputFileName, '-ar', '24000', '-ac', '1', '-f', 's16le', outputFileName];
let code = await ffmpegInstance.run(...params);
const code = await ffmpegInstance.run(...params);
if (code !== 0) {
throw new Error('FFmpeg process exited with code ' + code);
}
@@ -87,36 +87,36 @@ import imageSize from 'image-size';
try {
ffmpegInstance.fs.unlink(outputFileName);
} catch (unlinkError) {
logger.log('Error unlinking output file:', unlinkError);
console.error('Error unlinking output file:', unlinkError);
}
try {
ffmpegInstance.fs.unlink(inputFileName);
} catch (unlinkError) {
logger.log('Error unlinking input file:', unlinkError);
console.error('Error unlinking output file:', unlinkError);
}
}
}
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
await FFmpegService.extractThumbnail(videoPath, thumbnailPath);
let fileType = (await fileTypeFromFile(videoPath))?.ext ?? 'mp4';
const fileType = (await fileTypeFromFile(videoPath))?.ext ?? 'mp4';
const inputFileName = `${randomUUID()}.${fileType}`;
const ffmpegInstance = await FFmpeg.create({ core: '@ffmpeg.wasm/core-mt' });
ffmpegInstance.fs.writeFile(inputFileName, readFileSync(videoPath));
ffmpegInstance.setLogging(true);
let duration = 60;
ffmpegInstance.setLogger((level, ...msg) => {
ffmpegInstance.setLogger((_level, ...msg) => {
const message = msg.join(' ');
const durationMatch = message.match(/Duration: (\d+):(\d+):(\d+\.\d+)/);
if (durationMatch) {
const hours = parseInt(durationMatch[1], 10);
const minutes = parseInt(durationMatch[2], 10);
const seconds = parseFloat(durationMatch[3]);
const hours = parseInt(durationMatch[1] ?? '0', 10);
const minutes = parseInt(durationMatch[2] ?? '0', 10);
const seconds = parseFloat(durationMatch[3] ?? '0');
duration = hours * 3600 + minutes * 60 + seconds;
}
});
await ffmpegInstance.run('-i', inputFileName);
let image = imageSize(thumbnailPath);
const image = imageSize(thumbnailPath);
ffmpegInstance.fs.unlink(inputFileName);
const fileSize = statSync(videoPath).size;
return {
@@ -126,7 +126,7 @@ import imageSize from 'image-size';
format: fileType,
size: fileSize,
filePath: videoPath
}
};
}
}
type FFmpegMethod = 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
@@ -137,15 +137,15 @@ interface FFmpegTask {
}
export default async function handleFFmpegTask({ method, args }: FFmpegTask): Promise<any> {
switch (method) {
case 'extractThumbnail':
return await FFmpegService.extractThumbnail(...args as [string, string]);
case 'convertFile':
return await FFmpegService.convertFile(...args as [string, string, string]);
case 'convert':
return await FFmpegService.convert(...args as [string, string, LogWrapper]);
case 'getVideoInfo':
return await FFmpegService.getVideoInfo(...args as [string, string]);
default:
throw new Error(`Unknown method: ${method}`);
case 'extractThumbnail':
return await FFmpegService.extractThumbnail(...args as [string, string]);
case 'convertFile':
return await FFmpegService.convertFile(...args as [string, string, string]);
case 'convert':
return await FFmpegService.convert(...args as [string, string]);
case 'getVideoInfo':
return await FFmpegService.getVideoInfo(...args as [string, string]);
default:
throw new Error(`Unknown method: ${method}`);
}
}

View File

@@ -1,6 +1,6 @@
import Piscina from "piscina";
import { VideoInfo } from "./video";
import type { LogWrapper } from "./log";
/* eslint-disable @typescript-eslint/no-explicit-any */
import Piscina from 'piscina';
import { VideoInfo } from './video';
type EncodeArgs = {
method: 'extractThumbnail' | 'convertFile' | 'convert' | 'getVideoInfo';
@@ -30,11 +30,11 @@ export class FFmpegService {
await piscina.destroy();
}
public static async convert(filePath: string, pcmPath: string, logger: LogWrapper): Promise<Buffer> {
public static async convert(filePath: string, pcmPath: string): Promise<Buffer> {
const piscina = new Piscina<EncodeArgs, EncodeResult>({
filename: await getWorkerPath(),
});
const result = await piscina.run({ method: 'convert', args: [filePath, pcmPath, logger] });
const result = await piscina.run({ method: 'convert', args: [filePath, pcmPath] });
await piscina.destroy();
return result;
}
@@ -47,4 +47,4 @@ export class FFmpegService {
await piscina.destroy();
return result;
}
}
}

View File

@@ -83,7 +83,7 @@ class FileUUIDManager {
this.cache = new TimeBasedCache<string, FileUUIDData>(5000, ttl);
}
public encode(data: FileUUIDData, endString: string = "", customUUID?: string): string {
public encode(data: FileUUIDData, endString: string = '', customUUID?: string): string {
const uuid = customUUID ? customUUID : randomUUID().replace(/-/g, '') + endString;
this.cache.put(uuid, data);
return uuid;
@@ -101,7 +101,7 @@ export class FileNapCatOneBotUUIDWrap {
this.manager = new FileUUIDManager(ttl);
}
public encodeModelId(peer: Peer, modelId: string, fileId: string, fileUUID: string = "", endString: string = "", customUUID?: string): string {
public encodeModelId(peer: Peer, modelId: string, fileId: string, fileUUID: string = '', endString: string = '', customUUID?: string): string {
return this.manager.encode({ peer, modelId, fileId, fileUUID }, endString, customUUID);
}
@@ -109,8 +109,8 @@ export class FileNapCatOneBotUUIDWrap {
return this.manager.decode(uuid);
}
public encode(peer: Peer, msgId: string, elementId: string, fileUUID: string = "", customUUID?: string): string {
return this.manager.encode({ peer, msgId, elementId, fileUUID }, "", customUUID);
public encode(peer: Peer, msgId: string, elementId: string, fileUUID: string = '', customUUID?: string): string {
return this.manager.encode({ peer, msgId, elementId, fileUUID }, '', customUUID);
}
public decode(uuid: string): FileUUIDData | undefined {

View File

@@ -58,8 +58,8 @@ function timeoutPromise(timeout: number, errorMsg: string): Promise<void> {
async function checkFile(path: string): Promise<void> {
try {
await stat(path);
} catch (error: any) {
if (error.code === 'ENOENT') {
} catch (error: unknown) {
if ((error as Error & { code: string }).code === 'ENOENT') {
// 如果文件不存在,则抛出一个错误
throw new Error(`文件不存在: ${path}`);
} else {
@@ -169,6 +169,7 @@ export async function checkUriType(Uri: string) {
const data = uri.split(',')[1];
if (data) return { Uri: data, Type: FileUriType.Base64 };
}
return;
}, Uri);
if (OtherFileRet) return OtherFileRet;
@@ -190,7 +191,7 @@ export async function uriToLocalFile(dir: string, uri: string, filename: string
}
case FileUriType.Remote: {
const buffer = await httpDownload({ url: HandledUri, headers: headers });
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} });
fs.writeFileSync(filePath, buffer);
return { success: true, errMsg: '', fileName: filename, path: filePath };
}

View File

@@ -1,5 +1,5 @@
import * as crypto from "node:crypto";
import { PacketMsg } from "@/core/packet/message/message";
import * as crypto from 'node:crypto';
import { PacketMsg } from '@/core/packet/message/message';
interface ForwardMsgJson {
app: string
@@ -50,15 +50,15 @@ interface ForwardAdaptMsgElement {
}
export class ForwardMsgBuilder {
private static build(resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail["news"], summary?: string, prompt?: string): ForwardMsgJson {
private static build(resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
const id = crypto.randomUUID();
const isGroupMsg = msg.some(m => m.isGroupMsg);
if (!source) {
source = isGroupMsg ? "群聊的聊天记录" : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录';
source = isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录';
}
if (!news) {
news = msg.length === 0 ? [{
text: "Nya~ This message is send from NapCat.Packet!",
text: 'Nya~ This message is send from NapCat.Packet!',
}] : msg.map(m => ({
text: `${m.senderName}: ${m.msg?.map(msg => msg.preview).join('')}`,
}));
@@ -67,15 +67,15 @@ export class ForwardMsgBuilder {
summary = `查看${msg.length}条转发消息`;
}
if (!prompt) {
prompt = "[聊天记录]";
prompt = '[聊天记录]';
}
return {
app: "com.tencent.multimsg",
app: 'com.tencent.multimsg',
config: {
autosize: 1,
forward: 1,
round: 1,
type: "normal",
type: 'normal',
width: 300
},
desc: prompt,
@@ -93,8 +93,8 @@ export class ForwardMsgBuilder {
}
},
prompt,
ver: "0.0.0.5",
view: "contact",
ver: '0.0.0.5',
view: 'contact',
};
}
@@ -102,12 +102,12 @@ export class ForwardMsgBuilder {
return this.build(resId, []);
}
static fromPacketMsg(resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail["news"], summary?: string, prompt?: string): ForwardMsgJson {
static fromPacketMsg(resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
return this.build(resId, packetMsg.map(msg => ({
senderName: msg.senderName,
isGroupMsg: msg.groupId !== undefined,
msg: msg.msg.map(m => ({
preview: m.valid ? m.toPreview() : "[该消息类型暂不支持查看]",
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
}))
})), source, news, summary, prompt);
}

View File

@@ -1,14 +1,16 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import path from 'node:path';
import fs from 'fs';
import os from 'node:os';
import { QQLevel } from '@/core';
import { QQVersionConfigType } from './types';
export async function solveProblem<T extends (...arg: any[]) => any>(func: T, ...args: Parameters<T>): Promise<ReturnType<T> | undefined> {
return new Promise<ReturnType<T> | undefined>((resolve) => {
try {
const result = func(...args);
resolve(result);
} catch (e) {
} catch {
resolve(undefined);
}
});
@@ -166,14 +168,14 @@ export function calcQQLevel(level?: QQLevel) {
}
export function stringifyWithBigInt(obj: any) {
return JSON.stringify(obj, (key, value) =>
return JSON.stringify(obj, (_key, value) =>
typeof value === 'bigint' ? value.toString() : value
);
}
export function parseAppidFromMajor(nodeMajor: string): string | undefined {
const hexSequence = "A4 09 00 00 00 35";
const sequenceBytes = Buffer.from(hexSequence.replace(/ /g, ""), "hex");
const hexSequence = 'A4 09 00 00 00 35';
const sequenceBytes = Buffer.from(hexSequence.replace(/ /g, ''), 'hex');
const filePath = path.resolve(nodeMajor);
const fileContent = fs.readFileSync(filePath);
@@ -192,8 +194,8 @@ export function parseAppidFromMajor(nodeMajor: string): string | undefined {
const content = fileContent.subarray(start, end);
if (!content.every(byte => byte === 0x00)) {
try {
return content.toString("utf-8");
} catch (error) {
return content.toString('utf-8');
} catch {
break;
}
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import winston, { format, transports } from 'winston';
import { truncateString } from '@/common/helper';
import path from 'node:path';
@@ -34,7 +35,7 @@ class Subscription {
for (const history of Subscription.history) {
try {
listener(history);
} catch (_) {
} catch {
// ignore
}
}
@@ -68,7 +69,7 @@ export class LogWrapper {
format: format.combine(
format.timestamp({ format: 'MM-DD HH:mm:ss' }),
format.printf(({ timestamp, level, message, ...meta }) => {
const userInfo = meta.userInfo ? `${meta.userInfo} | ` : '';
const userInfo = meta['userInfo'] ? `${meta['userInfo']} | ` : '';
return `${timestamp} [${level}] ${userInfo}${message}`;
})
),
@@ -83,7 +84,7 @@ export class LogWrapper {
format: format.combine(
format.colorize(),
format.printf(({ timestamp, level, message, ...meta }) => {
const userInfo = meta.userInfo ? `${meta.userInfo} | ` : '';
const userInfo = meta['userInfo'] ? `${meta['userInfo']} | ` : '';
return `${timestamp} [${level}] ${userInfo}${message}`;
})
),
@@ -303,7 +304,7 @@ function textElementToText(textElement: any): string {
const originalContentLines = textElement.content.split('\n');
return `${originalContentLines[0]}${originalContentLines.length > 1 ? ' ...' : ''}`;
} else if (textElement.atType === NTMsgAtType.ATTYPEALL) {
return `@全体成员`;
return '@全体成员';
} else if (textElement.atType === NTMsgAtType.ATTYPEONE) {
return `${textElement.content} (${textElement.atUid})`;
}

View File

@@ -68,7 +68,10 @@ export class LimitedHashTable<K, V> {
const listSize = Math.min(size, keyList.length);
for (let i = 0; i < listSize; i++) {
const key = keyList[listSize - i];
result.push({ key, value: this.keyToValue.get(key)! });
if (key !== undefined) {
result.push({ key, value: this.keyToValue.get(key)! });
}
}
return result;
}
@@ -96,8 +99,10 @@ class MessageUniqueWrapper {
createUniqueMsgId(peer: Peer, msgId: string) {
const key = `${msgId}|${peer.chatType}|${peer.peerUid}`;
const hash = crypto.createHash('md5').update(key).digest();
//设置第一个bit为0 保证shortId为正数
hash[0] &= 0x7f;
if (hash[0]) {
//设置第一个bit为0 保证shortId为正数
hash[0] &= 0x7f;
}
const shortId = hash.readInt32BE(0);
//减少性能损耗
this.msgIdMap.set(msgId, shortId);
@@ -110,11 +115,11 @@ class MessageUniqueWrapper {
if (data) {
const [msgId, chatTypeStr, peerUid] = data.split('|');
const peer: Peer = {
chatType: parseInt(chatTypeStr),
peerUid,
chatType: parseInt(chatTypeStr ?? '0'),
peerUid: peerUid ?? '',
guildId: '',
};
return { MsgId: msgId, Peer: peer };
return { MsgId: msgId ?? '0', Peer: peer };
}
return undefined;
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { LogWrapper } from '@/common/log';
export function proxyHandlerOf(logger: LogWrapper) {
@@ -5,6 +6,7 @@ export function proxyHandlerOf(logger: LogWrapper) {
get(target: any, prop: any, receiver: any) {
if (typeof target[prop] === 'undefined') {
// 如果方法不存在返回一个函数这个函数调用existentMethod
// eslint-disable-next-line @typescript-eslint/no-unused-vars
return (..._args: unknown[]) => {
logger.logDebug(`${target.constructor.name} has no method ${prop}`);
};

View File

@@ -4,6 +4,7 @@ import { getDefaultQQVersionConfigInfo, getQQPackageInfoPath, getQQVersionConfig
import AppidTable from '@/core/external/appid.json';
import { LogWrapper } from '@/common/log';
import { getMajorPath } from '@/core';
import { QQAppidTableType, QQPackageInfoType, QQVersionConfigType } from './types';
export class QQBasicInfoWrapper {
QQMainPath: string | undefined;
@@ -86,14 +87,14 @@ export class QQBasicInfoWrapper {
try {
const majorAppid = this.getAppidV2ByMajor(fullVersion);
if (majorAppid) {
this.context.logger.log(`[QQ版本兼容性检测] 当前版本Appid未内置 通过Major获取 为了更好的性能请尝试更新NapCat`);
this.context.logger.log('[QQ版本兼容性检测] 当前版本Appid未内置 通过Major获取 为了更好的性能请尝试更新NapCat');
return { appid: majorAppid, qua: this.getQUAFallback() };
}
} catch (error) {
this.context.logger.log(`[QQ版本兼容性检测] 通过Major 获取Appid异常 请检测NapCat/QQNT是否正常`);
} catch {
this.context.logger.log('[QQ版本兼容性检测] 通过Major 获取Appid异常 请检测NapCat/QQNT是否正常');
}
// 最终兜底为老版本
this.context.logger.log(`[QQ版本兼容性检测] 获取Appid异常 请检测NapCat/QQNT是否正常`);
this.context.logger.log('[QQ版本兼容性检测] 获取Appid异常 请检测NapCat/QQNT是否正常');
this.context.logger.log(`[QQ版本兼容性检测] ${fullVersion} 版本兼容性不佳,可能会导致一些功能无法正常使用`,);
return { appid: this.getAppIdFallback(), qua: this.getQUAFallback() };
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import https from 'node:https';
import http from 'node:http';
@@ -41,11 +42,13 @@ export class RequestUtil {
private static extractCookies(setCookieHeaders: string[], cookies: { [key: string]: string }) {
setCookieHeaders.forEach((cookie) => {
const parts = cookie.split(';')[0].split('=');
const key = parts[0];
const value = parts[1];
if (key && value && key.length > 0 && value.length > 0) {
cookies[key] = value;
const parts = cookie.split(';')[0]?.split('=');
if (parts) {
const key = parts[0];
const value = parts[1];
if (key && value && key.length > 0 && value.length > 0) {
cookies[key] = value;
}
}
});
}

190
src/common/store.ts Normal file
View File

@@ -0,0 +1,190 @@
export type StoreValueType = string | number | boolean | object | null;
export type StoreValue<T extends StoreValueType = StoreValueType> = {
value: T;
expiresAt?: number;
};
class Store {
// 使用Map存储键值对
private store: Map<string, StoreValue>;
// 定时清理器
private cleanerTimer: NodeJS.Timeout;
// 用于分批次扫描的游标
private scanCursor: number = 0;
/**
* Store
* @param cleanInterval 清理间隔
* @param scanLimit 扫描限制(每次最多检查的键数)
*/
constructor(
cleanInterval: number = 1000, // 默认1秒执行一次
private scanLimit: number = 100 // 每次最多检查100个键
) {
this.store = new Map();
this.cleanerTimer = setInterval(() => this.cleanupExpired(), cleanInterval);
}
/**
* 设置键值对
* @param key 键
* @param value 值
* @param ttl 过期时间
* @returns void
* @example store.set('key', 'value', 60)
*/
set<T extends StoreValueType>(key: string, value: T, ttl?: number): void {
if (ttl && ttl <= 0) {
this.del(key);
return;
}
const expiresAt = ttl ? Date.now() + ttl * 1000 : undefined;
this.store.set(key, { value, expiresAt });
}
/**
* 清理过期键
*/
private cleanupExpired(): void {
const now = Date.now();
const keys = Array.from(this.store.keys());
let scanned = 0;
// 分批次扫描
while (scanned < this.scanLimit && this.scanCursor < keys.length) {
const key = keys[this.scanCursor++];
const entry = this.store.get(key!)!;
if (entry.expiresAt && entry.expiresAt < now) {
this.store.delete(key!);
}
scanned++;
}
// 重置游标(环形扫描)
if (this.scanCursor >= keys.length) {
this.scanCursor = 0;
}
}
/**
* 获取键值
* @param key 键
* @returns T | null
* @example store.get('key')
*/
get<T extends StoreValueType>(key: string): T | null {
this.checkKeyExpiry(key); // 每次访问都检查
const entry = this.store.get(key);
return entry ? (entry.value as T) : null;
}
/**
* 检查键是否过期
* @param key 键
*/
private checkKeyExpiry(key: string): void {
const entry = this.store.get(key);
if (entry?.expiresAt && entry.expiresAt < Date.now()) {
this.store.delete(key);
}
}
/**
* 检查键是否存在
* @param keys 键
* @returns number
* @example store.exists('key1', 'key2')
*/
exists(...keys: string[]): number {
return keys.filter((key) => {
this.checkKeyExpiry(key);
return this.store.has(key);
}).length;
}
/**
* 关闭存储器
*/
shutdown(): void {
clearInterval(this.cleanerTimer);
this.store.clear();
}
/**
* 删除键
* @param keys 键
* @returns number
* @example store.del('key1', 'key2')
*/
del(...keys: string[]): number {
return keys.reduce((count, key) => (this.store.delete(key) ? count + 1 : count), 0);
}
/**
* 设置键的过期时间
* @param key 键
* @param seconds 过期时间(秒)
* @returns boolean
* @example store.expire('key', 60)
*/
expire(key: string, seconds: number): boolean {
const entry = this.store.get(key);
if (!entry) return false;
entry.expiresAt = Date.now() + seconds * 1000;
return true;
}
/**
* 获取键的过期时间
* @param key 键
* @returns number | null
* @example store.ttl('key')
*/
ttl(key: string): number | null {
const entry = this.store.get(key);
if (!entry) return null;
if (!entry.expiresAt) return -1;
const remaining = entry.expiresAt - Date.now();
return remaining > 0 ? Math.floor(remaining / 1000) : -2;
}
/**
* 键值数字递增
* @param key 键
* @returns number
* @example store.incr('key')
*/
incr(key: string): number {
const current = this.get<StoreValueType>(key);
if (current === null) {
this.set(key, 1);
return 1;
}
let numericValue: number;
if (typeof current === 'number') {
numericValue = current;
} else if (typeof current === 'string') {
if (!/^-?\d+$/.test(current)) {
throw new Error('ERR value is not an integer');
}
numericValue = parseInt(current, 10);
} else {
throw new Error('ERR value is not an integer');
}
const newValue = numericValue + 1;
this.set(key, newValue);
return newValue;
}
}
const store = new Store();
export default store;

View File

@@ -6,7 +6,7 @@ let osName: string;
try {
osName = os.hostname();
} catch (e) {
} catch {
osName = 'NapCat'; // + crypto.randomUUID().substring(0, 4);
}

View File

@@ -1,17 +1,17 @@
//QQVersionType
type QQPackageInfoType = {
export type QQPackageInfoType = {
version: string;
buildVersion: string;
platform: string;
eleArch: string;
}
type QQVersionConfigType = {
export type QQVersionConfigType = {
baseVersion: string;
curVersion: string;
prevVersion: string;
onErrorVersions: Array<any>;
onErrorVersions: Array<unknown>;
buildId: string;
}
type QQAppidTableType = {
export type QQAppidTableType = {
[key: string]: { appid: string, qua: string };
}

View File

@@ -1 +1 @@
export const napCatVersion = '4.4.12';
export const napCatVersion = '4.4.16';

View File

@@ -1,15 +1,16 @@
import { MsfChangeReasonType, MsfStatusType } from "@/core/types/adapter";
/* eslint-disable @typescript-eslint/no-unused-vars */
import { MsfChangeReasonType, MsfStatusType } from '@/core/types/adapter';
export class NodeIDependsAdapter {
onMSFStatusChange(statusType: MsfStatusType, changeReasonType: MsfChangeReasonType) {
onMSFStatusChange(_statusType: MsfStatusType, _changeReasonType: MsfChangeReasonType) {
}
onMSFSsoError(args: unknown) {
onMSFSsoError(_args: unknown) {
}
getGroupCode(args: unknown) {
getGroupCode(_args: unknown) {
}
}

View File

@@ -1,10 +1,11 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
export class NodeIDispatcherAdapter {
dispatchRequest(arg: unknown) {
dispatchRequest(_arg: unknown) {
}
dispatchCall(arg: unknown) {
dispatchCall(_arg: unknown) {
}
dispatchCallWithJson(arg: unknown) {
dispatchCallWithJson(_arg: unknown) {
}
}

View File

@@ -1,25 +1,26 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
export class NodeIGlobalAdapter {
onLog(...args: unknown[]) {
onLog(..._args: unknown[]) {
}
onGetSrvCalTime(...args: unknown[]) {
onGetSrvCalTime(..._args: unknown[]) {
}
onShowErrUITips(...args: unknown[]) {
onShowErrUITips(..._args: unknown[]) {
}
fixPicImgType(...args: unknown[]) {
fixPicImgType(..._args: unknown[]) {
}
getAppSetting(...args: unknown[]) {
getAppSetting(..._args: unknown[]) {
}
onInstallFinished(...args: unknown[]) {
onInstallFinished(..._args: unknown[]) {
}
onUpdateGeneralFlag(...args: unknown[]) {
onUpdateGeneralFlag(..._args: unknown[]) {
}
onGetOfflineMsg(...args: unknown[]) {
onGetOfflineMsg(..._args: unknown[]) {
}
}

View File

@@ -27,6 +27,7 @@ import { encodeSilk } from '@/common/audio';
import { SendMessageContext } from '@/onebot/api';
import { getFileTypeForSendType } from '../helper/msg';
import { FFmpegService } from '@/common/ffmpeg';
import { rkeyDataType } from '../types/file';
export class NTQQFileApi {
context: InstanceContext;
@@ -40,7 +41,7 @@ export class NTQQFileApi {
this.rkeyManager = new RkeyManager([
'https://rkey.napneko.icu/rkeys'
],
this.context.logger
this.context.logger
);
}
@@ -61,7 +62,7 @@ export class NTQQFileApi {
async uploadFile(filePath: string, elementType: ElementType = ElementType.PIC, elementSubType: number = 0) {
const fileMd5 = await calculateFileMD5(filePath);
const extOrEmpty = await fileTypeFromFile(filePath).then(e => e?.ext ?? '').catch(e => '');
const extOrEmpty = await fileTypeFromFile(filePath).then(e => e?.ext ?? '').catch(() => '');
const ext = extOrEmpty ? `.${extOrEmpty}` : '';
let fileName = `${path.basename(filePath)}`;
if (fileName.indexOf('.') === -1) {
@@ -140,7 +141,7 @@ export class NTQQFileApi {
};
}
async createValidSendVideoElement(context: SendMessageContext, filePath: string, fileName: string = '', diyThumbPath: string = ''): Promise<SendVideoElement> {
async createValidSendVideoElement(context: SendMessageContext, filePath: string, fileName: string = '', _diyThumbPath: string = ''): Promise<SendVideoElement> {
let videoInfo = {
width: 1920,
height: 1080,
@@ -170,13 +171,22 @@ export class NTQQFileApi {
const thumbPath = pathLib.join(pathLib.dirname(thumbDir), `${md5}_0.png`);
try {
videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath);
} catch (error) {
} catch {
fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
}
if (_diyThumbPath) {
try {
await this.copyFile(_diyThumbPath, thumbPath);
} catch (e) {
this.context.logger.logError('复制自定义缩略图失败', e);
}
}
context.deleteAfterSentFiles.push(thumbPath);
const thumbSize = (await fsPromises.stat(thumbPath)).size;
const thumbMd5 = await calculateFileMD5(thumbPath);
context.deleteAfterSentFiles.push(path);
context.deleteAfterSentFiles.push(thumbPath);
const uploadName = (fileName || _fileName).toLocaleLowerCase().endsWith(`.${fileExt.toLocaleLowerCase()}`) ? (fileName || _fileName) : `${fileName || _fileName}.${fileExt}`;
return {
@@ -207,8 +217,7 @@ export class NTQQFileApi {
throw new Error('文件异常大小为0');
}
if (converted) {
fsPromises.unlink(silkPath).then().catch((e) => this.context.logger.logError('删除临时文件失败', e)
);
fsPromises.unlink(silkPath).then().catch((e) => this.context.logger.logError('删除临时文件失败', e));
}
return {
elementType: ElementType.PTT,
@@ -274,18 +283,18 @@ export class NTQQFileApi {
element.elementType === ElementType.FILE
) {
switch (element.elementType) {
case ElementType.PIC:
element.picElement!.sourcePath = elementResults[elementIndex];
break;
case ElementType.VIDEO:
element.videoElement!.filePath = elementResults[elementIndex];
break;
case ElementType.PTT:
element.pttElement!.filePath = elementResults[elementIndex];
break;
case ElementType.FILE:
element.fileElement!.filePath = elementResults[elementIndex];
break;
case ElementType.PIC:
element.picElement!.sourcePath = elementResults?.[elementIndex] ?? '';
break;
case ElementType.VIDEO:
element.videoElement!.filePath = elementResults?.[elementIndex] ?? '';
break;
case ElementType.PTT:
element.pttElement!.filePath = elementResults?.[elementIndex] ?? '';
break;
case ElementType.FILE:
element.fileElement!.filePath = elementResults?.[elementIndex] ?? '';
break;
}
elementIndex++;
}
@@ -299,7 +308,7 @@ export class NTQQFileApi {
if (force) {
try {
await fsPromises.unlink(sourcePath);
} catch (e) {
} catch {
//
}
} else {
@@ -401,27 +410,27 @@ export class NTQQFileApi {
}
private async getRkeyData() {
const rkeyData = {
const rkeyData: rkeyDataType = {
private_rkey: 'CAQSKAB6JWENi5LM_xp9vumLbuThJSaYf-yzMrbZsuq7Uz2qEc3Rbib9LP4',
group_rkey: 'CAQSKAB6JWENi5LM_xp9vumLbuThJSaYf-yzMrbZsuq7Uz2qffcqm614gds',
online_rkey: false
};
try {
if (this.core.apis.PacketApi.available) {
if (this.core.apis.PacketApi.available && this.packetRkey?.[0] && this.packetRkey?.[1]) {
const rkey_expired_private = !this.packetRkey || this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000;
const rkey_expired_group = !this.packetRkey || this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000;
if (rkey_expired_private || rkey_expired_group) {
this.packetRkey = await this.core.apis.PacketApi.pkt.operation.FetchRkey();
}
if (this.packetRkey && this.packetRkey.length > 0) {
rkeyData.group_rkey = this.packetRkey[1].rkey.slice(6);
rkeyData.private_rkey = this.packetRkey[0].rkey.slice(6);
rkeyData.group_rkey = this.packetRkey[1]?.rkey.slice(6) ?? '';
rkeyData.private_rkey = this.packetRkey[0]?.rkey.slice(6) ?? '';
rkeyData.online_rkey = true;
}
}
} catch (error: any) {
this.context.logger.logError('获取rkey失败', error.message);
} catch (error: unknown) {
this.context.logger.logError('获取rkey失败', (error as Error).message);
}
if (!rkeyData.online_rkey) {
@@ -438,7 +447,7 @@ export class NTQQFileApi {
return rkeyData;
}
private getImageUrlFromParsedUrl(imageFileId: string, appid: string, rkeyData: any): string {
private getImageUrlFromParsedUrl(imageFileId: string, appid: string, rkeyData: rkeyDataType): string {
const rkey = appid === '1406' ? rkeyData.private_rkey : rkeyData.group_rkey;
if (rkeyData.online_rkey) {
return IMAGE_HTTP_HOST_NT + `/download?appid=${appid}&fileid=${imageFileId}&rkey=${rkey}`;

View File

@@ -13,7 +13,7 @@ export class NTQQFriendApi {
async setBuddyRemark(uid: string, remark: string) {
return this.context.session.getBuddyService().setBuddyRemark({ uid, remark });
}
async getBuddyV2SimpleInfoMap(refresh = false) {
async getBuddyV2SimpleInfoMap() {
const buddyService = this.context.session.getBuddyService();
const buddyListV2 = await buddyService.getBuddyListV2('0', BuddyListReqType.KNOMAL);
const uids = buddyListV2.data.flatMap(item => item.buddyUids);
@@ -24,13 +24,13 @@ export class NTQQFriendApi {
);
}
async getBuddy(refresh = false): Promise<FriendV2[]> {
return Array.from((await this.getBuddyV2SimpleInfoMap(refresh)).values());
async getBuddy(): Promise<FriendV2[]> {
return Array.from((await this.getBuddyV2SimpleInfoMap()).values());
}
async getBuddyIdMap(refresh = false): Promise<LimitedHashTable<string, string>> {
async getBuddyIdMap(): Promise<LimitedHashTable<string, string>> {
const retMap: LimitedHashTable<string, string> = new LimitedHashTable<string, string>(5000);
const data = await this.getBuddyV2SimpleInfoMap(refresh);
const data = await this.getBuddyV2SimpleInfoMap();
data.forEach((value) => retMap.set(value.uin!, value.uid!));
return retMap;
}

View File

@@ -59,7 +59,7 @@ export class NTQQGroupApi {
}, pskey);
}
async getGroupShutUpMemberList(groupCode: string) {
async getGroupShutUpMemberList(groupCode: string): Promise<ShutUpGroupMember[]> {
const executor: TaskExecutor<ShutUpGroupMember[]> = async (resolve, reject, onCancel) => {
this.core.eventWrapper.registerListen(
'NodeIKernelGroupListener/onShutUpMemberListChanged',
@@ -215,6 +215,9 @@ export class NTQQGroupApi {
guildId: '',
peerUid: groupCode,
}, msgId, 1, false);
if (!MsgData.msgList[0]) {
throw new Error('消息不存在');
}
const param = {
groupCode: groupCode,
msgRandom: parseInt(MsgData.msgList[0].msgRandom),
@@ -255,6 +258,9 @@ export class NTQQGroupApi {
guildId: '',
peerUid: groupCode,
}, msgId, 1, false);
if (!MsgData.msgList[0]) {
throw new Error('消息不存在');
}
const param = {
groupCode: groupCode,
msgRandom: parseInt(MsgData.msgList[0].msgRandom),

View File

@@ -4,4 +4,6 @@ export * from './group';
export * from './msg';
export * from './user';
export * from './webapi';
export * from './system';
export * from './system';
export * from './packet';
export * from './file';

View File

@@ -201,7 +201,7 @@ export class NTQQMsgApi {
return this.context.session.getMsgService().getTempChatInfo(chatType, peerUid);
}
async sendMsg(peer: Peer, msgElements: SendMessageElement[], waitComplete = true, timeout = 10000) {
async sendMsg(peer: Peer, msgElements: SendMessageElement[], timeout = 10000) {
//唉?!我有个想法
if (peer.chatType === ChatType.KCHATTYPETEMPC2CFROMGROUP && peer.guildId && peer.guildId !== '') {
const member = await this.core.apis.GroupApi.getGroupMember(peer.guildId, peer.peerUid);
@@ -268,7 +268,7 @@ export class NTQQMsgApi {
if (!arkElement) {
continue;
}
const forwardData: any = JSON.parse(arkElement.arkElement?.bytesData ?? '');
const forwardData: { app: string } = JSON.parse(arkElement.arkElement?.bytesData ?? '');
if (forwardData.app != 'com.tencent.multimsg') {
continue;
}

View File

@@ -1,9 +1,9 @@
import * as os from 'os';
import offset from '@/core/external/offset.json';
import { InstanceContext, NapCatCore } from "@/core";
import { LogWrapper } from "@/common/log";
import { PacketClientSession } from "@/core/packet/clientSession";
import { napCatVersion } from "@/common/version";
import { InstanceContext, NapCatCore } from '@/core';
import { LogWrapper } from '@/common/log';
import { PacketClientSession } from '@/core/packet/clientSession';
import { napCatVersion } from '@/common/version';
interface OffsetType {
[key: string]: {

View File

@@ -25,7 +25,7 @@ export class NTQQSystemApi {
this.context.session.getMsgService().getOnLineDev();
}
async getArkJsonCollection(cid: string) {
async getArkJsonCollection() {
return await this.core.eventWrapper.callNoListenerEvent('NodeIKernelCollectionService/collectionArkShare', '1717662698058');
}

View File

@@ -69,7 +69,7 @@ export class NTQQUserApi {
}
async fetchUserDetailInfo(uid: string, mode: UserDetailSource = UserDetailSource.KDB) {
const [_retData, profile] = await this.core.eventWrapper.callNormalEventV2(
const [, profile] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelProfileService/fetchUserDetailInfo',
'NodeIKernelProfileListener/onUserDetailInfoChanged',
[
@@ -130,10 +130,10 @@ export class NTQQUserApi {
const requestUrl = 'https://ssl.ptlogin2.qq.com/jump?ptlang=1033&clientuin=' + this.core.selfInfo.uin +
'&clientkey=' + ClientKeyData.clientKey + '&u1=https%3A%2F%2F' + domain + '%2F' + this.core.selfInfo.uin + '%2Finfocenter&keyindex=19%27';
const data = await RequestUtil.HttpsGetCookies(requestUrl);
if (!data.p_skey || data.p_skey.length == 0) {
if (!data['p_skey'] || data['p_skey'].length == 0) {
try {
const pskey = (await this.getPSkey([domain])).domainPskeyMap.get(domain);
if (pskey) data.p_skey = pskey;
if (pskey) data['p_skey'] = pskey;
} catch {
return data;
}
@@ -145,7 +145,7 @@ export class NTQQUserApi {
return await this.context.session.getTipOffService().getPskey(domainList, true);
}
async getRobotUinRange(): Promise<Array<any>> {
async getRobotUinRange(): Promise<Array<unknown>> {
const robotUinRanges = await this.context.session.getRobotService().getRobotUinRange({
justFetchMsgConfig: '1',
type: 1,

View File

@@ -32,7 +32,7 @@ export class NTQQWebApi {
}).toString()}`;
try {
return RequestUtil.HttpGetText(url, 'GET', '', { 'Cookie': this.cookieToString(cookieObject) });
} catch (e) {
} catch {
return undefined;
}
}
@@ -67,7 +67,7 @@ export class NTQQWebApi {
}
}
async getGroupMembers(GroupCode: string, cached: boolean = true): Promise<WebApiGroupMember[]> {
async getGroupMembers(GroupCode: string): Promise<WebApiGroupMember[]> {
//logDebug('webapi 获取群成员', GroupCode);
const memberData: Array<WebApiGroupMember> = new Array<WebApiGroupMember>();
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
@@ -88,7 +88,9 @@ export class NTQQWebApi {
return [];
} else {
for (const key in fastRet.mems) {
memberData.push(fastRet.mems[key]);
if (fastRet.mems[key]) {
memberData.push(fastRet.mems[key]);
}
}
}
//初始化获取PageNum
@@ -116,7 +118,9 @@ export class NTQQWebApi {
continue;
}
for (const key in ret.mems) {
memberData.push(ret.mems[key]);
if (ret.mems[key]) {
memberData.push(ret.mems[key]);
}
}
}
return memberData;
@@ -185,7 +189,7 @@ export class NTQQWebApi {
{ 'Cookie': this.cookieToString(cookieObject) }
);
return ret;
} catch (e) {
} catch {
return undefined;
}
}
@@ -210,12 +214,12 @@ export class NTQQWebApi {
{ 'Cookie': this.cookieToString(cookieObject) }
);
return ret?.ec === 0 ? ret : undefined;
} catch (e) {
} catch {
return undefined;
}
}
private async getDataInternal(cookieObject: any, groupCode: string, type: number) {
private async getDataInternal(cookieObject: { [key: string]: string }, groupCode: string, type: number) {
let resJson;
try {
const res = await RequestUtil.HttpGetText(
@@ -228,7 +232,7 @@ export class NTQQWebApi {
{ 'Cookie': this.cookieToString(cookieObject) }
);
const match = /window\.__INITIAL_STATE__=(.*?);/.exec(res);
if (match) {
if (match?.[1]) {
resJson = JSON.parse(match[1].trim());
}
return type === 1 ? resJson?.talkativeList : resJson?.actorList;
@@ -238,13 +242,18 @@ export class NTQQWebApi {
}
}
private async getHonorList(cookieObject: any, groupCode: string, type: number) {
private async getHonorList(cookieObject: { [key: string]: string }, groupCode: string, type: number) {
const data = await this.getDataInternal(cookieObject, groupCode, type);
if (!data) {
this.context.logger.logError(`获取类型 ${type} 的荣誉信息失败`);
return [];
}
return data.map((item: any) => ({
return data.map((item: {
uin: string,
name: string,
avatar: string,
desc: string,
}) => ({
user_id: item?.uin,
nickname: item?.name,
avatar: item?.avatar,
@@ -254,7 +263,15 @@ export class NTQQWebApi {
async getGroupHonorInfo(groupCode: string, getType: WebHonorType) {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
const HonorInfo: any = { group_id: groupCode };
let HonorInfo = {
group_id: groupCode,
current_talkative: {},
talkative_list: [],
performer_list: [],
legend_list: [],
emotion_list: [],
strong_newbie_list: [],
};
if (getType === WebHonorType.TALKATIVE || getType === WebHonorType.ALL) {
const talkativeList = await this.getHonorList(cookieObject, groupCode, 1);
@@ -284,12 +301,12 @@ export class NTQQWebApi {
return HonorInfo;
}
private cookieToString(cookieObject: any) {
private cookieToString(cookieObject: { [key: string]: string }) {
return Object.entries(cookieObject).map(([key, value]) => `${key}=${value}`).join('; ');
}
public getBknFromCookie(cookieObject: any) {
const sKey = cookieObject.skey as string;
public getBknFromCookie(cookieObject: { [key: string]: string }) {
const sKey = cookieObject['skey'] as string;
let hash = 5381;
for (let i = 0; i < sKey.length; i++) {
@@ -322,18 +339,18 @@ export class NTQQWebApi {
data: pskey,
appid: 5
},
appid: "qun",
appid: 'qun',
checksum: img_md5,
check_type: 0,
file_len: img_size,
env: {
refer: "qzone",
deviceInfo: "h5"
refer: 'qzone',
deviceInfo: 'h5'
},
model: 0,
biz_req: {
sPicTitle: img_name,
sPicDesc: "",
sPicDesc: '',
sAlbumName: sAlbumName,
sAlbumID: sAlbumID,
iAlbumTypeID: 0,
@@ -341,7 +358,7 @@ export class NTQQWebApi {
iUploadType: 0,
iUpPicType: 0,
iBatchID: time,
sPicPath: "",
sPicPath: '',
iPicWidth: 0,
iPicHight: 0,
iWaterType: 0,
@@ -349,19 +366,19 @@ export class NTQQWebApi {
iNeedFeeds: 1,
iUploadTime: time,
mapExt: {
appid: "qun",
appid: 'qun',
userid: gc
}
},
session: "",
session: '',
asy_upload: 0,
cmd: "FileUpload"
cmd: 'FileUpload'
}]
};
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${img_md5}?g_tk=${GTK}`;
const post = await RequestUtil.HttpGetJson(api, 'POST', body, {
"Cookie": cookie,
"Content-Type": "application/json"
'Cookie': cookie,
'Content-Type': 'application/json'
});
return post;

View File

@@ -1,5 +1,5 @@
// TODO: further refactor in NapCat.Packet v2
import { NapProtoMsg, ProtoField, ScalarType } from "@napneko/nap-proto-core";
import { NapProtoMsg, ProtoField, ScalarType } from '@napneko/nap-proto-core';
const LikeDetail = {
txt: ProtoField(1, ScalarType.STRING),

View File

@@ -32,7 +32,7 @@ export class RkeyManager {
}
if (this.failureCount >= this.FAILURE_LIMIT) {
this.logger.logError(`[Rkey] 服务存在异常, 图片使用FallBack机制`);
this.logger.logError('[Rkey] 服务存在异常, 图片使用FallBack机制');
throw new Error('获取rkey失败次数过多请稍后再试');
}
@@ -51,7 +51,7 @@ export class RkeyManager {
return now > this.rkeyData.expired_time;
}
async refreshRkey(): Promise<any> {
async refreshRkey() {
//刷新rkey
for (const url of this.serverUrl) {
try {

View File

@@ -1,5 +1,5 @@
import os from "node:os";
import EventEmitter from "node:events";
import os from 'node:os';
import EventEmitter from 'node:events';
export interface SystemStatus {
cpu: {
@@ -35,9 +35,9 @@ export class StatusHelper {
const { total, active } = currentTimes.map((times, index) => {
const prevTimes = this.cpuTimes[index];
const totalCurrent = times.user + times.nice + times.sys + times.idle + times.irq;
const totalPrev = prevTimes.user + prevTimes.nice + prevTimes.sys + prevTimes.idle + prevTimes.irq;
const totalPrev = (prevTimes?.user ?? 0) + (prevTimes?.nice ?? 0) + (prevTimes?.sys ?? 0) + (prevTimes?.idle ?? 0) + (prevTimes?.irq ?? 0);
const activeCurrent = totalCurrent - times.idle;
const activePrev = totalPrev - prevTimes.idle;
const activePrev = totalPrev - (prevTimes?.idle ?? 0);
return {
total: totalCurrent - totalPrev,
active: activeCurrent - activePrev
@@ -49,8 +49,8 @@ export class StatusHelper {
this.cpuTimes = currentTimes;
return {
usage: this.replaceNaN(((active / total) * 100)).toFixed(2),
model: os.cpus()[0].model,
speed: os.cpus()[0].speed,
model: os.cpus()[0]?.model ?? 'none',
speed: os.cpus()[0]?.speed ?? 0,
core: os.cpus().length
};
}

View File

@@ -24,7 +24,7 @@ import path from 'node:path';
import fs from 'node:fs';
import { hostname, systemName, systemVersion } from '@/common/system';
import { NTEventWrapper } from '@/common/event';
import { GroupMember, KickedOffLineInfo, SelfInfo, SelfStatusInfo } from '@/core/types';
import { KickedOffLineInfo, SelfInfo, SelfStatusInfo } from '@/core/types';
import { NapCatConfigLoader } from '@/core/helper/config';
import os from 'node:os';
import { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/core/listeners';
@@ -52,13 +52,13 @@ export function loadQQWrapper(QQVersion: string): WrapperNodeApi {
}
let wrapperNodePath = path.resolve(appPath, 'wrapper.node');
if (!fs.existsSync(wrapperNodePath)) {
wrapperNodePath = path.join(appPath, `./resources/app/wrapper.node`);
wrapperNodePath = path.join(appPath, './resources/app/wrapper.node');
}
//老版本兼容 未来去掉
if (!fs.existsSync(wrapperNodePath)) {
wrapperNodePath = path.join(path.dirname(process.execPath), `./resources/app/versions/${QQVersion}/wrapper.node`);
}
const nativemodule: any = { exports: {} };
const nativemodule: { exports: WrapperNodeApi } = { exports: {} as WrapperNodeApi };
process.dlopen(nativemodule, wrapperNodePath);
return nativemodule.exports;
}
@@ -74,7 +74,7 @@ export function getMajorPath(QQVersion: string): string {
}
let majorPath = path.resolve(appPath, 'major.node');
if (!fs.existsSync(majorPath)) {
majorPath = path.join(appPath, `./resources/app/major.node`);
majorPath = path.join(appPath, './resources/app/major.node');
}
//老版本兼容 未来去掉
if (!fs.existsSync(majorPath)) {
@@ -177,7 +177,7 @@ export class NapCatCore {
profileListener.onSelfStatusChanged = (Info: SelfStatusInfo) => {
if (Info.status == 20) {
this.selfInfo.online = false;
this.context.logger.log("账号状态变更为离线");
this.context.logger.log('账号状态变更为离线');
} else {
this.selfInfo.online = true;
}

View File

@@ -1,9 +1,9 @@
import { LRUCache } from "@/common/lru-cache";
import crypto, { createHash } from "crypto";
import { OidbPacket, PacketHexStr } from "@/core/packet/transformer/base";
import { LogStack } from "@/core/packet/context/clientContext";
import { NapCoreContext } from "@/core/packet/context/napCoreContext";
import { PacketLogger } from "@/core/packet/context/loggerContext";
import { LRUCache } from '@/common/lru-cache';
import crypto, { createHash } from 'crypto';
import { OidbPacket, PacketHexStr } from '@/core/packet/transformer/base';
import { LogStack } from '@/core/packet/context/clientContext';
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
import { PacketLogger } from '@/core/packet/context/loggerContext';
export interface RecvPacket {
type: string, // 仅recv

View File

@@ -1,13 +1,13 @@
import { createHash } from "crypto";
import path, { dirname } from "path";
import { fileURLToPath } from "url";
import fs from "fs";
import { IPacketClient } from "@/core/packet/client/baseClient";
import { constants } from "node:os";
import { LRUCache } from "@/common/lru-cache";
import { LogStack } from "@/core/packet/context/clientContext";
import { NapCoreContext } from "@/core/packet/context/napCoreContext";
import { PacketLogger } from "@/core/packet/context/loggerContext";
import { createHash } from 'crypto';
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import { IPacketClient } from '@/core/packet/client/baseClient';
import { constants } from 'node:os';
import { LRUCache } from '@/common/lru-cache';
import { LogStack } from '@/core/packet/context/clientContext';
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
import { PacketLogger } from '@/core/packet/context/loggerContext';
// 0 send 1 recv
export interface NativePacketExportType {

View File

@@ -1,8 +1,8 @@
import { Data, WebSocket, ErrorEvent } from "ws";
import { IPacketClient, RecvPacket } from "@/core/packet/client/baseClient";
import { LogStack } from "@/core/packet/context/clientContext";
import { NapCoreContext } from "@/core/packet/context/napCoreContext";
import { PacketLogger } from "@/core/packet/context/loggerContext";
import { Data, WebSocket, ErrorEvent } from 'ws';
import { IPacketClient, RecvPacket } from '@/core/packet/client/baseClient';
import { LogStack } from '@/core/packet/context/clientContext';
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
import { PacketLogger } from '@/core/packet/context/loggerContext';
export class WsPacketClient extends IPacketClient {
private websocket: WebSocket | null = null;
@@ -23,7 +23,7 @@ export class WsPacketClient extends IPacketClient {
check(): boolean {
if (!this.napcore.config.packetServer) {
this.logStack.pushLogWarn(`wsPacketClient 未配置服务器地址`);
this.logStack.pushLogWarn('wsPacketClient 未配置服务器地址');
return false;
}
return true;
@@ -52,7 +52,7 @@ export class WsPacketClient extends IPacketClient {
try {
await this.connect();
return;
} catch (error) {
} catch {
this.reconnectAttempts++;
this.logStack.pushLogWarn(`${this.reconnectAttempts}/${this.maxReconnectAttempts} 次尝试重连失败!`);
await this.delay(5000);
@@ -80,7 +80,7 @@ export class WsPacketClient extends IPacketClient {
};
this.websocket.onclose = () => {
this.available = false;
this.logger.warn(`WebSocket 连接关闭,尝试重连...`);
this.logger.warn('WebSocket 连接关闭,尝试重连...');
reject(new Error('WebSocket 连接关闭'));
};
this.websocket.onmessage = (event) => this.handleMessage(event.data).catch(err => {

View File

@@ -1,5 +1,5 @@
import { PacketContext } from "@/core/packet/context/packetContext";
import { NapCatCore } from "@/core";
import { PacketContext } from '@/core/packet/context/packetContext';
import { NapCatCore } from '@/core';
export class PacketClientSession {
private readonly context: PacketContext;

View File

@@ -1,15 +1,15 @@
import { IPacketClient } from "@/core/packet/client/baseClient";
import { NativePacketClient } from "@/core/packet/client/nativeClient";
import { WsPacketClient } from "@/core/packet/client/wsClient";
import { OidbPacket } from "@/core/packet/transformer/base";
import { PacketLogger } from "@/core/packet/context/loggerContext";
import { NapCoreContext } from "@/core/packet/context/napCoreContext";
import { IPacketClient } from '@/core/packet/client/baseClient';
import { NativePacketClient } from '@/core/packet/client/nativeClient';
import { WsPacketClient } from '@/core/packet/client/wsClient';
import { OidbPacket } from '@/core/packet/transformer/base';
import { PacketLogger } from '@/core/packet/context/loggerContext';
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
type clientPriority = {
type clientPriorityType = {
[key: number]: (napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) => IPacketClient;
}
const clientPriority: clientPriority = {
const clientPriority: clientPriorityType = {
10: (napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) => new NativePacketClient(napCore, logger, logStack),
1: (napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) => new WsPacketClient(napCore, logger, logStack),
};
@@ -77,22 +77,22 @@ export class PacketClientContext {
async sendOidbPacket<T extends boolean = false>(pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
const raw = await this._client.sendOidbPacket(pkt, rsp);
return (rsp ? Buffer.from(raw.hex_data, "hex") : undefined) as T extends true ? Buffer : void;
return (rsp ? Buffer.from(raw.hex_data, 'hex') : undefined) as T extends true ? Buffer : void;
}
private newClient(): IPacketClient {
const prefer = this.napCore.config.packetBackend;
let client: IPacketClient | null;
switch (prefer) {
case "native":
this.logger.info("使用指定的 NativePacketClient 作为后端");
case 'native':
this.logger.info('使用指定的 NativePacketClient 作为后端');
client = new NativePacketClient(this.napCore, this.logger, this.logStack);
break;
case "frida":
this.logger.info("[Core] [Packet] 使用指定的 FridaPacketClient 作为后端");
case 'frida':
this.logger.info('[Core] [Packet] 使用指定的 FridaPacketClient 作为后端');
client = new WsPacketClient(this.napCore, this.logger, this.logStack);
break;
case "auto":
case 'auto':
case undefined:
client = this.judgeClient();
break;
@@ -101,10 +101,10 @@ export class PacketClientContext {
client = null;
}
if (!client?.check()) {
throw new Error("[Core] [Packet] 无可用的后端NapCat.Packet将不会加载");
throw new Error('[Core] [Packet] 无可用的后端NapCat.Packet将不会加载');
}
if (!client) {
throw new Error("[Core] [Packet] 后端异常NapCat.Packet将不会加载");
throw new Error('[Core] [Packet] 后端异常NapCat.Packet将不会加载');
}
return client;
}
@@ -120,7 +120,7 @@ export class PacketClientContext {
.sort((a, b) => b.score - a.score);
const selectedClient = sortedClients[0]?.client;
if (!selectedClient) {
throw new Error("[Core] [Packet] 无可用的后端NapCat.Packet将不会加载");
throw new Error('[Core] [Packet] 无可用的后端NapCat.Packet将不会加载');
}
this.logger.info(`自动选择 ${selectedClient.constructor.name} 作为后端`);
return selectedClient;

View File

@@ -1,5 +1,6 @@
import { LogLevel, LogWrapper } from "@/common/log";
import { NapCoreContext } from "@/core/packet/context/napCoreContext";
/* eslint-disable @typescript-eslint/no-explicit-any */
import { LogLevel, LogWrapper } from '@/common/log';
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
// TODO: check bind?
export class PacketLogger {
@@ -10,7 +11,7 @@ export class PacketLogger {
}
private _log(level: LogLevel, ...msg: any[]): void {
this.napLogger._log(level, "[Core] [Packet] " + msg);
this.napLogger._log(level, '[Core] [Packet] ' + msg);
}
debug(...msg: any[]): void {

View File

@@ -1,4 +1,4 @@
import { NapCatCore } from "@/core";
import { NapCatCore } from '@/core';
export interface NapCoreCompatBasicInfo {
readonly uin: number;

View File

@@ -1,20 +1,20 @@
import * as crypto from 'crypto';
import { PacketContext } from "@/core/packet/context/packetContext";
import * as trans from "@/core/packet/transformer";
import { PacketMsg } from "@/core/packet/message/message";
import { PacketContext } from '@/core/packet/context/packetContext';
import * as trans from '@/core/packet/transformer';
import { PacketMsg } from '@/core/packet/message/message';
import {
PacketMsgFileElement,
PacketMsgPicElement,
PacketMsgPttElement,
PacketMsgVideoElement
} from "@/core/packet/message/element";
import { ChatType } from "@/core";
import { MiniAppRawData, MiniAppReqParams } from "@/core/packet/entities/miniApp";
import { AIVoiceChatType } from "@/core/packet/entities/aiChat";
import { NapProtoDecodeStructType, NapProtoEncodeStructType } from "@napneko/nap-proto-core";
import { IndexNode, MsgInfo } from "@/core/packet/transformer/proto";
import { OidbPacket } from "@/core/packet/transformer/base";
import { ImageOcrResult } from "@/core/packet/entities/ocrResult";
} from '@/core/packet/message/element';
import { ChatType } from '@/core';
import { MiniAppRawData, MiniAppReqParams } from '@/core/packet/entities/miniApp';
import { AIVoiceChatType } from '@/core/packet/entities/aiChat';
import { NapProtoDecodeStructType, NapProtoEncodeStructType } from '@napneko/nap-proto-core';
import { IndexNode, MsgInfo } from '@/core/packet/transformer/proto';
import { OidbPacket } from '@/core/packet/transformer/base';
import { ImageOcrResult } from '@/core/packet/entities/ocrResult';
export class PacketOperationContext {
private readonly context: PacketContext;
@@ -61,7 +61,7 @@ export class PacketOperationContext {
}
status = Number((extBigInt & 0xff00n) + ((extBigInt >> 16n) & 0xffn));
return { status: 10, ext_status: status };
} catch (e) {
} catch {
return undefined;
}
}

View File

@@ -1,10 +1,10 @@
import { PacketHighwayContext } from "@/core/packet/highway/highwayContext";
import { NapCatCore } from "@/core";
import { PacketLogger } from "@/core/packet/context/loggerContext";
import { NapCoreContext } from "@/core/packet/context/napCoreContext";
import { PacketClientContext } from "@/core/packet/context/clientContext";
import { PacketOperationContext } from "@/core/packet/context/operationContext";
import { PacketMsgConverter } from "@/core/packet/message/converter";
import { PacketHighwayContext } from '@/core/packet/highway/highwayContext';
import { NapCatCore } from '@/core';
import { PacketLogger } from '@/core/packet/context/loggerContext';
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
import { PacketClientContext } from '@/core/packet/context/clientContext';
import { PacketOperationContext } from '@/core/packet/context/operationContext';
import { PacketMsgConverter } from '@/core/packet/message/converter';
export class PacketContext {
readonly msgConverter: PacketMsgConverter;

View File

@@ -1,9 +1,9 @@
import * as stream from 'node:stream';
import { ReadStream } from "node:fs";
import { HighwayTcpUploader } from "@/core/packet/highway/uploader/highwayTcpUploader";
import { HighwayHttpUploader } from "@/core/packet/highway/uploader/highwayHttpUploader";
import { PacketHighwaySig } from "@/core/packet/highway/highwayContext";
import { PacketLogger } from "@/core/packet/context/loggerContext";
import { ReadStream } from 'node:fs';
import { HighwayTcpUploader } from '@/core/packet/highway/uploader/highwayTcpUploader';
import { HighwayHttpUploader } from '@/core/packet/highway/uploader/highwayHttpUploader';
import { PacketHighwaySig } from '@/core/packet/highway/highwayContext';
import { PacketLogger } from '@/core/packet/context/loggerContext';
export interface PacketHighwayTrans {
uin: string;
@@ -27,7 +27,8 @@ export class PacketHighwayClient {
port: number = 80;
logger: PacketLogger;
constructor(sig: PacketHighwaySig, logger: PacketLogger, server: string = 'htdata3.qq.com', port: number = 80) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
constructor(sig: PacketHighwaySig, logger: PacketLogger, _server: string = 'htdata3.qq.com', _port: number = 80) {
this.sig = sig;
this.logger = logger;
}

View File

@@ -1,4 +1,4 @@
import assert from "node:assert";
import assert from 'node:assert';
export class Frame{
static pack(head: Buffer, body: Buffer): Buffer {

View File

@@ -1,22 +1,22 @@
import { PacketHighwayClient } from "@/core/packet/highway/client";
import { PacketLogger } from "@/core/packet/context/loggerContext";
import FetchSessionKey from "@/core/packet/transformer/highway/FetchSessionKey";
import { int32ip2str, oidbIpv4s2HighwayIpv4s } from "@/core/packet/highway/utils";
import { PacketHighwayClient } from '@/core/packet/highway/client';
import { PacketLogger } from '@/core/packet/context/loggerContext';
import FetchSessionKey from '@/core/packet/transformer/highway/FetchSessionKey';
import { int32ip2str, oidbIpv4s2HighwayIpv4s } from '@/core/packet/highway/utils';
import {
PacketMsgFileElement,
PacketMsgPicElement,
PacketMsgPttElement,
PacketMsgVideoElement
} from "@/core/packet/message/element";
import { ChatType, Peer } from "@/core";
import { calculateSha1, calculateSha1StreamBytes, computeMd5AndLengthWithLimit } from "@/core/packet/utils/crypto/hash";
import UploadGroupImage from "@/core/packet/transformer/highway/UploadGroupImage";
import { NapProtoMsg } from "@napneko/nap-proto-core";
import * as proto from "@/core/packet/transformer/proto";
import * as trans from "@/core/packet/transformer";
import fs from "fs";
import { NapCoreContext } from "@/core/packet/context/napCoreContext";
import { PacketClientContext } from "@/core/packet/context/clientContext";
} from '@/core/packet/message/element';
import { ChatType, Peer } from '@/core';
import { calculateSha1, calculateSha1StreamBytes, computeMd5AndLengthWithLimit } from '@/core/packet/utils/crypto/hash';
import UploadGroupImage from '@/core/packet/transformer/highway/UploadGroupImage';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import * as proto from '@/core/packet/transformer/proto';
import * as trans from '@/core/packet/transformer';
import fs from 'fs';
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
import { PacketClientContext } from '@/core/packet/context/clientContext';
export const BlockSize = 1024 * 1024;
@@ -142,7 +142,7 @@ export class PacketHighwayContext {
const resp = await this.client.sendOidbPacket(req, true);
const preRespData = UploadGroupImage.parse(resp);
const ukey = preRespData.upload.uKey;
if (ukey && ukey != "") {
if (ukey && ukey != '') {
this.logger.debug(`[Highway] uploadGroupImageReq get upload ukey: ${ukey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[0].index;
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
@@ -179,7 +179,7 @@ export class PacketHighwayContext {
const resp = await this.client.sendOidbPacket(req, true);
const preRespData = trans.UploadPrivateImage.parse(resp);
const ukey = preRespData.upload.uKey;
if (ukey && ukey != "") {
if (ukey && ukey != '') {
this.logger.debug(`[Highway] uploadC2CImageReq get upload ukey: ${ukey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[0].index;
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
@@ -210,14 +210,14 @@ export class PacketHighwayContext {
}
private async uploadGroupVideo(groupUin: number, video: PacketMsgVideoElement): Promise<void> {
if (!video.filePath || !video.thumbPath) throw new Error("video.filePath or video.thumbPath is empty");
if (!video.filePath || !video.thumbPath) throw new Error('video.filePath or video.thumbPath is empty');
video.fileSha1 = Buffer.from(await calculateSha1(video.filePath)).toString('hex');
video.thumbSha1 = Buffer.from(await calculateSha1(video.thumbPath)).toString('hex');
const req = trans.UploadGroupVideo.build(groupUin, video);
const resp = await this.client.sendOidbPacket(req, true);
const preRespData = trans.UploadGroupVideo.parse(resp);
const ukey = preRespData.upload.uKey;
if (ukey && ukey != "") {
if (ukey && ukey != '') {
this.logger.debug(`[Highway] uploadGroupVideoReq get upload video ukey: ${ukey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[0].index;
const md5 = Buffer.from(index.info.fileHash, 'hex');
@@ -244,7 +244,7 @@ export class PacketHighwayContext {
this.logger.debug(`[Highway] uploadGroupVideoReq get upload invalid ukey ${ukey}, don't need upload!`);
}
const subFile = preRespData.upload.subFileInfos[0];
if (subFile.uKey && subFile.uKey != "") {
if (subFile.uKey && subFile.uKey != '') {
this.logger.debug(`[Highway] uploadGroupVideoReq get upload video thumb ukey: ${subFile.uKey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[1].index;
const md5 = Buffer.from(index.info.fileHash, 'hex');
@@ -275,14 +275,14 @@ export class PacketHighwayContext {
}
private async uploadC2CVideo(peerUid: string, video: PacketMsgVideoElement): Promise<void> {
if (!video.filePath || !video.thumbPath) throw new Error("video.filePath or video.thumbPath is empty");
if (!video.filePath || !video.thumbPath) throw new Error('video.filePath or video.thumbPath is empty');
video.fileSha1 = Buffer.from(await calculateSha1(video.filePath)).toString('hex');
video.thumbSha1 = Buffer.from(await calculateSha1(video.thumbPath)).toString('hex');
const req = trans.UploadPrivateVideo.build(peerUid, video);
const resp = await this.client.sendOidbPacket(req, true);
const preRespData = trans.UploadPrivateVideo.parse(resp);
const ukey = preRespData.upload.uKey;
if (ukey && ukey != "") {
if (ukey && ukey != '') {
this.logger.debug(`[Highway] uploadC2CVideoReq get upload video ukey: ${ukey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[0].index;
const md5 = Buffer.from(index.info.fileHash, 'hex');
@@ -309,7 +309,7 @@ export class PacketHighwayContext {
this.logger.debug(`[Highway] uploadC2CVideoReq get upload invalid ukey ${ukey}, don't need upload!`);
}
const subFile = preRespData.upload.subFileInfos[0];
if (subFile.uKey && subFile.uKey != "") {
if (subFile.uKey && subFile.uKey != '') {
this.logger.debug(`[Highway] uploadC2CVideoReq get upload video thumb ukey: ${subFile.uKey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[1].index;
const md5 = Buffer.from(index.info.fileHash, 'hex');
@@ -345,7 +345,7 @@ export class PacketHighwayContext {
const resp = await this.client.sendOidbPacket(req, true);
const preRespData = trans.UploadGroupPtt.parse(resp);
const ukey = preRespData.upload.uKey;
if (ukey && ukey != "") {
if (ukey && ukey != '') {
this.logger.debug(`[Highway] uploadGroupPttReq get upload ptt ukey: ${ukey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[0].index;
const md5 = Buffer.from(index.info.fileHash, 'hex');
@@ -381,7 +381,7 @@ export class PacketHighwayContext {
const resp = await this.client.sendOidbPacket(req, true);
const preRespData = trans.UploadPrivatePtt.parse(resp);
const ukey = preRespData.upload.uKey;
if (ukey && ukey != "") {
if (ukey && ukey != '') {
this.logger.debug(`[Highway] uploadC2CPttReq get upload ptt ukey: ${ukey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[0].index;
const md5 = Buffer.from(index.info.fileHash, 'hex');
@@ -419,7 +419,7 @@ export class PacketHighwayContext {
const resp = await this.client.sendOidbPacket(req, true);
const preRespData = trans.UploadGroupFile.parse(resp);
if (!preRespData?.upload?.boolFileExist) {
this.logger.debug(`[Highway] uploadGroupFileReq file not exist, need upload!`);
this.logger.debug('[Highway] uploadGroupFileReq file not exist, need upload!');
const ext = new NapProtoMsg(proto.FileUploadExt).encode({
unknown1: 100,
unknown2: 1,
@@ -439,9 +439,9 @@ export class PacketHighwayContext {
},
clientInfo: {
clientType: 3,
appId: "100",
appId: '100',
terminalType: 3,
clientVer: "1.1.1",
clientVer: '1.1.1',
unknown: 4
},
fileNameInfo: {
@@ -469,7 +469,7 @@ export class PacketHighwayContext {
ext
);
} else {
this.logger.debug(`[Highway] uploadGroupFileReq file exist, don't need upload!`);
this.logger.debug('[Highway] uploadGroupFileReq file exist, don\'t need upload!');
}
file.fileUuid = preRespData.upload.fileId;
}
@@ -482,7 +482,7 @@ export class PacketHighwayContext {
const res = await this.client.sendOidbPacket(req, true);
const preRespData = trans.UploadPrivateFile.parse(res);
if (!preRespData.upload?.boolFileExist) {
this.logger.debug(`[Highway] uploadC2CFileReq file not exist, need upload!`);
this.logger.debug('[Highway] uploadC2CFileReq file not exist, need upload!');
const ext = new NapProtoMsg(proto.FileUploadExt).encode({
unknown1: 100,
unknown2: 1,
@@ -500,9 +500,9 @@ export class PacketHighwayContext {
},
clientInfo: {
clientType: 3,
appId: "100",
appId: '100',
terminalType: 3,
clientVer: "1.1.1",
clientVer: '1.1.1',
unknown: 4
},
fileNameInfo: {

View File

@@ -1,9 +1,9 @@
import crypto from "node:crypto";
import http from "node:http";
import { NapProtoMsg } from "@napneko/nap-proto-core";
import { IHighwayUploader } from "@/core/packet/highway/uploader/highwayUploader";
import { Frame } from "@/core/packet/highway/frame";
import * as proto from "@/core/packet/transformer/proto";
import crypto from 'node:crypto';
import http from 'node:http';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { IHighwayUploader } from '@/core/packet/highway/uploader/highwayUploader';
import { Frame } from '@/core/packet/highway/frame';
import * as proto from '@/core/packet/transformer/proto';
export class HighwayHttpUploader extends IHighwayUploader {
async upload(): Promise<void> {

View File

@@ -1,11 +1,11 @@
import net from "node:net";
import stream from "node:stream";
import crypto from "node:crypto";
import { NapProtoMsg } from "@napneko/nap-proto-core";
import { BlockSize } from "@/core/packet/highway/highwayContext";
import { Frame } from "@/core/packet/highway/frame";
import { IHighwayUploader } from "@/core/packet/highway/uploader/highwayUploader";
import * as proto from "@/core/packet/transformer/proto";
import net from 'node:net';
import stream from 'node:stream';
import crypto from 'node:crypto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { BlockSize } from '@/core/packet/highway/highwayContext';
import { Frame } from '@/core/packet/highway/frame';
import { IHighwayUploader } from '@/core/packet/highway/uploader/highwayUploader';
import * as proto from '@/core/packet/transformer/proto';
class HighwayTcpUploaderTransform extends stream.Transform {
uploader: HighwayTcpUploader;
@@ -17,7 +17,8 @@ class HighwayTcpUploaderTransform extends stream.Transform {
this.offset = 0;
}
_transform(data: Buffer, _: BufferEncoding, callback: stream.TransformCallback) {
// eslint-disable-next-line no-undef
override _transform(data: Buffer, _: BufferEncoding, callback: stream.TransformCallback) {
let chunkOffset = 0;
while (chunkOffset < data.length) {
const chunkSize = Math.min(BlockSize, data.length - chunkOffset);
@@ -60,6 +61,7 @@ export class HighwayTcpUploader extends IHighwayUploader {
socket.end();
reject(new Error('Upload aborted due to timeout'));
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [head, _] = Frame.unpack(chunk);
handleRspHeader(head);
});

View File

@@ -1,8 +1,8 @@
import * as tea from "@/core/packet/utils/crypto/tea";
import { NapProtoMsg } from "@napneko/nap-proto-core";
import { PacketHighwayTrans } from "@/core/packet/highway/client";
import { PacketLogger } from "@/core/packet/context/loggerContext";
import * as proto from "@/core/packet/transformer/proto";
import * as tea from '@/core/packet/utils/crypto/tea';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { PacketHighwayTrans } from '@/core/packet/highway/client';
import { PacketLogger } from '@/core/packet/context/loggerContext';
import * as proto from '@/core/packet/transformer/proto';
export abstract class IHighwayUploader {
readonly trans: PacketHighwayTrans;
@@ -32,7 +32,7 @@ export abstract class IHighwayUploader {
msgBaseHead: {
version: 1,
uin: this.trans.uin,
command: "PicUp.DataUp",
command: 'PicUp.DataUp',
seq: 0,
retryTimes: 0,
appId: 1600001604,

View File

@@ -1,5 +1,5 @@
import { NapProtoEncodeStructType } from "@napneko/nap-proto-core";
import * as proto from "@/core/packet/transformer/proto";
import { NapProtoEncodeStructType } from '@napneko/nap-proto-core';
import * as proto from '@/core/packet/transformer/proto';
export const int32ip2str = (ip: number) => {

View File

@@ -1,14 +1,14 @@
import * as crypto from "crypto";
import { PushMsgBody } from "@/core/packet/transformer/proto";
import { NapProtoEncodeStructType } from "@napneko/nap-proto-core";
import { PacketMsg, PacketSendMsgElement } from "@/core/packet/message/message";
import { IPacketMsgElement, PacketMsgTextElement } from "@/core/packet/message/element";
import { SendTextElement } from "@/core";
import * as crypto from 'crypto';
import { PushMsgBody } from '@/core/packet/transformer/proto';
import { NapProtoEncodeStructType } from '@napneko/nap-proto-core';
import { PacketMsg, PacketSendMsgElement } from '@/core/packet/message/message';
import { IPacketMsgElement, PacketMsgTextElement } from '@/core/packet/message/element';
import { SendTextElement } from '@/core';
export class PacketMsgBuilder {
protected static failBackText = new PacketMsgTextElement(
{
textElement: { content: "[该消息类型暂不支持查看]" }
textElement: { content: '[该消息类型暂不支持查看]' }
} as SendTextElement
);
@@ -24,7 +24,7 @@ export class PacketMsgBuilder {
}
return {
responseHead: {
fromUid: "",
fromUid: '',
fromUin: node.senderUin,
toUid: node.groupId ? undefined : selfUid,
forward: node.groupId ? undefined : {

View File

@@ -15,7 +15,7 @@ import {
SendStructLongMsgElement,
SendTextElement,
SendVideoElement
} from "@/core";
} from '@/core';
import {
IPacketMsgElement,
PacketMsgAtElement,
@@ -30,8 +30,8 @@ import {
PacketMsgTextElement,
PacketMsgVideoElement,
PacketMultiMsgElement
} from "@/core/packet/message/element";
import { PacketMsg, PacketSendMsgElement } from "@/core/packet/message/message";
} from '@/core/packet/message/element';
import { PacketMsg, PacketSendMsgElement } from '@/core/packet/message/message';
const SupportedElementTypes = [
ElementType.TEXT,
@@ -146,7 +146,7 @@ export class PacketMsgConverter {
? msg.sendMemberName
: msg.sendNickName && msg.sendNickName !== ''
? msg.sendNickName
: "QQ用户",
: 'QQ用户',
time: +msg.msgTime,
msg: msg.elements.map((element) => {
if (!this.isValidElementType(element.elementType)) return null;

View File

@@ -1,5 +1,5 @@
import * as zlib from "node:zlib";
import { NapProtoEncodeStructType, NapProtoMsg } from "@napneko/nap-proto-core";
import * as zlib from 'node:zlib';
import { NapProtoEncodeStructType, NapProtoMsg } from '@napneko/nap-proto-core';
import {
CustomFace,
Elem,
@@ -12,9 +12,8 @@ import {
OidbSvcTrpcTcp0XE37_800Response,
FileExtra,
GroupFileExtra
} from "@/core/packet/transformer/proto";
} from '@/core/packet/transformer/proto';
import {
BaseEmojiType,
FaceType,
NTMsgAtType,
PicType,
@@ -29,14 +28,15 @@ import {
SendStructLongMsgElement,
SendTextElement,
SendVideoElement
} from "@/core";
import { ForwardMsgBuilder } from "@/common/forward-msg-builder";
import { PacketMsg, PacketSendMsgElement } from "@/core/packet/message/message";
} from '@/core';
import { ForwardMsgBuilder } from '@/common/forward-msg-builder';
import { PacketMsg, PacketSendMsgElement } from '@/core/packet/message/message';
// raw <-> packet
// TODO: SendStructLongMsgElement
export abstract class IPacketMsgElement<T extends PacketSendMsgElement> {
protected constructor(rawElement: T) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected constructor(_rawElement: T) {
}
get valid(): boolean {
@@ -64,7 +64,7 @@ export class PacketMsgTextElement extends IPacketMsgElement<SendTextElement> {
this.text = element.textElement.content;
}
buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
override buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
return [{
text: {
str: this.text
@@ -72,7 +72,7 @@ export class PacketMsgTextElement extends IPacketMsgElement<SendTextElement> {
}];
}
toPreview(): string {
override toPreview(): string {
return this.text;
}
}
@@ -87,7 +87,7 @@ export class PacketMsgAtElement extends PacketMsgTextElement {
this.atAll = element.textElement.atType === NTMsgAtType.ATTYPEALL;
}
buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
override buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
return [{
text: {
str: this.text,
@@ -127,7 +127,7 @@ export class PacketMsgReplyElement extends IPacketMsgElement<SendReplyElement> {
return this.messageClientSeq === 0;
}
buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
override buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
return [{
srcMsg: {
origSeqs: [this.isGroupReply ? this.messageClientSeq : this.messageSeq],
@@ -152,8 +152,8 @@ export class PacketMsgReplyElement extends IPacketMsgElement<SendReplyElement> {
}];
}
toPreview(): string {
return "[回复消息]";
override toPreview(): string {
return '[回复消息]';
}
}
@@ -169,18 +169,18 @@ export class PacketMsgFaceElement extends IPacketMsgElement<SendFaceElement> {
this.isLargeFace = element.faceElement.faceType === FaceType.AniSticke;
}
buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
override buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
if (this.isLargeFace) {
return [{
commonElem: {
serviceType: 37,
pbElem: new NapProtoMsg(QBigFaceExtra).encode({
aniStickerPackId: "1",
aniStickerId: "8",
aniStickerPackId: '1',
aniStickerId: '8',
faceId: this.faceId,
sourceType: 1,
resultId: this.resultId,
preview: "",
preview: '',
randomType: 1
}),
businessType: 1
@@ -198,8 +198,8 @@ export class PacketMsgFaceElement extends IPacketMsgElement<SendFaceElement> {
serviceType: 33,
pbElem: new NapProtoMsg(QSmallFaceExtra).encode({
faceId: this.faceId,
preview: "",
preview2: ""
preview: '',
preview2: ''
}),
businessType: 1
}
@@ -207,8 +207,8 @@ export class PacketMsgFaceElement extends IPacketMsgElement<SendFaceElement> {
}
}
toPreview(): string {
return "[表情]";
override toPreview(): string {
return '[表情]';
}
}
@@ -226,7 +226,7 @@ export class PacketMsgMarkFaceElement extends IPacketMsgElement<SendMarketFaceEl
this.emojiKey = element.marketFaceElement.key;
}
buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
override buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
return [{
marketFace: {
faceName: this.emojiName,
@@ -245,7 +245,7 @@ export class PacketMsgMarkFaceElement extends IPacketMsgElement<SendMarketFaceEl
}];
}
toPreview(): string {
override toPreview(): string {
return `${this.emojiName}`;
}
}
@@ -280,11 +280,11 @@ export class PacketMsgPicElement extends IPacketMsgElement<SendPicElement> {
) : element.picElement.summary;
}
get valid(): boolean {
override get valid(): boolean {
return !!this.msgInfo;
}
buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
override buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
if (!this.msgInfo) return [];
return [{
commonElem: {
@@ -295,7 +295,7 @@ export class PacketMsgPicElement extends IPacketMsgElement<SendPicElement> {
}];
}
toPreview(): string {
override toPreview(): string {
return this.summary;
}
}
@@ -318,18 +318,18 @@ export class PacketMsgVideoElement extends IPacketMsgElement<SendVideoElement> {
this.fileSize = element.videoElement.fileSize;
this.filePath = element.videoElement.filePath;
this.thumbSize = element.videoElement.thumbSize;
this.thumbPath = element.videoElement.thumbPath?.get(0);
this.thumbPath = element.videoElement.thumbPath?.get(0) as string | undefined;
this.fileMd5 = element.videoElement.videoMd5;
this.thumbMd5 = element.videoElement.thumbMd5;
this.thumbWidth = element.videoElement.thumbWidth;
this.thumbHeight = element.videoElement.thumbHeight;
}
get valid(): boolean {
override get valid(): boolean {
return !!this.msgInfo;
}
buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
override buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
if (!this.msgInfo) return [];
return [{
commonElem: {
@@ -340,8 +340,8 @@ export class PacketMsgVideoElement extends IPacketMsgElement<SendVideoElement> {
}];
}
toPreview(): string {
return "[视频]";
override toPreview(): string {
return '[视频]';
}
}
@@ -361,11 +361,11 @@ export class PacketMsgPttElement extends IPacketMsgElement<SendPttElement> {
this.fileDuration = Math.round(element.pttElement.duration); // TODO: cc
}
get valid(): boolean {
override get valid(): boolean {
return false;
}
buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
override buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
return [];
// if (!this.msgInfo) return [];
// return [{
@@ -377,8 +377,8 @@ export class PacketMsgPttElement extends IPacketMsgElement<SendPttElement> {
// }];
}
toPreview(): string {
return "[语音]";
override toPreview(): string {
return '[语音]';
}
}
@@ -402,11 +402,11 @@ export class PacketMsgFileElement extends IPacketMsgElement<SendFileElement> {
this.fileSize = +element.fileElement.fileSize;
}
get valid(): boolean {
override get valid(): boolean {
return this.isGroupFile || Boolean(this._e37_800_rsp);
}
buildContent(): Uint8Array | undefined {
override buildContent(): Uint8Array | undefined {
if (this.isGroupFile || !this._e37_800_rsp) return undefined;
return new NapProtoMsg(FileExtra).encode({
file: {
@@ -437,7 +437,7 @@ export class PacketMsgFileElement extends IPacketMsgElement<SendFileElement> {
});
}
buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
override buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
if (!this.isGroupFile) return [];
const lb = Buffer.alloc(2);
const transElemVal = new NapProtoMsg(GroupFileExtra).encode({
@@ -450,7 +450,7 @@ export class PacketMsgFileElement extends IPacketMsgElement<SendFileElement> {
fileSize: BigInt(this.fileSize),
fileName: this.fileName,
fileSha: this.fileSha1,
extInfoString: "",
extInfoString: '',
fileMd5: this.fileMd5,
}
}
@@ -464,7 +464,7 @@ export class PacketMsgFileElement extends IPacketMsgElement<SendFileElement> {
}];
}
toPreview(): string {
override toPreview(): string {
return `[文件]${this.fileName}`;
}
}
@@ -477,7 +477,7 @@ export class PacketMsgLightAppElement extends IPacketMsgElement<SendArkElement>
this.payload = element.arkElement.bytesData;
}
buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
override buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
return [{
lightAppElem: {
data: Buffer.concat([
@@ -488,8 +488,8 @@ export class PacketMsgLightAppElement extends IPacketMsgElement<SendArkElement>
}];
}
toPreview(): string {
return "[卡片消息]";
override toPreview(): string {
return '[卡片消息]';
}
}
@@ -501,7 +501,7 @@ export class PacketMsgMarkDownElement extends IPacketMsgElement<SendMarkdownElem
this.content = element.markdownElement.content;
}
buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
override buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
return [{
commonElem: {
serviceType: 45,
@@ -513,7 +513,7 @@ export class PacketMsgMarkDownElement extends IPacketMsgElement<SendMarkdownElem
}];
}
toPreview(): string {
override toPreview(): string {
return `[Markdown消息 ${this.content}]`;
}
}
@@ -528,7 +528,7 @@ export class PacketMultiMsgElement extends IPacketMsgElement<SendStructLongMsgEl
this.message = message ?? [];
}
buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
override buildElement(): NapProtoEncodeStructType<typeof Elem>[] {
return [{
lightAppElem: {
data: Buffer.concat([
@@ -539,7 +539,7 @@ export class PacketMultiMsgElement extends IPacketMsgElement<SendStructLongMsgEl
}];
}
toPreview(): string {
return "[聊天记录]";
override toPreview(): string {
return '[聊天记录]';
}
}

View File

@@ -1,5 +1,5 @@
import { IPacketMsgElement } from "@/core/packet/message/element";
import { SendMessageElement, SendStructLongMsgElement } from "@/core";
import { IPacketMsgElement } from '@/core/packet/message/element';
import { SendMessageElement, SendStructLongMsgElement } from '@/core';
export type PacketSendMsgElement = SendMessageElement | SendStructLongMsgElement

View File

@@ -1,8 +1,8 @@
import * as proto from "@/core/packet/transformer/proto";
import { NapProtoMsg } from "@napneko/nap-proto-core";
import { OidbPacket, PacketTransformer } from "@/core/packet/transformer/base";
import OidbBase from "@/core/packet/transformer/oidb/oidbBase";
import { AIVoiceChatType } from "@/core/packet/entities/aiChat";
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketTransformer } from '@/core/packet/transformer/base';
import OidbBase from '@/core/packet/transformer/oidb/oidbBase';
import { AIVoiceChatType } from '@/core/packet/entities/aiChat';
class FetchAiVoiceList extends PacketTransformer<typeof proto.OidbSvcTrpcTcp0X929D_0Resp> {
constructor() {

View File

@@ -1,8 +1,8 @@
import * as proto from "@/core/packet/transformer/proto";
import { NapProtoMsg } from "@napneko/nap-proto-core";
import { OidbPacket, PacketTransformer } from "@/core/packet/transformer/base";
import OidbBase from "@/core/packet/transformer/oidb/oidbBase";
import { AIVoiceChatType } from "@/core/packet/entities/aiChat";
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketTransformer } from '@/core/packet/transformer/base';
import OidbBase from '@/core/packet/transformer/oidb/oidbBase';
import { AIVoiceChatType } from '@/core/packet/entities/aiChat';
class GetAiVoice extends PacketTransformer<typeof proto.OidbSvcTrpcTcp0X929B_0Resp> {
constructor() {

View File

@@ -1,7 +1,7 @@
import * as proto from "@/core/packet/transformer/proto";
import { NapProtoMsg } from "@napneko/nap-proto-core";
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from "@/core/packet/transformer/base";
import { MiniAppReqParams } from "@/core/packet/entities/miniApp";
import * as proto from '@/core/packet/transformer/proto';
import { NapProtoMsg } from '@napneko/nap-proto-core';
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
import { MiniAppReqParams } from '@/core/packet/entities/miniApp';
class GetMiniAppAdaptShareInfo extends PacketTransformer<typeof proto.MiniAppAdaptShareInfoResp> {
constructor() {
@@ -23,24 +23,24 @@ class GetMiniAppAdaptShareInfo extends PacketTransformer<typeof proto.MiniAppAda
templateType: req.templateType,
businessType: req.businessType,
picUrl: req.picUrl,
vidUrl: "",
vidUrl: '',
jumpUrl: req.jumpUrl,
iconUrl: req.iconUrl,
verType: req.verType,
shareType: req.shareType,
versionId: req.versionId,
withShareTicket: req.withShareTicket,
webURL: req.webUrl ?? "",
webURL: req.webUrl ?? '',
appidRich: Buffer.alloc(0),
template: {
templateId: "",
templateData: ""
templateId: '',
templateData: ''
},
field20: ""
field20: ''
}
});
return {
cmd: "LightAppSvc.mini_app_share.AdaptShareInfo",
cmd: 'LightAppSvc.mini_app_share.AdaptShareInfo',
data: PacketHexStrBuilder(data)
};
}

Some files were not shown because too many files have changed in this diff Show More