├── .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 | ![logo.png](https://socialify.git.ci/liyihao1110/NcatBot/image?custom_description=ncatbot+%EF%BC%8C%E5%9F%BA%E4%BA%8E+%E5%8D%8F%E8%AE%AE+%E7%9A%84+QQ+%E6%9C%BA%E5%99%A8%E4%BA%BA+Python+SDK%EF%BC%8C%E5%BF%AB%E9%80%9F%E5%BC%80%E5%8F%91%EF%BC%8C%E8%BD%BB%E6%9D%BE%E9%83%A8%E7%BD%B2%E3%80%82&description=1&font=Jost&forks=1&issues=1&logo=https%3A%2F%2Fimg.remit.ee%2Fapi%2Ffile%2FAgACAgUAAyEGAASHRsPbAAO9Z_FYKczZ5dly9IKmC93J_sF7qRUAAmXEMRtA2ohX1eSKajqfARABAAMCAAN5AAM2BA.jpg&pattern=Signal&pulls=1&stargazers=1&theme=Auto) 8 | 9 | 10 | [![OneBot v11](https://img.shields.io/badge/OneBot-v11-black.svg)](https://github.com/botuniverse/onebot) 11 | [![访问量统计](https://visitor-badge.laobi.icu/badge?page_id=li-yihao0328.ncatbot_sync)](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 | [![Star History Chart](https://api.star-history.com/svg?repos=liyihao1110/ncatbot&type=Date)](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 | 126 | """ 127 | return html 128 | 129 | 130 | async def html_to_png(html_content, output_png, chrome_executable=None): 131 | """ 132 | 利用 pyppeteer 启动 Chrome,将 HTML 渲染后保存为 PNG 图片。 133 | 134 | :param html_content: HTML 内容字符串 135 | :param output_png: 输出 PNG 文件的路径 136 | :param chrome_executable: Chrome 浏览器的可执行文件路径(可选) 137 | """ 138 | with tempfile.NamedTemporaryFile( 139 | "w", delete=False, suffix=".html", encoding="utf-8" 140 | ) as f: 141 | html_file = f.name 142 | f.write(html_content) 143 | 144 | file_url = "file:///" + html_file.replace("\\", "/") 145 | 146 | # 启动浏览器,若未指定 chrome_executable,则使用 pyppeteer 默认的 Chromium 147 | launch_options = {"headless": True, "args": ["--no-sandbox"]} 148 | if chrome_executable: 149 | launch_options["executablePath"] = chrome_executable 150 | 151 | browser = await launch(launch_options) 152 | page = await browser.newPage() 153 | 154 | await page.goto(file_url, {"waitUntil": "networkidle0"}) 155 | 156 | content_height = await page.evaluate("document.documentElement.scrollHeight") 157 | await page.setViewport( 158 | {"width": 1280, "height": content_height} 159 | ) # 如果觉得截取出来的图片过宽,修改width即可 160 | 161 | await page.screenshot({"path": output_png, "fullPage": False}) 162 | 163 | await browser.close() 164 | 165 | os.remove(html_file) 166 | 167 | 168 | async def md_maker(md_content): 169 | """ 170 | 将 Markdown 文本转换为 HTML,并生成 PNG 图片。 171 | :param md_content: Markdown 文本内容 172 | :return: 生成的 PNG 图片路径 173 | """ 174 | current_path = os.path.dirname(os.path.abspath(__file__)) 175 | external_css = read_file(os.path.join(current_path, "template/external.css")) 176 | highlight_css = HtmlFormatter().get_style_defs(".codehilite") 177 | custom_css = f""" 178 | /* 基本重置与布局 */ 179 | html, body {{ 180 | margin: 0; 181 | padding: 0; 182 | background-color: #f8f9fa; 183 | font-family: 'Helvetica Neue', Arial, sans-serif; /* 现代化字体 */ 184 | }} 185 | body {{ 186 | padding: 20px; 187 | }} 188 | 189 | /* container 控制宽度 */ 190 | .container {{ 191 | max-width: 960px; 192 | margin: auto; 193 | }} 194 | 195 | /* 自动换行处理,防止代码块超出页面宽度 */ 196 | pre code {{ 197 | white-space: pre-wrap; 198 | word-break: break-all; 199 | font-family: 'Fira Code', 'Courier New', monospace; /* 代码字体 */ 200 | }} 201 | 202 | /* Pygments 代码高亮样式 */ 203 | {highlight_css} 204 | 205 | /* 补充代码块背景和内边距(保证背景模板显示) */ 206 | .codehilite {{ 207 | background: #f0f0f0; /* 浅灰色背景 */ 208 | color: #000000; 209 | padding: 1em; 210 | border-radius: 5px; 211 | overflow-x: auto; 212 | font-family: 'Fira Code', 'Courier New', monospace; /* 代码字体 */ 213 | }} 214 | pre.codehilite {{ 215 | background: #f0f0f0; /* 浅灰色背景 */ 216 | color: #000000; 217 | padding: 1em; 218 | border-radius: 5px; 219 | overflow-x: auto; 220 | font-family: 'Fira Code', 'Courier New', monospace; /* 代码字体 */ 221 | }} 222 | /* 表格样式 */ 223 | table, th, td {{ 224 | border: 1px solid #dee2e6; 225 | border-collapse: collapse; 226 | }} 227 | th, td {{ 228 | padding: 0.75em; 229 | text-align: left; 230 | }} 231 | /* 针对超长文本、表格等设置 */ 232 | table {{ 233 | width: 100%; 234 | margin-bottom: 1em; 235 | }} 236 | """ 237 | html_content = markdown_to_html( 238 | md_content, external_css_urls=external_css, custom_css=custom_css 239 | ) 240 | chrome_path = get_chrome_path() 241 | if chrome_path is None: 242 | _log.info("未在注册表中找到 Chrome 浏览器路径,尝试自动安装Chromium") 243 | # raise Exception("未找到 Chrome 浏览器路径") 244 | else: 245 | _log.debug(f"Chrome 路径:{chrome_path}") 246 | output_png = os.path.join(tempfile.gettempdir(), "markdown.png") 247 | await html_to_png(html_content, output_png, chrome_path) 248 | return output_png 249 | -------------------------------------------------------------------------------- /src/ncatbot/utils/optional/time_task_scheduler.py: -------------------------------------------------------------------------------- 1 | # ------------------------- 2 | # @Author : Fish-LP fish.zh@outlook.com 3 | # @Date : 2025-03-21 20:40:12 4 | # @LastEditors : Fish-LP fish.zh@outlook.com 5 | # @LastEditTime : 2025-03-23 15:30:12 6 | # @Description : 定时任务支持 7 | # @Copyright (c) 2025 by Fish-LP, Fcatbot使用许可协议 8 | # ------------------------- 9 | import functools 10 | import re 11 | import time 12 | from datetime import datetime 13 | from typing import Any, Callable, Dict, List, Optional, Tuple, Union 14 | 15 | from schedule import Scheduler 16 | 17 | 18 | class TimeTaskScheduler: 19 | """ 20 | 任务调度器,支持以下特性: 21 | - 多模式调度: 间隔任务/每日定点任务/一次性任务 22 | - 动态参数生成函数/预定义静态参数传入 23 | - 外部循环单步执行模式/独立执行模式 24 | - 执行条件判断 25 | - 运行次数限制 26 | 27 | Attributes: 28 | _scheduler (Scheduler): 内部调度器实例 29 | _jobs (list): 存储所有任务信息的列表 30 | """ 31 | 32 | def __init__(self): 33 | self._scheduler = Scheduler() 34 | self._jobs = [] 35 | 36 | def _parse_time(self, time_str: str) -> dict: 37 | """ 38 | 解析时间参数为调度配置字典,支持格式: 39 | - 一次性任务: 'YYYY-MM-DD HH:MM:SS' 或 GitHub Action格式 'YYYY:MM:DD-HH:MM:SS' 40 | - 每日任务: 'HH:MM' 41 | - 间隔任务: 42 | * 基础单位: '120s', '2h30m', '0.5d' 43 | * 冒号分隔: '00:15:30' (时:分:秒) 44 | * 自然语言: '2天3小时5秒' 45 | 46 | Args: 47 | time_str (str): 时间参数字符串 48 | 49 | Returns: 50 | dict: 调度配置字典,包含以下键: 51 | - type: 调度类型 ('once'/'daily'/'interval') 52 | - value: 具体参数 (秒数/时间字符串) 53 | 54 | Raises: 55 | ValueError: 当时间格式无效时抛出 56 | """ 57 | # 尝试解析为一次性任务 58 | try: 59 | if re.match(r"^\d{4}:\d{2}:\d{2}-\d{2}:\d{2}:\d{2}$", time_str): 60 | dt_str = time_str.replace(":", "-", 2).replace("-", " ", 1) 61 | dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S") 62 | else: 63 | dt = datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S") 64 | 65 | now = datetime.now() 66 | if dt < now: 67 | raise ValueError("指定的时间已过期") 68 | 69 | return {"type": "once", "value": (dt - now).total_seconds()} 70 | except ValueError: 71 | pass 72 | 73 | # 尝试解析为每日任务 74 | if re.match(r"^([0-1][0-9]|2[0-3]):([0-5][0-9])$", time_str): 75 | try: 76 | datetime.strptime(time_str, "%H:%M") 77 | return {"type": "daily", "value": time_str} 78 | except ValueError: 79 | pass 80 | 81 | # 解析为间隔任务 82 | try: 83 | return {"type": "interval", "value": self._parse_interval(time_str)} 84 | except ValueError as e: 85 | raise ValueError(f"无效的时间格式: {time_str}") from e 86 | 87 | def _parse_interval(self, time_str: str) -> int: 88 | """ 89 | 解析间隔时间参数为秒数 90 | 91 | Args: 92 | time_str (str): 间隔时间字符串,支持格式: 93 | * 基础单位: '120s', '2h30m', '0.5d' 94 | * 冒号分隔: '00:15:30' (时:分:秒) 95 | * 自然语言: '2天3小时5秒' 96 | 97 | Returns: 98 | int: 总秒数 99 | 100 | Raises: 101 | ValueError: 当格式无效时抛出 102 | """ 103 | units = {"d": 86400, "h": 3600, "m": 60, "s": 1} 104 | 105 | # 单位组合格式 (如2h30m) 106 | unit_match = re.match(r"^([\d.]+)([dhms])?$", time_str, re.IGNORECASE) 107 | if unit_match: 108 | num, unit = unit_match.groups() 109 | unit = unit.lower() if unit else "s" 110 | return int(float(num) * units[unit]) 111 | 112 | # 冒号分隔格式 (如01:30:00) 113 | if ":" in time_str: 114 | parts = list(map(float, time_str.split(":"))) 115 | multipliers = [1, 60, 3600, 86400][-len(parts) :] 116 | return int(sum(p * m for p, m in zip(parts[::-1], multipliers))) 117 | 118 | # 自然语言格式 (如2天3小时5秒) 119 | lang_match = re.match(r"(\d+)\s*天\s*(\d+)\s*小时\s*(\d+)\s*秒", time_str) 120 | if lang_match: 121 | d, h, s = map(int, lang_match.groups()) 122 | return d * 86400 + h * 3600 + s 123 | 124 | raise ValueError("无法识别的间隔时间格式") 125 | 126 | def add_job( 127 | self, 128 | job_func: Callable, 129 | name: str, 130 | interval: Union[str, int, float], 131 | conditions: Optional[List[Callable[[], bool]]] = None, 132 | max_runs: Optional[int] = None, 133 | args: Optional[Tuple] = None, 134 | kwargs: Optional[Dict] = None, 135 | args_provider: Optional[Callable[[], Tuple]] = None, 136 | kwargs_provider: Optional[Callable[[], Dict[str, Any]]] = None, 137 | ) -> bool: 138 | """ 139 | 添加定时任务 140 | 141 | Args: 142 | job_func (Callable): 要执行的任务函数 143 | name (str): 任务唯一标识名称 144 | interval (Union[str, int, float]): 调度时间参数 145 | conditions (Optional[List[Callable]]): 执行条件列表 146 | max_runs (Optional[int]): 最大执行次数 147 | args (Optional[Tuple]): 静态位置参数 148 | kwargs (Optional[Dict]): 静态关键字参数 149 | args_provider (Optional[Callable]): 动态位置参数生成函数 150 | kwargs_provider (Optional[Callable]): 动态关键字参数生成函数 151 | 152 | Returns: 153 | bool: 是否添加成功 154 | 155 | Raises: 156 | ValueError: 当参数冲突或时间格式无效时 157 | """ 158 | # 名称唯一性检查 159 | if any(job["name"] == name for job in self._jobs): 160 | print(f"任务添加失败: 名称 '{name}' 已存在") 161 | return False 162 | 163 | # 参数冲突检查 164 | if (args and args_provider) or (kwargs and kwargs_provider): 165 | raise ValueError("静态参数和动态参数生成器不能同时使用") 166 | 167 | try: 168 | # 解析时间参数 169 | interval_cfg = self._parse_time(str(interval)) 170 | 171 | # 一次性任务强制设置max_runs=1 172 | if interval_cfg["type"] == "once": 173 | if max_runs and max_runs != 1: 174 | raise ValueError("一次性任务必须设置 max_runs=1") 175 | max_runs = 1 176 | 177 | job_info = { 178 | "name": name, 179 | "func": job_func, 180 | "max_runs": max_runs, 181 | "run_count": 0, 182 | "conditions": conditions or [], 183 | "static_args": args, 184 | "static_kwargs": kwargs or {}, 185 | "args_provider": args_provider, 186 | "kwargs_provider": kwargs_provider, 187 | } 188 | 189 | @functools.wraps(job_func) 190 | def job_wrapper(): 191 | # 执行次数检查 192 | if ( 193 | job_info["max_runs"] 194 | and job_info["run_count"] >= job_info["max_runs"] 195 | ): 196 | self.remove_job(name) 197 | return 198 | 199 | # 条件检查 200 | if not all(cond() for cond in job_info["conditions"]): 201 | return 202 | 203 | # 参数处理 204 | dyn_args = ( 205 | job_info["args_provider"]() if job_info["args_provider"] else () 206 | ) 207 | dyn_kwargs = ( 208 | job_info["kwargs_provider"]() if job_info["kwargs_provider"] else {} 209 | ) 210 | final_args = dyn_args or job_info["static_args"] or () 211 | final_kwargs = {**job_info["static_kwargs"], **dyn_kwargs} 212 | 213 | # 执行任务 214 | try: 215 | job_info["func"](*final_args, **final_kwargs) 216 | job_info["run_count"] += 1 217 | except Exception as e: 218 | print(f"任务执行失败 [{name}]: {str(e)}") 219 | 220 | # 创建调度任务 221 | if interval_cfg["type"] == "interval": 222 | job = self._scheduler.every(interval_cfg["value"]).seconds.do( 223 | job_wrapper 224 | ) 225 | elif interval_cfg["type"] == "daily": 226 | job = ( 227 | self._scheduler.every() 228 | .day.at(interval_cfg["value"]) 229 | .do(job_wrapper) 230 | ) 231 | elif interval_cfg["type"] == "once": 232 | job = self._scheduler.every(interval_cfg["value"]).seconds.do( 233 | job_wrapper 234 | ) 235 | 236 | job_info["schedule_job"] = job 237 | self._jobs.append(job_info) 238 | return True 239 | 240 | except Exception as e: 241 | print(f"任务添加失败: {str(e)}") 242 | return False 243 | 244 | def step(self) -> None: 245 | """单步执行""" 246 | self._scheduler.run_pending() 247 | 248 | def run(self) -> None: 249 | """独立运行""" 250 | try: 251 | while True: 252 | self.step() 253 | # 计算下一次任务的最早执行时间 254 | next_run_time = None 255 | for job in self._jobs: 256 | if job["schedule_job"].next_run is not None: 257 | if ( 258 | next_run_time is None 259 | or job["schedule_job"].next_run < next_run_time 260 | ): 261 | next_run_time = job["schedule_job"].next_run 262 | # 如果有任务待执行,计算需要等待的时间 263 | if next_run_time is not None: 264 | sleep_time = (next_run_time - datetime.now()).total_seconds() 265 | if sleep_time > 0: 266 | time.sleep(sleep_time) 267 | else: 268 | # 如果已经过了下一次执行时间,立即检查任务 269 | continue 270 | else: 271 | # 如果没有任务待执行,适当等待 272 | time.sleep(0.2) # 我觉得吧, 应该不需要等待 273 | except KeyboardInterrupt: 274 | print("\n调度器已安全停止") 275 | 276 | def remove_job(self, name: str) -> bool: 277 | """ 278 | 移除指定名称的任务 279 | 280 | Args: 281 | name (str): 要移除的任务名称 282 | 283 | Returns: 284 | bool: 是否成功找到并移除任务 285 | """ 286 | for job in self._jobs: 287 | if job["name"] == name: 288 | self._scheduler.cancel_job(job["schedule_job"]) 289 | self._jobs.remove(job) 290 | return True 291 | return False 292 | 293 | def get_job_status(self, name: str) -> Optional[dict]: 294 | """ 295 | 获取任务状态信息 296 | 297 | Args: 298 | name (str): 任务名称 299 | 300 | Returns: 301 | Optional[dict]: 包含状态信息的字典,格式: 302 | { 303 | 'name': 任务名称, 304 | 'next_run': 下次运行时间, 305 | 'run_count': 已执行次数, 306 | 'max_runs': 最大允许次数 307 | } 308 | """ 309 | for job in self._jobs: 310 | if job["name"] == name: 311 | return { 312 | "name": name, 313 | "next_run": job["schedule_job"].next_run, 314 | "run_count": job["run_count"], 315 | "max_runs": job["max_runs"], 316 | } 317 | return None 318 | -------------------------------------------------------------------------------- /src/ncatbot/utils/optional/visualize_data.py: -------------------------------------------------------------------------------- 1 | # ------------------------- 2 | # @Author : Fish-LP fish.zh@outlook.com 3 | # @Date : 2025-03-21 18:07:00 4 | # @LastEditors : Fish-LP fish.zh@outlook.com 5 | # @LastEditTime : 2025-03-23 16:14:38 6 | # @Description : 权限树可视化 7 | # @Copyright (c) 2025 by Fish-LP, Fcatbot使用许可协议 8 | # ------------------------- 9 | from ncatbot.utils.assets import Color 10 | 11 | 12 | def visualize_tree(data, parent_prefix="", is_last=True, is_root=True, level=0): 13 | """将嵌套数据结构转换为美观的彩色树形图""" 14 | # 存储每一行的列表 15 | lines = [] 16 | 17 | # 根据层级选择连接线的颜色,循环使用三种颜色 18 | connector_color = [ 19 | Color.YELLOW, 20 | Color.MAGENTA, 21 | Color.CYAN, 22 | ][level % 3] 23 | 24 | # 定义树形图的连接线和填充字符 25 | # 垂直线,用于非最后一个子节点的后续行 26 | vertical_line = f"{connector_color}│{Color.RESET} " 27 | # 水平线,用于连接中间节点 28 | horizontal_line = f"{connector_color}├──{Color.RESET} " 29 | # 角落线,用于连接最后一个节点 30 | corner_line = f"{connector_color}└──{Color.RESET} " 31 | # 空格填充,用于已结束分支的对齐 32 | space_fill = " " 33 | 34 | # 如果数据是字典类型 35 | if isinstance(data, dict): 36 | # 获取字典的键值对 37 | items = tuple(data.items()) 38 | for i, (key, value) in enumerate(items): 39 | # 判断是否是最后一个键值对 40 | is_last_item = i + 1 == len(items) 41 | 42 | # 生成当前行的前缀 43 | if is_root: # 如果是根节点 44 | # 根据是否是最后一个子节点选择角落线或水平线 45 | prefix = corner_line if is_last_item else horizontal_line 46 | # 新的前缀用于子节点的递归调用 47 | new_prefix = space_fill if is_last_item else vertical_line 48 | else: # 如果不是根节点 49 | # 根据父节点的前缀和是否是最后一个子节点生成前缀 50 | prefix = parent_prefix + ( 51 | corner_line if is_last_item else horizontal_line 52 | ) 53 | new_prefix = parent_prefix + ( 54 | space_fill if is_last_item else vertical_line 55 | ) 56 | 57 | # 处理键的显示,给键加上蓝色 58 | key_str = f"{Color.from_rgb(152, 245, 249)}{key}{Color.RESET}" 59 | # 将当前键添加到行列表 60 | lines.append(f"{prefix}{key_str}") 61 | 62 | # 递归处理子节点 63 | sub_lines = visualize_tree( 64 | value, # 子节点的值 65 | parent_prefix=new_prefix, # 新的父前缀 66 | is_last=is_last_item, # 是否是最后一个子节点 67 | is_root=False, # 不是根节点 68 | level=level + 1, # 层级加1 69 | ) 70 | # 将子节点的行添加到主行列表 71 | lines.extend(sub_lines) 72 | 73 | # 如果数据是列表类型 74 | elif isinstance(data, list): 75 | for i, item in enumerate(data): 76 | is_last_item = i + 1 == len(data) # 判断是否是最后一个列表项 77 | bullet = f"{Color.WHITE}•{Color.RESET}" # 列表项目符号 78 | 79 | # 生成当前行的前缀 80 | if is_last_item: # 如果是最后一项 81 | prefix = parent_prefix + corner_line 82 | new_prefix = parent_prefix + space_fill 83 | else: # 如果不是最后一项 84 | prefix = parent_prefix + horizontal_line 85 | new_prefix = parent_prefix + vertical_line 86 | 87 | # 将项目符号添加到行列表 88 | lines.append(f"{prefix}{bullet}") 89 | 90 | # 递归处理子节点 91 | sub_lines = visualize_tree( 92 | item, # 子节点的值 93 | parent_prefix=new_prefix, # 新的父前缀 94 | is_last=is_last_item, # 是否是最后一个子节点 95 | is_root=False, # 不是根节点 96 | level=level + 1, # 层级加1 97 | ) 98 | # 将子节点的行添加到主行列表 99 | lines.extend(sub_lines) 100 | 101 | # 如果是基本数据类型(非字典和列表) 102 | else: 103 | # 根据数据类型设置值的颜色 104 | value_color = Color.WHITE 105 | if isinstance(data, bool): # 布尔值 106 | value_color = Color.GREEN if data else Color.RED 107 | data = str(data) # 转换为字符串 108 | elif isinstance(data, (int, float)): # 数值 109 | value_color = Color.CYAN 110 | elif data is None: # None 111 | value_color = Color.GRAY 112 | data = "None" 113 | 114 | # 格式化值的字符串 115 | value_str = f"{value_color}{data}{Color.RESET}" 116 | # 根据是否是最后一个子节点选择连接线 117 | connector = corner_line if is_last else horizontal_line 118 | # 将值添加到行列表 119 | lines.append(f"{parent_prefix}{connector}{value_str}") 120 | 121 | # 返回所有行的列表 122 | return lines 123 | 124 | 125 | # 示例用法 126 | if __name__ == "__main__": 127 | sample_data = { 128 | "name": "Alice", 129 | "age": 30, 130 | "features": ["intelligent", "creative"], 131 | "children": [ 132 | {"name": "Bob", "age": 5, "toys": ["train", "ball"]}, 133 | { 134 | "name": "Charlie", 135 | "pet": None, 136 | "sex": 0, 137 | }, 138 | ], 139 | "active": True, 140 | } 141 | 142 | # 生成树形图 143 | tree = visualize_tree(sample_data) 144 | # 打印树形图 145 | print("\n".join(tree)) 146 | -------------------------------------------------------------------------------- /src/ncatbot/utils/template/external.css: -------------------------------------------------------------------------------- 1 | ["https://stackpath.bootstrapcdn.com/bootswatch/4.5.2/flatly/bootstrap.min.css"] 2 | --------------------------------------------------------------------------------