mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-28 15:50:27 +00:00
Compare commits
253 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5407392f08 | ||
|
|
3359c5ded9 | ||
|
|
caaf8be3b2 | ||
|
|
28ce5d3cb4 | ||
|
|
3dd56c711e | ||
|
|
511fb82ce0 | ||
|
|
1e5524a009 | ||
|
|
d5e6afc7b9 | ||
|
|
91e633b0fb | ||
|
|
397b9880b9 | ||
|
|
54eb26ba67 | ||
|
|
355f7fb4a0 | ||
|
|
325c455e38 | ||
|
|
a7a9792efe | ||
|
|
4bab93e545 | ||
|
|
3c60997b1c | ||
|
|
bf684d9166 | ||
|
|
5c4ee30f37 | ||
|
|
346374b442 | ||
|
|
cc8a387bde | ||
|
|
962685ade6 | ||
|
|
36fdaac406 | ||
|
|
db16db911b | ||
|
|
bb84dfcc27 | ||
|
|
3e71e541e6 | ||
|
|
bcc856d583 | ||
|
|
1eaf480a7d | ||
|
|
86123af7fc | ||
|
|
c3cd2aaf89 | ||
|
|
599afdc7ba | ||
|
|
ffe54af8d9 | ||
|
|
a7b30ef844 | ||
|
|
50b8cb14dc | ||
|
|
0b18f868bc | ||
|
|
c9acb9ae28 | ||
|
|
2e1506a05d | ||
|
|
b35283f970 | ||
|
|
54ac072bfb | ||
|
|
58cefb9cdc | ||
|
|
be4344634d | ||
|
|
2da5d242f7 | ||
|
|
fbd00b2576 | ||
|
|
72548c9575 | ||
|
|
003f3e946d | ||
|
|
dc51d01351 | ||
|
|
c5db525f4a | ||
|
|
c1377e6de7 | ||
|
|
d2c4f425c7 | ||
|
|
803b1a6c77 | ||
|
|
9a35ee9cd1 | ||
|
|
458d22223c | ||
|
|
4ed61136b2 | ||
|
|
1445a29e15 | ||
|
|
55ef040852 | ||
|
|
0c88319248 | ||
|
|
6778bd69de | ||
|
|
f9c9b3a852 | ||
|
|
9ce9e46c57 | ||
|
|
656bde25c8 | ||
|
|
791a360f28 | ||
|
|
376245b749 | ||
|
|
d3e3527c2b | ||
|
|
5b78dfbd5a | ||
|
|
1e461aae3c | ||
|
|
42abc2b9cb | ||
|
|
4f8c320658 | ||
|
|
cbacc89907 | ||
|
|
8e6da5e2d0 | ||
|
|
02980c4d1a | ||
|
|
0129188739 | ||
|
|
98ef642cd1 | ||
|
|
32e886e53b | ||
|
|
315d847f06 | ||
|
|
381d320967 | ||
|
|
2f5b62decb | ||
|
|
2afdb2a0da | ||
|
|
5bfbf92c21 | ||
|
|
a775a0dde9 | ||
|
|
d7f00c0594 | ||
|
|
77c8f874b6 | ||
|
|
fb0a20919b | ||
|
|
0300ba4648 | ||
|
|
d472eee777 | ||
|
|
41bd06e50a | ||
|
|
97334dfbf5 | ||
|
|
e3d8c8e940 | ||
|
|
f2c62db76e | ||
|
|
b1b051c4ce | ||
|
|
a754b2ecc7 | ||
|
|
e0eb625b75 | ||
|
|
937be7678e | ||
|
|
9b88946209 | ||
|
|
74de3d9100 | ||
|
|
42d50014a1 | ||
|
|
a36ae315b0 | ||
|
|
2161ec5fa7 | ||
|
|
32bba007cd | ||
|
|
84d3dc9f40 | ||
|
|
890d032794 | ||
|
|
66f30e1ebf | ||
|
|
ada614d007 | ||
|
|
ea3ab7f13f | ||
|
|
a5e4c24de3 | ||
|
|
bcc7d25b64 | ||
|
|
aae676fdc7 | ||
|
|
0e9aa43476 | ||
|
|
b2ff556aa6 | ||
|
|
69c5b78678 | ||
|
|
8be7f74e9f | ||
|
|
a05150ebe1 | ||
|
|
5e6b607ded | ||
|
|
df2dabfe76 | ||
|
|
5e032fcc6a | ||
|
|
44200a2208 | ||
|
|
e39bb05f01 | ||
|
|
677731dd70 | ||
|
|
fa8e6f2c59 | ||
|
|
509b23ff04 | ||
|
|
cf1765f5a4 | ||
|
|
c541c7e257 | ||
|
|
298b8b71c8 | ||
|
|
5c120a8231 | ||
|
|
88ee8f89fe | ||
|
|
12b8130372 | ||
|
|
58332dad24 | ||
|
|
e97f3e1283 | ||
|
|
e406dca7ae | ||
|
|
e4c1807f76 | ||
|
|
f4412bb086 | ||
|
|
27af8e52ac | ||
|
|
4c9a220300 | ||
|
|
1fe822cd20 | ||
|
|
0ab8d025bf | ||
|
|
a0f3d66607 | ||
|
|
06e7c3363a | ||
|
|
4d200de6b7 | ||
|
|
6200097f7c | ||
|
|
c7af0384fb | ||
|
|
dc87615bd6 | ||
|
|
ff2cfcee97 | ||
|
|
f3c07ed8fc | ||
|
|
7ab44dcb34 | ||
|
|
aa6699d06e | ||
|
|
3cb51a17a6 | ||
|
|
994e8ced3e | ||
|
|
75d26465f1 | ||
|
|
f5052935bd | ||
|
|
84b89de2a6 | ||
|
|
c4f9c4f630 | ||
|
|
c213cd6c3a | ||
|
|
9d22d6e3a0 | ||
|
|
a38419e3cb | ||
|
|
a64779684e | ||
|
|
ecd7012eee | ||
|
|
74a1011fcc | ||
|
|
d4b0a4acca | ||
|
|
ac6e593315 | ||
|
|
b1e77b1658 | ||
|
|
722c3554e9 | ||
|
|
1d08966571 | ||
|
|
fb50ae7544 | ||
|
|
ea695fc9e9 | ||
|
|
5c6d1e6a14 | ||
|
|
b030c40853 | ||
|
|
d6782c35e2 | ||
|
|
120e6db119 | ||
|
|
fa10f8ce19 | ||
|
|
31494b4687 | ||
|
|
857ed0f343 | ||
|
|
8133ff08a7 | ||
|
|
2d315c4d8e | ||
|
|
505f7b6ac9 | ||
|
|
2735eb14bd | ||
|
|
7afbc95eda | ||
|
|
91bb83d8c1 | ||
|
|
55550790e4 | ||
|
|
40221926a9 | ||
|
|
d069374a95 | ||
|
|
1183fe2057 | ||
|
|
f4605d4f74 | ||
|
|
3ce3fb685b | ||
|
|
07c2f7371f | ||
|
|
114aae98a9 | ||
|
|
0bd6548f45 | ||
|
|
06c5b7807b | ||
|
|
e8ef08cae2 | ||
|
|
0d251a9343 | ||
|
|
69a19b0e32 | ||
|
|
1b0b5f3494 | ||
|
|
5abdc8c538 | ||
|
|
9f0ba6d385 | ||
|
|
7863a0f45f | ||
|
|
ef4c2a935c | ||
|
|
40d2e948e4 | ||
|
|
3de4e905d3 | ||
|
|
655f4e199c | ||
|
|
13c3d3a2fb | ||
|
|
db2dca45f6 | ||
|
|
7330a05c78 | ||
|
|
a39c932868 | ||
|
|
a2c24c9197 | ||
|
|
5c3efc681f | ||
|
|
e70d2bd708 | ||
|
|
cf75a961fb | ||
|
|
159f317071 | ||
|
|
713eef592a | ||
|
|
cf03ad8fd9 | ||
|
|
0c0b27901a | ||
|
|
137fe3c8f2 | ||
|
|
d96174076a | ||
|
|
6d5662d96e | ||
|
|
57abd47d99 | ||
|
|
5092b3d791 | ||
|
|
649409d1be | ||
|
|
8f549d896a | ||
|
|
a1359ddbb5 | ||
|
|
304a0dda3e | ||
|
|
fff9c4a4d8 | ||
|
|
2c76102fc4 | ||
|
|
f576cd9417 | ||
|
|
9cfd224b74 | ||
|
|
c12f8de8b4 | ||
|
|
ed9a7c52e2 | ||
|
|
38fcaaa28b | ||
|
|
5317a1c1a9 | ||
|
|
4bc5933ea2 | ||
|
|
6a6bd33fe5 | ||
|
|
8256942a3d | ||
|
|
697632eee8 | ||
|
|
6bbf5b254d | ||
|
|
5831898c4a | ||
|
|
2cc413bec1 | ||
|
|
0af36e89d9 | ||
|
|
b2c0f5d2e5 | ||
|
|
80b74c7da9 | ||
|
|
f14f13b158 | ||
|
|
9dda00b6fa | ||
|
|
a29debb738 | ||
|
|
b990fc43df | ||
|
|
915e9552ee | ||
|
|
c522e0a386 | ||
|
|
c9cc08a9ba | ||
|
|
66e1b1662f | ||
|
|
9372e83bd8 | ||
|
|
b38a240dbb | ||
|
|
76b9506395 | ||
|
|
f1cf636aa2 | ||
|
|
312dcd0e13 | ||
|
|
42c2419613 | ||
|
|
8f7f748e82 | ||
|
|
7ad3bad1be | ||
|
|
5cd682e69f | ||
|
|
5d57780e84 |
2
.env.shell-analysis
Normal file
2
.env.shell-analysis
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_BUILD_TYPE = DEBUG
|
||||
VITE_BUILD_PLATFORM = Shell
|
||||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -150,3 +150,15 @@ jobs:
|
||||
NapCat.Shell.zip
|
||||
NapCat.Framework.Windows.Once.zip
|
||||
draft: true
|
||||
|
||||
build-docker:
|
||||
needs: release-napcat
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch Docker Build
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.NAPCAT_BUILD }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
https://api.github.com/repos/NapNeko/NapCat-Docker/actions/workflows/docker-publish.yml/dispatches \
|
||||
-d '{"ref": "main"}'
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,5 @@
|
||||
# Develop
|
||||
node_modules/
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
out/
|
||||
dist/
|
||||
|
||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -9,4 +9,9 @@
|
||||
"css.customData": [
|
||||
".vscode/tailwindcss.json"
|
||||
],
|
||||
}
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "never"
|
||||
},
|
||||
}
|
||||
|
||||
23
README.md
23
README.md
@@ -13,6 +13,15 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
|
||||
---
|
||||
|
||||
## New Feature
|
||||
在 v4.8.115+ 版本开始
|
||||
|
||||
1. NapCatQQ 支持 [Stream Api](https://napneko.github.io/develop/file)
|
||||
2. NapCatQQ 推荐 message_id/user_id/group_id 均使用字符串类型
|
||||
|
||||
- [1] 解决 Docker/跨设备/大文件 的多媒体上下传问题
|
||||
- [2] 采用字符串可以解决扩展到int64的问题,同时也可以解决部分语言(如JavaScript)对大整数支持不佳的问题,增加极少成本。
|
||||
|
||||
## Welcome
|
||||
+ NapCatQQ is a modern implementation of the Bot protocol based on NTQQ.
|
||||
- NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
|
||||
@@ -33,6 +42,7 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
|
||||
**首次使用**请务必查看如下文档看使用教程
|
||||
|
||||
> 项目非盈利,对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
|
||||
## Link
|
||||
|
||||
| Docs | [](https://napneko.github.io/) | [](https://doc.napneko.icu/) | [](https://napcat.napneko.icu/) |
|
||||
@@ -41,12 +51,17 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
| Docs | [](https://napneko.pages.dev/) | [](https://napcat.cyou/) | [](https://www.napcat.wiki) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| QQ Group | [](https://qm.qq.com/q/CMmPbGw0jA) | [](https://qm.qq.com/q/8zJMLjqy2Y) | [](https://qm.qq.com/q/HaRcfrHpUk) | [](https://qm.qq.com/q/I6LU87a0Yq) |
|
||||
| QQ Group | [](https://qm.qq.com/q/CMmPbGw0jA) | [](https://qm.qq.com/q/8zJMLjqy2Y) | [](https://qm.qq.com/q/CMmPbGw0jA) | [](https://qm.qq.com/q/I6LU87a0Yq) |
|
||||
|:-:|:-:|:-:|:-:|:-:|
|
||||
|
||||
| Telegram | [](https://t.me/MelodicMoonlight) |
|
||||
| Telegram | [](https://t.me/napcatqq) |
|
||||
|:-:|:-:|
|
||||
|
||||
| DeepWiki | [](https://deepwiki.com/NapNeko/NapCatQQ) |
|
||||
|:-:|:-:|
|
||||
|
||||
> 请不要在其余社区提及本项目(包括其余协议端/相关应用端项目)引发争论,如有建议到达官方交流群讨论或PR。
|
||||
|
||||
## Thanks
|
||||
|
||||
+ [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
|
||||
@@ -54,7 +69,9 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
+ [AstrBot](https://github.com/AstrBotDevs/AstrBot) 是完美适配本项目的LLM Bot框架 在此推荐一下
|
||||
|
||||
+ [MaiBot](https://github.com/MaiM-with-u/MaiBot) 一只赛博群友 麦麦 Bot框架 在此推荐一下
|
||||
|
||||
|
||||
+ [qq-chat-exporter](https://github.com/shuakami/qq-chat-exporter/) 基于NapCat的消息导出工具 在此推荐一下
|
||||
|
||||
+ 不过最最重要的 还是需要感谢屏幕前的你哦~
|
||||
|
||||
---
|
||||
|
||||
BIN
external/LiteLoaderWrapper.zip
vendored
BIN
external/LiteLoaderWrapper.zip
vendored
Binary file not shown.
Binary file not shown.
@@ -7,7 +7,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
|
||||
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
|
||||
:loop_read
|
||||
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
|
||||
set RetString=%%b
|
||||
set "RetString=%%~b"
|
||||
goto :napcat_boot
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ for %%a in ("%RetString%") do (
|
||||
set "pathWithoutUninstall=%%~dpa"
|
||||
)
|
||||
|
||||
SET QQPath=%pathWithoutUninstall%QQ.exe
|
||||
set "QQPath=%pathWithoutUninstall%QQ.exe"
|
||||
|
||||
if not exist "%QQpath%" (
|
||||
echo provided QQ path is invalid
|
||||
|
||||
@@ -7,7 +7,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
|
||||
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
|
||||
:loop_read
|
||||
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
|
||||
set RetString=%%b
|
||||
set "RetString=%%~b"
|
||||
goto :napcat_boot
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ for %%a in ("%RetString%") do (
|
||||
set "pathWithoutUninstall=%%~dpa"
|
||||
)
|
||||
|
||||
SET QQPath=%pathWithoutUninstall%QQ.exe
|
||||
set "QQPath=%pathWithoutUninstall%QQ.exe"
|
||||
|
||||
if not exist "%QQpath%" (
|
||||
echo provided QQ path is invalid
|
||||
|
||||
@@ -16,7 +16,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
|
||||
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
|
||||
:loop_read
|
||||
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
|
||||
set RetString=%%b
|
||||
set "RetString=%%~b"
|
||||
goto :napcat_boot
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ for %%a in ("%RetString%") do (
|
||||
set "pathWithoutUninstall=%%~dpa"
|
||||
)
|
||||
|
||||
SET QQPath=%pathWithoutUninstall%QQ.exe
|
||||
set "QQPath=%pathWithoutUninstall%QQ.exe"
|
||||
|
||||
if not exist "%QQPath%" (
|
||||
echo provided QQ path is invalid
|
||||
|
||||
@@ -16,7 +16,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
|
||||
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
|
||||
:loop_read
|
||||
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
|
||||
set RetString=%%b
|
||||
set "RetString=%%~b"
|
||||
goto :napcat_boot
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ for %%a in ("%RetString%") do (
|
||||
set "pathWithoutUninstall=%%~dpa"
|
||||
)
|
||||
|
||||
SET QQPath=%pathWithoutUninstall%QQ.exe
|
||||
set "QQPath=%pathWithoutUninstall%QQ.exe"
|
||||
|
||||
if not exist "%QQPath%" (
|
||||
echo provided QQ path is invalid
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "qq-chat",
|
||||
"version": "9.9.19-34740",
|
||||
"verHash": "f31348f2",
|
||||
"linuxVersion": "3.2.17-34740",
|
||||
"linuxVerHash": "5aa2d8d6",
|
||||
"verHash": "2c9d3f6c",
|
||||
"version": "9.9.22-40990",
|
||||
"linuxVersion": "3.2.20-40990",
|
||||
"linuxVerHash": "ec800879",
|
||||
"private": true,
|
||||
"description": "QQ",
|
||||
"productName": "QQ",
|
||||
@@ -17,7 +17,7 @@
|
||||
"qd": "externals/devtools/cli/index.js"
|
||||
},
|
||||
"main": "./loadNapCat.js",
|
||||
"buildVersion": "34740",
|
||||
"buildVersion": "40990",
|
||||
"isPureShell": true,
|
||||
"isByteCodeShell": true,
|
||||
"platform": "win32",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"name": "NapCatQQ",
|
||||
"slug": "NapCat.Framework",
|
||||
"description": "高性能的 OneBot 11 协议实现",
|
||||
"version": "4.7.72",
|
||||
"version": "4.9.19",
|
||||
"icon": "./logo.png",
|
||||
"authors": [
|
||||
{
|
||||
|
||||
15995
napcat.webui/package-lock.json
generated
Normal file
15995
napcat.webui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -62,24 +62,39 @@ const NetworkDisplayCard = <T extends keyof NetworkType>({
|
||||
<ButtonGroup
|
||||
fullWidth
|
||||
isDisabled={editing}
|
||||
radius="full"
|
||||
radius="sm"
|
||||
size="sm"
|
||||
variant="shadow"
|
||||
variant="flat"
|
||||
>
|
||||
<Button color="warning" startContent={<FiEdit3 />} onPress={onEdit}>
|
||||
<Button
|
||||
color="warning"
|
||||
startContent={<FiEdit3 size={16} />}
|
||||
onPress={onEdit}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color={debug ? 'success' : 'default'}
|
||||
startContent={<CgDebug />}
|
||||
color={debug ? 'secondary' : 'success'}
|
||||
variant="flat"
|
||||
startContent={
|
||||
<CgDebug
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
minWidth: '16px',
|
||||
minHeight: '16px'
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onPress={handleEnableDebug}
|
||||
>
|
||||
{debug ? '关闭调试' : '开启调试'}
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
startContent={<MdDeleteForever />}
|
||||
className="bg-danger/20 text-danger hover:bg-danger/30 transition-colors"
|
||||
variant="flat"
|
||||
startContent={<MdDeleteForever size={16} />}
|
||||
onPress={handleDelete}
|
||||
>
|
||||
删除
|
||||
|
||||
@@ -82,6 +82,7 @@ export default function FileTable({
|
||||
setPreviewImages([])
|
||||
setPreviewIndex(0)
|
||||
setShowImage(false)
|
||||
setPage(1)
|
||||
}, [currentPath])
|
||||
|
||||
const onPreviewImage = (name: string, images: PreviewImage[]) => {
|
||||
|
||||
@@ -170,3 +170,12 @@ const GenericForm = <T extends keyof NetworkConfigType>({
|
||||
}
|
||||
|
||||
export default GenericForm
|
||||
export function random_token(length: number) {
|
||||
const chars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*()-_=+[]{}|;:,.<>?'
|
||||
let result = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import GenericForm from './generic_form'
|
||||
import GenericForm, { random_token } from './generic_form'
|
||||
import type { Field } from './generic_form'
|
||||
|
||||
export interface HTTPClientFormProps {
|
||||
@@ -20,7 +20,7 @@ const HTTPClientForm: React.FC<HTTPClientFormProps> = ({
|
||||
url: 'http://localhost:8080',
|
||||
reportSelfMessage: false,
|
||||
messagePostFormat: 'array',
|
||||
token: '',
|
||||
token: random_token(16),
|
||||
debug: false
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import GenericForm from './generic_form'
|
||||
import GenericForm, { random_token } from './generic_form'
|
||||
import type { Field } from './generic_form'
|
||||
|
||||
export interface HTTPServerFormProps {
|
||||
@@ -17,12 +17,12 @@ const HTTPServerForm: React.FC<HTTPServerFormProps> = ({
|
||||
const defaultValues: HTTPServerFormType[0] = {
|
||||
enable: false,
|
||||
name: '',
|
||||
host: '0.0.0.0',
|
||||
host: '127.0.0.1',
|
||||
port: 3000,
|
||||
enableCors: true,
|
||||
enableWebsocket: true,
|
||||
messagePostFormat: 'array',
|
||||
token: '',
|
||||
token: random_token(16),
|
||||
debug: false
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import GenericForm from './generic_form'
|
||||
import GenericForm, { random_token } from './generic_form'
|
||||
import type { Field } from './generic_form'
|
||||
|
||||
export interface HTTPServerSSEFormProps {
|
||||
@@ -19,12 +19,12 @@ const HTTPServerSSEForm: React.FC<HTTPServerSSEFormProps> = ({
|
||||
const defaultValues: HTTPServerSSEFormType[0] = {
|
||||
enable: false,
|
||||
name: '',
|
||||
host: '0.0.0.0',
|
||||
host: '127.0.0.1',
|
||||
port: 3000,
|
||||
enableCors: true,
|
||||
enableWebsocket: true,
|
||||
messagePostFormat: 'array',
|
||||
token: '',
|
||||
token: random_token(16),
|
||||
debug: false,
|
||||
reportSelfMessage: false
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import GenericForm from './generic_form'
|
||||
import GenericForm, { random_token } from './generic_form'
|
||||
import type { Field } from './generic_form'
|
||||
|
||||
export interface WebsocketClientFormProps {
|
||||
@@ -22,7 +22,7 @@ const WebsocketClientForm: React.FC<WebsocketClientFormProps> = ({
|
||||
url: 'ws://localhost:8082',
|
||||
reportSelfMessage: false,
|
||||
messagePostFormat: 'array',
|
||||
token: '',
|
||||
token: random_token(16),
|
||||
debug: false,
|
||||
heartInterval: 30000,
|
||||
reconnectInterval: 30000
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import GenericForm from './generic_form'
|
||||
import GenericForm, { random_token } from './generic_form'
|
||||
import type { Field } from './generic_form'
|
||||
|
||||
export interface WebsocketServerFormProps {
|
||||
@@ -19,12 +19,12 @@ const WebsocketServerForm: React.FC<WebsocketServerFormProps> = ({
|
||||
const defaultValues: WebsocketServerFormType[0] = {
|
||||
enable: false,
|
||||
name: '',
|
||||
host: '0.0.0.0',
|
||||
host: '127.0.0.1',
|
||||
port: 3001,
|
||||
reportSelfMessage: false,
|
||||
enableForcePushEvent: true,
|
||||
messagePostFormat: 'array',
|
||||
token: '',
|
||||
token: random_token(16),
|
||||
debug: false,
|
||||
heartInterval: 30000
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const itemVariants = {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: { type: 'spring', stiffness: 300, damping: 20 }
|
||||
transition: { type: 'spring' as const, stiffness: 300, damping: 20 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,9 +24,7 @@ const oneBotHttpApiGroup = {
|
||||
},
|
||||
'/get_group_system_msg': {
|
||||
description: '获取群系统消息',
|
||||
request: z.object({
|
||||
group_id: z.union([z.string(), z.number()]).describe('群号')
|
||||
}),
|
||||
request: z.object({}),
|
||||
response: baseResponseSchema.extend({
|
||||
data: z.object({
|
||||
InvitedRequest: z
|
||||
@@ -37,6 +35,7 @@ const oneBotHttpApiGroup = {
|
||||
invitor_uin: z.string().describe('邀请人 QQ 号'),
|
||||
invitor_nick: z.string().describe('邀请人昵称'),
|
||||
group_id: z.string().describe('群号'),
|
||||
message: z.string().describe('入群回答'),
|
||||
group_name: z.string().describe('群名称'),
|
||||
checked: z.boolean().describe('是否已处理'),
|
||||
actor: z.string().describe('处理人 QQ 号')
|
||||
@@ -50,6 +49,7 @@ const oneBotHttpApiGroup = {
|
||||
requester_uin: z.string().describe('请求人 QQ 号'),
|
||||
requester_nick: z.string().describe('请求人昵称'),
|
||||
group_id: z.string().describe('群号'),
|
||||
message: z.string().describe('入群回答'),
|
||||
group_name: z.string().describe('群名称'),
|
||||
checked: z.boolean().describe('是否已处理'),
|
||||
actor: z.string().describe('处理人 QQ 号')
|
||||
@@ -604,7 +604,7 @@ const oneBotHttpApiGroup = {
|
||||
response: baseResponseSchema.extend({
|
||||
data: z
|
||||
.object({
|
||||
group_id: z.string().describe('群号'),
|
||||
group_id: z.number().describe('群号'),
|
||||
current_talkative: z
|
||||
.object({
|
||||
user_id: z.number().describe('QQ 号'),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill'
|
||||
|
||||
import { LogLevel } from '@/const/enum'
|
||||
|
||||
import { serverRequest } from '@/utils/request'
|
||||
import CryptoJS from "crypto-js";
|
||||
|
||||
export interface Log {
|
||||
level: LogLevel
|
||||
message: string
|
||||
@@ -17,7 +18,7 @@ export default class WebUIManager {
|
||||
}
|
||||
|
||||
public static async loginWithToken(token: string) {
|
||||
const sha256 = CryptoJS.SHA256(token + '.napcat').toString();
|
||||
const sha256 = CryptoJS.SHA256(token + '.napcat').toString()
|
||||
const { data } = await serverRequest.post<ServerResponse<AuthResponse>>(
|
||||
'/auth/login',
|
||||
{ hash: sha256 }
|
||||
@@ -33,13 +34,6 @@ export default class WebUIManager {
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async checkUsingDefaultToken() {
|
||||
const { data } = await serverRequest.get<ServerResponse<boolean>>(
|
||||
'/auth/check_using_default_token'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async proxy<T>(url = '') {
|
||||
const data = await serverRequest.get<ServerResponse<string>>(
|
||||
'/base/proxy?url=' + encodeURIComponent(url)
|
||||
@@ -152,4 +146,55 @@ export default class WebUIManager {
|
||||
|
||||
return eventSource
|
||||
}
|
||||
|
||||
// 获取WebUI基础配置
|
||||
public static async getWebUIConfig() {
|
||||
const { data } = await serverRequest.get<ServerResponse<WebUIConfig>>(
|
||||
'/WebUIConfig/GetConfig'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
// 更新WebUI基础配置
|
||||
public static async updateWebUIConfig(config: Partial<WebUIConfig>) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/WebUIConfig/UpdateConfig',
|
||||
config
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
// 获取是否禁用WebUI
|
||||
public static async getDisableWebUI() {
|
||||
const { data } = await serverRequest.get<ServerResponse<boolean>>(
|
||||
'/WebUIConfig/GetDisableWebUI'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
// 更新是否禁用WebUI
|
||||
public static async updateDisableWebUI(disable: boolean) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/WebUIConfig/UpdateDisableWebUI',
|
||||
{ disable }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
// 获取是否禁用非局域网访问
|
||||
public static async getDisableNonLANAccess() {
|
||||
const { data } = await serverRequest.get<ServerResponse<boolean>>(
|
||||
'/WebUIConfig/GetDisableNonLANAccess'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
// 更新是否禁用非局域网访问
|
||||
public static async updateDisableNonLANAccess(disable: boolean) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/WebUIConfig/UpdateDisableNonLANAccess',
|
||||
{ disable }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ export default function AboutPage() {
|
||||
shadow="sm"
|
||||
isPressable
|
||||
isExternal
|
||||
href="https://t.me/MelodicMoonlight"
|
||||
href="https://t.me/napcatqq"
|
||||
>
|
||||
<CardBody className="flex-row items-center gap-2">
|
||||
<span className="p-2 rounded-small bg-primary-50 text-primary-500">
|
||||
|
||||
@@ -14,8 +14,9 @@ const ChangePasswordCard = () => {
|
||||
const {
|
||||
control,
|
||||
handleSubmit: handleWebuiSubmit,
|
||||
formState: { isSubmitting },
|
||||
reset
|
||||
formState: { isSubmitting, errors },
|
||||
reset,
|
||||
watch
|
||||
} = useForm<{
|
||||
oldToken: string
|
||||
newToken: string
|
||||
@@ -29,9 +30,14 @@ const ChangePasswordCard = () => {
|
||||
const navigate = useNavigate()
|
||||
const [_, setToken] = useLocalStorage(key.token, '')
|
||||
|
||||
// 监听旧密码的值
|
||||
const oldTokenValue = watch('oldToken')
|
||||
|
||||
const onSubmit = handleWebuiSubmit(async (data) => {
|
||||
try {
|
||||
// 使用正常密码更新流程
|
||||
await WebUIManager.changePassword(data.oldToken, data.newToken)
|
||||
|
||||
toast.success('修改成功')
|
||||
setToken('')
|
||||
localStorage.removeItem(key.token)
|
||||
@@ -45,30 +51,75 @@ const ChangePasswordCard = () => {
|
||||
return (
|
||||
<>
|
||||
<title>修改密码 - NapCat WebUI</title>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="oldToken"
|
||||
rules={{
|
||||
required: '旧密码不能为空',
|
||||
validate: (value) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return '旧密码不能为空'
|
||||
}
|
||||
return true
|
||||
}
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="旧密码"
|
||||
placeholder="请输入旧密码"
|
||||
type="password"
|
||||
isRequired
|
||||
isInvalid={!!errors.oldToken}
|
||||
errorMessage={errors.oldToken?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="newToken"
|
||||
rules={{
|
||||
required: '新密码不能为空',
|
||||
minLength: {
|
||||
value: 6,
|
||||
message: '新密码至少需要6个字符'
|
||||
},
|
||||
validate: (value) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return '新密码不能为空'
|
||||
}
|
||||
if (value.trim().length !== value.length) {
|
||||
return '新密码不能包含前后空格'
|
||||
}
|
||||
if (value === oldTokenValue) {
|
||||
return '新密码不能与旧密码相同'
|
||||
}
|
||||
// 检查是否包含字母
|
||||
if (!/[a-zA-Z]/.test(value)) {
|
||||
return '新密码必须包含字母'
|
||||
}
|
||||
// 检查是否包含数字
|
||||
if (!/[0-9]/.test(value)) {
|
||||
return '新密码必须包含数字'
|
||||
}
|
||||
return true
|
||||
}
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="新密码"
|
||||
placeholder="请输入新密码"
|
||||
placeholder="至少6位,包含字母和数字"
|
||||
type="password"
|
||||
isRequired
|
||||
isInvalid={!!errors.newToken}
|
||||
errorMessage={errors.newToken?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import ChangePasswordCard from './change_password'
|
||||
import LoginConfigCard from './login'
|
||||
import OneBotConfigCard from './onebot'
|
||||
import ServerConfigCard from './server'
|
||||
import ThemeConfigCard from './theme'
|
||||
import WebUIConfigCard from './webui'
|
||||
|
||||
@@ -67,6 +68,11 @@ export default function ConfigPage() {
|
||||
<OneBotConfigCard />
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
<Tab title="服务器配置" key="server">
|
||||
<ConfingPageItem>
|
||||
<ServerConfigCard />
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
<Tab title="WebUI配置" key="webui">
|
||||
<ConfingPageItem>
|
||||
<WebUIConfigCard />
|
||||
|
||||
185
napcat.webui/src/pages/dashboard/config/server.tsx
Normal file
185
napcat.webui/src/pages/dashboard/config/server.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { Input } from '@heroui/input'
|
||||
import { Switch } from '@heroui/switch'
|
||||
import { useRequest } from 'ahooks'
|
||||
import { useEffect } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons'
|
||||
import PageLoading from '@/components/page_loading'
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager'
|
||||
|
||||
const ServerConfigCard = () => {
|
||||
const {
|
||||
data: configData,
|
||||
loading: configLoading,
|
||||
error: configError,
|
||||
refreshAsync: refreshConfig
|
||||
} = useRequest(WebUIManager.getWebUIConfig)
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit: handleConfigSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue: setConfigValue
|
||||
} = useForm<{
|
||||
host: string
|
||||
port: number
|
||||
loginRate: number
|
||||
disableWebUI: boolean
|
||||
disableNonLANAccess: boolean
|
||||
}>({
|
||||
defaultValues: {
|
||||
host: '0.0.0.0',
|
||||
port: 6099,
|
||||
loginRate: 10,
|
||||
disableWebUI: false,
|
||||
disableNonLANAccess: false
|
||||
}
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
if (configData) {
|
||||
setConfigValue('host', configData.host)
|
||||
setConfigValue('port', configData.port)
|
||||
setConfigValue('loginRate', configData.loginRate)
|
||||
setConfigValue('disableWebUI', configData.disableWebUI)
|
||||
setConfigValue('disableNonLANAccess', configData.disableNonLANAccess)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = handleConfigSubmit(async (data) => {
|
||||
try {
|
||||
await WebUIManager.updateWebUIConfig(data)
|
||||
toast.success('保存成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`保存失败: ${msg}`)
|
||||
}
|
||||
})
|
||||
|
||||
const onRefresh = async () => {
|
||||
try {
|
||||
await refreshConfig()
|
||||
toast.success('刷新成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`刷新失败: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reset()
|
||||
}, [configData])
|
||||
|
||||
if (configLoading) return <PageLoading loading={true} />
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>服务器配置 - NapCat WebUI</title>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex-shrink-0 w-full">服务器配置</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="监听地址"
|
||||
placeholder="请输入监听地址"
|
||||
description="服务器监听的IP地址,0.0.0.0表示监听所有网卡"
|
||||
isDisabled={!!configError}
|
||||
errorMessage={configError ? '获取配置失败' : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
value={field.value?.toString() || ''}
|
||||
label="监听端口"
|
||||
placeholder="请输入监听端口"
|
||||
description="服务器监听的端口号,范围1-65535"
|
||||
isDisabled={!!configError}
|
||||
errorMessage={configError ? '获取配置失败' : undefined}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="loginRate"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
value={field.value?.toString() || ''}
|
||||
label="登录速率限制"
|
||||
placeholder="请输入登录速率限制"
|
||||
description="每小时允许的登录尝试次数"
|
||||
isDisabled={!!configError}
|
||||
errorMessage={configError ? '获取配置失败' : undefined}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex-shrink-0 w-full">安全配置</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="disableWebUI"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
isSelected={field.value}
|
||||
onValueChange={field.onChange}
|
||||
isDisabled={!!configError}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span>禁用WebUI</span>
|
||||
<span className="text-sm text-default-400">
|
||||
启用后将完全禁用WebUI服务,需要重启生效
|
||||
</span>
|
||||
</div>
|
||||
</Switch>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="disableNonLANAccess"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
isSelected={field.value}
|
||||
onValueChange={field.onChange}
|
||||
isDisabled={!!configError}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span>禁用非局域网访问</span>
|
||||
<span className="text-sm text-default-400">
|
||||
启用后只允许局域网内的设备访问WebUI,提高安全性
|
||||
</span>
|
||||
</div>
|
||||
</Switch>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting || configLoading}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ServerConfigCard
|
||||
@@ -1,46 +1,15 @@
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { Suspense, useEffect } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Suspense } from 'react'
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
|
||||
import useAuth from '@/hooks/auth'
|
||||
import useDialog from '@/hooks/use-dialog'
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager'
|
||||
import DefaultLayout from '@/layouts/default'
|
||||
|
||||
const CheckDefaultPassword = () => {
|
||||
const { isAuth } = useAuth()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const checkDefaultPassword = async () => {
|
||||
const data = await WebUIManager.checkUsingDefaultToken()
|
||||
if (data) {
|
||||
dialog.confirm({
|
||||
title: '修改默认密码',
|
||||
content: '检测到当前密码为默认密码,请尽快修改密码。',
|
||||
confirmText: '前往修改',
|
||||
onConfirm: () => {
|
||||
navigate('/config?tab=token')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuth) {
|
||||
checkDefaultPassword()
|
||||
}
|
||||
}, [isAuth])
|
||||
return null
|
||||
}
|
||||
|
||||
export default function IndexPage() {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<CheckDefaultPassword />
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex justify-center px-10">
|
||||
|
||||
@@ -25,7 +25,6 @@ export default function WebLoginPage() {
|
||||
const [tokenValue, setTokenValue] = useState<string>(token || '')
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [, setLocalToken] = useLocalStorage<string>(key.token, '')
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!tokenValue) {
|
||||
toast.error('请输入token')
|
||||
@@ -93,42 +92,65 @@ export default function WebLoginPage() {
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="flex gap-5 py-5 px-5 md:px-10">
|
||||
<Input
|
||||
isClearable
|
||||
type="password"
|
||||
classNames={{
|
||||
label: 'text-black/50 dark:text-white/90',
|
||||
input: [
|
||||
'bg-transparent',
|
||||
'text-black/90 dark:text-white/90',
|
||||
'placeholder:text-default-700/50 dark:placeholder:text-white/60'
|
||||
],
|
||||
innerWrapper: 'bg-transparent',
|
||||
inputWrapper: [
|
||||
'shadow-xl',
|
||||
'bg-default-100/70',
|
||||
'dark:bg-default/60',
|
||||
'backdrop-blur-xl',
|
||||
'backdrop-saturate-200',
|
||||
'hover:bg-default-0/70',
|
||||
'dark:hover:bg-default/70',
|
||||
'group-data-[focus=true]:bg-default-100/50',
|
||||
'dark:group-data-[focus=true]:bg-default/60',
|
||||
'!cursor-text'
|
||||
]
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}}
|
||||
isDisabled={isLoading}
|
||||
label="Token"
|
||||
placeholder="请输入token"
|
||||
radius="lg"
|
||||
size="lg"
|
||||
startContent={
|
||||
<IoKeyOutline className="text-black/50 mb-0.5 dark:text-white/90 text-slate-400 pointer-events-none flex-shrink-0" />
|
||||
}
|
||||
value={tokenValue}
|
||||
onChange={(e) => setTokenValue(e.target.value)}
|
||||
onClear={() => setTokenValue('')}
|
||||
/>
|
||||
>
|
||||
{/* 隐藏的用户名字段,帮助浏览器识别登录表单 */}
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value="napcat-webui"
|
||||
autoComplete="username"
|
||||
className="absolute -left-[9999px] opacity-0 pointer-events-none"
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-label="Username"
|
||||
/>
|
||||
<Input
|
||||
isClearable
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
classNames={{
|
||||
label: 'text-black/50 dark:text-white/90',
|
||||
input: [
|
||||
'bg-transparent',
|
||||
'text-black/90 dark:text-white/90',
|
||||
'placeholder:text-default-700/50 dark:placeholder:text-white/60'
|
||||
],
|
||||
innerWrapper: 'bg-transparent',
|
||||
inputWrapper: [
|
||||
'shadow-xl',
|
||||
'bg-default-100/70',
|
||||
'dark:bg-default/60',
|
||||
'backdrop-blur-xl',
|
||||
'backdrop-saturate-200',
|
||||
'hover:bg-default-0/70',
|
||||
'dark:hover:bg-default/70',
|
||||
'group-data-[focus=true]:bg-default-100/50',
|
||||
'dark:group-data-[focus=true]:bg-default/60',
|
||||
'!cursor-text'
|
||||
]
|
||||
}}
|
||||
isDisabled={isLoading}
|
||||
label="Token"
|
||||
placeholder="请输入token"
|
||||
radius="lg"
|
||||
size="lg"
|
||||
startContent={
|
||||
<IoKeyOutline className="text-black/50 mb-0.5 dark:text-white/90 text-slate-400 pointer-events-none flex-shrink-0" />
|
||||
}
|
||||
value={tokenValue}
|
||||
onChange={(e) => setTokenValue(e.target.value)}
|
||||
onClear={() => setTokenValue('')}
|
||||
/>
|
||||
</form>
|
||||
<div className="text-center text-small text-default-600 dark:text-default-400 px-2">
|
||||
💡 提示:请从 NapCat 启动日志中查看登录密钥
|
||||
</div>
|
||||
<Button
|
||||
className="mx-10 mt-10 text-lg py-7"
|
||||
color="primary"
|
||||
|
||||
8
napcat.webui/src/types/server.d.ts
vendored
8
napcat.webui/src/types/server.d.ts
vendored
@@ -181,3 +181,11 @@ interface ThemeConfig {
|
||||
dark: ThemeConfigItem
|
||||
light: ThemeConfigItem
|
||||
}
|
||||
|
||||
interface WebUIConfig {
|
||||
host: string
|
||||
port: number
|
||||
loginRate: number
|
||||
disableWebUI: boolean
|
||||
disableNonLANAccess: boolean
|
||||
}
|
||||
|
||||
8502
package-lock.json
generated
Normal file
8502
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
40
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "napcat",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "4.7.72",
|
||||
"version": "4.9.19",
|
||||
"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",
|
||||
@@ -11,22 +11,28 @@
|
||||
"dev:universal": "vite build --mode universal",
|
||||
"dev:framework": "vite build --mode framework",
|
||||
"dev:shell": "vite build --mode shell",
|
||||
"dev:shell-analysis": "vite build --mode shell-analysis",
|
||||
"dev:webui": "cd napcat.webui && npm run dev",
|
||||
"lint": "eslint --fix src/**/*.{js,ts,vue}",
|
||||
"depend": "cd dist && npm install --omit=dev",
|
||||
"dev:depend": "npm i && cd napcat.webui && npm i"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/generator": "^7.28.0",
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/preset-typescript": "^7.24.7",
|
||||
"@eslint/compat": "^1.2.2",
|
||||
"@babel/traverse": "^7.28.0",
|
||||
"@babel/types": "^7.28.2",
|
||||
"@eslint/compat": "^1.3.1",
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@eslint/js": "^9.14.0",
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5",
|
||||
"@log4js-node/log4js-api": "^1.0.2",
|
||||
"@napneko/nap-proto-core": "^0.0.4",
|
||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@sinclair/typebox": "^0.34.9",
|
||||
"@rollup/plugin-typescript": "^12.1.4",
|
||||
"@sinclair/typebox": "^0.34.38",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
@@ -37,33 +43,33 @@
|
||||
"@types/type-is": "^1.6.7",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
||||
"@typescript-eslint/parser": "^8.3.0",
|
||||
"@typescript-eslint/parser": "^8.39.0",
|
||||
"ajv": "^8.13.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"commander": "^13.0.0",
|
||||
"compressing": "^1.10.1",
|
||||
"cors": "^2.8.5",
|
||||
"esbuild": "0.25.4",
|
||||
"esbuild": "0.25.8",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-import-resolver-typescript": "^4.0.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"fast-xml-parser": "^4.3.6",
|
||||
"file-type": "^20.0.0",
|
||||
"file-type": "^21.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"json5": "^2.2.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"multer": "^2.0.1",
|
||||
"napcat.protobuf": "^1.1.4",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.13.0",
|
||||
"vite": "^6.0.1",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vite": "^7.1.1",
|
||||
"vite-plugin-cp": "^6.0.0",
|
||||
"vite-tsconfig-paths": "^5.1.0",
|
||||
"napcat.protobuf": "^1.1.4",
|
||||
"winston": "^3.17.0",
|
||||
"compressing": "^1.10.1"
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.0.0",
|
||||
"silk-wasm": "^3.6.1",
|
||||
"ws": "^8.18.0"
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ async function handleWavFile(
|
||||
): Promise<{ input: Buffer; sampleRate: number }> {
|
||||
const { fmt } = getWavFileInfo(file);
|
||||
if (!ALLOW_SAMPLE_RATE.includes(fmt.sampleRate)) {
|
||||
return { input: await FFmpegService.convert(filePath, pcmPath), sampleRate: 24000 };
|
||||
const result = await FFmpegService.convert(filePath, pcmPath);
|
||||
return { input: await fsPromise.readFile(pcmPath), sampleRate: result.sampleRate };
|
||||
}
|
||||
return { input: file, sampleRate: fmt.sampleRate };
|
||||
}
|
||||
@@ -42,9 +43,18 @@ export async function encodeSilk(filePath: string, TEMP_DIR: string, logger: Log
|
||||
if (!isSilk(file)) {
|
||||
logger.log(`语音文件${filePath}需要转换成silk`);
|
||||
const pcmPath = `${pttPath}.pcm`;
|
||||
const { input, sampleRate } = isWav(file)
|
||||
? await handleWavFile(file, filePath, pcmPath)
|
||||
: { input: await FFmpegService.convert(filePath, pcmPath), sampleRate: 24000 };
|
||||
// const { input, sampleRate } = isWav(file) ? await handleWavFile(file, filePath, pcmPath): { input: await FFmpegService.convert(filePath, pcmPath) ? await fsPromise.readFile(pcmPath) : Buffer.alloc(0), sampleRate: 24000 };
|
||||
let input: Buffer;
|
||||
let sampleRate: number;
|
||||
if (isWav(file)) {
|
||||
const result = await handleWavFile(file, filePath, pcmPath);
|
||||
input = result.input;
|
||||
sampleRate = result.sampleRate;
|
||||
} else {
|
||||
const result = await FFmpegService.convert(filePath, pcmPath);
|
||||
input = await fsPromise.readFile(pcmPath);
|
||||
sampleRate = result.sampleRate;
|
||||
}
|
||||
const silk = await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { input: input, sampleRate: sampleRate });
|
||||
fsPromise.unlink(pcmPath).catch((e) => logger.logError('删除临时文件失败', pcmPath, e));
|
||||
await fsPromise.writeFile(pttPath, Buffer.from(silk.data));
|
||||
@@ -69,7 +79,7 @@ export async function encodeSilk(filePath: string, TEMP_DIR: string, logger: Log
|
||||
};
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.logError('convert silk failed', (error as Error).stack);
|
||||
logger.logError('convert silk failed', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,14 @@ import { LogWrapper } from './log';
|
||||
|
||||
const downloadOri = "https://github.com/NapNeko/ffmpeg-build/releases/download/v1.0.0/ffmpeg-7.1.1-win64.zip"
|
||||
const urls = [
|
||||
"https://github.moeyy.xyz/" + downloadOri,
|
||||
"https://ghp.ci/" + downloadOri,
|
||||
"https://gh.api.99988866.xyz/" + downloadOri,
|
||||
"https://gh.api.99988866.xyz/" + downloadOri,
|
||||
"https://j.1win.ggff.net/" + downloadOri,
|
||||
"https://git.yylx.win/" + downloadOri,
|
||||
"https://ghfile.geekertao.top/" + downloadOri,
|
||||
"https://gh-proxy.net/" + downloadOri,
|
||||
"https://ghm.078465.xyz/" + downloadOri,
|
||||
"https://gitproxy.127731.xyz/" + downloadOri,
|
||||
"https://jiashu.1win.eu.org/" + downloadOri,
|
||||
"https://github.tbedu.top/" + downloadOri,
|
||||
downloadOri
|
||||
];
|
||||
|
||||
|
||||
131
src/common/ffmpeg-adapter-factory.ts
Normal file
131
src/common/ffmpeg-adapter-factory.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* FFmpeg Adapter Factory
|
||||
* 自动检测并选择最佳的 FFmpeg 适配器
|
||||
*/
|
||||
|
||||
import { LogWrapper } from './log';
|
||||
import { FFmpegAddonAdapter } from './ffmpeg-addon-adapter';
|
||||
import { FFmpegExecAdapter } from './ffmpeg-exec-adapter';
|
||||
import type { IFFmpegAdapter } from './ffmpeg-adapter-interface';
|
||||
|
||||
/**
|
||||
* FFmpeg 适配器工厂
|
||||
*/
|
||||
export class FFmpegAdapterFactory {
|
||||
private static instance: IFFmpegAdapter | null = null;
|
||||
private static initPromise: Promise<IFFmpegAdapter> | null = null;
|
||||
|
||||
/**
|
||||
* 初始化并获取最佳的 FFmpeg 适配器
|
||||
* @param logger 日志记录器
|
||||
* @param ffmpegPath FFmpeg 可执行文件路径(用于 Exec 适配器)
|
||||
* @param ffprobePath FFprobe 可执行文件路径(用于 Exec 适配器)
|
||||
* @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath,用于 Addon 适配器)
|
||||
*/
|
||||
static async getAdapter(
|
||||
logger: LogWrapper,
|
||||
ffmpegPath: string = 'ffmpeg',
|
||||
ffprobePath: string = 'ffprobe',
|
||||
binaryPath?: string
|
||||
): Promise<IFFmpegAdapter> {
|
||||
// 如果已经初始化,直接返回
|
||||
if (this.instance) {
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
// 如果正在初始化,等待初始化完成
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
// 开始初始化
|
||||
this.initPromise = this.initialize(logger, ffmpegPath, ffprobePath, binaryPath);
|
||||
|
||||
try {
|
||||
this.instance = await this.initPromise;
|
||||
return this.instance;
|
||||
} finally {
|
||||
this.initPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化适配器
|
||||
*/
|
||||
private static async initialize(
|
||||
logger: LogWrapper,
|
||||
ffmpegPath: string,
|
||||
ffprobePath: string,
|
||||
binaryPath?: string
|
||||
): Promise<IFFmpegAdapter> {
|
||||
|
||||
// 1. 优先尝试使用 Native Addon
|
||||
if (binaryPath) {
|
||||
const addonAdapter = new FFmpegAddonAdapter(binaryPath);
|
||||
|
||||
logger.log('[FFmpeg] 检查 Native Addon 可用性...');
|
||||
if (await addonAdapter.isAvailable()) {
|
||||
logger.log('[FFmpeg] ✓ 使用 Native Addon 适配器');
|
||||
return addonAdapter;
|
||||
}
|
||||
|
||||
logger.log('[FFmpeg] Native Addon 不可用,尝试使用命令行工具');
|
||||
} else {
|
||||
logger.log('[FFmpeg] 未提供 binaryPath,跳过 Native Addon 检测');
|
||||
}
|
||||
|
||||
// 2. 降级到 execFile 实现
|
||||
const execAdapter = new FFmpegExecAdapter(ffmpegPath, ffprobePath, binaryPath, logger);
|
||||
|
||||
logger.log(`[FFmpeg] 检查命令行工具可用性: ${ffmpegPath}`);
|
||||
if (await execAdapter.isAvailable()) {
|
||||
logger.log('[FFmpeg] 使用命令行工具适配器 ✓');
|
||||
return execAdapter;
|
||||
}
|
||||
|
||||
// 3. 都不可用,返回 execAdapter 但会在使用时报错
|
||||
logger.logError('[FFmpeg] 警告: FFmpeg 不可用,将使用命令行适配器但可能失败');
|
||||
return execAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置适配器(用于测试或重新初始化)
|
||||
*/
|
||||
static reset(): void {
|
||||
this.instance = null;
|
||||
this.initPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 FFmpeg 路径并重新初始化
|
||||
* @param logger 日志记录器
|
||||
* @param ffmpegPath FFmpeg 可执行文件路径
|
||||
* @param ffprobePath FFprobe 可执行文件路径
|
||||
*/
|
||||
static async updateFFmpegPath(
|
||||
logger: LogWrapper,
|
||||
ffmpegPath: string,
|
||||
ffprobePath: string
|
||||
): Promise<void> {
|
||||
// 如果当前使用的是 Exec 适配器,更新路径
|
||||
if (this.instance && this.instance instanceof FFmpegExecAdapter) {
|
||||
logger.log(`[FFmpeg] 更新 FFmpeg 路径: ${ffmpegPath}`);
|
||||
this.instance.setFFmpegPath(ffmpegPath);
|
||||
this.instance.setFFprobePath(ffprobePath);
|
||||
|
||||
// 验证新路径是否可用
|
||||
if (await this.instance.isAvailable()) {
|
||||
logger.log('[FFmpeg] 新路径验证成功 ✓');
|
||||
} else {
|
||||
logger.logError('[FFmpeg] 警告: 新 FFmpeg 路径不可用');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前适配器(不初始化)
|
||||
*/
|
||||
static getCurrentAdapter(): IFFmpegAdapter | null {
|
||||
return this.instance;
|
||||
}
|
||||
}
|
||||
68
src/common/ffmpeg-adapter-interface.ts
Normal file
68
src/common/ffmpeg-adapter-interface.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* FFmpeg Adapter Interface
|
||||
* 定义统一的 FFmpeg 操作接口,支持多种实现方式
|
||||
*/
|
||||
|
||||
/**
|
||||
* 视频信息结果
|
||||
*/
|
||||
export interface VideoInfoResult {
|
||||
/** 视频宽度(像素) */
|
||||
width: number;
|
||||
/** 视频高度(像素) */
|
||||
height: number;
|
||||
/** 视频时长(秒) */
|
||||
duration: number;
|
||||
/** 容器格式 */
|
||||
format: string;
|
||||
/** 缩略图 Buffer */
|
||||
thumbnail?: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg 适配器接口
|
||||
*/
|
||||
export interface IFFmpegAdapter {
|
||||
/** 适配器名称 */
|
||||
readonly name: string;
|
||||
|
||||
/** 是否可用 */
|
||||
isAvailable(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 获取视频信息(包含缩略图)
|
||||
* @param videoPath 视频文件路径
|
||||
* @returns 视频信息
|
||||
*/
|
||||
getVideoInfo(videoPath: string): Promise<VideoInfoResult>;
|
||||
|
||||
/**
|
||||
* 获取音视频文件时长
|
||||
* @param filePath 文件路径
|
||||
* @returns 时长(秒)
|
||||
*/
|
||||
getDuration(filePath: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* 转换音频为 PCM 格式
|
||||
* @param filePath 输入文件路径
|
||||
* @param pcmPath 输出 PCM 文件路径
|
||||
* @returns PCM 数据 Buffer
|
||||
*/
|
||||
convertToPCM(filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number }>;
|
||||
|
||||
/**
|
||||
* 转换音频文件
|
||||
* @param inputFile 输入文件路径
|
||||
* @param outputFile 输出文件路径
|
||||
* @param format 目标格式 ('amr' | 'silk' 等)
|
||||
*/
|
||||
convertFile(inputFile: string, outputFile: string, format: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 提取视频缩略图
|
||||
* @param videoPath 视频文件路径
|
||||
* @param thumbnailPath 缩略图输出路径
|
||||
*/
|
||||
extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void>;
|
||||
}
|
||||
122
src/common/ffmpeg-addon-adapter.ts
Normal file
122
src/common/ffmpeg-addon-adapter.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* FFmpeg Native Addon Adapter
|
||||
* 使用原生 Node.js Addon 实现的 FFmpeg 适配器
|
||||
*/
|
||||
|
||||
import { platform, arch } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import type { FFmpeg } from './ffmpeg-addon';
|
||||
import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface';
|
||||
import { dlopen } from 'node:process';
|
||||
|
||||
/**
|
||||
* 获取 Native Addon 路径
|
||||
* @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath)
|
||||
*/
|
||||
function getAddonPath(binaryPath: string): string {
|
||||
const platformName = platform();
|
||||
const archName = arch();
|
||||
|
||||
let addonFileName: string = process.platform + '.' + process.arch;
|
||||
let addonPath = path.join(binaryPath, "./native/ffmpeg/", `ffmpegAddon.${addonFileName}.node`);
|
||||
if (!existsSync(addonPath)) {
|
||||
throw new Error(`Unsupported platform: ${platformName} ${archName} - Addon not found at ${addonPath}`);
|
||||
}
|
||||
return addonPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg Native Addon 适配器实现
|
||||
*/
|
||||
export class FFmpegAddonAdapter implements IFFmpegAdapter {
|
||||
public readonly name = 'FFmpegAddon';
|
||||
private addon: FFmpeg | null = null;
|
||||
private binaryPath: string;
|
||||
|
||||
constructor(binaryPath: string) {
|
||||
this.binaryPath = binaryPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Addon 是否可用
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
let temp_addon = { exports: {} };
|
||||
dlopen(temp_addon, getAddonPath(this.binaryPath));
|
||||
this.addon = temp_addon.exports as FFmpeg;
|
||||
return this.addon !== null;
|
||||
} catch (error) {
|
||||
console.log('[FFmpegAddonAdapter] Failed to load addon:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureAddon(): FFmpeg {
|
||||
if (!this.addon) {
|
||||
throw new Error('FFmpeg Addon is not available');
|
||||
}
|
||||
return this.addon;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频信息
|
||||
*/
|
||||
async getVideoInfo(videoPath: string): Promise<VideoInfoResult> {
|
||||
const addon = this.ensureAddon();
|
||||
const info = await addon.getVideoInfo(videoPath, 'bmp24');
|
||||
|
||||
return {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
duration: info.duration,
|
||||
format: info.format,
|
||||
thumbnail: info.image,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取时长
|
||||
*/
|
||||
async getDuration(filePath: string): Promise<number> {
|
||||
const addon = this.ensureAddon();
|
||||
return addon.getDuration(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 PCM
|
||||
*/
|
||||
async convertToPCM(filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number }> {
|
||||
const addon = this.ensureAddon();
|
||||
const result = await addon.decodeAudioToPCM(filePath, pcmPath, 24000);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换文件
|
||||
*/
|
||||
async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||
const addon = this.ensureAddon();
|
||||
|
||||
if (format === 'silk' || format === 'ntsilk') {
|
||||
// 使用 Addon 的 NTSILK 转换
|
||||
await addon.convertToNTSilkTct(inputFile, outputFile);
|
||||
} else {
|
||||
throw new Error(`Format '${format}' is not supported by FFmpeg Addon`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取缩略图
|
||||
*/
|
||||
async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
|
||||
const addon = this.ensureAddon();
|
||||
const info = await addon.getVideoInfo(videoPath);
|
||||
|
||||
// 将缩略图写入文件
|
||||
await writeFile(thumbnailPath, info.image);
|
||||
}
|
||||
}
|
||||
71
src/common/ffmpeg-addon.ts
Normal file
71
src/common/ffmpeg-addon.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* FFmpeg Node.js Native Addon Type Definitions
|
||||
*
|
||||
* This addon provides FFmpeg functionality for Node.js including:
|
||||
* - Video information extraction with thumbnail generation
|
||||
* - Audio/Video duration detection
|
||||
* - Audio format conversion to NTSILK
|
||||
* - Audio decoding to PCM
|
||||
*/
|
||||
|
||||
/**
|
||||
* Video information result object
|
||||
*/
|
||||
export interface VideoInfo {
|
||||
/** Video width in pixels */
|
||||
width: number;
|
||||
|
||||
/** Video height in pixels */
|
||||
height: number;
|
||||
|
||||
/** Video duration in seconds */
|
||||
duration: number;
|
||||
|
||||
/** Container format name (e.g., "mp4", "mkv", "avi") */
|
||||
format: string;
|
||||
|
||||
/** Video codec name (e.g., "h264", "hevc", "vp9") */
|
||||
videoCodec: string;
|
||||
|
||||
/** First frame thumbnail as BMP image buffer */
|
||||
image: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio PCM decoding result object
|
||||
*/
|
||||
export interface AudioPCMResult {
|
||||
/** PCM audio data as 16-bit signed integer samples */
|
||||
pcm: Buffer;
|
||||
|
||||
/** Sample rate in Hz (e.g., 44100, 48000, 24000) */
|
||||
sampleRate: number;
|
||||
|
||||
/** Number of audio channels (1 for mono, 2 for stereo) */
|
||||
channels: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg interface providing all audio/video processing methods
|
||||
*/
|
||||
export interface FFmpeg {
|
||||
/**
|
||||
* Get video information including resolution, duration, format, codec and first frame thumbnail
|
||||
*/
|
||||
getVideoInfo(filePath: string, format?: 'bmp' | 'bmp24'): Promise<VideoInfo>;
|
||||
|
||||
/**
|
||||
* Get duration of audio or video file in seconds
|
||||
*/
|
||||
getDuration(filePath: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Convert audio file to NTSILK format (WeChat voice message format)
|
||||
*/
|
||||
convertToNTSilkTct(inputPath: string, outputPath: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Decode audio file to raw PCM data
|
||||
*/
|
||||
decodeAudioToPCM(filePath: string, pcmPath: string, sampleRate?: number): Promise<{ result: boolean, sampleRate: number }>;
|
||||
}
|
||||
244
src/common/ffmpeg-exec-adapter.ts
Normal file
244
src/common/ffmpeg-exec-adapter.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* FFmpeg Exec Adapter
|
||||
* 使用 execFile 调用 FFmpeg 命令行工具的适配器实现
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { fileTypeFromFile } from 'file-type';
|
||||
import { imageSizeFallBack } from '@/image-size';
|
||||
import { downloadFFmpegIfNotExists } from './download-ffmpeg';
|
||||
import { LogWrapper } from './log';
|
||||
import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
function ensureDirExists(filePath: string): void {
|
||||
const dir = dirname(filePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg 命令行适配器实现
|
||||
*/
|
||||
export class FFmpegExecAdapter implements IFFmpegAdapter {
|
||||
public readonly name = 'FFmpegExec';
|
||||
private downloadAttempted = false;
|
||||
|
||||
constructor(
|
||||
private ffmpegPath: string = 'ffmpeg',
|
||||
private ffprobePath: string = 'ffprobe',
|
||||
private binaryPath?: string,
|
||||
private logger?: LogWrapper
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 检查 FFmpeg 是否可用,如果不可用则尝试下载
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
// 首先检查当前路径
|
||||
try {
|
||||
await execFileAsync(this.ffmpegPath, ['-version']);
|
||||
return true;
|
||||
} catch {
|
||||
// 如果失败且未尝试下载,尝试下载
|
||||
if (!this.downloadAttempted && this.binaryPath && this.logger) {
|
||||
this.downloadAttempted = true;
|
||||
|
||||
if (process.env['NAPCAT_DISABLE_FFMPEG_DOWNLOAD']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.log('[FFmpeg] 未找到可用的 FFmpeg,尝试自动下载...');
|
||||
const result = await downloadFFmpegIfNotExists(this.logger);
|
||||
|
||||
if (result.path && result.reset) {
|
||||
// 更新路径
|
||||
if (process.platform === 'win32') {
|
||||
this.ffmpegPath = join(result.path, 'ffmpeg.exe');
|
||||
this.ffprobePath = join(result.path, 'ffprobe.exe');
|
||||
this.logger.log('[FFmpeg] 已更新路径:', this.ffmpegPath);
|
||||
|
||||
// 再次检查
|
||||
try {
|
||||
await execFileAsync(this.ffmpegPath, ['-version']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 FFmpeg 路径
|
||||
*/
|
||||
setFFmpegPath(ffmpegPath: string): void {
|
||||
this.ffmpegPath = ffmpegPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 FFprobe 路径
|
||||
*/
|
||||
setFFprobePath(ffprobePath: string): void {
|
||||
this.ffprobePath = ffprobePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频信息
|
||||
*/
|
||||
async getVideoInfo(videoPath: string): Promise<VideoInfoResult> {
|
||||
// 获取文件大小和类型
|
||||
const [fileType, duration] = await Promise.all([
|
||||
fileTypeFromFile(videoPath).catch(() => null),
|
||||
this.getDuration(videoPath)
|
||||
]);
|
||||
|
||||
// 创建临时缩略图路径
|
||||
const thumbnailPath = `${videoPath}.thumbnail.bmp`;
|
||||
let width = 100;
|
||||
let height = 100;
|
||||
let thumbnail: Buffer | undefined;
|
||||
|
||||
try {
|
||||
await this.extractThumbnail(videoPath, thumbnailPath);
|
||||
|
||||
// 获取图片尺寸
|
||||
const dimensions = await imageSizeFallBack(thumbnailPath);
|
||||
width = dimensions.width ?? 100;
|
||||
height = dimensions.height ?? 100;
|
||||
|
||||
// 读取缩略图
|
||||
if (existsSync(thumbnailPath)) {
|
||||
thumbnail = readFileSync(thumbnailPath);
|
||||
}
|
||||
} catch (error) {
|
||||
// 使用默认值
|
||||
}
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
duration,
|
||||
format: fileType?.ext ?? 'mp4',
|
||||
thumbnail,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取时长
|
||||
*/
|
||||
async getDuration(filePath: string): Promise<number> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(this.ffprobePath, [
|
||||
'-v', 'error',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
filePath
|
||||
]);
|
||||
|
||||
const duration = parseFloat(stdout.trim());
|
||||
return isNaN(duration) ? 60 : duration;
|
||||
} catch {
|
||||
return 60; // 默认时长
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 PCM
|
||||
*/
|
||||
async convertToPCM(filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number }> {
|
||||
try {
|
||||
ensureDirExists(pcmPath);
|
||||
|
||||
await execFileAsync(this.ffmpegPath, [
|
||||
'-y',
|
||||
'-i', filePath,
|
||||
'-ar', '24000',
|
||||
'-ac', '1',
|
||||
'-f', 's16le',
|
||||
pcmPath
|
||||
]);
|
||||
|
||||
if (!existsSync(pcmPath)) {
|
||||
throw new Error('转换PCM失败,输出文件不存在');
|
||||
}
|
||||
|
||||
return { result: true, sampleRate: 24000 };
|
||||
} catch (error: any) {
|
||||
throw new Error(`FFmpeg处理转换出错: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换文件
|
||||
*/
|
||||
async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||
try {
|
||||
ensureDirExists(outputFile);
|
||||
|
||||
const params = format === 'amr'
|
||||
? [
|
||||
'-f', 's16le',
|
||||
'-ar', '24000',
|
||||
'-ac', '1',
|
||||
'-i', inputFile,
|
||||
'-ar', '8000',
|
||||
'-b:a', '12.2k',
|
||||
'-y',
|
||||
outputFile
|
||||
]
|
||||
: [
|
||||
'-f', 's16le',
|
||||
'-ar', '24000',
|
||||
'-ac', '1',
|
||||
'-i', inputFile,
|
||||
'-y',
|
||||
outputFile
|
||||
];
|
||||
|
||||
await execFileAsync(this.ffmpegPath, params);
|
||||
|
||||
if (!existsSync(outputFile)) {
|
||||
throw new Error('转换失败,输出文件不存在');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error converting file:', error);
|
||||
throw new Error(`文件转换失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取缩略图
|
||||
*/
|
||||
async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
|
||||
try {
|
||||
ensureDirExists(thumbnailPath);
|
||||
|
||||
const { stderr } = await execFileAsync(this.ffmpegPath, [
|
||||
'-i', videoPath,
|
||||
'-ss', '00:00:01.000',
|
||||
'-vframes', '1',
|
||||
'-y', // 覆盖输出文件
|
||||
thumbnailPath
|
||||
]);
|
||||
|
||||
if (!existsSync(thumbnailPath)) {
|
||||
throw new Error(`提取缩略图失败,输出文件不存在: ${stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting thumbnail:', error);
|
||||
throw new Error(`提取缩略图失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,195 +1,144 @@
|
||||
import { readFileSync, statSync, existsSync, mkdirSync } from 'fs';
|
||||
import path, { dirname } from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { statSync, existsSync, writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import type { VideoInfo } from './video';
|
||||
import { fileTypeFromFile } from 'file-type';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { platform } from 'node:os';
|
||||
import { LogWrapper } from './log';
|
||||
import { imageSizeFallBack } from '@/image-size';
|
||||
const currentPath = dirname(fileURLToPath(import.meta.url));
|
||||
const execFileAsync = promisify(execFile);
|
||||
const getFFmpegPath = (tool: string): string => {
|
||||
if (process.platform === 'win32') {
|
||||
import { FFmpegAdapterFactory } from './ffmpeg-adapter-factory';
|
||||
import type { IFFmpegAdapter } from './ffmpeg-adapter-interface';
|
||||
|
||||
const getFFmpegPath = (tool: string, binaryPath?: string): string => {
|
||||
if (process.platform === 'win32' && binaryPath) {
|
||||
const exeName = `${tool}.exe`;
|
||||
const isLocalExeExists = existsSync(path.join(currentPath, 'ffmpeg', exeName));
|
||||
return isLocalExeExists ? path.join(currentPath, 'ffmpeg', exeName) : exeName;
|
||||
const localPath = path.join(binaryPath, 'ffmpeg', exeName);
|
||||
const isLocalExeExists = existsSync(localPath);
|
||||
return isLocalExeExists ? localPath : exeName;
|
||||
}
|
||||
return tool;
|
||||
};
|
||||
export let FFMPEG_CMD = getFFmpegPath('ffmpeg');
|
||||
export let FFPROBE_CMD = getFFmpegPath('ffprobe');
|
||||
|
||||
export let FFMPEG_CMD = 'ffmpeg';
|
||||
export let FFPROBE_CMD = 'ffprobe';
|
||||
export class FFmpegService {
|
||||
// 确保目标目录存在
|
||||
public static setFfmpegPath(ffmpegPath: string,logger:LogWrapper): void {
|
||||
private static adapter: IFFmpegAdapter | null = null;
|
||||
private static initialized = false;
|
||||
|
||||
/**
|
||||
* 初始化 FFmpeg 服务
|
||||
* @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath)
|
||||
* @param logger 日志记录器
|
||||
*/
|
||||
public static async init(binaryPath: string, logger: LogWrapper): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查本地 ffmpeg 路径
|
||||
FFMPEG_CMD = getFFmpegPath('ffmpeg', binaryPath);
|
||||
FFPROBE_CMD = getFFmpegPath('ffprobe', binaryPath);
|
||||
|
||||
// 立即初始化适配器(会触发自动下载等逻辑)
|
||||
this.adapter = await FFmpegAdapterFactory.getAdapter(
|
||||
logger,
|
||||
FFMPEG_CMD,
|
||||
FFPROBE_CMD,
|
||||
binaryPath
|
||||
);
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 FFmpeg 适配器
|
||||
*/
|
||||
private static async getAdapter(): Promise<IFFmpegAdapter> {
|
||||
if (!this.adapter) {
|
||||
throw new Error('FFmpeg service not initialized. Please call FFmpegService.init() first.');
|
||||
}
|
||||
return this.adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 FFmpeg 路径并更新适配器
|
||||
* @deprecated 建议使用 init() 方法初始化
|
||||
*/
|
||||
public static async setFfmpegPath(ffmpegPath: string, logger: LogWrapper): Promise<void> {
|
||||
if (platform() === 'win32') {
|
||||
FFMPEG_CMD = path.join(ffmpegPath, 'ffmpeg.exe');
|
||||
FFPROBE_CMD = path.join(ffmpegPath, 'ffprobe.exe');
|
||||
logger.log('[Check] ffmpeg:', FFMPEG_CMD);
|
||||
logger.log('[Check] ffprobe:', FFPROBE_CMD);
|
||||
}
|
||||
}
|
||||
private static ensureDirExists(filePath: string): void {
|
||||
const dir = dirname(filePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
// 更新适配器路径
|
||||
await FFmpegAdapterFactory.updateFFmpegPath(logger, FFMPEG_CMD, FFPROBE_CMD);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取视频缩略图
|
||||
*/
|
||||
public static async extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void> {
|
||||
try {
|
||||
this.ensureDirExists(thumbnailPath);
|
||||
|
||||
const { stderr } = await execFileAsync(FFMPEG_CMD, [
|
||||
'-i', videoPath,
|
||||
'-ss', '00:00:01.000',
|
||||
'-vframes', '1',
|
||||
'-y', // 覆盖输出文件
|
||||
thumbnailPath
|
||||
]);
|
||||
|
||||
if (!existsSync(thumbnailPath)) {
|
||||
throw new Error(`提取缩略图失败,输出文件不存在: ${stderr}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting thumbnail:', error);
|
||||
throw new Error(`提取缩略图失败: ${(error as Error).message}`);
|
||||
}
|
||||
const adapter = await this.getAdapter();
|
||||
await adapter.extractThumbnail(videoPath, thumbnailPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换音频文件
|
||||
*/
|
||||
public static async convertFile(inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||
try {
|
||||
this.ensureDirExists(outputFile);
|
||||
|
||||
const params = format === 'amr'
|
||||
? [
|
||||
'-f', 's16le',
|
||||
'-ar', '24000',
|
||||
'-ac', '1',
|
||||
'-i', inputFile,
|
||||
'-ar', '8000',
|
||||
'-b:a', '12.2k',
|
||||
'-y',
|
||||
outputFile
|
||||
]
|
||||
: [
|
||||
'-f', 's16le',
|
||||
'-ar', '24000',
|
||||
'-ac', '1',
|
||||
'-i', inputFile,
|
||||
'-y',
|
||||
outputFile
|
||||
];
|
||||
|
||||
await execFileAsync(FFMPEG_CMD, params);
|
||||
|
||||
if (!existsSync(outputFile)) {
|
||||
throw new Error('转换失败,输出文件不存在');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error converting file:', error);
|
||||
throw new Error(`文件转换失败: ${(error as Error).message}`);
|
||||
}
|
||||
const adapter = await this.getAdapter();
|
||||
await adapter.convertFile(inputFile, outputFile, format);
|
||||
}
|
||||
|
||||
public static async convert(filePath: string, pcmPath: string): Promise<Buffer> {
|
||||
try {
|
||||
this.ensureDirExists(pcmPath);
|
||||
|
||||
await execFileAsync(FFMPEG_CMD, [
|
||||
'-y',
|
||||
'-i', filePath,
|
||||
'-ar', '24000',
|
||||
'-ac', '1',
|
||||
'-f', 's16le',
|
||||
pcmPath
|
||||
]);
|
||||
|
||||
if (!existsSync(pcmPath)) {
|
||||
throw new Error('转换PCM失败,输出文件不存在');
|
||||
}
|
||||
|
||||
return readFileSync(pcmPath);
|
||||
} catch (error: any) {
|
||||
throw new Error(`FFmpeg处理转换出错: ${error.message}`);
|
||||
}
|
||||
/**
|
||||
* 转换为 PCM 格式
|
||||
*/
|
||||
public static async convert(filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number }> {
|
||||
const adapter = await this.getAdapter();
|
||||
return adapter.convertToPCM(filePath, pcmPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频信息
|
||||
*/
|
||||
public static async getVideoInfo(videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
|
||||
const adapter = await this.getAdapter();
|
||||
|
||||
try {
|
||||
// 并行执行获取文件信息和提取缩略图
|
||||
const [fileInfo, duration] = await Promise.all([
|
||||
this.getFileInfo(videoPath, thumbnailPath),
|
||||
this.getVideoDuration(videoPath)
|
||||
]);
|
||||
// 获取文件大小
|
||||
const fileSize = statSync(videoPath).size;
|
||||
|
||||
// 使用适配器获取视频信息
|
||||
const videoInfo = await adapter.getVideoInfo(videoPath);
|
||||
|
||||
// 如果提供了缩略图路径且适配器返回了缩略图,保存到指定路径
|
||||
if (thumbnailPath && videoInfo.thumbnail) {
|
||||
writeFileSync(thumbnailPath, videoInfo.thumbnail);
|
||||
}
|
||||
|
||||
const result: VideoInfo = {
|
||||
width: fileInfo.width,
|
||||
height: fileInfo.height,
|
||||
time: duration,
|
||||
format: fileInfo.format,
|
||||
size: fileInfo.size,
|
||||
width: videoInfo.width,
|
||||
height: videoInfo.height,
|
||||
time: videoInfo.duration,
|
||||
format: videoInfo.format,
|
||||
size: fileSize,
|
||||
filePath: videoPath
|
||||
};
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async getFileInfo(videoPath: string, thumbnailPath: string): Promise<{
|
||||
format: string,
|
||||
size: number,
|
||||
width: number,
|
||||
height: number
|
||||
}> {
|
||||
|
||||
// 获取文件大小和类型
|
||||
const [fileType, fileSize] = await Promise.all([
|
||||
fileTypeFromFile(videoPath).catch(() => {
|
||||
return null;
|
||||
}),
|
||||
Promise.resolve(statSync(videoPath).size)
|
||||
]);
|
||||
|
||||
|
||||
try {
|
||||
await this.extractThumbnail(videoPath, thumbnailPath);
|
||||
// 获取图片尺寸
|
||||
const dimensions = await imageSizeFallBack(thumbnailPath);
|
||||
// 降级处理:返回默认值
|
||||
const fileType = await fileTypeFromFile(videoPath).catch(() => null);
|
||||
const fileSize = statSync(videoPath).size;
|
||||
|
||||
return {
|
||||
format: fileType?.ext ?? 'mp4',
|
||||
size: fileSize,
|
||||
width: dimensions.width ?? 100,
|
||||
height: dimensions.height ?? 100
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
format: fileType?.ext ?? 'mp4',
|
||||
size: fileSize,
|
||||
width: 100,
|
||||
height: 100
|
||||
height: 100,
|
||||
time: 60,
|
||||
format: fileType?.ext ?? 'mp4',
|
||||
size: fileSize,
|
||||
filePath: videoPath
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static async getVideoDuration(videoPath: string): Promise<number> {
|
||||
try {
|
||||
// 使用FFprobe获取时长
|
||||
const { stdout } = await execFileAsync(FFPROBE_CMD, [
|
||||
'-v', 'error',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
videoPath
|
||||
]);
|
||||
|
||||
const duration = parseFloat(stdout.trim());
|
||||
|
||||
return isNaN(duration) ? 60 : duration;
|
||||
} catch (error) {
|
||||
return 60; // 默认时长
|
||||
}
|
||||
}
|
||||
}
|
||||
280
src/common/health.ts
Normal file
280
src/common/health.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
export interface ResourceConfig<T extends any[], R> {
|
||||
/** 资源获取函数 */
|
||||
resourceFn: (...args: T) => Promise<R>;
|
||||
/** 失败后禁用时间(毫秒),默认 30 秒 */
|
||||
disableTime?: number;
|
||||
/** 最大重试次数,默认 3 次 */
|
||||
maxRetries?: number;
|
||||
/** 主动测试间隔(毫秒),默认 60 秒 */
|
||||
healthCheckInterval?: number;
|
||||
/** 最大健康检查失败次数,超过后永久禁用,默认 5 次 */
|
||||
maxHealthCheckFailures?: number;
|
||||
/** 健康检查函数,如果提供则优先使用此函数进行健康检查 */
|
||||
healthCheckFn?: (...args: T) => Promise<boolean>;
|
||||
/** 测试参数(用于健康检查) */
|
||||
testArgs?: T;
|
||||
}
|
||||
|
||||
interface ResourceTypeState {
|
||||
/** 资源配置 */
|
||||
config: {
|
||||
resourceFn: (...args: any[]) => Promise<any>;
|
||||
healthCheckFn?: (...args: any[]) => Promise<boolean>;
|
||||
disableTime: number;
|
||||
maxRetries: number;
|
||||
healthCheckInterval: number;
|
||||
maxHealthCheckFailures: number;
|
||||
testArgs?: any[];
|
||||
};
|
||||
/** 是否启用 */
|
||||
isEnabled: boolean;
|
||||
/** 禁用截止时间 */
|
||||
disableUntil: number;
|
||||
/** 当前重试次数 */
|
||||
currentRetries: number;
|
||||
/** 健康检查失败次数 */
|
||||
healthCheckFailureCount: number;
|
||||
/** 是否永久禁用 */
|
||||
isPermanentlyDisabled: boolean;
|
||||
/** 上次健康检查时间 */
|
||||
lastHealthCheckTime: number;
|
||||
/** 成功次数统计 */
|
||||
successCount: number;
|
||||
/** 失败次数统计 */
|
||||
failureCount: number;
|
||||
}
|
||||
|
||||
export class ResourceManager {
|
||||
private resourceTypes = new Map<string, ResourceTypeState>();
|
||||
private destroyed = false;
|
||||
|
||||
/**
|
||||
* 调用资源(自动注册或复用已有配置)
|
||||
*/
|
||||
async callResource<T extends any[], R>(
|
||||
type: string,
|
||||
config: ResourceConfig<T, R>,
|
||||
...args: T
|
||||
): Promise<R> {
|
||||
if (this.destroyed) {
|
||||
throw new Error('ResourceManager has been destroyed');
|
||||
}
|
||||
|
||||
// 获取或创建资源类型状态
|
||||
let state = this.resourceTypes.get(type);
|
||||
|
||||
if (!state) {
|
||||
// 首次注册
|
||||
state = {
|
||||
config: {
|
||||
resourceFn: config.resourceFn as (...args: any[]) => Promise<any>,
|
||||
healthCheckFn: config.healthCheckFn as ((...args: any[]) => Promise<boolean>) | undefined,
|
||||
disableTime: config.disableTime ?? 30000,
|
||||
maxRetries: config.maxRetries ?? 3,
|
||||
healthCheckInterval: config.healthCheckInterval ?? 60000,
|
||||
maxHealthCheckFailures: config.maxHealthCheckFailures ?? 20,
|
||||
testArgs: config.testArgs as any[] | undefined,
|
||||
},
|
||||
isEnabled: true,
|
||||
disableUntil: 0,
|
||||
currentRetries: 0,
|
||||
healthCheckFailureCount: 0,
|
||||
isPermanentlyDisabled: false,
|
||||
lastHealthCheckTime: 0,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
};
|
||||
this.resourceTypes.set(type, state);
|
||||
}
|
||||
|
||||
// 在调用前检查是否需要进行健康检查
|
||||
await this.checkAndPerformHealthCheck(state);
|
||||
|
||||
// 检查资源状态
|
||||
if (state.isPermanentlyDisabled) {
|
||||
throw new Error(`Resource type '${type}' is permanently disabled (success: ${state.successCount}, failure: ${state.failureCount})`);
|
||||
}
|
||||
|
||||
if (!this.isResourceAvailable(type)) {
|
||||
const disableUntilDate = new Date(state.disableUntil).toISOString();
|
||||
throw new Error(`Resource type '${type}' is currently disabled until ${disableUntilDate} (success: ${state.successCount}, failure: ${state.failureCount})`);
|
||||
}
|
||||
|
||||
// 调用资源
|
||||
try {
|
||||
const result = await config.resourceFn(...args);
|
||||
this.onResourceSuccess(state);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.onResourceFailure(state);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查资源类型是否可用
|
||||
*/
|
||||
isResourceAvailable(type: string): boolean {
|
||||
const state = this.resourceTypes.get(type);
|
||||
if (!state) {
|
||||
return true; // 未注册的资源类型视为可用
|
||||
}
|
||||
|
||||
if (state.isPermanentlyDisabled || !state.isEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Date.now() >= state.disableUntil;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源类型统计信息
|
||||
*/
|
||||
getResourceStats(type: string): { successCount: number; failureCount: number; isEnabled: boolean; isPermanentlyDisabled: boolean } | null {
|
||||
const state = this.resourceTypes.get(type);
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
successCount: state.successCount,
|
||||
failureCount: state.failureCount,
|
||||
isEnabled: state.isEnabled,
|
||||
isPermanentlyDisabled: state.isPermanentlyDisabled,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有资源类型统计
|
||||
*/
|
||||
getAllResourceStats(): Map<string, { successCount: number; failureCount: number; isEnabled: boolean; isPermanentlyDisabled: boolean }> {
|
||||
const stats = new Map();
|
||||
for (const [type, state] of this.resourceTypes) {
|
||||
stats.set(type, {
|
||||
successCount: state.successCount,
|
||||
failureCount: state.failureCount,
|
||||
isEnabled: state.isEnabled,
|
||||
isPermanentlyDisabled: state.isPermanentlyDisabled,
|
||||
});
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销资源类型
|
||||
*/
|
||||
unregister(type: string): boolean {
|
||||
return this.resourceTypes.delete(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁管理器
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resourceTypes.clear();
|
||||
this.destroyed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并执行健康检查(如果需要)
|
||||
*/
|
||||
private async checkAndPerformHealthCheck(state: ResourceTypeState): Promise<void> {
|
||||
// 如果资源可用或已永久禁用,无需健康检查
|
||||
if (state.isEnabled && Date.now() >= state.disableUntil) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.isPermanentlyDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// 检查是否还在禁用期内
|
||||
if (now < state.disableUntil) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否需要进行健康检查(根据间隔时间)
|
||||
if (now - state.lastHealthCheckTime < state.config.healthCheckInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 执行健康检查
|
||||
await this.performHealthCheck(state);
|
||||
}
|
||||
|
||||
private async performHealthCheck(state: ResourceTypeState): Promise<void> {
|
||||
state.lastHealthCheckTime = Date.now();
|
||||
|
||||
try {
|
||||
let healthCheckResult: boolean;
|
||||
|
||||
if (state.config.healthCheckFn) {
|
||||
const testArgs = state.config.testArgs || [];
|
||||
healthCheckResult = await state.config.healthCheckFn(...testArgs);
|
||||
} else {
|
||||
const testArgs = state.config.testArgs || [];
|
||||
await state.config.resourceFn(...testArgs);
|
||||
healthCheckResult = true;
|
||||
}
|
||||
|
||||
if (healthCheckResult) {
|
||||
// 健康检查成功,重新启用
|
||||
state.isEnabled = true;
|
||||
state.disableUntil = 0;
|
||||
state.currentRetries = 0;
|
||||
state.healthCheckFailureCount = 0;
|
||||
} else {
|
||||
throw new Error('Health check function returned false');
|
||||
}
|
||||
} catch {
|
||||
// 健康检查失败,增加失败计数
|
||||
state.healthCheckFailureCount++;
|
||||
|
||||
// 检查是否达到最大健康检查失败次数
|
||||
if (state.healthCheckFailureCount >= state.config.maxHealthCheckFailures) {
|
||||
// 永久禁用资源
|
||||
state.isPermanentlyDisabled = true;
|
||||
state.disableUntil = 0;
|
||||
} else {
|
||||
// 继续禁用一段时间
|
||||
state.disableUntil = Date.now() + state.config.disableTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onResourceSuccess(state: ResourceTypeState): void {
|
||||
state.currentRetries = 0;
|
||||
state.disableUntil = 0;
|
||||
state.healthCheckFailureCount = 0;
|
||||
state.successCount++;
|
||||
}
|
||||
|
||||
private onResourceFailure(state: ResourceTypeState): void {
|
||||
state.currentRetries++;
|
||||
state.failureCount++;
|
||||
|
||||
// 如果重试次数达到上限,禁用资源
|
||||
if (state.currentRetries >= state.config.maxRetries) {
|
||||
state.disableUntil = Date.now() + state.config.disableTime;
|
||||
state.currentRetries = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局实例
|
||||
export const resourceManager = new ResourceManager();
|
||||
|
||||
// 便捷函数
|
||||
export async function registerResource<T extends any[], R>(
|
||||
type: string,
|
||||
config: ResourceConfig<T, R>,
|
||||
...args: T
|
||||
): Promise<R> {
|
||||
return resourceManager.callResource(type, config, ...args);
|
||||
}
|
||||
@@ -163,8 +163,10 @@ export function getQQVersionConfigPath(exePath: string = ''): string | undefined
|
||||
|
||||
export function calcQQLevel(level?: QQLevel) {
|
||||
if (!level) return 0;
|
||||
const { crownNum, sunNum, moonNum, starNum } = level;
|
||||
return crownNum * 64 + sunNum * 16 + moonNum * 4 + starNum;
|
||||
//const { penguinNum, crownNum, sunNum, moonNum, starNum } = level;
|
||||
const { crownNum, sunNum, moonNum, starNum } = level
|
||||
//没补类型
|
||||
return crownNum * 64 + sunNum * 16 + moonNum * 4 + starNum;
|
||||
}
|
||||
|
||||
export function stringifyWithBigInt(obj: any) {
|
||||
@@ -204,4 +206,4 @@ export function parseAppidFromMajor(nodeMajor: string): string | undefined {
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,23 @@ export class NapCatPathWrapper {
|
||||
configPath: string;
|
||||
cachePath: string;
|
||||
staticPath: string;
|
||||
pluginPath: string;
|
||||
|
||||
constructor(mainPath: string = dirname(fileURLToPath(import.meta.url))) {
|
||||
this.binaryPath = mainPath;
|
||||
let writePath: string;
|
||||
if (os.platform() === 'darwin') {
|
||||
|
||||
if (process.env['NAPCAT_WORKDIR']) {
|
||||
writePath = process.env['NAPCAT_WORKDIR'];
|
||||
} else if (os.platform() === 'darwin') {
|
||||
writePath = path.join(os.homedir(), 'Library', 'Application Support', 'QQ', 'NapCat');
|
||||
} else {
|
||||
writePath = this.binaryPath;
|
||||
}
|
||||
|
||||
this.logsPath = path.join(writePath, 'logs');
|
||||
this.configPath = path.join(writePath, 'config');
|
||||
this.pluginPath = path.join(writePath, 'plugins');//dynamic load
|
||||
this.cachePath = path.join(writePath, 'cache');
|
||||
this.staticPath = path.join(this.binaryPath, 'static');
|
||||
if (!fs.existsSync(this.logsPath)) {
|
||||
|
||||
316
src/common/performance-monitor.ts
Normal file
316
src/common/performance-monitor.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* 性能监控器 - 用于统计函数调用次数、耗时等信息
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface FunctionStats {
|
||||
name: string;
|
||||
callCount: number;
|
||||
totalTime: number;
|
||||
averageTime: number;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
fileName?: string;
|
||||
lineNumber?: number;
|
||||
}
|
||||
|
||||
export class PerformanceMonitor {
|
||||
private static instance: PerformanceMonitor;
|
||||
private stats = new Map<string, FunctionStats>();
|
||||
private startTimes = new Map<string, number>();
|
||||
private reportInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
static getInstance(): PerformanceMonitor {
|
||||
if (!PerformanceMonitor.instance) {
|
||||
PerformanceMonitor.instance = new PerformanceMonitor();
|
||||
// 启动定时统计报告
|
||||
PerformanceMonitor.instance.startPeriodicReport();
|
||||
}
|
||||
return PerformanceMonitor.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始定时统计报告 (每60秒)
|
||||
*/
|
||||
private startPeriodicReport(): void {
|
||||
if (this.reportInterval) {
|
||||
clearInterval(this.reportInterval);
|
||||
}
|
||||
|
||||
this.reportInterval = setInterval(() => {
|
||||
if (this.stats.size > 0) {
|
||||
this.printPeriodicReport();
|
||||
this.writeDetailedLogToFile();
|
||||
}
|
||||
}, 60000); // 60秒
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止定时统计报告
|
||||
*/
|
||||
stopPeriodicReport(): void {
|
||||
if (this.reportInterval) {
|
||||
clearInterval(this.reportInterval);
|
||||
this.reportInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印定时统计报告 (简化版本)
|
||||
*/
|
||||
private printPeriodicReport(): void {
|
||||
const now = new Date().toLocaleString();
|
||||
console.log(`\n=== 性能监控定时报告 [${now}] ===`);
|
||||
|
||||
const totalFunctions = this.stats.size;
|
||||
const totalCalls = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.callCount, 0);
|
||||
const totalTime = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.totalTime, 0);
|
||||
|
||||
console.log(`📊 总览: ${totalFunctions} 个函数, ${totalCalls} 次调用, 总耗时: ${totalTime.toFixed(2)}ms`);
|
||||
|
||||
// 显示Top 5最活跃的函数
|
||||
console.log('\n🔥 最活跃函数 (Top 5):');
|
||||
this.getTopByCallCount(5).forEach((stat, index) => {
|
||||
console.log(`${index + 1}. ${stat.name} - 调用: ${stat.callCount}次, 总耗时: ${stat.totalTime.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
// 显示Top 5最耗时的函数
|
||||
console.log('\n⏱️ 最耗时函数 (Top 5):');
|
||||
this.getTopByTotalTime(5).forEach((stat, index) => {
|
||||
console.log(`${index + 1}. ${stat.name} - 总耗时: ${stat.totalTime.toFixed(2)}ms, 平均: ${stat.averageTime.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
console.log('===============================\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 将详细统计数据写入日志文件
|
||||
*/
|
||||
private writeDetailedLogToFile(): void {
|
||||
try {
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().replace(/[:.]/g, '-').split('T')[0];
|
||||
const timeStr = now.toTimeString().split(' ')[0]?.replace(/:/g, '-') || 'unknown-time';
|
||||
const timestamp = `${dateStr}_${timeStr}`;
|
||||
const fileName = `${timestamp}.log.txt`;
|
||||
const logPath = path.join(process.cwd(), 'logs', fileName);
|
||||
|
||||
// 确保logs目录存在
|
||||
const logsDir = path.dirname(logPath);
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const totalFunctions = this.stats.size;
|
||||
const totalCalls = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.callCount, 0);
|
||||
const totalTime = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.totalTime, 0);
|
||||
|
||||
let logContent = '';
|
||||
logContent += `=== 性能监控详细报告 ===\n`;
|
||||
logContent += `生成时间: ${now.toLocaleString()}\n`;
|
||||
logContent += `统计周期: 60秒\n`;
|
||||
logContent += `总览: ${totalFunctions} 个函数, ${totalCalls} 次调用, 总耗时: ${totalTime.toFixed(2)}ms\n\n`;
|
||||
|
||||
// 详细函数统计
|
||||
logContent += `=== 所有函数详细统计 ===\n`;
|
||||
const allStats = this.getStats().sort((a, b) => b.totalTime - a.totalTime);
|
||||
|
||||
allStats.forEach((stat, index) => {
|
||||
logContent += `${index + 1}. 函数: ${stat.name}\n`;
|
||||
logContent += ` 文件: ${stat.fileName || 'N/A'}\n`;
|
||||
logContent += ` 行号: ${stat.lineNumber || 'N/A'}\n`;
|
||||
logContent += ` 调用次数: ${stat.callCount}\n`;
|
||||
logContent += ` 总耗时: ${stat.totalTime.toFixed(4)}ms\n`;
|
||||
logContent += ` 平均耗时: ${stat.averageTime.toFixed(4)}ms\n`;
|
||||
logContent += ` 最小耗时: ${stat.minTime === Infinity ? 'N/A' : stat.minTime.toFixed(4)}ms\n`;
|
||||
logContent += ` 最大耗时: ${stat.maxTime.toFixed(4)}ms\n`;
|
||||
logContent += ` 性能占比: ${((stat.totalTime / totalTime) * 100).toFixed(2)}%\n`;
|
||||
logContent += `\n`;
|
||||
});
|
||||
|
||||
// 排行榜统计
|
||||
logContent += `=== 总耗时排行榜 (Top 20) ===\n`;
|
||||
this.getTopByTotalTime(20).forEach((stat, index) => {
|
||||
logContent += `${index + 1}. ${stat.name} - 总耗时: ${stat.totalTime.toFixed(2)}ms, 调用: ${stat.callCount}次, 平均: ${stat.averageTime.toFixed(2)}ms\n`;
|
||||
});
|
||||
|
||||
logContent += `\n=== 调用次数排行榜 (Top 20) ===\n`;
|
||||
this.getTopByCallCount(20).forEach((stat, index) => {
|
||||
logContent += `${index + 1}. ${stat.name} - 调用: ${stat.callCount}次, 总耗时: ${stat.totalTime.toFixed(2)}ms, 平均: ${stat.averageTime.toFixed(2)}ms\n`;
|
||||
});
|
||||
|
||||
logContent += `\n=== 平均耗时排行榜 (Top 20) ===\n`;
|
||||
this.getTopByAverageTime(20).forEach((stat, index) => {
|
||||
logContent += `${index + 1}. ${stat.name} - 平均: ${stat.averageTime.toFixed(2)}ms, 调用: ${stat.callCount}次, 总耗时: ${stat.totalTime.toFixed(2)}ms\n`;
|
||||
});
|
||||
|
||||
logContent += `\n=== 性能热点分析 ===\n`;
|
||||
// 找出最耗时的前10个函数
|
||||
const hotSpots = this.getTopByTotalTime(10);
|
||||
hotSpots.forEach((stat, index) => {
|
||||
const efficiency = stat.callCount / stat.totalTime; // 每毫秒的调用次数
|
||||
logContent += `${index + 1}. ${stat.name}\n`;
|
||||
logContent += ` 性能影响: ${((stat.totalTime / totalTime) * 100).toFixed(2)}%\n`;
|
||||
logContent += ` 调用效率: ${efficiency.toFixed(4)} 调用/ms\n`;
|
||||
logContent += ` 优化建议: ${stat.averageTime > 10 ? '考虑优化此函数的执行效率' :
|
||||
stat.callCount > 1000 ? '考虑减少此函数的调用频率' :
|
||||
'性能表现良好'}\n\n`;
|
||||
});
|
||||
|
||||
logContent += `=== 报告结束 ===\n`;
|
||||
|
||||
// 写入文件
|
||||
fs.writeFileSync(logPath, logContent, 'utf8');
|
||||
console.log(`📄 详细性能报告已保存到: ${logPath}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('写入性能日志文件时出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始记录函数调用
|
||||
*/
|
||||
startFunction(functionName: string, fileName?: string, lineNumber?: number): string {
|
||||
const callId = `${functionName}_${Date.now()}_${Math.random()}`;
|
||||
this.startTimes.set(callId, performance.now());
|
||||
|
||||
// 初始化或更新统计信息
|
||||
if (!this.stats.has(functionName)) {
|
||||
this.stats.set(functionName, {
|
||||
name: functionName,
|
||||
callCount: 0,
|
||||
totalTime: 0,
|
||||
averageTime: 0,
|
||||
minTime: Infinity,
|
||||
maxTime: 0,
|
||||
fileName,
|
||||
lineNumber
|
||||
});
|
||||
}
|
||||
|
||||
const stat = this.stats.get(functionName)!;
|
||||
stat.callCount++;
|
||||
|
||||
return callId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束记录函数调用
|
||||
*/
|
||||
endFunction(callId: string, functionName: string): void {
|
||||
const startTime = this.startTimes.get(callId);
|
||||
if (!startTime) return;
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
this.startTimes.delete(callId);
|
||||
|
||||
const stat = this.stats.get(functionName);
|
||||
if (!stat) return;
|
||||
|
||||
stat.totalTime += duration;
|
||||
stat.averageTime = stat.totalTime / stat.callCount;
|
||||
stat.minTime = Math.min(stat.minTime, duration);
|
||||
stat.maxTime = Math.max(stat.maxTime, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有统计信息
|
||||
*/
|
||||
getStats(): FunctionStats[] {
|
||||
return Array.from(this.stats.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取排行榜 - 按总耗时排序
|
||||
*/
|
||||
getTopByTotalTime(limit = 20): FunctionStats[] {
|
||||
return this.getStats()
|
||||
.sort((a, b) => b.totalTime - a.totalTime)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取排行榜 - 按调用次数排序
|
||||
*/
|
||||
getTopByCallCount(limit = 20): FunctionStats[] {
|
||||
return this.getStats()
|
||||
.sort((a, b) => b.callCount - a.callCount)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取排行榜 - 按平均耗时排序
|
||||
*/
|
||||
getTopByAverageTime(limit = 20): FunctionStats[] {
|
||||
return this.getStats()
|
||||
.sort((a, b) => b.averageTime - a.averageTime)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空统计数据
|
||||
*/
|
||||
clear(): void {
|
||||
this.stats.clear();
|
||||
this.startTimes.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印统计报告
|
||||
*/
|
||||
printReport(): void {
|
||||
console.log('\n=== 函数性能监控报告 ===');
|
||||
|
||||
console.log('\n🔥 总耗时排行榜 (Top 10):');
|
||||
this.getTopByTotalTime(10).forEach((stat, index) => {
|
||||
console.log(`${index + 1}. ${stat.name} - 总耗时: ${stat.totalTime.toFixed(2)}ms, 调用次数: ${stat.callCount}, 平均耗时: ${stat.averageTime.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
console.log('\n📈 调用次数排行榜 (Top 10):');
|
||||
this.getTopByCallCount(10).forEach((stat, index) => {
|
||||
console.log(`${index + 1}. ${stat.name} - 调用次数: ${stat.callCount}, 总耗时: ${stat.totalTime.toFixed(2)}ms, 平均耗时: ${stat.averageTime.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
console.log('\n⏱️ 平均耗时排行榜 (Top 10):');
|
||||
this.getTopByAverageTime(10).forEach((stat, index) => {
|
||||
console.log(`${index + 1}. ${stat.name} - 平均耗时: ${stat.averageTime.toFixed(2)}ms, 调用次数: ${stat.callCount}, 总耗时: ${stat.totalTime.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
console.log('\n========================\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取JSON格式的统计数据
|
||||
*/
|
||||
toJSON(): FunctionStats[] {
|
||||
return this.getStats();
|
||||
}
|
||||
}
|
||||
|
||||
// 全局性能监控器实例
|
||||
export const performanceMonitor = PerformanceMonitor.getInstance();
|
||||
|
||||
// 在进程退出时打印报告并停止定时器
|
||||
if (typeof process !== 'undefined') {
|
||||
process.on('exit', () => {
|
||||
performanceMonitor.stopPeriodicReport();
|
||||
performanceMonitor.printReport();
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
performanceMonitor.stopPeriodicReport();
|
||||
performanceMonitor.printReport();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
performanceMonitor.stopPeriodicReport();
|
||||
performanceMonitor.printReport();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
@@ -39,10 +39,10 @@ export class QQBasicInfoWrapper {
|
||||
|
||||
//基础函数
|
||||
getQQBuildStr() {
|
||||
return this.isQuickUpdate ? this.QQVersionConfig?.buildId : this.QQPackageInfo?.buildVersion;
|
||||
return this.QQVersionConfig?.curVersion.split('-')[1] ?? this.QQPackageInfo?.buildVersion;
|
||||
}
|
||||
|
||||
getFullQQVesion() {
|
||||
getFullQQVersion() {
|
||||
const version = this.isQuickUpdate ? this.QQVersionConfig?.curVersion : this.QQPackageInfo?.version;
|
||||
if (!version) throw new Error('QQ版本获取失败');
|
||||
return version;
|
||||
@@ -57,9 +57,9 @@ export class QQBasicInfoWrapper {
|
||||
//此方法不要直接使用
|
||||
getQUAFallback() {
|
||||
const platformMapping: Partial<Record<NodeJS.Platform, string>> = {
|
||||
win32: `V1_WIN_${this.getFullQQVesion()}_${this.getQQBuildStr()}_GW_B`,
|
||||
darwin: `V1_MAC_${this.getFullQQVesion()}_${this.getQQBuildStr()}_GW_B`,
|
||||
linux: `V1_LNX_${this.getFullQQVesion()}_${this.getQQBuildStr()}_GW_B`,
|
||||
win32: `V1_WIN_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`,
|
||||
darwin: `V1_MAC_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`,
|
||||
linux: `V1_LNX_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`,
|
||||
};
|
||||
return platformMapping[systemPlatform] ?? (platformMapping.win32)!;
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export class QQBasicInfoWrapper {
|
||||
getAppidV2(): { appid: string; qua: string } {
|
||||
// 通过已有表 性能好
|
||||
const appidTbale = AppidTable as unknown as QQAppidTableType;
|
||||
const fullVersion = this.getFullQQVesion();
|
||||
const fullVersion = this.getFullQQVersion();
|
||||
if (fullVersion) {
|
||||
const data = appidTbale[fullVersion];
|
||||
if (data) {
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const napCatVersion = '4.7.72';
|
||||
export const napCatVersion = '4.9.19';
|
||||
|
||||
@@ -42,8 +42,8 @@ export class NTQQFileApi {
|
||||
this.context = context;
|
||||
this.core = core;
|
||||
this.rkeyManager = new RkeyManager([
|
||||
'https://secret-service.bietiaop.com/rkeys',
|
||||
'http://ss.xingzhige.com/music_card/rkey',
|
||||
'https://secret-service.bietiaop.com/rkeys',
|
||||
],
|
||||
this.context.logger
|
||||
);
|
||||
@@ -64,13 +64,13 @@ export class NTQQFileApi {
|
||||
}
|
||||
}
|
||||
|
||||
async getFileUrl(chatType: ChatType, peer: string, fileUUID?: string, file10MMd5?: string | undefined) {
|
||||
if (this.core.apis.PacketApi.available) {
|
||||
async getFileUrl(chatType: ChatType, peer: string, fileUUID?: string, file10MMd5?: string | undefined, timeout: number = 5000) {
|
||||
if (this.core.apis.PacketApi.packetStatus) {
|
||||
try {
|
||||
if (chatType === ChatType.KCHATTYPEGROUP && fileUUID) {
|
||||
return this.core.apis.PacketApi.pkt.operation.GetGroupFileUrl(+peer, fileUUID);
|
||||
return this.core.apis.PacketApi.pkt.operation.GetGroupFileUrl(+peer, fileUUID, timeout);
|
||||
} else if (file10MMd5 && fileUUID) {
|
||||
return this.core.apis.PacketApi.pkt.operation.GetPrivateFileUrl(peer, fileUUID, file10MMd5);
|
||||
return this.core.apis.PacketApi.pkt.operation.GetPrivateFileUrl(peer, fileUUID, file10MMd5, timeout);
|
||||
}
|
||||
} catch (error) {
|
||||
this.context.logger.logError('获取文件URL失败', (error as Error).message);
|
||||
@@ -79,8 +79,8 @@ export class NTQQFileApi {
|
||||
throw new Error('fileUUID or file10MMd5 is undefined');
|
||||
}
|
||||
|
||||
async getPttUrl(peer: string, fileUUID?: string) {
|
||||
if (this.core.apis.PacketApi.available && fileUUID) {
|
||||
async getPttUrl(peer: string, fileUUID?: string, timeout: number = 5000) {
|
||||
if (this.core.apis.PacketApi.packetStatus && fileUUID) {
|
||||
let appid = new NapProtoMsg(FileId).decode(Buffer.from(fileUUID.replaceAll('-', '+').replaceAll('_', '/'), 'base64')).appid;
|
||||
try {
|
||||
if (appid && appid === 1403) {
|
||||
@@ -90,7 +90,7 @@ export class NTQQFileApi {
|
||||
uploadTime: 0,
|
||||
ttl: 0,
|
||||
subType: 0,
|
||||
});
|
||||
}, timeout);
|
||||
} else if (fileUUID) {
|
||||
return this.core.apis.PacketApi.pkt.operation.GetPttUrl(peer, {
|
||||
fileUuid: fileUUID,
|
||||
@@ -98,7 +98,7 @@ export class NTQQFileApi {
|
||||
uploadTime: 0,
|
||||
ttl: 0,
|
||||
subType: 0,
|
||||
});
|
||||
}, timeout);
|
||||
}
|
||||
} catch (error) {
|
||||
this.context.logger.logError('获取文件URL失败', (error as Error).message);
|
||||
@@ -107,8 +107,8 @@ export class NTQQFileApi {
|
||||
throw new Error('packet cant get ptt url');
|
||||
}
|
||||
|
||||
async getVideoUrlPacket(peer: string, fileUUID?: string) {
|
||||
if (this.core.apis.PacketApi.available && fileUUID) {
|
||||
async getVideoUrlPacket(peer: string, fileUUID?: string, timeout: number = 5000) {
|
||||
if (this.core.apis.PacketApi.packetStatus && fileUUID) {
|
||||
let appid = new NapProtoMsg(FileId).decode(Buffer.from(fileUUID.replaceAll('-', '+').replaceAll('_', '/'), 'base64')).appid;
|
||||
try {
|
||||
if (appid && appid === 1415) {
|
||||
@@ -118,7 +118,7 @@ export class NTQQFileApi {
|
||||
uploadTime: 0,
|
||||
ttl: 0,
|
||||
subType: 0,
|
||||
});
|
||||
}, timeout);
|
||||
} else if (fileUUID) {
|
||||
return this.core.apis.PacketApi.pkt.operation.GetVideoUrl(peer, {
|
||||
fileUuid: fileUUID,
|
||||
@@ -126,7 +126,7 @@ export class NTQQFileApi {
|
||||
uploadTime: 0,
|
||||
ttl: 0,
|
||||
subType: 0,
|
||||
});
|
||||
}, timeout);
|
||||
}
|
||||
} catch (error) {
|
||||
this.context.logger.logError('获取文件URL失败', (error as Error).message);
|
||||
@@ -260,23 +260,22 @@ export class NTQQFileApi {
|
||||
const thumbDir = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`);
|
||||
fs.mkdirSync(pathLib.dirname(thumbDir), { recursive: true });
|
||||
const thumbPath = pathLib.join(pathLib.dirname(thumbDir), `${md5}_0.png`);
|
||||
try {
|
||||
videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath);
|
||||
if (!fs.existsSync(thumbPath)) {
|
||||
this.context.logger.logError('获取视频缩略图失败', new Error('缩略图不存在'));
|
||||
throw new Error('获取视频缩略图失败');
|
||||
}
|
||||
} catch (e) {
|
||||
this.context.logger.logError('获取视频信息失败', e);
|
||||
fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
|
||||
}
|
||||
if (_diyThumbPath) {
|
||||
try {
|
||||
await this.copyFile(_diyThumbPath, thumbPath);
|
||||
} catch (e) {
|
||||
this.context.logger.logError('复制自定义缩略图失败', e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath);
|
||||
if (!fs.existsSync(thumbPath)) {
|
||||
this.context.logger.logError('获取视频缩略图失败', new Error('缩略图不存在'));
|
||||
throw new Error('获取视频缩略图失败');
|
||||
}
|
||||
} catch (e) {
|
||||
this.context.logger.logError('获取视频信息失败', e);
|
||||
fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
|
||||
}
|
||||
}
|
||||
context.deleteAfterSentFiles.push(thumbPath);
|
||||
const thumbSize = (await fsPromises.stat(thumbPath)).size;
|
||||
@@ -334,6 +333,10 @@ export class NTQQFileApi {
|
||||
fileSubId: '',
|
||||
playState: 1,
|
||||
autoConvertText: 0,
|
||||
storeID: 0,
|
||||
otherBusinessInfo: {
|
||||
aiVoiceType: 0
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -502,7 +505,7 @@ export class NTQQFileApi {
|
||||
};
|
||||
|
||||
try {
|
||||
if (this.core.apis.PacketApi.available) {
|
||||
if (this.core.apis.PacketApi.packetStatus) {
|
||||
const rkey_expired_private = !this.packetRkey || (this.packetRkey[0] && this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000);
|
||||
const rkey_expired_group = !this.packetRkey || (this.packetRkey[0] && this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000);
|
||||
if (rkey_expired_private || rkey_expired_group) {
|
||||
|
||||
@@ -49,7 +49,6 @@ export class NTQQGroupApi {
|
||||
async initApi() {
|
||||
this.initCache().then().catch(e => this.context.logger.logError(e));
|
||||
}
|
||||
|
||||
async createGrayTip(groupCode: string, tip: string) {
|
||||
return this.context.session.getMsgService().addLocalJsonGrayTipMsg(
|
||||
{
|
||||
|
||||
@@ -142,7 +142,6 @@ export class NTQQMsgApi {
|
||||
}
|
||||
|
||||
async queryFirstMsgBySender(peer: Peer, SendersUid: string[]) {
|
||||
console.log(peer, SendersUid);
|
||||
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', {
|
||||
chatInfo: peer,
|
||||
filterMsgType: [],
|
||||
@@ -192,7 +191,7 @@ export class NTQQMsgApi {
|
||||
}
|
||||
|
||||
async recallMsg(peer: Peer, msgId: string) {
|
||||
await this.core.eventWrapper.callNormalEventV2(
|
||||
return await this.core.eventWrapper.callNormalEventV2(
|
||||
'NodeIKernelMsgService/recallMsg',
|
||||
'NodeIKernelMsgListener/onMsgInfoListUpdate',
|
||||
[peer, [msgId]],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as os from 'os';
|
||||
import offset from '@/core/external/offset.json';
|
||||
import offset from '@/core/external/napi2native.json';
|
||||
import { InstanceContext, NapCatCore } from '@/core';
|
||||
import { LogWrapper } from '@/common/log';
|
||||
import { PacketClientSession } from '@/core/packet/clientSession';
|
||||
@@ -21,20 +21,26 @@ export class NTQQPacketApi {
|
||||
qqVersion: string | undefined;
|
||||
pkt!: PacketClientSession;
|
||||
errStack: string[] = [];
|
||||
packetStatus: boolean = false;
|
||||
|
||||
constructor(context: InstanceContext, core: NapCatCore) {
|
||||
this.context = context;
|
||||
this.core = core;
|
||||
this.logger = core.context.logger;
|
||||
}
|
||||
|
||||
async initApi() {
|
||||
await this.InitSendPacket(this.context.basicInfoWrapper.getFullQQVesion())
|
||||
.then()
|
||||
this.packetStatus = (await this.InitSendPacket(this.context.basicInfoWrapper.getFullQQVersion())
|
||||
.then((result) => {
|
||||
return result;
|
||||
})
|
||||
.catch((err) => {
|
||||
this.logger.logError(err);
|
||||
this.errStack.push(err);
|
||||
});
|
||||
return false;
|
||||
})) && this.pkt?.available;
|
||||
}
|
||||
|
||||
get available(): boolean {
|
||||
return this.pkt?.available ?? false;
|
||||
}
|
||||
@@ -61,6 +67,12 @@ export class NTQQPacketApi {
|
||||
}
|
||||
this.pkt = new PacketClientSession(this.core);
|
||||
await this.pkt.init(process.pid, table.recv, table.send);
|
||||
try {
|
||||
await this.pkt.operation.FetchRkey(1500);
|
||||
} catch (error) {
|
||||
this.logger.logError('测试Packet状态异常', error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
WebHonorType,
|
||||
} from '@/core';
|
||||
import { NapCatCore } from '..';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createReadStream, readFileSync, statSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { basename } from 'node:path';
|
||||
|
||||
import { qunAlbumControl } from '../data/webapi';
|
||||
import { createAlbumCommentRequest, createAlbumFeedPublish, createAlbumMediaFeed } from '../data/album';
|
||||
export class NTQQWebApi {
|
||||
context: InstanceContext;
|
||||
core: NapCatCore;
|
||||
@@ -264,7 +265,7 @@ export class NTQQWebApi {
|
||||
async getGroupHonorInfo(groupCode: string, getType: WebHonorType) {
|
||||
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
|
||||
let HonorInfo = {
|
||||
group_id: groupCode,
|
||||
group_id: Number(groupCode),
|
||||
current_talkative: {},
|
||||
talkative_list: [],
|
||||
performer_list: [],
|
||||
@@ -323,64 +324,193 @@ export class NTQQWebApi {
|
||||
}
|
||||
return (hash & 0x7FFFFFFF).toString();
|
||||
}
|
||||
async createQunAlbumSession(gc: string, sAlbumID: string, sAlbumName: string, path: string, skey: string, pskey: string, uin: string) {
|
||||
async getAlbumListByNTQQ(gc: string) {
|
||||
return await this.context.session.getAlbumService().getAlbumList({
|
||||
qun_id: gc,
|
||||
attach_info: '',
|
||||
seq: 3331,
|
||||
request_time_line: {
|
||||
request_invoke_time: "0"
|
||||
}
|
||||
})
|
||||
}
|
||||
async getAlbumList(gc: string) {
|
||||
const skey = await this.core.apis.UserApi.getSKey() || '';
|
||||
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
|
||||
const bkn = this.getBknFromSKey(skey);
|
||||
const uin = this.core.selfInfo.uin || '10001';
|
||||
const cookies = `p_uin=o${this.core.selfInfo.uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin} `;
|
||||
const api = `https://h5.qzone.qq.com/proxy/domain/u.photo.qzone.qq.com/cgi-bin/upp/qun_list_album_v2?`;
|
||||
const params = new URLSearchParams({
|
||||
random: '7570',
|
||||
g_tk: bkn,
|
||||
format: 'json',
|
||||
inCharset: 'utf-8',
|
||||
outCharset: 'utf-8',
|
||||
qua: 'V1_IPH_SQ_6.2.0_0_HDBM_T',
|
||||
cmd: 'qunGetAlbumList',
|
||||
qunId: gc,
|
||||
qunid: gc,
|
||||
start: '0',
|
||||
num: '1000',
|
||||
uin: uin,
|
||||
getMemberRole: '0'
|
||||
});
|
||||
const response = await RequestUtil.HttpGetJson<{ data: { album: Array<{ id: string, title: string }> } }>(api + params.toString(), 'GET', '', {
|
||||
'Cookie': cookies
|
||||
});
|
||||
return response.data.album;
|
||||
}
|
||||
|
||||
async createQunAlbumSession(gc: string, sAlbumID: string, sAlbumName: string, path: string, skey: string, pskey: string, img_md5: string, uin: string) {
|
||||
const img = readFileSync(path);
|
||||
const img_md5 = createHash('md5').update(img).digest('hex');
|
||||
const img_size = img.length;
|
||||
const img_name = basename(path);
|
||||
const time = Math.floor(Date.now() / 1000);
|
||||
const GTK = this.getBknFromSKey(pskey);
|
||||
const cookie = `p_uin=${uin}; p_skey=${pskey}; skey=${skey}; uin=${uin}`;
|
||||
const body = {
|
||||
control_req: [{
|
||||
uin: uin,
|
||||
token: {
|
||||
type: 4,
|
||||
data: pskey,
|
||||
appid: 5
|
||||
},
|
||||
appid: 'qun',
|
||||
checksum: img_md5,
|
||||
check_type: 0,
|
||||
file_len: img_size,
|
||||
env: {
|
||||
refer: 'qzone',
|
||||
deviceInfo: 'h5'
|
||||
},
|
||||
model: 0,
|
||||
biz_req: {
|
||||
sPicTitle: img_name,
|
||||
sPicDesc: '',
|
||||
sAlbumName: sAlbumName,
|
||||
sAlbumID: sAlbumID,
|
||||
iAlbumTypeID: 0,
|
||||
iBitmap: 0,
|
||||
iUploadType: 0,
|
||||
iUpPicType: 0,
|
||||
iBatchID: time,
|
||||
sPicPath: '',
|
||||
iPicWidth: 0,
|
||||
iPicHight: 0,
|
||||
iWaterType: 0,
|
||||
iDistinctUse: 0,
|
||||
iNeedFeeds: 1,
|
||||
iUploadTime: time,
|
||||
mapExt: {
|
||||
appid: 'qun',
|
||||
userid: gc
|
||||
}
|
||||
},
|
||||
session: '',
|
||||
asy_upload: 0,
|
||||
cmd: 'FileUpload'
|
||||
}]
|
||||
};
|
||||
const GTK = this.getBknFromSKey(skey);
|
||||
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
|
||||
const body = qunAlbumControl({
|
||||
uin,
|
||||
group_id: gc,
|
||||
pskey,
|
||||
pic_md5: img_md5,
|
||||
img_size,
|
||||
img_name,
|
||||
sAlbumName: sAlbumName,
|
||||
sAlbumID: sAlbumID
|
||||
});
|
||||
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${img_md5}?g_tk=${GTK}`;
|
||||
const post = await RequestUtil.HttpGetJson(api, 'POST', body, {
|
||||
const post = await RequestUtil.HttpGetJson<{ data: { session: string }, ret: number, msg: string }>(api, 'POST', body, {
|
||||
'Cookie': cookie,
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
|
||||
return post;
|
||||
}
|
||||
}
|
||||
|
||||
async uploadQunAlbumSlice(path: string, session: string, skey: string, pskey: string, uin: string, slice_size: number) {
|
||||
const img_size = statSync(path).size;
|
||||
let seq = 0;
|
||||
let offset = 0;
|
||||
const GTK = this.getBknFromSKey(skey);
|
||||
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
|
||||
|
||||
const stream = createReadStream(path, { highWaterMark: slice_size });
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const end = Math.min(offset + chunk.length, img_size);
|
||||
const form = new FormData();
|
||||
form.append('uin', uin);
|
||||
form.append('appid', 'qun');
|
||||
form.append('session', session);
|
||||
form.append('offset', offset.toString());
|
||||
form.append('data', new Blob([chunk], { type: 'application/octet-stream' }), 'blob');
|
||||
form.append('checksum', '');
|
||||
form.append('check_type', '0');
|
||||
form.append('retry', '0');
|
||||
form.append('seq', seq.toString());
|
||||
form.append('end', end.toString());
|
||||
form.append('cmd', 'FileUpload');
|
||||
form.append('slice_size', slice_size.toString());
|
||||
form.append('biz_req.iUploadType', '0');
|
||||
|
||||
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileUpload?seq=${seq}&retry=0&offset=${offset}&end=${end}&total=${img_size}&type=form&g_tk=${GTK}`;
|
||||
const response = await fetch(api, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Cookie': cookie,
|
||||
},
|
||||
body: form
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const post = await response.json() as { ret: number, msg: string }; if (post.ret !== 0) {
|
||||
throw new Error(`分片 ${seq} 上传失败: ${post.msg}`);
|
||||
}
|
||||
offset += chunk.length;
|
||||
seq++;
|
||||
}
|
||||
|
||||
return { success: true, message: '上传完成' };
|
||||
}
|
||||
|
||||
async uploadImageToQunAlbum(gc: string, sAlbumID: string, sAlbumName: string, path: string) {
|
||||
const skey = await this.core.apis.UserApi.getSKey() || '';
|
||||
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
|
||||
const img_md5 = createHash('md5').update(readFileSync(path)).digest('hex');
|
||||
const uin = this.core.selfInfo.uin || '10001';
|
||||
const session = (await this.createQunAlbumSession(gc, sAlbumID, sAlbumName, path, skey, pskey, img_md5, uin)).data.session;
|
||||
if (!session) throw new Error('创建群相册会话失败');
|
||||
await this.uploadQunAlbumSlice(path, session, skey, pskey, uin, 16384);
|
||||
}
|
||||
async getAlbumMediaListByNTQQ(gc: string, albumId: string, attach_info: string = '') {
|
||||
return (await this.context.session.getAlbumService().getMediaList({
|
||||
qun_id: gc,
|
||||
attach_info: attach_info,
|
||||
seq: 0,
|
||||
request_time_line: {
|
||||
request_invoke_time: "0"
|
||||
},
|
||||
album_id: albumId,
|
||||
lloc: '',
|
||||
batch_id: ''
|
||||
})).response;
|
||||
}
|
||||
|
||||
async doAlbumMediaPlainCommentByNTQQ(
|
||||
qunId: string,
|
||||
albumId: string,
|
||||
lloc: string,
|
||||
content: string) {
|
||||
const random_seq = Math.floor(Math.random() * 9000) + 1000;
|
||||
const uin = this.core.selfInfo.uin || '10001';
|
||||
//16位number数字
|
||||
const client_key = Date.now() * 1000
|
||||
return await this.context.session.getAlbumService().doQunComment(
|
||||
random_seq, {
|
||||
map_info: [],
|
||||
map_bytes_info: [],
|
||||
map_user_account: []
|
||||
},
|
||||
qunId,
|
||||
2,
|
||||
createAlbumMediaFeed(uin, albumId, lloc),
|
||||
createAlbumCommentRequest(uin, content, client_key)
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAlbumMediaByNTQQ(
|
||||
qunId: string,
|
||||
albumId: string,
|
||||
lloc: string) {
|
||||
const random_seq = Math.floor(Math.random() * 9000) + 1000;
|
||||
return await this.context.session.getAlbumService().deleteMedias(
|
||||
random_seq,
|
||||
qunId,
|
||||
albumId,
|
||||
[lloc],
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
async doAlbumMediaLikeByNTQQ(
|
||||
qunId: string,
|
||||
albumId: string,
|
||||
lloc: string,
|
||||
id: string) {
|
||||
const random_seq = Math.floor(Math.random() * 9000) + 1000;
|
||||
const uin = this.core.selfInfo.uin || '10001';
|
||||
return await this.context.session.getAlbumService().doQunLike(
|
||||
random_seq, {
|
||||
map_info: [],
|
||||
map_bytes_info: [],
|
||||
map_user_account: []
|
||||
}, {
|
||||
id: id,
|
||||
status: 1
|
||||
},
|
||||
createAlbumFeedPublish(qunId, uin, albumId, lloc)
|
||||
)
|
||||
}
|
||||
}
|
||||
221
src/core/data/album.ts
Normal file
221
src/core/data/album.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 群相册列表请求参数接口
|
||||
*/
|
||||
export interface AlbumListRequest {
|
||||
qun_id: string;
|
||||
attach_info: string;
|
||||
seq: number;
|
||||
request_time_line: {
|
||||
request_invoke_time: string;
|
||||
};
|
||||
album_id: string;
|
||||
lloc: string;
|
||||
batch_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建群相册列表请求参数
|
||||
* @param qunId 群号
|
||||
* @param albumId 相册ID
|
||||
* @param seq 请求序列号,默认值为0
|
||||
* @returns 请求参数对象
|
||||
*/
|
||||
export function createAlbumListRequest(
|
||||
qunId: string,
|
||||
albumId: string,
|
||||
seq: number = 0
|
||||
): AlbumListRequest {
|
||||
return {
|
||||
qun_id: qunId,
|
||||
attach_info: "",
|
||||
seq: seq,
|
||||
request_time_line: {
|
||||
request_invoke_time: "0"
|
||||
},
|
||||
album_id: albumId,
|
||||
lloc: "",
|
||||
batch_id: ""
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 相册媒体项请求接口
|
||||
*/
|
||||
export interface AlbumMediaFeed {
|
||||
cell_common: {
|
||||
time: string;
|
||||
};
|
||||
cell_user_info: {
|
||||
user: {
|
||||
uin: string;
|
||||
};
|
||||
};
|
||||
cell_media: {
|
||||
album_id: string;
|
||||
batch_id: string;
|
||||
media_items: Array<{
|
||||
image: {
|
||||
lloc: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建相册媒体请求参数
|
||||
* @param uin 用户QQ号
|
||||
* @param albumId 相册ID
|
||||
* @param lloc
|
||||
* @returns 媒体请求参数对象
|
||||
*/
|
||||
export function createAlbumMediaFeed(
|
||||
uin: string,
|
||||
albumId: string,
|
||||
lloc: string
|
||||
): AlbumMediaFeed {
|
||||
return {
|
||||
cell_common: {
|
||||
time: ""
|
||||
},
|
||||
cell_user_info: {
|
||||
user: {
|
||||
uin: uin
|
||||
}
|
||||
},
|
||||
cell_media: {
|
||||
album_id: albumId,
|
||||
batch_id: "",
|
||||
media_items: [{
|
||||
image: {
|
||||
lloc: lloc
|
||||
}
|
||||
}]
|
||||
}
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 相册评论内容接口
|
||||
*/
|
||||
export interface AlbumCommentContent {
|
||||
type: number;
|
||||
content: string;
|
||||
who: number;
|
||||
uid: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 相册评论请求接口
|
||||
*/
|
||||
export interface AlbumCommentReplyContent {
|
||||
client_key: number;
|
||||
content: AlbumCommentContent[];
|
||||
user: {
|
||||
uin: string;
|
||||
};
|
||||
}
|
||||
export enum RichMsgType {
|
||||
KRICHMSGTYPEPLAINTEXT,
|
||||
KRICHMSGTYPEAT,
|
||||
KRICHMSGTYPEURL,
|
||||
KRICHMSGTYPEMEDIA
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建相册评论请求参数
|
||||
* @param uin 用户QQ号
|
||||
* @param content 评论内容
|
||||
* @param client_key 客户端鉴权密钥
|
||||
* @returns 评论请求参数对象
|
||||
*/
|
||||
export function createAlbumCommentRequest(
|
||||
uin: string,
|
||||
content: string,
|
||||
client_key: number
|
||||
): AlbumCommentReplyContent {
|
||||
return {
|
||||
client_key: client_key,
|
||||
//暂时只支持纯文本吧
|
||||
content: [{
|
||||
type: RichMsgType.KRICHMSGTYPEPLAINTEXT,
|
||||
content: content,
|
||||
who: 0,
|
||||
uid: "",
|
||||
name: "",
|
||||
url: ""
|
||||
}],
|
||||
user: {
|
||||
uin: uin
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface AlbumFeedLikePublish {
|
||||
cell_common: {
|
||||
time: number;
|
||||
feed_id: string;
|
||||
};
|
||||
cell_user_info: {
|
||||
user: {
|
||||
uin: string;
|
||||
};
|
||||
};
|
||||
cell_media: {
|
||||
album_id: string;
|
||||
batch_id: number;
|
||||
media_items: Array<{
|
||||
type: number;
|
||||
image: {
|
||||
lloc: string;
|
||||
sloc: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
cell_qun_info: {
|
||||
qun_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建相册动态发布请求参数
|
||||
* @param qunId 群号
|
||||
* @param uin 用户QQ号
|
||||
* @param albumId 相册ID
|
||||
* @param lloc 信息
|
||||
* @param sloc 信息(可选,默认与lloc相同)
|
||||
* @returns 动态发布请求参数对象
|
||||
*/
|
||||
export function createAlbumFeedPublish(
|
||||
qunId: string,
|
||||
uin: string,
|
||||
albumId: string,
|
||||
lloc: string,
|
||||
sloc?: string
|
||||
): AlbumFeedLikePublish {
|
||||
return {
|
||||
cell_common: {
|
||||
time: Date.now(),
|
||||
feed_id: ""
|
||||
},
|
||||
cell_user_info: {
|
||||
user: {
|
||||
uin: uin
|
||||
}
|
||||
},
|
||||
cell_media: {
|
||||
album_id: albumId,
|
||||
batch_id: 0,
|
||||
media_items: [{
|
||||
type: 0,
|
||||
image: {
|
||||
lloc: lloc,
|
||||
sloc: sloc || lloc
|
||||
}
|
||||
}]
|
||||
},
|
||||
cell_qun_info: {
|
||||
qun_id: qunId
|
||||
}
|
||||
};
|
||||
}
|
||||
177
src/core/data/webapi.ts
Normal file
177
src/core/data/webapi.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
|
||||
export interface ControlReq {
|
||||
appid?: string;
|
||||
asy_upload?: number;
|
||||
biz_req?: BizReq;
|
||||
check_type?: number;
|
||||
checksum?: string;
|
||||
cmd?: string;
|
||||
env?: Env;
|
||||
file_len?: number;
|
||||
model?: number;
|
||||
session?: string;
|
||||
token?: Token;
|
||||
uin?: string;
|
||||
}
|
||||
|
||||
export interface BizReq {
|
||||
iAlbumTypeID: number;
|
||||
iBatchID: number;
|
||||
iBitmap: number;
|
||||
iDistinctUse: number;
|
||||
iNeedFeeds: number;
|
||||
iPicHight: number;
|
||||
iPicWidth: number;
|
||||
iUploadTime: number;
|
||||
iUploadType: number;
|
||||
iUpPicType: number;
|
||||
iWaterType: number;
|
||||
mapExt: MapExt;
|
||||
sAlbumID: string;
|
||||
sAlbumName: string;
|
||||
sPicDesc: string;
|
||||
sPicPath: string;
|
||||
sPicTitle: string;
|
||||
stExtendInfo: StExtendInfo;
|
||||
}
|
||||
|
||||
export interface MapExt {
|
||||
appid: string;
|
||||
userid: string;
|
||||
}
|
||||
|
||||
export interface StExtendInfo {
|
||||
mapParams: MapParams;
|
||||
}
|
||||
|
||||
export interface MapParams {
|
||||
batch_num: string;
|
||||
photo_num: string;
|
||||
video_num: string;
|
||||
}
|
||||
|
||||
export interface Env {
|
||||
deviceInfo: string;
|
||||
refer: string;
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
appid: number;
|
||||
data: string;
|
||||
type: number;
|
||||
}
|
||||
|
||||
export function qunAlbumControl({
|
||||
uin,
|
||||
group_id,
|
||||
pskey,
|
||||
pic_md5,
|
||||
img_size,
|
||||
img_name,
|
||||
sAlbumName,
|
||||
sAlbumID,
|
||||
photo_num = "1",
|
||||
video_num = "0",
|
||||
batch_num = "1"
|
||||
}: {
|
||||
uin: string,
|
||||
group_id: string,
|
||||
pskey: string,
|
||||
pic_md5: string,
|
||||
img_size: number,
|
||||
img_name: string,
|
||||
sAlbumName: string,
|
||||
sAlbumID: string,
|
||||
photo_num?: string,
|
||||
video_num?: string,
|
||||
batch_num?: string
|
||||
}
|
||||
): {
|
||||
control_req: ControlReq[]
|
||||
} {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
return {
|
||||
control_req: [
|
||||
{
|
||||
uin: uin,
|
||||
token: {
|
||||
type: 4,
|
||||
data: pskey,
|
||||
appid: 5
|
||||
},
|
||||
appid: "qun",
|
||||
checksum: pic_md5,
|
||||
check_type: 0,
|
||||
file_len: img_size,
|
||||
env: {
|
||||
refer: "qzone",
|
||||
deviceInfo: "h5"
|
||||
},
|
||||
model: 0,
|
||||
biz_req: {
|
||||
sPicTitle: img_name,
|
||||
sPicDesc: "",
|
||||
sAlbumName: sAlbumName,
|
||||
sAlbumID: sAlbumID,
|
||||
iAlbumTypeID: 0,
|
||||
iBitmap: 0,
|
||||
iUploadType: 0,
|
||||
iUpPicType: 0,
|
||||
iBatchID: timestamp,
|
||||
sPicPath: "",
|
||||
iPicWidth: 0,
|
||||
iPicHight: 0,
|
||||
iWaterType: 0,
|
||||
iDistinctUse: 0,
|
||||
iNeedFeeds: 1,
|
||||
iUploadTime: timestamp,
|
||||
mapExt: {
|
||||
appid: "qun",
|
||||
userid: group_id
|
||||
},
|
||||
stExtendInfo: {
|
||||
mapParams: {
|
||||
photo_num: photo_num,
|
||||
video_num: video_num,
|
||||
batch_num: batch_num
|
||||
}
|
||||
}
|
||||
},
|
||||
session: "",
|
||||
asy_upload: 0,
|
||||
cmd: "FileUpload"
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
export function createStreamUpload(
|
||||
{
|
||||
uin,
|
||||
session,
|
||||
offset,
|
||||
seq,
|
||||
end,
|
||||
slice_size,
|
||||
data
|
||||
|
||||
}: { uin: string, session: string, offset: number, seq: number, end: number, slice_size: number, data: string }
|
||||
) {
|
||||
return {
|
||||
uin: uin,
|
||||
appid: "qun",
|
||||
session: session,
|
||||
offset: offset,//分片起始位置
|
||||
data: data,//base64编码数据
|
||||
checksum: "",
|
||||
check_type: 0,
|
||||
retry: 0,//重试次数
|
||||
seq: seq,//分片序号
|
||||
end: end,//分片结束位置 文件总大小
|
||||
cmd: "FileUpload",
|
||||
slice_size: slice_size,//分片大小16KB 16384
|
||||
biz_req: {
|
||||
iUploadType: 3
|
||||
}
|
||||
};
|
||||
}
|
||||
140
src/core/external/appid.json
vendored
140
src/core/external/appid.json
vendored
@@ -302,5 +302,145 @@
|
||||
"9.9.19-35341": {
|
||||
"appid": 537291347,
|
||||
"qua": "V1_WIN_NQ_9.9.19_35341_GW_B"
|
||||
},
|
||||
"9.9.19-35469": {
|
||||
"appid": 537291398,
|
||||
"qua": "V1_WIN_NQ_9.9.19_35469_GW_B"
|
||||
},
|
||||
"3.2.18-35951": {
|
||||
"appid": 537296013,
|
||||
"qua": "V1_LNX_NQ_3.2.18_35951_GW_B"
|
||||
},
|
||||
"9.9.20-35951": {
|
||||
"appid": 537295977,
|
||||
"qua": "V1_WIN_NQ_9.9.20_35951_GW_B"
|
||||
},
|
||||
"3.2.18-36580": {
|
||||
"appid": 537298509,
|
||||
"qua": "V1_LNX_NQ_3.2.18_36580_GW_B"
|
||||
},
|
||||
"9.9.20-36580": {
|
||||
"appid": 537298473,
|
||||
"qua": "V1_WIN_NQ_9.9.20_36580_GW_B"
|
||||
},
|
||||
"9.9.20-37012": {
|
||||
"appid": 537304071,
|
||||
"qua": "V1_WIN_NQ_9.9.20_37012_GW_B"
|
||||
},
|
||||
"3.2.18-37012": {
|
||||
"appid": 537304107,
|
||||
"qua": "V1_LNX_NQ_3.2.18_37012_GW_B"
|
||||
},
|
||||
"3.2.18-37051": {
|
||||
"appid": 537304158,
|
||||
"qua": "V1_LNX_NQ_3.2.18_37051_GW_B"
|
||||
},
|
||||
"9.9.20-37051": {
|
||||
"appid": 537304122,
|
||||
"qua": "V1_WIN_NQ_9.9.20_37051_GW_B"
|
||||
},
|
||||
"9.9.20-37475": {
|
||||
"appid": 537304173,
|
||||
"qua": "V1_WIN_NQ_9.9.20_37475_GW_B"
|
||||
},
|
||||
"3.2.18-37475": {
|
||||
"appid": 537304210,
|
||||
"qua": "V1_LNX_NQ_3.2.18_37475_GW_B"
|
||||
},
|
||||
"9.9.20-37625": {
|
||||
"appid": 537304224,
|
||||
"qua": "V1_WIN_NQ_9.9.20_37625_GW_B"
|
||||
},
|
||||
"3.2.18-37625": {
|
||||
"appid": 537304261,
|
||||
"qua": "V1_LNX_NQ_3.2.18_37625_GW_B"
|
||||
},
|
||||
"9.9.21-38503": {
|
||||
"appid": 537307604,
|
||||
"qua": "V1_WIN_NQ_9.9.21_38503_GW_B"
|
||||
},
|
||||
"3.2.19-38503": {
|
||||
"appid": 537307640,
|
||||
"qua": "V1_LNX_NQ_3.2.19_38503_GW_B"
|
||||
},
|
||||
"3.2.19-38626": {
|
||||
"appid": 537307691,
|
||||
"qua": "V1_LNX_NQ_3.2.19_38626_GW_B"
|
||||
},
|
||||
"9.9.21-38711": {
|
||||
"appid": 537307655,
|
||||
"qua": "V1_WIN_NQ_9.9.21_38626_GW_B"
|
||||
},
|
||||
"9.9.21-38960": {
|
||||
"appid": 537313855,
|
||||
"qua": "V1_WIN_NQ_9.9.21_38960_GW_B"
|
||||
},
|
||||
"3.2.19-38960": {
|
||||
"appid": 537313891,
|
||||
"qua": "V1_LNX_NQ_3.2.19_38960_GW_B"
|
||||
},
|
||||
"3.2.19-39038": {
|
||||
"appid": 537313942,
|
||||
"qua": "V1_LNX_NQ_3.2.19_39038_GW_B"
|
||||
},
|
||||
"9.9.21-39038": {
|
||||
"appid": 537313906,
|
||||
"qua": "V1_WIN_NQ_9.9.21_39038_GW_B"
|
||||
},
|
||||
"9.9.22-40362": {
|
||||
"appid": 537314212,
|
||||
"qua": "V1_WIN_NQ_9.9.22_40362_GW_B"
|
||||
},
|
||||
"3.2.20-40768": {
|
||||
"appid": 537319840,
|
||||
"qua": "V1_LNX_NQ_3.2.20_40768_GW_B"
|
||||
},
|
||||
"9.9.22-40768": {
|
||||
"appid": 537319804,
|
||||
"qua": "V1_WIN_NQ_9.9.22_40768_GW_B"
|
||||
},
|
||||
"6.9.82-40768": {
|
||||
"appid": 537319829,
|
||||
"qua": "V1_MAC_NQ_6.9.82_40768_GW_B"
|
||||
},
|
||||
"3.2.20-40824": {
|
||||
"appid": 537319840,
|
||||
"qua": "V1_LNX_NQ_3.2.20_40824_GW_B"
|
||||
},
|
||||
"9.9.22-40824": {
|
||||
"appid": 537319804,
|
||||
"qua": "V1_WIN_NQ_9.9.22_40824_GW_B"
|
||||
},
|
||||
"6.9.82-40824": {
|
||||
"appid": 537319829,
|
||||
"qua": "V1_MAC_NQ_6.9.82_40824_GW_B"
|
||||
},
|
||||
"6.9.82-40990": {
|
||||
"appid": 537319880,
|
||||
"qua": "V1_MAC_NQ_6.9.82_40990_GW_B"
|
||||
},
|
||||
"9.9.22-40990": {
|
||||
"appid": 537319855,
|
||||
"qua": "V1_WIN_NQ_9.9.22.40990_GW_B"
|
||||
},
|
||||
"3.2.20-40990": {
|
||||
"appid": 537319891,
|
||||
"qua": "V1_LNX_NQ_3.2.20_40990_GW_B"
|
||||
},
|
||||
"9.9.23-41679": {
|
||||
"appid": 537320110,
|
||||
"qua": "V1_WIN_NQ_9.9.23_41679_GW_B"
|
||||
},
|
||||
"6.8.83-41679": {
|
||||
"appid": 537320135,
|
||||
"qua": "V1_MAC_NQ_6.9.83_41679_GW_B"
|
||||
},
|
||||
"9.9.23-41785": {
|
||||
"appid": 537320110,
|
||||
"qua": "V1_WIN_NQ_9.9.23_41785_GW_B"
|
||||
},
|
||||
"6.8.83-41785": {
|
||||
"appid": 537320135,
|
||||
"qua": "V1_MAC_NQ_6.9.83_41785_GW_B"
|
||||
}
|
||||
}
|
||||
66
src/core/external/napi2native.json
vendored
Normal file
66
src/core/external/napi2native.json
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"9.9.22-40990-x64": {
|
||||
"send": "1B5699C",
|
||||
"recv": "1D8CA9D"
|
||||
},
|
||||
"9.9.22-40824-x64": {
|
||||
"send": "1B5699C",
|
||||
"recv": "1D8CA9D"
|
||||
},
|
||||
"9.9.22-40768-x64": {
|
||||
"send": "1B5699C",
|
||||
"recv": "1D8CA9D"
|
||||
},
|
||||
"3.2.20-40768-x64": {
|
||||
"send": "2A1B840",
|
||||
"recv": "2D28F20"
|
||||
},
|
||||
"3.2.20-40824-x64": {
|
||||
"send": "2A1B840",
|
||||
"recv": "2D28F20"
|
||||
},
|
||||
"3.2.20-40990-x64": {
|
||||
"send": "2A1B840",
|
||||
"recv": "2D28F20"
|
||||
},
|
||||
"3.2.20-40990-arm64": {
|
||||
"send": "157C0E8",
|
||||
"recv": "1546658"
|
||||
},
|
||||
"3.2.20-40824-arm64": {
|
||||
"send": "157C0E8",
|
||||
"recv": "1546658"
|
||||
},
|
||||
"3.2.20-40768-arm64": {
|
||||
"send": "157C0E8",
|
||||
"recv": "1546658"
|
||||
},
|
||||
"9.9.23-41679-x64": {
|
||||
"send": "09FF0F4",
|
||||
"recv": "1D1A039"
|
||||
},
|
||||
"6.9.82-40824-arm64": {
|
||||
"send": "05FA930",
|
||||
"recv": "0B41B90"
|
||||
},
|
||||
"6.9.82-40768-arm64": {
|
||||
"send": "05FA930",
|
||||
"recv": "0B41B90"
|
||||
},
|
||||
"6.9.82-40990-arm64": {
|
||||
"send": "05FA930",
|
||||
"recv": "0B41B90"
|
||||
},
|
||||
"6.9.83-41679-arm64": {
|
||||
"send": "237D114",
|
||||
"recv": "0957648"
|
||||
},
|
||||
"6.9.83-41785-arm64": {
|
||||
"send": "23B0BF0",
|
||||
"recv": "095567C"
|
||||
},
|
||||
"9.9.23-41785-x64": {
|
||||
"send": "09FF0A4",
|
||||
"recv": "1D19FF9"
|
||||
}
|
||||
}
|
||||
@@ -383,6 +383,10 @@
|
||||
"send": "3BF1D50",
|
||||
"recv": "3BF6550"
|
||||
},
|
||||
"9.9.19-35469-x64": {
|
||||
"send": "3BF1D50",
|
||||
"recv": "3BF6550"
|
||||
},
|
||||
"3.2.17-35341-x64": {
|
||||
"send": "AE2F700",
|
||||
"recv": "AE33120"
|
||||
@@ -390,5 +394,185 @@
|
||||
"3.2.17-35341-arm64": {
|
||||
"send": "778D840",
|
||||
"recv": "7791170"
|
||||
},
|
||||
"9.9.20-35951-x64": {
|
||||
"send": "3034BAC",
|
||||
"recv": "3038354"
|
||||
},
|
||||
"3.2.18-35951-x64": {
|
||||
"send": "AFBBB00",
|
||||
"recv": "AFBF520"
|
||||
},
|
||||
"9.9.20-36580-x64": {
|
||||
"send": "30824B8",
|
||||
"recv": "3085C5C"
|
||||
},
|
||||
"3.2.18-36580-x64": {
|
||||
"send": "B0853E0",
|
||||
"recv": "B088E60"
|
||||
},
|
||||
"3.2.18-36580-arm64": {
|
||||
"send": "793DAC8",
|
||||
"recv": "7941458"
|
||||
},
|
||||
"3.2.18-37012-x64": {
|
||||
"send": "B20F960",
|
||||
"recv": "B2133E0"
|
||||
},
|
||||
"3.2.18-37012-arm64": {
|
||||
"send": "7A19E00",
|
||||
"recv": "7A1D790"
|
||||
},
|
||||
"9.9.20-37012-x64": {
|
||||
"send": "30CC958",
|
||||
"recv": "30D00FC"
|
||||
},
|
||||
"3.2.18-37051-x64": {
|
||||
"send": "B20F960",
|
||||
"recv": "B2133E0"
|
||||
},
|
||||
"3.2.18-37051-arm64": {
|
||||
"send": "7A19E00",
|
||||
"recv": "7A1D790"
|
||||
},
|
||||
"9.9.20-37051-x64": {
|
||||
"send": "30CC958",
|
||||
"recv": "30D00FC"
|
||||
},
|
||||
"9.9.20-37475-x64": {
|
||||
"send": "30D30D8",
|
||||
"recv": "30D687C"
|
||||
},
|
||||
"3.2.18-37475-x64": {
|
||||
"send": "B238EC0",
|
||||
"recv": "B23C940"
|
||||
},
|
||||
"3.2.18-37475-arm64": {
|
||||
"send": "7A34B38",
|
||||
"recv": "7A384C8"
|
||||
},
|
||||
"9.9.20-37625-x64": {
|
||||
"send": "30D39D8",
|
||||
"recv": "30D717C"
|
||||
},
|
||||
"3.2.18-37625-x64": {
|
||||
"send": "B2397E0",
|
||||
"recv": "B23D260"
|
||||
},
|
||||
"3.2.18-37625-arm64": {
|
||||
"send": "7A350D8",
|
||||
"recv": "7A38A68"
|
||||
},
|
||||
"9.9.21-38503-x64": {
|
||||
"send": "3105F38",
|
||||
"recv": "31096DC"
|
||||
},
|
||||
"3.2.19-38503-x64": {
|
||||
"send": "B2C1A60",
|
||||
"recv": "B2C54E0"
|
||||
},
|
||||
"3.2.19-38626-x64": {
|
||||
"send": "B2C1BE0",
|
||||
"recv": "B2C5660"
|
||||
},
|
||||
"9.9.21-38626-x64": {
|
||||
"send": "310A758",
|
||||
"recv": "310DEFC"
|
||||
},
|
||||
"3.2.19-38626-arm64": {
|
||||
"send": "7A8A490",
|
||||
"recv": "7A8DE20"
|
||||
},
|
||||
"9.9.21-38711-x64": {
|
||||
"send": "310A758",
|
||||
"recv": "310DEFC"
|
||||
},
|
||||
"3.2.19-38960-x64": {
|
||||
"send": "B3740E0",
|
||||
"recv": "B377B60"
|
||||
},
|
||||
"9.9.21-38960-x64": {
|
||||
"send": "313F7D8",
|
||||
"recv": "3142F7C"
|
||||
},
|
||||
"3.2.19-38960-arm64": {
|
||||
"send": "7B01D98",
|
||||
"recv": "7B05728"
|
||||
},
|
||||
"3.2.19-39038-x64": {
|
||||
"send": "B3759E0",
|
||||
"recv": "B379460"
|
||||
},
|
||||
"3.2.19-39038-arm64": {
|
||||
"send": "7B025C8",
|
||||
"recv": "7B05F58"
|
||||
},
|
||||
"9.9.21-39038-x64": {
|
||||
"send": "313FB58",
|
||||
"recv": "31432FC"
|
||||
},
|
||||
"9.9.22-40362-x64": {
|
||||
"send": "31C0EB8",
|
||||
"recv": "31C465C"
|
||||
},
|
||||
"3.2.20-40768-x64": {
|
||||
"send": "B69CFE0",
|
||||
"recv": "B6A0A60"
|
||||
},
|
||||
"9.9.22-40768-x64": {
|
||||
"send": "31C1838",
|
||||
"recv": "31C4FDC"
|
||||
},
|
||||
"3.2.20-40768-arm64": {
|
||||
"send": "7D49B18",
|
||||
"recv": "7D4D4A8"
|
||||
},
|
||||
"6.9.82-40768-arm64": {
|
||||
"send": "202A198",
|
||||
"recv": "202B718"
|
||||
},
|
||||
"9.9.22-40824-x64": {
|
||||
"send": "31C1838",
|
||||
"recv": "31C4FDC"
|
||||
},
|
||||
"3.2.20-40824-arm64": {
|
||||
"send": "7D49B18",
|
||||
"recv": "7D4D4A8"
|
||||
},
|
||||
"6.9.82-40824-arm64": {
|
||||
"send": "202A198",
|
||||
"recv": "202B718"
|
||||
},
|
||||
"3.2.20-40990-x64": {
|
||||
"send": "B69CFE0",
|
||||
"recv": "B6A0A60"
|
||||
},
|
||||
"3.2.20-40990-arm64": {
|
||||
"send": "7D49B18",
|
||||
"recv": "7D4D4A8"
|
||||
},
|
||||
"9.9.22-40990-x64": {
|
||||
"send": "31C1838",
|
||||
"recv": "31C4FDC"
|
||||
},
|
||||
"6.9.82-40990-arm64": {
|
||||
"send": "202A198",
|
||||
"recv": "202B718"
|
||||
},
|
||||
"9.9.23-41679-x64": {
|
||||
"send": "2C94520",
|
||||
"recv": "2C97AA0"
|
||||
},
|
||||
"6.9.83-41679-arm64": {
|
||||
"send": "3D718F8",
|
||||
"recv": "3D74208"
|
||||
},
|
||||
"9.9.23-41785-x64": {
|
||||
"send": "2C944A0",
|
||||
"recv": "2C97A20"
|
||||
},
|
||||
"6.8.83-41785-arm64": {
|
||||
"send": "3D6DA28",
|
||||
"recv": "3D70338"
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import os from 'node:os';
|
||||
import { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/core/listeners';
|
||||
import { proxiedListenerOf } from '@/common/proxy-handler';
|
||||
import { NTQQPacketApi } from './apis/packet';
|
||||
import { NativePacketHandler } from './packet/handler/client';
|
||||
export * from './wrapper';
|
||||
export * from './types';
|
||||
export * from './services';
|
||||
@@ -258,6 +259,7 @@ export interface InstanceContext {
|
||||
readonly loginService: NodeIKernelLoginService;
|
||||
readonly basicInfoWrapper: QQBasicInfoWrapper;
|
||||
readonly pathWrapper: NapCatPathWrapper;
|
||||
readonly packetHandler: NativePacketHandler;
|
||||
}
|
||||
|
||||
export interface StableNTApiWrapper {
|
||||
|
||||
@@ -40,7 +40,6 @@ export class NodeIKernelBuddyListener {
|
||||
}
|
||||
|
||||
onDelBatchBuddyInfos(_arg: unknown): any {
|
||||
console.log('onDelBatchBuddyInfos not implemented', ...arguments);
|
||||
}
|
||||
|
||||
onDoubtBuddyReqChange(_arg:
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { LRUCache } from '@/common/lru-cache';
|
||||
import crypto, { createHash } from 'crypto';
|
||||
import { OidbPacket, PacketHexStr } from '@/core/packet/transformer/base';
|
||||
import { LogStack } from '@/core/packet/context/clientContext';
|
||||
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
|
||||
import { PacketLogger } from '@/core/packet/context/loggerContext';
|
||||
|
||||
export interface RecvPacket {
|
||||
type: string, // 仅recv
|
||||
trace_id_md5?: string,
|
||||
data: RecvPacketData
|
||||
}
|
||||
|
||||
export interface RecvPacketData {
|
||||
seq: number
|
||||
cmd: string
|
||||
hex_data: string
|
||||
}
|
||||
|
||||
function randText(len: number): string {
|
||||
let text = '';
|
||||
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
for (let i = 0; i < len; i++) {
|
||||
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
|
||||
export abstract class IPacketClient {
|
||||
protected readonly napcore: NapCoreContext;
|
||||
protected readonly logger: PacketLogger;
|
||||
protected readonly cb = new LRUCache<string, (json: RecvPacketData) => Promise<void>>(500); // trace_id-type callback
|
||||
logStack: LogStack;
|
||||
available: boolean = false;
|
||||
|
||||
protected constructor(napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) {
|
||||
this.napcore = napCore;
|
||||
this.logger = logger;
|
||||
this.logStack = logStack;
|
||||
}
|
||||
|
||||
abstract check(): boolean;
|
||||
|
||||
abstract init(pid: number, recv: string, send: string): Promise<void>;
|
||||
|
||||
abstract sendCommandImpl(cmd: string, data: string, trace_id: string): void;
|
||||
|
||||
private async registerCallback(trace_id: string, type: string, callback: (json: RecvPacketData) => Promise<void>): Promise<void> {
|
||||
this.cb.put(createHash('md5').update(trace_id).digest('hex') + type, callback);
|
||||
}
|
||||
|
||||
private async sendCommand(cmd: string, data: string, trace_id: string, rsp: boolean = false, timeout: number = 20000, sendcb: (json: RecvPacketData) => void = () => {
|
||||
}): Promise<RecvPacketData> {
|
||||
return new Promise<RecvPacketData>((resolve, reject) => {
|
||||
if (!this.available) {
|
||||
reject(new Error('packetBackend 当前不可用!'));
|
||||
}
|
||||
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
reject(new Error(`sendCommand timed out after ${timeout} ms for ${cmd} with trace_id ${trace_id}`));
|
||||
}, timeout);
|
||||
|
||||
this.registerCallback(trace_id, 'send', async (json: RecvPacketData) => {
|
||||
sendcb(json);
|
||||
if (!rsp) {
|
||||
clearTimeout(timeoutHandle);
|
||||
resolve(json);
|
||||
}
|
||||
});
|
||||
|
||||
if (rsp) {
|
||||
this.registerCallback(trace_id, 'recv', async (json: RecvPacketData) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
resolve(json);
|
||||
});
|
||||
}
|
||||
|
||||
this.sendCommandImpl(cmd, data, trace_id);
|
||||
});
|
||||
}
|
||||
|
||||
async sendPacket(cmd: string, data: PacketHexStr, rsp = false): Promise<RecvPacketData> {
|
||||
const md5 = crypto.createHash('md5').update(data).digest('hex');
|
||||
const trace_id = (randText(4) + md5 + data).slice(0, data.length / 2);
|
||||
return this.sendCommand(cmd, data, trace_id, rsp, 20000, async () => {
|
||||
await this.napcore.sendSsoCmdReqByContend(cmd, trace_id);
|
||||
});
|
||||
}
|
||||
|
||||
async sendOidbPacket(pkt: OidbPacket, rsp = false): Promise<RecvPacketData> {
|
||||
return this.sendPacket(pkt.cmd, pkt.data, rsp);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,40 @@
|
||||
import { createHash } from 'crypto';
|
||||
import path, { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
import { IPacketClient } from '@/core/packet/client/baseClient';
|
||||
import { constants } from 'node:os';
|
||||
import { LRUCache } from '@/common/lru-cache';
|
||||
import { LogStack } from '@/core/packet/context/clientContext';
|
||||
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
|
||||
import { PacketLogger } from '@/core/packet/context/loggerContext';
|
||||
import { OidbPacket, PacketBuf } from '@/core/packet/transformer/base';
|
||||
export interface RecvPacket {
|
||||
type: string, // 仅recv
|
||||
data: RecvPacketData
|
||||
}
|
||||
|
||||
export interface RecvPacketData {
|
||||
seq: number
|
||||
cmd: string
|
||||
data: Buffer
|
||||
}
|
||||
|
||||
// 0 send 1 recv
|
||||
export interface NativePacketExportType {
|
||||
InitHook?: (send: string, recv: string, callback: (type: number, uin: string, cmd: string, seq: number, hex_data: string) => void, o3_hook: boolean) => boolean;
|
||||
SendPacket?: (cmd: string, data: string, trace_id: string) => void;
|
||||
initHook?: (send: string, recv: string) => boolean;
|
||||
}
|
||||
|
||||
export class NativePacketClient extends IPacketClient {
|
||||
export class NativePacketClient {
|
||||
protected readonly napcore: NapCoreContext;
|
||||
protected readonly logger: PacketLogger;
|
||||
protected readonly cb = new Map<string, (json: RecvPacketData) => Promise<any> | any>(); // hash-type callback
|
||||
logStack: LogStack;
|
||||
available: boolean = false;
|
||||
private readonly supportedPlatforms = ['win32.x64', 'linux.x64', 'linux.arm64', 'darwin.x64', 'darwin.arm64'];
|
||||
private readonly MoeHooExport: { exports: NativePacketExportType } = { exports: {} };
|
||||
private readonly sendEvent = new LRUCache<number, string>(500); // seq->trace_id
|
||||
|
||||
constructor(napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) {
|
||||
super(napCore, logger, logStack);
|
||||
this.napcore = napCore;
|
||||
this.logger = logger;
|
||||
this.logStack = logStack;
|
||||
}
|
||||
|
||||
check(): boolean {
|
||||
@@ -30,7 +43,7 @@ export class NativePacketClient extends IPacketClient {
|
||||
this.logStack.pushLogWarn(`NativePacketClient: 不支持的平台: ${platform}`);
|
||||
return false;
|
||||
}
|
||||
const moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './moehoo/MoeHoo.' + platform + '.node');
|
||||
const moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './native/napi2native/napi2native.' + platform + '.node');
|
||||
if (!fs.existsSync(moehoo_path)) {
|
||||
this.logStack.pushLogWarn(`NativePacketClient: 缺失运行时文件: ${moehoo_path}`);
|
||||
return false;
|
||||
@@ -40,29 +53,55 @@ export class NativePacketClient extends IPacketClient {
|
||||
|
||||
async init(_pid: number, recv: string, send: string): Promise<void> {
|
||||
const platform = process.platform + '.' + process.arch;
|
||||
const moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './moehoo/MoeHoo.' + platform + '.node');
|
||||
process.dlopen(this.MoeHooExport, moehoo_path, constants.dlopen.RTLD_LAZY);
|
||||
|
||||
this.MoeHooExport.exports.InitHook?.(send, recv, (type: number, _uin: string, cmd: string, seq: number, hex_data: string) => {
|
||||
const trace_id = createHash('md5').update(Buffer.from(hex_data, 'hex')).digest('hex');
|
||||
if (type === 0 && this.cb.get(trace_id + 'recv')) {
|
||||
//此时为send 提取seq
|
||||
this.sendEvent.put(seq, trace_id);
|
||||
}
|
||||
if (type === 1 && this.sendEvent.get(seq)) {
|
||||
//此时为recv 调用callback
|
||||
const trace_id = this.sendEvent.get(seq);
|
||||
const callback = this.cb.get(trace_id + 'recv');
|
||||
// console.log('callback:', callback, trace_id);
|
||||
callback?.({ seq, cmd, hex_data });
|
||||
}
|
||||
}, this.napcore.config.o3HookMode == 1);
|
||||
this.available = true;
|
||||
const isNewQQ = this.napcore.basicInfo.requireMinNTQQBuild("40824");
|
||||
if (isNewQQ) {
|
||||
const moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './native/napi2native/napi2native.' + platform + '.node');
|
||||
process.dlopen(this.MoeHooExport, moehoo_path, constants.dlopen.RTLD_LAZY);
|
||||
this.MoeHooExport?.exports.initHook?.(send, recv);
|
||||
this.available = true;
|
||||
}
|
||||
}
|
||||
|
||||
sendCommandImpl(cmd: string, data: string, trace_id: string): void {
|
||||
const trace_id_md5 = createHash('md5').update(trace_id).digest('hex');
|
||||
this.MoeHooExport.exports.SendPacket?.(cmd, data, trace_id_md5);
|
||||
this.cb.get(trace_id_md5 + 'send')?.({ seq: 0, cmd, hex_data: '' });
|
||||
async sendPacket(
|
||||
cmd: string,
|
||||
data: PacketBuf,
|
||||
rsp = false,
|
||||
timeout = 5000
|
||||
): Promise<RecvPacketData> {
|
||||
if (!rsp) {
|
||||
this.napcore
|
||||
.sendSsoCmdReqByContend(cmd, data)
|
||||
.catch(err =>
|
||||
this.logger.error(
|
||||
`[PacketClient] sendPacket 无响应命令发送失败 cmd=${cmd} err=${err}`
|
||||
)
|
||||
);
|
||||
return { seq: 0, cmd, data: Buffer.alloc(0) };
|
||||
}
|
||||
|
||||
const sendPromise = this.napcore
|
||||
.sendSsoCmdReqByContend(cmd, data)
|
||||
.then(ret => ({
|
||||
seq: 0,
|
||||
cmd,
|
||||
data: (ret as { rspbuffer: Buffer }).rspbuffer
|
||||
}));
|
||||
|
||||
const timeoutPromise = new Promise<RecvPacketData>((_, reject) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`[PacketClient] sendPacket 超时 cmd=${cmd} timeout=${timeout}ms`
|
||||
)
|
||||
),
|
||||
timeout
|
||||
);
|
||||
});
|
||||
|
||||
return Promise.race([sendPromise, timeoutPromise]);
|
||||
}
|
||||
async sendOidbPacket(pkt: OidbPacket, rsp = false, timeout = 5000): Promise<RecvPacketData> {
|
||||
return await this.sendPacket(pkt.cmd, pkt.data, rsp, timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { IPacketClient } from '@/core/packet/client/baseClient';
|
||||
import { NativePacketClient } from '@/core/packet/client/nativeClient';
|
||||
import { OidbPacket } from '@/core/packet/transformer/base';
|
||||
import { PacketLogger } from '@/core/packet/context/loggerContext';
|
||||
import { NapCoreContext } from '@/core/packet/context/napCoreContext';
|
||||
|
||||
type clientPriorityType = {
|
||||
[key: number]: (napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) => IPacketClient;
|
||||
}
|
||||
|
||||
const clientPriority: clientPriorityType = {
|
||||
10: (napCore: NapCoreContext, logger: PacketLogger, logStack: LogStack) => new NativePacketClient(napCore, logger, logStack)
|
||||
};
|
||||
|
||||
export class LogStack {
|
||||
private stack: string[] = [];
|
||||
private readonly logger: PacketLogger;
|
||||
@@ -52,7 +43,7 @@ export class PacketClientContext {
|
||||
private readonly napCore: NapCoreContext;
|
||||
private readonly logger: PacketLogger;
|
||||
private readonly logStack: LogStack;
|
||||
private readonly _client: IPacketClient;
|
||||
private readonly _client: NativePacketClient;
|
||||
|
||||
constructor(napCore: NapCoreContext, logger: PacketLogger) {
|
||||
this.napCore = napCore;
|
||||
@@ -73,50 +64,17 @@ export class PacketClientContext {
|
||||
await this._client.init(pid, recv, send);
|
||||
}
|
||||
|
||||
async sendOidbPacket<T extends boolean = false>(pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
|
||||
const raw = await this._client.sendOidbPacket(pkt, rsp);
|
||||
return (rsp ? Buffer.from(raw.hex_data, 'hex') : undefined) as T extends true ? Buffer : void;
|
||||
async sendOidbPacket<T extends boolean = false>(pkt: OidbPacket, rsp?: T, timeout?: number): Promise<T extends true ? Buffer : void> {
|
||||
const raw = await this._client.sendOidbPacket(pkt, rsp, timeout);
|
||||
return raw.data as T extends true ? Buffer : void;
|
||||
}
|
||||
|
||||
private newClient(): IPacketClient {
|
||||
const prefer = this.napCore.config.packetBackend;
|
||||
let client: IPacketClient | null;
|
||||
switch (prefer) {
|
||||
case 'native':
|
||||
this.logger.info('使用指定的 NativePacketClient 作为后端');
|
||||
client = new NativePacketClient(this.napCore, this.logger, this.logStack);
|
||||
break;
|
||||
case 'auto':
|
||||
case undefined:
|
||||
client = this.judgeClient();
|
||||
break;
|
||||
default:
|
||||
this.logger.error(`未知的PacketBackend ${prefer},请检查配置文件!`);
|
||||
client = null;
|
||||
}
|
||||
if (!client?.check()) {
|
||||
throw new Error('[Core] [Packet] 无可用的后端,NapCat.Packet将不会加载!');
|
||||
}
|
||||
if (!client) {
|
||||
throw new Error('[Core] [Packet] 后端异常,NapCat.Packet将不会加载!');
|
||||
private newClient(): NativePacketClient {
|
||||
this.logger.info('使用 NativePacketClient 作为后端');
|
||||
const client = new NativePacketClient(this.napCore, this.logger, this.logStack);
|
||||
if (!client.check()) {
|
||||
throw new Error('[Core] [Packet] NativePacketClient 不可用,NapCat.Packet将不会加载!');
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
private judgeClient(): IPacketClient {
|
||||
const sortedClients = Object.entries(clientPriority)
|
||||
.map(([priority, clientFactory]) => {
|
||||
const client = clientFactory(this.napCore, this.logger, this.logStack);
|
||||
const score = +priority * +client.check();
|
||||
return { client, score };
|
||||
})
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
const selectedClient = sortedClients[0]?.client;
|
||||
if (!selectedClient) {
|
||||
throw new Error('[Core] [Packet] 无可用的后端,NapCat.Packet将不会加载!');
|
||||
}
|
||||
this.logger.info(`自动选择 ${selectedClient.constructor.name} 作为后端`);
|
||||
return selectedClient;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NapCatCore } from '@/core';
|
||||
|
||||
export interface NapCoreCompatBasicInfo {
|
||||
readonly requireMinNTQQBuild: (buildVer: string) => boolean;
|
||||
readonly uin: number;
|
||||
readonly uid: string;
|
||||
readonly uin2uid: (uin: number) => Promise<string>;
|
||||
@@ -21,6 +22,7 @@ export class NapCoreContext {
|
||||
|
||||
get basicInfo() {
|
||||
return {
|
||||
requireMinNTQQBuild: (buildVer: string) => this.core.context.basicInfoWrapper.requireMinNTQQBuild(buildVer),
|
||||
uin: +this.core.selfInfo.uin,
|
||||
uid: this.core.selfInfo.uid,
|
||||
uin2uid: (uin: number) => this.core.apis.UserApi.getUidByUinV2(String(uin)).then(res => res ?? ''),
|
||||
@@ -32,5 +34,5 @@ export class NapCoreContext {
|
||||
return this.core.configLoader.configData;
|
||||
}
|
||||
|
||||
sendSsoCmdReqByContend = (cmd: string, trace_id: string) => this.core.context.session.getMsgService().sendSsoCmdReqByContend(cmd, trace_id);
|
||||
sendSsoCmdReqByContend = (cmd: string, data: Buffer) => this.core.context.session.getMsgService().sendSsoCmdReqByContend(cmd, data);
|
||||
}
|
||||
|
||||
@@ -34,10 +34,13 @@ export class PacketOperationContext {
|
||||
const req = trans.SendPoke.build(is_group, peer, target ?? peer);
|
||||
await this.context.client.sendOidbPacket(req);
|
||||
}
|
||||
|
||||
async FetchRkey() {
|
||||
async SetGroupTodo(groupUin: number, msgSeq: string) {
|
||||
const req = trans.SetGroupTodo.build(groupUin, msgSeq);
|
||||
await this.context.client.sendOidbPacket(req, true);
|
||||
}
|
||||
async FetchRkey(timeout: number = 10000) {
|
||||
const req = trans.FetchRkey.build();
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
|
||||
const res = trans.FetchRkey.parse(resp);
|
||||
return res.data.rkeyList;
|
||||
}
|
||||
@@ -119,37 +122,37 @@ export class PacketOperationContext {
|
||||
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
|
||||
}
|
||||
|
||||
async GetPttUrl(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>) {
|
||||
async GetPttUrl(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>, timeout?: number) {
|
||||
const req = trans.DownloadPtt.build(selfUid, node);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
|
||||
const res = trans.DownloadPtt.parse(resp);
|
||||
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
|
||||
}
|
||||
|
||||
async GetVideoUrl(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>) {
|
||||
async GetVideoUrl(selfUid: string, node: NapProtoEncodeStructType<typeof IndexNode>, timeout?: number) {
|
||||
const req = trans.DownloadVideo.build(selfUid, node);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
|
||||
const res = trans.DownloadVideo.parse(resp);
|
||||
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
|
||||
}
|
||||
|
||||
async GetGroupImageUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>) {
|
||||
async GetGroupImageUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>, timeout?: number) {
|
||||
const req = trans.DownloadGroupImage.build(groupUin, node);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
|
||||
const res = trans.DownloadImage.parse(resp);
|
||||
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
|
||||
}
|
||||
|
||||
async GetGroupPttUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>) {
|
||||
async GetGroupPttUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>, timeout?: number) {
|
||||
const req = trans.DownloadGroupPtt.build(groupUin, node);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
|
||||
const res = trans.DownloadImage.parse(resp);
|
||||
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
|
||||
}
|
||||
|
||||
async GetGroupVideoUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>) {
|
||||
async GetGroupVideoUrl(groupUin: number, node: NapProtoEncodeStructType<typeof IndexNode>, timeout: number = 20000) {
|
||||
const req = trans.DownloadGroupVideo.build(groupUin, node);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
|
||||
const res = trans.DownloadImage.parse(resp);
|
||||
return `https://${res.download.info.domain}${res.download.info.urlPath}${res.download.rKeyParam}`;
|
||||
}
|
||||
@@ -240,16 +243,16 @@ export class PacketOperationContext {
|
||||
return res.rename.retCode;
|
||||
}
|
||||
|
||||
async GetGroupFileUrl(groupUin: number, fileUUID: string) {
|
||||
async GetGroupFileUrl(groupUin: number, fileUUID: string, timeout?: number) {
|
||||
const req = trans.DownloadGroupFile.build(groupUin, fileUUID);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
|
||||
const res = trans.DownloadGroupFile.parse(resp);
|
||||
return `https://${res.download.downloadDns}/ftn_handler/${Buffer.from(res.download.downloadUrl).toString('hex')}/?fname=`;
|
||||
}
|
||||
|
||||
async GetPrivateFileUrl(self_id: string, fileUUID: string, md5: string) {
|
||||
async GetPrivateFileUrl(self_id: string, fileUUID: string, md5: string, timeout?: number) {
|
||||
const req = trans.DownloadPrivateFile.build(self_id, fileUUID, md5);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true, timeout);
|
||||
const res = trans.DownloadPrivateFile.parse(resp);
|
||||
return `http://${res.body?.result?.server}:${res.body?.result?.port}${res.body?.result?.url?.slice(8)}&isthumb=0`;
|
||||
}
|
||||
|
||||
214
src/core/packet/handler/client.ts
Normal file
214
src/core/packet/handler/client.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import path, { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
import { constants } from 'node:os';
|
||||
import { LogWrapper } from '@/common/log';
|
||||
import offset from '@/core/external/packet.json';
|
||||
interface OffsetType {
|
||||
[key: string]: {
|
||||
recv: string;
|
||||
send: string;
|
||||
};
|
||||
}
|
||||
|
||||
const typedOffset: OffsetType = offset;
|
||||
// 0 send 1 recv
|
||||
export interface NativePacketExportType {
|
||||
initHook?: (send: string, recv: string, callback: (type: PacketType, uin: string, cmd: string, seq: number, hex_data: string) => void, o3_hook: boolean) => boolean;
|
||||
}
|
||||
|
||||
export type PacketType = 0 | 1; // 0: send, 1: recv
|
||||
export type PacketCallback = (data: { type: PacketType, uin: string, cmd: string, seq: number, hex_data: string }) => void;
|
||||
|
||||
interface ListenerEntry {
|
||||
callback: PacketCallback;
|
||||
once: boolean;
|
||||
}
|
||||
|
||||
export class NativePacketHandler {
|
||||
private readonly supportedPlatforms = ['win32.x64', 'linux.x64', 'linux.arm64', 'darwin.x64', 'darwin.arm64'];
|
||||
private readonly MoeHooExport: { exports: NativePacketExportType } = { exports: {} };
|
||||
protected readonly logger: LogWrapper;
|
||||
|
||||
// 统一的监听器存储 - key: 'all' | 'type:0' | 'type:1' | 'cmd:xxx' | 'exact:type:cmd'
|
||||
private readonly listeners: Map<string, Set<ListenerEntry>> = new Map();
|
||||
|
||||
|
||||
constructor({ logger }: { logger: LogWrapper }) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加监听器的通用方法
|
||||
*/
|
||||
private addListener(key: string, callback: PacketCallback, once: boolean = false): () => void {
|
||||
if (!this.listeners.has(key)) {
|
||||
this.listeners.set(key, new Set());
|
||||
}
|
||||
const entry: ListenerEntry = { callback, once };
|
||||
this.listeners.get(key)!.add(entry);
|
||||
return () => this.removeListener(key, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除监听器的通用方法
|
||||
*/
|
||||
private removeListener(key: string, callback: PacketCallback): boolean {
|
||||
const entries = this.listeners.get(key);
|
||||
if (!entries) return false;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.callback === callback) {
|
||||
return entries.delete(entry);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ===== 永久监听器 =====
|
||||
|
||||
/** 监听所有数据包 */
|
||||
onAll(callback: PacketCallback): () => void {
|
||||
return this.addListener('all', callback);
|
||||
}
|
||||
|
||||
/** 监听特定类型的数据包 (0: send, 1: recv) */
|
||||
onType(type: PacketType, callback: PacketCallback): () => void {
|
||||
return this.addListener(`type:${type}`, callback);
|
||||
}
|
||||
|
||||
/** 监听所有发送的数据包 */
|
||||
onSend(callback: PacketCallback): () => void {
|
||||
return this.onType(0, callback);
|
||||
}
|
||||
|
||||
/** 监听所有接收的数据包 */
|
||||
onRecv(callback: PacketCallback): () => void {
|
||||
return this.onType(1, callback);
|
||||
}
|
||||
|
||||
/** 监听特定cmd的数据包(不限type) */
|
||||
onCmd(cmd: string, callback: PacketCallback): () => void {
|
||||
return this.addListener(`cmd:${cmd}`, callback);
|
||||
}
|
||||
|
||||
/** 监听特定type和cmd的数据包(精确匹配) */
|
||||
onExact(type: PacketType, cmd: string, callback: PacketCallback): () => void {
|
||||
return this.addListener(`exact:${type}:${cmd}`, callback);
|
||||
}
|
||||
|
||||
// ===== 一次性监听器 =====
|
||||
|
||||
/** 一次性监听所有数据包 */
|
||||
onceAll(callback: PacketCallback): () => void {
|
||||
return this.addListener('all', callback, true);
|
||||
}
|
||||
|
||||
/** 一次性监听特定类型的数据包 */
|
||||
onceType(type: PacketType, callback: PacketCallback): () => void {
|
||||
return this.addListener(`type:${type}`, callback, true);
|
||||
}
|
||||
|
||||
/** 一次性监听所有发送的数据包 */
|
||||
onceSend(callback: PacketCallback): () => void {
|
||||
return this.onceType(0, callback);
|
||||
}
|
||||
|
||||
/** 一次性监听所有接收的数据包 */
|
||||
onceRecv(callback: PacketCallback): () => void {
|
||||
return this.onceType(1, callback);
|
||||
}
|
||||
|
||||
/** 一次性监听特定cmd的数据包 */
|
||||
onceCmd(cmd: string, callback: PacketCallback): () => void {
|
||||
return this.addListener(`cmd:${cmd}`, callback, true);
|
||||
}
|
||||
|
||||
/** 一次性监听特定type和cmd的数据包 */
|
||||
onceExact(type: PacketType, cmd: string, callback: PacketCallback): () => void {
|
||||
return this.addListener(`exact:${type}:${cmd}`, callback, true);
|
||||
}
|
||||
|
||||
// ===== 移除监听器 =====
|
||||
|
||||
/** 移除特定的全局监听器 */
|
||||
off(key: string, callback: PacketCallback): boolean {
|
||||
return this.removeListener(key, callback);
|
||||
}
|
||||
|
||||
/** 移除特定key下的所有监听器 */
|
||||
offAll(key: string): void {
|
||||
this.listeners.delete(key);
|
||||
}
|
||||
|
||||
/** 移除所有监听器 */
|
||||
removeAllListeners(): void {
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发监听器 - 按优先级触发: 精确匹配 > cmd匹配 > type匹配 > 全局
|
||||
*/
|
||||
private emitPacket(type: PacketType, uin: string, cmd: string, seq: number, hex_data: string): void {
|
||||
const keys = [
|
||||
`exact:${type}:${cmd}`, // 精确匹配
|
||||
`cmd:${cmd}`, // cmd匹配
|
||||
`type:${type}`, // type匹配
|
||||
'all' // 全局
|
||||
];
|
||||
|
||||
for (const key of keys) {
|
||||
const entries = this.listeners.get(key);
|
||||
if (!entries) continue;
|
||||
|
||||
const toRemove: ListenerEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
entry.callback({ type, uin, cmd, seq, hex_data });
|
||||
if (entry.once) {
|
||||
toRemove.push(entry);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.logError('监听器回调执行出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 移除一次性监听器
|
||||
for (const entry of toRemove) {
|
||||
entries.delete(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async init(version: string): Promise<boolean> {
|
||||
const version_arch = version + '-' + process.arch;
|
||||
try {
|
||||
const send = typedOffset[version_arch]?.send;
|
||||
const recv = typedOffset[version_arch]?.recv;
|
||||
if (!send || !recv) {
|
||||
this.logger.logWarn(`NativePacketClient: 未找到对应版本的偏移数据: ${version_arch}`);
|
||||
return false;
|
||||
}
|
||||
const platform = process.platform + '.' + process.arch;
|
||||
if (!this.supportedPlatforms.includes(platform)) {
|
||||
this.logger.logWarn(`NativePacketClient: 不支持的平台: ${platform}`);
|
||||
return false;
|
||||
}
|
||||
const moehoo_path = path.join(dirname(fileURLToPath(import.meta.url)), './native/packet/MoeHoo.' + platform + '.node');
|
||||
|
||||
process.dlopen(this.MoeHooExport, moehoo_path, constants.dlopen.RTLD_LAZY);
|
||||
if (!fs.existsSync(moehoo_path)) {
|
||||
this.logger.logWarn(`NativePacketClient: 缺失运行时文件: ${moehoo_path}`);
|
||||
return false;
|
||||
}
|
||||
this.MoeHooExport.exports.initHook?.(send, recv, (type: PacketType, uin: string, cmd: string, seq: number, hex_data: string) => {
|
||||
this.emitPacket(type, uin, cmd, seq, hex_data);
|
||||
}, true);
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
this.logger.logError('NativePacketClient 初始化出错:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as proto from '@/core/packet/transformer/proto';
|
||||
import { NapProtoMsg } from '@napneko/nap-proto-core';
|
||||
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
|
||||
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/core/packet/transformer/base';
|
||||
import { MiniAppReqParams } from '@/core/packet/entities/miniApp';
|
||||
|
||||
class GetMiniAppAdaptShareInfo extends PacketTransformer<typeof proto.MiniAppAdaptShareInfoResp> {
|
||||
@@ -41,7 +41,7 @@ class GetMiniAppAdaptShareInfo extends PacketTransformer<typeof proto.MiniAppAda
|
||||
});
|
||||
return {
|
||||
cmd: 'LightAppSvc.mini_app_share.AdaptShareInfo',
|
||||
data: PacketHexStrBuilder(data)
|
||||
data: PacketBufBuilder(data)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
24
src/core/packet/transformer/action/SetGroupTodo.ts
Normal file
24
src/core/packet/transformer/action/SetGroupTodo.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as proto from '@/core/packet/transformer/proto';
|
||||
import { NapProtoMsg } from '@napneko/nap-proto-core';
|
||||
import { OidbPacket, PacketTransformer } from '@/core/packet/transformer/base';
|
||||
import OidbBase from '@/core/packet/transformer/oidb/oidbBase';
|
||||
|
||||
class SetGroupTodo extends PacketTransformer<typeof proto.OidbSvcTrpcTcpBase> {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
build(peer: number, msgSeq: string): OidbPacket {
|
||||
const data = new NapProtoMsg(proto.OidbSvcTrpcTcp0XF90_1).encode({
|
||||
groupUin: peer,
|
||||
msgSeq: BigInt(msgSeq)
|
||||
});
|
||||
return OidbBase.build(0xF90, 1, data);
|
||||
}
|
||||
|
||||
parse(data: Buffer) {
|
||||
return OidbBase.parse(data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new SetGroupTodo();
|
||||
@@ -8,3 +8,4 @@ export { default as SetSpecialTitle } from './SetSpecialTitle';
|
||||
export { default as ImageOCR } from './ImageOCR';
|
||||
export { default as MoveGroupFile } from './MoveGroupFile';
|
||||
export { default as RenameGroupFile } from './RenameGroupFile';
|
||||
export { default as SetGroupTodo } from './SetGroupTodo';
|
||||
@@ -1,15 +1,15 @@
|
||||
import { NapProtoDecodeStructType } from '@napneko/nap-proto-core';
|
||||
import { PacketMsgBuilder } from '@/core/packet/message/builder';
|
||||
|
||||
export type PacketHexStr = string & { readonly hexNya: unique symbol };
|
||||
export type PacketBuf = Buffer & { readonly hexNya: unique symbol };
|
||||
|
||||
export const PacketHexStrBuilder = (str: Uint8Array): PacketHexStr => {
|
||||
return Buffer.from(str).toString('hex') as PacketHexStr;
|
||||
export const PacketBufBuilder = (str: Uint8Array): PacketBuf => {
|
||||
return Buffer.from(str) as PacketBuf;
|
||||
};
|
||||
|
||||
export interface OidbPacket {
|
||||
cmd: string;
|
||||
data: PacketHexStr
|
||||
data: PacketBuf
|
||||
}
|
||||
|
||||
export abstract class PacketTransformer<T> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as proto from '@/core/packet/transformer/proto';
|
||||
import { NapProtoMsg } from '@napneko/nap-proto-core';
|
||||
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
|
||||
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/core/packet/transformer/base';
|
||||
|
||||
class FetchSessionKey extends PacketTransformer<typeof proto.HttpConn0x6ff_501Response> {
|
||||
constructor() {
|
||||
@@ -25,7 +25,7 @@ class FetchSessionKey extends PacketTransformer<typeof proto.HttpConn0x6ff_501Re
|
||||
});
|
||||
return {
|
||||
cmd: 'HttpConn.0x6ff_501',
|
||||
data: PacketHexStrBuilder(req)
|
||||
data: PacketBufBuilder(req)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as proto from '@/core/packet/transformer/proto';
|
||||
import { NapProtoMsg } from '@napneko/nap-proto-core';
|
||||
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
|
||||
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/core/packet/transformer/base';
|
||||
|
||||
class DownloadForwardMsg extends PacketTransformer<typeof proto.RecvLongMsgResp> {
|
||||
constructor() {
|
||||
@@ -25,7 +25,7 @@ class DownloadForwardMsg extends PacketTransformer<typeof proto.RecvLongMsgResp>
|
||||
});
|
||||
return {
|
||||
cmd: 'trpc.group.long_msg_interface.MsgService.SsoRecvLongMsg',
|
||||
data: PacketHexStrBuilder(req)
|
||||
data: PacketBufBuilder(req)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as proto from '@/core/packet/transformer/proto';
|
||||
import { NapProtoMsg } from '@napneko/nap-proto-core';
|
||||
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
|
||||
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/core/packet/transformer/base';
|
||||
|
||||
class FetchC2CMessage extends PacketTransformer<typeof proto.SsoGetC2cMsgResponse> {
|
||||
constructor() {
|
||||
@@ -15,7 +15,7 @@ class FetchC2CMessage extends PacketTransformer<typeof proto.SsoGetC2cMsgRespons
|
||||
});
|
||||
return {
|
||||
cmd: 'trpc.msg.register_proxy.RegisterProxy.SsoGetC2cMsg',
|
||||
data: PacketHexStrBuilder(req)
|
||||
data: PacketBufBuilder(req)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as proto from '@/core/packet/transformer/proto';
|
||||
import { NapProtoMsg } from '@napneko/nap-proto-core';
|
||||
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
|
||||
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/core/packet/transformer/base';
|
||||
|
||||
class FetchGroupMessage extends PacketTransformer<typeof proto.SsoGetGroupMsgResponse> {
|
||||
constructor() {
|
||||
@@ -18,7 +18,7 @@ class FetchGroupMessage extends PacketTransformer<typeof proto.SsoGetGroupMsgRes
|
||||
});
|
||||
return {
|
||||
cmd: 'trpc.msg.register_proxy.RegisterProxy.SsoGetGroupMsg',
|
||||
data: PacketHexStrBuilder(req)
|
||||
data: PacketBufBuilder(req)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import zlib from 'node:zlib';
|
||||
import * as proto from '@/core/packet/transformer/proto';
|
||||
import { NapProtoMsg } from '@napneko/nap-proto-core';
|
||||
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
|
||||
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/core/packet/transformer/base';
|
||||
import { PacketMsg } from '@/core/packet/message/message';
|
||||
|
||||
class UploadForwardMsg extends PacketTransformer<typeof proto.SendLongMsgResp> {
|
||||
@@ -39,7 +39,7 @@ class UploadForwardMsg extends PacketTransformer<typeof proto.SendLongMsgResp> {
|
||||
);
|
||||
return {
|
||||
cmd: 'trpc.group.long_msg_interface.MsgService.SsoSendLongMsg',
|
||||
data: PacketHexStrBuilder(req)
|
||||
data: PacketBufBuilder(req)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as proto from '@/core/packet/transformer/proto';
|
||||
import { NapProtoMsg } from '@napneko/nap-proto-core';
|
||||
import { OidbPacket, PacketHexStrBuilder, PacketTransformer } from '@/core/packet/transformer/base';
|
||||
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/core/packet/transformer/base';
|
||||
|
||||
class OidbBase extends PacketTransformer<typeof proto.OidbSvcTrpcTcpBase> {
|
||||
constructor() {
|
||||
@@ -16,7 +16,7 @@ class OidbBase extends PacketTransformer<typeof proto.OidbSvcTrpcTcpBase> {
|
||||
});
|
||||
return {
|
||||
cmd: `OidbSvcTrpcTcp.0x${cmd.toString(16).toUpperCase()}_${subCmd}`,
|
||||
data: PacketHexStrBuilder(data),
|
||||
data: PacketBufBuilder(data),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -30,3 +30,4 @@ export * from './oidb/Oidb.0xED3_1';
|
||||
export * from './oidb/Oidb.0XFE1_2';
|
||||
export * from './oidb/OidbBase';
|
||||
export * from './oidb/Oidb.0xE07';
|
||||
export * from './oidb/Oidb.0xf90_1';
|
||||
|
||||
@@ -74,6 +74,36 @@ export const GroupChange = {
|
||||
field7: ProtoField(7, ScalarType.BYTES, true),
|
||||
};
|
||||
|
||||
// Group Reaction Related
|
||||
export const GroupReactionDataInnerDataTarget = {
|
||||
seq: ProtoField(1, ScalarType.UINT64, true),
|
||||
};
|
||||
|
||||
export const GroupReactionDataContent = {
|
||||
code: ProtoField(1, ScalarType.STRING, true),
|
||||
count: ProtoField(3, ScalarType.UINT32, true),
|
||||
operatorUid: ProtoField(4, ScalarType.STRING, true),
|
||||
type: ProtoField(5, ScalarType.UINT32, true),
|
||||
};
|
||||
|
||||
export const GroupReactionDataInnerData = {
|
||||
groupReactionTarget: ProtoField(2, () => GroupReactionDataInnerDataTarget, true),
|
||||
groupReactionDataContent: ProtoField(3, () => GroupReactionDataContent, true),
|
||||
};
|
||||
export const GroupReactionDataInner = {
|
||||
data: ProtoField(1, () => GroupReactionDataInnerData, true),
|
||||
};
|
||||
export const GroupReactionData = {
|
||||
data: ProtoField(1, () => GroupReactionDataInner, true),
|
||||
};
|
||||
|
||||
export const GroupReactNotify = {
|
||||
groupUin: ProtoField(4, ScalarType.UINT64, true),
|
||||
field13: ProtoField(13, ScalarType.UINT32, true),
|
||||
groupReactionData: ProtoField(44, () => GroupReactionData, true),
|
||||
};
|
||||
|
||||
// Group Invite Related
|
||||
export const GroupInvite = {
|
||||
groupUin: ProtoField(1, ScalarType.UINT32),
|
||||
field2: ProtoField(2, ScalarType.UINT32),
|
||||
|
||||
6
src/core/packet/transformer/proto/oidb/Oidb.0xf90_1.ts
Normal file
6
src/core/packet/transformer/proto/oidb/Oidb.0xf90_1.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ProtoField, ScalarType } from "@napneko/nap-proto-core";
|
||||
|
||||
export const OidbSvcTrpcTcp0XF90_1 = {
|
||||
groupUin: ProtoField(1, ScalarType.UINT32),
|
||||
msgSeq: ProtoField(2, ScalarType.UINT64),
|
||||
};
|
||||
@@ -1,22 +1,52 @@
|
||||
import { AlbumCommentReplyContent, AlbumFeedLikePublish, AlbumListRequest, AlbumMediaFeed } from "../data/album";
|
||||
|
||||
export interface NodeIKernelAlbumService {
|
||||
|
||||
|
||||
setAlbumServiceInfo(...args: unknown[]): unknown;// needs 3 arguments
|
||||
|
||||
getMainPage(...args: unknown[]): unknown;// needs 2 arguments
|
||||
|
||||
getAlbumList(...args: unknown[]): unknown;// needs 1 arguments
|
||||
|
||||
getAlbumList(params: {
|
||||
qun_id: string,
|
||||
attach_info: string,
|
||||
seq: number,
|
||||
request_time_line: {
|
||||
request_invoke_time: string
|
||||
}
|
||||
}): Promise<{
|
||||
response: {
|
||||
seq: number,
|
||||
result: number,
|
||||
errMs: string,//没错就是errMs不是errMsg
|
||||
trace_id: string,
|
||||
is_from_cache: boolean,
|
||||
request_time_line: unknown,
|
||||
album_list: Array<{ name: string, album_id: string }>,
|
||||
attach_info: string,
|
||||
has_more: boolean,
|
||||
right: unknown,
|
||||
banner: unknown
|
||||
}
|
||||
}>
|
||||
getAlbumInfo(...args: unknown[]): unknown;// needs 1 arguments
|
||||
|
||||
deleteAlbum(...args: unknown[]): unknown;// needs 3 arguments
|
||||
|
||||
addAlbum(...args: unknown[]): unknown;// needs 2 arguments
|
||||
|
||||
deleteMedias(...args: unknown[]): unknown;// needs 4 arguments
|
||||
deleteMedias(seq: number, group_code: string, album_id: string, media_ids: string[], ban_ids: unknown[]): Promise<unknown>;// needs 4 arguments
|
||||
|
||||
modifyAlbum(...args: unknown[]): unknown;// needs 3 arguments
|
||||
|
||||
getMediaList(...args: unknown[]): unknown;// needs 1 arguments
|
||||
getMediaList(param: AlbumListRequest): Promise<{
|
||||
response: {
|
||||
seq: number,
|
||||
result: number,
|
||||
errMs: string,//没错就是errMs不是errMsg
|
||||
trace_id: string,
|
||||
request_time_line: unknown,
|
||||
}
|
||||
}>;// needs 1 arguments
|
||||
|
||||
quoteToQzone(...args: unknown[]): unknown;// needs 1 arguments
|
||||
|
||||
@@ -35,12 +65,36 @@ export interface NodeIKernelAlbumService {
|
||||
getQunLikes(...args: unknown[]): unknown;// needs 4 arguments
|
||||
|
||||
deleteQunFeed(...args: unknown[]): unknown;// needs 1 arguments
|
||||
|
||||
doQunComment(...args: unknown[]): unknown;// needs 6 arguments
|
||||
//seq random
|
||||
//stCommonExt {"map_info":[],"map_bytes_info":[],"map_user_account":[]}
|
||||
//qunId string
|
||||
doQunComment(seq: number, ext: {
|
||||
map_info: unknown[],
|
||||
map_bytes_info: unknown[],
|
||||
map_user_account: unknown[]
|
||||
},
|
||||
qunId: string,
|
||||
commentType: number,
|
||||
feed: AlbumMediaFeed,
|
||||
content: AlbumCommentReplyContent,
|
||||
): Promise<unknown>;// needs 6 arguments
|
||||
|
||||
doQunReply(...args: unknown[]): unknown;// needs 7 arguments
|
||||
|
||||
doQunLike(...args: unknown[]): unknown;// needs 5 arguments
|
||||
doQunLike(
|
||||
seq: number,
|
||||
ext: {
|
||||
map_info: unknown[],
|
||||
map_bytes_info: unknown[],
|
||||
map_user_account: unknown[]
|
||||
},
|
||||
param: {
|
||||
//{"id":"421_1_0_1012959257|V61Yiali4PELg90bThrH4Bo2iI1M5Kab|V5bCgAxMDEyOTU5MjU3e*KqaLVYdic!^||^421_1_0_1012959257|V61Yiali4PELg90bThrH4Bo2iI1M5Kab|17560336594^||^1","status":1}
|
||||
id: string,
|
||||
status: number
|
||||
},
|
||||
like: AlbumFeedLikePublish
|
||||
): Promise<unknown>;// needs 5 arguments
|
||||
|
||||
getRedPoints(...args: unknown[]): unknown;// needs 3 arguments
|
||||
|
||||
|
||||
@@ -585,7 +585,7 @@ export interface NodeIKernelMsgService {
|
||||
|
||||
prepareTempChat(args: unknown): unknown;
|
||||
|
||||
sendSsoCmdReqByContend(cmd: string, param: string): Promise<unknown>;
|
||||
sendSsoCmdReqByContend(cmd: string, param: unknown): Promise<unknown>;
|
||||
|
||||
getTempChatInfo(ChatType: number, Uid: string): Promise<TmpChatInfoApi>;
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface GrayTipRovokeElement {
|
||||
operatorUid: string;
|
||||
operatorNick: string;
|
||||
operatorRemark: string;
|
||||
isSelfOperate: boolean; // 是否是自己撤回的
|
||||
operatorMemRemark?: string;
|
||||
wording: string; // 自定义的撤回提示语
|
||||
}
|
||||
@@ -310,6 +311,10 @@ export interface PttElement {
|
||||
voiceType: number;
|
||||
waveAmplitudes: number[];
|
||||
autoConvertText: number;
|
||||
storeID: number;
|
||||
otherBusinessInfo: {
|
||||
aiVoiceType: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type SendRecommendedMsgElement = SendElementBase<ElementType.RECOMMENDEDMSG> & ElementBase<'recommendedMsgElement'>;
|
||||
@@ -348,7 +353,7 @@ export type SendPicElement = SendElementBase<ElementType.PIC> & ElementBase<'pic
|
||||
|
||||
export type SendPttElement = SendElementBase<ElementType.PTT> & ElementBase<'pttElement', {
|
||||
pttElement: ['fileName', 'filePath', 'md5HexStr', 'fileSize', 'duration', 'formatType', 'voiceType',
|
||||
'voiceChangeType', 'canConvert2Text', 'waveAmplitudes', 'fileSubId', 'playState', 'autoConvertText']
|
||||
'voiceChangeType', 'canConvert2Text', 'waveAmplitudes', 'fileSubId', 'playState', 'autoConvertText', 'storeID', 'otherBusinessInfo']
|
||||
}>;
|
||||
|
||||
export type SendFileElement = SendElementBase<ElementType.FILE> & ElementBase<'fileElement'>;
|
||||
|
||||
@@ -139,8 +139,20 @@ export interface NodeQQNTWrapperUtil {
|
||||
|
||||
isNull(): unknown;
|
||||
}
|
||||
|
||||
export interface NodeIQQNTStartupSessionWrapper {
|
||||
create(): NodeIQQNTStartupSessionWrapper;
|
||||
stop(): void;
|
||||
start(): void;
|
||||
createWithModuleList(uk: unknown): unknown;
|
||||
getSessionIdList(): unknown;
|
||||
}
|
||||
export interface NodeIQQNTWrapperSession {
|
||||
getNTWrapperSession(str: string): NodeIQQNTWrapperSession;
|
||||
|
||||
get(): NodeIQQNTWrapperSession;
|
||||
|
||||
new(): NodeIQQNTWrapperSession;
|
||||
|
||||
create(): NodeIQQNTWrapperSession;
|
||||
|
||||
init(
|
||||
@@ -269,6 +281,7 @@ export interface WrapperNodeApi {
|
||||
NodeIO3MiscService: NodeIO3MiscService;
|
||||
NodeQQNTWrapperUtil: NodeQQNTWrapperUtil;
|
||||
NodeIQQNTWrapperSession: NodeIQQNTWrapperSession;
|
||||
NodeIQQNTStartupSessionWrapper: NodeIQQNTStartupSessionWrapper
|
||||
NodeIQQNTWrapperEngine: NodeIQQNTWrapperEngine;
|
||||
NodeIKernelLoginService: NodeIKernelLoginService;
|
||||
|
||||
|
||||
3
src/example-plugin/README.md
Normal file
3
src/example-plugin/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Example Plugin
|
||||
## Install
|
||||
安装只需要将dist产物 `index.mjs` 目录放入 napcat根目录下`plugins/example`,如果没有这个目录请创建。
|
||||
12
src/example-plugin/index.ts
Normal file
12
src/example-plugin/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { EventType } from "@/onebot/event/OneBotEvent";
|
||||
import type { PluginModule } from "@/onebot/network/plugin-manger";
|
||||
|
||||
const plugin_init: PluginModule["plugin_init"] = async (_core, _obContext, _actions, _instance) => {
|
||||
console.log(`[Plugin: example] 插件已初始化`);
|
||||
}
|
||||
const plugin_onmessage: PluginModule["plugin_onmessage"] = async (adapter, _core, _obCtx, event, actions, instance) => {
|
||||
if (event.post_type === EventType.MESSAGE && event.raw_message.includes('ping')) {
|
||||
await actions.get('send_group_msg')?.handle({ group_id: String(event.group_id), message: 'pong' }, adapter, instance.config);
|
||||
}
|
||||
}
|
||||
export { plugin_init, plugin_onmessage };
|
||||
10
src/example-plugin/package.json
Normal file
10
src/example-plugin/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "advanced-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"description": "一个高级的 NapCat 插件示例",
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
}
|
||||
}
|
||||
30
src/example-plugin/vite.config.ts
Normal file
30
src/example-plugin/vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
import nodeResolve from '@rollup/plugin-node-resolve';
|
||||
import { builtinModules } from 'module';
|
||||
|
||||
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
conditions: ['node', 'default'],
|
||||
alias: {
|
||||
'@/core': resolve(__dirname, '../core'),
|
||||
'@': resolve(__dirname, '../'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
sourcemap: false,
|
||||
target: 'esnext',
|
||||
minify: false,
|
||||
lib: {
|
||||
entry: 'index.ts',
|
||||
formats: ['es'],
|
||||
fileName: () => 'index.mjs',
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [...nodeModules],
|
||||
},
|
||||
},
|
||||
plugins: [nodeResolve()],
|
||||
});
|
||||
@@ -9,13 +9,13 @@ import { NodeIKernelLoginService } from '@/core/services';
|
||||
import { NodeIQQNTWrapperSession, WrapperNodeApi } from '@/core/wrapper';
|
||||
import { InitWebUi, WebUiConfig, webUiRuntimePort } from '@/webui';
|
||||
import { NapCatOneBot11Adapter } from '@/onebot';
|
||||
import { downloadFFmpegIfNotExists } from '@/common/download-ffmpeg';
|
||||
import { FFmpegService } from '@/common/ffmpeg';
|
||||
import { NativePacketHandler } from '@/core/packet/handler/client';
|
||||
|
||||
//Framework ES入口文件
|
||||
export async function getWebUiUrl() {
|
||||
const WebUiConfigData = (await WebUiConfig.GetWebUIConfig());
|
||||
return 'http://127.0.0.1:' + webUiRuntimePort + '/webui/?token=' + WebUiConfigData.token;
|
||||
return 'http://127.0.0.1:' + webUiRuntimePort + '/webui/?token=' + encodeURIComponent(WebUiConfigData.token);
|
||||
}
|
||||
|
||||
export async function NCoreInitFramework(
|
||||
@@ -37,17 +37,23 @@ export async function NCoreInitFramework(
|
||||
const pathWrapper = new NapCatPathWrapper();
|
||||
const logger = new LogWrapper(pathWrapper.logsPath);
|
||||
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
|
||||
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVesion());
|
||||
if (!process.env['NAPCAT_DISABLE_FFMPEG_DOWNLOAD']) {
|
||||
downloadFFmpegIfNotExists(logger).then(({ path, reset }) => {
|
||||
if (reset && path) {
|
||||
FFmpegService.setFfmpegPath(path, logger);
|
||||
}
|
||||
}).catch(e => {
|
||||
logger.logError('[Ffmpeg] Error:', e);
|
||||
});
|
||||
}
|
||||
const wrapper = loadQQWrapper(basicInfoWrapper.getFullQQVersion());
|
||||
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用
|
||||
// nativePacketHandler.onAll((packet) => {
|
||||
// console.log('[Packet]', packet.uin, packet.cmd, packet.hex_data);
|
||||
// });
|
||||
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion());
|
||||
// 在 init 之后注册监听器
|
||||
|
||||
// 初始化 FFmpeg 服务
|
||||
await FFmpegService.init(pathWrapper.binaryPath, logger);
|
||||
//直到登录成功后,执行下一步
|
||||
// const selfInfo = {
|
||||
// uid: 'u_FUSS0_x06S_9Tf4na_WpUg',
|
||||
// uin: '3684714082',
|
||||
// nick: '',
|
||||
// online: true
|
||||
// }
|
||||
const selfInfo = await new Promise<SelfInfo>((resolveSelfInfo) => {
|
||||
const loginListener = new NodeIKernelLoginListener();
|
||||
loginListener.onQRCodeLoginSucceed = async (loginResult) => {
|
||||
@@ -66,7 +72,7 @@ export async function NCoreInitFramework(
|
||||
// 过早进入会导致addKernelMsgListener等Listener添加失败
|
||||
// await sleep(2500);
|
||||
// 初始化 NapCatFramework
|
||||
const loaderObject = new NapCatFramework(wrapper, session, logger, loginService, selfInfo, basicInfoWrapper, pathWrapper);
|
||||
const loaderObject = new NapCatFramework(wrapper, session, logger, loginService, selfInfo, basicInfoWrapper, pathWrapper, nativePacketHandler);
|
||||
await loaderObject.core.initCore();
|
||||
|
||||
//启动WebUi
|
||||
@@ -87,8 +93,10 @@ export class NapCatFramework {
|
||||
selfInfo: SelfInfo,
|
||||
basicInfoWrapper: QQBasicInfoWrapper,
|
||||
pathWrapper: NapCatPathWrapper,
|
||||
packetHandler: NativePacketHandler,
|
||||
) {
|
||||
this.context = {
|
||||
packetHandler,
|
||||
workingEnv: NapCatCoreWorkingEnv.Framework,
|
||||
wrapper,
|
||||
session,
|
||||
|
||||
26
src/framework/nativeLoader.cjs
Normal file
26
src/framework/nativeLoader.cjs
Normal file
@@ -0,0 +1,26 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
async function initializeNapCat(session, loginService, registerCallback) {
|
||||
//const logFile = path.join(currentPath, 'napcat.log');
|
||||
|
||||
console.log('[NapCat] [Info] 开始初始化NapCat');
|
||||
|
||||
//fs.writeFileSync(logFile, '', { flag: 'w' });
|
||||
|
||||
//fs.writeFileSync(logFile, '[NapCat] [Info] NapCat 初始化成功\n', { flag: 'a' });
|
||||
|
||||
try {
|
||||
const currentPath = path.dirname(__filename);
|
||||
const { NCoreInitFramework } = await import('file://' + path.join(currentPath, './napcat.mjs'));
|
||||
await NCoreInitFramework(session, loginService, (callback) => { registerCallback(callback) });
|
||||
|
||||
} catch (error) {
|
||||
console.log('[NapCat] [Error] 初始化NapCat', error);
|
||||
//fs.writeFileSync(logFile, `[NapCat] [Error] 初始化NapCat失败: ${error.message}\n`, { flag: 'a' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initializeNapCat: initializeNapCat
|
||||
};
|
||||
BIN
src/native/ffmpeg/ffmpegAddon.darwin.arm64.node
Normal file
BIN
src/native/ffmpeg/ffmpegAddon.darwin.arm64.node
Normal file
Binary file not shown.
BIN
src/native/ffmpeg/ffmpegAddon.linux.arm64.node
Normal file
BIN
src/native/ffmpeg/ffmpegAddon.linux.arm64.node
Normal file
Binary file not shown.
BIN
src/native/ffmpeg/ffmpegAddon.linux.x64.node
Normal file
BIN
src/native/ffmpeg/ffmpegAddon.linux.x64.node
Normal file
Binary file not shown.
BIN
src/native/ffmpeg/ffmpegAddon.win32.x64.node
Normal file
BIN
src/native/ffmpeg/ffmpegAddon.win32.x64.node
Normal file
Binary file not shown.
BIN
src/native/napi2native/napi2native.darwin.arm64.node
Normal file
BIN
src/native/napi2native/napi2native.darwin.arm64.node
Normal file
Binary file not shown.
BIN
src/native/napi2native/napi2native.linux.arm64.node
Normal file
BIN
src/native/napi2native/napi2native.linux.arm64.node
Normal file
Binary file not shown.
BIN
src/native/napi2native/napi2native.linux.x64.node
Normal file
BIN
src/native/napi2native/napi2native.linux.x64.node
Normal file
Binary file not shown.
BIN
src/native/napi2native/napi2native.win32.x64.node
Normal file
BIN
src/native/napi2native/napi2native.win32.x64.node
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user