├── .github
└── workflows
│ ├── pypi-publish-manual.yml
│ └── pypi-publish.yml
├── .gitignore
├── .pre-commit-config.yaml
├── CONTRIBUTING.md
├── LICENSE
├── MANIFEST.in
├── NOTICE
├── README.md
├── assets
└── background.png
├── config.yaml
├── docs
└── README.md
├── main.py
├── pyproject.toml
├── requirements.txt
├── setup.py
└── src
├── deploy
├── main.cpp
└── resource.rc
└── ncatbot
├── __init__.py
├── adapter
├── __init__.py
├── nc
│ ├── __init__.py
│ ├── install.py
│ ├── launcher.py
│ ├── login.py
│ └── start.py
└── net
│ ├── __init__.py
│ ├── connect.py
│ └── wsroute.py
├── cli
├── __init__.py
├── info_commands.py
├── main.py
├── plugin_commands.py
├── registry.py
├── system_commands.py
└── utils.py
├── core
├── __init__.py
├── api
│ ├── __init__.py
│ ├── api.py
│ └── sync_api.py
├── client.py
├── element.py
├── message.py
├── notice.py
└── request.py
├── plugin
├── RBACManager
│ ├── RBAC_Manager.py
│ ├── __init__.py
│ ├── permission_path.py
│ └── permission_trie.py
├── __init__.py
├── base_plugin
│ ├── __init__.py
│ ├── base_plugin.py
│ ├── builtin_function.py
│ ├── event_handler.py
│ └── time_task_scheduler.py
├── event
│ ├── __init__.py
│ ├── access_controller.py
│ ├── event.py
│ ├── event_bus.py
│ ├── filter.py
│ └── function.py
└── loader
│ ├── __init__.py
│ ├── compatible.py
│ ├── loader.py
│ └── pip_tool.py
├── scripts
├── __init__.py
├── publish.py
└── utils.py
└── utils
├── __init__.py
├── assets
├── __init__.py
├── color.py
├── literals.py
└── plugin_custom_err.py
├── config.py
├── env_checker.py
├── file_io.py
├── function_enhance.py
├── logger.py
├── network_io.py
├── optional
├── __init__.py
├── change_dir.py
├── mdmaker.py
├── time_task_scheduler.py
└── visualize_data.py
└── template
└── external.css
/.github/workflows/pypi-publish-manual.yml:
--------------------------------------------------------------------------------
1 | # This workflow will upload a Python Package using Twine when a release is created
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
3 |
4 | # This workflow uses actions that are not certified by GitHub.
5 | # They are provided by a third-party and are governed by
6 | # separate terms of service, privacy policy, and support
7 | # documentation.
8 |
9 | name: Upload Python Package
10 |
11 | on:
12 | push:
13 | branches:
14 | - main # 指定触发的分支,根据实际情况修改
15 | paths:
16 | - "ncatbot/__init__.py" # 只有当 __init__.py 文件发生变化时触发
17 | workflow_dispatch: # 允许手动触发
18 |
19 | permissions:
20 | contents: read
21 |
22 | jobs:
23 | build-and-publish:
24 | runs-on: ubuntu-latest
25 |
26 | steps:
27 | - name: Checkout code
28 | uses: actions/checkout@v2
29 |
30 | - name: Set up Python
31 | uses: actions/setup-python@v2
32 | with:
33 | python-version: '3.x' # 可以根据需要修改为具体的 Python 版本
34 |
35 | - name: Install dependencies
36 | run: |
37 | python -m pip install --upgrade pip
38 | pip install setuptools wheel twine # 安装打包和发布工具
39 |
40 | - name: Build the package
41 | run: |
42 | python setup.py sdist bdist_wheel # 创建包
43 |
44 | - name: Upload the package to PyPI
45 | env:
46 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
47 | run: |
48 | twine upload dist/* -u __token__ -p $PYPI_TOKEN # 使用 Twine 发布包
49 |
--------------------------------------------------------------------------------
/.github/workflows/pypi-publish.yml:
--------------------------------------------------------------------------------
1 | # 定义 GitHub Actions 工作流的名称
2 | name: Publish Python Package
3 |
4 | # 定义触发工作流的事件
5 | on:
6 | # 当有新的标签推送到仓库时触发
7 | push:
8 | tags:
9 | # 只匹配以 v 开头的标签
10 | - v*
11 |
12 | # 定义工作流中的任务
13 | jobs:
14 | # 定义一个名为 deploy 的任务
15 | deploy:
16 | # 指定任务运行的环境
17 | runs-on: ubuntu-latest
18 | # 定义任务所需的权限
19 | permissions:
20 | # 允许读取仓库内容
21 | contents: read
22 |
23 | # 定义任务的步骤
24 | steps:
25 | # 使用 actions/checkout 动作来检出代码
26 | - uses: actions/checkout@v4
27 | with:
28 | # 拉取所有历史记录
29 | fetch-depth: 0
30 |
31 | # 设置 Python 环境
32 | - name: Set up Python
33 | # 使用 actions/setup-python 动作来设置 Python 环境
34 | uses: actions/setup-python@v5
35 | with:
36 | # 使用 Python 3.x 版本
37 | python-version: '3.x'
38 |
39 | # 提取并验证版本号
40 | - name: Extract and Validate Version
41 | run: |
42 | # 从 GITHUB_REF 环境变量中提取标签名
43 | TAG_NAME=${GITHUB_REF#refs/tags/}
44 | # 去掉标签名前面的 v 字符
45 | VERSION=${TAG_NAME#v}
46 | # 验证版本号是否符合 X.Y.Z 格式
47 | if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
48 | # 若不符合格式,输出错误信息
49 | echo "::error::Invalid version format: $VERSION. Use X.Y.Z"
50 | # 退出脚本并返回错误码 1
51 | exit 1
52 | fi
53 | # 将版本号添加到 GITHUB_ENV 环境变量中
54 | echo "VERSION=$VERSION" >> $GITHUB_ENV
55 |
56 | # 安装构建工具
57 | - name: Install Build Tools
58 | run: |
59 | # 升级 pip 到最新版本
60 | python -m pip install --upgrade pip
61 | # 安装 setuptools、build 和 twine 工具
62 | pip install "setuptools>=61.0" build twine
63 |
64 | # 验证 pyproject.toml 文件
65 | - name: Validate pyproject.toml
66 | run: |
67 | # 检查 pyproject.toml 文件中是否包含指定的构建后端配置
68 | if ! grep -q 'build-backend = "setuptools.build_meta"' pyproject.toml; then
69 | # 若不包含,输出错误信息
70 | echo "::error::Invalid build-backend configuration"
71 | # 退出脚本并返回错误码 1
72 | exit 1
73 | fi
74 |
75 | # 清理之前的构建产物
76 | - name: Clean previous builds
77 | run: |
78 | # 删除 dist 目录下的所有文件
79 | rm -rf dist/*
80 | # 删除 build 目录下的所有文件
81 | rm -rf build/*
82 |
83 | # 更新包的版本号
84 | - name: Update package version
85 | run: |
86 | # 使用 sed 命令替换 src/ncatbot/__init__.py 文件中的版本号
87 | sed -i "s/^__version__ = .*/__version__ = \"${{ env.VERSION }}\"/" src/ncatbot/__init__.py
88 |
89 | # 构建 Python 包
90 | - name: Build Package
91 | run: python -m build --sdist --wheel --outdir dist/
92 |
93 | # 发布包到 PyPI
94 | - name: Publish to PyPI
95 | env:
96 | # 从 GitHub 仓库的 secrets 中获取 PyPI API 令牌
97 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
98 | run: |
99 | # 使用 twine 工具将构建好的包上传到 PyPI
100 | twine upload --username __token__ --password "$PYPI_TOKEN" dist/*
101 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # 在项目根目录创建 .gitignore
2 | echo "dist/
3 | build/
4 | *.egg-info/
5 | __pycache__/
6 | *.pyc" >> .gitignore
7 |
8 | # Byte-compiled / optimized / DLL files
9 | __pycache__/
10 | *.py[cod]
11 | *$py.class
12 |
13 | # C extensions
14 | *.so
15 |
16 | # Distribution / packaging
17 | .Python
18 | build/
19 | develop-eggs/
20 | dist/
21 | downloads/
22 | eggs/
23 | .eggs/
24 | lib/
25 | lib64/
26 | parts/
27 | sdist/
28 | var/
29 | wheels/
30 | share/python-wheels/
31 | *.egg-info/
32 | .installed.cfg
33 | *.egg
34 | MANIFEST
35 |
36 | # PyInstaller
37 | # Usually these files are written by a python script from a template
38 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
39 | *.manifest
40 | *.spec
41 |
42 | # Installer logs
43 | pip-log.txt
44 | pip-delete-this-directory.txt
45 |
46 | # Unit test / coverage reports
47 | htmlcov/
48 | .tox/
49 | .nox/
50 | .coverage
51 | .coverage.*
52 | .cache
53 | nosetests.xml
54 | coverage.xml
55 | *.cover
56 | *.py,cover
57 | .hypothesis/
58 | .pytest_cache/
59 | cover/
60 |
61 | # Translations
62 | *.mo
63 | *.pot
64 |
65 | # Django stuff:
66 | *.log
67 | local_settings.py
68 | db.sqlite3
69 | db.sqlite3-journal
70 |
71 | # Flask stuff:
72 | instance/
73 | .webassets-cache
74 |
75 | # Scrapy stuff:
76 | .scrapy
77 |
78 | # Sphinx documentation
79 | docs/_build/
80 |
81 | # PyBuilder
82 | .pybuilder/
83 | target/
84 |
85 | # Jupyter Notebook
86 | .ipynb_checkpoints
87 |
88 | # IPython
89 | profile_default/
90 | ipython_config.py
91 |
92 | # pyenv
93 | # For a library or package, you might want to ignore these files since the code is
94 | # intended to run in multiple environments; otherwise, check them in:
95 | # .python-version
96 |
97 | # pipenv
98 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
99 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
100 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
101 | # install all needed dependencies.
102 | #Pipfile.lock
103 |
104 | # UV
105 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
106 | # This is especially recommended for binary packages to ensure reproducibility, and is more
107 | # commonly ignored for libraries.
108 | #uv.lock
109 |
110 | # poetry
111 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
112 | # This is especially recommended for binary packages to ensure reproducibility, and is more
113 | # commonly ignored for libraries.
114 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
115 | #poetry.lock
116 |
117 | # pdm
118 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
119 | pdm.lock
120 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
121 | # in version control.
122 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
123 | .pdm.toml
124 | .pdm-python
125 | .pdm-build/
126 |
127 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128 | __pypackages__/
129 |
130 | # Celery stuff
131 | celerybeat-schedule
132 | celerybeat.pid
133 |
134 | # SageMath parsed files
135 | *.sage.py
136 |
137 | # Environments
138 | .env
139 | .venv
140 | env/
141 | venv/
142 | ENV/
143 | env.bak/
144 | venv.bak/
145 |
146 | # Spyder project settings
147 | .spyderproject
148 | .spyproject
149 |
150 | # Rope project settings
151 | .ropeproject
152 |
153 | # mkdocs documentation
154 | /site
155 |
156 | # mypy
157 | .mypy_cache/
158 | .dmypy.json
159 | dmypy.json
160 |
161 | # Pyre type checker
162 | .pyre/
163 |
164 | # pytype static type analyzer
165 | .pytype/
166 |
167 | # Cython debug symbols
168 | cython_debug/
169 |
170 | # PyCharm
171 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
172 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
173 | # and can be added to the global gitignore or merged into this file. For a more nuclear
174 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
175 | .idea/
176 |
177 | # PyPI configuration file
178 | .pypirc
179 | .fseventsd
180 |
181 | # 自定义
182 | ._*?
183 | *?test
184 | test*?
185 | test
186 | .DS_Store
187 | .Trashes
188 | .Spotlight-V100
189 | System Volume Information/IndexerVolumeGuid
190 | .vscode/
191 | plugins/
192 | logs/
193 | napcat/
194 | plugin.py
195 | data/
196 | package.zip
197 | resource.o
198 | number.txt
199 | **.exe
200 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | default_stages: [pre-commit, pre-push, manual]
2 | repos:
3 | - repo: https://github.com/pre-commit/pre-commit-hooks
4 | rev: v5.0.0
5 | hooks:
6 | - id: check-symlinks
7 | - id: destroyed-symlinks
8 | - id: trailing-whitespace
9 | - id: end-of-file-fixer
10 | - id: check-yaml
11 | - id: check-toml
12 | - id: check-ast
13 | - id: check-added-large-files
14 | - id: check-merge-conflict
15 | - id: check-executables-have-shebangs
16 | - id: check-shebang-scripts-are-executable
17 | - id: detect-private-key
18 | - id: debug-statements
19 | - id: mixed-line-ending
20 | args: ['--fix=crlf']
21 | - repo: https://github.com/astral-sh/ruff-pre-commit
22 | rev: v0.9.4
23 | hooks:
24 | - id: ruff
25 | args: [--fix, --exit-non-zero-on-fix]
26 | - repo: https://github.com/PyCQA/isort
27 | rev: 6.0.0
28 | hooks:
29 | - id: isort
30 | args: ["--profile", "black"]
31 | - repo: https://github.com/psf/black
32 | rev: 25.1.0
33 | hooks:
34 | - id: black
35 | - repo: https://github.com/codespell-project/codespell
36 | rev: v2.4.1
37 | hooks:
38 | - id: codespell
39 | additional_dependencies: [".[toml]"]
40 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # 贡献指南
2 | 欢迎参与ncatbot项目!我们致力于打造一个友好的开源社区,以下是参与贡献的规范流程。
3 | ## 📌 开始之前
4 | 1. 确认项目采用
5 | 2. 阅读 [项目文档](README.md)
6 | 3. 加入开发者交流群:`201487478`
7 | ## 🌱 开发流程
8 | ### 分支管理
9 | ```bash
10 | git clone https://github.com/liyihao1110/ncatbot
11 | git checkout -b feat/Function-development # 功能开发分支
12 | # 或
13 | git checkout -b fix/issue-fix # 问题修复分支
14 | ```
15 | ### 提交规范
16 | 采用 [Conventional Commits](https://www.conventionalcommits.org/) 规范:
17 | ```bash
18 | git commit -m "feat: 添加消息撤回功能"
19 | git commit -m "fix(api): 修复消息队列溢出问题 #123"
20 | git commit -m "docs: 更新安装指南"
21 | ```
22 | ### Pull Request
23 | 1. 推送分支到远程仓库
24 | 2. 在Github创建Pull Request
25 | 3. 关联相关Issue(如 `#123`)
26 | 4. 通过CI测试后等待审核
27 | ## 🧪 测试要求(待更新...)[tests文件夹未更新]
28 | 所有代码修改必须通过测试:
29 | ```bash
30 | # 运行单元测试
31 | pytest tests/
32 | # 检查代码风格
33 | flake8 ncatbot_sync/
34 | # 验证类型提示
35 | mypy ncatbot_sync/
36 | ```
37 | ## 🖋 代码规范
38 | 1. 遵循 PEP8 规范
39 | 2. 类型提示强制要求:
40 | ```python
41 | def send_message(content: str, group_id: int) -> MessageResult:
42 | ...
43 | ```
44 | 3. 文档字符串标准:
45 | ```python
46 | def handle_event(event: Event):
47 | """处理机器人事件
48 | Args:
49 | event: 继承自BaseEvent的事件对象
50 | Returns:
51 | 无返回值,可能产生副作用
52 | """
53 | ```
54 | ## 📚 文档贡献
55 | 1. 模块文档需包含使用示例
56 | 2. 中文文档在 `/docs/zh` 目录
57 | 3. 英文文档在 `/docs/en` 目录(如果你愿意的话)
58 | 4. 使用Markdown格式编写
59 | ## 🐛 Issue规范
60 | 提交问题请包含:
61 | ```markdown
62 | ## 环境信息
63 | - 系统版本:Windows 11 22H2
64 | - Python版本:3.10.6
65 | - 框架版本:v0.2.1
66 | - 项目版本:v0.0.1
67 | ## 重现步骤
68 | 1. 调用API发送图片
69 | 2. 连续发送10次请求
70 | 3. 观察控制台输出
71 | ## 预期行为
72 | 正常返回消息ID
73 | ## 实际行为
74 | 抛出ConnectionError异常
75 | ```
76 | ## 💡 新功能提案
77 | 提交提案需包含:
78 | 1. 功能使用场景
79 | 2. 建议的API设计
80 | 3. 与其他模块的兼容性分析
81 | ## 🚫 行为准则
82 | 1. 禁止提交恶意代码
83 | 2. 讨论时保持专业态度
84 | 3. 尊重不同技术选择
85 | 4. 及时响应代码审查意见
86 | ## 🙌 鸣谢
87 | 所有贡献者将加入贡献者名单,重大贡献者授予Committer角色。
88 | ## 📜 版本发布
89 | 在开发者交流群内通知所有成员后全员同意后进行版本发布,违者将被踢出开发者
90 | ## 📜 额外信息
91 | 1. 在不确定是否可用的情况下,请对源文件进行备份,备份目录定位于`/resources`文件夹
92 | 2. 请不要提交任何非必要的文件,如`.idea`,`__pycache__`等
93 | 3. 该文件可能会持续更新,请定期查看
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | NcatBot Non-Commercial License
2 |
3 | Copyright (c) 2025 NcatBot开发项目组
4 |
5 | 在遵守以下条款的前提下,特此免费授予任何获得本软件及相关文档文件(以下简称“软件”)的人员不受限制地处置本软件的权利,包括但不限于使用、复制、修改、合并、发布、分发、再许可的权利:
6 |
7 | 一、约束条款
8 | 1. 未经授权,禁止商业用途
9 | - 不得直接或间接通过本软件获利,包括但不限于:
10 | * 售卖软件副本或衍生作品
11 | * 作为商业产品或服务组成部分
12 | * 用于广告推广或流量变现
13 | * 其他以营利为目的的使用场景
14 |
15 | 2. 二次开发授权
16 | - 修改后的衍生作品需满足:
17 | * 必须保留原始版权声明
18 | * 需通过邮件(lyh_02@foxmail.com)提交授权申请
19 | * 获得书面授权后方可分发
20 |
21 | 二、违约处理
22 | 1. 违反上述条款自动终止授权
23 | 2. 需承担因此造成的所有法律责任
24 | 3. 侵权方需承担维权产生的合理费用
25 |
26 | 三、免责声明
27 | 本软件按"原样"提供,不做任何明示或暗示的担保,包括但不限于对适销性、特定用途适用性的担保。在任何情况下,作者或版权持有人均不对任何索赔、损害或其他责任负责。
28 |
29 | 四、管辖法律
30 | 本协议适用中华人民共和国法律,任何争议应提交厦门仲裁委员会仲裁解决。
31 |
32 | 本协议最终解释权归NcatBot开发项目组所有。
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include LICENSE
2 | include README.md
3 | include NOTICE
4 | include requirements.txt
5 | include pyproject.toml
6 | recursive-include src *.py # 包含所有Python文件
7 | recursive-include src *.txt *.md # 包含文本文件
--------------------------------------------------------------------------------
/NOTICE:
--------------------------------------------------------------------------------
1 | 商业使用授权申请需包含:
2 | - 申请方基本信息
3 | - 使用场景描述
4 | - 预计用户规模
5 | - 联系方式
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # 🚀 ncatbot
4 |
5 | ---
6 |
7 | 
8 |
9 |

10 | [](https://github.com/botuniverse/onebot)
11 | [](https://github.com/liyihao1110/ncatbot)
12 |

13 |

14 |

15 |

16 |
17 |
18 | [文档](docs/) | [许可证](LICENSE) | [QQ群](https://qm.qq.com/q/AmdNUkSxFY)
19 |
20 | NcatBot 是基于 onebot11协议 的 Python SDK, 它提供了一套方便易用的 Python 接口用于开发 QQ 机器人.
21 |
22 |
23 |
24 |
25 | ## 如何使用
26 |
27 | ### 用户使用
28 |
29 | 针对没有计算机基础的用户群体, 可以直接下载我们的**一键安装包**安装 NcatBot 运行时环境, 并使用他人发布的插件.
30 |
31 | [阅读文档了解更多](https://docs.ncatbot.xyz/guide/onestepi/)
32 |
33 | ### 开发者使用
34 |
35 | 请**认真阅读**[文档](https://docs.ncatbot.xyz/). 文档中包含详细的**开发指南**和**示例项目及其解析**.
36 |
37 | [插件仓库地址](https://github.com/ncatbot/NcatBot-Plugins).
38 |
39 |
40 | ## 欢迎来玩
41 |
42 | [是 QQ 群哦喵~](https://qm.qq.com/q/L6XGXYqL86)
43 |
44 | ## 获取帮助
45 |
46 | - 遇到任何困难时, 请先按照以下顺序尝试自己解决:
47 |
48 | 1. **仔细阅读**[文档](https://docs.ncatbot.xyz/).
49 | 2. 询问 [DeepSeek](https://chat.deepseek.com), [Kimi](https://kimi.ai) 等人工智能.
50 | 3. 搜索本项目的 [Issue 列表](https://github.com/liyihao1110/ncatbot/issues).
51 | - 如果以上方法都无法解决你的问题, 那么:
52 |
53 | 可以[进群](https://qm.qq.com/q/L6XGXYqL86)提问.
54 |
55 | ## 联系我们
56 |
57 | 作者: [最可爱的木子喵~](https://gitee.com/li-yihao0328)
58 |
59 | 邮箱:
60 |
61 |
62 | ## 使用限制
63 |
64 | 1. **严禁将本项目以任何形式用于传播 反动、暴力、淫秽 信息,违者将追究法律责任**.
65 | 2. 将本项目以**任何形式**用于**盈利性用途**时,必须取得项目开发组(本仓库 Collaborators 和 Owner)的**书面授权**.
66 |
67 |
68 | ## 致谢
69 |
70 | 感谢 [NapCat](https://github.com/NapNeko/NapCatQQ) 提供底层接口 | [IppClub](https://github.com/IppClub) 的宣传支持 | [Fcatbot](https://github.com/Fish-LP/Fcatbot) 提供代码和灵感.
71 |
72 | 感谢 [扶摇互联](https://www.fyyun.net/) | [林枫云](https://www.dkdun.cn/) 提供服务器支持.
73 |
74 | ## 参与贡献
75 | 欢迎通过 Issue 或 Pull Request 参与项目开发!请先阅读 [贡献指南](CONTRIBUTING.md)。
76 |
77 | 如果你在使用过程中遇到任何问题,欢迎在 [GitHub Issues](https://github.com/liyihao1110/ncatbot/issues) 中反馈。感谢你的支持!
78 |
79 |
80 |
81 |
82 | ## Star History
83 |
84 | [](https://www.star-history.com/#liyihao1110/ncatbot&Date)
85 |
86 | ## 贡献者们
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
--------------------------------------------------------------------------------
/assets/background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/liyihao1110/ncatbot/eebf29d8d1728fad415800b5c6ffbc747c235adb/assets/background.png
--------------------------------------------------------------------------------
/config.yaml:
--------------------------------------------------------------------------------
1 | # NcatBot 配置文件
2 |
3 | # 基本配置
4 | root: "123456" # root 账号
5 | bt_uin: "123456" # bot 账号
6 | ws_uri: "ws://localhost:3001" # ws 地址
7 | webui_uri: "http://localhost:6099" # webui 地址
8 | webui_token: "napcat" # webui 令牌
9 | ws_token: "" # ws_uri 令牌
10 | ws_listen_ip: "localhost" # ws 监听 ip, 默认只监听本机
11 | remote_mode: false # 是否远程模式, 即 NapCat 服务不在本机运行
12 |
13 | # 更新检查
14 | check_napcat_update: false # 是否检查 napcat 更新
15 | check_ncatbot_update: true # 是否检查 ncatbot 更新
16 |
17 | # 开发者调试
18 | debug: false # 是否开启调试模式
19 | skip_ncatbot_install_check: false # 是否跳过 napcat 安装检查
20 | skip_plugin_load: false # 是否跳过插件加载
21 |
22 | # 插件加载控制
23 | # 白名单和黑名单互斥,只能设置其中一个
24 | # 如果都不设置,则加载所有插件
25 | # plugin_whitelist: # 插件白名单,为空表示不启用白名单
26 | # - "plugin1"
27 | # - "plugin2"
28 | # plugin_blacklist: # 插件黑名单,为空表示不启用黑名单
29 | # - "plugin3"
30 | # - "plugin4"
31 |
32 | # NapCat 行为
33 | stop_napcat: false # NcatBot 下线时是否停止 NapCat
34 | enable_webui_interaction: true # 是否允许 NcatBot 与 NapCat webui 交互
35 | report_self_message: false # 是否报告 Bot 自己的消息
36 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | [文档](https://ncatbot.xyz)
2 |
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | from ncatbot.core import BotClient, GroupMessage, PrivateMessage
2 | from ncatbot.utils import config, get_log
3 |
4 | _log = get_log()
5 |
6 | config.set_bot_uin("123456") # 设置 bot qq 号 (必填)
7 | config.set_root("123456") # 设置 bot 超级管理员账号 (建议填写)
8 | config.set_ws_uri("ws://localhost:3001") # 设置 napcat websocket server 地址
9 | config.set_ws_token("") # 设置 token (websocket 的 token)
10 | config.set_webui_uri("http://localhost:6099") # 设置 napcat webui 地址
11 | config.set_webui_token("napcat") # 设置 token (webui 的 token)
12 |
13 | bot = BotClient()
14 |
15 |
16 | @bot.group_event()
17 | async def on_group_message(msg: GroupMessage):
18 | _log.info(msg)
19 | if msg.raw_message == "测试":
20 | await msg.reply(text="NcatBot 测试成功喵~")
21 |
22 |
23 | @bot.private_event()
24 | def on_private_message(msg: PrivateMessage):
25 | _log.info(msg)
26 | if msg.raw_message == "测试":
27 | bot.api.post_private_msg_sync(msg.user_id, text="NcatBot 测试成功喵~")
28 |
29 |
30 | if __name__ == "__main__":
31 | bot.run()
32 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools>=61.0"]
3 | build-backend = "setuptools.build_meta"
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | # This file is @generated by PDM.
2 | # Please do not edit it manually.
3 |
4 | anyio>=4.8.0
5 | appdirs>=1.4.4
6 | certifi>=2025.1.31
7 | charset-normalizer>=3.4.1
8 | colorama>=0.4.6; platform_system == "Windows"
9 | exceptiongroup>=1.2.2; python_version < "3.11"
10 | h11>=0.14.0
11 | httpcore>=1.0.7
12 | httpx>=0.28.1
13 | idna>=3.10
14 | importlib-metadata>=8.6.1
15 | markdown>=3.7
16 | psutil>=6.1.1
17 | pyee>=11.1.1
18 | pygments>=2.19.1
19 | pyppeteer>=2.0.0
20 | pyyaml>=6.0.2
21 | requests>=2.32.3
22 | sniffio>=1.3.1
23 | tqdm>=4.67.1
24 | typing-extensions>=4.12.2
25 | urllib3>=1.26.20
26 | websockets>=10.4
27 | zipp>=3.21.0
28 | packaging>=24.0
29 | qrcode>=8.0
30 | gitpython>=3.1.44
31 | schedule>=1.2.2
32 | Deprecated>=1.2.18
33 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | """
2 | Python 包配置主文件 (基于 setuptools)
3 |
4 | 文件结构要求:
5 | .
6 | ├── setup.py
7 | ├── pyproject.toml
8 | ├── requirements.txt
9 | ├── src/
10 | │ └── PACKAGE_NAME/ # 你的包目录
11 | │ ├── __init__.py
12 | │ └── ... # 其他代码文件
13 | └── README.md # 长描述内容来源
14 | """
15 |
16 | import os
17 | import re
18 | from pathlib import Path
19 | from setuptools import setup, find_packages
20 |
21 | # 基础配置 --------------------------------------------------------------
22 | PACKAGE_NAME = "ncatbot" # 更改为你的包名 (PyPI显示的名称)
23 | AUTHOR = "木子" # 作者/维护者姓名
24 | EMAIL = "lyh_02@qq.com" # 联系邮箱
25 | URL = "https://github.com/liyihao1110/ncatbot" # 项目主页
26 | DESCRIPTION = "NcatBot,基于 协议 的 QQ 机器人 Python SDK,快速开发,轻松部署。" # 简短描述
27 | LICENSE = "MIT 修改版" # 许可证类型 (MIT/Apache 2.0/GPL等)
28 |
29 | # 版本控制配置 -----------------------------------------------------------
30 | def get_version():
31 | """动态获取包版本号 (从__init__.py读取)"""
32 | version_file = os.path.join("src", PACKAGE_NAME, "__init__.py")
33 | with open(version_file, encoding="utf-8") as f:
34 | version_match = re.search(
35 | r"^__version__\s*=\s*['\"]([^'\"]*)['\"]", f.read(), re.M
36 | )
37 | if version_match:
38 | return version_match.group(1)
39 | raise RuntimeError("无法找到版本信息!请检查 {}".format(version_file))
40 |
41 | # 依赖管理 --------------------------------------------------------------
42 | def parse_requirements(filename="requirements.txt"):
43 | """加载依赖项列表"""
44 | requirements = []
45 | with open(filename, encoding="utf-8") as f:
46 | for line in f:
47 | line = line.strip()
48 | if line and not line.startswith("#"):
49 | requirements.append(line)
50 | return requirements
51 |
52 | # 长描述配置 (从README.md读取) --------------------------------------------
53 | def get_long_description():
54 | """生成PyPI长描述"""
55 | readme_path = Path(__file__).parent / "README.md"
56 | with open(readme_path, encoding="utf-8") as f:
57 | return f.read()
58 |
59 | # 主配置 ----------------------------------------------------------------
60 | setup(
61 | # 基础元数据
62 | name=PACKAGE_NAME,
63 | version=get_version(),
64 | author=AUTHOR,
65 | author_email=EMAIL,
66 | url=URL,
67 | description=DESCRIPTION,
68 | long_description=get_long_description(),
69 | long_description_content_type="text/markdown", # 如果使用.md文件
70 | license=LICENSE,
71 |
72 | # 包结构配置
73 | package_dir={"": "src"}, # 指定包根目录
74 | packages=find_packages(
75 | where="src", # 在src目录下查找
76 | exclude=["tests", "*.tests", "*.tests.*", "tests.*"] # 排除测试代码
77 | ),
78 | include_package_data=True, # 包含MANIFEST.in指定的数据文件
79 |
80 | # 依赖管理
81 | install_requires=parse_requirements(), # 生产环境依赖
82 | extras_require={ # 可选依赖组
83 | "dev": [ # 开发依赖 (pip install .[dev])
84 | "pytest>=6.0",
85 | "black>=22.3",
86 | "mypy>=0.910",
87 | ],
88 | "docs": [ # 文档生成依赖
89 | "sphinx>=4.0",
90 | "sphinx-rtd-theme>=1.0",
91 | ]
92 | },
93 |
94 | # 分类信息 (PyPI搜索优化)
95 | classifiers=[
96 | # 完整列表:https://pypi.org/classifiers/
97 | "Development Status :: 5 - Production/Stable",
98 | "Intended Audience :: Developers",
99 | "License :: OSI Approved :: MIT License",
100 | "Programming Language :: Python :: 3",
101 | "Programming Language :: Python :: 3.8",
102 | "Programming Language :: Python :: 3.9",
103 | "Programming Language :: Python :: 3.10",
104 | "Programming Language :: Python :: 3.11",
105 | "Operating System :: OS Independent",
106 | ],
107 |
108 | # 入口点配置 (CLI工具)
109 | entry_points={
110 | "console_scripts": [ # 创建命令行工具
111 | "ncatbot=ncatbot.cli.main:main", # 示例配置
112 | ],
113 | },
114 |
115 | # 高级配置
116 | python_requires=">=3.8", # 最低Python版本要求
117 | zip_safe=False, # 设为False以确保能正确解压包文件
118 | project_urls={ # 扩展项目链接 (PyPI右侧显示)
119 | "Documentation": "https://docs.ncatbot.xyz/",
120 | "Source Code": URL,
121 | "Bug Tracker": f"{URL}/issues",
122 | },
123 | )
--------------------------------------------------------------------------------
/src/deploy/resource.rc:
--------------------------------------------------------------------------------
1 | ZIPFILE RCDATA "package.zip"
2 |
--------------------------------------------------------------------------------
/src/ncatbot/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = "3.8.6"
2 |
--------------------------------------------------------------------------------
/src/ncatbot/adapter/__init__.py:
--------------------------------------------------------------------------------
1 | from ncatbot.adapter.nc import launch_napcat_service
2 | from ncatbot.adapter.net import Route, Websocket, check_websocket
3 |
4 | __all__ = ["Websocket", "launch_napcat_service", "Route", "check_websocket"]
5 |
--------------------------------------------------------------------------------
/src/ncatbot/adapter/nc/__init__.py:
--------------------------------------------------------------------------------
1 | # NapCat 环境适配
2 |
3 | from ncatbot.adapter.nc.launcher import launch_napcat_service
4 |
5 | __all__ = ["launch_napcat_service"]
6 |
--------------------------------------------------------------------------------
/src/ncatbot/adapter/nc/install.py:
--------------------------------------------------------------------------------
1 | # 安装 napcat
2 | import json
3 | import os
4 | import platform
5 | import subprocess
6 | import sys
7 |
8 | from requests import get
9 |
10 | from ncatbot.utils import (
11 | INSTALL_SCRIPT_URL,
12 | LINUX_NAPCAT_DIR,
13 | WINDOWS_NAPCAT_DIR,
14 | config,
15 | download_file,
16 | get_log,
17 | get_proxy_url,
18 | unzip_file,
19 | )
20 | from ncatbot.utils.env_checker import (
21 | check_linux_permissions,
22 | check_self_package_version,
23 | )
24 |
25 | LOG = get_log("adapter.nc.install")
26 |
27 |
28 | def get_napcat_dir():
29 | """获取 napcat 安装目录"""
30 | if platform.system() == "Windows":
31 | return WINDOWS_NAPCAT_DIR
32 | elif platform.system() == "Linux":
33 | return LINUX_NAPCAT_DIR
34 | else:
35 | LOG.warning("不支持的系统类型: %s, 可能需要自行适配", platform.system())
36 | LOG.warning("默认使用工作目录下 napcat/ 目录")
37 | return os.path.join(os.getcwd(), "napcat")
38 |
39 |
40 | def get_napcat_version():
41 | """从GitHub获取 napcat 版本号"""
42 | github_proxy_url = get_proxy_url()
43 | version_url = f"{github_proxy_url}https://raw.githubusercontent.com/NapNeko/NapCatQQ/main/package.json"
44 | version_response = get(version_url)
45 | if version_response.status_code == 200:
46 | version = version_response.json()["version"]
47 | LOG.debug(f"获取最新版本信息成功, 版本号: {version}")
48 | return version
49 | LOG.info(f"获取最新版本信息失败, http 状态码: {version_response.status_code}")
50 | return None
51 |
52 |
53 | def check_windows_qq_version():
54 | pass
55 |
56 |
57 | def install_napcat_windows(type: str):
58 | # TODO 检查 Windows QQ 的版本是否符合要求
59 | """
60 | Windows系统下载安装napcat
61 |
62 | Args:
63 | type: 安装类型, 可选值为 "install" 或 "update"
64 | Returns:
65 | bool: 安装成功返回 True, 否则返回 False
66 | """
67 | if type == "install":
68 | LOG.info("未找到 napcat ,是否要自动安装?")
69 | if input("输入 Y 继续安装或 N 退出: ").strip().lower() not in ["y", "yes"]:
70 | return False
71 | elif type == "update":
72 | if input("输入 Y 继续安装或 N 跳过更新: ").strip().lower() not in ["y", "yes"]:
73 | return False
74 |
75 | try:
76 | version = get_napcat_version()
77 | github_proxy_url = get_proxy_url()
78 | download_url = f"{github_proxy_url}https://github.com/NapNeko/NapCatQQ/releases/download/v{version}/NapCat.Shell.zip"
79 | if not version:
80 | return False
81 |
82 | # 下载并解压 napcat 客户端
83 | LOG.info(f"下载链接为 {download_url}...")
84 | LOG.info("正在下载 napcat 客户端, 请稍等...")
85 | download_file(download_url, f"{WINDOWS_NAPCAT_DIR}.zip")
86 | unzip_file(f"{WINDOWS_NAPCAT_DIR}.zip", WINDOWS_NAPCAT_DIR, True)
87 | check_windows_qq_version()
88 | return True
89 | except Exception as e:
90 | LOG.error("安装失败:", e)
91 | return False
92 |
93 |
94 | def install_napcat_linux(type: str):
95 | """Linux 系统下载安装 napcat 和 cli
96 |
97 | Args:
98 | type: 安装类型, 可选值为 "install" 或 "update"
99 |
100 | Returns:
101 | bool: 安装成功返回True, 否则返回False
102 | """
103 | if type == "install":
104 | LOG.warning("未找到 napcat ,是否要使用一键安装脚本安装?")
105 | if input("输入 Y 继续安装或 N 退出: ").strip().lower() not in ["y", "yes"]:
106 | return False
107 | elif type == "update":
108 | LOG.info("是否要更新 napcat 客户端?")
109 | if input("输入 Y 继续安装或 N 跳过更新: ").strip().lower() not in ["y", "yes"]:
110 | return False
111 |
112 | if check_linux_permissions("root") != "root":
113 | LOG.error("请使用 root 权限运行 ncatbot")
114 | return False
115 |
116 | try:
117 | LOG.info("正在下载一键安装脚本...")
118 | process = subprocess.Popen(
119 | f"sudo bash -c 'curl {INSTALL_SCRIPT_URL} -o install && yes | bash install'",
120 | shell=True,
121 | stdin=sys.stdin,
122 | stdout=sys.stdout,
123 | stderr=sys.stderr,
124 | )
125 | process.wait()
126 | if process.returncode == 0:
127 | LOG.info("napcat 客户端安装完成。")
128 | return True
129 | else:
130 | LOG.error("执行一键安装脚本失败, 请检查命令行输出")
131 | raise Exception("执行一键安装脚本失败")
132 | except Exception as e:
133 | LOG.error("执行一键安装脚本失败,错误信息:", e)
134 | raise e
135 |
136 |
137 | def install_napcat(type: str):
138 | """
139 | 下载和安装 napcat 客户端
140 |
141 | Args:
142 | type: 安装类型, 可选值为 "install" 或 "update"
143 |
144 | Returns:
145 | bool: 安装成功返回 True, 否则返回 False
146 | """
147 | if platform.system() == "Windows":
148 | return install_napcat_windows(type)
149 | elif platform.system() == "Linux":
150 | return install_napcat_linux(type)
151 | return False
152 |
153 |
154 | def check_permission():
155 | if check_linux_permissions("root") != "root":
156 | LOG.error("请使用 root 权限运行 ncatbot")
157 | raise Exception("请使用 root 权限运行 ncatbot")
158 |
159 |
160 | def check_ncatbot_installation():
161 | """检查 ncatbot 版本, 以及是否正确安装"""
162 | if not config.skip_ncatbot_install_check:
163 | # 检查版本和安装方式
164 | version_ok = check_self_package_version()
165 | if not version_ok:
166 | raise Exception("请使用 pip 安装 ncatbot")
167 | else:
168 | LOG.info("调试模式, 跳过 ncatbot 安装检查")
169 |
170 |
171 | def is_napcat_installed():
172 | napcat_dir = get_napcat_dir()
173 | return os.path.exists(napcat_dir)
174 |
175 |
176 | def install_or_update_napcat():
177 | """安装 napcat 或者检查 napcat 更新并重新安装"""
178 | if not is_napcat_installed():
179 | if not install_napcat("install"):
180 | LOG.error("NapCat 安装失败")
181 | raise Exception("NapCat 安装失败")
182 | elif config.check_napcat_update:
183 | # 检查 napcat 版本更新
184 | with open(
185 | os.path.join(get_napcat_dir(), "package.json"), "r", encoding="utf-8"
186 | ) as f:
187 | version = json.load(f)["version"]
188 | LOG.info(f"当前 napcat 版本: {version}, 正在检查更新...")
189 |
190 | github_version = get_napcat_version()
191 | if version != github_version:
192 | LOG.info(f"发现新版本: {github_version}, 是否要更新 napcat 客户端?")
193 | if not install_napcat("update"):
194 | LOG.info(f"跳过 napcat {version} 更新")
195 | else:
196 | LOG.info("当前 napcat 已是最新版本")
197 |
--------------------------------------------------------------------------------
/src/ncatbot/adapter/nc/launcher.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import platform
3 | import time
4 |
5 | from ncatbot.adapter.nc.install import install_or_update_napcat
6 | from ncatbot.adapter.nc.login import BotUINError, login, online_qq_is_bot
7 | from ncatbot.adapter.nc.start import start_napcat, stop_napcat
8 | from ncatbot.adapter.net import check_websocket
9 | from ncatbot.utils import config, get_log
10 |
11 | LOG = get_log("adapter.nc.launcher")
12 |
13 |
14 | def napcat_service_ok():
15 | return asyncio.run(check_websocket(config.ws_uri))
16 |
17 |
18 | def connect_napcat():
19 | """启动并尝试连接 napcat 直到成功"""
20 |
21 | def info_windows():
22 | LOG.info("===请允许终端修改计算机, 并在弹出的另一个终端扫码登录===")
23 | LOG.info(f"===确认 bot QQ 号 {config.bt_uin} 是否正确===")
24 |
25 | def info(quit=False):
26 | LOG.info("连接 napcat websocket 服务器超时, 请检查以下内容:")
27 | if platform.system() == "Windows":
28 | info_windows()
29 | elif platform.system() == "Linux":
30 | pass
31 | else:
32 | pass
33 | LOG.info(f"===websocket url 是否正确: {config.ws_uri}===")
34 | if quit:
35 | raise Exception("连接超时")
36 |
37 | MAX_TIME_EXPIRE = time.time() + 60
38 | # INFO_TIME_EXPIRE = time.time() + 20
39 | LOG.info("正在连接 napcat websocket 服务器...")
40 | while not napcat_service_ok():
41 | time.sleep(0.05)
42 | if time.time() > MAX_TIME_EXPIRE:
43 | info(True)
44 |
45 | LOG.info("连接 napcat websocket 服务器成功!")
46 |
47 |
48 | def ncatbot_service_remote_start():
49 | """尝试以远程模式连接到 NapCat 服务"""
50 | if napcat_service_ok():
51 | LOG.info(f"napcat 服务器 {config.ws_uri} 在线, 连接中...")
52 | if not config.enable_webui_interaction: # 跳过基于 WebUI 交互的检查
53 | LOG.warning(
54 | f"跳过基于 WebUI 交互的检查, 请自行确保 NapCat 已经登录了正确的 QQ {config.bt_uin}"
55 | )
56 | return True
57 | if not online_qq_is_bot():
58 | if not config.remote_mode:
59 | # 如果账号不对并且是本地模式, 则停止 NapCat 服务后重新启动
60 | stop_napcat()
61 | return False
62 | else:
63 | LOG.error(
64 | "远端的 NapCat 服务 QQ 账号信息与本地 bot_uin 不匹配, 请检查远端 NapCat 配置"
65 | )
66 | raise Exception("账号错误")
67 | return True
68 | elif config.remote_mode:
69 | LOG.error("远程模式已经启用, 无法到连接远程 NapCat 服务器, 将自动退出")
70 | LOG.error(f'服务器参数: uri="{config.ws_uri}", token="{config.ws_token}"')
71 | LOG.info(
72 | """可能的错误原因:
73 | 1. napcat webui 中服务器类型错误, 应该为 "WebSocket 服务器", 而非 "WebSocket 客户端"
74 | 2. napcat webui 中服务器配置了但没有启用, 请确保勾选了启用服务器"
75 | 3. napcat webui 中服务器 host 没有设置为监听全部地址, 应该将 host 改为 0.0.0.0
76 | 4. 检查以上配置后, 在本机尝试连接远程的 webui ,使用 error 信息中的的服务器参数, \"接口调试\"选择\"WebSocket\"尝试连接.
77 | 5. webui 中连接成功后再尝试启动 ncatbot
78 | """
79 | )
80 | raise Exception("服务器离线")
81 |
82 | LOG.info("NapCat 服务器离线, 启动本地 NapCat 服务中...")
83 | return False
84 |
85 |
86 | def launch_napcat_service(*args, **kwargs):
87 | """
88 | 启动配置中 QQ 对应的 NapCat 服务, 保证 WebSocket 接口可用, 包括以下任务:
89 |
90 | 1. 安装或者更新 NapCat
91 | 2. 配置 NapCat
92 | 3. 启动 NapCat 服务
93 | 4. NapCat 登录 QQ
94 | 5. 连接 NapCat 服务
95 | """
96 | if ncatbot_service_remote_start():
97 | return True
98 |
99 | install_or_update_napcat()
100 | start_napcat() # 配置、启动 NapCat 服务
101 | try:
102 | if config.enable_webui_interaction: # 如果允许 webui 交互, 则做登录引导
103 | login(reset=True) # NapCat 登录 QQ
104 | except BotUINError: # 我也不知道, 后面可能会把这玩意删了
105 | LOG.error("我觉得这个错误不该有, 如果遇到了请联系开发者")
106 | return False
107 | # stop_napcat()
108 | # launch_napcat_service(*args, **kwargs)
109 | connect_napcat() # 连接 NapCat 服务
110 | return True
111 |
--------------------------------------------------------------------------------
/src/ncatbot/adapter/nc/login.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | import platform
3 | import time
4 | import traceback
5 |
6 | import qrcode
7 | import requests
8 | from requests.exceptions import ConnectionError
9 | from urllib3.exceptions import NewConnectionError
10 |
11 | from ncatbot.utils import NAPCAT_WEBUI_SALT, config, get_log
12 |
13 | LOG = get_log("adapter.nc.login")
14 | main_handler = None
15 |
16 |
17 | def show_qrcode(qrcode_url):
18 | LOG.info(f"二维码指代的 url 地址: {qrcode_url}")
19 | qr = qrcode.QRCode()
20 | qr.add_data(qrcode_url)
21 | qr.print_ascii(invert=True)
22 |
23 |
24 | def get_handler(reset=False):
25 | global main_handler
26 | if main_handler is None or reset:
27 | main_handler = LoginHandler()
28 | return main_handler
29 |
30 |
31 | class BotUINError(Exception):
32 | def __init__(self, uin):
33 | super().__init__(
34 | f"BotUINError: 已经在线的 QQ {uin} 不是设置的 bot 账号 {config.bt_uin}"
35 | )
36 |
37 |
38 | class LoginError(Exception):
39 | def __init__(self):
40 | super().__init__("登录失败")
41 |
42 |
43 | class LoginHandler:
44 | # 登录处理器
45 | def __init__(self):
46 | MAX_TIME_EXPIER = time.time() + 15
47 | self.base_uri = config.webui_uri
48 | while True:
49 | try:
50 | time.sleep(0.02)
51 | hashed_token = hashlib.sha256(
52 | f"{config.webui_token}.{NAPCAT_WEBUI_SALT}".encode()
53 | ).hexdigest()
54 | content = requests.post(
55 | self.base_uri + "/api/auth/login",
56 | json={"hash": hashed_token},
57 | timeout=5,
58 | ).json()
59 | time.sleep(0.02)
60 | self.header = {
61 | "Authorization": "Bearer " + content["data"]["Credential"],
62 | }
63 | LOG.debug("成功连接到 WEBUI")
64 | return
65 | except TimeoutError:
66 | LOG.error("连接 WebUI 失败, 以下给出几种可能的解决方案:")
67 | if platform.system() == "Windows":
68 | LOG.info(
69 | "检查 Windows 安全中心, 查看是否有拦截了 NapCat 启动程序的日志。"
70 | "如果你修改了natcat的网页端开放端口(不是websocket),请修改启动参数:webui_uri='ws://xxxxx:xxxx'"
71 | )
72 | LOG.info("开放防火墙的 WebUI 端口 (默认 6099)")
73 | raise Exception("连接 WebUI 失败")
74 | except KeyError:
75 | if time.time() > MAX_TIME_EXPIER:
76 | # 尝试老版本 NapCat 登录
77 | try:
78 | content = requests.post(
79 | self.base_uri + "/api/auth/login",
80 | json={"token": config.webui_token},
81 | timeout=5,
82 | ).json()
83 | time.sleep(0.2)
84 | self.header = {
85 | "Authorization": "Bearer " + content["data"]["Credential"],
86 | }
87 | LOG.debug("成功连接到 WEBUI")
88 | return
89 | except Exception:
90 | LOG.error(
91 | "授权操作超时, 连接 WebUI 成功但无法获取授权信息, 可以使用 bot.run(enable_webui_interaction=False) 跳过鉴权"
92 | )
93 | raise Exception("连接 WebUI 失败")
94 | except (ConnectionError, NewConnectionError):
95 | if platform.system() == "Windows":
96 | if time.time() > MAX_TIME_EXPIER:
97 | LOG.error("授权操作超时")
98 | LOG.info(
99 | "请检查 Windows 安全中心, 查看是否有拦截了 NapCat 启动程序的日志"
100 | )
101 | raise Exception("连接 WebUI 失败")
102 | elif platform.system() == "Linux":
103 | if time.time() > MAX_TIME_EXPIER:
104 | LOG.error(
105 | "错误 LoginHandler.__init__ ConnectionError, 请保留日志并联系开发团队"
106 | )
107 | raise Exception("连接 WebUI 失败")
108 | else:
109 | LOG.error("不支持的操作系统, 请自行检查并适配")
110 | except Exception as e:
111 | if time.time() > MAX_TIME_EXPIER:
112 | LOG.error(
113 | f"未知错误 LoginHandler.__init__ {e}, 请保留日志并联系开发团队"
114 | )
115 | LOG.info(traceback.format_exc())
116 | raise Exception("连接 WebUI 失败")
117 |
118 | def get_quick_login(self):
119 | # 获取快速登录列表
120 | try:
121 | data = requests.post(
122 | self.base_uri + "/api/QQLogin/GetQuickLoginListNew",
123 | headers=self.header,
124 | timeout=5,
125 | ).json()["data"]
126 | list = [rec["uin"] for rec in data if rec["isQuickLogin"]]
127 | LOG.info("快速登录列表: " + str(list))
128 | return list
129 | except TimeoutError:
130 | LOG.warning("获取快速登录列表失败, 禁用快速登录")
131 | return []
132 |
133 | def check_login_status(self):
134 | # 检查 NapCat 是否登录
135 | try:
136 | return requests.post(
137 | self.base_uri + "/api/QQLogin/CheckLoginStatus",
138 | headers=self.header,
139 | timeout=5,
140 | ).json()["data"]["isLogin"]
141 | except TimeoutError:
142 | LOG.warning("检查登录状态超时, 默认未登录")
143 | return False
144 |
145 | def get_online_qq(self):
146 | """获取当前在线的 QQ 号, 如果无 QQ 在线, 则返回 None"""
147 | for _ in range(5):
148 | try:
149 | data = requests.post(
150 | self.base_uri + "/api/QQLogin/GetQQLoginInfo",
151 | headers=self.header,
152 | timeout=5,
153 | ).json()["data"]
154 | offline = not data.get("online", False)
155 | uin = data.get("uin", None)
156 | if not offline:
157 | return str(uin)
158 | except TimeoutError:
159 | LOG.warning("检查在线状态超时, 默认不在线")
160 | return False
161 | time.sleep(0.05)
162 | return None
163 |
164 | def check_online_status(self):
165 | # 检查 QQ 是否在线
166 | online_qq = self.get_online_qq()
167 | if online_qq is None:
168 | return False
169 | elif is_qq_equal(online_qq, config.bt_uin):
170 | return True
171 | else:
172 | raise BotUINError(online_qq)
173 |
174 | def send_quick_login_request(self):
175 | # 发送快速登录请求
176 | LOG.info("正在发送快速登录请求...")
177 | try:
178 | status = requests.post(
179 | self.base_uri + "/api/QQLogin/SetQuickLogin",
180 | headers=self.header,
181 | json={"uin": str(config.bt_uin)},
182 | timeout=5,
183 | ).json().get("message", "failed") in ["success", "QQ Is Logined"]
184 | return status
185 | except TimeoutError:
186 | LOG.warning("快速登录失败, 进行其它登录尝试")
187 | pass
188 |
189 | def reqeust_qrcode_url(self):
190 | EXPIRE = time.time() + 15
191 | while time.time() < EXPIRE:
192 | time.sleep(0.2)
193 | try:
194 | data = requests.post(
195 | self.base_uri + "/api/QQLogin/CheckLoginStatus",
196 | headers=self.header,
197 | timeout=5,
198 | ).json()["data"]
199 | val = data["qrcodeurl"]
200 | if val is not None and val != "":
201 | return val
202 | except TimeoutError:
203 | pass
204 |
205 | LOG.error(
206 | f"获取二维码失败, 请执行 `napcat stop; napcat start {config.bt_uin}` 后重启引导程序."
207 | )
208 | raise Exception("获取二维码失败")
209 |
210 | def login(self):
211 | def _login():
212 | uin = str(config.bt_uin)
213 | if uin in self.get_quick_login() and self.send_quick_login_request():
214 | return True
215 | else:
216 | LOG.warning("终端二维码登录为试验性功能, 如果失败请在 webui 登录")
217 | if True:
218 | try:
219 | show_qrcode(self.reqeust_qrcode_url())
220 | TIMEOUT_EXPIRE = time.time() + 60
221 | WARN_EXPIRE = time.time() + 30
222 | while not self.check_online_status():
223 | if time.time() > TIMEOUT_EXPIRE:
224 | LOG.error(
225 | "登录超时, 请重新操作, 如果无法扫码, 请在 webui 登录"
226 | )
227 | raise TimeoutError("登录超时")
228 | if time.time() > WARN_EXPIRE:
229 | LOG.warning("二维码即将失效, 请尽快扫码登录")
230 | WARN_EXPIRE += 60
231 | LOG.info("登录成功")
232 | except Exception as e:
233 | LOG.error(f"生成 ASCII 二维码时出错: {e}")
234 | raise Exception("登录失败")
235 | else:
236 | LOG.error("未找到二维码图片, 请在 webui 尝试扫码登录")
237 | raise Exception("登录失败")
238 |
239 | if not self.check_online_status():
240 | LOG.info("未登录 QQ, 尝试登录")
241 | return _login()
242 | LOG.info("napcat 已登录成功")
243 | return True
244 |
245 |
246 | def login(reset=False):
247 | if main_handler is None and platform.system() == "Windows":
248 | LOG.info("即将弹出权限请求, 请允许")
249 |
250 | get_handler(reset=reset).login()
251 |
252 |
253 | def is_qq_equal(uin, other):
254 | """判断 QQ 号是否相等"""
255 | return str(uin) == str(other)
256 |
257 |
258 | def online_qq_is_bot():
259 | handler = get_handler(reset=True)
260 | online_qq = handler.get_online_qq()
261 |
262 | if online_qq is not None and not is_qq_equal(online_qq, config.bt_uin):
263 | LOG.warning(
264 | f"当前在线的 QQ 号为: {online_qq}, 而配置的 bot QQ 号为: {config.bt_uin}"
265 | )
266 | return online_qq is None or is_qq_equal(online_qq, config.bt_uin)
267 |
268 |
269 | if __name__ == "__main__":
270 | pass
271 |
--------------------------------------------------------------------------------
/src/ncatbot/adapter/nc/start.py:
--------------------------------------------------------------------------------
1 | # 配置和启动本地 NapCat 服务
2 |
3 | import atexit
4 | import json
5 | import os
6 | import platform
7 | import shutil
8 | import subprocess
9 | import time
10 | from urllib.parse import urlparse
11 |
12 | from ncatbot.adapter.nc.install import check_permission, get_napcat_dir
13 | from ncatbot.utils import WINDOWS_NAPCAT_DIR, config, get_log
14 |
15 | LOG = get_log("adapter.nc.start")
16 |
17 |
18 | def is_napcat_running_linux():
19 | process = subprocess.Popen(
20 | ["bash", "napcat", "status", str(config.bt_uin)], stdout=subprocess.PIPE
21 | )
22 | process.wait()
23 | output = process.stdout.read().decode(encoding="utf-8")
24 | return output.find(str(config.bt_uin)) != -1
25 |
26 |
27 | def start_napcat_linux():
28 | """保证 NapCat 已经安装的前提下, 启动 NapCat 服务"""
29 | # Linux启动逻辑
30 | try:
31 | # 启动并注册清理函数
32 | if os.path.exists("napcat"):
33 | LOG.error(
34 | "工作目录下存在 napcat 目录, Linux 启动时不应该在工作目录下存在 napcat 目录"
35 | )
36 | raise FileExistsError("工作目录下存在 napcat 目录")
37 | process = subprocess.Popen(
38 | ["sudo", "bash", "napcat", "start", str(config.bt_uin)],
39 | stdout=subprocess.PIPE,
40 | )
41 | process.wait()
42 | if process.returncode != 0:
43 | LOG.error("启动 napcat 失败,请检查日志,napcat cli 可能没有被正确安装")
44 | raise FileNotFoundError("napcat cli 可能没有被正确安装")
45 | if config.stop_napcat:
46 | atexit.register(lambda: stop_napcat_linux(config.bt_uin))
47 | except Exception as e:
48 | LOG.error(f"pgrep 命令执行失败, 无法判断 QQ 是否启动, 请检查错误: {e}")
49 | raise e
50 |
51 | if not is_napcat_running_linux():
52 | LOG.error("napcat 启动失败,请检查日志")
53 | raise Exception("napcat 启动失败")
54 | else:
55 | time.sleep(0.5)
56 | LOG.info("napcat 启动成功")
57 |
58 |
59 | def stop_napcat_linux():
60 | process = subprocess.Popen(["bash", "napcat", "stop"], stdout=subprocess.PIPE)
61 | process.wait()
62 |
63 |
64 | def is_napcat_running_windows():
65 | """暂未实现逻辑"""
66 | return True
67 |
68 |
69 | def start_napcat_windows():
70 | # Windows启动逻辑
71 | def get_launcher_name():
72 | """获取对应系统的launcher名称"""
73 | is_server = "Server" in platform.release()
74 | if is_server:
75 | version = platform.release()
76 | if "2016" in version:
77 | LOG.info("当前操作系统为: Windows Server 2016")
78 | return "launcher-win10.bat"
79 | elif "2019" in version:
80 | LOG.info("当前操作系统为: Windows Server 2019")
81 | return "launcher-win10.bat"
82 | elif "2022" in version:
83 | LOG.info("当前操作系统为: Windows Server 2022")
84 | return "launcher-win10.bat"
85 | elif "2025" in version:
86 | LOG.info("当前操作系统为:Windows Server 2025")
87 | return "launcher.bat"
88 | else:
89 | LOG.error(
90 | f"不支持的的 Windows Server 版本: {version},将按照 Windows 10 内核启动"
91 | )
92 | return "launcher-win10.bat"
93 |
94 | if platform.release() == "10":
95 | LOG.info("当前操作系统为: Windows 10")
96 | return "launcher-win10.bat"
97 |
98 | if platform.release() == "11":
99 | LOG.info("当前操作系统为: Windows 11")
100 | return "launcher.bat"
101 |
102 | return "launcher-win10.bat"
103 |
104 | launcher = get_launcher_name()
105 | napcat_dir = os.path.abspath(WINDOWS_NAPCAT_DIR)
106 | launcher_path = os.path.join(napcat_dir, launcher)
107 |
108 | if not os.path.exists(launcher_path):
109 | LOG.error(f"找不到启动文件: {launcher_path}")
110 | raise FileNotFoundError(f"找不到启动文件: {launcher_path}")
111 |
112 | LOG.info(f"正在启动QQ, 启动器路径: {launcher_path}")
113 | subprocess.Popen(
114 | f'"{launcher_path}" {config.bt_uin}',
115 | shell=True,
116 | cwd=napcat_dir,
117 | stdout=subprocess.DEVNULL,
118 | stderr=subprocess.DEVNULL,
119 | )
120 |
121 |
122 | def stop_napcat_windows():
123 | """停止 NapCat 服务: 在 Windows 上强制结束 QQ.exe 进程"""
124 | try:
125 | # 使用 taskkill 强制结束 QQ.exe 进程
126 | subprocess.run(["taskkill", "/F", "/IM", "QQ.exe"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
127 | LOG.info("已成功停止 QQ.exe 进程(NapCat 服务)")
128 | except subprocess.CalledProcessError as e:
129 | # 如果 taskkill 命令执行失败,记录错误并抛出异常
130 | stdout = e.stdout.decode(errors='ignore') if e.stdout else ''
131 | stderr = e.stderr.decode(errors='ignore') if e.stderr else ''
132 | LOG.error(f"停止 NapCat 服务失败: {stderr or stdout}")
133 | raise RuntimeError(f"无法停止 QQ.exe 进程: {stderr or stdout}")
134 |
135 |
136 | def is_napcat_running():
137 | if platform.system() == "Linux":
138 | return is_napcat_running_linux()
139 | elif platform.system() == "Windows":
140 | return is_napcat_running_windows()
141 | else:
142 | LOG.warning("不提供官方支持的系统, 默认 NapCat 正在运行")
143 | return True
144 |
145 |
146 | def stop_napcat():
147 | """本地停止 NapCat 服务"""
148 | LOG.info("正在停止 NapCat 服务")
149 | if platform.system() == "Linux":
150 | stop_napcat_linux()
151 | elif platform.system() == "Windows":
152 | stop_napcat_windows()
153 | else:
154 | LOG.warning("不提供官方支持的系统, 默认 NapCat 未启动")
155 |
156 |
157 | def start_napcat():
158 | """本地配置并启动 NapCat 服务"""
159 | config_napcat()
160 | if platform.system() == "Linux":
161 | start_napcat_linux()
162 | elif platform.system() == "Windows":
163 | start_napcat_windows()
164 | else:
165 | LOG.warning("不提供官方支持的系统, 默认 NapCat 已启动")
166 |
167 |
168 | def config_napcat():
169 | """配置 napcat 服务器, 保证 napcat_dir 存在且被正确配置"""
170 | napcat_dir = get_napcat_dir()
171 |
172 | def config_onebot11():
173 | expected_data = {
174 | "network": {
175 | "websocketServers": [
176 | {
177 | "name": "WsServer",
178 | "enable": True,
179 | "host": config.ws_listen_ip,
180 | "port": int(urlparse(config.ws_uri).port),
181 | "messagePostFormat": "array",
182 | "reportSelfMessage": config.report_self_message,
183 | "token": (
184 | str(config.ws_token) if config.ws_token is not None else ""
185 | ),
186 | "enableForcePushEvent": True,
187 | "debug": False,
188 | "heartInterval": 30000,
189 | }
190 | ],
191 | },
192 | "musicSignUrl": "",
193 | "enableLocalFile2Url": False,
194 | "parseMultMsg": False,
195 | }
196 | try:
197 | with open(
198 | os.path.join(
199 | napcat_dir,
200 | "config",
201 | "onebot11_" + str(config.bt_uin) + ".json",
202 | ),
203 | "w",
204 | encoding="utf-8",
205 | ) as f:
206 | json.dump(expected_data, f, indent=4, ensure_ascii=False)
207 | except Exception as e:
208 | LOG.error("配置 onebot 失败: " + str(e))
209 | if not check_permission():
210 | LOG.info("请使用 root 权限运行 ncatbot")
211 | raise Exception("请使用 root 权限运行 ncatbot")
212 |
213 | def config_quick_login():
214 | ori = os.path.join(napcat_dir, "quickLoginExample.bat")
215 | dst = os.path.join(napcat_dir, f"{config.bt_uin}_quickLogin.bat")
216 | shutil.copy(ori, dst)
217 |
218 | def read_webui_config():
219 | # 确定 webui 路径
220 | webui_config_path = os.path.join(napcat_dir, "config", "webui.json")
221 | try:
222 | with open(webui_config_path, "r") as f:
223 | webui_config = json.load(f)
224 | port = webui_config.get("port", 6099)
225 | token = webui_config.get("token", "")
226 | config.webui_token = token
227 | config.webui_port = port
228 | LOG.info("Token: " + token + ", Webui port: " + str(port))
229 |
230 | except FileNotFoundError:
231 | LOG.warning("无法读取 WebUI 配置, 将使用默认配置")
232 |
233 | config_onebot11()
234 | config_quick_login()
235 | read_webui_config()
236 |
--------------------------------------------------------------------------------
/src/ncatbot/adapter/net/__init__.py:
--------------------------------------------------------------------------------
1 | # NapCat 网络连接适配
2 |
3 | from ncatbot.adapter.net.connect import Websocket
4 | from ncatbot.adapter.net.wsroute import Route, check_websocket
5 |
6 | __all__ = ["Websocket", "Route", "check_websocket"]
7 |
--------------------------------------------------------------------------------
/src/ncatbot/adapter/net/connect.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import json
3 |
4 | import websockets
5 |
6 | from ncatbot.utils import config, get_log
7 |
8 | _log = get_log()
9 |
10 |
11 | class Websocket:
12 | def __init__(self, client):
13 | self.client = client
14 | self._websocket_uri = f"{config.ws_uri}/event"
15 | self._header = (
16 | {
17 | "Content-Type": "application/json",
18 | "Authorization": f"Bearer {config.ws_token}",
19 | }
20 | if config.ws_token
21 | else {"Content-Type": "application/json"}
22 | )
23 |
24 | def on_message(self, message: dict):
25 | message_post_type = message.get("post_type")
26 | message_type = message.get("message_type")
27 | if message_post_type in {"message", "message_sent"}:
28 | if message_type == "group":
29 | asyncio.create_task(self.client.handle_group_event(message))
30 | elif message_type == "private":
31 | asyncio.create_task(self.client.handle_private_event(message))
32 | else:
33 | _log.error(
34 | "Unknown error: Unrecognized message type!Please check log info!"
35 | ) and _log.debug(message)
36 | elif message_post_type == "notice":
37 | asyncio.create_task(self.client.handle_notice_event(message))
38 | elif message_post_type == "request":
39 | asyncio.create_task(self.client.handle_request_event(message))
40 | elif message_post_type == "meta_event":
41 | if message["meta_event_type"] == "lifecycle":
42 | asyncio.create_task(self.client.handle_startup_event())
43 | _log.info(f"机器人 {message.get('self_id')} 成功启动")
44 | else:
45 | _log.debug(message)
46 | else:
47 | _log.error(
48 | "Unknown error: Unrecognized message type!Please check log info!"
49 | ) and _log.debug(message)
50 | del message_post_type, message_type
51 |
52 | async def on_connect(self):
53 | async with websockets.connect(
54 | uri=self._websocket_uri, extra_headers=self._header, ping_interval=None
55 | ) as ws:
56 | # 我发现你们在client.py中已经进行了websocket连接的测试,故删除了此处不必要的错误处理。
57 | while True:
58 | try:
59 | message = await ws.recv()
60 | message = json.loads(message)
61 | self.on_message(message)
62 | # 这里的错误处理没有进行细分,我觉得没有很大的必要,报错的可能性不大,如果你对websocket了解很深,请完善此部分。
63 | except Exception as e:
64 | _log.error(f"Websocket error: {e}")
65 | raise e
66 |
--------------------------------------------------------------------------------
/src/ncatbot/adapter/net/wsroute.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import json as j
3 |
4 | import websockets
5 |
6 | from ncatbot.utils import config, get_log
7 |
8 | _log = get_log()
9 |
10 |
11 | async def check_websocket(uri):
12 | """
13 | 检查指定的 WebSocket uri 是否可用。
14 |
15 | :return: 如果可用返回 True,否则返回 False
16 | """
17 | try:
18 | async with websockets.connect(
19 | f"{uri}",
20 | extra_headers=(
21 | {
22 | "Content-Type": "application/json",
23 | "Authorization": f"Bearer {config.ws_token}",
24 | }
25 | if config.ws_token
26 | else {"Content-Type": "application/json"}
27 | ),
28 | ):
29 | _log.debug(f"WebSocket {uri} 可用.")
30 | return True
31 | except Exception:
32 | # _log.error(f"检查 WebSocket 端口时发生错误: {e}")
33 | return False
34 |
35 |
36 | class Route:
37 | """
38 | 路由类,用于处理 WebSocket 连接。
39 | """
40 |
41 | def __init__(self):
42 | self.url = config.ws_uri + "/api"
43 | self.headers = (
44 | {
45 | "Content-Type": "application/json",
46 | "Authorization": f"Bearer {config.ws_token}",
47 | }
48 | if config.ws_token
49 | else {"Content-Type": "application/json"}
50 | )
51 |
52 | async def post(self, path, params=None, json=None):
53 | # 开大限制到 16MB
54 | async with websockets.connect(
55 | self.url, extra_headers=self.headers, max_size=2**24
56 | ) as ws:
57 | if params:
58 | await ws.send(
59 | j.dumps(
60 | {
61 | "action": path.replace("/", ""),
62 | "params": params,
63 | "echo": int(datetime.datetime.now().timestamp()),
64 | }
65 | )
66 | )
67 | elif json:
68 | await ws.send(
69 | j.dumps(
70 | {
71 | "action": path.replace("/", ""),
72 | "params": json,
73 | "echo": int(datetime.datetime.now().timestamp()),
74 | }
75 | )
76 | )
77 | return j.loads(await ws.recv())
78 |
--------------------------------------------------------------------------------
/src/ncatbot/cli/__init__.py:
--------------------------------------------------------------------------------
1 | """NcatBot CLI package."""
2 |
3 | import ncatbot.cli.info_commands
4 | import ncatbot.cli.plugin_commands
5 | import ncatbot.cli.system_commands
6 | from ncatbot.cli.registry import registry
7 | from ncatbot.cli.utils import (
8 | LOG,
9 | NCATBOT_PATH,
10 | NUMBER_SAVE,
11 | PYPI_SOURCE,
12 | TEST_PLUGIN,
13 | get_log,
14 | get_plugin_info_by_name,
15 | get_qq,
16 | )
17 |
18 | __all__ = [
19 | "registry",
20 | "PLUGIN_BROKEN_MARK",
21 | "NCATBOT_PATH",
22 | "TEST_PLUGIN",
23 | "NUMBER_SAVE",
24 | "PYPI_SOURCE",
25 | "BotClient",
26 | "LOG",
27 | "config",
28 | "get_log",
29 | "get_proxy_url",
30 | "get_plugin_info_by_name",
31 | "get_qq",
32 | "install_plugin_dependencies",
33 | ]
34 |
--------------------------------------------------------------------------------
/src/ncatbot/cli/info_commands.py:
--------------------------------------------------------------------------------
1 | """Help and information commands for NcatBot CLI."""
2 |
3 | from typing import Optional
4 |
5 | from ncatbot.cli.registry import registry
6 | from ncatbot.cli.utils import get_qq
7 |
8 |
9 | @registry.register("help", "显示命令帮助信息", "help [命令名]", aliases=["h", "?"])
10 | def show_command_help(command_name: Optional[str] = None) -> None:
11 | """Show detailed help for a specific command or all commands."""
12 | if command_name is None:
13 | # Show general help
14 | qq = get_qq()
15 | show_help(qq)
16 | return
17 |
18 | # Show help for a specific command
19 | if command_name not in registry.commands:
20 | print(f"不支持的命令: {command_name}")
21 | return
22 |
23 | cmd = registry.commands[command_name]
24 | print(f"命令: {cmd.name}")
25 | print(f"用法: {cmd.usage}")
26 | print(f"描述: {cmd.description}")
27 | if cmd.help_text and cmd.help_text != cmd.description:
28 | print(f"详细说明: {cmd.help_text}")
29 |
30 |
31 | @registry.register("version", "显示 NcatBot 版本", "version", aliases=["v", "ver"])
32 | def show_version() -> None:
33 | """Show the version of NcatBot."""
34 | try:
35 | import pkg_resources
36 |
37 | version = pkg_resources.get_distribution("ncatbot").version
38 | print(f"NcatBot 版本: {version}")
39 | except (ImportError, pkg_resources.DistributionNotFound):
40 | print("无法获取 NcatBot 版本信息")
41 |
42 |
43 | def show_help(qq: str) -> None:
44 | """Show general help information."""
45 | print("欢迎使用 NcatBot CLI!")
46 | print(f"当前 QQ 号为: {qq}")
47 | print(registry.get_help())
48 |
--------------------------------------------------------------------------------
/src/ncatbot/cli/main.py:
--------------------------------------------------------------------------------
1 | """Main entry point for NcatBot CLI."""
2 |
3 | import argparse
4 | import os
5 | from typing import Optional
6 |
7 |
8 | def setup_work_directory(work_dir: Optional[str] = None) -> None:
9 | """Set up the working directory for NcatBot."""
10 | if work_dir is None:
11 | work_dir = os.getcwd()
12 |
13 | if not os.path.exists(work_dir):
14 | raise FileNotFoundError(f"工作目录 {work_dir} 不存在")
15 |
16 | os.chdir(work_dir)
17 |
18 |
19 | def parse_args() -> argparse.Namespace:
20 | """Parse command line arguments."""
21 | parser = argparse.ArgumentParser(description="NcatBot CLI")
22 | parser.add_argument("-c", "--command", help="要执行的命令")
23 | parser.add_argument("-a", "--args", nargs="*", help="命令参数", default=[])
24 | parser.add_argument("-w", "--work-dir", help="工作目录")
25 | parser.add_argument("-d", "--debug", action="store_true", help="启用调试模式")
26 | return parser.parse_args()
27 |
28 |
29 | def main() -> None:
30 | """Main entry point for the CLI."""
31 | args = parse_args()
32 | setup_work_directory(args.work_dir)
33 |
34 | if args.command is not None:
35 | # Command line mode
36 | from ncatbot.utils.logger import logging
37 |
38 | if args.command not in ["run", "start", "r"]: # 有些时候日志很烦
39 | logging.getLogger().setLevel(logging.WARNING)
40 |
41 | from ncatbot.cli import registry
42 |
43 | try:
44 | registry.execute(args.command, *args.args)
45 | except Exception as e:
46 | raise e
47 | else:
48 | # Interactive mode
49 | from ncatbot.cli import get_qq, registry, system_commands
50 |
51 | print("输入 help 查看帮助")
52 | print("输入 s 启动 NcatBot, 输入 q 退出 CLI")
53 | while True:
54 | try:
55 | cmd = input(f"NcatBot ({get_qq()})> ").strip()
56 | if not cmd:
57 | continue
58 |
59 | parts = cmd.split()
60 | cmd_name = parts[0]
61 | cmd_args = parts[1:]
62 |
63 | if cmd_name == "exit":
64 | system_commands.exit_cli()
65 | return
66 |
67 | registry.execute(cmd_name, *cmd_args)
68 | except KeyboardInterrupt:
69 | print("\n再见!")
70 | break
71 | except Exception as e:
72 | print(f"错误: {e}")
73 |
74 |
75 | if __name__ == "__main__":
76 | main()
77 |
--------------------------------------------------------------------------------
/src/ncatbot/cli/registry.py:
--------------------------------------------------------------------------------
1 | """Command registry system for NcatBot CLI."""
2 |
3 | from typing import Any, Callable, Dict, List, Optional
4 |
5 |
6 | class Command:
7 | """Command class to represent a CLI command"""
8 |
9 | def __init__(
10 | self,
11 | name: str,
12 | func: Callable,
13 | description: str,
14 | usage: str,
15 | help_text: Optional[str] = None,
16 | aliases: Optional[List[str]] = None,
17 | ):
18 | self.name = name
19 | self.func = func
20 | self.description = description
21 | self.usage = usage
22 | self.help_text = help_text or description
23 | self.aliases = aliases or []
24 |
25 |
26 | class CommandRegistry:
27 | """Registry for CLI commands"""
28 |
29 | def __init__(self):
30 | self.commands: Dict[str, Command] = {}
31 | self.aliases: Dict[str, str] = {}
32 |
33 | def register(
34 | self,
35 | name: str,
36 | description: str,
37 | usage: str,
38 | help_text: Optional[str] = None,
39 | aliases: Optional[List[str]] = None,
40 | ):
41 | """Decorator to register a command"""
42 |
43 | def decorator(func: Callable) -> Callable:
44 | cmd = Command(name, func, description, usage, help_text, aliases)
45 | self.commands[name] = cmd
46 |
47 | # Register aliases
48 | if aliases:
49 | for alias in aliases:
50 | self.aliases[alias] = name
51 |
52 | return func
53 |
54 | return decorator
55 |
56 | def execute(self, command_name: str, *args, **kwargs) -> Any:
57 | """Execute a command by name"""
58 | # Check if the command is an alias
59 | if command_name in self.aliases:
60 | command_name = self.aliases[command_name]
61 |
62 | if command_name not in self.commands:
63 | print(f"不支持的命令: {command_name}")
64 | return None
65 |
66 | return self.commands[command_name].func(*args, **kwargs)
67 |
68 | def get_help(self) -> str:
69 | """Generate help text for all commands"""
70 | help_lines = ["支持的命令:"]
71 | for i, (name, cmd) in enumerate(sorted(self.commands.items()), 1):
72 | # Include aliases in the help text if they exist
73 | alias_text = f" (别名: {', '.join(cmd.aliases)})" if cmd.aliases else ""
74 | help_lines.append(f"{i}. '{cmd.usage}' - {cmd.description}{alias_text}")
75 | return "\n".join(help_lines)
76 |
77 |
78 | # Create a global command registry
79 | registry = CommandRegistry()
80 |
--------------------------------------------------------------------------------
/src/ncatbot/cli/system_commands.py:
--------------------------------------------------------------------------------
1 | """System management commands for NcatBot CLI."""
2 |
3 | import subprocess
4 | import sys
5 | import time
6 |
7 | from ncatbot.adapter.nc.install import install_napcat
8 | from ncatbot.cli.registry import registry
9 | from ncatbot.cli.utils import (
10 | NUMBER_SAVE,
11 | PYPI_SOURCE,
12 | )
13 | from ncatbot.core import BotClient
14 | from ncatbot.utils import config
15 |
16 |
17 | @registry.register("setqq", "重新设置 QQ 号", "setqq", aliases=["qq"])
18 | def set_qq() -> str:
19 | """Set or update the QQ number."""
20 | # 提示输入, 确认输入, 保存到文件
21 | qq = input("请输入 QQ 号: ")
22 | if not qq.isdigit():
23 | print("QQ 号必须为数字!")
24 | return set_qq()
25 |
26 | qq_confirm = input(f"请再输入一遍 QQ 号 {qq} 并确认: ")
27 | if qq != qq_confirm:
28 | print("两次输入的 QQ 号不一致!")
29 | return set_qq()
30 |
31 | with open(NUMBER_SAVE, "w") as f:
32 | f.write(qq)
33 | return qq
34 |
35 |
36 | @registry.register(
37 | "start", "启动 NcatBot", "start [-d|-D|--debug]", aliases=["s", "run"]
38 | )
39 | def start(*args: str) -> None:
40 | """Start the NcatBot client."""
41 | from ncatbot.cli.utils import LOG, get_qq
42 |
43 | print("正在启动 NcatBot...")
44 | print("按下 Ctrl + C 可以正常退出程序")
45 | config.set_bot_uin(get_qq())
46 | try:
47 | client = BotClient()
48 | client.run(
49 | skip_ncatbot_install_check=(
50 | "-d" in args or "-D" in args or "--debug" in args
51 | )
52 | )
53 | # skip_ncatbot_install_check 是 NcatBot 本体开发者调试后门
54 | except Exception as e:
55 | LOG.error(e)
56 |
57 |
58 | @registry.register(
59 | "update", "更新 NcatBot 和 NapCat", "update", aliases=["u", "upgrade"]
60 | )
61 | def update() -> None:
62 | """Update NcatBot and NapCat."""
63 | print("正在更新 NapCat 版本")
64 | install_napcat("update")
65 | print("正在更新 Ncatbot 版本, 更新后请重新运行 NcatBotCLI 或者 main.exe")
66 | time.sleep(1)
67 | subprocess.Popen(
68 | [
69 | sys.executable,
70 | "-m",
71 | "pip",
72 | "install",
73 | "--upgrade",
74 | "ncatbot",
75 | "-i",
76 | PYPI_SOURCE,
77 | ],
78 | shell=True,
79 | start_new_session=True,
80 | )
81 | print("Ncatbot 正在更新...")
82 | time.sleep(10)
83 | print("更新成功, 请重新运行 NcatBotCLI 或者 main.exe")
84 | exit(0)
85 |
86 |
87 | @registry.register("exit", "退出 CLI 工具", "exit", aliases=["quit", "q"])
88 | def exit_cli() -> None:
89 | """Exit the CLI tool."""
90 | print("\n 正在退出 Ncatbot CLI. 再见!")
91 |
--------------------------------------------------------------------------------
/src/ncatbot/cli/utils.py:
--------------------------------------------------------------------------------
1 | """Utility functions and constants for NcatBot CLI."""
2 |
3 | import os
4 |
5 | from ncatbot.plugin import PluginLoader
6 | from ncatbot.utils import PLUGINS_DIR, get_log
7 |
8 | # Constants
9 | PYPI_SOURCE = "https://mirrors.aliyun.com/pypi/simple/"
10 | NCATBOT_PATH = "ncatbot"
11 | TEST_PLUGIN = "TestPlugin"
12 | NUMBER_SAVE = "number.txt"
13 | PLUGIN_INDEX = {}
14 |
15 | # Initialize logger
16 | LOG = get_log("CLI")
17 |
18 |
19 | def get_qq() -> str:
20 | """Get the QQ number from the saved file."""
21 | from ncatbot.cli.plugin_commands import install
22 | from ncatbot.cli.system_commands import set_qq
23 |
24 | if os.path.exists(NUMBER_SAVE):
25 | with open(NUMBER_SAVE, "r") as f:
26 | return f.read()
27 | print("第一次运行, 即将安装测试插件, 若不需要测试插件, 稍后可以删除...")
28 | install("TestPlugin")
29 | return set_qq()
30 |
31 |
32 | def get_plugin_info(path: str):
33 | if os.path.exists(path):
34 | return PluginLoader(None).get_plugin_info(path)
35 | else:
36 | raise FileNotFoundError(f"dir not found: {path}")
37 |
38 |
39 | def get_plugin_info_by_name(name: str):
40 | """
41 | Args:
42 | name (str): 插件名
43 | Returns:
44 | Tuple[bool, str]: 是否存在插件, 插件版本
45 | """
46 | plugin_path = os.path.join(PLUGINS_DIR, name)
47 | if os.path.exists(plugin_path):
48 | return True, get_plugin_info(plugin_path)[1]
49 | else:
50 | return False, "0.0.0"
51 |
--------------------------------------------------------------------------------
/src/ncatbot/core/__init__.py:
--------------------------------------------------------------------------------
1 | from ncatbot.core.request import Request
2 | from ncatbot.core.api import BotAPI
3 | from ncatbot.core.client import BotClient
4 | from ncatbot.core.element import *
5 | from ncatbot.core.message import BaseMessage, GroupMessage, PrivateMessage
6 |
7 | __all__ = [
8 | "BotAPI",
9 | "BotClient",
10 | "GroupMessage",
11 | "PrivateMessage",
12 | "BaseMessage",
13 | "Request",
14 | # MessageChain 核心元素
15 | "MessageChain",
16 | "Text",
17 | "Image",
18 | "Reply",
19 | "At",
20 | "AtAll",
21 | "Face",
22 | "Json",
23 | "Record",
24 | "Video",
25 | "Dice",
26 | "Rps",
27 | "Music",
28 | "CustomMusic",
29 | ]
30 |
--------------------------------------------------------------------------------
/src/ncatbot/core/api/__init__.py:
--------------------------------------------------------------------------------
1 | from ncatbot.core.api.api import BotAPI
2 |
3 | __all__ = ["BotAPI"]
4 |
--------------------------------------------------------------------------------
/src/ncatbot/core/message.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from typing import Any
3 |
4 | from ncatbot.core.api import BotAPI
5 |
6 |
7 | class BaseMessage:
8 | api_initialized = False
9 | api = None
10 | __slots__ = (
11 | "self_id",
12 | "time",
13 | "post_type",
14 | "raw_message",
15 | "user_id",
16 | "message",
17 | "sender",
18 | "message_id",
19 | )
20 |
21 | def __init__(self, message):
22 | if not BaseMessage.api_initialized:
23 | BaseMessage.api = BotAPI()
24 | BaseMessage.api_initialized = True
25 | self.self_id: int = message.get("self_id", None)
26 | self.time = message.get("time", None)
27 | self.post_type = message.get("post_type", None)
28 | self.raw_message: str = message.get("raw_message", None)
29 | self.sender: BaseMessage._Sender = self._Sender(message.get("sender", {}))
30 | self.message: list[dict[str, Any]] = message.get("message", None)
31 | self.user_id: int = message.get("user_id", None)
32 | self.message_id: int = message.get("message_id", None)
33 |
34 | def __repr__(self):
35 | return str({items: str(getattr(self, items)) for items in self.__slots__})
36 |
37 | def reply(self, is_file: bool = False, **kwargs):
38 | raise NotImplementedError
39 |
40 | async def reply_text(self, text: str = "", **kwargs):
41 | """回复, 文字信息特化"""
42 | return await self.reply(text=text, **kwargs)
43 |
44 | def reply_text_sync(self, text: str = "", **kwargs):
45 | """同步回复, 文字信息特化"""
46 | # 检查是否有正在运行的事件循环
47 | try:
48 | loop = asyncio.get_running_loop()
49 | except RuntimeError:
50 | # 如果没有运行的事件循环,创建一个新的事件循环并运行协程
51 | loop = asyncio.new_event_loop()
52 | asyncio.set_event_loop(loop)
53 | loop.run_until_complete(self.reply(text=text, **kwargs))
54 | else:
55 | # 如果有运行的事件循环,直接创建任务
56 | asyncio.create_task(self.reply(text=text, **kwargs))
57 |
58 | def reply_sync(self, is_file: bool = False, **kwargs):
59 | """同步回复"""
60 | if not isinstance(is_file, bool):
61 | kwargs["rtf"] = is_file
62 | is_file = False
63 | try:
64 | loop = asyncio.get_running_loop()
65 | except RuntimeError:
66 | loop = asyncio.new_event_loop()
67 | asyncio.set_event_loop(loop)
68 | loop.run_until_complete(self.reply(is_file=is_file, **kwargs))
69 | else:
70 | asyncio.create_task(self.reply(is_file=is_file, **kwargs))
71 |
72 | class _Sender:
73 | def __init__(self, message):
74 | self.user_id = message.get("user_id", None)
75 | self.nickname = message.get("nickname", None)
76 | self.card = message.get("card", None)
77 |
78 | def __repr__(self):
79 | return str(self.__dict__)
80 |
81 |
82 | class GroupMessage(BaseMessage):
83 | __slots__ = (
84 | "group_id",
85 | "user_id",
86 | "message_type",
87 | "sub_type",
88 | "font",
89 | "sender",
90 | "raw_message",
91 | "message_id",
92 | "message_seq",
93 | "real_id",
94 | "message",
95 | "message_format",
96 | )
97 |
98 | def __init__(self, message):
99 | super().__init__(message)
100 | self.user_id = message.get("user_id", None)
101 | self.group_id = message.get("group_id", None)
102 | self.message_type = message.get("message_type", None)
103 | self.sub_type = message.get("sub_type", None)
104 | self.raw_message = message.get("raw_message", None)
105 | self.font = message.get("font", None)
106 | self.sender = self._Sender(message.get("sender", {}))
107 | self.message_id = message.get("message_id", None)
108 | self.message_seq = message.get("message_seq", None)
109 | self.real_id = message.get("real_id", None)
110 | self.message = message.get("message", [])
111 | self.message_format = message.get("message_format", None)
112 |
113 | def __repr__(self):
114 | return str({items: str(getattr(self, items)) for items in self.__slots__})
115 |
116 | async def reply(self, text: str = "", is_file: bool = False, **kwargs):
117 | if len(text):
118 | kwargs["text"] = text
119 | if is_file:
120 | return await self.api.post_group_file(self.group_id, **kwargs)
121 | else:
122 | return await self.api.post_group_msg(
123 | self.group_id, reply=self.message_id, **kwargs
124 | )
125 |
126 |
127 | class PrivateMessage(BaseMessage):
128 | __slots__ = (
129 | "message_id",
130 | "user_id",
131 | "message_seq",
132 | "real_id",
133 | "sender",
134 | "raw_message",
135 | "font",
136 | "sub_type",
137 | "message",
138 | "message_format",
139 | "target_id",
140 | "message_type",
141 | )
142 |
143 | def __init__(self, message):
144 | super().__init__(message)
145 |
146 | self.user_id = message.get("user_id", None)
147 | self.message_id = message.get("message_id", None)
148 | self.message_seq = message.get("message_seq", None)
149 | self.real_id = message.get("real_id", None)
150 | self.message_type = message.get("message_type", None)
151 | self.sender = self._Sender(message.get("sender", {}))
152 | self.raw_message = message.get("raw_message", None)
153 | self.font = message.get("font", None)
154 | self.sub_type = message.get("sub_type", None)
155 | self.message = message.get("message", [])
156 | self.message_format = message.get("message_format", None)
157 | self.target_id = message.get("target_id", None)
158 |
159 | def __repr__(self):
160 | return str({items: str(getattr(self, items)) for items in self.__slots__})
161 |
162 | async def reply(self, text: str = "", is_file: bool = False, **kwargs):
163 | if len(text):
164 | kwargs["text"] = text
165 | if is_file:
166 | return await self.api.post_private_file(self.user_id, **kwargs)
167 | else:
168 | return await self.api.post_private_msg(self.user_id, **kwargs)
169 |
--------------------------------------------------------------------------------
/src/ncatbot/core/notice.py:
--------------------------------------------------------------------------------
1 | import json
2 | from typing import List, Dict, Any, Optional
3 |
4 | # 定义 Sender 类
5 | class Sender:
6 | def __init__(self, data: Dict[str, Any]):
7 | self.user_id: int = data.get("user_id", None)
8 | self.nickname: str = data.get("nickname", None)
9 | self.card: str = data.get("card", None)
10 | self.sex: str = data.get("sex", None)
11 | self.age: int = data.get("age", None)
12 | self.area: str = data.get("area", None)
13 | self.level: str = data.get("level", None)
14 | self.title: str = data.get("title", None)
15 | self.role: str = data.get("role", None)
16 |
17 | def __str__(self):
18 | return str(self.__dict__)
19 |
20 | def __repr__(self):
21 | return str(self.__dict__)
22 |
23 | # 定义 Anonymous 类
24 | class Anonymous:
25 | def __init__(self, data: Dict[str, Any]):
26 | self.id: int = data.get("id", None)
27 | self.name: str = data.get("name", None)
28 | self.flag: str = data.get("flag", None)
29 |
30 | def __str__(self):
31 | return str(self.__dict__)
32 |
33 | def __repr__(self):
34 | return str(self.__dict__)
35 |
36 | # 定义 File 类(新增)
37 | class File:
38 | def __init__(self, data: Dict[str, Any]):
39 | self.id: str = data.get("id", None)
40 | self.name: str = data.get("name", None)
41 | self.size: int = data.get("size", None)
42 | self.busid: int = data.get("busid", None)
43 |
44 | def __str__(self):
45 | return str(self.__dict__)
46 |
47 | def __repr__(self):
48 | return str(self.__dict__)
49 |
50 | # 定义 GroupMessage 类
51 | class GroupMessage:
52 | def __init__(self, data: Dict[str, Any]):
53 | self.time: int = data.get("time", None)
54 | self.self_id: int = data.get("self_id", None)
55 | self.post_type: str = data.get("post_type", None)
56 | self.message_type: str = data.get("message_type", None)
57 | self.sub_type: str = data.get("sub_type", None)
58 | self.message_id: int = data.get("message_id", None)
59 | self.group_id: int = data.get("group_id", None)
60 | self.user_id: int = data.get("user_id", None)
61 | self.anonymous: Anonymous = Anonymous(data.get("anonymous", {}))
62 | self.message: List[Message] = [Message(msg) for msg in data.get("message", []) if isinstance(msg, dict)]
63 | self.raw_message: str = data.get("raw_message", None)
64 | self.font: int = data.get("font", None)
65 | self.sender: Sender = Sender(data.get("sender", {}))
66 |
67 | def __str__(self):
68 | return str(self.__dict__)
69 |
70 | def __repr__(self):
71 | return str(self.__dict__)
72 |
73 | # 定义 Message 类
74 | class Message:
75 | def __init__(self, data: Dict[str, Any]):
76 | self.type: str = data.get("type", None)
77 | self.data: Dict[str, Any] = data.get("data", {})
78 |
79 | def __str__(self):
80 | return str(self.__dict__)
81 |
82 | def __repr__(self):
83 | return str(self.__dict__)
84 |
85 | # 定义 PrivateMessage 类
86 | class PrivateMessage:
87 | def __init__(self, data: Dict[str, Any]):
88 | self.time: int = data.get("time", None)
89 | self.self_id: int = data.get("self_id", None)
90 | self.post_type: str = data.get("post_type", None)
91 | self.message_type: str = data.get("message_type", None)
92 | self.sub_type: str = data.get("sub_type", None)
93 | self.message_id: int = data.get("message_id", None)
94 | self.user_id: int = data.get("user_id", None)
95 | self.message: List[Message] = [Message(msg) for msg in data.get("message", []) if isinstance(msg, dict)]
96 | self.raw_message: str = data.get("raw_message", None)
97 | self.font: int = data.get("font", None)
98 | self.sender: Sender = Sender(data.get("sender", {}))
99 |
100 | def __str__(self):
101 | return str(self.__dict__)
102 |
103 | def __repr__(self):
104 | return str(self.__dict__)
105 |
106 | # 定义 NoticeMessage 类(新增)
107 | class NoticeMessage:
108 | def __init__(self, data: Dict[str, Any]):
109 | self.time: int = data.get("time", None)
110 | self.self_id: int = data.get("self_id", None)
111 | self.post_type: str = data.get("post_type", None)
112 | self.notice_type: str = data.get("notice_type", None)
113 |
114 | # 公共字段初始化
115 | self.group_id: Optional[int] = None
116 | self.user_id: Optional[int] = None
117 | self.sub_type: Optional[str] = None
118 | self.file: Optional[File] = None
119 | self.operator_id: Optional[int] = None
120 | self.duration: Optional[int] = None
121 | self.target_id: Optional[int] = None
122 | self.honor_type: Optional[str] = None
123 | self.message_id: Optional[int] = None
124 |
125 | # 根据不同的 notice_type 处理字段
126 | if self.notice_type == "group_upload":
127 | self.group_id = data.get("group_id")
128 | self.user_id = data.get("user_id")
129 | self.file = File(data.get("file", {}))
130 | elif self.notice_type == "group_admin":
131 | self.sub_type = data.get("sub_type")
132 | self.group_id = data.get("group_id")
133 | self.user_id = data.get("user_id")
134 | elif self.notice_type == "group_decrease":
135 | self.sub_type = data.get("sub_type")
136 | self.group_id = data.get("group_id")
137 | self.operator_id = data.get("operator_id")
138 | self.user_id = data.get("user_id")
139 | elif self.notice_type == "group_increase":
140 | self.sub_type = data.get("sub_type")
141 | self.group_id = data.get("group_id")
142 | self.operator_id = data.get("operator_id")
143 | self.user_id = data.get("user_id")
144 | elif self.notice_type == "group_ban":
145 | self.sub_type = data.get("sub_type")
146 | self.group_id = data.get("group_id")
147 | self.operator_id = data.get("operator_id")
148 | self.user_id = data.get("user_id")
149 | self.duration = data.get("duration")
150 | elif self.notice_type == "friend_add":
151 | self.user_id = data.get("user_id")
152 | elif self.notice_type == "group_recall":
153 | self.group_id = data.get("group_id")
154 | self.user_id = data.get("user_id")
155 | self.operator_id = data.get("operator_id")
156 | self.message_id = data.get("message_id")
157 | elif self.notice_type == "friend_recall":
158 | self.user_id = data.get("user_id")
159 | self.message_id = data.get("message_id")
160 | elif self.notice_type == "notify":
161 | self.sub_type = data.get("sub_type")
162 | if self.sub_type == "poke":
163 | self.group_id = data.get("group_id")
164 | self.user_id = data.get("user_id")
165 | self.target_id = data.get("target_id")
166 | elif self.sub_type == "lucky_king":
167 | self.group_id = data.get("group_id")
168 | self.user_id = data.get("user_id")
169 | self.target_id = data.get("target_id")
170 | elif self.sub_type == "honor":
171 | self.group_id = data.get("group_id")
172 | self.user_id = data.get("user_id")
173 | self.honor_type = data.get("honor_type")
174 |
175 | def __str__(self):
176 | return str(self.__dict__)
177 |
178 | def __repr__(self):
179 | return str(self.__dict__)
180 |
181 | # 定义 RequestMessage 类(新增)
182 | class RequestMessage:
183 | def __init__(self, data: Dict[str, Any]):
184 | self.time: int = data.get("time", None)
185 | self.self_id: int = data.get("self_id", None)
186 | self.post_type: str = data.get("post_type", None)
187 | self.request_type: str = data.get("request_type", None)
188 | self.user_id: int = data.get("user_id", None)
189 | self.comment: str = data.get("comment", None)
190 | self.flag: str = data.get("flag", None)
191 |
192 | # 处理特定类型的字段
193 | self.sub_type: Optional[str] = None
194 | self.group_id: Optional[int] = None
195 |
196 | if self.request_type == "group":
197 | self.sub_type = data.get("sub_type")
198 | self.group_id = data.get("group_id")
199 |
200 | def __str__(self):
201 | return str(self.__dict__)
202 |
203 | def __repr__(self):
204 | return str(self.__dict__)
205 |
206 | # 定义 parse_message 函数(修正逻辑错误)
207 | def parse_message(raw: str):
208 | try:
209 | data = json.loads(raw)
210 | post_type = data.get("post_type")
211 | if post_type == "message":
212 | message_type = data.get("message_type")
213 | if message_type == "group":
214 | return GroupMessage(data)
215 | elif message_type == "private":
216 | return PrivateMessage(data)
217 | elif post_type == "notice":
218 | return NoticeMessage(data)
219 | elif post_type == "request":
220 | return RequestMessage(data)
221 | return None
222 | except json.JSONDecodeError:
223 | return None
--------------------------------------------------------------------------------
/src/ncatbot/core/request.py:
--------------------------------------------------------------------------------
1 | from ncatbot.core.api import BotAPI
2 |
3 |
4 | class Request:
5 | """请求事件"""
6 |
7 | api_initialized = False
8 | api = None
9 | __slots__ = (
10 | "time",
11 | "self_id",
12 | "post_type",
13 | "request_type",
14 | "sub_type",
15 | "user_id",
16 | "group_id",
17 | "comment",
18 | "flag",
19 | )
20 |
21 | def __init__(self, msg: dict):
22 | if not self.api_initialized:
23 | Request.api_initialized = True
24 | Request.api = BotAPI()
25 |
26 | self.time = msg["time"]
27 | self.self_id = msg["self_id"]
28 | self.post_type = msg["post_type"]
29 | self.request_type = msg["request_type"]
30 | self.sub_type = msg.get("sub_type", None)
31 | self.user_id = msg["user_id"]
32 | self.group_id = msg.get("group_id", None)
33 | self.comment = msg["comment"]
34 | self.flag = msg["flag"]
35 |
36 | def __repr__(self):
37 | return str({items: str(getattr(self, items)) for items in self.__slots__})
38 |
39 | def is_friend_add(self):
40 | return self.request_type == "friend"
41 |
42 | def is_group_add(self):
43 | return self.request_type == "group"
44 |
45 | async def accept_async(self, comment: str = ""):
46 | await self.reply(True, comment)
47 |
48 | def accept_sync(self, comment: str = ""):
49 | self.reply_sync(True, comment)
50 |
51 | async def reply(self, accept: bool = True, comment: str = ""):
52 | if self.is_friend_add():
53 | await self.api.set_friend_add_request(self.flag, accept, comment)
54 | else:
55 | await self.api.set_group_add_request(self.flag, accept, comment)
56 |
57 | def reply_sync(self, accept: bool = True, comment: str = ""):
58 | if self.is_friend_add():
59 | self.api.set_friend_add_request_sync(self.flag, accept, comment)
60 | else:
61 | self.api.set_group_add_request_sync(self.flag, accept, comment)
62 |
--------------------------------------------------------------------------------
/src/ncatbot/plugin/RBACManager/__init__.py:
--------------------------------------------------------------------------------
1 | # -------------------------
2 | # @Author : Fish-LP fish.zh@outlook.com
3 | # @Date : 2025-02-24 21:59:13
4 | # @LastEditors : Fish-LP fish.zh@outlook.com
5 | # @LastEditTime : 2025-03-15 17:46:15
6 | # @Description : 喵喵喵, 我还没想好怎么介绍文件喵
7 | # @Copyright (c) 2025 by Fish-LP, Fcatbot使用许可协议
8 | # -------------------------
9 | from ncatbot.plugin.RBACManager.permission_trie import Trie
10 | from ncatbot.plugin.RBACManager.RBAC_Manager import PermissionPath, RBACManager
11 |
12 | """
13 | * 基本概念
14 | * RBAC(Role-Based Access Control)基于角色的访问控制, 是一种权限管理模型, 通过为用户分配角色, 再为角色分配权限, 实现对系统资源的访问控制。
15 | 用户 : 使用者, 代表一种使用职责或职能的个体。
16 | 角色 : 定义一组权限的集合, 代表一种职责或职能, 如管理员、编辑、访客等。
17 | 权限 : 允许或拒绝访问特定资源或执行特定操作的能力, 如读取、写入、删除文件等。
18 | 资源 : 系统中需要保护的对象, 如文件、数据库、Web页面、服务等。
19 |
20 | * 结构原理
21 | 用户—角色关系 : 用户与角色之间是多对多的关系, 一个用户可以拥有多个角色, 一个角色也可以分配给多个用户。
22 | 例如, 一个员工可以同时是 "内容编辑" 角色和 "内容审核" 角色的成员。
23 |
24 | 角色—权限关系 : 角色与权限之间也是多对多的关系。一个角色可以包含多个权限, 一个权限也可以被分配给多个角色。
25 | 例如, "内容编辑" 角色拥有对文档的编辑、删除权限, 而"内容审核" 角色拥有审核文档和编辑权限。
26 |
27 | * 角色继承 :
28 | 角色之间可以形成层级关系, 允许父角色的权限被子角色继承。这种继承关系可以简化权限管理
29 | 例如, 管理员角色可以继承一般用户的权限, 并在其基础上增加系统管理相关的权限。
30 |
31 | * 权限路径
32 | 描述系统中访问资源或操作的权限序列。
33 | 例如
34 | 具体权限 : "插件.插件功能1" 表示 "插件" 下的 "插件功能1" 权限。
35 | 通配符 "*" : "插件.*" 代表 "插件" 下的所有权限。
36 | 通配符 "**" : "插件.**" 代表 "插件" 下的所有权限,包括子权限。
37 |
38 | ! 警告: 没有进行完全的安全检查
39 | """
40 | __all__ = [
41 | "RBACManager",
42 | "PermissionPath",
43 | "Trie",
44 | ]
45 |
--------------------------------------------------------------------------------
/src/ncatbot/plugin/RBACManager/permission_path.py:
--------------------------------------------------------------------------------
1 | from typing import Iterator, Tuple, Union
2 |
3 |
4 | class PermissionPath:
5 | path_split = "." # 分隔符
6 | row_path: str
7 | path: tuple
8 |
9 | def __init__(self, path: Union[str, "PermissionPath", list, tuple]):
10 | if isinstance(path, PermissionPath):
11 | self.row_path = path.row_path
12 | self.path = path.path
13 | elif isinstance(path, (list, tuple)):
14 | self.row_path = self.path_split.join(path)
15 | self.path = tuple(path)
16 | elif isinstance(path, str):
17 | self.row_path = path
18 | self.path = tuple(path.split(self.path_split))
19 | else:
20 | raise ValueError(f"未知类型: {type(path)}")
21 |
22 | def __repr__(self):
23 | """
24 | 返回对象的字符串表示,用于调试。
25 | """
26 | return f"PermissionPath(path={self.row_path}, path_split={self.path_split})"
27 |
28 | def __str__(self):
29 | """
30 | 返回路径的字符串形式。
31 | """
32 | return self.row_path
33 |
34 | def __eq__(self, other):
35 | """
36 | 比较两个PermissionPath对象是否相等。
37 | """
38 | if isinstance(other, PermissionPath):
39 | return self.path == other.path
40 | elif isinstance(other, (list, tuple)):
41 | return self.path == tuple(other)
42 | elif isinstance(other, str):
43 | return self.row_path == other
44 | return False
45 |
46 | def __len__(self):
47 | """
48 | 返回路径的层级数。
49 | """
50 | return len(self.path)
51 |
52 | def __getitem__(self, index: int):
53 | """
54 | 获取路径的某个层级。
55 | """
56 | return self.path[index]
57 |
58 | def __iter__(self) -> Iterator:
59 | """
60 | 返回路径的迭代器。
61 | """
62 | return iter(self.path)
63 |
64 | def __contains__(self, path_node: str) -> bool:
65 | """
66 | 检查路径中是否包含某个节点。
67 | """
68 | return path_node in self.path
69 |
70 | def __call__(self, path: str):
71 | """
72 | 允许对象作为函数调用,返回一个新的PermissionPath对象。
73 | """
74 | return PermissionPath(path)
75 |
76 | def matching_path(self, path: str, complete=False) -> bool:
77 | if "*" in self.row_path and "*" in path:
78 | raise ValueError(f"{self.row_path} 与 {path} 不能同时使用通配符")
79 | if path == self.row_path:
80 | return True
81 | if len(self.path) < len(path.split(self.path_split)):
82 | if not (("**" in path) or ("**" in self.row_path)):
83 | return False
84 | template = self if "*" in self.row_path else PermissionPath(path)
85 | target = PermissionPath(path) if "*" in self.row_path else self
86 | for i, template_node in enumerate(template):
87 | target_node = target.get(i)
88 | if target_node:
89 | if template_node == "**":
90 | return True
91 | elif template_node == "*":
92 | if template.get(i + 1):
93 | pass
94 | elif not target.get(i + 1):
95 | return True
96 | else:
97 | if target_node == template_node:
98 | pass
99 | else:
100 | return False
101 | else:
102 | return not complete
103 | return True
104 |
105 | def join(self, *paths: str) -> "PermissionPath":
106 | """
107 | 将路径段连接成一个新的PermissionPath。
108 | """
109 | # return split.join(self.path)
110 | new_path = self.row_path
111 | for p in paths:
112 | if p:
113 | new_path = f"{new_path}{self.path_split}{p}"
114 | return PermissionPath(new_path)
115 |
116 | def split(self) -> Tuple[str, ...]:
117 | """
118 | 返回路径的分割后的元组形式。
119 | """
120 | return self.path
121 |
122 | def get(self, index: int, default=None):
123 | """
124 | 获取路径的某个层级。
125 | """
126 | try:
127 | respond = self.path[index]
128 | return respond
129 | except IndexError:
130 | return default
131 |
--------------------------------------------------------------------------------
/src/ncatbot/plugin/RBACManager/permission_trie.py:
--------------------------------------------------------------------------------
1 | # -------------------------
2 | # @Author : Fish-LP fish.zh@outlook.com
3 | # @Date : 2025-02-24 21:52:42
4 | # @LastEditors : Fish-LP fish.zh@outlook.com
5 | # @LastEditTime : 2025-03-21 18:32:44
6 | # @Description : 喵喵喵, 我还没想好怎么介绍文件喵
7 | # @Copyright (c) 2025 by Fish-LP, Fcatbot使用许可协议
8 | # -------------------------
9 | from ncatbot.plugin.RBACManager.permission_path import PermissionPath
10 | from ncatbot.utils import Color, visualize_tree
11 |
12 |
13 | class Trie:
14 | def __init__(self, case_sensitive: bool = True):
15 | self.trie = {}
16 | self.case_sensitive = case_sensitive # 设置是否区分大小写
17 |
18 | def __str__(self):
19 | return "\n".join([f"{Color.RED}*{Color.RESET}"] + visualize_tree(self.trie))
20 |
21 | def format_path(self, path: str) -> PermissionPath:
22 | if self.case_sensitive:
23 | return PermissionPath(path)
24 | else:
25 | return PermissionPath(path.lower())
26 |
27 | def add_path(self, path: str):
28 | path = self.format_path(path)
29 | if "*" in path or "**" in path:
30 | raise ValueError("创建路径不能使用[*]或[**]")
31 |
32 | current_node = self.trie # 从根节点开始
33 | for node in path:
34 | if node in current_node:
35 | current_node = current_node[node] # 移动到子节点
36 | else:
37 | current_node[node] = {} # 创建新节点
38 | current_node = current_node[node] # 移动到新节点
39 |
40 | def del_path(self, path, max_mod: bool = True):
41 | self.check_path(path, True)
42 | formatted_path = self.format_path(path)
43 |
44 | def helper(current_node, remaining_path, parent_chain):
45 | # 递归终止条件: 路径处理完毕
46 | if not remaining_path:
47 | if parent_chain: # 如果有父节点
48 | # 获取直接父节点和当前节点key(如父是 {"a": {}}, current_key是 "a")
49 | parent_dict, key = parent_chain[-1]
50 | if key in parent_dict:
51 | # 删除当前节点(比如删除 "a.b.c" 中的 "c")
52 | del parent_dict[key]
53 | # 如果开启最大修改模式,向上回溯删除孤链
54 | if max_mod:
55 | # 从倒数第二个父节点开始向上检查(因为已经处理完当前层)
56 | for i in range(len(parent_chain) - 2, -1, -1):
57 | current_parent, current_key = parent_chain[i]
58 | # 如果父节点中的当前key对应的字典已经为空
59 | if (
60 | current_key in current_parent
61 | and not current_parent.get(current_key, {})
62 | ):
63 | # 删除该孤节点(比如删除 "a.b" 因为 "a.b.c" 被删且没有其他子节点)
64 | del current_parent[current_key]
65 | else:
66 | break # 遇到非空节点则停止向上删除
67 | return
68 |
69 | # 分解路径: 当前层 + 剩余路径(比如 ["a", "*", "c"] -> 当前层是 "a",剩余是 ["*", "c"])
70 | current_part = remaining_path[0]
71 | remaining = remaining_path[1:]
72 |
73 | # 处理通配符 *
74 | if current_part == "*":
75 | # 遍历当前节点的所有子节点(用 tuple 避免迭代时修改字典的问题)
76 | for key in tuple(current_node.keys()):
77 | next_node = current_node[key]
78 | # 递归处理子节点,携带父节点链信息(比如父链增加 (current_node, key))
79 | helper(next_node, remaining, parent_chain + [(current_node, key)])
80 |
81 | # 处理通配符 **
82 | elif current_part == "**":
83 | # 删除当前节点的所有子节点(比如 "a.**" 删除a的所有子节点和它们的后代)
84 | for key in tuple(current_node.keys()):
85 | del current_node[key]
86 |
87 | # 如果开启最大修改模式,可能需要删除当前节点自身(如果它变成空节点)
88 | if parent_chain and max_mod:
89 | parent_dict, key_in_parent = parent_chain[-1]
90 | # 检查父节点中当前key对应的字典是否为空(比如删除 "a.**" 后检查 "a" 是否为空)
91 | if key_in_parent in parent_dict and not parent_dict.get(
92 | key_in_parent, {}
93 | ):
94 | # 删除父节点中的当前key(比如删除 "a")
95 | del parent_dict[key_in_parent]
96 | # 继续向上检查孤链(比如如果 "a" 被删,检查它的父节点是否需要删除)
97 | for i in range(len(parent_chain) - 2, -1, -1):
98 | current_parent, current_key = parent_chain[i]
99 | if (
100 | current_key in current_parent
101 | and not current_parent.get(current_key, {})
102 | ):
103 | del current_parent[current_key]
104 | else:
105 | break # 遇到非空节点则停止
106 | return # ** 通配符处理完毕后直接返回,不再处理剩余路径(因为已经删除了所有后代)
107 | # 处理普通节点
108 | else:
109 | if current_part in current_node:
110 | next_node = current_node[current_part]
111 | # 携带当前节点信息进入下一层递归
112 | helper(
113 | next_node,
114 | remaining,
115 | parent_chain + [(current_node, current_part)],
116 | )
117 |
118 | # 从根节点开始递归处理,初始父节点链为空
119 | helper(self.trie, formatted_path, [])
120 |
121 | @classmethod
122 | def _check_path_in_trie(
123 | cls, trie: dict, path: PermissionPath, complete: bool = False
124 | ):
125 | current_node = trie
126 |
127 | for i, node in enumerate(path):
128 | if node == "**":
129 | # 当 complete 为 True 时,** 必须是路径的最后一个节点
130 | if complete and i != len(path) - 1:
131 | return False
132 | return True
133 | elif node == "*":
134 | # 递归检查所有子节点,传递 complete 参数
135 | return any(
136 | cls._check_path_in_trie(
137 | current_node[child], path[i + 1 :], complete
138 | )
139 | for child in current_node
140 | )
141 | elif node not in current_node:
142 | return False
143 | current_node = current_node[node]
144 |
145 | # 检查是否到达路径尽头(如果是 complete 模式,当前节点必须无子节点)
146 | return not complete or not current_node
147 |
148 | def check_path(self, path: str, complete: bool = False):
149 | formatted_path = self.format_path(path)
150 | return self._check_path_in_trie(self.trie, formatted_path, complete)
151 |
--------------------------------------------------------------------------------
/src/ncatbot/plugin/__init__.py:
--------------------------------------------------------------------------------
1 | # -------------------------
2 | # @Author : Fish-LP fish.zh@outlook.com
3 | # @Date : 2025-02-21 18:23:06
4 | # @LastEditors : Fish-LP fish.zh@outlook.com
5 | # @LastEditTime : 2025-02-21 19:43:52
6 | # @Description : 喵喵喵, 我还没想好怎么介绍文件喵
7 | # @message: 喵喵喵?
8 | # @Copyright (c) 2025 by Fish-LP, Fcatbot使用许可协议
9 | # -------------------------
10 | from ncatbot.plugin.base_plugin import BasePlugin
11 | from ncatbot.plugin.event import (
12 | Conf,
13 | Event,
14 | EventBus,
15 | Func,
16 | get_global_access_controller,
17 | )
18 | from ncatbot.plugin.loader import (
19 | CompatibleEnrollment,
20 | PluginLoader,
21 | install_plugin_dependencies,
22 | )
23 | from ncatbot.plugin.RBACManager import RBACManager
24 |
25 | __all__ = [
26 | "BasePlugin",
27 | "EventBus",
28 | "Event",
29 | "Func",
30 | "Conf",
31 | "get_global_access_controller",
32 | "CompatibleEnrollment",
33 | "PluginLoader",
34 | "install_plugin_dependencies",
35 | "RBACManager",
36 | ]
37 |
--------------------------------------------------------------------------------
/src/ncatbot/plugin/base_plugin/__init__.py:
--------------------------------------------------------------------------------
1 | from ncatbot.plugin.base_plugin.base_plugin import BasePlugin
2 |
3 | __all__ = [
4 | "BasePlugin",
5 | ]
6 |
--------------------------------------------------------------------------------
/src/ncatbot/plugin/base_plugin/base_plugin.py:
--------------------------------------------------------------------------------
1 | # -------------------------
2 | # @Author : Fish-LP fish.zh@outlook.com
3 | # @Date : 2025-02-15 20:08:02
4 | # @LastEditors : Fish-LP fish.zh@outlook.com
5 | # @LastEditTime : 2025-03-23 21:50:37
6 | # @Description : 猫娘慢慢看,鱼鱼不急
7 | # @Copyright (c) 2025 by Fish-LP, Fcatbot使用许可协议
8 | # -------------------------
9 | import asyncio
10 | import inspect
11 | from pathlib import Path
12 | from typing import final
13 |
14 | from ncatbot.core import BotAPI
15 | from ncatbot.plugin.base_plugin.builtin_function import BuiltinFuncMixin
16 | from ncatbot.plugin.base_plugin.event_handler import EventHandlerMixin
17 | from ncatbot.plugin.base_plugin.time_task_scheduler import SchedulerMixin
18 | from ncatbot.plugin.event import Conf, Func
19 | from ncatbot.utils import (
20 | PERSISTENT_DIR,
21 | ChangeDir,
22 | Color,
23 | PluginLoadError,
24 | TimeTaskScheduler,
25 | UniversalLoader,
26 | get_log,
27 | visualize_tree,
28 | )
29 | from ncatbot.utils.file_io import (
30 | FileTypeUnknownError,
31 | LoadError,
32 | SaveError,
33 | )
34 |
35 | LOG = get_log("BasePlugin")
36 |
37 |
38 | class BasePlugin(EventHandlerMixin, SchedulerMixin, BuiltinFuncMixin):
39 | """插件基类
40 |
41 | # 概述
42 | 所有插件必须继承此类来实现插件功能。提供了插件系统所需的基本功能支持。
43 |
44 | # 必需属性
45 | - `name`: 插件名称
46 | - `version`: 插件版本号
47 |
48 | # 可选属性
49 | - `author`: 作者名称 (默认 'Unknown')
50 | - `info`: 插件描述 (默认为空)
51 | - `dependencies`: 依赖项配置 (默认 `{}`)
52 | - `save_type`: 数据保存类型 (默认 'json')
53 |
54 | # 功能特性
55 |
56 | ## 生命周期钩子
57 | - `_init_()`: 同步初始化
58 | - `on_load()`: 异步初始化
59 | - `_close_()`: 同步清理
60 | - `on_close()`: 异步清理
61 |
62 | ## 数据持久化
63 | - `data`: `UniversalLoader` 实例,管理插件数据
64 | - `work_space`: 工作目录上下文管理器
65 | - `self_space`: 源码目录上下文管理器
66 |
67 | ## 事件处理
68 | - `register_handler()`: 注册事件处理器
69 | - `unregister_handlers()`: 注销所有事件处理器
70 |
71 | ## 定时任务
72 | - `add_scheduled_task()`: 添加定时任务
73 | - `remove_scheduled_task()`: 移除定时任务
74 |
75 | # 属性说明
76 |
77 | ## 插件标识
78 | - `name (str)`: 插件名称,必须定义
79 | - `version (str)`: 插件版本号,必须定义
80 | - `author (str)`: 作者名称,默认 'Unknown'
81 | - `info (str)`: 插件描述信息,默认为空
82 | - `dependencies (dict)`: 插件依赖项配置,默认 `{}`
83 |
84 | ## 路径与数据
85 | - `self_path (Path)`: 插件源码所在目录路径
86 | - `this_file_path (Path)`: 插件主文件路径
87 | - `meta_data (dict)`: 插件元数据字典
88 | - `data (UniversalLoader)`: 插件数据管理器实例
89 | - `api (WebSocketHandler)`: API调用接口实例
90 |
91 | ## 目录管理
92 | - `work_space (ChangeDir)`: 工作目录上下文管理器
93 | - `self_space (ChangeDir)`: 源码目录上下文管理器
94 |
95 | ## 状态标记
96 | - `first_load (bool)`: 是否为首次加载
97 | - `debug (bool)`: 是否处于调试模式
98 |
99 | # 属性方法
100 | - `@property debug (bool)`: 获取调试模式状态
101 |
102 | # 核心方法
103 | - `__init__()`: 初始化插件实例
104 | - `__onload__()`: 加载插件,执行初始化
105 | - `__unload__()`: 卸载插件,执行清理
106 | - `on_load()`: 异步初始化钩子,可重写
107 | - `on_close()`: 异步清理钩子,可重写
108 | - `_init_()`: 同步初始化钩子,可重写
109 | - `_close_()`: 同步清理钩子,可重写
110 | """
111 |
112 | name: str
113 | version: str
114 | dependencies: dict
115 | author: str = "Unknown"
116 | info: str = "这个作者很懒且神秘,没有写一点点描述,真是一个神秘的插件"
117 | save_type: str = "json"
118 |
119 | self_path: Path
120 | this_file_path: Path
121 | meta_data: dict
122 | api: BotAPI
123 | first_load: bool = "True"
124 |
125 | @final
126 | def __init__(
127 | self,
128 | event_bus,
129 | time_task_scheduler: TimeTaskScheduler,
130 | debug: bool = False,
131 | **kwd,
132 | ):
133 | """初始化插件实例
134 |
135 | Args:
136 | event_bus: 事件总线实例
137 | time_task_scheduler: 定时任务调度器
138 | debug: 是否启用调试模式
139 | **kwd: 额外的关键字参数,将被设置为插件属性
140 |
141 | Raises:
142 | ValueError: 当缺少插件名称或版本号时抛出
143 | PluginLoadError: 当工作目录无效时抛出
144 | """
145 | # 为了类型注解添加的动态导入
146 |
147 | # 插件信息检查
148 | if not getattr(self, "name", None):
149 | raise ValueError("缺失插件名称")
150 | if not getattr(self, "version", None):
151 | raise ValueError("缺失插件版本号")
152 | if not getattr(self, "dependencies", None):
153 | self.dependencies = {}
154 | # 添加额外属性
155 | if kwd:
156 | for k, v in kwd.items():
157 | setattr(self, k, v)
158 |
159 | # 固定属性
160 | plugin_file = Path(inspect.getmodule(self.__class__).__file__).resolve()
161 | # plugins_dir = Path(PLUGINS_DIR).resolve()
162 | self.this_file_path = plugin_file
163 | # 使用插件文件所在目录作为self_path
164 | self.self_path = plugin_file.parent
165 | self._lock = asyncio.Lock() # 创建一个异步锁对象
166 | self._funcs: list[Func] = [] # 功能列表元数据
167 | self._configs: list[Conf] = [] # 配置项列表元数据
168 |
169 | # 隐藏属性
170 | self._debug = debug
171 | self._event_handlers = []
172 | self._event_bus = event_bus
173 | self._time_task_scheduler = time_task_scheduler
174 | # 使用插件目录名作为工作目录名
175 | plugin_dir_name = self.self_path.name
176 | self._work_path = Path(PERSISTENT_DIR).resolve() / plugin_dir_name
177 | self._data_path = self._work_path / f"{plugin_dir_name}.{self.save_type}"
178 |
179 | # 检查是否为第一次启动
180 | self.first_load = False
181 | if not self._work_path.exists():
182 | self._work_path.mkdir(parents=True)
183 | self.first_load = True
184 | elif not self._data_path.exists():
185 | self.first_load = True
186 |
187 | if not self._work_path.is_dir():
188 | raise PluginLoadError(self.name, f"{self._work_path} 不是目录文件夹")
189 |
190 | self.data = UniversalLoader(self._data_path, self.save_type)
191 | self.data["config"] = {}
192 | self.work_space = ChangeDir(self._work_path)
193 | self.self_space = ChangeDir(self.self_path)
194 |
195 | @property
196 | def debug(self) -> bool:
197 | """是否处于调试模式"""
198 | return self._debug
199 |
200 | @property
201 | def config(self) -> dict:
202 | return self.data["config"]
203 |
204 | @final
205 | async def __unload__(self, *arg, **kwd):
206 | """卸载插件时的清理操作
207 |
208 | 执行插件卸载前的清理工作,保存数据并注销事件处理器
209 |
210 | Raises:
211 | RuntimeError: 保存持久化数据失败时抛出
212 | """
213 | self.unregister_handlers()
214 | await asyncio.to_thread(self._close_, *arg, **kwd)
215 | await self.on_close(*arg, **kwd)
216 | try:
217 | if self.debug:
218 | LOG.warning(
219 | f"{Color.YELLOW}debug模式{Color.RED}取消{Color.RESET}退出时的保存行为"
220 | )
221 | print(
222 | f"{Color.GRAY}{self.name}\n",
223 | "\n".join(visualize_tree(self.data)),
224 | sep="",
225 | )
226 | else:
227 | self.data.save()
228 | except (FileTypeUnknownError, SaveError, FileNotFoundError) as e:
229 | raise RuntimeError(self.name, f"保存持久化数据时出错: {e}")
230 |
231 | @final
232 | async def __onload__(self):
233 | """加载插件时的初始化操作
234 |
235 | 执行插件加载时的初始化工作,加载数据
236 |
237 | Raises:
238 | RuntimeError: 读取持久化数据失败时抛出
239 | """
240 | # load时传入的参数作为属性被保存在self中
241 | if isinstance(self.data, (dict, list)):
242 | pass
243 | # data_loader = UniversalLoader(self._data_path, self.save_type)
244 | try:
245 | self.data.load()
246 | except (FileTypeUnknownError, LoadError, FileNotFoundError):
247 | if self.debug:
248 | pass
249 | else:
250 | open(self._data_path, "w").write("")
251 | self.data.save()
252 | self.data.load()
253 | await asyncio.to_thread(self._init_)
254 | await self.on_load()
255 |
256 | async def on_load(self):
257 | """插件初始化时的子函数,可被子类重写"""
258 | pass
259 |
260 | async def on_close(self, *arg, **kwd):
261 | """插件卸载时的子函数,可被子类重写"""
262 | pass
263 |
264 | def _init_(self):
265 | """插件初始化时的子函数,可被子类重写"""
266 | pass
267 |
268 | def _close_(self, *arg, **kwd):
269 | """插件卸载时的子函数,可被子类重写"""
270 | pass
271 |
272 | def _get_help(self):
273 | """自动生成帮助文档"""
274 | text = ""
275 | for func in self._funcs:
276 | text += f"{func.name}-{func.description}: 使用方式 {func.usage}\n"
277 | for conf in self._configs:
278 | text += f"{conf.key}-{conf.description}: 类型 {conf.value_type}, 默认值 {conf.default}\n"
279 | return text
280 |
281 | def _get_current_configs(self):
282 | """获取当前配置项"""
283 | text = ""
284 | for conf in self._configs:
285 | text += f"{conf.key}-{conf.description}: {self.config.get(conf.key, conf.default)}\n\n"
286 | return text
287 |
--------------------------------------------------------------------------------
/src/ncatbot/plugin/base_plugin/builtin_function.py:
--------------------------------------------------------------------------------
1 | import warnings
2 | from functools import wraps
3 | from typing import Any, Callable, Dict, List, Literal, final
4 |
5 | from ncatbot.core import BaseMessage
6 | from ncatbot.plugin.event import Conf, Func
7 | from ncatbot.utils import PermissionGroup
8 |
9 |
10 | def deprecated(message: str = None):
11 | """标记函数为弃用
12 |
13 | Args:
14 | message: 弃用说明
15 | """
16 |
17 | def decorator(func):
18 | @wraps(func)
19 | def wrapper(*args, **kwargs):
20 | warnings.warn(
21 | f"{func.__name__} is deprecated. {message or ''}",
22 | DeprecationWarning,
23 | stacklevel=2,
24 | )
25 | return func(*args, **kwargs)
26 |
27 | return wrapper
28 |
29 | return decorator
30 |
31 |
32 | class BuiltinFuncMixin:
33 | """内置功能混入类, 提供内置功能注册功能.
34 |
35 | # 描述
36 | 该混入类提供了功能和配置项支持, 即注册功能和配置项.
37 | """
38 |
39 | @final
40 | def _register_func(
41 | self,
42 | name: str,
43 | handler: Callable[[BaseMessage], Any],
44 | filter: Callable = None,
45 | prefix: str = None,
46 | regex: str = None,
47 | permission: PermissionGroup = PermissionGroup.USER.value,
48 | permission_raise: bool = False,
49 | description: str = "",
50 | usage: str = "",
51 | examples: List[str] = None,
52 | tags: List[str] = None,
53 | metadata: Dict[str, Any] = None,
54 | ):
55 | if all([name != var.name for var in self._funcs]):
56 | # 如果没有指定任何过滤器,使用功能名作为默认前缀
57 | if filter is None and prefix is None and regex is None:
58 | prefix = f"/{name}"
59 |
60 | self._funcs.append(
61 | Func(
62 | name,
63 | self.name,
64 | handler,
65 | filter=filter,
66 | prefix=prefix,
67 | regex=regex,
68 | permission=permission,
69 | permission_raise=permission_raise,
70 | description=description,
71 | usage=usage,
72 | examples=examples,
73 | tags=tags,
74 | metadata=metadata,
75 | )
76 | )
77 | else:
78 | raise ValueError(f"插件 {self.name} 已存在功能 {name}")
79 |
80 | def register_user_func(
81 | self,
82 | name: str,
83 | handler: Callable[[BaseMessage], Any],
84 | filter: Callable = None,
85 | prefix: str = None,
86 | regex: str = None,
87 | permission_raise: bool = False,
88 | description: str = "",
89 | usage: str = "",
90 | examples: List[str] = None,
91 | tags: List[str] = None,
92 | metadata: Dict[str, Any] = None,
93 | ):
94 | """注册普通用户功能
95 |
96 | Args:
97 | name: 功能名
98 | handler: 处理函数
99 | filter: 自定义过滤器
100 | prefix: 前缀匹配
101 | regex: 正则匹配
102 | permission_raise: 是否提权
103 | description: 功能描述
104 | usage: 使用说明
105 | examples: 使用示例
106 | tags: 功能标签
107 | metadata: 额外元数据
108 | """
109 | self._register_func(
110 | name,
111 | handler,
112 | filter,
113 | prefix,
114 | regex,
115 | PermissionGroup.USER.value,
116 | permission_raise,
117 | description,
118 | usage,
119 | examples,
120 | tags,
121 | metadata,
122 | )
123 |
124 | def register_admin_func(
125 | self,
126 | name: str,
127 | handler: Callable[[BaseMessage], Any],
128 | filter: Callable = None,
129 | prefix: str = None,
130 | regex: str = None,
131 | permission_raise: bool = True,
132 | description: str = "",
133 | usage: str = "",
134 | examples: List[str] = None,
135 | tags: List[str] = None,
136 | metadata: Dict[str, Any] = None,
137 | ):
138 | """注册管理员功能
139 |
140 | Args:
141 | name: 功能名
142 | handler: 处理函数
143 | filter: 自定义过滤器
144 | prefix: 前缀匹配
145 | regex: 正则匹配
146 | permission_raise: 是否提权
147 | description: 功能描述
148 | usage: 使用说明
149 | examples: 使用示例
150 | tags: 功能标签
151 | metadata: 额外元数据
152 | """
153 | self._register_func(
154 | name,
155 | handler,
156 | filter,
157 | prefix,
158 | regex,
159 | PermissionGroup.ADMIN.value,
160 | permission_raise,
161 | description,
162 | usage,
163 | examples,
164 | tags,
165 | metadata,
166 | )
167 |
168 | @deprecated("请使用 register_user_func 或 register_admin_func 替代")
169 | def register_default_func(
170 | self,
171 | handler: Callable[[BaseMessage], Any],
172 | permission: PermissionGroup = PermissionGroup.USER.value,
173 | description: str = "",
174 | usage: str = "",
175 | examples: List[str] = None,
176 | tags: List[str] = None,
177 | metadata: Dict[str, Any] = None,
178 | ):
179 | """默认处理功能 (已弃用)
180 |
181 | 如果没能触发其它功能, 则触发默认功能.
182 | 请使用 register_user_func 或 register_admin_func 替代.
183 | """
184 | self._register_func(
185 | "default",
186 | handler,
187 | None,
188 | None,
189 | None,
190 | permission,
191 | False,
192 | description,
193 | usage,
194 | examples,
195 | tags,
196 | metadata,
197 | )
198 |
199 | def register_config(
200 | self,
201 | key: str,
202 | default: Any,
203 | on_change: Callable[[str, BaseMessage], Any] = None,
204 | description: str = "",
205 | value_type: Literal["int", "bool", "str", "float"] = "",
206 | allowed_values: List[Any] = None,
207 | metadata: Dict[str, Any] = None,
208 | ):
209 | """注册配置项
210 | Args:
211 | key (str): 配置项键名
212 | default (Any): 默认值
213 | on_change (Callable[[str, BaseMessage], Any], optional): 配置变更回调函数. 接收新值和触发修改的消息对象.
214 | description (str, optional): 配置项描述
215 | value_type (str, optional): 值类型描述
216 | allowed_values (List[Any], optional): 允许的值列表
217 | metadata (Dict[str, Any], optional): 额外元数据
218 | """
219 | self._configs.append(
220 | Conf(
221 | self,
222 | key,
223 | on_change,
224 | default,
225 | description,
226 | value_type,
227 | allowed_values,
228 | metadata,
229 | )
230 | )
231 | if key not in self.data["config"]:
232 | self.data["config"][key] = default
233 |
--------------------------------------------------------------------------------
/src/ncatbot/plugin/base_plugin/event_handler.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Callable, List, final
2 | from uuid import UUID
3 |
4 | from ncatbot.plugin.event import Event, EventBus
5 |
6 |
7 | class EventHandlerMixin:
8 | """事件处理混入类,提供事件发布和订阅功能。
9 |
10 | # 描述
11 | 该混入类实现了完整的事件处理系统,包括事件的同步/异步发布以及处理器的管理功能。
12 | 作为一个Mixin类,它需要与具有 `_event_bus` 实例的类配合使用。
13 |
14 | # 属性
15 | - `_event_bus` (EventBus): 事件总线实例,用于处理事件的发布与订阅
16 | - `_event_handlers` (List[UUID]): 当前已注册的事件处理器ID列表
17 |
18 | # 使用示例
19 | ```python
20 | class MyClass(EventHandlerMixin):
21 | def __init__(self):
22 | self._event_bus = EventBus()
23 | self._event_handlers = []
24 | ```
25 | """
26 |
27 | _event_bus: EventBus
28 | _event_handlers: List[UUID]
29 |
30 | @final
31 | def publish_sync(self, event: Event) -> List[Any]:
32 | """同步发布事件。
33 |
34 | Args:
35 | event (Event): 要发布的事件对象。
36 |
37 | Returns:
38 | List[Any]: 所有事件处理器的返回值列表。
39 | """
40 | return self._event_bus.publish_sync(event)
41 |
42 | @final
43 | def publish_async(self, event: Event):
44 | """异步发布事件。
45 |
46 | Args:
47 | event (Event): 要发布的事件对象。
48 |
49 | Returns:
50 | None: 这个方法不返回任何值。
51 | """
52 | return self._event_bus.publish_async(event)
53 |
54 | @final
55 | def register_handler(
56 | self, event_type: str, handler: Callable[[Event], Any], priority: int = 0
57 | ) -> UUID:
58 | """注册一个事件处理器。
59 |
60 | Args:
61 | event_type (str): 事件类型标识符。
62 | handler (Callable[[Event], Any]): 事件处理器函数。
63 | priority (int, optional): 处理器优先级,默认为0。优先级越高,越先执行。
64 |
65 | Returns:
66 | UUID: 处理器的唯一标识符。
67 | """
68 | handler_id = self._event_bus.subscribe(event_type, handler, priority)
69 | self._event_handlers.append(handler_id)
70 | return handler_id
71 |
72 | @final
73 | def unregister_handler(self, handler_id: UUID) -> bool:
74 | """注销一个事件处理器。
75 |
76 | Args:
77 | handler_id (UUID): 要注销的事件处理器的唯一标识符。
78 |
79 | Returns:
80 | bool: 如果注销成功返回True,否则返回False。
81 | """
82 | if handler_id in self._event_handlers:
83 | rest = self._event_bus.unsubscribe(handler_id)
84 | if rest:
85 | self._event_handlers.remove(handler_id)
86 | return True
87 | return False
88 |
89 | @final
90 | def unregister_handlers(self):
91 | """注销所有事件处理器。
92 |
93 | Returns:
94 | None: 这个方法不返回任何值。
95 | """
96 | for handler_id in self._event_handlers:
97 | self._event_bus.unsubscribe(handler_id)
98 |
--------------------------------------------------------------------------------
/src/ncatbot/plugin/base_plugin/time_task_scheduler.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Callable, Dict, List, Optional, Tuple, Union, final
2 |
3 | from ncatbot.utils import TimeTaskScheduler, to_sync
4 |
5 |
6 | class SchedulerMixin:
7 | """定时任务调度混入类,提供定时任务的管理功能。
8 |
9 | # 描述
10 | 该混入类提供了定时任务的添加、移除等管理功能。支持灵活的任务调度配置,
11 | 包括固定间隔执行、条件触发、参数动态生成等特性。
12 |
13 | # 属性
14 | - `_time_task_scheduler` (TimeTaskScheduler): 时间任务调度器实例
15 |
16 | # 特性
17 | - 支持固定时间间隔的任务调度
18 | - 支持条件触发机制
19 | - 支持最大执行次数限制
20 | - 支持动态参数生成
21 | """
22 |
23 | _time_task_scheduler: TimeTaskScheduler
24 |
25 | @final
26 | def add_scheduled_task(
27 | self,
28 | job_func: Callable,
29 | name: str,
30 | interval: Union[str, int, float],
31 | conditions: Optional[List[Callable[[], bool]]] = None,
32 | max_runs: Optional[int] = None,
33 | args: Optional[Tuple] = None,
34 | kwargs: Optional[Dict] = None,
35 | args_provider: Optional[Callable[[], Tuple]] = None,
36 | kwargs_provider: Optional[Callable[[], Dict[str, Any]]] = None,
37 | ) -> bool:
38 | """添加一个定时任务。
39 |
40 | Args:
41 | job_func (Callable): 要执行的任务函数。
42 | name (str): 任务名称。
43 | interval (Union[str, int, float]): 任务执行的时间间隔。
44 | conditions (Optional[List[Callable[[], bool]]], optional): 任务执行的条件列表。默认为None。
45 | max_runs (Optional[int], optional): 任务的最大执行次数。默认为None。
46 | args (Optional[Tuple], optional): 任务函数的位置参数。默认为None。
47 | kwargs (Optional[Dict], optional): 任务函数的关键字参数。默认为None。
48 | args_provider (Optional[Callable[[], Tuple]], optional): 提供任务函数位置参数的函数。默认为None。
49 | kwargs_provider (Optional[Callable[[], Dict[str, Any]]], optional): 提供任务函数关键字参数的函数。默认为None。
50 |
51 | Returns:
52 | bool: 如果任务添加成功返回True,否则返回False。
53 | """
54 | job_func = to_sync(job_func)
55 |
56 | job_info = {
57 | "name": name,
58 | "job_func": job_func,
59 | "interval": interval,
60 | "max_runs": max_runs,
61 | "conditions": conditions or [],
62 | "args": args,
63 | "kwargs": kwargs or {},
64 | "args_provider": args_provider,
65 | "kwargs_provider": kwargs_provider,
66 | }
67 | return self._time_task_scheduler.add_job(**job_info)
68 |
69 | @final
70 | def remove_scheduled_task(self, task_name: str):
71 | """移除一个定时任务。
72 |
73 | Args:
74 | task_name (str): 要移除的任务名称。
75 |
76 | Returns:
77 | bool: 如果任务移除成功返回True,否则返回False。
78 | """
79 | return self._time_task_scheduler.remove_job(name=task_name)
80 |
--------------------------------------------------------------------------------
/src/ncatbot/plugin/event/__init__.py:
--------------------------------------------------------------------------------
1 | from ncatbot.plugin.event.access_controller import get_global_access_controller
2 | from ncatbot.plugin.event.event import Event
3 | from ncatbot.plugin.event.event_bus import EventBus
4 | from ncatbot.plugin.event.function import BUILT_IN_FUNCTIONS, Conf, Func
5 |
6 | __all__ = [
7 | "EventBus",
8 | "Event",
9 | "Func",
10 | "Conf",
11 | "get_global_access_controller",
12 | "BUILT_IN_FUNCTIONS",
13 | ]
14 |
--------------------------------------------------------------------------------
/src/ncatbot/plugin/event/event.py:
--------------------------------------------------------------------------------
1 | # -------------------------
2 | # @Author : Fish-LP fish.zh@outlook.com
3 | # @Date : 2025-02-21 18:23:06
4 | # @LastEditors : Fish-LP fish.zh@outlook.com
5 | # @LastEditTime : 2025-03-06 20:41:46
6 | # @Description : 喵喵喵, 我还没想好怎么介绍文件喵
7 | # @message: 喵喵喵?
8 | # @Copyright (c) 2025 by Fish-LP, Fcatbot使用许可协议
9 | # -------------------------
10 | from typing import Any, List, Union
11 |
12 |
13 | class EventType:
14 | # 暂时不启用
15 | def __init__(self, plugin_name: str, event_name: str):
16 | self.plugin_name = plugin_name
17 | self.event_name = event_name
18 |
19 | def __repr__(self):
20 | return f"{self.plugin_name}.{self.event_name}"
21 |
22 | def __str__(self):
23 | return f"{self.plugin_name}.{self.event_name}"
24 |
25 | def __eq__(self, other):
26 | return str(self) == str(other)
27 |
28 | def __ne__(self, other):
29 | return str(self) != str(other)
30 |
31 |
32 | class EventSource:
33 | def __init__(self, user_id: Union[str, int], group_id: Union[str, int]):
34 | self.user_id = str(user_id)
35 | self.group_id = str(group_id)
36 |
37 |
38 | class Event:
39 | """
40 | 事件类,用于封装事件的类型和数据
41 | """
42 |
43 | def __init__(self, type: str, data: Any, source: EventSource = None):
44 | """
45 | 初始化事件
46 |
47 | 参数:
48 | type: str - 事件的类型
49 | data: Any - 事件携带的数据
50 | """
51 | self.type = type
52 | self.data = data
53 | self.source = source # 事件源
54 | self._results: List[Any] = []
55 | self._propagation_stopped = False
56 |
57 | def stop_propagation(self):
58 | """
59 | 停止事件的传播
60 | 当调用此方法后,后续的处理器将不会被执行
61 | """
62 | self._propagation_stopped = True
63 |
64 | def add_result(self, result: Any):
65 | """
66 | 添加事件处理结果
67 |
68 | 参数:
69 | result: Any - 处理器返回的结果
70 | """
71 | self._results.append(result)
72 |
73 | def __repr__(self):
74 | return f'Event(type="{self.type}",data={self.data})'
75 |
--------------------------------------------------------------------------------
/src/ncatbot/plugin/event/event_bus.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import copy
3 | import inspect
4 | import re
5 | import uuid
6 | from typing import Any, Callable, List
7 |
8 | from ncatbot.core import BaseMessage
9 | from ncatbot.plugin.event.access_controller import get_global_access_controller
10 | from ncatbot.plugin.event.event import Event
11 | from ncatbot.plugin.event.function import BUILT_IN_FUNCTIONS, Conf, Func
12 | from ncatbot.utils import (
13 | OFFICIAL_GROUP_MESSAGE_EVENT,
14 | OFFICIAL_PRIVATE_MESSAGE_EVENT,
15 | PermissionGroup,
16 | get_log,
17 | run_func_async,
18 | )
19 |
20 | _log = get_log()
21 |
22 |
23 | class EventBus:
24 | """
25 | 事件总线类,用于管理和分发事件
26 | """
27 |
28 | def __init__(self, plugin_loader=None):
29 | """
30 | 初始化事件总线
31 | """
32 | from ncatbot.core import BotAPI
33 | from ncatbot.plugin.base_plugin.base_plugin import BasePlugin
34 | from ncatbot.plugin.loader.loader import PluginLoader
35 |
36 | self._exact_handlers = {}
37 | self._regex_handlers = []
38 | self.access_controller = get_global_access_controller()
39 | self.funcs: list[Func] = []
40 | self.configs: dict[str, Conf] = {}
41 | self.plugin_loader: PluginLoader = plugin_loader
42 | self.plugins: list[BasePlugin] = []
43 | self.api: BotAPI = None
44 | self.load_builtin_funcs()
45 | self.subscribe(
46 | OFFICIAL_GROUP_MESSAGE_EVENT, self._func_activator, 100
47 | ) # 加载功能注册钩子
48 | self.subscribe(
49 | OFFICIAL_PRIVATE_MESSAGE_EVENT, self._func_activator, 100
50 | ) # 加载功能注册钩子
51 |
52 | # TODO: 支持保护性功能, 激活保护性功能后该消息不会激活其它任何功能
53 | async def _func_activator(self, event: Event):
54 | activate_plugin_func = [] # 记录已经被激活功能的插件, 用于判断是否激活默认功能
55 | message: BaseMessage = event.data
56 | for func in self.funcs:
57 | if func.is_activate(event):
58 | if self.access_controller.with_permission(
59 | path=f"{func.plugin_name}.{func.name}",
60 | source=event.source,
61 | permission_raise=func.permission_raise,
62 | ):
63 | if func.name == "default":
64 | # 默认功能, 检查其余激活条件
65 | if all(
66 | [
67 | n not in activate_plugin_func
68 | for n in (func.plugin_name, "ncatbot")
69 | ]
70 | ):
71 | await run_func_async(func.func, message)
72 | # await func.func(message)
73 | else:
74 | activate_plugin_func.append(func.plugin_name)
75 | await run_func_async(func.func, message)
76 | elif func.reply:
77 | message.reply_text_sync("权限不足")
78 |
79 | def load_builtin_funcs(self):
80 | self.access_controller.create_permission_path(
81 | "ncatbot.cfg.main.placeholder", ignore_exist=True
82 | ) # 创建占位路径
83 | for func in BUILT_IN_FUNCTIONS:
84 | if func.name in ["plg", "cfg", "help", "reload"]: # 绑定 plg 的参数
85 | temp = copy.copy(func.func)
86 |
87 | async def async_func(message, event_bus=self, temp=temp):
88 | return await temp(message, event_bus)
89 |
90 | func.func = async_func
91 |
92 | self.funcs.append(func)
93 | self.access_controller.assign_permissions_to_role(
94 | role_name=func.permission,
95 | path=(
96 | f"{func.plugin_name}.{func.name}"
97 | if func.name != "cfg"
98 | else "ncatbot.cfg.**"
99 | ),
100 | mode="white",
101 | create_permission_path=True,
102 | )
103 |
104 | def remove_plugin(self, plugin):
105 | for cfg in plugin._configs:
106 | self.configs.pop(cfg.full_key)
107 |
108 | for func in plugin._funcs:
109 | for i, _ in enumerate(self.funcs):
110 | if self.funcs[i].name == func.name:
111 | self.funcs.pop(i)
112 | break
113 | self.plugins.remove(plugin)
114 |
115 | def add_plugin(self, plugin):
116 | self.plugins.append(plugin)
117 | self.set_plugin_funcs(plugin)
118 | self.set_plugin_configs(plugin)
119 |
120 | def set_plugin_configs(self, plugin):
121 | # 为了类型注解添加的额外检查
122 | from ncatbot.plugin.base_plugin.base_plugin import BasePlugin
123 |
124 | assert isinstance(plugin, BasePlugin)
125 | for conf in plugin._configs:
126 | _log.debug(f"加载插件 {plugin.name} 的配置 {conf.full_key}")
127 | if "config" in plugin.data and conf.key not in plugin.data["config"]:
128 | plugin.data["config"][conf.key] = conf.default
129 | self.access_controller.assign_permissions_to_role(
130 | role_name=PermissionGroup.ADMIN.value,
131 | path=f"ncatbot.cfg.{conf.full_key}",
132 | mode="white",
133 | create_permission_path=True,
134 | )
135 | self.configs[conf.full_key] = conf
136 |
137 | def set_plugin_funcs(self, plugin):
138 | # 为了类型注解添加的额外检查
139 | from ncatbot.plugin.base_plugin.base_plugin import BasePlugin
140 |
141 | assert isinstance(plugin, BasePlugin)
142 |
143 | for func in plugin._funcs:
144 | _log.debug(f"加载插件 {plugin.name} 的功能 {func.name}")
145 | self.access_controller.assign_permissions_to_role(
146 | role_name=func.permission,
147 | path=f"{func.plugin_name}.{func.name}",
148 | mode="white",
149 | create_permission_path=True,
150 | )
151 | self.funcs.append(func)
152 |
153 | def subscribe(
154 | self, event_type: str, handler: Callable[[Event], Any], priority: int = 0
155 | ) -> uuid.UUID:
156 | """
157 | 订阅事件处理器,并返回处理器的唯一 ID
158 |
159 | 参数:
160 | event_type: str - 事件类型或正则模式(以 're:' 开头表示正则匹配)
161 | handler: Callable[[Event], Any] - 事件处理器函数
162 | priority: int - 处理器的优先级(数字越大,优先级越高)
163 |
164 | 返回:
165 | str - 处理器的唯一 ID
166 | """
167 | handler_id = uuid.uuid4()
168 | pattern = None
169 | if event_type.startswith("re:"):
170 | try:
171 | pattern = re.compile(event_type[3:])
172 | except re.error as e:
173 | raise ValueError(f"无效正则表达式: {event_type[3:]}") from e
174 | self._regex_handlers.append((pattern, priority, handler, handler_id))
175 | else:
176 | self._exact_handlers.setdefault(event_type, []).append(
177 | (pattern, priority, handler, handler_id)
178 | )
179 | return handler_id
180 |
181 | def unsubscribe(self, handler_id: uuid.UUID) -> bool:
182 | """
183 | 取消订阅事件处理器
184 |
185 | 参数:
186 | handler_id: UUID - 处理器的唯一 ID
187 |
188 | 返回:
189 | bool - 是否成功取消订阅
190 | """
191 | # 取消精确匹配处理器
192 | for event_type in list(self._exact_handlers.keys()):
193 | self._exact_handlers[event_type] = [
194 | (patt, pr, h, hid)
195 | for (patt, pr, h, hid) in self._exact_handlers[event_type]
196 | if hid != handler_id
197 | ]
198 | if not self._exact_handlers[event_type]:
199 | del self._exact_handlers[event_type]
200 | # 取消正则匹配处理器
201 | self._regex_handlers = [
202 | (patt, pr, h, hid)
203 | for (patt, pr, h, hid) in self._regex_handlers
204 | if hid != handler_id
205 | ]
206 | return True
207 |
208 | async def publish_async(self, event: Event) -> List[Any]:
209 | """
210 | 异步发布事件
211 |
212 | 参数:
213 | event: Event - 要发布的事件
214 |
215 | 返回值:
216 | List[Any] - 所有处理器返回的结果的列表(通常是空列表)
217 | """
218 | handlers = []
219 | if event.type in self._exact_handlers:
220 | # 处理精确匹配处理器
221 | for pattern, priority, handler, handler_id in self._exact_handlers[
222 | event.type
223 | ]:
224 | handlers.append((handler, priority, handler_id))
225 | else:
226 | # 处理正则匹配处理器
227 | for pattern, priority, handler, handler_id in self._regex_handlers:
228 | if pattern and pattern.match(event.type):
229 | handlers.append((handler, priority, handler_id))
230 |
231 | # 按优先级排序
232 | sorted_handlers = sorted(handlers, key=lambda x: (-x[1], x[0].__name__))
233 |
234 | results = []
235 | # 按优先级顺序调用处理器
236 | for handler, priority, handler_id in sorted_handlers:
237 | if event._propagation_stopped:
238 | break
239 |
240 | if inspect.iscoroutinefunction(handler):
241 | await handler(event)
242 | else:
243 | asyncio.create_task(handler(event))
244 |
245 | # 收集结果
246 | results.extend(event._results)
247 |
248 | return results
249 |
250 | def publish_sync(self, event: Event) -> List[Any]:
251 | """
252 | 同步发布事件
253 |
254 | 参数:
255 | event: Event - 要发布的事件
256 |
257 | 返回值:
258 | List[Any] - 所有处理器返回的结果的列表
259 | """
260 | loop = asyncio.new_event_loop() # 创建新的事件循环
261 | try:
262 | asyncio.set_event_loop(loop) # 设置为当前事件循环
263 | return loop.run_until_complete(self.publish_async(event))
264 | finally:
265 | loop.close() # 关闭事件循环
266 |
--------------------------------------------------------------------------------
/src/ncatbot/plugin/event/filter.py:
--------------------------------------------------------------------------------
1 | import re
2 | from typing import Callable, Optional
3 |
4 | from ncatbot.plugin.event.event import Event
5 |
6 |
7 | class Filter:
8 | """消息过滤器基类"""
9 |
10 | def __init__(self):
11 | self.next_filter: Optional[Filter] = None
12 |
13 | def set_next(self, filter: "Filter") -> "Filter":
14 | """设置下一个过滤器,实现责任链模式"""
15 | self.next_filter = filter
16 | return filter
17 |
18 | def check(self, event: Event) -> bool:
19 | """检查事件是否通过过滤器"""
20 | if self._check(event):
21 | return True
22 | if self.next_filter:
23 | return self.next_filter.check(event)
24 | return False
25 |
26 | def _check(self, event: Event) -> bool:
27 | """具体的过滤逻辑,由子类实现"""
28 | raise NotImplementedError
29 |
30 |
31 | class PrefixFilter(Filter):
32 | """前缀匹配过滤器"""
33 |
34 | def __init__(self, prefix: str):
35 | super().__init__()
36 | self.prefix = prefix
37 |
38 | def _check(self, event: Event) -> bool:
39 | message = event.data.raw_message
40 | if not message:
41 | return False
42 | return message.startswith(self.prefix)
43 |
44 |
45 | class RegexFilter(Filter):
46 | """正则匹配过滤器"""
47 |
48 | def __init__(self, pattern: str):
49 | super().__init__()
50 | self.pattern = re.compile(pattern)
51 |
52 | def _check(self, event: Event) -> bool:
53 | message = event.data.raw_message
54 | if not message:
55 | return False
56 | return bool(self.pattern.match(message))
57 |
58 |
59 | class CustomFilter(Filter):
60 | """自定义过滤器"""
61 |
62 | def __init__(self, filter_func: Callable[[Event], bool]):
63 | super().__init__()
64 | self.filter_func = filter_func
65 |
66 | def _check(self, event: Event) -> bool:
67 | return self.filter_func(event.data)
68 |
69 |
70 | def create_filter(
71 | prefix: Optional[str] = None,
72 | regex: Optional[str] = None,
73 | custom_filter: Optional[Callable[[Event], bool]] = None,
74 | ) -> Optional[Filter]:
75 | """创建过滤器链
76 |
77 | Args:
78 | prefix: 前缀匹配
79 | regex: 正则匹配
80 | custom_filter: 自定义过滤函数
81 |
82 | Returns:
83 | 过滤器链的头部,如果所有参数都为None则返回None
84 | """
85 | filters = []
86 |
87 | if custom_filter:
88 | filters.append(CustomFilter(custom_filter))
89 |
90 | if prefix:
91 | filters.append(PrefixFilter(prefix))
92 |
93 | if regex:
94 | filters.append(RegexFilter(regex))
95 |
96 | if not filters:
97 | return None
98 |
99 | # 构建过滤器链
100 | for i in range(len(filters) - 1):
101 | filters[i].set_next(filters[i + 1])
102 |
103 | return filters[0]
104 |
--------------------------------------------------------------------------------
/src/ncatbot/plugin/loader/__init__.py:
--------------------------------------------------------------------------------
1 | from ncatbot.plugin.loader.compatible import CompatibleEnrollment
2 | from ncatbot.plugin.loader.loader import PluginLoader, install_plugin_dependencies
3 | from ncatbot.plugin.loader.pip_tool import PipTool
4 |
5 | __all__ = [
6 | "CompatibleEnrollment",
7 | "PluginLoader",
8 | "install_plugin_dependencies",
9 | "PipTool",
10 | ]
11 |
--------------------------------------------------------------------------------
/src/ncatbot/plugin/loader/compatible.py:
--------------------------------------------------------------------------------
1 | # -------------------------
2 | # @Author : Fish-LP fish.zh@outlook.com
3 | # @Date : 2025-02-21 18:23:06
4 | # @LastEditors : Fish-LP fish.zh@outlook.com
5 | # @LastEditTime : 2025-02-21 19:44:14
6 | # @Description : 喵喵喵, 我还没想好怎么介绍文件喵
7 | # @message: 喵喵喵?
8 | # @Copyright (c) 2025 by Fish-LP, Fcatbot使用许可协议
9 | # -------------------------
10 | import inspect
11 | from functools import wraps
12 | from weakref import WeakValueDictionary
13 |
14 | from ncatbot.plugin.event import Event
15 | from ncatbot.utils import (
16 | OFFICIAL_GROUP_MESSAGE_EVENT,
17 | OFFICIAL_NOTICE_EVENT,
18 | OFFICIAL_PRIVATE_MESSAGE_EVENT,
19 | OFFICIAL_REQUEST_EVENT,
20 | OFFICIAL_STARTUP_EVENT,
21 | )
22 |
23 |
24 | class CompatibleEnrollment:
25 | """兼容注册器"""
26 |
27 | events = {
28 | OFFICIAL_PRIVATE_MESSAGE_EVENT: [],
29 | OFFICIAL_GROUP_MESSAGE_EVENT: [],
30 | OFFICIAL_REQUEST_EVENT: [],
31 | OFFICIAL_NOTICE_EVENT: [],
32 | OFFICIAL_STARTUP_EVENT: [],
33 | }
34 |
35 | def __init__(self):
36 | self.plugins: WeakValueDictionary = WeakValueDictionary()
37 | raise ValueError("不需要实例化该类") # 防止实例化该类
38 |
39 | def event_decorator(event_type):
40 | """装饰器工厂,生成特定事件类型的装饰器"""
41 |
42 | def decorator_generator(types="all", row_event=False):
43 | def decorator(func):
44 | signature = inspect.signature(func)
45 | in_class = (
46 | len(signature.parameters) > 1
47 | or signature.parameters.get("self") is not None
48 | )
49 | if in_class:
50 | if row_event:
51 |
52 | @wraps(func)
53 | def wrapper(self, event: Event):
54 | return func(self, event)
55 |
56 | else:
57 |
58 | @wraps(func)
59 | def wrapper(self, event: Event):
60 | if len(signature.parameters) > 1:
61 | return func(self, event.data)
62 | else:
63 | return func(self)
64 |
65 | else:
66 | if row_event:
67 |
68 | @wraps(func)
69 | def wrapper(event: Event):
70 | return func(event)
71 |
72 | else:
73 |
74 | @wraps(func)
75 | def wrapper(event: Event):
76 | if len(signature.parameters) > 0:
77 | return func(event.data)
78 | else:
79 | return func()
80 |
81 | CompatibleEnrollment.events[event_type].append(
82 | (
83 | wrapper,
84 | 0,
85 | in_class,
86 | )
87 | )
88 | return wrapper
89 |
90 | return decorator
91 |
92 | return decorator_generator
93 |
94 | # 自动生成各个事件类型的装饰器
95 | group_event = event_decorator(OFFICIAL_GROUP_MESSAGE_EVENT)
96 | private_event = event_decorator(OFFICIAL_PRIVATE_MESSAGE_EVENT)
97 | notice_event = event_decorator(OFFICIAL_NOTICE_EVENT)
98 | request_event = event_decorator(OFFICIAL_REQUEST_EVENT)
99 | startup_event = event_decorator(OFFICIAL_STARTUP_EVENT)
100 |
--------------------------------------------------------------------------------
/src/ncatbot/scripts/__init__.py:
--------------------------------------------------------------------------------
1 | from ncatbot.scripts.utils import get_plugin_info, get_plugin_info_by_name
2 |
3 | __all__ = [
4 | "get_plugin_info",
5 | "get_plugin_info_by_name",
6 | ]
7 |
--------------------------------------------------------------------------------
/src/ncatbot/scripts/utils.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from ncatbot.plugin import PluginLoader
4 | from ncatbot.utils import PLUGINS_DIR
5 |
6 |
7 | def get_plugin_info(path: str):
8 | if os.path.exists(path):
9 | return PluginLoader(None).get_plugin_info(path)
10 | else:
11 | raise FileNotFoundError(f"dir not found: {path}")
12 |
13 |
14 | def get_plugin_info_by_name(name: str):
15 | """
16 | Args:
17 | name (str): 插件名
18 | Returns:
19 | Tuple[bool, str]: 是否存在插件, 插件版本
20 | """
21 | plugin_path = os.path.join(PLUGINS_DIR, name)
22 | if os.path.exists(plugin_path):
23 | return True, get_plugin_info(plugin_path)[1]
24 | else:
25 | return False, "0.0.0"
26 |
--------------------------------------------------------------------------------
/src/ncatbot/utils/__init__.py:
--------------------------------------------------------------------------------
1 | from ncatbot.utils.assets import (
2 | EVENT_QUEUE_MAX_SIZE,
3 | INSTALL_SCRIPT_URL,
4 | LINUX_NAPCAT_DIR,
5 | META_CONFIG_PATH,
6 | NAPCAT_CLI_PATH,
7 | NAPCAT_CLI_URL,
8 | NAPCAT_WEBUI_SALT,
9 | OFFICIAL_GROUP_MESSAGE_EVENT,
10 | OFFICIAL_HEARTBEAT_EVENT,
11 | OFFICIAL_NOTICE_EVENT,
12 | OFFICIAL_PRIVATE_MESSAGE_EVENT,
13 | OFFICIAL_REQUEST_EVENT,
14 | OFFICIAL_SHUTDOWN_EVENT,
15 | OFFICIAL_STARTUP_EVENT,
16 | PERSISTENT_DIR,
17 | PLUGIN_BROKEN_MARK,
18 | PLUGINS_DIR,
19 | PYPI_URL,
20 | REQUEST_SUCCESS,
21 | STATUS_BUSY,
22 | STATUS_DND,
23 | STATUS_HIDDEN,
24 | STATUS_LEARNING,
25 | STATUS_LEAVE,
26 | STATUS_LISTENING,
27 | STATUS_LOVE_YOU,
28 | STATUS_ONLINE,
29 | STATUS_Q_ME,
30 | WINDOWS_NAPCAT_DIR,
31 | Color,
32 | DefaultPermission,
33 | InvalidPluginStateError,
34 | PermissionGroup,
35 | PluginCircularDependencyError,
36 | PluginDependencyError,
37 | PluginLoadError,
38 | PluginNotFoundError,
39 | PluginUnloadError,
40 | PluginVersionError,
41 | Status,
42 | )
43 | from ncatbot.utils.config import config
44 | from ncatbot.utils.file_io import (
45 | UniversalLoader,
46 | convert_uploadable_object,
47 | read_file,
48 | unzip_file,
49 | )
50 | from ncatbot.utils.function_enhance import (
51 | add_sync_methods,
52 | report,
53 | run_func_async,
54 | run_func_sync,
55 | to_async,
56 | to_sync,
57 | )
58 | from ncatbot.utils.logger import get_log
59 | from ncatbot.utils.network_io import download_file, get_proxy_url
60 | from ncatbot.utils.optional import (
61 | ChangeDir,
62 | TimeTaskScheduler,
63 | md_maker,
64 | visualize_tree,
65 | )
66 |
67 | __all__ = [
68 | "SetConfig",
69 | "get_log",
70 | "get_proxy_url",
71 | "download_file",
72 | "UniversalLoader",
73 | "read_file",
74 | "convert_uploadable_object",
75 | "unzip_file",
76 | "config",
77 | "report",
78 | "to_sync",
79 | "to_async",
80 | "run_func_sync",
81 | "run_func_async",
82 | "add_sync_methods",
83 | # literals
84 | "NAPCAT_WEBUI_SALT",
85 | "WINDOWS_NAPCAT_DIR",
86 | "LINUX_NAPCAT_DIR",
87 | "INSTALL_SCRIPT_URL",
88 | "NAPCAT_CLI_URL",
89 | "PYPI_URL",
90 | "NAPCAT_CLI_PATH",
91 | "REQUEST_SUCCESS",
92 | "OFFICIAL_GROUP_MESSAGE_EVENT",
93 | "OFFICIAL_PRIVATE_MESSAGE_EVENT",
94 | "OFFICIAL_REQUEST_EVENT",
95 | "OFFICIAL_NOTICE_EVENT",
96 | "OFFICIAL_STARTUP_EVENT",
97 | "OFFICIAL_SHUTDOWN_EVENT",
98 | "OFFICIAL_HEARTBEAT_EVENT",
99 | "PLUGIN_BROKEN_MARK",
100 | "STATUS_ONLINE",
101 | "STATUS_Q_ME",
102 | "STATUS_LEAVE",
103 | "STATUS_BUSY",
104 | "STATUS_DND",
105 | "STATUS_HIDDEN",
106 | "STATUS_LISTENING",
107 | "STATUS_LOVE_YOU",
108 | "STATUS_LEARNING",
109 | "Status",
110 | "PermissionGroup",
111 | "DefaultPermission",
112 | "EVENT_QUEUE_MAX_SIZE",
113 | "PLUGINS_DIR",
114 | "META_CONFIG_PATH",
115 | "PERSISTENT_DIR",
116 | # custom errors
117 | "PluginCircularDependencyError",
118 | "PluginNotFoundError",
119 | "PluginLoadError",
120 | "PluginDependencyError",
121 | "PluginVersionError",
122 | "PluginUnloadError",
123 | "InvalidPluginStateError",
124 | # 为什么导入了不写进__alll__里……
125 | "Color",
126 | "TimeTaskScheduler",
127 | "ChangeDir",
128 | "md_maker",
129 | "visualize_tree",
130 | ]
131 |
--------------------------------------------------------------------------------
/src/ncatbot/utils/assets/__init__.py:
--------------------------------------------------------------------------------
1 | # 静态资源
2 |
3 |
4 | from ncatbot.utils.assets.color import Color
5 | from ncatbot.utils.assets.literals import (
6 | EVENT_QUEUE_MAX_SIZE,
7 | INSTALL_SCRIPT_URL,
8 | LINUX_NAPCAT_DIR,
9 | META_CONFIG_PATH,
10 | NAPCAT_CLI_PATH,
11 | NAPCAT_CLI_URL,
12 | NAPCAT_WEBUI_SALT,
13 | OFFICIAL_GROUP_MESSAGE_EVENT,
14 | OFFICIAL_HEARTBEAT_EVENT,
15 | OFFICIAL_NOTICE_EVENT,
16 | OFFICIAL_PRIVATE_MESSAGE_EVENT,
17 | OFFICIAL_REQUEST_EVENT,
18 | OFFICIAL_SHUTDOWN_EVENT,
19 | OFFICIAL_STARTUP_EVENT,
20 | PERSISTENT_DIR,
21 | PLUGIN_BROKEN_MARK,
22 | PLUGINS_DIR,
23 | PYPI_URL,
24 | REQUEST_SUCCESS,
25 | STATUS_BUSY,
26 | STATUS_DND,
27 | STATUS_HIDDEN,
28 | STATUS_LEARNING,
29 | STATUS_LEAVE,
30 | STATUS_LISTENING,
31 | STATUS_LOVE_YOU,
32 | STATUS_ONLINE,
33 | STATUS_Q_ME,
34 | WINDOWS_NAPCAT_DIR,
35 | DefaultPermission,
36 | PermissionGroup,
37 | Status,
38 | )
39 | from ncatbot.utils.assets.plugin_custom_err import (
40 | InvalidPluginStateError,
41 | PluginCircularDependencyError,
42 | PluginDependencyError,
43 | PluginLoadError,
44 | PluginNotFoundError,
45 | PluginUnloadError,
46 | PluginVersionError,
47 | )
48 |
49 | __all__ = [
50 | "PluginCircularDependencyError",
51 | "PluginDependencyError",
52 | "PluginNotFoundError",
53 | "PluginVersionError",
54 | "Color",
55 | # literals
56 | "NAPCAT_WEBUI_SALT",
57 | "WINDOWS_NAPCAT_DIR",
58 | "LINUX_NAPCAT_DIR",
59 | "INSTALL_SCRIPT_URL",
60 | "NAPCAT_CLI_URL",
61 | "PYPI_URL",
62 | "NAPCAT_CLI_PATH",
63 | "REQUEST_SUCCESS",
64 | "OFFICIAL_GROUP_MESSAGE_EVENT",
65 | "OFFICIAL_PRIVATE_MESSAGE_EVENT",
66 | "OFFICIAL_REQUEST_EVENT",
67 | "OFFICIAL_NOTICE_EVENT",
68 | "OFFICIAL_STARTUP_EVENT",
69 | "OFFICIAL_SHUTDOWN_EVENT",
70 | "OFFICIAL_HEARTBEAT_EVENT",
71 | "PLUGIN_BROKEN_MARK",
72 | "STATUS_ONLINE",
73 | "STATUS_Q_ME",
74 | "STATUS_LEAVE",
75 | "STATUS_BUSY",
76 | "STATUS_DND",
77 | "STATUS_HIDDEN",
78 | "STATUS_LISTENING",
79 | "STATUS_LOVE_YOU",
80 | "STATUS_LEARNING",
81 | "Status",
82 | "PermissionGroup",
83 | "DefaultPermission",
84 | "EVENT_QUEUE_MAX_SIZE",
85 | "PLUGINS_DIR",
86 | "META_CONFIG_PATH",
87 | "PERSISTENT_DIR",
88 | # custom errors
89 | "PluginCircularDependencyError",
90 | "PluginNotFoundError",
91 | "PluginLoadError",
92 | "PluginDependencyError",
93 | "PluginVersionError",
94 | "PluginUnloadError",
95 | "InvalidPluginStateError",
96 | ]
97 |
--------------------------------------------------------------------------------
/src/ncatbot/utils/assets/color.py:
--------------------------------------------------------------------------------
1 | # 终端颜色配置
2 |
3 |
4 | class Color:
5 | """
6 | 用于在终端中显示颜色和样式。
7 |
8 | 包含以下功能:
9 | - 前景: 设置颜色
10 | - 背景: 设置背景颜色
11 | - 样式: 设置样式(如加粗、下划线、反转)
12 | - RESET: 重置所有颜色和样式
13 | - from_rgb: 从 RGB 代码创建颜色
14 | """
15 |
16 | _COLOR = True # 假设终端支持 ANSI 颜色,实际使用时可能需要检测
17 |
18 | def __getattribute__(self, name):
19 | if self._COLOR:
20 | return super().__getattribute__(name)
21 | else:
22 | return ""
23 |
24 | # 前景颜色
25 | BLACK = "\033[30m"
26 | """前景-黑"""
27 | RED = "\033[31m"
28 | """前景-红"""
29 | GREEN = "\033[32m"
30 | """前景-绿"""
31 | YELLOW = "\033[33m"
32 | """前景-黄"""
33 | BLUE = "\033[34m"
34 | """前景-蓝"""
35 | MAGENTA = "\033[35m"
36 | """前景-品红"""
37 | CYAN = "\033[36m"
38 | """前景-青"""
39 | WHITE = "\033[37m"
40 | """前景-白"""
41 | GRAY = "\033[90m"
42 | """前景-灰"""
43 |
44 | # 背景颜色
45 | BG_BLACK = "\033[40m"
46 | """背景-黑"""
47 | BG_RED = "\033[41m"
48 | """背景-红"""
49 | BG_GREEN = "\033[42m"
50 | """背景-绿"""
51 | BG_YELLOW = "\033[43m"
52 | """背景-黄"""
53 | BG_BLUE = "\033[44m"
54 | """背景-蓝"""
55 | BG_MAGENTA = "\033[45m"
56 | """背景-品红"""
57 | BG_CYAN = "\033[46m"
58 | """背景-青"""
59 | BG_WHITE = "\033[47m"
60 | """背景-白"""
61 | BG_GRAY = "\033[100m"
62 | """背景-灰"""
63 |
64 | # 样式
65 | RESET = "\033[0m"
66 | """重置所有颜色和样式"""
67 | BOLD = "\033[1m"
68 | """加粗"""
69 | UNDERLINE = "\033[4m"
70 | """下划线"""
71 | REVERSE = "\033[7m"
72 | """反转(前景色和背景色互换)"""
73 | ITALIC = "\033[3m"
74 | """斜体"""
75 | BLINK = "\033[5m"
76 | """闪烁"""
77 | STRIKE = "\033[9m"
78 | """删除线"""
79 |
80 | @classmethod
81 | def from_rgb(cls, r, g, b, background=False):
82 | """
83 | 从 RGB 颜色代码创建颜色代码。
84 |
85 | :param r: 红色分量 (0-255)
86 | :param g: 绿色分量 (0-255)
87 | :param b: 蓝色分量 (0-255)
88 | :param background: 是否是背景颜色,默认为前景颜色
89 | :return: ANSI 颜色代码
90 | """
91 | if not cls._COLOR:
92 | return ""
93 | if background:
94 | return f"\033[48;2;{r};{g};{b}m"
95 | else:
96 | return f"\033[38;2;{r};{g};{b}m"
97 |
98 | @classmethod
99 | def rgb(cls, r, g, b):
100 | """
101 | 创建前景 RGB 颜色。
102 |
103 | :param r: 红色分量 (0-255)
104 | :param g: 绿色分量 (0-255)
105 | :param b: 蓝色分量 (0-255)
106 | :return: ANSI 前景颜色代码
107 | """
108 | return cls.from_rgb(r, g, b, background=False)
109 |
110 | @classmethod
111 | def bg_rgb(cls, r, g, b):
112 | """
113 | 创建背景 RGB 颜色。
114 |
115 | :param r: 红色分量 (0-255)
116 | :param g: 绿色分量 (0-255)
117 | :param b: 蓝色分量 (0-255)
118 | :return: ANSI 背景颜色代码
119 | """
120 | return cls.from_rgb(r, g, b, background=True)
121 |
122 | # 256 色模式
123 | @classmethod
124 | def color256(cls, color_code, background=False):
125 | """
126 | 使用 256 色模式创建颜色。
127 |
128 | :param color_code: 256 色中的颜色编号 (0-255)
129 | :param background: 是否是背景颜色,默认为前景颜色
130 | :return: ANSI 颜色代码
131 | """
132 | if not cls._COLOR:
133 | return ""
134 | if background:
135 | return f"\033[48;5;{color_code}m"
136 | else:
137 | return f"\033[38;5;{color_code}m"
138 |
139 | @classmethod
140 | def rgb256(cls, r, g, b, background=False):
141 | """
142 | 将 RGB 颜色转换为最接近的 256 色。
143 |
144 | :param r: 红色分量 (0-255)
145 | :param g: 绿色分量 (0-255)
146 | :param b: 蓝色分量 (0-255)
147 | :param background: 是否是背景颜色,默认为前景颜色
148 | :return: ANSI 256 色代码
149 | """
150 | if not cls._COLOR:
151 | return ""
152 |
153 | # 将 RGB 转换为 256 色
154 | def rgb_to_256(r, g, b):
155 | if r == g == b: # 灰度
156 | if r < 8:
157 | return 16
158 | if r > 248:
159 | return 231
160 | return round((r - 8) / 247 * 24) + 232
161 | return (
162 | 16
163 | + (36 * round(r / 255 * 5))
164 | + (6 * round(g / 255 * 5))
165 | + round(b / 255 * 5)
166 | )
167 |
168 | color_code = rgb_to_256(r, g, b)
169 | return cls.color256(color_code, background)
170 |
--------------------------------------------------------------------------------
/src/ncatbot/utils/assets/literals.py:
--------------------------------------------------------------------------------
1 | # 字面常量
2 | from enum import Enum
3 |
4 | WINDOWS_NAPCAT_DIR = "napcat"
5 | LINUX_NAPCAT_DIR = "/opt/QQ/resources/app/app_launcher/napcat"
6 |
7 | INSTALL_SCRIPT_URL = (
8 | "https://nclatest.znin.net/NapNeko/NapCat-Installer/main/script/install.sh"
9 | )
10 | NAPCAT_CLI_URL = (
11 | "https://nclatest.znin.net/NapNeko/NapCat-Installer/main/script/napcat_cli"
12 | )
13 | PYPI_URL = "https://mirrors.aliyun.com/pypi/simple/"
14 |
15 | NAPCAT_CLI_PATH = f"{LINUX_NAPCAT_DIR}/napcat_cli"
16 |
17 | REQUEST_SUCCESS = "ok"
18 | NAPCAT_WEBUI_SALT = "napcat"
19 |
20 | OFFICIAL_GROUP_MESSAGE_EVENT = "ncatbot.group_message_event"
21 | OFFICIAL_PRIVATE_MESSAGE_EVENT = "ncatbot.private_message_event"
22 | OFFICIAL_REQUEST_EVENT = "ncatbot.request_event"
23 | OFFICIAL_NOTICE_EVENT = "ncatbot.notice_event"
24 | OFFICIAL_STARTUP_EVENT = "ncatbot.startup_event"
25 | OFFICIAL_SHUTDOWN_EVENT = "ncatbot.shutdown_event"
26 | OFFICIAL_HEARTBEAT_EVENT = "ncatbot.heartbeat_event"
27 |
28 | PLUGIN_BROKEN_MARK = "插件已损坏"
29 |
30 | STATUS_ONLINE = {"status": 10, "ext_status": 0, "battery_status": 0}
31 | STATUS_Q_ME = {"status": 60, "ext_status": 0, "battery_status": 0}
32 | STATUS_LEAVE = {"status": 30, "ext_status": 0, "battery_status": 0}
33 | STATUS_BUSY = {"status": 50, "ext_status": 0, "battery_status": 0}
34 | STATUS_DND = {"status": 70, "ext_status": 0, "battery_status": 0}
35 | STATUS_HIDDEN = {"status": 40, "ext_status": 0, "battery_status": 0}
36 | STATUS_LISTENING = {"status": 10, "ext_status": 1028, "battery_status": 0}
37 | STATUS_LOVE_YOU = {"status": 10, "ext_status": 2006, "battery_status": 0}
38 | STATUS_LEARNING = {"status": 10, "ext_status": 1018, "battery_status": 0}
39 |
40 |
41 | class PermissionGroup(Enum):
42 | # 权限组常量
43 | ROOT = "root"
44 | ADMIN = "admin"
45 | USER = "user"
46 |
47 |
48 | class DefaultPermission(Enum):
49 | # 权限常量
50 | ACCESS = "access"
51 | SETADMIN = "setadmin"
52 |
53 |
54 | class Status:
55 | 在线 = {"status": 10, "ext_status": 0, "battery_status": 0}
56 | Q我吧 = {"status": 60, "ext_status": 0, "battery_status": 0}
57 | 离开 = {"status": 30, "ext_status": 0, "battery_status": 0}
58 | 忙碌 = {"status": 50, "ext_status": 0, "battery_status": 0}
59 | 请勿打扰 = {"status": 70, "ext_status": 0, "battery_status": 0}
60 | 隐身 = {"status": 40, "ext_status": 0, "battery_status": 0}
61 | 听歌中 = {"status": 10, "ext_status": 1028, "battery_status": 0}
62 | 春日限定 = {"status": 10, "ext_status": 2037, "battery_status": 0}
63 | 一起元梦 = {"status": 10, "ext_status": 2025, "battery_status": 0}
64 | 求星搭子 = {"status": 10, "ext_status": 2026, "battery_status": 0}
65 | 被掏空 = {"status": 10, "ext_status": 2014, "battery_status": 0}
66 | 今日天气 = {"status": 10, "ext_status": 1030, "battery_status": 0}
67 | 我crash了 = {"status": 10, "ext_status": 2019, "battery_status": 0}
68 | 爱你 = {"status": 10, "ext_status": 2006, "battery_status": 0}
69 | 恋爱中 = {"status": 10, "ext_status": 1051, "battery_status": 0}
70 | 好运锦鲤 = {"status": 10, "ext_status": 1071, "battery_status": 0}
71 | 水逆退散 = {"status": 10, "ext_status": 1201, "battery_status": 0}
72 | 嗨到飞起 = {"status": 10, "ext_status": 1056, "battery_status": 0}
73 | 元气满满 = {"status": 10, "ext_status": 1058, "battery_status": 0}
74 | 宝宝认证 = {"status": 10, "ext_status": 1070, "battery_status": 0}
75 | 一言难尽 = {"status": 10, "ext_status": 1063, "battery_status": 0}
76 | 难得糊涂 = {"status": 10, "ext_status": 2001, "battery_status": 0}
77 | emo中 = {"status": 10, "ext_status": 1401, "battery_status": 0}
78 | 我太难了 = {"status": 10, "ext_status": 1062, "battery_status": 0}
79 | 我想开了 = {"status": 10, "ext_status": 2013, "battery_status": 0}
80 | 我没事 = {"status": 10, "ext_status": 1052, "battery_status": 0}
81 | 想静静 = {"status": 10, "ext_status": 1061, "battery_status": 0}
82 | 悠哉哉 = {"status": 10, "ext_status": 1059, "battery_status": 0}
83 | 去旅行 = {"status": 10, "ext_status": 2015, "battery_status": 0}
84 | 信号弱 = {"status": 10, "ext_status": 1011, "battery_status": 0}
85 | 出去浪 = {"status": 10, "ext_status": 2003, "battery_status": 0}
86 | 肝作业 = {"status": 10, "ext_status": 2012, "battery_status": 0}
87 | 学习中 = {"status": 10, "ext_status": 1018, "battery_status": 0}
88 | 搬砖中 = {"status": 10, "ext_status": 2023, "battery_status": 0}
89 | 摸鱼中 = {"status": 10, "ext_status": 1300, "battery_status": 0}
90 | 无聊中 = {"status": 10, "ext_status": 1060, "battery_status": 0}
91 | timi中 = {"status": 10, "ext_status": 1027, "battery_status": 0}
92 | 睡觉中 = {"status": 10, "ext_status": 1016, "battery_status": 0}
93 | 熬夜中 = {"status": 10, "ext_status": 1032, "battery_status": 0}
94 | 追剧中 = {"status": 10, "ext_status": 1021, "battery_status": 0}
95 | 我的电量 = {"status": 10, "ext_status": 1000, "battery_status": 0}
96 |
97 |
98 | EVENT_QUEUE_MAX_SIZE = 64 # 事件队列最大长度
99 | PLUGINS_DIR = "plugins" # 插件目录
100 | META_CONFIG_PATH = None # 元数据,所有插件一份(只读)
101 | PERSISTENT_DIR = "data" # 插件私有数据目录
102 |
103 | __all__ = [
104 | "NAPCAT_WEBUI_SALT",
105 | "WINDOWS_NAPCAT_DIR",
106 | "LINUX_NAPCAT_DIR",
107 | "INSTALL_SCRIPT_URL",
108 | "NAPCAT_CLI_URL",
109 | "PYPI_URL",
110 | "NAPCAT_CLI_PATH",
111 | "REQUEST_SUCCESS",
112 | "OFFICIAL_GROUP_MESSAGE_EVENT",
113 | "OFFICIAL_PRIVATE_MESSAGE_EVENT",
114 | "OFFICIAL_REQUEST_EVENT",
115 | "OFFICIAL_NOTICE_EVENT",
116 | "OFFICIAL_STARTUP_EVENT",
117 | "OFFICIAL_SHUTDOWN_EVENT",
118 | "OFFICIAL_HEARTBEAT_EVENT",
119 | "PLUGIN_BROKEN_MARK",
120 | "STATUS_ONLINE",
121 | "STATUS_Q_ME",
122 | "STATUS_LEAVE",
123 | "STATUS_BUSY",
124 | "STATUS_DND",
125 | "STATUS_HIDDEN",
126 | "STATUS_LISTENING",
127 | "STATUS_LOVE_YOU",
128 | "STATUS_LEARNING",
129 | "Status",
130 | "PermissionGroup",
131 | "DefaultPermission",
132 | "EVENT_QUEUE_MAX_SIZE",
133 | "PLUGINS_DIR",
134 | "META_CONFIG_PATH",
135 | "PERSISTENT_DIR",
136 | ]
137 |
--------------------------------------------------------------------------------
/src/ncatbot/utils/assets/plugin_custom_err.py:
--------------------------------------------------------------------------------
1 | # -------------------------
2 | # @Author : Fish-LP fish.zh@outlook.com
3 | # @Date : 2025-02-21 18:23:06
4 | # @LastEditors : Fish-LP fish.zh@outlook.com
5 | # @LastEditTime : 2025-03-06 19:08:27
6 | # @Description : 插件类的自定义异常
7 | # @message: 喵喵喵?
8 | # @Copyright (c) 2025 by Fish-LP, Fcatbot使用许可协议
9 | # -------------------------
10 | class PluginSystemError(Exception):
11 | pass
12 |
13 |
14 | class PluginCircularDependencyError(PluginSystemError):
15 | def __init__(self, dependency_chain):
16 | super().__init__(f"检测到插件循环依赖: {' -> '.join(dependency_chain)}->...")
17 |
18 |
19 | class PluginNotFoundError(PluginSystemError):
20 | def __init__(self, plugin_name):
21 | super().__init__(f"插件 '{plugin_name}' 未找到")
22 |
23 |
24 | class PluginLoadError(PluginSystemError):
25 | def __init__(self, plugin_name, reason):
26 | super().__init__(f"无法加载插件 '{plugin_name}' : {reason}")
27 |
28 |
29 | class PluginDependencyError(PluginSystemError):
30 | def __init__(self, plugin_name, missing_dependency, version_constraints):
31 | super().__init__(
32 | f"插件 '{plugin_name}' 缺少依赖: '{missing_dependency}' {version_constraints}"
33 | )
34 |
35 |
36 | class PluginVersionError(PluginSystemError):
37 | def __init__(self, plugin_name, required_plugin, required_version, actual_version):
38 | super().__init__(
39 | f"插件 '{plugin_name}' 的依赖 '{required_plugin}' 版本不满足要求: 要求 '{required_version}', 实际版本 '{actual_version}'"
40 | )
41 |
42 |
43 | class PluginUnloadError(PluginSystemError):
44 | def __init__(self, plugin_name, reason):
45 | super().__init__(f"无法卸载插件 '{plugin_name}': {reason}")
46 |
47 |
48 | class InvalidPluginStateError(PluginSystemError):
49 | def __init__(self, plugin_name, state):
50 | super().__init__(f"插件 '{plugin_name}' 处于无效状态: {state}")
51 |
52 |
53 | __all__ = [
54 | "PluginCircularDependencyError",
55 | "PluginNotFoundError",
56 | "PluginLoadError",
57 | "PluginDependencyError",
58 | "PluginVersionError",
59 | "PluginUnloadError",
60 | "InvalidPluginStateError",
61 | ]
62 |
--------------------------------------------------------------------------------
/src/ncatbot/utils/config.py:
--------------------------------------------------------------------------------
1 | # 配置文件
2 | import os
3 | import time
4 | import urllib
5 | import urllib.parse
6 |
7 | import yaml
8 |
9 | from ncatbot.utils.logger import get_log
10 |
11 | LOG = get_log()
12 |
13 |
14 | class SetConfig:
15 |
16 | default_root = "123456"
17 | default_bt_uin = "123456"
18 | default_ws_uri = "ws://localhost:3001"
19 | default_webui_uri = "http://localhost:6099"
20 | default_webui_token = "napcat"
21 |
22 | def __init__(self):
23 | # 内部状态
24 | # 常用状态
25 | self.root = self.default_root # root 账号
26 | self.bt_uin = self.default_bt_uin # bot 账号
27 | self.ws_uri = self.default_ws_uri # ws 地址
28 | self.webui_uri = self.default_webui_uri # webui 地址
29 | self.webui_token = self.default_webui_token # webui 令牌
30 | self.ws_token = "" # ws_uri 令牌
31 | self.ws_listen_ip = "localhost" # ws 监听 ip, 默认只监听本机
32 | self.remote_mode = False # 是否远程模式, 即 NapCat 服务不在本机运行
33 | self.github_proxy = os.getenv(
34 | "GITHUB_PROXY", None
35 | ) # github 代理地址, 为 None 则自动获取, 为 "" 则不使用代理
36 | """
37 | 如果纯远程模式, 则 NcatBot 不对 NapCat 的行为做任何管理.
38 | 只使用 ws_uri 和 webui_uri 和 NapCat 进行交互, 不会配置启动 NapCat
39 | """
40 |
41 | # 更新检查
42 | self.check_napcat_update = False # 是否检查 napcat 更新
43 | self.check_ncatbot_update = True # 是否检查 ncatbot 更新
44 |
45 | # 开发者调试
46 | self.debug = False # 是否开启调试模式
47 | self.skip_ncatbot_install_check = False # 是否跳过 napcat 安装检查
48 | self.skip_plugin_load = False # 是否跳过插件加载
49 |
50 | # 插件加载控制
51 | self.plugin_whitelist = [] # 插件白名单,为空表示不启用白名单
52 | self.plugin_blacklist = [] # 插件黑名单,为空表示不启用黑名单
53 | self.check_plugin_dependecies = False # 加载时不检查插件 Python 第三方依赖
54 |
55 | # NapCat 行为
56 | self.stop_napcat = False # NcatBot 下线时是否停止 NapCat
57 | self.enable_webui_interaction = True # 是否允许 NcatBot 与 NapCat webui 交互
58 | self.suppress_client_initial_error = (
59 | False # 是否屏蔽实例化BotClient时的RuntimeError
60 | )
61 | self.report_self_message = False # 是否报告 Bot 自己的消息
62 |
63 | """
64 | 如果 enable_webui_interaction 为 False, 则 NcatBot 不会与 NapCat webui 交互
65 | 账号检查, 引导登录等行为均不会发生, 只使用 ws_uri 与 NapCat 交互
66 | """
67 |
68 | # 自动获取状态
69 | self.ws_host = None # ws host
70 | self.webui_host = None # webui host
71 | self.ws_port = None # ws 端口
72 | self.webui_port = None # webui 端口
73 |
74 | # 暂不支持的状态
75 |
76 | # 尝试从默认配置文件加载
77 | self._load_default_config()
78 |
79 | def _load_default_config(self):
80 | """尝试从默认配置文件加载配置"""
81 | default_config_path = "config.yaml"
82 | if os.path.exists(default_config_path):
83 | try:
84 | LOG.debug(f"从默认配置文件 {default_config_path} 加载配置")
85 | self.load_config(default_config_path)
86 | except Exception as e:
87 | LOG.error(f"加载默认配置文件失败: {e}")
88 |
89 | def __str__(self):
90 | return f"[BOTQQ]: {self.bt_uin} | [WSURI]: {self.ws_uri} | [WS_TOKEN]: {self.ws_token} | [ROOT]: {self.root} | [WEBUI]: {self.webui_uri}"
91 |
92 | def load_config(self, path):
93 | try:
94 | with open(path, "r", encoding="utf-8") as f:
95 | conf = yaml.safe_load(f)
96 | except FileNotFoundError as e:
97 | LOG.warning("未找到配置文件")
98 | raise ValueError("[setting] 配置文件不存在,请检查!") from e
99 | except yaml.YAMLError as e:
100 | raise ValueError("[setting] 配置文件格式错误,请检查!") from e
101 | except Exception as e:
102 | raise ValueError(f"[setting] 未知错误:{e}") from e
103 | try:
104 | self.__dict__.update(conf)
105 | except KeyError as e:
106 | raise KeyError(f"[setting] 缺少配置项,请检查!详情:{e}") from e
107 |
108 | def set_root(self, root: str):
109 | self.root = root
110 |
111 | def set_ws_uri(self, ws_uri: str):
112 | self.ws_uri = ws_uri
113 | self._standardize_ws_uri()
114 |
115 | def set_bot_uin(self, uin: str):
116 | self.bt_uin = uin
117 |
118 | def set_token(self, token: str):
119 | # 即将弃用
120 | self.ws_token = token
121 |
122 | def set_ws_token(self, token: str):
123 | self.ws_token = token
124 |
125 | def set_webui_token(self, token: str):
126 | self.webui_token = token
127 |
128 | def set_webui_uri(self, webui_uri: str):
129 | self.webui_uri = webui_uri
130 | self._standardize_webui_uri()
131 |
132 | def set_other_config(self, **kwargs):
133 | for k, v in kwargs.items():
134 | setattr(self, k, v)
135 |
136 | def is_plugin_enabled(self, plugin_name: str) -> bool:
137 | """检查插件是否应该被加载
138 |
139 | Args:
140 | plugin_name: 插件名称
141 |
142 | Returns:
143 | bool: 如果插件应该被加载则返回True,否则返回False
144 | """
145 | # 如果白名单和黑名单都为空,则加载所有插件
146 | if not self.plugin_whitelist and not self.plugin_blacklist:
147 | return True
148 |
149 | # 如果白名单不为空,则只加载白名单中的插件
150 | if self.plugin_whitelist:
151 | return plugin_name in self.plugin_whitelist
152 |
153 | # 如果黑名单不为空,则不加载黑名单中的插件
154 | if self.plugin_blacklist:
155 | return plugin_name not in self.plugin_blacklist
156 |
157 | # 默认加载所有插件
158 | return True
159 |
160 | def validate_config(self):
161 | def to_str():
162 | self.bt_uin = str(self.bt_uin)
163 | self.root = str(self.root)
164 |
165 | # 转为 str
166 | to_str()
167 |
168 | # 检查 bot_uin 和 root
169 | if self.bt_uin is self.default_bt_uin:
170 | LOG.warning("配置项中没有设置 QQ 号")
171 | self.set_bot_uin(input("请输入 bot 的 QQ 号:"))
172 | if self.root is self.default_root:
173 | LOG.warning("建议设置好 root 账号保证权限功能能够正常使用")
174 | LOG.info(self)
175 |
176 | # 检验插件白名单和黑名单
177 | if self.plugin_whitelist and self.plugin_blacklist:
178 | LOG.error("插件白名单和黑名单不能同时设置,请检查配置")
179 | raise ValueError("插件白名单和黑名单不能同时设置,请检查配置")
180 |
181 | if self.plugin_whitelist:
182 | LOG.info(f"已启用插件白名单: {', '.join(self.plugin_whitelist)}")
183 | elif self.plugin_blacklist:
184 | LOG.info(f"已启用插件黑名单: {', '.join(self.plugin_blacklist)}")
185 | else:
186 | LOG.info("未启用插件白名单或黑名单,将加载所有插件")
187 |
188 | # 检验 ws_uri
189 | self._standardize_ws_uri()
190 | if self.ws_host != "localhost" and self.ws_host != "127.0.0.1":
191 | LOG.info(
192 | "请注意, 当前配置的 NapCat 服务不是本地地址, 请确保远端 NapCat 服务正确配置."
193 | )
194 | time.sleep(1)
195 |
196 | # 检验 ws_listen_ip
197 | if self.ws_listen_ip not in {"0.0.0.0", self.ws_host}:
198 | LOG.warning("当前的 ws 监听地址与 ws 地址不一致, 可能无法正确连接")
199 |
200 | # 检验 webui_uri
201 | self._standardize_webui_uri()
202 |
203 | def _standardize_ws_uri(self):
204 | if not (self.ws_uri.startswith("ws://") or self.ws_uri.startswith("wss://")):
205 | self.ws_uri = f"ws://{self.ws_uri}"
206 | self.ws_host = urllib.parse.urlparse(self.ws_uri).hostname
207 | self.ws_port = urllib.parse.urlparse(self.ws_uri).port
208 |
209 | def _standardize_webui_uri(self):
210 | if not (
211 | self.webui_uri.startswith("http://")
212 | or self.webui_uri.startswith("https://")
213 | ):
214 | self.webui_uri = f"http://{self.webui_uri}"
215 | self.webui_host = urllib.parse.urlparse(self.webui_uri).hostname
216 | self.webui_port = urllib.parse.urlparse(self.webui_uri).port
217 |
218 |
219 | config = SetConfig()
220 |
--------------------------------------------------------------------------------
/src/ncatbot/utils/env_checker.py:
--------------------------------------------------------------------------------
1 | # 检查本地 NcatBot 环境
2 |
3 | import os
4 | import site
5 | import subprocess
6 | import sys
7 | import urllib
8 | import urllib.parse
9 |
10 | import requests
11 |
12 | from ncatbot.utils.assets.literals import PYPI_URL
13 | from ncatbot.utils.logger import get_log
14 |
15 | _log = get_log()
16 |
17 |
18 | def get_local_package_version(package_name):
19 | """
20 | 获取当前虚拟环境中已安装包的版本。
21 | :param package_name: 包名
22 | :return: 本地版本(字符串)或 None(如果包未安装)
23 | """
24 | try:
25 | # 指定 encoding 参数为 'utf-8'
26 | result = subprocess.run(
27 | [sys.executable, "-m", "pip", "list"],
28 | stdout=subprocess.PIPE,
29 | stderr=subprocess.PIPE,
30 | text=True,
31 | )
32 | for line in result.stdout.splitlines():
33 | if line.startswith("ncatbot"):
34 | parts = line.split() # 使用 split() 方法分割字符串,去除多余空格
35 | formatted_line = f"{parts[0]}: {parts[1]}"
36 | return formatted_line.split(": ")[1]
37 | return None # 如果没有找到版本信息或命令执行失败
38 | except subprocess.CalledProcessError:
39 | return None # pip 命令执行失败,包可能未安装
40 |
41 |
42 | def get_pypi_latest_version(package_name):
43 | """
44 | 获取 PyPI 上的最新版本。
45 | :param package_name: 包名
46 | :return: 最新版本(字符串)或 None(如果无法获取)
47 | """
48 | try:
49 | url = urllib.parse.urljoin(PYPI_URL, package_name + "/json")
50 | response = requests.get(url, timeout=3)
51 | response.raise_for_status() # 如果请求失败会抛出异常
52 | data = response.json()
53 | return data["info"]["version"]
54 | except (requests.exceptions.RequestException, requests.exceptions.Timeout):
55 | return None # 请求失败、超时或包不存在
56 |
57 |
58 | def is_package_installed(package_name):
59 | """
60 | 检查包是否已安装。
61 | :param package_name: 包名
62 | :return: True 如果包已安装,False 否则
63 | """
64 | # 获取当前环境的site-packages路径
65 | site_packages = site.getsitepackages() # 对于全局安装包
66 | user_site_packages = site.getusersitepackages() # 对于用户安装包
67 |
68 | # 针对不同平台,检查site-packages路径下是否存在该包
69 | for path in site_packages + [user_site_packages]:
70 | # 检查指定包是否存在于site-packages目录
71 | package_path = os.path.join(path, package_name)
72 | if os.path.exists(package_path):
73 | return True
74 |
75 | # 对于某些包,可能会有egg-info文件夹,我们也可以检查这个
76 | egg_info_path = os.path.join(path, f"{package_name}.egg-info")
77 | if os.path.exists(egg_info_path):
78 | return True
79 |
80 | return False
81 |
82 |
83 | def compare_versions(package_name):
84 | """
85 | 比较本地版本和 PyPI 上的版本,返回比较结果。
86 | :param package_name: 包名
87 | :return: 字典,包含安装状态、版本信息及比较结果
88 | """
89 | # 初始化返回值
90 | result = {
91 | "installed": False,
92 | "local_version": None,
93 | "latest_version": None,
94 | "update_available": False,
95 | "error": None,
96 | }
97 |
98 | # 检查包是否已安装
99 | if not is_package_installed(package_name):
100 | result["error"] = f"{package_name} 未安装"
101 | return result
102 |
103 | # 获取本地包版本
104 | local_version = get_local_package_version(package_name)
105 | if not local_version:
106 | result["error"] = f"{package_name} 未安装"
107 | return result
108 |
109 | # 获取 PyPI 最新版本
110 | latest_version = get_pypi_latest_version(package_name)
111 | if not latest_version:
112 | result["error"] = f"无法获取 {package_name} 在 PyPI 上的最新版本"
113 | return result
114 |
115 | # 更新结果
116 | result["installed"] = True
117 | result["local_version"] = local_version
118 | result["latest_version"] = latest_version
119 | result["update_available"] = local_version != latest_version
120 |
121 | return result
122 |
123 |
124 | def check_self_package_version():
125 | """
126 | 检查文件所在包的版本.
127 | """
128 | package_name = __package__
129 | result = compare_versions(package_name)
130 | if result["installed"]:
131 | if result["update_available"]:
132 | _log.warning("NcatBot 有可用更新!")
133 | _log.info("若使用 main.exe 或者 NcatBot CLI 启动, CLI 输入 update 即可更新")
134 | _log.info(
135 | "若手动安装, 推荐您使用以下命令更新: pip install --upgrade ncatbot"
136 | )
137 | return True
138 | else:
139 | if result["error"].startswith("无法获取"):
140 | _log.warning("获取 NcatBot 最新版本失败。")
141 | return True
142 | _log.error(f"包 {package_name} 未使用 pip 安装,请使用 pip 安装。")
143 | return False
144 |
145 |
146 | def check_linux_permissions(range: str = "all"):
147 | """检查Linux的root权限和包管理器
148 |
149 | Args:
150 | range (str): root, all
151 |
152 | Returns:
153 | str: root, package_manager, package_installer
154 | """
155 | try:
156 | result = subprocess.run(
157 | ["sudo", "whoami"],
158 | check=True,
159 | text=True,
160 | capture_output=True,
161 | )
162 | if result.stdout.strip() != "root":
163 | _log.error("当前用户不是root用户, 请使用sudo运行")
164 | raise Exception("当前用户不是root用户, 请使用sudo运行")
165 | except subprocess.CalledProcessError as e:
166 | _log.error(f"sudo 命令执行失败, 请检查错误: {e}")
167 | raise e
168 | except FileNotFoundError as e:
169 | _log.error("sudo 命令不存在, 请检查错误")
170 | raise e
171 | if range == "root":
172 | return "root"
173 | try:
174 | subprocess.run(
175 | ["apt-get", "--version"],
176 | check=True,
177 | stdout=subprocess.DEVNULL,
178 | stderr=subprocess.DEVNULL,
179 | )
180 | package_manager = "apt-get"
181 | except subprocess.CalledProcessError as e:
182 | _log.error(f"apt-get 命令执行失败, 请检查错误: {e}")
183 | raise e
184 | except FileNotFoundError:
185 | try:
186 | subprocess.run(
187 | ["dnf", "--version"],
188 | check=True,
189 | stdout=subprocess.DEVNULL,
190 | stderr=subprocess.DEVNULL,
191 | )
192 | package_manager = "dnf"
193 | except subprocess.CalledProcessError as e:
194 | _log.error(f"dnf 命令执行失败, 请检查错误: {e}")
195 | raise e
196 | except FileNotFoundError as e:
197 | _log.error("高级包管理器检查失败, 目前仅支持apt-get/dnf")
198 | raise e
199 | _log.info(f"当前高级包管理器: {package_manager}")
200 | try:
201 | subprocess.run(
202 | ["dpkg", "--version"],
203 | check=True,
204 | stdout=subprocess.DEVNULL,
205 | stderr=subprocess.DEVNULL,
206 | )
207 | package_installer = "dpkg"
208 | except subprocess.CalledProcessError as e:
209 | _log.error(f"dpkg 命令执行失败, 请检查错误: {e}")
210 | raise e
211 | except FileNotFoundError:
212 | try:
213 | subprocess.run(
214 | ["rpm", "--version"],
215 | check=True,
216 | stdout=subprocess.DEVNULL,
217 | stderr=subprocess.DEVNULL,
218 | )
219 | package_installer = "rpm"
220 | except subprocess.CalledProcessError as e:
221 | _log.error(f"rpm 命令执行失败, 请检查错误: {e}")
222 | raise e
223 | except FileNotFoundError as e:
224 | _log.error("基础包管理器检查失败, 目前仅支持 dpkg/rpm")
225 | raise e
226 | _log.info(f"当前基础包管理器: {package_installer}")
227 | return package_manager, package_installer
228 |
--------------------------------------------------------------------------------
/src/ncatbot/utils/function_enhance.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import inspect
3 | import traceback
4 | from functools import wraps
5 | from typing import Type, TypeVar
6 |
7 | from ncatbot.utils.assets import REQUEST_SUCCESS
8 | from ncatbot.utils.config import config
9 | from ncatbot.utils.logger import get_log
10 |
11 | T = TypeVar("T")
12 | _log = get_log()
13 |
14 |
15 | async def run_func_async(func, *args, **kwargs):
16 | # 异步运行异步或者同步的函数
17 | try:
18 | if inspect.iscoroutinefunction(func):
19 | return await func(*args, **kwargs)
20 | else:
21 | if config.__dict__.get("blocking_sync", False):
22 | return func(*args, **kwargs)
23 | else:
24 | import threading
25 |
26 | threading.Thread(
27 | target=func, args=args, kwargs=kwargs, daemon=True
28 | ).start()
29 | except Exception as e:
30 | _log.error(f"函数 {func.__name__} 执行失败: {e}")
31 | traceback.print_exc()
32 |
33 |
34 | def run_func_sync(func, *args, **kwargs):
35 | if inspect.iscoroutinefunction(func):
36 | # 同步运行一个异步或者同步的函数
37 | try:
38 | from threading import Thread
39 |
40 | loop = asyncio.get_running_loop()
41 | result = []
42 |
43 | def task():
44 | result.append(asyncio.run(func(*args, **kwargs)))
45 |
46 | t = Thread(target=task, daemon=True)
47 | t.start()
48 | t.join(timeout=5)
49 | if len(result) == 0:
50 | raise TimeoutError("异步函数执行超时")
51 | else:
52 | return result[0]
53 | except RuntimeError:
54 | pass
55 | try:
56 | loop = asyncio.new_event_loop() # 创建一个新的事件循环
57 | asyncio.set_event_loop(loop) # 设置为当前线程的事件循环
58 | return loop.run_until_complete(func(*args, **kwargs))
59 | finally:
60 | loop.close() # 关闭事件循环
61 | else:
62 | return func(*args, **kwargs)
63 |
64 |
65 | def to_sync(func):
66 | return lambda *args, **kwargs: run_func_sync(func, *args, **kwargs)
67 |
68 |
69 | def to_async(func):
70 | return lambda *args, **kwargs: run_func_async(func)(*args, **kwargs)
71 |
72 |
73 | def report(func):
74 | def check_and_log(result):
75 | if result.get("status", None) == REQUEST_SUCCESS:
76 | _log.debug(result)
77 | else:
78 | _log.warning(result)
79 | return result
80 |
81 | @wraps(func)
82 | async def wrapper(*args, **kwargs):
83 | result = await func(*args, **kwargs)
84 | return check_and_log(result)
85 |
86 | return wrapper
87 |
88 |
89 | def add_sync_methods(cls: Type[T]) -> Type[T]:
90 | """
91 | 类装饰器:为类动态添加同步版本的方法
92 | """
93 |
94 | def async_to_sync(async_func):
95 | """
96 | 装饰器:将异步函数转换为同步函数
97 | """
98 |
99 | @wraps(async_func) # 保留原始函数的文档信息
100 | def sync_func(*args, **kwargs):
101 | return to_sync(async_func)(*args, **kwargs)
102 |
103 | return sync_func
104 |
105 | for name, method in inspect.getmembers(cls, predicate=inspect.iscoroutinefunction):
106 | if name.startswith("_"): # 跳过私有方法
107 | continue
108 | sync_method_name = f"{name}_sync"
109 |
110 | # 获取原始方法的签名
111 | signature = inspect.signature(method)
112 | # 生成同步方法的文档字符串
113 | doc = f"""
114 | 同步版本的 {method.__name__}
115 | {method.__doc__}
116 | """
117 |
118 | # 动态生成同步方法
119 | sync_method = async_to_sync(method)
120 | sync_method.__signature__ = signature # 设置方法签名
121 | sync_method.__doc__ = doc # 设置文档字符串
122 |
123 | setattr(cls, sync_method_name, sync_method)
124 | return cls
125 |
--------------------------------------------------------------------------------
/src/ncatbot/utils/network_io.py:
--------------------------------------------------------------------------------
1 | import threading
2 | import time
3 | from concurrent.futures import ThreadPoolExecutor
4 | from queue import Queue
5 |
6 | import requests
7 | from tqdm import tqdm
8 |
9 | from ncatbot.utils import config
10 | from ncatbot.utils.logger import get_log
11 |
12 | _log = get_log()
13 |
14 |
15 | def download_file(url, file_name):
16 | """下载文件, 带进度条"""
17 | try:
18 | r = requests.get(url, stream=True)
19 | total_size = int(r.headers.get("content-length", 0))
20 | progress_bar = tqdm(
21 | total=total_size,
22 | unit="iB",
23 | unit_scale=True,
24 | desc=f"Downloading {file_name}",
25 | bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]",
26 | colour="green",
27 | dynamic_ncols=True,
28 | smoothing=0.3,
29 | mininterval=0.1,
30 | maxinterval=1.0,
31 | )
32 | with open(file_name, "wb") as f:
33 | for data in r.iter_content(chunk_size=1024):
34 | progress_bar.update(len(data))
35 | f.write(data)
36 | progress_bar.close()
37 | except Exception as e:
38 | _log.error(f"从 {url} 下载 {file_name} 失败")
39 | _log.error("错误信息:", e)
40 | return
41 |
42 |
43 | def get_proxy_url():
44 | """获取 github 代理 URL"""
45 | if config.github_proxy is not None:
46 | return config.github_proxy
47 | TIMEOUT = 5
48 | github_proxy_urls = [
49 | "https://ghfast.top/",
50 | "https://github.7boe.top/",
51 | "https://cdn.moran233.xyz/",
52 | "https://gh-proxy.ygxz.in/",
53 | "https://github.whrstudio.top/",
54 | "https://proxy.yaoyaoling.net/",
55 | "https://ghproxy.net/",
56 | "https://fastgit.cc/",
57 | "https://git.886.be/",
58 | "https://gh-proxy.com/",
59 | ]
60 | result_queue = Queue(maxsize=1)
61 |
62 | def check_proxy(url):
63 | try:
64 | response = requests.get(url, timeout=TIMEOUT)
65 | if response.status_code == 200:
66 | result_queue.put(url)
67 | except TimeoutError as e:
68 | _log.warning(f"请求失败: {url}, 错误: {e}")
69 |
70 | with ThreadPoolExecutor(max_workers=10) as executor:
71 | for url in github_proxy_urls:
72 | executor.submit(check_proxy, url)
73 | time.sleep(TIMEOUT)
74 | executor._threads.clear()
75 |
76 | available_proxy = []
77 | try:
78 | while True:
79 | available_proxy.append(result_queue.get(block=True, timeout=0.1))
80 | except Exception:
81 | pass
82 | if len(available_proxy) > 0:
83 | config.github_proxy = available_proxy[0]
84 | return config.github_proxy
85 | else:
86 | _log.warning("无法连接到任何 GitHub 代理, 将直接连接 GitHub")
87 | config.github_proxy = ""
88 | return config.github_proxy
89 |
90 |
91 | threading.Thread(target=get_proxy_url).start()
92 |
93 | if __name__ == "__main__":
94 |
95 | print("done")
96 |
--------------------------------------------------------------------------------
/src/ncatbot/utils/optional/__init__.py:
--------------------------------------------------------------------------------
1 | # 可选功能
2 |
3 |
4 | from ncatbot.utils.optional.change_dir import ChangeDir
5 | from ncatbot.utils.optional.mdmaker import md_maker
6 | from ncatbot.utils.optional.time_task_scheduler import TimeTaskScheduler
7 | from ncatbot.utils.optional.visualize_data import visualize_tree
8 |
9 | __all__ = ["ChangeDir", "visualize_tree", "TimeTaskScheduler", "md_maker"]
10 |
--------------------------------------------------------------------------------
/src/ncatbot/utils/optional/change_dir.py:
--------------------------------------------------------------------------------
1 | # -------------------------
2 | # @Author : Fish-LP fish.zh@outlook.com
3 | # @Date : 2025-02-18 21:06:40
4 | # @LastEditors : Fish-LP fish.zh@outlook.com
5 | # @LastEditTime : 2025-05-13 19:52:39
6 | # @Description : 上下文管理器, 用于暂时切换工作路径, 使用插件的私有数据目录时需要
7 | # @Copyright (c) 2025 by Fish-LP, Fcatbot使用许可协议
8 | # -------------------------
9 |
10 |
11 | import os
12 | import tempfile
13 | from contextlib import ContextDecorator
14 | from pathlib import Path
15 | from typing import Optional, Union
16 | from uuid import UUID, uuid4
17 |
18 | from ..logger import get_log
19 |
20 | LOG = get_log("ChangeDir")
21 |
22 |
23 | class ChangeDir(ContextDecorator):
24 | """
25 | 上下文管理器,用于暂时切换工作路径。
26 | 支持自动恢复原始路径和目录创建/清理。
27 | """
28 |
29 | _DIRS_REGISTRY: dict[UUID, str] = {} # 保存所有可用目录的 UUID 和路径
30 |
31 | def __init__(
32 | self,
33 | path: Optional[Union[str, UUID, Path]] = None,
34 | create_missing: bool = False,
35 | keep_temp: bool = False,
36 | init_path: bool = False,
37 | ) -> None:
38 | """
39 | 初始化工作路径切换器。
40 |
41 | Args
42 | path (Optional[str | UUID | Path]): 新的工作路径。若为 None,则创建临时目录。
43 | create_missing (bool): 如果目标路径不存在,是否自动创建。
44 | keep_temp (bool): 是否在退出后暂存临时目录。
45 | init_path (bool): 立刻初始化路径。否则在使用时初始化
46 | """
47 | self.create_missing = create_missing
48 | self.keep_temp = keep_temp
49 | self.temp_dir = None # 临时目录管理器
50 | self.origin_path = os.getcwd()
51 | self.path = path
52 | self.init = False
53 | self.new_path = ""
54 | self.dir_id = None # 目录对应的 UUID
55 | if init_path:
56 | self.init_path()
57 |
58 | def init_path(self):
59 | """初始化目标路径"""
60 | if self.init:
61 | return
62 | if isinstance(self.path, str):
63 | self.new_path = os.path.abspath(self.path)
64 | self._handle_str_path()
65 | elif isinstance(self.path, UUID):
66 | # 从路径注册表中加载路径
67 | self._load_path(self.path)
68 | elif isinstance(self.path, Path):
69 | self.new_path = str(self.path.absolute())
70 | self._handle_str_path()
71 | else:
72 | # 未指定路径,创建临时目录
73 | self._create_temp_directory()
74 | self.init = True
75 |
76 | def _handle_str_path(self) -> None:
77 | """
78 | 处理以字符串形式传入的路径。
79 | """
80 | if not os.path.exists(self.new_path):
81 | if self.create_missing:
82 | os.makedirs(self.new_path, exist_ok=True)
83 | LOG.debug(f"创建文件夹: {self.new_path}")
84 | else:
85 | raise FileNotFoundError(f"路径不存在: {self.new_path}")
86 | if not os.path.isdir(self.new_path):
87 | raise NotADirectoryError(f"路径不是文件夹: {self.new_path}")
88 | # 生成 UUID 并保存到注册表
89 | self.dir_id = uuid4()
90 | self._DIRS_REGISTRY[self.dir_id] = self.new_path
91 | LOG.debug(f"为目录 [{self.dir_id}] 注册路径: {self.new_path}")
92 |
93 | def _load_path(self, path_id: UUID) -> None:
94 | """
95 | 从路径注册表中加载路径。
96 | """
97 | self.new_path = self._DIRS_REGISTRY.get(path_id, "")
98 | if not self.new_path or not os.path.exists(self.new_path):
99 | raise FileNotFoundError(f"路径未找到: {path_id}")
100 | LOG.debug(f"加载目录: {path_id} → {self.new_path}")
101 |
102 | def _create_temp_directory(self) -> None:
103 | """
104 | 创建临时目录并记录相关信息。
105 | """
106 | self.temp_dir = tempfile.TemporaryDirectory()
107 | self.dir_id = uuid4()
108 | self.new_path = self.temp_dir.name
109 | self._DIRS_REGISTRY[self.dir_id] = self.new_path
110 | LOG.debug(f"创建临时目录 [{self.dir_id}]: {self.new_path}")
111 |
112 | def __enter__(self) -> "UUID":
113 | """
114 | 进入上下文时,初始化并切换到新的工作路径。
115 | """
116 | self.init_path()
117 | os.chdir(self.new_path)
118 | return self.dir_id if self.dir_id else UUID(int=0)
119 |
120 | def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
121 | """
122 | 退出上下文时,恢复原始路径并清理临时目录。
123 | """
124 | try:
125 | os.chdir(self.origin_path)
126 | LOG.debug(f"恢复目录: {self.origin_path}")
127 | except Exception as e:
128 | LOG.critical(f"恢复原始目录失败: {e}")
129 | raise RuntimeError(f"恢复原始目录失败: {self.origin_path}", self.origin_path)
130 |
131 | # 清理临时目录 如果需要
132 | if self.temp_dir and not self.keep_temp:
133 | try:
134 | self.temp_dir.cleanup()
135 | LOG.debug(f"删除临时目录: {self.new_path}")
136 | # 移除临时目录的注册记录
137 | if self.dir_id in self._DIRS_REGISTRY:
138 | del self._DIRS_REGISTRY[self.dir_id]
139 | except Exception as e:
140 | LOG.error(f"删除临时目录失败: {e}")
141 |
142 | if exc_type:
143 | LOG.debug(f"捕获到异常[{exc_type}]: {exc_val} | {exc_tb}")
144 | return False # 不处理异常,让异常继续传播
145 |
146 | def __del__(self) -> None:
147 | """
148 | 清理临时目录(即使上下文管理器未正常退出)。
149 | """
150 | # 清理临时目录 如果需要
151 | if self.temp_dir and not self.keep_temp:
152 | try:
153 | self.temp_dir.cleanup()
154 | LOG.debug(f"对象销毁时清理临时目录: {self.new_path}")
155 | # 移除临时目录的注册记录
156 | if self.dir_id in self._DIRS_REGISTRY:
157 | del self._DIRS_REGISTRY[self.dir_id]
158 | except Exception as e:
159 | LOG.error(f"清理临时目录失败: {e}")
160 |
--------------------------------------------------------------------------------
/src/ncatbot/utils/optional/mdmaker.py:
--------------------------------------------------------------------------------
1 | # 发送图片 MarkDown 文件
2 |
3 | import os
4 | import platform
5 | import tempfile
6 | from pathlib import Path
7 |
8 | import markdown
9 | from pygments.formatters import HtmlFormatter
10 | from pyppeteer import launch
11 |
12 | from ncatbot.utils.logger import get_log
13 |
14 | _log = get_log("utils")
15 |
16 |
17 | def read_file(file_path) -> any:
18 | with open(file_path, "r", encoding="utf-8") as f:
19 | return f.read()
20 |
21 |
22 | def get_chrome_path():
23 | """
24 | 获取 Chrome 浏览器的可执行文件路径。
25 | 在 Windows 上通过注册表查找,在 Linux 上通过 which 命令查找。
26 | """
27 | system = platform.system()
28 |
29 | if system == "Windows":
30 | import winreg
31 |
32 | registry_keys = [
33 | r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe",
34 | r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe",
35 | ]
36 | for root in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
37 | for sub_key in registry_keys:
38 | try:
39 | with winreg.OpenKey(root, sub_key) as key:
40 | path, _ = winreg.QueryValueEx(key, "")
41 | if os.path.exists(path):
42 | return path
43 | except FileNotFoundError:
44 | continue
45 | elif system == "Linux":
46 | chrome_paths = [
47 | "/usr/bin/google-chrome",
48 | "/usr/bin/google-chrome-stable",
49 | "/usr/bin/chromium-browser",
50 | "/usr/bin/chromium",
51 | "/snap/bin/chromium",
52 | "/snap/bin/chromium-browser",
53 | ]
54 | for path in chrome_paths:
55 | if os.path.exists(path):
56 | return path
57 | # 使用 which 命令查找
58 | which_chrome = os.popen("which google-chrome").read().strip()
59 | if which_chrome and os.path.exists(which_chrome):
60 | return which_chrome
61 | which_chromium = os.popen("which chromium").read().strip()
62 | if which_chromium and os.path.exists(which_chromium):
63 | return which_chromium
64 | if system == "Darwin": # macOS 系统
65 | chrome_paths = [
66 | Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
67 | Path("/Applications/Chromium.app/Contents/MacOS/Chromium"),
68 | ]
69 | for path in chrome_paths:
70 | if path.exists():
71 | return str(path)
72 |
73 | # 如果没有找到,则检查 pyppeteer 下载的 Chromium
74 | local_chromium_path = (
75 | Path.home() / "Library/Application Support/pyppeteer/local-chromium"
76 | )
77 | if local_chromium_path.exists():
78 | chrome_folders = list(local_chromium_path.iterdir())
79 | if chrome_folders: # 确保有文件夹
80 | chrome_folder = chrome_folders[0] # 取第一个文件夹
81 | chrome_executable = (
82 | chrome_folder
83 | / "chrome-mac"
84 | / "Chromium.app"
85 | / "Contents"
86 | / "MacOS"
87 | / "Chromium"
88 | )
89 | if chrome_executable.exists():
90 | return str(chrome_executable)
91 |
92 | return None
93 |
94 |
95 | def markdown_to_html(md_content, external_css_urls=None, custom_css=""):
96 | """
97 | 将 Markdown 文本转换为 HTML,并导入外部 CSS 模板及自定义 CSS 样式。
98 |
99 | :param md_content: Markdown 文本内容
100 | :param external_css_urls: 外部 CSS 链接列表
101 | :param custom_css: 自定义 CSS 样式,将嵌入在
119 |
120 |
121 |
122 | {html_body}
123 |
124 |
125 |