Compare commits

...

59 Commits

Author SHA1 Message Date
手瓜一十雪
a849b5edc0 feat: websocket server 2025-03-21 16:07:47 +08:00
手瓜一十雪
be2f3be4bd x 2025-03-21 15:59:15 +08:00
手瓜一十雪
f7cc25adc1 fix: 轻量版 2025-03-21 15:16:29 +08:00
手瓜一十雪
441a34e0bf fix: 完整tree-shaking 2025-03-21 15:04:53 +08:00
手瓜一十雪
7a4c82bded fix: network 2025-03-21 14:38:12 +08:00
手瓜一十雪
5fa2e9d8f5 fix: sse http 2025-03-21 14:35:41 +08:00
手瓜一十雪
b40873ada7 fix: http basic 2025-03-21 14:11:49 +08:00
手瓜一十雪
4db65cf860 Merge branch 'main' into refactor 2025-03-21 13:57:44 +08:00
手瓜一十雪
58d2bd3c81 Revert "fix: #883"
This reverts commit 79aa1dc67f.
2025-03-20 10:57:57 +08:00
手瓜一十雪
6534d05b76 Revert "fix"
This reverts commit 2d7de174c5.
2025-03-20 10:57:54 +08:00
手瓜一十雪
2d7de174c5 fix 2025-03-20 10:32:30 +08:00
手瓜一十雪
79aa1dc67f fix: #883 2025-03-20 10:32:13 +08:00
Mlikiowa
7792ad9ea0 release: v4.7.6 2025-03-19 07:35:12 +00:00
手瓜一十雪
be6671923b feat: 33139 2025-03-19 15:34:33 +08:00
手瓜一十雪
0fa1b3f044 feat: 33139 2025-03-19 15:26:55 +08:00
手瓜一十雪
4ab751696b Revert "fix: image size"
This reverts commit 2759a34d96.
2025-03-19 11:58:19 +08:00
手瓜一十雪
dce4eedf7d Revert "fix: moduleResolution"
This reverts commit 9ab776d53a.
2025-03-19 11:58:16 +08:00
手瓜一十雪
129b67b751 Revert "chore(deps-dev): bump image-size from 1.2.0 to 2.0.1"
This reverts commit e3feb6a73c.
2025-03-19 11:57:55 +08:00
手瓜一十雪
9ab776d53a fix: moduleResolution 2025-03-19 11:54:29 +08:00
手瓜一十雪
2759a34d96 fix: image size 2025-03-19 11:09:57 +08:00
手瓜一十雪
2f9f42750e Merge branch 'main' of https://github.com/NapNeko/NapCatQQ 2025-03-19 10:45:59 +08:00
手瓜一十雪
30abd1f904 fix: #884 2025-03-19 10:45:56 +08:00
手瓜一十雪
008075466e Merge pull request #887 from NapNeko/dependabot/npm_and_yarn/eslint-import-resolver-typescript-4.0.0
chore(deps-dev): bump eslint-import-resolver-typescript from 3.9.1 to 4.0.0
2025-03-19 10:39:33 +08:00
手瓜一十雪
5b4035c320 Merge pull request #888 from NapNeko/dependabot/npm_and_yarn/image-size-2.0.1
chore(deps-dev): bump image-size from 1.2.0 to 2.0.1
2025-03-19 10:39:21 +08:00
dependabot[bot]
e3feb6a73c chore(deps-dev): bump image-size from 1.2.0 to 2.0.1
Bumps [image-size](https://github.com/image-size/image-size) from 1.2.0 to 2.0.1.
- [Release notes](https://github.com/image-size/image-size/releases)
- [Commits](https://github.com/image-size/image-size/compare/v1.2.0...v2.0.1)

---
updated-dependencies:
- dependency-name: image-size
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-17 09:23:32 +00:00
dependabot[bot]
40fe73317d chore(deps-dev): bump eslint-import-resolver-typescript
Bumps [eslint-import-resolver-typescript](https://github.com/import-js/eslint-import-resolver-typescript) from 3.9.1 to 4.0.0.
- [Release notes](https://github.com/import-js/eslint-import-resolver-typescript/releases)
- [Changelog](https://github.com/import-js/eslint-import-resolver-typescript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-import-resolver-typescript/compare/v3.9.1...v4.0.0)

---
updated-dependencies:
- dependency-name: eslint-import-resolver-typescript
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-17 09:21:48 +00:00
pk5ls20
073745030c fix: #843
- maybe a temporary solution
2025-03-16 20:07:47 +08:00
Mlikiowa
c523437506 release: v4.7.5 2025-03-16 08:08:15 +00:00
手瓜一十雪
9eef570d37 fix: network prepare 2025-03-16 16:07:51 +08:00
Mlikiowa
be37b8cbbd release: v4.7.4 2025-03-16 07:57:45 +00:00
手瓜一十雪
c635496677 fix: msf Status 2025-03-16 15:57:27 +08:00
Mlikiowa
8753ecfd92 release: v4.7.3 2025-03-16 03:57:48 +00:00
手瓜一十雪
5eda1f2870 fix: quick login 2025-03-16 11:57:28 +08:00
Mlikiowa
d5a60074f7 release: v4.7.2 2025-03-16 03:55:17 +00:00
手瓜一十雪
91df57d932 fix: async error 2025-03-16 11:55:00 +08:00
Mlikiowa
e27d4c4302 release: v4.7.1 2025-03-16 03:40:48 +00:00
手瓜一十雪
55847f6e10 fix: quick login 延迟问题 2025-03-16 11:39:38 +08:00
手瓜一十雪
b39d8bae27 fix: login timer / add:不规范的promise 2025-03-16 10:42:15 +08:00
手瓜一十雪
b0cf23f775 doc: security 2025-03-16 09:36:27 +08:00
手瓜一十雪
c641246056 doc: code of conduct 2025-03-16 09:33:06 +08:00
Mlikiowa
1e5bc9bbea release: v4.7.0 2025-03-16 01:13:17 +00:00
手瓜一十雪
99b504b5f6 fix: #880 2025-03-16 09:12:52 +08:00
Mlikiowa
1146454fec release: v4.6.9 2025-03-15 10:58:09 +00:00
手瓜一十雪
805e014a75 fix: #877 2025-03-15 18:54:51 +08:00
Mlikiowa
d3acd1efc1 release: v4.6.8 2025-03-14 10:13:29 +00:00
手瓜一十雪
9fcd218a5a fix: #873 2025-03-14 18:12:58 +08:00
手瓜一十雪
d6a0830cfe fix: #875 2025-03-14 18:07:03 +08:00
手瓜一十雪
40a63b9c66 fix: #870 2025-03-14 17:53:03 +08:00
手瓜一十雪
eeb19a04cc fix: packet异常 2025-03-14 17:39:37 +08:00
Mlikiowa
91e457eb03 release: v4.6.7 2025-03-09 08:31:07 +00:00
手瓜一十雪
78d1919d7f feat: 32896 2025-03-09 16:30:43 +08:00
手瓜一十雪
8393acf173 Merge pull request #856 from HDTianRu/main
feat: 额外返回原msgSeq条目
2025-03-09 10:09:41 +08:00
手瓜一十雪
bca152a047 feat: readme 翻新 2025-03-09 10:08:49 +08:00
HDTianRu
6a15908a93 feat: 额外返回原msgSeq条目 2025-03-08 16:36:17 +08:00
bietiaop
c626bbab74 fix: #854 2025-03-07 10:25:38 +08:00
Mlikiowa
c5c7dcc6f2 release: v4.6.6 2025-03-06 10:51:30 +00:00
手瓜一十雪
03dafe727e fix: win 2025-03-06 18:51:05 +08:00
Mlikiowa
744921c45e release: v4.6.5 2025-03-06 10:09:45 +00:00
手瓜一十雪
610e07ac32 refactor: express-> hono 2025-02-26 12:12:24 +08:00
77 changed files with 773 additions and 2994 deletions

View File

@@ -6,5 +6,7 @@
"tsconfig.json": "tsconfig.*.json, env.d.ts, vite.config.ts",
"package.json": "package-lock.json, eslint*, .prettier*, .editorconfig, manifest.json, logo.png, .gitignore, LICENSE"
},
"css.customData": [".vscode/tailwindcss.json"],
"css.customData": [
".vscode/tailwindcss.json"
],
}

128
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
nanaeonn@outlook.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View File

@@ -1,67 +1,62 @@
<div align="center">
# NapCat
![NapCatQQ](https://socialify.git.ci/NapNeko/NapCatQQ/image?font=Jost&logo=https%3A%2F%2Fnapneko.github.io%2Fassets%2Fnewlogo.png&name=1&owner=1&pattern=Diagonal+Stripes&stargazers=1&theme=Auto)
_Modern protocol-side framework implemented based on NTQQ._
> 云起兮风生,心向远方兮路未曾至.
</div>
---
## 欢迎回家
NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
## 特性介绍
- [x] **安装简单**:就算是笨蛋也能使用
- [x] **性能友好**:就算是低内存也能使用
- [x] **接口丰富**:就算是没有也能使用
- [x] **稳定好用**:就算是被捉也能使用
## Welcome
+ NapCatQQ is a modern implementation of the Bot protocol based on NTQQ.
- NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
## 使用框架
## Feature
+ **Easy to Use**
- 作为初学者能够轻松使用.
+ **Quick and Efficient**
- 在低内存操作系统长时运行.
+ **Rich API Interface**
- 完整实现了大部分标准接口.
+ **Stable and Reliable**
- 持续稳定的开发与维护.
## Quick Start
可前往 [Release](https://github.com/NapNeko/NapCatQQ/releases/) 页面下载最新版本
**首次使用**请务必查看如下文档看使用教程
### 文档地址
## Link
[Cloudflare.Worker](https://doc.napneko.icu/)
| Docs | [![Github.IO](https://img.shields.io/badge/docs%20on-Github.IO-orange)](https://napneko.github.io/) | [![Cloudflare.Worker](https://img.shields.io/badge/docs%20on-Cloudflare.Worker-black)](https://doc.napneko.icu/) | [![Cloudflare.HKServer](https://img.shields.io/badge/docs%20on-Cloudflare.HKServer-informational)](https://napcat.napneko.icu/) |
|:-:|:-:|:-:|:-:|
[Cloudflare.HKServer](https://napcat.napneko.icu/)
| Docs | [![Cloudflare.Pages](https://img.shields.io/badge/docs%20on-Cloudflare.Pages-blue)](https://napneko.pages.dev/) | [![Server.Other](https://img.shields.io/badge/docs%20on-Server.Other-green)](https://docs.napcat.cyou/) | [![NapCat.Wiki](https://img.shields.io/badge/docs%20on-NapCat.Wiki-red)](https://www.napcat.wiki) |
|:-:|:-:|:-:|:-:|
[Github.IO](https://napneko.github.io/)
| Contact | [![QQ Group#1](https://img.shields.io/badge/QQ%20Group%231-Join-blue)](https://qm.qq.com/q/I6LU87a0Yq) | [![QQ Group#2](https://img.shields.io/badge/QQ%20Group%232-Join-blue)](https://qm.qq.com/q/HaRcfrHpUk) | [![Telegram](https://img.shields.io/badge/Telegram-MelodicMoonlight-blue)](https://t.me/MelodicMoonlight) |
|:-:|:-:|:-:|:-:|
[Cloudflare.Pages](https://napneko.pages.dev/)
## Thanks
[Server.Other](https://docs.napcat.cyou/)
+ [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
[NapCat.Wiki](https://www.napcat.wiki)
+ [LLOneBot](https://github.com/LLOneBot/LLOneBot) 相关的开发曾参与本项目部分开发
## 回家旅途
[QQ Group#1](https://qm.qq.com/q/I6LU87a0Yq)
[QQ Group#2](https://qm.qq.com/q/HaRcfrHpUk)
[Telegram](https://t.me/MelodicMoonlight)
> QQ Group#2 准许Bot / Telegram与QQ Group#2 为新建Group
## 性能设计/协议标准
NapCat 已实现90+的 OneBot / GoCQ 标准接口,并提供兼容性保留接口,其设计理念遵守 无数据库/异步优先/后台刷新 的性能思想。
由此设计带来一系列好处在开发中获取群员列表通常小于50Ms单条文本消息发送在320Ms以内在1k+的群聊流畅运行同时带来一些副作用消息Id无法持久无法上报撤回消息原始内容。
NapCat 在设计理念下遵守 OneBot 规范大多数要求并且积极改进,任何合理的标准化 Issue 与 Pr 将被接收。
## 感谢他们
感谢 [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
感谢 React 强力驱动 NapCat.WebUi
不过最最重要的 还是需要感谢屏幕前的你哦~
+ 不过最最重要的 还是需要感谢屏幕前的你哦~
---
## 特殊感谢
[LLOneBot](https://github.com/LLOneBot/LLOneBot) 相关的开发曾参与本项目
## License
本项目采用 混合协议 开源,因此使用本项目时,你需要注意以下几点:
1. 第三方库代码或修改部分遵循其原始开源许可.
2. 本项目获取部分项目授权而不受部分约束
2. 项目其余逻辑代码采用[本仓库开源许可](./LICENSE).
## 开源附加
任何使用本仓库代码的地方,都应当严格遵守[本仓库开源许可](./LICENSE)。**本仓库仅用于提高易用性,实现消息推送类功能,此外,禁止任何项目未经仓库主作者授权基于 NapCat 代码开发。使用请遵守当地法律法规,由此造成的问题由使用者和提供违规使用教程者负责。**
**本仓库仅用于提高易用性,实现消息推送类功能,此外,禁止任何项目未经仓库主作者授权基于 NapCat 代码开发。使用请遵守当地法律法规,由此造成的问题由使用者和提供违规使用教程者负责。**

11
SECURITY.md Normal file
View File

@@ -0,0 +1,11 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| > 4.0 | :white_check_mark: |
| < 4.0 | :x: |
## Reporting a Vulnerability
you should open an issue

View File

@@ -19,7 +19,7 @@ for %%a in ("%RetString%") do (
SET QQPath=%pathWithoutUninstall%QQ.exe
if not exist "%QQpath%" (
echo provided QQ path is invalid: %QQpath%
echo provided QQ path is invalid
pause
exit /b
)

View File

@@ -19,7 +19,7 @@ for %%a in ("%RetString%") do (
SET QQPath=%pathWithoutUninstall%QQ.exe
if not exist "%QQpath%" (
echo provided QQ path is invalid: %QQpath%
echo provided QQ path is invalid
pause
exit /b
)

View File

@@ -27,8 +27,8 @@ for %%a in ("%RetString%") do (
SET QQPath=%pathWithoutUninstall%QQ.exe
if not exist "%QQpath%" (
echo provided QQ path is invalid: %QQpath%
if not exist "%QQPath%" (
echo provided QQ path is invalid
pause
exit /b
)

View File

@@ -27,8 +27,8 @@ for %%a in ("%RetString%") do (
SET QQPath=%pathWithoutUninstall%QQ.exe
if not exist "%QQpath%" (
echo provided QQ path is invalid: %QQpath%
if not exist "%QQPath%" (
echo provided QQ path is invalid
pause
exit /b
)

View File

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

View File

@@ -136,7 +136,7 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
</div>
<Card
shadow="sm"
className="my-4 bg-opacity-50 backdrop-blur-md overflow-visible z-20"
className="my-4 bg-opacity-50 backdrop-blur-md overflow-visible"
>
<CardHeader className="font-bold text-lg gap-1 pb-0">
<span className="mr-2"></span>

View File

@@ -2,7 +2,7 @@
"name": "napcat",
"private": true,
"type": "module",
"version": "4.6.4",
"version": "4.7.6",
"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",
@@ -23,6 +23,7 @@
"@eslint/js": "^9.14.0",
"@ffmpeg.wasm/main": "^0.13.1",
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5",
"@hono/node-server": "^1.13.8",
"@log4js-node/log4js-api": "^1.0.2",
"@napneko/nap-proto-core": "^0.0.4",
"@rollup/plugin-node-resolve": "^16.0.0",
@@ -42,31 +43,30 @@
"ajv": "^8.13.0",
"async-mutex": "^0.5.0",
"commander": "^13.0.0",
"compressing": "^1.10.1",
"cors": "^2.8.5",
"esbuild": "0.25.0",
"eslint": "^9.14.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-import-resolver-typescript": "^4.0.0",
"eslint-plugin-import": "^2.29.1",
"express-rate-limit": "^7.5.0",
"fast-xml-parser": "^4.3.6",
"file-type": "^20.0.0",
"globals": "^16.0.0",
"hono": "^4.7.2",
"image-size": "^1.1.1",
"json5": "^2.2.3",
"multer": "^1.4.5-lts.1",
"napcat.protobuf": "^1.1.3",
"typescript": "^5.3.3",
"typescript-eslint": "^8.13.0",
"vite": "^6.0.1",
"vite-plugin-cp": "^4.0.8",
"vite-plugin-wasm": "^3.4.1",
"vite-tsconfig-paths": "^5.1.0",
"napcat.protobuf": "^1.1.3",
"winston": "^3.17.0",
"compressing": "^1.10.1"
},
"dependencies": {
"@ffmpeg.wasm/core-mt": "^0.13.2",
"express": "^5.0.0",
"silk-wasm": "^3.6.1",
"ws": "^8.18.0"
}
}
"@hono/node-ws": "^1.1.0",
"winston": "^3.17.0"
},
"dependencies": {}
}

View File

@@ -1 +1 @@
export const napCatVersion = '4.6.4';
export const napCatVersion = '4.7.6';

View File

@@ -27,6 +27,9 @@ export class NTQQGroupApi {
this.core = core;
}
async setGroupRemark(groupCode: string, remark: string) {
return this.context.session.getGroupService().modifyGroupRemark(groupCode, remark);
}
async fetchGroupDetail(groupCode: string) {
const [, detailInfo] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelGroupService/getGroupDetailInfo',
@@ -345,9 +348,9 @@ export class NTQQGroupApi {
return this.context.session.getGroupService().uploadGroupBulletinPic(groupCode, _Pskey, imageurl);
}
async handleGroupRequest(notify: GroupNotify, operateType: NTGroupRequestOperateTypes, reason?: string) {
async handleGroupRequest(doubt: boolean, notify: GroupNotify, operateType: NTGroupRequestOperateTypes, reason?: string) {
return this.context.session.getGroupService().operateSysNotify(
false,
doubt,
{
operateType: operateType,
targetMsg: {

View File

@@ -210,5 +210,21 @@
"3.2.16-32793": {
"appid": 537271279,
"qua": "V1_LNX_NQ_3.2.16_32793_GW_B"
},
"3.2.16-32869": {
"appid": 537271329,
"qua": "V1_LNX_NQ_3.2.16_32869_GW_B"
},
"9.9.18-32869": {
"appid": 537271294,
"qua": "V1_WIN_NQ_9.9.18_32869_GW_B"
},
"3.2.16-33139": {
"appid": 537273909,
"qua": "V1_LNX_NQ_3.2.16_33139_GW_B"
},
"9.9.18-33139": {
"appid": 537273874,
"qua": "V1_WIN_NQ_9.9.18_33139_GW_B"
}
}

View File

@@ -268,8 +268,8 @@
"recv": "7229F60"
},
"9.9.18-32793-x64": {
"send": "3D3051B",
"recv": "3D3F48C"
"send": "39F9A30",
"recv": "39FE230"
},
"3.2.16-32793-x64": {
"send": "A5E24C0",
@@ -278,5 +278,29 @@
"3.2.16-32793-arm64": {
"send": "7226630",
"recv": "7229F60"
},
"9.9.18-32869-x64": {
"send": "39F9A30",
"recv": "39FE230"
},
"3.2.16-32869-x64": {
"send": "A5E24C0",
"recv": "A5E5EE0"
},
"3.2.16-32869-arm64": {
"send": "7226630",
"recv": "7229F60"
},
"9.9.18-33139-x64": {
"send": "39F5870",
"recv": "39FA070"
},
"3.2.16-33139-x64": {
"send": "A634F60",
"recv": "A638980"
},
"3.2.16-33139-arm64": {
"send": "7262BB0",
"recv": "72664E0"
}
}

View File

@@ -1,5 +1,5 @@
export class NodeIKernelLoginListener {
onLoginConnected(...args: any[]): any {
onLoginConnected(): Promise<void> | void {
}
onLoginDisConnected(...args: any[]): any {

View File

@@ -1,4 +1,3 @@
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';
@@ -83,14 +82,14 @@ export class WsPacketClient extends IPacketClient {
this.logger.warn('WebSocket 连接关闭,尝试重连...');
reject(new Error('WebSocket 连接关闭'));
};
this.websocket.onmessage = (event) => this.handleMessage(event.data).catch(err => {
this.websocket.onmessage = (ev: MessageEvent<any>) => this.handleMessage(ev).catch(err => {
this.logger.error(`处理消息时出错: ${err}`);
});
this.websocket.onerror = (event: ErrorEvent) => {
this.websocket.onerror = (event) => {
this.available = false;
this.logger.error(`WebSocket 出错: ${event.message}`);
this.logger.error(`WebSocket 出错: ${event}`);
this.websocket?.close();
reject(new Error(`WebSocket 出错: ${event.message}`));
reject(new Error(`WebSocket 出错: ${event}`));
};
});
}
@@ -99,9 +98,9 @@ export class WsPacketClient extends IPacketClient {
return new Promise(resolve => setTimeout(resolve, ms));
}
private async handleMessage(message: Data): Promise<void> {
private async handleMessage(message: MessageEvent): Promise<void> {
try {
const json: RecvPacket = JSON.parse(message.toString());
const json: RecvPacket = JSON.parse(message.data.toString());
const trace_id_md5 = json.trace_id_md5;
const action = json?.type ?? 'init';
const event = this.cb.get(`${trace_id_md5}${action}`);

View File

@@ -9,15 +9,14 @@ class SetSpecialTitle extends PacketTransformer<typeof proto.OidbSvcTrpcTcpBase>
}
build(groupCode: number, uid: string, tittle: string): OidbPacket {
const oidb_0x8FC_2_body = new NapProtoMsg(proto.OidbSvcTrpcTcp0X8FC_2_Body).encode({
targetUid: uid,
specialTitle: tittle,
expiredTime: -1,
uinName: tittle
});
const oidb_0x8FC_2 = new NapProtoMsg(proto.OidbSvcTrpcTcp0X8FC_2).encode({
groupUin: +groupCode,
body: oidb_0x8FC_2_body
body: {
targetUid: uid,
specialTitle: tittle,
expiredTime: -1,
uinName: tittle
}
});
return OidbBase.build(0x8FC, 2, oidb_0x8FC_2, false, false);
}

View File

@@ -4,12 +4,12 @@ import { ProtoField, ScalarType } from '@napneko/nap-proto-core';
//设置群头衔 OidbSvcTrpcTcp.0x8fc_2
export const OidbSvcTrpcTcp0X8FC_2_Body = {
targetUid: ProtoField(1, ScalarType.STRING),
specialTitle: ProtoField(5, ScalarType.STRING),
expiredTime: ProtoField(6, ScalarType.SINT32),
uinName: ProtoField(7, ScalarType.STRING),
specialTitle: ProtoField(5, ScalarType.STRING, true),
expiredTime: ProtoField(6, ScalarType.INT32),
uinName: ProtoField(7, ScalarType.STRING, true),
targetName: ProtoField(8, ScalarType.STRING),
};
export const OidbSvcTrpcTcp0X8FC_2 = {
groupUin: ProtoField(1, ScalarType.UINT32),
body: ProtoField(3, ScalarType.BYTES),
body: ProtoField(3, () => OidbSvcTrpcTcp0X8FC_2_Body),
};

View File

@@ -165,7 +165,7 @@ export interface NodeIKernelGroupService {
modifyGroupName(groupCode: string, groupName: string, isNormalMember: boolean): Promise<GeneralCallResult>;
modifyGroupRemark(groupCode: string, remark: string): void;
modifyGroupRemark(groupCode: string, remark: string): Promise<GeneralCallResult>;
modifyGroupDetailInfo(groupCode: string, arg: unknown): void;

View File

@@ -60,7 +60,10 @@ export interface QuickLoginResult {
}
export interface NodeIKernelLoginService {
getMsfStatus: () => number;
setLoginMiscData(arg0: string, value: string): unknown;
getMachineGuid(): string;
get(): NodeIKernelLoginService;

View File

@@ -7,13 +7,11 @@ import { SelfInfo } from '@/core/types';
import { NodeIKernelLoginListener } from '@/core/listeners';
import { NodeIKernelLoginService } from '@/core/services';
import { NodeIQQNTWrapperSession, WrapperNodeApi } from '@/core/wrapper';
import { InitWebUi, WebUiConfig } from '@/webui';
import { NapCatOneBot11Adapter } from '@/onebot';
//Framework ES入口文件
export async function getWebUiUrl() {
const WebUiConfigData = (await WebUiConfig.GetWebUIConfig());
return 'http://127.0.0.1:' + WebUiConfigData.port + '/webui/?token=' + WebUiConfigData.token;
return 'http://127.0.0.1:' + 6099 + '/webui/?token=napcat';
}
export async function NCoreInitFramework(
@@ -58,8 +56,6 @@ export async function NCoreInitFramework(
const loaderObject = new NapCatFramework(wrapper, session, logger, loginService, selfInfo, basicInfoWrapper, pathWrapper);
await loaderObject.core.initCore();
//启动WebUi
InitWebUi(logger, pathWrapper).then().catch(e => logger.logError(e));
//初始化LLNC的Onebot实现
await new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper).InitOneBot();
}

View File

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

View File

@@ -20,6 +20,7 @@ class GetGroupInfo extends OneBotAction<Payload, OB11Group> {
const data = await this.core.apis.GroupApi.fetchGroupDetail(payload.group_id.toString());
return {
...data,
group_remark: '',
group_id: +payload.group_id,
group_name: data.groupName,
member_count: data.memberNum,

View File

@@ -20,11 +20,12 @@ export default class SetGroupAddRequest extends OneBotAction<Payload, null> {
const approve = payload.approve?.toString() !== 'false';
const reason = payload.reason ?? ' ';
const invite_notify = this.obContext.apis.MsgApi.notifyGroupInvite.get(flag);
const notify = invite_notify ?? await this.findNotify(flag);
const { doubt, notify } = invite_notify ? { doubt: false, notify: invite_notify } : await this.findNotify(flag);
if (!notify) {
throw new Error('No such request');
}
await this.core.apis.GroupApi.handleGroupRequest(
doubt,
notify,
approve ? NTGroupRequestOperateTypes.KAGREE : NTGroupRequestOperateTypes.KREFUSE,
reason,
@@ -36,7 +37,8 @@ export default class SetGroupAddRequest extends OneBotAction<Payload, null> {
let notify = (await this.core.apis.GroupApi.getSingleScreenNotifies(false, 100)).find(e => e.seq == flag);
if (!notify) {
notify = (await this.core.apis.GroupApi.getSingleScreenNotifies(true, 100)).find(e => e.seq == flag);
return { doubt: true, notify };
}
return notify;
return { doubt: false, notify };
}
}

View File

@@ -108,10 +108,12 @@ import { BotExit } from './extends/BotExit';
import { ClickInlineKeyboardButton } from './extends/ClickInlineKeyboardButton';
import { GetPrivateFileUrl } from './file/GetPrivateFileUrl';
import { GetUnidirectionalFriendList } from './extends/GetUnidirectionalFriendList';
import SetGroupRemark from './extends/SetGroupRemark';
export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCore) {
const actionHandlers = [
new SetGroupRemark(obContext, core),
new GetGroupInfoEx(obContext, core),
new FetchEmojiLike(obContext, core),
new GetFile(obContext, core),
@@ -227,8 +229,8 @@ export function createActionMap(obContext: NapCatOneBot11Adapter, core: NapCatCo
new GetGroupSystemMsg(obContext, core),
new BotExit(obContext, core),
new ClickInlineKeyboardButton(obContext, core),
new GetPrivateFileUrl(obContext,core),
new GetUnidirectionalFriendList(obContext,core),
new GetPrivateFileUrl(obContext, core),
new GetUnidirectionalFriendList(obContext, core),
];
type HandlerUnion = typeof actionHandlers[number];

View File

@@ -10,6 +10,7 @@ export interface InvalidCheckResult {
}
export const ActionName = {
SetGroupRemark: 'set_group_remark',
NapCat_GetPrivateFileUrl: 'get_private_file_url',
ClickInlineKeyboardButton: 'click_inline_keyboard_button',
GetUnidirectionalFriendList: 'get_unidirectional_friend_list',

View File

@@ -809,6 +809,7 @@ export class OneBotMsgApi {
message_id: msg.id!,
message_seq: msg.id!,
real_id: msg.id!,
real_seq: msg.msgSeq,
message_type: msg.chatType == ChatType.KCHATTYPEGROUP ? 'group' : 'private',
sender: {
user_id: +(msg.senderUin ?? 0),

View File

@@ -84,17 +84,19 @@ export class OneBotQuickActionApi {
let notify = (await this.core.apis.GroupApi.getSingleScreenNotifies(false, 100)).find(e => e.seq == flag);
if (!notify) {
notify = (await this.core.apis.GroupApi.getSingleScreenNotifies(true, 100)).find(e => e.seq == flag);
return { doubt: true, notify };
}
return notify;
return { doubt: false, notify };
}
async handleGroupRequest(request: OB11GroupRequestEvent, quickAction: QuickActionGroupRequest) {
const invite_notify = this.obContext.apis.MsgApi.notifyGroupInvite.get(request.flag);
const notify = invite_notify ?? await this.findNotify(request.flag);
const { doubt, notify } = invite_notify ? { doubt: false, notify: invite_notify } : await this.findNotify(request.flag);
if (!isNull(quickAction.approve) && notify) {
this.core.apis.GroupApi.handleGroupRequest(
doubt,
notify,
quickAction.approve ? NTGroupRequestOperateTypes.KAGREE : NTGroupRequestOperateTypes.KREFUSE,
quickAction.reason,

View File

@@ -73,6 +73,7 @@ export class OB11Construct {
static group(group: Group): OB11Group {
return {
group_remark: group.remarkName,
group_id: +group.groupCode,
group_name: group.groupName,
member_count: group.memberCount,

View File

@@ -20,8 +20,7 @@ import {
OB11WebSocketClientAdapter,
OB11NetworkManager,
OB11NetworkReloadType,
OB11HttpServerAdapter,
OB11WebSocketServerAdapter,
OB11HttpServerAdapter
} from '@/onebot/network';
import { NapCatPathWrapper } from '@/common/path';
import {
@@ -32,7 +31,6 @@ import {
OneBotUserApi,
} from '@/onebot/api';
import { ActionMap, createActionMap } from '@/onebot/action';
import { WebUiDataRuntime } from '@/webui/src/helper/Data';
import { OB11InputStatusEvent } from '@/onebot/event/notice/OB11InputStatusEvent';
import { MessageUnique } from '@/common/message-unique';
import { proxiedListenerOf } from '@/common/proxy-handler';
@@ -139,15 +137,6 @@ export class NapCatOneBot11Adapter {
}
for (const key of ob11Config.network.websocketServers) {
if (key.enable) {
this.networkManager.registerAdapter(
new OB11WebSocketServerAdapter(
key.name,
key,
this.core,
this,
this.actions
)
);
}
}
for (const key of ob11Config.network.websocketClients) {
@@ -168,64 +157,9 @@ export class NapCatOneBot11Adapter {
this.initMsgListener();
this.initBuddyListener();
this.initGroupListener();
WebUiDataRuntime.setQQVersion(this.core.context.basicInfoWrapper.getFullQQVesion());
WebUiDataRuntime.setQQLoginInfo(selfInfo);
WebUiDataRuntime.setQQLoginStatus(true);
WebUiDataRuntime.setOnOB11ConfigChanged(async (newConfig) => {
const prev = this.configLoader.configData;
this.configLoader.save(newConfig);
//this.context.logger.log(`OneBot11 配置更改:${JSON.stringify(prev)} -> ${JSON.stringify(newConfig)}`);
await this.reloadNetwork(prev, newConfig);
});
}
private async reloadNetwork(prev: OneBotConfig, now: OneBotConfig): Promise<void> {
const prevLog = await this.creatOneBotLog(prev);
const newLog = await this.creatOneBotLog(now);
this.context.logger.log(`[Notice] [OneBot11] 配置变更前:\n${prevLog}`);
this.context.logger.log(`[Notice] [OneBot11] 配置变更后:\n${newLog}`);
await this.handleConfigChange(prev.network.httpServers, now.network.httpServers, OB11HttpServerAdapter);
await this.handleConfigChange(prev.network.httpClients, now.network.httpClients, OB11HttpClientAdapter);
await this.handleConfigChange(prev.network.httpSseServers, now.network.httpSseServers, OB11HttpSSEServerAdapter);
await this.handleConfigChange(prev.network.websocketServers, now.network.websocketServers, OB11WebSocketServerAdapter);
await this.handleConfigChange(prev.network.websocketClients, now.network.websocketClients, OB11WebSocketClientAdapter);
}
private async handleConfigChange<CT extends NetworkAdapterConfig>(
prevConfig: NetworkAdapterConfig[],
nowConfig: NetworkAdapterConfig[],
adapterClass: new (
...args: ConstructorParameters<typeof IOB11NetworkAdapter<CT>>
) => IOB11NetworkAdapter<CT>
): Promise<void> {
// 比较旧的在新的找不到的回收
for (const adapterConfig of prevConfig) {
const existingAdapter = nowConfig.find((e) => e.name === adapterConfig.name);
if (!existingAdapter) {
const existingAdapter = this.networkManager.findSomeAdapter(adapterConfig.name);
if (existingAdapter) {
await this.networkManager.closeSomeAdaterWhenOpen([existingAdapter]);
}
}
}
// 通知新配置重载 删除关闭的 加入新开的
for (const adapterConfig of nowConfig) {
const existingAdapter = this.networkManager.findSomeAdapter(adapterConfig.name);
if (existingAdapter) {
const networkChange = await existingAdapter.reload(adapterConfig);
if (networkChange === OB11NetworkReloadType.NetWorkClose) {
await this.networkManager.closeSomeAdaterWhenOpen([existingAdapter]);
}
} else if (adapterConfig.enable) {
const newAdapter = new adapterClass(adapterConfig.name, adapterConfig as CT, this.core, this, this.actions);
await this.networkManager.registerAdapterAndOpen(newAdapter);
}
}
}
private initMsgListener() {
const msgListener = new NodeIKernelMsgListener();
msgListener.onRecvSysMsg = (msg) => {

View File

@@ -1,33 +1,59 @@
import { OB11EmitEventContent } from './index';
import { Request, Response } from 'express';
import { OB11HttpServerAdapter } from './http-server';
import { Context } from 'hono';
import { SSEStreamingApi, streamSSE } from 'hono/streaming';
import { Mutex } from 'async-mutex';
import { LifeCycleSubType, OB11LifeCycleEvent } from '../event/meta/OB11LifeCycleEvent';
export class OB11HttpSSEServerAdapter extends OB11HttpServerAdapter {
private sseClients: Response[] = [];
private sseClients: { context: Context; stream: SSEStreamingApi, mutex: Mutex }[] = [];
override async handleRequest(req: Request, res: Response) {
if (req.path === '/_events') {
this.createSseSupport(req, res);
override async actionHandler(c: Context): Promise<any> {
if (c.req.path === '/_events') {
return await this.createSseSupport(c);
} else {
super.httpApiRequest(req, res);
return super.actionHandler(c);
}
}
private async createSseSupport(req: Request, res: Response) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
private async createSseSupport(c: Context) {
return streamSSE(c, async (stream) => {
const client = { context: c, stream, mutex: new Mutex() };
this.sseClients.push(client);
client.mutex.acquire();
this.sseClients.push(res);
req.on('close', () => {
this.sseClients = this.sseClients.filter((client) => client !== res);
stream.onAbort(() => {
this.removeClient(stream);
client.mutex.release();
});
await stream.writeSSE({ data: JSON.stringify(new OB11LifeCycleEvent(this.core, LifeCycleSubType.CONNECT)) });
await client.mutex.waitForUnlock();
});
}
private removeClient(stream: SSEStreamingApi) {
const index = this.sseClients.findIndex(client => client.stream === stream);
if (index !== -1) {
this.sseClients.splice(index, 1);
}
}
override onEvent<T extends OB11EmitEventContent>(event: T) {
this.sseClients.forEach((res) => {
res.write(`data: ${JSON.stringify(event)}\n\n`);
const eventData = JSON.stringify(event);
Promise.all(
this.sseClients.map(async ({ stream, mutex }) => {
try {
await stream.writeSSE({ data: eventData });
} catch (error) {
mutex.release();
this.removeClient(stream);
}
})
).then().catch((error) => {
this.core.context.logger.logError('Error sending SSE event:', error);
});
}
}
}

View File

@@ -1,19 +1,17 @@
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
import express, { Express, NextFunction, Request, Response } from 'express';
import http from 'http';
import { Context, Hono, Next } from 'hono';
import { NapCatCore } from '@/core';
import { OB11Response } from '@/onebot/action/OneBotAction';
import { ActionMap } from '@/onebot/action';
import cors from 'cors';
import { cors } from 'hono/cors';
import { HttpServerConfig } from '@/onebot/config/config';
import { NapCatOneBot11Adapter } from '@/onebot';
import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
import json5 from 'json5';
import { isFinished } from 'on-finished';
import typeis from 'type-is';
import { serve } from '@hono/node-server';
export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig> {
private app: Express | undefined;
private server: http.Server | undefined;
private app: Hono | undefined;
private server: ReturnType<typeof serve> | undefined;
constructor(name: string, config: HttpServerConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap) {
super(name, config, core, obContext, actions);
@@ -27,17 +25,14 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
open() {
try {
if (this.isEnable) {
this.core.context.logger.logError('Cannot open a closed HTTP server');
this.core.context.logger.logError('[OneBot] [HTTP Server Adapter] 无法打开已经启动的HTTP服务器');
return;
}
if (!this.isEnable) {
this.initializeServer();
this.isEnable = true;
}
this.initializeServer();
this.isEnable = true;
} catch (e) {
this.core.context.logger.logError(`[OneBot] [HTTP Server Adapter] Boot Error: ${e}`);
this.core.context.logger.logError(`[OneBot] [HTTP Server Adapter] 启动错误: ${e}`);
}
}
async close() {
@@ -46,101 +41,159 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
this.app = undefined;
}
private initializeServer() {
this.app = express();
this.server = http.createServer(this.app);
this.app = new Hono();
// 注册全局中间件
this.app.use(cors());
this.app.use(express.urlencoded({ extended: true, limit: '5000mb' }));
this.app.use(this.authMiddleware.bind(this));
this.app.use(this.statusCheckMiddleware.bind(this));
this.app.use(this.payloadParserMiddleware.bind(this));
this.app.use((req, res, next) => {
if (isFinished(req)) {
next();
return;
}
if (!typeis.hasBody(req)) {
next();
return;
}
// 兼容处理没有带content-type的请求
req.headers['content-type'] = 'application/json';
let rawData = '';
req.on('data', (chunk) => {
rawData += chunk;
});
req.on('end', () => {
try {
req.body = { ...json5.parse(rawData || '{}'), ...req.body };
next();
} catch {
return res.status(400).send('Invalid JSON');
}
return;
});
req.on('error', () => {
return res.status(400).send('Invalid JSON');
});
});
//@ts-expect-error authorize
this.app.use((req, res, next) => this.authorize(this.config.token, req, res, next));
this.app.use(async (req, res) => {
await this.handleRequest(req, res);
});
this.server.listen(this.config.port, () => {
this.core.context.logger.log(`[OneBot] [HTTP Server Adapter] Start On Port ${this.config.port}`);
// 注册路由
this.app.get('/', this.rootHandler.bind(this));
this.app.all('/*', this.actionHandler.bind(this));
// 启动服务器
this.server = serve({
fetch: this.app.fetch.bind(this.app),
port: this.config.port,
});
this.core.context.logger.log(`[OneBot] [HTTP Server Adapter] 服务器已启动于端口 ${this.config.port}`);
}
private authorize(token: string | undefined, req: Request, res: Response, next: NextFunction) {
if (!token || token.length == 0) return next();//客户端未设置密钥
const HeaderClientToken = req.headers.authorization?.split('Bearer ').pop() || '';
const QueryClientToken = req.query['access_token'];
const ClientToken = typeof (QueryClientToken) === 'string' && QueryClientToken !== '' ? QueryClientToken : HeaderClientToken;
if (ClientToken === token) {
/**
* 身份验证中间件
*/
private async authMiddleware(c: Context, next: Next) {
const token = this.config.token;
if (!token || token.length === 0) {
return next(); // 未配置token跳过验证
}
// 从请求头或查询参数获取token
const headerToken = c.req.header('authorization')?.split('Bearer ').pop() || '';
const queryToken = c.req.query('access_token');
const clientToken = typeof queryToken === 'string' && queryToken !== ''
? queryToken
: headerToken;
if (clientToken === token) {
return next();
} else {
return res.status(403).send(JSON.stringify({ message: 'token verify failed!' }));
}
// 验证失败
c.status(403);
return c.json({ message: 'token验证失败' });
}
/**
* 服务器状态检查中间件
*/
private async statusCheckMiddleware(c: Context, next: Next) {
if (!this.isEnable) {
this.core.context.logger.log('[OneBot] [HTTP Server Adapter] 服务器已关闭');
return c.json(OB11Response.error('服务器已关闭', 200));
}
return next();
}
/**
* 请求参数解析中间件
* 按优先级解析请求参数JSON > 表单 > 查询参数
*/
private async payloadParserMiddleware(c: Context, next: Next) {
try {
// 初始化payload对象
let payload: Record<string, any> = {};
// 1. 提取查询参数
const queryParams = c.req.query();
if (Object.keys(queryParams).length > 0) {
payload = { ...queryParams };
}
// 2. 解析请求体
const contentType = c.req.header('content-type') || '';
let bodyData = {};
try {
// 优先尝试以JSON格式解析
if (contentType.includes('application/json') || contentType === '' || contentType.includes('text/plain')) {
try {
bodyData = await c.req.json();
} catch {
// JSON解析失败时尝试其他方式
}
}
// 如果JSON解析失败或不是JSON格式尝试其他格式
if (Object.keys(bodyData).length === 0) {
if (contentType.includes('application/x-www-form-urlencoded') ||
contentType.includes('multipart/form-data')) {
bodyData = await c.req.parseBody();
} else if (contentType) {
// 尝试通用解析
bodyData = await c.req.parseBody();
}
}
} catch (parseError) {
// 所有解析方式都失败,记录错误但继续处理
this.core.context.logger.log(`[OneBot] [HTTP Server Adapter] 请求体解析失败: ${parseError}`);
}
// 3. 合并参数
payload = { ...payload, ...bodyData };
// 4. 将解析结果保存到上下文
c.set('payload', payload);
return next();
} catch (error) {
this.core.context.logger.logError(`[OneBot] [HTTP Server Adapter] 请求处理错误: ${error}`);
return c.json(OB11Response.error(`参数解析失败: ${(error as Error)?.message || '未知错误'}`, 200));
}
}
async httpApiRequest(req: Request, res: Response) {
let payload = req.body;
if (req.method == 'get') {
payload = req.query;
} else if (req.query) {
payload = { ...req.body, ...req.query };
}
if (req.path === '' || req.path === '/') {
const hello = OB11Response.ok({});
hello.message = 'NapCat4 Is Running';
return res.json(hello);
}
const actionName = req.path.split('/')[1];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const action = this.actions.get(actionName as any);
if (action) {
/**
* 根路径处理器
*/
private rootHandler(c: Context) {
const response = OB11Response.ok({});
response.message = 'NapCat4 Is Running';
return c.json(response);
}
/**
* API动作处理器
*/
async actionHandler(c: Context) {
try {
const payload = c.get('payload') as Record<string, any>;
const actionName = c.req.path.split('/')[1];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const action = this.actions.get(actionName as any);
if (!action) {
return c.json(OB11Response.error(`不支持的API: ${actionName}`, 200));
}
try {
const result = await action.handle(payload, this.name, this.config);
return res.json(result);
return c.json(result);
} catch (error: unknown) {
return res.json(OB11Response.error((error as Error)?.stack?.toString() || (error as Error)?.message || 'Error Handle', 200));
const errorMessage = (error as Error)?.stack || (error as Error)?.message || 'Error Handle';
this.core.context.logger.logError(`[OneBot] [HTTP Server Adapter] API处理错误: ${errorMessage}`);
return c.json(OB11Response.error(errorMessage, 200));
}
} else {
return res.json(OB11Response.error('不支持的Api ' + actionName, 200));
} catch (error: unknown) {
const errorMessage = (error as Error)?.message || '未知错误';
this.core.context.logger.logError(`[OneBot] [HTTP Server Adapter] 请求处理失败: ${errorMessage}`);
return c.json(OB11Response.error(`请求处理失败: ${errorMessage}`, 200));
}
}
async handleRequest(req: Request, res: Response) {
if (!this.isEnable) {
this.core.context.logger.log('[OneBot] [HTTP Server Adapter] Server is closed');
res.json(OB11Response.error('Server is closed', 200));
return;
}
this.httpApiRequest(req, res);
return;
}
async reload(newConfig: HttpServerConfig) {
const wasEnabled = this.isEnable;
const oldPort = this.config.port;
@@ -164,4 +217,4 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
return OB11NetworkReloadType.Normal;
}
}
}

View File

@@ -103,5 +103,4 @@ export class OB11NetworkManager {
export * from './http-client';
export * from './websocket-client';
export * from './http-server';
export * from './websocket-server';
export * from './http-server';

View File

@@ -1,5 +1,4 @@
import { OB11EmitEventContent, OB11NetworkReloadType } from '@/onebot/network/index';
import { RawData, WebSocket } from 'ws';
import { OB11HeartbeatEvent } from '@/onebot/event/meta/OB11HeartbeatEvent';
import { NapCatCore } from '@/core';
import { ActionName } from '@/onebot/action/router';
@@ -10,10 +9,12 @@ import { WebsocketClientConfig } from '@/onebot/config/config';
import { NapCatOneBot11Adapter } from '@/onebot';
import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
import json5 from 'json5';
import { hc } from 'hono/client';
export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketClientConfig> {
private connection: WebSocket | null = null;
private heartbeatRef: NodeJS.Timeout | null = null;
private client = hc(this.config.url);
constructor(name: string, config: WebsocketClientConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap) {
super(name, config, core, obContext, actions);
@@ -65,37 +66,23 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
private async tryConnect() {
if (!this.connection && this.isEnable) {
let isClosedByError = false;
let wsClientX = this.client['ws']?.$ws(0);
if (!wsClientX) throw new Error('WebSocket Client Error');
this.connection = wsClientX;
this.connection = new WebSocket(this.config.url, {
maxPayload: 1024 * 1024 * 1024,
handshakeTimeout: 2000,
perMessageDeflate: false,
headers: {
'X-Self-ID': this.core.selfInfo.uin,
'Authorization': `Bearer ${this.config.token}`,
'x-client-role': 'Universal', // 为koishi adpter适配
'User-Agent': 'OneBot/11',
},
});
this.connection.on('ping', () => {
this.connection?.pong();
});
this.connection.on('pong', () => {
//this.logger.logDebug('[OneBot] [WebSocket Client] 收到pong');
});
this.connection.on('open', () => {
this.connection.addEventListener('open', () => {
try {
this.connectEvent(this.core);
} catch (e) {
this.logger.logError('[OneBot] [WebSocket Client] 发送连接生命周期失败', e);
}
});
this.connection.addEventListener('message', (event) => {
this.handleMessage(event.data);
});
this.connection.on('message', (data) => {
this.handleMessage(data);
});
this.connection.once('close', () => {
this.connection.addEventListener('close', () => {
if (!isClosedByError) {
this.logger.logError(`[OneBot] [WebSocket Client] 反向WebSocket (${this.config.url}) 连接意外关闭`);
this.logger.logError(`[OneBot] [WebSocket Client] 在 ${Math.floor(this.config.reconnectInterval / 1000)} 秒后尝试重新连接`);
@@ -105,7 +92,8 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
}
}
});
this.connection.on('error', (err) => {
this.connection.addEventListener('error', (err) => {
isClosedByError = true;
this.logger.logError(`[OneBot] [WebSocket Client] 反向WebSocket (${this.config.url}) 连接错误`, err);
this.logger.logError(`[OneBot] [WebSocket Client] 在 ${Math.floor(this.config.reconnectInterval / 1000)} 秒后尝试重新连接`);
@@ -124,7 +112,8 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
this.logger.logError('[OneBot] [WebSocket Client] 发送生命周期失败', e);
}
}
private async handleMessage(message: RawData) {
private async handleMessage(message: MessageEvent) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any } = { action: ActionName.Unknown, params: {} };
let echo = undefined;
@@ -148,6 +137,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config);
this.checkStateAndReply<unknown>({ ...retdata });
}
async reload(newConfig: WebsocketClientConfig) {
const wasEnabled = this.isEnable;
const oldUrl = this.config.url;
@@ -187,4 +177,4 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
return OB11NetworkReloadType.Normal;
}
}
}

View File

@@ -1,196 +1,210 @@
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
import urlParse from 'url';
import { RawData, WebSocket, WebSocketServer } from 'ws';
import { Mutex } from 'async-mutex';
import { OB11Response } from '@/onebot/action/OneBotAction';
import { ActionName } from '@/onebot/action/router';
import { NapCatCore } from '@/core';
import { OB11HeartbeatEvent } from '@/onebot/event/meta/OB11HeartbeatEvent';
import { IncomingMessage } from 'http';
import { ActionMap } from '@/onebot/action';
import { LifeCycleSubType, OB11LifeCycleEvent } from '@/onebot/event/meta/OB11LifeCycleEvent';
import { WebsocketServerConfig } from '@/onebot/config/config';
import { NapCatOneBot11Adapter } from '@/onebot';
import { IOB11NetworkAdapter } from '@/onebot/network/adapter';
import json5 from 'json5';
import { serve } from '@hono/node-server';
import { Context, Hono } from 'hono';
import { createNodeWebSocket } from '@hono/node-ws';
import { WSContext, WSMessageReceive } from 'hono/ws';
import { OB11Response } from '../action/OneBotAction';
import { ActionName } from '../action/router';
import { OB11HeartbeatEvent } from '@/onebot/event/meta/OB11HeartbeatEvent';
import { LifeCycleSubType, OB11LifeCycleEvent } from '@/onebot/event/meta/OB11LifeCycleEvent';
export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> {
wsServer?: WebSocketServer;
wsClients: WebSocket[] = [];
wsClientsMutex = new Mutex();
export class OB11WebsocketServerAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> {
private app: Hono | undefined;
private server: ReturnType<typeof serve> | undefined;
private clients: Set<WSContext<any>> = new Set();
private eventClients: Set<WSContext<any>> = new Set(); // 仅用于接收事件的客户端
private heartbeatIntervalId: NodeJS.Timeout | null = null;
wsClientWithEvent: WebSocket[] = [];
constructor(
name: string, config: WebsocketServerConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap
) {
constructor(name: string, config: WebsocketServerConfig, core: NapCatCore, obContext: NapCatOneBot11Adapter, actions: ActionMap) {
super(name, config, core, obContext, actions);
this.wsServer = new WebSocketServer({
port: this.config.port,
host: this.config.host === '0.0.0.0' ? '' : this.config.host,
maxPayload: 1024 * 1024 * 1024,
});
this.createServer(this.wsServer);
}
createServer(newServer: WebSocketServer) {
newServer.on('connection', async (wsClient, wsReq) => {
if (!this.isEnable) {
wsClient.close();
return;
}
//鉴权
this.authorize(this.config.token, wsClient, wsReq);
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(this.core, wsClient);
}
wsClient.on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Client Error:', err.message));
wsClient.on('message', (message) => {
this.handleMessage(wsClient, message).then().catch(e => this.logger.logError(e));
});
wsClient.on('ping', () => {
wsClient.pong();
});
wsClient.on('pong', () => {
//this.logger.logDebug('[OneBot] [WebSocket Server] Pong received');
});
wsClient.once('close', () => {
this.wsClientsMutex.runExclusive(async () => {
const NormolIndex = this.wsClients.indexOf(wsClient);
if (NormolIndex !== -1) {
this.wsClients.splice(NormolIndex, 1);
}
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
if (EventIndex !== -1) {
this.wsClientWithEvent.splice(EventIndex, 1);
}
override onEvent<T extends OB11EmitEventContent>(event: T) {
if (!this.isEnable || this.eventClients.size === 0) return;
});
});
await this.wsClientsMutex.runExclusive(async () => {
if (!isApiConnect) {
this.wsClientWithEvent.push(wsClient);
}
this.wsClients.push(wsClient);
});
}).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);
} catch (e) {
this.logger.logError('[OneBot] [WebSocket Server] 发送生命周期失败', e);
}
}
onEvent<T extends OB11EmitEventContent>(event: T) {
this.wsClientsMutex.runExclusive(async () => {
this.wsClientWithEvent.forEach((wsClient) => {
wsClient.send(JSON.stringify(event));
const eventData = JSON.stringify(event);
this.eventClients.forEach(client => {
try {
client.send(eventData);
} catch (e) {
this.core.context.logger.logError(`[OneBot] [Websocket Server Adapter] 向客户端发送事件失败: ${e}`);
}
});
});
if (this.config.debug) {
this.core.context.logger.logDebug(`[OneBot] [Websocket Server Adapter] 已广播事件到 ${this.eventClients.size} 个客户端`);
}
} catch (e) {
this.core.context.logger.logError(`[OneBot] [Websocket Server Adapter] 事件序列化失败: ${e}`);
}
}
open() {
if (this.isEnable) {
this.logger.logError('[OneBot] [WebSocket Server] Cannot open a opened WebSocket server');
return;
}
const addressInfo = this.wsServer?.address();
this.logger.log('[OneBot] [WebSocket Server] Server Started', typeof (addressInfo) === 'string' ? addressInfo : addressInfo?.address + ':' + addressInfo?.port);
try {
if (this.isEnable) {
this.core.context.logger.logError('[OneBot] [Websocket Server Adapter] 无法打开已经启动的Websocket服务器');
return;
}
this.initializeServer();
this.isEnable = true;
this.isEnable = true;
if (this.config.heartInterval > 0) {
this.registerHeartBeat();
// 启动心跳
if (this.config.heartInterval > 0) {
this.registerHeartBeat();
}
} catch (e) {
this.core.context.logger.logError(`[OneBot] [Websocket Server Adapter] 启动错误: ${e}`);
}
}
async close() {
this.isEnable = false;
this.wsServer?.close((err) => {
if (err) {
this.logger.logError('[OneBot] [WebSocket Server] Error closing server:', err.message);
} else {
this.logger.log('[OneBot] [WebSocket Server] Server Closed');
}
this.clients.clear();
this.eventClients.clear();
});
// 清除心跳定时器
if (this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId);
this.heartbeatIntervalId = null;
}
await this.wsClientsMutex.runExclusive(async () => {
this.wsClients.forEach((wsClient) => {
wsClient.close();
});
this.wsClients = [];
this.wsClientWithEvent = [];
});
this.server?.close();
this.app = undefined;
}
private registerHeartBeat() {
this.heartbeatIntervalId = setInterval(() => {
this.wsClientsMutex.runExclusive(async () => {
this.wsClientWithEvent.forEach((wsClient) => {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, this.config.heartInterval, this.core.selfInfo.online ?? true, true)));
if (!this.isEnable || this.eventClients.size === 0) return;
try {
const heartbeatEvent = new OB11HeartbeatEvent(
this.core,
this.config.heartInterval,
this.core.selfInfo.online ?? true,
true
);
const eventData = JSON.stringify(heartbeatEvent);
this.eventClients.forEach(client => {
try {
client.send(eventData);
} catch (e) {
this.core.context.logger.logError(`[OneBot] [Websocket Server Adapter] 发送心跳失败: ${e}`);
}
});
});
} catch (e) {
this.core.context.logger.logError(`[OneBot] [Websocket Server Adapter] 心跳事件生成失败: ${e}`);
}
}, this.config.heartInterval);
}
private authorize(token: string | undefined, wsClient: WebSocket, wsReq: IncomingMessage) {
if (!token || token.length == 0) return;//客户端未设置密钥
const QueryClientToken = urlParse.parse(wsReq?.url || '', true).query['access_token'];
const HeaderClientToken = wsReq.headers.authorization?.split('Bearer ').pop() || '';
const ClientToken = typeof (QueryClientToken) === 'string' && QueryClientToken !== '' ? QueryClientToken : HeaderClientToken;
if (ClientToken === token) {
private initializeServer() {
this.app = new Hono();
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app: this.app });
// 处理所有WebSocket请求
this.app.all('/*', upgradeWebSocket((c) => {
// 鉴权处理
if (this.config.token && this.config.token.length > 0) {
const url = new URL(c.req.url, `http://${c.req.header('host') || 'localhost'}`);
const queryToken = url.searchParams.get('access_token');
const authHeader = c.req.header('authorization');
const headerToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : '';
const clientToken = queryToken || headerToken;
if (clientToken !== this.config.token) {
return {
onOpen: (_evt, ws) => {
ws.send(JSON.stringify(OB11Response.res(null, 'failed', 1403, 'token验证失败')));
ws.close();
}
};
}
}
// 判断连接类型
const url = new URL(c.req.url, `http://${c.req.header('host') || 'localhost'}`);
const path = url.pathname;
const isApiConnect = path === '/api' || path === '/api/';
return {
onOpen: (_evt, ws) => {
this.clients.add(ws);
// 仅对非API连接添加到事件客户端列表
if (!isApiConnect) {
this.eventClients.add(ws);
// 发送连接生命周期事件
try {
ws.send(JSON.stringify(new OB11LifeCycleEvent(this.core, LifeCycleSubType.CONNECT)));
} catch (e) {
this.core.context.logger.logError(`[OneBot] [Websocket Server Adapter] 发送生命周期事件失败: ${e}`);
}
}
this.core.context.logger.log(`[OneBot] [Websocket Server Adapter] 客户端已连接,类型: ${isApiConnect ? 'API' : '事件'},当前连接数: ${this.clients.size}`);
},
onMessage: (evt, ws) => {
this.actionHandler(c, evt, ws);
},
onClose: (_evt, ws) => {
this.clients.delete(ws);
this.eventClients.delete(ws);
this.core.context.logger.log(`[OneBot] [Websocket Server Adapter] 客户端已断开,当前连接数: ${this.clients.size}`);
},
onError: (error) => {
this.core.context.logger.logError(`[OneBot] [Websocket Server Adapter] WebSocket错误: ${error}`);
}
};
}));
// 启动服务器
this.server = serve({
fetch: this.app.fetch.bind(this.app),
port: this.config.port,
hostname: this.config.host === '0.0.0.0' ? undefined : this.config.host,
});
injectWebSocket(this.server);
this.core.context.logger.log(`[OneBot] [Websocket Server Adapter] 服务器已启动于 ${this.config.host}:${this.config.port}`);
}
async actionHandler<T>(_c: Context, evt: MessageEvent<WSMessageReceive>, ws: WSContext<T>) {
const { data } = evt;
if (typeof data !== 'string') {
this.core.context.logger.logError('[OneBot] [Websocket Server Adapter] 收到非字符串消息');
return;
}
wsClient.send(JSON.stringify(OB11Response.res(null, 'failed', 1403, 'token验证失败')));
wsClient.close();
}
private checkStateAndReply<T>(data: T, wsClient: WebSocket) {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(data));
}
}
private async handleMessage(wsClient: WebSocket, message: RawData) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any } = { action: ActionName.Unknown, params: {} };
let echo = undefined;
try {
receiveData = json5.parse(message.toString());
receiveData = JSON.parse(data);
echo = receiveData.echo;
//this.logger.logDebug('收到正向Websocket消息', receiveData);
} catch {
this.checkStateAndReply<unknown>(OB11Response.error('json解析失败,请检查数据格式', 1400, echo), wsClient);
return;
return ws.send(JSON.stringify(OB11Response.error('json解析失败,请检查数据格式', 1400, echo)));
}
receiveData.params = (receiveData?.params) ? receiveData.params : {};//兼容类型验证 不然类型校验爆炸
receiveData.params = (receiveData?.params) ? receiveData.params : {}; // 兼容类型验证
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const action = this.actions.get(receiveData.action as any);
if (!action) {
this.logger.logError('[OneBot] [WebSocket Client] 发生错误', '不支持的API ' + receiveData.action);
this.checkStateAndReply<unknown>(OB11Response.error('不支持的API ' + receiveData.action, 1404, echo), wsClient);
return;
return ws.send(JSON.stringify(OB11Response.error('不支持的API ' + receiveData.action, 1404, echo)));
}
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config);
this.checkStateAndReply<unknown>({ ...retdata }, wsClient);
ws.send(JSON.stringify({ ...retdata }));
}
async reload(newConfig: WebsocketServerConfig) {
const wasEnabled = this.isEnable;
const oldPort = this.config.port;
const oldHost = this.config.host;
const oldHeartbeatInterval = this.config.heartInterval;
const oldHeartInterval = this.config.heartInterval;
this.config = newConfig;
if (newConfig.enable && !wasEnabled) {
@@ -201,21 +215,17 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
return OB11NetworkReloadType.NetWorkClose;
}
// 端口或主机变更需要重启服务器
if (oldPort !== newConfig.port || oldHost !== newConfig.host) {
this.close();
this.wsServer = new WebSocketServer({
port: newConfig.port,
host: newConfig.host === '0.0.0.0' ? '' : newConfig.host,
maxPayload: 1024 * 1024 * 1024,
});
this.createServer(this.wsServer);
if (newConfig.enable) {
this.open();
}
return OB11NetworkReloadType.NetWorkReload;
}
if (oldHeartbeatInterval !== newConfig.heartInterval) {
// 心跳间隔变更需要重新设置心跳
if (oldHeartInterval !== newConfig.heartInterval) {
if (this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId);
this.heartbeatIntervalId = null;
@@ -228,5 +238,4 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
return OB11NetworkReloadType.Normal;
}
}
}

View File

@@ -57,6 +57,7 @@ export interface OB11GroupMember {
}
export interface OB11Group {
group_remark: string; // 群备注
group_id: number; // 群ID
group_name: string; // 群名称
member_count?: number; // 成员数量

View File

@@ -10,6 +10,7 @@ export enum OB11MessageType {
// 消息接口定义
export interface OB11Message {
real_seq?: string;// 自行扩展
temp_source?: number;
message_sent_type?: string;
target_id?: number; // 自己发送消息/私聊消息

View File

@@ -26,10 +26,10 @@ import { LoginListItem, NodeIKernelLoginService } from '@/core/services';
import { program } from 'commander';
import qrcode from '@/qrcode/lib/main';
import { NapCatOneBot11Adapter } from '@/onebot';
import { InitWebUi } from '@/webui';
import { WebUiDataRuntime } from '@/webui/src/helper/Data';
import { napCatVersion } from '@/common/version';
import { NodeIO3MiscListener } from '@/core/listeners/NodeIO3MiscListener';
import { sleep } from '@/common/helper';
// NapCat Shell App ES 入口文件
async function handleUncaughtExceptions(logger: LogWrapper) {
process.on('uncaughtException', (err) => {
@@ -113,121 +113,96 @@ async function handleLogin(
quickLoginUin: string | undefined,
historyLoginList: LoginListItem[]
): Promise<SelfInfo> {
return new Promise<SelfInfo>((resolve) => {
const loginListener = new NodeIKernelLoginListener();
let isLogined = false;
let context = { isLogined: false };
let inner_resolve: (value: SelfInfo) => void;
let selfInfo: Promise<SelfInfo> = new Promise((resolve) => {
inner_resolve = resolve;
});
// 连接服务
loginListener.onUserLoggedIn = (userid: string) => {
logger.logError(`当前账号(${userid})已登录,无法重复登录`);
};
loginListener.onQRCodeLoginSucceed = async (loginResult) => {
isLogined = true;
resolve({
uid: loginResult.uid,
uin: loginResult.uin,
nick: '',
online: true,
});
};
loginListener.onQRCodeGetPicture = ({ pngBase64QrcodeData, qrcodeUrl }) => {
WebUiDataRuntime.setQQLoginQrcodeURL(qrcodeUrl);
const realBase64 = pngBase64QrcodeData.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(realBase64, 'base64');
logger.logWarn('请扫描下面的二维码然后在手Q上授权登录');
const qrcodePath = path.join(pathWrapper.cachePath, 'qrcode.png');
qrcode.generate(qrcodeUrl, { small: true }, (res) => {
logger.logWarn([
'\n',
res,
'二维码解码URL: ' + qrcodeUrl,
'如果控制台二维码无法扫码可以复制解码url到二维码生成网站生成二维码再扫码也可以打开下方的二维码路径图片进行扫码。',
].join('\n'));
fs.writeFile(qrcodePath, buffer, {}, () => {
logger.logWarn('二维码已保存到', qrcodePath);
});
});
};
loginListener.onQRCodeSessionFailed = (errType: number, errCode: number) => {
if (!isLogined) {
logger.logError('[Core] [Login] Login Error,ErrType: ', errType, ' ErrCode:', errCode);
if (errType == 1 && errCode == 3) {
// 二维码过期刷新
}
loginService.getQRCodePicture();
}
};
loginListener.onLoginFailed = (...args) => {
logger.logError('[Core] [Login] Login Error , ErrInfo: ', JSON.stringify(args));
};
loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger));
const isConnect = loginService.connect();
if (!isConnect) {
logger.logError('核心登录服务连接失败!');
return;
}
logger.log('核心登录服务连接成功!');
loginService.getLoginList().then((res) => {
// 遍历 res.LocalLoginInfoList[x].isQuickLogin是否可以 res.LocalLoginInfoList[x].uin 转为string 加入string[] 最后遍历完成调用WebUiDataRuntime.setQQQuickLoginList
const list = res.LocalLoginInfoList.filter((item) => item.isQuickLogin);
WebUiDataRuntime.setQQQuickLoginList(list.map((item) => item.uin.toString()));
WebUiDataRuntime.setQQNewLoginList(list);
const loginListener = new NodeIKernelLoginListener();
loginListener.onUserLoggedIn = (userid: string) => {
logger.logError(`当前账号(${userid})已登录,无法重复登录`);
};
loginListener.onQRCodeLoginSucceed = async (loginResult) => {
context.isLogined = true;
inner_resolve({
uid: loginResult.uid,
uin: loginResult.uin,
nick: '',
online: true,
});
WebUiDataRuntime.setQuickLoginCall(async (uin: string) => {
return await new Promise((resolve) => {
if (uin) {
logger.log('正在快速登录 ', uin);
loginService.quickLoginWithUin(uin).then(res => {
if (res.loginErrorInfo.errMsg) {
resolve({ result: false, message: res.loginErrorInfo.errMsg });
}
resolve({ result: true, message: '' });
}).catch((e) => {
logger.logError(e);
resolve({ result: false, message: '快速登录发生错误' });
});
} else {
resolve({ result: false, message: '快速登录失败' });
}
};
loginListener.onLoginConnected = () => {
waitForNetworkConnection(loginService, logger).then(() => {
handleLoginInner(context, logger, loginService, quickLoginUin, historyLoginList).then().catch(e => logger.logError(e));
});
}
loginListener.onQRCodeGetPicture = ({ pngBase64QrcodeData, qrcodeUrl }) => {
const realBase64 = pngBase64QrcodeData.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(realBase64, 'base64');
logger.logWarn('请扫描下面的二维码然后在手Q上授权登录');
const qrcodePath = path.join(pathWrapper.cachePath, 'qrcode.png');
qrcode.generate(qrcodeUrl, { small: true }, (res) => {
logger.logWarn([
'\n',
res,
'二维码解码URL: ' + qrcodeUrl,
'如果控制台二维码无法扫码可以复制解码url到二维码生成网站生成二维码再扫码也可以打开下方的二维码路径图片进行扫码。',
].join('\n'));
fs.writeFile(qrcodePath, buffer, {}, () => {
logger.logWarn('二维码已保存到', qrcodePath);
});
});
};
if (quickLoginUin) {
if (historyLoginList.some(u => u.uin === quickLoginUin)) {
logger.log('正在快速登录 ', quickLoginUin);
setTimeout(() => {
loginService.quickLoginWithUin(quickLoginUin)
.then(result => {
if (result.loginErrorInfo.errMsg) {
logger.logError('快速登录错误:', result.loginErrorInfo.errMsg);
if (!isLogined) loginService.getQRCodePicture();
}
})
.catch();
}, 1000);
} else {
logger.logError('快速登录失败,未找到该 QQ 历史登录记录,将使用二维码登录方式');
if (!isLogined) loginService.getQRCodePicture();
}
} else {
logger.log('没有 -q 指令指定快速登录,将使用二维码登录方式');
if (historyLoginList.length > 0) {
logger.log(`可用于快速登录的 QQ\n${historyLoginList
.map((u, index) => `${index + 1}. ${u.uin} ${u.nickName}`)
.join('\n')
}`);
loginListener.onQRCodeSessionFailed = (errType: number, errCode: number) => {
if (!context.isLogined) {
logger.logError('[Core] [Login] Login Error,ErrType: ', errType, ' ErrCode:', errCode);
if (errType == 1 && errCode == 3) {
// 二维码过期刷新
}
loginService.getQRCodePicture();
}
});
};
loginListener.onLoginFailed = (...args) => {
logger.logError('[Core] [Login] Login Error , ErrInfo: ', JSON.stringify(args));
};
loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger));
loginService.connect();
return await selfInfo;
}
async function handleLoginInner(context: { isLogined: boolean }, logger: LogWrapper, loginService: NodeIKernelLoginService, quickLoginUin: string | undefined, historyLoginList: LoginListItem[]) {
if (quickLoginUin) {
if (historyLoginList.some(u => u.uin === quickLoginUin)) {
logger.log('正在快速登录 ', quickLoginUin);
loginService.quickLoginWithUin(quickLoginUin)
.then(result => {
if (result.loginErrorInfo.errMsg) {
logger.logError('快速登录错误:', result.loginErrorInfo.errMsg);
if (!context.isLogined) loginService.getQRCodePicture();
}
})
.catch();
} else {
logger.logError('快速登录失败,未找到该 QQ 历史登录记录,将使用二维码登录方式');
if (!context.isLogined) loginService.getQRCodePicture();
}
} else {
logger.log('没有 -q 指令指定快速登录,将使用二维码登录方式');
if (historyLoginList.length > 0) {
logger.log(`可用于快速登录的 QQ\n${historyLoginList
.map((u, index) => `${index + 1}. ${u.uin} ${u.nickName}`)
.join('\n')
}`);
}
loginService.getQRCodePicture();
}
}
async function initializeSession(
@@ -284,6 +259,20 @@ async function handleProxy(session: NodeIQQNTWrapperSession, logger: LogWrapper)
});
}
}
async function waitForNetworkConnection(loginService: NodeIKernelLoginService, logger: LogWrapper) {
let network_ok = false;
let tryCount = 0;
while (!network_ok) {
network_ok = loginService.getMsfStatus() !== 3;// win 11 0连接 1未连接
logger.log('等待网络连接...');
await sleep(500);
tryCount++;
}
logger.log('网络已连接');
return network_ok;
}
export async function NCoreInitShell() {
console.log('NapCat Shell App Loading...');
const pathWrapper = new NapCatPathWrapper();
@@ -296,8 +285,6 @@ export async function NCoreInitShell() {
o3Service.addO3MiscListener(new NodeIO3MiscListener());
logger.log('[NapCat] [Core] NapCat.Core Version: ' + napCatVersion);
InitWebUi(logger, pathWrapper).then().catch(e => logger.logError(e));
const engine = wrapper.NodeIQQNTWrapperEngine.get();
const loginService = wrapper.NodeIKernelLoginService.get();
const session = wrapper.NodeIQQNTWrapperSession.create();

View File

@@ -1,3 +0,0 @@
# The Path of NapCatQQ
Tiny WebUi Backend for NapCatQQ

View File

@@ -1,209 +0,0 @@
/**
* @file WebUI服务入口文件
*/
import express from 'express';
import { createServer } from 'http';
import { LogWrapper } from '@/common/log';
import { NapCatPathWrapper } from '@/common/path';
import { WebUiConfigWrapper } from '@webapi/helper/config';
import { ALLRouter } from '@webapi/router';
import { cors } from '@webapi/middleware/cors';
import { createUrl } from '@webapi/utils/url';
import { sendError } from '@webapi/utils/response';
import { join } from 'node:path';
import { terminalManager } from '@webapi/terminal/terminal_manager';
import multer from 'multer'; // 新增引入multer用于错误捕获
// 实例化Express
const app = express();
const server = createServer(app);
/**
* 初始化并启动WebUI服务。
* 该函数配置了Express服务器以支持JSON解析和静态文件服务并监听6099端口。
* 无需参数。
* @returns {Promise<void>} 无返回值。
*/
export let WebUiConfig: WebUiConfigWrapper;
export let webUiPathWrapper: NapCatPathWrapper;
const MAX_PORT_TRY = 100;
import * as net from 'node:net';
import { WebUiDataRuntime } from './src/helper/Data';
export async function InitPort(parsedConfig: WebUiConfigType): Promise<[string, number, string]> {
try {
await tryUseHost(parsedConfig.host);
const port = await tryUsePort(parsedConfig.port, parsedConfig.host);
return [parsedConfig.host, port, parsedConfig.token];
} catch (error) {
console.log('host或port不可用', error);
return ['', 0, ''];
}
}
export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapper) {
webUiPathWrapper = pathWrapper;
WebUiConfig = new WebUiConfigWrapper();
const [host, port, token] = await InitPort(await WebUiConfig.GetWebUIConfig());
if (port == 0) {
logger.log('[NapCat] [WebUi] Current WebUi is not run.');
return;
}
setTimeout(async () => {
let autoLoginAccount = process.env['NAPCAT_QUICK_ACCOUNT'] || WebUiConfig.getAutoLoginAccount();
if (autoLoginAccount) {
try {
const { result, message } = await WebUiDataRuntime.requestQuickLogin(autoLoginAccount);
if (!result) {
throw new Error(message);
}
console.log(`[NapCat] [WebUi] Auto login account: ${autoLoginAccount}`);
} catch (error) {
console.log(`[NapCat] [WebUi] Auto login account failed.` + error);
}
}
}, 30000);
// ------------注册中间件------------
// 使用express的json中间件
app.use(express.json());
// CORS中间件
// TODO:
app.use(cors);
// 如果是webui字体文件挂载字体文件
app.use('/webui/fonts/AaCute.woff', async (_req, res, next) => {
const isFontExist = await WebUiConfig.CheckWebUIFontExist();
if (isFontExist) {
res.sendFile(WebUiConfig.GetWebUIFontPath());
} else {
next();
}
});
// 如果是自定义色彩构建一个css文件
app.use('/files/theme.css', async (_req, res) => {
const colors = await WebUiConfig.GetTheme();
let css = ':root, .light, [data-theme="light"] {';
for (const key in colors.light) {
css += `${key}: ${colors.light[key]};`;
}
css += '}';
css += '.dark, [data-theme="dark"] {';
for (const key in colors.dark) {
css += `${key}: ${colors.dark[key]};`;
}
css += '}';
res.send(css);
});
// ------------中间件结束------------
// ------------挂载路由------------
// 挂载静态路由(前端),路径为 /webui
app.use('/webui', express.static(pathWrapper.staticPath));
// 初始化WebSocket服务器
server.on('upgrade', (request, socket, head) => {
terminalManager.initialize(request, socket, head, logger);
});
// 挂载API接口
app.use('/api', ALLRouter);
// 所有剩下的请求都转到静态页面
const indexFile = join(pathWrapper.staticPath, 'index.html');
app.all(/\/webui\/(.*)/, (_req, res) => {
res.sendFile(indexFile);
});
// 初始服务(先放个首页)
app.all('/', (_req, res) => {
res.status(301).header('Location', '/webui').send();
});
// 错误处理中间件捕获multer的错误
app.use((err: Error, _: express.Request, res: express.Response, next: express.NextFunction) => {
if (err instanceof multer.MulterError) {
return sendError(res, err.message, true);
}
next(err);
});
// 全局错误处理中间件非multer错误
app.use((_: Error, __: express.Request, res: express.Response, ___: express.NextFunction) => {
sendError(res, 'An unknown error occurred.', true);
});
// ------------启动服务------------
server.listen(port, host, async () => {
// 启动后打印出相关地址
let searchParams = { token: token };
if (host !== '' && host !== '0.0.0.0') {
logger.log(
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl(host, port.toString(), '/webui', searchParams)}`
);
}
logger.log(
`[NapCat] [WebUi] WebUi Local Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}`
);
});
// ------------Over------------
}
async function tryUseHost(host: string): Promise<string> {
return new Promise((resolve, reject) => {
try {
const server = net.createServer();
server.on('listening', () => {
server.close();
resolve(host);
});
server.on('error', (err: any) => {
if (err.code === 'EADDRNOTAVAIL') {
reject(new Error('主机地址验证失败,可能为非本机地址'));
} else {
reject(new Error(`遇到错误: ${err.code}`));
}
});
// 尝试监听 让系统随机分配一个端口
server.listen(0, host);
} catch (error) {
// 这里捕获到的错误应该是启动服务器时的同步错误
reject(new Error(`服务器启动时发生错误: ${error}`));
}
});
}
async function tryUsePort(port: number, host: string, tryCount: number = 0): Promise<number> {
return new Promise((resolve, reject) => {
try {
const server = net.createServer();
server.on('listening', () => {
server.close();
resolve(port);
});
server.on('error', (err: any) => {
if (err.code === 'EADDRINUSE') {
if (tryCount < MAX_PORT_TRY) {
// 使用循环代替递归
resolve(tryUsePort(port + 1, host, tryCount + 1));
} else {
reject(new Error(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`));
}
} else {
reject(new Error(`遇到错误: ${err.code}`));
}
});
// 尝试监听端口
server.listen(port, host);
} catch (error) {
// 这里捕获到的错误应该是启动服务器时的同步错误
reject(new Error(`服务器启动时发生错误: ${error}`));
}
});
}

View File

@@ -1,115 +0,0 @@
import { RequestHandler } from 'express';
import { WebUiConfig } from '@/webui';
import { AuthHelper } from '@webapi/helper/SignToken';
import { WebUiDataRuntime } from '@webapi/helper/Data';
import { sendSuccess, sendError } from '@webapi/utils/response';
import { isEmpty } from '@webapi/utils/check';
// 检查是否使用默认Token
export const CheckDefaultTokenHandler: RequestHandler = async (_, res) => {
const webuiToken = await WebUiConfig.GetWebUIConfig();
if (webuiToken.token === 'napcat') {
return sendSuccess(res, true);
}
return sendSuccess(res, false);
};
// 登录
export const LoginHandler: RequestHandler = async (req, res) => {
// 获取WebUI配置
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
// 获取请求体中的token
const { token } = req.body;
// 获取客户端IP
const clientIP = req.ip || req.socket.remoteAddress || '';
// 如果token为空返回错误信息
if (isEmpty(token)) {
return sendError(res, 'token is empty');
}
// 检查登录频率
if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) {
return sendError(res, 'login rate limit');
}
//验证config.token是否等于token
if (WebUiConfigData.token !== token) {
return sendError(res, 'token is invalid');
}
// 签发凭证
const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(WebUiConfigData.token))).toString(
'base64'
);
// 返回成功信息
return sendSuccess(res, {
Credential: signCredential,
});
};
// 退出登录
export const LogoutHandler: RequestHandler = async (req, res) => {
const authorization = req.headers.authorization;
try {
const CredentialBase64: string = authorization?.split(' ')[1] as string;
const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
AuthHelper.revokeCredential(Credential);
return sendSuccess(res, 'Logged out successfully');
} catch (e) {
return sendError(res, 'Logout failed');
}
};
// 检查登录状态
export const checkHandler: RequestHandler = async (req, res) => {
// 获取WebUI配置
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
// 获取请求头中的Authorization
const authorization = req.headers.authorization;
// 检查凭证
try {
// 从Authorization中获取凭证
const CredentialBase64: string = authorization?.split(' ')[1] as string;
// 解析凭证
const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
// 检查凭证是否已被注销
if (AuthHelper.isCredentialRevoked(Credential)) {
return sendError(res, 'Token has been revoked');
}
// 验证凭证是否在一小时内有效
const valid = AuthHelper.validateCredentialWithinOneHour(WebUiConfigData.token, Credential);
// 返回成功信息
if (valid) return sendSuccess(res, null);
// 返回错误信息
return sendError(res, 'Authorization Failed');
} catch (e) {
// 返回错误信息
return sendError(res, 'Authorization Failed');
}
};
// 修改密码token
export const UpdateTokenHandler: RequestHandler = async (req, res) => {
const { oldToken, newToken } = req.body;
const authorization = req.headers.authorization;
if (isEmpty(oldToken) || isEmpty(newToken)) {
return sendError(res, 'oldToken or newToken is empty');
}
try {
// 注销当前的Token
if (authorization) {
const CredentialBase64: string = authorization.split(' ')[1] as string;
const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
AuthHelper.revokeCredential(Credential);
}
await WebUiConfig.UpdateToken(oldToken, newToken);
return sendSuccess(res, 'Token updated successfully');
} catch (e: any) {
return sendError(res, `Failed to update token: ${e.message}`);
}
};

View File

@@ -1,26 +0,0 @@
import { RequestHandler } from 'express';
import { WebUiDataRuntime } from '@webapi/helper/Data';
import { sendSuccess } from '@webapi/utils/response';
import { WebUiConfig } from '@/webui';
export const PackageInfoHandler: RequestHandler = (_, res) => {
const data = WebUiDataRuntime.getPackageJson();
sendSuccess(res, data);
};
export const QQVersionHandler: RequestHandler = (_, res) => {
const data = WebUiDataRuntime.getQQVersion();
sendSuccess(res, data);
};
export const GetThemeConfigHandler: RequestHandler = async (_, res) => {
const data = await WebUiConfig.GetTheme();
sendSuccess(res, data);
};
export const SetThemeConfigHandler: RequestHandler = async (req, res) => {
const { theme } = req.body;
await WebUiConfig.UpdateTheme(theme);
sendSuccess(res, { message: '更新成功' });
};

View File

@@ -1,399 +0,0 @@
import type { RequestHandler } from 'express';
import { sendError, sendSuccess } from '../utils/response';
import fsProm from 'fs/promises';
import fs from 'fs';
import path from 'path';
import os from 'os';
import compressing from 'compressing';
import { PassThrough } from 'stream';
import multer from 'multer';
import webUIFontUploader from '../uploader/webui_font';
import diskUploader from '../uploader/disk';
import { WebUiConfig } from '@/webui';
const isWindows = os.platform() === 'win32';
// 获取系统根目录列表Windows返回盘符列表其他系统返回['/']
const getRootDirs = async (): Promise<string[]> => {
if (!isWindows) return ['/'];
// Windows 驱动器字母 (A-Z)
const drives: string[] = [];
for (let i = 65; i <= 90; i++) {
const driveLetter = String.fromCharCode(i);
try {
await fsProm.access(`${driveLetter}:\\`);
drives.push(`${driveLetter}:`);
} catch {
// 如果驱动器不存在或无法访问,跳过
continue;
}
}
return drives.length > 0 ? drives : ['C:'];
};
// 规范化路径
const normalizePath = (inputPath: string): string => {
if (!inputPath) return isWindows ? 'C:\\' : '/';
// 如果是Windows且输入为纯盘符可能带或不带斜杠统一返回 "X:\"
if (isWindows && /^[A-Z]:[\\/]*$/i.test(inputPath)) {
return inputPath.slice(0, 2) + '\\';
}
return path.normalize(inputPath);
};
interface FileInfo {
name: string;
isDirectory: boolean;
size: number;
mtime: Date;
}
// 添加系统文件黑名单
const SYSTEM_FILES = new Set(['pagefile.sys', 'swapfile.sys', 'hiberfil.sys', 'System Volume Information']);
// 检查同类型的文件或目录是否存在
const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): Promise<boolean> => {
try {
const stat = await fsProm.stat(pathToCheck);
// 只有当类型相同时才认为是冲突
return stat.isDirectory() === isDirectory;
} catch {
return false;
}
};
// 获取目录内容
export const ListFilesHandler: RequestHandler = async (req, res) => {
try {
const requestPath = (req.query['path'] as string) || (isWindows ? 'C:\\' : '/');
const normalizedPath = normalizePath(requestPath);
const onlyDirectory = req.query['onlyDirectory'] === 'true';
// 如果是根路径且在Windows系统上返回盘符列表
if (isWindows && (!requestPath || requestPath === '/' || requestPath === '\\')) {
const drives = await getRootDirs();
const driveInfos: FileInfo[] = await Promise.all(
drives.map(async (drive) => {
try {
const stat = await fsProm.stat(`${drive}\\`);
return {
name: drive,
isDirectory: true,
size: 0,
mtime: stat.mtime,
};
} catch {
return {
name: drive,
isDirectory: true,
size: 0,
mtime: new Date(),
};
}
})
);
return sendSuccess(res, driveInfos);
}
const files = await fsProm.readdir(normalizedPath);
let fileInfos: FileInfo[] = [];
for (const file of files) {
// 跳过系统文件
if (SYSTEM_FILES.has(file)) continue;
try {
const fullPath = path.join(normalizedPath, file);
const stat = await fsProm.stat(fullPath);
fileInfos.push({
name: file,
isDirectory: stat.isDirectory(),
size: stat.size,
mtime: stat.mtime,
});
} catch (error) {
// 忽略无法访问的文件
// console.warn(`无法访问文件 ${file}:`, error);
continue;
}
}
// 如果请求参数 onlyDirectory 为 true则只返回目录信息
if (onlyDirectory) {
fileInfos = fileInfos.filter((info) => info.isDirectory);
}
return sendSuccess(res, fileInfos);
} catch (error) {
console.error('读取目录失败:', error);
return sendError(res, '读取目录失败');
}
};
// 创建目录
export const CreateDirHandler: RequestHandler = async (req, res) => {
try {
const { path: dirPath } = req.body;
const normalizedPath = normalizePath(dirPath);
// 检查是否已存在同类型(目录)
if (await checkSameTypeExists(normalizedPath, true)) {
return sendError(res, '同名目录已存在');
}
await fsProm.mkdir(normalizedPath, { recursive: true });
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '创建目录失败');
}
};
// 删除文件/目录
export const DeleteHandler: RequestHandler = async (req, res) => {
try {
const { path: targetPath } = req.body;
const normalizedPath = normalizePath(targetPath);
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
await fsProm.rm(normalizedPath, { recursive: true });
} else {
await fsProm.unlink(normalizedPath);
}
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '删除失败');
}
};
// 批量删除文件/目录
export const BatchDeleteHandler: RequestHandler = async (req, res) => {
try {
const { paths } = req.body;
for (const targetPath of paths) {
const normalizedPath = normalizePath(targetPath);
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
await fsProm.rm(normalizedPath, { recursive: true });
} else {
await fsProm.unlink(normalizedPath);
}
}
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '批量删除失败');
}
};
// 读取文件内容
export const ReadFileHandler: RequestHandler = async (req, res) => {
try {
const filePath = normalizePath(req.query['path'] as string);
const content = await fsProm.readFile(filePath, 'utf-8');
return sendSuccess(res, content);
} catch (error) {
return sendError(res, '读取文件失败');
}
};
// 写入文件内容
export const WriteFileHandler: RequestHandler = async (req, res) => {
try {
const { path: filePath, content } = req.body;
const normalizedPath = normalizePath(filePath);
await fsProm.writeFile(normalizedPath, content, 'utf-8');
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '写入文件失败');
}
};
// 创建新文件
export const CreateFileHandler: RequestHandler = async (req, res) => {
try {
const { path: filePath } = req.body;
const normalizedPath = normalizePath(filePath);
// 检查是否已存在同类型(文件)
if (await checkSameTypeExists(normalizedPath, false)) {
return sendError(res, '同名文件已存在');
}
await fsProm.writeFile(normalizedPath, '', 'utf-8');
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '创建文件失败');
}
};
// 重命名文件/目录
export const RenameHandler: RequestHandler = async (req, res) => {
try {
const { oldPath, newPath } = req.body;
const normalizedOldPath = normalizePath(oldPath);
const normalizedNewPath = normalizePath(newPath);
await fsProm.rename(normalizedOldPath, normalizedNewPath);
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '重命名失败');
}
};
// 移动文件/目录
export const MoveHandler: RequestHandler = async (req, res) => {
try {
const { sourcePath, targetPath } = req.body;
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '移动失败');
}
};
// 批量移动
export const BatchMoveHandler: RequestHandler = async (req, res) => {
try {
const { items } = req.body;
for (const { sourcePath, targetPath } of items) {
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
}
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '批量移动失败');
}
};
// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
export const DownloadHandler: RequestHandler = async (req, res) => {
try {
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');
let filename = path.basename(filePath);
if (stat.isDirectory()) {
filename = path.basename(filePath) + '.zip';
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
const zipStream = new PassThrough();
compressing.zip.compressDir(filePath, zipStream as unknown as fs.WriteStream).catch((err) => {
console.error('压缩目录失败:', err);
res.end();
});
zipStream.pipe(res);
return;
}
res.setHeader('Content-Length', stat.size);
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
const stream = fs.createReadStream(filePath);
stream.pipe(res);
} catch (error) {
return sendError(res, '下载失败');
}
};
// 批量下载:将多个文件/目录打包为 zip 文件下载
export const BatchDownloadHandler: RequestHandler = async (req, res) => {
try {
const { paths } = req.body as { paths: string[] };
if (!paths || !Array.isArray(paths) || paths.length === 0) {
return sendError(res, '参数错误');
}
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', 'attachment; filename=files.zip');
const zipStream = new compressing.zip.Stream();
// 修改:根据文件类型设置 relativePath
for (const filePath of paths) {
const normalizedPath = normalizePath(filePath);
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
zipStream.addEntry(normalizedPath, { relativePath: '' });
} else {
zipStream.addEntry(normalizedPath, { relativePath: path.basename(normalizedPath) });
}
}
zipStream.pipe(res);
res.on('finish', () => {
zipStream.destroy();
});
} catch (error) {
return sendError(res, '下载失败');
}
};
// 修改上传处理方法
export const UploadHandler: RequestHandler = async (req, res) => {
try {
await diskUploader(req, res);
return sendSuccess(res, true, '文件上传成功', true);
} catch (error) {
let errorMessage = '文件上传失败';
if (error instanceof multer.MulterError) {
switch (error.code) {
case 'LIMIT_FILE_SIZE':
errorMessage = '文件大小超过限制40MB';
break;
case 'LIMIT_UNEXPECTED_FILE':
errorMessage = '无效的文件上传字段';
break;
default:
errorMessage = `上传错误: ${error.message}`;
}
} else if (error instanceof Error) {
errorMessage = error.message;
}
return sendError(res, errorMessage, true);
}
};
// 上传WebUI字体文件处理方法
export const UploadWebUIFontHandler: RequestHandler = async (req, res) => {
try {
await webUIFontUploader(req, res);
return sendSuccess(res, true, '字体文件上传成功', true);
} catch (error) {
let errorMessage = '字体文件上传失败';
if (error instanceof multer.MulterError) {
switch (error.code) {
case 'LIMIT_FILE_SIZE':
errorMessage = '字体文件大小超过限制40MB';
break;
case 'LIMIT_UNEXPECTED_FILE':
errorMessage = '无效的文件上传字段';
break;
default:
errorMessage = `上传错误: ${error.message}`;
}
} else if (error instanceof Error) {
errorMessage = error.message;
}
return sendError(res, errorMessage, true);
}
};
// 删除WebUI字体文件处理方法
export const DeleteWebUIFontHandler: RequestHandler = async (_req, res) => {
try {
const fontPath = WebUiConfig.GetWebUIFontPath();
const exists = await WebUiConfig.CheckWebUIFontExist();
if (!exists) {
return sendSuccess(res, true);
}
await fsProm.unlink(fontPath);
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '删除字体文件失败');
}
};

View File

@@ -1,72 +0,0 @@
import type { RequestHandler } from 'express';
import { sendError, sendSuccess } from '../utils/response';
import { logSubscription } from '@/common/log';
import { terminalManager } from '../terminal/terminal_manager';
import { WebUiConfig } from '@/webui';
// 判断是否是 macos
const isMacOS = process.platform === 'darwin';
// 日志记录
export const LogHandler: RequestHandler = async (req, res) => {
const filename = req.query['id'];
if (!filename || typeof filename !== 'string') {
return sendError(res, 'ID不能为空');
}
if (filename.includes('..')) {
return sendError(res, 'ID不合法');
}
const logContent = await WebUiConfig.GetLogContent(filename);
return sendSuccess(res, logContent);
};
// 日志列表
export const LogListHandler: RequestHandler = async (_, res) => {
const logList = await WebUiConfig.GetLogsList();
return sendSuccess(res, logList);
};
// 实时日志SSE
export const LogRealTimeHandler: RequestHandler = async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Connection', 'keep-alive');
const listener = (log: string) => {
try {
res.write(`data: ${log}\n\n`);
} catch (error) {
console.error('向客户端写入日志数据时出错:', error);
}
};
logSubscription.subscribe(listener);
req.on('close', () => {
logSubscription.unsubscribe(listener);
});
};
// 终端相关处理器
export const CreateTerminalHandler: RequestHandler = async (req, res) => {
if (isMacOS) {
return sendError(res, 'MacOS不支持终端');
}
try {
const { cols, rows } = req.body;
const { id } = terminalManager.createTerminal(cols, rows);
return sendSuccess(res, { id });
} catch (error) {
console.error('Failed to create terminal:', error);
return sendError(res, '创建终端失败');
}
};
export const GetTerminalListHandler: RequestHandler = (_, res) => {
const list = terminalManager.getTerminalList();
return sendSuccess(res, list);
};
export const CloseTerminalHandler: RequestHandler = (req, res) => {
const id = req.params['id'];
if (!id) {
return sendError(res, 'ID不能为空');
}
terminalManager.closeTerminal(id);
return sendSuccess(res, {});
};

View File

@@ -1,60 +0,0 @@
import { RequestHandler } from 'express';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { loadConfig, OneBotConfig } from '@/onebot/config/config';
import { webUiPathWrapper } from '@/webui';
import { WebUiDataRuntime } from '@webapi/helper/Data';
import { sendError, sendSuccess } from '@webapi/utils/response';
import { isEmpty } from '@webapi/utils/check';
import json5 from 'json5';
// 获取OneBot11配置
export const OB11GetConfigHandler: RequestHandler = (_, res) => {
// 获取QQ登录状态
const isLogin = WebUiDataRuntime.getQQLoginStatus();
// 如果未登录,返回错误
if (!isLogin) {
return sendError(res, 'Not Login');
}
// 获取登录的QQ号
const uin = WebUiDataRuntime.getQQLoginUin();
// 读取配置文件路径
const configFilePath = resolve(webUiPathWrapper.configPath, `./onebot11_${uin}.json`);
// 尝试解析配置文件
try {
// 读取配置文件内容
const configFileContent = existsSync(configFilePath)
? readFileSync(configFilePath).toString()
: readFileSync(resolve(webUiPathWrapper.configPath, './onebot11.json')).toString();
// 解析配置文件并加载配置
const data = loadConfig(json5.parse(configFileContent)) as OneBotConfig;
// 返回配置文件
return sendSuccess(res, data);
} catch (e) {
return sendError(res, 'Config Get Error');
}
};
// 写入OneBot11配置
export const OB11SetConfigHandler: RequestHandler = async (req, res) => {
// 获取QQ登录状态
const isLogin = WebUiDataRuntime.getQQLoginStatus();
// 如果未登录,返回错误
if (!isLogin) {
return sendError(res, 'Not Login');
}
// 如果配置为空,返回错误
if (isEmpty(req.body.config)) {
return sendError(res, 'config is empty');
}
// 写入配置
try {
// 解析并加载配置
const config = loadConfig(json5.parse(req.body.config)) as OneBotConfig;
// 写入配置
await WebUiDataRuntime.setOB11Config(config);
return sendSuccess(res, null);
} catch (e) {
return sendError(res, 'Error: ' + e);
}
};

View File

@@ -1,14 +0,0 @@
import { RequestHandler } from 'express';
import { RequestUtil } from '@/common/request';
import { sendError, sendSuccess } from '../utils/response';
export const GetProxyHandler: RequestHandler = async (req, res) => {
let { url } = req.query;
if (url && typeof url === 'string') {
url = decodeURIComponent(url);
const responseText = await RequestUtil.HttpGetText(url);
return sendSuccess(res, responseText);
} else {
return sendError(res, 'url参数不合法');
}
};

View File

@@ -1,90 +0,0 @@
import { RequestHandler } from 'express';
import { WebUiDataRuntime } from '@webapi/helper/Data';
import { isEmpty } from '@webapi/utils/check';
import { sendError, sendSuccess } from '@webapi/utils/response';
import { WebUiConfig } from '@/webui';
// 获取QQ登录二维码
export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
// 判断是否已经登录
if (WebUiDataRuntime.getQQLoginStatus()) {
// 已经登录
return sendError(res, 'QQ Is Logined');
}
// 获取二维码
const qrcodeUrl = WebUiDataRuntime.getQQLoginQrcodeURL();
// 判断二维码是否为空
if (isEmpty(qrcodeUrl)) {
return sendError(res, 'QRCode Get Error');
}
// 返回二维码URL
const data = {
qrcode: qrcodeUrl,
};
return sendSuccess(res, data);
};
// 获取QQ登录状态
export const QQCheckLoginStatusHandler: RequestHandler = async (_, res) => {
const data = {
isLogin: WebUiDataRuntime.getQQLoginStatus(),
qrcodeurl: WebUiDataRuntime.getQQLoginQrcodeURL(),
};
return sendSuccess(res, data);
};
// 快速登录
export const QQSetQuickLoginHandler: RequestHandler = async (req, res) => {
// 获取QQ号
const { uin } = req.body;
// 判断是否已经登录
const isLogin = WebUiDataRuntime.getQQLoginStatus();
if (isLogin) {
return sendError(res, 'QQ Is Logined');
}
// 判断QQ号是否为空
if (isEmpty(uin)) {
return sendError(res, 'uin is empty');
}
// 获取快速登录状态
const { result, message } = await WebUiDataRuntime.requestQuickLogin(uin);
if (!result) {
return sendError(res, message);
}
//本来应该验证 但是http不宜这么搞 建议前端验证
//isLogin = WebUiDataRuntime.getQQLoginStatus();
return sendSuccess(res, null);
};
// 获取快速登录列表
export const QQGetQuickLoginListHandler: RequestHandler = async (_, res) => {
const quickLoginList = WebUiDataRuntime.getQQQuickLoginList();
return sendSuccess(res, quickLoginList);
};
// 获取快速登录列表(新)
export const QQGetLoginListNewHandler: RequestHandler = async (_, res) => {
const newLoginList = WebUiDataRuntime.getQQNewLoginList();
return sendSuccess(res, newLoginList);
};
// 获取登录的QQ的信息
export const getQQLoginInfoHandler: RequestHandler = async (_, res) => {
const data = WebUiDataRuntime.getQQLoginInfo();
return sendSuccess(res, data);
};
// 获取自动登录QQ账号
export const getAutoLoginAccountHandler: RequestHandler = async (_, res) => {
const data = WebUiConfig.getAutoLoginAccount();
return sendSuccess(res, data);
};
// 设置自动登录QQ账号
export const setAutoLoginAccountHandler: RequestHandler = async (req, res) => {
const { uin } = req.body;
await WebUiConfig.UpdateAutoLoginAccount(uin);
return sendSuccess(res, null);
};

View File

@@ -1,19 +0,0 @@
import { RequestHandler } from 'express';
import { SystemStatus, statusHelperSubscription } from '@/core/helper/status';
export const StatusRealTimeHandler: RequestHandler = async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Connection', 'keep-alive');
const sendStatus = (status: SystemStatus) => {
try{
res.write(`data: ${JSON.stringify(status)}\n\n`);
} catch (e) {
console.error(`An error occurred when writing sendStatus data to client: ${e}`);
}
};
statusHelperSubscription.on('statusUpdate', sendStatus);
req.on('close', () => {
statusHelperSubscription.off('statusUpdate', sendStatus);
res.end();
});
};

View File

@@ -1,13 +0,0 @@
export enum HttpStatusCode {
OK = 200,
BadRequest = 400,
Unauthorized = 401,
Forbidden = 403,
NotFound = 404,
InternalServerError = 500,
}
export enum ResponseCode {
Success = 0,
Error = -1,
}

View File

@@ -1,121 +0,0 @@
import type { LoginRuntimeType } from '../types/data';
import packageJson from '../../../../package.json';
import store from '@/common/store';
const LoginRuntime: LoginRuntimeType = {
LoginCurrentTime: Date.now(),
LoginCurrentRate: 0,
QQLoginStatus: false, //已实现 但太傻了 得去那边注册个回调刷新
QQQRCodeURL: '',
QQLoginUin: '',
QQLoginInfo: {
uid: '',
uin: '',
nick: '',
},
QQVersion: 'unknown',
NapCatHelper: {
onOB11ConfigChanged: async () => {
return;
},
onQuickLoginRequested: async () => {
return { result: false, message: '' };
},
QQLoginList: [],
NewQQLoginList: [],
},
packageJson: packageJson,
};
export const WebUiDataRuntime = {
checkLoginRate(ip: string, RateLimit: number): boolean {
const key = `login_rate:${ip}`;
const count = store.get<number>(key) || 0;
if (count === 0) {
// 第一次访问设置计数器为1并设置60秒过期
store.set(key, 1, 60);
return true;
}
if (count >= RateLimit) {
return false;
}
store.incr(key);
return true;
},
getQQLoginStatus(): LoginRuntimeType['QQLoginStatus'] {
return LoginRuntime.QQLoginStatus;
},
setQQLoginStatus(status: LoginRuntimeType['QQLoginStatus']): void {
LoginRuntime.QQLoginStatus = status;
},
setQQLoginQrcodeURL(url: LoginRuntimeType['QQQRCodeURL']): void {
LoginRuntime.QQQRCodeURL = url;
},
getQQLoginQrcodeURL(): LoginRuntimeType['QQQRCodeURL'] {
return LoginRuntime.QQQRCodeURL;
},
setQQLoginInfo(info: LoginRuntimeType['QQLoginInfo']): void {
LoginRuntime.QQLoginInfo = info;
LoginRuntime.QQLoginUin = info.uin.toString();
},
getQQLoginInfo(): LoginRuntimeType['QQLoginInfo'] {
return LoginRuntime.QQLoginInfo;
},
getQQLoginUin(): LoginRuntimeType['QQLoginUin'] {
return LoginRuntime.QQLoginUin;
},
getQQQuickLoginList(): LoginRuntimeType['NapCatHelper']['QQLoginList'] {
return LoginRuntime.NapCatHelper.QQLoginList;
},
setQQQuickLoginList(list: LoginRuntimeType['NapCatHelper']['QQLoginList']): void {
LoginRuntime.NapCatHelper.QQLoginList = list;
},
getQQNewLoginList(): LoginRuntimeType['NapCatHelper']['NewQQLoginList'] {
return LoginRuntime.NapCatHelper.NewQQLoginList;
},
setQQNewLoginList(list: LoginRuntimeType['NapCatHelper']['NewQQLoginList']): void {
LoginRuntime.NapCatHelper.NewQQLoginList = list;
},
setQuickLoginCall(func: LoginRuntimeType['NapCatHelper']['onQuickLoginRequested']): void {
LoginRuntime.NapCatHelper.onQuickLoginRequested = func;
},
requestQuickLogin: function (uin) {
return LoginRuntime.NapCatHelper.onQuickLoginRequested(uin);
} as LoginRuntimeType['NapCatHelper']['onQuickLoginRequested'],
setOnOB11ConfigChanged(func: LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged']): void {
LoginRuntime.NapCatHelper.onOB11ConfigChanged = func;
},
setOB11Config: function (ob11) {
return LoginRuntime.NapCatHelper.onOB11ConfigChanged(ob11);
} as LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged'],
getPackageJson() {
return LoginRuntime.packageJson;
},
setQQVersion(version: string) {
LoginRuntime.QQVersion = version;
},
getQQVersion() {
return LoginRuntime.QQVersion;
},
};

View File

@@ -1,88 +0,0 @@
import crypto from 'crypto';
import store from '@/common/store';
export class AuthHelper {
private static readonly secretKey = Math.random().toString(36).slice(2);
/**
* 签名凭证方法。
* @param token 待签名的凭证字符串。
* @returns 签名后的凭证对象。
*/
public static signCredential(token: string): WebUiCredentialJson {
const innerJson: WebUiCredentialInnerJson = {
CreatedTime: Date.now(),
TokenEncoded: token,
};
const jsonString = JSON.stringify(innerJson);
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
return { Data: innerJson, Hmac: hmac };
}
/**
* 检查凭证是否被篡改的方法。
* @param credentialJson 凭证的JSON对象。
* @returns 布尔值,表示凭证是否有效。
*/
public static checkCredential(credentialJson: WebUiCredentialJson): boolean {
try {
const jsonString = JSON.stringify(credentialJson.Data);
const calculatedHmac = crypto
.createHmac('sha256', AuthHelper.secretKey)
.update(jsonString, 'utf8')
.digest('hex');
return calculatedHmac === credentialJson.Hmac;
} catch (error) {
return false;
}
}
/**
* 验证凭证在1小时内有效且token与原始token相同。
* @param token 待验证的原始token。
* @param credentialJson 已签名的凭证JSON对象。
* @returns 布尔值表示凭证是否有效且token匹配。
*/
public static validateCredentialWithinOneHour(token: string, credentialJson: WebUiCredentialJson): boolean {
// 首先检查凭证是否被篡改
const isValid = AuthHelper.checkCredential(credentialJson);
if (!isValid) {
return false;
}
// 检查凭证是否在黑名单中
if (AuthHelper.isCredentialRevoked(credentialJson)) {
return false;
}
const currentTime = Date.now() / 1000;
const createdTime = credentialJson.Data.CreatedTime;
const timeDifference = currentTime - createdTime;
return timeDifference <= 3600 && credentialJson.Data.TokenEncoded === token;
}
/**
* 注销指定的Token凭证
* @param credentialJson 凭证JSON对象
* @returns void
*/
public static revokeCredential(credentialJson: WebUiCredentialJson): void {
const jsonString = JSON.stringify(credentialJson.Data);
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
// 将已注销的凭证添加到黑名单中有效期1小时
store.set(`revoked:${hmac}`, true, 3600);
}
/**
* 检查凭证是否已被注销
* @param credentialJson 凭证JSON对象
* @returns 布尔值,表示凭证是否已被注销
*/
public static isCredentialRevoked(credentialJson: WebUiCredentialJson): boolean {
const jsonString = JSON.stringify(credentialJson.Data);
const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
return store.exists(`revoked:${hmac}`) > 0;
}
}

View File

@@ -1,179 +0,0 @@
import { webUiPathWrapper } from '@/webui';
import { Type, Static } from '@sinclair/typebox';
import Ajv from 'ajv';
import fs, { constants } from 'node:fs/promises';
import { resolve } from 'node:path';
import { deepMerge } from '../utils/object';
import { themeType } from '../types/theme';
// 限制尝试端口的次数,避免死循环
// 定义配置的类型
const WebUiConfigSchema = Type.Object({
host: Type.String({ default: '0.0.0.0' }),
port: Type.Number({ default: 6099 }),
token: Type.String({ default: 'napcat' }),
loginRate: Type.Number({ default: 10 }),
autoLoginAccount: Type.String({ default: '' }),
theme: themeType,
});
export type WebUiConfigType = Static<typeof WebUiConfigSchema>;
// 读取当前目录下名为 webui.json 的配置文件,如果不存在则创建初始化配置文件
export class WebUiConfigWrapper {
WebUiConfigData: WebUiConfigType | undefined = undefined;
private validateAndApplyDefaults(config: Partial<WebUiConfigType>): WebUiConfigType {
new Ajv({ coerceTypes: true, useDefaults: true }).compile(WebUiConfigSchema)(config);
return config as WebUiConfigType;
}
private async ensureConfigFileExists(configPath: string): Promise<void> {
const configExists = await fs
.access(configPath, constants.F_OK)
.then(() => true)
.catch(() => false);
if (!configExists) {
await fs.writeFile(configPath, JSON.stringify(this.validateAndApplyDefaults({}), null, 4));
}
}
private async readAndValidateConfig(configPath: string): Promise<WebUiConfigType> {
const fileContent = await fs.readFile(configPath, 'utf-8');
return this.validateAndApplyDefaults(JSON.parse(fileContent));
}
private async writeConfig(configPath: string, config: WebUiConfigType): Promise<void> {
const hasWritePermission = await fs
.access(configPath, constants.W_OK)
.then(() => true)
.catch(() => false);
if (hasWritePermission) {
await fs.writeFile(configPath, JSON.stringify(config, null, 4));
} else {
console.warn(`文件: ${configPath} 没有写入权限, 配置的更改部分可能会在重启后还原.`);
}
}
async GetWebUIConfig(): Promise<WebUiConfigType> {
if (this.WebUiConfigData) {
return this.WebUiConfigData;
}
try {
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
await this.ensureConfigFileExists(configPath);
const parsedConfig = await this.readAndValidateConfig(configPath);
this.WebUiConfigData = parsedConfig;
return this.WebUiConfigData;
} catch (e) {
console.log('读取配置文件失败', e);
return this.validateAndApplyDefaults({});
}
}
async UpdateWebUIConfig(newConfig: Partial<WebUiConfigType>): Promise<void> {
const configPath = resolve(webUiPathWrapper.configPath, './webui.json');
const currentConfig = await this.GetWebUIConfig();
const mergedConfig = deepMerge({ ...currentConfig }, newConfig);
const updatedConfig = this.validateAndApplyDefaults(mergedConfig);
await this.writeConfig(configPath, updatedConfig);
this.WebUiConfigData = updatedConfig;
}
async UpdateToken(oldToken: string, newToken: string): Promise<void> {
const currentConfig = await this.GetWebUIConfig();
if (currentConfig.token !== oldToken) {
throw new Error('旧 token 不匹配');
}
await this.UpdateWebUIConfig({ token: newToken });
}
// 获取日志文件夹路径
async GetLogsPath(): Promise<string> {
return resolve(webUiPathWrapper.logsPath);
}
// 获取日志列表
async GetLogsList(): Promise<string[]> {
const logsPath = resolve(webUiPathWrapper.logsPath);
const logsExist = await fs
.access(logsPath, constants.F_OK)
.then(() => true)
.catch(() => false);
if (logsExist) {
return (await fs.readdir(logsPath))
.filter((file) => file.endsWith('.log'))
.map((file) => file.replace('.log', ''));
}
return [];
}
// 获取指定日志文件内容
async GetLogContent(filename: string): Promise<string> {
const logPath = resolve(webUiPathWrapper.logsPath, `${filename}.log`);
const logExists = await fs
.access(logPath, constants.R_OK)
.then(() => true)
.catch(() => false);
if (logExists) {
return await fs.readFile(logPath, 'utf-8');
}
return '';
}
// 获取字体文件夹内的字体列表
async GetFontList(): Promise<string[]> {
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
const fontsExist = await fs
.access(fontsPath, constants.F_OK)
.then(() => true)
.catch(() => false);
if (fontsExist) {
return (await fs.readdir(fontsPath)).filter((file) => file.endsWith('.ttf'));
}
return [];
}
// 判断字体是否存在webui.woff
async CheckWebUIFontExist(): Promise<boolean> {
const fontsPath = resolve(webUiPathWrapper.configPath, './fonts');
return await fs
.access(resolve(fontsPath, './webui.woff'), constants.F_OK)
.then(() => true)
.catch(() => false);
}
// 获取webui字体文件路径
GetWebUIFontPath(): string {
return resolve(webUiPathWrapper.configPath, './fonts/webui.woff');
}
getAutoLoginAccount(): string | undefined {
return this.WebUiConfigData?.autoLoginAccount;
}
// 获取自动登录账号
async GetAutoLoginAccount(): Promise<string> {
return (await this.GetWebUIConfig()).autoLoginAccount;
}
// 更新自动登录账号
async UpdateAutoLoginAccount(uin: string): Promise<void> {
await this.UpdateWebUIConfig({ autoLoginAccount: uin });
}
// 获取主题内容
async GetTheme(): Promise<WebUiConfigType['theme']> {
const config = await this.GetWebUIConfig();
return config.theme;
}
// 更新主题内容
async UpdateTheme(theme: WebUiConfigType['theme']): Promise<void> {
await this.UpdateWebUIConfig({ theme: theme });
}
}

View File

@@ -1,46 +0,0 @@
import { NextFunction, Request, Response } from 'express';
import { WebUiConfig } from '@/webui';
import { AuthHelper } from '@webapi/helper/SignToken';
import { sendError } from '@webapi/utils/response';
// 鉴权中间件
export async function auth(req: Request, res: Response, next: NextFunction) {
// 判断当前url是否为/login 如果是跳过鉴权
if (req.url == '/auth/login') {
return next();
}
// 判断是否有Authorization头
if (req.headers?.authorization) {
// 切割参数以获取token
const authorization = req.headers.authorization.split(' ');
// 当Bearer后面没有参数时
if (authorization.length < 2) {
return sendError(res, 'Unauthorized');
}
// 获取token
const token = authorization[1];
// 解析token
let Credential: WebUiCredentialJson;
try {
Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8'));
} catch (e) {
return sendError(res, 'Unauthorized');
}
// 获取配置
const config = await WebUiConfig.GetWebUIConfig();
// 验证凭证在1小时内有效且token与原始token相同
const credentialJson = AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
if (credentialJson) {
// 通过验证
return next();
}
// 验证失败
return sendError(res, 'Unauthorized');
}
// 没有Authorization头
return sendError(res, 'Unauthorized');
}

View File

@@ -1,16 +0,0 @@
import type { RequestHandler } from 'express';
// CORS 中间件,跨域用
export const cors: RequestHandler = (req, res, next) => {
const origin = req.headers.origin || '*';
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
res.header('Access-Control-Allow-Credentials', 'true');
if (req.method === 'OPTIONS') {
res.sendStatus(204);
return;
}
next();
};

View File

@@ -1,15 +0,0 @@
import { Router } from 'express';
import { GetThemeConfigHandler, PackageInfoHandler, QQVersionHandler, SetThemeConfigHandler } from '../api/BaseInfo';
import { StatusRealTimeHandler } from '@webapi/api/Status';
import { GetProxyHandler } from '../api/Proxy';
const router = Router();
// router: 获取nc的package.json信息
router.get('/QQVersion', QQVersionHandler);
router.get('/PackageInfo', PackageInfoHandler);
router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
router.get('/proxy', GetProxyHandler);
router.get('/Theme', GetThemeConfigHandler);
router.post('/SetTheme', SetThemeConfigHandler);
export { router as BaseRouter };

View File

@@ -1,49 +0,0 @@
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import {
ListFilesHandler,
CreateDirHandler,
DeleteHandler,
ReadFileHandler,
WriteFileHandler,
CreateFileHandler,
BatchDeleteHandler, // 添加这一行
RenameHandler,
MoveHandler,
BatchMoveHandler,
DownloadHandler,
BatchDownloadHandler, // 新增下载处理方法
UploadHandler,
UploadWebUIFontHandler,
DeleteWebUIFontHandler, // 添加上传处理器
} from '../api/File';
const router = Router();
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟内
max: 60, // 最大60个请求
validate: {
xForwardedForHeader: false,
},
});
router.use(apiLimiter);
router.get('/list', ListFilesHandler);
router.post('/mkdir', CreateDirHandler);
router.post('/delete', DeleteHandler);
router.get('/read', ReadFileHandler);
router.post('/write', WriteFileHandler);
router.post('/create', CreateFileHandler);
router.post('/batchDelete', BatchDeleteHandler);
router.post('/rename', RenameHandler);
router.post('/move', MoveHandler);
router.post('/batchMove', BatchMoveHandler);
router.post('/download', DownloadHandler);
router.post('/batchDownload', BatchDownloadHandler);
router.post('/upload', UploadHandler);
router.post('/font/upload/webui', UploadWebUIFontHandler);
router.post('/font/delete/webui', DeleteWebUIFontHandler);
export { router as FileRouter };

View File

@@ -1,23 +0,0 @@
import { Router } from 'express';
import {
LogHandler,
LogListHandler,
LogRealTimeHandler,
CreateTerminalHandler,
GetTerminalListHandler,
CloseTerminalHandler,
} from '../api/Log';
const router = Router();
// 日志相关路由
router.get('/GetLog', LogHandler);
router.get('/GetLogList', LogListHandler);
router.get('/GetLogRealTime', LogRealTimeHandler);
// 终端相关路由
router.get('/terminal/list', GetTerminalListHandler);
router.post('/terminal/create', CreateTerminalHandler);
router.post('/terminal/:id/close', CloseTerminalHandler);
export { router as LogRouter };

View File

@@ -1,11 +0,0 @@
import { Router } from 'express';
import { OB11GetConfigHandler, OB11SetConfigHandler } from '@webapi/api/OB11Config';
const router = Router();
// router:读取配置
router.post('/GetConfig', OB11GetConfigHandler);
// router:写入配置
router.post('/SetConfig', OB11SetConfigHandler);
export { router as OB11ConfigRouter };

View File

@@ -1,32 +0,0 @@
import { Router } from 'express';
import {
QQCheckLoginStatusHandler,
QQGetQRcodeHandler,
QQGetQuickLoginListHandler,
QQSetQuickLoginHandler,
QQGetLoginListNewHandler,
getQQLoginInfoHandler,
getAutoLoginAccountHandler,
setAutoLoginAccountHandler,
} from '@webapi/api/QQLogin';
const router = Router();
// router:获取快速登录列表
router.all('/GetQuickLoginList', QQGetQuickLoginListHandler);
// router:获取快速登录列表(新)
router.all('/GetQuickLoginListNew', QQGetLoginListNewHandler);
// router:检查QQ登录状态
router.post('/CheckLoginStatus', QQCheckLoginStatusHandler);
// router:获取QQ登录二维码
router.post('/GetQQLoginQrcode', QQGetQRcodeHandler);
// router:设置QQ快速登录
router.post('/SetQuickLogin', QQSetQuickLoginHandler);
// router:获取QQ登录信息
router.post('/GetQQLoginInfo', getQQLoginInfoHandler);
// router:获取快速登录QQ账号
router.post('/GetQuickLoginQQ', getAutoLoginAccountHandler);
// router:设置自动登录QQ账号
router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
export { router as QQLoginRouter };

View File

@@ -1,23 +0,0 @@
import { Router } from 'express';
import {
CheckDefaultTokenHandler,
checkHandler,
LoginHandler,
LogoutHandler,
UpdateTokenHandler,
} from '@webapi/api/Auth';
const router = Router();
// router:登录
router.post('/login', LoginHandler);
// router:检查登录状态
router.post('/check', checkHandler);
// router:注销
router.post('/logout', LogoutHandler);
// router:更新token
router.post('/update_token', UpdateTokenHandler);
// router:检查默认token
router.get('/check_using_default_token', CheckDefaultTokenHandler);
export { router as AuthRouter };

View File

@@ -1,39 +0,0 @@
/**
* @file 所有路由的入口文件
*/
import { Router } from 'express';
import { OB11ConfigRouter } from '@webapi/router/OB11Config';
import { auth } from '@webapi/middleware/auth';
import { sendSuccess } from '@webapi/utils/response';
import { QQLoginRouter } from '@webapi/router/QQLogin';
import { AuthRouter } from '@webapi/router/auth';
import { LogRouter } from '@webapi/router/Log';
import { BaseRouter } from '@webapi/router/Base';
import { FileRouter } from './File';
const router = Router();
// 鉴权中间件
router.use(auth);
// router:测试用
router.all('/test', (_, res) => {
return sendSuccess(res);
});
// router:基础信息相关路由
router.use('/base', BaseRouter);
// router:WebUI登录相关路由
router.use('/auth', AuthRouter);
// router:QQ登录相关路由
router.use('/QQLogin', QQLoginRouter);
// router:OB11配置相关路由
router.use('/OB11Config', OB11ConfigRouter);
// router:日志相关路由
router.use('/Log', LogRouter);
// file:文件相关路由
router.use('/File', FileRouter);
export { router as ALLRouter };

View File

@@ -1,21 +0,0 @@
import path from 'path';
Object.defineProperty(global, '__dirname', {
get() {
const err = new Error();
const stack = err.stack?.split('\n') || [];
let callerFile = '';
// 遍历错误堆栈,跳过当前文件所在行
// 注意:堆栈格式可能不同,请根据实际环境调整索引及正则表达式
for (const line of stack) {
const match = line.match(/\((.*):\d+:\d+\)/);
if (match) {
callerFile = match[1];
if (!callerFile.includes('init-dynamic-dirname.ts')) {
break;
}
}
}
return callerFile ? path.dirname(callerFile) : '';
},
});

View File

@@ -1,183 +0,0 @@
import './init-dynamic-dirname';
import { WebUiConfig } from '@/webui';
import { AuthHelper } from '../helper/SignToken';
import { LogWrapper } from '@/common/log';
import { WebSocket, WebSocketServer } from 'ws';
import os from 'os';
import { IPty, spawn as ptySpawn } from '@/pty';
import { randomUUID } from 'crypto';
interface TerminalInstance {
pty: IPty; // 改用 PTY 实例
lastAccess: number;
sockets: Set<WebSocket>;
// 新增标识,用于防止重复关闭
isClosing: boolean;
// 新增:存储终端历史输出
buffer: string;
}
class TerminalManager {
private terminals: Map<string, TerminalInstance> = new Map();
private wss: WebSocketServer | null = null;
initialize(req: any, socket: any, head: any, logger?: LogWrapper) {
logger?.log('[NapCat] [WebUi] terminal websocket initialized');
this.wss = new WebSocketServer({
noServer: true,
verifyClient: async (info, cb) => {
// 验证 token
const url = new URL(info.req.url || '', 'ws://localhost');
const token = url.searchParams.get('token');
const terminalId = url.searchParams.get('id');
if (!token || !terminalId) {
cb(false, 401, 'Unauthorized');
return;
}
// 解析token
let Credential: WebUiCredentialJson;
try {
Credential = JSON.parse(Buffer.from(token, 'base64').toString('utf-8'));
} catch (e) {
cb(false, 401, 'Unauthorized');
return;
}
const config = await WebUiConfig.GetWebUIConfig();
const validate = AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
if (!validate) {
cb(false, 401, 'Unauthorized');
return;
}
cb(true);
},
});
this.wss.handleUpgrade(req, socket, head, (ws) => {
this.wss?.emit('connection', ws, req);
});
this.wss.on('connection', async (ws, req) => {
logger?.log('建立终端连接');
try {
const url = new URL(req.url || '', 'ws://localhost');
const terminalId = url.searchParams.get('id')!;
const instance = this.terminals.get(terminalId);
if (!instance) {
ws.close();
return;
}
instance.sockets.add(ws);
instance.lastAccess = Date.now();
// 新增:发送当前终端内容给新连接
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'output', data: instance.buffer }));
}
ws.on('message', (data) => {
if (instance) {
const result = JSON.parse(data.toString());
if (result.type === 'input') {
instance.pty.write(result.data);
}
// 新增:处理 resize 消息
if (result.type === 'resize') {
instance.pty.resize(result.cols, result.rows);
}
}
});
ws.on('close', () => {
instance.sockets.delete(ws);
if (instance.sockets.size === 0 && !instance.isClosing) {
instance.isClosing = true;
if (os.platform() === 'win32') {
process.kill(instance.pty.pid);
} else {
instance.pty.kill();
}
}
});
} catch (err) {
console.error('WebSocket authentication failed:', err);
ws.close();
}
});
}
// 修改:新增 cols 和 rows 参数,同步 xterm 尺寸,防止错位
createTerminal(cols: number, rows: number) {
const id = randomUUID();
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
const pty = ptySpawn(shell, [], {
name: 'xterm-256color',
cols, // 使用客户端传入的 cols
rows, // 使用客户端传入的 rows
cwd: process.cwd(),
env: {
...process.env,
LANG: os.platform() === 'win32' ? 'chcp 65001' : 'zh_CN.UTF-8',
TERM: 'xterm-256color',
},
});
const instance: TerminalInstance = {
pty,
lastAccess: Date.now(),
sockets: new Set(),
isClosing: false,
buffer: '', // 初始化终端内容缓存
};
pty.onData((data: any) => {
// 追加数据到 buffer
instance.buffer += data;
// 发送数据给已连接的 websocket
instance.sockets.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'output', data }));
}
});
});
pty.onExit(() => {
this.closeTerminal(id);
});
this.terminals.set(id, instance);
// 返回生成的 id 及对应实例,方便后续通知客户端使用该 id
return { id, instance };
}
closeTerminal(id: string) {
const instance = this.terminals.get(id);
if (instance) {
if (!instance.isClosing) {
instance.isClosing = true;
if (os.platform() === 'win32') {
process.kill(instance.pty.pid);
} else {
instance.pty.kill();
}
}
instance.sockets.forEach((ws) => ws.close());
this.terminals.delete(id);
}
}
getTerminal(id: string) {
return this.terminals.get(id);
}
getTerminalList() {
return Array.from(this.terminals.keys()).map((id) => ({
id,
lastAccess: this.terminals.get(id)!.lastAccess,
}));
}
}
export const terminalManager = new TerminalManager();

View File

@@ -1,6 +0,0 @@
interface WebUiConfigType {
host: string;
port: number;
token: string;
loginRate: number;
}

View File

@@ -1,19 +0,0 @@
import type { LoginListItem, SelfInfo } from '@/core';
import type { OneBotConfig } from '@/onebot/config/config';
interface LoginRuntimeType {
LoginCurrentTime: number;
LoginCurrentRate: number;
QQLoginStatus: boolean;
QQQRCodeURL: string;
QQLoginUin: string;
QQLoginInfo: SelfInfo;
QQVersion: string;
NapCatHelper: {
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string }>;
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
QQLoginList: string[];
NewQQLoginList: LoginListItem[];
};
packageJson: object;
}

View File

@@ -1,7 +0,0 @@
interface APIResponse<T> {
code: number;
message: string;
data: T;
}
type Protocol = 'http' | 'https' | 'ws' | 'wss';

View File

@@ -1,9 +0,0 @@
interface WebUiCredentialInnerJson {
CreatedTime: number;
TokenEncoded: string;
}
interface WebUiCredentialJson {
Data: WebUiCredentialInnerJson;
Hmac: string;
}

View File

@@ -1,260 +0,0 @@
import { Type } from '@sinclair/typebox';
export const themeType = Type.Object(
{
dark: Type.Record(Type.String(), Type.String()),
light: Type.Record(Type.String(), Type.String()),
},
{
default: {
dark: {
'--heroui-background': '0 0% 0%',
'--heroui-foreground-50': '240 5.88% 10%',
'--heroui-foreground-100': '240 3.7% 15.88%',
'--heroui-foreground-200': '240 5.26% 26.08%',
'--heroui-foreground-300': '240 5.2% 33.92%',
'--heroui-foreground-400': '240 3.83% 46.08%',
'--heroui-foreground-500': '240 5.03% 64.9%',
'--heroui-foreground-600': '240 4.88% 83.92%',
'--heroui-foreground-700': '240 5.88% 90%',
'--heroui-foreground-800': '240 4.76% 95.88%',
'--heroui-foreground-900': '0 0% 98.04%',
'--heroui-foreground': '210 5.56% 92.94%',
'--heroui-focus': '212.01999999999998 100% 46.67%',
'--heroui-overlay': '0 0% 0%',
'--heroui-divider': '0 0% 100%',
'--heroui-divider-opacity': '0.15',
'--heroui-content1': '240 5.88% 10%',
'--heroui-content1-foreground': '0 0% 98.04%',
'--heroui-content2': '240 3.7% 15.88%',
'--heroui-content2-foreground': '240 4.76% 95.88%',
'--heroui-content3': '240 5.26% 26.08%',
'--heroui-content3-foreground': '240 5.88% 90%',
'--heroui-content4': '240 5.2% 33.92%',
'--heroui-content4-foreground': '240 4.88% 83.92%',
'--heroui-default-50': '240 5.88% 10%',
'--heroui-default-100': '240 3.7% 15.88%',
'--heroui-default-200': '240 5.26% 26.08%',
'--heroui-default-300': '240 5.2% 33.92%',
'--heroui-default-400': '240 3.83% 46.08%',
'--heroui-default-500': '240 5.03% 64.9%',
'--heroui-default-600': '240 4.88% 83.92%',
'--heroui-default-700': '240 5.88% 90%',
'--heroui-default-800': '240 4.76% 95.88%',
'--heroui-default-900': '0 0% 98.04%',
'--heroui-default-foreground': '0 0% 100%',
'--heroui-default': '240 5.26% 26.08%',
'--heroui-danger-50': '301.89 82.61% 22.55%',
'--heroui-danger-100': '308.18 76.39% 28.24%',
'--heroui-danger-200': '313.85 70.65% 36.08%',
'--heroui-danger-300': '319.73 65.64% 44.51%',
'--heroui-danger-400': '325.82 69.62% 53.53%',
'--heroui-danger-500': '331.82 75% 65.49%',
'--heroui-danger-600': '337.84 83.46% 73.92%',
'--heroui-danger-700': '343.42 90.48% 83.53%',
'--heroui-danger-800': '350.53 90.48% 91.76%',
'--heroui-danger-900': '324 90.91% 95.69%',
'--heroui-danger-foreground': '0 0% 100%',
'--heroui-danger': '325.82 69.62% 53.53%',
'--heroui-primary-50': '340 84.91% 10.39%',
'--heroui-primary-100': '339.33 86.54% 20.39%',
'--heroui-primary-200': '339.11 85.99% 30.78%',
'--heroui-primary-300': '339 86.54% 40.78%',
'--heroui-primary-400': '339.2 90.36% 51.18%',
'--heroui-primary-500': '339 90% 60.78%',
'--heroui-primary-600': '339.11 90.6% 70.78%',
'--heroui-primary-700': '339.33 90% 80.39%',
'--heroui-primary-800': '340 91.84% 90.39%',
'--heroui-primary-900': '339.13 92% 95.1%',
'--heroui-primary-foreground': '0 0% 100%',
'--heroui-primary': '339.2 90.36% 51.18%',
'--heroui-secondary-50': '270 66.67% 9.41%',
'--heroui-secondary-100': '270 66.67% 18.82%',
'--heroui-secondary-200': '270 66.67% 28.24%',
'--heroui-secondary-300': '270 66.67% 37.65%',
'--heroui-secondary-400': '270 66.67% 47.06%',
'--heroui-secondary-500': '270 59.26% 57.65%',
'--heroui-secondary-600': '270 59.26% 68.24%',
'--heroui-secondary-700': '270 59.26% 78.82%',
'--heroui-secondary-800': '270 59.26% 89.41%',
'--heroui-secondary-900': '270 61.54% 94.9%',
'--heroui-secondary-foreground': '0 0% 100%',
'--heroui-secondary': '270 59.26% 57.65%',
'--heroui-success-50': '145.71 77.78% 8.82%',
'--heroui-success-100': '146.2 79.78% 17.45%',
'--heroui-success-200': '145.79 79.26% 26.47%',
'--heroui-success-300': '146.01 79.89% 35.1%',
'--heroui-success-400': '145.96 79.46% 43.92%',
'--heroui-success-500': '146.01 62.45% 55.1%',
'--heroui-success-600': '145.79 62.57% 66.47%',
'--heroui-success-700': '146.2 61.74% 77.45%',
'--heroui-success-800': '145.71 61.4% 88.82%',
'--heroui-success-900': '146.67 64.29% 94.51%',
'--heroui-success-foreground': '0 0% 0%',
'--heroui-success': '145.96 79.46% 43.92%',
'--heroui-warning-50': '37.14 75% 10.98%',
'--heroui-warning-100': '37.14 75% 21.96%',
'--heroui-warning-200': '36.96 73.96% 33.14%',
'--heroui-warning-300': '37.01 74.22% 44.12%',
'--heroui-warning-400': '37.03 91.27% 55.1%',
'--heroui-warning-500': '37.01 91.26% 64.12%',
'--heroui-warning-600': '36.96 91.24% 73.14%',
'--heroui-warning-700': '37.14 91.3% 81.96%',
'--heroui-warning-800': '37.14 91.3% 90.98%',
'--heroui-warning-900': '54.55 91.67% 95.29%',
'--heroui-warning-foreground': '0 0% 0%',
'--heroui-warning': '37.03 91.27% 55.1%',
'--heroui-code-background': '240 5.56% 7.06%',
'--heroui-strong': '190.14 94.67% 44.12%',
'--heroui-code-mdx': '190.14 94.67% 44.12%',
'--heroui-divider-weight': '1px',
'--heroui-disabled-opacity': '.5',
'--heroui-font-size-tiny': '0.75rem',
'--heroui-font-size-small': '0.875rem',
'--heroui-font-size-medium': '1rem',
'--heroui-font-size-large': '1.125rem',
'--heroui-line-height-tiny': '1rem',
'--heroui-line-height-small': '1.25rem',
'--heroui-line-height-medium': '1.5rem',
'--heroui-line-height-large': '1.75rem',
'--heroui-radius-small': '8px',
'--heroui-radius-medium': '12px',
'--heroui-radius-large': '14px',
'--heroui-border-width-small': '1px',
'--heroui-border-width-medium': '2px',
'--heroui-border-width-large': '3px',
'--heroui-box-shadow-small':
'0px 0px 5px 0px rgba(0, 0, 0, .05), 0px 2px 10px 0px rgba(0, 0, 0, .2), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'--heroui-box-shadow-medium':
'0px 0px 15px 0px rgba(0, 0, 0, .06), 0px 2px 30px 0px rgba(0, 0, 0, .22), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'--heroui-box-shadow-large':
'0px 0px 30px 0px rgba(0, 0, 0, .07), 0px 30px 60px 0px rgba(0, 0, 0, .26), inset 0px 0px 1px 0px hsla(0, 0%, 100%, .15)',
'--heroui-hover-opacity': '.9',
},
light: {
'--heroui-background': '0 0% 100%',
'--heroui-foreground-50': '240 5.88% 95%',
'--heroui-foreground-100': '240 3.7% 90%',
'--heroui-foreground-200': '240 5.26% 80%',
'--heroui-foreground-300': '240 5.2% 70%',
'--heroui-foreground-400': '240 3.83% 60%',
'--heroui-foreground-500': '240 5.03% 50%',
'--heroui-foreground-600': '240 4.88% 40%',
'--heroui-foreground-700': '240 5.88% 30%',
'--heroui-foreground-800': '240 4.76% 20%',
'--heroui-foreground-900': '0 0% 10%',
'--heroui-foreground': '210 5.56% 7.06%',
'--heroui-focus': '212.01999999999998 100% 53.33%',
'--heroui-overlay': '0 0% 100%',
'--heroui-divider': '0 0% 0%',
'--heroui-divider-opacity': '0.85',
'--heroui-content1': '240 5.88% 95%',
'--heroui-content1-foreground': '0 0% 10%',
'--heroui-content2': '240 3.7% 90%',
'--heroui-content2-foreground': '240 4.76% 20%',
'--heroui-content3': '240 5.26% 80%',
'--heroui-content3-foreground': '240 5.88% 30%',
'--heroui-content4': '240 5.2% 70%',
'--heroui-content4-foreground': '240 4.88% 40%',
'--heroui-default-50': '240 5.88% 95%',
'--heroui-default-100': '240 3.7% 90%',
'--heroui-default-200': '240 5.26% 80%',
'--heroui-default-300': '240 5.2% 70%',
'--heroui-default-400': '240 3.83% 60%',
'--heroui-default-500': '240 5.03% 50%',
'--heroui-default-600': '240 4.88% 40%',
'--heroui-default-700': '240 5.88% 30%',
'--heroui-default-800': '240 4.76% 20%',
'--heroui-default-900': '0 0% 10%',
'--heroui-default-foreground': '0 0% 0%',
'--heroui-default': '240 5.26% 80%',
'--heroui-danger-50': '324 90.91% 95.69%',
'--heroui-danger-100': '350.53 90.48% 91.76%',
'--heroui-danger-200': '343.42 90.48% 83.53%',
'--heroui-danger-300': '337.84 83.46% 73.92%',
'--heroui-danger-400': '331.82 75% 65.49%',
'--heroui-danger-500': '325.82 69.62% 53.53%',
'--heroui-danger-600': '319.73 65.64% 44.51%',
'--heroui-danger-700': '313.85 70.65% 36.08%',
'--heroui-danger-800': '308.18 76.39% 28.24%',
'--heroui-danger-900': '301.89 82.61% 22.55%',
'--heroui-danger-foreground': '0 0% 100%',
'--heroui-danger': '325.82 69.62% 53.53%',
'--heroui-primary-50': '339.13 92% 95.1%',
'--heroui-primary-100': '340 91.84% 90.39%',
'--heroui-primary-200': '339.33 90% 80.39%',
'--heroui-primary-300': '339.11 90.6% 70.78%',
'--heroui-primary-400': '339 90% 60.78%',
'--heroui-primary-500': '339.2 90.36% 51.18%',
'--heroui-primary-600': '339 86.54% 40.78%',
'--heroui-primary-700': '339.11 85.99% 30.78%',
'--heroui-primary-800': '339.33 86.54% 20.39%',
'--heroui-primary-900': '340 84.91% 10.39%',
'--heroui-primary-foreground': '0 0% 100%',
'--heroui-primary': '339.2 90.36% 51.18%',
'--heroui-secondary-50': '270 61.54% 94.9%',
'--heroui-secondary-100': '270 59.26% 89.41%',
'--heroui-secondary-200': '270 59.26% 78.82%',
'--heroui-secondary-300': '270 59.26% 68.24%',
'--heroui-secondary-400': '270 59.26% 57.65%',
'--heroui-secondary-500': '270 66.67% 47.06%',
'--heroui-secondary-600': '270 66.67% 37.65%',
'--heroui-secondary-700': '270 66.67% 28.24%',
'--heroui-secondary-800': '270 66.67% 18.82%',
'--heroui-secondary-900': '270 66.67% 9.41%',
'--heroui-secondary-foreground': '0 0% 100%',
'--heroui-secondary': '270 66.67% 47.06%',
'--heroui-success-50': '146.67 64.29% 94.51%',
'--heroui-success-100': '145.71 61.4% 88.82%',
'--heroui-success-200': '146.2 61.74% 77.45%',
'--heroui-success-300': '145.79 62.57% 66.47%',
'--heroui-success-400': '146.01 62.45% 55.1%',
'--heroui-success-500': '145.96 79.46% 43.92%',
'--heroui-success-600': '146.01 79.89% 35.1%',
'--heroui-success-700': '145.79 79.26% 26.47%',
'--heroui-success-800': '146.2 79.78% 17.45%',
'--heroui-success-900': '145.71 77.78% 8.82%',
'--heroui-success-foreground': '0 0% 0%',
'--heroui-success': '145.96 79.46% 43.92%',
'--heroui-warning-50': '54.55 91.67% 95.29%',
'--heroui-warning-100': '37.14 91.3% 90.98%',
'--heroui-warning-200': '37.14 91.3% 81.96%',
'--heroui-warning-300': '36.96 91.24% 73.14%',
'--heroui-warning-400': '37.01 91.26% 64.12%',
'--heroui-warning-500': '37.03 91.27% 55.1%',
'--heroui-warning-600': '37.01 74.22% 44.12%',
'--heroui-warning-700': '36.96 73.96% 33.14%',
'--heroui-warning-800': '37.14 75% 21.96%',
'--heroui-warning-900': '37.14 75% 10.98%',
'--heroui-warning-foreground': '0 0% 0%',
'--heroui-warning': '37.03 91.27% 55.1%',
'--heroui-code-background': '221.25 17.39% 18.04%',
'--heroui-strong': '316.95 100% 65.29%',
'--heroui-code-mdx': '316.95 100% 65.29%',
'--heroui-divider-weight': '1px',
'--heroui-disabled-opacity': '.5',
'--heroui-font-size-tiny': '0.75rem',
'--heroui-font-size-small': '0.875rem',
'--heroui-font-size-medium': '1rem',
'--heroui-font-size-large': '1.125rem',
'--heroui-line-height-tiny': '1rem',
'--heroui-line-height-small': '1.25rem',
'--heroui-line-height-medium': '1.5rem',
'--heroui-line-height-large': '1.75rem',
'--heroui-radius-small': '8px',
'--heroui-radius-medium': '12px',
'--heroui-radius-large': '14px',
'--heroui-border-width-small': '1px',
'--heroui-border-width-medium': '2px',
'--heroui-border-width-large': '3px',
'--heroui-box-shadow-small':
'0px 0px 5px 0px rgba(0, 0, 0, .02), 0px 2px 10px 0px rgba(0, 0, 0, .06), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'--heroui-box-shadow-medium':
'0px 0px 15px 0px rgba(0, 0, 0, .03), 0px 2px 30px 0px rgba(0, 0, 0, .08), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'--heroui-box-shadow-large':
'0px 0px 30px 0px rgba(0, 0, 0, .04), 0px 30px 60px 0px rgba(0, 0, 0, .12), 0px 0px 1px 0px rgba(0, 0, 0, .3)',
'--heroui-hover-opacity': '.8',
},
},
}
);

View File

@@ -1,85 +0,0 @@
import multer from 'multer';
import { Request, Response } from 'express';
import fs from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';
const isWindows = process.platform === 'win32';
// 修改:使用 Buffer 转码文件名,解决文件上传时乱码问题
const decodeFileName = (fileName: string): string => {
try {
return Buffer.from(fileName, 'binary').toString('utf8');
} catch {
return fileName;
}
};
export const createDiskStorage = (uploadPath: string) => {
return multer.diskStorage({
destination: (
_: Request,
file: Express.Multer.File,
cb: (error: Error | null, destination: string) => void
) => {
try {
const decodedName = decodeFileName(file.originalname);
if (!uploadPath) {
return cb(new Error('上传路径不能为空'), '');
}
if (isWindows && uploadPath === '\\') {
return cb(new Error('根目录不允许上传文件'), '');
}
// 处理文件夹上传的情况
if (decodedName.includes('/') || decodedName.includes('\\')) {
const fullPath = path.join(uploadPath, path.dirname(decodedName));
fs.mkdirSync(fullPath, { recursive: true });
cb(null, fullPath);
} else {
cb(null, uploadPath);
}
} catch (error) {
cb(error as Error, '');
}
},
filename: (_: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
try {
const decodedName = decodeFileName(file.originalname);
const fileName = path.basename(decodedName);
// 检查文件是否存在
const fullPath = path.join(uploadPath, decodedName);
if (fs.existsSync(fullPath)) {
const ext = path.extname(fileName);
const name = path.basename(fileName, ext);
cb(null, `${name}-${randomUUID()}${ext}`);
} else {
cb(null, fileName);
}
} catch (error) {
cb(error as Error, '');
}
},
});
};
export const createDiskUpload = (uploadPath: string) => {
const upload = multer({ storage: createDiskStorage(uploadPath) }).array('files');
return upload;
};
const diskUploader = (req: Request, res: Response) => {
const uploadPath = (req.query['path'] || '') as string;
return new Promise((resolve, reject) => {
createDiskUpload(uploadPath)(req, res, (error) => {
if (error) {
// 错误处理
return reject(error);
}
return resolve(true);
});
});
};
export default diskUploader;

View File

@@ -1,52 +0,0 @@
import multer from 'multer';
import { WebUiConfigWrapper } from '../helper/config';
import path from 'path';
import fs from 'fs';
import type { Request, Response } from 'express';
export const webUIFontStorage = multer.diskStorage({
destination: (_, __, cb) => {
try {
const fontsPath = path.dirname(WebUiConfigWrapper.GetWebUIFontPath());
// 确保字体目录存在
fs.mkdirSync(fontsPath, { recursive: true });
cb(null, fontsPath);
} catch (error) {
// 确保错误信息被正确传递
cb(new Error(`创建字体目录失败:${(error as Error).message}`), '');
}
},
filename: (_, __, cb) => {
// 统一保存为webui.woff
cb(null, 'webui.woff');
},
});
export const webUIFontUpload = multer({
storage: webUIFontStorage,
fileFilter: (_, file, cb) => {
// 再次验证文件类型
if (!file.originalname.toLowerCase().endsWith('.woff')) {
cb(new Error('只支持WOFF格式的字体文件'));
return;
}
cb(null, true);
},
limits: {
fileSize: 40 * 1024 * 1024, // 限制40MB
},
}).single('file');
const webUIFontUploader = (req: Request, res: Response) => {
return new Promise((resolve, reject) => {
webUIFontUpload(req, res, (error) => {
if (error) {
// 错误处理
// sendError(res, error.message, true);
return reject(error);
}
return resolve(true);
});
});
};
export default webUIFontUploader;

View File

@@ -1 +0,0 @@
export const isEmpty = <T>(data: T) => data === undefined || data === null || data === '';

View File

@@ -1,22 +0,0 @@
export function deepMerge<T extends Record<string, any>>(target: T, source: Partial<T>): T {
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
// 如果 source[key] 为 undefined则跳过保留 target[key]
if (source[key] === undefined) {
continue;
}
if (
target[key] !== undefined &&
typeof target[key] === 'object' &&
!Array.isArray(target[key]) &&
typeof source[key] === 'object' &&
!Array.isArray(source[key])
) {
target[key] = deepMerge({ ...target[key] }, source[key]!) as T[Extract<keyof T, string>];
} else {
target[key] = source[key]! as T[Extract<keyof T, string>];
}
}
}
return target;
}

View File

@@ -1,47 +0,0 @@
import type { Response } from 'express';
import { ResponseCode, HttpStatusCode } from '@webapi/const/status';
export const sendResponse = <T>(
res: Response,
data?: T,
code: ResponseCode = 0,
message = 'success',
useSend: boolean = false
) => {
const result = {
code,
message,
data,
};
if (useSend) {
res.status(HttpStatusCode.OK).send(JSON.stringify(result));
return;
}
res.status(HttpStatusCode.OK).json(result);
};
export const sendError = (res: Response, message = 'error', useSend: boolean = false) => {
const result = {
code: ResponseCode.Error,
message,
};
if (useSend) {
res.status(HttpStatusCode.OK).send(JSON.stringify(result));
return;
}
res.status(HttpStatusCode.OK).json(result);
};
export const sendSuccess = <T>(res: Response, data?: T, message = 'success', useSend: boolean = false) => {
const result = {
code: ResponseCode.Success,
data,
message,
};
if (useSend) {
res.status(HttpStatusCode.OK).send(JSON.stringify(result));
return;
}
res.status(HttpStatusCode.OK).json(result);
};

View File

@@ -1,47 +0,0 @@
/**
* @file URL工具
*/
import { isIP } from 'node:net';
/**
* 将 host主机地址 转换为标准格式
* @param host 主机地址
* @returns 标准格式的IP地址
* @example normalizeHost('10.0.3.2') => '10.0.3.2'
* @example normalizeHost('0.0.0.0') => '127.0.0.1'
* @example normalizeHost('2001:4860:4801:51::27') => '[2001:4860:4801:51::27]'
*/
export const normalizeHost = (host: string) => {
if (host === '0.0.0.0') return '127.0.0.1';
if (isIP(host) === 6) return `[${host}]`;
return host;
};
/**
* 创建URL
* @param host 主机地址
* @param port 端口
* @param path URL路径
* @param search URL参数
* @returns 完整URL
* @example createUrl('127.0.0.1', '8080', '/api', { token: '123456' }) => 'http://127.0.0.1:8080/api?token=123456'
* @example createUrl('baidu.com', '80', void 0, void 0, 'https') => 'https://baidu.com:80/'
*/
export const createUrl = (
host: string,
port: string,
path = '/',
search?: Record<string, any>,
protocol: Protocol = 'http'
) => {
const url = new URL(`${protocol}://${normalizeHost(host)}`);
url.port = port;
url.pathname = path;
if (search) {
for (const key in search) {
url.searchParams.set(key, search[key]);
}
}
return url.toString();
};

View File

@@ -1,7 +0,0 @@
{
"host": "0.0.0.0",
"port": 6099,
"prefix": "",
"token": "random",
"loginRate": 3
}

View File

@@ -3,12 +3,9 @@ import { defineConfig, PluginOption, UserConfig } from 'vite';
import { resolve } from 'path';
import nodeResolve from '@rollup/plugin-node-resolve';
import { builtinModules } from 'module';
import wasm from 'vite-plugin-wasm';
//依赖排除
const external = [
'silk-wasm',
'ws',
'express',
'@ffmpeg.wasm/core-mt'
];
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
@@ -42,6 +39,7 @@ const UniversalBaseConfigPlugin: PluginOption[] = [
],
}),
nodeResolve(),
wasm()
];
const FrameworkBaseConfigPlugin: PluginOption[] = [
@@ -61,6 +59,7 @@ const FrameworkBaseConfigPlugin: PluginOption[] = [
],
}),
nodeResolve(),
wasm()
];
const ShellBaseConfigPlugin: PluginOption[] = [
@@ -68,7 +67,6 @@ const ShellBaseConfigPlugin: PluginOption[] = [
targets: [
{ src: './src/native/packet', dest: 'dist/moehoo', flatten: false },
{ src: './src/native/pty', dest: 'dist/pty', flatten: false },
{ src: './napcat.webui/dist/', dest: 'dist/static/', flatten: false },
{ src: './src/core/external/napcat.json', dest: 'dist/config/' },
{ src: './package.json', dest: 'dist' },
{ src: './launcher/', dest: 'dist', flatten: true },
@@ -78,6 +76,7 @@ const ShellBaseConfigPlugin: PluginOption[] = [
],
}),
nodeResolve(),
wasm()
];
const UniversalBaseConfig = () =>
defineConfig({