Compare commits

..

11 Commits

Author SHA1 Message Date
手瓜一十雪
c9e91a9b94 fix: defalut config 2025-02-04 19:49:56 +08:00
Mlikiowa
43fb62c5bd release: v4.5.4 2025-02-04 11:35:51 +00:00
手瓜一十雪
cb8727d487 fix: reload and parse msg 2025-02-04 19:34:51 +08:00
Mlikiowa
a94e03e2fd release: v4.5.3 2025-02-04 10:16:07 +00:00
手瓜一十雪
425c3c6432 fix: 避免重复reload 2025-02-04 18:14:13 +08:00
手瓜一十雪
89b9610016 fix: 避免read异常 2025-02-04 18:13:42 +08:00
手瓜一十雪
62fe88f868 Merge pull request #760 from NapNeko/config-refactor
refactor
2025-02-04 18:09:57 +08:00
手瓜一十雪
11a7f5fade refactor 2025-02-04 18:09:30 +08:00
bietiaop
fbde997f7c style: 调整样式 2025-02-04 17:58:38 +08:00
bietiaop
26734a35ef fix: 文件下载 2025-02-04 15:31:10 +08:00
Mlikiowa
715c4ac534 release: v4.5.2 2025-02-04 06:52:11 +00:00
23 changed files with 654 additions and 149 deletions

View File

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

View File

