mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-02 16:50:27 +00:00
Compare commits
26 Commits
v4.17.11
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff6d76a1a7 | ||
|
|
b42b6c3cf0 | ||
|
|
740d2118f1 | ||
|
|
77bdcfd249 | ||
|
|
896e1c209a | ||
|
|
cff07c7ce5 | ||
|
|
5c04b799a6 | ||
|
|
1fc4655ae1 | ||
|
|
eb07cdb715 | ||
|
|
964fd98914 | ||
|
|
f9764c9559 | ||
|
|
b71a4913eb | ||
|
|
f961830836 | ||
|
|
dd8b5f84a6 | ||
|
|
48ffd5597a | ||
|
|
1b73d68cbf | ||
|
|
5fec649425 | ||
|
|
052e7fa2b3 | ||
|
|
04e425d17a | ||
|
|
cbe0506577 | ||
|
|
32ec097f51 | ||
|
|
53f27ea9e2 | ||
|
|
41d94cd5e2 | ||
|
|
285d352bc8 | ||
|
|
a3b3836b8a | ||
|
|
b9f61cc0ee |
62
README.md
62
README.md
@@ -5,47 +5,41 @@
|
||||
|
||||
_Modern protocol-side framework implemented based on NTQQ._
|
||||
|
||||
> 云起兮风生,心向远方兮路未曾至.
|
||||
> Where clouds rise and winds are born, the heart yearns for distant lands yet untraveled.
|
||||
|
||||
English | [简体中文](./docs/README_zh-CN.md) | [繁體中文](./docs/README_zh-TW.md) | [日本語](./docs/README_ja.md) | [한국어](./docs/README_ko.md) | [Русский](./docs/README_ru.md) | [العربية](./docs/README_ar.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 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)对大整数支持不佳的问题,增加极少成本。
|
||||
## Notice
|
||||
NapCat is currently looking for a new primary maintainer. Please email nanaeonn@outlook.com if you are interested. No public community groups will be established during this period, but NapCat will continue to receive regular updates.
|
||||
|
||||
## Welcome
|
||||
|
||||
- NapCatQQ is a modern implementation of the Bot protocol based on NTQQ.
|
||||
- NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
|
||||
|
||||
## Feature
|
||||
## Features
|
||||
|
||||
- **Easy to Use**
|
||||
- 作为初学者能够轻松使用.
|
||||
- Beginners can get started effortlessly.
|
||||
- **Quick and Efficient**
|
||||
- 在低内存操作系统长时运行.
|
||||
- Runs for extended periods on low-memory systems.
|
||||
- **Rich API Interface**
|
||||
- 完整实现了大部分标准接口.
|
||||
- Fully implements most standard interfaces.
|
||||
- **Stable and Reliable**
|
||||
- 持续稳定的开发与维护.
|
||||
- Continuous and stable development and maintenance.
|
||||
|
||||
## Quick Start
|
||||
|
||||
可前往 [Release](https://github.com/NapNeko/NapCatQQ/releases/) 页面下载最新版本
|
||||
Download the latest version from the [Release](https://github.com/NapNeko/NapCatQQ/releases/) page.
|
||||
|
||||
**首次使用**请务必查看如下文档看使用教程
|
||||
**First-time users** should make sure to read the documentation below for a usage guide.
|
||||
|
||||
> 项目非盈利,涉及 对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
|
||||
> This is a non-profit project. For integration issues, basic questions, or underlying framework issues, please search for solutions on your own — this project's community does not provide such support.
|
||||
|
||||
## Link
|
||||
## Links
|
||||
|
||||
| Docs | [](https://napneko.github.io/) | [](https://doc.napneko.icu/) | [](https://napcat.napneko.icu/) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
@@ -53,37 +47,31 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
| Docs | [](https://napneko.pages.dev/) | [](https://napcat.top/) | [](https://napcat.top/) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| 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/napcatqq) |
|
||||
|:-:|:-:|
|
||||
|
||||
| DeepWiki | [](https://deepwiki.com/NapNeko/NapCatQQ) |
|
||||
|:-:|:-:|
|
||||
|
||||
> 请不要在其余社区提及本项目(包括其余协议端/相关应用端项目)引发争论,如有建议到达官方交流群讨论或PR。
|
||||
> Please do not mention this project in other communities (including other protocol-side or related application-side projects) to avoid disputes. If you have suggestions, please discuss them in the official group or submit a PR.
|
||||
|
||||
## Thanks
|
||||
|
||||
- [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
|
||||
- [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) — Great support for this project; some code was referenced (with permission)
|
||||
|
||||
- [AstrBot](https://github.com/AstrBotDevs/AstrBot) 是完美适配本项目的LLM Bot框架 在此推荐一下
|
||||
- [AstrBot](https://github.com/AstrBotDevs/AstrBot) — An LLM Bot framework that perfectly integrates with this project
|
||||
|
||||
- [MaiBot](https://github.com/MaiM-with-u/MaiBot) 一只赛博群友 麦麦 Bot框架 在此推荐一下
|
||||
- [MaiBot](https://github.com/MaiM-with-u/MaiBot) — A cyber companion Bot framework
|
||||
|
||||
- [qq-chat-exporter](https://github.com/shuakami/qq-chat-exporter/) 基于NapCat的消息导出工具 在此推荐一下
|
||||
- [qq-chat-exporter](https://github.com/shuakami/qq-chat-exporter/) — A message export tool based on NapCat
|
||||
|
||||
- 不过最最重要的 还是需要感谢屏幕前的你哦~
|
||||
- And most importantly, thank you — the person reading this right now~
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
本项目采用 混合协议 开源,因此使用本项目时,你需要注意以下几点:
|
||||
This project is open-sourced under a hybrid license. When using this project, please note the following:
|
||||
|
||||
1. 第三方库代码或修改部分遵循其原始开源许可.
|
||||
2. 本项目获取部分项目授权而不受部分约束
|
||||
2. 项目其余逻辑代码采用[本仓库开源许可](./LICENSE).
|
||||
1. Third-party library code or modified portions follow their original open-source licenses.
|
||||
2. This project has obtained authorization from certain projects and is not subject to some restrictions.
|
||||
3. The remaining logic code of the project is governed by the [repository license](./LICENSE).
|
||||
|
||||
**本仓库仅用于提高易用性,实现消息推送类功能,此外,禁止任何项目未经仓库主作者授权基于 NapCat 代码开发。使用请遵守当地法律法规,由此造成的问题由使用者和提供违规使用教程者负责。**
|
||||
**This repository is solely intended to improve usability and implement message push features. Furthermore, developing any project based on NapCat code without prior authorization from the repository's main author is prohibited. Please comply with local laws and regulations when using this project. Any issues arising from misuse are the responsibility of the user and anyone providing tutorials for unauthorized use.**
|
||||
|
||||
81
docs/README_ar.md
Normal file
81
docs/README_ar.md
Normal file
@@ -0,0 +1,81 @@
|
||||
<div dir="rtl">
|
||||
|
||||
<img src="https://napneko.github.io/assets/newnewlogo.png" width = "305" height = "411" alt="NapCat" align=left />
|
||||
<div align="center">
|
||||
|
||||
# NapCat
|
||||
|
||||
_إطار عمل حديث على جانب البروتوكول مبني على NTQQ._
|
||||
|
||||
> حيث تنهض السحب وتولد الرياح، يتوق القلب إلى أراضٍ بعيدة لم تُسلك بعد.
|
||||
|
||||
[English](../README.md) | [简体中文](./README_zh-CN.md) | [繁體中文](./README_zh-TW.md) | [日本語](./README_ja.md) | [한국어](./README_ko.md) | [Русский](./README_ru.md) | العربية
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## إعلان
|
||||
يبحث NapCat حالياً عن مشرف رئيسي جديد. يرجى التواصل عبر البريد الإلكتروني nanaeonn@outlook.com. لن يتم إنشاء مجموعات مجتمع عامة خلال هذه الفترة، لكن NapCat يضمن استمرار التحديثات المنتظمة.
|
||||
|
||||
## مرحباً
|
||||
|
||||
- NapCatQQ هو تطبيق حديث لبروتوكول Bot مبني على NTQQ
|
||||
|
||||
## المميزات
|
||||
|
||||
- **سهل الاستخدام**
|
||||
- يمكن للمبتدئين البدء بسهولة.
|
||||
- **سريع وفعال**
|
||||
- يعمل لفترات طويلة على أنظمة ذات ذاكرة منخفضة.
|
||||
- **واجهة API غنية**
|
||||
- تنفيذ كامل لمعظم الواجهات القياسية.
|
||||
- **مستقر وموثوق**
|
||||
- تطوير وصيانة مستمرة ومستقرة.
|
||||
|
||||
## البداية السريعة
|
||||
|
||||
قم بتنزيل أحدث إصدار من صفحة [Release](https://github.com/NapNeko/NapCatQQ/releases/)
|
||||
|
||||
**للمستخدمين لأول مرة** تأكد من قراءة الوثائق أدناه للحصول على دليل الاستخدام
|
||||
|
||||
> هذا مشروع غير ربحي. للمشاكل المتعلقة بالتكامل والأسئلة الأساسية ومشاكل إطار العمل الأساسي، يرجى البحث عن الحلول بنفسك — مجتمع هذا المشروع لا يقدم مثل هذا الدعم.
|
||||
|
||||
## الروابط
|
||||
|
||||
| الوثائق | [](https://napneko.github.io/) | [](https://doc.napneko.icu/) | [](https://napcat.napneko.icu/) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| الوثائق | [](https://napneko.pages.dev/) | [](https://napcat.top/) | [](https://napcat.top/) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| DeepWiki | [](https://deepwiki.com/NapNeko/NapCatQQ) |
|
||||
|:-:|:-:|
|
||||
|
||||
> يرجى عدم ذكر هذا المشروع في مجتمعات أخرى (بما في ذلك مشاريع البروتوكول الأخرى / مشاريع التطبيقات ذات الصلة) لتجنب الخلافات. إذا كانت لديك اقتراحات، يرجى مناقشتها في المجموعة الرسمية أو تقديم PR.
|
||||
|
||||
## شكر وتقدير
|
||||
|
||||
- [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) — دعم كبير لهذا المشروع؛ تمت الإشارة إلى بعض الأكواد (بإذن)
|
||||
|
||||
- [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
|
||||
|
||||
- والأهم من ذلك كله، شكراً لك — أنت الذي تقرأ هذا الآن~
|
||||
|
||||
---
|
||||
|
||||
## الرخصة
|
||||
|
||||
هذا المشروع مفتوح المصدر بموجب رخصة هجينة. عند استخدام هذا المشروع، يرجى ملاحظة ما يلي:
|
||||
|
||||
1. كود المكتبات الخارجية أو الأجزاء المعدلة تخضع لرخصها مفتوحة المصدر الأصلية.
|
||||
2. حصل هذا المشروع على إذن من بعض المشاريع ولا يخضع لبعض القيود.
|
||||
3. باقي الكود المنطقي للمشروع يخضع [لرخصة هذا المستودع](../LICENSE).
|
||||
|
||||
**هذا المستودع مخصص فقط لتحسين سهولة الاستخدام وتنفيذ ميزات دفع الرسائل. علاوة على ذلك، يُحظر تطوير أي مشروع بناءً على كود NapCat دون إذن مسبق من المؤلف الرئيسي للمستودع. يرجى الامتثال للقوانين واللوائح المحلية عند الاستخدام. أي مشاكل ناتجة عن سوء الاستخدام تقع على عاتق المستخدم ومن يقدم تعليمات الاستخدام غير المصرح به.**
|
||||
|
||||
</div>
|
||||
77
docs/README_ja.md
Normal file
77
docs/README_ja.md
Normal file
@@ -0,0 +1,77 @@
|
||||
<img src="https://napneko.github.io/assets/newnewlogo.png" width = "305" height = "411" alt="NapCat" align=right />
|
||||
<div align="center">
|
||||
|
||||
# NapCat
|
||||
|
||||
_NTQQをベースにした最新のプロトコル側フレームワーク._
|
||||
|
||||
> 雲起きて風生じ、心は遠方を向けど路は未だ至らず.
|
||||
|
||||
[English](../README.md) | [简体中文](./README_zh-CN.md) | [繁體中文](./README_zh-TW.md) | 日本語 | [한국어](./README_ko.md) | [Русский](./README_ru.md) | [العربية](./README_ar.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## お知らせ
|
||||
NapCat は現在、新しいメインメンテナーを募集しています。nanaeonn@outlook.com までメールでご連絡ください。この期間中、公開コミュニティグループは設立しませんが、NapCat は通常通りの更新を保証します。
|
||||
|
||||
## ようこそ
|
||||
|
||||
- NapCatQQ は NTQQ をベースにした最新の Bot プロトコル側実装です
|
||||
|
||||
## 特徴
|
||||
|
||||
- **簡単に使える**
|
||||
- 初心者でも簡単に使用できます.
|
||||
- **高速で効率的**
|
||||
- 低メモリ環境のOSで長時間稼働できます.
|
||||
- **豊富な API インターフェース**
|
||||
- ほとんどの標準インターフェースを完全に実装しています.
|
||||
- **安定して信頼できる**
|
||||
- 継続的で安定した開発とメンテナンスを行っています.
|
||||
|
||||
## クイックスタート
|
||||
|
||||
最新バージョンは [Release](https://github.com/NapNeko/NapCatQQ/releases/) ページからダウンロードできます
|
||||
|
||||
**初めてご使用の方**は、以下のドキュメントで使用ガイドを必ずご確認ください
|
||||
|
||||
> このプロジェクトは非営利です。連携に関する問題/基本的な問題/下位フレームワークの問題については、ご自身で検索して解決してください。本プロジェクトのコミュニティではそのような回答は提供していません。
|
||||
|
||||
## リンク
|
||||
|
||||
| ドキュメント | [](https://napneko.github.io/) | [](https://doc.napneko.icu/) | [](https://napcat.napneko.icu/) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| ドキュメント | [](https://napneko.pages.dev/) | [](https://napcat.top/) | [](https://napcat.top/) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| DeepWiki | [](https://deepwiki.com/NapNeko/NapCatQQ) |
|
||||
|:-:|:-:|
|
||||
|
||||
> 他のコミュニティでこのプロジェクト(他のプロトコル側/関連アプリケーション側のプロジェクトを含む)に言及して論争を引き起こさないでください。提案がある場合は、公式交流グループで議論するか、PRを提出してください。
|
||||
|
||||
## 謝辞
|
||||
|
||||
- [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 本プロジェクトへの多大なサポート、一部コードを参考にしています(許可済み)
|
||||
|
||||
- [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 ベースのメッセージエクスポートツールです
|
||||
|
||||
- そして何より大切なのは、画面の前のあなたに感謝することです~
|
||||
|
||||
---
|
||||
|
||||
## ライセンス
|
||||
|
||||
本プロジェクトはハイブリッドライセンスの下でオープンソース化されています。本プロジェクトを使用する際は、以下の点にご注意ください:
|
||||
|
||||
1. サードパーティライブラリのコードまたは修正部分は、元のオープンソースライセンスに従います.
|
||||
2. 本プロジェクトは一部のプロジェクトから許可を得ており、一部の制約を受けません.
|
||||
3. プロジェクトのその他のロジックコードは[本リポジトリのオープンソースライセンス](../LICENSE)に従います.
|
||||
|
||||
**本リポジトリは使いやすさの향上とメッセージプッシュ機能の実装のみを目的としています。また、リポジトリの主要作者の事前許可なく NapCat のコードに基づいてプロジェクトを開発することは禁止されています。使用する際は現地の法律と規制を遵守してください。これに起因する問題は、使用者および違反使用チュートリアルの提供者の責任となります。**
|
||||
77
docs/README_ko.md
Normal file
77
docs/README_ko.md
Normal file
@@ -0,0 +1,77 @@
|
||||
<img src="https://napneko.github.io/assets/newnewlogo.png" width = "305" height = "411" alt="NapCat" align=right />
|
||||
<div align="center">
|
||||
|
||||
# NapCat
|
||||
|
||||
_NTQQ 기반으로 구현된 최신 프로토콜 사이드 프레임워크._
|
||||
|
||||
> 구름이 일고 바람이 불어, 마음은 먼 곳을 향하나 길은 아직 닿지 못했네.
|
||||
|
||||
[English](../README.md) | [简体中文](./README_zh-CN.md) | [繁體中文](./README_zh-TW.md) | [日本語](./README_ja.md) | 한국어 | [Русский](./README_ru.md) | [العربية](./README_ar.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 공지
|
||||
NapCat은 현재 새로운 주요 메인테이너를 찾고 있습니다. nanaeonn@outlook.com 으로 이메일을 보내주세요. 이 기간 동안 공개 커뮤니티 그룹은 만들어지지 않지만, NapCat은 정기적인 업데이트를 보장합니다.
|
||||
|
||||
## 환영합니다
|
||||
|
||||
- NapCatQQ는 NTQQ를 기반으로 한 최신 Bot 프로토콜 구현체입니다
|
||||
|
||||
## 특징
|
||||
|
||||
- **사용하기 쉬움**
|
||||
- 초보자도 쉽게 사용할 수 있습니다.
|
||||
- **빠르고 효율적**
|
||||
- 저메모리 시스템에서도 장시간 안정적으로 실행됩니다.
|
||||
- **풍부한 API 인터페이스**
|
||||
- 대부분의 표준 인터페이스를 완전히 구현했습니다.
|
||||
- **안정적이고 신뢰할 수 있음**
|
||||
- 지속적이고 안정적인 개발과 유지보수를 하고 있습니다.
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
[Release](https://github.com/NapNeko/NapCatQQ/releases/) 페이지에서 최신 버전을 다운로드할 수 있습니다
|
||||
|
||||
**처음 사용하시는 분**은 아래 문서에서 사용 가이드를 반드시 확인해 주세요
|
||||
|
||||
> 이 프로젝트는 비영리입니다. 연동 문제/기본 문제/하위 프레임워크 문제는 직접 검색하여 해결해 주세요. 본 프로젝트 커뮤니티에서는 이러한 지원을 제공하지 않습니다.
|
||||
|
||||
## 링크
|
||||
|
||||
| 문서 | [](https://napneko.github.io/) | [](https://doc.napneko.icu/) | [](https://napcat.napneko.icu/) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| 문서 | [](https://napneko.pages.dev/) | [](https://napcat.top/) | [](https://napcat.top/) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| DeepWiki | [](https://deepwiki.com/NapNeko/NapCatQQ) |
|
||||
|:-:|:-:|
|
||||
|
||||
> 다른 커뮤니티에서 이 프로젝트(다른 프로토콜 측/관련 애플리케이션 측 프로젝트 포함)를 언급하여 논쟁을 일으키지 마세요. 제안이 있으시면 공식 그룹에서 토론하거나 PR을 제출해 주세요.
|
||||
|
||||
## 감사의 말
|
||||
|
||||
- [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) — 본 프로젝트에 대한 큰 지원, 일부 코드 참고 (허가 완료)
|
||||
|
||||
- [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 기반의 메시지 내보내기 도구
|
||||
|
||||
- 그리고 가장 중요한 것은, 지금 이 글을 읽고 있는 당신에게 감사드립니다~
|
||||
|
||||
---
|
||||
|
||||
## 라이선스
|
||||
|
||||
이 프로젝트는 하이브리드 라이선스로 오픈소스화되어 있습니다. 이 프로젝트를 사용할 때 다음 사항에 유의해 주세요:
|
||||
|
||||
1. 서드파티 라이브러리 코드 또는 수정된 부분은 원래의 오픈소스 라이선스를 따릅니다.
|
||||
2. 이 프로젝트는 일부 프로젝트로부터 허가를 받아 일부 제한을 받지 않습니다.
|
||||
3. 프로젝트의 나머지 로직 코드는 [본 리포지토리 라이선스](../LICENSE)를 따릅니다.
|
||||
|
||||
**이 리포지토리는 사용 편의성 향상과 메시지 푸시 기능 구현만을 목적으로 합니다. 또한, 리포지토리 주요 작성자의 사전 승인 없이 NapCat 코드를 기반으로 프로젝트를 개발하는 것은 금지되어 있습니다. 사용 시 현지 법률 및 규정을 준수해 주세요. 이로 인해 발생하는 문제는 사용자 및 위반 사용 튜토리얼 제공자의 책임입니다.**
|
||||
77
docs/README_ru.md
Normal file
77
docs/README_ru.md
Normal file
@@ -0,0 +1,77 @@
|
||||
<img src="https://napneko.github.io/assets/newnewlogo.png" width = "305" height = "411" alt="NapCat" align=right />
|
||||
<div align="center">
|
||||
|
||||
# NapCat
|
||||
|
||||
_Современный фреймворк на стороне протокола, реализованный на базе NTQQ._
|
||||
|
||||
> Облака поднимаются, ветер рождается, сердце стремится вдаль, но путь ещё не пройден.
|
||||
|
||||
[English](../README.md) | [简体中文](./README_zh-CN.md) | [繁體中文](./README_zh-TW.md) | [日本語](./README_ja.md) | [한국어](./README_ko.md) | Русский | [العربية](./README_ar.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Объявление
|
||||
NapCat в настоящее время ищет нового основного мейнтейнера. Пожалуйста, свяжитесь с нами по электронной почте nanaeonn@outlook.com. В этот период публичные группы сообщества создаваться не будут, но NapCat гарантирует регулярные обновления.
|
||||
|
||||
## Добро пожаловать
|
||||
|
||||
- NapCatQQ — это современная реализация протокола Bot на базе NTQQ
|
||||
|
||||
## Особенности
|
||||
|
||||
- **Простота использования**
|
||||
- Начинающие пользователи смогут легко освоить работу.
|
||||
- **Скорость и эффективность**
|
||||
- Длительная работа на системах с ограниченным объёмом памяти.
|
||||
- **Богатый API-интерфейс**
|
||||
- Полная реализация большинства стандартных интерфейсов.
|
||||
- **Стабильность и надёжность**
|
||||
- Постоянная стабильная разработка и поддержка.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
Скачайте последнюю версию на странице [Release](https://github.com/NapNeko/NapCatQQ/releases/)
|
||||
|
||||
**При первом использовании** обязательно ознакомьтесь с руководством в документации ниже
|
||||
|
||||
> Проект является некоммерческим. Вопросы интеграции, базовые вопросы и вопросы по нижележащему фреймворку решайте самостоятельно — сообщество данного проекта не предоставляет такой поддержки.
|
||||
|
||||
## Ссылки
|
||||
|
||||
| Документация | [](https://napneko.github.io/) | [](https://doc.napneko.icu/) | [](https://napcat.napneko.icu/) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| Документация | [](https://napneko.pages.dev/) | [](https://napcat.top/) | [](https://napcat.top/) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| DeepWiki | [](https://deepwiki.com/NapNeko/NapCatQQ) |
|
||||
|:-:|:-:|
|
||||
|
||||
> Пожалуйста, не упоминайте данный проект (включая другие протокольные/прикладные проекты) в других сообществах во избежание споров. Если у вас есть предложения, обсуждайте их в официальной группе или отправляйте PR.
|
||||
|
||||
## Благодарности
|
||||
|
||||
- [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) — большая поддержка данного проекта, частично использован код (с разрешения)
|
||||
|
||||
- [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
|
||||
|
||||
- И самое главное — спасибо вам, читающему это~
|
||||
|
||||
---
|
||||
|
||||
## Лицензия
|
||||
|
||||
Данный проект распространяется с открытым исходным кодом под гибридной лицензией. При использовании данного проекта обратите внимание на следующее:
|
||||
|
||||
1. Код сторонних библиотек или модифицированные части подчиняются их исходным лицензиям с открытым исходным кодом.
|
||||
2. Данный проект получил разрешение от ряда проектов и не подчиняется некоторым ограничениям.
|
||||
3. Остальной логический код проекта распространяется по [лицензии данного репозитория](../LICENSE).
|
||||
|
||||
**Данный репозиторий предназначен исключительно для повышения удобства использования и реализации функций отправки сообщений. Кроме того, запрещается разработка проектов на основе кода NapCat без предварительного разрешения главного автора репозитория. При использовании соблюдайте местное законодательство. За любые проблемы, вызванные нарушением, несут ответственность пользователь и лицо, предоставившее инструкции по незаконному использованию.**
|
||||
77
docs/README_zh-CN.md
Normal file
77
docs/README_zh-CN.md
Normal file
@@ -0,0 +1,77 @@
|
||||
<img src="https://napneko.github.io/assets/newnewlogo.png" width = "305" height = "411" alt="NapCat" align=right />
|
||||
<div align="center">
|
||||
|
||||
# NapCat
|
||||
|
||||
_基于 NTQQ 实现的现代化协议端框架._
|
||||
|
||||
> 云起兮风生,心向远方兮路未曾至.
|
||||
|
||||
[English](../README.md) | 简体中文 | [繁體中文](./README_zh-TW.md) | [日本語](./README_ja.md) | [한국어](./README_ko.md) | [Русский](./README_ru.md) | [العربية](./README_ar.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 公告
|
||||
NapCat 当前正在寻找新的主要维护者,欢迎email到 nanaeonn@outlook.com ,在此期不会建立任何公开社区交流群,Napcat会保证此期间的正常更新。
|
||||
|
||||
## 欢迎
|
||||
|
||||
- NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
|
||||
|
||||
## 特性
|
||||
|
||||
- **易于使用**
|
||||
- 作为初学者能够轻松使用.
|
||||
- **快速高效**
|
||||
- 在低内存操作系统长时运行.
|
||||
- **丰富的 API 接口**
|
||||
- 完整实现了大部分标准接口.
|
||||
- **稳定可靠**
|
||||
- 持续稳定的开发与维护.
|
||||
|
||||
## 快速开始
|
||||
|
||||
可前往 [Release](https://github.com/NapNeko/NapCatQQ/releases/) 页面下载最新版本
|
||||
|
||||
**首次使用**请务必查看如下文档看使用教程
|
||||
|
||||
> 项目非盈利,涉及 对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
|
||||
|
||||
## 链接
|
||||
|
||||
| 文档 | [](https://napneko.github.io/) | [](https://doc.napneko.icu/) | [](https://napcat.napneko.icu/) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| 文档 | [](https://napneko.pages.dev/) | [](https://napcat.top/) | [](https://napcat.top/) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| DeepWiki | [](https://deepwiki.com/NapNeko/NapCatQQ) |
|
||||
|:-:|:-:|
|
||||
|
||||
> 请不要在其余社区提及本项目(包括其余协议端/相关应用端项目)引发争论,如有建议到达官方交流群讨论或PR。
|
||||
|
||||
## 致谢
|
||||
|
||||
- [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
|
||||
|
||||
- [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的消息导出工具 在此推荐一下
|
||||
|
||||
- 不过最最重要的 还是需要感谢屏幕前的你哦~
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 混合协议 开源,因此使用本项目时,你需要注意以下几点:
|
||||
|
||||
1. 第三方库代码或修改部分遵循其原始开源许可.
|
||||
2. 本项目获取部分项目授权而不受部分约束
|
||||
2. 项目其余逻辑代码采用[本仓库开源许可](../LICENSE).
|
||||
|
||||
**本仓库仅用于提高易用性,实现消息推送类功能,此外,禁止任何项目未经仓库主作者授权基于 NapCat 代码开发。使用请遵守当地法律法规,由此造成的问题由使用者和提供违规使用教程者负责。**
|
||||
77
docs/README_zh-TW.md
Normal file
77
docs/README_zh-TW.md
Normal file
@@ -0,0 +1,77 @@
|
||||
<img src="https://napneko.github.io/assets/newnewlogo.png" width = "305" height = "411" alt="NapCat" align=right />
|
||||
<div align="center">
|
||||
|
||||
# NapCat
|
||||
|
||||
_基於 NTQQ 實現的現代化協議端框架._
|
||||
|
||||
> 雲起兮風生,心向遠方兮路未曾至.
|
||||
|
||||
[English](../README.md) | [简体中文](./README_zh-CN.md) | 繁體中文 | [日本語](./README_ja.md) | [한국어](./README_ko.md) | [Русский](./README_ru.md) | [العربية](./README_ar.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 公告
|
||||
NapCat 目前正在尋找新的主要維護者,歡迎透過 email 聯繫 nanaeonn@outlook.com。在此期間不會建立任何公開社群交流群,NapCat 會保證此期間的正常更新。
|
||||
|
||||
## 歡迎
|
||||
|
||||
- NapCatQQ 是現代化的基於 NTQQ 的 Bot 協議端實現
|
||||
|
||||
## 特性
|
||||
|
||||
- **易於使用**
|
||||
- 初學者也能輕鬆上手.
|
||||
- **快速高效**
|
||||
- 在低記憶體系統長時間穩定運行.
|
||||
- **豐富的 API 介面**
|
||||
- 完整實現了大部分標準介面.
|
||||
- **穩定可靠**
|
||||
- 持續穩定的開發與維護.
|
||||
|
||||
## 快速開始
|
||||
|
||||
可前往 [Release](https://github.com/NapNeko/NapCatQQ/releases/) 頁面下載最新版本
|
||||
|
||||
**首次使用**請務必查看以下文件中的使用教學
|
||||
|
||||
> 專案為非營利性質,涉及 對接問題/基礎問題/底層框架問題 請自行搜尋解決,本專案社群不提供此類解答。
|
||||
|
||||
## 連結
|
||||
|
||||
| 文件 | [](https://napneko.github.io/) | [](https://doc.napneko.icu/) | [](https://napcat.napneko.icu/) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| 文件 | [](https://napneko.pages.dev/) | [](https://napcat.top/) | [](https://napcat.top/) |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
|
||||
| DeepWiki | [](https://deepwiki.com/NapNeko/NapCatQQ) |
|
||||
|:-:|:-:|
|
||||
|
||||
> 請不要在其他社群提及本專案(包括其他協議端/相關應用端專案)以免引發爭論。如有建議,請到官方交流群討論或提交 PR。
|
||||
|
||||
## 致謝
|
||||
|
||||
- [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 對本專案的大力支持,參考部分程式碼(已獲授權)
|
||||
|
||||
- [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 的訊息匯出工具,在此推薦
|
||||
|
||||
- 不過最最重要的,還是要感謝螢幕前的你哦~
|
||||
|
||||
---
|
||||
|
||||
## 授權條款
|
||||
|
||||
本專案採用混合協議開源,因此使用本專案時,您需要注意以下幾點:
|
||||
|
||||
1. 第三方函式庫程式碼或修改部分遵循其原始開源授權.
|
||||
2. 本專案已獲取部分專案授權而不受部分約束.
|
||||
3. 專案其餘邏輯程式碼採用[本倉庫開源授權](../LICENSE).
|
||||
|
||||
**本倉庫僅用於提高易用性,實現訊息推送類功能,此外,禁止任何專案未經倉庫主作者授權基於 NapCat 程式碼開發。使用請遵守當地法律法規,由此造成的問題由使用者和提供違規使用教學者負責。**
|
||||
@@ -16,7 +16,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.13.0",
|
||||
"ajv": "^8.18.0",
|
||||
"file-type": "^21.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -68,7 +68,7 @@ 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);
|
||||
await this.pkt.operation.FetchRkey(3000);
|
||||
} catch (error) {
|
||||
this.logger.logError('测试Packet状态异常', error);
|
||||
return false;
|
||||
|
||||
10
packages/napcat-core/external/napcat.json
vendored
10
packages/napcat-core/external/napcat.json
vendored
@@ -5,5 +5,13 @@
|
||||
"consoleLogLevel": "info",
|
||||
"packetBackend": "auto",
|
||||
"packetServer": "",
|
||||
"o3HookMode": 1
|
||||
"o3HookMode": 1,
|
||||
"bypass": {
|
||||
"hook": false,
|
||||
"window": false,
|
||||
"module": false,
|
||||
"process": false,
|
||||
"container": false,
|
||||
"js": false
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,19 @@
|
||||
import { ConfigBase } from '@/napcat-core/helper/config-base';
|
||||
import { NapCatCore } from '@/napcat-core/index';
|
||||
import { Type, Static } from '@sinclair/typebox';
|
||||
import { AnySchema } from 'ajv';
|
||||
import Ajv, { AnySchema } from 'ajv';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import json5 from 'json5';
|
||||
|
||||
export const BypassOptionsSchema = Type.Object({
|
||||
hook: Type.Boolean({ default: true }),
|
||||
window: Type.Boolean({ default: true }),
|
||||
module: Type.Boolean({ default: true }),
|
||||
process: Type.Boolean({ default: true }),
|
||||
container: Type.Boolean({ default: true }),
|
||||
js: Type.Boolean({ default: true }),
|
||||
});
|
||||
|
||||
export const NapcatConfigSchema = Type.Object({
|
||||
fileLog: Type.Boolean({ default: false }),
|
||||
@@ -11,10 +23,31 @@ export const NapcatConfigSchema = Type.Object({
|
||||
packetBackend: Type.String({ default: 'auto' }),
|
||||
packetServer: Type.String({ default: '' }),
|
||||
o3HookMode: Type.Number({ default: 0 }),
|
||||
bypass: Type.Optional(BypassOptionsSchema),
|
||||
});
|
||||
|
||||
export type NapcatConfig = Static<typeof NapcatConfigSchema>;
|
||||
|
||||
/**
|
||||
* 从指定配置目录读取 napcat.json,按 NapcatConfigSchema 校验并填充默认值
|
||||
* 用于登录前(无 NapCatCore 实例时)的早期配置读取
|
||||
*/
|
||||
export function loadNapcatConfig (configPath: string): NapcatConfig {
|
||||
const ajv = new Ajv({ useDefaults: true, coerceTypes: true });
|
||||
const validate = ajv.compile<NapcatConfig>(NapcatConfigSchema);
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
const configFile = path.join(configPath, 'napcat.json');
|
||||
if (fs.existsSync(configFile)) {
|
||||
data = json5.parse(fs.readFileSync(configFile, 'utf-8'));
|
||||
}
|
||||
} catch {
|
||||
// 读取失败时使用 schema 默认值
|
||||
}
|
||||
validate(data);
|
||||
return data as NapcatConfig;
|
||||
}
|
||||
|
||||
export class NapCatConfigLoader extends ConfigBase<NapcatConfig> {
|
||||
constructor (core: NapCatCore, configPath: string, schema: AnySchema) {
|
||||
super('napcat', core, configPath, schema);
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"inversify": "^7.10.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"@protobuf-ts/runtime": "^2.11.1",
|
||||
"ajv": "^8.13.0",
|
||||
"ajv": "^8.18.0",
|
||||
"@sinclair/typebox": "^0.34.38",
|
||||
"file-type": "^21.0.0",
|
||||
"compressing": "^1.10.1",
|
||||
|
||||
@@ -194,7 +194,7 @@ export class NativePacketHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async init (version: string): Promise<boolean> {
|
||||
async init (version: string, o3HookMode: boolean = false): Promise<boolean> {
|
||||
const version_arch = version + '-' + process.arch;
|
||||
try {
|
||||
if (!this.loaded) {
|
||||
@@ -215,7 +215,7 @@ export class NativePacketHandler {
|
||||
|
||||
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);
|
||||
}, o3HookMode);
|
||||
this.logger.log('[PacketHandler] 初始化成功');
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,10 +4,19 @@ import fs from 'fs';
|
||||
import { constants } from 'node:os';
|
||||
import { LogWrapper } from '../../helper/log';
|
||||
|
||||
export interface BypassOptions {
|
||||
hook?: boolean;
|
||||
window?: boolean;
|
||||
module?: boolean;
|
||||
process?: boolean;
|
||||
container?: boolean;
|
||||
js?: boolean;
|
||||
}
|
||||
|
||||
export interface Napi2NativeExportType {
|
||||
initHook?: (send: string, recv: string) => boolean;
|
||||
setVerbose?: (verbose: boolean) => void; // 默认关闭日志
|
||||
enableAllBypasses?: () => void;
|
||||
enableAllBypasses?: (options?: BypassOptions) => boolean;
|
||||
}
|
||||
|
||||
export class Napi2NativeLoader {
|
||||
|
||||
@@ -29,10 +29,11 @@ export interface PasswordLoginArgType {
|
||||
uin: string;
|
||||
passwordMd5: string;// passwMD5
|
||||
step: number;// 猜测是需要二次认证 参数 一次为0
|
||||
newDeviceLoginSig: string;
|
||||
proofWaterSig: string;
|
||||
proofWaterRand: string;
|
||||
proofWaterSid: string;
|
||||
newDeviceLoginSig: Uint8Array;
|
||||
proofWaterSig: Uint8Array;
|
||||
proofWaterRand: Uint8Array;
|
||||
proofWaterSid: Uint8Array;
|
||||
unusualDeviceCheckSig: Uint8Array;
|
||||
}
|
||||
|
||||
export interface LoginListItem {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InitWebUi, WebUiConfig, webUiRuntimePort } from 'napcat-webui-backend/i
|
||||
import { NapCatAdapterManager } from 'napcat-adapter';
|
||||
import { NativePacketHandler } from 'napcat-core/packet/handler/client';
|
||||
import { Napi2NativeLoader } from 'napcat-core/packet/handler/napi2nativeLoader';
|
||||
import { loadNapcatConfig } from '@/napcat-core/helper/config';
|
||||
import { FFmpegService } from 'napcat-core/helper/ffmpeg/ffmpeg';
|
||||
import { logSubscription, LogWrapper } from 'napcat-core/helper/log';
|
||||
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
|
||||
@@ -42,19 +43,22 @@ export async function NCoreInitFramework (
|
||||
const wrapper = loadQQWrapper(basicInfoWrapper.QQMainPath, basicInfoWrapper.getFullQQVersion());
|
||||
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用
|
||||
const napi2nativeLoader = new Napi2NativeLoader({ logger }); // 初始化 Napi2NativeLoader 用于后续使用
|
||||
const napcatConfig = loadNapcatConfig(pathWrapper.configPath);
|
||||
//console.log('[NapCat] [Napi2NativeLoader]', napi2nativeLoader.nativeExports.enableAllBypasses?.());
|
||||
if (process.env['NAPCAT_DISABLE_BYPASS'] !== '1') {
|
||||
const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.();
|
||||
const bypassOptions = napcatConfig.bypass ?? {};
|
||||
const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions);
|
||||
if (bypassEnabled) {
|
||||
logger.log('[NapCat] Napi2NativeLoader: 已启用Bypass');
|
||||
}
|
||||
logger.log('[NapCat] Napi2NativeLoader: Framework模式Bypass配置:', bypassOptions);
|
||||
} else {
|
||||
logger.log('[NapCat] Napi2NativeLoader: Bypass已通过环境变量禁用');
|
||||
}
|
||||
// nativePacketHandler.onAll((packet) => {
|
||||
// console.log('[Packet]', packet.uin, packet.cmd, packet.hex_data);
|
||||
// });
|
||||
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion());
|
||||
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion(), napcatConfig.o3HookMode === 1 ? true : false);
|
||||
// 在 init 之后注册监听器
|
||||
|
||||
// 初始化 FFmpeg 服务
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"napcat-adapter": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"napcat-vite": "workspace:*",
|
||||
"napcat-qrcode": "workspace:*"
|
||||
"napcat-qrcode": "workspace:*",
|
||||
"json5": "^2.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -13,14 +13,12 @@ import { URL } from 'url';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { OB11HeartbeatEvent } from '@/napcat-onebot/event/meta/OB11HeartbeatEvent';
|
||||
import { OB11LifeCycleEvent, LifeCycleSubType } from '@/napcat-onebot/event/meta/OB11LifeCycleEvent';
|
||||
import { Mutex } from 'async-mutex';
|
||||
|
||||
export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig> {
|
||||
private app: Express | undefined;
|
||||
private server: http.Server | undefined;
|
||||
private wsServer?: WebSocketServer;
|
||||
private wsClients: WebSocket[] = [];
|
||||
private wsClientsMutex = new Mutex();
|
||||
private heartbeatIntervalId: NodeJS.Timeout | null = null;
|
||||
private wsClientWithEvent: WebSocket[] = [];
|
||||
|
||||
@@ -30,19 +28,17 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
|
||||
override async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
// http server is passive, no need to emit event
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
const promises = this.wsClientWithEvent.map((wsClient) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(event));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
const promises = this.wsClientWithEvent.map((wsClient) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(event));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
open () {
|
||||
@@ -65,13 +61,9 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
this.server?.close();
|
||||
this.app = undefined;
|
||||
this.stopHeartbeat();
|
||||
await this.wsClientsMutex.runExclusive(async () => {
|
||||
this.wsClients.forEach((wsClient) => {
|
||||
wsClient.close();
|
||||
});
|
||||
this.wsClients = [];
|
||||
this.wsClientWithEvent = [];
|
||||
});
|
||||
this.wsClients.forEach((wsClient) => wsClient.close());
|
||||
this.wsClients = [];
|
||||
this.wsClientWithEvent = [];
|
||||
this.wsServer?.close();
|
||||
}
|
||||
|
||||
@@ -153,36 +145,29 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
wsClient.on('message', (message) => {
|
||||
this.handleWSMessage(wsClient, message).then().catch(e => this.logger.logError(e));
|
||||
});
|
||||
wsClient.on('ping', () => {
|
||||
wsClient.pong();
|
||||
});
|
||||
wsClient.on('pong', () => {
|
||||
// this.logger.logDebug('[OneBot] [HTTP WebSocket] Pong received');
|
||||
});
|
||||
wsClient.once('close', () => {
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
const NormolIndex = this.wsClients.indexOf(wsClient);
|
||||
if (NormolIndex !== -1) {
|
||||
this.wsClients.splice(NormolIndex, 1);
|
||||
}
|
||||
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
|
||||
if (EventIndex !== -1) {
|
||||
this.wsClientWithEvent.splice(EventIndex, 1);
|
||||
}
|
||||
if (this.wsClientWithEvent.length === 0) {
|
||||
this.stopHeartbeat();
|
||||
}
|
||||
});
|
||||
});
|
||||
await this.wsClientsMutex.runExclusive(async () => {
|
||||
if (!isApiConnect) {
|
||||
this.wsClientWithEvent.push(wsClient);
|
||||
const NormolIndex = this.wsClients.indexOf(wsClient);
|
||||
if (NormolIndex !== -1) {
|
||||
this.wsClients.splice(NormolIndex, 1);
|
||||
}
|
||||
this.wsClients.push(wsClient);
|
||||
if (this.wsClientWithEvent.length > 0) {
|
||||
this.startHeartbeat();
|
||||
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
|
||||
if (EventIndex !== -1) {
|
||||
this.wsClientWithEvent.splice(EventIndex, 1);
|
||||
}
|
||||
if (this.wsClientWithEvent.length === 0) {
|
||||
this.stopHeartbeat();
|
||||
}
|
||||
});
|
||||
if (!isApiConnect) {
|
||||
this.wsClientWithEvent.push(wsClient);
|
||||
}
|
||||
this.wsClients.push(wsClient);
|
||||
if (this.wsClientWithEvent.length > 0) {
|
||||
this.startHeartbeat();
|
||||
}
|
||||
}).on('error', (err) => this.logger.log('[OneBot] [HTTP WebSocket] Server Error:', err.message));
|
||||
}
|
||||
|
||||
@@ -197,12 +182,10 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
|
||||
private startHeartbeat () {
|
||||
if (this.heartbeatIntervalId) return;
|
||||
this.heartbeatIntervalId = setInterval(() => {
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
this.wsClientWithEvent.forEach((wsClient) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, 30000, this.core.selfInfo.online ?? true, true)));
|
||||
}
|
||||
});
|
||||
this.wsClientWithEvent.forEach((wsClient) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, 30000, this.core.selfInfo.online ?? true, true)));
|
||||
}
|
||||
});
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface PluginPackageJson {
|
||||
author?: string;
|
||||
homepage?: string;
|
||||
repository?: string | { type: string; url: string; };
|
||||
icon?: string; // 插件图标文件路径(相对于插件目录),如 "icon.png"
|
||||
}
|
||||
|
||||
// ==================== 插件配置 Schema ====================
|
||||
|
||||
@@ -85,9 +85,6 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
||||
},
|
||||
|
||||
});
|
||||
this.connection.on('ping', () => {
|
||||
this.connection?.pong();
|
||||
});
|
||||
this.connection.on('pong', () => {
|
||||
// this.logger.logDebug('[OneBot] [WebSocket Client] 收到pong');
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
|
||||
import { URL } from 'url';
|
||||
import { RawData, WebSocket, WebSocketServer } from 'ws';
|
||||
import { Mutex } from 'async-mutex';
|
||||
import { OB11Response } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
@@ -17,7 +16,6 @@ import json5 from 'json5';
|
||||
export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> {
|
||||
wsServer?: WebSocketServer;
|
||||
wsClients: WebSocket[] = [];
|
||||
wsClientsMutex = new Mutex();
|
||||
private heartbeatIntervalId: NodeJS.Timeout | null = null;
|
||||
wsClientWithEvent: WebSocket[] = [];
|
||||
|
||||
@@ -58,36 +56,29 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
wsClient.on('message', (message) => {
|
||||
this.handleMessage(wsClient, message).then().catch(e => this.logger.logError(e));
|
||||
});
|
||||
wsClient.on('ping', () => {
|
||||
wsClient.pong();
|
||||
});
|
||||
wsClient.on('pong', () => {
|
||||
// this.logger.logDebug('[OneBot] [WebSocket Server] Pong received');
|
||||
});
|
||||
wsClient.once('close', () => {
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
const NormolIndex = this.wsClients.indexOf(wsClient);
|
||||
if (NormolIndex !== -1) {
|
||||
this.wsClients.splice(NormolIndex, 1);
|
||||
}
|
||||
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
|
||||
if (EventIndex !== -1) {
|
||||
this.wsClientWithEvent.splice(EventIndex, 1);
|
||||
}
|
||||
if (this.wsClientWithEvent.length === 0) {
|
||||
this.stopHeartbeat();
|
||||
}
|
||||
});
|
||||
});
|
||||
await this.wsClientsMutex.runExclusive(async () => {
|
||||
if (!isApiConnect) {
|
||||
this.wsClientWithEvent.push(wsClient);
|
||||
const NormolIndex = this.wsClients.indexOf(wsClient);
|
||||
if (NormolIndex !== -1) {
|
||||
this.wsClients.splice(NormolIndex, 1);
|
||||
}
|
||||
this.wsClients.push(wsClient);
|
||||
if (this.wsClientWithEvent.length > 0) {
|
||||
this.startHeartbeat();
|
||||
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
|
||||
if (EventIndex !== -1) {
|
||||
this.wsClientWithEvent.splice(EventIndex, 1);
|
||||
}
|
||||
if (this.wsClientWithEvent.length === 0) {
|
||||
this.stopHeartbeat();
|
||||
}
|
||||
});
|
||||
if (!isApiConnect) {
|
||||
this.wsClientWithEvent.push(wsClient);
|
||||
}
|
||||
this.wsClients.push(wsClient);
|
||||
if (this.wsClientWithEvent.length > 0) {
|
||||
this.startHeartbeat();
|
||||
}
|
||||
}).on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Server Error:', err.message));
|
||||
}
|
||||
|
||||
@@ -100,19 +91,17 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
const promises = this.wsClientWithEvent.map((wsClient) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(event));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
const promises = this.wsClientWithEvent.map((wsClient) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(event));
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
open () {
|
||||
@@ -136,24 +125,18 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
}
|
||||
});
|
||||
this.stopHeartbeat();
|
||||
await this.wsClientsMutex.runExclusive(async () => {
|
||||
this.wsClients.forEach((wsClient) => {
|
||||
wsClient.close();
|
||||
});
|
||||
this.wsClients = [];
|
||||
this.wsClientWithEvent = [];
|
||||
});
|
||||
this.wsClients.forEach((wsClient) => wsClient.close());
|
||||
this.wsClients = [];
|
||||
this.wsClientWithEvent = [];
|
||||
}
|
||||
|
||||
private startHeartbeat () {
|
||||
if (this.heartbeatIntervalId || this.config.heartInterval <= 0) return;
|
||||
this.heartbeatIntervalId = setInterval(() => {
|
||||
this.wsClientsMutex.runExclusive(async () => {
|
||||
this.wsClientWithEvent.forEach((wsClient) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, this.config.heartInterval, this.core.selfInfo.online ?? true, true)));
|
||||
}
|
||||
});
|
||||
this.wsClientWithEvent.forEach((wsClient) => {
|
||||
if (wsClient.readyState === WebSocket.OPEN) {
|
||||
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, this.config.heartInterval, this.core.selfInfo.online ?? true, true)));
|
||||
}
|
||||
});
|
||||
}, this.config.heartInterval);
|
||||
}
|
||||
|
||||
@@ -20,13 +20,12 @@
|
||||
"fast-xml-parser": "^4.3.6",
|
||||
"type-is": "^1.6.7",
|
||||
"on-finished": "^2.3.5",
|
||||
"ajv": "^8.13.0",
|
||||
"ajv": "^8.18.0",
|
||||
"@sinclair/typebox": "^0.34.38",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.0.0",
|
||||
"ws": "^8.18.3",
|
||||
"file-type": "^21.0.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"napcat-protobuf": "workspace:*",
|
||||
"json5": "^2.2.3",
|
||||
"napcat-core": "workspace:*",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.13.0",
|
||||
"ajv": "^8.18.0",
|
||||
"@sinclair/typebox": "^0.34.38",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.0.0",
|
||||
|
||||
@@ -20,6 +20,7 @@ import { hostname, systemVersion } from 'napcat-common/src/system';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { LoginListItem, NodeIKernelLoginService } from 'napcat-core/services';
|
||||
import qrcode from 'napcat-qrcode/lib/main';
|
||||
import { NapCatAdapterManager } from 'napcat-adapter';
|
||||
@@ -31,12 +32,14 @@ import { sleep } from 'napcat-common/src/helper';
|
||||
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
|
||||
import { NativePacketHandler } from 'napcat-core/packet/handler/client';
|
||||
import { Napi2NativeLoader } from 'napcat-core/packet/handler/napi2nativeLoader';
|
||||
import { loadNapcatConfig } from '@/napcat-core/helper/config';
|
||||
import { logSubscription, LogWrapper } from '@/napcat-core/helper/log';
|
||||
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
|
||||
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
|
||||
import { statusHelperSubscription } from '@/napcat-core/helper/status';
|
||||
import { applyPendingUpdates } from '@/napcat-webui-backend/src/api/UpdateNapCat';
|
||||
import { connectToNamedPipe } from './pipe';
|
||||
|
||||
// NapCat Shell App ES 入口文件
|
||||
async function handleUncaughtExceptions (logger: LogWrapper) {
|
||||
process.on('uncaughtException', (err) => {
|
||||
@@ -192,6 +195,24 @@ async function handleLogin (
|
||||
return await selfInfo;
|
||||
}
|
||||
async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWrapper, loginService: NodeIKernelLoginService, quickLoginUin: string | undefined, historyLoginList: LoginListItem[]) {
|
||||
const resolveQuickPasswordMd5 = (): string | undefined => {
|
||||
const quickPasswordMd5 = process.env['NAPCAT_QUICK_PASSWORD_MD5']?.trim();
|
||||
if (quickPasswordMd5) {
|
||||
if (/^[a-fA-F0-9]{32}$/.test(quickPasswordMd5)) {
|
||||
return quickPasswordMd5.toLowerCase();
|
||||
}
|
||||
logger.logError('NAPCAT_QUICK_PASSWORD_MD5 格式无效(需为 32 位 MD5)');
|
||||
}
|
||||
|
||||
const quickPassword = process.env['NAPCAT_QUICK_PASSWORD'];
|
||||
if (typeof quickPassword === 'string' && quickPassword.length > 0) {
|
||||
logger.log('检测到 NAPCAT_QUICK_PASSWORD,已在内存中计算 MD5 用于回退登录');
|
||||
return createHash('md5').update(quickPassword, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 注册刷新二维码回调
|
||||
WebUiDataRuntime.setRefreshQRCodeCallback(async () => {
|
||||
loginService.getQRCodePicture();
|
||||
@@ -202,10 +223,12 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
|
||||
if (uin) {
|
||||
logger.log('正在快速登录 ', uin);
|
||||
loginService.quickLoginWithUin(uin).then(res => {
|
||||
if (res.loginErrorInfo.errMsg) {
|
||||
WebUiDataRuntime.setQQLoginError(res.loginErrorInfo.errMsg);
|
||||
const quickLoginSuccess = res.result === '0' && !res.loginErrorInfo?.errMsg;
|
||||
if (!quickLoginSuccess) {
|
||||
const errMsg = res.loginErrorInfo?.errMsg || `快速登录失败,错误码: ${res.result}`;
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: res.loginErrorInfo.errMsg });
|
||||
resolve({ result: false, message: errMsg });
|
||||
} else {
|
||||
WebUiDataRuntime.setQQLoginStatus(true);
|
||||
WebUiDataRuntime.setQQLoginError('');
|
||||
@@ -232,21 +255,43 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
|
||||
uin,
|
||||
passwordMd5,
|
||||
step: 0,
|
||||
newDeviceLoginSig: '',
|
||||
proofWaterSig: '',
|
||||
proofWaterRand: '',
|
||||
proofWaterSid: '',
|
||||
newDeviceLoginSig: new Uint8Array(),
|
||||
proofWaterSig: new Uint8Array(),
|
||||
proofWaterRand: new Uint8Array(),
|
||||
proofWaterSid: new Uint8Array(),
|
||||
unusualDeviceCheckSig: new Uint8Array(),
|
||||
}).then(res => {
|
||||
if (res.result === '140022008') {
|
||||
const errMsg = '需要验证码,暂不支持';
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: errMsg });
|
||||
const proofWaterUrl = res.loginErrorInfo?.proofWaterUrl || '';
|
||||
logger.log('需要验证码, proofWaterUrl: ', proofWaterUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '需要验证码',
|
||||
needCaptcha: true,
|
||||
proofWaterUrl,
|
||||
});
|
||||
} else if (res.result === '140022010') {
|
||||
const errMsg = '新设备需要扫码登录,暂不支持';
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: errMsg });
|
||||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||||
logger.log('新设备需要扫码验证, jumpUrl: ', jumpUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '新设备需要扫码验证',
|
||||
needNewDevice: true,
|
||||
jumpUrl,
|
||||
newDevicePullQrCodeSig,
|
||||
});
|
||||
} else if (res.result === '140022011') {
|
||||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||||
logger.log('异常设备需要验证, jumpUrl: ', jumpUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '异常设备需要验证',
|
||||
needNewDevice: true,
|
||||
jumpUrl,
|
||||
newDevicePullQrCodeSig,
|
||||
});
|
||||
} else if (res.result !== '0') {
|
||||
const errMsg = res.loginErrorInfo?.errMsg || '密码登录失败';
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
@@ -268,21 +313,170 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
|
||||
}
|
||||
});
|
||||
});
|
||||
const tryPasswordFallbackLogin = async (uin: string): Promise<{ success: boolean, attempted: boolean; }> => {
|
||||
const quickPasswordMd5 = resolveQuickPasswordMd5();
|
||||
if (!quickPasswordMd5) {
|
||||
logger.log(`QQ ${uin} 未配置回退密码环境变量,建议优先使用 ACCOUNT + NAPCAT_QUICK_PASSWORD(NAPCAT_QUICK_PASSWORD_MD5 作为备用),将使用二维码登录方式`);
|
||||
return { success: false, attempted: false };
|
||||
}
|
||||
|
||||
logger.log('正在尝试密码回退登录 ', uin);
|
||||
const fallbackResult = await WebUiDataRuntime.requestPasswordLogin(uin, quickPasswordMd5);
|
||||
if (fallbackResult.result) {
|
||||
logger.log('密码回退登录成功 ', uin);
|
||||
return { success: true, attempted: true };
|
||||
}
|
||||
if (fallbackResult.needCaptcha) {
|
||||
const captchaTip = fallbackResult.proofWaterUrl
|
||||
? `密码回退需要验证码,请在 WebUi 中继续完成验证:${fallbackResult.proofWaterUrl}`
|
||||
: '密码回退需要验证码,请在 WebUi 中继续完成验证';
|
||||
logger.logWarn(captchaTip);
|
||||
WebUiDataRuntime.setQQLoginError('密码回退需要验证码,请在 WebUi 中继续完成验证');
|
||||
return { success: false, attempted: true };
|
||||
}
|
||||
if (fallbackResult.needNewDevice) {
|
||||
const newDeviceTip = fallbackResult.jumpUrl
|
||||
? `密码回退需要新设备验证,请在 WebUi 中继续完成验证:${fallbackResult.jumpUrl}`
|
||||
: '密码回退需要新设备验证,请在 WebUi 中继续完成验证';
|
||||
logger.logWarn(newDeviceTip);
|
||||
WebUiDataRuntime.setQQLoginError('密码回退需要新设备验证,请在 WebUi 中继续完成验证');
|
||||
return { success: false, attempted: true };
|
||||
}
|
||||
logger.logError('密码回退登录失败:', fallbackResult.message);
|
||||
return { success: false, attempted: true };
|
||||
};
|
||||
|
||||
// 注册验证码登录回调(密码登录需要验证码时的第二步)
|
||||
WebUiDataRuntime.setCaptchaLoginCall(async (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) => {
|
||||
return await new Promise((resolve) => {
|
||||
if (uin && passwordMd5 && ticket) {
|
||||
logger.log('正在验证码登录 ', uin);
|
||||
loginService.passwordLogin({
|
||||
uin,
|
||||
passwordMd5,
|
||||
step: 1,
|
||||
newDeviceLoginSig: new Uint8Array(),
|
||||
proofWaterSig: new TextEncoder().encode(ticket),
|
||||
proofWaterRand: new TextEncoder().encode(randstr),
|
||||
proofWaterSid: new TextEncoder().encode(sid),
|
||||
unusualDeviceCheckSig: new Uint8Array(),
|
||||
}).then(res => {
|
||||
console.log('验证码登录结果: ', res);
|
||||
if (res.result === '140022010') {
|
||||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||||
logger.log('验证码登录后需要新设备验证, jumpUrl: ', jumpUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '新设备需要扫码验证',
|
||||
needNewDevice: true,
|
||||
jumpUrl,
|
||||
newDevicePullQrCodeSig,
|
||||
});
|
||||
} else if (res.result === '140022011') {
|
||||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||||
logger.log('验证码登录后需要异常设备验证, jumpUrl: ', jumpUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '异常设备需要验证',
|
||||
needNewDevice: true,
|
||||
jumpUrl,
|
||||
newDevicePullQrCodeSig,
|
||||
});
|
||||
} else if (res.result !== '0') {
|
||||
const errMsg = res.loginErrorInfo?.errMsg || '验证码登录失败';
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: errMsg });
|
||||
} else {
|
||||
WebUiDataRuntime.setQQLoginStatus(true);
|
||||
WebUiDataRuntime.setQQLoginError('');
|
||||
resolve({ result: true, message: '' });
|
||||
}
|
||||
}).catch((e) => {
|
||||
logger.logError(e);
|
||||
WebUiDataRuntime.setQQLoginError('验证码登录发生错误');
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: '验证码登录发生错误' });
|
||||
});
|
||||
} else {
|
||||
resolve({ result: false, message: '验证码登录失败:参数不完整' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 注册新设备登录回调(密码登录需要新设备验证时的第二步)
|
||||
WebUiDataRuntime.setNewDeviceLoginCall(async (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) => {
|
||||
return await new Promise((resolve) => {
|
||||
if (uin && passwordMd5 && newDevicePullQrCodeSig) {
|
||||
logger.log('正在新设备验证登录 ', uin);
|
||||
loginService.passwordLogin({
|
||||
uin,
|
||||
passwordMd5,
|
||||
step: 2,
|
||||
newDeviceLoginSig: new TextEncoder().encode(newDevicePullQrCodeSig),
|
||||
proofWaterSig: new Uint8Array(),
|
||||
proofWaterRand: new Uint8Array(),
|
||||
proofWaterSid: new Uint8Array(),
|
||||
unusualDeviceCheckSig: new Uint8Array(),
|
||||
}).then(res => {
|
||||
if (res.result === '140022011') {
|
||||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||||
logger.log('新设备验证后需要异常设备验证, jumpUrl: ', jumpUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '异常设备需要验证',
|
||||
needNewDevice: true,
|
||||
jumpUrl,
|
||||
newDevicePullQrCodeSig,
|
||||
});
|
||||
} else if (res.result !== '0') {
|
||||
const errMsg = res.loginErrorInfo?.errMsg || '新设备验证登录失败';
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: errMsg });
|
||||
} else {
|
||||
WebUiDataRuntime.setQQLoginStatus(true);
|
||||
WebUiDataRuntime.setQQLoginError('');
|
||||
resolve({ result: true, message: '' });
|
||||
}
|
||||
}).catch((e) => {
|
||||
logger.logError(e);
|
||||
WebUiDataRuntime.setQQLoginError('新设备验证登录发生错误');
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: '新设备验证登录发生错误' });
|
||||
});
|
||||
} else {
|
||||
resolve({ result: false, message: '新设备验证登录失败:参数不完整' });
|
||||
}
|
||||
});
|
||||
});
|
||||
if (quickLoginUin) {
|
||||
if (historyLoginList.some(u => u.uin === quickLoginUin)) {
|
||||
logger.log('正在快速登录 ', quickLoginUin);
|
||||
loginService.quickLoginWithUin(quickLoginUin)
|
||||
.then(result => {
|
||||
if (result.loginErrorInfo.errMsg) {
|
||||
logger.logError('快速登录错误:', result.loginErrorInfo.errMsg);
|
||||
WebUiDataRuntime.setQQLoginError(result.loginErrorInfo.errMsg);
|
||||
if (!context.isLogined) loginService.getQRCodePicture();
|
||||
.then(async result => {
|
||||
const quickLoginSuccess = result.result === '0' && !result.loginErrorInfo?.errMsg;
|
||||
if (!quickLoginSuccess) {
|
||||
const errMsg = result.loginErrorInfo?.errMsg || `快速登录失败,错误码: ${result.result}`;
|
||||
logger.logError('快速登录错误:', errMsg);
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
const { success, attempted } = await tryPasswordFallbackLogin(quickLoginUin);
|
||||
if (!success && !attempted && !context.isLogined) loginService.getQRCodePicture();
|
||||
}
|
||||
})
|
||||
.catch();
|
||||
.catch(async (error) => {
|
||||
logger.logError('快速登录异常:', error);
|
||||
WebUiDataRuntime.setQQLoginError('快速登录发生错误');
|
||||
const { success, attempted } = await tryPasswordFallbackLogin(quickLoginUin);
|
||||
if (!success && !attempted && !context.isLogined) loginService.getQRCodePicture();
|
||||
});
|
||||
} else {
|
||||
logger.logError('快速登录失败,未找到该 QQ 历史登录记录,将使用二维码登录方式');
|
||||
if (!context.isLogined) loginService.getQRCodePicture();
|
||||
logger.logError('快速登录失败,未找到该 QQ 历史登录记录,将尝试密码回退登录');
|
||||
const { success, attempted } = await tryPasswordFallbackLogin(quickLoginUin);
|
||||
if (!success && !attempted && !context.isLogined) loginService.getQRCodePicture();
|
||||
}
|
||||
} else {
|
||||
logger.log('没有 -q 指令指定快速登录,将使用二维码登录方式');
|
||||
@@ -392,7 +586,6 @@ export async function NCoreInitShell () {
|
||||
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
|
||||
const nativePacketHandler = new NativePacketHandler({ logger });
|
||||
const napi2nativeLoader = new Napi2NativeLoader({ logger });
|
||||
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion());
|
||||
|
||||
// 初始化 FFmpeg 服务
|
||||
await FFmpegService.init(pathWrapper.binaryPath, logger);
|
||||
@@ -401,15 +594,16 @@ export async function NCoreInitShell () {
|
||||
await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e));
|
||||
}
|
||||
const wrapper = loadQQWrapper(basicInfoWrapper.QQMainPath, basicInfoWrapper.getFullQQVersion());
|
||||
// wrapper.node 加载后再初始化 hook,按 schema 读取配置
|
||||
const napcatConfig = loadNapcatConfig(pathWrapper.configPath);
|
||||
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion(), napcatConfig.o3HookMode === 1 ? true : false);
|
||||
if (process.env['NAPCAT_ENABLE_VERBOSE_LOG'] === '1') {
|
||||
napi2nativeLoader.nativeExports.setVerbose?.(true);
|
||||
}
|
||||
// wrapper.node 加载后立刻启用 Bypass(可通过环境变量禁用)
|
||||
if (process.env['NAPCAT_DISABLE_BYPASS'] !== '1') {
|
||||
const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.();
|
||||
if (bypassEnabled) {
|
||||
logger.log('[NapCat] Napi2NativeLoader: 已启用Bypass');
|
||||
}
|
||||
const bypassOptions = napcatConfig.bypass ?? {};
|
||||
napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions);
|
||||
} else {
|
||||
logger.log('[NapCat] Napi2NativeLoader: Bypass已通过环境变量禁用');
|
||||
}
|
||||
@@ -463,6 +657,13 @@ export async function NCoreInitShell () {
|
||||
o3Service.reportAmgomWeather('login', 'a1', [dataTimestape, '0', '0']);
|
||||
|
||||
const selfInfo = await handleLogin(loginService, logger, pathWrapper, quickLoginUin, historyLoginList);
|
||||
|
||||
// 登录成功后通知 Master 进程(用于切换崩溃重试策略)
|
||||
if (typeof process.send === 'function') {
|
||||
process.send({ type: 'login-success' });
|
||||
logger.log('[NapCat] 已通知主进程登录成功');
|
||||
}
|
||||
|
||||
const amgomDataPiece = 'eb1fd6ac257461580dc7438eb099f23aae04ca679f4d88f53072dc56e3bb1129';
|
||||
o3Service.setAmgomDataPiece(basicInfoWrapper.QQVersionAppid, new Uint8Array(Buffer.from(amgomDataPiece, 'hex')));
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const ENV = {
|
||||
|
||||
// Worker 消息类型
|
||||
interface WorkerMessage {
|
||||
type: 'restart' | 'restart-prepare' | 'shutdown';
|
||||
type: 'restart' | 'restart-prepare' | 'shutdown' | 'login-success';
|
||||
secretKey?: string;
|
||||
port?: number;
|
||||
}
|
||||
@@ -65,6 +65,7 @@ const recentCrashTimestamps: number[] = [];
|
||||
const CRASH_TIME_WINDOW = 10000; // 10秒时间窗口
|
||||
const MAX_CRASHES_IN_WINDOW = 3; // 最大崩溃次数
|
||||
|
||||
|
||||
/**
|
||||
* 获取进程类型名称(用于日志)
|
||||
*/
|
||||
@@ -275,6 +276,8 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
|
||||
restartWorker(message.secretKey, message.port).catch(e => {
|
||||
logger.logError(`[NapCat] [${processType}] 重启Worker进程失败:`, e);
|
||||
});
|
||||
} else if (message.type === 'login-success') {
|
||||
logger.log(`[NapCat] [${processType}] Worker进程已登录成功,切换到正常重试策略`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -297,13 +300,13 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
|
||||
// 记录本次崩溃
|
||||
recentCrashTimestamps.push(now);
|
||||
|
||||
// 检查是否超过崩溃阈值
|
||||
if (recentCrashTimestamps.length >= MAX_CRASHES_IN_WINDOW) {
|
||||
logger.logError(`[NapCat] [${processType}] Worker进程在 ${CRASH_TIME_WINDOW / 1000} 秒内异常退出 ${MAX_CRASHES_IN_WINDOW} 次,主进程退出`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出 (${recentCrashTimestamps.length}/${MAX_CRASHES_IN_WINDOW}),正在尝试重新拉起...`);
|
||||
|
||||
startWorker(true).catch(e => {
|
||||
logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e);
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"napcat-qrcode": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"json5": "^2.2.3",
|
||||
"@types/node": "^22.0.1",
|
||||
"napcat-vite": "workspace:*"
|
||||
},
|
||||
|
||||
14
packages/napcat-types/external-shims.d.ts
vendored
14
packages/napcat-types/external-shims.d.ts
vendored
@@ -61,20 +61,6 @@ declare module 'yaml' {
|
||||
export const stringify: (...args: any[]) => any;
|
||||
}
|
||||
|
||||
declare module 'async-mutex' {
|
||||
export class Mutex {
|
||||
acquire (): Promise<() => void>;
|
||||
runExclusive<T> (callback: () => T | Promise<T>): Promise<T>;
|
||||
}
|
||||
export class Semaphore {
|
||||
acquire (): Promise<[() => void, number]>;
|
||||
runExclusive<T> (callback: () => T | Promise<T>): Promise<T>;
|
||||
release (): void;
|
||||
}
|
||||
const _async_mutex_default: { Mutex: typeof Mutex; Semaphore: typeof Semaphore; };
|
||||
export default _async_mutex_default;
|
||||
}
|
||||
|
||||
declare module 'napcat-protobuf' {
|
||||
export class NapProtoMsg<T = any> {
|
||||
constructor (schema: any);
|
||||
|
||||
@@ -35,9 +35,6 @@ const EXTERNAL_TYPE_REPLACEMENTS = {
|
||||
'ValidateFunction<T>': 'any',
|
||||
// inversify
|
||||
'Container': 'any',
|
||||
// async-mutex
|
||||
'Mutex': 'any',
|
||||
'Semaphore': 'any',
|
||||
// napcat-protobuf
|
||||
'NapProtoDecodeStructType': 'any',
|
||||
'NapProtoEncodeStructType': 'any',
|
||||
@@ -90,15 +87,15 @@ function replaceExternalTypes (content) {
|
||||
// 使用类型上下文的模式匹配
|
||||
const typeContextPatterns = [
|
||||
// : Type
|
||||
/:\s*(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[;,)\]\}|&]|$)/g,
|
||||
/:\s*(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[;,)\]\}|&]|$)/g,
|
||||
// <Type>
|
||||
/<(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)>/g,
|
||||
/<(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)>/g,
|
||||
// Type[]
|
||||
/(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)\[\]/g,
|
||||
/(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)\[\]/g,
|
||||
// extends Type
|
||||
/extends\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
|
||||
/extends\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
|
||||
// implements Type
|
||||
/implements\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
|
||||
/implements\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
|
||||
];
|
||||
|
||||
for (const pattern of typeContextPatterns) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import express from 'express';
|
||||
import type { WebUiConfigType } from './src/types';
|
||||
import { createServer } from 'http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createServer as createHttpsServer } from 'https';
|
||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
import { WebUiConfigWrapper } from '@/napcat-webui-backend/src/helper/config';
|
||||
@@ -156,16 +156,60 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
||||
WebUiDataRuntime.setWebUiConfigQuickFunction(
|
||||
async () => {
|
||||
const autoLoginAccount = process.env['NAPCAT_QUICK_ACCOUNT'] || WebUiConfig.getAutoLoginAccount();
|
||||
if (autoLoginAccount) {
|
||||
try {
|
||||
const { result, message } = await WebUiDataRuntime.requestQuickLogin(autoLoginAccount);
|
||||
if (!result) {
|
||||
throw new Error(message);
|
||||
const resolveQuickPasswordMd5 = (): string | undefined => {
|
||||
const quickPasswordMd5FromEnv = process.env['NAPCAT_QUICK_PASSWORD_MD5']?.trim();
|
||||
if (quickPasswordMd5FromEnv) {
|
||||
if (/^[a-fA-F0-9]{32}$/.test(quickPasswordMd5FromEnv)) {
|
||||
return quickPasswordMd5FromEnv.toLowerCase();
|
||||
}
|
||||
console.log(`[NapCat] [WebUi] Auto login account: ${autoLoginAccount}`);
|
||||
} catch (error) {
|
||||
console.log('[NapCat] [WebUi] Auto login account failed.' + error);
|
||||
console.log('[NapCat] [WebUi] NAPCAT_QUICK_PASSWORD_MD5 格式无效(需为 32 位 MD5)');
|
||||
}
|
||||
|
||||
const quickPassword = process.env['NAPCAT_QUICK_PASSWORD'];
|
||||
if (typeof quickPassword === 'string' && quickPassword.length > 0) {
|
||||
console.log('[NapCat] [WebUi] 检测到 NAPCAT_QUICK_PASSWORD,已在内存中计算 MD5 用于回退登录');
|
||||
return createHash('md5').update(quickPassword, 'utf8').digest('hex');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
if (!autoLoginAccount) {
|
||||
return;
|
||||
}
|
||||
const quickPasswordMd5 = resolveQuickPasswordMd5();
|
||||
|
||||
try {
|
||||
const { result, message } = await WebUiDataRuntime.requestQuickLogin(autoLoginAccount);
|
||||
if (result) {
|
||||
console.log(`[NapCat] [WebUi] 自动快速登录成功: ${autoLoginAccount}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[NapCat] [WebUi] 自动快速登录失败: ${message || '未知错误'}`);
|
||||
} catch (error) {
|
||||
console.log('[NapCat] [WebUi] 自动快速登录异常:' + error);
|
||||
}
|
||||
|
||||
if (!quickPasswordMd5) {
|
||||
console.log(`[NapCat] [WebUi] QQ ${autoLoginAccount} 未配置回退密码环境变量,建议优先使用 ACCOUNT + NAPCAT_QUICK_PASSWORD(NAPCAT_QUICK_PASSWORD_MD5 作为备用),保持二维码登录兜底`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { result, message, needCaptcha, needNewDevice } = await WebUiDataRuntime.requestPasswordLogin(autoLoginAccount, quickPasswordMd5);
|
||||
if (result) {
|
||||
console.log(`[NapCat] [WebUi] 自动密码回退登录成功: ${autoLoginAccount}`);
|
||||
return;
|
||||
}
|
||||
if (needCaptcha) {
|
||||
console.log(`[NapCat] [WebUi] 自动密码回退登录需要验证码,请在登录页面继续完成: ${autoLoginAccount}`);
|
||||
return;
|
||||
}
|
||||
if (needNewDevice) {
|
||||
console.log(`[NapCat] [WebUi] 自动密码回退登录需要新设备验证,请在登录页面继续完成: ${autoLoginAccount}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[NapCat] [WebUi] 自动密码回退登录失败: ${message || '未知错误'}`);
|
||||
} catch (error) {
|
||||
console.log('[NapCat] [WebUi] 自动密码回退登录异常:' + error);
|
||||
}
|
||||
});
|
||||
// ------------注册中间件------------
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@sinclair/typebox": "^0.34.38",
|
||||
"ajv": "^8.13.0",
|
||||
"ajv": "^8.18.0",
|
||||
"compressing": "^1.10.3",
|
||||
"compression": "^1.8.1",
|
||||
"express": "^5.0.0",
|
||||
|
||||
90
packages/napcat-webui-backend/src/api/NapCatConfig.ts
Normal file
90
packages/napcat-webui-backend/src/api/NapCatConfig.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import json5 from 'json5';
|
||||
|
||||
import Ajv from 'ajv';
|
||||
import { NapcatConfigSchema } from '@/napcat-core/helper/config';
|
||||
|
||||
// 动态获取 NapCat 配置默认值
|
||||
function getDefaultNapcatConfig (): Record<string, unknown> {
|
||||
const ajv = new Ajv({ useDefaults: true, coerceTypes: true });
|
||||
const validate = ajv.compile(NapcatConfigSchema);
|
||||
const data = {};
|
||||
validate(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 napcat 配置文件路径
|
||||
*/
|
||||
function getNapcatConfigPath (): string {
|
||||
return resolve(webUiPathWrapper.configPath, './napcat.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 napcat 配置
|
||||
*/
|
||||
function readNapcatConfig (): Record<string, unknown> {
|
||||
const configPath = getNapcatConfigPath();
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
const content = readFileSync(configPath, 'utf-8');
|
||||
return { ...getDefaultNapcatConfig(), ...json5.parse(content) };
|
||||
}
|
||||
} catch (_e) {
|
||||
// 读取失败,使用默认值
|
||||
}
|
||||
return { ...getDefaultNapcatConfig() };
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 napcat 配置
|
||||
*/
|
||||
function writeNapcatConfig (config: Record<string, unknown>): void {
|
||||
const configPath = resolve(webUiPathWrapper.configPath, './napcat.json');
|
||||
mkdirSync(webUiPathWrapper.configPath, { recursive: true });
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
// 获取 NapCat 配置
|
||||
export const NapCatGetConfigHandler: RequestHandler = (_, res) => {
|
||||
try {
|
||||
const config = readNapcatConfig();
|
||||
return sendSuccess(res, config);
|
||||
} catch (e) {
|
||||
return sendError(res, 'Config Get Error: ' + (e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 设置 NapCat 配置
|
||||
export const NapCatSetConfigHandler: RequestHandler = (req, res) => {
|
||||
try {
|
||||
const newConfig = req.body;
|
||||
if (!newConfig || typeof newConfig !== 'object') {
|
||||
return sendError(res, 'config is empty or invalid');
|
||||
}
|
||||
|
||||
// 读取当前配置并合并
|
||||
const currentConfig = readNapcatConfig();
|
||||
const mergedConfig = { ...currentConfig, ...newConfig };
|
||||
|
||||
// 验证 bypass 字段
|
||||
if (mergedConfig.bypass && typeof mergedConfig.bypass === 'object') {
|
||||
const bypass = mergedConfig.bypass as Record<string, unknown>;
|
||||
const validKeys = ['hook', 'window', 'module', 'process', 'container', 'js'];
|
||||
for (const key of validKeys) {
|
||||
if (key in bypass && typeof bypass[key] !== 'boolean') {
|
||||
return sendError(res, `bypass.${key} must be boolean`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeNapcatConfig(mergedConfig);
|
||||
return sendSuccess(res, null);
|
||||
} catch (e) {
|
||||
return sendError(res, 'Config Set Error: ' + (e as Error).message);
|
||||
}
|
||||
};
|
||||
@@ -8,6 +8,49 @@ import path from 'path';
|
||||
import fs from 'fs';
|
||||
import compressing from 'compressing';
|
||||
|
||||
/**
|
||||
* 获取插件图标 URL
|
||||
* 优先使用 package.json 中的 icon 字段,否则检查缓存的图标文件
|
||||
*/
|
||||
function getPluginIconUrl (pluginId: string, pluginPath: string, iconField?: string): string | undefined {
|
||||
// 1. 检查 package.json 中指定的 icon 文件
|
||||
if (iconField) {
|
||||
const iconPath = path.join(pluginPath, iconField);
|
||||
if (fs.existsSync(iconPath)) {
|
||||
return `/api/Plugin/Icon/${encodeURIComponent(pluginId)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查 config 目录中缓存的图标 (固定 icon.png)
|
||||
const cachedIcon = path.join(webUiPathWrapper.configPath, 'plugins', pluginId, 'icon.png');
|
||||
if (fs.existsSync(cachedIcon)) {
|
||||
return `/api/Plugin/Icon/${encodeURIComponent(pluginId)}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找插件图标文件的实际路径
|
||||
*/
|
||||
function findPluginIconPath (pluginId: string, pluginPath: string, iconField?: string): string | undefined {
|
||||
// 1. 优先使用 package.json 中指定的 icon
|
||||
if (iconField) {
|
||||
const iconPath = path.join(pluginPath, iconField);
|
||||
if (fs.existsSync(iconPath)) {
|
||||
return iconPath;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查 config 目录中缓存的图标 (固定 icon.png)
|
||||
const cachedIcon = path.join(webUiPathWrapper.configPath, 'plugins', pluginId, 'icon.png');
|
||||
if (fs.existsSync(cachedIcon)) {
|
||||
return cachedIcon;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Helper to get the plugin manager adapter
|
||||
const getPluginManager = (): OB11PluginMangerAdapter | null => {
|
||||
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
|
||||
@@ -77,6 +120,7 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
||||
hasPages: boolean;
|
||||
homepage?: string;
|
||||
repository?: string;
|
||||
icon?: string;
|
||||
}> = new Array();
|
||||
|
||||
// 收集所有插件的扩展页面
|
||||
@@ -117,7 +161,8 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
||||
homepage: p.packageJson?.homepage,
|
||||
repository: typeof p.packageJson?.repository === 'string'
|
||||
? p.packageJson.repository
|
||||
: p.packageJson?.repository?.url
|
||||
: p.packageJson?.repository?.url,
|
||||
icon: getPluginIconUrl(p.id, p.pluginPath, p.packageJson?.icon),
|
||||
});
|
||||
|
||||
// 收集插件的扩展页面
|
||||
@@ -600,3 +645,24 @@ export const ImportLocalPluginHandler: RequestHandler = async (req, res) => {
|
||||
return sendError(res, 'Failed to import plugin: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取插件图标
|
||||
*/
|
||||
export const GetPluginIconHandler: RequestHandler = async (req, res) => {
|
||||
const pluginId = req.params['pluginId'];
|
||||
if (!pluginId) return sendError(res, 'Plugin ID is required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
|
||||
|
||||
const plugin = pluginManager.getPluginInfo(pluginId);
|
||||
if (!plugin) return sendError(res, 'Plugin not found');
|
||||
|
||||
const iconPath = findPluginIconPath(pluginId, plugin.pluginPath, plugin.packageJson?.icon);
|
||||
if (!iconPath) {
|
||||
return res.status(404).json({ code: -1, message: 'Icon not found' });
|
||||
}
|
||||
|
||||
return res.sendFile(iconPath);
|
||||
};
|
||||
|
||||
@@ -287,6 +287,95 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
|
||||
console.log('[extractPlugin] Extracted files:', files);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装后尝试缓存插件图标
|
||||
* 如果插件 package.json 没有 icon 字段,则尝试从 GitHub 头像获取并缓存到 config 目录
|
||||
*/
|
||||
async function cachePluginIcon (pluginId: string, storePlugin: PluginStoreList['plugins'][0]): Promise<void> {
|
||||
const PLUGINS_DIR = getPluginsDir();
|
||||
const pluginDir = path.join(PLUGINS_DIR, pluginId);
|
||||
const configDir = path.join(webUiPathWrapper.configPath, 'plugins', pluginId);
|
||||
|
||||
// 检查 package.json 是否已有 icon 字段
|
||||
const packageJsonPath = path.join(pluginDir, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
if (pkg.icon) {
|
||||
const iconPath = path.join(pluginDir, pkg.icon);
|
||||
if (fs.existsSync(iconPath)) {
|
||||
return; // 已有 icon,无需缓存
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否已有缓存的图标 (固定 icon.png)
|
||||
if (fs.existsSync(path.join(configDir, 'icon.png'))) {
|
||||
return; // 已有缓存图标
|
||||
}
|
||||
|
||||
// 尝试从 GitHub 获取头像
|
||||
let avatarUrl: string | undefined;
|
||||
|
||||
// 从 downloadUrl 提取 GitHub 用户名
|
||||
if (storePlugin.downloadUrl) {
|
||||
try {
|
||||
const url = new URL(storePlugin.downloadUrl);
|
||||
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 1) {
|
||||
avatarUrl = `https://github.com/${parts[0]}.png?size=128`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
// 从 homepage 提取
|
||||
if (!avatarUrl && storePlugin.homepage) {
|
||||
try {
|
||||
const url = new URL(storePlugin.homepage);
|
||||
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 1) {
|
||||
avatarUrl = `https://github.com/${parts[0]}.png?size=128`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
if (!avatarUrl) return;
|
||||
|
||||
try {
|
||||
// 确保 config 目录存在
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
|
||||
const response = await fetch(avatarUrl, {
|
||||
headers: { 'User-Agent': 'NapCat-WebUI' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) return;
|
||||
|
||||
const iconPath = path.join(configDir, 'icon.png');
|
||||
const fileStream = createWriteStream(iconPath);
|
||||
await pipeline(response.body as any, fileStream);
|
||||
|
||||
console.log(`[cachePluginIcon] Cached icon for ${pluginId} at ${iconPath}`);
|
||||
} catch (e: any) {
|
||||
console.warn(`[cachePluginIcon] Failed to cache icon for ${pluginId}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件商店列表
|
||||
*/
|
||||
@@ -374,6 +463,13 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
|
||||
}
|
||||
}
|
||||
|
||||
// 安装后尝试缓存插件图标(如果 package.json 没有 icon 字段),失败可跳过
|
||||
try {
|
||||
await cachePluginIcon(id, plugin);
|
||||
} catch (e: any) {
|
||||
console.warn(`[InstallPlugin] Failed to cache icon for ${id}, skipping:`, e.message);
|
||||
}
|
||||
|
||||
return sendSuccess(res, {
|
||||
message: 'Plugin installed successfully',
|
||||
plugin,
|
||||
@@ -497,6 +593,12 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
|
||||
}
|
||||
|
||||
sendProgress('安装成功!', 100);
|
||||
|
||||
// 安装后尝试缓存插件图标(如果 package.json 没有 icon 字段)
|
||||
cachePluginIcon(id, plugin).catch(e => {
|
||||
console.warn(`[cachePluginIcon] Failed to cache icon for ${id}:`, e.message);
|
||||
});
|
||||
|
||||
res.write(`data: ${JSON.stringify({
|
||||
success: true,
|
||||
message: 'Plugin installed successfully',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import https from 'https';
|
||||
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
@@ -7,6 +8,37 @@ import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/respons
|
||||
import { Registry20Utils, MachineInfoUtils } from '@/napcat-webui-backend/src/utils/guid';
|
||||
import os from 'node:os';
|
||||
|
||||
// oidb 新设备验证请求辅助函数
|
||||
function oidbRequest (uid: string, body: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const postData = JSON.stringify(body);
|
||||
const req = https.request({
|
||||
hostname: 'oidb.tim.qq.com',
|
||||
path: `/v3/oidbinterface/oidb_0xc9e_8?uid=${encodeURIComponent(uid)}&getqrcode=1&sdkappid=39998&actype=2`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
},
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch {
|
||||
reject(new Error('Failed to parse oidb response'));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// 获取 Registry20 路径的辅助函数
|
||||
const getRegistryPath = () => {
|
||||
// 优先从 WebUiDataRuntime 获取早期设置的 dataPath
|
||||
@@ -171,8 +203,62 @@ export const QQPasswordLoginHandler: RequestHandler = async (req, res) => {
|
||||
}
|
||||
|
||||
// 执行密码登录
|
||||
const { result, message } = await WebUiDataRuntime.requestPasswordLogin(uin, passwordMd5);
|
||||
const { result, message, needCaptcha, proofWaterUrl, needNewDevice, jumpUrl, newDevicePullQrCodeSig } = await WebUiDataRuntime.requestPasswordLogin(uin, passwordMd5);
|
||||
if (!result) {
|
||||
if (needCaptcha && proofWaterUrl) {
|
||||
return sendSuccess(res, { needCaptcha: true, proofWaterUrl });
|
||||
}
|
||||
if (needNewDevice && jumpUrl) {
|
||||
return sendSuccess(res, { needNewDevice: true, jumpUrl, newDevicePullQrCodeSig });
|
||||
}
|
||||
return sendError(res, message);
|
||||
}
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
// 验证码登录(密码登录需要验证码时的第二步)
|
||||
export const QQCaptchaLoginHandler: RequestHandler = async (req, res) => {
|
||||
const { uin, passwordMd5, ticket, randstr, sid } = req.body;
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (isLogin) {
|
||||
return sendError(res, 'QQ Is Logined');
|
||||
}
|
||||
if (isEmpty(uin) || isEmpty(passwordMd5)) {
|
||||
return sendError(res, 'uin or passwordMd5 is empty');
|
||||
}
|
||||
if (isEmpty(ticket) || isEmpty(randstr)) {
|
||||
return sendError(res, 'captcha ticket or randstr is empty');
|
||||
}
|
||||
|
||||
const { result, message, needNewDevice, jumpUrl, newDevicePullQrCodeSig: sig } = await WebUiDataRuntime.requestCaptchaLogin(uin, passwordMd5, ticket, randstr, sid || '');
|
||||
if (!result) {
|
||||
if (needNewDevice && jumpUrl) {
|
||||
return sendSuccess(res, { needNewDevice: true, jumpUrl, newDevicePullQrCodeSig: sig });
|
||||
}
|
||||
return sendError(res, message);
|
||||
}
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
// 新设备验证登录(密码登录需要新设备验证时的第二步)
|
||||
export const QQNewDeviceLoginHandler: RequestHandler = async (req, res) => {
|
||||
const { uin, passwordMd5, newDevicePullQrCodeSig } = req.body;
|
||||
const isLogin = WebUiDataRuntime.getQQLoginStatus();
|
||||
if (isLogin) {
|
||||
return sendError(res, 'QQ Is Logined');
|
||||
}
|
||||
if (isEmpty(uin) || isEmpty(passwordMd5)) {
|
||||
return sendError(res, 'uin or passwordMd5 is empty');
|
||||
}
|
||||
if (isEmpty(newDevicePullQrCodeSig)) {
|
||||
return sendError(res, 'newDevicePullQrCodeSig is empty');
|
||||
}
|
||||
|
||||
const { result, message, needNewDevice, jumpUrl, newDevicePullQrCodeSig: sig } = await WebUiDataRuntime.requestNewDeviceLogin(uin, passwordMd5, newDevicePullQrCodeSig);
|
||||
if (!result) {
|
||||
if (needNewDevice && jumpUrl) {
|
||||
return sendSuccess(res, { needNewDevice: true, jumpUrl, newDevicePullQrCodeSig: sig });
|
||||
}
|
||||
return sendError(res, message);
|
||||
}
|
||||
return sendSuccess(res, null);
|
||||
@@ -412,4 +498,61 @@ export const QQResetLinuxDeviceIDHandler: RequestHandler = async (_, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// OIDB 新设备 QR 验证
|
||||
// ============================================================
|
||||
|
||||
// 获取新设备验证二维码 (通过 OIDB 接口)
|
||||
export const QQGetNewDeviceQRCodeHandler: RequestHandler = async (req, res) => {
|
||||
const { uin, jumpUrl } = req.body;
|
||||
if (!uin || !jumpUrl) {
|
||||
return sendError(res, 'uin and jumpUrl are required');
|
||||
}
|
||||
|
||||
// 从 jumpUrl 中提取参数
|
||||
// jumpUrl 格式: https://accounts.qq.com/safe/verify?...&uin-token=xxx&sig=yyy
|
||||
// sig -> str_dev_auth_token, uin-token -> str_uin_token
|
||||
const url = new URL(jumpUrl);
|
||||
const strDevAuthToken = url.searchParams.get('sig') || '';
|
||||
const strUinToken = url.searchParams.get('uin-token') || '';
|
||||
|
||||
if (!strDevAuthToken || !strUinToken) {
|
||||
return sendError(res, 'Failed to get new device QR code: unable to extract sig/uin-token from jumpUrl');
|
||||
}
|
||||
|
||||
const body = {
|
||||
str_dev_auth_token: strDevAuthToken,
|
||||
uint32_flag: 1,
|
||||
uint32_url_type: 0,
|
||||
str_uin_token: strUinToken,
|
||||
str_dev_type: 'Windows',
|
||||
str_dev_name: os.hostname() || 'DESKTOP-NAPCAT',
|
||||
};
|
||||
|
||||
const result = await oidbRequest(uin, body);
|
||||
return sendSuccess(res, result);
|
||||
};
|
||||
|
||||
// 轮询新设备验证二维码状态
|
||||
export const QQPollNewDeviceQRHandler: RequestHandler = async (req, res) => {
|
||||
const { uin, bytesToken } = req.body;
|
||||
if (!uin || !bytesToken) {
|
||||
return sendError(res, 'uin and bytesToken are required');
|
||||
}
|
||||
|
||||
try {
|
||||
const body = {
|
||||
uint32_flag: 0,
|
||||
bytes_token: bytesToken, // base64 编码的 token
|
||||
};
|
||||
|
||||
const result = await oidbRequest(uin, body);
|
||||
// result 应包含 uint32_guarantee_status:
|
||||
// 0 = 等待扫码, 3 = 已扫码, 1 = 已确认 (包含 str_nt_succ_token)
|
||||
return sendSuccess(res, result);
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to poll QR status: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,12 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
onPasswordLoginRequested: async () => {
|
||||
return { result: false, message: '密码登录功能未初始化' };
|
||||
},
|
||||
onCaptchaLoginRequested: async () => {
|
||||
return { result: false, message: '验证码登录功能未初始化' };
|
||||
},
|
||||
onNewDeviceLoginRequested: async () => {
|
||||
return { result: false, message: '新设备登录功能未初始化' };
|
||||
},
|
||||
onRestartProcessRequested: async () => {
|
||||
return { result: false, message: '重启功能未初始化' };
|
||||
},
|
||||
@@ -148,6 +154,22 @@ export const WebUiDataRuntime = {
|
||||
return LoginRuntime.NapCatHelper.onPasswordLoginRequested(uin, passwordMd5);
|
||||
} as LoginRuntimeType['NapCatHelper']['onPasswordLoginRequested'],
|
||||
|
||||
setCaptchaLoginCall (func: LoginRuntimeType['NapCatHelper']['onCaptchaLoginRequested']): void {
|
||||
LoginRuntime.NapCatHelper.onCaptchaLoginRequested = func;
|
||||
},
|
||||
|
||||
requestCaptchaLogin: function (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) {
|
||||
return LoginRuntime.NapCatHelper.onCaptchaLoginRequested(uin, passwordMd5, ticket, randstr, sid);
|
||||
} as LoginRuntimeType['NapCatHelper']['onCaptchaLoginRequested'],
|
||||
|
||||
setNewDeviceLoginCall (func: LoginRuntimeType['NapCatHelper']['onNewDeviceLoginRequested']): void {
|
||||
LoginRuntime.NapCatHelper.onNewDeviceLoginRequested = func;
|
||||
},
|
||||
|
||||
requestNewDeviceLogin: function (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) {
|
||||
return LoginRuntime.NapCatHelper.onNewDeviceLoginRequested(uin, passwordMd5, newDevicePullQrCodeSig);
|
||||
} as LoginRuntimeType['NapCatHelper']['onNewDeviceLoginRequested'],
|
||||
|
||||
setOnOB11ConfigChanged (func: LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged']): void {
|
||||
LoginRuntime.NapCatHelper.onOB11ConfigChanged = func;
|
||||
},
|
||||
|
||||
12
packages/napcat-webui-backend/src/router/NapCatConfig.ts
Normal file
12
packages/napcat-webui-backend/src/router/NapCatConfig.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
import { NapCatGetConfigHandler, NapCatSetConfigHandler } from '@/napcat-webui-backend/src/api/NapCatConfig';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
// router:获取 NapCat 配置
|
||||
router.get('/GetConfig', NapCatGetConfigHandler);
|
||||
// router:设置 NapCat 配置
|
||||
router.post('/SetConfig', NapCatSetConfigHandler);
|
||||
|
||||
export { router as NapCatConfigRouter };
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
// import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
RegisterPluginManagerHandler,
|
||||
PluginConfigSSEHandler,
|
||||
PluginConfigChangeHandler,
|
||||
ImportLocalPluginHandler
|
||||
// ImportLocalPluginHandler,
|
||||
GetPluginIconHandler
|
||||
} from '@/napcat-webui-backend/src/api/Plugin';
|
||||
import {
|
||||
GetPluginStoreListHandler,
|
||||
@@ -30,32 +31,32 @@ if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, uniqueSuffix + '-' + file.originalname);
|
||||
}
|
||||
});
|
||||
// const storage = multer.diskStorage({
|
||||
// destination: (_req, _file, cb) => {
|
||||
// cb(null, uploadDir);
|
||||
// },
|
||||
// filename: (_req, file, cb) => {
|
||||
// const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
// cb(null, uniqueSuffix + '-' + file.originalname);
|
||||
// }
|
||||
// });
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024, // 50MB 限制
|
||||
},
|
||||
fileFilter: (_req, file, cb) => {
|
||||
// 只允许 .zip 文件
|
||||
if (file.mimetype === 'application/zip' ||
|
||||
file.mimetype === 'application/x-zip-compressed' ||
|
||||
file.originalname.endsWith('.zip')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only .zip files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
// const upload = multer({
|
||||
// storage,
|
||||
// limits: {
|
||||
// fileSize: 50 * 1024 * 1024, // 50MB 限制
|
||||
// },
|
||||
// fileFilter: (_req, file, cb) => {
|
||||
// // 只允许 .zip 文件
|
||||
// if (file.mimetype === 'application/zip' ||
|
||||
// file.mimetype === 'application/x-zip-compressed' ||
|
||||
// file.originalname.endsWith('.zip')) {
|
||||
// cb(null, true);
|
||||
// } else {
|
||||
// cb(new Error('Only .zip files are allowed'));
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
@@ -67,7 +68,8 @@ router.post('/Config', SetPluginConfigHandler);
|
||||
router.get('/Config/SSE', PluginConfigSSEHandler);
|
||||
router.post('/Config/Change', PluginConfigChangeHandler);
|
||||
router.post('/RegisterManager', RegisterPluginManagerHandler);
|
||||
router.post('/Import', upload.single('plugin'), ImportLocalPluginHandler);
|
||||
// router.post('/Import', upload.single('plugin'), ImportLocalPluginHandler); // 禁用插件上传
|
||||
router.get('/Icon/:pluginId', GetPluginIconHandler);
|
||||
|
||||
// 插件商店相关路由
|
||||
router.get('/Store/List', GetPluginStoreListHandler);
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
setAutoLoginAccountHandler,
|
||||
QQRefreshQRcodeHandler,
|
||||
QQPasswordLoginHandler,
|
||||
QQCaptchaLoginHandler,
|
||||
QQNewDeviceLoginHandler,
|
||||
QQGetNewDeviceQRCodeHandler,
|
||||
QQPollNewDeviceQRHandler,
|
||||
QQResetDeviceIDHandler,
|
||||
QQRestartNapCatHandler,
|
||||
QQGetDeviceGUIDHandler,
|
||||
@@ -50,6 +54,14 @@ router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
|
||||
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
|
||||
// router:密码登录
|
||||
router.post('/PasswordLogin', QQPasswordLoginHandler);
|
||||
// router:验证码登录(密码登录需要验证码时的第二步)
|
||||
router.post('/CaptchaLogin', QQCaptchaLoginHandler);
|
||||
// router:新设备验证登录(密码登录需要新设备验证时的第二步)
|
||||
router.post('/NewDeviceLogin', QQNewDeviceLoginHandler);
|
||||
// router:获取新设备验证二维码 (OIDB)
|
||||
router.post('/GetNewDeviceQRCode', QQGetNewDeviceQRCodeHandler);
|
||||
// router:轮询新设备验证二维码状态 (OIDB)
|
||||
router.post('/PollNewDeviceQR', QQPollNewDeviceQRHandler);
|
||||
// router:重置设备信息
|
||||
router.post('/ResetDeviceID', QQResetDeviceIDHandler);
|
||||
// router:重启NapCat
|
||||
|
||||
@@ -19,6 +19,7 @@ import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
|
||||
import { ProcessRouter } from './Process';
|
||||
import { PluginRouter } from './Plugin';
|
||||
import { MirrorRouter } from './Mirror';
|
||||
import { NapCatConfigRouter } from './NapCatConfig';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
@@ -53,5 +54,7 @@ router.use('/Process', ProcessRouter);
|
||||
router.use('/Plugin', PluginRouter);
|
||||
// router:镜像管理相关路由
|
||||
router.use('/Mirror', MirrorRouter);
|
||||
// router:NapCat配置相关路由
|
||||
router.use('/NapCatConfig', NapCatConfigRouter);
|
||||
|
||||
export { router as ALLRouter };
|
||||
|
||||
@@ -57,7 +57,9 @@ export interface LoginRuntimeType {
|
||||
OneBotContext: any | null; // OneBot 上下文,用于调试功能
|
||||
NapCatHelper: {
|
||||
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
|
||||
onPasswordLoginRequested: (uin: string, passwordMd5: string) => Promise<{ result: boolean; message: string; }>;
|
||||
onPasswordLoginRequested: (uin: string, passwordMd5: string) => Promise<{ result: boolean; message: string; needCaptcha?: boolean; proofWaterUrl?: string; needNewDevice?: boolean; jumpUrl?: string; newDevicePullQrCodeSig?: string; }>;
|
||||
onCaptchaLoginRequested: (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) => Promise<{ result: boolean; message: string; needNewDevice?: boolean; jumpUrl?: string; newDevicePullQrCodeSig?: string; }>;
|
||||
onNewDeviceLoginRequested: (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) => Promise<{ result: boolean; message: string; needNewDevice?: boolean; jumpUrl?: string; newDevicePullQrCodeSig?: string; }>;
|
||||
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
|
||||
onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>;
|
||||
QQLoginList: string[];
|
||||
|
||||
@@ -54,7 +54,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
|
||||
textValue='title'
|
||||
>
|
||||
<div className='flex items-center gap-2 justify-center'>
|
||||
<div className='w-5 h-5 -ml-3'>
|
||||
<div className='w-5 h-5 -ml-3 flex items-center justify-center'>
|
||||
<PlusIcon />
|
||||
</div>
|
||||
<div className='text-primary-400'>新建网络配置</div>
|
||||
|
||||
@@ -12,43 +12,18 @@ import { useState } from 'react';
|
||||
import key from '@/const/key';
|
||||
import { PluginItem } from '@/controllers/plugin_manager';
|
||||
|
||||
/** 提取作者头像 URL */
|
||||
function getAuthorAvatar (homepage?: string, repository?: string): string | undefined {
|
||||
// 1. 尝试从 repository 提取 GitHub 用户名
|
||||
if (repository) {
|
||||
try {
|
||||
// 处理 git+https://github.com/... 或 https://github.com/...
|
||||
const repoUrl = repository.replace(/^git\+/, '').replace(/\.git$/, '');
|
||||
const url = new URL(repoUrl);
|
||||
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 1) {
|
||||
return `https://github.com/${parts[0]}.png`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
function getPluginIconUrl (iconPath?: string): string | undefined {
|
||||
if (!iconPath) return undefined;
|
||||
try {
|
||||
const raw = localStorage.getItem(key.token);
|
||||
if (!raw) return iconPath;
|
||||
const token = JSON.parse(raw);
|
||||
const url = new URL(iconPath, window.location.origin);
|
||||
url.searchParams.set('webui_token', token);
|
||||
return url.pathname + url.search;
|
||||
} catch {
|
||||
return iconPath;
|
||||
}
|
||||
|
||||
// 2. 尝试从 homepage 提取
|
||||
if (homepage) {
|
||||
try {
|
||||
const url = new URL(homepage);
|
||||
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 1) {
|
||||
return `https://github.com/${parts[0]}.png`;
|
||||
}
|
||||
} else {
|
||||
// 如果是自定义域名,尝试获取 favicon
|
||||
return `https://api.iowen.cn/favicon/${url.hostname}.png`;
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface PluginDisplayCardProps {
|
||||
@@ -66,15 +41,15 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
|
||||
onConfig,
|
||||
hasConfig = false,
|
||||
}) => {
|
||||
const { name, version, author, description, status, homepage, repository } = data;
|
||||
const { name, version, author, description, status, icon } = data;
|
||||
const isEnabled = status === 'active';
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
// 综合尝试提取头像,最后兜底使用 Vercel 风格头像
|
||||
const avatarUrl = getAuthorAvatar(homepage, repository) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
|
||||
// 后端已处理 icon,前端只需拼接 token;无 icon 时兜底 Vercel 风格头像
|
||||
const avatarUrl = getPluginIconUrl(icon) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
|
||||
|
||||
const handleToggle = () => {
|
||||
setProcessing(true);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
|
||||
interface NewDeviceVerifyProps {
|
||||
/** jumpUrl from loginErrorInfo */
|
||||
jumpUrl: string;
|
||||
/** QQ uin for OIDB requests */
|
||||
uin: string;
|
||||
/** Called when QR verification is confirmed, passes str_nt_succ_token */
|
||||
onVerified: (token: string) => void;
|
||||
/** Called when user cancels */
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
type QRStatus = 'loading' | 'waiting' | 'scanned' | 'confirmed' | 'error';
|
||||
|
||||
const NewDeviceVerify: React.FC<NewDeviceVerifyProps> = ({
|
||||
jumpUrl,
|
||||
uin,
|
||||
onVerified,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [qrUrl, setQrUrl] = useState<string>('');
|
||||
const [status, setStatus] = useState<QRStatus>('loading');
|
||||
const [errorMsg, setErrorMsg] = useState<string>('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startPolling = useCallback((token: string) => {
|
||||
stopPolling();
|
||||
pollTimerRef.current = setInterval(async () => {
|
||||
if (!mountedRef.current) return;
|
||||
try {
|
||||
const result = await QQManager.pollNewDeviceQR(uin, token);
|
||||
if (!mountedRef.current) return;
|
||||
const s = result?.uint32_guarantee_status;
|
||||
if (s === 3) {
|
||||
setStatus('scanned');
|
||||
} else if (s === 1) {
|
||||
stopPolling();
|
||||
setStatus('confirmed');
|
||||
const ntToken = result?.str_nt_succ_token || '';
|
||||
onVerified(ntToken);
|
||||
}
|
||||
// s === 0 means still waiting, do nothing
|
||||
} catch {
|
||||
// Ignore poll errors, keep polling
|
||||
}
|
||||
}, 2500);
|
||||
}, [uin, onVerified, stopPolling]);
|
||||
|
||||
const fetchQRCode = useCallback(async () => {
|
||||
setStatus('loading');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const result = await QQManager.getNewDeviceQRCode(uin, jumpUrl);
|
||||
if (!mountedRef.current) return;
|
||||
if (result?.str_url) {
|
||||
setQrUrl(result.str_url);
|
||||
setStatus('waiting');
|
||||
// bytes_token 用于轮询,如果 OIDB 未返回则用空字符串
|
||||
startPolling(result.bytes_token || '');
|
||||
} else {
|
||||
setStatus('error');
|
||||
setErrorMsg('获取二维码失败,请重试');
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mountedRef.current) return;
|
||||
setStatus('error');
|
||||
setErrorMsg((e as Error).message || '获取二维码失败');
|
||||
}
|
||||
}, [uin, jumpUrl, startPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
fetchQRCode();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
stopPolling();
|
||||
};
|
||||
}, [fetchQRCode, stopPolling]);
|
||||
|
||||
const statusText: Record<QRStatus, string> = {
|
||||
loading: '正在获取二维码...',
|
||||
waiting: '请使用手机QQ扫描二维码完成验证',
|
||||
scanned: '已扫描,请在手机上确认',
|
||||
confirmed: '验证成功,正在登录...',
|
||||
error: errorMsg || '获取二维码失败',
|
||||
};
|
||||
|
||||
const statusColor: Record<QRStatus, string> = {
|
||||
loading: 'text-default-500',
|
||||
waiting: 'text-warning',
|
||||
scanned: 'text-primary',
|
||||
confirmed: 'text-success',
|
||||
error: 'text-danger',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4 items-center'>
|
||||
<p className='text-warning text-sm'>
|
||||
检测到新设备登录,请使用手机QQ扫描下方二维码完成验证
|
||||
</p>
|
||||
|
||||
<div className='flex flex-col items-center gap-3' style={{ minHeight: 280 }}>
|
||||
{status === 'loading' ? (
|
||||
<div className='flex items-center justify-center' style={{ height: 240 }}>
|
||||
<Spinner size='lg' />
|
||||
</div>
|
||||
) : status === 'error' ? (
|
||||
<div className='flex flex-col items-center justify-center gap-3' style={{ height: 240 }}>
|
||||
<p className='text-danger text-sm'>{errorMsg}</p>
|
||||
<Button color='primary' variant='flat' onPress={fetchQRCode}>
|
||||
重新获取
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-3 bg-white rounded-lg'>
|
||||
<QRCodeSVG value={qrUrl} size={220} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={`text-sm ${statusColor[status]}`}>
|
||||
{statusText[status]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-3'>
|
||||
{status === 'waiting' && (
|
||||
<Button color='default' variant='flat' size='sm' onPress={fetchQRCode}>
|
||||
刷新二维码
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='light'
|
||||
color='danger'
|
||||
size='sm'
|
||||
onPress={onCancel}
|
||||
>
|
||||
取消验证
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewDeviceVerify;
|
||||
@@ -6,17 +6,37 @@ import { Input } from '@heroui/input';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { IoChevronDown } from 'react-icons/io5';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
|
||||
import type { QQItem } from '@/components/quick_login';
|
||||
import { isQQQuickNewItem } from '@/utils/qq';
|
||||
import TencentCaptchaModal from '@/components/tencent_captcha';
|
||||
import type { CaptchaCallbackData } from '@/components/tencent_captcha';
|
||||
import NewDeviceVerify from '@/components/new_device_verify';
|
||||
|
||||
interface PasswordLoginProps {
|
||||
onSubmit: (uin: string, password: string) => void;
|
||||
onCaptchaSubmit?: (uin: string, password: string, captchaData: CaptchaCallbackData) => void;
|
||||
onNewDeviceVerified?: (token: string) => void;
|
||||
isLoading: boolean;
|
||||
qqList: (QQItem | LoginListItem)[];
|
||||
captchaState?: {
|
||||
needCaptcha: boolean;
|
||||
proofWaterUrl: string;
|
||||
uin: string;
|
||||
password: string;
|
||||
} | null;
|
||||
captchaVerifying?: boolean;
|
||||
newDeviceState?: {
|
||||
needNewDevice: boolean;
|
||||
jumpUrl: string;
|
||||
uin: string;
|
||||
} | null;
|
||||
onCaptchaCancel?: () => void;
|
||||
onNewDeviceCancel?: () => void;
|
||||
}
|
||||
|
||||
const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, isLoading, qqList }) => {
|
||||
const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, onCaptchaSubmit, onNewDeviceVerified, isLoading, qqList, captchaState, captchaVerifying, newDeviceState, onCaptchaCancel, onNewDeviceCancel }) => {
|
||||
const [uin, setUin] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
@@ -34,87 +54,128 @@ const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, isLoading, qqLi
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-8'>
|
||||
<div className='flex justify-center'>
|
||||
<Image
|
||||
className='shadow-lg'
|
||||
height={100}
|
||||
radius='full'
|
||||
src={`https://q1.qlogo.cn/g?b=qq&nk=${uin || '0'}&s=100`}
|
||||
width={100}
|
||||
alt="QQ Avatar"
|
||||
{captchaState?.needCaptcha && captchaState.proofWaterUrl ? (
|
||||
<div className='flex flex-col gap-4 items-center'>
|
||||
{captchaVerifying ? (
|
||||
<>
|
||||
<p className='text-primary text-sm'>验证码已提交,正在等待服务器验证结果...</p>
|
||||
<div className='flex items-center justify-center py-8 gap-3'>
|
||||
<Spinner size='lg' />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className='text-warning text-sm'>登录需要安全验证,请完成验证码</p>
|
||||
<TencentCaptchaModal
|
||||
proofWaterUrl={captchaState.proofWaterUrl}
|
||||
onSuccess={(data) => {
|
||||
onCaptchaSubmit?.(captchaState.uin, captchaState.password, data);
|
||||
}}
|
||||
onCancel={onCaptchaCancel}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant='light'
|
||||
color='danger'
|
||||
size='sm'
|
||||
onPress={onCaptchaCancel}
|
||||
>
|
||||
取消验证
|
||||
</Button>
|
||||
</div>
|
||||
) : newDeviceState?.needNewDevice && newDeviceState.jumpUrl ? (
|
||||
<NewDeviceVerify
|
||||
jumpUrl={newDeviceState.jumpUrl}
|
||||
uin={newDeviceState.uin}
|
||||
onVerified={(token) => onNewDeviceVerified?.(token)}
|
||||
onCancel={onNewDeviceCancel}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<Input
|
||||
type="text"
|
||||
label="QQ账号"
|
||||
placeholder="请输入QQ号"
|
||||
value={uin}
|
||||
onValueChange={setUin}
|
||||
variant="bordered"
|
||||
size='lg'
|
||||
autoComplete="off"
|
||||
endContent={
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly variant="light" size="sm" radius="full">
|
||||
<IoChevronDown size={16} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
aria-label="QQ Login History"
|
||||
items={qqList}
|
||||
onAction={(key) => setUin(key.toString())}
|
||||
>
|
||||
{(item) => (
|
||||
<DropdownItem key={item.uin} textValue={item.uin}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Avatar
|
||||
alt={item.uin}
|
||||
className='flex-shrink-0'
|
||||
size='sm'
|
||||
src={
|
||||
isQQQuickNewItem(item)
|
||||
? item.faceUrl
|
||||
: `https://q1.qlogo.cn/g?b=qq&nk=${item.uin}&s=1`
|
||||
}
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
{isQQQuickNewItem(item)
|
||||
? `${item.nickName}(${item.uin})`
|
||||
: item.uin}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
label="密码"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onValueChange={setPassword}
|
||||
variant="bordered"
|
||||
size='lg'
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-center mt-5'>
|
||||
<Button
|
||||
className='w-64 max-w-full'
|
||||
color='primary'
|
||||
isLoading={isLoading}
|
||||
radius='full'
|
||||
size='lg'
|
||||
variant='shadow'
|
||||
onPress={handleSubmit}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex justify-center'>
|
||||
<Image
|
||||
className='shadow-lg'
|
||||
height={100}
|
||||
radius='full'
|
||||
src={`https://q1.qlogo.cn/g?b=qq&nk=${uin || '0'}&s=100`}
|
||||
width={100}
|
||||
alt="QQ Avatar"
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<Input
|
||||
type="text"
|
||||
label="QQ账号"
|
||||
placeholder="请输入QQ号"
|
||||
value={uin}
|
||||
onValueChange={setUin}
|
||||
variant="bordered"
|
||||
size='lg'
|
||||
autoComplete="off"
|
||||
endContent={
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly variant="light" size="sm" radius="full">
|
||||
<IoChevronDown size={16} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
aria-label="QQ Login History"
|
||||
items={qqList}
|
||||
onAction={(key) => setUin(key.toString())}
|
||||
>
|
||||
{(item) => (
|
||||
<DropdownItem key={item.uin} textValue={item.uin}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Avatar
|
||||
alt={item.uin}
|
||||
className='flex-shrink-0'
|
||||
size='sm'
|
||||
src={
|
||||
isQQQuickNewItem(item)
|
||||
? item.faceUrl
|
||||
: `https://q1.qlogo.cn/g?b=qq&nk=${item.uin}&s=1`
|
||||
}
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
{isQQQuickNewItem(item)
|
||||
? `${item.nickName}(${item.uin})`
|
||||
: item.uin}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
label="密码"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onValueChange={setPassword}
|
||||
variant="bordered"
|
||||
size='lg'
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className='flex justify-center mt-5'>
|
||||
<Button
|
||||
className='w-64 max-w-full'
|
||||
color='primary'
|
||||
isLoading={isLoading}
|
||||
radius='full'
|
||||
size='lg'
|
||||
variant='shadow'
|
||||
onPress={handleSubmit}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
TencentCaptcha: new (
|
||||
appid: string,
|
||||
callback: (res: TencentCaptchaResult) => void,
|
||||
options?: Record<string, unknown>
|
||||
) => { show: () => void; destroy: () => void; };
|
||||
}
|
||||
}
|
||||
|
||||
export interface TencentCaptchaResult {
|
||||
ret: number;
|
||||
appid?: string;
|
||||
ticket?: string;
|
||||
randstr?: string;
|
||||
errorCode?: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface CaptchaCallbackData {
|
||||
ticket: string;
|
||||
randstr: string;
|
||||
appid: string;
|
||||
sid: string;
|
||||
}
|
||||
|
||||
interface TencentCaptchaProps {
|
||||
/** proofWaterUrl returned from login error, contains uin/sid/aid params */
|
||||
proofWaterUrl: string;
|
||||
/** Called when captcha verification succeeds */
|
||||
onSuccess: (data: CaptchaCallbackData) => void;
|
||||
/** Called when captcha is cancelled or fails */
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
function parseUrlParams (url: string): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
try {
|
||||
const u = new URL(url);
|
||||
u.searchParams.forEach((v, k) => { params[k] = v; });
|
||||
} catch {
|
||||
const match = url.match(/[?&]([^#]+)/);
|
||||
if (match) {
|
||||
match[1].split('&').forEach(pair => {
|
||||
const [k, v] = pair.split('=');
|
||||
if (k) params[k] = decodeURIComponent(v || '');
|
||||
});
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function loadScript (src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.TencentCaptcha) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const tag = document.createElement('script');
|
||||
tag.src = src;
|
||||
tag.onload = () => resolve();
|
||||
tag.onerror = () => reject(new Error(`Failed to load ${src}`));
|
||||
document.head.appendChild(tag);
|
||||
});
|
||||
}
|
||||
|
||||
const TencentCaptchaModal: React.FC<TencentCaptchaProps> = ({
|
||||
proofWaterUrl,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}) => {
|
||||
const captchaRef = useRef<{ destroy: () => void; } | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const handleResult = useCallback((res: TencentCaptchaResult, sid: string) => {
|
||||
if (!mountedRef.current) return;
|
||||
if (res.ret === 0 && res.ticket && res.randstr) {
|
||||
onSuccess({
|
||||
ticket: res.ticket,
|
||||
randstr: res.randstr,
|
||||
appid: res.appid || '',
|
||||
sid,
|
||||
});
|
||||
} else {
|
||||
onCancel?.();
|
||||
}
|
||||
}, [onSuccess, onCancel]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const params = parseUrlParams(proofWaterUrl);
|
||||
const appid = params.aid || '2081081773';
|
||||
const sid = params.sid || '';
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
await loadScript('https://captcha.gtimg.com/TCaptcha.js');
|
||||
} catch {
|
||||
try {
|
||||
await loadScript('https://ssl.captcha.qq.com/TCaptcha.js');
|
||||
} catch {
|
||||
// Both CDN failed, generate fallback ticket
|
||||
if (mountedRef.current) {
|
||||
handleResult({
|
||||
ret: 0,
|
||||
ticket: `terror_1001_${appid}_${Math.floor(Date.now() / 1000)}`,
|
||||
randstr: '@' + Math.random().toString(36).substring(2),
|
||||
errorCode: 1001,
|
||||
errorMessage: 'jsload_error',
|
||||
}, sid);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
try {
|
||||
const captcha = new window.TencentCaptcha(
|
||||
appid,
|
||||
(res) => handleResult(res, sid),
|
||||
{
|
||||
type: 'popup',
|
||||
showHeader: false,
|
||||
login_appid: params.login_appid,
|
||||
uin: params.uin,
|
||||
sid: params.sid,
|
||||
enableAged: true,
|
||||
}
|
||||
);
|
||||
captchaRef.current = captcha;
|
||||
captcha.show();
|
||||
} catch {
|
||||
if (mountedRef.current) {
|
||||
handleResult({
|
||||
ret: 0,
|
||||
ticket: `terror_1001_${appid}_${Math.floor(Date.now() / 1000)}`,
|
||||
randstr: '@' + Math.random().toString(36).substring(2),
|
||||
errorCode: 1001,
|
||||
errorMessage: 'init_error',
|
||||
}, sid);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
captchaRef.current?.destroy();
|
||||
captchaRef.current = null;
|
||||
};
|
||||
}, [proofWaterUrl, handleResult]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 gap-3">
|
||||
<Spinner size="lg" />
|
||||
<span className="text-default-500">正在加载验证码...</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TencentCaptchaModal;
|
||||
@@ -26,6 +26,8 @@ export interface PluginItem {
|
||||
homepage?: string;
|
||||
/** 仓库链接 */
|
||||
repository?: string;
|
||||
/** 插件图标 URL(由后端返回) */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/** 扩展页面信息 */
|
||||
|
||||
@@ -96,10 +96,93 @@ export default class QQManager {
|
||||
}
|
||||
|
||||
public static async passwordLogin (uin: string, passwordMd5: string) {
|
||||
await serverRequest.post<ServerResponse<null>>('/QQLogin/PasswordLogin', {
|
||||
const data = await serverRequest.post<ServerResponse<{
|
||||
needCaptcha?: boolean;
|
||||
proofWaterUrl?: string;
|
||||
needNewDevice?: boolean;
|
||||
jumpUrl?: string;
|
||||
newDevicePullQrCodeSig?: string;
|
||||
} | null>>('/QQLogin/PasswordLogin', {
|
||||
uin,
|
||||
passwordMd5,
|
||||
});
|
||||
return data.data.data;
|
||||
}
|
||||
|
||||
public static async captchaLogin (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) {
|
||||
const data = await serverRequest.post<ServerResponse<{
|
||||
needNewDevice?: boolean;
|
||||
jumpUrl?: string;
|
||||
newDevicePullQrCodeSig?: string;
|
||||
} | null>>('/QQLogin/CaptchaLogin', {
|
||||
uin,
|
||||
passwordMd5,
|
||||
ticket,
|
||||
randstr,
|
||||
sid,
|
||||
});
|
||||
return data.data.data;
|
||||
}
|
||||
|
||||
public static async newDeviceLogin (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) {
|
||||
const data = await serverRequest.post<ServerResponse<{
|
||||
needNewDevice?: boolean;
|
||||
jumpUrl?: string;
|
||||
newDevicePullQrCodeSig?: string;
|
||||
} | null>>('/QQLogin/NewDeviceLogin', {
|
||||
uin,
|
||||
passwordMd5,
|
||||
newDevicePullQrCodeSig,
|
||||
});
|
||||
return data.data.data;
|
||||
}
|
||||
|
||||
public static async getNewDeviceQRCode (uin: string, jumpUrl: string) {
|
||||
const data = await serverRequest.post<ServerResponse<{
|
||||
str_url?: string;
|
||||
bytes_token?: string;
|
||||
uint32_guarantee_status?: number;
|
||||
ActionStatus?: string;
|
||||
ErrorCode?: number;
|
||||
ErrorInfo?: string;
|
||||
}>>('/QQLogin/GetNewDeviceQRCode', {
|
||||
uin,
|
||||
jumpUrl,
|
||||
});
|
||||
const result = data.data.data;
|
||||
if (result?.str_url) {
|
||||
let bytesToken = result.bytes_token || '';
|
||||
if (!bytesToken && result.str_url) {
|
||||
// 只对 str_url 参数值做 base64 编码
|
||||
try {
|
||||
const urlObj = new URL(result.str_url);
|
||||
const strUrlParam = urlObj.searchParams.get('str_url') || '';
|
||||
bytesToken = strUrlParam ? btoa(strUrlParam) : '';
|
||||
} catch {
|
||||
bytesToken = '';
|
||||
}
|
||||
}
|
||||
return {
|
||||
str_url: result.str_url,
|
||||
bytes_token: bytesToken,
|
||||
uint32_guarantee_status: result.uint32_guarantee_status,
|
||||
ActionStatus: result.ActionStatus,
|
||||
ErrorCode: result.ErrorCode,
|
||||
ErrorInfo: result.ErrorInfo,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static async pollNewDeviceQR (uin: string, bytesToken: string) {
|
||||
const data = await serverRequest.post<ServerResponse<{
|
||||
uint32_guarantee_status?: number;
|
||||
str_nt_succ_token?: string;
|
||||
}>>('/QQLogin/PollNewDeviceQR', {
|
||||
uin,
|
||||
bytesToken,
|
||||
});
|
||||
return data.data.data;
|
||||
}
|
||||
|
||||
public static async resetDeviceID () {
|
||||
@@ -178,5 +261,23 @@ export default class QQManager {
|
||||
public static async resetLinuxDeviceID () {
|
||||
await serverRequest.post<ServerResponse<null>>('/QQLogin/ResetLinuxDeviceID');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NapCat 配置管理
|
||||
// ============================================================
|
||||
|
||||
public static async getNapCatConfig () {
|
||||
const { data } = await serverRequest.get<ServerResponse<NapCatConfig>>(
|
||||
'/NapCatConfig/GetConfig'
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
public static async setNapCatConfig (config: Partial<NapCatConfig>) {
|
||||
await serverRequest.post<ServerResponse<null>>(
|
||||
'/NapCatConfig/SetConfig',
|
||||
config
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
import SwitchCard from '@/components/switch_card';
|
||||
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
|
||||
interface BypassFormData {
|
||||
hook: boolean;
|
||||
window: boolean;
|
||||
module: boolean;
|
||||
process: boolean;
|
||||
container: boolean;
|
||||
js: boolean;
|
||||
o3HookMode: boolean;
|
||||
}
|
||||
|
||||
|
||||
const BypassConfigCard = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue,
|
||||
} = useForm<BypassFormData>();
|
||||
|
||||
const loadConfig = async (showTip = false) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const config = await QQManager.getNapCatConfig();
|
||||
const bypass = config.bypass ?? {} as Partial<BypassOptions>;
|
||||
setValue('hook', bypass.hook ?? false);
|
||||
setValue('window', bypass.window ?? false);
|
||||
setValue('module', bypass.module ?? false);
|
||||
setValue('process', bypass.process ?? false);
|
||||
setValue('container', bypass.container ?? false);
|
||||
setValue('js', bypass.js ?? false);
|
||||
setValue('o3HookMode', config.o3HookMode === 1);
|
||||
if (showTip) toast.success('刷新成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`获取配置失败: ${msg}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
try {
|
||||
const { o3HookMode, ...bypass } = data;
|
||||
await QQManager.setNapCatConfig({ bypass, o3HookMode: o3HookMode ? 1 : 0 });
|
||||
toast.success('保存成功,重启后生效');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`保存失败: ${msg}`);
|
||||
}
|
||||
});
|
||||
|
||||
const onReset = () => {
|
||||
loadConfig();
|
||||
};
|
||||
|
||||
const onRefresh = async () => {
|
||||
await loadConfig(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
if (loading) return <PageLoading loading />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>反检测配置 - NapCat WebUI</title>
|
||||
<div className='flex flex-col gap-1 mb-2'>
|
||||
<h3 className='text-lg font-semibold text-default-700'>反检测开关配置</h3>
|
||||
<p className='text-sm text-default-500'>
|
||||
控制 Napi2Native 模块的各项反检测功能,修改后需重启生效。
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'>
|
||||
<Controller
|
||||
control={control}
|
||||
name='hook'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='Hook'
|
||||
description='hook特征隐藏'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='window'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='Window'
|
||||
description='窗口伪造'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='module'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='Module'
|
||||
description='加载模块隐藏'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='process'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='Process'
|
||||
description='进程反检测'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='container'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='Container'
|
||||
description='容器反检测'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='js'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='JS'
|
||||
description='JS反检测'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='o3HookMode'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='o3HookMode'
|
||||
description='O3 Hook 模式'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={onReset}
|
||||
isSubmitting={isSubmitting}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BypassConfigCard;
|
||||
@@ -14,6 +14,7 @@ import SSLConfigCard from './ssl';
|
||||
import ThemeConfigCard from './theme';
|
||||
import WebUIConfigCard from './webui';
|
||||
import BackupConfigCard from './backup';
|
||||
import BypassConfigCard from './bypass';
|
||||
|
||||
export interface ConfigPageProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -114,6 +115,11 @@ export default function ConfigPage () {
|
||||
<BackupConfigCard />
|
||||
</ConfigPageItem>
|
||||
</Tab>
|
||||
<Tab title='反检测' key='bypass'>
|
||||
<ConfigPageItem>
|
||||
<BypassConfigCard />
|
||||
</ConfigPageItem>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -131,7 +131,7 @@ const WebUIConfigCard = () => {
|
||||
isLoading={isLoadingOptions}
|
||||
className='w-fit'
|
||||
>
|
||||
{!isLoadingOptions && '📥'}
|
||||
{!isLoadingOptions}
|
||||
准备选项
|
||||
</Button>
|
||||
<Button
|
||||
@@ -225,12 +225,12 @@ const WebUIConfigCard = () => {
|
||||
disabled={!registrationOptions}
|
||||
className='w-fit'
|
||||
>
|
||||
🔐 注册Passkey
|
||||
注册Passkey
|
||||
</Button>
|
||||
</div>
|
||||
{registrationOptions && (
|
||||
<div className='text-xs text-green-600'>
|
||||
✅ 注册选项已准备就绪,可以开始注册
|
||||
注册选项已准备就绪,可以开始注册
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMdRefresh } from 'react-icons/io';
|
||||
import { MdExtension } from 'react-icons/md';
|
||||
@@ -93,14 +93,45 @@ export default function ExtensionPage () {
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
// 拖拽滚动支持(鼠标 + 触摸)
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isDragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
const scrollLeft = useRef(0);
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
isDragging.current = true;
|
||||
startX.current = e.clientX;
|
||||
scrollLeft.current = el.scrollLeft;
|
||||
el.setPointerCapture(e.pointerId);
|
||||
el.style.cursor = 'grabbing';
|
||||
el.style.userSelect = 'none';
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!isDragging.current || !scrollRef.current) return;
|
||||
const dx = e.clientX - startX.current;
|
||||
scrollRef.current.scrollLeft = scrollLeft.current - dx;
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
||||
if (!isDragging.current || !scrollRef.current) return;
|
||||
isDragging.current = false;
|
||||
scrollRef.current.releasePointerCapture(e.pointerId);
|
||||
scrollRef.current.style.cursor = 'grab';
|
||||
scrollRef.current.style.userSelect = '';
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>扩展页面 - NapCat WebUI</title>
|
||||
<div className='p-2 md:p-4 relative h-[calc(100vh-6rem)] md:h-[calc(100vh-4rem)] flex flex-col'>
|
||||
<PageLoading loading={loading} />
|
||||
|
||||
<div className='flex mb-4 items-center justify-between gap-4 flex-wrap'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='flex mb-4 items-center gap-4 flex-nowrap min-w-0'>
|
||||
<div className='flex items-center gap-4 shrink-0'>
|
||||
<div className='flex items-center gap-2 text-default-600'>
|
||||
<MdExtension size={24} />
|
||||
<span className='text-lg font-medium'>插件扩展页面</span>
|
||||
@@ -115,39 +146,49 @@ export default function ExtensionPage () {
|
||||
</Button>
|
||||
</div>
|
||||
{extensionPages.length > 0 && (
|
||||
<Tabs
|
||||
aria-label='Extension Pages'
|
||||
className='max-w-full'
|
||||
selectedKey={selectedTab}
|
||||
onSelectionChange={(key) => setSelectedTab(key as string)}
|
||||
classNames={{
|
||||
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
|
||||
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
||||
panel: 'hidden',
|
||||
}}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className='overflow-x-auto min-w-0 flex-1 scrollbar-thin scrollbar-thumb-default-300 scrollbar-track-transparent cursor-grab touch-pan-x'
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
{tab.icon && <span>{tab.icon}</span>}
|
||||
<span
|
||||
className='cursor-pointer hover:underline truncate max-w-[6rem] md:max-w-none'
|
||||
title={`插件:${tab.pluginName}\n点击在新窗口打开`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openInNewWindow(tab.pluginId, tab.path);
|
||||
}}
|
||||
>
|
||||
{tab.title}
|
||||
</span>
|
||||
<span className='text-xs text-default-400 hidden md:inline'>({tab.pluginName})</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
<Tabs
|
||||
aria-label='Extension Pages'
|
||||
className='w-max min-w-full'
|
||||
selectedKey={selectedTab}
|
||||
onSelectionChange={(key) => setSelectedTab(key as string)}
|
||||
classNames={{
|
||||
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md flex-nowrap',
|
||||
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
||||
panel: 'hidden',
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
title={
|
||||
<div className='flex items-center gap-2 whitespace-nowrap'>
|
||||
{tab.icon && <span>{tab.icon}</span>}
|
||||
<span
|
||||
className='cursor-pointer hover:underline truncate max-w-[6rem] md:max-w-none'
|
||||
title={`插件:${tab.pluginName}\n点击在新窗口打开`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openInNewWindow(tab.pluginId, tab.path);
|
||||
}}
|
||||
>
|
||||
{tab.title}
|
||||
</span>
|
||||
<span className='text-xs text-default-400 hidden md:inline'>({tab.pluginName})</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMdRefresh } from 'react-icons/io';
|
||||
import { FiUpload } from 'react-icons/fi';
|
||||
// import { FiUpload } from 'react-icons/fi';
|
||||
import { useDisclosure } from '@heroui/modal';
|
||||
|
||||
import PageLoading from '@/components/page_loading';
|
||||
@@ -19,7 +19,7 @@ export default function PluginPage () {
|
||||
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
const [currentPluginId, setCurrentPluginId] = useState<string>('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
// const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadPlugins = async () => {
|
||||
setLoading(true);
|
||||
@@ -61,54 +61,43 @@ export default function PluginPage () {
|
||||
|
||||
const handleUninstall = async (plugin: PluginItem) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let cleanData = false;
|
||||
dialog.confirm({
|
||||
title: '卸载插件',
|
||||
content: (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>确定要卸载插件「{plugin.name}」吗? 此操作不可恢复。</p>
|
||||
<p className="text-small text-default-500">如果插件创建了配置文件,是否一并删除?</p>
|
||||
<p className="text-base text-default-800">确定要卸载插件「<span className="font-semibold text-danger">{plugin.name}</span>」吗? 此操作不可恢复。</p>
|
||||
<div className="mt-2 bg-default-100 dark:bg-default-50/10 p-3 rounded-lg flex flex-col gap-1">
|
||||
<label className="flex items-center gap-2 cursor-pointer w-fit">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => { cleanData = e.target.checked; }}
|
||||
className="w-4 h-4 cursor-pointer accent-danger"
|
||||
/>
|
||||
<span className="text-small font-medium text-default-700">同时删除其配置文件</span>
|
||||
</label>
|
||||
<p className="text-xs text-default-500 pl-6 break-all w-full">配置目录: config/plugins/{plugin.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
confirmText: '确定卸载',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => {
|
||||
// Ask for data cleanup
|
||||
dialog.confirm({
|
||||
title: '删除配置',
|
||||
content: (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>是否同时清理插件「{plugin.name}」的配置文件?</p>
|
||||
<div className="text-small text-default-500">
|
||||
<p>配置目录: config/plugins/{plugin.id}</p>
|
||||
<p>点击"确定"清理配置,点击"取消"仅卸载插件。</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
confirmText: '清理并卸载',
|
||||
cancelText: '仅卸载',
|
||||
onConfirm: async () => {
|
||||
await performUninstall(true);
|
||||
},
|
||||
onCancel: async () => {
|
||||
await performUninstall(false);
|
||||
}
|
||||
});
|
||||
const loadingToast = toast.loading('卸载中...');
|
||||
try {
|
||||
await PluginManager.uninstallPlugin(plugin.id, cleanData);
|
||||
toast.success('卸载成功', { id: loadingToast });
|
||||
loadPlugins();
|
||||
resolve();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message, { id: loadingToast });
|
||||
reject(e);
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
const performUninstall = async (cleanData: boolean) => {
|
||||
const loadingToast = toast.loading('卸载中...');
|
||||
try {
|
||||
await PluginManager.uninstallPlugin(plugin.id, cleanData);
|
||||
toast.success('卸载成功', { id: loadingToast });
|
||||
loadPlugins();
|
||||
resolve();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message, { id: loadingToast });
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -117,60 +106,60 @@ export default function PluginPage () {
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
if (pluginManagerNotFound) {
|
||||
dialog.confirm({
|
||||
title: '插件管理器未加载',
|
||||
content: (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-default-600">
|
||||
插件管理器尚未加载,无法导入插件。
|
||||
</p>
|
||||
<p className="text-sm text-default-600">
|
||||
是否立即注册插件管理器?
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
confirmText: '注册插件管理器',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await PluginManager.registerPluginManager();
|
||||
toast.success('插件管理器注册成功');
|
||||
setPluginManagerNotFound(false);
|
||||
// 注册成功后打开文件选择器
|
||||
fileInputRef.current?.click();
|
||||
} catch (e: any) {
|
||||
toast.error('注册失败: ' + e.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
// const handleImportClick = () => {
|
||||
// if (pluginManagerNotFound) {
|
||||
// dialog.confirm({
|
||||
// title: '插件管理器未加载',
|
||||
// content: (
|
||||
// <div className="space-y-2">
|
||||
// <p className="text-sm text-default-600">
|
||||
// 插件管理器尚未加载,无法导入插件。
|
||||
// </p>
|
||||
// <p className="text-sm text-default-600">
|
||||
// 是否立即注册插件管理器?
|
||||
// </p>
|
||||
// </div>
|
||||
// ),
|
||||
// confirmText: '注册插件管理器',
|
||||
// cancelText: '取消',
|
||||
// onConfirm: async () => {
|
||||
// try {
|
||||
// await PluginManager.registerPluginManager();
|
||||
// toast.success('插件管理器注册成功');
|
||||
// setPluginManagerNotFound(false);
|
||||
// // 注册成功后打开文件选择器
|
||||
// fileInputRef.current?.click();
|
||||
// } catch (e: any) {
|
||||
// toast.error('注册失败: ' + e.message);
|
||||
// }
|
||||
// },
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
// fileInputRef.current?.click();
|
||||
// };
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
// const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// const file = e.target.files?.[0];
|
||||
// if (!file) return;
|
||||
|
||||
// 重置 input,允许重复选择同一文件
|
||||
e.target.value = '';
|
||||
// // 重置 input,允许重复选择同一文件
|
||||
// e.target.value = '';
|
||||
|
||||
if (!file.name.endsWith('.zip')) {
|
||||
toast.error('请选择 .zip 格式的插件包');
|
||||
return;
|
||||
}
|
||||
// if (!file.name.endsWith('.zip')) {
|
||||
// toast.error('请选择 .zip 格式的插件包');
|
||||
// return;
|
||||
// }
|
||||
|
||||
const loadingToast = toast.loading('正在导入插件...');
|
||||
try {
|
||||
const result = await PluginManager.importLocalPlugin(file);
|
||||
toast.success(result.message, { id: loadingToast });
|
||||
loadPlugins();
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || '导入失败', { id: loadingToast });
|
||||
}
|
||||
};
|
||||
// const loadingToast = toast.loading('正在导入插件...');
|
||||
// try {
|
||||
// const result = await PluginManager.importLocalPlugin(file);
|
||||
// toast.success(result.message, { id: loadingToast });
|
||||
// loadPlugins();
|
||||
// } catch (err: any) {
|
||||
// toast.error(err.message || '导入失败', { id: loadingToast });
|
||||
// }
|
||||
// };
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -193,6 +182,7 @@ export default function PluginPage () {
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
{/* 禁用插件上传
|
||||
<Button
|
||||
className="bg-primary-100/50 hover:bg-primary-200/50 text-primary-700 backdrop-blur-md"
|
||||
radius='full'
|
||||
@@ -208,6 +198,7 @@ export default function PluginPage () {
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
*/}
|
||||
</div>
|
||||
|
||||
{pluginManagerNotFound ? (
|
||||
|
||||
@@ -374,10 +374,10 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" variant="light" onPress={onClose}>
|
||||
Close
|
||||
关闭
|
||||
</Button>
|
||||
<Button color="primary" onPress={handleSave} isLoading={saving}>
|
||||
Save
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Input } from '@heroui/input';
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { Pagination } from '@heroui/pagination';
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
|
||||
import clsx from 'clsx';
|
||||
@@ -19,6 +20,16 @@ import { PluginStoreItem } from '@/types/plugin-store';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
import key from '@/const/key';
|
||||
|
||||
/** Fisher-Yates 洗牌算法,返回新数组 */
|
||||
function shuffleArray<T> (arr: T[]): T[] {
|
||||
const shuffled = [...arr];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
interface EmptySectionProps {
|
||||
isEmpty: boolean;
|
||||
}
|
||||
@@ -86,6 +97,10 @@ export default function PluginStorePage () {
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginStoreItem | null>(null);
|
||||
|
||||
// 分页状态
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const loadPlugins = async (forceRefresh: boolean = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -145,6 +160,7 @@ export default function PluginStorePage () {
|
||||
tools: filtered.filter(p => p.tags?.includes('工具')),
|
||||
entertainment: filtered.filter(p => p.tags?.includes('娱乐')),
|
||||
other: filtered.filter(p => !p.tags?.some(t => ['官方', '工具', '娱乐'].includes(t))),
|
||||
random: shuffleArray(filtered),
|
||||
};
|
||||
|
||||
return categories;
|
||||
@@ -175,9 +191,30 @@ export default function PluginStorePage () {
|
||||
{ key: 'tools', title: '工具', count: categorizedPlugins.tools?.length || 0 },
|
||||
{ key: 'entertainment', title: '娱乐', count: categorizedPlugins.entertainment?.length || 0 },
|
||||
{ key: 'other', title: '其它', count: categorizedPlugins.other?.length || 0 },
|
||||
{ key: 'random', title: '随机', count: categorizedPlugins.random?.length || 0 },
|
||||
];
|
||||
}, [categorizedPlugins]);
|
||||
|
||||
// 当前分类的总数和分页数据
|
||||
const currentCategoryPlugins = useMemo(() => categorizedPlugins[activeTab] || [], [categorizedPlugins, activeTab]);
|
||||
const totalPages = useMemo(() => Math.max(1, Math.ceil(currentCategoryPlugins.length / ITEMS_PER_PAGE)), [currentCategoryPlugins.length]);
|
||||
const paginatedPlugins = useMemo(() => {
|
||||
const start = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
return currentCategoryPlugins.slice(start, start + ITEMS_PER_PAGE);
|
||||
}, [currentCategoryPlugins, currentPage]);
|
||||
|
||||
// 切换分类或搜索时重置页码
|
||||
const handleTabChange = useCallback((key: string) => {
|
||||
setActiveTab(key);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 搜索变化时重置页码
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchQuery(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleInstall = async (plugin: PluginStoreItem) => {
|
||||
// 弹窗选择下载镜像
|
||||
setPendingInstallPlugin(plugin);
|
||||
@@ -338,7 +375,7 @@ export default function PluginStorePage () {
|
||||
placeholder='搜索(Ctrl+F)...'
|
||||
startContent={<IoMdSearch className='text-default-400' />}
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
onValueChange={handleSearchChange}
|
||||
className='max-w-xs w-full'
|
||||
size='sm'
|
||||
isClearable
|
||||
@@ -370,7 +407,7 @@ export default function PluginStorePage () {
|
||||
aria-label='Plugin Store Categories'
|
||||
className='max-w-full'
|
||||
selectedKey={activeTab}
|
||||
onSelectionChange={(key) => setActiveTab(String(key))}
|
||||
onSelectionChange={(key) => handleTabChange(String(key))}
|
||||
classNames={{
|
||||
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
|
||||
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
||||
@@ -395,9 +432,9 @@ export default function PluginStorePage () {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EmptySection isEmpty={!categorizedPlugins[activeTab]?.length} />
|
||||
<EmptySection isEmpty={!currentCategoryPlugins.length} />
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-4'>
|
||||
{categorizedPlugins[activeTab]?.map((plugin) => {
|
||||
{paginatedPlugins.map((plugin) => {
|
||||
const installInfo = getPluginInstallInfo(plugin);
|
||||
return (
|
||||
<PluginStoreCard
|
||||
@@ -414,6 +451,24 @@ export default function PluginStorePage () {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 分页控件 */}
|
||||
{totalPages > 1 && (
|
||||
<div className='flex justify-center mt-6 mb-2'>
|
||||
<Pagination
|
||||
total={totalPages}
|
||||
page={currentPage}
|
||||
onChange={setCurrentPage}
|
||||
showControls
|
||||
showShadow
|
||||
color='primary'
|
||||
size='lg'
|
||||
classNames={{
|
||||
wrapper: 'backdrop-blur-md bg-white/40 dark:bg-black/20',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import QrCodeLogin from '@/components/qr_code_login';
|
||||
import QuickLogin from '@/components/quick_login';
|
||||
import type { QQItem } from '@/components/quick_login';
|
||||
import { ThemeSwitch } from '@/components/theme-switch';
|
||||
import type { CaptchaCallbackData } from '@/components/tencent_captcha';
|
||||
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
@@ -58,6 +59,21 @@ export default function QQLoginPage () {
|
||||
const [refresh, setRefresh] = useState<boolean>(false);
|
||||
const [activeTab, setActiveTab] = useState<string>('shortcut');
|
||||
const firstLoad = useRef<boolean>(true);
|
||||
const [captchaState, setCaptchaState] = useState<{
|
||||
needCaptcha: boolean;
|
||||
proofWaterUrl: string;
|
||||
uin: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
const [captchaVerifying, setCaptchaVerifying] = useState(false);
|
||||
const [newDeviceState, setNewDeviceState] = useState<{
|
||||
needNewDevice: boolean;
|
||||
jumpUrl: string;
|
||||
newDevicePullQrCodeSig: string;
|
||||
uin: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
// newDevicePullQrCodeSig is kept for step:2 login after QR verification
|
||||
const onSubmit = async () => {
|
||||
if (!uinValue) {
|
||||
toast.error('请选择快捷登录的QQ');
|
||||
@@ -83,8 +99,28 @@ export default function QQLoginPage () {
|
||||
try {
|
||||
// 计算密码的MD5值
|
||||
const passwordMd5 = CryptoJS.MD5(password).toString();
|
||||
await QQManager.passwordLogin(uin, passwordMd5);
|
||||
toast.success('密码登录请求已发送');
|
||||
const result = await QQManager.passwordLogin(uin, passwordMd5);
|
||||
if (result?.needCaptcha && result.proofWaterUrl) {
|
||||
// 需要验证码,显示验证码组件
|
||||
setCaptchaState({
|
||||
needCaptcha: true,
|
||||
proofWaterUrl: result.proofWaterUrl,
|
||||
uin,
|
||||
password,
|
||||
});
|
||||
toast('需要安全验证,请完成验证码', { icon: '🔒' });
|
||||
} else if (result?.needNewDevice && result.jumpUrl) {
|
||||
setNewDeviceState({
|
||||
needNewDevice: true,
|
||||
jumpUrl: result.jumpUrl,
|
||||
newDevicePullQrCodeSig: result.newDevicePullQrCodeSig || '',
|
||||
uin,
|
||||
password,
|
||||
});
|
||||
toast('检测到新设备,请扫码验证', { icon: '📱' });
|
||||
} else {
|
||||
toast.success('密码登录请求已发送');
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`密码登录失败: ${msg}`);
|
||||
@@ -93,6 +129,75 @@ export default function QQLoginPage () {
|
||||
}
|
||||
};
|
||||
|
||||
const onCaptchaSubmit = async (uin: string, password: string, captchaData: CaptchaCallbackData) => {
|
||||
setIsLoading(true);
|
||||
setCaptchaVerifying(true);
|
||||
try {
|
||||
const passwordMd5 = CryptoJS.MD5(password).toString();
|
||||
const result = await QQManager.captchaLogin(uin, passwordMd5, captchaData.ticket, captchaData.randstr, captchaData.sid);
|
||||
if (result?.needNewDevice && result.jumpUrl) {
|
||||
setCaptchaState(null);
|
||||
setNewDeviceState({
|
||||
needNewDevice: true,
|
||||
jumpUrl: result.jumpUrl,
|
||||
newDevicePullQrCodeSig: result.newDevicePullQrCodeSig || '',
|
||||
uin,
|
||||
password,
|
||||
});
|
||||
toast('检测到异常设备,请扫码验证', { icon: '📱' });
|
||||
} else {
|
||||
toast.success('验证码登录请求已发送');
|
||||
setCaptchaState(null);
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`验证码登录失败: ${msg}`);
|
||||
setCaptchaState(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setCaptchaVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onCaptchaCancel = () => {
|
||||
setCaptchaState(null);
|
||||
};
|
||||
|
||||
const onNewDeviceVerified = async (token: string) => {
|
||||
if (!newDeviceState) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const passwordMd5 = CryptoJS.MD5(newDeviceState.password).toString();
|
||||
// Use the str_nt_succ_token from QR verification as newDevicePullQrCodeSig for step:2
|
||||
const sig = token || newDeviceState.newDevicePullQrCodeSig;
|
||||
const result = await QQManager.newDeviceLogin(newDeviceState.uin, passwordMd5, sig);
|
||||
if (result?.needNewDevice && result.jumpUrl) {
|
||||
// 新设备验证后又触发了异常设备验证,更新 jumpUrl
|
||||
setNewDeviceState({
|
||||
needNewDevice: true,
|
||||
jumpUrl: result.jumpUrl,
|
||||
newDevicePullQrCodeSig: result.newDevicePullQrCodeSig || '',
|
||||
uin: newDeviceState.uin,
|
||||
password: newDeviceState.password,
|
||||
});
|
||||
toast('检测到异常设备,请继续扫码验证', { icon: '📱' });
|
||||
} else {
|
||||
toast.success('新设备验证登录请求已发送');
|
||||
setNewDeviceState(null);
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`新设备验证登录失败: ${msg}`);
|
||||
setNewDeviceState(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onNewDeviceCancel = () => {
|
||||
setNewDeviceState(null);
|
||||
};
|
||||
|
||||
const onUpdateQrCode = async () => {
|
||||
if (firstLoad.current) setIsLoading(true);
|
||||
try {
|
||||
@@ -249,7 +354,14 @@ export default function QQLoginPage () {
|
||||
<PasswordLogin
|
||||
isLoading={isLoading}
|
||||
onSubmit={onPasswordSubmit}
|
||||
onCaptchaSubmit={onCaptchaSubmit}
|
||||
onNewDeviceVerified={onNewDeviceVerified}
|
||||
qqList={qqList}
|
||||
captchaState={captchaState}
|
||||
captchaVerifying={captchaVerifying}
|
||||
newDeviceState={newDeviceState}
|
||||
onCaptchaCancel={onCaptchaCancel}
|
||||
onNewDeviceCancel={onNewDeviceCancel}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key='qrcode' title='扫码登录'>
|
||||
|
||||
19
packages/napcat-webui-frontend/src/types/napcat_conf.d.ts
vendored
Normal file
19
packages/napcat-webui-frontend/src/types/napcat_conf.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
interface BypassOptions {
|
||||
hook: boolean;
|
||||
window: boolean;
|
||||
module: boolean;
|
||||
process: boolean;
|
||||
container: boolean;
|
||||
js: boolean;
|
||||
}
|
||||
|
||||
interface NapCatConfig {
|
||||
fileLog: boolean;
|
||||
consoleLog: boolean;
|
||||
fileLogLevel: string;
|
||||
consoleLogLevel: string;
|
||||
packetBackend: string;
|
||||
packetServer: string;
|
||||
o3HookMode: number;
|
||||
bypass?: BypassOptions;
|
||||
}
|
||||
90
pnpm-lock.yaml
generated
90
pnpm-lock.yaml
generated
@@ -65,8 +65,8 @@ importers:
|
||||
packages/napcat-common:
|
||||
dependencies:
|
||||
ajv:
|
||||
specifier: ^8.13.0
|
||||
version: 8.17.1
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.0
|
||||
file-type:
|
||||
specifier: ^21.0.0
|
||||
version: 21.1.0
|
||||
@@ -84,8 +84,8 @@ importers:
|
||||
specifier: ^0.34.38
|
||||
version: 0.34.41
|
||||
ajv:
|
||||
specifier: ^8.13.0
|
||||
version: 8.17.1
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.0
|
||||
compressing:
|
||||
specifier: ^1.10.1
|
||||
version: 1.10.3
|
||||
@@ -136,6 +136,9 @@ importers:
|
||||
|
||||
packages/napcat-framework:
|
||||
dependencies:
|
||||
json5:
|
||||
specifier: ^2.2.3
|
||||
version: 2.2.3
|
||||
napcat-adapter:
|
||||
specifier: workspace:*
|
||||
version: link:../napcat-adapter
|
||||
@@ -171,11 +174,8 @@ importers:
|
||||
specifier: ^0.34.38
|
||||
version: 0.34.41
|
||||
ajv:
|
||||
specifier: ^8.13.0
|
||||
version: 8.17.1
|
||||
async-mutex:
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.0
|
||||
cors:
|
||||
specifier: ^2.8.5
|
||||
version: 2.8.5
|
||||
@@ -261,8 +261,8 @@ importers:
|
||||
specifier: ^0.34.38
|
||||
version: 0.34.41
|
||||
ajv:
|
||||
specifier: ^8.13.0
|
||||
version: 8.17.1
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.0
|
||||
cors:
|
||||
specifier: ^2.8.5
|
||||
version: 2.8.5
|
||||
@@ -357,6 +357,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^22.0.1
|
||||
version: 22.19.1
|
||||
json5:
|
||||
specifier: ^2.2.3
|
||||
version: 2.2.3
|
||||
napcat-vite:
|
||||
specifier: workspace:*
|
||||
version: link:../napcat-vite
|
||||
@@ -424,8 +427,8 @@ importers:
|
||||
specifier: ^0.34.38
|
||||
version: 0.34.41
|
||||
ajv:
|
||||
specifier: ^8.13.0
|
||||
version: 8.17.1
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.0
|
||||
compressing:
|
||||
specifier: ^1.10.3
|
||||
version: 1.10.3
|
||||
@@ -894,6 +897,9 @@ packages:
|
||||
'@codemirror/commands@6.10.1':
|
||||
resolution: {integrity: sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==}
|
||||
|
||||
'@codemirror/commands@6.10.2':
|
||||
resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==}
|
||||
|
||||
'@codemirror/lang-css@6.3.1':
|
||||
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
|
||||
|
||||
@@ -918,6 +924,9 @@ packages:
|
||||
'@codemirror/theme-one-dark@6.1.3':
|
||||
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
|
||||
|
||||
'@codemirror/view@6.39.16':
|
||||
resolution: {integrity: sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==}
|
||||
|
||||
'@codemirror/view@6.39.6':
|
||||
resolution: {integrity: sha512-/N+SoP5NndJjkGInp3BwlUa3KQKD6bDo0TV6ep37ueAdQ7BVu/PqlZNywmgjCq0MQoZadZd8T+MZucSr7fktyQ==}
|
||||
|
||||
@@ -2085,6 +2094,9 @@ packages:
|
||||
'@lezer/common@1.5.0':
|
||||
resolution: {integrity: sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==}
|
||||
|
||||
'@lezer/common@1.5.1':
|
||||
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
|
||||
|
||||
'@lezer/css@1.3.0':
|
||||
resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==}
|
||||
|
||||
@@ -3405,11 +3417,11 @@ packages:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
ajv@6.12.6:
|
||||
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
||||
ajv@6.14.0:
|
||||
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
|
||||
|
||||
ajv@8.17.1:
|
||||
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
|
||||
ajv@8.18.0:
|
||||
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
|
||||
|
||||
ansi-colors@4.1.3:
|
||||
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
|
||||
@@ -3518,9 +3530,6 @@ packages:
|
||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
async-mutex@0.5.0:
|
||||
resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
|
||||
|
||||
async@3.2.6:
|
||||
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
|
||||
|
||||
@@ -4609,16 +4618,17 @@ packages:
|
||||
|
||||
glob@10.4.5:
|
||||
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
glob@8.1.0:
|
||||
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
|
||||
engines: {node: '>=12'}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
globals@14.0.0:
|
||||
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
||||
@@ -5763,6 +5773,7 @@ packages:
|
||||
prebuild-install@7.1.3:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
|
||||
hasBin: true
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
@@ -6462,6 +6473,7 @@ packages:
|
||||
tar@6.2.1:
|
||||
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
text-hex@1.0.0:
|
||||
resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==}
|
||||
@@ -7126,6 +7138,13 @@ snapshots:
|
||||
'@codemirror/view': 6.39.6
|
||||
'@lezer/common': 1.5.0
|
||||
|
||||
'@codemirror/commands@6.10.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.3
|
||||
'@codemirror/view': 6.39.16
|
||||
'@lezer/common': 1.5.1
|
||||
|
||||
'@codemirror/lang-css@6.3.1':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
@@ -7167,7 +7186,7 @@ snapshots:
|
||||
'@codemirror/search@6.5.11':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.3
|
||||
'@codemirror/view': 6.39.6
|
||||
'@codemirror/view': 6.39.16
|
||||
crelt: 1.0.6
|
||||
|
||||
'@codemirror/state@6.5.3':
|
||||
@@ -7181,6 +7200,13 @@ snapshots:
|
||||
'@codemirror/view': 6.39.6
|
||||
'@lezer/highlight': 1.2.3
|
||||
|
||||
'@codemirror/view@6.39.16':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.3
|
||||
crelt: 1.0.6
|
||||
style-mod: 4.1.3
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
'@codemirror/view@6.39.6':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.3
|
||||
@@ -7423,7 +7449,7 @@ snapshots:
|
||||
|
||||
'@eslint/eslintrc@3.3.1':
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
ajv: 6.14.0
|
||||
debug: 4.4.3
|
||||
espree: 10.4.0
|
||||
globals: 14.0.0
|
||||
@@ -8562,6 +8588,8 @@ snapshots:
|
||||
|
||||
'@lezer/common@1.5.0': {}
|
||||
|
||||
'@lezer/common@1.5.1': {}
|
||||
|
||||
'@lezer/css@1.3.0':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.0
|
||||
@@ -10263,14 +10291,14 @@ snapshots:
|
||||
screenfull: 5.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
ajv@6.12.6:
|
||||
ajv@6.14.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-json-stable-stringify: 2.1.0
|
||||
json-schema-traverse: 0.4.1
|
||||
uri-js: 4.4.1
|
||||
|
||||
ajv@8.17.1:
|
||||
ajv@8.18.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.0
|
||||
@@ -10398,10 +10426,6 @@ snapshots:
|
||||
|
||||
async-function@1.0.0: {}
|
||||
|
||||
async-mutex@0.5.0:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
async@3.2.6: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
@@ -10672,12 +10696,12 @@ snapshots:
|
||||
codemirror@6.0.2:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/commands': 6.10.1
|
||||
'@codemirror/commands': 6.10.2
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/lint': 6.9.2
|
||||
'@codemirror/search': 6.5.11
|
||||
'@codemirror/state': 6.5.3
|
||||
'@codemirror/view': 6.39.6
|
||||
'@codemirror/view': 6.39.16
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
@@ -11344,7 +11368,7 @@ snapshots:
|
||||
'@humanwhocodes/module-importer': 1.0.1
|
||||
'@humanwhocodes/retry': 0.4.3
|
||||
'@types/estree': 1.0.8
|
||||
ajv: 6.12.6
|
||||
ajv: 6.14.0
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.3
|
||||
|
||||
Reference in New Issue
Block a user