@@ -16,6 +16,16 @@ import store from '@/store'
const WebLoginPage = lazy(() => import('@/pages/web_login'))
const IndexPage = lazy(() => import('@/pages/index'))
const QQLoginPage = lazy(() => import('@/pages/qq_login'))
const DashboardIndexPage = lazy(() => import('@/pages/dashboard'))
const AboutPage = lazy(() => import('@/pages/dashboard/about'))
const ConfigPage = lazy(() => import('@/pages/dashboard/config'))
const DebugPage = lazy(() => import('@/pages/dashboard/debug'))
const HttpDebug = lazy(() => import('@/pages/dashboard/debug/http'))
const WSDebug = lazy(() => import('@/pages/dashboard/debug/websocket'))
const FileManagerPage = lazy(() => import('@/pages/dashboard/file_manager'))
const LogsPage = lazy(() => import('@/pages/dashboard/logs'))
const NetworkPage = lazy(() => import('@/pages/dashboard/network'))
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'))
function App() {
return (
@@ -58,9 +68,21 @@ function AuthChecker({ children }: { children: React.ReactNode }) {
function AppRoutes() {
return (
<Routes>
<Route element={<IndexPage />} path="/*" />
<Route element={<QQLoginPage />} path="/qq_login" />
<Route element={<WebLoginPage />} path="/web_login" />
<Route path="/" element={<IndexPage />}>
<Route index element={<DashboardIndexPage />} />
<Route path="network" element={<NetworkPage />} />
<Route path="config" element={<ConfigPage />} />
<Route path="logs" element={<LogsPage />} />
<Route path="debug" element={<DebugPage />}>
<Route path="ws" element={<WSDebug />} />
<Route path="http" element={<HttpDebug />} />
</Route>
<Route path="file_manager" element={<FileManagerPage />} />
<Route path="terminal" element={<TerminalPage />} />
<Route path="about" element={<AboutPage />} />
</Route>
<Route path="/qq_login" element={<QQLoginPage />} />
<Route path="/web_login" element={<WebLoginPage />} />
</Routes>
)
}

View File

@@ -9,6 +9,7 @@ import {
import { Spinner } from '@heroui/spinner'
import { useRequest } from 'ahooks'
import path from 'path-browserify'
import { useEffect } from 'react'
import FileManager from '@/controllers/file_manager'
@@ -33,6 +34,7 @@ export default function FilePreviewModal({
async () => FileManager.downloadToURL(filePath),
{
refreshDeps: [filePath],
manual: true,
refreshDepsAction: () => {
const ext = path.extname(filePath).toLowerCase()
if (!filePath || !supportedPreviewExts.includes(ext)) {
@@ -66,6 +68,12 @@ export default function FilePreviewModal({
)
}
useEffect(() => {
if (filePath) {
run()
}
}, [])
return (
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior="inside" size="3xl">
<ModalContent>

View File

@@ -33,6 +33,7 @@ export default function ImageNameButton({
async () => FileManager.downloadToURL(filePath),
{
refreshDeps: [filePath],
manual: true,
refreshDepsAction: () => {
const ext = path.extname(filePath).toLowerCase()
if (!filePath || !imageExts.includes(ext)) {
@@ -52,6 +53,11 @@ export default function ImageNameButton({
}
}, [data, name, onAddPreview])
useEffect(() => {
if (filePath) {
run()
}
}, [])
return (
<Button
variant="light"

View File

@@ -20,7 +20,7 @@ const WebsocketServerForm: React.FC<WebsocketServerFormProps> = ({
enable: false,
name: '',
host: '0.0.0.0',
port: 3000,
port: 3001,
reportSelfMessage: false,
enableForcePushEvent: true,
messagePostFormat: 'array',

View File

@@ -0,0 +1,265 @@
import {
AnimatePresence,
HTMLMotionProps,
TargetAndTransition,
Transition,
motion
} from 'motion/react'
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState
} from 'react'
function cn(...classes: (string | undefined | null | boolean)[]): string {
return classes.filter(Boolean).join(' ')
}
export interface RotatingTextRef {
next: () => void
previous: () => void
jumpTo: (index: number) => void
reset: () => void
}
export interface RotatingTextProps
extends Omit<
HTMLMotionProps<'span'>,
'children' | 'transition' | 'initial' | 'animate' | 'exit'
> {
texts: string[]
transition?: Transition
initial?: TargetAndTransition
animate?: TargetAndTransition
exit?: TargetAndTransition
animatePresenceMode?: 'sync' | 'wait'
animatePresenceInitial?: boolean
rotationInterval?: number
staggerDuration?: number
staggerFrom?: 'first' | 'last' | 'center' | 'random' | number
loop?: boolean
auto?: boolean
splitBy?: string
onNext?: (index: number) => void
mainClassName?: string
splitLevelClassName?: string
elementLevelClassName?: string
}
const RotatingText = forwardRef<RotatingTextRef, RotatingTextProps>(
(
{
texts,
transition = { type: 'spring', damping: 25, stiffness: 300 },
initial = { y: '100%', opacity: 0 },
animate = { y: 0, opacity: 1 },
exit = { y: '-120%', opacity: 0 },
animatePresenceMode = 'wait',
animatePresenceInitial = false,
rotationInterval = 2000,
staggerDuration = 0,
staggerFrom = 'first',
loop = true,
auto = true,
splitBy = 'characters',
onNext,
mainClassName,
splitLevelClassName,
elementLevelClassName,
...rest
},
ref
) => {
const [currentTextIndex, setCurrentTextIndex] = useState<number>(0)
const splitIntoCharacters = (text: string): string[] => {
return Array.from(text)
}
const elements = useMemo(() => {
const currentText: string = texts[currentTextIndex]
if (splitBy === 'characters') {
const words = currentText.split(' ')
return words.map((word, i) => ({
characters: splitIntoCharacters(word),
needsSpace: i !== words.length - 1
}))
}
if (splitBy === 'words') {
return currentText.split(' ').map((word, i, arr) => ({
characters: [word],
needsSpace: i !== arr.length - 1
}))
}
if (splitBy === 'lines') {
return currentText.split('\n').map((line, i, arr) => ({
characters: [line],
needsSpace: i !== arr.length - 1
}))
}
return currentText.split(splitBy).map((part, i, arr) => ({
characters: [part],
needsSpace: i !== arr.length - 1
}))
}, [texts, currentTextIndex, splitBy])
const getStaggerDelay = useCallback(
(index: number, totalChars: number): number => {
const total = totalChars
if (staggerFrom === 'first') return index * staggerDuration
if (staggerFrom === 'last') return (total - 1 - index) * staggerDuration
if (staggerFrom === 'center') {
const center = Math.floor(total / 2)
return Math.abs(center - index) * staggerDuration
}
if (staggerFrom === 'random') {
const randomIndex = Math.floor(Math.random() * total)
return Math.abs(randomIndex - index) * staggerDuration
}
return Math.abs((staggerFrom as number) - index) * staggerDuration
},
[staggerFrom, staggerDuration]
)
const handleIndexChange = useCallback(
(newIndex: number) => {
setCurrentTextIndex(newIndex)
if (onNext) onNext(newIndex)
},
[onNext]
)
const next = useCallback(() => {
const nextIndex =
currentTextIndex === texts.length - 1
? loop
? 0
: currentTextIndex
: currentTextIndex + 1
if (nextIndex !== currentTextIndex) {
handleIndexChange(nextIndex)
}
}, [currentTextIndex, texts.length, loop, handleIndexChange])
const previous = useCallback(() => {
const prevIndex =
currentTextIndex === 0
? loop
? texts.length - 1
: currentTextIndex
: currentTextIndex - 1
if (prevIndex !== currentTextIndex) {
handleIndexChange(prevIndex)
}
}, [currentTextIndex, texts.length, loop, handleIndexChange])
const jumpTo = useCallback(
(index: number) => {
const validIndex = Math.max(0, Math.min(index, texts.length - 1))
if (validIndex !== currentTextIndex) {
handleIndexChange(validIndex)
}
},
[texts.length, currentTextIndex, handleIndexChange]
)
const reset = useCallback(() => {
if (currentTextIndex !== 0) {
handleIndexChange(0)
}
}, [currentTextIndex, handleIndexChange])
useImperativeHandle(
ref,
() => ({
next,
previous,
jumpTo,
reset
}),
[next, previous, jumpTo, reset]
)
useEffect(() => {
if (!auto) return
const intervalId = setInterval(next, rotationInterval)
return () => clearInterval(intervalId)
}, [next, rotationInterval, auto])
return (
<motion.span
className={cn(
'flex flex-wrap whitespace-pre-wrap relative',
mainClassName
)}
{...rest}
layout
transition={transition}
>
<span className="sr-only">{texts[currentTextIndex]}</span>
<AnimatePresence
mode={animatePresenceMode}
initial={animatePresenceInitial}
>
<motion.div
key={currentTextIndex}
className={cn(
splitBy === 'lines'
? 'flex flex-col w-full'
: 'flex flex-wrap whitespace-pre-wrap relative'
)}
layout
aria-hidden="true"
initial={initial as HTMLMotionProps<'div'>['initial']}
animate={animate as HTMLMotionProps<'div'>['animate']}
exit={exit as HTMLMotionProps<'div'>['exit']}
>
{elements.map((wordObj, wordIndex, array) => {
const previousCharsCount = array
.slice(0, wordIndex)
.reduce((sum, word) => sum + word.characters.length, 0)
return (
<span
key={wordIndex}
className={cn('inline-flex', splitLevelClassName)}
>
{wordObj.characters.map((char, charIndex) => (
<motion.span
key={charIndex}
initial={initial as HTMLMotionProps<'span'>['initial']}
animate={animate as HTMLMotionProps<'span'>['animate']}
exit={exit as HTMLMotionProps<'span'>['exit']}
transition={{
...transition,
delay: getStaggerDelay(
previousCharsCount + charIndex,
array.reduce(
(sum, word) => sum + word.characters.length,
0
)
)
}}
className={cn('inline-block', elementLevelClassName)}
>
{char}
</motion.span>
))}
{wordObj.needsSpace && (
<span className="whitespace-pre"> </span>
)}
</span>
)
})}
</motion.div>
</AnimatePresence>
</motion.span>
)
}
)
RotatingText.displayName = 'RotatingText'
export default RotatingText

View File

@@ -16,7 +16,6 @@ import { compareVersion } from '@/utils/version'
import WebUIManager from '@/controllers/webui_manager'
import { GithubRelease } from '@/types/github'
import packageJson from '../../package.json'
import TailwindMarkdown from './tailwind_markdown'
export interface SystemInfoItemProps {
@@ -198,11 +197,6 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
<CardBody className="flex-1">
<div className="flex flex-col justify-between h-full">
<NapCatVersion />
<SystemInfoItem
title="WebUI 版本"
icon={<IoLogoChrome className="text-xl" />}
value={packageJson.version}
/>
<SystemInfoItem
title="QQ 版本"
icon={<FaQq className="text-lg" />}
@@ -216,6 +210,11 @@ const SystemInfo: React.FC<SystemInfoProps> = (props) => {
)
}
/>
<SystemInfoItem
title="WebUI 版本"
icon={<IoLogoChrome className="text-xl" />}
value="Next"
/>
<SystemInfoItem
title="系统版本"
icon={<RiMacFill className="text-xl" />}

View File

@@ -99,7 +99,7 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
if (theme === 'dark') {
terminalRef.current.options.theme = {
background: '#00000000',
black: '#000000',
black: '#ffffff',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',

View File

@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from 'react'
// 全局图片缓存
const imageCache = new Map<string, HTMLImageElement>()
export function usePreloadImages(urls: string[]) {
const [loadedUrls, setLoadedUrls] = useState<Record<string, boolean>>({})
const [isLoading, setIsLoading] = useState(true)
const isMounted = useRef(true)
useEffect(() => {
isMounted.current = true
// 检查是否所有图片都已缓存
const allCached = urls.every((url) => imageCache.has(url))
if (allCached) {
setLoadedUrls(urls.reduce((acc, url) => ({ ...acc, [url]: true }), {}))
setIsLoading(false)
return
}
setIsLoading(true)
const loadedImages: Record<string, boolean> = {}
let pendingCount = urls.length
urls.forEach((url) => {
// 如果已经缓存,直接标记为已加载
if (imageCache.has(url)) {
loadedImages[url] = true
pendingCount--
if (pendingCount === 0) {
setLoadedUrls(loadedImages)
setIsLoading(false)
}
return
}
const img = new Image()
img.onload = () => {
if (!isMounted.current) return
loadedImages[url] = true
imageCache.set(url, img)
pendingCount--
if (pendingCount === 0) {
setLoadedUrls(loadedImages)
setIsLoading(false)
}
}
img.onerror = () => {
if (!isMounted.current) return
loadedImages[url] = false
pendingCount--
if (pendingCount === 0) {
setLoadedUrls(loadedImages)
setIsLoading(false)
}
}
img.src = url
})
return () => {
isMounted.current = false
}
}, [urls])
return { loadedUrls, isLoading }
}

View File

@@ -1,12 +1,18 @@
import { Chip } from '@heroui/chip'
import { Card, CardBody } from '@heroui/card'
import { Image } from '@heroui/image'
import { Link } from '@heroui/link'
import { Spinner } from '@heroui/spinner'
import { useRequest } from 'ahooks'
import clsx from 'clsx'
import { useMemo } from 'react'
import { BsTelegram, BsTencentQq } from 'react-icons/bs'
import { IoDocument } from 'react-icons/io5'
import HoverTiltedCard from '@/components/hover_titled_card'
import NapCatRepoInfo from '@/components/napcat_repo_info'
import { title } from '@/components/primitives'
import RotatingText from '@/components/rotating_text'
import { usePreloadImages } from '@/hooks/use-preload-images'
import { useTheme } from '@/hooks/use-theme'
import logo from '@/assets/images/logo.png'
import WebUIManager from '@/controllers/webui_manager'
@@ -14,54 +20,181 @@ import WebUIManager from '@/controllers/webui_manager'
function VersionInfo() {
const { data, loading, error } = useRequest(WebUIManager.getPackageInfo)
return (
<div className="flex items-center gap-2 mb-5">
<Chip
startContent={
<Chip color="warning" size="sm" className="-ml-0.5 select-none">
NapCat
</Chip>
}
>
<div className="flex items-center gap-2 text-2xl font-bold">
<div className="flex items-center gap-2">
<div className="text-danger-500 drop-shadow-md">NapCat</div>
{error ? (
error.message
) : loading ? (
<Spinner size="sm" />
) : (
data?.version
<RotatingText
texts={['WebUI', data?.version ?? '']}
mainClassName="overflow-hidden flex items-center bg-danger-500 px-2 rounded-lg text-default-50 shadow-md"
staggerFrom={'last'}
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '-120%' }}
staggerDuration={0.025}
splitLevelClassName="overflow-hidden"
transition={{ type: 'spring', damping: 30, stiffness: 400 }}
rotationInterval={2000}
/>
)}
</Chip>
</div>
</div>
)
}
export default function AboutPage() {
const { isDark } = useTheme()
const imageUrls = useMemo(
() => [
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=dark',
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=dark'
],
[]
)
const { loadedUrls, isLoading } = usePreloadImages(imageUrls)
const getImageUrl = useMemo(
() => (baseUrl: string) => {
const theme = isDark ? 'dark' : 'light'
const fullUrl = baseUrl.replace(
/color_scheme=(?:light|dark)/,
`color_scheme=${theme}`
)
return isLoading ? null : loadedUrls[fullUrl] ? fullUrl : null
},
[isDark, isLoading, loadedUrls]
)
const renderImage = useMemo(
() => (baseUrl: string, alt: string) => {
const imageUrl = getImageUrl(baseUrl)
if (!imageUrl) {
return (
<div className="flex-1 h-32 flex items-center justify-center bg-default-100 rounded-lg">
<Spinner />
</div>
)
}
return (
<Image
className="flex-1 pointer-events-none select-none"
src={imageUrl}
alt={alt}
/>
)
},
[getImageUrl]
)
return (
<>
<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 mb-6">
<HoverTiltedCard imageSrc={logo} />
<section className="max-w-7xl py-8 md:py-10 px-5 mx-auto space-y-10">
<div className="w-full flex flex-col md:flex-row gap-4">
<div className="flex flex-col md:flex-row items-center">
<HoverTiltedCard imageSrc={logo} overlayContent="" />
</div>
<VersionInfo />
<div className="mb-6 flex flex-col items-center gap-4">
<p
className={clsx(
title({
color: 'cyan',
shadow: true
}),
'!text-3xl'
)}
>
NapCat Contributors
</p>
<Image
className="w-[600px] max-w-full pointer-events-none select-none"
src="https://contrib.rocks/image?repo=bietiaop/NapCatQQ"
alt="Contributors"
/>
<div className="flex-1 flex flex-col gap-2 py-2">
<VersionInfo />
<div className="space-y-1">
<p className="font-bold text-danger-400">NapCat ?</p>
<p className="text-default-800">
TypeScript构建的Bot框架,,QQ
Node模块提供给客户端的接口,Bot的功能.
</p>
<p className="font-bold text-danger-400"></p>
<p className="text-default-800">
QQ
便使 OneBot HTTP /
WebSocket
QQ发送接口之类的接口
</p>
</div>
</div>
</div>
<div className="flex flex-row gap-2 flex-wrap justify-around">
<Card
as={Link}
shadow="sm"
isPressable
isExternal
href="https://qm.qq.com/q/F9cgs1N3Mc"
>
<CardBody className="flex-row items-center gap-2">
<span className="p-2 rounded-small bg-primary-50">
<BsTencentQq size={16} />
</span>
<span>1</span>
</CardBody>
</Card>
<Card
as={Link}
shadow="sm"
isPressable
isExternal
href="https://qm.qq.com/q/hSt0u9PVn"
>
<CardBody className="flex-row items-center gap-2">
<span className="p-2 rounded-small bg-primary-50">
<BsTencentQq size={16} />
</span>
<span>2</span>
</CardBody>
</Card>
<Card
as={Link}
shadow="sm"
isPressable
isExternal
href="https://t.me/MelodicMoonlight"
>
<CardBody className="flex-row items-center gap-2">
<span className="p-2 rounded-small bg-primary-50">
<BsTelegram size={16} />
</span>
<span>Telegram</span>
</CardBody>
</Card>
<Card
as={Link}
shadow="sm"
isPressable
isExternal
href="https://napcat.napneko.icu/"
>
<CardBody className="flex-row items-center gap-2">
<span className="p-2 rounded-small bg-primary-50">
<IoDocument size={16} />
</span>
<span>使</span>
</CardBody>
</Card>
</div>
<div className="flex flex-col md:flex-row md:items-start gap-4">
<div className="w-full flex flex-col gap-4">
{renderImage(
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
'Contributors'
)}
{renderImage(
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
'Activity Trends'
)}
</div>
<NapCatRepoInfo />
</div>
</section>

View File

@@ -15,7 +15,7 @@ import { useWebSocketDebug } from '@/hooks/use-websocket-debug'
export default function WSDebug() {
const url = new URL(window.location.origin)
url.port = '3000'
url.port = '3001'
url.protocol = 'ws:'
const defaultWsUrl = url.href
const [socketConfig, setSocketConfig] = useLocalStorage(key.wsDebugConfig, {

View File

@@ -1,46 +1,35 @@
import { Spinner } from '@heroui/spinner'
import { AnimatePresence, motion } from 'motion/react'
import { Route, Routes, useLocation } from 'react-router-dom'
import { Suspense } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import DefaultLayout from '@/layouts/default'
import DashboardIndexPage from './dashboard'
import AboutPage from './dashboard/about'
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()
return (
<DefaultLayout>
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -50 }}
transition={{ duration: 0.3 }}
>
<Routes location={location} key={location.pathname}>
<Route element={<DashboardIndexPage />} path="/" />
<Route element={<NetworkPage />} path="/network" />
<Route element={<ConfigPage />} path="/config" />
<Route element={<LogsPage />} path="/logs" />
<Route element={<DebugPage />} path="/debug">
<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>
</AnimatePresence>
<Suspense
fallback={
<div className="flex justify-center px-10">
<Spinner />
</div>
}
>
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: 'tween',
ease: 'easeInOut'
}}
>
<Outlet />
</motion.div>
</AnimatePresence>
</Suspense>
</DefaultLayout>
)
}

View File

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

View File

@@ -2,73 +2,73 @@ import path from 'node:path';
import fs from 'node:fs';
import type { NapCatCore } from '@/core';
import json5 from 'json5';
import Ajv, { AnySchema, ValidateFunction } from 'ajv';
export abstract class ConfigBase<T> {
name: string;
core: NapCatCore;
configPath: string;
configData: T = {} as T;
ajv: Ajv;
validate: ValidateFunction<T>;
protected constructor(name: string, core: NapCatCore, configPath: string, copy_default: boolean = true) {
protected constructor(name: string, core: NapCatCore, configPath: string, ConfigSchema: AnySchema) {
this.name = name;
this.core = core;
this.configPath = configPath;
this.ajv = new Ajv({ useDefaults: true, coerceTypes: true });
this.validate = this.ajv.compile<T>(ConfigSchema);
fs.mkdirSync(this.configPath, { recursive: true });
this.read(copy_default);
this.read();
}
protected getKeys(): string[] | null {
// 决定 key 在json配置文件中的顺序
return null;
getConfigPath(pathName?: string): string {
const filename = pathName ? `${this.name}_${pathName}.json` : `${this.name}.json`;
return path.join(this.configPath, filename);
}
getConfigPath(pathName: string | undefined): string {
if (!pathName) {
const filename = `${this.name}.json`;
const mainPath = this.core.context.pathWrapper.binaryPath;
return path.join(mainPath, 'config', filename);
} else {
const filename = `${this.name}_${pathName}.json`;
return path.join(this.configPath, filename);
}
}
read(copy_default: boolean = true): T {
read(): T {
const configPath = this.getConfigPath(this.core.selfInfo.uin);
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: unknown) {
this.core.context.logger.logError('[Core] [Config] 创建配置文件时发生错误:', (e as Error).message);
const defaultConfigPath = this.getConfigPath();
if (!fs.existsSync(configPath)) {
if (fs.existsSync(defaultConfigPath)) {
this.configData = this.loadConfig(defaultConfigPath);
}
} else if (!fs.existsSync(configPath) && !copy_default) {
fs.writeFileSync(configPath, '{}');
this.save();
return this.configData;
}
return this.loadConfig(configPath);
}
private loadConfig(configPath: string): T {
try {
this.configData = json5.parse(fs.readFileSync(configPath, 'utf-8'));
let newConfigData = json5.parse(fs.readFileSync(configPath, 'utf-8'));
this.validate(newConfigData);
this.configData = newConfigData;
this.core.context.logger.logDebug(`[Core] [Config] 配置文件${configPath}加载`, this.configData);
return this.configData;
} catch (e: unknown) {
if (e instanceof SyntaxError) {
this.core.context.logger.logError('[Core] [Config] 配置文件格式错误,请检查配置文件:', e.message);
} else {
this.core.context.logger.logError('[Core] [Config] 读取配置文件时发生错误:', (e as Error).message);
}
this.handleError(e, '读取配置文件时发生错误');
return {} as T;
}
}
save(newConfigData: T = this.configData) {
const selfInfo = this.core.selfInfo;
save(newConfigData: T = this.configData): void {
const configPath = this.getConfigPath(this.core.selfInfo.uin);
this.validate(newConfigData);
this.configData = newConfigData;
const configPath = this.getConfigPath(selfInfo.uin);
try {
fs.writeFileSync(configPath, JSON.stringify(newConfigData, this.getKeys(), 2));
fs.writeFileSync(configPath, JSON.stringify(this.configData, null, 2));
} catch (e: unknown) {
this.core.context.logger.logError(`保存配置文件 ${configPath} 时发生错误:`, (e as Error).message);
this.handleError(e, `保存配置文件 ${configPath} 时发生错误:`);
}
}
}
private handleError(e: unknown, message: string): void {
if (e instanceof SyntaxError) {
this.core.context.logger.logError(`[Core] [Config] 操作配置文件格式错误,请检查配置文件:`, e.message);
} else {
this.core.context.logger.logError(`[Core] [Config] ${message}:`, (e as Error).message);
}
}
}

View File

@@ -1 +1 @@
export const napCatVersion = '4.5.1';
export const napCatVersion = '4.5.4';

View File

@@ -1,11 +1,21 @@
import { ConfigBase } from '@/common/config-base';
import napCatDefaultConfig from '@/core/external/napcat.json';
import { NapCatCore } from '@/core';
import { Type, Static } from '@sinclair/typebox';
import { AnySchema } from 'ajv';
export type NapCatConfig = typeof napCatDefaultConfig;
export const NapcatConfigSchema = Type.Object({
fileLog: Type.Boolean({ default: false }),
consoleLog: Type.Boolean({ default: true }),
fileLogLevel: Type.String({ default: 'debug' }),
consoleLogLevel: Type.String({ default: 'info' }),
packetBackend: Type.String({ default: 'auto' }),
packetServer: Type.String({ default: '' })
});
export class NapCatConfigLoader extends ConfigBase<NapCatConfig> {
constructor(core: NapCatCore, configPath: string) {
super('napcat', core, configPath);
export type NapcatConfig = Static<typeof NapcatConfigSchema>;
export class NapCatConfigLoader extends ConfigBase<NapcatConfig> {
constructor(core: NapCatCore, configPath: string, schema: AnySchema) {
super('napcat', core, configPath, schema);
}
}

View File

@@ -25,7 +25,7 @@ import fs from 'node:fs';
import { hostname, systemName, systemVersion } from '@/common/system';
import { NTEventWrapper } from '@/common/event';
import { KickedOffLineInfo, SelfInfo, SelfStatusInfo } from '@/core/types';
import { NapCatConfigLoader } from '@/core/helper/config';
import { NapCatConfigLoader, NapcatConfigSchema } from '@/core/helper/config';
import os from 'node:os';
import { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/core/listeners';
import { proxiedListenerOf } from '@/common/proxy-handler';
@@ -99,7 +99,7 @@ export class NapCatCore {
this.context = context;
this.util = this.context.wrapper.NodeQQNTWrapperUtil;
this.eventWrapper = new NTEventWrapper(context.session);
this.configLoader = new NapCatConfigLoader(this, this.context.pathWrapper.configPath);
this.configLoader = new NapCatConfigLoader(this, this.context.pathWrapper.configPath,NapcatConfigSchema);
this.apis = {
FileApi: new NTQQFileApi(this.context, this),
SystemApi: new NTQQSystemApi(this.context, this),

View File

@@ -857,7 +857,7 @@ export class OneBotMsgApi {
return parsedElement;
}
}
return [];
return;
},
));

View File

@@ -78,7 +78,7 @@ const NetworkConfigSchema = Type.Object({
plugins: Type.Array(PluginConfigSchema, { default: [] })
}, { default: {} });
const OneBotConfigSchema = Type.Object({
export const OneBotConfigSchema = Type.Object({
network: NetworkConfigSchema,
musicSignUrl: Type.String({ default: '' }),
enableLocalFile2Url: Type.Boolean({ default: false }),

View File

@@ -1,9 +1,10 @@
import { ConfigBase } from '@/common/config-base';
import { NapCatCore } from '@/core';
import { OneBotConfig } from './config';
import { AnySchema } from 'ajv';
export class OB11ConfigLoader extends ConfigBase<OneBotConfig> {
constructor(core: NapCatCore, configPath: string) {
super('onebot11', core, configPath, false);
constructor(core: NapCatCore, configPath: string, schema: AnySchema) {
super('onebot11', core, configPath, schema);
}
}

View File

@@ -44,8 +44,8 @@ import { LRUCache } from '@/common/lru-cache';
import { BotOfflineEvent } from './event/notice/BotOfflineEvent';
import {
NetworkAdapterConfig,
loadConfig,
OneBotConfig,
OneBotConfigSchema,
} from './config/config';
import { OB11Message } from './types';
import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
@@ -66,9 +66,7 @@ export class NapCatOneBot11Adapter {
constructor(core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
this.core = core;
this.context = context;
this.configLoader = new OB11ConfigLoader(core, pathWrapper.configPath);
this.configLoader.save(this.configLoader.configData);
this.configLoader.save(loadConfig(this.configLoader.configData));
this.configLoader = new OB11ConfigLoader(core, pathWrapper.configPath, OneBotConfigSchema);
this.apis = {
GroupApi: new OneBotGroupApi(this, core),
UserApi: new OneBotUserApi(this, core),
@@ -176,9 +174,6 @@ export class NapCatOneBot11Adapter {
WebUiDataRuntime.setQQLoginStatus(true);
WebUiDataRuntime.setOnOB11ConfigChanged(async (newConfig) => {
const prev = this.configLoader.configData;
// 保证默认配置
newConfig = loadConfig(newConfig);
this.configLoader.save(newConfig);
//this.context.logger.log(`OneBot11 配置更改:${JSON.stringify(prev)} -> ${JSON.stringify(newConfig)}`);
await this.reloadNetwork(prev, newConfig);

View File

@@ -15,7 +15,7 @@ import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
import json5 from 'json5';
export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> {
wsServer: WebSocketServer;
wsServer?: WebSocketServer;
wsClients: WebSocket[] = [];
wsClientsMutex = new Mutex();
private heartbeatIntervalId: NodeJS.Timeout | null = null;
@@ -30,7 +30,11 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
host: this.config.host === '0.0.0.0' ? '' : this.config.host,
maxPayload: 1024 * 1024 * 1024,
});
this.wsServer.on('connection', async (wsClient, wsReq) => {
this.createServer(this.wsServer);
}
createServer(newServer: WebSocketServer) {
newServer.on('connection', async (wsClient, wsReq) => {
if (!this.isEnable) {
wsClient.close();
return;
@@ -40,7 +44,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
const paramUrl = wsReq.url?.indexOf('?') !== -1 ? wsReq.url?.substring(0, wsReq.url?.indexOf('?')) : wsReq.url;
const isApiConnect = paramUrl === '/api' || paramUrl === '/api/';
if (!isApiConnect) {
this.connectEvent(core, wsClient);
this.connectEvent(this.core, wsClient);
}
wsClient.on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Client Error:', err.message));
@@ -74,7 +78,6 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
});
}).on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Server Error:', err.message));
}
connectEvent(core: NapCatCore, wsClient: WebSocket) {
try {
this.checkStateAndReply<unknown>(new OB11LifeCycleEvent(core, LifeCycleSubType.CONNECT), wsClient);
@@ -96,7 +99,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
this.logger.logError('[OneBot] [WebSocket Server] Cannot open a opened WebSocket server');
return;
}
const addressInfo = this.wsServer.address();
const addressInfo = this.wsServer?.address();
this.logger.log('[OneBot] [WebSocket Server] Server Started', typeof (addressInfo) === 'string' ? addressInfo : addressInfo?.address + ':' + addressInfo?.port);
this.isEnable = true;
@@ -108,7 +111,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
async close() {
this.isEnable = false;
this.wsServer.close((err) => {
this.wsServer?.close((err) => {
if (err) {
this.logger.logError('[OneBot] [WebSocket Server] Error closing server:', err.message);
} else {
@@ -205,6 +208,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
host: newConfig.host === '0.0.0.0' ? '' : newConfig.host,
maxPayload: 1024 * 1024 * 1024,
});
this.createServer(this.wsServer);
if (newConfig.enable) {
this.open();
}

View File

@@ -268,7 +268,11 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
export const DownloadHandler: RequestHandler = async (req, res) => {
try {
const filePath = normalizePath(req.query['path'] as string);
const filePath = normalizePath( req.query[ 'path' ] as string );
if (!filePath) {
return sendError( res, '参数错误' );
}
const stat = await fsProm.stat(filePath);
res.setHeader('Content-Type', 'application/octet-stream');